diff --git a/CHANGELOG.md b/CHANGELOG.md index 0acaac1f..b3a55cc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,36 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Changed +- **Grid Tiles is the default Dashboard style; Full view replaces the old + Full width preset** (#321). The `grafana-grid@1` engine is renamed to + **Grid Tiles** in the UI (the persisted `{type:'grafana-grid',version:1}` + identifier is unchanged) and is now the layout every newly created + Dashboard starts in (empty `items`, a `columns-2` flow fallback; new tiles + keep the span-6/height-2 default). The old `flow@1/full-width` preset is + removed completely — from the flow JSON Schema and generated types, flow + normalization/render, the selector, `is-wide` CSS, legacy layout mapping + (`wide` now maps to `report`), grid→flow fallback generation (now + `columns-2`), and all fixtures/tests; valid flow presets are now only + `report`, `columns-2`, `columns-3`. **Full view** is introduced as a + transient, never-persisted render mode over Grid Tiles: every tile renders + one-per-row at the full effective column count (12/6/4/2 responsive) while + the authored spans are left untouched. Toggling Grid Tiles ↔ Full view runs + no `change-layout`, never commits, and never bumps the Dashboard revision; + a reload or a newly opened viewer session always starts in Grid Tiles. + Selecting Full view from a flow preset performs exactly one persisted + flow→grid conversion, then only the transient override; selecting a flow + preset from Full view clears the override and persists the flow change. + While Full view is active, reorder/add/delete and vertical height changes + still persist, but the corner resize becomes **vertical-only** (a + `ns-resize` affordance with a "Resize tile height" label) — horizontal + pointer movement can never change a span. The editable style selector is + ordered `Grid Tiles, Full view, Report, 2 columns, 3 columns`; a read-only + Dashboard exposes a reduced `Grid Tiles / Full view` runtime toggle (only + when its layout is grafana-grid — a read-only flow Dashboard shows no + selector), and the selector's accessible name is now `Dashboard style`. + Export/import never carry Full view state. There is no read-compatibility + path for `flow@1/full-width` development data (owner decision — the project + has no production compatibility requirement for it). - **Grafana-grid KPI tiles are polished in both Dashboard modes** (#316, follow-on to #291). Edit mode keeps the full editing shell but drops the never-populated tile footer (no more phantom separator line). View mode diff --git a/README.md b/README.md index d17c21f0..0be226f7 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Panel preview and an unconfigured Dashboard KPI tile show the cards inside the ordinary `.kpi-panel` grid, while a **favorited, explicitly-KPI-typed** Dashboard query instead joins a full-width **KPI band** — a flat, wrapping card stream with no per-favorite name, description, or statistics footer, spanning every -Dashboard layout (Full width/Report/2/3 columns). Consecutive explicit KPI +flow Dashboard layout (Report/2/3 columns). Consecutive explicit KPI favorites merge into one shared band. The complete [`kpi-panel.json`](examples/kpi-panel.json) Library example can be opened from **File ▾ → Import queries** to see both. diff --git a/schemas/dashboard-layout-flow-v1.schema.json b/schemas/dashboard-layout-flow-v1.schema.json index e21d1174..a7a0ac52 100644 --- a/schemas/dashboard-layout-flow-v1.schema.json +++ b/schemas/dashboard-layout-flow-v1.schema.json @@ -35,9 +35,9 @@ "$defs": { "flowPresetV1": { "title": "Flow preset", - "description": "Desktop column arrangement: full-width and report render one column (report centers a constrained-width column), columns-2 and columns-3 render equal columns.", + "description": "Desktop column arrangement: report renders one constrained-width centered column, columns-2 and columns-3 render equal columns.", "type": "string", - "enum": ["full-width", "report", "columns-2", "columns-3"] + "enum": ["report", "columns-2", "columns-3"] }, "flowHeightV1": { "title": "Tile height", diff --git a/schemas/generated/library-v2.bundle.schema.json b/schemas/generated/library-v2.bundle.schema.json index ecd91cc0..35f0a982 100644 --- a/schemas/generated/library-v2.bundle.schema.json +++ b/schemas/generated/library-v2.bundle.schema.json @@ -1252,10 +1252,9 @@ "$defs": { "flowPresetV1": { "title": "Flow preset", - "description": "Desktop column arrangement: full-width and report render one column (report centers a constrained-width column), columns-2 and columns-3 render equal columns.", + "description": "Desktop column arrangement: report renders one constrained-width centered column, columns-2 and columns-3 render equal columns.", "type": "string", "enum": [ - "full-width", "report", "columns-2", "columns-3" diff --git a/src/dashboard/application/dashboard-authoring-session.ts b/src/dashboard/application/dashboard-authoring-session.ts index b8680c67..520d8062 100644 --- a/src/dashboard/application/dashboard-authoring-session.ts +++ b/src/dashboard/application/dashboard-authoring-session.ts @@ -30,6 +30,7 @@ import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; import { resolveDashboardPresentations } from '../model/presentation-resolver.js'; import { buildDashboardExportBundle } from '../model/dashboard-export.js'; import { defaultLayoutRegistry } from '../layouts/layout-registry.js'; +import { deriveFlowFallback } from '../layouts/grafana-grid-layout.js'; import { applyCommand } from './dashboard-commands.js'; import type { DashboardCommand, DashboardCommandResult } from './dashboard-commands.js'; import { createQueryResolver } from './dashboard-query-resolver.js'; @@ -86,7 +87,12 @@ export interface DashboardAuthoringSessionDeps { function createEmptyDashboard(id: string): DashboardDocumentV1 { return { documentVersion: 1, id, title: 'Dashboard', revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, + layout: { + type: 'grafana-grid', + version: 1, + items: {}, + fallback: deriveFlowFallback({ type: 'grafana-grid', version: 1, items: {} }, []), + }, filters: [], tiles: [], }; } diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts index 167e6419..e87a3535 100644 --- a/src/dashboard/application/dashboard-viewer-session.ts +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -49,7 +49,7 @@ 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 type { GrafanaGridLayoutModel, GridRenderMode } from '../layouts/grafana-grid-layout.js'; import { resolveLayoutPluginSync } from '../layouts/layout-registry.js'; import type { DashboardLayoutRegistry } from '../layouts/layout-registry.js'; import type { @@ -109,7 +109,7 @@ export interface ViewerFilterState { * same-named fields (both have `columns`) never collide on one object. */ export type DashboardLayoutView = | (FlowLayoutModel & { engine: 'flow' }) - | { engine: 'grafana-grid'; grid: GrafanaGridLayoutModel }; + | { engine: 'grafana-grid'; grid: GrafanaGridLayoutModel; renderMode: GridRenderMode }; export interface DashboardViewState { tiles: ViewerTileState[]; @@ -220,6 +220,15 @@ export interface DashboardViewerSession { * flow model is recomputed. The tile SET must be unchanged (a membership * change rebuilds the session). */ syncDocument(next: DashboardDocumentV1): void; + /** #321 "Full view": set the TRANSIENT grafana-grid render-mode override + * ('tiles' = today's packed multi-tile-per-row grid, 'full' = every tile + * full-width, one per row). Runtime-only — never persisted, never a + * document mutation, never a commit/revision bump; it just republishes the + * current document through the new mode. Survives every other command + * (add/remove/reorder/height/syncDocument) since it lives outside + * `documentRef` entirely. A fresh session (reload/new viewer) always starts + * at 'tiles'. */ + setGridRenderMode(mode: GridRenderMode): void; /** Cancel all work and turn every later entry point into a no-op. */ destroy(): void; } @@ -297,6 +306,10 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // re-running tiles; the initial tile SET is fixed for the session's analysis. let documentRef: DashboardDocumentV1 = deps.document; let destroyed = false; + // #321 "Full view": a TRANSIENT runtime render-mode override, entirely + // outside `documentRef` — never read/written by any command, never + // persisted. A fresh session always starts at 'tiles'. + let gridRenderMode: GridRenderMode = 'tiles'; const queryById = new Map(); for (const query of queries) { @@ -429,8 +442,9 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa ? { engine: 'grafana-grid', grid: computeGrafanaGridLayout({ - tiles: visible, layout: documentRef.layout, containerWidth: deps.containerWidth?.(), + tiles: visible, layout: documentRef.layout, containerWidth: deps.containerWidth?.(), renderMode: gridRenderMode, }), + renderMode: gridRenderMode, } : { engine: 'flow', ...computeFlowLayout({ tiles: visible, layout: documentRef.layout, mobile }) }; return { @@ -747,6 +761,12 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa publish(); } + function setGridRenderMode(mode: GridRenderMode): void { + if (destroyed || gridRenderMode === mode) return; + gridRenderMode = mode; + publish(); + } + function destroy(): void { destroyed = true; for (const runtime of tiles) { @@ -762,6 +782,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa return { state: stateSignal as ReadonlySignal, controls, getFilterField, - start, refresh, refreshTile, setFilter, applyFilter, clearFilter, clearAllFilters, cancelTile, syncDocument, destroy, + start, refresh, refreshTile, setFilter, applyFilter, clearFilter, clearAllFilters, cancelTile, syncDocument, + setGridRenderMode, destroy, }; } diff --git a/src/dashboard/layouts/flow-layout.ts b/src/dashboard/layouts/flow-layout.ts index 59ecae45..b59544f8 100644 --- a/src/dashboard/layouts/flow-layout.ts +++ b/src/dashboard/layouts/flow-layout.ts @@ -126,11 +126,11 @@ export const flowLayoutPlugin: DashboardLayoutPlugin = { // stay renderer/theme concerns; this module owns column count, effective span, // deterministic row-major packing, KPI-band grouping, and mobile normalization. -/** Desktop column count for each flow preset (#280 "Presets"). full-width and - * report render one column (report centers a constrained-width column); +/** Desktop column count for each flow preset (#280 "Presets"; full-width + * removed #321). report renders one column (centered, constrained-width); * columns-2/columns-3 render two/three equal columns. */ export const FLOW_PRESET_COLUMNS: Record = { - 'full-width': 1, report: 1, 'columns-2': 2, 'columns-3': 3, + report: 1, 'columns-2': 2, 'columns-3': 3, }; const FLOW_PRESETS = new Set(Object.keys(FLOW_PRESET_COLUMNS)); @@ -141,7 +141,8 @@ const FLOW_PRESETS = new Set(Object.keys(FLOW_PRESET_COLUMNS)); export const FLOW_MOBILE_BREAKPOINT = 768; /** The desktop column count for a preset; an unknown/absent preset falls back - * to full-width (1). */ + * to 1 column (the same column count as `report`, the nearest valid single- + * column preset since full-width was removed, #321). */ export function presetColumns(preset: unknown): number { return typeof preset === 'string' && Object.hasOwn(FLOW_PRESET_COLUMNS, preset) ? FLOW_PRESET_COLUMNS[preset as FlowPresetV1] : 1; @@ -238,7 +239,7 @@ export function computeFlowLayout(input: ComputeFlowLayoutInput): FlowLayoutMode const { tiles, layout, mobile = false } = input; const surface = flowSurface(layout); const rawPreset = surface && typeof surface.preset === 'string' ? surface.preset : undefined; - const preset: FlowPresetV1 = rawPreset && FLOW_PRESETS.has(rawPreset) ? rawPreset as FlowPresetV1 : 'full-width'; + const preset: FlowPresetV1 = rawPreset && FLOW_PRESETS.has(rawPreset) ? rawPreset as FlowPresetV1 : 'report'; const items = surface && isObject(surface.items) ? surface.items as Record : {}; const columns = mobile ? 1 : presetColumns(preset); diff --git a/src/dashboard/layouts/grafana-grid-layout.ts b/src/dashboard/layouts/grafana-grid-layout.ts index 3ae79b8f..fc215e8b 100644 --- a/src/dashboard/layouts/grafana-grid-layout.ts +++ b/src/dashboard/layouts/grafana-grid-layout.ts @@ -300,6 +300,14 @@ export interface GrafanaGridTileRender { tileId: string; index: number; span: number; + /** The tile's resolved STORED span (`resolveGridPlacement(items[id]).span`) + * before any render-mode override and before the effective-columns clamp + * (#321 Full view). In `'tiles'` mode this equals the clamped `span`'s + * pre-clamp source value; in `'full'` mode `span` is overwritten to + * `columns` for the row-per-tile layout, so `persistedSpan` is the only + * place the unchanged stored span still travels — the UI's full-view + * resize must persist THIS value, never the overridden `span`. */ + persistedSpan: number; /** Row units (1..16), already canonicalized/defaulted by * `resolveGridPlacement` — never the legacy string form (#291 * height-units follow-up: renamed from `height` so a discriminating @@ -329,6 +337,13 @@ export interface GrafanaGridVisibleTile { isKpi?: boolean; } +/** The grafana-grid@1 render mode (#321 "Full view"): `'tiles'` is today's + * packed multi-tile-per-row grid; `'full'` renders every visible tile at the + * full effective column count — one tile per row — for the transient + * "Full view" render mode. Persistence is unaffected either way: the stored + * placement (`persistedSpan`) is never rewritten by a render-mode change. */ +export type GridRenderMode = 'tiles' | 'full'; + export interface ComputeGrafanaGridLayoutInput { tiles: readonly GrafanaGridVisibleTile[]; /** The grafana-grid layout document (or any object whose `items` holds @@ -337,6 +352,9 @@ export interface ComputeGrafanaGridLayoutInput { layout: unknown; /** The rendering container's width in px; see `effectiveGridColumns`. */ containerWidth?: number; + /** Render mode (#321); defaults to `'tiles'` (today's packed behavior) + * when absent. */ + renderMode?: GridRenderMode; } function gridItemsFor(layout: unknown): Record { @@ -351,7 +369,7 @@ function gridItemsFor(layout: unknown): Record { * no row-grouping type, band, or fold (rowless). Pure and non-mutating. */ export function computeGrafanaGridLayout(input: ComputeGrafanaGridLayoutInput): GrafanaGridLayoutModel { - const { tiles, layout, containerWidth } = input; + const { tiles, layout, containerWidth, renderMode = 'tiles' } = input; const columns = effectiveGridColumns(containerWidth); const items = gridItemsFor(layout); @@ -359,13 +377,20 @@ export function computeGrafanaGridLayout(input: ComputeGrafanaGridLayoutInput): let cursor = 0; const renders: GrafanaGridTileRender[] = tiles.map((tile, index) => { const placement = resolveGridPlacement(items[tile.id]); - const span = effectiveGridSpan(placement.span, columns); + const span = renderMode === 'full' ? columns : effectiveGridSpan(placement.span, columns); if (cursor + span > columns) { row += 1; cursor = 0; } const render: GrafanaGridTileRender = { - tileId: tile.id, index, span, heightUnits: placement.height, isKpi: !!tile.isKpi, row, colStart: cursor, + tileId: tile.id, + index, + span, + persistedSpan: placement.span, + heightUnits: placement.height, + isKpi: !!tile.isKpi, + row, + colStart: cursor, }; cursor += span; return render; @@ -389,8 +414,9 @@ export interface GrafanaGridFallbackTile { * 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. + * default (span 1). `columns-2` is the fallback preset (#321: full-width was + * removed from flow@1 entirely) — the canonical remaining single-decision + * fallback for a rowless grid with no fixed column count. */ export function deriveFlowFallback( gridLayout: unknown, tiles: readonly GrafanaGridFallbackTile[], @@ -403,7 +429,7 @@ export function deriveFlowFallback( span: flowSpanFromGridSpan(gridPlacement.span), height: gridHeightUnitsToFlowHeight(gridPlacement.height), }; } - return { type: 'flow', version: 1, preset: 'full-width', items: flowItems }; + return { type: 'flow', version: 1, preset: 'columns-2', items: flowItems }; } // ── Pure resize math (#291 Wave 3 — corner-drag resize): the DOM listener in diff --git a/src/generated/json-schema-validators.js b/src/generated/json-schema-validators.js index 9bab9120..13126d43 100644 --- a/src/generated/json-schema-validators.js +++ b/src/generated/json-schema-validators.js @@ -3809,7 +3809,7 @@ function validate44(data, { instancePath = "", parentData, parentDataProperty, r } validate44.evaluated = { "props": true, "dynamicProps": false, "dynamicItems": false }; var validateFlowLayoutV1 = validate46; -var schema57 = { "title": "Flow preset", "description": "Desktop column arrangement: full-width and report render one column (report centers a constrained-width column), columns-2 and columns-3 render equal columns.", "type": "string", "enum": ["full-width", "report", "columns-2", "columns-3"] }; +var schema57 = { "title": "Flow preset", "description": "Desktop column arrangement: report renders one constrained-width centered column, columns-2 and columns-3 render equal columns.", "type": "string", "enum": ["report", "columns-2", "columns-3"] }; var schema58 = { "title": "Tile placement", "description": "Closed placement contract: unknown fields fail validation. Future extension requires flow@2 or an explicit extension namespace.", "type": "object", "properties": { "span": { "title": "Column span", "description": "Columns the tile occupies; the effective span is clamped to the active column count.", "type": "integer", "enum": [1, 2, 3] }, "height": { "$ref": "#/$defs/flowHeightV1" } }, "additionalProperties": false, "x-altinity-order": ["span", "height"] }; var schema59 = { "title": "Tile height", "description": "Normative height ordering is compact < medium < large; exact pixels are renderer-defined.", "type": "string", "enum": ["compact", "medium", "large"] }; function validate47(data, { instancePath = "", parentData, parentDataProperty, rootData = data, dynamicAnchors = {} } = {}) { @@ -4001,7 +4001,7 @@ function validate46(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - if (!(data2 === "full-width" || data2 === "report" || data2 === "columns-2" || data2 === "columns-3")) { + if (!(data2 === "report" || data2 === "columns-2" || data2 === "columns-3")) { const err10 = { instancePath: instancePath + "/preset", schemaPath: "#/$defs/flowPresetV1/enum", keyword: "enum", params: { allowedValues: schema57.enum }, message: "must be equal to one of the allowed values" }; if (vErrors === null) { vErrors = [err10]; @@ -4586,7 +4586,7 @@ function validate54(data, { instancePath = "", parentData, parentDataProperty, r } errors++; } - if (!(data2 === "full-width" || data2 === "report" || data2 === "columns-2" || data2 === "columns-3")) { + if (!(data2 === "report" || data2 === "columns-2" || data2 === "columns-3")) { const err10 = { instancePath: instancePath + "/preset", schemaPath: "#/$defs/flowPresetV1/enum", keyword: "enum", params: { allowedValues: schema57.enum }, message: "must be equal to one of the allowed values" }; if (vErrors === null) { vErrors = [err10]; diff --git a/src/generated/json-schema.types.ts b/src/generated/json-schema.types.ts index c1bd9908..6c9bced6 100644 --- a/src/generated/json-schema.types.ts +++ b/src/generated/json-schema.types.ts @@ -628,9 +628,9 @@ export interface LibraryV2 { /** * Flow preset * - * Desktop column arrangement: full-width and report render one column (report centers a constrained-width column), columns-2 and columns-3 render equal columns. + * Desktop column arrangement: report renders one constrained-width centered column, columns-2 and columns-3 render equal columns. */ -export type FlowPresetV1 = "full-width" | "report" | "columns-2" | "columns-3"; +export type FlowPresetV1 = "report" | "columns-2" | "columns-3"; /** * Tile height diff --git a/src/generated/json-schemas.js b/src/generated/json-schemas.js index 51acf5a6..507cd8a7 100644 --- a/src/generated/json-schemas.js +++ b/src/generated/json-schemas.js @@ -1251,10 +1251,9 @@ export const flowLayoutV1Schema = { "$defs": { "flowPresetV1": { "title": "Flow preset", - "description": "Desktop column arrangement: full-width and report render one column (report centers a constrained-width column), columns-2 and columns-3 render equal columns.", + "description": "Desktop column arrangement: report renders one constrained-width centered column, columns-2 and columns-3 render equal columns.", "type": "string", "enum": [ - "full-width", "report", "columns-2", "columns-3" diff --git a/src/styles.css b/src/styles.css index 04cee5fa..fe745a22 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2401,18 +2401,12 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } max-width: 1560px; margin: 0 auto; } /* Report mode (#149 D2): one centered column (1100px) with taller tiles. The - .is-report/.is-wide selectors outweigh the responsive `.dash-grid` rules - above (class+class > single class), so both stay one column at every width — + .is-report selector outweighs the responsive `.dash-grid` rules above + (class+class > single class), so it stays one column at every width — consistent with the all-modes-to-one-column narrow fallback. */ .dash-grid.is-report { max-width: 1100px; } .dash-grid.is-report .dash-tile { min-height: 440px; } -.dash-grid.is-report .dash-tile.is-kpi, -.dash-grid.is-wide .dash-tile.is-kpi { min-height: 0; } -/* Full width mode (#184): one tile per row filling the whole available - dashboard content width (inside the grid's existing 20px page gutters), for - horizontally expansive Grafana-style panels. Unlike Report it keeps the - normal Arrange tile height — it tests width, not document-like height. */ -.dash-grid.is-wide { max-width: none; width: 100%; margin: 0; } +.dash-grid.is-report .dash-tile.is-kpi { min-height: 0; } /* D1 shows charts read-only: renderChart is called with controls:false so the Type/X/Y config bar isn't built at all (a settings-popover arrives in D6). */ .dash-empty { padding: 60px 20px; text-align: center; color: var(--fg-mute); font-size: 13px; } @@ -2564,7 +2558,6 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } .dash-toolbar { padding: 6px 10px; } .dash-grid, - .dash-grid.is-wide, .dash-grid.is-report { grid-template-columns: 1fr; max-width: none; width: 100%; margin: 0; padding: 12px; } @@ -2581,9 +2574,9 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } 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 + `display:grid` — same override pattern already used by `.is-report` 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 @@ -2638,6 +2631,11 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } .dash-gg-tile.dash-gg-resizing { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); transition: none; } +/* #321 Full view: every tile renders full width (one per row), so the resize + handle is VERTICAL-ONLY — a `ns-resize` cursor rather than the two- + dimensional `nwse-resize` above, hinting that horizontal movement has no + effect. */ +.dash-gg-grid.is-full .dash-gg-resize { cursor: ns-resize; } /* 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; } diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index d2f9e896..f9f12e42 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -51,10 +51,10 @@ import type { import { defaultLayoutRegistry, resolveLayoutPluginSync } from '../dashboard/layouts/layout-registry.js'; import type { FlowLayoutModel } from '../dashboard/layouts/flow-layout.js'; import { - DEFAULT_GRID_HEIGHT_UNITS, GRAFANA_GRID_MAX_COLUMNS, GRID_GAP_PX, contentBoxWidth, gridHeightUnitsToPx, - snapGridHeight, snapGridSpan, + DEFAULT_GRID_HEIGHT_UNITS, GRAFANA_GRID_MAX_COLUMNS, GRID_GAP_PX, contentBoxWidth, deriveFlowFallback, + gridHeightUnitsToPx, snapGridHeight, snapGridSpan, } from '../dashboard/layouts/grafana-grid-layout.js'; -import type { GrafanaGridLayoutModel } from '../dashboard/layouts/grafana-grid-layout.js'; +import type { GrafanaGridLayoutModel, GridRenderMode } 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'; @@ -182,6 +182,10 @@ interface TileEl { panelState: { key: string;[k: string]: unknown } | null; destroy: (() => void) | null; paintedRows: unknown[][] | null; + /** #321: the grid resize handle, when built (grafana-grid + edit mode) — its + * accessible label toggles between 'Resize' (tiles) and 'Resize tile + * height' (full view, vertical-only) as the render mode changes. */ + resizeHandle: HTMLElement | null; } /** Synthesize a filter definition per distinct `{name:Type}` panel-tile param @@ -337,7 +341,11 @@ export async function renderDashboard(app: DashboardApp): Promise { ? workspace.dashboard : { documentVersion: 1, id: 'empty', title: state.libraryName.value, revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, filters: [], tiles: [], + layout: { + type: 'grafana-grid', version: 1, items: {}, + fallback: deriveFlowFallback({ type: 'grafana-grid', version: 1, items: {} }, []), + }, + filters: [], tiles: [], }; let committedRevision = currentDoc.revision; @@ -392,49 +400,103 @@ export async function renderDashboard(app: DashboardApp): Promise { app.dom.themeBtn = themeBtn; // ── Preset switcher (change-layout command) ─────────────────────────────── + // #321: the local mirror of the viewer session's TRANSIENT grid render-mode + // override ('tiles'|'full') — read by `getActive`/`onPick` below (built + // synchronously, before the first publish) and kept current by the render + // effect (Part D) whenever `sview.layout.renderMode` changes. + let gridRenderMode: GridRenderMode = 'tiles'; // 2026-07-18 owner override: moved off the filter toolbar and into the top // header row (right after the tile-count chip) so the toolbar's whole width // is available for filters; a compact select needs far less room than the // four-button segmented control it replaces. - const layoutSelect = buildLayoutSelect([ - ['full-width', 'Full width', 'One tile per row using all available width'], + // #321: "Full view" is a TRANSIENT runtime render-mode override over the + // grafana-grid engine (never persisted) — it sits alongside "Grid Tiles" in + // the editable selector. A read-only (detached) view gets a REDUCED + // selector with only those two entries — layout editing (the flow presets, + // and the flow<->grid engine switch) stays an edit-mode-only affordance, + // but the render-mode toggle is harmless to expose read-only since it never + // persists anything. + const EDITABLE_LAYOUT_OPTIONS: LayoutOption[] = [ + ['grafana-grid', 'Grid Tiles', 'A responsive tile grid using authored spans and heights'], + ['full', 'Full view', 'Temporary full-width view — tile widths are not saved'], ['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'], - ['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 READONLY_LAYOUT_OPTIONS: LayoutOption[] = [ + ['grafana-grid', 'Grid Tiles', 'A responsive tile grid using authored spans and heights'], + ['full', 'Full view', 'Temporary full-width view — tile widths are not saved'], + ]; + const getActiveLayoutOption = (): string => (currentDoc.layout.type === 'grafana-grid' + ? (gridRenderMode === 'full' ? 'full' : 'grafana-grid') + : typeof currentDoc.layout.preset === 'string' ? currentDoc.layout.preset : 'report'); + const layoutSelect = buildLayoutSelect( + readOnly ? READONLY_LAYOUT_OPTIONS : EDITABLE_LAYOUT_OPTIONS, + getActiveLayoutOption, + (value) => { + // #321 read-only: the reduced selector offers ONLY 'grafana-grid'/'full' + // — either choice is ONLY ever the transient render-mode override, NEVER + // a command / persistence. + if (readOnly) { + session.setGridRenderMode(value === 'full' ? 'full' : 'tiles'); + layoutSelect.sync(); + return; + } + if (value === 'grafana-grid') { + // Full view -> Grid Tiles: clear the transient override in place (no + // command). Flow -> Grid Tiles: the existing persisted engine switch. + // Already grid+tiles: no-op. + if (gridRenderMode === 'full') session.setGridRenderMode('tiles'); + else if (currentDoc.layout.type !== 'grafana-grid') { + runCommand({ type: 'change-layout', layout: { type: 'grafana-grid', version: 1 } as DashboardLayoutDocumentV1 }); + } + layoutSelect.sync(); + return; + } + if (value === 'full') { + // Grid already active: only the transient override changes. Flow + // active: persist the ONE flow->grid conversion, THEN apply the + // override (still transient) — the conversion is the only persisted + // change; the full-view override itself never is. + if (currentDoc.layout.type !== 'grafana-grid') { + runCommand({ type: 'change-layout', layout: { type: 'grafana-grid', version: 1 } as DashboardLayoutDocumentV1 }); + } + session.setGridRenderMode('full'); + layoutSelect.sync(); + return; + } + // A flow preset: clear any transient full-view override first (#321 — + // picking a flow preset always lands on 'tiles' semantics), then apply + // the existing persisted flow preset/engine-switch logic unchanged. + if (gridRenderMode === 'full') session.setGridRenderMode('tiles'); + 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 } }); + } + layoutSelect.sync(); + }, + 'Dashboard style', + ); const layoutWrap = h('div', { class: 'dash-layout-wrap' }, layoutSelect.el); + // #321 BLOCKER fix: the reduced read-only selector (Grid Tiles / Full view) + // is a grafana-grid-only render-mode toggle — expose it read-only ONLY when + // the persisted doc is grafana-grid. A read-only FLOW doc (report/columns-2/ + // columns-3 — any pre-#321 shared doc) must hide the selector entirely: no + // engine switch is possible read-only, so this is decided once at build + // time from the static `currentDoc.layout.type`. Editable mode is unchanged + // (always the full selector). + const showLayoutSelect = !readOnly || currentDoc.layout.type === 'grafana-grid'; // #302: the Dashboard page's own resource-scoped File menu (import/export + - // open-for-viewing). #288: a read-only (detached view) tab hides the layout - // switcher — layout editing is an edit-mode-only affordance. + // open-for-viewing). const fileMenuBtn = buildDashboardFileMenu(app, readOnly); const header = h('div', { class: 'dash-header' }, h('a', { class: 'dash-back', href: app.conn.basePath || '/sql', title: 'Back to SQL Browser', 'aria-label': 'Back to SQL Browser', }, Icon.arrow(), h('span', { class: 'dash-back-label' }, 'SQL Browser')), h('div', { class: 'dash-title' }, currentDoc.title || state.libraryName.value), - tileCount, readOnly ? null : layoutWrap, + tileCount, showLayoutSelect ? layoutWrap : null, h('div', { class: 'dash-spacer', style: { flex: '1' } }), h('span', { class: 'dash-chip dash-src', title: app.conn.host() }, h('span', { class: 'dash-dot' }), app.conn.host()), updated, fileMenuBtn, themeBtn, refreshBtn); @@ -559,7 +621,8 @@ export async function renderDashboard(app: DashboardApp): Promise { // rendered values, not a stale/default guess. `colStart` (#291 review F3) // is what lets the drag PIN the tile's column position for the gesture's // duration — see `wireGridResize` below. - const gridPlacementByTile = new Map(); + 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 @@ -574,6 +637,21 @@ export async function renderDashboard(app: DashboardApp): Promise { card.style.height = gridHeightUnitsToPx(heightUnits) + 'px'; } + // #321: the resize handle's accessible label/title reflects the CURRENT + // render mode ('tiles' = two-dimensional resize, 'full' = vertical-only) — + // the cursor affordance is pure CSS (`.dash-gg-grid.is-full .dash-gg-resize`, + // styles.css), so only the label needs a per-tile DOM update when the mode + // flips (Part D, the render effect). + function resizeHandleLabel(full: boolean): string { + return full ? 'Resize tile height' : 'Resize'; + } + function applyResizeHandleMode(tileEl: TileEl, full: boolean): void { + if (!tileEl.resizeHandle) return; + const label = resizeHandleLabel(full); + tileEl.resizeHandle.title = label; + tileEl.resizeHandle.setAttribute('aria-label', label); + } + // #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 @@ -597,36 +675,49 @@ export async function renderDashboard(app: DashboardApp): Promise { // preview and the persisted span are clamped to `columns - colStart` for // the gesture. Widening further than that needs a second drag after the // next repack (deterministic beats a jumpy mid-drag reflow). + // #321 Full view (vertical-only resize): while `gridRenderMode === 'full'` + // every tile renders at the full effective column count (its EFFECTIVE + // `span`, in `gridPlacementByTile`) — horizontal pointer movement is + // ignored entirely (no `grid-column` re-pin: the card IS full width, there + // is no sub-span to preview), and the pointerup dispatch re-sends the + // tile's UNCHANGED `persistedSpan` (the authored span `gridPlacementByTile` + // also carries — never the overridden full-width `span`) alongside the new + // height, so a Full-view resize can only ever change height. 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 full = gridRenderMode === 'full'; const columns = Math.max(1, currentGridColumns); const placement = gridPlacementByTile.get(tileId); const colStart = placement ? placement.colStart : 0; + const persistedSpan = placement ? placement.persistedSpan : columns; // The columns actually available at this tile's pinned start — the - // clamp ceiling for both the live preview and the persisted span. + // clamp ceiling for both the live preview and the persisted span + // (tiles mode only — full view never touches span). const maxSpan = Math.max(1, columns - colStart); let curSpan = Math.min(placement ? placement.span : columns, maxSpan); let curHeight = placement ? placement.heightUnits : DEFAULT_GRID_HEIGHT_UNITS; - card.style.gridColumn = `${colStart + 1} / span ${curSpan}`; + if (!full) card.style.gridColumn = `${colStart + 1} / span ${curSpan}`; const rect = card.getBoundingClientRect(); const colWidthPx = (measuredGridWidth() - GRID_GAP_PX * (columns - 1)) / columns; 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, maxSpan); + if (!full) { + const span = snapGridSpan(ev.clientX - rect.left, colWidthPx, GRID_GAP_PX, maxSpan); + if (span !== curSpan) { curSpan = span; card.style.gridColumn = `${colStart + 1} / span ${curSpan}`; } + } const height = snapGridHeight(ev.clientY - rect.top); - if (span !== curSpan) { curSpan = span; card.style.gridColumn = `${colStart + 1} / span ${curSpan}`; } if (height !== curHeight) { curHeight = height; setGridHeightPx(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 } }); + runCommand({ type: 'update-placement', tileId, placement: { span: full ? persistedSpan : curSpan, height: curHeight } }); }; win.addEventListener('pointermove', move as EventListener); win.addEventListener('pointerup', up); @@ -650,7 +741,9 @@ export async function renderDashboard(app: DashboardApp): Promise { 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 resizeHandle = !readOnly ? h('div', { class: 'dash-gg-resize', title: 'Resize' }) : null; + const resizeHandle = !readOnly + ? h('div', { class: 'dash-gg-resize', title: 'Resize', 'aria-label': 'Resize' }) + : null; // #316: a static, per-load mode class (view mode never toggles mid-session // — `readOnly` is resolved once above, before any tile is built) — CSS // scopes the frameless-KPI-in-view-mode treatment to @@ -674,7 +767,8 @@ export async function renderDashboard(app: DashboardApp): Promise { }); } if (resizeHandle) wireGridResize(ts.tileId, resizeHandle, card); - const tileEl: TileEl = { card, body, foot, panelState: null, destroy: null, paintedRows: null }; + const tileEl: TileEl = { card, body, foot, panelState: null, destroy: null, paintedRows: null, resizeHandle }; + if (resizeHandle) applyResizeHandleMode(tileEl, gridRenderMode === 'full'); tileEls.set(ts.tileId, tileEl); return tileEl; } @@ -702,7 +796,16 @@ export async function renderDashboard(app: DashboardApp): Promise { }); tileEl.destroy = out.destroy || null; tileEl.body.replaceChildren(out.node); - tileEl.foot.replaceChildren(...tileFooter(ts.meta as NonNullable)); + // #329: a 'ready' tile can legitimately carry no result meta (`ts.meta` + // is `… | null`, only set after a query executes — a Text panel renders + // static content and never does), so the footer is rendered only when + // there IS meta. The previous `as NonNullable` cast lied and threw + // `Cannot read properties of null (reading 'rows')` in `tileFooter`, + // which — reached inside the grafana-grid reconcile loop BEFORE the host + // gets `dash-gg-grid` — aborted the entire Grid Tiles render (#321 made + // that the default engine). The flow renderer shares this path and had + // the same latent crash. + tileEl.foot.replaceChildren(...(ts.meta ? tileFooter(ts.meta) : [])); tileEl.paintedRows = ts.rows; } @@ -791,7 +894,7 @@ export async function renderDashboard(app: DashboardApp): Promise { // #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.remove('dash-gg-grid', 'is-full'); // #321: is-full is grid-engine-only chrome grid.classList.toggle('is-report', layout.preset === 'report'); grid.style.gridTemplateColumns = ''; grid.replaceChildren(...layout.rows.map((row) => { @@ -881,14 +984,18 @@ export async function renderDashboard(app: DashboardApp): Promise { // 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.remove('is-report'); // flow-only preset modifier 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, heightUnits: t.heightUnits, colStart: t.colStart }); + // #321: `persistedSpan` is the authored (never render-mode-overridden) + // span — the ONLY value a Full-view resize re-persists on pointerup. + gridPlacementByTile.set(t.tileId, { + span: t.span, heightUnits: t.heightUnits, colStart: t.colStart, persistedSpan: t.persistedSpan, + }); tileEl.card.classList.add('dash-gg-tile'); // (`is-kpi` + the group role/name are maintained by `reconcileGridTile`, // which runs on EVERY pass — this loop is signature-gated and would miss @@ -906,8 +1013,8 @@ export async function renderDashboard(app: DashboardApp): Promise { // 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. + // `is-report` 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 @@ -963,6 +1070,17 @@ export async function renderDashboard(app: DashboardApp): Promise { ); if (sview.layout.engine !== lastEngineRendered) { lastLayoutSig = ''; lastGridSig = ''; lastEngineRendered = sview.layout.engine; } activeEngine = sview.layout.engine; + // #321: keep the local render-mode mirror current from the published + // grafana-grid layout view — the ONLY place this session-owned, transient + // state is read back into the UI. A change re-syncs the selector, flips + // the grid host's `is-full` class (the CSS vertical-resize-cursor hook), + // and updates every built resize handle's accessible label. + if (sview.layout.engine === 'grafana-grid' && sview.layout.renderMode !== gridRenderMode) { + gridRenderMode = sview.layout.renderMode; + layoutSelect.sync(); + grid.classList.toggle('is-full', gridRenderMode === 'full'); + for (const tileEl of tileEls.values()) applyResizeHandleMode(tileEl, gridRenderMode === 'full'); + } if (sview.layout.engine === 'grafana-grid') reconcileGrafanaGrid(sview, sview.layout.grid); else reconcileGrid(sview, sview.layout); refreshBtn.disabled = sview.running; diff --git a/src/workspace/legacy-migration.ts b/src/workspace/legacy-migration.ts index 7e0f2d31..3caa8637 100644 --- a/src/workspace/legacy-migration.ts +++ b/src/workspace/legacy-migration.ts @@ -49,11 +49,12 @@ export interface LegacyWorkspaceInput { /** Map the legacy Dashboard layout preferences to a normative flow@1 preset. * Reuses the existing `activeDashboardView` derivation (core/dashboard.ts) and - * remaps its `wide` value to the flow preset name `full-width`; `report`, + * remaps its `wide` value to the nearest valid single-column flow preset, + * `report` (#321: `full-width` was removed from flow@1 entirely); `report`, * `columns-2`, and `columns-3` already match the flow preset names. */ export function legacyLayoutToFlowPreset(dashLayout: string, dashCols: number): FlowPresetV1 { const view = activeDashboardView({ dashLayout, dashCols }); - return view === 'wide' ? 'full-width' : view; + return view === 'wide' ? 'report' : view; } /** Build the one candidate StoredWorkspaceV1 from the legacy state (steps 2-4). diff --git a/tests/e2e/dashboard-grid.html b/tests/e2e/dashboard-grid.html index 3cb412ae..a110dedf 100644 --- a/tests/e2e/dashboard-grid.html +++ b/tests/e2e/dashboard-grid.html @@ -57,6 +57,13 @@

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

+
+

Full view (#321 transient render-mode override) — vertical-only resize

+
+
+
+
+ diff --git a/tests/e2e/dashboard-grid.spec.js b/tests/e2e/dashboard-grid.spec.js index 543656b9..5a438457 100644 --- a/tests/e2e/dashboard-grid.spec.js +++ b/tests/e2e/dashboard-grid.spec.js @@ -261,4 +261,67 @@ test.describe('Dashboard grafana-grid layout', () => { await expect(page.locator('#viewonly-grid .dash-gg-del')).toHaveCount(0); await expect(page.locator('#viewonly-grid .dash-gg-resize')).toHaveCount(0); }); + + // #321 "Full view": a TRANSIENT grafana-grid render-mode override — every + // tile spans the full effective column count, and resize is vertical-only. + test('Full view renders every tile at the full effective column count (#321)', async ({ page }) => { + await openWide(page); + const columns = await page.evaluate(() => window.__fullColumns()); + const cards = page.locator('#full-grid .dash-tile'); + await expect(cards).toHaveCount(2); + for (const id of ['f1', 'f2']) { + const card = page.locator(`#full-grid .dash-tile[data-tile-id="${id}"]`); + expect(await card.evaluate((node) => node.style.gridColumn)).toBe(`span ${columns}`); + } + // The authored (persisted) spans still travel on the model, unchanged by + // the full-width override. + expect(await page.evaluate(() => window.__fullPersistedSpan('f1'))).toBe(4); + expect(await page.evaluate(() => window.__fullPersistedSpan('f2'))).toBe(8); + // The resize handle's accessible label reflects vertical-only resize. + const handle = page.locator('#full-grid .dash-tile[data-tile-id="f1"] .dash-gg-resize'); + await expect(handle).toHaveAttribute('aria-label', 'Resize tile height'); + // The CSS vertical-resize cursor hook is present on the grid host. + expect(await page.locator('#full-grid').evaluate((node) => node.classList.contains('is-full'))).toBe(true); + expect(await page.locator('#full-grid .dash-gg-resize').first().evaluate((node) => getComputedStyle(node).cursor)).toBe('ns-resize'); + }); + + test('Full view resize is vertical-only: horizontal movement never changes span, and the dispatched span is the UNCHANGED persisted one', async ({ page }) => { + await openWide(page); + const card = page.locator('#full-grid .dash-tile[data-tile-id="f1"]'); // authored span 4, rendered full width + const handle = page.locator('#full-grid .dash-tile[data-tile-id="f1"] .dash-gg-resize'); + const columns = await page.evaluate(() => window.__fullColumns()); + const before = await card.evaluate((node) => node.style.gridColumn); + expect(before).toBe(`span ${columns}`); + + 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/); + + // A large horizontal + vertical drag, dispatched synthetically on window + // (where the wiring listens): Firefox does not deliver a real mouse move + // this far outside the viewport. Height is measured from the CARD's top + // (`clientY - rect.top`, the exact math the app uses), so drive clientY to + // `rect.top + 390` for a deterministic 4-row-unit result — distinct from + // the tile's authored 2 units, proving the height actually changed — while + // the huge clientX delta proves horizontal movement is ignored (span + // stays the full column count, no grid-column re-pin). + await page.evaluate(({ x, y }) => { + window.dispatchEvent(new PointerEvent('pointermove', { clientX: x, clientY: y })); + }, { x: rect.left + 100000, y: rect.top + 390 }); + expect(await card.evaluate((node) => node.style.gridColumn)).toBe(`span ${columns}`); + const expectedHeight = await page.evaluate(() => window.__gridHeightUnitsToPx(window.__snapGridHeight(390)) + 'px'); + expect(await card.evaluate((node) => node.style.height)).toBe(expectedHeight); + + await page.mouse.up(); + await expect(card).not.toHaveClass(/dash-gg-resizing/); + const events = await page.evaluate(() => window.__fullResizeEvents); + expect(events).toHaveLength(1); + // The re-dispatched span is the tile's PERSISTED (authored) span, 4 — + // never the full-width rendered span (`columns`). + expect(events[0].tileId).toBe('f1'); + expect(events[0].span).toBe(4); + }); }); diff --git a/tests/e2e/dashboard-mobile.html b/tests/e2e/dashboard-mobile.html index 013a45fb..6447bd39 100644 --- a/tests/e2e/dashboard-mobile.html +++ b/tests/e2e/dashboard-mobile.html @@ -17,8 +17,9 @@
A deliberately long production operations Dashboard name
6 favorites
- + + @@ -91,8 +92,9 @@ localStorage.setItem('asb:dashCols', '3'); window.__layoutApplyCount = 0; window.__setLayout = (mode) => { + // #321: 'full-width'/'wide' removed — every mode this harness exercises + // is a flow preset (report/columns-2/columns-3). const grid = document.querySelector('.dash-grid'); - grid.classList.toggle('is-wide', mode === 'wide'); grid.classList.toggle('is-report', mode === 'report'); grid.style.setProperty('--dash-cols', mode === 'columns-2' ? '2' : '3'); window.__layoutApplyCount++; diff --git a/tests/e2e/dashboard-mobile.spec.js b/tests/e2e/dashboard-mobile.spec.js index 85c0d33d..f6907478 100644 --- a/tests/e2e/dashboard-mobile.spec.js +++ b/tests/e2e/dashboard-mobile.spec.js @@ -68,7 +68,9 @@ test.describe('Dashboard mobile layout', () => { test('visually normalizes every saved layout on mobile and restores desktop CSS on resize', async ({ page }) => { await openAt(page, 390); - for (const mode of ['wide', 'report', 'columns-2', 'columns-3']) { + // 'wide'/'full-width' removed (#321) — every remaining flow preset still + // normalizes to one column on mobile. + for (const mode of ['report', 'columns-2', 'columns-3']) { await page.evaluate((next) => window.__setLayout(next), mode); const layout = await page.locator('.dash-grid').evaluate((grid) => { const tile = grid.querySelector('.dash-tile'); diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index ef8ab77f..1df76cbc 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -4359,7 +4359,7 @@ describe('mobile best-effort mode (#126)', () => { describe('Dashboard viewing (open-source, handoff, actions) — #288/#302', () => { const vdash = () => ({ documentVersion: 1, id: 'd', title: 'My View', revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } }, + layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, filters: [], tiles: [{ id: 't1', queryId: 'q1' }], }); const vquery = () => savedQuery({ id: 'q1', name: 'q1', sql: 'SELECT 1' }); diff --git a/tests/unit/canonical-json.test.ts b/tests/unit/canonical-json.test.ts index 9e20fc4a..c0e8ff00 100644 --- a/tests/unit/canonical-json.test.ts +++ b/tests/unit/canonical-json.test.ts @@ -81,7 +81,7 @@ describe('documented shapes', () => { const dashboard = { tiles: [{ queryId: 'q1', id: 't1' }], filters: [], - layout: { version: 1, type: 'flow', preset: 'full-width', items: { t1: { height: 'medium', span: 1 } } }, + layout: { version: 1, type: 'flow', preset: 'report', items: { t1: { height: 'medium', span: 1 } } }, revision: 1, title: 'D', id: 'd1', documentVersion: 1, }; const query = { spec: { name: 'Q' }, specVersion: 1, sql: 'SELECT 1', id: 'q1' }; @@ -113,7 +113,7 @@ describe('documented shapes', () => { const doc = canonicalJson({ tiles: [{ presentation: { override: { b: 1 }, variant: 'v' }, queryId: 'q', id: 't' }], filters: [{ defaultActive: true, id: 'f', parameter: 'p' }], - layout: { type: 'flow', version: 1, config: { z: 1, a: 2 }, fallback: { type: 'flow', version: 1, preset: 'full-width', items: {} } }, + layout: { type: 'flow', version: 1, config: { z: 1, a: 2 }, fallback: { type: 'flow', version: 1, preset: 'report', items: {} } }, revision: 2, title: 'T', id: 'd', documentVersion: 1, }, DASHBOARD_DOCUMENT_SHAPE); expect(doc.indexOf('"variant"')).toBeLessThan(doc.indexOf('"override"')); diff --git a/tests/unit/dashboard-authoring-session.test.ts b/tests/unit/dashboard-authoring-session.test.ts index 9371a0f4..09e0bd85 100644 --- a/tests/unit/dashboard-authoring-session.test.ts +++ b/tests/unit/dashboard-authoring-session.test.ts @@ -18,7 +18,7 @@ const filterQuery = (id: string) => ({ const emptyDash = () => ({ documentVersion: 1 as const, id: 'dash', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, filters: [], tiles: [], + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, filters: [], tiles: [], }); const workspaceFixture = (over: Partial = {}): StoredWorkspaceV1 => ({ @@ -194,10 +194,14 @@ describe('DashboardAuthoringSession — revision semantics and commit', () => { expect(retry.ok && retry.dashboardRevision).toBe(2); // the failed attempt did not consume a revision }); - it('starts an empty flow Dashboard (revision 1) when the workspace has none', async () => { + it('starts an empty grafana-grid Dashboard (revision 1) with a columns-2 flow fallback when the workspace has none', async () => { const { session } = makeSession({ workspace: workspaceFixture({ dashboard: null }) }); expect(session.state.value.document.id).toBe('g1'); expect(session.state.value.document.revision).toBe(1); + expect(session.state.value.document.layout).toEqual({ + type: 'grafana-grid', version: 1, items: {}, + fallback: { type: 'flow', version: 1, preset: 'columns-2', items: {} }, + }); await session.execute({ type: 'add-query', queryId: 'q1' }); const committed = await session.commit(); expect(committed.ok && committed.dashboardRevision).toBe(1); diff --git a/tests/unit/dashboard-commands.test.ts b/tests/unit/dashboard-commands.test.ts index 19c8d5c0..29b69395 100644 --- a/tests/unit/dashboard-commands.test.ts +++ b/tests/unit/dashboard-commands.test.ts @@ -13,7 +13,7 @@ const query = (id: string, dashboard?: Record) => ({ const draft = (over: Partial = {}): DashboardDocumentV1 => ({ documentVersion: 1, id: 'd', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, filters: [], tiles: [], ...over, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, filters: [], tiles: [], ...over, } as DashboardDocumentV1); const makeCtx = (queries: unknown[], plugin: DashboardLayoutPlugin = flowLayoutPlugin): ApplyCommandContext => { @@ -62,7 +62,7 @@ describe('applyCommand — add-query / add-query-instance', () => { describe('applyCommand — remove / move', () => { const seeded = () => draft({ tiles: [{ id: 'a', queryId: 'q' }, { id: 'b', queryId: 'q' }, { id: 'c', queryId: 'q' }] as never, - layout: { type: 'flow', version: 1, preset: 'full-width', items: { a: {}, b: {} } } as never, + layout: { type: 'flow', version: 1, preset: 'report', items: { a: {}, b: {} } } as never, }); it('removes a tile and fails for a missing one', () => { @@ -230,7 +230,7 @@ describe('applyCommand — change-layout engine switch (#291 owner decision 3)', }); 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 fallbackLayout = { type: 'flow', version: 1, preset: 'report', 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')]); @@ -242,7 +242,7 @@ describe('applyCommand — change-layout engine switch (#291 owner decision 3)', }); 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 fallbackLayout = { type: 'flow', version: 1, preset: 'report', 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')]); @@ -277,7 +277,7 @@ describe('applyCommand — change-layout engine switch (#291 owner decision 3)', 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' } }, + type: 'flow', version: 1, preset: 'columns-2', items: { t1: { span: 2, height: 'medium' } }, }); } }); @@ -298,7 +298,7 @@ describe('applyCommand — change-layout engine switch (#291 owner decision 3)', if (result.ok) { expect(result.dashboard.layout.items).toEqual({ t1: { span: 4 }, t2: { span: 8 } }); expect(result.dashboard.layout.fallback).toEqual({ - type: 'flow', version: 1, preset: 'full-width', + type: 'flow', version: 1, preset: 'columns-2', items: { t1: { span: 1, height: 'medium' }, t2: { span: 2, height: 'medium' } }, }); } @@ -328,7 +328,7 @@ describe('applyCommand — grid fallback regeneration on every mutating command expect(result.ok).toBe(true); if (result.ok) { expect(result.dashboard.layout.fallback).toEqual({ - type: 'flow', version: 1, preset: 'full-width', + type: 'flow', version: 1, preset: 'columns-2', items: { a: { span: 1, height: 'medium' }, b: { span: 2, height: 'medium' }, 'tile-1': { span: 2, height: 'medium' } }, }); } @@ -343,7 +343,7 @@ describe('applyCommand — grid fallback regeneration on every mutating command expect(result.ok).toBe(true); if (result.ok) { expect(result.dashboard.layout.fallback).toEqual({ - type: 'flow', version: 1, preset: 'full-width', + type: 'flow', version: 1, preset: 'columns-2', items: { a: { span: 3, height: 'medium' }, 'tile-1': { span: 2, height: 'medium' } }, }); } @@ -354,7 +354,7 @@ describe('applyCommand — grid fallback regeneration on every mutating command 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' } }, + type: 'flow', version: 1, preset: 'columns-2', items: { b: { span: 2, height: 'medium' } }, }); } }); @@ -364,7 +364,7 @@ describe('applyCommand — grid fallback regeneration on every mutating command expect(result.ok).toBe(true); if (result.ok) { expect(result.dashboard.layout.fallback).toEqual({ - type: 'flow', version: 1, preset: 'full-width', + type: 'flow', version: 1, preset: 'columns-2', items: { a: { span: 1, height: 'medium' }, b: { span: 2, height: 'medium' } }, }); } @@ -375,7 +375,7 @@ describe('applyCommand — grid fallback regeneration on every mutating command expect(result.ok).toBe(true); if (result.ok) { expect(result.dashboard.layout.fallback).toEqual({ - type: 'flow', version: 1, preset: 'full-width', + type: 'flow', version: 1, preset: 'columns-2', items: { a: { span: 3, height: 'medium' }, b: { span: 2, height: 'medium' } }, }); } diff --git a/tests/unit/dashboard-export.test.ts b/tests/unit/dashboard-export.test.ts index 71e6b019..7309cd8e 100644 --- a/tests/unit/dashboard-export.test.ts +++ b/tests/unit/dashboard-export.test.ts @@ -14,7 +14,7 @@ const dashboard = ( id: string, tileQueryIds: string[], filterSourceIds: string[] = [], ): DashboardDocumentV1 => ({ documentVersion: 1, id, title: `Dashboard ${id}`, revision: 3, - layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, tiles: tileQueryIds.map((queryId, index) => ({ id: `${id}-t${index}`, queryId, presentation: { kind: 'table' }, } as unknown as DashboardDocumentV1['tiles'][number])), diff --git a/tests/unit/dashboard-viewer-session.test.ts b/tests/unit/dashboard-viewer-session.test.ts index 400223b5..1f14aeca 100644 --- a/tests/unit/dashboard-viewer-session.test.ts +++ b/tests/unit/dashboard-viewer-session.test.ts @@ -686,6 +686,90 @@ describe('grafana-grid engine routing (#291)', () => { }); }); +// #321 "Full view": setGridRenderMode is a TRANSIENT runtime override — never +// a document mutation, never a commit (there is nothing to commit against; +// the session has no `workspace.commit` seam at all), never a revision bump. +describe('setGridRenderMode / Full view (#321)', () => { + const gridDoc = (over: Partial = {}) => doc({ + tiles: [tile('a', 'qa'), tile('b', 'qb')], + layout: { type: 'grafana-grid', version: 1, items: { a: { span: 4, height: 2 } } }, + ...over, + }); + const gridQueries = () => [query('qa', 'SELECT 1'), query('qb', 'SELECT 2')]; + + it('defaults to tiles mode; every tile keeps its authored/effective span', async () => { + const { exec } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const session = createDashboardViewerSession(makeDeps({ document: gridDoc(), exec, queries: gridQueries() })); + await session.start(); + const layout = session.state.value.layout; + if (layout.engine !== 'grafana-grid') throw new Error('expected grafana-grid engine'); + expect(layout.renderMode).toBe('tiles'); + expect(layout.grid.tiles[0]).toMatchObject({ tileId: 'a', span: 4, persistedSpan: 4 }); + expect(layout.grid.tiles[1]).toMatchObject({ tileId: 'b', span: 6, persistedSpan: 6 }); + }); + + it('setGridRenderMode(\'full\') republishes with every tile spanning the full column count, ' + + 'WITHOUT touching the document or committing', async () => { + const { exec } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const document = gridDoc(); + const itemsBefore = JSON.stringify(document.layout.items); + const session = createDashboardViewerSession(makeDeps({ document, exec, queries: gridQueries() })); + await session.start(); + session.setGridRenderMode('full'); + const layout = session.state.value.layout; + if (layout.engine !== 'grafana-grid') throw new Error('expected grafana-grid engine'); + expect(layout.renderMode).toBe('full'); + expect(layout.grid.tiles.every((t) => t.span === layout.grid.columns)).toBe(true); + // persistedSpan is untouched — the authored spans still travel. + expect(layout.grid.tiles[0].persistedSpan).toBe(4); + expect(layout.grid.tiles[1].persistedSpan).toBe(6); + // The caller's own document object (and its items) is bit-identical. + expect(JSON.stringify(document.layout.items)).toBe(itemsBefore); + }); + + it('setGridRenderMode(\'tiles\') after \'full\' restores the exact authored spans', async () => { + const { exec } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const session = createDashboardViewerSession(makeDeps({ document: gridDoc(), exec, queries: gridQueries() })); + await session.start(); + session.setGridRenderMode('full'); + session.setGridRenderMode('tiles'); + const layout = session.state.value.layout; + if (layout.engine !== 'grafana-grid') throw new Error('expected grafana-grid engine'); + expect(layout.renderMode).toBe('tiles'); + expect(layout.grid.tiles[0]).toMatchObject({ span: 4, persistedSpan: 4 }); + expect(layout.grid.tiles[1]).toMatchObject({ span: 6, persistedSpan: 6 }); + }); + + it('survives a subsequent syncDocument (placement command) — the render-mode override is session-owned, not document-owned', async () => { + const { exec } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const document = gridDoc(); + const session = createDashboardViewerSession(makeDeps({ document, exec, queries: gridQueries() })); + await session.start(); + session.setGridRenderMode('full'); + // A placement-command-style syncDocument (a height change on tile 'a'). + session.syncDocument({ + ...document, + layout: { type: 'grafana-grid', version: 1, items: { a: { span: 4, height: 5 } } }, + }); + const layout = session.state.value.layout; + if (layout.engine !== 'grafana-grid') throw new Error('expected grafana-grid engine'); + expect(layout.renderMode).toBe('full'); + expect(layout.grid.tiles.every((t) => t.span === layout.grid.columns)).toBe(true); + expect(layout.grid.tiles[0].heightUnits).toBe(5); + }); + + it('is a no-op after destroy', async () => { + const { exec } = makeExec(() => ({ columns: [{ name: 'n' }], rows: [[1]] })); + const session = createDashboardViewerSession(makeDeps({ document: gridDoc(), exec, queries: gridQueries() })); + await session.start(); + session.destroy(); + session.setGridRenderMode('full'); + const layout = session.state.value.layout; + if (layout.engine !== 'grafana-grid') throw new Error('expected grafana-grid engine'); + expect(layout.renderMode).toBe('tiles'); + }); +}); + 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]] })); diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index 25f82daa..7b3f0fc5 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -11,6 +11,9 @@ import { snapshotAuth, restoreAuth, hasAuth, isAuthRequest, isAuthGrant, } from '../../src/core/auth-handoff.js'; import { renderDashboard } from '../../src/ui/dashboard.js'; +import { applyCommand } from '../../src/dashboard/application/dashboard-commands.js'; +import { createQueryResolver } from '../../src/dashboard/application/dashboard-query-resolver.js'; +import { resolveLayoutPluginSync } from '../../src/dashboard/layouts/layout-registry.js'; import { applyStreamLine } from '../../src/core/stream.js'; import { emptyRecentMap, recordRecent } from '../../src/core/recent-values.js'; import { makeApp, FakeChart } from '../helpers/fake-app.js'; @@ -403,14 +406,14 @@ describe('renderDashboard — flow layout + preset switcher (#280)', () => { expect(layoutSelect(app.root).value).toBe('columns-2'); const rows = qsa(app.root, '.dash-row'); expect((rows[0].style as CSSStyleDeclaration).gridTemplateColumns).toContain('repeat(2'); - // Switch to full-width — one column. - pickLayout(app.root, 'full-width'); - expect(layoutSelect(app.root).value).toBe('full-width'); + // Switch to report — one column (full-width was removed, #321). + pickLayout(app.root, 'report'); + expect(layoutSelect(app.root).value).toBe('report'); expect((qsa(app.root, '.dash-row')[0].style as CSSStyleDeclaration).gridTemplateColumns).toContain('repeat(1'); expect(commit).toHaveBeenCalled(); }); - it('defaults the preset to full-width when the layout omits it', async () => { + it('defaults the preset to report when the layout omits it (full-width removed, #321)', async () => { const { app } = dashApp({ workspace: wsWith({ queries: [q('q1', 'SELECT k, v FROM a')], @@ -419,7 +422,7 @@ describe('renderDashboard — flow layout + preset switcher (#280)', () => { }), }); await render(app); - expect(layoutSelect(app.root).value).toBe('full-width'); + expect(layoutSelect(app.root).value).toBe('report'); expect((qsa(app.root, '.dash-row')[0].style as CSSStyleDeclaration).gridTemplateColumns).toContain('repeat(1'); expect(qsa(app.root, '.dash-tile').length).toBe(1); }); @@ -448,7 +451,7 @@ describe('renderDashboard — reorder (drag only) + sort (#153/#280)', () => { const twoTiles = () => 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: 'full-width', items: {} }, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, }); const order = (app: TestApp): string[] => qsa(app.root, '.dash-tile .dash-tile-name').map((n) => n.textContent || ''); @@ -630,6 +633,57 @@ describe('renderDashboard — grafana-grid engine (#291)', () => { expect(qs(card, '.kpi-card')).not.toBeNull(); }); + // #329: a Dashboard tile that is 'ready' but carries NO result meta — a Text + // panel renders static content and never executes a query, so `ts.meta` stays + // null. `paintPanel` used to pass it to `tileFooter` via a false + // `as NonNullable` cast, throwing `Cannot read properties of null (reading + // 'rows')` — and because that ran inside `reconcileGrafanaGrid`'s per-tile + // loop BEFORE the host gains `dash-gg-grid`, one such tile aborted the whole + // Grid Tiles render (blank grid). #321 made Grid Tiles the default, so this + // pre-existing crash sat on the primary path. + it('renders a metaless (Text) tile in grafana-grid without crashing — footer is simply empty (#329)', async () => { + const { app } = dashApp({ + responder: (sql) => (sql.includes('data') + ? { columns: [{ name: 'k', type: 'String' }, { name: 'v', type: 'UInt64' }], rows: [['a', 1]] } + : {}), + workspace: wsWith({ + queries: [ + q('tq', "SELECT 'hello' AS body", { panel: { cfg: { type: 'text' } } }), + q('dq', 'SELECT k, v FROM data', { panel: { cfg: { type: 'table' } } }), + ], + tiles: [{ id: 't1', queryId: 'tq' }, { id: 't2', queryId: 'dq' }], + layout: { type: 'grafana-grid', version: 1, items: {} }, + }), + }); + await render(app); + // The grid actually rendered (pre-fix it threw and left 0 tiles / no host). + expect(qs(app.root, '.dash-gg-grid')).not.toBeNull(); + const cards = qsa(app.root, '.dash-gg-tile'); + expect(cards.length).toBe(2); + // The metaless (Text) tile has an EMPTY footer; the data tile has the + // rows·ms·bytes footer. + const foots = cards.map((c) => qs(c, '.dash-tile-foot')); + const footTexts = foots.map((f) => (f ? f.textContent || '' : '')); + expect(footTexts.some((t) => t === '')).toBe(true); + expect(footTexts.some((t) => t.includes('rows'))).toBe(true); + }); + + it('renders a metaless (Text) tile in a flow layout without crashing (#329 — shared paintPanel path)', async () => { + const { app } = dashApp({ + responder: () => ({}), + workspace: wsWith({ + queries: [q('tq', "SELECT 'hello' AS body", { panel: { cfg: { type: 'text' } } })], + tiles: [{ id: 't1', queryId: 'tq' }], + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, + }), + }); + await render(app); + // Flow rendered its rows structure and the tile's footer is empty (no meta). + const foot = qs(app.root, '.dash-tile-foot'); + expect(foot).not.toBeNull(); + expect(foot.textContent).toBe(''); + }); + // #316: the tile shell for a grafana-grid KPI tile — edit mode keeps the // full editing chrome except the footer (which the generic KPI path never // populates); view mode strips every visible frame, leaving only the KPI @@ -711,7 +765,7 @@ describe('renderDashboard — grafana-grid engine (#291)', () => { // Round-trip through flow and back to grid (#291's own cached-card-reuse // path) — a plain re-render exercises the same reconcile functions a // panel-type flip would, without needing a live Spec-editor change. - pickLayout(app.root, 'full-width'); + pickLayout(app.root, 'report'); pickLayout(app.root, 'grafana-grid'); card = qs(app.root, '.dash-gg-tile'); expect(card.classList.contains('is-kpi')).toBe(true); @@ -719,7 +773,7 @@ describe('renderDashboard — grafana-grid engine (#291)', () => { expect(card.getAttribute('role')).toBe('group'); }); - it('reflects the active engine in the 5-option layout select and switches engines via change-layout', async () => { + it('reflects the active engine in the 5-option editable layout select and switches engines via change-layout', async () => { const { app, commit } = dashApp({ workspace: wsWith({ queries: [q('q1', 'SELECT k, v FROM a')], @@ -729,11 +783,18 @@ describe('renderDashboard — grafana-grid engine (#291)', () => { }); await render(app); const select = layoutSelect(app.root); + // #321: 'full-width' removed; 'Grid Tiles'/'Full view' are the two new + // grafana-grid-related entries (Full view is a transient render-mode + // override, never an engine of its own). expect([...select.options].map((o) => o.value)).toEqual( - ['full-width', 'report', 'columns-2', 'columns-3', 'grafana-grid'], + ['grafana-grid', 'full', 'report', 'columns-2', 'columns-3'], + ); + expect([...select.options].map((o) => o.textContent)).toEqual( + ['Grid Tiles', 'Full view', 'Report', '2 columns', '3 columns'], ); + expect(select.getAttribute('aria-label')).toBe('Dashboard style'); expect(select.value).toBe('columns-2'); - // Picking "Grafana grid" sends change-layout {type:'grafana-grid',version:1}. + // Picking "Grid Tiles" 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(); @@ -741,8 +802,8 @@ describe('renderDashboard — grafana-grid engine (#291)', () => { // 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'); + pickLayout(app.root, 'report'); + expect(layoutSelect(app.root).value).toBe('report'); 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. @@ -796,7 +857,7 @@ describe('renderDashboard — grafana-grid engine (#291)', () => { 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: {} }, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, }), }); await render(app); @@ -865,7 +926,7 @@ describe('renderDashboard — grafana-grid engine (#291)', () => { 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: {} }, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, }), }); await render(app); @@ -889,7 +950,7 @@ describe('renderDashboard — grafana-grid engine (#291)', () => { 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: {} }, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, }), }); await render(app); @@ -929,6 +990,224 @@ describe('renderDashboard — grafana-grid engine (#291)', () => { }); }); +// #321 "Full view": a TRANSIENT grafana-grid render-mode override — every +// tile renders full width, never persisted, never a commit. +describe('renderDashboard — Full view (#321)', () => { + // A valid flow@1 fallback is required for the grid->flow direction of + // change-layout (dashboard-commands.ts) — unlike the sibling grafana-grid + // describe block above, this one exercises grid<->flow round-trips. + 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' } }, + fallback: { type: 'flow', version: 1, preset: 'columns-2', items: {} }, + }, + }); + + it('selecting Full view makes every tile span the full column count without committing; Grid Tiles restores authored spans', async () => { + const { app, commit } = dashApp({ workspace: twoTilesGrid() }); + await render(app); + const gridEl = qs(app.root, '.dash-gg-grid'); + expect((gridEl.style as CSSStyleDeclaration).gridTemplateColumns).toContain('repeat(12'); + pickLayout(app.root, 'full'); + expect(layoutSelect(app.root).value).toBe('full'); + for (const card of qsa(app.root, '.dash-gg-tile')) { + expect((card.style as CSSStyleDeclaration).gridColumn).toBe('span 12'); + } + expect(commit).not.toHaveBeenCalled(); + expect(qs(app.root, '.dash-gg-grid')?.classList.contains('is-full')).toBe(true); + // Grid Tiles restores the exact authored spans — still no commit. + pickLayout(app.root, 'grafana-grid'); + expect(layoutSelect(app.root).value).toBe('grafana-grid'); + const cards = qsa(app.root, '.dash-gg-tile'); + expect((cards[0].style as CSSStyleDeclaration).gridColumn).toBe('span 4'); + expect((cards[1].style as CSSStyleDeclaration).gridColumn).toBe('span 6'); // grid default + expect(commit).not.toHaveBeenCalled(); + expect(qs(app.root, '.dash-gg-grid')?.classList.contains('is-full')).toBe(false); + }); + + it('delete still dispatches remove-tile and persists while Full view is active', async () => { + const { app, commit } = dashApp({ workspace: twoTilesGrid() }); + await render(app); + pickLayout(app.root, 'full'); + expect(layoutSelect(app.root).value).toBe('full'); + // Delete still dispatches remove-tile and persists. + qs(app.root, '.dash-gg-del').click(); + expect(qsa(app.root, '.dash-gg-tile').length).toBe(1); + expect(commit).toHaveBeenCalled(); + // Full view survives the commit-driven republish. + expect(layoutSelect(app.root).value).toBe('full'); + expect((qsa(app.root, '.dash-gg-tile')[0].style as CSSStyleDeclaration).gridColumn).toBe('span 12'); + }); + + it('reorder (drag) still dispatches move-tile and persists while Full view is active', async () => { + const { app, commit } = dashApp({ workspace: twoTilesGrid() }); + await render(app); + pickLayout(app.root, 'full'); + expect(layoutSelect(app.root).value).toBe('full'); + const nameOf = (el: Element): string | null => qs(el, '.dash-tile-name')?.getAttribute('title') ?? null; + const before = qsa(app.root, '.dash-gg-tile').map(nameOf); + expect(before).toEqual(['q1', 'q2']); + const cards = qsa(app.root, '.dash-gg-tile'); + // Same drag mechanism as the flow reorder suite above (#153/#280) — the + // grafana-grid engine reuses the identical move-tile command/DOM wiring + // (#291), just scoped to `.dash-gg-tile`. + cards[1].dispatchEvent(new Event('dragstart', { bubbles: true })); + cards[0].dispatchEvent(new Event('dragover', { bubbles: true })); + cards[0].dispatchEvent(new Event('drop', { bubbles: true })); + const after = qsa(app.root, '.dash-gg-tile').map(nameOf); + expect(after).toEqual(['q2', 'q1']); // move-tile applied — persisted order + expect(commit).toHaveBeenCalled(); + // Full view survives the commit-driven republish; every tile still full width. + expect(layoutSelect(app.root).value).toBe('full'); + for (const card of qsa(app.root, '.dash-gg-tile')) { + expect((card.style as CSSStyleDeclaration).gridColumn).toBe('span 12'); + } + }); + + it('adding a tile (add-query) seeds the grafana-grid default placement (span 6 / height 2), which renders full-width while Full view is active', async () => { + // #321 SHOULD-FIX: dashboard.ts itself never dispatches `add-query` (no + // add affordance lives in this render module — that command comes from + // the Library/Spec-editor "add to dashboard" path); this drives the SAME + // command path `runCommand` uses (`applyCommand` + `createQueryResolver` + // + `resolveLayoutPluginSync`, dashboard.ts:576-593) to build a workspace + // as-if a tile had just been added, then renders it to assert the + // resulting placement. + const q3 = q('q3', 'SELECT k, v FROM c'); + const base = twoTilesGrid(); + const queries = [...base.queries, q3]; + const added = applyCommand( + base.dashboard as unknown as Parameters[0], + { type: 'add-query', queryId: 'q3' }, + { resolver: createQueryResolver(queries), genTileId: () => 't3', plugin: resolveLayoutPluginSync(base.dashboard.layout) }, + ); + expect(added.ok).toBe(true); + if (!added.ok) return; + const normalized = resolveLayoutPluginSync(added.dashboard.layout).normalize(added.dashboard); + const workspace = { ...base, queries, dashboard: normalized }; + + const { app, commit } = dashApp({ workspace: workspace as unknown as ReturnType }); + await render(app); + pickLayout(app.root, 'full'); + const addedCard = qsa(app.root, '.dash-gg-tile') + .find((card) => qs(card, '.dash-tile-name')?.getAttribute('title') === 'q3')!; + expect(addedCard).toBeTruthy(); + expect((addedCard.style as CSSStyleDeclaration).gridColumn).toBe('span 12'); // full-width override + expect(commit).not.toHaveBeenCalled(); // Full view itself never persists + + // Switch back to Grid Tiles: the PERSISTED default placement — span 6, + // height 2 (208px = 32 + 88*2) — is exactly what add-query seeded, not + // the transient full-width render. + pickLayout(app.root, 'grafana-grid'); + const restoredCard = qsa(app.root, '.dash-gg-tile') + .find((card) => qs(card, '.dash-tile-name')?.getAttribute('title') === 'q3')!; + expect((restoredCard.style as CSSStyleDeclaration).gridColumn).toBe('span 6'); + expect((restoredCard.style as CSSStyleDeclaration).height).toBe('208px'); + }); + + it('a resize gesture in Full view is vertical-only: dispatches update-placement with the UNCHANGED persisted span', async () => { + const { app, commit } = dashApp({ workspace: twoTilesGrid() }); + await render(app); + const gridEl = qs(app.root, '.dash-gg-grid'); + Object.defineProperty(gridEl, 'clientWidth', { value: 1200, configurable: true }); + pickLayout(app.root, 'full'); + const card = qsa(app.root, '.dash-gg-tile')[0]; // t1, authored span 4 + expect((card.style as CSSStyleDeclaration).gridColumn).toBe('span 12'); // full-width override + const handle = qs(card, '.dash-gg-resize'); + expect(handle.title).toBe('Resize tile height'); + expect(handle.getAttribute('aria-label')).toBe('Resize tile height'); + handle.dispatchEvent(new PointerEvent('pointerdown', { clientX: 0, clientY: 0 })); + // Horizontal movement has no effect — gridColumn is never re-pinned to a + // sub-span (the card stays full width) even with a large clientX delta. + window.dispatchEvent(new PointerEvent('pointermove', { clientX: 100000, clientY: 280 })); + expect((card.style as CSSStyleDeclaration).gridColumn).toBe('span 12'); + expect((card.style as CSSStyleDeclaration).height).toBe('296px'); // height still snaps (3 row units) + window.dispatchEvent(new PointerEvent('pointerup')); + expect(commit).toHaveBeenCalledTimes(1); + // The persisted (authored) span — 4, NOT the full-width 12 — survives. + const after = qsa(app.root, '.dash-gg-tile')[0]; + expect((after.style as CSSStyleDeclaration).gridColumn).toBe('span 12'); // still rendered full width + // Switching back to Grid Tiles proves the PERSISTED span was 4, not 12. + pickLayout(app.root, 'grafana-grid'); + expect((qsa(app.root, '.dash-gg-tile')[0].style as CSSStyleDeclaration).gridColumn).toBe('span 4'); + }); + + it('a resize handle reads "Resize" (two-dimensional) in tiles mode', async () => { + const { app } = dashApp({ workspace: twoTilesGrid() }); + await render(app); + const handle = qs(app.root, '.dash-gg-resize'); + expect(handle.title).toBe('Resize'); + expect(handle.getAttribute('aria-label')).toBe('Resize'); + }); + + it('read-only view: the reduced selector only calls session.setGridRenderMode — never a command', async () => { + const detached = twoTilesGrid(); + const { app, commit } = modeApp({ + workspace: null, detached, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' }, + }); + await render(app); + const select = layoutSelect(app.root); + expect([...select.options].map((o) => o.value)).toEqual(['grafana-grid', 'full']); + pickLayout(app.root, 'full'); + expect(layoutSelect(app.root).value).toBe('full'); + for (const card of qsa(app.root, '.dash-gg-tile')) { + expect((card.style as CSSStyleDeclaration).gridColumn).toBe('span 12'); + } + expect(commit).not.toHaveBeenCalled(); + pickLayout(app.root, 'grafana-grid'); + expect(layoutSelect(app.root).value).toBe('grafana-grid'); + expect(commit).not.toHaveBeenCalled(); + }); + + it('selecting Full view from a flow preset performs exactly ONE persisted conversion, then stays runtime-only', 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); + expect(commit).not.toHaveBeenCalled(); + pickLayout(app.root, 'full'); + expect(layoutSelect(app.root).value).toBe('full'); + expect(commit).toHaveBeenCalledTimes(1); // the ONE flow->grid conversion + expect(qs(app.root, '.dash-gg-grid')).not.toBeNull(); + expect((qs(app.root, '.dash-gg-tile').style as CSSStyleDeclaration).gridColumn).toBe('span 12'); + }); + + it('selecting a flow preset from Full view clears the override and persists the selected flow layout', async () => { + const { app, commit } = dashApp({ workspace: twoTilesGrid() }); + await render(app); + pickLayout(app.root, 'full'); + expect(commit).not.toHaveBeenCalled(); + pickLayout(app.root, 'columns-2'); + expect(layoutSelect(app.root).value).toBe('columns-2'); + expect(commit).toHaveBeenCalledTimes(1); // the grid->flow conversion + expect(qs(app.root, '.dash-gg-grid')).toBeNull(); + expect(qsa(app.root, '.dash-row').length).toBeGreaterThan(0); + }); + + it('a fresh render (new viewer session) always starts in Grid Tiles mode', async () => { + const { app } = dashApp({ workspace: twoTilesGrid() }); + await render(app); + expect(layoutSelect(app.root).value).toBe('grafana-grid'); + expect((qsa(app.root, '.dash-gg-tile')[0].style as CSSStyleDeclaration).gridColumn).toBe('span 4'); + }); + + it('no is-wide class is ever present on the grid host, in any mode', async () => { + const { app } = dashApp({ workspace: twoTilesGrid() }); + await render(app); + expect(qs(app.root, '.dash-grid')?.classList.contains('is-wide')).toBe(false); + pickLayout(app.root, 'full'); + expect(qs(app.root, '.dash-grid')?.classList.contains('is-wide')).toBe(false); + pickLayout(app.root, 'grafana-grid'); + expect(qs(app.root, '.dash-grid')?.classList.contains('is-wide')).toBe(false); + pickLayout(app.root, 'columns-2'); + expect(qs(app.root, '.dash-grid')?.classList.contains('is-wide')).toBe(false); + }); +}); + 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({ @@ -1112,7 +1391,7 @@ describe('renderDashboard — isolated per-dashboard filter persistence (#303)', // A structural republish (preset switch → syncDocument) with the SAME // filter value/active must not persist again (the dedicated persist // signature, not the bar-rebuild signature, gates the write). - pickLayout(app.root, 'full-width'); + pickLayout(app.root, 'report'); expect(saveJSON.mock.calls.length).toBe(callsAfterCommit); }); }); @@ -1292,7 +1571,7 @@ describe('app.renderDashboard', () => { const query = savedQuery({ id: '1', name: 'Q', sql: 'SELECT k, v FROM mychart' }); app.loadDashboardWorkspace = async () => ({ storageVersion: 1, id: 'w', name: 'W', queries: [query], - dashboard: { documentVersion: 1, id: 'd', title: 'D', revision: 1, layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, filters: [], tiles: [{ id: 't1', queryId: '1' }] }, + dashboard: { documentVersion: 1, id: 'd', title: 'D', revision: 1, layout: { type: 'flow', version: 1, preset: 'report', items: {} }, filters: [], tiles: [{ id: 't1', queryId: '1' }] }, }); await app.renderDashboard(); expect(qs(app.root, '.dash-tile canvas')).not.toBeNull(); @@ -1427,7 +1706,12 @@ describe('renderDashboard — open-source modes (#288)', () => { expect(calls.length).toBe(0); }); - it('current-workspace: id resolves only in the detached store → read-only view (no drag, no layout switcher)', async () => { + it('current-workspace: id resolves only in the detached store → read-only view (no drag, no layout selector for a flow doc, #321)', async () => { + // `wsWith`'s default layout is flow@1 — this is the pre-#321 shape any + // existing shared doc has. The reduced read-only selector is a + // grafana-grid-only render-mode toggle; for a read-only FLOW doc there is + // no engine switch possible read-only, so the selector must be HIDDEN + // entirely (not shown with a dead 'Full view' option over a flow layout). const detached = wsWith({ id: 'd', queries: [q('q1', 'SELECT 1')], tiles: [{ id: 't1', queryId: 'q1' }] }); const { app } = modeApp({ workspace: null, detached, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' } }); await render(app); @@ -1437,6 +1721,25 @@ describe('renderDashboard — open-source modes (#288)', () => { expect(layoutSelect(app.root)).toBeNull(); }); + it('current-workspace: read-only + grafana-grid doc → the reduced Grid Tiles / Full view selector IS shown and functional (#321)', async () => { + const detached = wsWith({ + id: 'd', queries: [q('q1', 'SELECT 1')], tiles: [{ id: 't1', queryId: 'q1' }], + layout: { type: 'grafana-grid', version: 1, items: {} }, + }); + const { app, commit } = modeApp({ workspace: null, detached, openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' } }); + await render(app); + expect(qs(app.root, '.dash-notfound')).toBeNull(); + // #321: read-only still shows the REDUCED Grid Tiles / Full view selector + // (a runtime-only render-mode toggle, never persistence) — the flow + // presets and the flow<->grid engine switch stay edit-mode-only. + const select = layoutSelect(app.root); + expect(select).not.toBeNull(); + expect([...select.options].map((o) => o.value)).toEqual(['grafana-grid', 'full']); + pickLayout(app.root, 'full'); + expect(layoutSelect(app.root).value).toBe('full'); + expect(commit).not.toHaveBeenCalled(); + }); + it('session-bundle: consumes the one-time handoff into a read-only view', async () => { const detached = wsWith({ id: 'd', queries: [q('q1', 'SELECT 1')], tiles: [{ id: 't1', queryId: 'q1' }] }); const consume = vi.fn(async () => detached as never); diff --git a/tests/unit/file-menu.test.ts b/tests/unit/file-menu.test.ts index be482c9c..e2dec00e 100644 --- a/tests/unit/file-menu.test.ts +++ b/tests/unit/file-menu.test.ts @@ -64,7 +64,7 @@ const panelQuery = (id: string, name = id, sql = 'SELECT 1'): SavedQueryV2 => ({ }); const dashboardDoc = (over: Partial = {}): DashboardDocumentV1 => ({ documentVersion: 1, id: 'd1', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, filters: [], tiles: [], ...over, }); const bundleDoc = (over: Partial = {}): PortableBundleV1 => ({ diff --git a/tests/unit/flow-layout.test.ts b/tests/unit/flow-layout.test.ts index 3e58d501..dc75fd72 100644 --- a/tests/unit/flow-layout.test.ts +++ b/tests/unit/flow-layout.test.ts @@ -6,7 +6,7 @@ import { } from '../../src/dashboard/layouts/flow-layout.js'; import type { DashboardDocumentV1 } from '../../src/generated/json-schema.types.js'; -const flowLayout = (items: Record> = {}) => ({ type: 'flow', version: 1, preset: 'full-width', items }); +const flowLayout = (items: Record> = {}) => ({ type: 'flow', version: 1, preset: 'report', items }); const doc = (over: Partial = {}): DashboardDocumentV1 => ({ documentVersion: 1, id: 'd', title: 'D', revision: 1, layout: flowLayout(), filters: [], tiles: [], ...over, } as DashboardDocumentV1); @@ -33,7 +33,7 @@ describe('setFlowPlacement', () => { }); it('creates the items map when the flow layout is missing one', () => { - const layout: Record = { type: 'flow', version: 1, preset: 'full-width' }; + const layout: Record = { type: 'flow', version: 1, preset: 'report' }; setFlowPlacement(layout, 't1', { span: 1 }); expect(layout.items).toEqual({ t1: { span: 1 } }); }); @@ -45,7 +45,7 @@ describe('setFlowPlacement', () => { }); it('creates the items map on a fallback that lacks one', () => { - const layout: Record = { type: 'grid', version: 9, fallback: { type: 'flow', version: 1, preset: 'full-width' } }; + const layout: Record = { type: 'grid', version: 9, fallback: { type: 'flow', version: 1, preset: 'report' } }; setFlowPlacement(layout, 't1', { span: 1 }); expect((layout.fallback as { items: unknown }).items).toEqual({ t1: { span: 1 } }); }); @@ -109,14 +109,15 @@ describe('flowLayoutPlugin.validatePlacement', () => { describe('presetColumns', () => { it('maps each preset to its desktop column count', () => { - expect(presetColumns('full-width')).toBe(1); expect(presetColumns('report')).toBe(1); expect(presetColumns('columns-2')).toBe(2); expect(presetColumns('columns-3')).toBe(3); expect(FLOW_PRESET_COLUMNS['columns-3']).toBe(3); + expect(Object.keys(FLOW_PRESET_COLUMNS).sort()).toEqual(['columns-2', 'columns-3', 'report']); }); - it('falls back to full-width (1) for an unknown or non-string preset', () => { + it('falls back to 1 column for an unknown or non-string preset (full-width removed, #321)', () => { + expect(presetColumns('full-width')).toBe(1); expect(presetColumns('masonry')).toBe(1); expect(presetColumns(undefined)).toBe(1); expect(presetColumns(2)).toBe(1); @@ -228,15 +229,17 @@ describe('computeFlowLayout', () => { expect(model.rows[0].tiles[0].span).toBe(2); }); - it('falls back to full-width with defaults when the layout has no flow surface', () => { + it('falls back to report with defaults when the layout has no flow surface', () => { const model = computeFlowLayout({ tiles: tiles('a', 'b'), layout: { type: 'grid', version: 9 } }); - expect(model.preset).toBe('full-width'); + expect(model.preset).toBe('report'); expect(model.columns).toBe(1); expect(model.rows.map((row) => row.tiles.map((tile) => tile.tileId))).toEqual([['a'], ['b']]); // A non-object layout is tolerated too. - expect(computeFlowLayout({ tiles: tiles('a'), layout: null }).preset).toBe('full-width'); - // An unknown preset string on a flow surface degrades to full-width. - expect(computeFlowLayout({ tiles: tiles('a'), layout: flow('bogus') }).preset).toBe('full-width'); + expect(computeFlowLayout({ tiles: tiles('a'), layout: null }).preset).toBe('report'); + // An unknown/invalid preset string on a flow surface (including the + // removed full-width) degrades to report, the nearest single-column preset. + expect(computeFlowLayout({ tiles: tiles('a'), layout: flow('bogus') }).preset).toBe('report'); + expect(computeFlowLayout({ tiles: tiles('a'), layout: flow('full-width') }).preset).toBe('report'); }); it('exposes the mobile breakpoint constant', () => { diff --git a/tests/unit/grafana-grid-layout.test.ts b/tests/unit/grafana-grid-layout.test.ts index c5cc2a1d..9a8b0e4f 100644 --- a/tests/unit/grafana-grid-layout.test.ts +++ b/tests/unit/grafana-grid-layout.test.ts @@ -305,7 +305,7 @@ describe('computeGrafanaGridLayout', () => { 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, heightUnits: 2, row: 0, colStart: 0 }); + expect(model.tiles[0]).toMatchObject({ tileId: 'a', span: 6, persistedSpan: 6, heightUnits: 2, row: 0, colStart: 0 }); }); it('clamps effective span per responsive breakpoint without mutating stored spans', () => { @@ -313,6 +313,7 @@ describe('computeGrafanaGridLayout', () => { 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(model.tiles[0].persistedSpan).toBe(12); // resolved stored span, before the effective clamp expect(layout.items.a.span).toBe(12); // persisted span untouched }); @@ -370,6 +371,48 @@ describe('computeGrafanaGridLayout', () => { const model = computeGrafanaGridLayout({ tiles: tiles('a', 'b'), layout: null }); expect(model.tiles.map((t) => t.span)).toEqual([6, 6]); }); + + // #321 "Full view": renderMode:'full' overrides every tile's effective span + // to the full column count (one tile per row), while the resolved STORED + // span still travels, unchanged, as `persistedSpan`. + describe('renderMode', () => { + it('defaults to tiles-mode behavior when renderMode is absent', () => { + const layout = gridLayout({ a: { span: 4 }, b: { span: 8 } }); + const withMode = computeGrafanaGridLayout({ tiles: tiles('a', 'b'), layout, containerWidth: 1200, renderMode: 'tiles' }); + const withoutMode = computeGrafanaGridLayout({ tiles: tiles('a', 'b'), layout, containerWidth: 1200 }); + expect(withoutMode).toEqual(withMode); + }); + + it.each([ + [1200, 12], [800, 6], [500, 4], [300, 2], + ])('at containerWidth %d (columns %d), every tile spans the full column count and gets its own row', (containerWidth, columns) => { + const layout = gridLayout({ a: { span: 4 }, b: { span: 8 }, c: { span: 12 } }); + const model = computeGrafanaGridLayout({ + tiles: tiles('a', 'b', 'c'), layout, containerWidth, renderMode: 'full', + }); + expect(model.columns).toBe(columns); + for (const render of model.tiles) { + expect(render.span).toBe(columns); + expect(render.colStart).toBe(0); + } + expect(model.tiles.map((t) => t.row)).toEqual([0, 1, 2]); // each tile on its own row + }); + + it('preserves the resolved stored span, unchanged, as persistedSpan in full mode', () => { + const layout = gridLayout({ a: { span: 4 }, b: { span: 8 } }); + const model = computeGrafanaGridLayout({ tiles: tiles('a', 'b'), layout, containerWidth: 1200, renderMode: 'full' }); + expect(model.tiles.map((t) => t.persistedSpan)).toEqual([4, 8]); + expect(model.tiles.map((t) => t.span)).toEqual([12, 12]); // effective span overridden to full width + expect(layout.items.a.span).toBe(4); // persistence untouched + expect(layout.items.b.span).toBe(8); + }); + + it('still resolves persistedSpan to the grid default for a tile with no persisted placement', () => { + const model = computeGrafanaGridLayout({ tiles: tiles('a'), layout: gridLayout(), renderMode: 'full' }); + expect(model.tiles[0].persistedSpan).toBe(6); // DEFAULT_GRID_PLACEMENT.span + expect(model.tiles[0].span).toBe(12); + }); + }); }); describe('deriveFlowFallback', () => { @@ -379,7 +422,7 @@ describe('deriveFlowFallback', () => { }); const fallback = deriveFlowFallback(layout, [{ id: 'a' }, { id: 'b' }, { id: 'c' }]); expect(fallback).toEqual({ - type: 'flow', version: 1, preset: 'full-width', + type: 'flow', version: 1, preset: 'columns-2', items: { a: { span: 1, height: 'compact' }, b: { span: 2, height: 'large' }, @@ -400,7 +443,7 @@ describe('deriveFlowFallback', () => { }); it('handles an empty tile list and a non-object layout', () => { - expect(deriveFlowFallback(gridLayout(), [])).toEqual({ type: 'flow', version: 1, preset: 'full-width', items: {} }); + expect(deriveFlowFallback(gridLayout(), [])).toEqual({ type: 'flow', version: 1, preset: 'columns-2', items: {} }); const fallback = deriveFlowFallback(null, [{ id: 't1' }]); expect(fallback.items).toEqual({ t1: { span: 2, height: 'medium' } }); }); @@ -500,7 +543,7 @@ describe('regenerateGridFallback', () => { const layout = gridLayout({ a: { span: 4, height: 1 } }); regenerateGridFallback(layout, [{ id: 'a' }, { id: 'b' }]); expect((layout as { fallback?: unknown }).fallback).toEqual({ - type: 'flow', version: 1, preset: 'full-width', + type: 'flow', version: 1, preset: 'columns-2', items: { a: { span: 1, height: 'compact' }, b: { span: 2, height: 'medium' } }, }); }); @@ -508,11 +551,11 @@ describe('regenerateGridFallback', () => { 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' } } }); + expect(layout.fallback).toEqual({ type: 'flow', version: 1, preset: 'columns-2', 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: {} }; + const flow = { type: 'flow', version: 1, preset: 'columns-2', items: {} }; regenerateGridFallback(flow, [{ id: 'a' }]); expect(flow).not.toHaveProperty('fallback'); expect(() => regenerateGridFallback(null, [{ id: 'a' }])).not.toThrow(); @@ -531,7 +574,7 @@ describe('regenerateGridFallback', () => { null, 'nope', { queryId: 'q3' }, { id: 42 }, ]); expect((layout as { fallback?: unknown }).fallback).toEqual({ - type: 'flow', version: 1, preset: 'full-width', + type: 'flow', version: 1, preset: 'columns-2', items: { a: { span: 1, height: 'medium' }, b: { span: 2, height: 'medium' } }, }); }); diff --git a/tests/unit/import-planner.test.ts b/tests/unit/import-planner.test.ts index 5fbbde9b..3f90116e 100644 --- a/tests/unit/import-planner.test.ts +++ b/tests/unit/import-planner.test.ts @@ -24,7 +24,7 @@ const filterQuery = (id: string, name = id): SavedQueryV2 => ({ const dashboardDoc = (over: Partial = {}): DashboardDocumentV1 => ({ documentVersion: 1, id: 'd1', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, filters: [], tiles: [], ...over, }); @@ -176,7 +176,7 @@ describe('rewriteDashboardReferences', () => { const dashboard = dashboardDoc({ tiles: [{ id: 't1', queryId: 'p1' }], filters: [{ id: 'flt1', parameter: 'p', sourceQueryId: 'f1' }, { id: 'flt2', parameter: 'q' }], - layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } }, + layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, }); it('rewrites BOTH tile.queryId and filter.sourceQueryId via an IdMapping', () => { @@ -292,7 +292,7 @@ describe('planImportDashboard', () => { id: 'd1', revision: 5, tiles: [{ id: 't1', queryId: 'p1' }], filters: [{ id: 'flt1', parameter: 'p', sourceQueryId: 'f1' }], - layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } }, + layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, })], }); @@ -355,7 +355,7 @@ describe('planImportDashboard', () => { id: 'd1', tiles: [{ id: 't1', queryId: 'p1' }], // 'ghost' names no tile — layout-orphan-placement, unrelated to query mapping. - layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {}, ghost: {} } }, + layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {}, ghost: {} } }, })], }); const plan = planImportDashboard(ws, badBundle, 'd1', [], 'replace', counter()); @@ -392,7 +392,7 @@ describe('planReplaceWorkspace', () => { id: 'd1', revision: 2, tiles: [{ id: 't1', queryId: 'p1' }], filters: [{ id: 'flt1', parameter: 'p', sourceQueryId: 'f1' }], - layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } }, + layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, })], }); const plan = planReplaceWorkspace(ws, bundleWithDashboard, 'd1', [], counter()); @@ -416,7 +416,7 @@ describe('planReplaceWorkspace', () => { dashboards: [dashboardDoc({ id: 'd1', tiles: [{ id: 't1', queryId: 'p1' }], - layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } }, + layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, })], }); const decisions: QueryDecision[] = [{ sourceId: 'p1', action: 'skip' }]; diff --git a/tests/unit/layout-registry.test.ts b/tests/unit/layout-registry.test.ts index d4750a9c..44517235 100644 --- a/tests/unit/layout-registry.test.ts +++ b/tests/unit/layout-registry.test.ts @@ -9,7 +9,7 @@ import type { DashboardLayoutPlugin } from '../../src/dashboard/layouts/flow-lay import type { DashboardLayoutRegistration } from '../../src/dashboard/layouts/layout-registry.js'; const flow = (items: Record> = {}) => - ({ type: 'flow', version: 1, preset: 'full-width', items }); + ({ type: 'flow', version: 1, preset: 'report', items }); // A stub grid plugin for a second registered engine. const gridPlugin: DashboardLayoutPlugin = { diff --git a/tests/unit/legacy-migration.test.ts b/tests/unit/legacy-migration.test.ts index 3fc758ac..ad9a65ed 100644 --- a/tests/unit/legacy-migration.test.ts +++ b/tests/unit/legacy-migration.test.ts @@ -35,7 +35,7 @@ const legacy = (over: Partial = {}): LegacyWorkspaceInput describe('legacyLayoutToFlowPreset', () => { it('maps every legacy layout preference to a normative flow@1 preset', () => { - expect(legacyLayoutToFlowPreset('wide', 3)).toBe('full-width'); + expect(legacyLayoutToFlowPreset('wide', 3)).toBe('report'); expect(legacyLayoutToFlowPreset('report', 3)).toBe('report'); expect(legacyLayoutToFlowPreset('arrange', 2)).toBe('columns-2'); expect(legacyLayoutToFlowPreset('arrange', 3)).toBe('columns-3'); @@ -61,7 +61,7 @@ describe('buildLegacyMigrationCandidate', () => { expect(dash.id).toBe('id-2'); expect(dash.title).toBe('My Library'); expect(dash.revision).toBe(1); - expect(dash.layout).toEqual({ type: 'flow', version: 1, preset: 'full-width', items: {} }); + expect(dash.layout).toEqual({ type: 'flow', version: 1, preset: 'report', items: {} }); // Only the two favorites became tiles, in catalog order, each with a fresh ID. expect(dash.tiles).toEqual([ { id: 'id-3', queryId: 'fav1' }, diff --git a/tests/unit/portable-bundle-codec.test.ts b/tests/unit/portable-bundle-codec.test.ts index 5f2043df..6777d26a 100644 --- a/tests/unit/portable-bundle-codec.test.ts +++ b/tests/unit/portable-bundle-codec.test.ts @@ -11,7 +11,7 @@ const has = (d: WorkspaceDiagnostic[], code: string): boolean => d.some((x) => x const panelQuery = (id: string) => ({ id, sql: 'SELECT 1', specVersion: 1, spec: { name: id, panel: { cfg: { type: 'bar', x: 0, y: [1] } } } }); const dashboardDoc = (over: Record = {}) => ({ documentVersion: 1, id: 'd1', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, filters: [], tiles: [], ...over, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, filters: [], tiles: [], ...over, }); const bundle = (over: Record = {}) => ({ format: PORTABLE_BUNDLE_FORMAT, version: 1, exportedAt: '2026-07-17T00:00:00.000Z', @@ -23,7 +23,7 @@ describe('validatePortableBundleDocument', () => { expect(validatePortableBundleDocument(bundle())).toEqual([]); const full = bundle({ queries: [panelQuery('p1')], - dashboards: [dashboardDoc({ tiles: [{ id: 't1', queryId: 'p1' }], layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } } })], + dashboards: [dashboardDoc({ tiles: [{ id: 't1', queryId: 'p1' }], layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } } })], }); expect(validatePortableBundleDocument(full)).toEqual([]); }); diff --git a/tests/unit/saved-query-mutation.test.ts b/tests/unit/saved-query-mutation.test.ts index b3998db1..8c4cc3a4 100644 --- a/tests/unit/saved-query-mutation.test.ts +++ b/tests/unit/saved-query-mutation.test.ts @@ -27,7 +27,7 @@ const baseWorkspace = (): StoredWorkspaceV1 => ({ ], dashboard: { documentVersion: 1, id: 'dash', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } }, + layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, filters: [{ id: 'flt', parameter: 'country', sourceQueryId: 'f1', targets: ['t1'] }], tiles: [{ id: 't1', queryId: 'p1' }], }, @@ -80,7 +80,7 @@ describe('planSavedQueryMutation — rejection without repair', () => { queries: [panelQuery('p1', 'SELECT a,b WHERE c={country:String}'), filterQuery('f1'), filterQuery('f2')], dashboard: { documentVersion: 1, id: 'dash', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } }, + layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, filters: [{ id: 'flt', parameter: 'country', sourceQueryId: 'f1', targets: ['t1'] }], tiles: [{ id: 't1', queryId: 'p1' }], }, @@ -122,7 +122,7 @@ describe('planSavedQueryMutation — atomic repair', () => { queries: [panelQuery('p1', 'SELECT a,b', { variants: { alt: {}, other: {} } })], dashboard: { documentVersion: 1, id: 'dash', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } }, + layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, filters: [], tiles: [{ id: 't1', queryId: 'p1', presentation: { variant: 'alt' } }], }, } as StoredWorkspaceV1; @@ -163,7 +163,7 @@ describe('planSavedQueryMutation — repairs skip unaffected and target-less ent queries: [panelQuery('p1', 'SELECT a,b', { variants: { alt: {}, other: {} } }), panelQuery('p2', 'SELECT a,b')], dashboard: { documentVersion: 1, id: 'dash', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {}, t2: {}, t3: {}, t4: {} } }, + layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {}, t2: {}, t3: {}, t4: {} } }, filters: [{ id: 'flt', parameter: 'x' }], // no source, no targets tiles: [ { id: 't1', queryId: 'p1', presentation: { variant: 'alt' } }, // has a presentation, gets switched @@ -198,7 +198,7 @@ describe('planSavedQueryMutation — repairs skip unaffected and target-less ent queries: [panelQuery('p1', 'SELECT a,b'), panelQuery('p2', 'SELECT a,b')], dashboard: { documentVersion: 1, id: 'dash', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {}, t2: {} } }, + layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {}, t2: {} } }, filters: [{ id: 'flt', parameter: 'x' }], // no source tiles: [{ id: 't1', queryId: 'p1' }, { id: 't2', queryId: 'p2' }], }, @@ -214,7 +214,7 @@ describe('planSavedQueryMutation — repairs skip unaffected and target-less ent storageVersion: 1, id: 'ws', name: 'WS', queries: [panelQuery('p1', 'SELECT a,b')], dashboard: { documentVersion: 1, id: 'dash', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, filters: ['bad', { id: 'flt', parameter: 'x' }], tiles: ['bad', { id: 't1', queryId: 'p1' }], }, } as unknown as StoredWorkspaceV1; @@ -244,7 +244,7 @@ describe('planSavedQueryMutation — grafana-grid@1 engine awareness (#291)', () 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: {}, + type: 'flow', version: 1, preset: 'columns-2', items: {}, }); }); }); diff --git a/tests/unit/session-bundle.test.ts b/tests/unit/session-bundle.test.ts index de4c53c1..4884280c 100644 --- a/tests/unit/session-bundle.test.ts +++ b/tests/unit/session-bundle.test.ts @@ -17,7 +17,7 @@ const dashboardDoc = ( ): DashboardDocumentV1 => ({ documentVersion: 1, id, title, revision: 1, layout: { - type: 'flow', version: 1, preset: 'full-width', + type: 'flow', version: 1, preset: 'report', items: Object.fromEntries(tileQueryIds.map((_, index) => [`${id}-t${index}`, {}])), }, filters: [], diff --git a/tests/unit/state.test.ts b/tests/unit/state.test.ts index d3aea8ec..190741e2 100644 --- a/tests/unit/state.test.ts +++ b/tests/unit/state.test.ts @@ -366,7 +366,7 @@ describe('saved queries', () => { describe('toggleFavorite wires Dashboard tile membership (#299)', () => { const blankDashboard = (): DashboardDocumentV1 => ({ documentVersion: 1, id: 'dash', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, filters: [], tiles: [], }); diff --git a/tests/unit/stored-workspace.test.ts b/tests/unit/stored-workspace.test.ts index 5c54ce86..f188adaf 100644 --- a/tests/unit/stored-workspace.test.ts +++ b/tests/unit/stored-workspace.test.ts @@ -11,7 +11,7 @@ const has = (d: WorkspaceDiagnostic[], code: string): boolean => d.some((x) => x const panelQuery = (id: string) => ({ id, sql: 'SELECT 1', specVersion: 1, spec: { name: id, panel: { cfg: { type: 'bar', x: 0, y: [1] } } } }); const dashboardDoc = (over: Record = {}) => ({ documentVersion: 1, id: 'd1', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, filters: [], tiles: [], ...over, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, filters: [], tiles: [], ...over, }); const workspace = (over: Record = {}) => ({ storageVersion: 1, id: 'w1', name: 'W', queries: [], dashboard: null, ...over, @@ -23,7 +23,7 @@ describe('validateStoredWorkspaceDocument', () => { expect(validateStoredWorkspaceDocument(workspace({ queries: [panelQuery('p1')] }))).toEqual([]); const withDashboard = workspace({ queries: [panelQuery('p1')], - dashboard: dashboardDoc({ tiles: [{ id: 't1', queryId: 'p1' }], layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } } }), + dashboard: dashboardDoc({ tiles: [{ id: 't1', queryId: 'p1' }], layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } } }), }); expect(validateStoredWorkspaceDocument(withDashboard)).toEqual([]); }); @@ -55,7 +55,7 @@ describe('validateStoredWorkspaceDocument', () => { expect(has(d, 'workspace-duplicate-query-id')).toBe(true); // Dashboard-side semantics run against the workspace queries. const bad = validateStoredWorkspaceDocument(workspace({ - dashboard: dashboardDoc({ tiles: [{ id: 't1', queryId: 'gone' }], layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } } }), + dashboard: dashboardDoc({ tiles: [{ id: 't1', queryId: 'gone' }], layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } } }), })); expect(has(bad, 'dashboard-tile-query-missing')).toBe(true); }); diff --git a/tests/unit/tile-membership.test.ts b/tests/unit/tile-membership.test.ts index 5971cec0..2f568684 100644 --- a/tests/unit/tile-membership.test.ts +++ b/tests/unit/tile-membership.test.ts @@ -14,7 +14,7 @@ const noRoleQuery = (id: string): SavedQueryV2 => ({ const dashboard = (over: Partial = {}): DashboardDocumentV1 => ({ documentVersion: 1, id: 'dash', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: {} }, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, filters: [], tiles: [], ...over, } as DashboardDocumentV1); @@ -92,7 +92,7 @@ describe('toggleTileMembership', () => { it('normalizes the result — a removed tile drops its layout placement, a new tile gets none stored', () => { const d = dashboard({ - layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: { span: 2, height: 'large' } } }, + layout: { type: 'flow', version: 1, preset: 'report', items: { t1: { span: 2, height: 'large' } } }, tiles: [{ id: 't1', queryId: 'p1' }], }); const removed = toggleTileMembership(d, panelQuery('p1'), false, genTileId())!; @@ -114,7 +114,7 @@ describe('toggleTileMembership — grafana-grid@1 engine awareness (#291)', () = // 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' } }, + type: 'flow', version: 1, preset: 'columns-2', items: { 'tile-1': { span: 2, height: 'medium' } }, }); }); @@ -127,7 +127,7 @@ describe('toggleTileMembership — grafana-grid@1 engine awareness (#291)', () = 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: {}, + type: 'flow', version: 1, preset: 'columns-2', items: {}, }); }); }); diff --git a/tests/unit/workspace-repository.test.ts b/tests/unit/workspace-repository.test.ts index 5d711a4a..f8ededf6 100644 --- a/tests/unit/workspace-repository.test.ts +++ b/tests/unit/workspace-repository.test.ts @@ -43,7 +43,7 @@ const withDashboard = (over: Record = {}): StoredWorkspaceV1 => queries: [panelQuery('p1')], dashboard: { documentVersion: 1, id: 'd1', title: 'D', revision: 7, - layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } }, + layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, filters: [], tiles: [{ id: 't1', queryId: 'p1' }], }, ...over, @@ -109,7 +109,7 @@ describe('createWorkspaceRepository.commit', () => { const bad = workspace({ dashboard: { documentVersion: 1, id: 'd', title: 'D', revision: 1, - layout: { type: 'flow', version: 1, preset: 'full-width', items: { t1: {} } }, + layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, filters: [], tiles: [{ id: 't1', queryId: 'gone' }], }, }); diff --git a/tests/unit/workspace-semantics.test.ts b/tests/unit/workspace-semantics.test.ts index 88a0576c..f2ebc122 100644 --- a/tests/unit/workspace-semantics.test.ts +++ b/tests/unit/workspace-semantics.test.ts @@ -19,7 +19,7 @@ const panelQuery = (id: string, over: Record = {}, dashboard?: 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 flowLayout = (items: Record = {}) => ({ type: 'flow', version: 1, preset: 'report', items }); const gridLayout = (items: Record = {}) => ({ type: 'grafana-grid', version: 1, items }); const dashboardDoc = (over: Record = {}) => ({ documentVersion: 1, id: 'd1', title: 'D', revision: 1, @@ -287,12 +287,12 @@ describe('validateDashboardSemantics', () => { }); it('enforces the serialized layout-config byte limit', () => { - const layout = { type: 'flow', version: 1, preset: 'full-width', items: {}, config: { blob: 'x'.repeat(PORTABLE_LIMITS.maxSerializedLayoutConfigBytes + 10) } }; + const layout = { type: 'flow', version: 1, preset: 'report', items: {}, config: { blob: 'x'.repeat(PORTABLE_LIMITS.maxSerializedLayoutConfigBytes + 10) } }; expect(has(validateDashboardSemantics(dashboardDoc({ layout })), 'limit-layout-config-bytes')).toBe(true); }); it('skips layout item checks when items is not an object', () => { - const layout = { type: 'flow', version: 1, preset: 'full-width' }; + const layout = { type: 'flow', version: 1, preset: 'report' }; // Missing `items` is a schema error, but checkItems must not throw. expect(has(validateDashboardSemantics(dashboardDoc({ layout })), 'schema-required')).toBe(true); });