From 12f2277b83ebd50ea0412c407ca764f2a5a5db6c Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 27 May 2026 10:19:14 +0200 Subject: [PATCH 1/2] web app: UX quick wins for the browser dashboard Eight polish improvements following the existing data-action + delegated-listener + escHtml/escAttr patterns. Search & filtering - Debounced search inputs (~140ms, per-input keys) so fast typing doesn't re-render whole tables or re-rank the typeahead on every keystroke. - "No results" empty states on Measures + Columns with context-aware messaging (search match / no unused left / empty model), plus an inline Clear-search link via a new clear-filter action that also cancels any pending debounce. Lineage typeahead - Keyboard navigation: ArrowDown/Up move a highlight through hits, Enter opens the highlighted or first hit, Esc dismisses. Enter mid-debounce flushes a synchronous render so it always picks. - Theme-aware highlight style via var(--row-hover) plus an accent bar. Discoverability - Brief "Loaded - N tables - N measures - X KB - Y ms" stat shown on the overlay before it dismisses, plus a matching console.log. - Global "/" shortcut: jump to Lineage and focus the typeahead (classic site-search idiom; ignored while typing in inputs). - Auto-focus + select the search box when switching to Measures or Columns (mirrors the Lineage tab pattern). - Copy DAX-reference button on Source-Map rows with check confirmation, plus a shared copyText helper with clipboard/execCommand fallback. Build clean (npm run build:browser); new identifiers verified inlined into docs/index.html and docs/browser/entry.js. No new tests since there is no client-test suite. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/browser/entry.ts | 24 ++++++ src/client/main.ts | 164 +++++++++++++++++++++++++++++++++++++-- src/styles/dashboard.css | 2 + 3 files changed, 183 insertions(+), 7 deletions(-) diff --git a/src/browser/entry.ts b/src/browser/entry.ts index 58dc19d..c33debd 100644 --- a/src/browser/entry.ts +++ b/src/browser/entry.ts @@ -534,12 +534,26 @@ function rewireLandingButtons(): void { */ // LoadMode type now lives in ./pair-picker.ts (imported at the top). +// Sum the byte sizes of a VFS map (UTF-16 chars — close enough as a proxy +// for the "loaded" stat shown briefly before the overlay dismisses). +function totalVfsSize(files: Map): number { + let n = 0; + for (const v of files.values()) n += v.length; + return n; +} +function fmtBytes(n: number): string { + if (n < 1024) return n + " B"; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB"; + return (n / (1024 * 1024)).toFixed(2) + " MB"; +} + async function processFiles( files: Map, pickedName: string, fromSample: boolean, loadMode: LoadMode = "full", ): Promise { + const t0 = performance.now(); if (files.size === 0) { // The two-step picker handles the ".Report / .SemanticModel // picked directly" case upstream, so if we arrive here with zero @@ -635,6 +649,16 @@ async function processFiles( improvementsMdLite, }, loadMode); + // Briefly surface the load stat (tables/measures/size/duration) before + // dismissing the overlay — gives users a transparent signal of model + // size and parse cost. ~800 ms is long enough to read, short enough not + // to feel like a delay. + const dt = Math.round(performance.now() - t0); + const sz = fmtBytes(totalVfsSize(files)); + setStatus(`Loaded · ${fullData.tables.length} tables · ${fullData.measures.length} measures · ${sz} · ${dt} ms`); + // eslint-disable-next-line no-console + console.log(`[entry] loaded "${reportName}" — ${fullData.tables.length} tables · ${fullData.measures.length} measures · ${sz} in ${dt}ms (fromSample=${fromSample})`); + await new Promise(r => setTimeout(r, 800)); hideOverlay(); setStatus(""); } diff --git a/src/client/main.ts b/src/client/main.ts index 71119f6..dc19730 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -225,6 +225,30 @@ const pageData=(DATA.pages||[]).slice(); // chip's action without bubbling to the parent's toggle — no need // for event.stopPropagation() at each site. // ───────────────────────────────────────────────────────────────────── +// Shared clipboard helper with navigator.clipboard → execCommand fallback + +// optional visual "✓" feedback on the source button. Used by data-action +// dispatchers (e.g. copy-ref) that need to copy a short string on click. +function copyText(text: string, btn?: HTMLElement): void { + function ok(): void { + if (!btn) return; + const orig = btn.textContent; + btn.textContent = "✓"; + setTimeout(function(){ btn.textContent = orig; }, 900); + } + function fallback(): void { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand("copy"); ok(); } catch (_e) { /* swallow */ } + document.body.removeChild(ta); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(ok).catch(fallback); + } else fallback(); +} + // In-document anchor clicks inside the Docs tab (.md-rendered) // — intercept so they scroll to the heading within the Docs // container instead of appending #anchor to the URL (which makes the @@ -303,6 +327,8 @@ document.addEventListener('click', function(e){ case 'md-lite-mode': switchMdLiteMode((d.mode as "lite" | "detailed") || "detailed"); break; case 'sort': sortTable(d.table, d.key); break; case 'unused-filter': toggleUnused(d.entity); break; + case 'clear-filter': clearFilter(d.entity); break; + case 'copy-ref': copyText("'" + (d.table || "") + "'[" + (d.column || "") + "]", el); break; case 'theme': toggleTheme(); break; case 'theme-set': setTheme(d.themeName || 'dark'); break; case 'show-whats-new': showChangelogPopup(); break; @@ -343,6 +369,17 @@ document.addEventListener('click', function(e){ // missed the oninput ones; this closes the "no inline handlers" // invariant. Same structural guarantee: user text reaches // filterTable via HTMLInputElement.value (browser-decoded, safe). +// Debounce search-driven re-renders so fast typing doesn't re-render a whole +// table / re-rank the typeahead on every keystroke. Keyed per input (action + +// entity) so Measures and Columns debounce independently. ~140ms reads as +// instant but coalesces a burst of keystrokes into one render. +const SEARCH_DEBOUNCE_MS = 140; +const inputDebounce: Record> = {}; +function debounceInput(key: string, fn: () => void): void { + if (inputDebounce[key]) clearTimeout(inputDebounce[key]); + inputDebounce[key] = setTimeout(fn, SEARCH_DEBOUNCE_MS); +} + document.addEventListener('input', function(e){ const target = e.target as Element | null; const el = target?.closest && target.closest('[data-action]') as HTMLInputElement | null; @@ -350,8 +387,17 @@ document.addEventListener('input', function(e){ var a = el.getAttribute('data-action'); var d = el.dataset; switch (a) { - case 'filter': filterTable(d.entity, el.value); break; - case 'lineage-search': renderLineageSearchResults(el.value); break; + case 'filter': { + const entity = d.entity as string, value = el.value; + debounceInput('filter:' + entity, function(){ filterTable(entity, value); }); + break; + } + case 'lineage-search': { + const value = el.value; + lineageSearchActive = -1; // typing invalidates any keyboard highlight + debounceInput('lineage-search', function(){ renderLineageSearchResults(value); }); + break; + } } }); @@ -378,6 +424,68 @@ document.addEventListener('click', function(e){ } }); +// ───────────────────────────────────────────────────────────────────── +// Lineage typeahead keyboard navigation — ↓/↑ move a highlight through the +// dropdown hits, Enter opens the highlighted (or first) hit, Esc dismisses. +// Reads hits straight from the DOM so it stays in sync with whatever +// renderLineageSearchResults() last rendered. Delegated off document so it +// works regardless of script/DOM load order. +// ───────────────────────────────────────────────────────────────────── +let lineageSearchActive = -1; // index of the highlighted hit, -1 = none + +function lineageHits(): HTMLElement[] { + const host = document.getElementById("lineage-search-results"); + if (!host || host.style.display === "none") return []; + return Array.from(host.querySelectorAll(".lineage-search-hit")) as HTMLElement[]; +} +function highlightLineageHit(idx: number): void { + const hits = lineageHits(); + if (!hits.length) return; + lineageSearchActive = idx; + hits.forEach(function(h, i){ + if (i === idx) { h.classList.add("active"); h.scrollIntoView({ block: "nearest" }); } + else h.classList.remove("active"); + }); +} +function moveLineageHighlight(delta: number): void { + const hits = lineageHits(); + if (!hits.length) return; + const idx = lineageSearchActive < 0 + ? (delta > 0 ? 0 : hits.length - 1) + : (lineageSearchActive + delta + hits.length) % hits.length; + highlightLineageHit(idx); +} +document.addEventListener("keydown", function(e: KeyboardEvent){ + const t = e.target as HTMLElement | null; + if (!t || t.id !== "lineage-search-input") return; + if (e.key === "ArrowDown") { if (lineageHits().length) { e.preventDefault(); moveLineageHighlight(1); } } + else if (e.key === "ArrowUp") { if (lineageHits().length) { e.preventDefault(); moveLineageHighlight(-1); } } + else if (e.key === "Enter") { + let hits = lineageHits(); + if (!hits.length) { // flush a pending debounced render so Enter still works mid-debounce + renderLineageSearchResults((t as HTMLInputElement).value); + hits = lineageHits(); + } + const pick = (lineageSearchActive >= 0 && hits[lineageSearchActive]) || hits[0]; + if (pick) { e.preventDefault(); navigateLineage(pick.dataset.type, pick.dataset.name); lineageSearchActive = -1; } + } else if (e.key === "Escape") { + const host = document.getElementById("lineage-search-results"); + if (host) { host.style.display = "none"; host.innerHTML = ""; } + lineageSearchActive = -1; + } +}); + +// Global "/" shortcut — classic site-search: jumps to the Lineage tab and +// focuses the typeahead. Ignored when the user is already typing somewhere +// (inputs, textareas, contenteditable) so it doesn't hijack normal text entry. +document.addEventListener("keydown", function(e: KeyboardEvent){ + if (e.key !== "/" || e.ctrlKey || e.altKey || e.metaKey) return; + const t = e.target as HTMLElement | null; + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; + e.preventDefault(); + switchTab("lineage"); // switchTab focuses #lineage-search-input on a 0ms timeout +}); + // uc — see src/client/render/escape.ts function renderSummary(){ @@ -473,6 +581,13 @@ function switchTab( id: any) { const input = document.getElementById("lineage-search-input") as HTMLInputElement | null; if (input) setTimeout(function(){ input.focus(); }, 0); } + // Measures / Columns tabs: focus + select the search box so the user can + // type to filter immediately. Mirrors the Lineage tab's "single keystroke + // from tab-switch to typing" pattern. + if (id === "measures" || id === "columns") { + const search = document.querySelector('.search-input[data-entity="' + id + '"]') as HTMLInputElement | null; + if (search) setTimeout(function(){ search.focus(); search.select(); }, 0); + } // Functions + Calc Groups tabs display DAX bodies. Running through // addCopyButtons() here (which also highlights) colourises any new // blocks that weren't highlighted at initial render. @@ -485,7 +600,7 @@ function renderMeasures(){ items.sort((a: any, b: any) =>{let av=a[s.key],bv=b[s.key];if(typeof av==='string')return s.desc?bv.localeCompare(av):av.localeCompare(bv);return s.desc?bv-av:av-bv;}); if(showUnusedOnly.measures)items=items.filter((m: any) =>m.status!=='direct'); if(searchTerms.measures){const q=searchTerms.measures.toLowerCase();items=items.filter((m: any) =>m.name.toLowerCase().includes(q)||m.table.toLowerCase().includes(q));} - document.getElementById("tbody-measures")!.innerHTML=items.map((m: any) =>{ + document.getElementById("tbody-measures")!.innerHTML=items.length?items.map((m: any) =>{ const deps=m.daxDependencies.map((d: any) =>`${escHtml(d)}`).join("")||''; const pages=[...new Set(m.usedIn.map((u: any) =>u.pageName))]; const used=pages.map((p: any) =>`${escHtml(p)}`).join("")||''; @@ -493,7 +608,7 @@ function renderMeasures(){ const nameAttr=m.description?' title="'+escAttr(m.description)+'" data-desc="1"':''; const descRow=m.description?'
'+escHtml(m.description)+'
':''; return `${escHtml(m.name)}${statusBadge}${descRow}${escHtml(m.table)}${m.usageCount}${m.pageCount}${deps}${used}${escHtml(m.formatString||'—')}`; - }).join(""); + }).join(""):tableEmptyRow(7,"measures"); setPanelFooter("footer-measures", "Showing "+items.length+" of "+DATA.measures.length+" measures · "+DATA.totals.measuresUnused+" unused · "+DATA.totals.measuresIndirect+" indirect", sortIndicator(sortState.measures)); @@ -504,7 +619,7 @@ function renderColumns(){ items.sort((a: any, b: any) =>{let av=a[s.key],bv=b[s.key];if(typeof av==='string')return s.desc?bv.localeCompare(av):av.localeCompare(bv);return s.desc?bv-av:av-bv;}); if(showUnusedOnly.columns)items=items.filter((c: any) =>c.status!=='direct'); if(searchTerms.columns){const q=searchTerms.columns.toLowerCase();items=items.filter((c: any) =>c.name.toLowerCase().includes(q)||c.table.toLowerCase().includes(q));} - document.getElementById("tbody-columns")!.innerHTML=items.map((c: any) =>{ + document.getElementById("tbody-columns")!.innerHTML=items.length?items.map((c: any) =>{ const pages=[...new Set(c.usedIn.map((u: any) =>u.pageName))]; const used=pages.map((p: any) =>`${escHtml(p)}`).join("")||''; // SLICER badge intentionally omitted here — it now lives on the per-column @@ -514,7 +629,7 @@ function renderColumns(){ const cNameAttr=c.description?' title="'+escAttr(c.description)+'" data-desc="1"':''; const cDescRow=c.description?'
'+escHtml(c.description)+'
':''; return `${escHtml(c.name)}${statusBadge}${cDescRow}${escHtml(c.table)}${escHtml(c.dataType)}${c.usageCount}${c.pageCount}${used}`; - }).join(""); + }).join(""):tableEmptyRow(6,"columns"); setPanelFooter("footer-columns", "Showing "+items.length+" of "+DATA.columns.length+" columns · "+DATA.totals.columnsUnused+" unused", sortIndicator(sortState.columns)); @@ -1875,7 +1990,12 @@ function renderSourceMap(){ if(r.isHidden)flags.push('hidden'); if(r.isCalculated)flags.push('calc'); body+=''+ - ''+escHtml(r.column)+''+(flags.length?' '+flags.join(" "):'')+''+ + ''+escHtml(r.column)+''+(flags.length?' '+flags.join(" "):'')+ + ''+ + ''+ ''+escHtml(r.table)+''+ ''+escHtml(r.dataType)+''+ ''+escHtml(r.mode)+''+ @@ -2091,6 +2211,36 @@ function sortTable( t: any, k: any) {const s=sortState[t];if(s.key===k)s.desc=!s function filterTable( t: any, v: any) {searchTerms[t]=v;t==="measures"?renderMeasures():renderColumns();} function toggleUnused( t: any) {showUnusedOnly[t]=!showUnusedOnly[t];document.getElementById("btn-unused-"+(t==="measures"?"m":"c"))!.classList.toggle("active");t==="measures"?renderMeasures():renderColumns();} +// Full-width placeholder row shown when a Measures/Columns table renders zero +// rows. Distinguishes "no search match" (offers a clear link) from "no unused +// left" (a positive) from "empty model". +function tableEmptyRow(colspan: number, entity: string): string { + const term = searchTerms[entity]; + let inner: string; + if (term) { + inner = 'No ' + entity + ' match “' + escHtml(term) + '”' + + 'Clear search'; + } else if (showUnusedOnly[entity]) { + inner = 'No unused ' + entity + ' — everything here is referenced. ✓'; + } else { + inner = 'No ' + entity + ' in this model.'; + } + return '' + inner + ''; +} + +// Clear only the search term for a table (leaves the unused-only toggle as-is) +// and re-render. Cancels any pending debounced filter so a stale keystroke +// can't re-apply the term after the clear. +function clearFilter(entity: any){ + if (inputDebounce['filter:' + entity]) clearTimeout(inputDebounce['filter:' + entity]); + searchTerms[entity] = ""; + const input = document.querySelector('.search-input[data-entity="' + entity + '"]') as HTMLInputElement | null; + if (input) input.value = ""; + entity === "measures" ? renderMeasures() : renderColumns(); +} + // Docs-tab Lite/Detailed mode toggle. "detailed" preserves the current // behaviour (the rich reference shape); "lite" emits the paste-into- // wiki summary version, sourced from MARKDOWN_*_LITE globals baked diff --git a/src/styles/dashboard.css b/src/styles/dashboard.css index bbaff6c..4831cbd 100644 --- a/src/styles/dashboard.css +++ b/src/styles/dashboard.css @@ -297,6 +297,8 @@ .search-input{flex:1;padding:var(--space-4) var(--space-5);font-size:var(--fs-md);font-family:inherit;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-md);color:var(--text-body);outline:none;transition:border-color .15s} .search-input:focus{border-color:var(--accent)} .search-input::placeholder{color:var(--text-faint)} + .lineage-search-hit:hover,.lineage-search-hit.active{background:var(--row-hover)} + .lineage-search-hit.active{box-shadow:inset 2px 0 0 var(--accent)} .filter-btn{padding:6px 12px;font-size:11px;border:1px solid var(--border);border-radius:6px;cursor:pointer;background:var(--surface);color:var(--text-dim);font-family:inherit;transition:all .15s} .filter-btn:hover,.filter-btn.active{background:var(--border);color:var(--text)} .filter-btn.active{border-color:var(--accent);color:var(--accent)} From 8b9484414df491450316753614eabbcf31360f9f Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 27 May 2026 10:24:46 +0200 Subject: [PATCH 2/2] fix(web app): use template literals around data-* attributes (XSS-fuzz) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new HTML strings added in the previous commit used string concat ('hidden'); if(r.isCalculated)flags.push('calc'); + // Template literals around the data-* attributes — string concatenation + // that splices `' + escAttr(...) + '` inside `data-table="…"` trips the + // XSS-fuzz structural test, which scans the inlined script source for + // raw single-quotes inside data-* attribute patterns. Same rule the + // lineage typeahead follows (see renderLineageSearchResults). + const copyBtn = ``; body+=''+ ''+escHtml(r.column)+''+(flags.length?' '+flags.join(" "):'')+ - ''+ + copyBtn+ ''+ ''+escHtml(r.table)+''+ ''+escHtml(r.dataType)+''+ @@ -2218,9 +2221,12 @@ function tableEmptyRow(colspan: number, entity: string): string { const term = searchTerms[entity]; let inner: string; if (term) { - inner = 'No ' + entity + ' match “' + escHtml(term) + '”' - + 'Clear search'; + // Template literal for the data-entity attribute — string concatenation + // here trips the XSS-fuzz structural test (raw single-quotes inside the + // inlined script's data-* attribute patterns). See note above and the + // matching pattern in renderLineageSearchResults / renderSourceMap. + inner = `No ${escHtml(entity)} match “${escHtml(term)}”` + + ` Clear search`; } else if (showUnusedOnly[entity]) { inner = 'No unused ' + entity + ' — everything here is referenced. ✓'; } else {