fix(dashboard): stabilize four-column widget placement

This commit is contained in:
2026-07-30 15:36:59 +02:00
parent f0732bead2
commit 49afc6d73d
10 changed files with 491 additions and 72 deletions

View File

@@ -15,11 +15,29 @@ class DashboardWidgetPlacementPayload(BaseModel):
instance_id: str = Field(min_length=1, max_length=120) instance_id: str = Field(min_length=1, max_length=120)
widget_id: WidgetId widget_id: WidgetId
size: Literal["small", "medium", "wide", "full"] = "medium" size: Literal["small", "medium", "wide", "full"] = "medium"
column_start: int | None = Field(default=None, ge=1, le=4)
configuration: dict[str, ConfigurationValue] = Field( configuration: dict[str, ConfigurationValue] = Field(
default_factory=dict, default_factory=dict,
max_length=40, 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") @field_validator("configuration")
@classmethod @classmethod
def validate_configuration( def validate_configuration(

View File

@@ -83,6 +83,7 @@ class DashboardLayoutApiTests(unittest.TestCase):
"instance_id": "instance-1", "instance_id": "instance-1",
"widget_id": "dashboard.installed-modules", "widget_id": "dashboard.installed-modules",
"size": "wide", "size": "wide",
"column_start": 2,
"configuration": { "configuration": {
"maxItems": 5, "maxItems": 5,
"showVersions": False, "showVersions": False,
@@ -118,6 +119,10 @@ class DashboardLayoutApiTests(unittest.TestCase):
False, False,
original.json()["placements"][0]["configuration"]["showVersions"], original.json()["placements"][0]["configuration"]["showVersions"],
) )
self.assertEqual(
2,
original.json()["placements"][0]["column_start"],
)
def test_stale_revision_is_rejected(self) -> None: def test_stale_revision_is_rejected(self) -> None:
payload = { payload = {
@@ -174,6 +179,48 @@ class DashboardLayoutApiTests(unittest.TestCase):
self.assertEqual(422, response.status_code) 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: def test_account_layout_context_limit_is_enforced(self) -> None:
with self.session_factory() as session: with self.session_factory() as session:
session.add_all( session.add_all(

View File

@@ -10,6 +10,7 @@ export type DashboardWidgetPlacementResponse = {
instance_id: string; instance_id: string;
widget_id: string; widget_id: string;
size: DashboardWidgetSize; size: DashboardWidgetSize;
column_start?: number | null;
configuration: DashboardWidgetConfiguration; configuration: DashboardWidgetConfiguration;
}; };

View File

@@ -3,6 +3,7 @@ import {
useLayoutEffect, useLayoutEffect,
useRef, useRef,
useState, useState,
type CSSProperties,
type HTMLAttributes, type HTMLAttributes,
type DragEvent as ReactDragEvent, type DragEvent as ReactDragEvent,
type ReactNode type ReactNode
@@ -23,6 +24,7 @@ import {
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
dashboardMasonryRowSpan, dashboardMasonryRowSpan,
dashboardWidgetColumnSpan,
defaultWidgetSize, defaultWidgetSize,
widgetIsConfigurable, widgetIsConfigurable,
type DashboardWidgetPlacement type DashboardWidgetPlacement
@@ -117,6 +119,7 @@ export default function DashboardGrid({
return ( return (
<div <div
className={`dashboard-widget-grid${configuring ? " is-configuring" : ""}`} className={`dashboard-widget-grid${configuring ? " is-configuring" : ""}`}
data-dashboard-grid
onDragOver={(event) => { onDragOver={(event) => {
if (!configuring || event.target !== event.currentTarget) return; if (!configuring || event.target !== event.currentTarget) return;
onDragOverEnd(event); onDragOverEnd(event);
@@ -131,6 +134,7 @@ export default function DashboardGrid({
key={`catalogue-placeholder:${item.widgetId}`} key={`catalogue-placeholder:${item.widgetId}`}
title={widget?.title ?? "New widget"} title={widget?.title ?? "New widget"}
size={item.size} size={item.size}
columnStart={item.columnStart}
onDragOver={(event) => event.preventDefault()} onDragOver={(event) => event.preventDefault()}
onDrop={onDropPreview} onDrop={onDropPreview}
/> />
@@ -145,6 +149,7 @@ export default function DashboardGrid({
key={placement.instanceId} key={placement.instanceId}
title={widget.title} title={widget.title}
size={placement.size} size={placement.size}
columnStart={placement.columnStart}
preserveHeight preserveHeight
onDragOver={(event) => event.preventDefault()} onDragOver={(event) => event.preventDefault()}
onDrop={onDropPreview} onDrop={onDropPreview}
@@ -167,6 +172,7 @@ export default function DashboardGrid({
<DashboardMasonryItem <DashboardMasonryItem
key={placement.instanceId} key={placement.instanceId}
className={`dashboard-widget dashboard-widget-${placement.size}`} className={`dashboard-widget dashboard-widget-${placement.size}`}
style={dashboardColumnStyle(placement.columnStart)}
onDragOver={ onDragOver={
configuring configuring
? (event) => onDragOver(event, placement.instanceId) ? (event) => onDragOver(event, placement.instanceId)
@@ -246,6 +252,7 @@ export default function DashboardGrid({
function DashboardDropPlaceholder({ function DashboardDropPlaceholder({
title, title,
size, size,
columnStart,
preserveHeight = false, preserveHeight = false,
onDragOver, onDragOver,
onDrop, onDrop,
@@ -253,6 +260,7 @@ function DashboardDropPlaceholder({
}: { }: {
title: string; title: string;
size: DashboardWidgetPlacement["size"]; size: DashboardWidgetPlacement["size"];
columnStart: number;
preserveHeight?: boolean; preserveHeight?: boolean;
onDragOver: (event: ReactDragEvent<HTMLElement>) => void; onDragOver: (event: ReactDragEvent<HTMLElement>) => void;
onDrop: (event: ReactDragEvent<HTMLElement>) => void; onDrop: (event: ReactDragEvent<HTMLElement>) => void;
@@ -268,9 +276,13 @@ function DashboardDropPlaceholder({
] ]
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
style={dashboardColumnStyle(columnStart)}
onDragOver={onDragOver} onDragOver={onDragOver}
onDrop={onDrop} onDrop={onDrop}
aria-label={`Place ${title} here`} aria-label={
`Place ${title} in column ${columnStart}, spanning `
+ `${dashboardWidgetColumnSpan(size)} columns`
}
> >
{children && ( {children && (
<div className="dashboard-widget-placeholder-content"> <div className="dashboard-widget-placeholder-content">
@@ -285,6 +297,12 @@ function DashboardDropPlaceholder({
); );
} }
function dashboardColumnStyle(columnStart: number): CSSProperties {
return {
"--dashboard-column-start": columnStart
} as CSSProperties;
}
function DashboardMasonryItem({ function DashboardMasonryItem({
children, children,
...props ...props

View File

@@ -26,7 +26,8 @@ import {
useUnsavedDraftGuard, useUnsavedDraftGuard,
type ApiSettings, type ApiSettings,
type AuthInfo, type AuthInfo,
type DashboardWidgetContribution type DashboardWidgetContribution,
type DashboardWidgetSize
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
fetchDashboardLayout, fetchDashboardLayout,
@@ -37,6 +38,9 @@ import WidgetConfigurationDialog from "./WidgetConfigurationDialog";
import WidgetLibrary from "./WidgetLibrary"; import WidgetLibrary from "./WidgetLibrary";
import { import {
appendPlacement, appendPlacement,
DASHBOARD_COLUMN_COUNT,
dashboardWidgetColumnSpan,
defaultWidgetSize,
defaultDashboardLayout, defaultDashboardLayout,
insertPlacement, insertPlacement,
layoutFromResponse, layoutFromResponse,
@@ -44,6 +48,7 @@ import {
layoutsEqual, layoutsEqual,
localLayoutKey, localLayoutKey,
MAX_DASHBOARD_WIDGETS, MAX_DASHBOARD_WIDGETS,
nextDashboardColumnStart,
readLocalLayout, readLocalLayout,
reconcileDashboardLayout, reconcileDashboardLayout,
removePlacement, removePlacement,
@@ -57,6 +62,7 @@ import type {
DashboardDragItem, DashboardDragItem,
DashboardDropTarget DashboardDropTarget
} from "./dashboardEditorTypes"; } from "./dashboardEditorTypes";
import { dashboardColumnStartFromPointer } from "./dashboardEditorTypes";
type LayoutSource = "server" | "browser" | "default"; type LayoutSource = "server" | "browser" | "default";
@@ -259,8 +265,11 @@ export default function DashboardPage({
setDraftLayout((current) => updater(current)); setDraftLayout((current) => updater(current));
} }
function addWidget(widget: DashboardWidgetContribution) { function addWidget(
updateDraft((layout) => appendPlacement(layout, widget)); widget: DashboardWidgetContribution,
columnStart?: number
) {
updateDraft((layout) => appendPlacement(layout, widget, columnStart));
} }
function startDrag(event: ReactDragEvent, item: DashboardDragItem) { function startDrag(event: ReactDragEvent, item: DashboardDragItem) {
@@ -284,20 +293,15 @@ export default function DashboardPage({
event.dataTransfer.dropEffect = dragItem.kind === "catalogue" ? "copy" : "move"; event.dataTransfer.dropEffect = dragItem.kind === "catalogue" ? "copy" : "move";
const bounds = event.currentTarget.getBoundingClientRect(); const bounds = event.currentTarget.getBoundingClientRect();
const verticalPosition = (event.clientY - bounds.top) / bounds.height; const verticalPosition = (event.clientY - bounds.top) / bounds.height;
const edge = const edge = verticalPosition < 0.5 ? "before" : "after";
verticalPosition < 0.3 const columnStart = dropColumnStart(event);
|| (
verticalPosition <= 0.7
&& event.clientX < bounds.left + bounds.width / 2
)
? "before"
: "after";
setDropTarget((current) => setDropTarget((current) =>
current?.kind === "placement" current?.kind === "placement"
&& current.instanceId === instanceId && current.instanceId === instanceId
&& current.edge === edge && current.edge === edge
&& current.columnStart === columnStart
? current ? current
: { kind: "placement", instanceId, edge } : { kind: "placement", instanceId, edge, columnStart }
); );
} }
@@ -305,8 +309,54 @@ export default function DashboardPage({
if (!dragItem) return; if (!dragItem) return;
event.preventDefault(); event.preventDefault();
event.dataTransfer.dropEffect = dragItem.kind === "catalogue" ? "copy" : "move"; event.dataTransfer.dropEffect = dragItem.kind === "catalogue" ? "copy" : "move";
const columnStart = dropColumnStart(event);
setDropTarget((current) => 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<HTMLElement>
): number {
const size = draggedWidgetSize();
if (!size) return 1;
const grid = event.currentTarget.closest<HTMLElement>(
"[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.instanceId === target.instanceId
? dropTarget.edge ? dropTarget.edge
: "after"; : "after";
const columnStart = dropTarget?.columnStart ?? target.columnStart;
if (dragItem.kind === "placement") { if (dragItem.kind === "placement") {
updateDraft((layout) => updateDraft((layout) =>
reorderPlacement(layout, dragItem.instanceId, target.instanceId, edge) reorderPlacement(
layout,
dragItem.instanceId,
target.instanceId,
edge,
columnStart
)
); );
} else { } else {
const widget = widgetById.get(dragItem.widgetId); const widget = widgetById.get(dragItem.widgetId);
if (widget) { if (widget) {
updateDraft((layout) => 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.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (!dragItem) return; if (!dragItem) return;
const columnStart = dropTarget?.columnStart;
if (dragItem.kind === "placement") { if (dragItem.kind === "placement") {
const last = draftLayout.placements.at(-1); const last = draftLayout.placements.at(-1);
if (last) { if (last) {
updateDraft((layout) => updateDraft((layout) =>
reorderPlacement(layout, dragItem.instanceId, last.instanceId, "after") reorderPlacement(
layout,
dragItem.instanceId,
last.instanceId,
"after",
columnStart
)
); );
} }
} else { } else {
const widget = widgetById.get(dragItem.widgetId); const widget = widgetById.get(dragItem.widgetId);
if (widget) addWidget(widget); if (widget) addWidget(widget, columnStart);
} }
finishDrag(); finishDrag();
} }

View File

@@ -197,5 +197,12 @@ function valueIsEmpty(value: DashboardWidgetConfigurationValue | undefined): boo
} }
function sizeLabel(size: DashboardWidgetSize): string { 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"})`;
} }

View File

@@ -10,8 +10,9 @@ export type DashboardDropTarget =
kind: "placement"; kind: "placement";
instanceId: string; instanceId: string;
edge: "before" | "after"; edge: "before" | "after";
columnStart: number;
} }
| { kind: "end" }; | { kind: "end"; columnStart: number };
export type DashboardGridPreviewItem = export type DashboardGridPreviewItem =
| { | {
@@ -23,6 +24,7 @@ export type DashboardGridPreviewItem =
kind: "catalogue"; kind: "catalogue";
widgetId: string; widgetId: string;
size: DashboardWidgetSize; size: DashboardWidgetSize;
columnStart: number;
}; };
export function dashboardGridPreview( export function dashboardGridPreview(
@@ -47,7 +49,8 @@ export function dashboardGridPreview(
items.splice(insertionIndex, 0, { items.splice(insertionIndex, 0, {
kind: "catalogue", kind: "catalogue",
widgetId: dragItem.widgetId, widgetId: dragItem.widgetId,
size: catalogueSize size: catalogueSize,
columnStart: dropTarget.columnStart
}); });
return items; return items;
} }
@@ -72,12 +75,58 @@ export function dashboardGridPreview(
})); }));
preview.splice(insertionIndex, 0, { preview.splice(insertionIndex, 0, {
kind: "placement", kind: "placement",
placement: source, placement: {
...source,
columnStart: dropTarget.columnStart
},
placeholder: true placeholder: true
}); });
return preview; 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( function previewInsertionIndex(
placements: DashboardWidgetPlacement[], placements: DashboardWidgetPlacement[],
target: DashboardDropTarget, target: DashboardDropTarget,

View File

@@ -13,6 +13,7 @@ export type DashboardWidgetPlacement = {
instanceId: string; instanceId: string;
widgetId: string; widgetId: string;
size: DashboardWidgetSize; size: DashboardWidgetSize;
columnStart: number;
configuration: DashboardWidgetConfiguration; configuration: DashboardWidgetConfiguration;
}; };
@@ -29,15 +30,19 @@ type LegacyDashboardLayout = {
}; };
const SIZES: DashboardWidgetSize[] = ["small", "medium", "wide", "full"]; const SIZES: DashboardWidgetSize[] = ["small", "medium", "wide", "full"];
export const DASHBOARD_COLUMN_COUNT = 4;
export const MAX_DASHBOARD_WIDGETS = 100; export const MAX_DASHBOARD_WIDGETS = 100;
export function createWidgetPlacement( export function createWidgetPlacement(
widget: DashboardWidgetContribution widget: DashboardWidgetContribution,
columnStart = 1
): DashboardWidgetPlacement { ): DashboardWidgetPlacement {
const size = defaultWidgetSize(widget);
return { return {
instanceId: newInstanceId(), instanceId: newInstanceId(),
widgetId: widget.id, widgetId: widget.id,
size: defaultWidgetSize(widget), size,
columnStart: normalizeDashboardColumnStart(size, columnStart),
configuration: { ...(widget.defaultConfiguration ?? {}) } configuration: { ...(widget.defaultConfiguration ?? {}) }
}; };
} }
@@ -45,14 +50,27 @@ export function createWidgetPlacement(
export function defaultDashboardLayout( export function defaultDashboardLayout(
widgets: DashboardWidgetContribution[] widgets: DashboardWidgetContribution[]
): DashboardLayoutState { ): 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 { return {
layoutVersion: 1, layoutVersion: 1,
revision: 0, revision: 0,
knownWidgetIds: widgets.map((widget) => widget.id), knownWidgetIds: widgets.map((widget) => widget.id),
placements: widgets placements
.filter((widget) => widget.defaultVisible !== false)
.slice(0, MAX_DASHBOARD_WIDGETS)
.map(createWidgetPlacement)
}; };
} }
@@ -63,14 +81,21 @@ export function reconcileDashboardLayout(
const widgetById = new Map(widgets.map((widget) => [widget.id, widget])); const widgetById = new Map(widgets.map((widget) => [widget.id, widget]));
const seenInstances = new Set<string>(); const seenInstances = new Set<string>();
const seenWidgets = new Set<string>(); const seenWidgets = new Set<string>();
const placements = layout.placements.flatMap((placement) => { const placements: DashboardWidgetPlacement[] = [];
if (!placement.instanceId || seenInstances.has(placement.instanceId)) return []; for (const placement of layout.placements) {
if (!placement.instanceId || seenInstances.has(placement.instanceId)) continue;
const widget = widgetById.get(placement.widgetId); 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); seenInstances.add(placement.instanceId);
seenWidgets.add(placement.widgetId); seenWidgets.add(placement.widgetId);
return [normalizePlacement(placement, widget)]; placements.push(
}); normalizePlacement(
placement,
widget,
nextDashboardColumnStart(placements, placement.size)
)
);
}
const known = new Set(layout.knownWidgetIds); const known = new Set(layout.knownWidgetIds);
for (const widget of widgets) { for (const widget of widgets) {
@@ -79,7 +104,13 @@ export function reconcileDashboardLayout(
&& !known.has(widget.id) && !known.has(widget.id)
&& widget.defaultVisible !== false && widget.defaultVisible !== false
) { ) {
placements.push(createWidgetPlacement(widget)); const size = defaultWidgetSize(widget);
placements.push(
createWidgetPlacement(
widget,
nextDashboardColumnStart(placements, size)
)
);
} }
known.add(widget.id); known.add(widget.id);
} }
@@ -95,11 +126,20 @@ export function reconcileDashboardLayout(
export function layoutFromResponse( export function layoutFromResponse(
response: DashboardLayoutResponse response: DashboardLayoutResponse
): DashboardLayoutState { ): DashboardLayoutState {
const placements: DashboardWidgetPlacement[] = [];
for (const placement of response.placements) {
placements.push(
placementFromResponse(
placement,
nextDashboardColumnStart(placements, placement.size)
)
);
}
return { return {
layoutVersion: 1, layoutVersion: 1,
revision: response.revision, revision: response.revision,
knownWidgetIds: uniqueStrings(response.known_widget_ids), knownWidgetIds: uniqueStrings(response.known_widget_ids),
placements: response.placements.map(placementFromResponse) placements
}; };
} }
@@ -116,7 +156,8 @@ export function layoutUpdatePayload(
export function appendPlacement( export function appendPlacement(
layout: DashboardLayoutState, layout: DashboardLayoutState,
widget: DashboardWidgetContribution widget: DashboardWidgetContribution,
columnStart?: number
): DashboardLayoutState { ): DashboardLayoutState {
if ( if (
layout.placements.length >= MAX_DASHBOARD_WIDGETS layout.placements.length >= MAX_DASHBOARD_WIDGETS
@@ -127,10 +168,17 @@ export function appendPlacement(
) { ) {
return layout; return layout;
} }
const size = defaultWidgetSize(widget);
return { return {
...layout, ...layout,
knownWidgetIds: uniqueStrings([...layout.knownWidgetIds, widget.id]), 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, layout: DashboardLayoutState,
widget: DashboardWidgetContribution, widget: DashboardWidgetContribution,
targetInstanceId: string, targetInstanceId: string,
edge: "before" | "after" edge: "before" | "after",
columnStart?: number
): DashboardLayoutState { ): DashboardLayoutState {
if ( if (
layout.placements.length >= MAX_DASHBOARD_WIDGETS layout.placements.length >= MAX_DASHBOARD_WIDGETS
@@ -152,14 +201,21 @@ export function insertPlacement(
const targetIndex = layout.placements.findIndex( const targetIndex = layout.placements.findIndex(
(placement) => placement.instanceId === targetInstanceId (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 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 { return {
...layout, ...layout,
knownWidgetIds: uniqueStrings([...layout.knownWidgetIds, widget.id]), knownWidgetIds: uniqueStrings([...layout.knownWidgetIds, widget.id]),
placements: [ placements: [
...layout.placements.slice(0, insertionIndex), ...layout.placements.slice(0, insertionIndex),
createWidgetPlacement(widget), createWidgetPlacement(widget, columnStart ?? fallbackColumnStart),
...layout.placements.slice(insertionIndex) ...layout.placements.slice(insertionIndex)
] ]
}; };
@@ -181,11 +237,18 @@ export function updatePlacement(
layout: DashboardLayoutState, layout: DashboardLayoutState,
nextPlacement: DashboardWidgetPlacement nextPlacement: DashboardWidgetPlacement
): DashboardLayoutState { ): DashboardLayoutState {
const normalized = {
...nextPlacement,
columnStart: normalizeDashboardColumnStart(
nextPlacement.size,
nextPlacement.columnStart
)
};
return { return {
...layout, ...layout,
placements: layout.placements.map((placement) => placements: layout.placements.map((placement) =>
placement.instanceId === nextPlacement.instanceId placement.instanceId === nextPlacement.instanceId
? nextPlacement ? normalized
: placement : placement
) )
}; };
@@ -195,13 +258,23 @@ export function reorderPlacement(
layout: DashboardLayoutState, layout: DashboardLayoutState,
sourceInstanceId: string, sourceInstanceId: string,
targetInstanceId: string, targetInstanceId: string,
edge: "before" | "after" edge: "before" | "after",
columnStart?: number
): DashboardLayoutState { ): DashboardLayoutState {
if (sourceInstanceId === targetInstanceId) return layout;
const source = layout.placements.find( const source = layout.placements.find(
(placement) => placement.instanceId === sourceInstanceId (placement) => placement.instanceId === sourceInstanceId
); );
if (!source) return layout; 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( const remaining = layout.placements.filter(
(placement) => placement.instanceId !== sourceInstanceId (placement) => placement.instanceId !== sourceInstanceId
); );
@@ -214,12 +287,46 @@ export function reorderPlacement(
...layout, ...layout,
placements: [ placements: [
...remaining.slice(0, insertionIndex), ...remaining.slice(0, insertionIndex),
source, movedSource,
...remaining.slice(insertionIndex) ...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( export function dashboardMasonryRowSpan(
height: number, height: number,
rowHeight: number, rowHeight: number,
@@ -311,29 +418,43 @@ export function widgetIsConfigurable(
function normalizePlacement( function normalizePlacement(
placement: DashboardWidgetPlacement, placement: DashboardWidgetPlacement,
widget: DashboardWidgetContribution | undefined widget: DashboardWidgetContribution | undefined,
fallbackColumnStart: number
): DashboardWidgetPlacement { ): DashboardWidgetPlacement {
if (!widget) return placement; const supported = widget ? supportedWidgetSizes(widget) : SIZES;
const supported = supportedWidgetSizes(widget); const size = supported.includes(placement.size)
? placement.size
: widget
? defaultWidgetSize(widget)
: "medium";
return { return {
...placement, ...placement,
size: supported.includes(placement.size) size,
? placement.size columnStart: normalizeDashboardColumnStart(
: defaultWidgetSize(widget), size,
placement.columnStart,
fallbackColumnStart
),
configuration: { configuration: {
...(widget.defaultConfiguration ?? {}), ...(widget?.defaultConfiguration ?? {}),
...placement.configuration ...placement.configuration
} }
}; };
} }
function placementFromResponse( function placementFromResponse(
placement: DashboardWidgetPlacementResponse placement: DashboardWidgetPlacementResponse,
fallbackColumnStart: number
): DashboardWidgetPlacement { ): DashboardWidgetPlacement {
return { return {
instanceId: placement.instance_id, instanceId: placement.instance_id,
widgetId: placement.widget_id, widgetId: placement.widget_id,
size: placement.size, size: placement.size,
columnStart: normalizeDashboardColumnStart(
placement.size,
placement.column_start,
fallbackColumnStart
),
configuration: { ...placement.configuration } configuration: { ...placement.configuration }
}; };
} }
@@ -345,6 +466,7 @@ function placementToResponse(
instance_id: placement.instanceId, instance_id: placement.instanceId,
widget_id: placement.widgetId, widget_id: placement.widgetId,
size: placement.size, size: placement.size,
column_start: placement.columnStart,
configuration: { ...placement.configuration } configuration: { ...placement.configuration }
}; };
} }
@@ -355,8 +477,9 @@ function parseStoredLayout(value: unknown): DashboardLayoutState | null {
if (!Array.isArray(candidate.placements) || !Array.isArray(candidate.knownWidgetIds)) { if (!Array.isArray(candidate.placements) || !Array.isArray(candidate.knownWidgetIds)) {
return null; return null;
} }
const placements = candidate.placements.flatMap((item) => { const placements: DashboardWidgetPlacement[] = [];
if (!item || typeof item !== "object") return []; for (const item of candidate.placements) {
if (!item || typeof item !== "object") continue;
const placement = item as Partial<DashboardWidgetPlacement>; const placement = item as Partial<DashboardWidgetPlacement>;
if ( if (
typeof placement.instanceId !== "string" typeof placement.instanceId !== "string"
@@ -366,15 +489,23 @@ function parseStoredLayout(value: unknown): DashboardLayoutState | null {
|| typeof placement.configuration !== "object" || typeof placement.configuration !== "object"
|| Array.isArray(placement.configuration) || Array.isArray(placement.configuration)
) { ) {
return []; continue;
} }
return [{ placements.push({
instanceId: placement.instanceId, instanceId: placement.instanceId,
widgetId: placement.widgetId, widgetId: placement.widgetId,
size: placement.size as DashboardWidgetSize, size: placement.size as DashboardWidgetSize,
columnStart: normalizeDashboardColumnStart(
placement.size as DashboardWidgetSize,
placement.columnStart,
nextDashboardColumnStart(
placements,
placement.size as DashboardWidgetSize
)
),
configuration: sanitizeConfiguration(placement.configuration) configuration: sanitizeConfiguration(placement.configuration)
}];
}); });
}
return { return {
layoutVersion: 1, layoutVersion: 1,
revision: typeof candidate.revision === "number" ? candidate.revision : 0, revision: typeof candidate.revision === "number" ? candidate.revision : 0,
@@ -394,14 +525,23 @@ function migrateLegacyLayout(
} }
const visible = new Set(uniqueStrings(candidate.visible)); const visible = new Set(uniqueStrings(candidate.visible));
const widgetById = new Map(widgets.map((widget) => [widget.id, widget])); 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 { return {
layoutVersion: 1, layoutVersion: 1,
revision: 0, revision: 0,
knownWidgetIds: uniqueStrings(candidate.known), knownWidgetIds: uniqueStrings(candidate.known),
placements: [...visible].flatMap((widgetId) => { placements
const widget = widgetById.get(widgetId);
return widget ? [createWidgetPlacement(widget)] : [];
})
}; };
} }

View File

@@ -23,6 +23,7 @@
position: relative; position: relative;
align-self: start; align-self: start;
margin-bottom: 18px; margin-bottom: 18px;
grid-column-start: var(--dashboard-column-start, auto);
} }
.dashboard-widget > .card { .dashboard-widget > .card {
@@ -31,16 +32,19 @@
} }
.dashboard-widget-small { .dashboard-widget-small {
grid-column: span 1; grid-column-end: span 1;
} }
.dashboard-widget-medium { .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 { .dashboard-widget-full {
grid-column: 1 / -1; grid-column-end: span 4;
} }
.dashboard-compact-list.detail-list div { .dashboard-compact-list.detail-list div {
@@ -318,6 +322,15 @@
grid-template-columns: repeat(2, minmax(220px, 1fr)); 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 { .dashboard-widget-library {
grid-template-columns: minmax(200px, 240px) minmax(0, 1fr) 36px; grid-template-columns: minmax(200px, 240px) minmax(0, 1fr) 36px;
} }

View File

@@ -2,13 +2,18 @@ import assert from "node:assert/strict";
import { import {
appendPlacement, appendPlacement,
dashboardMasonryRowSpan, dashboardMasonryRowSpan,
dashboardWidgetColumnSpan,
defaultDashboardLayout, defaultDashboardLayout,
layoutUpdatePayload, layoutUpdatePayload,
normalizeDashboardColumnStart,
reconcileDashboardLayout, reconcileDashboardLayout,
removePlacement, removePlacement,
reorderPlacement reorderPlacement
} from "../src/features/dashboard/dashboardLayout.ts"; } 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 = [ const widgets = [
{ {
@@ -36,6 +41,7 @@ const defaults = defaultDashboardLayout(widgets);
assert.deepEqual(defaults.knownWidgetIds, ["one", "two"]); assert.deepEqual(defaults.knownWidgetIds, ["one", "two"]);
assert.equal(defaults.placements.length, 1); assert.equal(defaults.placements.length, 1);
assert.equal(defaults.placements[0].widgetId, "one"); assert.equal(defaults.placements[0].widgetId, "one");
assert.equal(defaults.placements[0].columnStart, 1);
assert.deepEqual(defaults.placements[0].configuration, { count: 3 }); assert.deepEqual(defaults.placements[0].configuration, { count: 3 });
const withTwo = appendPlacement(defaults, widgets[1]); const withTwo = appendPlacement(defaults, widgets[1]);
@@ -43,6 +49,11 @@ assert.deepEqual(
withTwo.placements.map((placement) => placement.widgetId), withTwo.placements.map((placement) => placement.widgetId),
["one", "two"] ["one", "two"]
); );
assert.equal(
withTwo.placements[1].columnStart,
2,
"new widgets should use the next available horizontal position"
);
assert.equal( assert.equal(
appendPlacement(withTwo, widgets[1]).placements.length, appendPlacement(withTwo, widgets[1]).placements.length,
2, 2,
@@ -53,12 +64,19 @@ const reordered = reorderPlacement(
withTwo, withTwo,
withTwo.placements[1].instanceId, withTwo.placements[1].instanceId,
withTwo.placements[0].instanceId, withTwo.placements[0].instanceId,
"before" "before",
3
); );
assert.deepEqual( assert.deepEqual(
reordered.placements.map((placement) => placement.widgetId), reordered.placements.map((placement) => placement.widgetId),
["two", "one"] ["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); const removed = removePlacement(reordered, reordered.placements[0].instanceId);
assert.deepEqual( assert.deepEqual(
@@ -75,7 +93,8 @@ const placementPreview = dashboardGridPreview(
{ {
kind: "placement", kind: "placement",
instanceId: withTwo.placements[1].instanceId, instanceId: withTwo.placements[1].instanceId,
edge: "after" edge: "after",
columnStart: 3
} }
); );
assert.deepEqual( assert.deepEqual(
@@ -87,6 +106,13 @@ assert.deepEqual(
["two:false", "one:true"], ["two:false", "one:true"],
"placement previews must show the prospective order" "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( const cataloguePreview = dashboardGridPreview(
withTwo.placements, withTwo.placements,
@@ -94,7 +120,8 @@ const cataloguePreview = dashboardGridPreview(
{ {
kind: "placement", kind: "placement",
instanceId: withTwo.placements[0].instanceId, instanceId: withTwo.placements[0].instanceId,
edge: "after" edge: "after",
columnStart: 2
}, },
"wide" "wide"
); );
@@ -107,11 +134,19 @@ assert.deepEqual(
["one", "three:wide", "two"], ["one", "three:wide", "two"],
"catalogue previews must reserve the prospective widget size" "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 = { const unavailablePlacement = {
instanceId: "unavailable-instance", instanceId: "unavailable-instance",
widgetId: "temporarily-disabled", widgetId: "temporarily-disabled",
size: "wide" as const, size: "wide" as const,
columnStart: 2,
configuration: { mode: "summary" } configuration: { mode: "summary" }
}; };
const reconciled = reconcileDashboardLayout( const reconciled = reconcileDashboardLayout(
@@ -124,8 +159,8 @@ const reconciled = reconcileDashboardLayout(
widgets widgets
); );
assert.equal( assert.equal(
reconciled.placements[0], reconciled.placements[0].columnStart,
unavailablePlacement, 2,
"temporarily unavailable contributions must retain their placement" "temporarily unavailable contributions must retain their placement"
); );
assert.equal( assert.equal(
@@ -138,8 +173,29 @@ assert.equal(reconciled.revision, 4);
const payload = layoutUpdatePayload(reconciled); const payload = layoutUpdatePayload(reconciled);
assert.equal(payload.expected_revision, 4); assert.equal(payload.expected_revision, 4);
assert.equal(payload.placements[0].instance_id, "unavailable-instance"); 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(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 = { const repeatingWidget = {
...widgets[0], ...widgets[0],
id: "repeating", id: "repeating",