pwaLUPMIS2/src/import-modal.js
ekke 203ca5bc4d Permit-iframe hardening, import UX refinements, workshop deliverables
Permit-iframe hardening:
- public/embed.php — replace the 302 redirect on unauthenticated visits
  with an in-iframe HTML "Sign in to view the map" card (HTTP 401)
  whose primary button uses target="_top" to break the iframe and send
  the parent window to the SSO portal. The 302 was broken UX inside an
  iframe because the LUSPA portal refuses to be framed.
- public/embed.php + public/.htaccess — strip X-Frame-Options at the
  embed endpoint (defence in depth). Apache's <Files "embed.php">
  Header always unset X-Frame-Options + PHP's header_remove() both
  ensure the only iframe-policy header on the response is our CSP
  frame-ancestors (which already allows the permits subdomain). Fixes
  Safari's "Refused to display ... because it set 'X-Frame-Options'
  to 'SAMEORIGIN'" when the container's reverse proxy injects it.

Import UX refinements:
- Spinner overlay (index.html #import-spinner-overlay + main.js
  showImportSpinner/hideImportSpinner) shown during the file-drop →
  mapping-modal gap. Wired at the top of each handle*Import and at
  every error / early-return path; hidden by stageImport() just before
  openImportMappingModal() so it spans both the JS parse and the
  SQLocal staging insert.
- Per-feature client_uuid tagging — each imported OL feature now
  carries _externalImportId + _clientUuid set in stageImport(). These
  tags are the link that lets later edits find the matching staging
  row, and they are passed through to addExternalImportFeatures.
- Geometry-edit persistence — new public callback registry
  MapView.onFeatureModified(cb) fired from a modifyend listener on
  _modifyInteraction. main.js handler writes the new WKT (EPSG:4326)
  back to external_import_features.geometry_wkt via new helper
  updateExternalImportFeatureGeometry(clientUuid, wkt). Non-imported
  features carry no tags, so the handler is a no-op for them.
- Delete persistence — removefeature listener on each imported layer's
  source. New helper deleteExternalImportFeature(clientUuid) runs an
  atomic DELETE + decrement of external_imports.feature_count and
  broadcasts the changes so the LayerSwitcher badge can recount.
- Field-mapping dropdown — sample values + bold field names.
  New helpers sampleSourceValues(fc) in import-detect.js (picks first
  non-empty value per attribute, JSON-stringifies objects, collapses
  whitespace, truncates to 35 chars) and toBoldUnicode(s) in
  import-modal.js (ASCII letters/digits → Mathematical Alphanumeric
  Symbols block). Options now read as "𝐮𝐩𝐧 — [12345-6789]";
  HTML/CSS bold doesn't render inside <option> elements, so Unicode
  bold codepoints are the cross-browser way.

Workshop deliverables:
- LUPMIS2_Improvements_Mar_to_Jun_2026.docx — handout mirroring the
  slide deck one-to-one (160 paragraphs, branded styling).
- LUPMIS2_Workshop_Mar_to_Jun_2026.pptx — 16-slide pptxgenjs deck
  (16:9 widescreen, brand palette, hero + content + closing masters,
  embedded staged-upload diagram on slide 9).
- LUPMIS2_Staged_Upload_Flow.svg + .png — three swim-lane diagram of
  the staged-upload pipeline with a dedicated "Client QA Gate"
  callout. Hand-crafted SVG + 2400 px PNG.

save_gps_trail.php diagnosis (no code change, on the database team):
the reported "CORS" error is a missing endpoint — Apache returns 404
with no CORS headers and the browser surfaces it as access-control.
Once the endpoint is deployed the API server's global CORS handling
attaches the right headers and the GPS-trail sync will work without
client changes.

dist/ rebuilt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 13:16:37 +00:00

264 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Import-mapping modal controller.
*
* openImportMappingModal({ importId, filename, fc, onResult })
*
* Populates the modal from the parsed FeatureCollection, lets the user pick
* a target type and adjust the field map, then calls onResult with one of:
*
* { action: 'cancel' } — keep as Other / view only
* { action: 'save', targetType, mapping }
* { action: 'upload', targetType, mapping }
*
* The caller is responsible for updating external_imports + the staged
* features (this module knows nothing about the DB or the map).
*
* See LUPMIS2_Import_Upload_Design.docx §3.1.
*/
import { Modal } from 'bootstrap';
import {
TARGET_TYPES,
TARGET_FIELDS,
detectTargetType,
autoMapFields,
listSourceFields,
sampleSourceValues,
} from './import-detect.js';
const els = {}; // cached DOM lookups
let modal = null;
let state = null; // { importId, filename, fc, sourceFields, mapping, targetType, onResult }
function cacheEls() {
if (els.root) return;
els.root = document.getElementById('importMappingModal');
els.filename = document.getElementById('import-modal-filename');
els.summary = document.getElementById('import-modal-summary');
els.target = document.getElementById('import-modal-target');
els.targetHint = document.getElementById('import-modal-target-hint');
els.fieldsWrap = document.getElementById('import-modal-fields-wrap');
els.tbody = document.getElementById('import-modal-fields-tbody');
els.btnSave = document.getElementById('import-modal-save');
els.btnSaveUpload = document.getElementById('import-modal-save-upload');
els.btnCancel = document.getElementById('import-modal-cancel');
// Populate the target-type dropdown once.
if (els.target && !els.target.dataset.populated) {
els.target.innerHTML = TARGET_TYPES
.map((t) => `<option value="${t.key}">${t.label}</option>`)
.join('');
els.target.dataset.populated = '1';
}
// Event wiring (idempotent).
if (els.target && !els.target.dataset.wired) {
els.target.dataset.wired = '1';
els.target.addEventListener('change', onTargetChange);
}
if (els.btnSave && !els.btnSave.dataset.wired) {
els.btnSave.dataset.wired = '1';
els.btnSave.addEventListener('click', () => finish('save'));
}
if (els.btnSaveUpload && !els.btnSaveUpload.dataset.wired) {
els.btnSaveUpload.dataset.wired = '1';
els.btnSaveUpload.addEventListener('click', () => finish('upload'));
}
// Cancel uses Bootstrap's data-bs-dismiss; we hook the hidden event so
// closing via × / ESC / Cancel all behave the same: a 'cancel' result.
if (els.root && !els.root.dataset.wired) {
els.root.dataset.wired = '1';
els.root.addEventListener('hidden.bs.modal', () => {
if (state?.onResult && !state._resolved) {
state._resolved = true;
state.onResult({ action: 'cancel' });
}
state = null;
});
}
}
/** Render the field-mapping table for the current targetType. */
function renderFieldsTable() {
const targetType = state.targetType;
const columns = TARGET_FIELDS[targetType] || [];
// "Other (view only)" → no fields to map.
if (targetType === 'other' || columns.length === 0) {
els.fieldsWrap.style.display = 'none';
return;
}
els.fieldsWrap.style.display = '';
// Each dropdown option shows the source field name AND a one-feature
// sample value so the user can recognise the attribute by its content,
// not just its name. Sample is computed once in state.sourceSamples; see
// sampleSourceValues() in import-detect.js.
//
// The field name is rendered with Unicode mathematical-bold characters
// (U+1D400 / U+1D41A / U+1D7CE blocks) because HTML / CSS bold doesn't
// render inside <option> elements — browsers strip markup and ignore
// font-weight on options. Unicode bold works cross-browser without
// HTML and gives an unmistakable visual distinction from the sample.
const sampleSuffix = (s) => {
const v = state.sourceSamples[s];
return v ? ` — [${escapeHtml(v)}]` : ' — [(empty)]';
};
const optionsHtml = ['<option value="">(none)</option>']
.concat(state.sourceFields.map((s) =>
`<option value="${escapeAttr(s)}">${escapeHtml(toBoldUnicode(s))}${sampleSuffix(s)}</option>`))
.join('');
els.tbody.innerHTML = columns.map((col) => {
const current = state.mapping[col] || '';
// Mark the matching option as selected. The opening `<option value="…">`
// is unique per field, so prefix-replace is safe regardless of label text.
const select = optionsHtml.replace(
`<option value="${escapeAttr(current)}">`,
`<option value="${escapeAttr(current)}" selected>`
);
return `
<tr>
<td><code>${escapeHtml(col)}</code></td>
<td>
<select class="form-select form-select-sm import-field-map"
data-col="${escapeAttr(col)}">
${select}
</select>
</td>
</tr>
`;
}).join('');
// Update state.mapping when a row's source field changes.
els.tbody.querySelectorAll('.import-field-map').forEach((sel) => {
sel.addEventListener('change', (e) => {
const col = e.target.dataset.col;
state.mapping[col] = e.target.value || null;
});
});
}
/** Handler: target dropdown changed. */
function onTargetChange() {
const newType = els.target.value;
state.targetType = newType;
state.mapping = autoMapFields(state.fc, newType);
// Target-type hint: gentle reminder when "other" is chosen.
if (newType === 'other') {
els.targetHint.innerHTML =
'<em>This dataset will be visible on the map but cannot be uploaded ' +
'to the database. You can change the type later.</em>';
els.btnSave.disabled = false; // saves the "other" state
els.btnSaveUpload.disabled = true;
} else {
els.targetHint.innerHTML =
'Each LUPMIS2 column is matched to a source field where possible. ' +
'You can override any choice below.';
els.btnSave.disabled = false;
els.btnSaveUpload.disabled = false;
}
renderFieldsTable();
}
/** Handler: Save or Save+Upload clicked. */
function finish(action) {
if (!state || state._resolved) return;
state._resolved = true;
const { targetType, mapping, onResult } = state;
modal.hide();
if (onResult) {
onResult({
action, // 'save' | 'upload'
targetType,
mapping: targetType === 'other' ? null : { ...mapping },
});
}
state = null;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Open the modal for a fresh import. Resolves via onResult callback.
*
* @param {Object} opts
* @param {number} opts.importId — staging row id (purely for the caller)
* @param {string} opts.filename
* @param {Object} opts.fc — parsed FeatureCollection
* @param {Function} opts.onResult — see module header
*/
export function openImportMappingModal(opts) {
cacheEls();
if (!els.root) {
console.warn('[ImportModal] Modal element missing — calling onResult with cancel');
opts.onResult?.({ action: 'cancel' });
return;
}
const fc = opts.fc;
const featureCount = fc?.features?.length ?? 0;
const targetType = detectTargetType(fc);
state = {
importId: opts.importId,
filename: opts.filename,
fc,
sourceFields: listSourceFields(fc),
sourceSamples: sampleSourceValues(fc),
targetType,
mapping: autoMapFields(fc, targetType),
onResult: opts.onResult,
_resolved: false,
};
// Header summary
els.filename.textContent = opts.filename || 'imported dataset';
els.summary.textContent = `${featureCount} feature${featureCount === 1 ? '' : 's'}`;
// Initial dropdown + hint + fields
els.target.value = state.targetType;
onTargetChange();
modal = Modal.getOrCreateInstance(els.root);
modal.show();
}
// ---------------------------------------------------------------------------
// HTML-escape helpers
// ---------------------------------------------------------------------------
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeAttr(s) { return escapeHtml(s); }
/**
* Map ASCII letters and digits to their Unicode mathematical-bold equivalents
* (U+1D400 / U+1D41A / U+1D7CE blocks). Used to render source-attribute names
* inside <option> labels with visible weight, since HTML/CSS bold is ignored
* by browsers inside <select> dropdowns. Characters outside [A-Za-z0-9] pass
* through unchanged so underscores, hyphens, and punctuation stay readable.
*/
function toBoldUnicode(s) {
let out = '';
for (const ch of String(s)) {
const cp = ch.codePointAt(0);
if (cp >= 0x61 && cp <= 0x7A) { // a-z
out += String.fromCodePoint(cp - 0x61 + 0x1D41A);
} else if (cp >= 0x41 && cp <= 0x5A) { // A-Z
out += String.fromCodePoint(cp - 0x41 + 0x1D400);
} else if (cp >= 0x30 && cp <= 0x39) { // 0-9
out += String.fromCodePoint(cp - 0x30 + 0x1D7CE);
} else {
out += ch;
}
}
return out;
}