From 7ff29ca93976293ca168870121299caa0bd72fc3 Mon Sep 17 00:00:00 2001 From: stroblme Date: Tue, 25 Aug 2026 12:30:22 +0200 Subject: [PATCH] Make a dashboard its widgets: drop the pages and sections nobody drew --- backend/fluksio/flow/dashboards.py | 107 +++++---- backend/tests/api/routes/test_panels.py | 21 +- backend/tests/flow/test_dashboards.py | 77 ++++--- backend/tests/flow/test_provenance.py | 50 ++--- frontend/scripts/capture-screenshots.mjs | 8 +- frontend/src/client/schemas.gen.ts | 127 +---------- frontend/src/client/types.gen.ts | 43 +--- .../src/components/Common/DashboardMosaic.tsx | 32 ++- .../components/Dashboard/DashboardEditor.tsx | 207 ++++++++---------- .../components/Dashboard/DashboardView.tsx | 75 +------ .../src/components/Dashboard/RunsContext.tsx | 7 +- .../src/routes/_layout/dashboards/index.tsx | 2 - frontend/tests/editor.spec.ts | 4 +- frontend/tests/endpoints.spec.ts | 2 +- frontend/tests/mobile.spec.ts | 2 +- frontend/tests/panel.spec.ts | 2 +- frontend/tests/widgets.spec.ts | 4 +- 17 files changed, 273 insertions(+), 497 deletions(-) diff --git a/backend/fluksio/flow/dashboards.py b/backend/fluksio/flow/dashboards.py index edf1d6b..688a64d 100644 --- a/backend/fluksio/flow/dashboards.py +++ b/backend/fluksio/flow/dashboards.py @@ -374,32 +374,57 @@ class WidgetDef(BaseModel): return self -class SectionDef(BaseModel): - """A grid of widgets under a heading.""" - - id: str - title: str = "" - widgets: list[WidgetDef] = Field(default_factory=list) - - @field_validator("id") - @classmethod - def _check_id(cls, value: str) -> str: - return _validate_name(value) +def _placement(widget: dict[str, Any]) -> dict[str, Any]: + """Where a stored widget sits, by the widest breakpoint it names.""" + layout = widget.get("layout") or {} + for key in ("lg", "md", "sm"): + box = layout.get(key) + if isinstance(box, dict): + return dict(box) + return {} -class PageDef(BaseModel): - """One tab of a dashboard.""" +def _flatten_pages(pages: list[Any]) -> list[dict[str, Any]]: + """The widgets of a document written as pages and sections. - id: str - title: str = "" - #: A lucide icon name, or empty. - icon: str = "" - sections: list[SectionDef] = Field(default_factory=list) + Only the first page: no UI ever wrote a second one, and a panel carries + several whole dashboards instead. Its sections are stacked into one grid + the way the viewer always drew them, so a document that placed its widgets + keeps the arrangement it had rather than piling everything at row zero. + """ + if not pages or not isinstance(pages[0], dict): + return [] + sections = [s for s in (pages[0].get("sections") or []) if isinstance(s, dict)] + lists = [ + [w for w in (s.get("widgets") or []) if isinstance(w, dict)] for s in sections + ] + flat = [w for widgets in lists for w in widgets] + placed = any( + (_placement(w).get("x") or 0) > 0 or (_placement(w).get("y") or 0) > 0 + for w in flat + ) + if len(sections) < 2 or not placed: + return flat - @field_validator("id") - @classmethod - def _check_id(cls, value: str) -> str: - return _validate_name(value) + stacked: list[dict[str, Any]] = [] + offset = 0 + for widgets in lists: + bottom = 0 + for widget in widgets: + box = _placement(widget) + y = max(0, int(box.get("y") or 0)) + bottom = max(bottom, y + max(1, int(box.get("h") or 2))) + if offset: + widget = { + **widget, + "layout": { + **(widget.get("layout") or {}), + "lg": {**box, "y": y + offset}, + }, + } + stacked.append(widget) + offset += bottom + return stacked class DashboardDef(BaseModel): @@ -419,7 +444,11 @@ class DashboardDef(BaseModel): #: A lucide icon name, drawn on the panel rail; empty falls back to two #: letters of the title. icon: str = "" - pages: list[PageDef] = Field(default_factory=list) + #: One grid. Pages and sections were in the schema and never in the UI — + #: only the first page was ever read and its sections were drawn as one — + #: so a dashboard is its widgets, and several dashboards on one device is + #: what a panel is for. + widgets: list[WidgetDef] = Field(default_factory=list) #: Settings the whole dashboard carries, by name — see ``SettingDef``. The #: one channel a dashboard consumes as a dashboard rather than as a set of #: tiles, so a screen on a wall can be told things nobody standing at it @@ -431,6 +460,21 @@ class DashboardDef(BaseModel): #: never stored — the draft file's existence is the only record of it. has_draft: bool = False + @model_validator(mode="before") + @classmethod + def _flatten(cls, data: Any) -> Any: + """Read a document written as pages and sections as one grid. + + Stored dashboards live in each installation's git repository, so the + old shape is normalised on the way in rather than migrated: an + untouched document keeps working, and the next save writes it flat. + """ + if isinstance(data, dict) and "widgets" not in data and "pages" in data: + pages = data.get("pages") or [] + data = {k: v for k, v in data.items() if k != "pages"} + data["widgets"] = _flatten_pages(pages) + return data + @field_validator("name") @classmethod def _check_name(cls, value: str) -> str: @@ -456,10 +500,6 @@ class DashboardDef(BaseModel): ) return self - @property - def widgets(self) -> list[WidgetDef]: - return [w for p in self.pages for s in p.sections for w in s.widgets] - @property def setting_messages(self) -> list[str]: """Every message a bound setting reads. Empty for a static dashboard.""" @@ -471,7 +511,6 @@ class DashboardSummary(BaseModel): name: str title: str = "" - page_count: int = 0 widget_count: int = 0 has_draft: bool = False #: Of the working copy, so publishing from a list needs no second read. @@ -533,7 +572,6 @@ class DashboardStore: DashboardSummary( name=defn.name, title=defn.title, - page_count=len(defn.pages), widget_count=len(defn.widgets), has_draft=defn.has_draft, version=defn.version, @@ -747,7 +785,6 @@ def default_dashboard(name: str) -> DashboardDef: return DashboardDef( name=name, title=name.replace("_", " ").capitalize(), - pages=[PageDef(id="main", title="Overview", sections=[SectionDef(id="main")])], ) @@ -840,13 +877,7 @@ def results_dashboard(flow: FlowDef) -> DashboardDef: return DashboardDef( name=results_name(flow.name), title=f"{flow.title or flow.name} results", - pages=[ - PageDef( - id="main", - title="Results", - sections=[SectionDef(id="main", widgets=widgets)], - ) - ], + widgets=widgets, ) @@ -865,9 +896,7 @@ __all__ = [ "DashboardStore", "DashboardSummary", "DashboardsPublic", - "PageDef", "Placement", - "SectionDef", "SettingDef", "WidgetDef", "default_dashboard", diff --git a/backend/tests/api/routes/test_panels.py b/backend/tests/api/routes/test_panels.py index cf61ebe..0ca689c 100644 --- a/backend/tests/api/routes/test_panels.py +++ b/backend/tests/api/routes/test_panels.py @@ -243,7 +243,7 @@ def _dashboard_with( ) -> None: """A published dashboard carrying these widgets.""" saved = _dashboard(client, headers, name) - saved["pages"] = [{"id": "main", "sections": [{"id": "main", "widgets": widgets}]}] + saved["widgets"] = widgets written = client.put(f"{DASHBOARDS}/{name}", headers=headers, json=saved) assert written.status_code == 200, written.text published = client.post( @@ -706,22 +706,11 @@ def test_a_panels_socket_carries_only_what_it_draws( ) saved = _dashboard(client, superuser_token_headers, "panel_socket") - saved["pages"] = [ + saved["widgets"] = [ { - "id": "main", - "title": "Overview", - "sections": [ - { - "id": "main", - "widgets": [ - { - "id": "w1", - "type": "stat", - "config": {"message": "house.kitchen.temperature"}, - } - ], - } - ], + "id": "w1", + "type": "stat", + "config": {"message": "house.kitchen.temperature"}, } ] written = client.put( diff --git a/backend/tests/flow/test_dashboards.py b/backend/tests/flow/test_dashboards.py index c1c5ba4..cfa582e 100644 --- a/backend/tests/flow/test_dashboards.py +++ b/backend/tests/flow/test_dashboards.py @@ -6,8 +6,6 @@ from fluksio.flow.dashboards import ( DashboardDef, DashboardNotFound, DashboardStore, - PageDef, - SectionDef, SettingDef, WidgetDef, default_dashboard, @@ -40,7 +38,7 @@ def test_a_dashboard_survives_a_round_trip(store: DashboardStore): read = store.read("house") assert read.name == "house" - assert [p.id for p in read.pages] == ["main"] + assert read.widgets == [] assert read.version == saved.version @@ -138,20 +136,10 @@ def test_the_deepest_chart_decides_how_much_past_is_kept(store: DashboardStore): store.write( DashboardDef( name="house", - pages=[ - PageDef( - id="main", - sections=[ - SectionDef(id="a", widgets=[chart("heating.temp", 400)]), - SectionDef( - id="b", - widgets=[ - chart("heating.temp", 900), - chart("solar.watts", 100), - ], - ), - ], - ) + widgets=[ + chart("heating.temp", 400), + chart("heating.temp", 900), + chart("solar.watts", 100), ], ) ) @@ -163,14 +151,7 @@ def test_a_chart_cannot_ask_for_an_unbounded_series(store: DashboardStore): store.write( DashboardDef( name="house", - pages=[ - PageDef( - id="main", - sections=[ - SectionDef(id="a", widgets=[chart("heating.temp", 10**9)]) - ], - ) - ], + widgets=[chart("heating.temp", 10**9)], ) ) @@ -514,3 +495,49 @@ def test_a_chart_of_runs_must_say_which_runs_and_which_metric(): runs_chart(metric="study.loss") with pytest.raises(ValueError, match="between 1 and 5"): runs_chart(metric="study.loss", flow="study", latest=9) + + +def test_a_document_written_as_pages_is_read_as_one_grid(): + """Stored dashboards live in each installation's repository. + + So the old shape is normalised on the way in rather than migrated, and a + placed second section keeps its arrangement instead of piling onto the + first — which is how the viewer always drew it. + """ + old = { + "name": "house", + "pages": [ + { + "id": "main", + "sections": [ + { + "id": "a", + "widgets": [ + { + "id": "top", + "type": "stat", + "layout": {"lg": {"x": 0, "y": 0, "w": 3, "h": 2}}, + } + ], + }, + { + "id": "b", + "widgets": [ + { + "id": "under", + "type": "stat", + "layout": {"lg": {"x": 0, "y": 1, "w": 3, "h": 2}}, + } + ], + }, + ], + } + ], + } + + read = DashboardDef.model_validate(old) + + assert [w.id for w in read.widgets] == ["top", "under"] + # The first section is two rows deep, so the second one starts under it. + assert read.widgets[1].layout["lg"].y == 3 + assert "pages" not in read.model_dump() diff --git a/backend/tests/flow/test_provenance.py b/backend/tests/flow/test_provenance.py index 4a2ccfc..5b0559b 100644 --- a/backend/tests/flow/test_provenance.py +++ b/backend/tests/flow/test_provenance.py @@ -8,8 +8,6 @@ control or another flow, it pulses a node that did nothing at all. from fluksio.flow.dashboards import ( DashboardDef, DashboardStore, - PageDef, - SectionDef, WidgetDef, ) from fluksio.flow.events import EventBus @@ -104,35 +102,25 @@ def test_the_widgets_wired_into_a_flow_are_reported(tmp_path): DashboardDef( name="panel", title="Panel", - pages=[ - PageDef( - id="main", - sections=[ - SectionDef( - id="main", - widgets=[ - WidgetDef( - id="setpoint", - type="slider", - title="Setpoint", - config={"target": "house.setpoint"}, - ), - WidgetDef( - id="reading", - type="stat", - title="Reading", - config={"message": "house.temp"}, - ), - # Another flow's message: not this flow's business. - WidgetDef( - id="elsewhere", - type="stat", - config={"message": "garage.temp"}, - ), - ], - ) - ], - ) + widgets=[ + WidgetDef( + id="setpoint", + type="slider", + title="Setpoint", + config={"target": "house.setpoint"}, + ), + WidgetDef( + id="reading", + type="stat", + title="Reading", + config={"message": "house.temp"}, + ), + # Another flow's message: not this flow's business. + WidgetDef( + id="elsewhere", + type="stat", + config={"message": "garage.temp"}, + ), ], ) ) diff --git a/frontend/scripts/capture-screenshots.mjs b/frontend/scripts/capture-screenshots.mjs index d15208a..b0d09c6 100644 --- a/frontend/scripts/capture-screenshots.mjs +++ b/frontend/scripts/capture-screenshots.mjs @@ -124,7 +124,9 @@ async function captureRuns(page, dir) { // watched on, showing finished ones. This is the seam the feature exists for. const picked = await page .getByTestId("run-link") - .evaluateAll((links) => links.slice(0, 2).map((a) => a.getAttribute("href"))) + .evaluateAll((links) => + links.slice(0, 2).map((a) => a.getAttribute("href")), + ) const ids = picked .map((href) => (href || "").split("/").pop()) .filter(Boolean) @@ -132,7 +134,9 @@ async function captureRuns(page, dir) { const apiUrl = APP_URL.replace("//app.", "//api.") const results = await page.evaluate(async (base) => { const answer = await fetch(`${base}/api/v1/dashboards/`, { - headers: { Authorization: `Bearer ${localStorage.getItem("access_token")}` }, + headers: { + Authorization: `Bearer ${localStorage.getItem("access_token")}`, + }, }) if (!answer.ok) return null const body = await answer.json() diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index aae31b8..2c4bf04 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -428,12 +428,12 @@ export const DashboardDef_InputSchema = { title: 'Icon', default: '' }, - pages: { + widgets: { items: { - '$ref': '#/components/schemas/PageDef-Input' + '$ref': '#/components/schemas/WidgetDef' }, type: 'array', - title: 'Pages' + title: 'Widgets' }, settings: { additionalProperties: { @@ -496,12 +496,12 @@ export const DashboardDef_OutputSchema = { title: 'Icon', default: '' }, - pages: { + widgets: { items: { - '$ref': '#/components/schemas/PageDef-Output' + '$ref': '#/components/schemas/WidgetDef' }, type: 'array', - title: 'Pages' + title: 'Widgets' }, settings: { additionalProperties: { @@ -538,11 +538,6 @@ export const DashboardSummarySchema = { title: 'Title', default: '' }, - page_count: { - type: 'integer', - title: 'Page Count', - default: 0 - }, widget_count: { type: 'integer', title: 'Widget Count', @@ -2048,66 +2043,6 @@ export const OAuthClientRegisterSchema = { description: 'RFC 7591 dynamic client registration request.' } as const; -export const PageDef_InputSchema = { - properties: { - id: { - type: 'string', - title: 'Id' - }, - title: { - type: 'string', - title: 'Title', - default: '' - }, - icon: { - type: 'string', - title: 'Icon', - default: '' - }, - sections: { - items: { - '$ref': '#/components/schemas/SectionDef-Input' - }, - type: 'array', - title: 'Sections' - } - }, - type: 'object', - required: ['id'], - title: 'PageDef', - description: 'One tab of a dashboard.' -} as const; - -export const PageDef_OutputSchema = { - properties: { - id: { - type: 'string', - title: 'Id' - }, - title: { - type: 'string', - title: 'Title', - default: '' - }, - icon: { - type: 'string', - title: 'Icon', - default: '' - }, - sections: { - items: { - '$ref': '#/components/schemas/SectionDef-Output' - }, - type: 'array', - title: 'Sections' - } - }, - type: 'object', - required: ['id'], - title: 'PageDef', - description: 'One tab of a dashboard.' -} as const; - export const PairRequestSchema = { properties: { code: { @@ -2676,56 +2611,6 @@ export const SecretValueSchema = { title: 'SecretValue' } as const; -export const SectionDef_InputSchema = { - properties: { - id: { - type: 'string', - title: 'Id' - }, - title: { - type: 'string', - title: 'Title', - default: '' - }, - widgets: { - items: { - '$ref': '#/components/schemas/WidgetDef' - }, - type: 'array', - title: 'Widgets' - } - }, - type: 'object', - required: ['id'], - title: 'SectionDef', - description: 'A grid of widgets under a heading.' -} as const; - -export const SectionDef_OutputSchema = { - properties: { - id: { - type: 'string', - title: 'Id' - }, - title: { - type: 'string', - title: 'Title', - default: '' - }, - widgets: { - items: { - '$ref': '#/components/schemas/WidgetDef' - }, - type: 'array', - title: 'Widgets' - } - }, - type: 'object', - required: ['id'], - title: 'SectionDef', - description: 'A grid of widgets under a heading.' -} as const; - export const SeriesAnswerSchema = { properties: { metric: { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 208dfb6..b4a95fa 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -116,7 +116,7 @@ export type DashboardDef_Input = { canvas_width?: number; canvas_height?: number; icon?: string; - pages?: Array; + widgets?: Array; settings?: { [key: string]: SettingDef; }; @@ -134,7 +134,7 @@ export type DashboardDef_Output = { canvas_width?: number; canvas_height?: number; icon?: string; - pages?: Array; + widgets?: Array; settings?: { [key: string]: SettingDef; }; @@ -153,7 +153,6 @@ export type DashboardsPublic = { export type DashboardSummary = { name: string; title?: string; - page_count?: number; widget_count?: number; has_draft?: boolean; version?: number; @@ -750,26 +749,6 @@ export type OAuthClientRegister = { token_endpoint_auth_method?: (string | null); }; -/** - * One tab of a dashboard. - */ -export type PageDef_Input = { - id: string; - title?: string; - icon?: string; - sections?: Array; -}; - -/** - * One tab of a dashboard. - */ -export type PageDef_Output = { - id: string; - title?: string; - icon?: string; - sections?: Array; -}; - export type PairRequest = { code: string; }; @@ -947,24 +926,6 @@ export type SecretValue = { value: string; }; -/** - * A grid of widgets under a heading. - */ -export type SectionDef_Input = { - id: string; - title?: string; - widgets?: Array; -}; - -/** - * A grid of widgets under a heading. - */ -export type SectionDef_Output = { - id: string; - title?: string; - widgets?: Array; -}; - export type SeriesAnswer = { metric: string; lines?: Array; diff --git a/frontend/src/components/Common/DashboardMosaic.tsx b/frontend/src/components/Common/DashboardMosaic.tsx index b1ce53f..5db9d06 100644 --- a/frontend/src/components/Common/DashboardMosaic.tsx +++ b/frontend/src/components/Common/DashboardMosaic.tsx @@ -68,32 +68,26 @@ type Block = { /** * The first page's widgets as one grid. * - * Sections are separate grids on the real thing, each starting at its own row - * zero, so each is pushed down past the one before it to keep them apart here. */ function blocksOf(dashboard: DashboardDef_Output): { blocks: Block[] rows: number } { const blocks: Block[] = [] - let offset = 0 - for (const section of (dashboard.pages ?? [])[0]?.sections ?? []) { - let bottom = 0 - for (const widget of section.widgets ?? []) { - const { x = 0, y = 0, w = 3, h = 2 } = placement(widget) - blocks.push({ - id: widget.id, - type: widget.type, - x: Math.max(0, x), - y: Math.max(0, y) + offset, - w: Math.max(1, w), - h: Math.max(1, h), - }) - bottom = Math.max(bottom, Math.max(0, y) + Math.max(1, h)) - } - offset += bottom + let rows = 0 + for (const widget of dashboard.widgets ?? []) { + const { x = 0, y = 0, w = 3, h = 2 } = placement(widget) + blocks.push({ + id: widget.id, + type: widget.type, + x: Math.max(0, x), + y: Math.max(0, y), + w: Math.max(1, w), + h: Math.max(1, h), + }) + rows = Math.max(rows, Math.max(0, y) + Math.max(1, h)) } - return { blocks, rows: Math.max(1, offset) } + return { blocks, rows: Math.max(1, rows) } } /** diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index 9e67009..7246fca 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -59,14 +59,11 @@ import { columnsOf, type Dashboard, DashboardView, - flatWidgets, GRID_GAP, isPlaced, - pagesOf, placement, rowHeightOf, rowsOf, - sectionsOf, widgetsOf, } from "./DashboardView" import { PanelRail } from "./PanelRail" @@ -147,13 +144,7 @@ const INTERACTIVE = "button, a, input, select, textarea, [role='switch'], [role='combobox'], [role='slider'], .react-resizable-handle, .widget-grip" function nextId(dashboard: DashboardDef_Output, type: string): string { - const taken = new Set( - pagesOf(dashboard).flatMap((page) => - sectionsOf(page).flatMap((section) => - widgetsOf(section).map((widget) => widget.id), - ), - ), - ) + const taken = new Set(widgetsOf(dashboard).map((widget) => widget.id)) let candidate = type for (let i = 2; taken.has(candidate); i++) candidate = `${type}${i}` return candidate @@ -323,34 +314,10 @@ export function DashboardEditor({ // a save in flight is still "no unpublished changes" until it lands. const hasDraft = Boolean(dashboard.has_draft) const columns = columnsOf(draft) - const page = pagesOf(draft)[0] - // Every widget the panel shows, not just the first section's — a document - // written with several sections is one arrangement here, as it is there. - const widgets = page ? flatWidgets(page) : [] + const widgets = widgetsOf(draft) const updateWidgets = (next: WidgetDef[]) => { - if (!page) return - commit({ - ...draft, - // Only this page is rewritten: a document carrying pages the editor - // does not show round-trips them untouched. - pages: pagesOf(draft).map((candidate) => - candidate.id !== page.id - ? candidate - : { - ...candidate, - // One canvas, one grid: what several sections were read as is - // written back as one, which is also what keeps their row - // offsets from being applied a second time on the next read. - sections: [ - { - ...(sectionsOf(candidate)[0] ?? { id: "main" }), - widgets: next, - }, - ], - }, - ), - }) + commit({ ...draft, widgets: next }) } const addWidget = (type: WidgetKind) => { @@ -468,91 +435,91 @@ export function DashboardEditor({ /> ) - const body = !page ? ( -

- This dashboard has no pages yet. -

- ) : edit && widgets.length === 0 ? ( -

- Nothing on this page yet. Add a widget from the bar below. -

- ) : stacked ? ( - // One column at the viewport's width. Edit mode still picks a widget and - // opens its settings; only the arrangement is missing. -
- + Nothing on this dashboard yet. Add a widget from the bar below. +

+ ) : stacked ? ( + // One column at the viewport's width. Edit mode still picks a widget and + // opens its settings; only the arrangement is missing. +
+ +
+ ) : ( + -
- ) : ( - ({ - to: "/dashboards/$name", - params: { name }, - search: edit ? { edit: true } : {}, - })} - /> - ) : null - } - > - {(scale) => - !edit ? ( - - ) : ( - - {widgets.map((widget) => ( -
{pickable(widget, true)}
- ))} -
- ) - } -
- ) + dots={edit} + rail={ + railDashboards ? ( + ({ + to: "/dashboards/$name", + params: { name }, + search: edit ? { edit: true } : {}, + })} + /> + ) : null + } + > + {(scale) => + !edit ? ( + + ) : ( + + {widgets.map((widget) => ( +
{pickable(widget, true)}
+ ))} +
+ ) + } + + ) return ( diff --git a/frontend/src/components/Dashboard/DashboardView.tsx b/frontend/src/components/Dashboard/DashboardView.tsx index 36c85bd..4ffa6f4 100644 --- a/frontend/src/components/Dashboard/DashboardView.tsx +++ b/frontend/src/components/Dashboard/DashboardView.tsx @@ -1,12 +1,6 @@ import { motion } from "motion/react" import { useEffect, useRef, useState } from "react" -import type { - DashboardDef_Output, - PageDef_Output, - Placement, - SectionDef_Output, - WidgetDef, -} from "@/client" +import type { DashboardDef_Output, Placement, WidgetDef } from "@/client" import { cn } from "@/lib/utils" import "./dashboard.css" @@ -68,9 +62,8 @@ export const RAIL_INSET = 72 * The generated client marks every list optional, because the server fills * them in. These three keep that from spreading through the components. */ -export const pagesOf = (dashboard: DashboardDef_Output) => dashboard.pages ?? [] -export const sectionsOf = (page: PageDef_Output) => page.sections ?? [] -export const widgetsOf = (section: SectionDef_Output) => section.widgets ?? [] +export const widgetsOf = (dashboard: DashboardDef_Output) => + dashboard.widgets ?? [] export const columnsOf = (dashboard: Dashboard) => dashboard.columns || DEFAULT_COLUMNS @@ -293,52 +286,6 @@ export const isPlaced = (widgets: WidgetDef[]) => return x > 0 || y > 0 }) -/** - * The page's widgets as one arrangement. - * - * Sections used to be drawn as separate grids stacked down the page, each - * starting at its own row zero, so a document written with several of them is - * read as one grid by pushing each below the one before it. That is what lets - * the editor and the panel show the same thing: the editor arranges one grid, - * and it now arranges all of them. - * - * An unarranged document is left alone — every widget still sits at 0,0 there, - * so there is no arrangement to keep apart and the grid auto-flows instead. - * - * ponytail: a section's own heading stops being drawn with it. No UI ever - * wrote one, so only a seeded document has any — restoring them means giving - * a group a box on the canvas, which is a widget, not a section. - */ -export function flatWidgets(page: PageDef_Output): WidgetDef[] { - const sections = sectionsOf(page) - const all = sections.flatMap(widgetsOf) - if (sections.length < 2 || !isPlaced(all)) return all - - const flat: WidgetDef[] = [] - let offset = 0 - for (const section of sections) { - let bottom = 0 - for (const widget of widgetsOf(section)) { - const placed = placement(widget) - const y = Math.max(0, placed.y ?? 0) - bottom = Math.max(bottom, y + Math.max(1, placed.h ?? 2)) - flat.push( - offset === 0 - ? widget - : { - ...widget, - layout: { - ...(widget.layout ?? {}), - lg: { ...placed, y: y + offset }, - }, - }, - ) - } - offset += bottom - } - return flat -} - /** * One page, drawn as the single grid the panel shows. * @@ -360,26 +307,14 @@ export function DashboardView({ /** Whether the panel carries a rail, which takes a column of the canvas. */ rail?: boolean }) { - // ponytail: the first page only. A panel carries several whole dashboards - // — that is what the rail is for — so nothing writes a second page. - const page = pagesOf(dashboard)[0] - - if (!page) { - return ( -

- This dashboard has no pages yet. -

- ) - } - - const all = flatWidgets(page) + const all = widgetsOf(dashboard) if (all.length === 0) { return (

- Nothing on this page yet. Edit it to add a widget. + Nothing on this dashboard yet. Edit it to add a widget.

) } diff --git a/frontend/src/components/Dashboard/RunsContext.tsx b/frontend/src/components/Dashboard/RunsContext.tsx index 0ae9477..b9ebbfc 100644 --- a/frontend/src/components/Dashboard/RunsContext.tsx +++ b/frontend/src/components/Dashboard/RunsContext.tsx @@ -8,7 +8,7 @@ import { runQueryOptions, shortId, } from "@/components/Runs/queries" -import { type Dashboard, flatWidgets, pagesOf } from "./DashboardView" +import { type Dashboard, widgetsOf } from "./DashboardView" import { type ContextLine, type DataContext, @@ -22,10 +22,9 @@ import { * asking the server for its whole series would be a query nobody draws. */ function chartedNames(dashboard: Dashboard | undefined): string[] { - const page = pagesOf(dashboard ?? ({} as Dashboard))[0] - if (!page) return [] + if (!dashboard) return [] const names = new Set() - for (const widget of flatWidgets(page)) { + for (const widget of widgetsOf(dashboard)) { if (widget.type !== "chart") continue const config = (widget.config ?? {}) as { series?: { message?: string }[] diff --git a/frontend/src/routes/_layout/dashboards/index.tsx b/frontend/src/routes/_layout/dashboards/index.tsx index ddc462c..45b7f6c 100644 --- a/frontend/src/routes/_layout/dashboards/index.tsx +++ b/frontend/src/routes/_layout/dashboards/index.tsx @@ -217,8 +217,6 @@ function Dashboards() { draft={dashboard.has_draft} detail={`${dashboard.widget_count} widget${ dashboard.widget_count === 1 ? "" : "s" - } · ${dashboard.page_count} page${ - dashboard.page_count === 1 ? "" : "s" }`} selecting={selection.selecting} selected={selection.selected.includes(dashboard.name)} diff --git a/frontend/tests/editor.spec.ts b/frontend/tests/editor.spec.ts index 54c0774..23ee60c 100644 --- a/frontend/tests/editor.spec.ts +++ b/frontend/tests/editor.spec.ts @@ -48,7 +48,7 @@ test.beforeAll(async ({ browser }) => { dashboard.columns = 12 dashboard.canvas_width = 1920 dashboard.canvas_height = 1080 - dashboard.pages[0].sections[0].widgets = WIDGETS + dashboard.widgets = WIDGETS await api(page, `/dashboards/${dashboardName}`, { method: "PUT", data: dashboard, @@ -65,7 +65,7 @@ async function placements(page: Page) { const doc = await ( await api(page, `/dashboards/${dashboardName}?draft=true`) ).json() - const widgets = doc.pages[0].sections[0].widgets as { + const widgets = doc.widgets as { id: string layout: { lg: { x: number; y: number } } }[] diff --git a/frontend/tests/endpoints.spec.ts b/frontend/tests/endpoints.spec.ts index 1dada2d..7aa3eb1 100644 --- a/frontend/tests/endpoints.spec.ts +++ b/frontend/tests/endpoints.spec.ts @@ -55,7 +55,7 @@ test.beforeAll(async ({ browser }) => { const dashboard = await ( await api(page, `/dashboards/${dashboardName}`, { method: "POST" }) ).json() - dashboard.pages[0].sections[0].widgets = [ + dashboard.widgets = [ { id: "lever", type: "slider", diff --git a/frontend/tests/mobile.spec.ts b/frontend/tests/mobile.spec.ts index 242d07c..78c0011 100644 --- a/frontend/tests/mobile.spec.ts +++ b/frontend/tests/mobile.spec.ts @@ -174,7 +174,7 @@ test.beforeAll(async ({ browser }) => { const dashboard = await ( await api(page, `/dashboards/${dashboardName}`, { method: "POST" }) ).json() - dashboard.pages[0].sections[0].widgets = [ + dashboard.widgets = [ { id: "top", type: "stat", diff --git a/frontend/tests/panel.spec.ts b/frontend/tests/panel.spec.ts index eaadefd..1f17c95 100644 --- a/frontend/tests/panel.spec.ts +++ b/frontend/tests/panel.spec.ts @@ -51,7 +51,7 @@ test.beforeAll(async ({ browser }) => { await api(page, `/dashboards/${name}`, { method: "POST" }) const doc = await (await api(page, `/dashboards/${name}?draft=true`)).json() doc.settings = { look: { value: "glass" } } - doc.pages[0].sections[0].widgets = [ + doc.widgets = [ { id: "stat", type: "stat", diff --git a/frontend/tests/widgets.spec.ts b/frontend/tests/widgets.spec.ts index 5711d60..98435c1 100644 --- a/frontend/tests/widgets.spec.ts +++ b/frontend/tests/widgets.spec.ts @@ -89,7 +89,7 @@ test.beforeAll(async ({ browser }) => { // Two rows taller than the default panel: the tiles below already fill it, // and a widget past the last row is clipped rather than drawn. dashboard.canvas_height = 1400 - dashboard.pages[0].sections[0].widgets = [ + dashboard.widgets = [ { id: "load", type: "bar", @@ -202,7 +202,7 @@ test.beforeAll(async ({ browser }) => { const stack = await ( await api(page, `/dashboards/${stackName}`, { method: "POST" }) ).json() - stack.pages[0].sections[0].widgets = [ + stack.widgets = [ { id: "split", type: "bar",