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)
|
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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"})`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)] : [];
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user