diff --git a/CHANGELOG.md b/CHANGELOG.md index 797f1c48..5c5baa3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,90 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] -## [0.4.5] - 2026-07-14 +### Added +- **Favorited saved queries can now act as Dashboard Filter sources** (#160). + One explicit read-only query returns exactly one row containing any number of + `Array(T)`, `Array(Tuple(value T, label L))`, or `Map(K,V)` helpers. Exact + result-column names upgrade matching Dashboard parameters to strict, + searchable single-select controls; invalid sources and provider conflicts + fall back per field without delaying or removing unrelated panels. Filter + requests run and reconcile persisted activation before Panel requests start, + with bounded concurrency, cancellation generations, Refresh, and source Retry. + The workbench result selector is role-aware, preserves dormant Panel config, + and provides a completed-run-only Filter preview without changing shared + Dashboard values. +- `examples/query-log-explorer.json` — a worked Dashboard Filter sources demo + against `system.query_log` on any cluster: one Filter source per option + shape (`Array(Tuple(value, label))`, `Map(String, String)`, plain + `Array(T)`), plain auto-detected fields alongside them, a KPI panel, four + analytical Panels adapted from the Altinity KB's ["Handy queries for + system.query_log"](https://kb.altinity.com/altinity-kb-useful-queries/query_log/), + a Logs panel, and a Text panel explaining the demo. + +### Fixed +- Review follow-ups on the Dashboard Filter sources work above, found in a + UI/UX pass on #232 before merge: a curated field's clear (×) button now + reports the cleared value (not the stale prior selection) to + `varValues`/`filterActive`, and gets an `aria-label` naming the field it + clears instead of an anonymous "×" (this is what the e2e suite was actually + catching — same bug, both assertions). The clear button is icon-based and + positioned inside the field like every other clear affordance, instead of + falling into normal flow below the input. The Dashboard's role/Filter + diagnostic banners (`.dash-config-diagnostic`, e.g. "Filter helper … has no + current Panel consumer") and the workbench Filter preview's type/diagnostic + text now have real styling — both referenced undefined CSS variables and + rendered as unstyled body text. The tab-strip/Library "Filter" role badge no + longer reads as a second open tab (it shared the bordered `.qtab` row with no + styling of its own). `Enum8`/`Enum16` and `LowCardinality(...)` columns are + now recognized as valid Filter/KPI scalar types (the type parser rejected + Enum's quoted member list and never unwrapped `LowCardinality`). A Filter or + KPI query's Table/JSON view no longer shows `[object Object]` for named-tuple + columns serialized as objects. A curated field now gets the same + is-invalid/conflict affordance a plain filter field does. A real pointer + click on the clear button double-committed (mousedown blurred the input + before the click handler ran); fixed with the same commit-before-blur + `preventDefault` pattern `combobox.js` already uses for option commits. A + curated field never got the `is-optional` CSS class, so it always showed + the required-field asterisk even when its param was genuinely optional. +- A second pre-merge cleanup pass on #232 removing invented primitives and + duplication, and fixing bugs found alongside them: + - The curated Filter field (Dashboard filter bar **and** the bottom-drawer + Filter preview) is rewritten to reuse the shared `var-combo` combobox + primitive (`combobox.js`'s `createCombobox`/`wireComboInput`, the same + `.var-combo`/`.var-input`/`.var-combo-list` clothes the enum/recent/ + relative-time fields wear). It previously hand-rolled its own listbox with + CSS classes that did not exist, so the dropdown rendered as an unstyled + inline bulleted list that pushed the clear (×) button out of place. + - The `{severity, code, message, …}` diagnostic factory duplicated across + three Filter modules is now one shared `core/diagnostics.js` helper (#236). + - Filter sources reuse the tile wave's generation/abort guard + (`supersedeSlot`/`slot.gen`) instead of a parallel re-implementation (#237). + - Curated Filter fields are seeded from a persisted last-known bundle + (`asb:filterCurated`) so they paint as the searchable dropdown immediately + instead of flashing a plain text input for one frame on each load (#234). + - The result-presentation picker no longer breaks for a `table`-typed panel: + it mapped to a `panel:table` value that matched no option, leaving the + select blank with no way back to Table — a table panel now resolves to the + `(auto)` entry (Table's surface remains the adjacent Table view). + - Typing SQL now re-evaluates the whole Spec validator graph only for + Filter-role tabs (whose diagnostics depend on the SQL), not on every + keystroke of every tab. + - The result-presentation ``, and the styled `position:fixed` +// `.var-combo-list` popover, wired through the shared `wireComboInput` helper. +// It does NOT hand-roll its own listbox classes or its own listener block +// (an earlier draft did, and rendered as an unstyled inline bulleted list). +// +// It differs from those three only in policy, not in looks: it is STRICT +// (blur/Enter revert to the last committed option instead of keeping arbitrary +// text — a curated source enumerates every legal value), and it carries an +// explicit inactive state ("All"/"Not set") with a clear button, since a +// dashboard filter's default is "no predicate" rather than empty text. Picking +// an option activates it; the × clears back to inactive. + +import { createCombobox, idSafe, wireComboInput } from './combobox.js'; +import { h } from './dom.js'; +import { Icon } from './icons.js'; + +/** + * @param {{ + * document?: Document, name: string, options?: {value: string, label: string}[], + * value?: string, active?: boolean, inactiveLabel?: string, preview?: boolean, + * onValueChange?: (value: string, active: boolean) => void, + * onCommit?: (value: string, active: boolean) => void, + * }} opts + * @returns {{el: HTMLElement, input: HTMLInputElement, destroy: () => void}} + */ +export function buildFilterOptionField({ + document: doc, name, options = [], value = '', active = false, + inactiveLabel = 'All', preview = false, onValueChange = () => {}, onCommit = () => {}, +}) { + const d = doc || document; + const suffix = idSafe(name); + const listId = 'filter-option-list-' + suffix; + const liveId = 'filter-option-live-' + suffix; + const selected = () => options.find((option) => option.value === value); + const input = h('input', { + type: 'text', id: 'filter-option-' + suffix, class: 'var-input', 'aria-label': name, + role: 'combobox', 'aria-autocomplete': 'list', 'aria-expanded': 'false', 'aria-controls': listId, + autocomplete: 'off', placeholder: inactiveLabel, + }); + const listEl = h('ul', { class: 'var-combo-list', id: listId, role: 'listbox', hidden: true }); + const liveEl = h('div', { class: 'sr-only', id: liveId, 'aria-live': 'polite' }); + const display = () => (active ? (selected()?.label ?? value) : ''); + input.value = display(); + let committedText = input.value; + + const commitOption = (option) => { + value = option.value; + active = true; + input.value = option.label; + committedText = option.label; + onValueChange(value, true); + onCommit(value, true); + }; + + const combo = createCombobox({ + input, listEl, liveEl, document: d, + getOptions: (text) => { + const q = String(text || '').toLowerCase(); + return options.filter((option) => !q + || option.label.toLowerCase().includes(q) || option.value.toLowerCase().includes(q)); + }, + onCommit: commitOption, + }); + + // Strict commit (blur/Enter with no active dropdown option): only an exact + // label/value match commits; anything else reverts to the last committed + // text — a curated field never holds free text. + const strictCommit = () => { + const typed = input.value; + const option = options.find((item) => item.label === typed || item.value === typed); + if (option) commitOption(option); + else input.value = committedText; + }; + + // Reuse the shared focus/input/keydown/blur/composition wiring (combobox.js) + // — the same helper enum/recent/relative-time fields use. `onValueInput` is a + // no-op (selection, not keystrokes, commits a strict field); the combobox's + // own onCommit handles an option pick, and this onCommit handles the + // blur/Enter strict path. + wireComboInput({ input, ...combo }, { onValueInput: () => {}, onCommit: strictCommit }); + if (preview) input.setAttribute('data-preview-local', 'true'); + + // The inline clear (×) resets to the inactive "All" state. Omitted in the + // read-only drawer preview (`preview`), where the field is a demonstration in + // a grid cell, not a live dashboard filter — the user asked for no × there. + const clear = preview ? null : h('button', { + class: 'var-combo-clear-inline', type: 'button', title: inactiveLabel, + 'aria-label': `Clear ${name}`, + // Commit BEFORE blur (#174 §1, same as an option's own mousedown-commit in + // combobox.js): without this, a real pointer click blurs the input FIRST, + // and the blur handler's strictCommit() re-commits whatever text is still + // showing before this handler even runs — double-committing the clear. + onmousedown: (e) => e.preventDefault(), + onclick: () => { + value = ''; + active = false; + input.value = ''; + committedText = ''; + onValueChange(value, false); + onCommit(value, false); + }, + }, Icon.close()); + + return { + el: h('div', { class: 'var-combo filter-select' }, input, clear, listEl, liveEl), + input, + destroy: combo.close, + }; +} diff --git a/src/ui/filter-preview.js b/src/ui/filter-preview.js new file mode 100644 index 00000000..5dc27a44 --- /dev/null +++ b/src/ui/filter-preview.js @@ -0,0 +1,49 @@ +import { h } from './dom.js'; +import { buildFilterOptionField } from './filter-option-field.js'; + +const message = (text, cls = '') => h('div', { class: `filter-preview-message ${cls}`.trim() }, text); + +// The Filter drawer preview: a read-only description of the option bundles a +// Filter source produces, laid out with the SAME grid classes as the Table view +// (`.res-table`) so it reads as a consistent result view rather than a bespoke +// panel. One row per helper — `name · options · type · example` — where the +// `example` cell hosts a live (local-only) combobox so the shape can be tried +// out without touching shared Dashboard filter state. The interactive control +// is `buildFilterOptionField` in its `preview` mode (no clear × — this is a demo +// cell, not a live filter). The table is built by hand rather than via +// `renderGrid` because that renderer stringifies every cell and can't host the +// live element. +const HEADERS = ['name', 'options', 'type', 'example']; + +export function renderFilterPreview(app) { + const preview = app.activeTab().filterPreview; + if (!preview) return message('Run the query to preview Filter options.'); + if (preview.status === 'running') return message('Filter preview appears when the query completes.'); + if (preview.status === 'error') return message(preview.error || 'Filter options failed.', 'is-error'); + const { helpers, diagnostics } = preview.normalized; + const out = h('div', { class: 'filter-preview' }); + if (helpers.length) { + const headRow = h('tr', null, + h('th', { style: { textAlign: 'center', color: 'var(--fg-faint)', minWidth: '36px' } }, '#'), + ...HEADERS.map((label) => h('th', null, h('div', { class: 'h-inner' }, h('span', { class: 'h-name' }, label))))); + const tbody = h('tbody', null); + helpers.forEach((helper, i) => { + const field = buildFilterOptionField({ + document: app.document, name: helper.name, options: helper.options, + inactiveLabel: 'All', preview: true, onValueChange: () => {}, + }); + tbody.appendChild(h('tr', null, + h('td', { class: 'idx' }, String(i + 1)), + h('td', { class: 'cell' }, h('div', { class: 'cell-val' }, helper.name)), + h('td', { class: 'cell num' }, h('div', { class: 'cell-val' }, helper.totalOptions.toLocaleString())), + h('td', { class: 'cell' }, h('div', { class: 'cell-val' }, helper.sourceType)), + h('td', { class: 'cell filter-example' }, field.el))); + }); + out.appendChild(h('div', { class: 'res-table-wrap' }, + h('table', { class: 'res-table' }, h('thead', null, headRow), tbody))); + } + for (const diagnostic of diagnostics) { + out.appendChild(h('div', { class: `filter-preview-diagnostic is-${diagnostic.severity}` }, diagnostic.message)); + } + return out.childNodes.length ? out : message('No options'); +} diff --git a/src/ui/grid-render.js b/src/ui/grid-render.js index 09c17de4..8cc59322 100644 --- a/src/ui/grid-render.js +++ b/src/ui/grid-render.js @@ -181,7 +181,11 @@ export function renderGrid({ columns, rows: rawRows, sort, onSort, widths, onCel tr.appendChild(h('td', { class: 'idx' }, String(ri + 1))); row.forEach((v, ci) => { const isNum = isNumericType(columns[ci].type); - const text = v == null ? '' : String(v); + // Named tuples/maps can arrive as plain objects (e.g. a Filter or KPI + // query's owned execution profile requests + // output_format_json_named_tuples_as_objects) — String(v) on those would + // read as "[object Object]" instead of the value (same fix as logs.js). + const text = v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v); // Truncate in-cell (CSS max-width + ellipsis); click opens the full value // in a side drawer so one fat column (e.g. HTML blobs) can't dominate. // `onCell` is optional: a consumer with no cell-detail surface omits it diff --git a/src/ui/panels.js b/src/ui/panels.js index 94dc0857..c638f9c5 100644 --- a/src/ui/panels.js +++ b/src/ui/panels.js @@ -21,7 +21,7 @@ import { h } from './dom.js'; import { Icon } from './icons.js'; import { renderChart } from './chart-render.js'; -import { patchSpecDraft, tabPanel } from '../state.js'; +import { patchSpecDraft, setTabSpecDraft, tabPanel } from '../state.js'; import { patchQueryPanel } from '../core/saved-query.js'; import { renderGridView, GRID_VIS_CAP } from './grid-render.js'; import { renderLogs } from './logs.js'; @@ -31,6 +31,9 @@ import { } from '../core/panel-cfg.js'; import { CHART_TYPES, schemaKey } from '../core/chart-data.js'; import { renderKpiPanel } from './kpi-panel.js'; +import { + applyResultChoice, DASHBOARD_ROLE_RESULT_CHOICES, PANEL_RESULT_CHOICES, resultChoiceForSpec, +} from '../core/result-choice.js'; // ── Markdown AST → DOM ─────────────────────────────────────────────────────── @@ -304,31 +307,63 @@ function writePanel(app, hooks, payload, activate = false) { export function renderPanelTypePicker(app, r, hooks) { const { hasGrid, columns, saved, resolved, rescueLogs } = panelContext(app, r); const select = h('select', { - class: 'result-panel-select' + (app.state.resultView.value === 'panel' ? ' active' : ''), - 'aria-label': 'Panel type', - title: 'Choose a panel visualization', + class: 'result-panel-select' + (['panel', 'filter'].includes(app.state.resultView.value) ? ' active' : ''), + 'aria-label': 'Result presentation', + title: 'Choose a panel visualization or Dashboard role', onchange: (e) => { - const type = e.target.value; - if (!type) return; - const base = saved && !resolved.rederived - ? saved - : { cfg: resolved.cfg, key: hasGrid && isChartFamily(resolved.cfg.type) ? schemaKey(columns) : null }; - const next = switchPanelType(base, type, columns); - if (hasGrid && isChartFamily(next.cfg.type)) next.key = schemaKey(columns); - writePanel(app, hooks, next, true); + const selectedId = e.target.value.includes(':') ? e.target.value : `panel:${e.target.value}`; + const choice = [...PANEL_RESULT_CHOICES, ...DASHBOARD_ROLE_RESULT_CHOICES] + .find((item) => item.id === selectedId); + if (!choice) return; + const tab = app.activeTab(); + const apply = (spec) => { + let query = { id: tab.savedId, sql: tab.sqlDraft, specVersion: tab.specVersion, spec }; + if (choice.kind === 'panel') { + const base = saved && !resolved.rederived + ? saved + : { cfg: resolved.cfg, key: hasGrid && isChartFamily(resolved.cfg.type) ? schemaKey(columns) : null }; + query = patchQueryPanel(query, { cfg: base.cfg, key: base.key ?? undefined }); + } + return applyResultChoice(query, choice, columns).spec; + }; + let result; + if (choice.kind === 'role' && !tab.specDiagnostics?.some((item) => item.code === 'invalid-json')) { + setTabSpecDraft(tab, apply(tab.specParsed), { dirty: true, validationService: app.specValidators }); + result = { ok: true, invalidTab: null }; + } else { + result = patchSpecDraft(tab, apply, { dirty: true, validationService: app.specValidators }); + } + if (!result.ok) { app.activateInvalidSpecDraft(result.invalidTab); return; } + app.revalidateSpecDrafts(); + app.specEditor.syncFromState(); + app.state.resultView.value = choice.kind === 'role' ? 'filter' : 'panel'; + hooks.markDirty(); + hooks.rerender(); }, }); - const prompt = h('option', { value: '' }, 'Panel…'); + // A disabled placeholder shown whenever the drawer is on Table/JSON (not a + // preview). Selecting it is impossible, so picking ANY real entry — even the + // query's current type/role — is a genuine `change` that switches the view to + // that preview. Without it, `select.value` would already equal the current + // choice and re-picking it would fire no event (the view would never switch). + const prompt = h('option', { value: '' }, 'Preview…'); prompt.disabled = true; select.appendChild(prompt); - for (const option of PANEL_PICKER_OPTIONS) { - const el = h('option', { value: option.value }, option.label); - select.appendChild(el); + const panelGroup = h('optgroup', { label: 'Panel' }); + if (resultChoiceForSpec(app.activeTab().specParsed) === 'panel:auto') { + const auto = h('option', { value: 'panel:auto' }, '(auto)'); + auto.disabled = true; + panelGroup.appendChild(auto); } - // The authoring type, even while rescueLogs means the preview below is a - // temporary fallback chart/table rather than the saved Logs config. - const authoringType = rescueLogs ? 'logs' : resolved.cfg.type !== 'table' ? resolved.cfg.type : ''; - select.value = app.state.resultView.value === 'panel' ? authoringType : ''; + for (const option of PANEL_RESULT_CHOICES) panelGroup.appendChild(h('option', { value: option.id }, option.label)); + const roleGroup = h('optgroup', { label: 'Dashboard role' }); + for (const option of DASHBOARD_ROLE_RESULT_CHOICES) roleGroup.appendChild(h('option', { value: option.id }, option.label)); + select.append(panelGroup, roleGroup); + // Reflect the current choice only while a preview is showing; on Table/JSON + // the placeholder is selected so any pick is a real change (see above). + select.value = ['panel', 'filter'].includes(app.state.resultView.value) + ? resultChoiceForSpec(app.activeTab().specParsed) + : ''; return select; } diff --git a/src/ui/results.js b/src/ui/results.js index 7952ee0f..6223f377 100644 --- a/src/ui/results.js +++ b/src/ui/results.js @@ -23,6 +23,7 @@ import { openInDetachedTab } from './detached-view.js'; import { buildFilterBar } from './filter-bar.js'; import { startDrag, clampDrawerWidth } from './splitters.js'; import { panelExecution } from '../core/panel-execution.js'; +import { renderFilterPreview } from './filter-preview.js'; // View id → tab glyph for the EXPLAIN view strip (kept here so core/explain.js // stays DOM-free). Pipeline reuses the node-graph share glyph. @@ -71,14 +72,16 @@ export function renderResults(app) { } const view = app.state.resultView.value; const streamingBlank = app.state.running.value && (!r || (r.rows.length === 0 && r.rawText == null)); - if (streamingBlank) { + if (streamingBlank && view !== 'filter') { inner.appendChild(loadingPlaceholder('Starting query…')); - } else if (!r && view !== 'panel') { + } else if (!r && view !== 'panel' && view !== 'filter') { // The Panel tab renders even with no result at all (#166): a text panel // needs none, and query-backed types show their own empty-preview hint. inner.appendChild(h('div', { class: 'empty-results' }, h('div', { class: 'chip' }, Icon.play()), h('div', null, 'Press ', h('kbd', null, '⌘↵'), ' to run query'))); + } else if (view === 'filter') { + inner.appendChild(renderFilterPreview(app)); } else if (r && r.error) { inner.appendChild(h('div', { class: 'results-error' }, r.error)); } else if (r && r.schemaGraph) { diff --git a/src/ui/saved-history.js b/src/ui/saved-history.js index 49af1699..bcf52041 100644 --- a/src/ui/saved-history.js +++ b/src/ui/saved-history.js @@ -14,6 +14,8 @@ import { import { isAutoRunnable } from '../core/sql-split.js'; import { isQuerylessPanel } from '../core/panel-cfg.js'; import { queryDescription, queryFavorite, queryName, queryPanel, queryView } from '../core/saved-query.js'; +import { effectiveDashboardRole } from '../core/result-choice.js'; +import { filterRoleBadge } from './tabs.js'; // Make a Library/History row draggable; dropping it on the editor inserts the // query wrapped as a `( … )` subquery (see the editor's drop handler). @@ -143,6 +145,9 @@ function renderSaved(app, list) { h('div', { class: 'top' }, star, h('span', { class: 'name' }, name), + effectiveDashboardRole(q.spec) === 'filter' + ? filterRoleBadge(app, () => app.actions.loadIntoNewTab(q) || app.activeTab()) + : null, h('button', { class: 'sv-act', title: 'Edit name & description', onclick: (e) => { diff --git a/src/ui/tabs.js b/src/ui/tabs.js index e6533438..863e1c82 100644 --- a/src/ui/tabs.js +++ b/src/ui/tabs.js @@ -6,6 +6,26 @@ import { Icon } from './icons.js'; import { activeTab, allocTabId, newTabObj, setTabSpecDraft, tabDirty } from '../state.js'; import { cloneJson, queryName, upgradeSavedQuery } from '../core/saved-query.js'; import { batch } from '@preact/signals-core'; +import { effectiveDashboardRole } from '../core/result-choice.js'; + +/** + * The "Filter" role badge shown next to a tab name (tabs.js) or a Library row + * (saved-history.js) — the one shared button both surfaces need so the label, + * tooltip, and click affordance can't drift between them (CLAUDE.md rule 5: + * extract on a second consumer). `onOpen()` does whatever surface-specific + * work gets a tab active + its spec text, then this reveals the role field. + */ +export function filterRoleBadge(app, onOpen) { + return h('button', { + class: 'query-role-badge', title: 'Open Filter role in Spec', + onclick: (event) => { + event.stopPropagation(); + const tab = onOpen(); + app.actions.setEditorMode('spec'); + app.specEditor.revealOffset(tab.specText.indexOf('"role"')); + }, + }, 'Filter'); +} /** Paint the tab strip into app.dom.qtabsInner. */ export function renderTabs(app) { @@ -15,6 +35,9 @@ export function renderTabs(app) { const isActive = t.id === app.state.activeTabId.value; return h('div', { class: 'qtab' + (isActive ? ' active' : ''), onclick: () => selectTab(app, t.id) }, h('span', { class: 'name' }, t.name), + effectiveDashboardRole(t.specParsed) === 'filter' + ? filterRoleBadge(app, () => { selectTab(app, t.id); return t; }) + : null, tabDirty(t) ? h('span', { class: 'dirty' }) : null, app.state.tabs.value.length > 1 ? h('button', { diff --git a/tests/e2e/filter-source.html b/tests/e2e/filter-source.html new file mode 100644 index 00000000..cb04bd74 --- /dev/null +++ b/tests/e2e/filter-source.html @@ -0,0 +1,43 @@ + + + + + Filter source harness + + + + +
+
+ + + diff --git a/tests/e2e/filter-source.spec.js b/tests/e2e/filter-source.spec.js new file mode 100644 index 00000000..60da0af5 --- /dev/null +++ b/tests/e2e/filter-source.spec.js @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Dashboard Filter sources', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/e2e/filter-source.html'); + await page.waitForFunction(() => window.__ready === true); + }); + + test('renders the workbench option preview with its helper contract', async ({ page }) => { + const preview = page.getByRole('main', { name: 'Workbench Filter preview' }); + // A result-grid (consistent with the Table view): # · name · options · type · example. + await expect(preview.locator('table.res-table thead th')).toHaveText(['#', 'name', 'options', 'type', 'example']); + const cells = preview.locator('table.res-table tbody tr td.cell'); + await expect(cells.nth(0)).toHaveText('origin'); + await expect(cells.nth(1)).toHaveText('3'); + await expect(cells.nth(2)).toHaveText('Array(Tuple(value String, label String))'); + // The interactive combobox lives in the example cell — no clear × in the preview. + await expect(preview.getByRole('combobox')).toHaveAttribute('placeholder', 'All'); + await expect(preview.getByRole('button', { name: 'Clear origin' })).toHaveCount(0); + }); + + test('searches labels and commits only an exact returned value', async ({ page }) => { + const dashboard = page.getByRole('main', { name: 'Dashboard curated filter' }); + const input = dashboard.getByRole('combobox'); + await expect(input).toHaveAttribute('placeholder', 'All'); + await input.fill('new'); + await expect(dashboard.getByRole('option')).toHaveCount(1); + await expect(dashboard.getByRole('option')).toHaveText('New York'); + await dashboard.getByRole('option').click(); + await expect(input).toHaveValue('New York'); + expect(await page.evaluate(() => window.__selection)).toEqual({ value: 'JFK', active: true, commits: 1 }); + + await dashboard.getByRole('button', { name: 'Clear origin' }).click(); + await expect(input).toHaveValue(''); + expect(await page.evaluate(() => window.__selection)).toEqual({ value: '', active: false, commits: 2 }); + }); + + test('rejects arbitrary text and supports keyboard selection', async ({ page }) => { + const dashboard = page.getByRole('main', { name: 'Dashboard curated filter' }); + const input = dashboard.getByRole('combobox'); + await input.fill('arbitrary'); + await input.blur(); + await expect(input).toHaveValue(''); + expect(await page.evaluate(() => window.__selection.commits)).toBe(0); + + await input.fill('Atlanta'); + await input.press('Enter'); + await expect(input).toHaveValue('Atlanta'); + expect(await page.evaluate(() => window.__selection)).toEqual({ value: 'ATL', active: true, commits: 1 }); + }); +}); diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index a5746f7d..939fbfd4 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -439,6 +439,43 @@ describe('query run', () => { app.renderApp(); return { app, e }; } + it('runs Filter SQL with owned structured transport and commits only the completed preview', async () => { + const { app } = appForRun([ + [(u, sql) => /SELECT \['ATL'\]/.test(sql), resp({ body: streamBody([ + '{"meta":[{"name":"origin","type":"Array(String)"}]}\n', + '{"row":{"origin":["ATL","JFK"]}}\n', + ]) })], + ]); + const tab = app.activeTab(); + tab.sqlDraft = "SELECT ['ATL'] AS origin"; + tab.specParsed.dashboard = { role: 'filter' }; + tab.specText = JSON.stringify(tab.specParsed); + app.state.resultView.value = 'filter'; + await app.actions.run(); + const request = app.chCtx.fetch.mock.calls.find(([, init]) => /SELECT \['ATL'\]/.test(init.body)); + expect(request[0]).toContain('default_format=JSONEachRowWithProgress'); + expect(request[0]).toContain('max_result_rows=2'); + expect(request[0]).toContain('readonly=2'); + expect(request[0]).toContain('output_format_json_quote_64bit_integers=1'); + expect(tab.filterPreview.status).toBe('success'); + expect(tab.filterPreview.normalized.helpers[0].options).toEqual([ + { value: 'ATL', label: 'ATL' }, { value: 'JFK', label: 'JFK' }, + ]); + expect(app.dom.resultsRegion.textContent).toContain('origin'); + expect(app.state.resultView.value).toBe('filter'); + }); + it('blocks invalid Filter SQL before auth/network, including multi-statement and parameters', async () => { + const { app } = appForRun([]); + const tab = app.activeTab(); + tab.specParsed.dashboard = { role: 'filter' }; + tab.specText = JSON.stringify(tab.specParsed); + tab.sqlDraft = 'SELECT {x:String}; SELECT 2'; + await app.actions.run(); + expect(app.chCtx.fetch.mock.calls.some(([, init]) => init?.body === tab.sqlDraft)).toBe(false); + expect(tab.result.error).toContain('exactly one statement'); + expect(tab.filterPreview.status).toBe('error'); + expect(app.state.resultView.value).toBe('filter'); + }); it('runs an explicit KPI with owned typed streaming and renders the shared cards', async () => { const { app } = appForRun([ [(u, sql) => /SELECT 42/.test(sql), resp({ body: streamBody([ @@ -624,6 +661,17 @@ describe('query run', () => { // the tab strip re-rendered with the dirty marker expect(app.dom.qtabsInner.querySelector('.dirty')).not.toBeNull(); }); + it('re-evaluates a Filter-role Spec live as its SQL is typed (audit #2 gate)', () => { + const { app } = appForRun([]); + const tab = app.activeTab(); + tab.specParsed = { dashboard: { role: 'filter' } }; + tab.specText = JSON.stringify(tab.specParsed); + const view = app.dom.sqlEditorView; + // A Filter source must be a single statement — typing two makes the Spec's + // SQL-dependent diagnostic appear without touching the Spec editor. + view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: 'SELECT 1; SELECT 2' } }); + expect(tab.specDiagnostics.some((d) => /exactly one statement/.test(d.message))).toBe(true); + }); it('query variables (#134): renders an input per detected {name:Type}, hides when none', () => { const { app } = appForRun([]); app.activeTab().sqlDraft = 'SELECT {database:String}, {table:String}'; diff --git a/tests/unit/clickhouse-type.test.js b/tests/unit/clickhouse-type.test.js new file mode 100644 index 00000000..6fb985e9 --- /dev/null +++ b/tests/unit/clickhouse-type.test.js @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { + arrayElement, isSupportedOptionScalar, mapTypes, namedTupleMembers, + parseClickHouseType, unwrapNullable, +} from '../../src/core/clickhouse-type.js'; + +describe('ClickHouse type parser', () => { + it('parses nested wrappers, whitespace, numeric args, and named tuples', () => { + const parsed = parseClickHouseType(' Nullable( Array( Tuple( `label name` String, value Decimal(10, 2) ) ) ) '); + expect(unwrapNullable(parsed).name).toBe('Array'); + const members = namedTupleMembers(arrayElement(parsed)); + expect(members.map((m) => [m.name, m.type.raw])).toEqual([ + ['label name', 'String'], ['value', 'Decimal(10, 2)'], + ]); + }); + it('distinguishes positional tuples and reads maps', () => { + expect(namedTupleMembers(parseClickHouseType('Tuple(String, UInt64)'))).toBeNull(); + expect(mapTypes(parseClickHouseType('Map(String, Nullable(UInt64))')).map((n) => n.name)).toEqual(['String', 'Nullable']); + }); + it('rejects malformed and unbalanced input', () => { + for (const value of ['', 'Array(', 'Array(String))', 'Map(String)', 'Tuple(name String, UInt8)', "Enum8('a' = 1", 'LowCardinality(String, String)']) { + expect(parseClickHouseType(value)).toBeNull(); + } + }); + it('classifies supported scalars through Nullable', () => { + for (const value of ['String', 'FixedString(3)', 'UUID', 'UInt256', 'Int8', 'Decimal(20, 4)', 'Float64', 'Bool', 'Date32', 'DateTime64(3)']) { + expect(isSupportedOptionScalar(parseClickHouseType(`Nullable(${value})`))).toBe(true); + } + expect(isSupportedOptionScalar(parseClickHouseType('Array(String)'))).toBe(false); + expect(arrayElement(null)).toBeNull(); + expect(mapTypes(null)).toBeNull(); + }); + it("parses Enum8/Enum16's quoted member list as an opaque leaf scalar", () => { + const enum8 = parseClickHouseType("Enum8('active' = 1, 'deleted' = 2)"); + expect(enum8.name).toBe('Enum8'); + expect(enum8.raw).toBe("Enum8('active' = 1, 'deleted' = 2)"); + expect(isSupportedOptionScalar(enum8)).toBe(true); + expect(isSupportedOptionScalar(parseClickHouseType("Nullable(Enum16('a' = 1))"))).toBe(true); + const arrayOfEnum = arrayElement(parseClickHouseType("Array(Enum8('a' = 1, 'b' = 2))")); + expect(arrayOfEnum.name).toBe('Enum8'); + }); + it('unwraps LowCardinality alongside Nullable, in either nesting order', () => { + expect(unwrapNullable(parseClickHouseType('LowCardinality(String)')).name).toBe('String'); + expect(isSupportedOptionScalar(parseClickHouseType('LowCardinality(String)'))).toBe(true); + expect(isSupportedOptionScalar(parseClickHouseType('LowCardinality(Nullable(String))'))).toBe(true); + expect(isSupportedOptionScalar(parseClickHouseType('Nullable(LowCardinality(String))'))).toBe(true); + }); +}); diff --git a/tests/unit/dashboard-filters.test.js b/tests/unit/dashboard-filters.test.js new file mode 100644 index 00000000..185d070c --- /dev/null +++ b/tests/unit/dashboard-filters.test.js @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { mergeDashboardFilterHelpers } from '../../src/core/dashboard-filters.js'; + +const helper = (name, options) => ({ name, sourceType: 'Array(String)', shape: 'array', options, totalOptions: options.length, truncated: false }); +const provider = (sourceId, sourceName, helpers) => ({ sourceId, sourceName, helpers }); + +describe('Dashboard Filter helper merge', () => { + it('has harmless defaults', () => { + expect(mergeDashboardFilterHelpers()).toEqual({ fields: {}, diagnostics: [], values: {}, active: {}, changed: [] }); + expect(mergeDashboardFilterHelpers({ providers: [{}] }).fields).toEqual({}); + }); + it('matches exact consumers and retains healthy siblings', () => { + const out = mergeDashboardFilterHelpers({ + providers: [provider('a', 'Options', [helper('origin', [{ value: 'ATL', label: 'Atlanta' }]), helper('unused', [])])], + controls: [{ name: 'origin', type: 'String', optional: true }], + }); + expect(out.fields.origin).toMatchObject({ declaredType: 'String', sourceId: 'a' }); + expect(out.fields.unused).toBeUndefined(); + expect(out.diagnostics.map((d) => d.code)).toEqual(['filter-helper-unused']); + }); + it('rejects duplicate providers per helper without affecting other names', () => { + const out = mergeDashboardFilterHelpers({ + providers: [ + provider('a', 'A', [helper('x', []), helper('aOnly', [])]), + provider('b', 'B', [helper('x', []), helper('bOnly', [])]), + ], + controls: ['x', 'aOnly', 'bOnly'].map((name) => ({ name, type: 'String', optional: false })), + }); + expect(Object.keys(out.fields)).toEqual(['aOnly', 'bOnly']); + expect(out.diagnostics[0]).toMatchObject({ code: 'filter-duplicate-provider', helperName: 'x' }); + expect(out.diagnostics[0].message).toContain('A, B'); + }); + it('falls back on consumer conflicts or invalid options', () => { + const out = mergeDashboardFilterHelpers({ + providers: [provider('p', 'P', [ + helper('conflict', [{ value: '1', label: 'one' }]), + helper('bad', [{ value: '256', label: 'too large' }]), + ])], + controls: [ + { name: 'conflict', type: 'UInt8', optional: false, conflict: ['UInt8', 'String'] }, + { name: 'bad', type: 'UInt8', optional: false }, + ], + }); + expect(out.fields).toEqual({}); + expect(out.diagnostics.map((d) => d.code)).toEqual(['filter-target-type-conflict', 'filter-option-consumer-invalid']); + }); + it('reconciles stale active values without replacing dormant values', () => { + const out = mergeDashboardFilterHelpers({ + providers: [provider('p', 'P', [helper('x', [{ value: 'new', label: 'New' }]), helper('empty', [{ value: '', label: '(empty)' }])])], + controls: [{ name: 'x', type: 'String', optional: true }, { name: 'empty', type: 'String', optional: true }], + values: { x: 'stale', empty: '' }, active: { x: true, empty: true }, + }); + expect(out.values).toEqual({ x: 'stale', empty: '' }); + expect(out.active).toEqual({ x: false, empty: true }); + expect(out.changed).toEqual(['x']); + }); + it('preserves provider diagnostics and is case-sensitive', () => { + const out = mergeDashboardFilterHelpers({ + providers: [{ ...provider('p', 'P', [helper('Origin', [])]), diagnostics: [{ severity: 'info', code: 'source-info', message: 'i' }] }], + controls: [{ name: 'origin', type: 'String', optional: false }], + }); + expect(out.fields).toEqual({}); + expect(out.diagnostics.map((d) => d.code)).toEqual(['source-info', 'filter-helper-unused']); + }); + it('uses a source id in duplicate diagnostics and keeps already-valid active selections', () => { + const out = mergeDashboardFilterHelpers({ + providers: [provider('a', '', [helper('x', [{ value: '1', label: 'One' }])]), provider('b', null, [helper('x', [])])], + controls: [{ name: 'x', type: 'UInt8', optional: false }], values: { x: '1' }, active: { x: true }, + }); + expect(out.diagnostics[0].message).toContain('a, b'); + const single = mergeDashboardFilterHelpers({ + providers: [provider('a', 'A', [helper('x', [{ value: '1', label: 'One' }])])], + controls: [{ name: 'x', type: 'UInt8', optional: false }], values: { x: '1' }, active: { x: true }, + }); + expect(single.active.x).toBe(true); + expect(single.changed).toEqual([]); + }); +}); diff --git a/tests/unit/dashboard.test.js b/tests/unit/dashboard.test.js index d9c37670..80ddc843 100644 --- a/tests/unit/dashboard.test.js +++ b/tests/unit/dashboard.test.js @@ -191,6 +191,157 @@ function dashApp(favorites, runTile) { const setSaved = (app, queries) => { app.state.savedQueries = queries.map(savedQuery); }; describe('renderDashboard', () => { + it('runs Filter sources before Panels, creates no Filter tile, and upgrades the matching field', async () => { + const calls = []; + const runTile = vi.fn(async (sql, params) => { + calls.push(sql); + if (sql === 'SELECT filter_options') return { + columns: [{ name: 'origin', type: 'Array(String)' }], rows: [[['ATL', 'JFK']]], meta: { rows: 1, bytes: 10 }, + }; + expect(params).toEqual({ param_origin: 'ATL' }); + return chartResult(); + }); + const app = dashApp([ + { id: 'f', name: 'Airport options', sql: 'SELECT filter_options', favorite: true, dashboard: { role: 'filter' } }, + { id: 'p', name: 'Flights', sql: 'SELECT * FROM flights WHERE origin={origin:String}', favorite: true }, + ], runTile); + app.state.varValues.origin = 'ATL'; + app.state.filterActive.origin = true; + await renderDashboard(app); + expect(calls).toEqual(['SELECT filter_options', 'SELECT * FROM flights WHERE origin={origin:String}']); + expect(app.root.querySelectorAll('.dash-tile')).toHaveLength(1); + const curated = app.root.querySelector('.filter-select .var-input'); + expect(curated).not.toBeNull(); + expect(curated.value).toBe('ATL'); + expect(app.root.textContent).not.toContain('Airport optionsLoading'); + }); + + it('seeds curated fields from the persisted cache for an immediate combobox and re-persists the live bundle (#234)', async () => { + const runTile = vi.fn(async (sql) => (sql === 'SELECT filter_options' + ? { columns: [{ name: 'origin', type: 'Array(String)' }], rows: [[['ATL', 'JFK']]], meta: { rows: 1, bytes: 1 } } + : chartResult())); + const app = dashApp([ + { id: 'f', name: 'Options', sql: 'SELECT filter_options', favorite: true, dashboard: { role: 'filter' } }, + { id: 'p', name: 'Panel', sql: 'SELECT * FROM t WHERE origin={origin:String}', favorite: true }, + ], runTile); + app.state.filterCurated = { origin: { options: [{ value: 'ATL', label: 'ATL' }], sourceType: 'Array(String)' } }; + // The first synchronous paint (before the async Filter wave resolves) must + // already show the curated combobox from cache — not a plain-text field. + const pending = renderDashboard(app); + expect(app.root.querySelector('.filter-select .var-input')).not.toBeNull(); + await pending; + // …and the live wave persists its own bundle for the next load. + expect(app.saveJSON).toHaveBeenCalledWith('asb:filterCurated', expect.objectContaining({ + origin: expect.objectContaining({ options: [{ value: 'ATL', label: 'ATL' }, { value: 'JFK', label: 'JFK' }] }), + })); + }); + + it('deactivates a stale curated value without replacing it and gates a required Panel', async () => { + const runTile = vi.fn(async (sql) => sql === 'SELECT filter_options' + ? { columns: [{ name: 'origin', type: 'Array(String)' }], rows: [[['JFK']]], meta: { rows: 1, bytes: 1 } } + : chartResult()); + const app = dashApp([ + { id: 'f', name: 'Options', sql: 'SELECT filter_options', favorite: true, dashboard: { role: 'filter' } }, + { id: 'p', name: 'Panel', sql: 'SELECT * FROM t WHERE origin={origin:String}', favorite: true }, + ], runTile); + app.state.varValues.origin = 'ATL'; + app.state.filterActive.origin = true; + await renderDashboard(app); + expect(app.state.varValues.origin).toBe('ATL'); + expect(app.state.filterActive.origin).toBe(false); + expect(app.saveFilterActive).toHaveBeenCalled(); + expect(runTile.mock.calls.map(([sql]) => sql)).toEqual(['SELECT filter_options']); + expect(app.root.querySelector('.dash-tile-unfilled').textContent).toContain('origin'); + expect(app.root.querySelector('.filter-select .var-input').placeholder).toBe('Not set'); + }); + + it('falls back per target on duplicate providers and still runs Panels', async () => { + const runTile = vi.fn(async (sql) => sql.includes('filter_') + ? { columns: [{ name: 'x', type: 'Array(String)' }], rows: [[['a']]], meta: { rows: 1, bytes: 1 } } + : chartResult()); + const app = dashApp([ + { id: 'f1', name: 'One', sql: 'SELECT filter_one', favorite: true, dashboard: { role: 'filter' } }, + { id: 'f2', name: 'Two', sql: 'SELECT filter_two', favorite: true, dashboard: { role: 'filter' } }, + { id: 'p', name: 'Panel', sql: 'SELECT {x:String}', favorite: true }, + ], runTile); + app.state.varValues.x = 'a'; + app.state.filterActive.x = true; + await renderDashboard(app); + expect(app.root.querySelector('.filter-select .var-input')).toBeNull(); + expect(app.root.querySelector('.dash-filter-diagnostics').textContent).toContain('Multiple Filter queries provide "x": One, Two'); + expect(app.root.querySelectorAll('.dash-tile')).toHaveLength(1); + expect(runTile).toHaveBeenCalledTimes(3); + }); + + it('uses ordinary fallback controls on a failed Filter request and exposes source Retry', async () => { + const runTile = vi.fn(async (sql) => sql === 'SELECT filter_options' ? { error: 'boom' } : chartResult()); + const app = dashApp([ + { id: 'f', name: 'Options', sql: 'SELECT filter_options', favorite: true, dashboard: { role: 'filter' } }, + { id: 'p', name: 'Panel', sql: 'SELECT {x:String}', favorite: true }, + ], runTile); + app.state.varValues.x = 'a'; + app.state.filterActive.x = true; + await renderDashboard(app); + expect(app.root.querySelector('.filter-select .var-input')).toBeNull(); + expect(app.root.querySelector('.dash-filter-diagnostics').textContent).toContain('Options: boom'); + expect(app.root.querySelector('.dash-filter-diagnostics button').textContent).toBe('Retry'); + expect(app.root.querySelectorAll('.dash-tile')).toHaveLength(1); + }); + + it('reports an invalid Filter source without sending it and still runs Panels', async () => { + const runTile = vi.fn(async () => chartResult()); + const app = dashApp([ + { id: 'f', name: 'Bad options', sql: 'SELECT 1 FORMAT CSV', favorite: true, dashboard: { role: 'filter' } }, + { id: 'p', name: 'Panel', sql: 'SELECT 1', favorite: true }, + ], runTile); + await renderDashboard(app); + expect(runTile).toHaveBeenCalledTimes(1); + expect(runTile).toHaveBeenCalledWith('SELECT 1', {}); + expect(app.root.querySelector('.dash-filter-diagnostics').textContent).toContain('cannot include a trailing FORMAT'); + expect(app.root.querySelectorAll('.dash-tile')).toHaveLength(1); + }); + + it('keeps Setup and unknown future roles out of Panel execution with diagnostics', async () => { + const runTile = vi.fn(); + const app = dashApp([ + { id: 's', name: 'Prepare', sql: 'CREATE TABLE t', favorite: true, dashboard: { role: 'setup' } }, + { id: 'u', name: 'Future', sql: 'SELECT 1', favorite: true, dashboard: { role: 'future-role' } }, + ], runTile); + await renderDashboard(app); + expect(runTile).not.toHaveBeenCalled(); + expect(app.root.querySelectorAll('.dash-tile')).toHaveLength(0); + expect(app.root.textContent).toContain('Prepare uses Setup, which is not implemented yet.'); + expect(app.root.textContent).toContain('Future has unknown Dashboard role "future-role".'); + }); + + it('retries only the failed Filter source and re-runs Panels affected by reconciliation', async () => { + let filterAttempt = 0; + const runTile = vi.fn(async (sql) => { + if (sql === 'SELECT filter_options') { + filterAttempt++; + if (filterAttempt === 1) return { error: 'temporary' }; + return { columns: [{ name: 'x', type: 'Array(String)' }], rows: [[['new']]], meta: { rows: 1, bytes: 1 } }; + } + return chartResult(); + }); + const app = dashApp([ + { id: 'f', name: 'Options', sql: 'SELECT filter_options', favorite: true, dashboard: { role: 'filter' } }, + { id: 'p', name: 'Panel', sql: 'SELECT {x:String}', favorite: true }, + ], runTile); + app.state.varValues.x = 'stale'; + app.state.filterActive.x = true; + await renderDashboard(app); + expect(runTile).toHaveBeenCalledTimes(2); + app.root.querySelector('.dash-filter-diagnostics button').dispatchEvent(new Event('click', { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(app.ensureFreshToken).toHaveBeenCalledTimes(3); + expect(filterAttempt).toBe(2); + expect(app.state.filterActive.x).toBe(false); + expect(app.saveFilterActive).toHaveBeenCalled(); + expect(runTile).toHaveBeenCalledTimes(3); + expect(app.root.querySelector('.filter-select .var-input')).not.toBeNull(); + }); it('renders a header + a chart tile per chartable favorite', async () => { const favorites = [ { id: '1', name: 'Chart A', sql: 'chartA', favorite: true }, diff --git a/tests/unit/diagnostics.test.js b/tests/unit/diagnostics.test.js new file mode 100644 index 00000000..f4581ddf --- /dev/null +++ b/tests/unit/diagnostics.test.js @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { diagnostic } from '../../src/core/diagnostics.js'; + +describe('diagnostic factory (#236)', () => { + it('builds the {severity, code, message} shape with no extra', () => { + expect(diagnostic('error', 'x-code', 'Something failed.')).toEqual({ + severity: 'error', code: 'x-code', message: 'Something failed.', + }); + }); + + it('merges extra fields onto the diagnostic', () => { + expect(diagnostic('warning', 'y-code', 'Heads up.', { helperName: 'origin', optionIndex: 3 })).toEqual({ + severity: 'warning', code: 'y-code', message: 'Heads up.', helperName: 'origin', optionIndex: 3, + }); + }); + + it('lets extra override nothing core but adds a path (Filter contract shape)', () => { + expect(diagnostic('error', 'filter-sql-empty', 'Empty.', { path: ['dashboard', 'role'] })).toEqual({ + severity: 'error', code: 'filter-sql-empty', message: 'Empty.', path: ['dashboard', 'role'], + }); + }); +}); diff --git a/tests/unit/filter-bar.test.js b/tests/unit/filter-bar.test.js index 3a2b88e0..89019831 100644 --- a/tests/unit/filter-bar.test.js +++ b/tests/unit/filter-bar.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { analyzeParameterizedSources, fieldControls } from '../../src/core/param-pipeline.js'; import { buildFilterBar, FILTER_DEBOUNCE_MS } from '../../src/ui/filter-bar.js'; import { makeApp } from '../helpers/fake-app.js'; @@ -46,4 +46,46 @@ describe('buildFilterBar (shared filter row)', () => { it('exposes the shared debounce constant', () => { expect(FILTER_DEBOUNCE_MS).toBe(500); }); + + it('persists and commits curated selections', () => { + const app = makeApp(); + const onCommit = vi.fn(); + const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), onCommit, okField, { + curatedFields: { x: { options: [{ value: 'a', label: 'Alpha' }] } }, + }); + document.body.appendChild(bar); + bar.querySelector('input').dispatchEvent(new Event('focus')); + bar.querySelector('[role="option"]').dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + expect(app.state.varValues.x).toBe('a'); + expect(app.state.filterActive.x).toBe(true); + expect(app.saveVarValues).toHaveBeenCalled(); + expect(app.saveFilterActive).toHaveBeenCalled(); + expect(onCommit).toHaveBeenCalledWith('x'); + bar.remove(); + }); + + it('marks a curated field is-optional when its param is optional, same as a plain field', () => { + const app = makeApp(); + const bar = buildFilterBar( + app, + paramsFor('SELECT {y:String} FROM t /*[ AND x = {x:String} ]*/'), + () => {}, okField, + { curatedFields: { y: { options: [{ value: 'a', label: 'Alpha' }] }, x: { options: [{ value: 'b', label: 'Beta' }] } } }, + ); + const fields = [...bar.querySelectorAll('.var-field')]; + expect(fields.map((f) => f.querySelector('.var-name').textContent)).toEqual(['y', 'x']); + expect(fields.map((f) => f.classList.contains('is-optional'))).toEqual([false, true]); + expect(fields.every((f) => f.classList.contains('is-curated'))).toBe(true); + }); + + it('applies the shared is-invalid affordance to a curated field, same as a plain one', () => { + const app = makeApp(); + const invalidField = () => ({ state: 'invalid', reason: 'Bad value' }); + const bar = buildFilterBar(app, paramsFor('SELECT {x:String}'), () => {}, invalidField, { + curatedFields: { x: { options: [{ value: 'a', label: 'Alpha' }] } }, + }); + const input = bar.querySelector('input'); + expect(input.classList.contains('is-invalid')).toBe(true); + expect(input.title).toBe('Bad value'); + }); }); diff --git a/tests/unit/filter-execution.test.js b/tests/unit/filter-execution.test.js new file mode 100644 index 00000000..dae65ce7 --- /dev/null +++ b/tests/unit/filter-execution.test.js @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { + FILTER_RESULT_BYTE_CAP, FILTER_TOP_LEVEL_ROW_LIMIT, filterExecution, filterSqlDiagnostics, +} from '../../src/core/filter-execution.js'; + +describe('Filter execution', () => { + it('owns a lossless, read-only, bounded structured transport', () => { + const out = filterExecution('SELECT [1] AS id', { params: { custom: 1 } }); + expect(out).toMatchObject({ owned: true, format: 'Filter', rowLimit: FILTER_TOP_LEVEL_ROW_LIMIT, error: null, diagnostics: [] }); + expect(out.params).toMatchObject({ readonly: 2, max_result_bytes: FILTER_RESULT_BYTE_CAP, custom: 1, + output_format_json_named_tuples_as_objects: 1, output_format_json_quote_64bit_integers: 1, + output_format_json_quote_decimals: 1, output_format_json_quote_64bit_floats: 1 }); + }); + it('reports every static SQL contract failure', () => { + expect(filterSqlDiagnostics('')).toMatchObject([{ code: 'filter-sql-empty' }]); + expect(filterSqlDiagnostics('SELECT 1; SELECT 2').map((d) => d.code)).toContain('filter-sql-statement-count'); + expect(filterSqlDiagnostics('CREATE TABLE t (x Int8)').map((d) => d.code)).toContain('filter-sql-not-row-returning'); + expect(filterSqlDiagnostics('SELECT {x:String}').map((d) => d.code)).toContain('filter-source-parameters'); + expect(filterSqlDiagnostics('SELECT 1 /*[ WHERE x={x:String} ]*/').map((d) => d.code)).toContain('filter-source-parameters'); + expect(filterSqlDiagnostics('SELECT 1 FORMAT JSON').map((d) => d.code)).toContain('filter-owned-format'); + expect(filterExecution('SELECT 1 FORMAT JSON').error).toContain('FORMAT'); + }); +}); diff --git a/tests/unit/filter-option-field.test.js b/tests/unit/filter-option-field.test.js new file mode 100644 index 00000000..5191f1c1 --- /dev/null +++ b/tests/unit/filter-option-field.test.js @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest'; +import { buildFilterOptionField } from '../../src/ui/filter-option-field.js'; + +const options = [ + { value: '', label: '(empty)' }, + { value: 'ATL', label: 'Atlanta' }, + { value: 'JFK', label: 'New York' }, +]; + +describe('strict Filter option field', () => { + it('searches labels, commits exact values, and keeps inactive distinct from empty', () => { + const onValueChange = vi.fn(); + const onCommit = vi.fn(); + const field = buildFilterOptionField({ document, name: 'origin', options, inactiveLabel: 'All', onValueChange, onCommit }); + document.body.appendChild(field.el); + // Wears the shared var-combo clothes (regression: it used to hand-roll its + // own unstyled listbox classes) — same wrapper/input/list every combobox + // field uses, so it renders identically next to a plain filter field. + expect(field.el.classList.contains('var-combo')).toBe(true); + expect(field.input.classList.contains('var-input')).toBe(true); + expect(field.el.querySelector('ul.var-combo-list')).toBeTruthy(); + expect(field.input.value).toBe(''); + expect(field.input.placeholder).toBe('All'); + field.input.dispatchEvent(new Event('focus')); + field.input.value = 'new'; + field.input.dispatchEvent(new Event('input')); + const optionEls = field.el.querySelectorAll('[role="option"]'); + expect(optionEls).toHaveLength(1); + expect(optionEls[0].textContent).toBe('New York'); + optionEls[0].dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + expect(onValueChange).toHaveBeenLastCalledWith('JFK', true); + expect(onCommit).toHaveBeenLastCalledWith('JFK', true); + expect(field.input.value).toBe('New York'); + const clearBtn = field.el.querySelector('.var-combo-clear-inline'); + expect(clearBtn.getAttribute('aria-label')).toBe('Clear origin'); + clearBtn.click(); + expect(onValueChange).toHaveBeenLastCalledWith('', false); + expect(onCommit).toHaveBeenLastCalledWith('', false); + field.destroy(); + field.el.remove(); + }); + it('rejects arbitrary text and supports an active empty-string option', () => { + const onCommit = vi.fn(); + const field = buildFilterOptionField({ document, name: 'x', options, value: '', active: true, onCommit }); + document.body.appendChild(field.el); + expect(field.input.value).toBe('(empty)'); + field.input.value = 'arbitrary'; + field.input.dispatchEvent(new Event('blur')); + expect(field.input.value).toBe('(empty)'); + expect(onCommit).not.toHaveBeenCalled(); + field.el.remove(); + }); + it('prevents its own mousedown from stealing focus off the input (#174 §1 mousedown-before-blur pattern, same as an option commit)', () => { + const onValueChange = vi.fn(); + const onCommit = vi.fn(); + const field = buildFilterOptionField({ document, name: 'x', options, value: 'ATL', active: true, onValueChange, onCommit }); + document.body.appendChild(field.el); + const clearBtn = field.el.querySelector('.var-combo-clear-inline'); + const mousedown = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); + // dispatchEvent returns false when a listener called preventDefault (cancelable event). + expect(clearBtn.dispatchEvent(mousedown)).toBe(false); + clearBtn.click(); + // Exactly one commit for the clear — a real pointer click that blurred the + // input FIRST would otherwise re-commit the still-showing "Atlanta" value + // via strictCommit() before this click handler even ran. + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange).toHaveBeenCalledWith('', false); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith('', false); + field.el.remove(); + }); + it('matches by option value (not just label) and commits with default callbacks', () => { + // No onValueChange/onCommit passed — the defaults must be safe to call. + const field = buildFilterOptionField({ document, name: 'x', options }); + document.body.appendChild(field.el); + field.input.dispatchEvent(new Event('focus')); + field.input.value = 'atl'; // matches the ATL *value*, not the "Atlanta" label + field.input.dispatchEvent(new Event('input')); + const optionEls = field.el.querySelectorAll('[role="option"]'); + expect(optionEls).toHaveLength(1); + expect(optionEls[0].textContent).toBe('Atlanta'); + expect(() => optionEls[0].dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))).not.toThrow(); + expect(field.input.value).toBe('Atlanta'); + field.el.remove(); + }); + it('shows a raw active value when it matches no known option label', () => { + const field = buildFilterOptionField({ document, name: 'x', options, value: 'ZZZ', active: true }); + expect(field.input.value).toBe('ZZZ'); + field.el.remove(); + }); + it('commits an exact label with Enter', () => { + const onCommit = vi.fn(); + const field = buildFilterOptionField({ document, name: 'x', options, onCommit }); + document.body.appendChild(field.el); + field.input.value = 'Atlanta'; + field.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(onCommit).toHaveBeenCalledWith('ATL', true); + expect(field.input.value).toBe('Atlanta'); + field.el.remove(); + }); +}); diff --git a/tests/unit/filter-options.test.js b/tests/unit/filter-options.test.js new file mode 100644 index 00000000..ad909d6a --- /dev/null +++ b/tests/unit/filter-options.test.js @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { readFilterOptions } from '../../src/core/filter-options.js'; + +const read = (columns, row, extra = {}) => readFilterOptions({ columns, row, rowCount: 1, ...extra }); + +describe('Filter option reader', () => { + it('normalizes scalar arrays losslessly, preserves order, and first-wins duplicates', () => { + const out = read([{ name: 'id', type: 'Array(UInt64)' }], [['9007199254740993', '', '9007199254740993']]); + expect(out.helpers[0]).toMatchObject({ name: 'id', shape: 'array', totalOptions: 3, truncated: false, + options: [{ value: '9007199254740993', label: '9007199254740993' }, { value: '', label: '' }] }); + expect(out.diagnostics.map((d) => d.code)).toEqual(['filter-duplicate-option']); + }); + it('normalizes named tuple arrays in source order and ignores extra members', () => { + const columns = [{ name: 'origin', type: 'Array(Tuple(label String, extra UInt8, value String))' }]; + const out = read(columns, [[{ value: 'ATL', label: 'Atlanta', extra: 1 }, { value: 'JFK', label: 'New York' }]]); + expect(out.helpers[0]).toMatchObject({ shape: 'tuple-array', options: [{ value: 'ATL', label: 'Atlanta' }, { value: 'JFK', label: 'New York' }] }); + }); + it('normalizes Maps and sorts by label then value', () => { + const out = read([{ name: 'year', type: 'Map(UInt16, String)' }], [{ 2024: 'Same', 2023: 'Same', 2022: 'Earlier' }]); + expect(out.helpers[0].options).toEqual([ + { value: '2022', label: 'Earlier' }, { value: '2023', label: 'Same' }, { value: '2024', label: 'Same' }, + ]); + }); + it('normalizes the two #160-documented value/label shapes (named tuple array, and Map)', () => { + // Array(Tuple(value, label)) — e.g. + // arraySort(x -> x.label, groupUniqArray((Origin AS value, OriginCityName AS label))) + // (emitted as {value,label} objects via output_format_json_named_tuples_as_objects). + const tuple = read([{ name: 'origin', type: 'Array(Tuple(value String, label String))' }], + [[{ value: 'ATL', label: 'Atlanta' }, { value: 'JFK', label: 'New York' }]]); + expect(tuple.helpers[0]).toMatchObject({ shape: 'tuple-array', + options: [{ value: 'ATL', label: 'Atlanta' }, { value: 'JFK', label: 'New York' }] }); + // Map(K, V) — e.g. mapFromArrays(groupArray(Origin), groupArray(OriginCityName)). + const map = read([{ name: 'origin', type: 'Map(String, String)' }], [{ ATL: 'Atlanta', JFK: 'New York' }]); + expect(map.helpers[0]).toMatchObject({ shape: 'map', + options: [{ value: 'ATL', label: 'Atlanta' }, { value: 'JFK', label: 'New York' }] }); + // The query-log-explorer `user` filter: value = the full user that binds to + // {user:String}, label = the name before '@' for display. + const users = read([{ name: 'user', type: 'Array(Tuple(value String, label String))' }], + [[{ value: 'btyshkevich@altinity.com', label: 'btyshkevich' }, { value: 'default', label: 'default' }]]); + expect(users.helpers[0].options).toEqual([ + { value: 'btyshkevich@altinity.com', label: 'btyshkevich' }, { value: 'default', label: 'default' }]); + }); + it('enforces the result envelope before helper parsing', () => { + expect(readFilterOptions({ rowCount: 0 }).diagnostics[0].code).toBe('filter-row-count'); + expect(readFilterOptions({ rowCount: 2 }).diagnostics[0].code).toBe('filter-row-count'); + expect(read([{ name: 'x', type: 'Array(String)' }, { name: 'x', type: 'Array(String)' }], [[], []]).diagnostics[0].code).toBe('filter-duplicate-helper-name'); + const capped = read(Array.from({ length: 3 }, (_, i) => ({ name: String(i), type: 'Array(String)' })), [[], [], []], { helperCap: 2 }); + expect(capped.diagnostics[0].code).toBe('filter-helper-cap'); + }); + it('keeps valid siblings when another helper is malformed', () => { + const out = read([ + { name: 'good', type: 'Array(String)' }, { name: 'bad', type: 'String' }, { name: 'also', type: 'Map(String, String)' }, + ], [['a'], 'x', { z: 'Zed' }]); + expect(out.helpers.map((h) => h.name)).toEqual(['good', 'also']); + expect(out.diagnostics.map((d) => d.code)).toContain('filter-unsupported-helper-type'); + expect(out.diagnostics.map((d) => d.code)).not.toContain('filter-no-valid-helpers'); + }); + it('rejects NULL, unsupported values, positional/missing tuple members, and empty invalid sources', () => { + expect(read([{ name: 'x', type: 'Array(String)' }], [[null]]).diagnostics.map((d) => d.code)).toContain('filter-null-option'); + expect(read([{ name: 'x', type: 'Array(String)' }], [[{}]]).diagnostics.map((d) => d.code)).toContain('filter-option-type'); + expect(read([{ name: 'x', type: 'Array(Tuple(String, String))' }], [[['a', 'A']]]).diagnostics.map((d) => d.code)).toContain('filter-unsupported-helper-type'); + expect(read([{ name: 'x', type: 'Array(Tuple(value String))' }], [[{ value: 'a' }]]).diagnostics.map((d) => d.code)).toContain('filter-missing-option-label'); + expect(read([{ name: 'x', type: 'Array(Tuple(label String))' }], [[{ label: 'A' }]]).diagnostics.map((d) => d.code)).toContain('filter-missing-option-value'); + expect(read([], []).diagnostics.at(-1).code).toBe('filter-no-valid-helpers'); + }); + it('rejects malformed tuple option declarations and runtime members', () => { + expect(read([{ name: 'x', type: 'Array(Tuple(value Array(String), label String))' }], [[]]).diagnostics[0].code).toBe('filter-option-type'); + expect(read([{ name: 'x', type: 'Array(Tuple(value String, label String))' }], [['not-an-object']]).diagnostics[0].code).toBe('filter-invalid-option-tuple'); + expect(read([{ name: 'x', type: 'Array(Tuple(value String, label String))' }], [[{ value: null, label: 'N' }]]).diagnostics[0].code).toBe('filter-null-option'); + expect(read([{ name: 'x', type: 'Array(Tuple(value String, label String))' }], [[{ value: {}, label: 'N' }]]).diagnostics[0].code).toBe('filter-option-type'); + expect(read([{ name: 'x', type: 'Array(Array(String))' }], [[['nested']]]).diagnostics[0].code).toBe('filter-unsupported-helper-type'); + }); + it('rejects malformed Map declarations and runtime pairs', () => { + expect(read([{ name: 'x', type: 'Map(Array(String), String)' }], [{}]).diagnostics[0].code).toBe('filter-unsupported-helper-type'); + expect(read([{ name: 'x', type: 'Map(String, String)' }], ['bad']).diagnostics[0].code).toBe('filter-option-type'); + expect(read([{ name: 'x', type: 'Map(String, String)' }], [[['only-key']]]).diagnostics[0].code).toBe('filter-null-option'); + expect(read([{ name: 'x', type: 'Map(String, String)' }], [[[null, 'label']]]).diagnostics[0].code).toBe('filter-null-option'); + expect(read([{ name: 'x', type: 'Map(String, String)' }], [[['key', {}]]]).diagnostics[0].code).toBe('filter-option-type'); + expect(read([{ name: 'x', type: 'Map(String, String)' }], [[['b', 'Same'], ['a', 'Same']]]).helpers[0].options).toEqual([ + { value: 'a', label: 'Same' }, { value: 'b', label: 'Same' }, + ]); + }); + it('reports malformed ClickHouse type syntax without discarding healthy siblings', () => { + const out = read([{ name: 'bad', type: 'Array(' }, { name: 'good', type: 'Array(String)' }], [[], ['ok']]); + expect(out.helpers.map((h) => h.name)).toEqual(['good']); + expect(out.diagnostics[0].code).toBe('filter-unsupported-helper-type'); + }); + it('retains empty helpers and reports truncation', () => { + expect(read([{ name: 'x', type: 'Array(String)' }], [[]]).helpers[0].options).toEqual([]); + const out = read([{ name: 'x', type: 'Array(String)' }], [['a', 'b', 'c']], { optionCap: 2 }); + expect(out.helpers[0]).toMatchObject({ totalOptions: 3, truncated: true, options: [{ value: 'a', label: 'a' }, { value: 'b', label: 'b' }] }); + expect(out.diagnostics.at(-1).code).toBe('filter-options-truncated'); + }); +}); diff --git a/tests/unit/filter-preview.test.js b/tests/unit/filter-preview.test.js new file mode 100644 index 00000000..da97ce4a --- /dev/null +++ b/tests/unit/filter-preview.test.js @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { renderFilterPreview } from '../../src/ui/filter-preview.js'; +import { makeApp } from '../helpers/fake-app.js'; + +describe('Filter preview', () => { + it('renders no-result, running, and error states', () => { + const app = makeApp(); + expect(renderFilterPreview(app).textContent).toContain('Run the query'); + app.activeTab().filterPreview = { status: 'running' }; + expect(renderFilterPreview(app).textContent).toContain('when the query completes'); + app.activeTab().filterPreview = { status: 'error', error: 'boom' }; + expect(renderFilterPreview(app).textContent).toBe('boom'); + }); + it('renders helpers as a result-grid with a name/options/type/example header and a local-only combobox', () => { + const app = makeApp(); + app.activeTab().filterPreview = { + status: 'success', + normalized: { + helpers: [ + { name: 'user', sourceType: 'Array(String)', totalOptions: 2, truncated: false, + options: [{ value: 'ATL', label: 'Atlanta' }, { value: 'JFK', label: 'New York' }] }, + { name: 'query_kind', sourceType: 'Array(String)', totalOptions: 1, truncated: false, + options: [{ value: 'Select', label: 'Select' }] }, + ], + diagnostics: [{ severity: 'warning', code: 'filter-options-truncated', message: 'limited' }], + }, + }; + const out = renderFilterPreview(app); + // Same grid presentation as the Table view. + const table = out.querySelector('table.res-table'); + expect(table).toBeTruthy(); + expect([...table.querySelectorAll('thead th')].map((th) => th.textContent)) + .toEqual(['#', 'name', 'options', 'type', 'example']); + const rows = [...table.querySelectorAll('tbody tr')]; + expect(rows).toHaveLength(2); + // Row 1: number, name, option count, type, and the interactive combobox. + const cells = rows[0].querySelectorAll('td'); + expect(cells[0].textContent).toBe('1'); + expect(cells[1].textContent).toBe('user'); + expect(cells[2].textContent).toBe('2'); + expect(cells[3].textContent).toBe('Array(String)'); + const combo = cells[4].querySelector('.var-combo.filter-select .var-input'); + expect(combo).toBeTruthy(); + // No clear × in the preview (demo cell, not a live filter). + expect(cells[4].querySelector('.var-combo-clear-inline')).toBeNull(); + // The combobox is local-only: committing an option never touches shared state. + combo.dispatchEvent(new Event('focus')); + cells[4].querySelector('[role="option"]').dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + expect(app.state.varValues).toEqual({}); + expect(app.state.filterActive).toEqual({}); + expect(app.saveVarValues).not.toHaveBeenCalled(); + // Diagnostics render below the grid. + expect(out.querySelector('.filter-preview-diagnostic.is-warning').textContent).toBe('limited'); + }); + it('renders an empty successful result and the default error message', () => { + const app = makeApp(); + app.activeTab().filterPreview = { status: 'success', normalized: { helpers: [], diagnostics: [] } }; + expect(renderFilterPreview(app).textContent).toBe('No options'); + app.activeTab().filterPreview = { status: 'error' }; + expect(renderFilterPreview(app).textContent).toBe('Filter options failed.'); + }); +}); diff --git a/tests/unit/grid-render.test.js b/tests/unit/grid-render.test.js index 05c1e05b..ff7425c0 100644 --- a/tests/unit/grid-render.test.js +++ b/tests/unit/grid-render.test.js @@ -49,6 +49,13 @@ describe('renderGrid', () => { const cells = el.querySelectorAll('tbody tr')[1].querySelectorAll('td.cell'); expect(cells[1].textContent).toBe(''); // null renders empty }); + it('renders an object-shaped cell value (named tuple as object) as JSON, not "[object Object]"', () => { + const el = renderGrid(gridArgs({ + columns: [{ name: 'db', type: 'Array(Tuple(value String, label String))' }], + rows: [[[{ value: 'a', label: 'A' }]]], + })); + expect(el.querySelector('td.cell').textContent).toBe('[{"value":"a","label":"A"}]'); + }); it('a column without a type gets an empty hover title and no num class', () => { const el = renderGrid(gridArgs({ columns: [{ name: 'x' }], rows: [['a']] })); expect(el.querySelectorAll('thead th')[1].getAttribute('title')).toBe(''); diff --git a/tests/unit/panels.test.js b/tests/unit/panels.test.js index f2193985..fddc3168 100644 --- a/tests/unit/panels.test.js +++ b/tests/unit/panels.test.js @@ -1,10 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { renderMarkdown, renderResolvedPanel, PANEL_TYPES, PANEL_PICKER_OPTIONS } from '../../src/ui/panels.js'; +import { renderMarkdown, renderResolvedPanel, PANEL_TYPES } from '../../src/ui/panels.js'; import { renderResults } from '../../src/ui/results.js'; import { parseMarkdown } from '../../src/core/markdown-lite.js'; import { resolvePanel } from '../../src/core/panel-cfg.js'; import { newResult } from '../../src/core/stream.js'; import { makeApp } from '../helpers/fake-app.js'; +import { DASHBOARD_ROLE_RESULT_CHOICES, PANEL_RESULT_CHOICES } from '../../src/core/result-choice.js'; const md = (text) => renderMarkdown(parseMarkdown(text)); @@ -110,7 +111,7 @@ function panelApp(result, panelCfg = null, over = {}) { const region = (app) => app.dom.resultsRegion; const pickType = (app, type) => { const sel = region(app).querySelector('.result-panel-select'); - sel.value = type; + sel.value = type.includes(':') ? type : `panel:${type}`; sel.dispatchEvent(new Event('change', { bubbles: true })); }; @@ -144,9 +145,11 @@ describe('Panel drawer tab', () => { const app = panelApp(chartResult()); renderResults(app); const sel = region(app).querySelector('.result-panel-select'); - expect([...sel.options].map((o) => o.value)).toEqual(['', ...PANEL_PICKER_OPTIONS.map((o) => o.value)]); + expect([...sel.options].map((o) => o.value)).toEqual([ + '', 'panel:auto', ...PANEL_RESULT_CHOICES.map((o) => o.id), ...DASHBOARD_ROLE_RESULT_CHOICES.map((o) => o.id), + ]); expect([...sel.options].map((o) => o.value)).not.toContain('table'); - expect(sel.value).toBe('hbar'); // autoPanel's pick for a categorical result + expect(sel.value).toBe('panel:auto'); // reflects the current choice while the panel preview shows expect(region(app).querySelectorAll('.result-view-tab')).toHaveLength(2); // Table + JSON; no fixed Panel button expect(region(app).querySelector('.panel-config')).toBeNull(); // no separate picker row expect(region(app).querySelector('.chart-view canvas')).not.toBeNull(); @@ -164,6 +167,41 @@ describe('Panel drawer tab', () => { expect(app.actions.run).not.toHaveBeenCalled(); // previews never execute SQL expect(region(app).querySelector('.chart-view')).not.toBeNull(); }); + it('selects Filter as a role, preserves Panel configuration, and never runs SQL', () => { + const app = panelApp(chartResult(), { type: 'line', x: 0, y: [1], future: true }); + app.activeTab().specParsed.dashboard = { role: 'panel', future: { keep: true } }; + renderResults(app); + pickType(app, 'role:filter'); + expect(app.activeTab().specParsed.dashboard).toEqual({ role: 'filter', future: { keep: true } }); + expect(app.activeTab().panelCfg).toMatchObject({ type: 'line', future: true }); + expect(app.state.resultView.value).toBe('filter'); + expect(app.activeTab().dirtySpec).toBe(true); + expect(app.actions.run).not.toHaveBeenCalled(); + renderResults(app); + expect(region(app).textContent).toContain('Run the query to preview Filter options.'); + }); + it('switches a Filter query back to Panel without rewriting unrelated Spec fields', () => { + const app = panelApp(chartResult(), { type: 'line', x: 0, y: [1] }); + app.activeTab().specParsed.dashboard = { role: 'filter', future: 1 }; + app.activeTab().specParsed.keep = true; + renderResults(app); + pickType(app, 'pie'); + expect(app.activeTab().specParsed.dashboard).toEqual({ role: 'panel', future: 1 }); + expect(app.activeTab().specParsed.keep).toBe(true); + expect(app.activeTab().panelCfg.type).toBe('pie'); + expect(app.state.resultView.value).toBe('panel'); + }); + it('shows the placeholder on Table/JSON and switches to the preview when the current type is re-picked', () => { + const app = panelApp(chartResult(), { type: 'line', x: 0, y: [1] }, { resultView: 'table' }); + renderResults(app); + const sel = region(app).querySelector('.result-panel-select'); + expect(sel.value).toBe(''); // placeholder while viewing the raw Table + // Re-picking the query's CURRENT type still switches to its preview — the + // placeholder makes it a genuine change event (the reported inconsistency). + pickType(app, 'line'); + expect(app.state.resultView.value).toBe('panel'); + expect(region(app).querySelector('.chart-view')).not.toBeNull(); + }); it('a panel control merges into a linked dirty valid Spec draft', () => { const app = panelApp(chartResult(), { type: 'bar', x: 0, y: [1] }); const tab = app.activeTab(); @@ -190,6 +228,20 @@ describe('Panel drawer tab', () => { expect(tab.specText).toBe('{"panel":'); expect(app.actions.rerenderTabs).not.toHaveBeenCalled(); }); + it('an already-rendered chart field focuses a Spec draft that becomes invalid', () => { + const app = panelApp(chartResult(), { type: 'bar', x: 0, y: [1] }); + const tab = app.activeTab(); + renderResults(app); + const xSelect = [...region(app).querySelectorAll('.chart-field')] + .find((field) => field.querySelector('.chart-field-label').textContent === 'X').querySelector('select'); + tab.specText = '{'; + tab.specParsed = null; + tab.specDiagnostics = [{ code: 'invalid-json' }]; + tab.dirtySpec = true; + xSelect.value = '1'; + xSelect.dispatchEvent(new Event('change', { bubbles: true })); + expect(app.activateInvalidSpecDraft).toHaveBeenCalledWith(tab); + }); it('the toolbar selector activates Panel view from the ordinary Table view', () => { const app = panelApp(chartResult()); app.state.resultView.value = 'table'; @@ -287,7 +339,7 @@ describe('Panel drawer tab', () => { expect(region(app).querySelector('.kpi-card')).not.toBeNull(); expect(region(app).querySelector('.res-table')).toBeNull(); // ...the toolbar picker still reads Logs, the authoring type... - expect(region(app).querySelector('.result-panel-select').value).toBe('logs'); + expect(region(app).querySelector('.result-panel-select').value).toBe('panel:logs'); // ...the three Logs role selectors are the rescue controls... const configRows = region(app).querySelectorAll('.panel-config .chart-config'); expect(configRows).toHaveLength(1); diff --git a/tests/unit/result-choice.test.js b/tests/unit/result-choice.test.js new file mode 100644 index 00000000..11ae7e8d --- /dev/null +++ b/tests/unit/result-choice.test.js @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { + applyResultChoice, DASHBOARD_ROLE_RESULT_CHOICES, effectiveDashboardRole, + PANEL_RESULT_CHOICES, resultChoiceForSpec, +} from '../../src/core/result-choice.js'; + +const query = (spec) => ({ id: 'q', sql: 'SELECT 1', specVersion: 1, spec }); + +describe('result choices', () => { + it('uses effective Panel defaults and exposes an extendable role list', () => { + expect(effectiveDashboardRole({})).toBe('panel'); + expect(resultChoiceForSpec({})).toBe('panel:auto'); + expect(resultChoiceForSpec({ dashboard: { role: 'filter' }, panel: { cfg: { type: 'line' } } })).toBe('role:filter'); + expect(PANEL_RESULT_CHOICES.some((c) => c.id === 'panel:kpi')).toBe(true); + expect(DASHBOARD_ROLE_RESULT_CHOICES).toEqual([{ id: 'role:filter', kind: 'role', role: 'filter', label: 'Filter' }]); + }); + it('maps a table (or unknown) panel to panel:auto, since Table is not a picker option', () => { + // Regression: a table-typed panel used to yield 'panel:table', which matches + // no