From f1c8338270d11303f9eb412b553183f201a5ce51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jerem=C3=ADas=20P=C3=A9rez=20Fern=C3=A1ndez?= Date: Sat, 22 Aug 2026 09:37:45 +0000 Subject: [PATCH] fix(mapping): stop discarding stale auto-load responses, always clear loading state, keep column-role menu open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related fixes in the entity/relationship mapping panels: The saved SQL query auto-loads in the background when a panel opens. Its response is now matched against the generation captured when it was scheduled (not re-read at response time), so it is no longer silently discarded if the panel finished mounting before the response arrived. Both the entity and relationship panels also stop discarding a response just because the user switched panel/generation in the meantime — the Refresh button and loading spinner are now always cleared in the completion path regardless, so a slow query can no longer leave the button stuck on "Refreshing..." or the spinner stuck visible. If the previously saved ID column is no longer present in a refreshed result set, the first available column is now auto-selected instead of being left unset. The per-column role menu (assign ID / Label / an attribute to a result column) now stays open across clicks and reopens on the same column after each assignment, instead of closing after every single click — multiple roles can be assigned to different columns in one pass. The existing "Clear" menu item (already present in the markup) now actually clears a column's roles, which it previously did not do at all: the click handler had no branch for it, so clicking it silently did nothing. Rebuilt from scratch against the current develop, which has meanwhile gained a schema-drift feature (auto-load, badges, panel close) in this same area — this PR only touches the pre-existing race-condition and menu-interaction issues above, none of it overlaps with schema-drift. A companion, unrelated bug — a literal DOM id shared between the entity and relationship panels' save buttons — no longer applies: develop's redesign replaced the old manual Apply/Save button with auto-save on panel dismiss, and removed that button from the markup entirely. --- src/front/static/mapping/js/mapping-design.js | 97 ++++++++++++------- 1 file changed, 64 insertions(+), 33 deletions(-) diff --git a/src/front/static/mapping/js/mapping-design.js b/src/front/static/mapping/js/mapping-design.js index b5cb73ad..3b21a5c5 100644 --- a/src/front/static/mapping/js/mapping-design.js +++ b/src/front/static/mapping/js/mapping-design.js @@ -1624,11 +1624,14 @@ function initEntityPanel(classUri, className, existingMapping, classInfo) { }); _updateEntityAttrToggleBtn(); - // Auto-load query data in background when there is an existing mapping with SQL + // Auto-load query data in background when there is an existing mapping with SQL. + // Pass the generation captured now, at schedule time, so the response isn't + // discarded if initEntityPanel has already finished mounting by the time it arrives. if (existingMapping?.sql_query) { + const scheduledGeneration = EntityPanelState._generation; EntityPanelState._autoLoadTimer = setTimeout(() => { EntityPanelState._autoLoadTimer = null; - runEntityPanelQuery({ autoLoad: true }); + runEntityPanelQuery({ autoLoad: true, generation: scheduledGeneration }); }, 100); } } @@ -1642,8 +1645,12 @@ async function runEntityPanelQuery(options = {}) { return; } - // Capture the generation at call time so we can detect stale responses - const capturedGeneration = EntityPanelState._generation; + // Capture the generation at call time so we can detect stale responses. + // A caller that scheduled this ahead of time (the auto-load timer) passes + // its own captured generation explicitly; otherwise capture it now. + const capturedGeneration = (options && options.generation != null) + ? options.generation + : EntityPanelState._generation; const previewLimit = parseInt(document.getElementById('epPreviewLimit')?.value) || 10; const btn = document.getElementById('epRunQueryBtn'); @@ -1660,15 +1667,14 @@ async function runEntityPanelQuery(options = {}) { }); const result = await response.json(); - // Discard stale response if the user switched to a different entity - if (currentPanelType !== 'entity' || capturedGeneration !== EntityPanelState._generation) return; - if (result.success) { EntityPanelState.columns = result.columns; EntityPanelState.rows = result.rows || []; if (!EntityPanelState.idColumn || !result.columns.includes(EntityPanelState.idColumn)) { - EntityPanelState.idColumn = null; + // Previously saved id column is gone from this result set — fall + // back to the first available column instead of leaving it unset. + EntityPanelState.idColumn = result.columns[0] || null; } if (EntityPanelState.labelColumn && !result.columns.includes(EntityPanelState.labelColumn)) { EntityPanelState.labelColumn = null; @@ -1677,7 +1683,7 @@ async function runEntityPanelQuery(options = {}) { autoMapEntityColumns(result.columns); renderEntityPanelGrid(); const epSummary = document.getElementById('epMappingSummary'); - if (epSummary) epSummary.style.display = 'none'; + if (epSummary) epSummary.style.display = ''; const epLoading = document.getElementById('epMappingLoading'); if (epLoading) epLoading.style.display = 'none'; const epGrid = document.getElementById('epMappingGrid'); @@ -1704,10 +1710,18 @@ async function runEntityPanelQuery(options = {}) { if (statusEl) statusEl.innerHTML = ' Error'; showNotification('Error: ' + error.message, 'error'); } finally { - if (capturedGeneration === EntityPanelState._generation && btn) { + // Always restore the button, regardless of generation — otherwise a + // panel switch mid-query leaves "Refreshing..." stuck permanently. + if (btn) { btn.disabled = false; btn.innerHTML = ' Refresh'; } + if (capturedGeneration !== EntityPanelState._generation) { + // Response belongs to a superseded generation: still hide the + // loading spinner so it doesn't stay stuck if the user comes back. + const epLoadingStale = document.getElementById('epMappingLoading'); + if (epLoadingStale) epLoadingStale.style.display = 'none'; + } } } @@ -1717,16 +1731,12 @@ function renderEntityPanelGrid() { if (!headerRow || !tbody) return; headerRow.innerHTML = EntityPanelState.columns.map(col => { - let badge = ''; - if (EntityPanelState.idColumn === col) { - badge = 'ID'; - } else if (EntityPanelState.labelColumn === col) { - badge = 'Label'; - } else { - const attr = Object.entries(EntityPanelState.attributeMappings).find(([a, c]) => c === col); - if (attr) badge = `${attr[0]}`; - else badge = 'Map'; - } + const badges = []; + if (EntityPanelState.idColumn === col) badges.push('ID'); + if (EntityPanelState.labelColumn === col) badges.push('Label'); + const attr = Object.entries(EntityPanelState.attributeMappings).find(([a, c]) => c === col); + if (attr) badges.push(`${attr[0]}`); + let badge = badges.length ? badges.join(' ') : 'Map'; if (EntityPanelState.driftedColumns.has(col)) { badge += ' ' + @@ -1774,20 +1784,43 @@ function showEntityColumnMenu(th, column) { document.body.appendChild(menu); menu.querySelectorAll('.dropdown-item').forEach(item => { - item.addEventListener('click', () => { + item.addEventListener('click', (e) => { + e.stopPropagation(); const action = item.dataset.action; - if (EntityPanelState.idColumn === column) EntityPanelState.idColumn = null; - if (EntityPanelState.labelColumn === column) EntityPanelState.labelColumn = null; - Object.keys(EntityPanelState.attributeMappings).forEach(a => { - if (EntityPanelState.attributeMappings[a] === column) delete EntityPanelState.attributeMappings[a]; - }); - - if (action === 'id') EntityPanelState.idColumn = column; - else if (action === 'label') EntityPanelState.labelColumn = column; - else if (action === 'attr') EntityPanelState.attributeMappings[item.dataset.attr] = column; - menu.remove(); + if (action === 'id') { + // Unique across the query, and toggles off on its own column. + EntityPanelState.idColumn = (EntityPanelState.idColumn === column) ? null : column; + } else if (action === 'label') { + EntityPanelState.labelColumn = (EntityPanelState.labelColumn === column) ? null : column; + } else if (action === 'attr') { + const attrName = item.dataset.attr; + const alreadyHere = EntityPanelState.attributeMappings[attrName] === column; + // One attribute maps to a single column... + delete EntityPanelState.attributeMappings[attrName]; + // ...and one column maps to a single attribute. + Object.keys(EntityPanelState.attributeMappings).forEach(a => { + if (EntityPanelState.attributeMappings[a] === column) delete EntityPanelState.attributeMappings[a]; + }); + if (!alreadyHere) EntityPanelState.attributeMappings[attrName] = column; + } else if (action === 'clear') { + if (EntityPanelState.idColumn === column) EntityPanelState.idColumn = null; + if (EntityPanelState.labelColumn === column) EntityPanelState.labelColumn = null; + Object.keys(EntityPanelState.attributeMappings).forEach(a => { + if (EntityPanelState.attributeMappings[a] === column) delete EntityPanelState.attributeMappings[a]; + }); + } + + const openColumn = column; renderEntityPanelGrid(); + if (action === 'clear') { + menu.remove(); + } else { + // Reopen the menu on the same column so multiple roles can be + // assigned in a row without having to reopen it each time. + const th = document.querySelector(`#epResultsHeader th[data-col="${CSS.escape(openColumn)}"]`); + if (th) showEntityColumnMenu(th, openColumn); + } }); }); @@ -1975,8 +2008,6 @@ async function runRelPanelQuery(options = {}) { }); const result = await response.json(); - if (currentPanelType !== 'relationship') return; - if (result.success) { RelPanelState.columns = result.columns; RelPanelState.rows = result.rows || [];