From 54c5d0ff1e74fea73129fbb842cc29cca59c663b Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 15:49:47 +0200 Subject: [PATCH] feat(dashboard): replace in-place widget drag with modal sortable list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-place drag (N46 / a147cbc) had two failure modes: - Bucket constraints: each layout group (charts / rails / feed) was its own SortableContext; drops outside the active group silently no-op'd, so any cross-region drag did nothing. - Long drags lost their drop target: dnd-kit's closestCenter collision detection on a sparse grid would intermittently null out `over` mid-drag, which presented as the dragged tile snapping back to its original slot. Switched to a single-flat-list modal: - New : opens from the "Rearrange" button, shows every visible widget as a row with a drag handle and a position number, single vertical SortableContext, Save commits. - Dashboard shell strips the DndContext + per-bucket SortableContext wrappers + the SortableWidget cell + all dnd-kit imports related to the canvas drag. Each widget renders as a plain . - Rearrange button now opens the dialog instead of toggling a drag mode. Disabled when there's fewer than 2 visible widgets. The drag persistence fix from ee4d5c8 still applies — the dialog's Save calls the same setOrder() that PATCHes preferences. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/dashboard/dashboard-shell.tsx | 193 +++++------------- .../dashboard/rearrange-widgets-dialog.tsx | 147 +++++++++++++ 2 files changed, 198 insertions(+), 142 deletions(-) create mode 100644 src/components/dashboard/rearrange-widgets-dialog.tsx diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index be030630..1e36390c 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -2,25 +2,7 @@ import { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { - DndContext, - KeyboardSensor, - PointerSensor, - closestCenter, - useSensor, - useSensors, - type DragEndEvent, -} from '@dnd-kit/core'; -import { - SortableContext, - arrayMove, - rectSortingStrategy, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { GripVertical, Move } from 'lucide-react'; +import { Move } from 'lucide-react'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets'; @@ -30,6 +12,7 @@ import { ExportDashboardPdfButton } from '@/components/reports/export-dashboard- import { Button } from '@/components/ui/button'; import { CustomizeWidgetsMenu } from './customize-widgets-menu'; import { DateRangePicker } from './date-range-picker'; +import { RearrangeWidgetsDialog } from './rearrange-widgets-dialog'; import { TimezoneDriftBanner } from './timezone-drift-banner'; import { WidgetErrorBoundary } from './widget-error-boundary'; import type { DashboardWidget } from './widget-registry'; @@ -94,10 +77,10 @@ export function DashboardShell({ initialWidgetVisibility, }: DashboardShellProps = {}) { const [range, setRange] = useState('30d'); - // Rearrange mode — flipped via the Move button in the actions row. - // While on, every WidgetCell renders a drag handle and dragging - // reorders within the group (chart / rail / feed). - const [rearranging, setRearranging] = useState(false); + // Rearrange dialog — the in-place drag scaffolding was retired in + // favour of a modal sortable list (single SortableContext, no bucket + // constraints, keyboard-accessible). See rearrange-widgets-dialog.tsx. + const [rearrangeOpen, setRearrangeOpen] = useState(false); const { visibleWidgets, setOrder } = useDashboardWidgets({ initialVisibility: initialWidgetVisibility ?? null, @@ -107,11 +90,6 @@ export function DashboardShell({ // keyboard sensor wires arrow keys for accessibility. activationConstraint // requires an 8px drag distance before activating so a click on a child // doesn't accidentally start a drag. - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), - ); - // Bucket once so the JSX stays readable. Registry order is preserved // inside each bucket, so reordering the registry reorders the render. const charts = visibleWidgets.filter((w) => w.group === 'chart'); @@ -203,12 +181,13 @@ export function DashboardShell({ @@ -232,80 +211,45 @@ export function DashboardShell({ the row; the rails-only grid uses a slightly tighter `280px` minimum so KPI tiles + rails fit 3-4 across on a wide viewport instead of stretching to 600px+ each. */} - { - const { active, over } = event; - if (!over || active.id === over.id) return; - // Determine which group this drag is inside (charts / rails / - // feed) by matching the active id against the bucket lists. - // dnd-kit only triggers onDragEnd when the drop lands inside - // the same SortableContext, so it's safe to assume both ids - // share a bucket. - for (const bucket of [charts, rails, feed]) { - const oldIndex = bucket.findIndex((w) => w.id === active.id); - const newIndex = bucket.findIndex((w) => w.id === over.id); - if (oldIndex === -1 || newIndex === -1) continue; - // Build the new full-list order from the reordered bucket - // plus the other buckets (preserving their order). Persist. - const reordered = arrayMove(bucket, oldIndex, newIndex); - const otherBuckets = [charts, rails, feed].filter((b) => b !== bucket); - const nextOrder = [ - ...reordered.map((w) => w.id), - ...otherBuckets.flatMap((b) => b.map((w) => w.id)), - ]; - setOrder(nextOrder); - break; - } - }} - > - {charts.length > 0 && rails.length > 0 ? ( -
- w.id)} strategy={rectSortingStrategy}> -
- {charts.map((w) => ( - - ))} -
-
- + {charts.length > 0 && rails.length > 0 ? ( +
+
+ {charts.map((w) => ( + + ))}
- ) : charts.length > 0 ? ( - w.id)} strategy={rectSortingStrategy}> -
- {charts.map((w) => ( - - ))} -
-
- ) : rails.length > 0 ? ( - w.id)} strategy={rectSortingStrategy}> -
- {rails.map((w) => ( - - ))} -
-
- ) : null} - - w.id)} strategy={verticalListSortingStrategy}> - {feed.map((w) => ( - + +
+ ) : charts.length > 0 ? ( +
+ {charts.map((w) => ( + ))} - - +
+ ) : rails.length > 0 ? ( +
+ {rails.map((w) => ( + + ))} +
+ ) : null} + + {feed.map((w) => ( + + ))} {visibleWidgets.length === 0 ? : null} + + setOrder(orderedIds)} + />
); } @@ -329,45 +273,10 @@ function EmptyDashboardHint() { } /** - * Sortable wrapper around the widget render. Renders the widget as-is when - * rearrange mode is off (zero footprint); when on, attaches the dnd-kit - * sortable hooks and exposes a grip handle in the top-right corner. - * The handle is the only drag activator so a rep can still click inside - * the widget without accidentally starting a drag. + * Plain widget cell. The rearrange affordance lives in + * `` (modal-driven), so individual cards no + * longer need a per-cell drag handle or sortable wiring. */ -function SortableWidget({ - widget, - range, - showHandle, -}: { - widget: DashboardWidget; - range: DateRange; - showHandle: boolean; -}) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: widget.id, - disabled: !showHandle, - }); - const style = { - transform: CSS.Transform.toString(transform), - transition, - zIndex: isDragging ? 50 : undefined, - opacity: isDragging ? 0.6 : undefined, - }; - return ( -
- {showHandle ? ( - - ) : null} - {widget.render(range)} -
- ); +function WidgetCell({ widget, range }: { widget: DashboardWidget; range: DateRange }) { + return {widget.render(range)}; } diff --git a/src/components/dashboard/rearrange-widgets-dialog.tsx b/src/components/dashboard/rearrange-widgets-dialog.tsx new file mode 100644 index 00000000..4d67a808 --- /dev/null +++ b/src/components/dashboard/rearrange-widgets-dialog.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + SortableContext, + arrayMove, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { GripVertical } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { cn } from '@/lib/utils'; +import type { DashboardWidget } from './widget-registry'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + widgets: readonly DashboardWidget[]; + onSave: (orderedIds: string[]) => void; +} + +/** + * Single-flat-list reorder dialog. Replaces the in-place drag-on-canvas + * that struggled with cross-bucket drops + dnd-kit's collision-detection + * losing the drop target on long drags. Here the list is one + * SortableContext, vertically arranged, with a drag handle per row — + * predictable behaviour, keyboard-accessible (arrow keys with focus on + * a handle), no bucket constraints to learn. + */ +export function RearrangeWidgetsDialog({ open, onOpenChange, widgets, onSave }: Props) { + // Local draft so the rep can cancel without committing. Seeded each + // time the dialog opens so a re-open after an external order change + // (e.g. another tab) doesn't show stale data. + const [ids, setIds] = useState(() => widgets.map((w) => w.id)); + + useEffect(() => { + if (open) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setIds(widgets.map((w) => w.id)); + } + }, [open, widgets]); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + function onDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = ids.indexOf(String(active.id)); + const newIndex = ids.indexOf(String(over.id)); + if (oldIndex === -1 || newIndex === -1) return; + setIds((prev) => arrayMove(prev, oldIndex, newIndex)); + } + + function handleSave() { + onSave(ids); + onOpenChange(false); + } + + // Lookup label by id so the row renders the same label as the widget + // registry exposes — keeps the dialog in sync if labels change. + const widgetById = new Map(widgets.map((w) => [w.id, w])); + + return ( + + + + Rearrange dashboard widgets + + Drag a row to change its position. The new order applies after you save. + + +
+ + +
    + {ids.map((id, idx) => { + const widget = widgetById.get(id); + if (!widget) return null; + return ; + })} +
+
+
+
+ + + + +
+
+ ); +} + +function Row({ id, index, label }: { id: string; index: number; label: string }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + }); + return ( +
  • + + + {index + 1} + + {label} +
  • + ); +}