fix(dashboard): stabilize four-column widget placement
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -10,6 +10,7 @@ export type DashboardWidgetPlacementResponse = {
|
||||
instance_id: string;
|
||||
widget_id: string;
|
||||
size: DashboardWidgetSize;
|
||||
column_start?: number | null;
|
||||
configuration: DashboardWidgetConfiguration;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`dashboard-widget-grid${configuring ? " is-configuring" : ""}`}
|
||||
data-dashboard-grid
|
||||
onDragOver={(event) => {
|
||||
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({
|
||||
<DashboardMasonryItem
|
||||
key={placement.instanceId}
|
||||
className={`dashboard-widget dashboard-widget-${placement.size}`}
|
||||
style={dashboardColumnStyle(placement.columnStart)}
|
||||
onDragOver={
|
||||
configuring
|
||||
? (event) => 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<HTMLElement>) => void;
|
||||
onDrop: (event: ReactDragEvent<HTMLElement>) => 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 && (
|
||||
<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({
|
||||
children,
|
||||
...props
|
||||
|
||||
@@ -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<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.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();
|
||||
}
|
||||
|
||||
@@ -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"})`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>();
|
||||
const seenWidgets = new Set<string>();
|
||||
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<DashboardWidgetPlacement>;
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user