From 770fc6bc3cc3b3e9272b1a0036b6da29c38c8ee7 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 22 Jul 2026 11:50:58 +0200 Subject: [PATCH] fix(#368): respect saved Table view on Dashboard Resolve legacy view: 'table' as an explicit Table presentation before automatic panel detection, while retaining panel, variant, and override precedence. Co-Authored-By: OpenAI Codex Claude-Session: unavailable (OpenAI Codex) --- CHANGELOG.md | 6 +++ src/dashboard/model/presentation-resolver.ts | 16 +++++- tests/unit/dashboard-viewer-session.test.ts | 22 +++++++++ tests/unit/presentation-resolver.test.ts | 52 ++++++++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03f253dd..04c6540a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] +### Fixed +- **Dashboard tiles now respect an explicit saved `view: "table"`** (#368). + The compatibility form resolves to a Table base presentation before runtime + panel detection, while an explicit `panel` and its variants/overrides retain + their existing precedence. + ### Added - **Searchable multiselect for query-backed Dashboard filters** (#189). A source-backed filter whose executable consumers agree on one `Array(T)` diff --git a/src/dashboard/model/presentation-resolver.ts b/src/dashboard/model/presentation-resolver.ts index f5148c28..d40e5442 100644 --- a/src/dashboard/model/presentation-resolver.ts +++ b/src/dashboard/model/presentation-resolver.ts @@ -64,7 +64,21 @@ export function resolvePresentation(input: ResolvePresentationInput): ResolvePre ({ ok: false, diagnostics: sortDiagnostics(diagnostics) }); const spec = isObject(query) ? query.spec : undefined; - const basePanel = isObject(spec) && isObject(spec.panel) ? cloneJson(spec.panel) : {}; + // `view: 'table'` predates the first-class panel form but is still an + // explicit saved presentation choice. Preserve it as a Table base before + // deriving any runtime panel: otherwise resolvePanel() sees no renderer and + // correctly auto-detects a chart, KPI, or Logs panel instead. A panel object + // without its own `cfg` is metadata, not an explicit renderer, so retain + // that metadata while supplying the compatibility Table cfg. An existing + // cfg (including a malformed/null one) remains authoritative and is left to + // normal validation rather than silently repaired. + const persistedPanel = isObject(spec) && isObject(spec.panel) + ? cloneJson(spec.panel) + : {}; + const hasExplicitCfg = isObject(spec) && isObject(spec.panel) && Object.hasOwn(spec.panel, 'cfg'); + const basePanel = isObject(spec) && spec.view === 'table' && !hasExplicitCfg + ? { ...persistedPanel, cfg: { type: 'table' } } + : persistedPanel; const baseType = cfgType(basePanel); const dashboard = isObject(spec) && isObject(spec.dashboard) ? spec.dashboard : undefined; const variants = dashboard && isObject(dashboard.variants) ? dashboard.variants : undefined; diff --git a/tests/unit/dashboard-viewer-session.test.ts b/tests/unit/dashboard-viewer-session.test.ts index 712eec89..df283f60 100644 --- a/tests/unit/dashboard-viewer-session.test.ts +++ b/tests/unit/dashboard-viewer-session.test.ts @@ -123,6 +123,28 @@ describe('createDashboardViewerSession', () => { expect(session.state.value.tiles[0].isKpi).toBe(false); }); + it('keeps an explicit legacy Table view on a chartable, log-shaped, and scalar Dashboard tile', async () => { + const { exec } = makeExec((sql) => { + if (sql.includes('chart')) return { columns: [{ name: 'time', type: 'DateTime' }, { name: 'value', type: 'UInt64' }], rows: [['2026-01-01', 1], ['2026-01-02', 2]] }; + if (sql.includes('logs')) return { columns: [{ name: 'event_time', type: 'DateTime' }, { name: 'message', type: 'String' }], rows: [['2026-01-01', 'hello']] }; + return { columns: [{ name: 'value', type: 'UInt64' }], rows: [[1]] }; + }); + const document = doc({ tiles: [tile('chart', 'chart'), tile('logs', 'logs'), tile('scalar', 'scalar')] }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [ + query('chart', 'SELECT chart', { view: 'table' }), + query('logs', 'SELECT logs', { view: 'table' }), + query('scalar', 'SELECT scalar', { view: 'table' }), + ], + })); + await session.start(); + expect(session.state.value.tiles.map((entry) => entry.panel)).toEqual([ + { cfg: { type: 'table' } }, { cfg: { type: 'table' } }, { cfg: { type: 'table' } }, + ]); + expect(session.state.value.tiles.every((entry) => !entry.isKpi)).toBe(true); + }); + it('shows an unfilled tile when a required param has no value, issuing no request', async () => { const { exec, calls } = makeExec(); const document = doc({ tiles: [tile('t1', 'q1')] }); diff --git a/tests/unit/presentation-resolver.test.ts b/tests/unit/presentation-resolver.test.ts index 3ea2cca5..a467fcf9 100644 --- a/tests/unit/presentation-resolver.test.ts +++ b/tests/unit/presentation-resolver.test.ts @@ -22,6 +22,58 @@ describe('resolvePresentation', () => { if (result.ok) expect(result.panel).toEqual(basePanel()); }); + it('treats the legacy explicit Table view as a Table base presentation', () => { + const query = { id: 'q', sql: 'SELECT a,b', specVersion: 1, spec: { name: 'q', view: 'table' } }; + const result = resolvePresentation({ query, tile: tileFor() }); + expect(result).toEqual({ ok: true, panel: { cfg: { type: 'table' } } }); + }); + + it('adds a Table cfg while preserving panel metadata when cfg is absent', () => { + const emptyPanel = { + id: 'q', sql: 'SELECT a', specVersion: 1, + spec: { name: 'q', view: 'table', panel: {} }, + }; + const fieldConfigPanel = { + id: 'q', sql: 'SELECT a', specVersion: 1, + spec: { name: 'q', view: 'table', panel: { fieldConfig: { defaults: { decimals: 2 } } } }, + }; + expect(resolvePresentation({ query: emptyPanel, tile: tileFor() })).toEqual({ + ok: true, panel: { cfg: { type: 'table' } }, + }); + expect(resolvePresentation({ query: fieldConfigPanel, tile: tileFor() })).toEqual({ + ok: true, panel: { cfg: { type: 'table' }, fieldConfig: { defaults: { decimals: 2 } } }, + }); + }); + + it('leaves a query without an explicit presentation unconfigured for runtime auto-detection', () => { + const query = { id: 'q', sql: 'SELECT a,b', specVersion: 1, spec: { name: 'q' } }; + const result = resolvePresentation({ query, tile: tileFor() }); + expect(result).toEqual({ ok: true, panel: {} }); + }); + + it('keeps an explicit panel authoritative over the legacy Table view', () => { + const query = { + id: 'q', sql: 'SELECT a', specVersion: 1, + spec: { name: 'q', view: 'table', panel: { cfg: { type: 'kpi' } } }, + }; + const result = resolvePresentation({ query, tile: tileFor() }); + expect(result).toEqual({ ok: true, panel: { cfg: { type: 'kpi' } } }); + }); + + it('applies named variants and tile overrides over the legacy Table base', () => { + const query = { + id: 'q', sql: 'SELECT a', specVersion: 1, + spec: { name: 'q', view: 'table', dashboard: { variants: { compact: { fieldConfig: { defaults: { decimals: 1 } } } } } }, + }; + const result = resolvePresentation({ + query, tile: tileFor({ variant: 'compact', override: { fieldConfig: { columns: { a: { unit: 'ms' } } } } }), + }); + expect(result).toEqual({ ok: true, panel: { + cfg: { type: 'table' }, + fieldConfig: { defaults: { decimals: 1 }, columns: { a: { unit: 'ms' } } }, + } }); + }); + it('applies a valid named variant patch', () => { const query = makeQuery({ variants: { alt: { fieldConfig: { defaults: { unit: 'ms' } } } } }); const result = resolvePresentation({ query, tile: tileFor({ variant: 'alt' }) });