From 49afc6d73d16466773599bf2203fff5e08fe11a2 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 30 Jul 2026 15:36:59 +0200 Subject: [PATCH] fix(dashboard): stabilize four-column widget placement --- src/govoplan_dashboard/backend/schemas.py | 18 ++ tests/test_dashboard_layouts.py | 47 ++++ webui/src/api/dashboard.ts | 1 + .../src/features/dashboard/DashboardGrid.tsx | 20 +- .../src/features/dashboard/DashboardPage.tsx | 104 +++++++-- .../dashboard/WidgetConfigurationDialog.tsx | 9 +- .../dashboard/dashboardEditorTypes.ts | 55 ++++- .../src/features/dashboard/dashboardLayout.ts | 220 ++++++++++++++---- webui/src/styles/dashboard.css | 21 +- webui/tests/dashboard-layout.test.ts | 68 +++++- 10 files changed, 491 insertions(+), 72 deletions(-) diff --git a/src/govoplan_dashboard/backend/schemas.py b/src/govoplan_dashboard/backend/schemas.py index c6c6a79..a6edf62 100644 --- a/src/govoplan_dashboard/backend/schemas.py +++ b/src/govoplan_dashboard/backend/schemas.py @@ -15,11 +15,29 @@ class DashboardWidgetPlacementPayload(BaseModel): instance_id: str = Field(min_length=1, max_length=120) widget_id: WidgetId size: Literal["small", "medium", "wide", "full"] = "medium" + column_start: int | None = Field(default=None, ge=1, le=4) configuration: dict[str, ConfigurationValue] = Field( default_factory=dict, max_length=40, ) + @model_validator(mode="after") + def validate_column_span(self) -> "DashboardWidgetPlacementPayload": + if self.column_start is None: + return self + span = { + "small": 1, + "medium": 2, + "wide": 3, + "full": 4, + }[self.size] + if self.column_start + span - 1 > 4: + raise ValueError( + f"A {self.size} widget cannot start in column " + f"{self.column_start} of a four-column Dashboard." + ) + return self + @field_validator("configuration") @classmethod def validate_configuration( diff --git a/tests/test_dashboard_layouts.py b/tests/test_dashboard_layouts.py index 12dfaa0..e381c7e 100644 --- a/tests/test_dashboard_layouts.py +++ b/tests/test_dashboard_layouts.py @@ -83,6 +83,7 @@ class DashboardLayoutApiTests(unittest.TestCase): "instance_id": "instance-1", "widget_id": "dashboard.installed-modules", "size": "wide", + "column_start": 2, "configuration": { "maxItems": 5, "showVersions": False, @@ -118,6 +119,10 @@ class DashboardLayoutApiTests(unittest.TestCase): False, original.json()["placements"][0]["configuration"]["showVersions"], ) + self.assertEqual( + 2, + original.json()["placements"][0]["column_start"], + ) def test_stale_revision_is_rejected(self) -> None: payload = { @@ -174,6 +179,48 @@ class DashboardLayoutApiTests(unittest.TestCase): self.assertEqual(422, response.status_code) + def test_widget_span_must_fit_the_four_column_grid(self) -> None: + response = self.client.put( + "/api/v1/dashboard/layout", + json={ + "expected_revision": 0, + "layout_version": 1, + "placements": [ + { + "instance_id": "instance-1", + "widget_id": "example.widget", + "size": "medium", + "column_start": 4, + "configuration": {}, + } + ], + "known_widget_ids": ["example.widget"], + }, + ) + + self.assertEqual(422, response.status_code) + + def test_legacy_placement_without_column_is_still_accepted(self) -> None: + response = self.client.put( + "/api/v1/dashboard/layout", + json={ + "expected_revision": 0, + "layout_version": 1, + "placements": [ + { + "instance_id": "instance-1", + "widget_id": "example.widget", + "size": "medium", + "configuration": {}, + } + ], + "known_widget_ids": ["example.widget"], + }, + ) + + self.assertEqual(200, response.status_code) + self.assertIsNone(response.json()["placements"][0]["column_start"]) + def test_account_layout_context_limit_is_enforced(self) -> None: with self.session_factory() as session: session.add_all( diff --git a/webui/src/api/dashboard.ts b/webui/src/api/dashboard.ts index a9373bd..2e55fba 100644 --- a/webui/src/api/dashboard.ts +++ b/webui/src/api/dashboard.ts @@ -10,6 +10,7 @@ export type DashboardWidgetPlacementResponse = { instance_id: string; widget_id: string; size: DashboardWidgetSize; + column_start?: number | null; configuration: DashboardWidgetConfiguration; }; diff --git a/webui/src/features/dashboard/DashboardGrid.tsx b/webui/src/features/dashboard/DashboardGrid.tsx index 4ca2cb9..85b4386 100644 --- a/webui/src/features/dashboard/DashboardGrid.tsx +++ b/webui/src/features/dashboard/DashboardGrid.tsx @@ -3,6 +3,7 @@ import { useLayoutEffect, useRef, useState, + type CSSProperties, type HTMLAttributes, type DragEvent as ReactDragEvent, type ReactNode @@ -23,6 +24,7 @@ import { } from "@govoplan/core-webui"; import { dashboardMasonryRowSpan, + dashboardWidgetColumnSpan, defaultWidgetSize, widgetIsConfigurable, type DashboardWidgetPlacement @@ -117,6 +119,7 @@ export default function DashboardGrid({ return (
{ if (!configuring || event.target !== event.currentTarget) return; onDragOverEnd(event); @@ -131,6 +134,7 @@ export default function DashboardGrid({ key={`catalogue-placeholder:${item.widgetId}`} title={widget?.title ?? "New widget"} size={item.size} + columnStart={item.columnStart} onDragOver={(event) => event.preventDefault()} onDrop={onDropPreview} /> @@ -145,6 +149,7 @@ export default function DashboardGrid({ key={placement.instanceId} title={widget.title} size={placement.size} + columnStart={placement.columnStart} preserveHeight onDragOver={(event) => event.preventDefault()} onDrop={onDropPreview} @@ -167,6 +172,7 @@ export default function DashboardGrid({ onDragOver(event, placement.instanceId) @@ -246,6 +252,7 @@ export default function DashboardGrid({ function DashboardDropPlaceholder({ title, size, + columnStart, preserveHeight = false, onDragOver, onDrop, @@ -253,6 +260,7 @@ function DashboardDropPlaceholder({ }: { title: string; size: DashboardWidgetPlacement["size"]; + columnStart: number; preserveHeight?: boolean; onDragOver: (event: ReactDragEvent) => void; onDrop: (event: ReactDragEvent) => void; @@ -268,9 +276,13 @@ function DashboardDropPlaceholder({ ] .filter(Boolean) .join(" ")} + style={dashboardColumnStyle(columnStart)} onDragOver={onDragOver} onDrop={onDrop} - aria-label={`Place ${title} here`} + aria-label={ + `Place ${title} in column ${columnStart}, spanning ` + + `${dashboardWidgetColumnSpan(size)} columns` + } > {children && (
@@ -285,6 +297,12 @@ function DashboardDropPlaceholder({ ); } +function dashboardColumnStyle(columnStart: number): CSSProperties { + return { + "--dashboard-column-start": columnStart + } as CSSProperties; +} + function DashboardMasonryItem({ children, ...props diff --git a/webui/src/features/dashboard/DashboardPage.tsx b/webui/src/features/dashboard/DashboardPage.tsx index 9b8794b..d0ce2ab 100644 --- a/webui/src/features/dashboard/DashboardPage.tsx +++ b/webui/src/features/dashboard/DashboardPage.tsx @@ -26,7 +26,8 @@ import { useUnsavedDraftGuard, type ApiSettings, type AuthInfo, - type DashboardWidgetContribution + type DashboardWidgetContribution, + type DashboardWidgetSize } from "@govoplan/core-webui"; import { fetchDashboardLayout, @@ -37,6 +38,9 @@ import WidgetConfigurationDialog from "./WidgetConfigurationDialog"; import WidgetLibrary from "./WidgetLibrary"; import { appendPlacement, + DASHBOARD_COLUMN_COUNT, + dashboardWidgetColumnSpan, + defaultWidgetSize, defaultDashboardLayout, insertPlacement, layoutFromResponse, @@ -44,6 +48,7 @@ import { layoutsEqual, localLayoutKey, MAX_DASHBOARD_WIDGETS, + nextDashboardColumnStart, readLocalLayout, reconcileDashboardLayout, removePlacement, @@ -57,6 +62,7 @@ import type { DashboardDragItem, DashboardDropTarget } from "./dashboardEditorTypes"; +import { dashboardColumnStartFromPointer } from "./dashboardEditorTypes"; type LayoutSource = "server" | "browser" | "default"; @@ -259,8 +265,11 @@ export default function DashboardPage({ setDraftLayout((current) => updater(current)); } - function addWidget(widget: DashboardWidgetContribution) { - updateDraft((layout) => appendPlacement(layout, widget)); + function addWidget( + widget: DashboardWidgetContribution, + columnStart?: number + ) { + updateDraft((layout) => appendPlacement(layout, widget, columnStart)); } function startDrag(event: ReactDragEvent, item: DashboardDragItem) { @@ -284,20 +293,15 @@ export default function DashboardPage({ event.dataTransfer.dropEffect = dragItem.kind === "catalogue" ? "copy" : "move"; const bounds = event.currentTarget.getBoundingClientRect(); const verticalPosition = (event.clientY - bounds.top) / bounds.height; - const edge = - verticalPosition < 0.3 - || ( - verticalPosition <= 0.7 - && event.clientX < bounds.left + bounds.width / 2 - ) - ? "before" - : "after"; + const edge = verticalPosition < 0.5 ? "before" : "after"; + const columnStart = dropColumnStart(event); setDropTarget((current) => current?.kind === "placement" && current.instanceId === instanceId && current.edge === edge + && current.columnStart === columnStart ? current - : { kind: "placement", instanceId, edge } + : { kind: "placement", instanceId, edge, columnStart } ); } @@ -305,8 +309,54 @@ export default function DashboardPage({ if (!dragItem) return; event.preventDefault(); event.dataTransfer.dropEffect = dragItem.kind === "catalogue" ? "copy" : "move"; + const columnStart = dropColumnStart(event); setDropTarget((current) => - current?.kind === "end" ? current : { kind: "end" } + current?.kind === "end" && current.columnStart === columnStart + ? current + : { kind: "end", columnStart } + ); + } + + function draggedWidgetSize(): DashboardWidgetSize | null { + if (!dragItem) return null; + if (dragItem.kind === "placement") { + return draftLayout.placements.find( + (placement) => placement.instanceId === dragItem.instanceId + )?.size ?? null; + } + const widget = widgetById.get(dragItem.widgetId); + return widget ? defaultWidgetSize(widget) : null; + } + + function dropColumnStart( + event: ReactDragEvent + ): number { + const size = draggedWidgetSize(); + if (!size) return 1; + const grid = event.currentTarget.closest( + "[data-dashboard-grid]" + ); + if (!grid) return 1; + const styles = window.getComputedStyle(grid); + const visibleColumns = styles.gridTemplateColumns + .split(/\s+/) + .filter(Boolean).length; + if (visibleColumns !== 4) { + if (dragItem?.kind === "placement") { + return draftLayout.placements.find( + (placement) => placement.instanceId === dragItem.instanceId + )?.columnStart ?? 1; + } + return nextDashboardColumnStart(draftLayout.placements, size); + } + const bounds = grid.getBoundingClientRect(); + return dashboardColumnStartFromPointer( + event.clientX, + bounds.left, + bounds.width, + Number.parseFloat(styles.columnGap), + dashboardWidgetColumnSpan(size), + DASHBOARD_COLUMN_COUNT ); } @@ -321,15 +371,28 @@ export default function DashboardPage({ && dropTarget.instanceId === target.instanceId ? dropTarget.edge : "after"; + const columnStart = dropTarget?.columnStart ?? target.columnStart; if (dragItem.kind === "placement") { updateDraft((layout) => - reorderPlacement(layout, dragItem.instanceId, target.instanceId, edge) + reorderPlacement( + layout, + dragItem.instanceId, + target.instanceId, + edge, + columnStart + ) ); } else { const widget = widgetById.get(dragItem.widgetId); if (widget) { updateDraft((layout) => - insertPlacement(layout, widget, target.instanceId, edge) + insertPlacement( + layout, + widget, + target.instanceId, + edge, + columnStart + ) ); } } @@ -353,16 +416,23 @@ export default function DashboardPage({ event.preventDefault(); event.stopPropagation(); if (!dragItem) return; + const columnStart = dropTarget?.columnStart; if (dragItem.kind === "placement") { const last = draftLayout.placements.at(-1); if (last) { updateDraft((layout) => - reorderPlacement(layout, dragItem.instanceId, last.instanceId, "after") + reorderPlacement( + layout, + dragItem.instanceId, + last.instanceId, + "after", + columnStart + ) ); } } else { const widget = widgetById.get(dragItem.widgetId); - if (widget) addWidget(widget); + if (widget) addWidget(widget, columnStart); } finishDrag(); } diff --git a/webui/src/features/dashboard/WidgetConfigurationDialog.tsx b/webui/src/features/dashboard/WidgetConfigurationDialog.tsx index 2977dc0..b020f04 100644 --- a/webui/src/features/dashboard/WidgetConfigurationDialog.tsx +++ b/webui/src/features/dashboard/WidgetConfigurationDialog.tsx @@ -197,5 +197,12 @@ function valueIsEmpty(value: DashboardWidgetConfigurationValue | undefined): boo } function sizeLabel(size: DashboardWidgetSize): string { - return size.charAt(0).toUpperCase() + size.slice(1); + const columns = { + small: 1, + medium: 2, + wide: 3, + full: 4 + }[size]; + const label = size.charAt(0).toUpperCase() + size.slice(1); + return `${label} (${columns} ${columns === 1 ? "column" : "columns"})`; } diff --git a/webui/src/features/dashboard/dashboardEditorTypes.ts b/webui/src/features/dashboard/dashboardEditorTypes.ts index c327de9..475dea8 100644 --- a/webui/src/features/dashboard/dashboardEditorTypes.ts +++ b/webui/src/features/dashboard/dashboardEditorTypes.ts @@ -10,8 +10,9 @@ export type DashboardDropTarget = kind: "placement"; instanceId: string; edge: "before" | "after"; + columnStart: number; } - | { kind: "end" }; + | { kind: "end"; columnStart: number }; export type DashboardGridPreviewItem = | { @@ -23,6 +24,7 @@ export type DashboardGridPreviewItem = kind: "catalogue"; widgetId: string; size: DashboardWidgetSize; + columnStart: number; }; export function dashboardGridPreview( @@ -47,7 +49,8 @@ export function dashboardGridPreview( items.splice(insertionIndex, 0, { kind: "catalogue", widgetId: dragItem.widgetId, - size: catalogueSize + size: catalogueSize, + columnStart: dropTarget.columnStart }); return items; } @@ -72,12 +75,58 @@ export function dashboardGridPreview( })); preview.splice(insertionIndex, 0, { kind: "placement", - placement: source, + placement: { + ...source, + columnStart: dropTarget.columnStart + }, placeholder: true }); return preview; } +export function dashboardColumnStartFromPointer( + pointerX: number, + gridLeft: number, + gridWidth: number, + columnGap: number, + columnSpan: number, + columnCount: number +): number { + const safeColumnCount = + Number.isFinite(columnCount) ? Math.max(1, Math.trunc(columnCount)) : 1; + const safeWidth = + Number.isFinite(gridWidth) && gridWidth > 0 ? gridWidth : 1; + const safeGap = + Number.isFinite(columnGap) ? Math.max(0, columnGap) : 0; + const columnWidth = Math.max( + 1, + ( + safeWidth + - safeGap * (safeColumnCount - 1) + ) / safeColumnCount + ); + const span = + Number.isFinite(columnSpan) + ? Math.min(safeColumnCount, Math.max(1, Math.trunc(columnSpan))) + : 1; + const spanWidth = columnWidth * span + safeGap * (span - 1); + const stride = columnWidth + safeGap; + const maximumStart = safeColumnCount - span + 1; + let nearestStart = 1; + let nearestDistance = Number.POSITIVE_INFINITY; + + for (let columnStart = 1; columnStart <= maximumStart; columnStart += 1) { + const center = + gridLeft + (columnStart - 1) * stride + spanWidth / 2; + const distance = Math.abs(pointerX - center); + if (distance < nearestDistance) { + nearestStart = columnStart; + nearestDistance = distance; + } + } + return nearestStart; +} + function previewInsertionIndex( placements: DashboardWidgetPlacement[], target: DashboardDropTarget, diff --git a/webui/src/features/dashboard/dashboardLayout.ts b/webui/src/features/dashboard/dashboardLayout.ts index 2384d56..7520eb1 100644 --- a/webui/src/features/dashboard/dashboardLayout.ts +++ b/webui/src/features/dashboard/dashboardLayout.ts @@ -13,6 +13,7 @@ export type DashboardWidgetPlacement = { instanceId: string; widgetId: string; size: DashboardWidgetSize; + columnStart: number; configuration: DashboardWidgetConfiguration; }; @@ -29,15 +30,19 @@ type LegacyDashboardLayout = { }; const SIZES: DashboardWidgetSize[] = ["small", "medium", "wide", "full"]; +export const DASHBOARD_COLUMN_COUNT = 4; export const MAX_DASHBOARD_WIDGETS = 100; export function createWidgetPlacement( - widget: DashboardWidgetContribution + widget: DashboardWidgetContribution, + columnStart = 1 ): DashboardWidgetPlacement { + const size = defaultWidgetSize(widget); return { instanceId: newInstanceId(), widgetId: widget.id, - size: defaultWidgetSize(widget), + size, + columnStart: normalizeDashboardColumnStart(size, columnStart), configuration: { ...(widget.defaultConfiguration ?? {}) } }; } @@ -45,14 +50,27 @@ export function createWidgetPlacement( export function defaultDashboardLayout( widgets: DashboardWidgetContribution[] ): DashboardLayoutState { + const placements: DashboardWidgetPlacement[] = []; + for (const widget of widgets) { + if ( + widget.defaultVisible === false + || placements.length >= MAX_DASHBOARD_WIDGETS + ) { + continue; + } + const size = defaultWidgetSize(widget); + placements.push( + createWidgetPlacement( + widget, + nextDashboardColumnStart(placements, size) + ) + ); + } return { layoutVersion: 1, revision: 0, knownWidgetIds: widgets.map((widget) => widget.id), - placements: widgets - .filter((widget) => widget.defaultVisible !== false) - .slice(0, MAX_DASHBOARD_WIDGETS) - .map(createWidgetPlacement) + placements }; } @@ -63,14 +81,21 @@ export function reconcileDashboardLayout( const widgetById = new Map(widgets.map((widget) => [widget.id, widget])); const seenInstances = new Set(); const seenWidgets = new Set(); - const placements = layout.placements.flatMap((placement) => { - if (!placement.instanceId || seenInstances.has(placement.instanceId)) return []; + const placements: DashboardWidgetPlacement[] = []; + for (const placement of layout.placements) { + if (!placement.instanceId || seenInstances.has(placement.instanceId)) continue; const widget = widgetById.get(placement.widgetId); - if (widget && !widget.allowMultiple && seenWidgets.has(widget.id)) return []; + if (widget && !widget.allowMultiple && seenWidgets.has(widget.id)) continue; seenInstances.add(placement.instanceId); seenWidgets.add(placement.widgetId); - return [normalizePlacement(placement, widget)]; - }); + placements.push( + normalizePlacement( + placement, + widget, + nextDashboardColumnStart(placements, placement.size) + ) + ); + } const known = new Set(layout.knownWidgetIds); for (const widget of widgets) { @@ -79,7 +104,13 @@ export function reconcileDashboardLayout( && !known.has(widget.id) && widget.defaultVisible !== false ) { - placements.push(createWidgetPlacement(widget)); + const size = defaultWidgetSize(widget); + placements.push( + createWidgetPlacement( + widget, + nextDashboardColumnStart(placements, size) + ) + ); } known.add(widget.id); } @@ -95,11 +126,20 @@ export function reconcileDashboardLayout( export function layoutFromResponse( response: DashboardLayoutResponse ): DashboardLayoutState { + const placements: DashboardWidgetPlacement[] = []; + for (const placement of response.placements) { + placements.push( + placementFromResponse( + placement, + nextDashboardColumnStart(placements, placement.size) + ) + ); + } return { layoutVersion: 1, revision: response.revision, knownWidgetIds: uniqueStrings(response.known_widget_ids), - placements: response.placements.map(placementFromResponse) + placements }; } @@ -116,7 +156,8 @@ export function layoutUpdatePayload( export function appendPlacement( layout: DashboardLayoutState, - widget: DashboardWidgetContribution + widget: DashboardWidgetContribution, + columnStart?: number ): DashboardLayoutState { if ( layout.placements.length >= MAX_DASHBOARD_WIDGETS @@ -127,10 +168,17 @@ export function appendPlacement( ) { return layout; } + const size = defaultWidgetSize(widget); return { ...layout, knownWidgetIds: uniqueStrings([...layout.knownWidgetIds, widget.id]), - placements: [...layout.placements, createWidgetPlacement(widget)] + placements: [ + ...layout.placements, + createWidgetPlacement( + widget, + columnStart ?? nextDashboardColumnStart(layout.placements, size) + ) + ] }; } @@ -138,7 +186,8 @@ export function insertPlacement( layout: DashboardLayoutState, widget: DashboardWidgetContribution, targetInstanceId: string, - edge: "before" | "after" + edge: "before" | "after", + columnStart?: number ): DashboardLayoutState { if ( layout.placements.length >= MAX_DASHBOARD_WIDGETS @@ -152,14 +201,21 @@ export function insertPlacement( const targetIndex = layout.placements.findIndex( (placement) => placement.instanceId === targetInstanceId ); - if (targetIndex < 0) return appendPlacement(layout, widget); + if (targetIndex < 0) return appendPlacement(layout, widget, columnStart); const insertionIndex = targetIndex + (edge === "after" ? 1 : 0); + const size = defaultWidgetSize(widget); + const fallbackColumnStart = edge === "before" + ? layout.placements[targetIndex].columnStart + : nextDashboardColumnStart( + layout.placements.slice(0, insertionIndex), + size + ); return { ...layout, knownWidgetIds: uniqueStrings([...layout.knownWidgetIds, widget.id]), placements: [ ...layout.placements.slice(0, insertionIndex), - createWidgetPlacement(widget), + createWidgetPlacement(widget, columnStart ?? fallbackColumnStart), ...layout.placements.slice(insertionIndex) ] }; @@ -181,11 +237,18 @@ export function updatePlacement( layout: DashboardLayoutState, nextPlacement: DashboardWidgetPlacement ): DashboardLayoutState { + const normalized = { + ...nextPlacement, + columnStart: normalizeDashboardColumnStart( + nextPlacement.size, + nextPlacement.columnStart + ) + }; return { ...layout, placements: layout.placements.map((placement) => placement.instanceId === nextPlacement.instanceId - ? nextPlacement + ? normalized : placement ) }; @@ -195,13 +258,23 @@ export function reorderPlacement( layout: DashboardLayoutState, sourceInstanceId: string, targetInstanceId: string, - edge: "before" | "after" + edge: "before" | "after", + columnStart?: number ): DashboardLayoutState { - if (sourceInstanceId === targetInstanceId) return layout; const source = layout.placements.find( (placement) => placement.instanceId === sourceInstanceId ); if (!source) return layout; + const movedSource = { + ...source, + columnStart: normalizeDashboardColumnStart( + source.size, + columnStart ?? source.columnStart + ) + }; + if (sourceInstanceId === targetInstanceId) { + return updatePlacement(layout, movedSource); + } const remaining = layout.placements.filter( (placement) => placement.instanceId !== sourceInstanceId ); @@ -214,12 +287,46 @@ export function reorderPlacement( ...layout, placements: [ ...remaining.slice(0, insertionIndex), - source, + movedSource, ...remaining.slice(insertionIndex) ] }; } +export function dashboardWidgetColumnSpan( + size: DashboardWidgetSize +): number { + if (size === "small") return 1; + if (size === "medium") return 2; + if (size === "wide") return 3; + return DASHBOARD_COLUMN_COUNT; +} + +export function normalizeDashboardColumnStart( + size: DashboardWidgetSize, + columnStart: unknown, + fallback = 1 +): number { + const maximum = DASHBOARD_COLUMN_COUNT - dashboardWidgetColumnSpan(size) + 1; + const candidate = + typeof columnStart === "number" && Number.isFinite(columnStart) + ? Math.trunc(columnStart) + : fallback; + return Math.min(maximum, Math.max(1, candidate)); +} + +export function nextDashboardColumnStart( + placements: DashboardWidgetPlacement[], + size: DashboardWidgetSize +): number { + const previous = placements.at(-1); + if (!previous) return 1; + const candidate = + previous.columnStart + dashboardWidgetColumnSpan(previous.size); + const maximum = DASHBOARD_COLUMN_COUNT - dashboardWidgetColumnSpan(size) + 1; + return candidate <= maximum ? candidate : 1; +} + export function dashboardMasonryRowSpan( height: number, rowHeight: number, @@ -311,29 +418,43 @@ export function widgetIsConfigurable( function normalizePlacement( placement: DashboardWidgetPlacement, - widget: DashboardWidgetContribution | undefined + widget: DashboardWidgetContribution | undefined, + fallbackColumnStart: number ): DashboardWidgetPlacement { - if (!widget) return placement; - const supported = supportedWidgetSizes(widget); + const supported = widget ? supportedWidgetSizes(widget) : SIZES; + const size = supported.includes(placement.size) + ? placement.size + : widget + ? defaultWidgetSize(widget) + : "medium"; return { ...placement, - size: supported.includes(placement.size) - ? placement.size - : defaultWidgetSize(widget), + size, + columnStart: normalizeDashboardColumnStart( + size, + placement.columnStart, + fallbackColumnStart + ), configuration: { - ...(widget.defaultConfiguration ?? {}), + ...(widget?.defaultConfiguration ?? {}), ...placement.configuration } }; } function placementFromResponse( - placement: DashboardWidgetPlacementResponse + placement: DashboardWidgetPlacementResponse, + fallbackColumnStart: number ): DashboardWidgetPlacement { return { instanceId: placement.instance_id, widgetId: placement.widget_id, size: placement.size, + columnStart: normalizeDashboardColumnStart( + placement.size, + placement.column_start, + fallbackColumnStart + ), configuration: { ...placement.configuration } }; } @@ -345,6 +466,7 @@ function placementToResponse( instance_id: placement.instanceId, widget_id: placement.widgetId, size: placement.size, + column_start: placement.columnStart, configuration: { ...placement.configuration } }; } @@ -355,8 +477,9 @@ function parseStoredLayout(value: unknown): DashboardLayoutState | null { if (!Array.isArray(candidate.placements) || !Array.isArray(candidate.knownWidgetIds)) { return null; } - const placements = candidate.placements.flatMap((item) => { - if (!item || typeof item !== "object") return []; + const placements: DashboardWidgetPlacement[] = []; + for (const item of candidate.placements) { + if (!item || typeof item !== "object") continue; const placement = item as Partial; if ( typeof placement.instanceId !== "string" @@ -366,15 +489,23 @@ function parseStoredLayout(value: unknown): DashboardLayoutState | null { || typeof placement.configuration !== "object" || Array.isArray(placement.configuration) ) { - return []; + continue; } - return [{ + placements.push({ instanceId: placement.instanceId, widgetId: placement.widgetId, size: placement.size as DashboardWidgetSize, + columnStart: normalizeDashboardColumnStart( + placement.size as DashboardWidgetSize, + placement.columnStart, + nextDashboardColumnStart( + placements, + placement.size as DashboardWidgetSize + ) + ), configuration: sanitizeConfiguration(placement.configuration) - }]; - }); + }); + } return { layoutVersion: 1, revision: typeof candidate.revision === "number" ? candidate.revision : 0, @@ -394,14 +525,23 @@ function migrateLegacyLayout( } const visible = new Set(uniqueStrings(candidate.visible)); const widgetById = new Map(widgets.map((widget) => [widget.id, widget])); + const placements: DashboardWidgetPlacement[] = []; + for (const widgetId of visible) { + const widget = widgetById.get(widgetId); + if (!widget) continue; + const size = defaultWidgetSize(widget); + placements.push( + createWidgetPlacement( + widget, + nextDashboardColumnStart(placements, size) + ) + ); + } return { layoutVersion: 1, revision: 0, knownWidgetIds: uniqueStrings(candidate.known), - placements: [...visible].flatMap((widgetId) => { - const widget = widgetById.get(widgetId); - return widget ? [createWidgetPlacement(widget)] : []; - }) + placements }; } diff --git a/webui/src/styles/dashboard.css b/webui/src/styles/dashboard.css index 89f9574..c5264c8 100644 --- a/webui/src/styles/dashboard.css +++ b/webui/src/styles/dashboard.css @@ -23,6 +23,7 @@ position: relative; align-self: start; margin-bottom: 18px; + grid-column-start: var(--dashboard-column-start, auto); } .dashboard-widget > .card { @@ -31,16 +32,19 @@ } .dashboard-widget-small { - grid-column: span 1; + grid-column-end: span 1; } .dashboard-widget-medium { - grid-column: span 2; + grid-column-end: span 2; +} + +.dashboard-widget-wide { + grid-column-end: span 3; } -.dashboard-widget-wide, .dashboard-widget-full { - grid-column: 1 / -1; + grid-column-end: span 4; } .dashboard-compact-list.detail-list div { @@ -318,6 +322,15 @@ grid-template-columns: repeat(2, minmax(220px, 1fr)); } + .dashboard-widget { + grid-column-start: auto; + } + + .dashboard-widget-wide, + .dashboard-widget-full { + grid-column: 1 / -1; + } + .dashboard-widget-library { grid-template-columns: minmax(200px, 240px) minmax(0, 1fr) 36px; } diff --git a/webui/tests/dashboard-layout.test.ts b/webui/tests/dashboard-layout.test.ts index 6b0b126..38f1684 100644 --- a/webui/tests/dashboard-layout.test.ts +++ b/webui/tests/dashboard-layout.test.ts @@ -2,13 +2,18 @@ import assert from "node:assert/strict"; import { appendPlacement, dashboardMasonryRowSpan, + dashboardWidgetColumnSpan, defaultDashboardLayout, layoutUpdatePayload, + normalizeDashboardColumnStart, reconcileDashboardLayout, removePlacement, reorderPlacement } from "../src/features/dashboard/dashboardLayout.ts"; -import { dashboardGridPreview } from "../src/features/dashboard/dashboardEditorTypes.ts"; +import { + dashboardColumnStartFromPointer, + dashboardGridPreview +} from "../src/features/dashboard/dashboardEditorTypes.ts"; const widgets = [ { @@ -36,6 +41,7 @@ const defaults = defaultDashboardLayout(widgets); assert.deepEqual(defaults.knownWidgetIds, ["one", "two"]); assert.equal(defaults.placements.length, 1); assert.equal(defaults.placements[0].widgetId, "one"); +assert.equal(defaults.placements[0].columnStart, 1); assert.deepEqual(defaults.placements[0].configuration, { count: 3 }); const withTwo = appendPlacement(defaults, widgets[1]); @@ -43,6 +49,11 @@ assert.deepEqual( withTwo.placements.map((placement) => placement.widgetId), ["one", "two"] ); +assert.equal( + withTwo.placements[1].columnStart, + 2, + "new widgets should use the next available horizontal position" +); assert.equal( appendPlacement(withTwo, widgets[1]).placements.length, 2, @@ -53,12 +64,19 @@ const reordered = reorderPlacement( withTwo, withTwo.placements[1].instanceId, withTwo.placements[0].instanceId, - "before" + "before", + 3 ); assert.deepEqual( reordered.placements.map((placement) => placement.widgetId), ["two", "one"] ); +assert.equal( + reordered.placements[0].columnStart, + 3, + "moving a widget must change its column without moving other columns" +); +assert.equal(reordered.placements[1].columnStart, 1); const removed = removePlacement(reordered, reordered.placements[0].instanceId); assert.deepEqual( @@ -75,7 +93,8 @@ const placementPreview = dashboardGridPreview( { kind: "placement", instanceId: withTwo.placements[1].instanceId, - edge: "after" + edge: "after", + columnStart: 3 } ); assert.deepEqual( @@ -87,6 +106,13 @@ assert.deepEqual( ["two:false", "one:true"], "placement previews must show the prospective order" ); +assert.equal( + placementPreview[1].kind === "placement" + ? placementPreview[1].placement.columnStart + : null, + 3, + "placement previews must show the prospective column" +); const cataloguePreview = dashboardGridPreview( withTwo.placements, @@ -94,7 +120,8 @@ const cataloguePreview = dashboardGridPreview( { kind: "placement", instanceId: withTwo.placements[0].instanceId, - edge: "after" + edge: "after", + columnStart: 2 }, "wide" ); @@ -107,11 +134,19 @@ assert.deepEqual( ["one", "three:wide", "two"], "catalogue previews must reserve the prospective widget size" ); +assert.equal( + cataloguePreview[1].kind === "catalogue" + ? cataloguePreview[1].columnStart + : null, + 2, + "catalogue previews must reserve the selected horizontal position" +); const unavailablePlacement = { instanceId: "unavailable-instance", widgetId: "temporarily-disabled", size: "wide" as const, + columnStart: 2, configuration: { mode: "summary" } }; const reconciled = reconcileDashboardLayout( @@ -124,8 +159,8 @@ const reconciled = reconcileDashboardLayout( widgets ); assert.equal( - reconciled.placements[0], - unavailablePlacement, + reconciled.placements[0].columnStart, + 2, "temporarily unavailable contributions must retain their placement" ); assert.equal( @@ -138,8 +173,29 @@ assert.equal(reconciled.revision, 4); const payload = layoutUpdatePayload(reconciled); assert.equal(payload.expected_revision, 4); assert.equal(payload.placements[0].instance_id, "unavailable-instance"); +assert.equal(payload.placements[0].column_start, 2); assert.equal(payload.placements[0].configuration.mode, "summary"); +assert.equal(dashboardWidgetColumnSpan("small"), 1); +assert.equal(dashboardWidgetColumnSpan("medium"), 2); +assert.equal(dashboardWidgetColumnSpan("wide"), 3); +assert.equal(dashboardWidgetColumnSpan("full"), 4); +assert.equal( + normalizeDashboardColumnStart("medium", 4), + 3, + "column starts must be clamped so the widget stays in the four-column grid" +); +assert.equal( + dashboardColumnStartFromPointer(500, 0, 1000, 20, 2, 4), + 2, + "a two-column widget must be placeable in the middle columns" +); +assert.equal( + dashboardColumnStartFromPointer(950, 0, 1000, 20, 3, 4), + 2, + "a three-column widget must be placeable from column two" +); + const repeatingWidget = { ...widgets[0], id: "repeating",