From dc33501a50eebe88bd45a97f5c6c467b7dfae216 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 18 Jul 2026 17:34:25 +0000 Subject: [PATCH 1/8] feat(#291): grafana-grid@1 schema + pure layout plugin + registry registration (wave 1) - schemas/dashboard-layout-grafana-grid-v1.schema.json: rowless 12-col engine, items {span 1..12, height compact|medium|large}, no preset; manifest entry + regenerated types/validators (fallback slot stays pinned to flow@1) - src/dashboard/layouts/grafana-grid-layout.ts: DashboardLayoutPlugin + computeGrafanaGridLayout (deterministic row-major packing, responsive column clamp 12/6/4/2), deriveGrafanaGridPlacement, flow<->grid span conversion, deriveFlowFallback (validated flow@1) - workspace-semantics: isSupportedLayout generalized to the engine map; new narrow isFlowLayout for fallback/flow-only call sites so a grafana-grid doc can never be accepted as a flow fallback; semantics validation now validates a non-flow primary against its own schema AND still requires a flow@1 fallback - defaultLayoutRegistry registers grafana-grid@1 behind a lazy load() - tests: grafana-grid-layout (100/100/100/100), layout-registry, workspace- semantics, schema-build extended Part of #291. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GLMprUnwnz4oaz3cnLRKcz --- build/schema-manifest.mjs | 13 + ...shboard-layout-grafana-grid-v1.schema.json | 59 +++ .../generated/library-v2.bundle.schema.json | 81 ++++ schemas/generated/schema-catalog.json | 6 + src/dashboard/layouts/flow-layout.ts | 14 +- src/dashboard/layouts/grafana-grid-layout.ts | 296 +++++++++++++++ src/dashboard/layouts/layout-registry.ts | 22 +- src/dashboard/model/workspace-semantics.ts | 63 +++- src/generated/json-schema-validators.js | 356 +++++++++++++++--- src/generated/json-schema.types.ts | 50 +++ src/generated/json-schemas.js | 83 ++++ tests/unit/grafana-grid-layout.test.ts | 304 +++++++++++++++ tests/unit/layout-registry.test.ts | 31 +- tests/unit/schema-build.test.js | 9 +- tests/unit/workspace-semantics.test.ts | 56 ++- 15 files changed, 1372 insertions(+), 71 deletions(-) create mode 100644 schemas/dashboard-layout-grafana-grid-v1.schema.json create mode 100644 src/dashboard/layouts/grafana-grid-layout.ts create mode 100644 tests/unit/grafana-grid-layout.test.ts diff --git a/build/schema-manifest.mjs b/build/schema-manifest.mjs index 37468895..95900a8a 100644 --- a/build/schema-manifest.mjs +++ b/build/schema-manifest.mjs @@ -33,6 +33,19 @@ export const SCHEMA_MANIFEST = [ validatorExport: 'validateFlowLayoutV1', typeExport: 'FlowLayoutV1', }, + // grafana-grid@1 (#291): a second layout engine, sibling to flow@1. The + // generic layout envelope in dashboard-v1.schema.json is already open + // (type/version/items are unconstrained there), so this schema needs no + // $ref from dashboard-v1 — it is its own manifest root purely to get a + // compiled validator + generated types, exactly like flow's own root. The + // `fallback` slot stays pinned to flow@1 only; this schema is never a + // fallback target. + { + path: 'schemas/dashboard-layout-grafana-grid-v1.schema.json', + schemaExport: 'grafanaGridLayoutV1Schema', + validatorExport: 'validateGrafanaGridLayoutV1', + typeExport: 'GrafanaGridLayoutV1', + }, { path: 'schemas/dashboard-v1.schema.json', schemaExport: 'dashboardV1Schema', diff --git a/schemas/dashboard-layout-grafana-grid-v1.schema.json b/schemas/dashboard-layout-grafana-grid-v1.schema.json new file mode 100644 index 00000000..aa81dd52 --- /dev/null +++ b/schemas/dashboard-layout-grafana-grid-v1.schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json", + "title": "Altinity SQL Browser Dashboard grafana-grid@1 layout", + "description": "The normative grafana-grid@1 Dashboard layout: rowless deterministic packing driven by dashboard.tiles order into a single 12-column grid, and per-tile span/height placements keyed by tile ID. Unlike flow@1 this engine has no preset. A grafana-grid primary layout always pairs with a valid flow@1 DashboardLayoutFallbackV1 document, regenerated on every mutation.", + "x-altinity-kind": "dashboard-layout-grafana-grid", + "x-altinity-version": 1, + "type": "object", + "required": ["type", "version", "items"], + "properties": { + "type": { + "title": "Layout engine", + "description": "Layout engine identifier; always grafana-grid for this contract.", + "type": "string", + "const": "grafana-grid" + }, + "version": { + "title": "Layout engine version", + "description": "grafana-grid contract version; always 1 for this contract.", + "type": "integer", + "const": 1 + }, + "items": { + "title": "Tile placements", + "description": "Per-tile placement keyed by tile ID. A missing placement uses span 6 and medium height.", + "type": "object", + "maxProperties": 100, + "propertyNames": { "minLength": 1, "maxLength": 256 }, + "additionalProperties": { "$ref": "#/$defs/grafanaGridTilePlacementV1" } + } + }, + "additionalProperties": false, + "x-altinity-order": ["type", "version", "items"], + "$defs": { + "grafanaGridHeightV1": { + "title": "Tile height", + "description": "Normative height ordering is compact < medium < large; exact pixels are renderer-defined. Shared vocabulary with flow@1.", + "type": "string", + "enum": ["compact", "medium", "large"] + }, + "grafanaGridTilePlacementV1": { + "title": "Tile placement", + "description": "Closed placement contract: unknown fields fail validation. Future extension requires grafana-grid@2 or an explicit extension namespace.", + "type": "object", + "properties": { + "span": { + "title": "Column span", + "description": "Columns (of 12) the tile occupies; the effective span is clamped to the active column count at each responsive breakpoint.", + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "height": { "$ref": "#/$defs/grafanaGridHeightV1" } + }, + "additionalProperties": false, + "x-altinity-order": ["span", "height"] + } + } +} diff --git a/schemas/generated/library-v2.bundle.schema.json b/schemas/generated/library-v2.bundle.schema.json index f83fb337..54a33406 100644 --- a/schemas/generated/library-v2.bundle.schema.json +++ b/schemas/generated/library-v2.bundle.schema.json @@ -1298,6 +1298,87 @@ } } }, + "dashboard-layout-grafana-grid-v1": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json", + "title": "Altinity SQL Browser Dashboard grafana-grid@1 layout", + "description": "The normative grafana-grid@1 Dashboard layout: rowless deterministic packing driven by dashboard.tiles order into a single 12-column grid, and per-tile span/height placements keyed by tile ID. Unlike flow@1 this engine has no preset. A grafana-grid primary layout always pairs with a valid flow@1 DashboardLayoutFallbackV1 document, regenerated on every mutation.", + "x-altinity-kind": "dashboard-layout-grafana-grid", + "x-altinity-version": 1, + "type": "object", + "required": [ + "type", + "version", + "items" + ], + "properties": { + "type": { + "title": "Layout engine", + "description": "Layout engine identifier; always grafana-grid for this contract.", + "type": "string", + "const": "grafana-grid" + }, + "version": { + "title": "Layout engine version", + "description": "grafana-grid contract version; always 1 for this contract.", + "type": "integer", + "const": 1 + }, + "items": { + "title": "Tile placements", + "description": "Per-tile placement keyed by tile ID. A missing placement uses span 6 and medium height.", + "type": "object", + "maxProperties": 100, + "propertyNames": { + "minLength": 1, + "maxLength": 256 + }, + "additionalProperties": { + "$ref": "#/$defs/grafanaGridTilePlacementV1" + } + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "type", + "version", + "items" + ], + "$defs": { + "grafanaGridHeightV1": { + "title": "Tile height", + "description": "Normative height ordering is compact < medium < large; exact pixels are renderer-defined. Shared vocabulary with flow@1.", + "type": "string", + "enum": [ + "compact", + "medium", + "large" + ] + }, + "grafanaGridTilePlacementV1": { + "title": "Tile placement", + "description": "Closed placement contract: unknown fields fail validation. Future extension requires grafana-grid@2 or an explicit extension namespace.", + "type": "object", + "properties": { + "span": { + "title": "Column span", + "description": "Columns (of 12) the tile occupies; the effective span is clamped to the active column count at each responsive breakpoint.", + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "height": { + "$ref": "#/$defs/grafanaGridHeightV1" + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "span", + "height" + ] + } + } + }, "dashboard-v1": { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json", diff --git a/schemas/generated/schema-catalog.json b/schemas/generated/schema-catalog.json index 73caa733..8976fe48 100644 --- a/schemas/generated/schema-catalog.json +++ b/schemas/generated/schema-catalog.json @@ -27,6 +27,12 @@ "id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json", "path": "../dashboard-layout-flow-v1.schema.json" }, + { + "kind": "dashboard-layout-grafana-grid", + "version": 1, + "id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json", + "path": "../dashboard-layout-grafana-grid-v1.schema.json" + }, { "kind": "dashboard", "version": 1, diff --git a/src/dashboard/layouts/flow-layout.ts b/src/dashboard/layouts/flow-layout.ts index b4b518c2..24b77020 100644 --- a/src/dashboard/layouts/flow-layout.ts +++ b/src/dashboard/layouts/flow-layout.ts @@ -13,7 +13,7 @@ import { diagnostic } from '../model/workspace-diagnostics.js'; import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; -import { isSupportedLayout } from '../model/workspace-semantics.js'; +import { isFlowLayout } from '../model/workspace-semantics.js'; import { cloneJson } from '../../core/saved-query.js'; import { partitionKpiBands } from '../../core/dashboard.js'; import type { @@ -47,12 +47,12 @@ export interface DashboardLayoutPlugin { * `null` (no flow surface to normalize). */ function flowItemsHost(layout: unknown): Record | null { if (!isObject(layout)) return null; - if (isSupportedLayout(layout.type, layout.version)) { + if (isFlowLayout(layout.type, layout.version)) { if (!isObject(layout.items)) { layout.items = {}; } return layout.items as Record; } const fallback = layout.fallback; - if (isObject(fallback) && isSupportedLayout(fallback.type, fallback.version)) { + if (isObject(fallback) && isFlowLayout(fallback.type, fallback.version)) { if (!isObject(fallback.items)) { fallback.items = {}; } return fallback.items as Record; } @@ -175,9 +175,9 @@ export function resolvePlacement(placement: unknown): Required | null { if (!isObject(layout)) return null; - if (isSupportedLayout(layout.type, layout.version)) return layout; + if (isFlowLayout(layout.type, layout.version)) return layout; const fallback = layout.fallback; - if (isObject(fallback) && isSupportedLayout(fallback.type, fallback.version)) return fallback; + if (isObject(fallback) && isFlowLayout(fallback.type, fallback.version)) return fallback; return null; } @@ -286,9 +286,9 @@ export function computeFlowLayout(input: ComputeFlowLayoutInput): FlowLayoutMode * fallback" path. */ export function resolveActiveLayoutPlugin(layout: unknown, path: Path = ['layout']): LoadLayoutPluginResult { if (isObject(layout)) { - if (isSupportedLayout(layout.type, layout.version)) return { ok: true, plugin: flowLayoutPlugin }; + if (isFlowLayout(layout.type, layout.version)) return { ok: true, plugin: flowLayoutPlugin }; const fallback = layout.fallback; - if (isObject(fallback) && isSupportedLayout(fallback.type, fallback.version)) { + if (isObject(fallback) && isFlowLayout(fallback.type, fallback.version)) { return { ok: true, plugin: flowLayoutPlugin }; } } diff --git a/src/dashboard/layouts/grafana-grid-layout.ts b/src/dashboard/layouts/grafana-grid-layout.ts new file mode 100644 index 00000000..43f6f436 --- /dev/null +++ b/src/dashboard/layouts/grafana-grid-layout.ts @@ -0,0 +1,296 @@ +// The grafana-grid@1 layout plugin (#291): a second Dashboard layout engine, +// sibling to flow@1 (flow-layout.ts). Same `DashboardLayoutPlugin` contract +// (`normalize`, `validatePlacement`) plus its own pure render math. Pure — no +// DOM, no globals; the DOM reconciliation (Wave 3) is a separate module. +// +// Design choices this module makes (owner decisions round, #291 plan): +// - Rowless: unlike flow@1's row-major `FlowRow[]`, grafana-grid@1 lays every +// visible tile directly onto a single flat 12-column grid, in canonical +// `dashboard.tiles[]` order — no row grouping type at all (`FlowRow` is +// deliberately NOT reused/widened). `computeGrafanaGridLayout` still runs a +// real, pure, deterministic packing simulation (row/colStart per tile) so +// the model is testable and usable by a non-CSS-grid consumer (print/export, +// a future canvas renderer) without depending on the browser's own grid +// auto-placement to reproduce identical wrapping. +// - KPI tiles get no special banding (no "kpi-band" concept exists in a +// rowless grid): a KPI tile is placed exactly like any other tile, using +// its own `{span, height}` placement, in canonical order. `isKpi` is +// carried through to the render model only so a renderer can still style a +// KPI tile differently (chrome, not placement). +// - Heights reuse flow@1's `compact|medium|large` vocabulary verbatim, not a +// new scale (the `dashboard-layout-grafana-grid-v1.schema.json` defs use +// the same values; the generated `GrafanaGridHeightV1` type is structurally +// identical to `FlowHeightV1`). +// - `deriveGrafanaGridPlacement` reuses flow's own `sizeHints` → span mapping +// (`deriveFlowPlacement`) and then converts through `gridSpanFromFlowSpan`, +// rather than duplicating the sizeHints interpretation. +// - `deriveFlowFallback` ALWAYS resolves every known tile's EFFECTIVE grid +// placement (via `resolveGridPlacement`, which fills in the grid default +// span 6/medium when a tile has no persisted grid item) before converting +// to a flow item. This is a deliberate choice over "skip tiles with no +// persisted grid placement" (which would let them fall through to flow's +// OWN different default, span 1): every grid mutation regenerates a fully +// explicit, deterministic flow@1 fallback for every tile, so a default-sized +// grid tile (span 6) maps to its flow equivalent (span 2), not flow's own +// unrelated default. + +import { diagnostic } from '../model/workspace-diagnostics.js'; +import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; +import { cloneJson } from '../../core/saved-query.js'; +import { deriveFlowPlacement } from './flow-layout.js'; +import type { DashboardLayoutPlugin } from './flow-layout.js'; +import type { + DashboardDocumentV1, FlowLayoutV1, FlowTilePlacementV1, + GrafanaGridHeightV1, GrafanaGridTilePlacementV1, +} from '../../generated/json-schema.types.js'; + +type Path = (string | number)[]; + +const isObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const VALID_HEIGHTS = new Set(['compact', 'medium', 'large']); +const PLACEMENT_FIELDS = new Set(['span', 'height']); + +/** The maximum column count grafana-grid@1 ever resolves to (its widest + * responsive breakpoint, ≥1160px container width). */ +export const GRAFANA_GRID_MAX_COLUMNS = 12; + +const isValidGridSpan = (value: unknown): value is number => + Number.isInteger(value) && (value as number) >= 1 && (value as number) <= GRAFANA_GRID_MAX_COLUMNS; + +/** The grafana-grid@1 default placement (#291): span 6 (half the 12-column + * grid), medium height — matching flow@1's default height. */ +export const DEFAULT_GRID_PLACEMENT: Required = { span: 6, height: 'medium' }; + +/** The object holding the active grid placements — the primary layout's + * `items` (grafana-grid@1 is never a fallback target, so there is no + * fallback-surface duck-typing here unlike flow's `flowItemsHost`). */ +function gridItemsHost(layout: unknown): Record | null { + if (!isObject(layout)) return null; + if (!isObject(layout.items)) { layout.items = {}; } + return layout.items as Record; +} + +/** Set one tile's grid placement on a layout document (mutates in place). + * No-op when the layout is not an object. */ +export function setGridPlacement(layout: unknown, tileId: string, placement: unknown): void { + const items = gridItemsHost(layout); + if (items) items[tileId] = placement; +} + +/** Derive an initial grid placement from a query's `sizeHints.preferred`, + * reusing flow@1's own `compact|medium|wide` → span mapping + * (`deriveFlowPlacement`) and converting the result through + * `gridSpanFromFlowSpan`. Always returns a complete placement — grafana-grid + * has an explicit default (span 6, medium) rather than flow's "no opinion" + * `undefined`. */ +export function deriveGrafanaGridPlacement(sizeHints: unknown): Required { + const flowPlacement = deriveFlowPlacement(sizeHints); + if (!flowPlacement || flowPlacement.span === undefined) return { ...DEFAULT_GRID_PLACEMENT }; + // `deriveFlowPlacement`'s own contract: whenever it returns a placement + // (span defined), height is always set too (it never returns a bare span). + return { span: gridSpanFromFlowSpan(flowPlacement.span), height: flowPlacement.height! }; +} + +const GRID_SPAN_FROM_FLOW_SPAN: Record<1 | 2 | 3, 4 | 6 | 12> = { 1: 4, 2: 6, 3: 12 }; + +/** flow span (1|2|3) → grid span (4|6|12), used when seeding a grid layout + * from an existing flow placement (flow→grid engine switch). */ +export function gridSpanFromFlowSpan(flowSpan: 1 | 2 | 3): 4 | 6 | 12 { + return GRID_SPAN_FROM_FLOW_SPAN[flowSpan]; +} + +/** grid span (1..12) → flow span (1|2|3): 1-4→1, 5-8→2, 9-12→3. Used to + * regenerate the flow@1 fallback on every grid mutation. An invalid/missing + * grid span is treated as the grid default (6), which maps to flow span 2. */ +export function flowSpanFromGridSpan(gridSpan: unknown): 1 | 2 | 3 { + const span = isValidGridSpan(gridSpan) ? gridSpan : DEFAULT_GRID_PLACEMENT.span; + return span <= 4 ? 1 : span <= 8 ? 2 : 3; +} + +/** Merge one stored grid placement with `DEFAULT_GRID_PLACEMENT`: a + * missing/invalid span or height falls back to the default, so the result is + * always a complete `{span, height}` (mirrors flow's `resolvePlacement`). */ +export function resolveGridPlacement(placement: unknown): Required { + const p = isObject(placement) ? placement : {}; + return { + span: isValidGridSpan(p.span) ? (p.span as number) : DEFAULT_GRID_PLACEMENT.span, + height: VALID_HEIGHTS.has(p.height) ? (p.height as GrafanaGridHeightV1) : DEFAULT_GRID_PLACEMENT.height, + }; +} + +function normalize(dashboard: DashboardDocumentV1): DashboardDocumentV1 { + const next = cloneJson(dashboard); + const tileIds = new Set(); + for (const tile of Array.isArray(next.tiles) ? next.tiles : []) { + if (isObject(tile) && typeof tile.id === 'string') tileIds.add(tile.id); + } + const items = gridItemsHost(next.layout); + if (items) { + for (const key of Object.keys(items)) { + if (!tileIds.has(key)) delete items[key]; + } + } + return next; +} + +function validatePlacement(placement: unknown, path: Path = []): WorkspaceDiagnostic[] { + if (!isObject(placement)) { + return [diagnostic(path, 'layout-placement-invalid', 'Placement must be an object')]; + } + const out: WorkspaceDiagnostic[] = []; + for (const key of Object.keys(placement)) { + if (!PLACEMENT_FIELDS.has(key)) { + out.push(diagnostic([...path, key], 'layout-placement-unknown-field', + `Unknown grafana-grid placement field ${JSON.stringify(key)}`)); + } + } + if (Object.hasOwn(placement, 'span') && !isValidGridSpan(placement.span)) { + out.push(diagnostic([...path, 'span'], 'layout-placement-invalid-span', + 'Grafana-grid placement span must be an integer from 1 to 12')); + } + if (Object.hasOwn(placement, 'height') && !VALID_HEIGHTS.has(placement.height)) { + out.push(diagnostic([...path, 'height'], 'layout-placement-invalid-height', + 'Grafana-grid placement height must be compact, medium, or large')); + } + return out; +} + +/** The single grafana-grid@1 plugin instance (stateless; safe to share). */ +export const grafanaGridLayoutPlugin: DashboardLayoutPlugin = { + type: 'grafana-grid', version: 1, normalize, validatePlacement, +}; + +// ── Pure render math: rowless packing (#291) ──────────────────────────────── + +/** Effective column count for a container width (#291 "Responsive clamp"): + * ≥1160px→12, ≥720px→6, ≥470px→4, else→2. An absent/non-finite width + * defaults to the widest desktop breakpoint (12) — the useful default for + * tests and non-DOM consumers (print/export) where no measured width exists. */ +export function effectiveGridColumns(containerWidth?: unknown): number { + const width = typeof containerWidth === 'number' && Number.isFinite(containerWidth) ? containerWidth : Infinity; + if (width >= 1160) return 12; + if (width >= 720) return 6; + if (width >= 470) return 4; + return 2; +} + +/** The effective span for one tile: `min(storedSpan ?? default, columns)`. + * An invalid/missing stored span is treated as the grid default (6); the + * persisted span itself is never mutated (mirrors flow's `effectiveSpan`). */ +export function effectiveGridSpan(storedSpan: unknown, columns: number): number { + const span = isValidGridSpan(storedSpan) ? storedSpan : DEFAULT_GRID_PLACEMENT.span; + return Math.min(span, Math.max(1, columns)); +} + +/** One tile as placed for one render pass — a flat position in a single + * grid, NOT a row-grouped `FlowRow` (#291 "rowless"). `row`/`colStart` are a + * real deterministic packing simulation (row-major, wraps when the next + * tile's span does not fit in the remaining columns), independent of + * whatever grid-auto-placement a DOM renderer may additionally rely on. */ +export interface GrafanaGridTileRender { + tileId: string; + index: number; + span: number; + height: GrafanaGridHeightV1; + isKpi: boolean; + row: number; + colStart: number; +} + +/** The computed grafana-grid@1 render model: a single flat grid, no rows + * type, tagged with `engine` so a caller can discriminate it against a flow + * `FlowLayoutModel` without either type needing to know about the other. */ +export interface GrafanaGridLayoutModel { + engine: 'grafana-grid'; + /** Effective columns for the given container width. */ + columns: number; + /** Every visible tile, positioned, in `dashboard.tiles[]` semantic order. */ + tiles: GrafanaGridTileRender[]; + /** Visible tile IDs in semantic order (parity with flow's `order`). */ + order: string[]; +} + +/** One visible tile the grid lays out, in `dashboard.tiles[]` semantic order. + * `isKpi` marks an explicitly-configured KPI panel (chrome only — grafana- + * grid@1 has no KPI band). */ +export interface GrafanaGridVisibleTile { + id: string; + isKpi?: boolean; +} + +export interface ComputeGrafanaGridLayoutInput { + tiles: readonly GrafanaGridVisibleTile[]; + /** The grafana-grid layout document (or any object whose `items` holds + * grid placements); tolerated when absent/non-object (every tile then + * renders at the grid default). */ + layout: unknown; + /** The rendering container's width in px; see `effectiveGridColumns`. */ + containerWidth?: number; +} + +function gridItemsFor(layout: unknown): Record { + return isObject(layout) && isObject(layout.items) ? (layout.items as Record) : {}; +} + +/** + * Compute the deterministic grafana-grid@1 render model (#291): a single + * flat 12-(or fewer-)column grid, tiles placed in `dashboard.tiles[]` order. + * Row-major packing: place each tile's effective span in the current row + * when it fits, else wrap to a new row — tiles never overlap, and there is + * no row-grouping type, band, or fold (rowless). Pure and non-mutating. + */ +export function computeGrafanaGridLayout(input: ComputeGrafanaGridLayoutInput): GrafanaGridLayoutModel { + const { tiles, layout, containerWidth } = input; + const columns = effectiveGridColumns(containerWidth); + const items = gridItemsFor(layout); + + let row = 0; + let cursor = 0; + const renders: GrafanaGridTileRender[] = tiles.map((tile, index) => { + const placement = resolveGridPlacement(items[tile.id]); + const span = effectiveGridSpan(placement.span, columns); + if (cursor + span > columns) { + row += 1; + cursor = 0; + } + const render: GrafanaGridTileRender = { + tileId: tile.id, index, span, height: placement.height, isKpi: !!tile.isKpi, row, colStart: cursor, + }; + cursor += span; + return render; + }); + + return { engine: 'grafana-grid', columns, tiles: renders, order: tiles.map((tile) => tile.id) }; +} + +// ── Engine conversion: grid → flow fallback (#291) ────────────────────────── + +/** One tile grafana-grid@1 needs only the stable ID of, to regenerate a + * flow@1 fallback for it. */ +export interface GrafanaGridFallbackTile { + id: string; +} + +/** + * Derive a complete, valid flow@1 layout document from a grafana-grid@1 + * layout, for use as the Dashboard's `fallback` (#291: "every grid mutation + * regenerates the flow@1 fallback deterministically"). Every known tile gets + * an explicit flow item — even one with no persisted grid placement, which + * resolves to the grid default (span 6) and maps to its flow equivalent + * (span 2), rather than silently falling through to flow's own unrelated + * default (span 1). `full-width` is the fallback preset: the closest single- + * column analog to a rowless grid with no fixed column count. + */ +export function deriveFlowFallback( + gridLayout: unknown, tiles: readonly GrafanaGridFallbackTile[], +): FlowLayoutV1 { + const items = gridItemsFor(gridLayout); + const flowItems: Record = {}; + for (const tile of tiles) { + const gridPlacement = resolveGridPlacement(items[tile.id]); + flowItems[tile.id] = { span: flowSpanFromGridSpan(gridPlacement.span), height: gridPlacement.height }; + } + return { type: 'flow', version: 1, preset: 'full-width', items: flowItems }; +} diff --git a/src/dashboard/layouts/layout-registry.ts b/src/dashboard/layouts/layout-registry.ts index 88d2f07e..2cc9bba7 100644 --- a/src/dashboard/layouts/layout-registry.ts +++ b/src/dashboard/layouts/layout-registry.ts @@ -16,9 +16,10 @@ import { diagnostic } from '../model/workspace-diagnostics.js'; import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; -import { isSupportedLayout } from '../model/workspace-semantics.js'; +import { isFlowLayout } from '../model/workspace-semantics.js'; import { flowLayoutPlugin } from './flow-layout.js'; import type { DashboardLayoutPlugin } from './flow-layout.js'; +import { grafanaGridLayoutPlugin } from './grafana-grid-layout.js'; const isObject = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); @@ -59,6 +60,17 @@ export const flowLayoutRegistration: DashboardLayoutRegistration = { load: () => Promise.resolve(flowLayoutPlugin), }; +/** The grafana-grid@1 registration (#291): a second built-in engine. `load` + * is registration-time-lazy only, matching flow's — the plugin module is + * still inlined in the one bundle (CLAUDE.md hard rule 4 forbids network + * code-splitting); it is simply not constructed/imported by a caller until a + * Dashboard document actually requests `type: 'grafana-grid'`. */ +export const grafanaGridLayoutRegistration: DashboardLayoutRegistration = { + id: 'grafana-grid', + versions: [1], + load: () => Promise.resolve(grafanaGridLayoutPlugin), +}; + /** Build a registry from a set of registrations. flow@1 is always present (a * passed `flow` registration is ignored in favour of the built-in, so the * fallback engine can never be shadowed). */ @@ -90,7 +102,7 @@ export function createLayoutRegistry( const flowFallbackPlugin = async (layout: Record): Promise => { const fallback = layout.fallback; - if (isObject(fallback) && isSupportedLayout(fallback.type, fallback.version)) { + if (isObject(fallback) && isFlowLayout(fallback.type, fallback.version)) { return load('flow', 1); } return null; @@ -113,6 +125,6 @@ export function createLayoutRegistry( return { supports, load, resolve }; } -/** The default registry: only the built-in flow@1 engine (v1 ships no other - * layout). */ -export const defaultLayoutRegistry: DashboardLayoutRegistry = createLayoutRegistry(); +/** The default registry: the built-in flow@1 engine plus grafana-grid@1 + * (#291), the first second-engine consumer of this registry. */ +export const defaultLayoutRegistry: DashboardLayoutRegistry = createLayoutRegistry([grafanaGridLayoutRegistration]); diff --git a/src/dashboard/model/workspace-semantics.ts b/src/dashboard/model/workspace-semantics.ts index a49a49af..a8897017 100644 --- a/src/dashboard/model/workspace-semantics.ts +++ b/src/dashboard/model/workspace-semantics.ts @@ -17,10 +17,34 @@ import { scanParamDeclarations } from '../../core/param-scan.js'; export const FLOW_LAYOUT_V1_SCHEMA_ID = 'https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json'; +export const GRAFANA_GRID_LAYOUT_V1_SCHEMA_ID = + 'https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json'; -/** The layout engines this build can render. flow@1 is the only v1 engine; - * anything else must carry a valid flow@1 fallback or fail before execution. */ +/** Every primary layout engine this build can render, and the compiled schema + * that validates its own document shape (#291 adds grafana-grid@1 as a + * second engine alongside flow@1). */ +const SUPPORTED_LAYOUT_SCHEMAS: Record = { + flow: { versions: [1], schemaId: FLOW_LAYOUT_V1_SCHEMA_ID }, + 'grafana-grid': { versions: [1], schemaId: GRAFANA_GRID_LAYOUT_V1_SCHEMA_ID }, +}; + +/** True for any registered primary engine at a version this build renders + * (flow@1 or grafana-grid@1 in v1). Anything else must carry a valid flow@1 + * fallback or fail before execution. NOT the right check for "is this + * specifically the flow@1 fallback slot" — that stays pinned to flow@1 only + * (`isFlowLayout`) and never widens to another engine, even when that engine + * is itself supported as a primary. */ export const isSupportedLayout = (type: unknown, version: unknown): boolean => + typeof type === 'string' && typeof version === 'number' + && Object.hasOwn(SUPPORTED_LAYOUT_SCHEMAS, type) + && SUPPORTED_LAYOUT_SCHEMAS[type].versions.includes(version); + +/** flow@1 specifically. Used wherever a value must be THE flow engine itself: + * the flow plugin's own item host/render surface (flow-layout.ts), and the + * Dashboard layout's `fallback` slot, which is pinned to flow@1 and must + * never accept another engine even once that engine is a supported primary + * (#291's "do not widen the fallback slot"). */ +export const isFlowLayout = (type: unknown, version: unknown): boolean => type === 'flow' && version === 1; type Path = (string | number)[]; @@ -292,7 +316,10 @@ export function validateDashboardSemantics(dashboard: unknown, { } }; checkItems(layout.items, [...layoutPath, 'items']); - if (isSupportedLayout(layout.type, layout.version)) { + if (isFlowLayout(layout.type, layout.version)) { + // flow@1 IS the fallback engine: a flow primary needs no fallback of + // its own, validated directly against its own schema (unchanged from + // pre-#291 behavior). for (const schemaError of validationService.validate(FLOW_LAYOUT_V1_SCHEMA_ID, layout)) { out.push({ ...schemaError, @@ -301,10 +328,38 @@ export function validateDashboardSemantics(dashboard: unknown, { }); } } else { + // Any other primary — a second known engine (grafana-grid@1) or a + // truly unsupported/unknown one — always requires its own valid flow@1 + // fallback (#291): the fallback slot is pinned to flow@1 and never + // widens, so it stays the one universal safety net regardless of which + // other engine is primary. A known non-flow engine ALSO gets its own + // items validated against its own schema, in addition to the fallback. + const primarySchema = isSupportedLayout(layout.type, layout.version) + ? SUPPORTED_LAYOUT_SCHEMAS[layout.type as string].schemaId : undefined; + if (primarySchema !== undefined) { + // Validate only the engine's own declared shape: `fallback`/`config` + // are envelope-only slots (dashboardLayoutDocumentV1 in + // dashboard-v1.schema.json), not part of any concrete per-engine + // schema (flow@1's own schema doesn't declare them either) — passing + // them through would spuriously fail the engine's closed + // `additionalProperties: false` schema. + const primaryOwn = Object.fromEntries( + Object.entries(layout).filter(([key]) => key !== 'fallback' && key !== 'config'), + ); + for (const schemaError of validationService.validate(primarySchema, primaryOwn)) { + out.push({ + ...schemaError, + path: [...layoutPath, ...schemaError.path], + ...(dashboardId === undefined ? {} : { resource: dashboardId }), + }); + } + } const fallback = layout.fallback; if (fallback === undefined || fallback === null) { emit(layoutPath, 'layout-unsupported-without-fallback', - `Layout ${JSON.stringify(layout.type)}@${JSON.stringify(layout.version)} is unsupported and has no flow@1 fallback`); + primarySchema !== undefined + ? `Layout ${JSON.stringify(layout.type)}@${JSON.stringify(layout.version)} requires a valid flow@1 fallback` + : `Layout ${JSON.stringify(layout.type)}@${JSON.stringify(layout.version)} is unsupported and has no flow@1 fallback`); } else { for (const schemaError of validationService.validate(FLOW_LAYOUT_V1_SCHEMA_ID, fallback)) { out.push({ diff --git a/src/generated/json-schema-validators.js b/src/generated/json-schema-validators.js index c2150bca..86d6ae12 100644 --- a/src/generated/json-schema-validators.js +++ b/src/generated/json-schema-validators.js @@ -4085,11 +4085,267 @@ function validate46(data, { instancePath = "", parentData, parentDataProperty, r return errors === 0; } validate46.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -var validateDashboardV1 = validate49; -function validate52(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +var validateGrafanaGridLayoutV1 = validate49; +var schema62 = { "title": "Tile height", "description": "Normative height ordering is compact < medium < large; exact pixels are renderer-defined. Shared vocabulary with flow@1.", "type": "string", "enum": ["compact", "medium", "large"] }; +function validate50(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { let vErrors = null; let errors = 0; - const evaluated0 = validate52.evaluated; + const evaluated0 = validate50.evaluated; + if (evaluated0.dynamicProps) { + evaluated0.props = void 0; + } + if (evaluated0.dynamicItems) { + evaluated0.items = void 0; + } + if (data && typeof data == "object" && !Array.isArray(data)) { + for (const key0 in data) { + if (!(key0 === "span" || key0 === "height")) { + const err0 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); + } + errors++; + } + } + if (data.span !== void 0) { + let data0 = data.span; + if (!(typeof data0 == "number" && (!(data0 % 1) && !isNaN(data0)) && isFinite(data0))) { + const err1 = { instancePath: instancePath + "/span", schemaPath: "#/properties/span/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (vErrors === null) { + vErrors = [err1]; + } else { + vErrors.push(err1); + } + errors++; + } + if (typeof data0 == "number" && isFinite(data0)) { + if (data0 > 12 || isNaN(data0)) { + const err2 = { instancePath: instancePath + "/span", schemaPath: "#/properties/span/maximum", keyword: "maximum", params: { comparison: "<=", limit: 12 }, message: "must be <= 12" }; + if (vErrors === null) { + vErrors = [err2]; + } else { + vErrors.push(err2); + } + errors++; + } + if (data0 < 1 || isNaN(data0)) { + const err3 = { instancePath: instancePath + "/span", schemaPath: "#/properties/span/minimum", keyword: "minimum", params: { comparison: ">=", limit: 1 }, message: "must be >= 1" }; + if (vErrors === null) { + vErrors = [err3]; + } else { + vErrors.push(err3); + } + errors++; + } + } + } + if (data.height !== void 0) { + let data1 = data.height; + if (typeof data1 !== "string") { + const err4 = { instancePath: instancePath + "/height", schemaPath: "#/$defs/grafanaGridHeightV1/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err4]; + } else { + vErrors.push(err4); + } + errors++; + } + if (!(data1 === "compact" || data1 === "medium" || data1 === "large")) { + const err5 = { instancePath: instancePath + "/height", schemaPath: "#/$defs/grafanaGridHeightV1/enum", keyword: "enum", params: { allowedValues: schema62.enum }, message: "must be equal to one of the allowed values" }; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + errors++; + } + } + } else { + const err6 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err6]; + } else { + vErrors.push(err6); + } + errors++; + } + validate50.errors = vErrors; + return errors === 0; +} +validate50.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate49(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { + ; + let vErrors = null; + let errors = 0; + const evaluated0 = validate49.evaluated; + if (evaluated0.dynamicProps) { + evaluated0.props = void 0; + } + if (evaluated0.dynamicItems) { + evaluated0.items = void 0; + } + if (data && typeof data == "object" && !Array.isArray(data)) { + if (data.type === void 0) { + const err0 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "type" }, message: "must have required property 'type'" }; + if (vErrors === null) { + vErrors = [err0]; + } else { + vErrors.push(err0); + } + errors++; + } + if (data.version === void 0) { + const err1 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "version" }, message: "must have required property 'version'" }; + if (vErrors === null) { + vErrors = [err1]; + } else { + vErrors.push(err1); + } + errors++; + } + if (data.items === void 0) { + const err2 = { instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: "items" }, message: "must have required property 'items'" }; + if (vErrors === null) { + vErrors = [err2]; + } else { + vErrors.push(err2); + } + errors++; + } + for (const key0 in data) { + if (!(key0 === "type" || key0 === "version" || key0 === "items")) { + const err3 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err3]; + } else { + vErrors.push(err3); + } + errors++; + } + } + if (data.type !== void 0) { + let data0 = data.type; + if (typeof data0 !== "string") { + const err4 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err4]; + } else { + vErrors.push(err4); + } + errors++; + } + if ("grafana-grid" !== data0) { + const err5 = { instancePath: instancePath + "/type", schemaPath: "#/properties/type/const", keyword: "const", params: { allowedValue: "grafana-grid" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err5]; + } else { + vErrors.push(err5); + } + errors++; + } + } + if (data.version !== void 0) { + let data1 = data.version; + if (!(typeof data1 == "number" && (!(data1 % 1) && !isNaN(data1)) && isFinite(data1))) { + const err6 = { instancePath: instancePath + "/version", schemaPath: "#/properties/version/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }; + if (vErrors === null) { + vErrors = [err6]; + } else { + vErrors.push(err6); + } + errors++; + } + if (1 !== data1) { + const err7 = { instancePath: instancePath + "/version", schemaPath: "#/properties/version/const", keyword: "const", params: { allowedValue: 1 }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err7]; + } else { + vErrors.push(err7); + } + errors++; + } + } + if (data.items !== void 0) { + let data2 = data.items; + if (data2 && typeof data2 == "object" && !Array.isArray(data2)) { + if (Object.keys(data2).length > 100) { + const err8 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/maxProperties", keyword: "maxProperties", params: { limit: 100 }, message: "must NOT have more than 100 properties" }; + if (vErrors === null) { + vErrors = [err8]; + } else { + vErrors.push(err8); + } + errors++; + } + for (const key1 in data2) { + const _errs8 = errors; + if (typeof key1 === "string") { + if (func1(key1) > 256) { + const err9 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/propertyNames/maxLength", keyword: "maxLength", params: { limit: 256 }, message: "must NOT have more than 256 characters", propertyName: key1 }; + if (vErrors === null) { + vErrors = [err9]; + } else { + vErrors.push(err9); + } + errors++; + } + if (func1(key1) < 1) { + const err10 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/propertyNames/minLength", keyword: "minLength", params: { limit: 1 }, message: "must NOT have fewer than 1 characters", propertyName: key1 }; + if (vErrors === null) { + vErrors = [err10]; + } else { + vErrors.push(err10); + } + errors++; + } + } + var valid1 = _errs8 === errors; + if (!valid1) { + const err11 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/propertyNames", keyword: "propertyNames", params: { propertyName: key1 }, message: "property name must be valid" }; + if (vErrors === null) { + vErrors = [err11]; + } else { + vErrors.push(err11); + } + errors++; + } + } + for (const key2 in data2) { + if (!validate50(data2[key2], { instancePath: instancePath + "/items/" + key2.replace(/~/g, "~0").replace(/\//g, "~1"), parentData: data2, parentDataProperty: key2, rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate50.errors : vErrors.concat(validate50.errors); + errors = vErrors.length; + } + } + } else { + const err12 = { instancePath: instancePath + "/items", schemaPath: "#/properties/items/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err12]; + } else { + vErrors.push(err12); + } + errors++; + } + } + } else { + const err13 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err13]; + } else { + vErrors.push(err13); + } + errors++; + } + validate49.errors = vErrors; + return errors === 0; +} +validate49.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +var validateDashboardV1 = validate52; +function validate55(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { + let vErrors = null; + let errors = 0; + const evaluated0 = validate55.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -4159,15 +4415,15 @@ function validate52(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate52.errors = vErrors; + validate55.errors = vErrors; return errors === 0; } -validate52.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -function validate51(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +validate55.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate54(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { ; let vErrors = null; let errors = 0; - const evaluated0 = validate51.evaluated; + const evaluated0 = validate54.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -4331,8 +4587,8 @@ function validate51(data, { instancePath = "", parentData, parentDataProperty, r } } for (const key2 in data3) { - if (!validate52(data3[key2], { instancePath: instancePath + "/items/" + key2.replace(/~/g, "~0").replace(/\//g, "~1"), parentData: data3, parentDataProperty: key2, rootData, dynamicAnchors })) { - vErrors = vErrors === null ? validate52.errors : vErrors.concat(validate52.errors); + if (!validate55(data3[key2], { instancePath: instancePath + "/items/" + key2.replace(/~/g, "~0").replace(/\//g, "~1"), parentData: data3, parentDataProperty: key2, rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate55.errors : vErrors.concat(validate55.errors); errors = vErrors.length; } } @@ -4355,14 +4611,14 @@ function validate51(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate51.errors = vErrors; + validate54.errors = vErrors; return errors === 0; } -validate51.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -function validate50(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +validate54.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate53(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { let vErrors = null; let errors = 0; - const evaluated0 = validate50.evaluated; + const evaluated0 = validate53.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -4555,8 +4811,8 @@ function validate50(data, { instancePath = "", parentData, parentDataProperty, r } } if (data.fallback !== void 0) { - if (!validate51(data.fallback, { instancePath: instancePath + "/fallback", parentData: data, parentDataProperty: "fallback", rootData, dynamicAnchors })) { - vErrors = vErrors === null ? validate51.errors : vErrors.concat(validate51.errors); + if (!validate54(data.fallback, { instancePath: instancePath + "/fallback", parentData: data, parentDataProperty: "fallback", rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate54.errors : vErrors.concat(validate54.errors); errors = vErrors.length; } } @@ -4569,14 +4825,14 @@ function validate50(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate50.errors = vErrors; + validate53.errors = vErrors; return errors === 0; } -validate50.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -function validate57(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +validate53.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate60(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { let vErrors = null; let errors = 0; - const evaluated0 = validate57.evaluated; + const evaluated0 = validate60.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -4648,14 +4904,14 @@ function validate57(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate57.errors = vErrors; + validate60.errors = vErrors; return errors === 0; } -validate57.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -function validate56(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +validate60.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate59(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { let vErrors = null; let errors = 0; - const evaluated0 = validate56.evaluated; + const evaluated0 = validate59.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -4817,8 +5073,8 @@ function validate56(data, { instancePath = "", parentData, parentDataProperty, r } } if (data.presentation !== void 0) { - if (!validate57(data.presentation, { instancePath: instancePath + "/presentation", parentData: data, parentDataProperty: "presentation", rootData, dynamicAnchors })) { - vErrors = vErrors === null ? validate57.errors : vErrors.concat(validate57.errors); + if (!validate60(data.presentation, { instancePath: instancePath + "/presentation", parentData: data, parentDataProperty: "presentation", rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate60.errors : vErrors.concat(validate60.errors); errors = vErrors.length; } } @@ -4831,15 +5087,15 @@ function validate56(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate56.errors = vErrors; + validate59.errors = vErrors; return errors === 0; } -validate56.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -function validate49(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +validate59.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +function validate52(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { ; let vErrors = null; let errors = 0; - const evaluated0 = validate49.evaluated; + const evaluated0 = validate52.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -5050,8 +5306,8 @@ function validate49(data, { instancePath = "", parentData, parentDataProperty, r } } if (data.layout !== void 0) { - if (!validate50(data.layout, { instancePath: instancePath + "/layout", parentData: data, parentDataProperty: "layout", rootData, dynamicAnchors })) { - vErrors = vErrors === null ? validate50.errors : vErrors.concat(validate50.errors); + if (!validate53(data.layout, { instancePath: instancePath + "/layout", parentData: data, parentDataProperty: "layout", rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate53.errors : vErrors.concat(validate53.errors); errors = vErrors.length; } } @@ -5346,8 +5602,8 @@ function validate49(data, { instancePath = "", parentData, parentDataProperty, r } const len2 = data15.length; for (let i3 = 0; i3 < len2; i3++) { - if (!validate56(data15[i3], { instancePath: instancePath + "/tiles/" + i3, parentData: data15, parentDataProperty: i3, rootData, dynamicAnchors })) { - vErrors = vErrors === null ? validate56.errors : vErrors.concat(validate56.errors); + if (!validate59(data15[i3], { instancePath: instancePath + "/tiles/" + i3, parentData: data15, parentDataProperty: i3, rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate59.errors : vErrors.concat(validate59.errors); errors = vErrors.length; } } @@ -5370,16 +5626,16 @@ function validate49(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate49.errors = vErrors; + validate52.errors = vErrors; return errors === 0; } -validate49.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -var validateStoredWorkspaceV1 = validate60; -function validate60(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +validate52.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +var validateStoredWorkspaceV1 = validate63; +function validate63(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { ; let vErrors = null; let errors = 0; - const evaluated0 = validate60.evaluated; + const evaluated0 = validate63.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -5561,8 +5817,8 @@ function validate60(data, { instancePath = "", parentData, parentDataProperty, r let valid3 = false; let passing0 = null; const _errs13 = errors; - if (!validate49(data5, { instancePath: instancePath + "/dashboard", parentData: data, parentDataProperty: "dashboard", rootData, dynamicAnchors })) { - vErrors = vErrors === null ? validate49.errors : vErrors.concat(validate49.errors); + if (!validate52(data5, { instancePath: instancePath + "/dashboard", parentData: data, parentDataProperty: "dashboard", rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate52.errors : vErrors.concat(validate52.errors); errors = vErrors.length; } var _valid0 = _errs13 === errors; @@ -5618,16 +5874,16 @@ function validate60(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate60.errors = vErrors; + validate63.errors = vErrors; return errors === 0; } -validate60.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; -var validatePortableBundleV1 = validate63; -function validate63(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { +validate63.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +var validatePortableBundleV1 = validate66; +function validate66(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { ; let vErrors = null; let errors = 0; - const evaluated0 = validate63.evaluated; + const evaluated0 = validate66.evaluated; if (evaluated0.dynamicProps) { evaluated0.props = void 0; } @@ -5867,8 +6123,8 @@ function validate63(data, { instancePath = "", parentData, parentDataProperty, r } const len1 = data9.length; for (let i1 = 0; i1 < len1; i1++) { - if (!validate49(data9[i1], { instancePath: instancePath + "/dashboards/" + i1, parentData: data9, parentDataProperty: i1, rootData, dynamicAnchors })) { - vErrors = vErrors === null ? validate49.errors : vErrors.concat(validate49.errors); + if (!validate52(data9[i1], { instancePath: instancePath + "/dashboards/" + i1, parentData: data9, parentDataProperty: i1, rootData, dynamicAnchors })) { + vErrors = vErrors === null ? validate52.errors : vErrors.concat(validate52.errors); errors = vErrors.length; } } @@ -5891,13 +6147,14 @@ function validate63(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - validate63.errors = vErrors; + validate66.errors = vErrors; return errors === 0; } -validate63.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; +validate66.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; export { validateDashboardV1, validateFlowLayoutV1, + validateGrafanaGridLayoutV1, validateLibraryV2, validatePortableBundleV1, validateQuerySpecV1, @@ -5910,6 +6167,7 @@ export const validatorsById = { "https://altinity.com/schemas/altinity-sql-browser/saved-query-v2.schema.json": validateSavedQueryV2, "https://altinity.com/schemas/altinity-sql-browser/library-v2.schema.json": validateLibraryV2, "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json": validateFlowLayoutV1, + "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json": validateGrafanaGridLayoutV1, "https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json": validateDashboardV1, "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json": validateStoredWorkspaceV1, "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json": validatePortableBundleV1, diff --git a/src/generated/json-schema.types.ts b/src/generated/json-schema.types.ts index 742b920a..390be681 100644 --- a/src/generated/json-schema.types.ts +++ b/src/generated/json-schema.types.ts @@ -681,6 +681,56 @@ export interface FlowLayoutV1 { items: Record; } +// dashboard-layout-grafana-grid v1 — https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json + +/** + * Tile height + * + * Normative height ordering is compact < medium < large; exact pixels are renderer-defined. Shared vocabulary with flow@1. + */ +export type GrafanaGridHeightV1 = "compact" | "medium" | "large"; + +/** + * Tile placement + * + * Closed placement contract: unknown fields fail validation. Future extension requires grafana-grid@2 or an explicit extension namespace. + */ +export interface GrafanaGridTilePlacementV1 { + /** + * Column span + * + * Columns (of 12) the tile occupies; the effective span is clamped to the active column count at each responsive breakpoint. + */ + span?: number; + height?: GrafanaGridHeightV1; +} + +/** + * Altinity SQL Browser Dashboard grafana-grid@1 layout + * + * The normative grafana-grid@1 Dashboard layout: rowless deterministic packing driven by dashboard.tiles order into a single 12-column grid, and per-tile span/height placements keyed by tile ID. Unlike flow@1 this engine has no preset. A grafana-grid primary layout always pairs with a valid flow@1 DashboardLayoutFallbackV1 document, regenerated on every mutation. + */ +export interface GrafanaGridLayoutV1 { + /** + * Layout engine + * + * Layout engine identifier; always grafana-grid for this contract. + */ + type: "grafana-grid"; + /** + * Layout engine version + * + * grafana-grid contract version; always 1 for this contract. + */ + version: 1; + /** + * Tile placements + * + * Per-tile placement keyed by tile ID. A missing placement uses span 6 and medium height. + */ + items: Record; +} + // dashboard v1 — https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json /** diff --git a/src/generated/json-schemas.js b/src/generated/json-schemas.js index a6409390..5784b818 100644 --- a/src/generated/json-schemas.js +++ b/src/generated/json-schemas.js @@ -1298,6 +1298,88 @@ export const flowLayoutV1Schema = { } }; +export const grafanaGridLayoutV1Schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json", + "title": "Altinity SQL Browser Dashboard grafana-grid@1 layout", + "description": "The normative grafana-grid@1 Dashboard layout: rowless deterministic packing driven by dashboard.tiles order into a single 12-column grid, and per-tile span/height placements keyed by tile ID. Unlike flow@1 this engine has no preset. A grafana-grid primary layout always pairs with a valid flow@1 DashboardLayoutFallbackV1 document, regenerated on every mutation.", + "x-altinity-kind": "dashboard-layout-grafana-grid", + "x-altinity-version": 1, + "type": "object", + "required": [ + "type", + "version", + "items" + ], + "properties": { + "type": { + "title": "Layout engine", + "description": "Layout engine identifier; always grafana-grid for this contract.", + "type": "string", + "const": "grafana-grid" + }, + "version": { + "title": "Layout engine version", + "description": "grafana-grid contract version; always 1 for this contract.", + "type": "integer", + "const": 1 + }, + "items": { + "title": "Tile placements", + "description": "Per-tile placement keyed by tile ID. A missing placement uses span 6 and medium height.", + "type": "object", + "maxProperties": 100, + "propertyNames": { + "minLength": 1, + "maxLength": 256 + }, + "additionalProperties": { + "$ref": "#/$defs/grafanaGridTilePlacementV1" + } + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "type", + "version", + "items" + ], + "$defs": { + "grafanaGridHeightV1": { + "title": "Tile height", + "description": "Normative height ordering is compact < medium < large; exact pixels are renderer-defined. Shared vocabulary with flow@1.", + "type": "string", + "enum": [ + "compact", + "medium", + "large" + ] + }, + "grafanaGridTilePlacementV1": { + "title": "Tile placement", + "description": "Closed placement contract: unknown fields fail validation. Future extension requires grafana-grid@2 or an explicit extension namespace.", + "type": "object", + "properties": { + "span": { + "title": "Column span", + "description": "Columns (of 12) the tile occupies; the effective span is clamped to the active column count at each responsive breakpoint.", + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "height": { + "$ref": "#/$defs/grafanaGridHeightV1" + } + }, + "additionalProperties": false, + "x-altinity-order": [ + "span", + "height" + ] + } + } +}; + export const dashboardV1Schema = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json", @@ -1755,6 +1837,7 @@ export const schemasById = { "https://altinity.com/schemas/altinity-sql-browser/saved-query-v2.schema.json": savedQueryV2Schema, "https://altinity.com/schemas/altinity-sql-browser/library-v2.schema.json": libraryV2Schema, "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json": flowLayoutV1Schema, + "https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json": grafanaGridLayoutV1Schema, "https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json": dashboardV1Schema, "https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json": storedWorkspaceV1Schema, "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json": portableBundleV1Schema, diff --git a/tests/unit/grafana-grid-layout.test.ts b/tests/unit/grafana-grid-layout.test.ts new file mode 100644 index 00000000..fc536b6c --- /dev/null +++ b/tests/unit/grafana-grid-layout.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_GRID_PLACEMENT, GRAFANA_GRID_MAX_COLUMNS, + computeGrafanaGridLayout, deriveFlowFallback, deriveGrafanaGridPlacement, + effectiveGridColumns, effectiveGridSpan, flowSpanFromGridSpan, grafanaGridLayoutPlugin, + gridSpanFromFlowSpan, resolveGridPlacement, setGridPlacement, +} from '../../src/dashboard/layouts/grafana-grid-layout.js'; +import { FLOW_LAYOUT_V1_SCHEMA_ID } from '../../src/dashboard/model/workspace-semantics.js'; +import { jsonSchemaValidationService } from '../../src/core/library-codec.js'; +import type { DashboardDocumentV1 } from '../../src/generated/json-schema.types.js'; + +const gridLayout = (items: Record> = {}) => ({ type: 'grafana-grid', version: 1, items }); +const doc = (over: Partial = {}): DashboardDocumentV1 => ({ + documentVersion: 1, id: 'd', title: 'D', revision: 1, layout: gridLayout(), filters: [], tiles: [], ...over, +} as DashboardDocumentV1); + +describe('constants', () => { + it('exposes the grid default placement and max column count', () => { + expect(GRAFANA_GRID_MAX_COLUMNS).toBe(12); + expect(DEFAULT_GRID_PLACEMENT).toEqual({ span: 6, height: 'medium' }); + }); +}); + +describe('gridSpanFromFlowSpan / flowSpanFromGridSpan', () => { + it('maps flow span to grid span', () => { + expect(gridSpanFromFlowSpan(1)).toBe(4); + expect(gridSpanFromFlowSpan(2)).toBe(6); + expect(gridSpanFromFlowSpan(3)).toBe(12); + }); + + it('maps grid span to flow span at each boundary', () => { + expect(flowSpanFromGridSpan(1)).toBe(1); + expect(flowSpanFromGridSpan(4)).toBe(1); + expect(flowSpanFromGridSpan(5)).toBe(2); + expect(flowSpanFromGridSpan(8)).toBe(2); + expect(flowSpanFromGridSpan(9)).toBe(3); + expect(flowSpanFromGridSpan(12)).toBe(3); + }); + + it('treats an invalid/missing grid span as the grid default (6), which maps to flow span 2', () => { + expect(flowSpanFromGridSpan(undefined)).toBe(2); + expect(flowSpanFromGridSpan(null)).toBe(2); + expect(flowSpanFromGridSpan(0)).toBe(2); + expect(flowSpanFromGridSpan(13)).toBe(2); + expect(flowSpanFromGridSpan(2.5)).toBe(2); + expect(flowSpanFromGridSpan('4')).toBe(2); + }); +}); + +describe('deriveGrafanaGridPlacement', () => { + it('maps preferred size hints through flow span to grid span', () => { + expect(deriveGrafanaGridPlacement({ preferred: 'wide' })).toEqual({ span: 12, height: 'medium' }); + expect(deriveGrafanaGridPlacement({ preferred: 'medium' })).toEqual({ span: 6, height: 'medium' }); + expect(deriveGrafanaGridPlacement({ preferred: 'compact' })).toEqual({ span: 4, height: 'medium' }); + }); + + it('falls back to the grid default without a usable hint', () => { + expect(deriveGrafanaGridPlacement(undefined)).toEqual(DEFAULT_GRID_PLACEMENT); + expect(deriveGrafanaGridPlacement({})).toEqual(DEFAULT_GRID_PLACEMENT); + expect(deriveGrafanaGridPlacement({ preferred: 'other' })).toEqual(DEFAULT_GRID_PLACEMENT); + }); +}); + +describe('resolveGridPlacement', () => { + it('merges a stored placement with the defaults', () => { + expect(resolveGridPlacement({ span: 4, height: 'large' })).toEqual({ span: 4, height: 'large' }); + expect(resolveGridPlacement({ span: 9 })).toEqual({ span: 9, height: 'medium' }); + expect(resolveGridPlacement({ height: 'compact' })).toEqual({ span: 6, height: 'compact' }); + }); + + it('falls back to the defaults for a missing or invalid placement', () => { + expect(resolveGridPlacement(undefined)).toEqual(DEFAULT_GRID_PLACEMENT); + expect(resolveGridPlacement({ span: 13, height: 'huge' })).toEqual(DEFAULT_GRID_PLACEMENT); + expect(resolveGridPlacement({ span: 0 })).toEqual(DEFAULT_GRID_PLACEMENT); + expect(resolveGridPlacement({ span: 2.5 })).toEqual(DEFAULT_GRID_PLACEMENT); + expect(resolveGridPlacement('nope')).toEqual(DEFAULT_GRID_PLACEMENT); + }); +}); + +describe('setGridPlacement', () => { + it('sets a placement on a grid layout', () => { + const layout = gridLayout(); + setGridPlacement(layout, 't1', { span: 4 }); + expect(layout.items).toEqual({ t1: { span: 4 } }); + }); + + it('creates the items map when the grid layout is missing one', () => { + const layout: Record = { type: 'grafana-grid', version: 1 }; + setGridPlacement(layout, 't1', { span: 6 }); + expect(layout.items).toEqual({ t1: { span: 6 } }); + }); + + it('is a no-op on a non-object layout', () => { + expect(() => setGridPlacement(null, 't1', { span: 1 })).not.toThrow(); + expect(() => setGridPlacement(5, 't1', { span: 1 })).not.toThrow(); + }); +}); + +describe('grafanaGridLayoutPlugin', () => { + it('exposes its identity', () => { + expect(grafanaGridLayoutPlugin.type).toBe('grafana-grid'); + expect(grafanaGridLayoutPlugin.version).toBe(1); + }); + + describe('normalize', () => { + it('prunes placements that no longer name a tile, without mutating the input', () => { + const input = doc({ tiles: [{ id: 't1', queryId: 'q' }] as never, layout: gridLayout({ t1: { span: 4 }, ghost: {} }) }); + const result = grafanaGridLayoutPlugin.normalize(input); + expect(result.layout.items).toEqual({ t1: { span: 4 } }); + expect((input.layout.items as Record).ghost).toBeDefined(); // input untouched + }); + + it('creates the items map when missing and tolerates a non-object layout', () => { + const noItems = doc({ tiles: [{ id: 't1', queryId: 'q' }] as never, layout: { type: 'grafana-grid', version: 1 } as never }); + expect(grafanaGridLayoutPlugin.normalize(noItems).layout.items).toEqual({}); + + const notObject = doc({ tiles: [{ id: 't1', queryId: 'q' }] as never, layout: null as never }); + expect(() => grafanaGridLayoutPlugin.normalize(notObject)).not.toThrow(); + expect(grafanaGridLayoutPlugin.normalize(notObject).layout).toBeNull(); + }); + + it('tolerates a non-array tiles field — every placement becomes an orphan', () => { + const noTiles = doc({ tiles: undefined as never, layout: gridLayout({ ghost: {} }) }); + expect(grafanaGridLayoutPlugin.normalize(noTiles).layout.items).toEqual({}); + }); + }); + + describe('validatePlacement', () => { + it('accepts a valid placement, including partials', () => { + expect(grafanaGridLayoutPlugin.validatePlacement({ span: 4, height: 'large' })).toEqual([]); + expect(grafanaGridLayoutPlugin.validatePlacement({ span: 12 })).toEqual([]); + expect(grafanaGridLayoutPlugin.validatePlacement({ height: 'compact' })).toEqual([]); + expect(grafanaGridLayoutPlugin.validatePlacement({})).toEqual([]); + }); + + it('rejects a non-object placement', () => { + const diagnostics = grafanaGridLayoutPlugin.validatePlacement('nope', ['layout', 'items', 't1']); + expect(diagnostics.map((d) => d.code)).toEqual(['layout-placement-invalid']); + expect(diagnostics[0].path).toEqual(['layout', 'items', 't1']); + }); + + it('rejects unknown fields and invalid span/height', () => { + const diagnostics = grafanaGridLayoutPlugin.validatePlacement({ span: 13, height: 'huge', bogus: true }); + const codes = diagnostics.map((d) => d.code); + expect(codes).toContain('layout-placement-unknown-field'); + expect(codes).toContain('layout-placement-invalid-span'); + expect(codes).toContain('layout-placement-invalid-height'); + }); + + it('rejects a span of 0, a non-integer span, and a too-large span', () => { + expect(grafanaGridLayoutPlugin.validatePlacement({ span: 0 }).map((d) => d.code)) + .toContain('layout-placement-invalid-span'); + expect(grafanaGridLayoutPlugin.validatePlacement({ span: 2.5 }).map((d) => d.code)) + .toContain('layout-placement-invalid-span'); + expect(grafanaGridLayoutPlugin.validatePlacement({ span: 13 }).map((d) => d.code)) + .toContain('layout-placement-invalid-span'); + }); + + it('uses the default empty path', () => { + expect(grafanaGridLayoutPlugin.validatePlacement('nope')[0].path).toEqual([]); + }); + }); +}); + +describe('effectiveGridColumns', () => { + it('maps container width to the responsive breakpoints', () => { + expect(effectiveGridColumns(1160)).toBe(12); + expect(effectiveGridColumns(2000)).toBe(12); + expect(effectiveGridColumns(1159)).toBe(6); + expect(effectiveGridColumns(720)).toBe(6); + expect(effectiveGridColumns(719)).toBe(4); + expect(effectiveGridColumns(470)).toBe(4); + expect(effectiveGridColumns(469)).toBe(2); + expect(effectiveGridColumns(0)).toBe(2); + }); + + it('defaults to the widest breakpoint (12) for an absent or non-finite width', () => { + expect(effectiveGridColumns(undefined)).toBe(12); + expect(effectiveGridColumns(NaN)).toBe(12); + expect(effectiveGridColumns(Infinity)).toBe(12); + expect(effectiveGridColumns('1200')).toBe(12); + }); +}); + +describe('effectiveGridSpan', () => { + it('clamps the stored span to the active column count (min)', () => { + expect(effectiveGridSpan(12, 12)).toBe(12); + expect(effectiveGridSpan(12, 4)).toBe(4); + expect(effectiveGridSpan(3, 2)).toBe(2); + }); + + it('treats an absent/invalid stored span as the default (6)', () => { + expect(effectiveGridSpan(undefined, 12)).toBe(6); + expect(effectiveGridSpan(13, 12)).toBe(6); + expect(effectiveGridSpan('2', 12)).toBe(6); + }); + + it('never returns less than one even with a zero column count', () => { + expect(effectiveGridSpan(6, 0)).toBe(1); + }); +}); + +describe('computeGrafanaGridLayout', () => { + const tiles = (...ids: string[]) => ids.map((id) => ({ id })); + + it('defaults a missing placement to span 6, medium height, at the widest breakpoint', () => { + const model = computeGrafanaGridLayout({ tiles: tiles('a'), layout: gridLayout() }); + expect(model.engine).toBe('grafana-grid'); + expect(model.columns).toBe(12); + expect(model.tiles[0]).toMatchObject({ tileId: 'a', span: 6, height: 'medium', row: 0, colStart: 0 }); + }); + + it('clamps effective span per responsive breakpoint without mutating stored spans', () => { + const layout = gridLayout({ a: { span: 12 } }); + const model = computeGrafanaGridLayout({ tiles: tiles('a'), layout, containerWidth: 470 }); + expect(model.columns).toBe(4); + expect(model.tiles[0].span).toBe(4); // 12 clamped to 4 + expect(layout.items.a.span).toBe(12); // persisted span untouched + }); + + it('packs tiles row-major, wrapping to a new row when the next span does not fit, with no overlaps', () => { + // 12 columns, spans [8, 8, 4]: row0 = [8] (remaining 4 < 8 → wrap), row1 = [8], row2 = [4] + const layout = gridLayout({ a: { span: 8 }, b: { span: 8 }, c: { span: 4 } }); + const model = computeGrafanaGridLayout({ tiles: tiles('a', 'b', 'c'), layout, containerWidth: 1200 }); + expect(model.tiles.map((t) => [t.tileId, t.row, t.colStart])).toEqual([ + ['a', 0, 0], ['b', 1, 0], ['c', 1, 8], + ]); + for (const render of model.tiles) expect(render.colStart + render.span).toBeLessThanOrEqual(model.columns); + }); + + it('packs several small tiles into the same row until it is full', () => { + const layout = gridLayout({ a: { span: 4 }, b: { span: 4 }, c: { span: 4 }, d: { span: 4 } }); + const model = computeGrafanaGridLayout({ tiles: tiles('a', 'b', 'c', 'd'), layout, containerWidth: 1200 }); + expect(model.tiles.map((t) => t.row)).toEqual([0, 0, 0, 1]); + expect(model.tiles.map((t) => t.colStart)).toEqual([0, 4, 8, 0]); + }); + + it('places KPI tiles exactly like any other tile — no banding', () => { + const layout = gridLayout({ k1: { span: 4 }, t1: { span: 4 }, k2: { span: 4 } }); + const model = computeGrafanaGridLayout({ + tiles: [{ id: 'k1', isKpi: true }, { id: 't1' }, { id: 'k2', isKpi: true }], + layout, containerWidth: 1200, + }); + expect(model.tiles.map((t) => [t.tileId, t.isKpi, t.row])).toEqual([['k1', true, 0], ['t1', false, 0], ['k2', true, 0]]); + }); + + it('keeps semantic tiles[] order as the model order, ignoring items for unknown tile ids', () => { + const layout = gridLayout({ a: { span: 4 }, ghost: { span: 12 } }); + const model = computeGrafanaGridLayout({ tiles: tiles('a'), layout }); + expect(model.order).toEqual(['a']); + expect(model.tiles).toHaveLength(1); + }); + + it('falls back to defaults for an invalid persisted span/height', () => { + const layout = gridLayout({ a: { span: 99, height: 'huge' } }); + const model = computeGrafanaGridLayout({ tiles: tiles('a'), layout }); + expect(model.tiles[0]).toMatchObject({ span: 6, height: 'medium' }); + }); + + it('handles an empty tile list', () => { + const model = computeGrafanaGridLayout({ tiles: [], layout: gridLayout() }); + expect(model.tiles).toEqual([]); + expect(model.order).toEqual([]); + }); + + it('tolerates a non-object layout — every tile renders at the grid default', () => { + const model = computeGrafanaGridLayout({ tiles: tiles('a', 'b'), layout: null }); + expect(model.tiles.map((t) => t.span)).toEqual([6, 6]); + }); +}); + +describe('deriveFlowFallback', () => { + it('maps each tile\'s grid span/height to its flow equivalent', () => { + const layout = gridLayout({ + a: { span: 4, height: 'compact' }, b: { span: 8, height: 'large' }, c: { span: 12 }, + }); + const fallback = deriveFlowFallback(layout, [{ id: 'a' }, { id: 'b' }, { id: 'c' }]); + expect(fallback).toEqual({ + type: 'flow', version: 1, preset: 'full-width', + items: { + a: { span: 1, height: 'compact' }, + b: { span: 2, height: 'large' }, + c: { span: 3, height: 'medium' }, + }, + }); + }); + + it('resolves a tile with no persisted grid placement to the grid default (span 6 → flow span 2)', () => { + const fallback = deriveFlowFallback(gridLayout(), [{ id: 't1' }]); + expect(fallback.items).toEqual({ t1: { span: 2, height: 'medium' } }); + }); + + it('handles an empty tile list and a non-object layout', () => { + expect(deriveFlowFallback(gridLayout(), [])).toEqual({ type: 'flow', version: 1, preset: 'full-width', items: {} }); + const fallback = deriveFlowFallback(null, [{ id: 't1' }]); + expect(fallback.items).toEqual({ t1: { span: 2, height: 'medium' } }); + }); + + it('always produces a document that validates cleanly as a flow@1 layout', () => { + const layout = gridLayout({ a: { span: 1, height: 'huge' as never }, b: {} }); + const fallback = deriveFlowFallback(layout, [{ id: 'a' }, { id: 'b' }, { id: 'c' }]); + expect(jsonSchemaValidationService.validate(FLOW_LAYOUT_V1_SCHEMA_ID, fallback)).toEqual([]); + }); +}); diff --git a/tests/unit/layout-registry.test.ts b/tests/unit/layout-registry.test.ts index 581ea53d..8a23322b 100644 --- a/tests/unit/layout-registry.test.ts +++ b/tests/unit/layout-registry.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest'; import { - createLayoutRegistry, defaultLayoutRegistry, flowLayoutRegistration, + createLayoutRegistry, defaultLayoutRegistry, flowLayoutRegistration, grafanaGridLayoutRegistration, } from '../../src/dashboard/layouts/layout-registry.js'; import { flowLayoutPlugin } from '../../src/dashboard/layouts/flow-layout.js'; +import { grafanaGridLayoutPlugin } from '../../src/dashboard/layouts/grafana-grid-layout.js'; import type { DashboardLayoutPlugin } from '../../src/dashboard/layouts/flow-layout.js'; import type { DashboardLayoutRegistration } from '../../src/dashboard/layouts/layout-registry.js'; @@ -108,8 +109,34 @@ describe('resolve', () => { }); describe('defaultLayoutRegistry', () => { - it('exposes only the built-in flow@1 engine', () => { + it('exposes the built-in flow@1 and grafana-grid@1 engines (#291), and nothing else', () => { expect(defaultLayoutRegistry.supports('flow', 1)).toBe(true); + expect(defaultLayoutRegistry.supports('grafana-grid', 1)).toBe(true); + expect(defaultLayoutRegistry.supports('grafana-grid', 2)).toBe(false); expect(defaultLayoutRegistry.supports('grid', 2)).toBe(false); }); + + it('registration-time-lazy loads the real grafana-grid@1 plugin instance', async () => { + expect(grafanaGridLayoutRegistration.id).toBe('grafana-grid'); + expect(await defaultLayoutRegistry.load('grafana-grid', 1)).toBe(grafanaGridLayoutPlugin); + }); + + it('resolves a grafana-grid@1 primary layout directly (no fallback needed)', async () => { + const result = await defaultLayoutRegistry.resolve({ type: 'grafana-grid', version: 1, items: {}, fallback: flow() }); + expect(result.ok).toBe(true); + if (result.ok) { expect(result.plugin).toBe(grafanaGridLayoutPlugin); expect(result.usedFallback).toBe(false); } + }); + + it('falls back to flow@1 for an unsupported grafana-grid version', async () => { + const result = await defaultLayoutRegistry.resolve({ + type: 'grafana-grid', version: 9, items: {}, fallback: flow({ a: { span: 1 } }), + }); + expect(result.ok).toBe(true); + if (result.ok) { expect(result.plugin).toBe(flowLayoutPlugin); expect(result.usedFallback).toBe(true); } + }); + + it('fails closed for an unsupported grafana-grid version without a valid flow@1 fallback', async () => { + const result = await defaultLayoutRegistry.resolve({ type: 'grafana-grid', version: 9 }); + expect(result.ok).toBe(false); + }); }); diff --git a/tests/unit/schema-build.test.js b/tests/unit/schema-build.test.js index 2dfc68a8..ebd8bcf0 100644 --- a/tests/unit/schema-build.test.js +++ b/tests/unit/schema-build.test.js @@ -20,13 +20,15 @@ describe('multi-schema build', () => { 'schemas/saved-query-v2.schema.json', 'schemas/library-v2.schema.json', 'schemas/dashboard-layout-flow-v1.schema.json', + 'schemas/dashboard-layout-grafana-grid-v1.schema.json', 'schemas/dashboard-v1.schema.json', 'schemas/stored-workspace-v1.schema.json', 'schemas/portable-bundle-v1.schema.json', ]); const KINDS = [ ['query-spec', 1], ['saved-query', 2], ['library', 2], - ['dashboard-layout-flow', 1], ['dashboard', 1], ['stored-workspace', 1], ['portable-bundle', 1], + ['dashboard-layout-flow', 1], ['dashboard-layout-grafana-grid', 1], + ['dashboard', 1], ['stored-workspace', 1], ['portable-bundle', 1], ]; const records = await loadRecords(); expect(records.map(({ schema }) => [schema['x-altinity-kind'], schema['x-altinity-version']])) @@ -44,6 +46,7 @@ describe('multi-schema build', () => { 'https://altinity.com/schemas/altinity-sql-browser/saved-query-v2.schema.json', 'https://altinity.com/schemas/altinity-sql-browser/library-v2.schema.json', 'https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-flow-v1.schema.json', + 'https://altinity.com/schemas/altinity-sql-browser/dashboard-layout-grafana-grid-v1.schema.json', 'https://altinity.com/schemas/altinity-sql-browser/dashboard-v1.schema.json', 'https://altinity.com/schemas/altinity-sql-browser/stored-workspace-v1.schema.json', 'https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v1.schema.json', @@ -97,7 +100,7 @@ describe('multi-schema build', () => { it('emits the committed TypeScript artifact with pinned names, openness, and closedness', async () => { expect(SCHEMA_MANIFEST.map((entry) => entry.typeExport)).toEqual([ 'QuerySpecV1', 'SavedQueryV2', 'LibraryV2', - 'FlowLayoutV1', 'DashboardDocumentV1', 'StoredWorkspaceV1', 'PortableBundleV1', + 'FlowLayoutV1', 'GrafanaGridLayoutV1', 'DashboardDocumentV1', 'StoredWorkspaceV1', 'PortableBundleV1', ]); const sources = await generatedSources(); const types = Object.entries(sources).find(([path]) => path.endsWith('json-schema.types.ts'))[1]; @@ -126,7 +129,9 @@ describe('multi-schema build', () => { expect(types).toContain('export interface PortableBundleV1'); expect(types).toContain('export type DashboardLayoutFallbackV1 = FlowLayoutV1;'); expect(types).toContain('export type QueryPresentationPatchV1 = Record;'); + expect(types).toContain('export interface GrafanaGridLayoutV1'); expect(block('FlowTilePlacementV1')).not.toContain('[k: string]'); + expect(block('GrafanaGridTilePlacementV1')).not.toContain('[k: string]'); expect(block('DashboardDocumentV1')).not.toContain('[k: string]'); expect(block('PortableBundleV1')).toContain('dashboards: DashboardDocumentV1[];'); expect(block('StoredWorkspaceV1')).toContain('dashboard: DashboardDocumentV1 | null;'); diff --git a/tests/unit/workspace-semantics.test.ts b/tests/unit/workspace-semantics.test.ts index bdf5df54..88a0576c 100644 --- a/tests/unit/workspace-semantics.test.ts +++ b/tests/unit/workspace-semantics.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { - isSupportedLayout, queryDashboardRole, + isFlowLayout, isSupportedLayout, queryDashboardRole, unsupportedDashboardVersionDiagnostics, unsupportedSpecVersionDiagnostics, validateDashboardCollectionSemantics, validateDashboardSemantics, validateQueryCollectionSemantics, @@ -20,6 +20,7 @@ const filterQuery = (id: string, sql = "SELECT ['a','b'] AS country") => ({ id, sql, specVersion: 1, spec: { name: id, dashboard: { role: 'filter' } }, }); const flowLayout = (items: Record = {}) => ({ type: 'flow', version: 1, preset: 'full-width', items }); +const gridLayout = (items: Record = {}) => ({ type: 'grafana-grid', version: 1, items }); const dashboardDoc = (over: Record = {}) => ({ documentVersion: 1, id: 'd1', title: 'D', revision: 1, layout: flowLayout(), filters: [], tiles: [], ...over, @@ -27,10 +28,20 @@ const dashboardDoc = (over: Record = {}) => ({ const tile = (id: string, queryId: string, over: Record = {}) => ({ id, queryId, ...over }); describe('helper predicates', () => { - it('isSupportedLayout accepts only flow@1', () => { + it('isSupportedLayout accepts every registered primary engine at its supported version', () => { expect(isSupportedLayout('flow', 1)).toBe(true); expect(isSupportedLayout('flow', 2)).toBe(false); + expect(isSupportedLayout('grafana-grid', 1)).toBe(true); + expect(isSupportedLayout('grafana-grid', 2)).toBe(false); expect(isSupportedLayout('grid', 1)).toBe(false); + expect(isSupportedLayout(null, 1)).toBe(false); + expect(isSupportedLayout('flow', '1')).toBe(false); + }); + + it('isFlowLayout accepts only flow@1, even for another supported engine', () => { + expect(isFlowLayout('flow', 1)).toBe(true); + expect(isFlowLayout('flow', 2)).toBe(false); + expect(isFlowLayout('grafana-grid', 1)).toBe(false); }); it('queryDashboardRole defaults to panel through every non-role shape', () => { @@ -225,6 +236,47 @@ describe('validateDashboardSemantics', () => { expect(has(validateDashboardSemantics(orphanFallback, { queries: [panelQuery('p1')] }), 'layout-orphan-placement')).toBe(true); }); + it('validates a grafana-grid@1 primary against its own schema AND still requires a flow@1 fallback (#291)', () => { + const clean = dashboardDoc({ + tiles: [tile('t1', 'p1')], + layout: gridLayout({ t1: { span: 6, height: 'medium' } }), + }); + // A known-but-non-flow primary always needs its own fallback, even when + // its own items are perfectly valid. + expect(has(validateDashboardSemantics(clean, { queries: [panelQuery('p1')] }), 'layout-unsupported-without-fallback')).toBe(true); + + const withFallback = dashboardDoc({ + tiles: [tile('t1', 'p1')], + layout: { ...gridLayout({ t1: { span: 6, height: 'medium' } }), fallback: flowLayout({ t1: { span: 2 } }) }, + }); + expect(validateDashboardSemantics(withFallback, { queries: [panelQuery('p1')] })).toEqual([]); + + // Invalid grid placement (span out of 1..12 range) is validated against + // grafana-grid's OWN schema, not the flow schema. + const badGridItems = dashboardDoc({ + tiles: [tile('t1', 'p1')], + layout: { ...gridLayout({ t1: { span: 13 } }), fallback: flowLayout({ t1: { span: 2 } }) }, + }); + expect(has(validateDashboardSemantics(badGridItems, { queries: [panelQuery('p1')] }), 'schema-number-range')).toBe(true); + + // A bad flow fallback is still reported even when the grid primary itself is fine. + const badFallback = dashboardDoc({ + tiles: [tile('t1', 'p1')], + layout: { ...gridLayout({ t1: { span: 6 } }), fallback: { type: 'flow', version: 1, preset: 'nope', items: {} } }, + }); + expect(has(validateDashboardSemantics(badFallback, { queries: [panelQuery('p1')] }), 'schema-invalid-enum')).toBe(true); + + // The grid primary's own schema errors omit `resource` too when the + // dashboard itself has no id (mirrors the flow/fallback schema-error path). + const badGridNoId = { + documentVersion: 1, title: 'T', revision: 1, filters: [], + tiles: [tile('t1', 'p1')], + layout: { ...gridLayout({ t1: { span: 13 } }), fallback: flowLayout({ t1: { span: 2 } }) }, + }; + const noIdDiagnostics = validateDashboardSemantics(badGridNoId, { queries: [panelQuery('p1')] }); + expect(noIdDiagnostics.some((d) => d.code === 'schema-number-range' && d.resource === undefined)).toBe(true); + }); + it('enforces the layout item-count limit independently of the tile count', () => { const items: Record = {}; const tiles = []; From 5ab609151348d3fbaaa2304e30c7dae6c555c324 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 18 Jul 2026 17:55:30 +0000 Subject: [PATCH 2/8] feat(#291): engine-aware dashboard authoring commands (wave 2) - change-layout switches engines: flow->grid seeds items from flow placements (span 1->4, 2->6, 3->12; height carried) and snapshots the flow layout as fallback; grid->flow restores the fallback (optionally applying a preset); missing fallback -> dashboard-command-layout-fallback-missing diagnostic - update-placement validates/writes through the ACTIVE engine plugin (grid span 1..12, flow 1..3) via new resolveLayoutPluginSync - add-time seeding under grid uses deriveGrafanaGridPlacement (always explicit) - regenerateGridFallback shared primitive: every tile/placement mutation under grid (commands, tile-membership, saved-query-mutation) deterministically regenerates a valid flow@1 fallback - UI never manages fallback itself - tests: commands engine-switch matrix, fallback regen per mutating command, per-engine placement bounds, membership/mutation grid cases Part of #291. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GLMprUnwnz4oaz3cnLRKcz --- .../dashboard-authoring-session.ts | 16 +- .../application/dashboard-commands.ts | 126 ++++++++++- .../application/saved-query-mutation.ts | 13 +- src/dashboard/application/tile-membership.ts | 19 +- src/dashboard/layouts/grafana-grid-layout.ts | 13 ++ src/dashboard/layouts/layout-registry.ts | 16 ++ tests/unit/dashboard-commands.test.ts | 207 +++++++++++++++++- tests/unit/grafana-grid-layout.test.ts | 27 ++- tests/unit/layout-registry.test.ts | 15 ++ tests/unit/saved-query-mutation.test.ts | 24 ++ tests/unit/tile-membership.test.ts | 29 +++ 11 files changed, 478 insertions(+), 27 deletions(-) diff --git a/src/dashboard/application/dashboard-authoring-session.ts b/src/dashboard/application/dashboard-authoring-session.ts index aad14518..b8680c67 100644 --- a/src/dashboard/application/dashboard-authoring-session.ts +++ b/src/dashboard/application/dashboard-authoring-session.ts @@ -29,7 +29,7 @@ import { diagnostic, sortDiagnostics } from '../model/workspace-diagnostics.js'; import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; import { resolveDashboardPresentations } from '../model/presentation-resolver.js'; import { buildDashboardExportBundle } from '../model/dashboard-export.js'; -import { resolveActiveLayoutPlugin } from '../layouts/flow-layout.js'; +import { defaultLayoutRegistry } from '../layouts/layout-registry.js'; import { applyCommand } from './dashboard-commands.js'; import type { DashboardCommand, DashboardCommandResult } from './dashboard-commands.js'; import { createQueryResolver } from './dashboard-query-resolver.js'; @@ -145,16 +145,20 @@ export function createDashboardAuthoringSession( `Command expected draft version ${options.expectedDraftVersion} but the draft is at ${baseVersion}`)], baseVersion); } - // Resolve the active layout plugin — for change-layout, the NEW layout. + // Resolve the active layout plugin through the full registry (#291: the + // grafana-grid@1 engine, not just flow@1) — for change-layout, the NEW + // layout; for every other command, the document's CURRENTLY active + // layout, so `update-placement`/`add-query` seeding and validation route + // through whichever engine (grid or flow) is actually active. const layoutForPlugin = command.type === 'change-layout' ? command.layout : current.document.layout; - const load = resolveActiveLayoutPlugin(layoutForPlugin); - if (!load.ok) return returnFail(load.diagnostics, baseVersion); + const resolved = await defaultLayoutRegistry.resolve(layoutForPlugin); + if (!resolved.ok) return returnFail(resolved.diagnostics, baseVersion); const resolver = createQueryResolver(queries); - const applied = applyCommand(current.document, command, { resolver, genTileId: genId, plugin: load.plugin }); + const applied = applyCommand(current.document, command, { resolver, genTileId: genId, plugin: resolved.plugin }); if (!applied.ok) return returnFail(applied.diagnostics, baseVersion); - const normalized = load.plugin.normalize(applied.dashboard); + const normalized = resolved.plugin.normalize(applied.dashboard); const diagnostics = validateCandidate(normalized); if (diagnostics.length) return returnFail(diagnostics, baseVersion); diff --git a/src/dashboard/application/dashboard-commands.ts b/src/dashboard/application/dashboard-commands.ts index 3947a976..79517c1f 100644 --- a/src/dashboard/application/dashboard-commands.ts +++ b/src/dashboard/application/dashboard-commands.ts @@ -17,8 +17,12 @@ import { cloneJson } from '../../core/saved-query.js'; import { diagnostic } from '../model/workspace-diagnostics.js'; import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; -import { deriveFlowPlacement, setFlowPlacement } from '../layouts/flow-layout.js'; +import { deriveFlowPlacement, resolvePlacement, setFlowPlacement } from '../layouts/flow-layout.js'; import type { DashboardLayoutPlugin } from '../layouts/flow-layout.js'; +import { + deriveGrafanaGridPlacement, gridSpanFromFlowSpan, + regenerateGridFallback as regenerateGridLayoutFallback, setGridPlacement, +} from '../layouts/grafana-grid-layout.js'; import type { QueryResolver } from './dashboard-query-resolver.js'; import type { DashboardDocumentV1, DashboardLayoutDocumentV1, DashboardTileV1, @@ -67,6 +71,30 @@ const missingTile = (tileId: string): WorkspaceDiagnostic => const tileIndex = (tiles: unknown[], tileId: string): number => tiles.findIndex((tile) => isObject(tile) && tile.id === tileId); +/** Command types whose tile/placement mutation must regenerate a + * grafana-grid@1 layout's flow@1 `fallback` (#291 "every grid mutation + * regenerates the flow@1 fallback deterministically") — every command that + * changes `tiles[]` membership/order or a grid placement. `change-layout` + * manages `fallback` itself (see its case below, both engine-switch + * directions); `update-tile` never touches tiles[] membership/order or + * placements, so it is deliberately excluded. */ +const GRID_FALLBACK_COMMANDS = new Set([ + 'add-query', 'add-query-instance', 'remove-tile', 'move-tile', 'update-placement', +]); + +/** Regenerate a grafana-grid@1 layout's flow@1 `fallback` from the + * document's CURRENT items + tile set, via the single shared primitive + * (`regenerateGridLayoutFallback`, grafana-grid-layout.ts) every #291 + * application-layer mutation path calls — a no-op when the layout is not + * grafana-grid@1. Centralized here (called once, from `applyCommand`) so no + * individual command case needs its own copy. */ +function regenerateGridFallback(dashboard: DashboardDocumentV1): void { + const tileRefs = dashboard.tiles + .filter((tile): tile is DashboardTileV1 => isObject(tile) && typeof tile.id === 'string') + .map((tile) => ({ id: tile.id })); + regenerateGridLayoutFallback(dashboard.layout, tileRefs); +} + /** One-level merge of an `update-tile` presentation patch onto the tile's * existing presentation. A non-object patch replaces the whole field; else * each patch sub-field replaces (or, when `null`, deletes) the same sub-field @@ -86,11 +114,22 @@ function mergeTilePresentation(existing: unknown, patch: unknown): unknown { /** Apply one command to an ISOLATED clone of `draft`, returning the candidate * dashboard or command-level diagnostics. The input `draft` is never - * mutated. */ + * mutated. On success, when the ACTIVE command type touches tiles[] or grid + * placements while grafana-grid@1 is the primary layout, the flow@1 + * `fallback` is regenerated as one centralized post-step (#291; + * `change-layout` is excluded — it manages `fallback` itself). */ export function applyCommand( draft: DashboardDocumentV1, command: DashboardCommand, ctx: ApplyCommandContext, ): ApplyCommandResult { const dashboard = cloneJson(draft); + const result = applyCommandToClone(dashboard, command, ctx); + if (result.ok && GRID_FALLBACK_COMMANDS.has(command.type)) regenerateGridFallback(result.dashboard); + return result; +} + +function applyCommandToClone( + dashboard: DashboardDocumentV1, command: DashboardCommand, ctx: ApplyCommandContext, +): ApplyCommandResult { const tiles = dashboard.tiles as unknown[]; switch (command.type) { @@ -115,8 +154,16 @@ export function applyCommand( const query = ctx.resolver.get(queryId); const sizeHints = isObject(query) && isObject(query.spec) && isObject(query.spec.dashboard) ? query.spec.dashboard.sizeHints : undefined; - const placement = deriveFlowPlacement(sizeHints); - if (placement) setFlowPlacement(dashboard.layout, id, placement); + // Add-time placement seeding is engine-aware (#291): grafana-grid@1 + // always gets an explicit placement (its own default when there is no + // usable hint), matching `deriveGrafanaGridPlacement`'s "no opinion" + // contract being the grid default rather than flow's bare `undefined`. + if (ctx.plugin.type === 'grafana-grid') { + setGridPlacement(dashboard.layout, id, deriveGrafanaGridPlacement(sizeHints)); + } else { + const placement = deriveFlowPlacement(sizeHints); + if (placement) setFlowPlacement(dashboard.layout, id, placement); + } return { ok: true, dashboard, value: { tileId: id } }; } @@ -176,19 +223,80 @@ export function applyCommand( case 'update-placement': { const index = tileIndex(tiles, command.tileId); if (index < 0) return failWith(missingTile(command.tileId)); + // Validated through the ACTIVE engine's own plugin (`ctx.plugin`, + // resolved by the session from the current document — grid: span + // 1..12; flow: span 1..3) rather than a hardcoded flow plugin (#291). const placementDiags = ctx.plugin.validatePlacement(command.placement, ['layout', 'items', command.tileId]); if (placementDiags.length) return { ok: false, diagnostics: placementDiags }; - setFlowPlacement(dashboard.layout, command.tileId, cloneJson(command.placement)); + if (ctx.plugin.type === 'grafana-grid') { + setGridPlacement(dashboard.layout, command.tileId, cloneJson(command.placement)); + } else { + setFlowPlacement(dashboard.layout, command.tileId, cloneJson(command.placement)); + } return { ok: true, dashboard, value: undefined }; } // change-layout: the load/version/fallback failures are the session's - // `resolveActiveLayoutPlugin` step; a normalization that produces an - // invalid document is the session's validation step. Here we only install - // the (cloned) new layout. - default: + // registry-resolution step; a normalization that produces an invalid + // document is the session's validation step. Here we install the new + // layout AND, when it is an engine switch (#291 owner decision 3), + // convert placements between engines: + // - flow -> grafana-grid: seed every tile's grid placement from its + // current flow placement (`resolvePlacement` fills the flow default + // for a tile with none), converted via `gridSpanFromFlowSpan`, and + // snapshot the CURRENT flow layout (minus its own `fallback`, which a + // flow primary never carries) as the new `layout.fallback`. + // - grafana-grid -> flow: restore `layout.fallback` (kept continuously + // valid by the grid-mutation fallback regeneration above) as the new + // flow primary, dropping the `fallback` field itself (a flow primary + // IS the fallback engine, so it never carries one). Switching to a + // flow PRESET while grid is active is exactly this restore, then + // applying `command.layout.preset` on top — the caller (Wave 3 UI) + // only ever needs to send `{ type: 'flow', version: 1, preset? }`. + // - anything else (same-engine change, e.g. a flow preset switch while + // flow is already active, or an unrecognized combination): install the + // (cloned) new layout document wholesale, as `change-layout` always + // did before #291. A regenerated fallback still backstops a wholesale + // grid layout that did not already carry one. + case 'change-layout': { + const currentType = dashboard.layout.type; + const targetType = command.layout.type; + + if (currentType === 'flow' && targetType === 'grafana-grid') { + const flowItems = dashboard.layout.items ?? {}; + const gridItems: Record = {}; + for (const tile of tiles) { + if (!isObject(tile) || typeof tile.id !== 'string') continue; + const flowPlacement = resolvePlacement(flowItems[tile.id]); + gridItems[tile.id] = { span: gridSpanFromFlowSpan(flowPlacement.span), height: flowPlacement.height }; + } + // Drop the (never-present-on-a-flow-primary) `fallback` field before + // snapshotting — a flow primary IS the fallback engine, so it never + // carries one, but this stays defensive against a malformed input. + const { fallback: _droppedFallback, ...flowSnapshot } = dashboard.layout; + dashboard.layout = { + type: 'grafana-grid', version: 1, items: gridItems, fallback: flowSnapshot, + } as unknown as DashboardLayoutDocumentV1; + return { ok: true, dashboard, value: undefined }; + } + + if (currentType === 'grafana-grid' && targetType === 'flow') { + const fallback = dashboard.layout.fallback; + if (!fallback) { + return failWith(diagnostic(['layout'], 'dashboard-command-layout-fallback-missing', + 'No flow@1 fallback to restore for this Dashboard')); + } + const restored = cloneJson(fallback) as unknown as Record; + const preset = typeof command.layout.preset === 'string' ? command.layout.preset : undefined; + if (preset !== undefined) restored.preset = preset; + dashboard.layout = restored as unknown as DashboardLayoutDocumentV1; + return { ok: true, dashboard, value: undefined }; + } + dashboard.layout = cloneJson(command.layout); + regenerateGridFallback(dashboard); return { ok: true, dashboard, value: undefined }; + } } } diff --git a/src/dashboard/application/saved-query-mutation.ts b/src/dashboard/application/saved-query-mutation.ts index c3bd4bdf..ae910ac7 100644 --- a/src/dashboard/application/saved-query-mutation.ts +++ b/src/dashboard/application/saved-query-mutation.ts @@ -20,7 +20,8 @@ import type { JsonSchemaValidationService } from '../../core/json-schema-validat import type { SpecSchemaService } from '../../core/spec-schema.js'; import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; import { resolveDashboardPresentations } from '../model/presentation-resolver.js'; -import { flowLayoutPlugin } from '../layouts/flow-layout.js'; +import { resolveLayoutPluginSync } from '../layouts/layout-registry.js'; +import { regenerateGridFallback } from '../layouts/grafana-grid-layout.js'; import { validateStoredWorkspaceDocument } from '../../workspace/stored-workspace.js'; import type { DashboardDocumentV1, SavedQueryV2, StoredWorkspaceV1, @@ -166,7 +167,15 @@ export function planSavedQueryMutation( const queries = applyQueryMutation(workspace.queries, mutation); let dashboard: DashboardDocumentV1 | null = workspace.dashboard ? cloneJson(workspace.dashboard) : null; if (dashboard && repair) dashboard = applyRepair(dashboard, mutation.queryId, repair); - if (dashboard) dashboard = flowLayoutPlugin.normalize(dashboard); + // Normalize through the ACTIVE layout engine's own plugin (#291: flow@1 or + // grafana-grid@1, resolved from the document's own `layout.type`) rather + // than a hardcoded flow plugin, then regenerate the flow@1 fallback when + // grafana-grid@1 is active (a repair can add/remove tiles, exactly like the + // authoring commands do) — a no-op under flow@1. + if (dashboard) { + dashboard = resolveLayoutPluginSync(dashboard.layout).normalize(dashboard); + regenerateGridFallback(dashboard.layout, dashboard.tiles.map((tile) => ({ id: tile.id }))); + } const candidate: StoredWorkspaceV1 = { storageVersion: 1, id: workspace.id, name: workspace.name, queries: cloneJson(queries), dashboard, diff --git a/src/dashboard/application/tile-membership.ts b/src/dashboard/application/tile-membership.ts index 31349ee8..da066584 100644 --- a/src/dashboard/application/tile-membership.ts +++ b/src/dashboard/application/tile-membership.ts @@ -16,7 +16,8 @@ // folds the result into the same commit candidate as the favorite patch. import { queryDashboardRole } from '../model/workspace-semantics.js'; -import { flowLayoutPlugin } from '../layouts/flow-layout.js'; +import { resolveLayoutPluginSync } from '../layouts/layout-registry.js'; +import { regenerateGridFallback } from '../layouts/grafana-grid-layout.js'; import type { DashboardDocumentV1, SavedQueryV2 } from '../../generated/json-schema.types.js'; /** Remove every tile referencing `queryId`, and scrub those tile ids out of @@ -47,9 +48,15 @@ function removeTilesForQuery(dashboard: DashboardDocumentV1, queryId: string): D * ids from every filter's `targets`. * - `dashboard` null in → `null` out (no Dashboard yet; favorite flip only). * - * The result is always run through `flowLayoutPlugin.normalize` (a new tile - * gets the flow default placement lazily — nothing to add up front — and a - * removed tile's placement item, if any, is dropped) and is always a fresh + * The result is always run through the ACTIVE layout engine's own + * `normalize` (#291: flow@1 or grafana-grid@1, resolved from the document's + * OWN `layout.type` via `resolveLayoutPluginSync` rather than a hardcoded + * flow plugin) — a new tile gets its engine's default placement lazily + * (nothing to add up front), and a removed tile's placement item, if any, is + * dropped. When grafana-grid@1 is active, the flow@1 `fallback` is then + * regenerated too (#291 "every grid mutation regenerates the flow@1 + * fallback"; a no-op under flow@1) — this membership change adds/removes a + * tile just like the authoring commands do. The result is always a fresh * copy; `dashboard` is never mutated. */ export function toggleTileMembership( @@ -68,5 +75,7 @@ export function toggleTileMembership( } else if (hasTile) { next = removeTilesForQuery(dashboard, query.id); } - return flowLayoutPlugin.normalize(next); + const normalized = resolveLayoutPluginSync(next.layout).normalize(next); + regenerateGridFallback(normalized.layout, normalized.tiles.map((tile) => ({ id: tile.id }))); + return normalized; } diff --git a/src/dashboard/layouts/grafana-grid-layout.ts b/src/dashboard/layouts/grafana-grid-layout.ts index 43f6f436..06265dbf 100644 --- a/src/dashboard/layouts/grafana-grid-layout.ts +++ b/src/dashboard/layouts/grafana-grid-layout.ts @@ -294,3 +294,16 @@ export function deriveFlowFallback( } return { type: 'flow', version: 1, preset: 'full-width', items: flowItems }; } + +/** Regenerate a grafana-grid@1 layout's flow@1 `fallback` IN PLACE from its + * current `items` + the given tile set (mutates `layout.fallback`, mirroring + * `setGridPlacement`'s own mutate-in-place contract) — a no-op when `layout` + * is not a grafana-grid@1 document. The single shared primitive every #291 + * application-layer mutation path (authoring commands, tile-membership star + * toggle, saved-query mutation planning) calls so "every grid mutation + * regenerates the flow@1 fallback deterministically" is enforced once, not + * duplicated per call site. */ +export function regenerateGridFallback(layout: unknown, tiles: readonly GrafanaGridFallbackTile[]): void { + if (!isObject(layout) || layout.type !== 'grafana-grid') return; + layout.fallback = deriveFlowFallback(layout, tiles); +} diff --git a/src/dashboard/layouts/layout-registry.ts b/src/dashboard/layouts/layout-registry.ts index 2cc9bba7..99cad88e 100644 --- a/src/dashboard/layouts/layout-registry.ts +++ b/src/dashboard/layouts/layout-registry.ts @@ -128,3 +128,19 @@ export function createLayoutRegistry( /** The default registry: the built-in flow@1 engine plus grafana-grid@1 * (#291), the first second-engine consumer of this registry. */ export const defaultLayoutRegistry: DashboardLayoutRegistry = createLayoutRegistry([grafanaGridLayoutRegistration]); + +/** Synchronous plugin resolution for pure call sites that mutate a Dashboard + * document but cannot await the async registry (`tile-membership.ts`, + * `saved-query-mutation.ts` — #291): the grafana-grid@1 plugin when the + * layout's primary is grafana-grid@1, else the flow@1 plugin. Both built-in + * plugins are stateless, already-constructed values (`load()` never truly + * defers for either — see the module doc comment above), so no async is + * needed to pick between them. Falling back to the flow plugin for any + * OTHER primary (unknown/unsupported, or a genuine flow@1 primary) matches + * `resolve()`'s own fallback behavior: `flowLayoutPlugin.normalize` already + * knows how to operate against a `fallback` when the primary isn't flow@1 + * itself (`flowItemsHost`/`flowSurface` in flow-layout.ts). */ +export function resolveLayoutPluginSync(layout: unknown): DashboardLayoutPlugin { + if (isObject(layout) && layout.type === 'grafana-grid' && layout.version === 1) return grafanaGridLayoutPlugin; + return flowLayoutPlugin; +} diff --git a/tests/unit/dashboard-commands.test.ts b/tests/unit/dashboard-commands.test.ts index 74c71a18..9bbb1d16 100644 --- a/tests/unit/dashboard-commands.test.ts +++ b/tests/unit/dashboard-commands.test.ts @@ -3,6 +3,8 @@ import { applyCommand } from '../../src/dashboard/application/dashboard-commands import type { ApplyCommandContext, DashboardCommand } from '../../src/dashboard/application/dashboard-commands.js'; import { createQueryResolver } from '../../src/dashboard/application/dashboard-query-resolver.js'; import { flowLayoutPlugin } from '../../src/dashboard/layouts/flow-layout.js'; +import { grafanaGridLayoutPlugin } from '../../src/dashboard/layouts/grafana-grid-layout.js'; +import type { DashboardLayoutPlugin } from '../../src/dashboard/layouts/flow-layout.js'; import type { DashboardDocumentV1 } from '../../src/generated/json-schema.types.js'; const query = (id: string, dashboard?: Record) => ({ @@ -14,13 +16,13 @@ const draft = (over: Partial = {}): DashboardDocumentV1 => layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, filters: [], tiles: [], ...over, } as DashboardDocumentV1); -const makeCtx = (queries: unknown[]): ApplyCommandContext => { +const makeCtx = (queries: unknown[], plugin: DashboardLayoutPlugin = flowLayoutPlugin): ApplyCommandContext => { let n = 0; - return { resolver: createQueryResolver(queries), genTileId: () => `tile-${++n}`, plugin: flowLayoutPlugin }; + return { resolver: createQueryResolver(queries), genTileId: () => `tile-${++n}`, plugin }; }; -const run = (d: DashboardDocumentV1, command: DashboardCommand, queries: unknown[]) => - applyCommand(d, command, makeCtx(queries)); +const run = (d: DashboardDocumentV1, command: DashboardCommand, queries: unknown[], plugin?: DashboardLayoutPlugin) => + applyCommand(d, command, makeCtx(queries, plugin)); describe('applyCommand — add-query / add-query-instance', () => { it('adds a default tile and derives an initial placement from size hints', () => { @@ -155,6 +157,203 @@ describe('applyCommand — update-placement / change-layout', () => { }); }); +const gridDraft = (over: Partial = {}): DashboardDocumentV1 => draft({ + layout: { type: 'grafana-grid', version: 1, items: {} } as never, ...over, +}); + +describe('applyCommand — grafana-grid@1 engine awareness (#291)', () => { + describe('add-time placement seeding under grid', () => { + it('seeds an explicit grid placement from size hints (deriveGrafanaGridPlacement), not deriveFlowPlacement', () => { + const q = query('q', { sizeHints: { preferred: 'wide' } }); + const result = run(gridDraft(), { type: 'add-query', queryId: 'q' }, [q], grafanaGridLayoutPlugin); + expect(result.ok).toBe(true); + if (result.ok) expect(result.dashboard.layout.items).toEqual({ 'tile-1': { span: 12, height: 'medium' } }); + }); + + it('seeds the grid default placement even without a usable size hint — unlike flow\'s "no opinion" (undefined)', () => { + const result = run(gridDraft(), { type: 'add-query', queryId: 'q' }, [query('q')], grafanaGridLayoutPlugin); + expect(result.ok).toBe(true); + if (result.ok) expect(result.dashboard.layout.items).toEqual({ 'tile-1': { span: 6, height: 'medium' } }); + }); + }); + + describe('update-placement bounds per engine', () => { + it('validates through the ACTIVE grid plugin: spans up to 12 are valid, 13 is rejected', () => { + const seeded = gridDraft({ tiles: [{ id: 't1', queryId: 'q' }] as never }); + const ok = run(seeded, { type: 'update-placement', tileId: 't1', placement: { span: 9 } }, [query('q')], grafanaGridLayoutPlugin); + expect(ok.ok && ok.dashboard.layout.items).toEqual({ t1: { span: 9 } }); + const tooLarge = run(seeded, { type: 'update-placement', tileId: 't1', placement: { span: 13 } }, [query('q')], grafanaGridLayoutPlugin); + expect(tooLarge.ok).toBe(false); + if (!tooLarge.ok) expect(tooLarge.diagnostics[0].code).toBe('layout-placement-invalid-span'); + }); + + it('a span the flow engine would reject (9) is accepted only because ctx.plugin is the grid one — proves it is not hardcoded', () => { + const seeded = gridDraft({ tiles: [{ id: 't1', queryId: 'q' }] as never }); + const underFlow = run(seeded, { type: 'update-placement', tileId: 't1', placement: { span: 9 } }, [query('q')], flowLayoutPlugin); + expect(underFlow.ok).toBe(false); + const underGrid = run(seeded, { type: 'update-placement', tileId: 't1', placement: { span: 9 } }, [query('q')], grafanaGridLayoutPlugin); + expect(underGrid.ok).toBe(true); + }); + }); +}); + +describe('applyCommand — change-layout engine switch (#291 owner decision 3)', () => { + it('flow -> grafana-grid seeds every tile\'s grid placement from its current flow placement (span matrix) and snapshots flow as fallback', () => { + const flowLayout = { + type: 'flow', version: 1, preset: 'columns-2', + items: { a: { span: 1, height: 'compact' }, b: { span: 2, height: 'large' }, c: { span: 3 } }, + }; + const d = draft({ + tiles: [ + { id: 'a', queryId: 'q' }, { id: 'b', queryId: 'q' }, { id: 'c', queryId: 'q' }, { id: 'noPlacement', queryId: 'q' }, + ] as never, + layout: flowLayout as never, + }); + const result = run(d, { type: 'change-layout', layout: { type: 'grafana-grid', version: 1 } as never }, [query('q')]); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.dashboard.layout.type).toBe('grafana-grid'); + expect(result.dashboard.layout.items).toEqual({ + a: { span: 4, height: 'compact' }, + b: { span: 6, height: 'large' }, + c: { span: 12, height: 'medium' }, + noPlacement: { span: 4, height: 'medium' }, + }); + expect(result.dashboard.layout.fallback).toEqual(flowLayout); + }); + + it('grafana-grid -> flow restores the fallback verbatim, dropping the fallback field itself', () => { + const fallbackLayout = { type: 'flow', version: 1, preset: 'full-width', items: { t1: { span: 2, height: 'large' } } }; + const grid = { type: 'grafana-grid', version: 1, items: { t1: { span: 6, height: 'large' } }, fallback: fallbackLayout }; + const d = draft({ tiles: [{ id: 't1', queryId: 'q' }] as never, layout: grid as never }); + const result = run(d, { type: 'change-layout', layout: { type: 'flow', version: 1 } as never }, [query('q')]); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.dashboard.layout).toEqual(fallbackLayout); + expect(result.dashboard.layout).not.toHaveProperty('fallback'); + } + }); + + it('switching to a flow PRESET while grid is active restores the fallback, then applies the preset on top', () => { + const fallbackLayout = { type: 'flow', version: 1, preset: 'full-width', items: { t1: { span: 2, height: 'large' } } }; + const grid = { type: 'grafana-grid', version: 1, items: { t1: { span: 6, height: 'large' } }, fallback: fallbackLayout }; + const d = draft({ tiles: [{ id: 't1', queryId: 'q' }] as never, layout: grid as never }); + const result = run(d, { type: 'change-layout', layout: { type: 'flow', version: 1, preset: 'columns-3' } as never }, [query('q')]); + expect(result.ok).toBe(true); + if (result.ok) expect(result.dashboard.layout).toEqual({ ...fallbackLayout, preset: 'columns-3' }); + }); + + it('flow -> grafana-grid tolerates a flow layout with no items field at all (every tile seeds from the flow default)', () => { + const d = draft({ + tiles: [{ id: 'a', queryId: 'q' }] as never, + layout: { type: 'flow', version: 1, preset: 'report' } as never, + }); + const result = run(d, { type: 'change-layout', layout: { type: 'grafana-grid', version: 1 } as never }, [query('q')]); + expect(result.ok).toBe(true); + if (result.ok) expect(result.dashboard.layout.items).toEqual({ a: { span: 4, height: 'medium' } }); + }); + + it('grafana-grid -> flow fails without a fallback to restore, leaving diagnostics', () => { + const d = draft({ layout: { type: 'grafana-grid', version: 1, items: {} } as never }); + const result = run(d, { type: 'change-layout', layout: { type: 'flow', version: 1 } as never }, [query('q')]); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.diagnostics[0].code).toBe('dashboard-command-layout-fallback-missing'); + }); + + it('a same-engine change (grid -> grid) installs the layout wholesale and still backstops a fallback', () => { + const d = draft({ + tiles: [{ id: 't1', queryId: 'q' }] as never, + layout: { type: 'grafana-grid', version: 1, items: { t1: { span: 4 } } } as never, + }); + const result = run(d, { type: 'change-layout', layout: { type: 'grafana-grid', version: 1, items: { t1: { span: 8 } } } as never }, [query('q')]); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.dashboard.layout.items).toEqual({ t1: { span: 8 } }); + expect(result.dashboard.layout.fallback).toEqual({ + type: 'flow', version: 1, preset: 'full-width', items: { t1: { span: 2, height: 'medium' } }, + }); + } + }); +}); + +describe('applyCommand — grid fallback regeneration on every mutating command (#291)', () => { + const seededGrid = () => gridDraft({ + tiles: [{ id: 'a', queryId: 'q' }, { id: 'b', queryId: 'q' }] as never, + layout: { type: 'grafana-grid', version: 1, items: { a: { span: 4 }, b: { span: 8 } } } as never, + }); + + it('add-query regenerates the flow fallback to include the new tile', () => { + const result = run(seededGrid(), { type: 'add-query', queryId: 'c' }, [query('q'), query('c')], grafanaGridLayoutPlugin); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.dashboard.layout.fallback).toEqual({ + type: 'flow', version: 1, preset: 'full-width', + items: { a: { span: 1, height: 'medium' }, b: { span: 2, height: 'medium' }, 'tile-1': { span: 2, height: 'medium' } }, + }); + } + }); + + it('add-query-instance regenerates the fallback too', () => { + const withTile = draft({ + tiles: [{ id: 'a', queryId: 'q' }] as never, + layout: { type: 'grafana-grid', version: 1, items: { a: { span: 12 } } } as never, + }); + const result = run(withTile, { type: 'add-query-instance', queryId: 'q' }, [query('q')], grafanaGridLayoutPlugin); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.dashboard.layout.fallback).toEqual({ + type: 'flow', version: 1, preset: 'full-width', + items: { a: { span: 3, height: 'medium' }, 'tile-1': { span: 2, height: 'medium' } }, + }); + } + }); + + it('remove-tile regenerates the fallback, dropping the removed tile entirely', () => { + const result = run(seededGrid(), { type: 'remove-tile', tileId: 'a' }, [query('q')], grafanaGridLayoutPlugin); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.dashboard.layout.fallback).toEqual({ + type: 'flow', version: 1, preset: 'full-width', items: { b: { span: 2, height: 'medium' } }, + }); + } + }); + + it('move-tile regenerates the fallback (tile SET unchanged, still recomputed deterministically)', () => { + const result = run(seededGrid(), { type: 'move-tile', tileId: 'a', toIndex: 1 }, [query('q')], grafanaGridLayoutPlugin); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.dashboard.layout.fallback).toEqual({ + type: 'flow', version: 1, preset: 'full-width', + items: { a: { span: 1, height: 'medium' }, b: { span: 2, height: 'medium' } }, + }); + } + }); + + it('update-placement regenerates the fallback to reflect the new span', () => { + const result = run(seededGrid(), { type: 'update-placement', tileId: 'a', placement: { span: 12 } }, [query('q')], grafanaGridLayoutPlugin); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.dashboard.layout.fallback).toEqual({ + type: 'flow', version: 1, preset: 'full-width', + items: { a: { span: 3, height: 'medium' }, b: { span: 2, height: 'medium' } }, + }); + } + }); + + it('update-tile does NOT regenerate the fallback — it never touches tiles[] membership/order or placements', () => { + const result = run(seededGrid(), { type: 'update-tile', tileId: 'a', patch: { title: 'x' } }, [query('q')], grafanaGridLayoutPlugin); + expect(result.ok).toBe(true); + if (result.ok) expect((result.dashboard.layout as { fallback?: unknown }).fallback).toBeUndefined(); + }); + + it('never regenerates a flow layout\'s fallback (flow never carries one)', () => { + const flowDoc = draft({ tiles: [{ id: 't1', queryId: 'q' }] as never }); + const result = run(flowDoc, { type: 'add-query', queryId: 'q2' }, [query('q'), query('q2')]); + expect(result.ok).toBe(true); + if (result.ok) expect(result.dashboard.layout).not.toHaveProperty('fallback'); + }); +}); + describe('applyCommand — isolation', () => { it('never mutates the input draft', () => { const input = draft({ tiles: [{ id: 't1', queryId: 'q' }] as never }); diff --git a/tests/unit/grafana-grid-layout.test.ts b/tests/unit/grafana-grid-layout.test.ts index fc536b6c..af683c64 100644 --- a/tests/unit/grafana-grid-layout.test.ts +++ b/tests/unit/grafana-grid-layout.test.ts @@ -3,7 +3,7 @@ import { DEFAULT_GRID_PLACEMENT, GRAFANA_GRID_MAX_COLUMNS, computeGrafanaGridLayout, deriveFlowFallback, deriveGrafanaGridPlacement, effectiveGridColumns, effectiveGridSpan, flowSpanFromGridSpan, grafanaGridLayoutPlugin, - gridSpanFromFlowSpan, resolveGridPlacement, setGridPlacement, + gridSpanFromFlowSpan, regenerateGridFallback, resolveGridPlacement, setGridPlacement, } from '../../src/dashboard/layouts/grafana-grid-layout.js'; import { FLOW_LAYOUT_V1_SCHEMA_ID } from '../../src/dashboard/model/workspace-semantics.js'; import { jsonSchemaValidationService } from '../../src/core/library-codec.js'; @@ -302,3 +302,28 @@ describe('deriveFlowFallback', () => { expect(jsonSchemaValidationService.validate(FLOW_LAYOUT_V1_SCHEMA_ID, fallback)).toEqual([]); }); }); + +describe('regenerateGridFallback', () => { + it('mutates a grafana-grid layout\'s fallback in place, deterministically from its current items', () => { + const layout = gridLayout({ a: { span: 4, height: 'compact' } }); + regenerateGridFallback(layout, [{ id: 'a' }, { id: 'b' }]); + expect((layout as { fallback?: unknown }).fallback).toEqual({ + type: 'flow', version: 1, preset: 'full-width', + items: { a: { span: 1, height: 'compact' }, b: { span: 2, height: 'medium' } }, + }); + }); + + it('overwrites a stale fallback already present on the layout', () => { + const layout = { ...gridLayout({ a: { span: 12 } }), fallback: { type: 'flow', version: 1, preset: 'report', items: {} } }; + regenerateGridFallback(layout, [{ id: 'a' }]); + expect(layout.fallback).toEqual({ type: 'flow', version: 1, preset: 'full-width', items: { a: { span: 3, height: 'medium' } } }); + }); + + it('is a no-op on a non-grid layout or a non-object value', () => { + const flow = { type: 'flow', version: 1, preset: 'full-width', items: {} }; + regenerateGridFallback(flow, [{ id: 'a' }]); + expect(flow).not.toHaveProperty('fallback'); + expect(() => regenerateGridFallback(null, [{ id: 'a' }])).not.toThrow(); + expect(() => regenerateGridFallback('nope', [{ id: 'a' }])).not.toThrow(); + }); +}); diff --git a/tests/unit/layout-registry.test.ts b/tests/unit/layout-registry.test.ts index 8a23322b..d4750a9c 100644 --- a/tests/unit/layout-registry.test.ts +++ b/tests/unit/layout-registry.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createLayoutRegistry, defaultLayoutRegistry, flowLayoutRegistration, grafanaGridLayoutRegistration, + resolveLayoutPluginSync, } from '../../src/dashboard/layouts/layout-registry.js'; import { flowLayoutPlugin } from '../../src/dashboard/layouts/flow-layout.js'; import { grafanaGridLayoutPlugin } from '../../src/dashboard/layouts/grafana-grid-layout.js'; @@ -140,3 +141,17 @@ describe('defaultLayoutRegistry', () => { expect(result.ok).toBe(false); }); }); + +describe('resolveLayoutPluginSync (#291)', () => { + it('resolves the grafana-grid@1 plugin for a grafana-grid@1 primary', () => { + expect(resolveLayoutPluginSync({ type: 'grafana-grid', version: 1, items: {} })).toBe(grafanaGridLayoutPlugin); + }); + + it('resolves the flow@1 plugin for everything else — a flow primary, an unsupported grid version, an unknown type, or a non-object', () => { + expect(resolveLayoutPluginSync(flow())).toBe(flowLayoutPlugin); + expect(resolveLayoutPluginSync({ type: 'grafana-grid', version: 2, items: {} })).toBe(flowLayoutPlugin); + expect(resolveLayoutPluginSync({ type: 'nope', version: 1 })).toBe(flowLayoutPlugin); + expect(resolveLayoutPluginSync(null)).toBe(flowLayoutPlugin); + expect(resolveLayoutPluginSync('nope')).toBe(flowLayoutPlugin); + }); +}); diff --git a/tests/unit/saved-query-mutation.test.ts b/tests/unit/saved-query-mutation.test.ts index a9ee82f9..b3998db1 100644 --- a/tests/unit/saved-query-mutation.test.ts +++ b/tests/unit/saved-query-mutation.test.ts @@ -225,6 +225,30 @@ describe('planSavedQueryMutation — repairs skip unaffected and target-less ent }); }); +describe('planSavedQueryMutation — grafana-grid@1 engine awareness (#291)', () => { + it('normalizes through the ACTIVE grid plugin and regenerates the flow@1 fallback on a tile-removing repair', () => { + const workspace: StoredWorkspaceV1 = { + storageVersion: 1, id: 'ws', name: 'WS', + queries: [panelQuery('p1', 'SELECT a,b WHERE c={country:String}'), filterQuery('f1')], + dashboard: { + documentVersion: 1, id: 'dash', title: 'D', revision: 1, + layout: { type: 'grafana-grid', version: 1, items: { t1: { span: 8 } } }, + filters: [{ id: 'flt', parameter: 'country', sourceQueryId: 'f1', targets: ['t1'] }], + tiles: [{ id: 't1', queryId: 'p1' }], + }, + } as StoredWorkspaceV1; + const plan = planSavedQueryMutation(workspace, { type: 'delete-query', queryId: 'p1' }, { type: 'remove-affected-tiles' }); + expect(plan.ok).toBe(true); + const dashboard = plan.candidate!.dashboard!; + expect(dashboard.layout.type).toBe('grafana-grid'); + expect(dashboard.tiles).toEqual([]); + expect(dashboard.layout.items).toEqual({}); // orphan grid placement pruned + expect((dashboard.layout as { fallback?: unknown }).fallback).toEqual({ + type: 'flow', version: 1, preset: 'full-width', items: {}, + }); + }); +}); + describe('planSavedQueryMutation — no dashboard, and suggestRepairs', () => { it('always accepts a mutation when the workspace has no dashboard', () => { const workspace = { ...baseWorkspace(), dashboard: null } as StoredWorkspaceV1; diff --git a/tests/unit/tile-membership.test.ts b/tests/unit/tile-membership.test.ts index 71435d28..5971cec0 100644 --- a/tests/unit/tile-membership.test.ts +++ b/tests/unit/tile-membership.test.ts @@ -102,3 +102,32 @@ describe('toggleTileMembership', () => { expect((added.layout as { items: Record }).items).toEqual({}); }); }); + +describe('toggleTileMembership — grafana-grid@1 engine awareness (#291)', () => { + it('normalizes through the ACTIVE grid plugin (not a hardcoded flow one) and regenerates the flow@1 fallback on star ON', () => { + const d = dashboard({ layout: { type: 'grafana-grid', version: 1, items: {} } }); + const next = toggleTileMembership(d, panelQuery('p1'), true, genTileId())!; + expect(next.layout.type).toBe('grafana-grid'); + expect(next.tiles).toEqual([{ id: 'tile-1', queryId: 'p1' }]); + // No explicit grid placement is seeded here either (parity with flow's own + // pre-#291 behavior) — the new tile resolves to the grid default (span 6) + // at render time, which the regenerated fallback reflects (flow span 2). + expect((next.layout as { items: Record }).items).toEqual({}); + expect((next.layout as { fallback?: unknown }).fallback).toEqual({ + type: 'flow', version: 1, preset: 'full-width', items: { 'tile-1': { span: 2, height: 'medium' } }, + }); + }); + + it('regenerates the flow@1 fallback (dropping the removed tile) on star OFF', () => { + const d = dashboard({ + layout: { type: 'grafana-grid', version: 1, items: { t1: { span: 12 } } }, + tiles: [{ id: 't1', queryId: 'p1' }], + }); + const next = toggleTileMembership(d, panelQuery('p1'), false, genTileId())!; + expect(next.tiles).toEqual([]); + expect((next.layout as { items: Record }).items).toEqual({}); + expect((next.layout as { fallback?: unknown }).fallback).toEqual({ + type: 'flow', version: 1, preset: 'full-width', items: {}, + }); + }); +}); From 6e3cce3acc7023ce7ef43b84d47e4afb6e0261e8 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 18 Jul 2026 18:30:46 +0000 Subject: [PATCH 3/8] feat(#291): rowless grafana-grid UI - viewer wiring, grid render, edit interactions (wave 3) - dashboard-viewer-session: layout routed through the registry-resolved active plugin; DashboardViewState.layout becomes a discriminated union ({engine:'flow', ...FlowLayoutModel} | {engine:'grafana-grid', grid}); containerWidth dep feeds the responsive column clamp (12/6/4/2) - src/ui/dashboard.ts: single-CSS-grid reconciliation path (.dash-gg-grid, 8px gap, span + semantic-height classes 118/210/296px), KPI tiles placed like any tile; layout select extended to 4 flow presets + Grafana grid driving engine-switching change-layout; edit mode (!readOnly): whole-card drag-reorder (move-tile), corner-drag resize with pure snap math -> one update-placement per drag, tile delete (remove-tile); immediate propagation - no Save/Undo (owner decision) - fixed pre-existing runCommand bug: plugin was hardcoded to flow for validation + normalize; now resolves the active engine (post-command layout for normalize, so engine switches normalize through the result) - grafana-grid-layout: GRID_GAP_PX/GRID_HEIGHT_PX + snapGridSpan/ snapGridHeight pure resize math (100% covered) - styles.css: grid host/tile/edit-affordance chrome, both themes Part of #291. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GLMprUnwnz4oaz3cnLRKcz --- .../application/dashboard-viewer-session.ts | 40 ++- src/dashboard/layouts/grafana-grid-layout.ts | 40 +++ src/styles.css | 70 +++++ src/ui/dashboard.ts | 274 ++++++++++++++++-- tests/unit/dashboard-viewer-session.test.ts | 115 +++++++- tests/unit/dashboard.test.ts | 192 ++++++++++++ tests/unit/grafana-grid-layout.test.ts | 49 +++- 7 files changed, 749 insertions(+), 31 deletions(-) diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts index e53616d6..167e6419 100644 --- a/src/dashboard/application/dashboard-viewer-session.ts +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -48,6 +48,9 @@ import { diagnostic as wsDiagnostic } from '../model/workspace-diagnostics.js'; import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; import { computeFlowLayout } from '../layouts/flow-layout.js'; import type { FlowLayoutModel } from '../layouts/flow-layout.js'; +import { computeGrafanaGridLayout } from '../layouts/grafana-grid-layout.js'; +import type { GrafanaGridLayoutModel } from '../layouts/grafana-grid-layout.js'; +import { resolveLayoutPluginSync } from '../layouts/layout-registry.js'; import type { DashboardLayoutRegistry } from '../layouts/layout-registry.js'; import type { DashboardDocumentV1, DashboardTileV1, DashboardFilterDefinitionV1, Panel, SavedQueryV2, @@ -98,10 +101,20 @@ export interface ViewerFilterState { options: FilterHelperOption[] | null; } +/** The Dashboard's per-render layout view (#291) — a discriminated union over + * the active layout ENGINE (`resolveLayoutPluginSync`, layout-registry.ts): + * `flow` keeps every `FlowLayoutModel` field verbatim (bit-identical to the + * pre-#291 shape) with an `engine` tag added; `grafana-grid` nests its own + * render model under `grid` instead of spreading it, so the two engines' + * same-named fields (both have `columns`) never collide on one object. */ +export type DashboardLayoutView = + | (FlowLayoutModel & { engine: 'flow' }) + | { engine: 'grafana-grid'; grid: GrafanaGridLayoutModel }; + export interface DashboardViewState { tiles: ViewerTileState[]; filters: ViewerFilterState[]; - layout: FlowLayoutModel; + layout: DashboardLayoutView; /** Count of ACTIVE filter DEFINITIONS (not non-empty stored values, #188). */ activeFilterCount: number; running: boolean; @@ -152,6 +165,13 @@ export interface DashboardViewerDeps { wallNow(): number; /** True at/below the mobile breakpoint — normalizes the flow to one column. */ isMobile?(): boolean; + /** Rendering container width in px, for the grafana-grid engine's own + * responsive effective-columns clamp (12/6/4/2 at the 1160/720/470 + * breakpoints, `effectiveGridColumns`) — flow's responsive behavior stays + * the coarser `isMobile` binary flip above, unaffected by this. Absent or + * non-finite (not yet measured, or a non-DOM consumer) renders at the + * widest desktop breakpoint (12). */ + containerWidth?(): number | undefined; /** Fired when the token preflight fails (the shell wires sign-out). */ onAuthFailed?(): void; /** #171 bound-param recording on a successful tile. */ @@ -397,10 +417,26 @@ 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 })); + // #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 + // publish and cannot await the async registry). An unsupported/foreign + // primary with a valid flow@1 fallback still resolves to the flow plugin + // here, exactly as before #291 (`computeFlowLayout`'s own fallback + // handling, untouched) — flow behavior stays bit-identical. + const plugin = resolveLayoutPluginSync(documentRef.layout); + const layout: DashboardLayoutView = plugin.type === 'grafana-grid' + ? { + engine: 'grafana-grid', + grid: computeGrafanaGridLayout({ + tiles: visible, layout: documentRef.layout, containerWidth: deps.containerWidth?.(), + }), + } + : { engine: 'flow', ...computeFlowLayout({ tiles: visible, layout: documentRef.layout, mobile }) }; return { tiles: tiles.map((runtime) => ({ ...runtime.state })), filters: filters.map((filter) => ({ ...filter.state })), - layout: computeFlowLayout({ tiles: visible, layout: documentRef.layout, mobile }), + layout, activeFilterCount: filters.filter((filter) => filter.state.active).length, running, updatedAt, diagnostics: presentationDiagnostics, }; diff --git a/src/dashboard/layouts/grafana-grid-layout.ts b/src/dashboard/layouts/grafana-grid-layout.ts index 06265dbf..4551b716 100644 --- a/src/dashboard/layouts/grafana-grid-layout.ts +++ b/src/dashboard/layouts/grafana-grid-layout.ts @@ -295,6 +295,46 @@ export function deriveFlowFallback( return { type: 'flow', version: 1, preset: 'full-width', items: flowItems }; } +// ── Pure resize math (#291 Wave 3 — corner-drag resize): the DOM listener in +// ui/dashboard.ts stays a thin imperative adapter (rule 5); the snap/tier +// arithmetic lives here so it is 100%-covered without any DOM. ──────────────── + +/** The 8px gap between grid cells/rows — a single source of truth the CSS + * grid host (`gap: 8px`, styles.css) and the resize pointer math both use, so + * a corner-drag's column-width computation matches what the browser actually + * renders. */ +export const GRID_GAP_PX = 8; + +/** Semantic tile height, in px — the same three tiers the mock encodes + * (118/210/296), used both to render a tile's CSS height class and to snap a + * corner-drag's vertical delta to the nearest tier. */ +export const GRID_HEIGHT_PX: Record = { compact: 118, medium: 210, large: 296 }; + +/** Snap a corner-drag's horizontal pixel delta to a column span: `round((dx + + * gap) / (colWidth + gap))`, clamped to `1..columns` — the same formula the + * design mock's reference implementation uses (`grafana-dashboard-behavior.js`). + * A non-finite/zero `colWidthPx` (no measured column width yet) still returns + * a clamped integer rather than NaN/Infinity. */ +export function snapGridSpan(dxPx: number, colWidthPx: number, gapPx: number, columns: number): number { + const safeColumns = Math.max(1, columns); + const denominator = colWidthPx + gapPx; + if (!Number.isFinite(denominator) || denominator <= 0) return 1; + const raw = Math.round((dxPx + gapPx) / denominator); + return Math.max(1, Math.min(safeColumns, raw)); +} + +/** Snap a corner-drag's vertical pixel delta to the nearest semantic height + * tier (compact|medium|large), by absolute distance to each tier's px value. */ +export function snapGridHeight(dyPx: number): GrafanaGridHeightV1 { + let best: GrafanaGridHeightV1 = 'compact'; + let bestDelta = Infinity; + for (const tier of Object.keys(GRID_HEIGHT_PX) as GrafanaGridHeightV1[]) { + const delta = Math.abs(GRID_HEIGHT_PX[tier] - dyPx); + if (delta < bestDelta) { bestDelta = delta; best = tier; } + } + return best; +} + /** Regenerate a grafana-grid@1 layout's flow@1 `fallback` IN PLACE from its * current `items` + the given tile set (mutates `layout.fallback`, mirroring * `setGridPlacement`'s own mutate-in-place contract) — a no-op when `layout` diff --git a/src/styles.css b/src/styles.css index 6c5e7904..6ae4c6be 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2427,3 +2427,73 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } min-width: 0; max-width: none; width: 100%; } } + +/* ── Grafana-grid layout engine (#291) ───────────────────────────────────── + A second, ROWLESS Dashboard layout: one flat CSS grid host (no `.dash-row` + sub-grids, no KPI band — every tile, KPI or not, is placed the same way). + `.dash-grid.dash-gg-grid` overrides `.dash-grid`'s own `display:flex` with + `display:grid` — same override pattern already used by `.is-report`/ + `.is-wide` above (two classes on the host beat one on specificity), reusing + its outer padding/max-width. Every grid-only rule below is scoped under the + `.dash-gg-grid` ancestor (or the class is explicitly removed by the flow + path, ui/dashboard.ts) so a tile card CACHED across an engine switch never + keeps stray grid chrome once flow renders again. Density/relationships are + the design mock's own (8px gap, semantic tile heights, hover-revealed + dense chrome); colors are this app's own theme tokens (both themes). */ +.dash-grid.dash-gg-grid { + display: grid; grid-auto-rows: min-content; gap: 8px; align-items: stretch; +} +.dash-gg-grid .dash-tile { min-height: 0; } +/* Semantic tile heights (#291 owner decision — reuses flow@1's own + compact/medium/large vocabulary verbatim): fixed px tiers, applied per + tile instead of via a shared row height. */ +.dash-gg-grid .dash-gg-tile.dash-gg-h-compact { height: 118px; } +.dash-gg-grid .dash-gg-tile.dash-gg-h-medium { height: 210px; } +.dash-gg-grid .dash-gg-tile.dash-gg-h-large { height: 296px; } +/* Dense Grafana chrome: a tighter head than flow's own tile head (13px name, + 11px/9px padding) — smaller, ellipsized title, hover/focus-revealed grip + + delete (both build only in edit mode, ui/dashboard.ts; CSS additionally + keeps them invisible while flow renders a cached card). */ +.dash-gg-grid .dash-tile-head { + display: flex; align-items: center; gap: 6px; padding: 6px 8px 4px; min-height: 22px; +} +.dash-gg-grid .dash-tile-name { font-size: 12px; font-weight: 600; } +.dash-gg-grip { + display: none; flex-shrink: 0; color: var(--fg-faint); cursor: grab; font-size: 11px; line-height: 1; +} +.dash-gg-grid .dash-gg-grip { display: inline-flex; } +.dash-gg-grip::before { content: '⠿'; } +.dash-gg-del { + display: none; margin-left: auto; flex-shrink: 0; width: 20px; height: 20px; + align-items: center; justify-content: center; padding: 0; color: var(--fg-faint); + background: transparent; border: none; border-radius: 5px; cursor: pointer; opacity: 0; +} +.dash-gg-grid .dash-gg-del { display: inline-flex; } +.dash-gg-grid .dash-gg-tile:hover .dash-gg-del, +.dash-gg-del:focus-visible { opacity: 1; } +.dash-gg-del:hover { color: var(--error-fg); background: var(--error-bg); } +/* Corner-drag resize handle (bottom-right, edit mode only): nwse-resize + cursor, hover-revealed diagonal glyph, accent highlight while resizing. */ +.dash-gg-tile { position: relative; } +.dash-gg-resize { display: none; } +.dash-gg-grid .dash-gg-resize { + display: block; position: absolute; right: 0; bottom: 0; width: 14px; height: 14px; cursor: nwse-resize; +} +.dash-gg-resize::after { + content: ''; position: absolute; right: 3px; bottom: 3px; width: 6px; height: 6px; + border-right: 2px solid var(--fg-faint); border-bottom: 2px solid var(--fg-faint); + opacity: 0; +} +.dash-gg-grid .dash-gg-tile:hover .dash-gg-resize::after, +.dash-gg-resize:hover::after { opacity: 1; } +.dash-gg-resize:hover::after { border-color: var(--accent); } +.dash-gg-tile.dash-gg-resizing { + border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); transition: none; +} +/* A grid tile's KPI content (#291: KPI tiles render inline, no band) fills the + tile body like a chart/table does. */ +.dash-gg-tile .dash-tile-body { flex-wrap: wrap; gap: 8px; } +.dash-gg-tile .dash-tile-body > .kpi-card { flex: 1; min-width: 0; max-width: none; inline-size: auto; } +@media (max-width: 768px) { + .dash-grid.dash-gg-grid { grid-template-columns: 1fr !important; } +} diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 3cc24727..4b1b949d 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -48,8 +48,12 @@ import { createDashboardViewerSession } from '../dashboard/application/dashboard import type { DashboardViewerSession, DashboardViewState, ViewerTileState, ViewerFilterState, } from '../dashboard/application/dashboard-viewer-session.js'; -import { defaultLayoutRegistry } from '../dashboard/layouts/layout-registry.js'; -import { flowLayoutPlugin } from '../dashboard/layouts/flow-layout.js'; +import { defaultLayoutRegistry, resolveLayoutPluginSync } from '../dashboard/layouts/layout-registry.js'; +import type { FlowLayoutModel } from '../dashboard/layouts/flow-layout.js'; +import { + GRAFANA_GRID_MAX_COLUMNS, GRID_GAP_PX, snapGridHeight, snapGridSpan, +} from '../dashboard/layouts/grafana-grid-layout.js'; +import type { GrafanaGridLayoutModel } from '../dashboard/layouts/grafana-grid-layout.js'; import { applyCommand } from '../dashboard/application/dashboard-commands.js'; import { createQueryResolver } from '../dashboard/application/dashboard-query-resolver.js'; import { resolveDashboardMode } from '../dashboard/application/session-bundle.js'; @@ -60,7 +64,8 @@ import type { DashboardFilterBag } from '../dashboard/model/dashboard-filter-sto import { loadJSON } from '../core/storage.js'; import { KEYS } from '../state.js'; import type { - DashboardDocumentV1, DashboardFilterDefinitionV1, FlowPresetV1, SavedQueryV2, StoredWorkspaceV1, + DashboardDocumentV1, DashboardFilterDefinitionV1, DashboardLayoutDocumentV1, FlowPresetV1, + GrafanaGridHeightV1, SavedQueryV2, StoredWorkspaceV1, } from '../generated/json-schema.types.js'; import type { App, AppDom, ActionsRegistry } from './app.types.js'; import type { DashboardOpenSource } from '../dashboard/application/dashboard-open-source.js'; @@ -80,6 +85,7 @@ const Icon: { sun(): SVGElement; moon(): SVGElement; arrow(): SVGElement; + trash(): SVGElement; } = IconUntyped; const formatRows: (n: number | null | undefined) => string = formatRowsUntyped; @@ -330,6 +336,14 @@ export async function renderDashboard(app: DashboardApp): Promise { // defaults back over it. const initialBag: DashboardFilterBag = readDashboardFilterBag(loadJSON(KEYS.dashFilters, {}), currentDoc.id); + // #291: the grafana-grid engine's own responsive effective-columns clamp + // (12/6/4/2) needs a measured container width, unlike flow's coarser + // `isMobile` binary flip — `containerWidthPx` is set once the grid host is + // mounted (below, near `app.root!.replaceChildren`) and kept live by a + // resize listener; it stays `undefined` (→ the widest desktop breakpoint) + // for a flow-only Dashboard, a pre-mount publish, or a non-measurable + // (happy-dom) environment. + let containerWidthPx: number | undefined; const session: DashboardViewerSession = createDashboardViewerSession({ document: viewerDoc, queries, @@ -339,6 +353,7 @@ export async function renderDashboard(app: DashboardApp): Promise { now: () => app.now(), wallNow: () => app.wallNow(), isMobile: () => state.isMobile.value, + containerWidth: () => containerWidthPx, onAuthFailed: () => app.conn.chCtx.onSignedOut(), recordBoundParams: (bp) => app.params.recordBoundParams(bp), initialFilters: initialBag, @@ -368,8 +383,27 @@ export async function renderDashboard(app: DashboardApp): Promise { ['report', 'Report', 'One centered, taller tile per row'], ['columns-2', '2 columns', 'Arrange tiles in two columns'], ['columns-3', '3 columns', 'Arrange tiles in three columns'], - ], () => (typeof currentDoc.layout.preset === 'string' ? currentDoc.layout.preset : 'full-width'), - (preset) => { runCommand({ type: 'change-layout', layout: { ...currentDoc.layout, preset: preset as FlowPresetV1 } }); }, + ['grafana-grid', 'Grafana grid', 'A dense, rowless tile grid (Grafana-style)'], + ], () => (currentDoc.layout.type === 'grafana-grid' + ? 'grafana-grid' + : typeof currentDoc.layout.preset === 'string' ? currentDoc.layout.preset : 'full-width'), + (value) => { + // #291: picking "Grafana grid" switches ENGINE — change-layout seeds/ + // derives grid placements from the current flow layout and snapshots it + // as the fallback (Wave 2's own contract; the UI never manages the + // fallback itself). Picking a flow preset while grid is active restores + // that fallback (bare `{type:'flow',version:1,preset}` — grid carries no + // flow `items`/`preset` shape to spread). Picking a flow preset while + // flow is ALREADY active keeps the existing spread of `currentDoc.layout` + // (preserving per-tile `items`) — only `preset` changes. + if (value === 'grafana-grid') { + runCommand({ type: 'change-layout', layout: { type: 'grafana-grid', version: 1 } as DashboardLayoutDocumentV1 }); + } else if (currentDoc.layout.type === 'grafana-grid') { + runCommand({ type: 'change-layout', layout: { type: 'flow', version: 1, preset: value as FlowPresetV1 } }); + } else { + runCommand({ type: 'change-layout', layout: { ...currentDoc.layout, preset: value as FlowPresetV1 } }); + } + }, 'Dashboard layout'); const layoutWrap = h('div', { class: 'dash-layout-wrap' }, layoutSelect.el); @@ -442,13 +476,22 @@ export async function renderDashboard(app: DashboardApp): Promise { // commands; the dashboard UI drives only move-tile (drag) and change-layout // (preset) — span/height (update-placement) is tuned in the Spec editor. function runCommand(command: Parameters[1]): void { + // #291: validate/seed against whichever engine is ACTIVE before the + // command applies (`resolveLayoutPluginSync` — grid: span 1..12, flow: + // span 1..3). A `change-layout` engine switch is normalized through the + // RESULTING document's own engine, so a post-switch grid document is + // pruned by the grid plugin (its own `items`), not flow's (which would + // only ever see its own fallback surface). + const activePlugin = resolveLayoutPluginSync(currentDoc.layout); const applied = applyCommand(currentDoc, command, { - resolver: createQueryResolver(queries), genTileId: () => 'tile', plugin: flowLayoutPlugin, + resolver: createQueryResolver(queries), genTileId: () => 'tile', plugin: activePlugin, }); - // A UI-driven command (drag move-tile, preset change-layout) is always - // valid; a rejected candidate is simply ignored (no draft change). + // A UI-driven command (drag move-tile, preset change-layout, grid + // resize/delete) is always valid; a rejected candidate is simply ignored + // (no draft change). if (applied.ok) { - const normalized = flowLayoutPlugin.normalize(applied.dashboard); + const resultPlugin = resolveLayoutPluginSync(applied.dashboard.layout); + const normalized = resultPlugin.normalize(applied.dashboard); currentDoc = normalized; layoutSelect.sync(); session.syncDocument({ @@ -468,17 +511,90 @@ export async function renderDashboard(app: DashboardApp): Promise { // ── Tile DOM ────────────────────────────────────────────────────────────── const tileEls = new Map(); let dragTileId: string | null = null; + // #291: which engine is active as of the last publish — read by the grid- + // only delete/resize handlers (built once per tile in `ensureTileEl`, below, + // and cached across engine switches) so a cached card's grid chrome stays + // visually hidden AND inert while flow is active, instead of a per-switch + // DOM rebuild. `null` before the first publish (never actually read then — + // no pointer/click interaction can precede it). + let activeEngine: 'flow' | 'grafana-grid' | null = null; + // The tile's LAST rendered grid placement (span/height) — read at the start + // of a corner-drag so the drag continues from the actual rendered values, + // not a stale/default guess. + const gridPlacementByTile = new Map(); + // The grafana-grid engine's last-rendered effective column count — read at + // the start of a corner-drag for the column-width math; a safe desktop + // default before the first grid publish (never read before one, same + // reasoning as `activeEngine` above). + let currentGridColumns = GRAFANA_GRID_MAX_COLUMNS; + + const GRID_HEIGHT_CLASSES = ['dash-gg-h-compact', 'dash-gg-h-medium', 'dash-gg-h-large']; + function setGridHeightClass(card: HTMLElement, height: GrafanaGridHeightV1): void { + card.classList.remove(...GRID_HEIGHT_CLASSES); + card.classList.add('dash-gg-h-' + height); + } + + // #291 corner-drag resize (Workbench edit mode + grafana-grid engine only): + // pointer math stays a THIN adapter over the pure `snapGridSpan`/ + // `snapGridHeight` (grafana-grid-layout.ts, rule 5) — live preview via + // inline style/class during the drag, one `update-placement` dispatch on + // pointerup. A no-op while flow is active (`activeEngine` guard) even + // though the handle DOM always exists once built (CSS hides it under the + // ancestor `.dash-gg-grid` scope; this is the interaction-level backstop). + function wireGridResize(tileId: string, handle: HTMLElement, card: HTMLElement): void { + handle.addEventListener('pointerdown', (event: Event) => { + if (activeEngine !== 'grafana-grid') return; + const start = event as PointerEvent; + start.preventDefault(); + start.stopPropagation(); // never let the resize handle start a card drag + const rect = card.getBoundingClientRect(); + const columns = Math.max(1, currentGridColumns); + const colWidthPx = (grid.clientWidth - GRID_GAP_PX * (columns - 1)) / columns; + const placement = gridPlacementByTile.get(tileId); + let curSpan = placement ? placement.span : columns; + let curHeight: GrafanaGridHeightV1 = placement ? placement.height : 'medium'; + card.classList.add('dash-gg-resizing'); + const win = doc.defaultView || window; + const move = (ev: PointerEvent): void => { + const span = snapGridSpan(ev.clientX - rect.left, colWidthPx, GRID_GAP_PX, columns); + const height = snapGridHeight(ev.clientY - rect.top); + if (span !== curSpan) { curSpan = span; card.style.gridColumn = 'span ' + span; } + if (height !== curHeight) { curHeight = height; setGridHeightClass(card, height); } + }; + const up = (): void => { + card.classList.remove('dash-gg-resizing'); + win.removeEventListener('pointermove', move as EventListener); + win.removeEventListener('pointerup', up); + runCommand({ type: 'update-placement', tileId, placement: { span: curSpan, height: curHeight } }); + }; + win.addEventListener('pointermove', move as EventListener); + win.addEventListener('pointerup', up); + }); + } function ensureTileEl(ts: ViewerTileState): TileEl { const existing = tileEls.get(ts.tileId); if (existing) return existing; - const head = h('div', { class: 'dash-tile-head' }, h('span', { class: 'dash-tile-name', title: ts.title }, ts.title)); + // Grip (drag affordance) + delete are edit-mode-only (`!readOnly`, a + // static per-load condition like the drag wiring below); both are + // grafana-grid-only in PRACTICE, but built unconditionally once per tile + // and gated visually (CSS, ancestor `.dash-gg-grid` scope) + at the + // interaction level (`activeEngine` check on delete's click) so a cached + // card carries no leftover interactive affordance while flow is active. + const grip = !readOnly ? h('span', { class: 'dash-gg-grip', title: 'Drag to reorder', 'aria-hidden': 'true' }) : null; + const delBtn = !readOnly ? h('button', { + 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 body = h('div', { class: 'dash-tile-body' }); const foot = h('div', { class: 'dash-tile-foot' }); - const card = h('div', { class: 'dash-tile', draggable: String(!readOnly) }, head, body, foot); - // Pointer drag is the sole reorder mechanism (#286 owner override): a drop - // persists the new dashboard.tiles[] order through the move-tile command. - // A read-only (detached view) dashboard is not reorderable (#288). + const resizeHandle = !readOnly ? h('div', { class: 'dash-gg-resize', title: 'Resize' }) : null; + const card = h('div', { class: 'dash-tile', draggable: String(!readOnly) }, head, body, foot, resizeHandle); + // Pointer drag is the sole reorder mechanism (#286 owner override, reused + // verbatim for grafana-grid@1 tiles by #291 — same move-tile command, no + // engine branching needed): a drop persists the new dashboard.tiles[] + // order. A read-only (detached view) dashboard is not reorderable (#288). if (!readOnly) { card.addEventListener('dragstart', () => { dragTileId = ts.tileId; }); card.addEventListener('dragover', (event) => event.preventDefault()); @@ -490,6 +606,7 @@ export async function renderDashboard(app: DashboardApp): Promise { dragTileId = null; }); } + if (resizeHandle) wireGridResize(ts.tileId, resizeHandle, card); const tileEl: TileEl = { card, body, foot, panelState: null, destroy: null, paintedRows: null }; tileEls.set(ts.tileId, tileEl); return tileEl; @@ -526,9 +643,12 @@ export async function renderDashboard(app: DashboardApp): Promise { // unchanged (the sort mutated the panel state, not the data). function paintForce(ts: ViewerTileState, tileEl: TileEl): void { tileEl.paintedRows = null; paintPanel(ts, tileEl); } - function reconcileTile(ts: ViewerTileState): void { - const tileEl = ensureTileEl(ts); - if (ts.isKpi) return; // KPI tiles are rendered inside their band, not as a card + // The ordinary (non-KPI) tile body: painted result, or an error/unfilled/ + // loading state card — shared by BOTH engines' reconciliation (flow's + // `reconcileTile` skips a KPI tile entirely — it renders inside the KPI + // band instead; grid's `reconcileGridTile` renders a KPI tile's cards + // inline via `renderKpiInto` instead of calling this). + function paintTileBody(ts: ViewerTileState, tileEl: TileEl): void { if (ts.status === 'ready') { paintPanel(ts, tileEl); return; } destroyChart(tileEl); tileEl.paintedRows = null; @@ -543,6 +663,12 @@ export async function renderDashboard(app: DashboardApp): Promise { } } + function reconcileTile(ts: ViewerTileState): void { + const tileEl = ensureTileEl(ts); + if (ts.isKpi) return; // KPI tiles are rendered inside their band, not as a card (flow only) + paintTileBody(ts, tileEl); + } + // Render one KPI tile's cards (or its non-ready state) into `host`. On 'ready' // the viewer guarantees columns/rows (no defensive fallback). function renderKpiInto(host: HTMLElement, ts: ViewerTileState): void { @@ -564,20 +690,25 @@ export async function renderDashboard(app: DashboardApp): Promise { // ── Grid reconciliation from the flow model ─────────────────────────────── let lastLayoutSig = ''; - function reconcileGrid(sview: DashboardViewState): void { + function reconcileGrid(sview: DashboardViewState, layout: FlowLayoutModel): void { const byId = new Map(sview.tiles.map((t) => [t.tileId, t])); for (const ts of sview.tiles) reconcileTile(ts); const sig = JSON.stringify({ - m: sview.layout.mobile, c: sview.layout.columns, p: sview.layout.preset, - rows: sview.layout.rows.map((r) => ({ k: r.kind, t: r.tiles.map((t) => [t.tileId, t.span]) })), + m: layout.mobile, c: layout.columns, p: layout.preset, + rows: layout.rows.map((r) => ({ k: r.kind, t: r.tiles.map((t) => [t.tileId, t.span]) })), }); // Rebuild the row STRUCTURE only when the flow model changes (a reorder, // preset, or mobile flip) — moving stable tile cards, so charts are never // thrashed. if (sig !== lastLayoutSig) { lastLayoutSig = sig; - grid.classList.toggle('is-report', sview.layout.preset === 'report'); - grid.replaceChildren(...sview.layout.rows.map((row) => { + // #291: undo any grafana-grid-only chrome a cached card picked up the + // last time the grid engine was active (that reconciliation is gated + // off entirely while flow renders, so it can't clean up after itself). + grid.classList.remove('dash-gg-grid'); + grid.classList.toggle('is-report', layout.preset === 'report'); + grid.style.gridTemplateColumns = ''; + grid.replaceChildren(...layout.rows.map((row) => { if (row.kind === 'kpi-band') { const stream = h('div', { class: 'dash-kpi-stream', ...KPI_STREAM_ARIA }); for (const member of row.tiles) stream.appendChild(h('div', { class: 'dash-kpi-member', 'data-tile': member.tileId })); @@ -586,7 +717,11 @@ export async function renderDashboard(app: DashboardApp): Promise { const rowEl = h('div', { class: 'dash-row', style: { display: 'grid', gridTemplateColumns: `repeat(${row.columns}, minmax(0, 1fr))`, gap: '12px' } }); for (const t of row.tiles) { const tileEl = tileEls.get(t.tileId); - if (tileEl) { tileEl.card.style.gridColumn = `span ${t.span}`; rowEl.appendChild(tileEl.card); } + if (tileEl) { + tileEl.card.classList.remove('dash-gg-tile', ...GRID_HEIGHT_CLASSES); + tileEl.card.style.gridColumn = `span ${t.span}`; + rowEl.appendChild(tileEl.card); + } } return rowEl; })); @@ -600,8 +735,58 @@ export async function renderDashboard(app: DashboardApp): Promise { } } + // ── Grid reconciliation from the grafana-grid@1 model (#291) ───────────── + // Rowless: a SINGLE CSS grid host, every tile (KPI or not) placed by + // `grid-column: span N` + a height class — no row wrappers, no KPI band. + // Tile CONTENT reuses the exact same resolvePanel/renderResolvedPanel/ + // renderKpiCards paths as flow (`reconcileGridTile` below); only the DOM + // placement differs. + function reconcileGridTile(ts: ViewerTileState): void { + const tileEl = ensureTileEl(ts); + if (ts.isKpi) { renderKpiInto(tileEl.body, ts); return; } + paintTileBody(ts, tileEl); + } + + let lastGridSig = ''; + function reconcileGrafanaGrid(sview: DashboardViewState, gridModel: GrafanaGridLayoutModel): void { + const byId = new Map(sview.tiles.map((t) => [t.tileId, t])); + for (const t of gridModel.tiles) { + const ts = byId.get(t.tileId); + if (ts) reconcileGridTile(ts); + } + currentGridColumns = gridModel.columns; + const sig = JSON.stringify({ c: gridModel.columns, tiles: gridModel.tiles.map((t) => [t.tileId, t.span, t.height]) }); + // Rebuild the host STRUCTURE only when the grid model changes (a reorder, + // resize, delete, responsive clamp, or membership change) — moving stable + // tile cards, so charts/KPI content are never thrashed mid-drag. + if (sig === lastGridSig) return; + lastGridSig = sig; + grid.classList.remove('is-report', 'is-wide'); // flow-only preset modifiers + grid.classList.add('dash-gg-grid'); + grid.style.gridTemplateColumns = `repeat(${gridModel.columns}, 1fr)`; + const cards: HTMLElement[] = []; + for (const t of gridModel.tiles) { + const tileEl = tileEls.get(t.tileId); + if (!tileEl) continue; + gridPlacementByTile.set(t.tileId, { span: t.span, height: t.height }); + tileEl.card.classList.add('dash-gg-tile'); + tileEl.card.classList.toggle('is-kpi', t.isKpi); + tileEl.card.style.gridColumn = `span ${t.span}`; + setGridHeightClass(tileEl.card, t.height); + cards.push(tileEl.card); + } + grid.replaceChildren(...cards); + } + // ── Effect: reconcile on every publish (and on the mobile-breakpoint flip) ─ let lastMobile = state.isMobile.value; + // #291: the ENGINE rendered by the last reconciliation — a switch resets + // both engines' own change-detection signature caches so the next publish + // always rebuilds the host structure (clearing the OTHER engine's leftover + // chrome: `dash-gg-grid`/`dash-gg-tile`/height classes on a flow switch, or + // `is-report`/`is-wide` on a grid switch) instead of a coincidental sig + // match silently skipping that cleanup. + let lastEngineRendered: 'flow' | 'grafana-grid' | null = null; let barSig = ''; // #303: the committed-filter bag for a published view, built exactly the way // the persist step below and the seed just under it both need it. @@ -624,7 +809,13 @@ export async function renderDashboard(app: DashboardApp): Promise { const mobileNow = state.isMobile.value; // tracked so a breakpoint flip re-runs the effect // A breakpoint flip after the last publish needs a fresh flow model — // republish through the viewer (recomputes it with the new mobile flag). - if (mobileNow !== lastMobile && mobileNow !== sview.layout.mobile) { lastMobile = mobileNow; session.syncDocument(currentDoc); return; } + // grafana-grid has no `mobile` concept of its own (its responsive + // 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); + return; + } lastMobile = mobileNow; // Rebuild the shared filter bar only when its field structure changes // (activation, committed value, or curated options arriving) — not on tile @@ -648,7 +839,10 @@ export async function renderDashboard(app: DashboardApp): Promise { filterDiagnosticsHost.replaceChildren( ...sview.diagnostics.map((d) => h('div', { class: 'dash-config-diagnostic is-error' }, d.message)), ); - reconcileGrid(sview); + if (sview.layout.engine !== lastEngineRendered) { lastLayoutSig = ''; lastGridSig = ''; lastEngineRendered = sview.layout.engine; } + activeEngine = sview.layout.engine; + if (sview.layout.engine === 'grafana-grid') reconcileGrafanaGrid(sview, sview.layout.grid); + else reconcileGrid(sview, sview.layout); refreshBtn.disabled = sview.running; if (!sview.running && sview.updatedAt != null) { updated.textContent = 'Updated ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); @@ -662,5 +856,35 @@ export async function renderDashboard(app: DashboardApp): Promise { h('div', { class: 'dash-topbar' }, header, toolbar), filterDiagnosticsHost, empty, grid)); + // #291: measure the grid host's real width now that it is mounted — BEFORE + // `session.start()`'s first publish — so the initial grafana-grid render + // already reflects the actual container instead of the pre-mount default + // (12 columns). A resize re-measures and forces a fresh publish (mirroring + // how a mobile-breakpoint flip already forces one for flow, above) only + // while the grid engine is active; flow's own responsive behavior stays the + // untouched `state.isMobile` signal flip. `clientWidth` is always 0 under + // happy-dom (no real layout engine) — `measureGridWidth` then leaves + // `containerWidthPx` `undefined`, which resolves to the widest (12-column) + // breakpoint, exactly the useful non-DOM default `effectiveGridColumns` + // itself documents. + function measureGridWidth(): void { + const w = grid.clientWidth; + containerWidthPx = w > 0 ? w : undefined; + } + measureGridWidth(); + // Unlike a repeatedly-opened modal (e.g. the EXPLAIN graph overlay), the + // Dashboard page is a single full-page navigation — the listener lives for + // the page's lifetime, no self-removal-on-disconnect needed. + const gridWin = doc.defaultView; + if (gridWin) { + const onGridResize = (): void => { + if (activeEngine !== 'grafana-grid') return; + const prevWidth = containerWidthPx; + measureGridWidth(); + if (containerWidthPx !== prevWidth) session.syncDocument(currentDoc); + }; + gridWin.addEventListener('resize', onGridResize); + } + await session.start(); } diff --git a/tests/unit/dashboard-viewer-session.test.ts b/tests/unit/dashboard-viewer-session.test.ts index 11eaed02..e3061ebc 100644 --- a/tests/unit/dashboard-viewer-session.test.ts +++ b/tests/unit/dashboard-viewer-session.test.ts @@ -86,6 +86,8 @@ describe('createDashboardViewerSession', () => { expect(state.running).toBe(false); expect(state.updatedAt).not.toBeNull(); // Flow model reflects the columns-2 preset and the stored span-2 placement. + expect(state.layout.engine).toBe('flow'); + if (state.layout.engine !== 'flow') throw new Error('expected flow engine'); expect(state.layout.columns).toBe(2); expect(state.layout.rows[0].tiles[0].span).toBe(2); expect(VIEWER_TILE_CONCURRENCY).toBe(6); @@ -569,7 +571,9 @@ describe('per-tile control and lifecycle', () => { expect(calls.length).toBe(base); expect(session.state.value.tiles.map((t) => t.tileId)).toEqual(['b', 'a']); expect(session.state.value.tiles[0].status).toBe('ready'); // result preserved - expect(session.state.value.layout.rows[0].tiles[0]).toMatchObject({ tileId: 'b', span: 2 }); + const syncedLayout = session.state.value.layout; + if (syncedLayout.engine !== 'flow') throw new Error('expected flow engine'); + expect(syncedLayout.rows[0].tiles[0]).toMatchObject({ tileId: 'b', span: 2 }); // An unknown tile id in the next document is dropped defensively. session.syncDocument({ ...document, tiles: [tile('a', 'qa'), tile('ghostly', 'x')] }); expect(session.state.value.tiles.map((t) => t.tileId)).toEqual(['a']); @@ -578,6 +582,109 @@ describe('per-tile control and lifecycle', () => { expect(session.state.value.tiles.map((t) => t.tileId)).toEqual(['a']); }); + it('tags the flow layout view with engine:\'flow\' — bit-identical otherwise', async () => { + const { exec } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const document = doc({ + tiles: [tile('a', 'qa')], + layout: { type: 'flow', version: 1, preset: 'columns-3', items: { a: { span: 3 } } }, + }); + const session = createDashboardViewerSession(makeDeps({ document, exec, queries: [query('qa', 'SELECT 1')] })); + await session.start(); + const layout = session.state.value.layout; + expect(layout.engine).toBe('flow'); + if (layout.engine === 'flow') { + expect(layout.preset).toBe('columns-3'); + expect(layout.columns).toBe(3); + expect(layout.rows[0].tiles[0].span).toBe(3); + } + }); +}); + +// #291: engine routing (grafana-grid@1) — buildState resolves the active +// engine synchronously (resolveLayoutPluginSync) rather than always calling +// computeFlowLayout; a grid document nests its own render model under +// `layout.grid`, discriminated by `layout.engine`. +describe('grafana-grid engine routing (#291)', () => { + const gridDoc = (over: Partial = {}) => doc({ + tiles: [tile('a', 'qa'), tile('b', 'qb')], + layout: { type: 'grafana-grid', version: 1, items: { a: { span: 4, height: 'compact' } } }, + ...over, + }); + + it('tags the layout view with engine:\'grafana-grid\' and nests the grid render model', async () => { + const { exec } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const session = createDashboardViewerSession(makeDeps({ + document: gridDoc(), exec, queries: [query('qa', 'SELECT 1'), query('qb', 'SELECT 2')], + })); + await session.start(); + const layout = session.state.value.layout; + expect(layout.engine).toBe('grafana-grid'); + if (layout.engine === 'grafana-grid') { + expect(layout.grid.engine).toBe('grafana-grid'); + expect(layout.grid.order).toEqual(['a', 'b']); + expect(layout.grid.tiles[0]).toMatchObject({ tileId: 'a', span: 4, height: 'compact' }); + // No persisted placement for 'b' → the grid default (span 6, medium). + expect(layout.grid.tiles[1]).toMatchObject({ tileId: 'b', span: 6, height: 'medium' }); + } + }); + + it('clamps effective columns from the injected containerWidth seam', async () => { + const { exec } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const session = createDashboardViewerSession(makeDeps({ + document: gridDoc(), exec, queries: [query('qa', 'SELECT 1'), query('qb', 'SELECT 2')], + containerWidth: () => 600, // >=470, <720 → 4 effective columns + })); + await session.start(); + const layout = session.state.value.layout; + if (layout.engine === 'grafana-grid') expect(layout.grid.columns).toBe(4); + else throw new Error('expected grafana-grid engine'); + }); + + it('defaults to the widest breakpoint (12 columns) when containerWidth is absent', async () => { + const { exec } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const session = createDashboardViewerSession(makeDeps({ + document: gridDoc(), exec, queries: [query('qa', 'SELECT 1'), query('qb', 'SELECT 2')], + })); + await session.start(); + const layout = session.state.value.layout; + if (layout.engine === 'grafana-grid') expect(layout.grid.columns).toBe(12); + else throw new Error('expected grafana-grid engine'); + }); + + it('places a KPI grid tile inline (no banding) and still runs its query', async () => { + const { exec, calls } = makeExec(() => ({ columns: [{ name: 'value' }], rows: [[7]] })); + const document = gridDoc({ + tiles: [tile('k1', 'qk')], + layout: { type: 'grafana-grid', version: 1, items: { k1: { span: 4 } } }, + }); + const session = createDashboardViewerSession(makeDeps({ + document, exec, queries: [query('qk', 'SELECT 7 AS value', { panel: { cfg: { type: 'kpi' } } })], + })); + await session.start(); + expect(calls.length).toBe(1); + const layout = session.state.value.layout; + if (layout.engine === 'grafana-grid') { + expect(layout.grid.tiles[0]).toMatchObject({ tileId: 'k1', isKpi: true, span: 4 }); + } else throw new Error('expected grafana-grid engine'); + expect(session.state.value.tiles[0].status).toBe('ready'); + }); + + it('falls back to the flow engine for an unsupported grid version with no valid fallback (existing dashboard-layout-load-failed shape unaffected)', async () => { + const { exec } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const document = doc({ + tiles: [tile('a', 'qa')], + layout: { type: 'grafana-grid', version: 2, items: {} } as unknown as DashboardDocumentV1['layout'], + }); + const session = createDashboardViewerSession(makeDeps({ document, exec, queries: [query('qa', 'SELECT 1')] })); + await session.start(); + // An unsupported grid version with no flow@1 fallback resolves to the + // flow plugin (resolveLayoutPluginSync's own documented fallback), which + // renders every tile at the flow default (no persisted flow surface). + expect(session.state.value.layout.engine).toBe('flow'); + }); +}); + +describe('flow layout (mobile normalization)', () => { it('normalizes the flow layout on mobile and coerces filter values to strings', async () => { const { exec } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); let mobile = true; @@ -590,8 +697,10 @@ describe('per-tile control and lifecycle', () => { exec, queries: [query('qa', 'SELECT {p:String} AS n')], isMobile: () => mobile, })); await session.start(); - expect(session.state.value.layout.columns).toBe(1); - expect(session.state.value.layout.rows[0].tiles[0].span).toBe(1); + const mobileLayout = session.state.value.layout; + if (mobileLayout.engine !== 'flow') throw new Error('expected flow engine'); + expect(mobileLayout.columns).toBe(1); + expect(mobileLayout.rows[0].tiles[0].span).toBe(1); // A numeric default coerces to a string; setting null clears it. await session.setFilter('f1', 5); expect(session.state.value.filters[0].active).toBe(true); diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index fbf433ad..1afc44b2 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -558,6 +558,198 @@ describe('renderDashboard — KPI bands (#240)', () => { }); }); +// #291: the grafana-grid@1 layout engine — a rowless single CSS grid host, +// engine switching via the 5-option layout select, and Workbench-only edit +// interactions (drag-reorder reuses flow's existing pattern verbatim, so it +// is not re-tested here — corner-drag resize + delete are the new surfaces). +describe('renderDashboard — grafana-grid engine (#291)', () => { + const twoTilesGrid = () => wsWith({ + queries: [q('q1', 'SELECT k, v FROM a'), q('q2', 'SELECT k, v FROM b')], + tiles: [{ id: 't1', queryId: 'q1' }, { id: 't2', queryId: 'q2' }], + layout: { type: 'grafana-grid', version: 1, items: { t1: { span: 4, height: 'compact' } } }, + }); + + it('renders tiles through a single rowless grid host with span + height classes, no row wrappers', async () => { + const { app } = dashApp({ workspace: twoTilesGrid() }); + await render(app); + expect(qs(app.root, '.dash-gg-grid')).not.toBeNull(); + expect(qsa(app.root, '.dash-row').length).toBe(0); // rowless — no per-row wrappers, no KPI band + const cards = qsa(app.root, '.dash-gg-tile'); + expect(cards.length).toBe(2); + expect((cards[0].style as CSSStyleDeclaration).gridColumn).toBe('span 4'); + expect(cards[0].classList.contains('dash-gg-h-compact')).toBe(true); + // No persisted placement for t2 → the grid default (span 6, medium). + expect((cards[1].style as CSSStyleDeclaration).gridColumn).toBe('span 6'); + expect(cards[1].classList.contains('dash-gg-h-medium')).toBe(true); + expect((qs(app.root, '.dash-gg-grid').style as CSSStyleDeclaration).gridTemplateColumns).toContain('repeat(12'); + }); + + it('places a KPI tile inline (no band) in grid mode, still through the shared KPI card renderer', async () => { + const { app } = dashApp({ + responder: () => ({ columns: [{ name: 'value', type: 'UInt64' }], rows: [[42]] }), + workspace: wsWith({ + queries: [q('k1', 'SELECT 1 AS value', { panel: { cfg: { type: 'kpi' } } })], + tiles: [{ id: 't1', queryId: 'k1' }], + layout: { type: 'grafana-grid', version: 1, items: { t1: { span: 4 } } }, + }), + }); + await render(app); + expect(qs(app.root, '.dash-kpi-band')).toBeNull(); + const card = qs(app.root, '.dash-gg-tile'); + expect(card.classList.contains('is-kpi')).toBe(true); + expect(qs(card, '.kpi-card')).not.toBeNull(); + }); + + it('reflects the active engine in the 5-option layout select and switches engines via change-layout', async () => { + const { app, commit } = dashApp({ + workspace: wsWith({ + queries: [q('q1', 'SELECT k, v FROM a')], + tiles: [{ id: 't1', queryId: 'q1' }], + layout: { type: 'flow', version: 1, preset: 'columns-2', items: {} }, + }), + }); + await render(app); + const select = layoutSelect(app.root); + expect([...select.options].map((o) => o.value)).toEqual( + ['full-width', 'report', 'columns-2', 'columns-3', 'grafana-grid'], + ); + expect(select.value).toBe('columns-2'); + // Picking "Grafana grid" sends change-layout {type:'grafana-grid',version:1}. + pickLayout(app.root, 'grafana-grid'); + expect(layoutSelect(app.root).value).toBe('grafana-grid'); + expect(qs(app.root, '.dash-gg-grid')).not.toBeNull(); + expect(commit).toHaveBeenCalled(); + // Picking a flow preset while grid is active restores the regenerated + // flow@1 fallback (bare {type:'flow',version:1,preset} — grid carries no + // flow items/preset shape to spread). + pickLayout(app.root, 'full-width'); + expect(layoutSelect(app.root).value).toBe('full-width'); + expect(qs(app.root, '.dash-gg-grid')).toBeNull(); // cleaned up, not just hidden + expect(qsa(app.root, '.dash-row').length).toBeGreaterThan(0); + // The cached tile card sheds its grid-only chrome, not just the host. + expect(qs(app.root, '.dash-gg-tile')).toBeNull(); + expect(qs(app.root, '.dash-tile')).not.toBeNull(); + }); + + it('preserves per-tile flow items when switching between flow presets (not an engine switch)', async () => { + const { app } = dashApp({ + workspace: wsWith({ + queries: [q('q1', 'SELECT k, v FROM a'), q('q2', 'SELECT k, v FROM b')], + tiles: [{ id: 't1', queryId: 'q1' }, { id: 't2', queryId: 'q2' }], + layout: { type: 'flow', version: 1, preset: 'columns-2', items: { t1: { span: 2 } } }, + }), + }); + await render(app); + pickLayout(app.root, 'columns-3'); + expect(layoutSelect(app.root).value).toBe('columns-3'); + // 3-column preset with a persisted span-2 tile — still a flow row (not + // dropped by the switch). + expect(qsa(app.root, '.dash-row')[0].style.gridTemplateColumns).toContain('repeat(3'); + }); + + it('shows grip/delete/resize affordances only in edit mode (!readOnly)', async () => { + const { app } = dashApp({ workspace: twoTilesGrid() }); + await render(app); + expect(qsa(app.root, '.dash-gg-grip').length).toBe(2); + expect(qsa(app.root, '.dash-gg-del').length).toBe(2); + expect(qsa(app.root, '.dash-gg-resize').length).toBe(2); + + const detached = twoTilesGrid(); + const { app: readonlyApp } = modeApp({ + workspace: null, detached, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' }, + }); + await render(readonlyApp); + expect(qsa(readonlyApp.root, '.dash-gg-grip').length).toBe(0); + expect(qsa(readonlyApp.root, '.dash-gg-del').length).toBe(0); + expect(qsa(readonlyApp.root, '.dash-gg-resize').length).toBe(0); + }); + + it('delete dispatches remove-tile and drops the tile from the grid', async () => { + const { app, commit } = dashApp({ workspace: twoTilesGrid() }); + await render(app); + expect(qsa(app.root, '.dash-gg-tile').length).toBe(2); + qs(app.root, '.dash-gg-del').click(); + expect(qsa(app.root, '.dash-gg-tile').length).toBe(1); + expect(commit).toHaveBeenCalled(); + }); + + it('a delete click is a no-op while flow (not grid) is active', async () => { + const { app, commit } = dashApp({ + workspace: wsWith({ + queries: [q('q1', 'SELECT k, v FROM a')], tiles: [{ id: 't1', queryId: 'q1' }], + layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, + }), + }); + await render(app); + qs(app.root, '.dash-gg-del').click(); + expect(commit).not.toHaveBeenCalled(); + expect(qsa(app.root, '.dash-tile').length).toBe(1); + }); + + it('corner-drag resize snaps span/height live and dispatches one update-placement on pointerup', async () => { + const { app, commit } = dashApp({ workspace: twoTilesGrid() }); + await render(app); + const gridEl = qs(app.root, '.dash-gg-grid'); + // 12 columns, 8px gap → colWidth = (1200 - 8*11)/12 ≈ 92.67px. + Object.defineProperty(gridEl, 'clientWidth', { value: 1200, configurable: true }); + const card = qsa(app.root, '.dash-gg-tile')[0]; // t1, starts span 4 / compact + const handle = qs(card, '.dash-gg-resize'); + handle.dispatchEvent(new PointerEvent('pointerdown', { clientX: 0, clientY: 0 })); + expect(card.classList.contains('dash-gg-resizing')).toBe(true); + // clientX=600 → round((600+8)/100.67) = 6 columns; clientY=280 → closer to + // 296 (large) than 210 (medium) — both differ from the starting 4/compact. + window.dispatchEvent(new PointerEvent('pointermove', { clientX: 600, clientY: 280 })); + expect((card.style as CSSStyleDeclaration).gridColumn).toBe('span 6'); + expect(card.classList.contains('dash-gg-h-large')).toBe(true); + expect(commit).not.toHaveBeenCalled(); // no command dispatched until pointerup + window.dispatchEvent(new PointerEvent('pointerup')); + expect(card.classList.contains('dash-gg-resizing')).toBe(false); + expect(commit).toHaveBeenCalledTimes(1); // exactly one update-placement dispatch + // The committed placement survives reconciliation (re-derived from state). + const after = qsa(app.root, '.dash-gg-tile')[0]; + expect((after.style as CSSStyleDeclaration).gridColumn).toBe('span 6'); + expect(after.classList.contains('dash-gg-h-large')).toBe(true); + }); + + it('a resize pointerdown is a no-op while flow (not grid) is active', async () => { + const { app, commit } = dashApp({ + workspace: wsWith({ + queries: [q('q1', 'SELECT k, v FROM a')], tiles: [{ id: 't1', queryId: 'q1' }], + layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, + }), + }); + await render(app); + const handle = qs(app.root, '.dash-gg-resize'); + handle.dispatchEvent(new PointerEvent('pointerdown', { clientX: 0, clientY: 0 })); + expect(commit).not.toHaveBeenCalled(); + }); + + it('a container resize re-clamps the effective column count', async () => { + const { app } = dashApp({ workspace: twoTilesGrid() }); + await render(app); + const gridEl = qs(app.root, '.dash-gg-grid'); + expect((gridEl.style as CSSStyleDeclaration).gridTemplateColumns).toContain('repeat(12'); + Object.defineProperty(gridEl, 'clientWidth', { value: 600, configurable: true }); // >=470,<720 → 4 columns + window.dispatchEvent(new Event('resize')); + await Promise.resolve(); await Promise.resolve(); + expect((qs(app.root, '.dash-gg-grid').style as CSSStyleDeclaration).gridTemplateColumns).toContain('repeat(4'); + }); + + it('a resize while flow (not grid) is active does not force a spurious republish', async () => { + const { app } = dashApp({ + workspace: wsWith({ + queries: [q('q1', 'SELECT k, v FROM a')], tiles: [{ id: 't1', queryId: 'q1' }], + layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, + }), + }); + await render(app); + // No throw, and flow's own row structure is untouched by a resize. + const rowsBefore = qsa(app.root, '.dash-row').length; + expect(() => window.dispatchEvent(new Event('resize'))).not.toThrow(); + expect(qsa(app.root, '.dash-row').length).toBe(rowsBefore); + }); +}); + describe('renderDashboard — shared rich filter bar over the viewer (#188)', () => { it('renders the shared rich field family — one var-field per declared param type', async () => { const { app } = dashApp({ diff --git a/tests/unit/grafana-grid-layout.test.ts b/tests/unit/grafana-grid-layout.test.ts index af683c64..c4a2752c 100644 --- a/tests/unit/grafana-grid-layout.test.ts +++ b/tests/unit/grafana-grid-layout.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest'; import { - DEFAULT_GRID_PLACEMENT, GRAFANA_GRID_MAX_COLUMNS, + DEFAULT_GRID_PLACEMENT, GRAFANA_GRID_MAX_COLUMNS, GRID_GAP_PX, GRID_HEIGHT_PX, computeGrafanaGridLayout, deriveFlowFallback, deriveGrafanaGridPlacement, effectiveGridColumns, effectiveGridSpan, flowSpanFromGridSpan, grafanaGridLayoutPlugin, gridSpanFromFlowSpan, regenerateGridFallback, resolveGridPlacement, setGridPlacement, + snapGridHeight, snapGridSpan, } from '../../src/dashboard/layouts/grafana-grid-layout.js'; import { FLOW_LAYOUT_V1_SCHEMA_ID } from '../../src/dashboard/model/workspace-semantics.js'; import { jsonSchemaValidationService } from '../../src/core/library-codec.js'; @@ -303,6 +304,52 @@ describe('deriveFlowFallback', () => { }); }); +// #291 Wave 3: pure corner-drag resize math — the DOM pointer listener +// (ui/dashboard.ts) stays a thin imperative adapter over these. +describe('snapGridSpan', () => { + it('rounds a horizontal pixel delta to the nearest column span, per the mock formula', () => { + // colWidth=96, gap=8 → each column step is 104px; span N sits at (N*104 - 8)px. + expect(snapGridSpan(96, 96, GRID_GAP_PX, 12)).toBe(1); // exactly one column's width + expect(snapGridSpan(400, 96, GRID_GAP_PX, 12)).toBe(4); + expect(snapGridSpan(0, 96, GRID_GAP_PX, 12)).toBe(1); // never below 1 + }); + + it('clamps to the active column count', () => { + expect(snapGridSpan(10000, 96, GRID_GAP_PX, 4)).toBe(4); + expect(snapGridSpan(-500, 96, GRID_GAP_PX, 4)).toBe(1); + }); + + it('treats a non-finite or non-positive column width as span 1 rather than NaN/Infinity', () => { + expect(snapGridSpan(400, 0, 0, 12)).toBe(1); + expect(snapGridSpan(400, -8, GRID_GAP_PX, 12)).toBe(1); + expect(snapGridSpan(400, NaN, GRID_GAP_PX, 12)).toBe(1); + }); + + it('treats a zero/negative column count as at least 1', () => { + expect(snapGridSpan(400, 96, GRID_GAP_PX, 0)).toBe(1); + }); +}); + +describe('snapGridHeight', () => { + it('snaps to the nearest tier by absolute pixel distance', () => { + expect(snapGridHeight(0)).toBe('compact'); + expect(snapGridHeight(GRID_HEIGHT_PX.compact)).toBe('compact'); + expect(snapGridHeight(160)).toBe('compact'); // closer to 118 than 210 + expect(snapGridHeight(170)).toBe('medium'); // closer to 210 than 118 + expect(snapGridHeight(GRID_HEIGHT_PX.medium)).toBe('medium'); + expect(snapGridHeight(260)).toBe('large'); // closer to 296 than 210 + expect(snapGridHeight(GRID_HEIGHT_PX.large)).toBe('large'); + expect(snapGridHeight(10000)).toBe('large'); + }); +}); + +describe('GRID_GAP_PX / GRID_HEIGHT_PX', () => { + it('exposes the shared gap and height-tier constants', () => { + expect(GRID_GAP_PX).toBe(8); + expect(GRID_HEIGHT_PX).toEqual({ compact: 118, medium: 210, large: 296 }); + }); +}); + describe('regenerateGridFallback', () => { it('mutates a grafana-grid layout\'s fallback in place, deterministically from its current items', () => { const layout = gridLayout({ a: { span: 4, height: 'compact' } }); From 0280a044e5096761df3f23dcdd99102100917988 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 18 Jul 2026 18:48:20 +0000 Subject: [PATCH 4/8] test(#291): real-browser e2e harness for the grafana-grid layout (wave 3b) Narrow static harness (dashboard-mobile.html pattern, no createApp): packing positions vs the pure model, semantic height px, container-width column clamp 12/6/4/2, no horizontal overflow at 360px, corner-drag resize live preview + single terminal dispatch, hover-revealed chrome + view-mode absence. 6/6 green on chromium and webkit (firefox full-parallel goto flake is pre-existing; spec passes 6/6 at --workers=1). Part of #291. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GLMprUnwnz4oaz3cnLRKcz --- tests/e2e/dashboard-grid.html | 259 +++++++++++++++++++++++++++++++ tests/e2e/dashboard-grid.spec.js | 193 +++++++++++++++++++++++ 2 files changed, 452 insertions(+) create mode 100644 tests/e2e/dashboard-grid.html create mode 100644 tests/e2e/dashboard-grid.spec.js diff --git a/tests/e2e/dashboard-grid.html b/tests/e2e/dashboard-grid.html new file mode 100644 index 00000000..f90ae32f --- /dev/null +++ b/tests/e2e/dashboard-grid.html @@ -0,0 +1,259 @@ + + + + + Dashboard grafana-grid harness + + + + +
+

packing (mixed spans, 12-col)

+
+
+
+
+ +
+

semantic heights (compact/medium/large)

+
+
+
+
+ +
+

responsive clamp (host-width driven, no viewport media query)

+
+
+
+
+ +
+

edit mode (corner-drag resize + hover chrome)

+
+
+
+
+ +
+

view/read-only mode (no edit affordances at all)

+
+
+
+
+ + + + diff --git a/tests/e2e/dashboard-grid.spec.js b/tests/e2e/dashboard-grid.spec.js new file mode 100644 index 00000000..e7d8dab6 --- /dev/null +++ b/tests/e2e/dashboard-grid.spec.js @@ -0,0 +1,193 @@ +import { test, expect } from '@playwright/test'; + +// Grafana-grid layout engine (#291 Wave 3): real-layout coverage for what +// happy-dom cannot see — actual CSS grid packing/wrapping, semantic tile +// pixel heights, the responsive column clamp, corner-drag resize live +// feedback, and hover-revealed edit chrome. The pure math itself +// (effectiveGridColumns/snapGridSpan/snapGridHeight/computeGrafanaGridLayout) +// is already 100%-covered under vitest — this suite verifies the REAL +// browser renders what that math promises, not the math again. + +async function openWide(page) { + await page.setViewportSize({ width: 1400, height: 1000 }); + await page.goto('/tests/e2e/dashboard-grid.html'); + await page.waitForFunction(() => window.__ready === true); +} + +test.describe('Dashboard grafana-grid layout', () => { + test('packs mixed spans into the row/colStart the pure model computed, wrapping only where it doesn\'t fit', async ({ page }) => { + await openWide(page); + const grid = page.locator('#packing-grid'); + await expect(grid).toHaveCSS('display', 'grid'); + + const model = await page.evaluate(() => window.__packingModel); + expect(model.columns).toBe(12); // 1400px container ⇒ widest breakpoint + + // Content box (inside the grid host's own left/right padding — 20px, + // `.dash-grid` in styles.css) is where grid columns actually start; the + // border-box rect alone would be off by that padding. + const gridBox = await grid.evaluate((node) => { + const r = node.getBoundingClientRect(); + const cs = getComputedStyle(node); + const padLeft = parseFloat(cs.paddingLeft); + const padRight = parseFloat(cs.paddingRight); + return { left: r.left + padLeft, right: r.right - padRight, width: r.width - padLeft - padRight }; + }); + const boxes = await page.locator('#packing-grid .dash-tile').evaluateAll( + (nodes) => nodes.map((node) => { const r = node.getBoundingClientRect(); return { left: r.left, right: r.right, top: r.top }; }), + ); + expect(boxes).toHaveLength(model.tiles.length); + + // Column width implied by the rendered grid (12 columns, 8px gap — GRID_GAP_PX). + const colWidth = (gridBox.width - 8 * 11) / 12; + for (let i = 0; i < model.tiles.length; i++) { + const t = model.tiles[i]; + const box = boxes[i]; + const expectedLeft = gridBox.left + t.colStart * (colWidth + 8); + expect(box.left).toBeCloseTo(expectedLeft, 0); + // Same row ⇒ same top (within a px); wrapped row ⇒ strictly lower and + // does not overlap the previous row's bottom. + const sameRowAsPrev = i > 0 && model.tiles[i - 1].row === t.row; + if (sameRowAsPrev) expect(Math.abs(box.top - boxes[i - 1].top)).toBeLessThan(2); + else if (i > 0) expect(box.top).toBeGreaterThan(boxes[i - 1].top); + } + // The model's own expectation for this fixture: 2 rows (5 tiles then 3). + expect(model.tiles.map((t) => t.row)).toEqual([0, 0, 0, 0, 0, 1, 1, 1]); + expect(model.tiles.map((t) => t.colStart)).toEqual([0, 3, 6, 9, 10, 0, 4, 6]); + // No tile ever overflows the grid's own right edge. + for (const box of boxes) expect(box.right).toBeLessThanOrEqual(gridBox.right + 1); + }); + + test('renders compact/medium/large tiles at their semantic pixel heights', async ({ page }) => { + await openWide(page); + const heights = await page.locator('#heights-grid .dash-tile').evaluateAll( + (nodes) => nodes.map((node) => node.getBoundingClientRect().height), + ); + expect(heights[0]).toBeCloseTo(118, 0); + expect(heights[1]).toBeCloseTo(210, 0); + expect(heights[2]).toBeCloseTo(296, 0); + }); + + test('clamps effective columns at the 12/6/4/2 container-width breakpoints, and a full-span tile never overflows', async ({ page }) => { + await openWide(page); + const cases = [ + { width: 1200, columns: 12 }, + { width: 800, columns: 6 }, + { width: 500, columns: 4 }, + { width: 300, columns: 2 }, + ]; + for (const { width, columns } of cases) { + await page.evaluate((px) => window.__setResponsiveWidth(px), width); + const result = await page.evaluate(() => { + const grid = document.getElementById('responsive-grid'); + const wide = document.querySelector('#responsive-grid [data-tile-id="r-wide"]'); + const gridRect = grid.getBoundingClientRect(); + const cs = getComputedStyle(grid); + const padLeft = parseFloat(cs.paddingLeft); + const padRight = parseFloat(cs.paddingRight); + const contentLeft = gridRect.left + padLeft; + const contentRight = gridRect.right - padRight; + const wideRect = wide.getBoundingClientRect(); + return { + templateColumnCount: getComputedStyle(grid).gridTemplateColumns.trim().split(/\s+/).length, + modelColumns: window.__responsiveModel.columns, + wideWidth: wideRect.width, + wideLeft: wideRect.left, + contentLeft, + contentWidth: contentRight - contentLeft, + overflowsRight: wideRect.right > contentRight + 1, + }; + }); + expect(result.modelColumns).toBe(columns); + expect(result.templateColumnCount).toBe(columns); + // A stored span-12 tile clamps to `effectiveGridSpan(12, columns) === columns` + // ⇒ it spans the full row (the grid's own content-box left edge to right edge) + // at every tier. + expect(result.wideLeft).toBeCloseTo(result.contentLeft, 0); + expect(result.wideWidth).toBeCloseTo(result.contentWidth, 0); + expect(result.overflowsRight).toBe(false); + } + }); + + test('never overflows the viewport horizontally at a real 360px width', async ({ page }) => { + await page.setViewportSize({ width: 360, height: 800 }); + await page.goto('/tests/e2e/dashboard-grid.html'); + await page.waitForFunction(() => window.__ready === true); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + expect(overflow).toBeLessThanOrEqual(0); + }); + + test('corner-drag resize live-previews span/height during the drag and dispatches exactly one terminal placement', async ({ page }) => { + await openWide(page); + const card = page.locator('#edit-grid .dash-tile[data-tile-id="e1"]'); + const handle = page.locator('#edit-grid .dash-gg-resize'); + await expect(card).toHaveClass(/dash-gg-h-medium/); + expect(await card.evaluate((node) => node.style.gridColumn)).toBe('span 6'); + + // Raw page.mouse.* calls don't auto-scroll (unlike locator.click()) — + // the harness stacks several scenario sections, so the edit-mode grid + // can start below the fold. + await handle.scrollIntoViewIfNeeded(); + const rect = await card.evaluate((node) => { const r = node.getBoundingClientRect(); return { left: r.left, top: r.top }; }); + const handleBox = await handle.boundingBox(); + + await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2); + await page.mouse.down(); + await expect(card).toHaveClass(/dash-gg-resizing/); + + // Drag to a point 3.5 columns wide, ~large-tier tall — assert the LIVE + // (pre-release) preview matches the same pure snap functions the app uses. + const midTargetX = rect.left + 3.5 * (await page.evaluate(() => window.__editColWidthPx()) + 8); + const midTargetY = rect.top + 250; + await page.mouse.move(midTargetX, midTargetY, { steps: 5 }); + const midExpected = await page.evaluate(({ dx, dy }) => ({ + span: window.__snapGridSpan(dx, window.__editColWidthPx(), window.__GRID_GAP_PX, window.__editColumns()), + height: window.__snapGridHeight(dy), + }), { dx: midTargetX - rect.left, dy: midTargetY - rect.top }); + expect(await card.evaluate((node) => node.style.gridColumn)).toBe(`span ${midExpected.span}`); + await expect(card).toHaveClass(new RegExp('dash-gg-h-' + midExpected.height)); + expect(await page.evaluate(() => window.__resizeEvents.length)).toBe(0); // no dispatch mid-drag + + // Move to a final, distinct target and release — exactly one terminal event. + const finalTargetX = rect.left + 2.2 * (await page.evaluate(() => window.__editColWidthPx()) + 8); + const finalTargetY = rect.top + 118; + await page.mouse.move(finalTargetX, finalTargetY, { steps: 5 }); + await page.mouse.up(); + await expect(card).not.toHaveClass(/dash-gg-resizing/); + + const finalExpected = await page.evaluate(({ dx, dy }) => ({ + span: window.__snapGridSpan(dx, window.__editColWidthPx(), window.__GRID_GAP_PX, window.__editColumns()), + height: window.__snapGridHeight(dy), + }), { dx: finalTargetX - rect.left, dy: finalTargetY - rect.top }); + expect(await card.evaluate((node) => node.style.gridColumn)).toBe(`span ${finalExpected.span}`); + await expect(card).toHaveClass(new RegExp('dash-gg-h-' + finalExpected.height)); + const events = await page.evaluate(() => window.__resizeEvents); + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ tileId: 'e1', span: finalExpected.span, height: finalExpected.height }); + }); + + test('hover reveals the delete button and resize glyph only in edit mode; view mode never builds edit affordances', async ({ page }) => { + await openWide(page); + const delBtn = page.locator('#edit-grid .dash-gg-del'); + const resizeHandle = page.locator('#edit-grid .dash-gg-resize'); + const grip = page.locator('#edit-grid .dash-gg-grip'); + + // Grip is always visible in edit mode (not hover-gated); delete + the + // resize glyph start hidden (opacity 0) and reveal on hover (styles.css + // "Grafana-grid layout engine" section). + await expect(grip).toBeVisible(); + expect(await delBtn.evaluate((node) => getComputedStyle(node).opacity)).toBe('0'); + expect(await resizeHandle.evaluate((node) => getComputedStyle(node, '::after').opacity)).toBe('0'); + + await page.locator('#edit-grid .dash-tile[data-tile-id="e1"]').hover(); + expect(await delBtn.evaluate((node) => getComputedStyle(node).opacity)).toBe('1'); + expect(await resizeHandle.evaluate((node) => getComputedStyle(node, '::after').opacity)).toBe('1'); + + // View/read-only mode never constructs the edit affordances at all (not + // merely CSS-hidden) — `ui/dashboard.ts`'s `ensureTileEl` gates their + // construction on `!readOnly`. + await expect(page.locator('#viewonly-grid .dash-gg-grip')).toHaveCount(0); + await expect(page.locator('#viewonly-grid .dash-gg-del')).toHaveCount(0); + await expect(page.locator('#viewonly-grid .dash-gg-resize')).toHaveCount(0); + }); +}); From 83321e21d38314e8b66348b9c9c7f0f79c123418 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 18 Jul 2026 19:22:16 +0000 Subject: [PATCH 5/8] fix(#291): review round - real-browser layout fixes + engine-dispatch cleanup Verified findings from the 8-angle review + adversarial verification: - remove the 768px !important grid-template override that fought the JS container-width clamp (span>1 tiles rendered on implicit phantom tracks in the 470-768px band; reproduced in real chromium) - measure the grid's CONTENT-box width (clientWidth minus computed padding) for both breakpoint tiers and resize column math (pure contentBoxWidth) - corner-drag resize pins the tile to its explicit colStart for the drag (span clamped to remaining columns) so a mid-row tile can never self-wrap and desync the persisted placement from the pointer - renderDashboard removes the previously-installed window resize listener (reloadDashboardRoute re-entry stacked handlers + pinned stale sessions) - bare same-engine change-layout (no items) preserves existing placements instead of silently wiping them via the wholesale branch - one BUILTIN_SYNC_PLUGINS table now feeds both resolveLayoutPluginSync and defaultLayoutRegistry (single registration point for engine #3) - delete dead resolveActiveLayoutPlugin (stale semantics, zero prod callers); drop redundant GrafanaGridLayoutModel.order; regenerateGridFallback owns the type guard + tile-ref mapping (3 call sites collapsed, no flow-path alloc); setPlacementForActiveEngine dedupes the per-engine write branch - e2e harness updated to the pinned-resize contract + new mid-row drag test Gates: npm test 3657 passed, build OK, check:arch OK, full e2e chromium+webkit 104 passed. Part of #291. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GLMprUnwnz4oaz3cnLRKcz --- .../application/dashboard-commands.ts | 66 +++++++------ .../application/saved-query-mutation.ts | 2 +- src/dashboard/application/tile-membership.ts | 2 +- src/dashboard/layouts/flow-layout.ts | 24 ----- src/dashboard/layouts/grafana-grid-layout.ts | 60 +++++++++--- src/dashboard/layouts/layout-registry.ts | 58 ++++++----- src/styles.css | 11 ++- src/ui/dashboard.ts | 97 ++++++++++++++++--- tests/e2e/dashboard-grid.html | 65 +++++++++---- tests/e2e/dashboard-grid.spec.js | 81 +++++++++++++--- tests/unit/dashboard-commands.test.ts | 34 +++++++ tests/unit/dashboard-viewer-session.test.ts | 2 +- tests/unit/dashboard.test.ts | 65 ++++++++++++- tests/unit/flow-layout.test.ts | 19 +--- tests/unit/grafana-grid-layout.test.ts | 43 +++++++- 15 files changed, 464 insertions(+), 165 deletions(-) diff --git a/src/dashboard/application/dashboard-commands.ts b/src/dashboard/application/dashboard-commands.ts index 79517c1f..41432aa8 100644 --- a/src/dashboard/application/dashboard-commands.ts +++ b/src/dashboard/application/dashboard-commands.ts @@ -82,17 +82,16 @@ const GRID_FALLBACK_COMMANDS = new Set([ 'add-query', 'add-query-instance', 'remove-tile', 'move-tile', 'update-placement', ]); -/** Regenerate a grafana-grid@1 layout's flow@1 `fallback` from the - * document's CURRENT items + tile set, via the single shared primitive - * (`regenerateGridLayoutFallback`, grafana-grid-layout.ts) every #291 - * application-layer mutation path calls — a no-op when the layout is not - * grafana-grid@1. Centralized here (called once, from `applyCommand`) so no - * individual command case needs its own copy. */ -function regenerateGridFallback(dashboard: DashboardDocumentV1): void { - const tileRefs = dashboard.tiles - .filter((tile): tile is DashboardTileV1 => isObject(tile) && typeof tile.id === 'string') - .map((tile) => ({ id: tile.id })); - regenerateGridLayoutFallback(dashboard.layout, tileRefs); +/** Set one tile's placement through whichever engine plugin is ACTIVE + * (`ctx.plugin`): grid → `setGridPlacement`, flow → `setFlowPlacement`. The + * one shared primitive both the `add-query`/`add-query-instance` seed step + * and `update-placement` use (#291 review F10 — this dispatch was + * duplicated at both call sites). */ +function setPlacementForActiveEngine( + plugin: DashboardLayoutPlugin, layout: unknown, tileId: string, placement: unknown, +): void { + if (plugin.type === 'grafana-grid') setGridPlacement(layout, tileId, placement); + else setFlowPlacement(layout, tileId, placement); } /** One-level merge of an `update-tile` presentation patch onto the tile's @@ -123,7 +122,9 @@ export function applyCommand( ): ApplyCommandResult { const dashboard = cloneJson(draft); const result = applyCommandToClone(dashboard, command, ctx); - if (result.ok && GRID_FALLBACK_COMMANDS.has(command.type)) regenerateGridFallback(result.dashboard); + if (result.ok && GRID_FALLBACK_COMMANDS.has(command.type)) { + regenerateGridLayoutFallback(result.dashboard.layout, result.dashboard.tiles); + } return result; } @@ -158,12 +159,9 @@ function applyCommandToClone( // always gets an explicit placement (its own default when there is no // usable hint), matching `deriveGrafanaGridPlacement`'s "no opinion" // contract being the grid default rather than flow's bare `undefined`. - if (ctx.plugin.type === 'grafana-grid') { - setGridPlacement(dashboard.layout, id, deriveGrafanaGridPlacement(sizeHints)); - } else { - const placement = deriveFlowPlacement(sizeHints); - if (placement) setFlowPlacement(dashboard.layout, id, placement); - } + const placement = ctx.plugin.type === 'grafana-grid' + ? deriveGrafanaGridPlacement(sizeHints) : deriveFlowPlacement(sizeHints); + if (placement) setPlacementForActiveEngine(ctx.plugin, dashboard.layout, id, placement); return { ok: true, dashboard, value: { tileId: id } }; } @@ -229,11 +227,7 @@ function applyCommandToClone( const placementDiags = ctx.plugin.validatePlacement(command.placement, ['layout', 'items', command.tileId]); if (placementDiags.length) return { ok: false, diagnostics: placementDiags }; - if (ctx.plugin.type === 'grafana-grid') { - setGridPlacement(dashboard.layout, command.tileId, cloneJson(command.placement)); - } else { - setFlowPlacement(dashboard.layout, command.tileId, cloneJson(command.placement)); - } + setPlacementForActiveEngine(ctx.plugin, dashboard.layout, command.tileId, cloneJson(command.placement)); return { ok: true, dashboard, value: undefined }; } @@ -254,11 +248,18 @@ function applyCommandToClone( // flow PRESET while grid is active is exactly this restore, then // applying `command.layout.preset` on top — the caller (Wave 3 UI) // only ever needs to send `{ type: 'flow', version: 1, preset? }`. - // - anything else (same-engine change, e.g. a flow preset switch while - // flow is already active, or an unrecognized combination): install the - // (cloned) new layout document wholesale, as `change-layout` always - // did before #291. A regenerated fallback still backstops a wholesale - // grid layout that did not already carry one. + // - same engine (type+version unchanged) with the incoming layout + // carrying NO `items` field at all: a bare `{type,version}` (e.g. an + // engine-switch dispatch sent while that engine is ALREADY active — + // #291 review F5) must not silently reset `items` to `{}` and discard + // every placement. Preserve the CURRENT items/fallback, with whatever + // fields the command DOES carry (e.g. a flow `preset`) applied on top. + // - anything else (same-engine change WITH an explicit `items` field — + // e.g. a flow preset switch that also spreads its current items, or an + // unrecognized combination): install the (cloned) new layout document + // wholesale, as `change-layout` always did before #291. A regenerated + // fallback still backstops a wholesale grid layout that did not + // already carry one. case 'change-layout': { const currentType = dashboard.layout.type; const targetType = command.layout.type; @@ -294,8 +295,15 @@ function applyCommandToClone( return { ok: true, dashboard, value: undefined }; } + if (currentType === targetType && dashboard.layout.version === command.layout.version + && !Object.hasOwn(command.layout, 'items')) { + dashboard.layout = { ...cloneJson(dashboard.layout), ...cloneJson(command.layout) } as DashboardLayoutDocumentV1; + regenerateGridLayoutFallback(dashboard.layout, dashboard.tiles); + return { ok: true, dashboard, value: undefined }; + } + dashboard.layout = cloneJson(command.layout); - regenerateGridFallback(dashboard); + regenerateGridLayoutFallback(dashboard.layout, dashboard.tiles); return { ok: true, dashboard, value: undefined }; } } diff --git a/src/dashboard/application/saved-query-mutation.ts b/src/dashboard/application/saved-query-mutation.ts index ae910ac7..9b3c8bb7 100644 --- a/src/dashboard/application/saved-query-mutation.ts +++ b/src/dashboard/application/saved-query-mutation.ts @@ -174,7 +174,7 @@ export function planSavedQueryMutation( // authoring commands do) — a no-op under flow@1. if (dashboard) { dashboard = resolveLayoutPluginSync(dashboard.layout).normalize(dashboard); - regenerateGridFallback(dashboard.layout, dashboard.tiles.map((tile) => ({ id: tile.id }))); + regenerateGridFallback(dashboard.layout, dashboard.tiles); } const candidate: StoredWorkspaceV1 = { storageVersion: 1, id: workspace.id, name: workspace.name, diff --git a/src/dashboard/application/tile-membership.ts b/src/dashboard/application/tile-membership.ts index da066584..05424237 100644 --- a/src/dashboard/application/tile-membership.ts +++ b/src/dashboard/application/tile-membership.ts @@ -76,6 +76,6 @@ export function toggleTileMembership( next = removeTilesForQuery(dashboard, query.id); } const normalized = resolveLayoutPluginSync(next.layout).normalize(next); - regenerateGridFallback(normalized.layout, normalized.tiles.map((tile) => ({ id: tile.id }))); + regenerateGridFallback(normalized.layout, normalized.tiles); return normalized; } diff --git a/src/dashboard/layouts/flow-layout.ts b/src/dashboard/layouts/flow-layout.ts index 24b77020..59ecae45 100644 --- a/src/dashboard/layouts/flow-layout.ts +++ b/src/dashboard/layouts/flow-layout.ts @@ -119,10 +119,6 @@ export const flowLayoutPlugin: DashboardLayoutPlugin = { type: 'flow', version: 1, normalize, validatePlacement, }; -export type LoadLayoutPluginResult = - | { ok: true; plugin: DashboardLayoutPlugin } - | { ok: false; diagnostics: WorkspaceDiagnostic[] }; - // ── Phase 4: the normative flow@1 render model (#280 "Normative flow@1 // contract") ──────────────────────────────────────────────────────────────── // Pure layout MATH shared by the viewer, the authoring preview, print/export, @@ -278,23 +274,3 @@ export function computeFlowLayout(input: ComputeFlowLayoutInput): FlowLayoutMode return { preset, columns, mobile, rows, order: tiles.map((tile) => tile.id) }; } - -/** Resolve the active layout plugin for one layout document. flow@1 primary → - * the flow plugin; an unsupported primary WITH a valid flow@1 fallback → the - * flow plugin (operating on the fallback); otherwise a load failure — the - * `change-layout` "plugin cannot load / unsupported version without a valid - * fallback" path. */ -export function resolveActiveLayoutPlugin(layout: unknown, path: Path = ['layout']): LoadLayoutPluginResult { - if (isObject(layout)) { - if (isFlowLayout(layout.type, layout.version)) return { ok: true, plugin: flowLayoutPlugin }; - const fallback = layout.fallback; - if (isObject(fallback) && isFlowLayout(fallback.type, fallback.version)) { - return { ok: true, plugin: flowLayoutPlugin }; - } - } - return { - ok: false, - diagnostics: [diagnostic(path, 'dashboard-layout-load-failed', - 'Dashboard layout cannot be loaded and has no valid flow@1 fallback')], - }; -} diff --git a/src/dashboard/layouts/grafana-grid-layout.ts b/src/dashboard/layouts/grafana-grid-layout.ts index 4551b716..95e5fbf7 100644 --- a/src/dashboard/layouts/grafana-grid-layout.ts +++ b/src/dashboard/layouts/grafana-grid-layout.ts @@ -208,8 +208,6 @@ export interface GrafanaGridLayoutModel { columns: number; /** Every visible tile, positioned, in `dashboard.tiles[]` semantic order. */ tiles: GrafanaGridTileRender[]; - /** Visible tile IDs in semantic order (parity with flow's `order`). */ - order: string[]; } /** One visible tile the grid lays out, in `dashboard.tiles[]` semantic order. @@ -262,7 +260,7 @@ export function computeGrafanaGridLayout(input: ComputeGrafanaGridLayoutInput): return render; }); - return { engine: 'grafana-grid', columns, tiles: renders, order: tiles.map((tile) => tile.id) }; + return { engine: 'grafana-grid', columns, tiles: renders }; } // ── Engine conversion: grid → flow fallback (#291) ────────────────────────── @@ -335,15 +333,53 @@ export function snapGridHeight(dyPx: number): GrafanaGridHeightV1 { return best; } +/** Extract `{id}` refs from a RAW `dashboard.tiles[]`-shaped array — a + * malformed entry (non-object, or a non-string `id`) is dropped, never + * thrown. Lets every #291 call site hand `regenerateGridFallback` its own + * `dashboard.tiles` array directly instead of pre-mapping/filtering it + * itself (#291 review F9: three call sites duplicated this exact + * `filter(isObject...).map(...)`). */ +function tileRefsOf(tiles: readonly unknown[]): GrafanaGridFallbackTile[] { + const out: GrafanaGridFallbackTile[] = []; + for (const tile of tiles) { + if (isObject(tile) && typeof tile.id === 'string') out.push({ id: tile.id }); + } + return out; +} + /** Regenerate a grafana-grid@1 layout's flow@1 `fallback` IN PLACE from its - * current `items` + the given tile set (mutates `layout.fallback`, mirroring - * `setGridPlacement`'s own mutate-in-place contract) — a no-op when `layout` - * is not a grafana-grid@1 document. The single shared primitive every #291 - * application-layer mutation path (authoring commands, tile-membership star - * toggle, saved-query mutation planning) calls so "every grid mutation - * regenerates the flow@1 fallback deterministically" is enforced once, not - * duplicated per call site. */ -export function regenerateGridFallback(layout: unknown, tiles: readonly GrafanaGridFallbackTile[]): void { + * current `items` + the given RAW `dashboard.tiles[]` array (mutates + * `layout.fallback`, mirroring `setGridPlacement`'s own mutate-in-place + * contract) — a no-op when `layout` is not a grafana-grid@1 document. The + * single shared primitive every #291 application-layer mutation path + * (authoring commands, tile-membership star toggle, saved-query mutation + * planning) calls so "every grid mutation regenerates the flow@1 fallback + * deterministically" is enforced once, not duplicated per call site. The + * non-grid guard runs BEFORE the tiles→refs mapping/allocation (#291 review + * F9) — calling this on the far-more-common flow-engine document costs only + * the guard check, never a `tiles[]` walk that would just be thrown away. */ +export function regenerateGridFallback(layout: unknown, tiles: readonly unknown[]): void { if (!isObject(layout) || layout.type !== 'grafana-grid') return; - layout.fallback = deriveFlowFallback(layout, tiles); + layout.fallback = deriveFlowFallback(layout, tileRefsOf(tiles)); +} + +// ── Measurement math (#291 review F2): the grid host's `clientWidth` INCLUDES +// its own horizontal padding (`.dash-grid`'s `padding: 18px 20px 40px`, +// styles.css), but CSS grid TRACKS occupy the CONTENT box — using +// `clientWidth` directly for the responsive breakpoint clamp or the resize +// column-width math misclassifies tiers near a threshold and skews the +// column width by the same amount. `ui/dashboard.ts` pairs this with a thin +// DOM reader (`getComputedStyle(el).paddingLeft/Right`) for both call sites. ─ + +/** The grid host's CONTENT-box width: `clientWidth` minus its own horizontal + * padding, clamped to a minimum of 0. A non-finite padding read (e.g. an + * empty computed-style string under a no-stylesheet test environment, which + * `parseFloat` turns into `NaN`) is treated as 0 rather than propagating NaN + * — the un-padded `clientWidth` itself, exactly today's (pre-fix) behavior + * when no padding can be read. Pure — the DOM `getComputedStyle` read is the + * caller's job. */ +export function contentBoxWidth(clientWidth: number, padLeft: number, padRight: number): number { + const left = Number.isFinite(padLeft) ? padLeft : 0; + const right = Number.isFinite(padRight) ? padRight : 0; + return Math.max(0, clientWidth - left - right); } diff --git a/src/dashboard/layouts/layout-registry.ts b/src/dashboard/layouts/layout-registry.ts index 99cad88e..3a497491 100644 --- a/src/dashboard/layouts/layout-registry.ts +++ b/src/dashboard/layouts/layout-registry.ts @@ -52,24 +52,32 @@ export interface DashboardLayoutRegistry { resolve(layout: unknown, path?: (string | number)[]): Promise; } +/** The built-in engines with synchronous, already-constructed plugin + * instances (#291 review F6) — the SINGLE source of truth both + * `resolveLayoutPluginSync` (below) and `defaultLayoutRegistry`'s + * registrations derive their dispatch from, so a third built-in engine is + * added in exactly one place instead of two independent, driftable lists + * (a hardcoded `if (type === 'grafana-grid')` alongside a separately + * maintained registration array used to silently default an unhandled new + * engine to flow in the sync path). `load` is registration-time-lazy only + * for every entry, matching flow's original contract — the plugin modules + * are still inlined in the one bundle (CLAUDE.md hard rule 4 forbids network + * code-splitting); a plugin is simply not CONSTRUCTED/imported by a caller + * until a Dashboard document actually requests its `type`. */ +const BUILTIN_SYNC_PLUGINS: readonly DashboardLayoutPlugin[] = [flowLayoutPlugin, grafanaGridLayoutPlugin]; + +/** One registration for a built-in sync plugin — `load` needs no async work + * since the instance already exists. */ +function syncRegistration(plugin: DashboardLayoutPlugin): DashboardLayoutRegistration { + return { id: plugin.type, versions: [plugin.version], load: () => Promise.resolve(plugin) }; +} + /** The always-available flow@1 registration — its `load` returns the shared, * stateless plugin instance (no code-splitting needed for the built-in). */ -export const flowLayoutRegistration: DashboardLayoutRegistration = { - id: 'flow', - versions: [1], - load: () => Promise.resolve(flowLayoutPlugin), -}; - -/** The grafana-grid@1 registration (#291): a second built-in engine. `load` - * is registration-time-lazy only, matching flow's — the plugin module is - * still inlined in the one bundle (CLAUDE.md hard rule 4 forbids network - * code-splitting); it is simply not constructed/imported by a caller until a - * Dashboard document actually requests `type: 'grafana-grid'`. */ -export const grafanaGridLayoutRegistration: DashboardLayoutRegistration = { - id: 'grafana-grid', - versions: [1], - load: () => Promise.resolve(grafanaGridLayoutPlugin), -}; +export const flowLayoutRegistration: DashboardLayoutRegistration = syncRegistration(flowLayoutPlugin); + +/** The grafana-grid@1 registration (#291): a second built-in engine. */ +export const grafanaGridLayoutRegistration: DashboardLayoutRegistration = syncRegistration(grafanaGridLayoutPlugin); /** Build a registry from a set of registrations. flow@1 is always present (a * passed `flow` registration is ignored in favour of the built-in, so the @@ -125,14 +133,17 @@ export function createLayoutRegistry( return { supports, load, resolve }; } -/** The default registry: the built-in flow@1 engine plus grafana-grid@1 - * (#291), the first second-engine consumer of this registry. */ -export const defaultLayoutRegistry: DashboardLayoutRegistry = createLayoutRegistry([grafanaGridLayoutRegistration]); +/** The default registry: every `BUILTIN_SYNC_PLUGINS` entry, registered — + * flow@1 plus grafana-grid@1 (#291), the first second-engine consumer of + * this registry. Derived from the same table `resolveLayoutPluginSync` + * reads (#291 review F6), rather than a separately maintained array. */ +export const defaultLayoutRegistry: DashboardLayoutRegistry = + createLayoutRegistry(BUILTIN_SYNC_PLUGINS.map(syncRegistration)); /** Synchronous plugin resolution for pure call sites that mutate a Dashboard * document but cannot await the async registry (`tile-membership.ts`, - * `saved-query-mutation.ts` — #291): the grafana-grid@1 plugin when the - * layout's primary is grafana-grid@1, else the flow@1 plugin. Both built-in + * `saved-query-mutation.ts` — #291): looked up in `BUILTIN_SYNC_PLUGINS` by + * exact `{type, version}` match, else the flow@1 plugin. Both built-in * plugins are stateless, already-constructed values (`load()` never truly * defers for either — see the module doc comment above), so no async is * needed to pick between them. Falling back to the flow plugin for any @@ -141,6 +152,9 @@ export const defaultLayoutRegistry: DashboardLayoutRegistry = createLayoutRegist * knows how to operate against a `fallback` when the primary isn't flow@1 * itself (`flowItemsHost`/`flowSurface` in flow-layout.ts). */ export function resolveLayoutPluginSync(layout: unknown): DashboardLayoutPlugin { - if (isObject(layout) && layout.type === 'grafana-grid' && layout.version === 1) return grafanaGridLayoutPlugin; + if (isObject(layout)) { + const match = BUILTIN_SYNC_PLUGINS.find((plugin) => plugin.type === layout.type && plugin.version === layout.version); + if (match) return match; + } return flowLayoutPlugin; } diff --git a/src/styles.css b/src/styles.css index 6ae4c6be..90bbe7c5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2494,6 +2494,11 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } tile body like a chart/table does. */ .dash-gg-tile .dash-tile-body { flex-wrap: wrap; gap: 8px; } .dash-gg-tile .dash-tile-body > .kpi-card { flex: 1; min-width: 0; max-width: none; inline-size: auto; } -@media (max-width: 768px) { - .dash-grid.dash-gg-grid { grid-template-columns: 1fr !important; } -} +/* No `@media (max-width: 768px)` override here (#291 review finding F1): unlike + flow's binary mobile flip, grafana-grid@1's ENTIRE responsive behavior is the + JS container-width clamp (`effectiveGridColumns`, 12/6/4/2 at ≥1160/≥720/≥470) + which already sets an inline `grid-template-columns: repeat(N, 1fr)` on this + host and clamps every tile's `grid-column: span N` to fit. A `!important` + media override here does not know about a tile's span — a span>1 tile whose + host was forced to `1fr` still tries to occupy N implicit tracks, rendering + side-by-side/overflowing instead of the model's actual N-column layout. */ diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 4b1b949d..2d52af1e 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -51,7 +51,7 @@ import type { import { defaultLayoutRegistry, resolveLayoutPluginSync } from '../dashboard/layouts/layout-registry.js'; import type { FlowLayoutModel } from '../dashboard/layouts/flow-layout.js'; import { - GRAFANA_GRID_MAX_COLUMNS, GRID_GAP_PX, snapGridHeight, snapGridSpan, + GRAFANA_GRID_MAX_COLUMNS, GRID_GAP_PX, contentBoxWidth, snapGridHeight, snapGridSpan, } from '../dashboard/layouts/grafana-grid-layout.js'; import type { GrafanaGridLayoutModel } from '../dashboard/layouts/grafana-grid-layout.js'; import { applyCommand } from '../dashboard/application/dashboard-commands.js'; @@ -123,6 +123,15 @@ export interface DashboardApp { const valueString = (value: unknown): string => (typeof value === 'string' ? value : value == null ? '' : String(value)); +/** #291 review F4: `renderDashboard` can run more than once against the SAME + * window — `app.reloadDashboardRoute()` (app.ts) re-invokes it in place after + * an import-commit while already on `/dashboard` (file-menu.ts's Import + * flow). Module-level so a later call can find and remove the PRIOR call's + * resize listener before installing its own; without this, repeated renders + * stack listeners that all still close over their own render's now-stale + * `session`/`currentDoc`/`containerWidthPx`. */ +let installedGridResizeListener: { win: Window; handler: () => void } | null = null; + /** * Build the flow preset switcher as a compact `