fix(dashboard): stabilize four-column widget placement

This commit is contained in:
2026-07-30 15:36:59 +02:00
parent f0732bead2
commit 49afc6d73d
10 changed files with 491 additions and 72 deletions
+87 -17
View File
@@ -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();
}