diff --git a/CHANGELOG.md b/CHANGELOG.md index 9630b674..73ffb9ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] +### Changed +- **`VariableBarApp`'s shared activation port is now caller-neutral** (#478). + `state.filterActive`/`params.saveFilterActive` — named after Workbench + persistence even though Dashboard's own caller uses them for an unpersisted + in-memory draft — are renamed to `state.activeByName`/`params.saveActive` + on the shared `variable-bar.ts` contract only. Every persisted name and + storage key is unchanged (`AppState.filterActive`, + `WorkbenchParameterSession.saveFilterActive`, `effectiveFilterActive`, + `asb:filterActive`; no storage migration). Both callers now construct an + explicit adapter instead of a same-shape cast: Dashboard aliases its local + draft activation map with a no-op `saveActive` (the draft is never + persisted); detached Data aliases the real `AppState.filterActive` object + and routes `saveActive` to `app.params.saveFilterActive()`. Pure rename + + adapter refactor — no user-visible behavior changes. + ### Fixed - **Dashboard styles now persist independent dimensions and temporary column previews no longer mutate authored layouts** (behavioral correction to diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 0d38c88c..bbddfe42 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -819,10 +819,14 @@ export async function renderDashboard( const draftActive: Record = {}; const variableBarApp: VariableBarApp = { document: doc, - state: { varValues: draftValues, filterActive: draftActive, varRecent: state.varRecent }, + // #478: aliases the local draft maps above — an in-memory-only activation + // map, never persisted, so `saveActive` is a no-op adapter (there is + // nothing to save; unlike detached Data, this Dashboard draft has no + // Workbench-persisted counterpart to route to). + state: { varValues: draftValues, activeByName: draftActive, varRecent: state.varRecent }, params: { saveVarValues: () => {}, - saveFilterActive: () => {}, + saveActive: () => {}, clearVarRecent: (name: string) => app.params.clearVarRecent(name), }, wallNow: () => app.wallNow(), diff --git a/src/ui/results.ts b/src/ui/results.ts index ea5a6754..b48fc252 100644 --- a/src/ui/results.ts +++ b/src/ui/results.ts @@ -174,8 +174,10 @@ export interface ResultsApp { * only ever needs `executeRead` (the detached Data view's own re-run). */ exec: Pick; /** #276 Phase 5: no flat `App` delegates for the params-group members this - * module (and, via the `as VariableBarApp` cast below, variable-bar.ts) needs — - * `app.params.*` directly. */ + * module needs — `app.params.*` directly. #478 replaced the former + * `as VariableBarApp` cast with an explicit adapter below, so `saveFilterActive` + * is now reached through that adapter's caller-neutral `saveActive` rather than + * by variable-bar.ts reading this shape directly. */ params: Pick; /** #276 Phase 5: no flat `App.savePref` delegate — `app.prefs.save(name, * value)` directly (the cell-detail drawer's own resize persist). */ @@ -1111,7 +1113,22 @@ export function expandDataPane(app: ResultsApp, r: QueryResult): DetachedView { }).fields[name]; // A committed field re-runs only this detached query (rerun ignores the // param name buildVariableBar passes — a single source re-runs wholesale). - const variableBar = buildVariableBar(app as VariableBarApp, fields, rerun, getField, { document: doc, ariaLabel: 'Query variables' }); + // #478: an explicit adapter, not a same-shape cast — `activeByName` + // ALIASES the real `AppState.filterActive` object (the bar mutates it + // in place; a copy would silently stop `effectiveFilterActive` above, + // and every other Workbench reader, from observing the edit), and + // `saveActive` routes to the real persisted Workbench save. + const variableBarApp: VariableBarApp = { + document: doc, + state: { varValues: app.state.varValues, activeByName: app.state.filterActive, varRecent: app.state.varRecent }, + params: { + saveVarValues: () => app.params.saveVarValues(), + saveActive: () => app.params.saveFilterActive(), + clearVarRecent: (name: string) => app.params.clearVarRecent(name), + }, + wallNow: () => app.wallNow(), + }; + const variableBar = buildVariableBar(variableBarApp, fields, rerun, getField, { document: doc, ariaLabel: 'Query variables' }); variableBarDispose = variableBar.dispose; refreshBtn = h('button', { class: 'res-act detached-refresh', title: 'Re-run this query with the current variable values', diff --git a/src/ui/variable-bar.ts b/src/ui/variable-bar.ts index 6f39d1ea..83c9f85f 100644 --- a/src/ui/variable-bar.ts +++ b/src/ui/variable-bar.ts @@ -1,5 +1,5 @@ // The shared `{name:Type}` variable bar: one field per parameter, driving the -// same `state.varValues`/`state.filterActive` machinery the SQL Browser +// same `state.varValues`/`state.activeByName` machinery the SQL Browser // workbench uses. Extracted from the dashboard (#149 D3) when the detached Data // view (#185) became its second consumer (CLAUDE.md rule 5) — both the // dashboard's global variables and the detached view's per-query variable row @@ -45,7 +45,6 @@ import { Icon } from './icons.js'; import type { KeyboardOwner } from './app.types.js'; import type { VariableOption } from '../core/variable-options.types.js'; import type { DashboardTimeRangeGroup, TimeRangeRecent } from '../core/time-range.js'; -import type { WorkbenchParameterSession } from '../application/workbench-parameter-session.js'; /** The narrow slice of the real `app` controller this module reads — not the * full ~50-member `App` contract (app.types.ts). A real `App` satisfies this @@ -56,12 +55,27 @@ export interface VariableBarApp { document: Document; state: { varValues: Record; - filterActive: Record; + /** #478: caller-neutral activation port — this module is shared by + * Dashboard (an in-memory draft map, no persistence) and detached Data + * (the persisted Workbench `AppState.filterActive`), so it must not name + * either caller's storage model. ALIASED by both callers, never copied: + * this module mutates it in place (a select's commit, a text field's + * activation-follows-value), so a copy would silently stop the caller's + * own reads (`effectiveFilterActive`, `activeMap`) from observing edits. */ + activeByName: Record; varRecent: RecentMap; }; /** #276 Phase 5: no flat `App.saveVarValues`/`saveFilterActive`/ - * `clearVarRecent` delegates — this module reads `app.params.*` directly. */ - params: Pick; + * `clearVarRecent` delegates — this module reads `app.params.*` directly. + * #478: `saveActive` replaces the Workbench-named `saveFilterActive` — a + * caller-neutral port every caller supplies its OWN explicit adapter for + * (Dashboard: a no-op, its draft is never persisted; detached Data: routes + * to `app.params.saveFilterActive()`, the real persisted Workbench save). */ + params: { + saveVarValues(): void; + saveActive(): void; + clearVarRecent(name: string): void; + }; wallNow(): number; } @@ -295,7 +309,7 @@ export interface VariableBarHandle { /** * Build a variable bar: one field per `{name:Type}` parameter in `params` (the * shape from `fieldControls(analysis)`), sharing `app.state.varValues` / - * `app.state.filterActive` / `app.state.varRecent` with every other surface. + * `app.state.activeByName` / `app.state.varRecent` with every other surface. * Hidden entirely (no row, no spacing) when `params` is empty — same convention * as the workbench's var-strip. Typing debounces before calling `onCommit(name)`; * Enter or blur fires immediately, clearing any pending debounce so a value @@ -435,7 +449,7 @@ export function buildVariableBar( name: p.name, options: spec.options ?? [], selected: spec.selection ?? [], - active: !!app.state.filterActive[p.name], + active: !!app.state.activeByName[p.name], loading: !!spec.loading, incomplete: !!spec.optionsIncomplete, title: p.name + ': ' + p.type, @@ -444,7 +458,7 @@ export function buildVariableBar( // Activation travels with the value: a non-empty selection is active, and // Clear-then-Apply returns the variable to unset. onApply: (values, active) => { - app.state.filterActive[p.name] = active; + app.state.activeByName[p.name] = active; options.onCommitVariableSelection?.(p.name, values, active); }, onKeyboardOwnerChange: options.onKeyboardOwnerChange, @@ -471,7 +485,7 @@ export function buildVariableBar( name: p.name, options: spec.options ?? [], value: app.state.varValues[p.name] || '', - active: !!app.state.filterActive[p.name], + active: !!app.state.activeByName[p.name], title: p.name + ': ' + p.type, onCommit: (value, active) => { // A select commits value AND activation together — a pick (or the × that @@ -480,7 +494,7 @@ export function buildVariableBar( // every other reader (the invalid-field affordance, a sibling rebuild) // still sees one source of truth. app.state.varValues[p.name] = value; - app.state.filterActive[p.name] = active; + app.state.activeByName[p.name] = active; options.onCommitVariable?.(p.name, value, active); }, }); @@ -501,7 +515,7 @@ export function buildVariableBar( * a native checkbox is indeterminate while inactive, then emits the same * canonical strings the former true/false combobox options emitted. */ const buildBoolField = (p: FieldControl): HTMLElement => { - const active = !!app.state.filterActive[p.name]; + const active = !!app.state.activeByName[p.name]; const optionalHint = p.optional ? ' — optional: unset leaves its filter block out' : ''; const hintId = `var-bool-${idSafe(p.name)}-hint`; const input = h('input', { @@ -512,7 +526,7 @@ export function buildVariableBar( onchange: () => { const value = input.checked ? 'true' : 'false'; app.state.varValues[p.name] = value; - app.state.filterActive[p.name] = true; + app.state.activeByName[p.name] = true; options.onCommitVariable?.(p.name, value, true); }, }); @@ -568,9 +582,9 @@ export function buildVariableBar( // Text controls sync activation with the value (#165): an activation // flip re-runs affected tiles exactly like a value change (same // debounce + generation guard downstream). - app.state.filterActive[p.name] = input.value !== ''; + app.state.activeByName[p.name] = input.value !== ''; app.params.saveVarValues(); - app.params.saveFilterActive(); + app.params.saveActive(); applyFieldState(input, getField(p.name, 'input'), baseTitle, combo?.previewEl); // `!`: DOM's clearTimeout is a documented no-op on `null`/`undefined` — // the original .js called it unconditionally (`timer` starts `null`). diff --git a/tests/e2e/time-range.html b/tests/e2e/time-range.html index 224ee8c4..4c211e9b 100644 --- a/tests/e2e/time-range.html +++ b/tests/e2e/time-range.html @@ -176,8 +176,8 @@ const draftActive = {}; const variableBarApp = { document, - state: { varValues: draftValues, filterActive: draftActive, varRecent: {} }, - params: { saveVarValues() {}, saveFilterActive() {}, clearVarRecent() {} }, + state: { varValues: draftValues, activeByName: draftActive, varRecent: {} }, + params: { saveVarValues() {}, saveActive() {}, clearVarRecent() {} }, wallNow: () => WALL, }; // Session-lifetime, per-group "Recently used" ranges (owner decision: not diff --git a/tests/unit/results.test.ts b/tests/unit/results.test.ts index 92965a15..3e14486d 100644 --- a/tests/unit/results.test.ts +++ b/tests/unit/results.test.ts @@ -10,6 +10,7 @@ import type { import { makeApp } from '../helpers/fake-app.js'; import type { FakeChart } from '../helpers/fake-app.js'; import { newResult as newResultUntyped } from '../../src/core/stream.js'; +import { emptyRecentMap, recordRecent } from '../../src/core/recent-values.js'; import { formatRows } from '../../src/core/format.js'; import { queryPanel } from '../../src/core/saved-query.js'; import type { AppState, ResultSort } from '../../src/state.js'; @@ -902,6 +903,64 @@ describe('expandDataPane', () => { expect(app.exec.executeRead).not.toHaveBeenCalled(); // open = snapshot, no request }); + // #478: `variable-bar.ts`'s `VariableBarApp.state.activeByName` is a + // caller-neutral port; this detached-Data caller must ALIAS the real + // `AppState.filterActive` object into it, never copy it — the bar mutates + // that map in place (a text field's activation-follows-value, #165), so a + // copy would silently strand `effectiveFilterActive`/every other Workbench + // reader on the old, never-updated object. The identity check below is + // what makes a copy-instead-of-alias regression fail: capture the object + // reference BEFORE the bar is ever built, then prove the bar's own edit + // lands on that SAME object, and that it routes through the real, + // persisted `saveFilterActive` (not a Dashboard-style no-op). + it('#478: aliases the real AppState.filterActive object (not a copy) and calls saveFilterActive', () => { + const app = makeApp(); + const activeRef = app.state.filterActive; + expandDataPane(app, paramResult()); + const overlay = qs(document, '.graph-overlay'); + const input = qs(overlay, '.detached-variable-row .var-field input'); + input.value = 'Warning'; + input.dispatchEvent(new Event('input', { bubbles: true })); // #165: activation syncs synchronously + // Still the exact same object — the adapter aliased it at construction, + // it never swapped `app.state.filterActive` for a fresh reference. + expect(app.state.filterActive).toBe(activeRef); + // ...and the bar's own write landed on that real object, not a private copy. + expect(activeRef.level).toBe(true); + // The adapter's `saveActive` routed to the real persisted Workbench save. + expect(app.params.saveFilterActive).toHaveBeenCalled(); + }); + + // #478: the adapter's remaining two members — `wallNow` and `clearVarRecent` + // — are each closures over the real `app`, not the real `app` reached + // around; these two tests are this file's only exercise of either closure + // body (a same-shape cast would need neither, so these regress a silent + // revert back to `app as VariableBarApp`). + it('#478: a DateTime field resolves its preview through the adapter\'s wallNow closure', () => { + const wallNow = vi.fn(() => 0); + const app = makeApp({ wallNow }); + const r = tableResult(); + r.source = { + sql: 'SELECT n, s FROM t WHERE d = {asOf:DateTime}', + tabId: 't1', rowLimit: 100, title: 'Filtered', description: 'warnings only', + }; + app.state.varValues.asOf = '-1d'; + expandDataPane(app, r); + expect(wallNow).toHaveBeenCalled(); + }); + + it('#478: Clear recent on the field footer routes through the adapter\'s clearVarRecent closure', () => { + const app = makeApp(); + app.state.varRecent = recordRecent(emptyRecentMap(), 'level', 'Warning'); + expandDataPane(app, paramResult()); + const overlay = qs(document, '.graph-overlay'); + const input = qs(overlay, '.detached-variable-row .var-field input'); + input.dispatchEvent(new Event('focus')); + qs(overlay, '.var-combo-footer button').dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, cancelable: true }), + ); + expect(app.params.clearVarRecent).toHaveBeenCalledWith('level'); + }); + it('a retained Refresh button is inert after its detached view closes', async () => { const app = makeApp(); app.state.varValues.level = 'Warning'; diff --git a/tests/unit/variable-bar.test.ts b/tests/unit/variable-bar.test.ts index b1f136d2..7c6a34e5 100644 --- a/tests/unit/variable-bar.test.ts +++ b/tests/unit/variable-bar.test.ts @@ -2,11 +2,30 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { analyzeParameterizedSources, fieldControls } from '../../src/core/param-pipeline.js'; import type { FieldControl, PreparedFieldState } from '../../src/core/param-pipeline.js'; import { buildVariableBar, VARIABLE_DEBOUNCE_MS } from '../../src/ui/variable-bar.js'; +import type { VariableBarApp } from '../../src/ui/variable-bar.js'; import { emptyRecentMap, recordRecent } from '../../src/core/recent-values.js'; import { parseParamType } from '../../src/core/param-type.js'; import type { DashboardTimeRangeGroup, TimeRangeRecent } from '../../src/core/time-range.js'; import { makeApp } from '../helpers/fake-app.js'; +// #478: this spec exercises `buildVariableBar` directly against the full +// fake `App` fixture, so it needs its OWN small adapter — the same shape +// `dashboard.ts`/`results.ts` build in production — rather than a same-shape +// cast. `activeByName` ALIASES `app.state.filterActive` (never copies it: the +// bar mutates it in place), so every assertion below that reads +// `app.state.filterActive`/`app.params.saveFilterActive` after building a bar +// through this adapter still observes the bar's own writes. +const asBarApp = (app: ReturnType): VariableBarApp => ({ + document: app.document, + state: { varValues: app.state.varValues, activeByName: app.state.filterActive, varRecent: app.state.varRecent }, + params: { + saveVarValues: () => app.params.saveVarValues(), + saveActive: () => app.params.saveFilterActive(), + clearVarRecent: (name: string) => app.params.clearVarRecent(name), + }, + wallNow: () => app.wallNow(), +}); + // #447 rewrote this spec: `buildVariableBar` has ONE field branch left. The // curated branch (a Dashboard filter drawing its options from a saved // "Filter"-role query — the strict single-select combobox, the multiselect @@ -25,7 +44,7 @@ describe('buildVariableBar (shared variable row)', () => { it('is a labeled group and builds a field per param when ariaLabel + document are given', () => { const app = makeApp(); const bar = buildVariableBar( - app, + asBarApp(app), paramsFor('SELECT * FROM t WHERE x = {x:String}'), () => {}, okField, @@ -39,7 +58,7 @@ describe('buildVariableBar (shared variable row)', () => { it('renders a hidden-but-labeled empty bar when there are no params', () => { const app = makeApp(); - const bar = buildVariableBar(app, [], () => {}, okField, { ariaLabel: 'Query variables' }); + const bar = buildVariableBar(asBarApp(app), [], () => {}, okField, { ariaLabel: 'Query variables' }); expect(bar.el.style.display).toBe('none'); expect(bar.el.getAttribute('aria-label')).toBe('Query variables'); expect(bar.el.querySelectorAll('.var-field').length).toBe(0); @@ -53,7 +72,7 @@ describe('buildVariableBar (shared variable row)', () => { it('defaults to app.document and no group role when no options are passed', () => { const app = makeApp(); - const bar = buildVariableBar(app, paramsFor('SELECT {x:String}'), () => {}, okField); + const bar = buildVariableBar(asBarApp(app), paramsFor('SELECT {x:String}'), () => {}, okField); expect(bar.el.getAttribute('role')).toBeNull(); expect(bar.el.getAttribute('aria-label')).toBeNull(); expect(bar.el.querySelectorAll('.var-field').length).toBe(1); @@ -61,14 +80,14 @@ describe('buildVariableBar (shared variable row)', () => { it('marks a plain optional parameter as optional', () => { const bar = buildVariableBar( - makeApp(), paramsFor('SELECT 1 /*[ WHERE x = {x:String} ]*/'), () => {}, okField, + asBarApp(makeApp()), paramsFor('SELECT 1 /*[ WHERE x = {x:String} ]*/'), () => {}, okField, ); expect(bar.el.querySelector('.var-field')!.classList.contains('is-optional')).toBe(true); }); it('a blur before any edit is a no-op commit', () => { const onCommit = vi.fn(); - const bar = buildVariableBar(makeApp(), paramsFor('SELECT {x:String}'), onCommit, okField); + const bar = buildVariableBar(asBarApp(makeApp()), paramsFor('SELECT {x:String}'), onCommit, okField); bar.el.querySelector('input')!.dispatchEvent(new Event('blur')); expect(onCommit).not.toHaveBeenCalled(); }); @@ -78,7 +97,7 @@ describe('buildVariableBar (shared variable row)', () => { try { const app = makeApp(); const onCommit = vi.fn(); - const bar = buildVariableBar(app, paramsFor('SELECT {x:String}'), onCommit, okField); + const bar = buildVariableBar(asBarApp(app), paramsFor('SELECT {x:String}'), onCommit, okField); const input = bar.el.querySelector('input')! as HTMLInputElement; input.value = 'abc'; input.dispatchEvent(new Event('input', { bubbles: true })); @@ -104,7 +123,7 @@ describe('buildVariableBar (shared variable row)', () => { vi.useFakeTimers(); try { const onCommit = vi.fn(); - const bar = buildVariableBar(makeApp(), paramsFor('SELECT {x:String}'), onCommit, okField); + const bar = buildVariableBar(asBarApp(makeApp()), paramsFor('SELECT {x:String}'), onCommit, okField); const input = bar.el.querySelector('input')! as HTMLInputElement; input.value = 'abc'; input.dispatchEvent(new Event('input', { bubbles: true })); // arms the debounce @@ -126,7 +145,7 @@ describe('buildVariableBar (shared variable row)', () => { const app = makeApp(); app.state.varRecent = recordRecent(emptyRecentMap(), 'x', 'foo'); const onCommit = vi.fn(); - const bar = buildVariableBar(app, paramsFor('SELECT {x:String}'), onCommit, okField, { document }); + const bar = buildVariableBar(asBarApp(app), paramsFor('SELECT {x:String}'), onCommit, okField, { document }); document.body.appendChild(bar.el); const input = bar.el.querySelector('input')!; input.dispatchEvent(new Event('focus')); @@ -146,7 +165,7 @@ describe('buildVariableBar (shared variable row)', () => { const app = makeApp(); app.state.varRecent = recordRecent(emptyRecentMap(), 'x', 'foo'); const onCommit = vi.fn(); - const bar = buildVariableBar(app, paramsFor('SELECT {x:String}'), onCommit, okField, { document }); + const bar = buildVariableBar(asBarApp(app), paramsFor('SELECT {x:String}'), onCommit, okField, { document }); document.body.appendChild(bar.el); const input = bar.el.querySelector('input')!; input.dispatchEvent(new Event('focus')); // opens the list without typing — no timer armed @@ -157,7 +176,7 @@ describe('buildVariableBar (shared variable row)', () => { }); it('reports no focused field when the document has no active element', () => { - const bar = buildVariableBar(makeApp(), paramsFor('SELECT {x:String}'), () => {}, okField, { document }); + const bar = buildVariableBar(asBarApp(makeApp()), paramsFor('SELECT {x:String}'), () => {}, okField, { document }); const descriptor = Object.getOwnPropertyDescriptor(document, 'activeElement'); Object.defineProperty(document, 'activeElement', { configurable: true, value: null }); try { @@ -173,7 +192,7 @@ describe('buildVariableBar (shared variable row)', () => { { id: 'A', kind: 'tab', sql: 'SELECT {x:UInt64}', bindPolicy: 'row-returning' }, { id: 'B', kind: 'tab', sql: 'SELECT {x:String}', bindPolicy: 'row-returning' }, ])); - const bar = buildVariableBar(makeApp(), params, () => {}, okField); + const bar = buildVariableBar(asBarApp(makeApp()), params, () => {}, okField); const input = bar.el.querySelector('input')!; expect(input.classList.contains('is-conflict')).toBe(true); expect(input.title).toContain('Conflicting type declarations: UInt64 vs String'); @@ -181,7 +200,7 @@ describe('buildVariableBar (shared variable row)', () => { it('applies the shared is-invalid affordance from the prepared batch (#170)', () => { const invalidField = (): PreparedFieldState => ({ state: 'invalid', reason: 'Bad value' }); - const bar = buildVariableBar(makeApp(), paramsFor('SELECT {x:String}'), () => {}, invalidField); + const bar = buildVariableBar(asBarApp(makeApp()), paramsFor('SELECT {x:String}'), () => {}, invalidField); const input = bar.el.querySelector('input')! as HTMLInputElement; expect(input.classList.contains('is-invalid')).toBe(true); expect(input.getAttribute('aria-invalid')).toBe('true'); @@ -190,7 +209,7 @@ describe('buildVariableBar (shared variable row)', () => { it('an optional parameter names the blank-leaves-it-out contract in its tooltip', () => { const bar = buildVariableBar( - makeApp(), paramsFor('SELECT 1 /*[ WHERE x = {x:String} ]*/'), () => {}, okField, + asBarApp(makeApp()), paramsFor('SELECT 1 /*[ WHERE x = {x:String} ]*/'), () => {}, okField, ); const input = bar.el.querySelector('input')! as HTMLInputElement; expect(input.title).toBe('x: String — optional: blank leaves its filter block out'); @@ -201,26 +220,26 @@ describe('buildVariableBar (shared variable row)', () => { describe('field width (#345)', () => { it('a plain text field (String) gets the generic string width', () => { const app = makeApp(); - const bar = buildVariableBar(app, paramsFor('SELECT {name:String}'), () => {}, okField); + const bar = buildVariableBar(asBarApp(app), paramsFor('SELECT {name:String}'), () => {}, okField); const input = bar.el.querySelector('.var-input')!; expect(input.style.getPropertyValue('--var-input-ch')).toBe('16'); }); it('a tiny-integer field (UInt8) gets the bool/tiny-int width', () => { const app = makeApp(); - const bar = buildVariableBar(app, paramsFor('SELECT {flag:UInt8}'), () => {}, okField); + const bar = buildVariableBar(asBarApp(app), paramsFor('SELECT {flag:UInt8}'), () => {}, okField); const input = bar.el.querySelector('.var-input')!; expect(input.style.getPropertyValue('--var-input-ch')).toBe('9'); }); it('a Date field is narrower than a DateTime field, even though both render the date-like combobox', () => { const app = makeApp(); - const dateBar = buildVariableBar(app, paramsFor('SELECT {d:Date}'), () => {}, okField); - const dtBar = buildVariableBar(app, paramsFor('SELECT {dt:DateTime}'), () => {}, okField); + const dateBar = buildVariableBar(asBarApp(app), paramsFor('SELECT {d:Date}'), () => {}, okField); + const dtBar = buildVariableBar(asBarApp(app), paramsFor('SELECT {dt:DateTime}'), () => {}, okField); expect(dateBar.el.querySelector('.var-input')!.style.getPropertyValue('--var-input-ch')).toBe('13'); expect(dtBar.el.querySelector('.var-input')!.style.getPropertyValue('--var-input-ch')).toBe('17'); }); it("a declared Enum8 field gets the enum width", () => { const app = makeApp(); - const bar = buildVariableBar(app, paramsFor("SELECT {kind:Enum8('a' = 1, 'b' = 2)}"), () => {}, okField); + const bar = buildVariableBar(asBarApp(app), paramsFor("SELECT {kind:Enum8('a' = 1, 'b' = 2)}"), () => {}, okField); const input = bar.el.querySelector('.var-input')!; expect(input.style.getPropertyValue('--var-input-ch')).toBe('14'); }); @@ -230,13 +249,13 @@ describe('buildVariableBar (shared variable row)', () => { { id: 'A', kind: 'tab', sql: 'SELECT {x:UInt64}', bindPolicy: 'row-returning' }, { id: 'B', kind: 'tab', sql: 'SELECT {x:String}', bindPolicy: 'row-returning' }, ])); - const bar = buildVariableBar(app, params, () => {}, okField); + const bar = buildVariableBar(asBarApp(app), params, () => {}, okField); const input = bar.el.querySelector('.var-input')!; expect(input.style.getPropertyValue('--var-input-ch')).toBe('13'); // UInt64 (first declaration) → numeric }); it('never changes while typing — set once at field build, not on every keystroke', () => { const app = makeApp(); - const bar = buildVariableBar(app, paramsFor('SELECT {name:String}'), () => {}, okField); + const bar = buildVariableBar(asBarApp(app), paramsFor('SELECT {name:String}'), () => {}, okField); const input = bar.el.querySelector('.var-input')!; const before = input.style.getPropertyValue('--var-input-ch'); input.value = 'a much longer value than the field is wide'; @@ -273,7 +292,7 @@ describe('buildVariableBar (shared variable row)', () => { it('renders a "Time" section (label + control + separator) AHEAD of the fields, suppresses the pair, and labels the rest "Variables"', () => { const app = makeApp(); - const bar = buildVariableBar(app, groupParams, () => {}, okField, { timeRange: [trEntry()] }); + const bar = buildVariableBar(asBarApp(app), groupParams, () => {}, okField, { timeRange: [trEntry()] }); expect(bar.el.contains(bar.timeEl)).toBe(true); expect(bar.el.contains(bar.ordinaryEl)).toBe(true); expect([...bar.el.querySelectorAll('.flabel')].map((n) => n.textContent)).toEqual(['Time', 'Variables']); @@ -299,27 +318,27 @@ describe('buildVariableBar (shared variable row)', () => { it('omits the "Variables" label when every remaining param is grouped (no non-group field left)', () => { const app = makeApp(); const params = paramsFor('SELECT k FROM t WHERE d >= {from:DateTime} AND d < {to:DateTime}'); - const bar = buildVariableBar(app, params, () => {}, okField, { timeRange: [trEntry()] }); + const bar = buildVariableBar(asBarApp(app), params, () => {}, okField, { timeRange: [trEntry()] }); expect([...bar.el.querySelectorAll('.flabel')].map((n) => n.textContent)).toEqual(['Time']); expect(bar.el.querySelector('.var-field:not(.is-time-range)')).toBeNull(); }); it('renders no time section (no flabel/trf-sep) when timeRange is absent or empty — the plain path', () => { const app = makeApp(); - const absent = buildVariableBar(app, groupParams, () => {}, okField); + const absent = buildVariableBar(asBarApp(app), groupParams, () => {}, okField); expect(absent.el.querySelector('.flabel')).toBeNull(); expect(absent.el.querySelector('.trf-sep')).toBeNull(); expect(absent.el.querySelector('.trf-trigger')).toBeNull(); // All three params render as ordinary fields (nothing suppressed). expect([...absent.el.querySelectorAll('.var-name')].map((n) => n.textContent)).toEqual(['from', 'to', 'region']); - const empty = buildVariableBar(app, groupParams, () => {}, okField, { timeRange: [] }); + const empty = buildVariableBar(asBarApp(app), groupParams, () => {}, okField, { timeRange: [] }); expect(empty.el.querySelector('.flabel')).toBeNull(); }); it('openPopoverKey()/focusFieldTrigger() speak the group key-space; dispose() cancels an open time-range popover', () => { const app = makeApp(); const onApplyTimeRange = vi.fn(); - const bar = buildVariableBar(app, groupParams, () => {}, okField, { timeRange: [trEntry()], onApplyTimeRange }); + const bar = buildVariableBar(asBarApp(app), groupParams, () => {}, okField, { timeRange: [trEntry()], onApplyTimeRange }); document.body.appendChild(bar.el); const key = `group:${GROUP_KEY}`; expect(bar.openPopoverKey()).toBeNull(); @@ -343,7 +362,7 @@ describe('buildVariableBar (shared variable row)', () => { it('an Apply routes through onApplyTimeRange with the group + trimmed bounds', () => { const app = makeApp(); const onApplyTimeRange = vi.fn(); - const bar = buildVariableBar(app, groupParams, () => {}, okField, { timeRange: [trEntry()], onApplyTimeRange }); + const bar = buildVariableBar(asBarApp(app), groupParams, () => {}, okField, { timeRange: [trEntry()], onApplyTimeRange }); document.body.appendChild(bar.el); (bar.el.querySelector('.trf-trigger') as HTMLButtonElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); const inputs = [...document.body.querySelectorAll('.trf-input')] as HTMLInputElement[]; @@ -360,7 +379,7 @@ describe('buildVariableBar (shared variable row)', () => { it('an Apply with no onApplyTimeRange wired is a silent no-op (an older/simpler caller)', () => { const app = makeApp(); - const bar = buildVariableBar(app, groupParams, () => {}, okField, { timeRange: [trEntry()] }); + const bar = buildVariableBar(asBarApp(app), groupParams, () => {}, okField, { timeRange: [trEntry()] }); document.body.appendChild(bar.el); (bar.el.querySelector('.trf-trigger') as HTMLButtonElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); const inputs = [...document.body.querySelectorAll('.trf-input')] as HTMLInputElement[]; @@ -373,7 +392,7 @@ describe('buildVariableBar (shared variable row)', () => { it('refreshTimeRangeLabels(nowMs) re-resolves every time-range control label in place; a no-op with no controls', () => { const app = makeApp(); - const bar = buildVariableBar(app, groupParams, () => {}, okField, { + const bar = buildVariableBar(asBarApp(app), groupParams, () => {}, okField, { timeRange: [trEntry({ fromValue: '-1d', toValue: 'now', active: true, waveNowMs: 0 })], }); const trigger = bar.el.querySelector('.trf-trigger') as HTMLButtonElement; @@ -382,7 +401,7 @@ describe('buildVariableBar (shared variable row)', () => { bar.refreshTimeRangeLabels(86_400_000); expect(trigger.textContent).not.toBe(before); // No time-range controls at all → a harmless no-op. - const plain = buildVariableBar(app, paramsFor('SELECT {x:String}'), () => {}, okField); + const plain = buildVariableBar(asBarApp(app), paramsFor('SELECT {x:String}'), () => {}, okField); expect(() => plain.refreshTimeRangeLabels(1)).not.toThrow(); }); @@ -390,7 +409,7 @@ describe('buildVariableBar (shared variable row)', () => { const app = makeApp(); const onApplyTimeRange = vi.fn(); const recents: TimeRangeRecent[] = [{ from: '-7d', to: 'now' }]; - const bar = buildVariableBar(app, groupParams, () => {}, okField, { + const bar = buildVariableBar(asBarApp(app), groupParams, () => {}, okField, { timeRange: [trEntry({ recents: () => recents })], onApplyTimeRange, }); document.body.appendChild(bar.el); @@ -409,7 +428,7 @@ describe('buildVariableBar (shared variable row)', () => { try { const app = makeApp(); const onCommit = vi.fn(); - const bar = buildVariableBar(app, paramsFor('SELECT {x:String}'), onCommit, okField); + const bar = buildVariableBar(asBarApp(app), paramsFor('SELECT {x:String}'), onCommit, okField); const input = bar.el.querySelector('input')! as HTMLInputElement; input.value = 'a'; input.dispatchEvent(new Event('input', { bubbles: true })); // arms the debounce @@ -422,7 +441,7 @@ describe('buildVariableBar (shared variable row)', () => { }); it('dispose() with no pending debounce is a no-op (the never-typed field)', () => { - const bar = buildVariableBar(makeApp(), paramsFor('SELECT {x:String}'), () => {}, okField); + const bar = buildVariableBar(asBarApp(makeApp()), paramsFor('SELECT {x:String}'), () => {}, okField); expect(() => bar.dispose()).not.toThrow(); }); }); @@ -437,7 +456,7 @@ describe('buildVariableBar — Dashboard variable controls (#447 phase 2)', () = const bars: Array<{ dispose(): void }> = []; const build = (sql: string, options: Parameters[4] = {}) => { const app = makeApp(); - const bar = buildVariableBar(app, paramsFor(sql), () => {}, okField, { document, ...options }); + const bar = buildVariableBar(asBarApp(app), paramsFor(sql), () => {}, okField, { document, ...options }); bars.push(bar); return { app, bar }; }; @@ -489,7 +508,7 @@ describe('buildVariableBar — Dashboard variable controls (#447 phase 2)', () = app.state.varValues.country = 'de'; app.state.filterActive.country = true; const onCommitVariable = vi.fn(); - const bar = buildVariableBar(app, paramsFor('SELECT {country:String}'), () => {}, okField, { + const bar = buildVariableBar(asBarApp(app), paramsFor('SELECT {country:String}'), () => {}, okField, { document, variables: { country: { options: OPTIONS } }, onCommitVariable, }); bars.push(bar); @@ -559,7 +578,7 @@ describe('buildVariableBar — Dashboard variable controls (#447 phase 2)', () = }); it('setVariableOptions on a bar with no fields at all is a no-op', () => { - const bar = buildVariableBar(makeApp(), [], () => {}, okField, { document, variables: {} }); + const bar = buildVariableBar(asBarApp(makeApp()), [], () => {}, okField, { document, variables: {} }); expect(() => bar.setVariableOptions({ x: { options: OPTIONS, error: null } })).not.toThrow(); }); @@ -831,7 +850,7 @@ describe('buildVariableBar — Dashboard variable controls (#447 phase 2)', () = app.state.varValues.enabled = 'yes'; app.state.filterActive.enabled = true; // Build from the persisted state, just as Dashboard publication does. - const restored = buildVariableBar(app, paramsFor('SELECT 1 /*[ WHERE enabled = {enabled:Bool} ]*/'), () => {}, okField, { + const restored = buildVariableBar(asBarApp(app), paramsFor('SELECT 1 /*[ WHERE enabled = {enabled:Bool} ]*/'), () => {}, okField, { document, variables: { enabled: { options: null } }, }); bars.push(restored);