From 6fa560c3d5985bd15188e763742986a49e666ca7 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 23 Jul 2026 22:05:20 +0200 Subject: [PATCH 1/8] feat(dashboard): unify workbench and dashboard chrome --- CHANGELOG.md | 11 +- .../application/dashboard-viewer-session.ts | 79 +++++++-- src/styles.css | 110 +++++++++--- src/ui/app-header.ts | 107 +++--------- src/ui/dashboard.ts | 165 +++++++++++++----- src/ui/file-menu.ts | 28 ++- src/ui/filter-bar.ts | 25 ++- tests/e2e/dashboard-mobile.html | 53 ++++-- tests/e2e/dashboard-mobile.spec.js | 73 +++----- tests/unit/app.test.ts | 7 +- tests/unit/dashboard-viewer-session.test.ts | 113 +++++++++++- tests/unit/dashboard.test.ts | 121 ++++++++++--- tests/unit/filter-bar.test.ts | 11 +- 13 files changed, 635 insertions(+), 268 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b14863c..0711a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Added +- **Unified SQL Browser and Dashboard chrome.** Both surfaces now share the + same brand, surface, workspace, connection, examples, shortcuts, theme, and + user zones. Dashboard layout, tile count/search, time range, refresh, and + View/Edit controls live in a sticky primary tool row; ordinary parameters + live in a separate sticky filter row. Tile search matches effective titles + and descriptions without rerunning queries or changing saved layout/order, + and ordinary-filter Clear all restores defaults in one execution wave while + preserving every time-range pair. - **Unified `/sql` routing for Workbench and Dashboard surfaces** (#407). Workspace identity, surface, and presentation mode now live in canonical `ws`, `surface`, and `mode` query parameters. Workbench/Dashboard switches @@ -19,8 +27,7 @@ auto-generated per-PR notes; this file is the curated, human-readable history. falls back, and an empty workspace offers Create only in edit mode. The old surface becomes an inert loading route before cross-workspace navigation, while same-workspace Back/Forward switches immediately. Dashboard uses the - shared header row for its surface, tile count, File, active-style menu, name, - View/Edit, update, Refresh, and theme controls. Global Workbench shortcuts + shared application header and route controls. Global Workbench shortcuts fail closed outside a ready matching route, and renderer generations let in-flight durable writes finish without obsolete Dashboard or Workbench callbacks repainting the selected surface. Direct Dashboard startup now shares the diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts index bb2cef4..946147f 100644 --- a/src/dashboard/application/dashboard-viewer-session.ts +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -79,6 +79,8 @@ export interface ViewerTileState { tileId: string; queryId: string; title: string; + /** Dashboard-local description override, then the saved-query description. */ + description: string; status: ViewerTileStatus; isKpi: boolean; /** The resolved effective panel (base + variant + override), or null when the @@ -171,7 +173,13 @@ export type DashboardLayoutView = | { engine: 'grafana-grid'; grid: GrafanaGridLayoutModel; renderMode: GridRenderMode }; export interface DashboardViewState { + /** Search-matched tiles only, in their unchanged saved order. */ tiles: ViewerTileState[]; + totalTileCount: number; + visibleTileCount: number; + tileSearch: string; + /** Filter ids whose current value/activation differs from its declared default. */ + resettableFilterIds: string[]; filters: ViewerFilterState[]; layout: DashboardLayoutView; /** Count of ACTIVE filter DEFINITIONS (not non-empty stored values, #188). */ @@ -304,6 +312,10 @@ export interface DashboardViewerSession { /** Reset every filter to its `defaultActive`/`defaultValue`, coalesced into * ONE affected-panel wave (#188 clear-all). */ clearAllFilters(): Promise; + /** Reset only the named filter definitions to their declared defaults. */ + resetFilters(filterIds: readonly string[]): Promise; + /** Set the transient presentation-only tile search. */ + setTileSearch(query: string): void; /** Abort one tile's in-flight request. */ cancelTile(tileId: string): void; /** Adopt a layout/order-edited document (reorder, span/height, preset) WITHOUT @@ -421,6 +433,16 @@ const toParamValue = (value: unknown): unknown => * state must never alias an array a caller (the document, the persisted * seed, `setFilter`/`applyFilter`'s own caller) still holds a reference to. */ const copyValue = (value: unknown): unknown => (Array.isArray(value) ? value.slice() : value); +const filterResetActive = (def: DashboardFilterDefinitionV1): boolean => + def.defaultActive ?? false; +const filterInitialActive = (def: DashboardFilterDefinitionV1): boolean => + def.defaultActive ?? (Array.isArray(def.defaultValue) + ? def.defaultValue.length > 0 + : def.defaultValue != null && def.defaultValue !== ''); +const filterDefaultValue = (def: DashboardFilterDefinitionV1): string | string[] => + (Array.isArray(def.defaultValue) + ? def.defaultValue.map(toValueString) + : toValueString(def.defaultValue)); /** Local copy of `effectiveFilterActive` (state.ts is off-limits to this * layer): a param with an explicit activation entry uses it; otherwise a @@ -469,6 +491,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // outside `documentRef` — never read/written by any command, never // persisted. A fresh session always starts at 'tiles'. let gridRenderMode: GridRenderMode = 'tiles'; + let tileSearch = ''; // #335: the single wall-clock snapshot the LATEST execution wave resolved its // relative tokens against (published as `state.waveWallNowMs`). `null` until // the first wave; every wave entry point (`refresh`/`runAffectedWave`/the @@ -505,8 +528,10 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa const isText = type === 'text'; const explicit: Panel | null = isObject(panel) && isObject(panel.cfg) ? (panel as unknown as Panel) : null; const title = (typeof tile.title === 'string' && tile.title) || (query ? queryName(query) : tile.queryId) || tile.id; + const description = (typeof tile.description === 'string' && tile.description) + || (typeof query?.spec?.description === 'string' ? query.spec.description : ''); const state: ViewerTileState = { - tileId: tile.id, queryId: tile.queryId, title, isKpi, panel, + tileId: tile.id, queryId: tile.queryId, title, description, isKpi, panel, status: presentationError ? 'error' : 'idle', columns: null, rows: null, meta: null, error: presentationError ? presentationError.message : null, @@ -529,14 +554,8 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // Filter runtime records, in filter order. const filters: FilterRuntime[] = (Array.isArray(documentRef.filters) ? documentRef.filters : []).map((def) => { - const defaultValue = copyValue(def.defaultValue ?? ''); - // Merge-gate review (Finding B): array-aware — an omitted `defaultActive` - // infers INACTIVE from an empty array default (`[]`, matching `''`), and - // ACTIVE from a non-empty one, the same length-based rule `setFilter`'s - // own value-implies-active already applies to a committed array. - const defaultActive = def.defaultActive ?? (Array.isArray(def.defaultValue) - ? def.defaultValue.length > 0 - : (def.defaultValue != null && def.defaultValue !== '')); + const defaultValue = filterDefaultValue(def); + const defaultActive = filterInitialActive(def); // #303: a persisted seed for this filter's id overrides the pure-default // init above (untouched when `initialFilters` is absent/empty, or has no // entry for `def.id`). #189: `copyValue` defends against aliasing the @@ -872,7 +891,15 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa function buildState(running: boolean, updatedAt: number | null): DashboardViewState { const mobile = !!deps.isMobile?.(); - const visible = tiles.map((runtime) => ({ id: runtime.tile.id, isKpi: runtime.isKpi })); + const normalizeSearch = (value: string): string => + value.trim().replace(/\s+/g, ' ').toLocaleLowerCase(); + const normalizedSearch = normalizeSearch(tileSearch); + const visibleRuntimes = normalizedSearch + ? tiles.filter((runtime) => + normalizeSearch(runtime.state.title).includes(normalizedSearch) + || normalizeSearch(runtime.state.description).includes(normalizedSearch)) + : tiles; + const visible = visibleRuntimes.map((runtime) => ({ id: runtime.tile.id, isKpi: runtime.isKpi })); // #291: route to whichever engine the CURRENT document's layout resolves // to (`resolveLayoutPluginSync` — the same sync helper the application // layer's other non-awaitable call sites use, since this runs on every @@ -891,7 +918,15 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa } : { engine: 'flow', ...computeFlowLayout({ tiles: visible, layout: documentRef.layout, mobile }) }; return { - tiles: tiles.map((runtime) => ({ ...runtime.state })), + tiles: visibleRuntimes.map((runtime) => ({ ...runtime.state })), + totalTileCount: tiles.length, + visibleTileCount: visibleRuntimes.length, + tileSearch, + resettableFilterIds: filters.filter((filter) => { + const defaultActive = filterResetActive(filter.def); + const defaultValue = filterDefaultValue(filter.def); + return filter.state.active !== defaultActive || !sameSelection(filter.state.value, defaultValue); + }).map((filter) => filter.def.id), filters: filters.map((filter) => ({ ...filter.state })), layout, activeFilterCount: filters.filter((filter) => filter.state.active).length, @@ -1662,27 +1697,39 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa } async function clearAllFilters(): Promise { + await resetFilters(filters.map((filter) => filter.def.id)); + } + + async function resetFilters(filterIds: readonly string[]): Promise { if (destroyed) return; + const ids = new Set(filterIds); const changed: string[] = []; for (const filter of filters) { - const nextActive = filter.def.defaultActive ?? false; + if (!ids.has(filter.def.id)) continue; + const nextActive = filterResetActive(filter.def); // #189: `copyValue` defends the default against aliasing (a // `defaultValue` array literal on the document); `sameSelection` // (filter-selection.ts) compares STRUCTURALLY so an array value/default // never falls through the old `!==` reference check into a spurious // "changed" on every reset. - const nextValue = copyValue(filter.def.defaultValue ?? ''); + const nextValue = filterDefaultValue(filter.def); if (filter.state.active !== nextActive || !sameSelection(filter.state.value, nextValue)) { changed.push(filter.def.parameter); } filter.state.active = nextActive; filter.state.value = nextValue; } - publish(); + if (changed.length) publish(); // Coalesce every reset into ONE affected-panel wave (#188 clear-all). if (changed.length) await commitAndRerun(changed); } + function setTileSearch(query: string): void { + if (destroyed || tileSearch === query) return; + tileSearch = query; + publish(); + } + function cancelTile(tileId: string): void { const runtime = tiles.find((entry) => entry.tile.id === tileId); if (!runtime) return; @@ -1735,7 +1782,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa return { state: stateSignal as ReadonlySignal, controls, timeRangeGroups, getFilterField, - start, refresh, refreshTile, setFilter, applyFilter, applyFilters, clearFilter, clearAllFilters, - cancelTile, syncDocument, setGridRenderMode, destroy, + start, refresh, refreshTile, setFilter, applyFilter, applyFilters, clearFilter, clearAllFilters, resetFilters, + setTileSearch, cancelTile, syncDocument, setGridRenderMode, destroy, }; } diff --git a/src/styles.css b/src/styles.css index 4c533a8..0c1167c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -281,11 +281,22 @@ body { .app-header { height: 44px; display: flex; align-items: center; - padding: 0 14px; gap: 14px; + padding: 0 12px; gap: 10px; background: var(--bg-header); border-bottom: 1px solid var(--border); flex-shrink: 0; } +.header-brand-zone, +.header-context-zone, +.header-utility-zone { + display: flex; align-items: center; min-width: 0; +} +.header-brand-zone { gap: 8px; flex: 0 0 auto; } +.header-context-zone { + gap: 4px; flex: 1 1 auto; padding-left: 8px; + border-left: 1px solid var(--border); +} +.header-utility-zone { gap: 4px; flex: 0 0 auto; } .logo-mark { width: 22px; height: 22px; border-radius: 5px; @@ -294,7 +305,7 @@ body { color: #fff; font-weight: 700; font-size: 12px; flex-shrink: 0; } -.logo-name { font-size: 13px; font-weight: 600; color: var(--fg); } +.logo-name { font-size: 13px; font-weight: 650; color: var(--fg); white-space: nowrap; } .env-chip { font-size: 11px; color: var(--fg-faint); padding: 2px 6px; @@ -307,6 +318,16 @@ body { font-size: 11.5px; color: var(--fg-mute); white-space: nowrap; flex-shrink: 0; } +.connection-chip { + min-width: 0; max-width: 300px; height: 26px; padding: 0 8px; + border: 1px solid var(--border); border-radius: 6px; + background: var(--bg-chip); +} +.connection-host { + max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-family: var(--mono); color: var(--fg); +} +.connection-sep { color: var(--fg-faint); } .conn-status::before { content: ''; width: 7px; height: 7px; border-radius: 4px; background: #22c55e; box-shadow: 0 0 6px #22c55e; @@ -387,6 +408,8 @@ body { max-width: 280px; min-width: 0; } .lib-name:hover { background: var(--bg-hover); } +.lib-name-readonly { cursor: default; } +.lib-name-readonly:hover { background: transparent; } .lib-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .lib-dirty { width: 6px; height: 6px; border-radius: 3px; background: var(--accent); flex-shrink: 0; } .lib-name-input { @@ -1752,7 +1775,10 @@ body.detached-tab .graph-overlay-panel { box-shadow: 0 1px 2px rgba(0, 0, 0, .14); } .editor-mode-btn.is-disabled { opacity: .42; cursor: not-allowed; } -.app-surface-switch .editor-mode-btn { min-width: 0; white-space: nowrap; } +.app-surface-switch .editor-mode-btn { + min-width: 0; white-space: nowrap; + font-size: 12.5px; font-weight: 500; +} .dashboard-mode-switch { flex-shrink: 0; } .editor-mode-btn:focus-visible, .tb-btn:focus-visible, @@ -2363,9 +2389,10 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } concern (side-by-side suits wide-but-short landscape). The hidden items are all decorative or desktop-only; File / theme / user menu remain. */ @media (max-width: 900px) { - .logo-name, .env-chip, .app-header .hd-divider, .conn-status, .hd-hide-mobile { display: none; } - /* #302: the header Dashboard control keeps its icon but drops its label on - narrow screens (its accessible name stays on the button). */ + .logo-name, .env-chip, .app-header .hd-divider, .hd-hide-mobile { display: none; } + .connection-chip .connection-host, + .connection-chip .connection-sep { display: none; } + /* Legacy header controls may still opt into hiding a nonessential label. */ .hd-hide-mobile-label { display: none; } /* Cap the user-menu label so a long local-part (`user ▾`) can't widen the header back past the viewport. */ @@ -2377,8 +2404,10 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } narrowest portrait phones (~360px), where the default 14px gaps alone still overflowed. */ .app-header { gap: 4px; padding: 0 6px; } + .header-brand-zone { gap: 4px; } + .header-context-zone { padding-left: 4px; } .app-header .editor-mode-switch { padding: 1px; gap: 1px; } - .app-header .editor-mode-btn { height: 20px; min-width: 0; padding: 0 5px; font-size: 10px; } + .app-header .editor-mode-btn { height: 20px; min-width: 0; padding: 0 5px; font-size: 11px; } .app-header .hd-file-btn { padding: 0 4px; font-size: 11px; } .app-header .lib-title { flex: 1 1 40px; overflow: hidden; } .app-header .lib-name { max-width: 100%; padding: 0 3px; font-size: 11px; } @@ -2507,10 +2536,15 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } mobile, now that it holds nothing else worth showing when there are no filters. */ .dash-toolbar { - display: flex; align-items: center; gap: 12px; flex-wrap: nowrap; - padding: 8px 20px; background: var(--bg-header); border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 10px; flex-wrap: nowrap; + min-height: 40px; padding: 5px 20px; + background: var(--bg-header); border-bottom: 1px solid var(--border); + overflow-x: auto; overflow-y: hidden; scrollbar-width: none; + overscroll-behavior-x: contain; } -.dash-toolbar:not(.has-filters) { display: none; } +.dash-toolbar::-webkit-scrollbar { display: none; } +.dash-toolbar > * { flex-shrink: 0; } +.dash-toolbar-spacer { flex: 1 1 auto; min-width: 8px; } /* The File-style layout menu in the header's top row, replacing the old `.dash-seg` segmented button group that lived in the filter toolbar. */ .dash-layout-wrap { display: inline-flex; align-items: center; flex: 0 0 auto; } @@ -2520,10 +2554,10 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } (`.dash-filter-host`, the shared scroll contract above, following the workbench `.var-strip` / editor-tabs precedent) so excess fields scroll instead of wrapping, at every viewport width — not just the ≤768px - breakpoint. No visible Clear-all or "N active" count sit beside it - (2026-07-18 owner override — see the header comment in dashboard.ts). The - 4px vertical padding keeps a focused field's box-shadow ring from being - clipped by the viewport's `overflow-y: hidden`. */ + breakpoint. Selective Clear all sits outside this viewport, while the + removed "N active" count stays absent. The 4px vertical padding keeps a + focused field's box-shadow ring from being clipped by the viewport's + `overflow-y: hidden`. */ .dash-filter-host { flex: 1 1 auto; min-width: 0; padding: 4px 0; } .dash-filters { display: flex; align-items: flex-start; gap: 14px; flex-wrap: wrap; } /* `.dash-filters` is shared with the detached Data view's filter row @@ -2533,7 +2567,38 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } rather than changed on the shared class itself. `.var-field` already sets `flex-shrink: 0`, so `flex-wrap: nowrap` alone is enough for the overflow to reach the scroll viewport above — no extra width rule needed. */ -.dash-filter-host .dash-filters { flex-wrap: nowrap; } +.dash-filter-time, +.dash-filter-ordinary { display: contents; } +.dash-filter-host, +.dash-time-filter-host { + display: flex; align-items: flex-start; gap: 14px; flex-wrap: nowrap; +} +.dash-time-filter-host .trf-sep { display: none; } +.dash-time-filter-host:empty { display: none; } +.dash-time-filter-host { min-width: 0; } +.dash-tile-search-wrap { + position: relative; display: inline-flex; align-items: center; min-width: 150px; +} +.dash-tile-search-wrap > svg { + position: absolute; left: 8px; color: var(--fg-faint); pointer-events: none; +} +.dash-tile-search { + width: 170px; height: 28px; padding: 0 9px 0 26px; + border: 1px solid var(--border); border-radius: 6px; + background: var(--bg-input); color: var(--fg); font: 12px var(--ui); outline: none; +} +.dash-tile-search::placeholder { color: var(--fg-mute); } +.dash-tile-search:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent); +} +.dash-clear-filters { + height: 26px; padding: 0 7px; border: 0; border-radius: 5px; + background: transparent; color: var(--fg-mute); font: 500 12px var(--ui); + cursor: pointer; white-space: nowrap; +} +.dash-clear-filters:hover:not(:disabled) { background: var(--bg-hover); color: var(--fg); } +.dash-clear-filters:disabled { opacity: .45; cursor: default; } .dash-icobtn { width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; background: var(--bg-chip); color: var(--fg-mute); border: 1px solid var(--border); @@ -2556,7 +2621,7 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--fg-mute); background: var(--bg-chip); padding: 3px 9px; border-radius: 20px; } -.dash-fav { color: var(--accent); flex-shrink: 0; white-space: nowrap; } +.dash-tile-count { white-space: nowrap; border-radius: 5px; } .dash-src { font-family: var(--mono); } .dash-dot { width: 6px; height: 6px; border-radius: 6px; background: #22C55E; } .dash-updated { @@ -2637,8 +2702,10 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } .dash-grid.dash-reordering .dash-gg-tile { transition: none !important; } } .dash-tile-head { + display: flex; align-items: flex-start; gap: 8px; padding: 11px 12px 9px; border-bottom: 1px solid var(--border-faint); } +.dash-tile-heading { min-width: 0; flex: 1; display: flex; flex-direction: column; } .dash-tile-name { font-size: 13px; font-weight: 600; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; @@ -2786,18 +2853,17 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } width: 30px; height: 30px; padding: 0; justify-content: center; flex-shrink: 0; } .dash-back-label, - .dash-fav, .dash-src, .dash-updated, - .dash-spacer, - .dash-layout-wrap { display: none; } + .dash-spacer { display: none; } .dash-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dash-icobtn { width: 30px; height: 30px; flex-shrink: 0; } - .dash-toolbar { padding: 6px 10px; } + .dash-toolbar { padding: 5px 10px; } + .dash-tile-search { width: 145px; } .dash-grid, .dash-grid.is-report { @@ -2805,6 +2871,10 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } } .dash-grid.is-report .dash-tile { min-height: 300px; } } +@media (max-width: 520px) { + .connection-chip { display: none; } + .app-header .lib-name { max-width: 96px; } +} @media (max-width: 520px) { .dash-kpi-stream .kpi-card, .dash-kpi-state-card { flex-basis: 100%; min-inline-size: 0; max-inline-size: none; inline-size: 100%; diff --git a/src/ui/app-header.ts b/src/ui/app-header.ts index d0c93bb..9704d4d 100644 --- a/src/ui/app-header.ts +++ b/src/ui/app-header.ts @@ -1,35 +1,27 @@ import { h } from './dom.js'; import { Icon } from './icons.js'; import { shortVersion, userShortName } from '../core/format.js'; -import { libraryControls } from './file-menu.js'; +import { buildWorkspaceTitle, libraryControls } from './file-menu.js'; import type { App } from './app.types.js'; export interface AppHeaderOptions { - /** Dashboard's route-owned controls, already wired to its live viewer - * session. Supplying this bag selects the compact one-row Dashboard header; - * Workbench continues to use the normal Library controls and status tail. */ - dashboardControls?: { - tileCount: HTMLElement; - fileButton: HTMLButtonElement; - style: HTMLElement | null; - title: HTMLElement; - updated: HTMLElement; - refresh: HTMLElement; - }; - /** Missing-dashboard routes have no viewer session, but still own the - * Dashboard-scoped File menu. */ - dashboardFileButton?: HTMLButtonElement; + /** Surface-scoped File menu. Workbench uses its workspace/query menu. */ + fileButton?: HTMLButtonElement; + /** Dashboard View is the only read-only workspace-title presentation. */ + workspaceTitleEditable?: boolean; } -function routeButton( +export function routeButton( label: string, active: boolean, onClick: () => void, ): HTMLButtonElement { return h('button', { class: `editor-mode-btn${active ? ' active' : ''}`, + 'aria-label': label, 'aria-pressed': active ? 'true' : 'false', disabled: active, onclick: active ? undefined : onClick, - }, label); + title: label, + }, h('span', { class: 'surface-label' }, label)); } function surfaceSwitch(app: App): HTMLElement { @@ -47,78 +39,35 @@ function surfaceSwitch(app: App): HTMLElement { })); } -function dashboardModeSwitch(app: App): HTMLElement { - const route = app.sqlRoute as Extract; - const key = app.currentWorkspace?.key ?? route.workspaceKey; - return h('div', { - class: 'editor-mode-switch dashboard-mode-switch', - role: 'group', 'aria-label': 'Dashboard mode', - }, - routeButton('View', route.mode === 'view', () => { - void app.navigateSqlRoute({ surface: 'dashboard', workspaceKey: key, mode: 'view' }, 'replace'); - }), - routeButton('Edit', route.mode === 'edit', () => { - void app.navigateSqlRoute({ surface: 'dashboard', workspaceKey: key, mode: 'edit' }, 'replace'); - })); -} - /** The one application header used by both Workbench and Dashboard. */ export function buildAppHeader(app: App, options: AppHeaderOptions = {}): HTMLElement { app.dom.themeBtn = h('button', { class: 'hd-btn', title: 'Toggle theme', onclick: () => app.toggleTheme(), }, app.state.theme === 'dark' ? Icon.sun() : Icon.moon()); - const dashboard = app.sqlRoute.surface === 'dashboard'; - const prefix = [ - h('div', { class: 'logo-mark' }, Icon.brand()), - h('div', { class: 'logo-name' }, 'Altinity®'), - surfaceSwitch(app), - h('div', { class: 'env-chip' }, app.conn.host()), - ]; - if (dashboard) { - const controls = options.dashboardControls; - app.dom.libraryTitle = undefined; - app.dom.dashboardNav = undefined; - if (!controls) { - app.dom.fileBtn = options.dashboardFileButton; - return h('div', { class: 'app-header dashboard-app-header' }, - ...prefix, - options.dashboardFileButton!, - h('div', { - class: 'dash-title', title: app.state.libraryName.value, - }, app.state.libraryName.value), - dashboardModeSwitch(app), - h('div', { class: 'dash-spacer', style: { flex: '1' } }), - app.dom.themeBtn); - } - app.dom.fileBtn = controls.fileButton; - return h('div', { class: 'app-header dashboard-app-header' }, - ...prefix, - controls.tileCount, - controls.fileButton, - controls.style, - controls.title, - dashboardModeSwitch(app), - h('div', { class: 'dash-spacer', style: { flex: '1' } }), - controls.updated, - controls.refresh, - app.dom.themeBtn); - } - const version = app.state.serverVersion; app.dom.connStatus = h('div', { - class: `conn-status${version ? '' : ' dim'}`, - title: version ? `ClickHouse ${version}` : '', - }, h('span', { class: 'ver' }, version ? `ClickHouse ${shortVersion(version)}` : 'Connecting…')); + class: `conn-status connection-chip${version ? '' : ' dim'}`, + title: version ? `${app.conn.host()} · ClickHouse ${version}` : app.conn.host(), + }, h('span', { class: 'connection-host' }, app.conn.host()), + h('span', { class: 'connection-sep', 'aria-hidden': 'true' }, '·'), + h('span', { class: 'ver' }, version ? `CH ${shortVersion(version)}` : 'Connecting…')); app.dom.userBtn = h('button', { class: 'hd-btn user-btn', title: app.conn.email(), onclick: () => app.actions.openUserMenu(), }, h('span', { class: 'user-short' }, userShortName(app.conn.email())), Icon.chevDown()); - const workspaceControls = libraryControls(app); - return h('div', { class: 'app-header' }, - ...prefix, - h('div', { class: 'hd-divider' }), - ...workspaceControls, - h('div', { style: { flex: '1' } }), + const workspaceControls = options.fileButton + ? [options.fileButton, buildWorkspaceTitle(app, options.workspaceTitleEditable !== false)] + : libraryControls(app); + app.dom.fileBtn = workspaceControls[0]; + return h('div', { + class: `app-header${app.sqlRoute.surface === 'dashboard' ? ' dashboard-app-header' : ''}`, + }, + h('div', { class: 'header-brand-zone' }, + h('div', { class: 'logo-mark' }, Icon.brand()), + h('div', { class: 'logo-name' }, 'Altinity®'), + surfaceSwitch(app)), + h('div', { class: 'header-context-zone' }, ...workspaceControls), + h('div', { class: 'header-utility-zone' }, app.dom.connStatus, h('a', { class: 'hd-btn hd-hide-mobile', @@ -130,5 +79,5 @@ export function buildAppHeader(app: App, options: AppHeaderOptions = {}): HTMLEl title: 'Keyboard shortcuts (?)', onclick: () => app.actions.openShortcuts(), }, Icon.shortcuts()), app.dom.themeBtn, - app.dom.userBtn); + app.dom.userBtn)); } diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 5b9605e..6e9a3e2 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -30,7 +30,7 @@ import { effect } from '@preact/signals-core'; import { h } from './dom.js'; import { Icon as IconUntyped } from './icons.js'; -import { buildAppHeader } from './app-header.js'; +import { buildAppHeader, routeButton } from './app-header.js'; import { openMenu } from './menu.js'; import type { MenuHandle, MenuRow } from './menu.js'; import { flashToast } from './toast.js'; @@ -53,7 +53,7 @@ import { queryDashboardRole } from '../dashboard/model/workspace-semantics.js'; import { queryFavorite } from '../core/saved-query.js'; import { selectOutputColumns } from '../core/select-columns.js'; import { renderKpiCards, KPI_STREAM_ARIA } from './kpi-panel.js'; -import { buildFilterBar } from './filter-bar.js'; +import { buildFilterBar, FILTER_DEBOUNCE_MS } from './filter-bar.js'; import type { FilterBarApp, FilterBarHandle } from './filter-bar.js'; import { pushRecentRange } from '../core/time-range.js'; import { formatChartTimeLabel, formatChartTimeRange } from '../core/time-range.js'; @@ -109,6 +109,7 @@ const Icon: { chevDown(): SVGElement; download(): SVGElement; upload(): SVGElement; + search(): SVGElement; } = IconUntyped; const formatRows: (n: number | null | undefined) => string = formatRowsUntyped; @@ -123,7 +124,7 @@ export interface DashboardApp { dom: AppDom; root: Element | null; toggleTheme(): void; - conn: Pick; + conn: Pick; exec: Pick; now(): number; wallNow(): number; @@ -150,7 +151,7 @@ export interface DashboardApp { // committed truth. Fires only AFTER the app-level refresh projected a real change. onWorkspaceExternallyChanged: App['onWorkspaceExternallyChanged']; // #302 — the Dashboard page's own File-menu operations. - actions: Pick; + actions: Pick; genId(): string; /** #303: persists the isolated per-dashboard filter store (`KEYS.dashFilters`). */ saveJSON(key: string, value: unknown): void; @@ -355,11 +356,28 @@ function renderMissingDashboard( app.root!.replaceChildren(h('div', { class: 'dash-page' }, h('div', { class: 'dash-topbar' }, buildAppHeader(app as App, { - dashboardFileButton: buildDashboardFileMenu(app, readOnly), - })), + fileButton: buildDashboardFileMenu(app, readOnly), + workspaceTitleEditable: !readOnly, + }), + h('div', { class: 'dash-toolbar dash-toolbar-primary' }, + h('span', { class: 'dash-toolbar-spacer' }), + buildDashboardModeSwitch(app))), body)); } +function buildDashboardModeSwitch(app: DashboardApp): HTMLElement { + const route = app.sqlRoute as Extract; + const routeKey = app.currentWorkspace?.key ?? route.workspaceKey; + const button = (label: 'View' | 'Edit', mode: 'view' | 'edit'): HTMLButtonElement => + routeButton(label, route.mode === mode, () => { + void app.navigateSqlRoute({ surface: 'dashboard', workspaceKey: routeKey, mode }, 'replace'); + }); + return h('div', { + class: 'editor-mode-switch dashboard-mode-switch', + role: 'group', 'aria-label': 'Dashboard mode', + }, button('View', 'view'), button('Edit', 'edit')); +} + /** #302/#331 — the standalone Dashboard header's own "File" menu: a keyboard- * and screen-reader-accessible dropdown owning Dashboard-scoped operations, * built on the shared `openMenu` primitive (menu.ts) — the same structure + @@ -505,10 +523,15 @@ export async function renderDashboard(app: DashboardApp): Promise { recordBoundParams: (bp) => app.params.recordBoundParams(bp), initialFilters: initialBag, }); + let trackedSessionTileIds = new Set(viewerDoc.tiles.map((tile) => tile.id)); + const syncSessionDocument = (next: DashboardDocumentV1): void => { + session.syncDocument(next); + trackedSessionTileIds = new Set(next.tiles.map((tile) => tile.id)); + }; // ── Header chrome ─────────────────────────────────────────────────────── const tileCountLabel = h('span'); - const tileCount = h('span', { class: 'dash-chip dash-fav' }, Icon.star(true), tileCountLabel); + const tileCount = h('span', { class: 'dash-chip dash-tile-count' }, tileCountLabel); const updated = h('span', { class: 'dash-updated' }); const refreshBtn = h('button', { class: 'editor-mode-btn dash-refresh', title: 'Re-run all tiles', 'aria-label': 'Refresh dashboard', @@ -608,26 +631,53 @@ export async function renderDashboard(app: DashboardApp): Promise { // Dashboard keeps the shared header's File word and placement. View exposes // the safe Export row only; edit additionally exposes Import. const header = buildAppHeader(app as App, { - dashboardControls: { - tileCount, - fileButton: buildDashboardFileMenu(app, readOnly), - style: showLayoutSelect ? layoutWrap : null, - title: h('div', { - class: 'dash-title', title: currentDoc.title || state.libraryName.value, - }, currentDoc.title || state.libraryName.value), - updated, - refresh: refreshControl, + fileButton: buildDashboardFileMenu(app, readOnly), + workspaceTitleEditable: !readOnly, + }); + + const dashboardModeSwitch = buildDashboardModeSwitch(app); + + let tileSearchTimer: ReturnType | null = null; + const commitTileSearch = (input: HTMLInputElement): void => { + if (tileSearchTimer != null) clearTimeout(tileSearchTimer); + tileSearchTimer = null; + session.setTileSearch(input.value); + }; + const tileSearchInput = h('input', { + class: 'dash-tile-search', type: 'search', placeholder: 'Search tiles', + 'aria-label': 'Search dashboard tiles', + oninput: (event: Event) => { + const input = event.target as HTMLInputElement; + if (tileSearchTimer != null) clearTimeout(tileSearchTimer); + tileSearchTimer = setTimeout(() => commitTileSearch(input), FILTER_DEBOUNCE_MS); }, + onblur: (event: Event) => commitTileSearch(event.target as HTMLInputElement), + onkeydown: (event: KeyboardEvent) => { + if (event.key === 'Enter') commitTileSearch(event.target as HTMLInputElement); + }, + }) as HTMLInputElement; + const tileSearch = h('label', { class: 'dash-tile-search-wrap' }, + Icon.search(), tileSearchInput); + const timeFilterHost = h('div', { + class: 'dash-time-filter-host dash-filters', + role: 'group', 'aria-label': 'Dashboard time filters', + }); + const ordinaryFilterHost = h('div', { + class: 'dash-filter-host dash-filters', + role: 'group', 'aria-label': 'Dashboard filters', }); + const ordinaryTimeIds = new Set(session.timeRangeGroups.flatMap((group) => + [group.fromFilterId, group.toFilterId])); + const ordinaryFilterIds = session.state.value.filters + .filter((filter) => !ordinaryTimeIds.has(filter.id)).map((filter) => filter.id); + const clearFiltersBtn = h('button', { + class: 'dash-clear-filters', type: 'button', disabled: true, + onclick: () => { void session.resetFilters(ordinaryFilterIds); }, + }, 'Clear all') as HTMLButtonElement; // ── Filter bar (shared buildFilterBar, viewer-backed) ───────────────────── - // #294: the field region scrolls horizontally in its own viewport - // (`.dash-filter-host`) so it never wraps the toolbar onto a second row. - // No visible Clear-all control (reverses the #286/#293 decision) — no - // visible "N active" count either (2026-07-18 owner override, reverses - // #294's own retained-count acceptance criterion) — `session.clearAllFilters()` - // stays a tested application-level operation with no UI trigger. - const filterHost = h('div', { class: 'dash-filter-host' }); + // The compound time controls mount in the primary row; ordinary controls + // mount in the second scrolling row beside selective Clear all. // #189: a PERSISTENT sr-only announcer, a SIBLING of `filterHost` (never a // child — `filterHost.replaceChildren` below only ever replaces the bar's // own root) so it survives the very rebuild that fires it: when a rebuild @@ -830,7 +880,8 @@ export async function renderDashboard(app: DashboardApp): Promise { filterBarApp, session.controls, onCommit, getField, { curatedFields, document: doc, onApplyCurated, timeRange, onApplyTimeRange }, ); - filterHost.replaceChildren(bar.el); + timeFilterHost.replaceChildren(bar.timeEl); + ordinaryFilterHost.replaceChildren(bar.ordinaryEl); currentFilterBar = bar; filterBarUpdateStatus = bar.updateStatus; // Maintainer merge-gate fix (#189): announce the refresh ONLY when the @@ -865,6 +916,19 @@ export async function renderDashboard(app: DashboardApp): Promise { const grid = h('div', { class: 'dash-grid' }); const empty = h('div', { class: 'dash-empty', style: { display: currentDoc.tiles.length ? 'none' : '' } }, 'No tiles yet — star a query in the Queries panel to add it to the dashboard.'); + const searchEmpty = h('div', { class: 'dash-empty dash-search-empty', style: { display: 'none' } }, + h('h2', null, 'No tiles match'), + h('p', null, 'Try a different title or description.'), + h('button', { + class: 'dash-btn', + onclick: () => { + if (tileSearchTimer != null) clearTimeout(tileSearchTimer); + tileSearchTimer = null; + tileSearchInput.value = ''; + session.setTileSearch(''); + tileSearchInput.focus(); + }, + }, 'Clear search')); // #291 review F2: `grid.clientWidth` INCLUDES the host's own horizontal // padding (`.dash-grid`'s `padding: 18px 20px 40px`, styles.css), but CSS @@ -949,7 +1013,7 @@ export async function renderDashboard(app: DashboardApp): Promise { // trips its own edit) or a rebase corrects it once resolutions land. currentDoc = normalized; layoutMenu.sync(); - session.syncDocument(withImplicitFilters(normalized)); + syncSessionDocument(withImplicitFilters(normalized)); pendingCommands.push(command); @@ -1102,17 +1166,12 @@ export async function renderDashboard(app: DashboardApp): Promise { // — deferred until the queue is idle // so no in-flight resolution handler from THIS render survives into the // rebuilt one. - if (rebased.tiles.some((t) => !sessionTileIds().has(t.id))) needsRebuild = true; + if (rebased.tiles.some((t) => !trackedSessionTileIds.has(t.id))) needsRebuild = true; if (needsRebuild) { rebuildRouteFromCommitted(); return; } - session.syncDocument(withImplicitFilters(rebased)); + syncSessionDocument(withImplicitFilters(rebased)); layoutMenu.sync(); } - /** Tile ids the viewer session still tracks a runtime record for. */ - function sessionTileIds(): Set { - return new Set(session.state.value.tiles.map((ts) => ts.tileId)); - } - // ── Tile DOM ────────────────────────────────────────────────────────────── const tileEls = new Map(); // Flow KPI tiles do not render their cached `.dash-tile` card. Their @@ -1631,7 +1690,12 @@ export async function renderDashboard(app: DashboardApp): Promise { class: 'dash-gg-del', title: 'Remove tile', 'aria-label': 'Remove ' + ts.title + ' from the dashboard', onclick: () => { if (activeEngine === 'grafana-grid') runCommand({ type: 'remove-tile', tileId: ts.tileId }); }, }, Icon.trash()) : null; - const head = h('div', { class: 'dash-tile-head' }, grip, h('span', { class: 'dash-tile-name', title: ts.title }, ts.title), delBtn); + const heading = h('div', { class: 'dash-tile-heading' }, + h('span', { class: 'dash-tile-name', title: ts.title }, ts.title), + ts.description ? h('span', { + class: 'dash-tile-desc', title: ts.description, + }, ts.description) : null); + const head = h('div', { class: 'dash-tile-head' }, grip, heading, delBtn); const body = h('div', { class: 'dash-tile-body' }); const foot = h('div', { class: 'dash-tile-foot' }); const resizeHandle = !readOnly @@ -2012,7 +2076,7 @@ export async function renderDashboard(app: DashboardApp): Promise { // behavior is the `containerWidth`-driven effective-columns clamp below). if (sview.layout.engine === 'flow' && mobileNow !== lastMobile && mobileNow !== sview.layout.mobile) { lastMobile = mobileNow; - session.syncDocument(currentDoc); + syncSessionDocument(currentDoc); return; } lastMobile = mobileNow; @@ -2061,8 +2125,13 @@ export async function renderDashboard(app: DashboardApp): Promise { lastFilterPersistSig = persistSig; app.saveJSON(KEYS.dashFilters, writeDashboardFilterBag(loadJSON(KEYS.dashFilters, {}), currentDoc.id, filterBag)); } - tileCountLabel.textContent = sview.tiles.length + (sview.tiles.length === 1 ? ' tile' : ' tiles'); - empty.style.display = sview.tiles.length ? 'none' : ''; + tileCountLabel.textContent = sview.tileSearch.trim() + ? `${sview.visibleTileCount} of ${sview.totalTileCount} tiles` + : `${sview.totalTileCount} ${sview.totalTileCount === 1 ? 'tile' : 'tiles'}`; + const noMatch = !!sview.tileSearch.trim() && sview.visibleTileCount === 0 && sview.totalTileCount > 0; + empty.style.display = sview.totalTileCount === 0 ? '' : 'none'; + searchEmpty.style.display = noMatch ? '' : 'none'; + clearFiltersBtn.disabled = !sview.resettableFilterIds.some((id) => ordinaryFilterIds.includes(id)); // Genuine dashboard-config diagnostics only (a tile whose presentation // could not resolve, etc.). Per-filter "required/invalid" badges were // dropped as noise (owner decision) — an unfilled required filter simply @@ -2096,13 +2165,25 @@ export async function renderDashboard(app: DashboardApp): Promise { } }); - const toolbar = h('div', { class: 'dash-toolbar' + (session.state.value.filters.length ? ' has-filters' : '') }, - filterHost, filterRefreshLiveEl); + const primaryToolbar = h('div', { class: 'dash-toolbar dash-toolbar-primary' }, + showLayoutSelect ? layoutWrap : null, + tileCount, + tileSearch, + timeFilterHost, + h('span', { class: 'dash-toolbar-spacer' }), + updated, + refreshControl, + dashboardModeSwitch); + const hasOrdinaryFilters = ordinaryFilterIds.length > 0; + const filterToolbar = h('div', { + class: 'dash-toolbar dash-toolbar-filters', + style: hasOrdinaryFilters ? undefined : { display: 'none' }, + }, ordinaryFilterHost, clearFiltersBtn, filterRefreshLiveEl); // `!`: the dashboard renders only into a mounted page. app.root!.replaceChildren(h('div', { class: 'dash-page' }, - h('div', { class: 'dash-topbar' }, header, toolbar), - filterDiagnosticsHost, empty, grid)); + h('div', { class: 'dash-topbar' }, header, primaryToolbar, filterToolbar), + filterDiagnosticsHost, empty, searchEmpty, grid)); // Own every route-scoped resource in one teardown. An in-place Dashboard // rebuild must not leave Chart.js observers, signal effects, popovers, or @@ -2110,6 +2191,8 @@ export async function renderDashboard(app: DashboardApp): Promise { installedDashboardCleanup = () => { currentFilterBar?.dispose(); currentFilterBar = null; + if (tileSearchTimer != null) clearTimeout(tileSearchTimer); + tileSearchTimer = null; disposeDashboardEffect(); for (const tileEl of tileEls.values()) destroyChart(tileEl); chartInteraction.destroy(); @@ -2148,7 +2231,7 @@ export async function renderDashboard(app: DashboardApp): Promise { if (activeEngine !== 'grafana-grid') return; const prevWidth = containerWidthPx; measureGridWidth(); - if (containerWidthPx !== prevWidth) session.syncDocument(currentDoc); + if (containerWidthPx !== prevWidth) syncSessionDocument(currentDoc); }; gridWin.addEventListener('resize', onGridResize); installedGridResizeListener = { win: gridWin, handler: onGridResize }; diff --git a/src/ui/file-menu.ts b/src/ui/file-menu.ts index ae43158..731d61e 100644 --- a/src/ui/file-menu.ts +++ b/src/ui/file-menu.ts @@ -58,19 +58,23 @@ export function libraryControls(app: App): HTMLElement[] { 'aria-haspopup': 'menu', 'aria-expanded': 'false', onclick: () => openFileMenu(app), }, h('span', null, 'File'), Icon.chevDown()); - app.dom.libraryTitle = h('div', { class: 'lib-title' }); - renderLibraryTitle(app); - // #407: Dashboard navigation lives next to the workspace name (not in the - // File menu). It switches the current tab to the unified Dashboard surface, - // including for a workspace whose editable Dashboard has not been created. - // label collapses to icon-only on narrow screens via `hd-hide-mobile-label`, - // but the accessible name ("Open Dashboard") is always present. + // Retain the controller seam for callers/tests that invoke the historical + // shortcut directly; the visible surface switch now owns this navigation. app.dom.dashboardNav = h('button', { class: 'hd-dash-nav', title: 'Open Dashboard', 'aria-label': 'Open Dashboard', onclick: () => app.actions.openDashboard(), - }, Icon.layers(), h('span', { class: 'hd-dash-nav-label hd-hide-mobile-label' }, 'Dashboard →')); + }, Icon.layers(), h('span', { class: 'hd-dash-nav-label' }, 'Dashboard →')); renderDashboardNav(app); - return [app.dom.fileBtn, app.dom.libraryTitle, app.dom.dashboardNav]; + return [app.dom.fileBtn, buildWorkspaceTitle(app, true)]; +} + +/** Build the shared header workspace identity in editable or read-only form. */ +export function buildWorkspaceTitle(app: App, editable: boolean): HTMLElement { + app.dom.libraryTitle = h('div', { + class: 'lib-title', 'data-editable': editable ? 'true' : 'false', + }); + renderLibraryTitle(app); + return app.dom.libraryTitle; } /** Keep the unified Dashboard surface reachable even when `dashboard === null`; @@ -88,6 +92,12 @@ export function renderLibraryTitle(app: App): void { if (!slot) return; const state = app.state; slot.replaceChildren(); + if (slot.dataset.editable === 'false') { + slot.appendChild(h('span', { + class: 'lib-name lib-name-readonly', title: state.libraryName.value, + }, h('span', { class: 'lib-name-text' }, state.libraryName.value))); + return; + } if (app.editingLibrary) { const input = h('input', { class: 'lib-name-input', value: state.libraryName.value }); let done = false; diff --git a/src/ui/filter-bar.ts b/src/ui/filter-bar.ts index 5c4ebd6..6f69e03 100644 --- a/src/ui/filter-bar.ts +++ b/src/ui/filter-bar.ts @@ -293,6 +293,10 @@ export const FILTER_DEBOUNCE_MS = 500; * focus state, neither of which a status flip should ever disturb. */ export interface FilterBarHandle { el: HTMLElement; + /** Separately mountable compound time-range region. */ + timeEl: HTMLElement; + /** Separately mountable ordinary-filter region. */ + ordinaryEl: HTMLElement; dispose(): void; updateStatus(states: Record): void; /** #189, #189-F2b, GENERALIZED (#335): the opaque KEY of a popover-bearing @@ -370,8 +374,11 @@ export function buildFilterBar( const attrs: Record = { class: 'dash-filters' }; if (options.ariaLabel) { attrs.role = 'group'; attrs['aria-label'] = options.ariaLabel; } if (!params.length) { + const timeEl = h('div', { class: 'dash-filter-time', style: { display: 'none' } }); + const ordinaryEl = h('div', { class: 'dash-filter-ordinary', style: { display: 'none' } }); return { - el: h('div', { ...attrs, style: { display: 'none' } }), + el: h('div', { ...attrs, style: { display: 'none' } }, timeEl, ordinaryEl), + timeEl, ordinaryEl, dispose: () => {}, updateStatus: () => {}, openPopoverKey: () => null, focusedFieldKey: () => null, focusFieldTrigger: () => {}, refreshTimeRangeLabels: () => {}, @@ -600,12 +607,18 @@ export function buildFilterBar( // per-param fields. With no time-range groups `timeSection` is empty and no // "Filters" label renders, so the child list is byte-identical to the // pre-#335 `...params.map(...)` output. - const children: (HTMLElement | null)[] = [...timeSection]; - if (timeRange.length && perParamFields.length) children.push(h('span', { class: 'flabel' }, 'Filters')); - children.push(...perParamFields); - const el = h('div', attrs, ...children); + const timeEl = h('div', { + class: 'dash-filter-time', + style: timeRange.length ? undefined : { display: 'none' }, + }, ...timeSection); + const ordinaryEl = h('div', { + class: 'dash-filter-ordinary', + style: perParamFields.length ? undefined : { display: 'none' }, + }, timeRange.length && perParamFields.length ? h('span', { class: 'flabel' }, 'Filters') : null, + ...perParamFields); + const el = h('div', attrs, timeEl, ordinaryEl); return { - el, + el, timeEl, ordinaryEl, dispose: () => { timerClears.forEach((clear) => clear()); // Disposing a control WHILE its popover is open is that control's own diff --git a/tests/e2e/dashboard-mobile.html b/tests/e2e/dashboard-mobile.html index 11c090c..1b4987f 100644 --- a/tests/e2e/dashboard-mobile.html +++ b/tests/e2e/dashboard-mobile.html @@ -17,11 +17,13 @@
-
-
+
+
+
+
-
Empty toolbar (no filters)
+
Panel one
Panel two
@@ -43,8 +45,8 @@ dashboardFileButton.className = 'hd-file-btn dash-file-btn'; dashboardFileButton.textContent = 'File'; const tileCount = document.createElement('span'); - tileCount.className = 'dash-chip dash-fav'; - tileCount.textContent = '★ 6 tiles'; + tileCount.className = 'dash-chip dash-tile-count'; + tileCount.textContent = '6 tiles'; const styleWrap = document.createElement('div'); styleWrap.className = 'dash-layout-wrap'; const styleButton = document.createElement('button'); @@ -130,18 +132,34 @@ }; document.querySelector('#shared-header').replaceWith( buildAppHeader(headerApp, { - dashboardControls: { - tileCount, - fileButton: dashboardFileButton, - style: styleWrap, - title: dashboardTitle, - updated, - refresh: refreshControl, - }, + fileButton: dashboardFileButton, + workspaceTitleEditable: true, }), ); + const modeSwitch = document.createElement('div'); + modeSwitch.className = 'editor-mode-switch dashboard-mode-switch'; + modeSwitch.setAttribute('role', 'group'); + modeSwitch.setAttribute('aria-label', 'Dashboard mode'); + for (const label of ['View', 'Edit']) { + const button = document.createElement('button'); + button.className = 'editor-mode-btn' + (label === 'Edit' ? ' active' : ''); + button.textContent = label; + modeSwitch.append(button); + } + const tileSearch = document.createElement('input'); + tileSearch.className = 'dash-tile-search'; + tileSearch.placeholder = 'Search tiles'; + const timeHost = document.createElement('div'); + timeHost.className = 'dash-time-filter-host dash-filters'; + timeHost.setAttribute('role', 'group'); + timeHost.setAttribute('aria-label', 'Dashboard time filters'); + document.querySelector('#primary-toolbar').append( + styleWrap, tileCount, tileSearch, timeHost, + Object.assign(document.createElement('span'), { className: 'dash-toolbar-spacer' }), + updated, refreshControl, modeSwitch, + ); - const filters = document.querySelector('.dash-filters'); + const filters = document.querySelector('.dash-filter-ordinary'); // 'version' is the one optional field here — real coverage for the bold // (required) vs muted (optional) `.var-name` contrast (2026-07-18: replaced // the leading "*" glyph). @@ -200,9 +218,10 @@ // append style as every other field in this hand-built harness, and it // leaves the existing fields' positions (region first) untouched so the // pre-#335 layout assertions keep measuring exactly what they did before. - filters.append(trHeading); - filters.append(trField.el); - filters.append(trSep); + const timeRegion = document.createElement('div'); + timeRegion.className = 'dash-filter-time'; + timeRegion.append(trHeading, trField.el, trSep); + timeHost.append(timeRegion); window.__prefs = { dashLayout: 'report', dashCols: 3 }; localStorage.setItem('asb:dashLayout', 'report'); diff --git a/tests/e2e/dashboard-mobile.spec.js b/tests/e2e/dashboard-mobile.spec.js index f9a1121..71a6fb5 100644 --- a/tests/e2e/dashboard-mobile.spec.js +++ b/tests/e2e/dashboard-mobile.spec.js @@ -16,9 +16,7 @@ test.describe('Dashboard mobile layout', () => { await expect(page.getByRole('button', { name: 'Toggle theme' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Refresh dashboard' })).toBeVisible(); await expect(page.locator('.dash-refresh-label')).toHaveCount(0); - for (const selector of ['.dash-fav', '.dash-updated', '.dash-layout-wrap']) { - await expect(page.locator(selector)).toBeHidden(); - } + await expect(page.locator('.dash-layout-wrap')).toBeVisible(); const geometry = await header.evaluate((node) => { const visible = [...node.children].filter((child) => getComputedStyle(child).display !== 'none'); @@ -40,19 +38,19 @@ test.describe('Dashboard mobile layout', () => { expect(await page.evaluate(() => window.__refreshCount)).toBe(1); }); - test('keeps title and actions reachable without viewport overflow at 360px', async ({ page }) => { + test('keeps primary tools reachable through one-row horizontal scrolling at 360px', async ({ page }) => { await openAt(page, 360, 800); - const result = await page.locator('.dashboard-app-header').evaluate((header) => { - const title = header.querySelector('.dash-title').getBoundingClientRect(); - const refresh = header.querySelector('.dash-refresh').getBoundingClientRect(); + const result = await page.locator('.dash-toolbar-primary').evaluate((toolbar) => { + const children = [...toolbar.children]; + const tops = children.map((child) => child.getBoundingClientRect().top); return { - wraps: Math.abs((title.top + title.height / 2) - (refresh.top + refresh.height / 2)) > 2, - titleBeforeActions: title.right <= refresh.left, - actionsInside: refresh.right <= innerWidth, + oneRow: Math.max(...tops) - Math.min(...tops) < 2, + scrolls: toolbar.scrollWidth > toolbar.clientWidth, + overflowX: getComputedStyle(toolbar).overflowX, pageOverflow: document.documentElement.scrollWidth - innerWidth, }; }); - expect(result).toEqual({ wraps: false, titleBeforeActions: true, actionsInside: true, pageOverflow: 0 }); + expect(result).toEqual({ oneRow: true, scrolls: true, overflowX: 'auto', pageOverflow: 0 }); }); test('keeps every visible Dashboard header control reachable just above the mobile breakpoint', async ({ page }) => { @@ -117,7 +115,6 @@ test.describe('Dashboard mobile layout', () => { test('scrolls filters in one row while fixed combobox content escapes clipping', async ({ page }) => { await openAt(page, 390); const scroll = page.locator('.dash-filter-host'); - const filters = page.locator('.dash-filters'); const before = await scroll.evaluate((node) => ({ clientWidth: node.clientWidth, scrollWidth: node.scrollWidth, @@ -129,7 +126,7 @@ test.describe('Dashboard mobile layout', () => { expect(Math.max(...before.fieldTops) - Math.min(...before.fieldTops)).toBeLessThan(2); expect(Math.min(...before.fieldWidths)).toBeGreaterThan(150); expect(before.overflowX).toBe('auto'); - expect(await filters.evaluate((node) => getComputedStyle(node).flexWrap)).toBe('nowrap'); + expect(await scroll.evaluate((node) => getComputedStyle(node).flexWrap)).toBe('nowrap'); await scroll.evaluate((node) => { node.scrollLeft = node.scrollWidth; }); expect(await scroll.evaluate((node) => node.scrollLeft)).toBeGreaterThan(0); @@ -161,19 +158,14 @@ test.describe('Dashboard mobile layout', () => { await openAt(page, 360, 800); await expect(page.locator('.trf-trigger')).toBeVisible(); // The "Time" section label sits in the same filter row, ahead of the fields. - await expect(page.locator('.dash-filters .flabel', { hasText: 'Time' })).toBeVisible(); + await expect(page.locator('.dash-time-filter-host .flabel', { hasText: 'Time' })).toBeVisible(); - const result = await page.locator('.dash-filter-host').evaluate((host) => { + const result = await page.locator('.dash-time-filter-host').evaluate((host) => { const field = host.querySelector('.var-field.is-time-range'); const trigger = host.querySelector('.trf-trigger'); - const tops = [...host.querySelectorAll('.var-field')].map((f) => f.getBoundingClientRect().top); return { hasField: !!field, triggerText: trigger.textContent, - overflowX: getComputedStyle(host).overflowX, - flexWrap: getComputedStyle(host.querySelector('.dash-filters')).flexWrap, - scrolls: host.scrollWidth > host.clientWidth, - topsAligned: Math.max(...tops) - Math.min(...tops) < 2, fieldWidth: field.getBoundingClientRect().width, pageOverflow: document.documentElement.scrollWidth - innerWidth, }; @@ -183,10 +175,6 @@ test.describe('Dashboard mobile layout', () => { expect(result.triggerText).toContain('→'); // The row scrolls (never wraps, never clips the page) — the compound // control's wide trigger stays on the single field row with the others. - expect(result.overflowX).toBe('auto'); - expect(result.flexWrap).toBe('nowrap'); - expect(result.scrolls).toBe(true); - expect(result.topsAligned).toBe(true); expect(result.fieldWidth).toBeGreaterThan(150); expect(result.pageOverflow).toBeLessThanOrEqual(0); }); @@ -194,16 +182,13 @@ test.describe('Dashboard mobile layout', () => { test('keeps the time-range control on the field row without viewport overflow in landscape (~780px) (#335)', async ({ page }) => { await openAt(page, 780, 420); await expect(page.locator('.trf-trigger')).toBeVisible(); - const result = await page.locator('.dash-filter-host').evaluate((host) => { + const result = await page.locator('.dash-time-filter-host').evaluate((host) => { const trigger = host.querySelector('.trf-trigger').getBoundingClientRect(); - const tops = [...host.querySelectorAll('.var-field')].map((f) => f.getBoundingClientRect().top); return { - triggerOnRow: Math.max(...tops) - Math.min(...tops) < 2, triggerWithinRow: trigger.top >= host.getBoundingClientRect().top - 1, pageOverflow: document.documentElement.scrollWidth - innerWidth, }; }); - expect(result.triggerOnRow).toBe(true); expect(result.triggerWithinRow).toBe(true); expect(result.pageOverflow).toBeLessThanOrEqual(0); }); @@ -217,7 +202,7 @@ test.describe('Dashboard mobile layout', () => { test('marks a required filter name bold instead of a leading asterisk; an optional name stays muted (2026-07-18)', async ({ page }) => { await openAt(page, 1100, 800); - const names = await page.locator('.dash-filters .var-name').evaluateAll((nodes) => nodes.map((node) => ({ + const names = await page.locator('.dash-filter-host .var-name').evaluateAll((nodes) => nodes.map((node) => ({ // The old convention prepended a literal "*" via `::after { content }` — // never part of `textContent` even before this change — so the real // regression check is the CSS-generated content string itself, not the @@ -235,9 +220,9 @@ test.describe('Dashboard mobile layout', () => { for (const n of optional) expect(Number(n.fontWeight)).toBeLessThan(700); }); - test('desktop: filters stay on one row and scroll horizontally, no Clear-all or count control exists (#294)', async ({ page }) => { + test('desktop: filters stay on one row and Clear all remains reachable', async ({ page }) => { await openAt(page, 1100, 800); - await expect(page.locator('.dash-filter-clear-all')).toHaveCount(0); + await expect(page.locator('.dash-clear-filters')).toBeVisible(); await expect(page.locator('.dash-filter-count')).toHaveCount(0); await expect(page.locator('.dash-filter-count-host')).toHaveCount(0); @@ -246,11 +231,10 @@ test.describe('Dashboard mobile layout', () => { const layout = await page.evaluate(() => { const host = document.querySelector('.dash-filter-host'); - const filters = document.querySelector('.dash-filters'); - const fields = [...filters.querySelectorAll('.var-field')]; + const fields = [...host.querySelectorAll('.var-field')]; return { toolbarWrap: getComputedStyle(document.querySelector('.dash-toolbar')).flexWrap, - filtersWrap: getComputedStyle(filters).flexWrap, + filtersWrap: getComputedStyle(host).flexWrap, scrollWidth: host.scrollWidth, clientWidth: host.clientWidth, fieldTops: fields.map((field) => field.getBoundingClientRect().top), @@ -277,11 +261,11 @@ test.describe('Dashboard mobile layout', () => { await page.locator('.dash-filter-host').evaluate((node) => { node.scrollLeft = node.scrollWidth; }); const after = await toolbar.evaluate((node) => node.getBoundingClientRect().height); expect(after).toBe(before); - const lastField = page.locator('.dash-filters .var-field').last(); + const lastField = page.locator('.dash-filter-host .var-field').last(); await expect(lastField).toBeInViewport(); }); - test('the File-style layout picker follows File in the one-row Dashboard header', async ({ page }) => { + test('the File-style layout picker leads the Dashboard primary tool row', async ({ page }) => { await openAt(page, 1100, 800); const style = page.locator('.dash-style-btn'); await expect(style).toBeVisible(); @@ -300,17 +284,16 @@ test.describe('Dashboard mobile layout', () => { expect(controlSizes.refresh).toEqual(controlSizes.edit); const geometry = await page.evaluate(() => { - const header = document.querySelector('.dashboard-app-header'); - const children = [...header.children]; - const tileCountIndex = children.findIndex((c) => c.classList.contains('dash-fav')); - const fileIndex = children.findIndex((c) => c.classList.contains('dash-file-btn')); + const toolbar = document.querySelector('.dash-toolbar-primary'); + const children = [...toolbar.children]; + const tileCountIndex = children.findIndex((c) => c.classList.contains('dash-tile-count')); const layoutWrapIndex = children.findIndex((c) => c.classList.contains('dash-layout-wrap')); const layoutWrap = children[layoutWrapIndex]; const tileCount = children[tileCountIndex]; return { - tileCountBeforeFile: fileIndex === tileCountIndex + 1, - styleRightAfterFile: layoutWrapIndex === fileIndex + 1, - inHeaderNotToolbar: !document.querySelector('.dash-toolbar .dash-layout-wrap'), + styleFirst: layoutWrapIndex === 0, + tileCountSecond: tileCountIndex === 1, + inToolbarNotHeader: !document.querySelector('.dashboard-app-header .dash-layout-wrap'), sameRow: Math.abs( (layoutWrap.getBoundingClientRect().top + layoutWrap.getBoundingClientRect().height / 2) - (tileCount.getBoundingClientRect().top + tileCount.getBoundingClientRect().height / 2), @@ -318,8 +301,8 @@ test.describe('Dashboard mobile layout', () => { }; }); expect(geometry).toEqual({ - tileCountBeforeFile: true, styleRightAfterFile: true, - inHeaderNotToolbar: true, sameRow: true, + styleFirst: true, tileCountSecond: true, + inToolbarNotHeader: true, sameRow: true, }); await style.click(); diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index 2b50816..0983eae 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -967,6 +967,9 @@ describe('renderApp shell', () => { expect(qs(app.root, '.logo-name').textContent).toBe('Altinity®'); expect(qsa(app.root, '.app-surface-switch .editor-mode-btn').map((button) => button.textContent)) .toEqual(['SQL Browser', 'Dashboard']); + expect(qsa(app.root, '.app-surface-switch svg')).toHaveLength(0); + expect(qsa(app.root, '.app-surface-switch .editor-mode-btn').map((button) => button.getAttribute('aria-label'))) + .toEqual(['SQL Browser', 'Dashboard']); expect(qs(app.root, '.dashboard-mode-switch')).toBeNull(); app.navigateSqlRoute = vi.fn(async () => {}); qsa(app.root, '.app-surface-switch .editor-mode-btn') @@ -985,8 +988,8 @@ describe('renderApp shell', () => { const app = createApp(env()); app.state.serverVersion = '26.7.1.42'; app.renderApp(); - expect(qs(app.root, '.conn-status').textContent).toBe('ClickHouse 26.7.1'); - expect(qs(app.root, '.conn-status').getAttribute('title')).toBe('ClickHouse 26.7.1.42'); + expect(qs(app.root, '.conn-status').textContent).toBe('ch.example·CH 26.7.1'); + expect(qs(app.root, '.conn-status').getAttribute('title')).toBe('ch.example · ClickHouse 26.7.1.42'); }); it('toggles theme via the header button', () => { const { app } = rendered(); diff --git a/tests/unit/dashboard-viewer-session.test.ts b/tests/unit/dashboard-viewer-session.test.ts index 1889daa..a72bc00 100644 --- a/tests/unit/dashboard-viewer-session.test.ts +++ b/tests/unit/dashboard-viewer-session.test.ts @@ -3,7 +3,7 @@ import { createDashboardViewerSession, VIEWER_TILE_CONCURRENCY, } from '../../src/dashboard/application/dashboard-viewer-session.js'; import type { - DashboardViewerDeps, ViewerExecutor, ViewerReadRequest, + DashboardLayoutView, DashboardViewerDeps, ViewerExecutor, ViewerReadRequest, } from '../../src/dashboard/application/dashboard-viewer-session.js'; import type { DashboardDocumentV1, DashboardFilterDefinitionV1, DashboardTileV1, SavedQueryV2, @@ -456,6 +456,117 @@ describe('filters and the #235 execution planner', () => { expect(calls.length).toBe(base2); }); + it('tile search matches normalized title/description, repacks both layouts, and never executes again', async () => { + const { exec, calls } = makeExec(); + const document = doc({ + tiles: [ + tile('a', 'qa', { title: 'Revenue Overview' }), + tile('b', 'qb', { description: 'Latency by region' }), + tile('c', 'qc'), + tile('d', 'qd', { title: '', description: '' }), + ], + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, + queries: [ + query('qa', 'SELECT 1'), query('qb', 'SELECT 2'), + query('qc', 'SELECT 3', { description: 'Capacity forecast' }), + query('qd', 'SELECT 4', { name: 'Fallback title', description: 'Fallback description' }), + ], + })); + await session.start(); + const executed = calls.length; + session.setTileSearch(' revenue overview '); + expect(session.state.value.tiles.map((entry) => entry.tileId)).toEqual(['a']); + expect(session.state.value).toMatchObject({ + totalTileCount: 4, visibleTileCount: 1, tileSearch: ' revenue overview ', + }); + expect(session.state.value.tiles[0].description).toBe(''); + if (session.state.value.layout.engine !== 'flow') throw new Error('expected flow'); + expect(session.state.value.layout.rows.flatMap((row) => row.tiles).map((entry) => entry.tileId)).toEqual(['a']); + + session.setTileSearch('capacity'); + expect(session.state.value.tiles.map((entry) => entry.tileId)).toEqual(['c']); + expect(session.state.value.tiles[0].description).toBe('Capacity forecast'); + session.setTileSearch('fallback description'); + expect(session.state.value.tiles.map((entry) => entry.tileId)).toEqual(['d']); + expect(session.state.value.tiles[0]).toMatchObject({ + title: 'Fallback title', description: 'Fallback description', + }); + session.setTileSearch('capacity'); + session.syncDocument({ + ...document, + layout: { type: 'grafana-grid', version: 1, items: { c: { span: 4, height: 2 } } }, + }); + const gridLayout = session.state.value.layout as DashboardLayoutView; + if (gridLayout.engine !== 'grafana-grid') throw new Error('expected grid'); + expect(gridLayout.grid.tiles.map((entry) => entry.tileId)).toEqual(['c']); + expect(calls.length).toBe(executed); + + session.setTileSearch('missing'); + expect(session.state.value.visibleTileCount).toBe(0); + session.setTileSearch(''); + expect(session.state.value.tiles.map((entry) => entry.tileId)).toEqual(['a', 'b', 'c', 'd']); + session.setTileSearch(''); // identical search is a no-op + }); + + it('Clear all deactivates an inferred-active default and compares authored defaults in UI string form', async () => { + const { exec, calls } = makeExec(); + const session = createDashboardViewerSession(makeDeps({ + document: doc({ + tiles: [tile('a', 'qa')], + filters: [{ id: 'n', parameter: 'n', defaultValue: 5 }], + }), + exec, queries: [query('qa', 'SELECT {n:UInt8}')], + })); + await session.start(); + expect(session.state.value.filters.find((filter) => filter.id === 'n')) + .toMatchObject({ active: true, value: '5' }); + expect(session.state.value.resettableFilterIds).toEqual(['n']); + const before = calls.length; + await session.clearAllFilters(); + expect(session.state.value.filters.find((filter) => filter.id === 'n')) + .toMatchObject({ active: false, value: '5' }); + expect(session.state.value.resettableFilterIds).toEqual([]); + expect(calls.length).toBeGreaterThan(before); + + await session.applyFilter('n', '5', false); + expect(session.state.value.resettableFilterIds).toEqual([]); + }); + + it('resetFilters restores only selected defaults in one wave and no-ops when unchanged or destroyed', async () => { + const { exec, calls } = makeExec(); + const session = createDashboardViewerSession(makeDeps({ + document: doc({ + tiles: [tile('a', 'qa'), tile('b', 'qb')], + filters: [ + { id: 'time', parameter: 'from', defaultActive: true, defaultValue: '-1d' }, + { id: 'region', parameter: 'region', defaultActive: true, defaultValue: 'all' }, + ], + }), + exec, + queries: [ + query('qa', 'SELECT {from:String}'), query('qb', 'SELECT {region:String}'), + ], + })); + await session.start(); + await session.setFilter('time', '-7d'); + await session.setFilter('region', 'west'); + expect(session.state.value.resettableFilterIds).toEqual(['time', 'region']); + const before = calls.length; + await session.resetFilters(['region', 'unknown']); + expect(calls.length - before).toBe(1); + expect(session.state.value.filters.map((filter) => filter.value)).toEqual(['-7d', 'all']); + expect(session.state.value.resettableFilterIds).toEqual(['time']); + const unchanged = calls.length; + await session.resetFilters(['region']); + expect(calls.length).toBe(unchanged); + session.destroy(); + await session.resetFilters(['time']); + session.setTileSearch('ignored'); + expect(session.state.value.tileSearch).toBe(''); + }); + }); // #359: the filter-source runtime split — N filter DEFINITIONS sharing one diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index 9559dff..d987bb6 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -406,11 +406,11 @@ describe('renderDashboard — read-flip to dashboard.tiles (#286)', () => { }); await render(app); expect(qsa(app.root, '.dash-tile').length).toBe(2); - expect(qs(app.root, '.dash-fav span:last-child')?.textContent).toBe('2 tiles'); + expect(qs(app.root, '.dash-tile-count')?.textContent).toBe('2 tiles'); const sqls = calls.map((c) => c.sql); expect(sqls).toContain('SELECT k, v FROM a'); expect(sqls).toContain('SELECT k, v FROM b'); - expect(qs(app.root, '.dash-title')?.textContent).toBe('My Dash'); + expect(qs(app.root, '.lib-name-text')?.textContent).toBe('W'); expect(qsa(app.root, '.dash-tile canvas').length).toBeGreaterThan(0); }); @@ -418,13 +418,70 @@ describe('renderDashboard — read-flip to dashboard.tiles (#286)', () => { const { app } = dashApp({ workspace: wsWith({ tiles: [] }) }); await render(app); expect((qs(app.root, '.dash-empty') as HTMLElement).style.display).toBe(''); - expect(qs(app.root, '.dash-fav span:last-child')?.textContent).toBe('0 tiles'); + expect(qs(app.root, '.dash-tile-count')?.textContent).toBe('0 tiles'); }); it('uses the library name when the dashboard title is empty', async () => { const { app } = dashApp({ workspace: wsWith({ title: '', tiles: [] }) }); await render(app); - expect(qs(app.root, '.dash-title')?.textContent).toBe('W'); + expect(qs(app.root, '.lib-name-text')?.textContent).toBe('W'); + }); + + it('searches tile titles and descriptions without rerunning queries, shows counts, and recovers from no match', async () => { + const { app, calls } = dashApp({ + workspace: wsWith({ + queries: [ + q('q1', 'SELECT 1', { name: 'Revenue', description: 'Monthly sales' }), + q('q2', 'SELECT 2', { name: 'Latency', description: 'Regional p95' }), + ], + tiles: [{ id: 't1', queryId: 'q1' }, { id: 't2', queryId: 'q2' }], + }), + }); + await render(app); + const executed = calls.length; + const search = qs(app.root, '.dash-tile-search'); + vi.useFakeTimers(); + search.value = 'reg'; + search.dispatchEvent(new Event('input', { bubbles: true })); + search.value = 'regional'; + search.dispatchEvent(new Event('input', { bubbles: true })); + vi.advanceTimersByTime(500); + vi.useRealTimers(); + expect(qsa(app.root, '.dash-tile')).toHaveLength(1); + expect(qs(app.root, '.dash-tile-name').textContent).toBe('Latency'); + expect(qs(app.root, '.dash-tile-desc').textContent).toBe('Regional p95'); + expect(qs(app.root, '.dash-tile-count').textContent).toBe('1 of 2 tiles'); + expect(calls.length).toBe(executed); + + search.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + search.value = 'not here'; + search.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(qs(app.root, '.dash-search-empty').style.display).toBe(''); + + vi.useFakeTimers(); + search.value = 'pending search'; + search.dispatchEvent(new Event('input', { bubbles: true })); + qs(app.root, '.dash-search-empty button').click(); + vi.useRealTimers(); + expect(search.value).toBe(''); + expect(qsa(app.root, '.dash-tile')).toHaveLength(2); + expect(calls.length).toBe(executed); + + // Search filters presentation only. A later layout commit must still see + // every session runtime, including the currently non-matching tile. + search.value = 'regional'; + search.dispatchEvent(new Event('input', { bubbles: true })); + search.dispatchEvent(new Event('blur')); + const rerender = vi.spyOn(app, 'renderDashboard'); + pickLayout(app.root, 'columns-3'); + await flush(); + expect(rerender).not.toHaveBeenCalled(); + expect(qsa(app.root, '.dash-tile')).toHaveLength(1); + + // A route teardown cancels a still-pending search callback. + search.value = 'pending teardown'; + search.dispatchEvent(new Event('input', { bubbles: true })); + await render(app); }); it('falls back to an empty dashboard when no workspace resolves', async () => { @@ -2768,7 +2825,7 @@ describe('renderDashboard — shared rich filter bar over the viewer (#188)', () expect(added[0].params.param_p).toBe('x'); }); - it('shows no visible Clear-all control or "N active" count at any active count (#294; count removed by a 2026-07-18 owner override)', async () => { + it('shows ordinary-filter Clear all and enables it only when a filter differs from its default', async () => { const { app } = dashApp({ workspace: wsWith({ queries: [q('q1', 'SELECT k, v FROM a WHERE n = {n:UInt8}')], @@ -2777,9 +2834,19 @@ describe('renderDashboard — shared rich filter bar over the viewer (#188)', () }), }); await render(app); - // `DashboardViewerSession.clearAllFilters()`/`activeFilterCount` stay - // tested application-level operations/state with no UI trigger or display. - expect(qs(app.root, '.dash-filter-clear-all')).toBeNull(); + const clear = qs(app.root, '.dash-clear-filters'); + expect(clear).not.toBeNull(); + expect(clear.disabled).toBe(true); + const input = qs(app.root, '.dash-filter-host input'); + input.value = '7'; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('blur')); + await flush(); + expect(clear.disabled).toBe(false); + clear.click(); + await flush(); + expect(clear.disabled).toBe(true); + expect(qs(app.root, '.dash-filter-host input').value).toBe('5'); expect(qs(app.root, '.dash-filter-count')).toBeNull(); expect(qs(app.root, '.dash-filter-count-host')).toBeNull(); }); @@ -2794,7 +2861,7 @@ describe('renderDashboard — shared rich filter bar over the viewer (#188)', () }); await render(app); const host = qs(app.root, '.dash-filter-host'); - expect(host.contains(qs(host, '.dash-filters'))).toBe(true); + expect(host.contains(qs(host, '.dash-filter-ordinary'))).toBe(true); }); it('renders no per-filter "required/invalid" badge (owner decision — dropped as noise)', async () => { @@ -3104,7 +3171,9 @@ describe('renderDashboard — compound time-range control (#335)', () => { }); await render(app); expect(qs(app.root, '.trf-trigger')).not.toBeNull(); - expect(qsa(app.root, '.dash-filter-host .flabel').map((n) => n.textContent)).toEqual(['Time', 'Filters']); + expect(qs(app.root, '.dash-time-filter-host .trf-trigger')).not.toBeNull(); + expect(qsa(app.root, '.dash-filters .flabel').map((node) => node.textContent)) + .toEqual(['Time', 'Filters']); // The pair's own two fields are gone; only the non-group field remains. const names = qsa(app.root, '.dash-filter-host .var-field:not(.is-time-range) .var-name').map((n) => n.textContent); expect(names).toEqual(['region']); @@ -3922,9 +3991,13 @@ describe('renderDashboard — unified live modes (#407)', () => { const viewed = modeApp({ workspace: empty, mode: 'view' }); await render(viewed.app); expect(viewed.app.root?.textContent).toContain('This workspace has no dashboard'); + expect(qsa(viewed.app.root, '.dashboard-mode-switch .editor-mode-btn').map((button) => button.textContent)) + .toEqual(['View', 'Edit']); expect(viewed.calls).toHaveLength(0); const edited = modeApp({ workspace: empty, mode: 'edit' }); await render(edited.app); + expect(qsa(edited.app.root, '.dashboard-mode-switch .editor-mode-btn').map((button) => button.textContent)) + .toEqual(['View', 'Edit']); const create = qs(edited.app.root, '.dash-create'); expect(edited.commit).not.toHaveBeenCalled(); create.click(); @@ -3998,13 +4071,13 @@ describe('renderDashboard — unified live modes (#407)', () => { expect(qs(header, '.logo-name').textContent).toBe('Altinity®'); expect(qsa(header, '.app-surface-switch .editor-mode-btn').map((button) => button.textContent)) .toEqual(['SQL Browser', 'Dashboard']); - expect(qsa(header, '.dashboard-mode-switch .editor-mode-btn').map((button) => button.textContent)) + expect(qsa(app.root, '.dashboard-mode-switch .editor-mode-btn').map((button) => button.textContent)) .toEqual(['View', 'Edit']); - expect(qs(header, '.dash-title').textContent).toBe('My Dash'); - expect(qs(header, '.dash-fav')).not.toBeNull(); + expect(qs(header, '.lib-name-text').textContent).toBe('W'); + expect(qs(app.root, '.dash-tile-count')).not.toBeNull(); expect(qs(header, '.dash-file-btn')).not.toBeNull(); - expect(qs(header, '.dash-layout-wrap')).not.toBeNull(); - const style = qs(header, '.dash-style-btn'); + expect(qs(app.root, '.dash-layout-wrap')).not.toBeNull(); + const style = qs(app.root, '.dash-style-btn'); expect(style.classList.contains('hd-file-btn')).toBe(true); expect(style.textContent).toBe('2 columns'); expect(header.textContent).not.toContain('Style'); @@ -4015,14 +4088,14 @@ describe('renderDashboard — unified live modes (#407)', () => { expect(qsa(styleMenu, '.fm-item .fm-label').map((item) => item.textContent)) .toEqual(['Grid Tiles', 'Full view', 'Report', '2 columns', '3 columns']); style.click(); - expect(qs(header, '.dash-updated')).not.toBeNull(); - const refresh = qs(header, '.dash-refresh'); + expect(qs(app.root, '.dash-updated')).not.toBeNull(); + const refresh = qs(app.root, '.dash-refresh'); expect(refresh.classList.contains('editor-mode-btn')).toBe(true); expect(refresh.parentElement?.classList.contains('editor-mode-switch')).toBe(true); - expect(qs(header, '.conn-status')).toBeNull(); - expect(qs(header, '[title="View examples"]')).toBeNull(); - expect(qs(header, '[title^="Keyboard shortcuts"]')).toBeNull(); - expect(qs(header, '.user-btn')).toBeNull(); + expect(qs(header, '.conn-status')).not.toBeNull(); + expect(qs(header, '[title="View examples"]')).not.toBeNull(); + expect(qs(header, '[title^="Keyboard shortcuts"]')).not.toBeNull(); + expect(qs(header, '.user-btn')).not.toBeNull(); expect(qs(app.root, '.dash-contextbar')).toBeNull(); }); @@ -4123,13 +4196,13 @@ describe('renderDashboard — Dashboard header File menu (#302)', () => { expect(labels).not.toContain('Import Dashboard…'); }); - it('live view shows the Dashboard name without exposing workspace Rename', async () => { + it('live view shows the workspace name without exposing Rename', async () => { const workspace = wsWith({ id: 'd' }); const { app } = modeApp({ workspace, mode: 'view' }); app.state.libraryName.value = 'Operations'; await render(app); - expect(qs(app.root, '.dash-title').textContent).toBe('My Dash'); - expect(qs(app.root, '.lib-title')).toBeNull(); + expect(qs(app.root, '.lib-name-text').textContent).toBe('Operations'); + expect(qs(app.root, '.lib-title')).not.toBeNull(); expect(qs(app.root, 'button.lib-name')).toBeNull(); }); diff --git a/tests/unit/filter-bar.test.ts b/tests/unit/filter-bar.test.ts index a60cf6e..3c06afb 100644 --- a/tests/unit/filter-bar.test.ts +++ b/tests/unit/filter-bar.test.ts @@ -613,18 +613,17 @@ describe('buildFilterBar (shared filter row)', () => { it('renders a "Time" section (label + control + separator) AHEAD of the fields, suppresses the pair, and labels the rest "Filters"', () => { const app = makeApp(); const bar = buildFilterBar(app, groupParams, () => {}, okField, { timeRange: [trEntry()] }); - // Two section labels, in order: Time then Filters. + 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', 'Filters']); - // Exactly one compound control, one separator. + // The combined form retains its section separator. expect(bar.el.querySelectorAll('.var-field.is-time-range').length).toBe(1); expect(bar.el.querySelector('.trf-trigger')).not.toBeNull(); expect(bar.el.querySelectorAll('.trf-sep').length).toBe(1); // The pair's own two individual fields are gone; only the non-group field remains. - const names = [...bar.el.querySelectorAll('.dash-filters > .var-field:not(.is-time-range) .var-name')].map((n) => n.textContent); + const names = [...bar.ordinaryEl.querySelectorAll('.var-field:not(.is-time-range) .var-name')].map((n) => n.textContent); expect(names).toEqual(['region']); - // DOM order: Time label, control, separator, Filters label, region field. - const order = [...bar.el.children].map((c) => c.className.split(' ')[0] + (c.classList.contains('flabel') ? ':' + c.textContent : '')); - expect(order).toEqual(['flabel:Time', 'var-field', 'trf-sep', 'flabel:Filters', 'var-field']); + expect([...bar.el.children]).toEqual([bar.timeEl, bar.ordinaryEl]); }); it('omits the "Filters" label when every remaining param is grouped (no non-group field left)', () => { From 67f7f548974b25c0b3dc8ebdfe549eb818f9d942 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 23 Jul 2026 20:18:47 +0000 Subject: [PATCH 2/8] test(e2e): ignore hidden dashboard toolbar controls --- tests/e2e/dashboard-mobile.spec.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/e2e/dashboard-mobile.spec.js b/tests/e2e/dashboard-mobile.spec.js index 71a6fb5..193e24b 100644 --- a/tests/e2e/dashboard-mobile.spec.js +++ b/tests/e2e/dashboard-mobile.spec.js @@ -41,10 +41,13 @@ test.describe('Dashboard mobile layout', () => { test('keeps primary tools reachable through one-row horizontal scrolling at 360px', async ({ page }) => { await openAt(page, 360, 800); const result = await page.locator('.dash-toolbar-primary').evaluate((toolbar) => { - const children = [...toolbar.children]; - const tops = children.map((child) => child.getBoundingClientRect().top); + const children = [...toolbar.children].filter((child) => getComputedStyle(child).display !== 'none'); + const centers = children.map((child) => { + const rect = child.getBoundingClientRect(); + return rect.top + rect.height / 2; + }); return { - oneRow: Math.max(...tops) - Math.min(...tops) < 2, + oneRow: Math.max(...centers) - Math.min(...centers) < 2, scrolls: toolbar.scrollWidth > toolbar.clientWidth, overflowX: getComputedStyle(toolbar).overflowX, pageOverflow: document.documentElement.scrollWidth - innerWidth, From 45430534b0d82f3f69b28349027a6d076c0f9ccd Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 23 Jul 2026 20:22:58 +0000 Subject: [PATCH 3/8] fix: address dashboard review blockers --- CHANGELOG.md | 5 ++++ .../application/dashboard-viewer-session.ts | 8 ++--- src/ui/app.ts | 12 ++++---- src/ui/dashboard.ts | 4 +-- tests/unit/app.test.ts | 4 +++ tests/unit/dashboard-viewer-session.test.ts | 10 +++---- tests/unit/dashboard.test.ts | 30 +++++++++++++------ 7 files changed, 47 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0711a61..0cd21ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] +### Fixed +- Keep the shared connection host visible after the server-version probe, + restore Dashboard filters to their initial default-derived active state, and + retain Dashboard time-range announcements when no ordinary filters exist. + ### Added - **Unified SQL Browser and Dashboard chrome.** Both surfaces now share the same brand, surface, workspace, connection, examples, shortcuts, theme, and diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts index 946147f..7412ffb 100644 --- a/src/dashboard/application/dashboard-viewer-session.ts +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -309,7 +309,7 @@ export interface DashboardViewerSession { /** Deactivate one filter WITHOUT discarding its value (reactivation restores * it); one affected-panel wave (#188 clear-one). */ clearFilter(filterId: string): Promise; - /** Reset every filter to its `defaultActive`/`defaultValue`, coalesced into + /** Restore every filter to its initial default-derived state, coalesced into * ONE affected-panel wave (#188 clear-all). */ clearAllFilters(): Promise; /** Reset only the named filter definitions to their declared defaults. */ @@ -433,8 +433,6 @@ const toParamValue = (value: unknown): unknown => * state must never alias an array a caller (the document, the persisted * seed, `setFilter`/`applyFilter`'s own caller) still holds a reference to. */ const copyValue = (value: unknown): unknown => (Array.isArray(value) ? value.slice() : value); -const filterResetActive = (def: DashboardFilterDefinitionV1): boolean => - def.defaultActive ?? false; const filterInitialActive = (def: DashboardFilterDefinitionV1): boolean => def.defaultActive ?? (Array.isArray(def.defaultValue) ? def.defaultValue.length > 0 @@ -923,7 +921,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa visibleTileCount: visibleRuntimes.length, tileSearch, resettableFilterIds: filters.filter((filter) => { - const defaultActive = filterResetActive(filter.def); + const defaultActive = filterInitialActive(filter.def); const defaultValue = filterDefaultValue(filter.def); return filter.state.active !== defaultActive || !sameSelection(filter.state.value, defaultValue); }).map((filter) => filter.def.id), @@ -1706,7 +1704,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa const changed: string[] = []; for (const filter of filters) { if (!ids.has(filter.def.id)) continue; - const nextActive = filterResetActive(filter.def); + const nextActive = filterInitialActive(filter.def); // #189: `copyValue` defends the default against aliasing (a // `defaultValue` array literal on the document); `sameSelection` // (filter-selection.ts) compares STRUCTURALLY so an array value/default diff --git a/src/ui/app.ts b/src/ui/app.ts index c9ae64a..b3c5bd4 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -439,14 +439,16 @@ export function createApp(env: CreateAppEnv = {}): App { // `state.schemaError` the service writes — those signals/effects are // exactly as before. function setConn(online: boolean): void { - if (!app.dom.connStatus) return; - app.dom.connStatus.classList.toggle('dim', !online); + const chip = app.dom.connStatus; + if (!chip) return; + chip.classList.toggle('dim', !online); + const host = app.conn.host(); const full = app.state.serverVersion; // Show a short version (e.g. 26.3.10); full string on hover so the header // doesn't crowd/overflow on a narrow window. - app.dom.connStatus.title = online ? 'ClickHouse ' + full : ''; - app.dom.connStatus.replaceChildren(h('span', { class: 'ver' }, - online ? 'ClickHouse ' + shortVersion(full) : 'offline')); + const version = chip.querySelector('.ver'); + if (version) version.textContent = online ? `CH ${shortVersion(full)}` : 'offline'; + chip.title = online ? `${host} · ClickHouse ${full}` : `${host} · offline`; } const catalog = createSchemaCatalogService({ loadServerVersion: ch.loadServerVersion, diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 6e9a3e2..233879e 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -2178,11 +2178,11 @@ export async function renderDashboard(app: DashboardApp): Promise { const filterToolbar = h('div', { class: 'dash-toolbar dash-toolbar-filters', style: hasOrdinaryFilters ? undefined : { display: 'none' }, - }, ordinaryFilterHost, clearFiltersBtn, filterRefreshLiveEl); + }, ordinaryFilterHost, clearFiltersBtn); // `!`: the dashboard renders only into a mounted page. app.root!.replaceChildren(h('div', { class: 'dash-page' }, - h('div', { class: 'dash-topbar' }, header, primaryToolbar, filterToolbar), + h('div', { class: 'dash-topbar' }, header, primaryToolbar, filterToolbar, filterRefreshLiveEl), filterDiagnosticsHost, empty, searchEmpty, grid)); // Own every route-scoped resource in one teardown. An in-place Dashboard diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index 0983eae..ddbf8a6 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -1058,6 +1058,9 @@ describe('loadVersion / loadSchema', () => { await app.catalog.loadVersion(); expect(app.state.serverVersion).toBe('26.3.1'); expect(app.dom.connStatus!.textContent).toContain('26.3.1'); + expect(qs(app.dom.connStatus!, '.connection-host')?.textContent).toBe(app.conn.host()); + expect(qs(app.dom.connStatus!, '.connection-sep')).not.toBeNull(); + expect(app.dom.connStatus!.title).toBe(`${app.conn.host()} · ClickHouse 26.3.1`); }); it('marks offline when the version query fails', async () => { const e = env({ fetch: makeFetch([[(u, sql) => /version/.test(sql), resp({ ok: false, status: 500, text: 'err' })]]) }); @@ -1065,6 +1068,7 @@ describe('loadVersion / loadSchema', () => { app.renderApp(); await app.catalog.loadVersion(); expect(app.dom.connStatus!.textContent).toContain('offline'); + expect(qs(app.dom.connStatus!, '.connection-host')?.textContent).toBe(app.conn.host()); }); it('records a schema error', async () => { const e = env({ fetch: makeFetch([ diff --git a/tests/unit/dashboard-viewer-session.test.ts b/tests/unit/dashboard-viewer-session.test.ts index a72bc00..f412de4 100644 --- a/tests/unit/dashboard-viewer-session.test.ts +++ b/tests/unit/dashboard-viewer-session.test.ts @@ -510,7 +510,7 @@ describe('filters and the #235 execution planner', () => { session.setTileSearch(''); // identical search is a no-op }); - it('Clear all deactivates an inferred-active default and compares authored defaults in UI string form', async () => { + it('Clear all restores an inferred-active default and compares authored defaults in UI string form', async () => { const { exec, calls } = makeExec(); const session = createDashboardViewerSession(makeDeps({ document: doc({ @@ -522,16 +522,16 @@ describe('filters and the #235 execution planner', () => { await session.start(); expect(session.state.value.filters.find((filter) => filter.id === 'n')) .toMatchObject({ active: true, value: '5' }); - expect(session.state.value.resettableFilterIds).toEqual(['n']); + expect(session.state.value.resettableFilterIds).toEqual([]); const before = calls.length; await session.clearAllFilters(); expect(session.state.value.filters.find((filter) => filter.id === 'n')) - .toMatchObject({ active: false, value: '5' }); + .toMatchObject({ active: true, value: '5' }); expect(session.state.value.resettableFilterIds).toEqual([]); - expect(calls.length).toBeGreaterThan(before); + expect(calls.length).toBe(before); await session.applyFilter('n', '5', false); - expect(session.state.value.resettableFilterIds).toEqual([]); + expect(session.state.value.resettableFilterIds).toEqual(['n']); }); it('resetFilters restores only selected defaults in one wave and no-ops when unchanged or destroyed', async () => { diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index d987bb6..a8d8a10 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -3072,7 +3072,7 @@ describe('renderDashboard — searchable multiselect + array-wrapped curated fil const tileCalls = calls.slice(before).filter((c) => 'param_p' in c.params); expect(tileCalls.some((c) => c.params.param_p === "['x','y']")).toBe(false); expect(document.body.querySelector('.ms-popover')).toBeNull(); - expect(qs(app.root, '.dash-toolbar > .sr-only').textContent).toBe('Filter options were refreshed'); + expect(qs(app.root, '.dash-topbar > .sr-only').textContent).toBe('Filter options were refreshed'); // The committed value ['x'] (never touched by the draft) is now dormant // against the NEW option set — the merge deactivates it (existing // dormant-value self-heal behavior, unrelated to this fix) — the fresh @@ -3115,7 +3115,7 @@ describe('renderDashboard — searchable multiselect + array-wrapped curated fil const field = qs(app.root, '.dash-filter-host .var-field.is-curated'); qs(field, '.ms-trigger').dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(qs(document.body, '.ms-popover')).not.toBeNull(); - const liveRegionBefore = qs(app.root, '.dash-toolbar > .sr-only').textContent; + const liveRegionBefore = qs(app.root, '.dash-topbar > .sr-only').textContent; // Check the second option ('y') too, then Apply — a real value change. const draftCb = qsa(document.body, '.ms-option input[type="checkbox"]')[1]; draftCb.checked = true; @@ -3130,8 +3130,8 @@ describe('renderDashboard — searchable multiselect + array-wrapped curated fil const tileCalls = calls.slice(before).filter((c) => 'param_p' in c.params); expect(tileCalls.some((c) => c.params.param_p === "['x','y']")).toBe(true); // the commit went through // The live region is untouched — no false "refreshed" announcement. - expect(qs(app.root, '.dash-toolbar > .sr-only').textContent).toBe(liveRegionBefore); - expect(qs(app.root, '.dash-toolbar > .sr-only').textContent).not.toBe('Filter options were refreshed'); + expect(qs(app.root, '.dash-topbar > .sr-only').textContent).toBe(liveRegionBefore); + expect(qs(app.root, '.dash-topbar > .sr-only').textContent).not.toBe('Filter options were refreshed'); // Focus lands on the FRESH bar's trigger for the same parameter (the old // one, focused by `close()`, was detached by the synchronous rebuild). const newTrigger = qs(app.root, '.ms-trigger'); @@ -3191,6 +3191,18 @@ describe('renderDashboard — compound time-range control (#335)', () => { expect(qsa(app.root, '.dash-filter-host .var-field:not(.is-time-range) .var-name')).toEqual([]); }); + it('keeps the time-range live region outside the hidden ordinary-filter toolbar', async () => { + const { app } = dashApp({ + workspace: wsWith({ queries: [paired()], tiles: [{ id: 't1', queryId: 'q1' }] }), + }); + await render(app); + const liveRegion = qs(app.root, '.dash-topbar > .sr-only'); + const ordinaryToolbar = qs(app.root, '.dash-toolbar-filters'); + expect(liveRegion.getAttribute('aria-live')).toBe('polite'); + expect(ordinaryToolbar.style.display).toBe('none'); + expect(ordinaryToolbar.contains(liveRegion)).toBe(false); + }); + it('Apply commits BOTH bounds through session.applyFilters in one wave and announces the range', async () => { const { app, calls } = dashApp({ workspace: wsWith({ queries: [paired()], tiles: [{ id: 't1', queryId: 'q1' }] }), @@ -3203,7 +3215,7 @@ describe('renderDashboard — compound time-range control (#335)', () => { expect(added.length).toBeGreaterThanOrEqual(1); // One atomic wave binds BOTH parameters on every affected tile call. expect(added.every((c) => 'param_from' in c.params && 'param_to' in c.params)).toBe(true); - expect(qs(app.root, '.dash-toolbar > .sr-only').textContent).toBe('Time range applied: -1d → now'); + expect(qs(app.root, '.dash-topbar > .sr-only').textContent).toBe('Time range applied: -1d → now'); // The bar rebuilt on the committed-value change and now shows a resolved, // active range (not "Not set"). expect(qs(app.root, '.trf-trigger').textContent).not.toBe('Not set'); @@ -3236,10 +3248,10 @@ describe('renderDashboard — compound time-range control (#335)', () => { await flush(); startApply('-7d', 'now'); await flush(); - expect(qs(app.root, '.dash-toolbar > .sr-only').textContent).toBe('Time range applied: -7d → now'); + expect(qs(app.root, '.dash-topbar > .sr-only').textContent).toBe('Time range applied: -7d → now'); release(); await flush(); - expect(qs(app.root, '.dash-toolbar > .sr-only').textContent).toBe('Time range applied: -7d → now'); + expect(qs(app.root, '.dash-topbar > .sr-only').textContent).toBe('Time range applied: -7d → now'); rootEl(app).remove(); }); @@ -3269,7 +3281,7 @@ describe('renderDashboard — compound time-range control (#335)', () => { surfaceGeneration += 1; release(); await flush(); - expect(qs(app.root, '.dash-toolbar > .sr-only').textContent).toBe(''); + expect(qs(app.root, '.dash-topbar > .sr-only').textContent).toBe(''); rootEl(app).remove(); }); @@ -3431,7 +3443,7 @@ describe('renderDashboard — compound time-range control (#335)', () => { window.dispatchEvent(brushEvent('pointerup', 200)); for (let i = 0; i < 10 && !calls.slice(before).some((call) => 'param_from' in call.params && 'param_to' in call.params); i++) await flush(); expect(calls.slice(before).some((call) => 'param_from' in call.params && 'param_to' in call.params)).toBe(true); - expect(qs(app.root, '.dash-toolbar > .sr-only').textContent).toContain('Time range applied:'); + expect(qs(app.root, '.dash-topbar > .sr-only').textContent).toContain('Time range applied:'); qs(app.root, '.trf-trigger').dispatchEvent(clickEv()); expect(qs(document.body, '.trf-recent').textContent) .toBe('2023-11-14 22:13:20 → 2027-01-15 08:00:00'); From a7476811d23013226bd20194c3c0dbdbf0e96ef9 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 23 Jul 2026 23:23:22 +0200 Subject: [PATCH 4/8] fix: hide KPI hover metadata --- CHANGELOG.md | 2 ++ src/styles.css | 10 +++++----- tests/e2e/dashboard-grid-kpi.spec.js | 4 ++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cd21ef..8055bea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Fixed +- Keep Grafana-grid KPI hover chrome control-only: saved-query titles and + descriptions no longer overlay metric values while moving or editing a tile. - Keep the shared connection host visible after the server-version probe, restore Dashboard filters to their initial default-derived active state, and retain Dashboard time-range announcements when no ordinary filters exist. diff --git a/src/styles.css b/src/styles.css index 0c1167c..3b47567 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3015,11 +3015,11 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } position: absolute; z-index: 2; inset: 0 0 auto 0; min-height: 0; padding: 4px 5px; border: 0; opacity: 0; pointer-events: none; } -.dash-gg-grid .dash-gg-tile.is-kpi > .dash-tile-head > .dash-tile-name { - width: fit-content; max-width: calc(100% - 30px); padding: 2px 6px; - border-radius: 4px; background: color-mix(in srgb, var(--bg-modal) 92%, transparent); - box-shadow: 0 1px 4px rgba(0, 0, 0, .18); -} +/* KPI values are the tile's identity. Hover chrome must never lay its saved + query title/description over that evidence; retain the header only as the + positioning host for the remove control. The card itself already exposes + the query title through its accessible group name. */ +.dash-gg-grid .dash-gg-tile.is-kpi > .dash-tile-head > .dash-tile-heading { display: none; } .dash-gg-grid .dash-gg-tile.is-kpi > .dash-tile-head > .dash-gg-del { pointer-events: auto; } .dash-gg-grid .dash-gg-tile.is-kpi > .dash-tile-body { padding: 0; } .dash-gg-grid .dash-gg-tile.is-kpi .dash-gg-grip { display: none; } diff --git a/tests/e2e/dashboard-grid-kpi.spec.js b/tests/e2e/dashboard-grid-kpi.spec.js index cacb25d..cb204bb 100644 --- a/tests/e2e/dashboard-grid-kpi.spec.js +++ b/tests/e2e/dashboard-grid-kpi.spec.js @@ -57,6 +57,10 @@ test.describe('Dashboard grafana-grid KPI tiles (#340)', () => { await card.hover(); await expect(head).toBeVisible(); + // KPI hover chrome is deliberately control-only: the saved query title + // and description would overlap the primary metric values. + await expect(card.locator('.dash-tile-heading')).toBeHidden(); + expect(await card.locator('.dash-tile-heading').evaluate((node) => getComputedStyle(node).display)).toBe('none'); const hoveredOutline = await card.evaluate((node) => getComputedStyle(node, '::before').outlineColor); expect(hoveredOutline).not.toBe('rgba(0, 0, 0, 0)'); expect(await card.evaluate((node) => ({ w: node.getBoundingClientRect().width, h: node.getBoundingClientRect().height }))).toEqual(before); From 047e84349459bbfce64b09570e1f4467011a2d28 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 23 Jul 2026 21:38:01 +0000 Subject: [PATCH 5/8] test(e2e): align KPI fixture header markup --- tests/e2e/dashboard-grid-kpi.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/e2e/dashboard-grid-kpi.html b/tests/e2e/dashboard-grid-kpi.html index f27d4b3..287eb4f 100644 --- a/tests/e2e/dashboard-grid-kpi.html +++ b/tests/e2e/dashboard-grid-kpi.html @@ -113,11 +113,14 @@

real-viewport 360px overflow + one-per-row check

grip.setAttribute('aria-hidden', 'true'); head.appendChild(grip); } + const heading = document.createElement('div'); + heading.className = 'dash-tile-heading'; const name = document.createElement('span'); name.className = 'dash-tile-name'; name.title = title; name.textContent = title; - head.appendChild(name); + heading.appendChild(name); + head.appendChild(heading); let delBtn = null; if (!readOnly) { delBtn = document.createElement('button'); From f6b21f4859fbe919eac3f2d615fa4a8468792213 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 23 Jul 2026 21:41:35 +0000 Subject: [PATCH 6/8] ci(docker): publish images only on releases --- .github/workflows/docker.yml | 47 +++-------------------------------- .github/workflows/release.yml | 6 ++--- 2 files changed, 7 insertions(+), 46 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c4ebdfb..bc01cae 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,52 +1,18 @@ name: docker # Build the production nginx image and publish it multi-arch to GHCR. -# • pull request → build only (validation, no push) -# • push to main → :edge + :sha- -# • push tag v X.Y.Z → :X.Y.Z + :latest (this is the release image) +# A version-tag push is the release boundary: PR and main CI validate the +# application itself, while this workflow publishes the release image. on: push: - branches: [main] tags: ['v*.*.*'] - pull_request: - workflow_dispatch: env: REGISTRY: ghcr.io IMAGE_NAME: altinity/altinity-sql-browser jobs: - # Keep the workflow/check present on every PR, but avoid QEMU + a multi-arch - # build when no production-image input changed. Job-level skips satisfy - # required checks; workflow-level path filters do not reliably do so. - changes: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - outputs: - docker: ${{ steps.filter.outputs.docker }} - steps: - - uses: actions/checkout@v7 - if: github.event_name == 'pull_request' - - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 - if: github.event_name == 'pull_request' - id: filter - with: - filters: | - docker: - - 'Dockerfile' - - 'src/**' - - 'schemas/**' - - 'build/**' - - 'deploy/**' - - 'package.json' - - 'package-lock.json' - - '.github/workflows/docker.yml' - docker: - needs: changes - if: github.event_name != 'pull_request' || needs.changes.outputs.docker == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -60,9 +26,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - # No push on PRs — build-only validation. GITHUB_TOKEN is enough for GHCR. - name: Log in to GHCR - if: github.event_name != 'pull_request' uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} @@ -75,18 +39,15 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - type=ref,event=branch - type=raw,value=edge,enable={{is_default_branch}} type=semver,pattern={{version}} - type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - type=sha,prefix=sha- + type=raw,value=latest - name: Build and push uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64,linux/arm64 - push: ${{ github.event_name != 'pull_request' }} + push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} annotations: ${{ steps.meta.outputs.annotations }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5300236..a7ea573 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,9 @@ name: release -# Cut a release by pushing a tag, e.g.: git tag v0.1.0 && git push origin v0.1.0 +# Cut a release by pushing a semver tag, e.g.: git tag v0.1.0 && git push origin v0.1.0 on: push: - tags: ['v*'] + tags: ['v*.*.*'] permissions: contents: write # create the GitHub Release + upload assets @@ -36,7 +36,7 @@ jobs: # Package the Helm chart and push it to GHCR as an OCI artifact — same # approach as altinity-mcp. The container image itself is built+pushed by - # docker.yml (also on the v* tag). + # docker.yml (also on the vX.Y.Z tag). - uses: azure/setup-helm@v5 with: version: latest From 0248059a25dfd2566a98ab9ceae87e09a54e50eb Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 23 Jul 2026 21:45:25 +0000 Subject: [PATCH 7/8] ci: run browser tests nightly --- .github/workflows/ci.yml | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb372f5..28664a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,6 @@ jobs: examples: ${{ steps.filter.outputs.examples }} unit: ${{ steps.filter.outputs.unit }} build: ${{ steps.filter.outputs.build }} - e2e: ${{ steps.filter.outputs.e2e }} bundle: ${{ steps.filter.outputs.bundle }} steps: - uses: actions/checkout@v7 @@ -60,14 +59,6 @@ jobs: - 'package.json' - 'package-lock.json' - '.github/workflows/**' - e2e: - - 'src/**' - - 'schemas/**' - - 'tests/e2e/**' - - 'playwright.config.js' - - 'package.json' - - 'package-lock.json' - - '.github/workflows/**' bundle: - 'src/**' - 'schemas/**' @@ -224,18 +215,14 @@ jobs: sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck shellcheck install.sh build/bundle.sh - # Real-browser regression tests (Playwright). Runs on Linux runners where the - # Gecko/Chromium binaries launch normally — separate from the unit suite so a - # missing browser binary can't mask a unit failure, and vice versa. The - # harness imports /src directly over a python http.server (started by the + # Nightly/manual real-browser regression tests (Playwright). Keep the costly + # browser matrix out of ordinary PR and push CI; unit tests cover each commit. + # The harness imports /src directly over a python http.server (started by the # Playwright config's webServer), so no build step is needed. e2e: - needs: changes if: >- - github.event_name == 'push' || github.event_name == 'schedule' || - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request' && needs.changes.outputs.e2e == 'true') + github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 From b6f752c314727c6c3c75e103f9aac94b2d02f180 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 23 Jul 2026 21:47:11 +0000 Subject: [PATCH 8/8] ci: run browser tests for releases --- .github/workflows/ci.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28664a0..e148198 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: ci on: push: branches: [main] - tags: ['v*'] + tags: ['v*.*.*'] pull_request: branches: [main] workflow_dispatch: @@ -215,12 +215,14 @@ jobs: sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck shellcheck install.sh build/bundle.sh - # Nightly/manual real-browser regression tests (Playwright). Keep the costly - # browser matrix out of ordinary PR and push CI; unit tests cover each commit. + # Nightly/manual/release real-browser regression tests (Playwright). Keep the + # costly browser matrix out of ordinary PR and branch-push CI; unit tests + # cover each commit, while release tags validate the shipped image/artifact. # The harness imports /src directly over a python http.server (started by the # Playwright config's webServer), so no build step is needed. e2e: if: >- + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest