) => 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",