feat(dashboard): replace in-place widget drag with modal sortable list
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 <RearrangeWidgetsDialog>: 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 <WidgetCell>. - 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 fromee4d5c8still applies — the dialog's Save calls the same setOrder() that PATCHes preferences. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,25 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import { Move } from 'lucide-react';
|
||||||
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 { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { CustomizeWidgetsMenu } from './customize-widgets-menu';
|
import { CustomizeWidgetsMenu } from './customize-widgets-menu';
|
||||||
import { DateRangePicker } from './date-range-picker';
|
import { DateRangePicker } from './date-range-picker';
|
||||||
|
import { RearrangeWidgetsDialog } from './rearrange-widgets-dialog';
|
||||||
import { TimezoneDriftBanner } from './timezone-drift-banner';
|
import { TimezoneDriftBanner } from './timezone-drift-banner';
|
||||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||||
import type { DashboardWidget } from './widget-registry';
|
import type { DashboardWidget } from './widget-registry';
|
||||||
@@ -94,10 +77,10 @@ export function DashboardShell({
|
|||||||
initialWidgetVisibility,
|
initialWidgetVisibility,
|
||||||
}: DashboardShellProps = {}) {
|
}: DashboardShellProps = {}) {
|
||||||
const [range, setRange] = useState<DateRange>('30d');
|
const [range, setRange] = useState<DateRange>('30d');
|
||||||
// Rearrange mode — flipped via the Move button in the actions row.
|
// Rearrange dialog — the in-place drag scaffolding was retired in
|
||||||
// While on, every WidgetCell renders a drag handle and dragging
|
// favour of a modal sortable list (single SortableContext, no bucket
|
||||||
// reorders within the group (chart / rail / feed).
|
// constraints, keyboard-accessible). See rearrange-widgets-dialog.tsx.
|
||||||
const [rearranging, setRearranging] = useState(false);
|
const [rearrangeOpen, setRearrangeOpen] = useState(false);
|
||||||
|
|
||||||
const { visibleWidgets, setOrder } = useDashboardWidgets({
|
const { visibleWidgets, setOrder } = useDashboardWidgets({
|
||||||
initialVisibility: initialWidgetVisibility ?? null,
|
initialVisibility: initialWidgetVisibility ?? null,
|
||||||
@@ -107,11 +90,6 @@ export function DashboardShell({
|
|||||||
// keyboard sensor wires arrow keys for accessibility. activationConstraint
|
// keyboard sensor wires arrow keys for accessibility. activationConstraint
|
||||||
// requires an 8px drag distance before activating so a click on a child
|
// requires an 8px drag distance before activating so a click on a child
|
||||||
// doesn't accidentally start a drag.
|
// 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
|
// Bucket once so the JSX stays readable. Registry order is preserved
|
||||||
// inside each bucket, so reordering the registry reorders the render.
|
// inside each bucket, so reordering the registry reorders the render.
|
||||||
const charts = visibleWidgets.filter((w) => w.group === 'chart');
|
const charts = visibleWidgets.filter((w) => w.group === 'chart');
|
||||||
@@ -203,12 +181,13 @@ export function DashboardShell({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={rearranging ? 'default' : 'outline'}
|
variant="outline"
|
||||||
onClick={() => setRearranging((r) => !r)}
|
onClick={() => setRearrangeOpen(true)}
|
||||||
title={rearranging ? 'Finish rearranging' : 'Rearrange widgets'}
|
title="Rearrange widgets"
|
||||||
|
disabled={visibleWidgets.length < 2}
|
||||||
>
|
>
|
||||||
<Move className="mr-1.5 h-4 w-4" aria-hidden />
|
<Move className="mr-1.5 h-4 w-4" aria-hidden />
|
||||||
{rearranging ? 'Done' : 'Rearrange'}
|
Rearrange
|
||||||
</Button>
|
</Button>
|
||||||
<CustomizeWidgetsMenu />
|
<CustomizeWidgetsMenu />
|
||||||
</div>
|
</div>
|
||||||
@@ -232,80 +211,45 @@ export function DashboardShell({
|
|||||||
the row; the rails-only grid uses a slightly tighter `280px`
|
the row; the rails-only grid uses a slightly tighter `280px`
|
||||||
minimum so KPI tiles + rails fit 3-4 across on a wide viewport
|
minimum so KPI tiles + rails fit 3-4 across on a wide viewport
|
||||||
instead of stretching to 600px+ each. */}
|
instead of stretching to 600px+ each. */}
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={(event: DragEndEvent) => {
|
|
||||||
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 ? (
|
{charts.length > 0 && rails.length > 0 ? (
|
||||||
<div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]">
|
<div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
<SortableContext items={charts.map((w) => w.id)} strategy={rectSortingStrategy}>
|
|
||||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
|
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
|
||||||
{charts.map((w) => (
|
{charts.map((w) => (
|
||||||
<SortableWidget key={w.id} widget={w} range={range} showHandle={rearranging} />
|
<WidgetCell key={w.id} widget={w} range={range} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
|
||||||
<aside className="min-w-0 space-y-4">
|
<aside className="min-w-0 space-y-4">
|
||||||
<SortableContext
|
|
||||||
items={rails.map((w) => w.id)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{rails.map((w) => (
|
{rails.map((w) => (
|
||||||
<SortableWidget key={w.id} widget={w} range={range} showHandle={rearranging} />
|
<WidgetCell key={w.id} widget={w} range={range} />
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
) : charts.length > 0 ? (
|
) : charts.length > 0 ? (
|
||||||
<SortableContext items={charts.map((w) => w.id)} strategy={rectSortingStrategy}>
|
|
||||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
|
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
|
||||||
{charts.map((w) => (
|
{charts.map((w) => (
|
||||||
<SortableWidget key={w.id} widget={w} range={range} showHandle={rearranging} />
|
<WidgetCell key={w.id} widget={w} range={range} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
|
||||||
) : rails.length > 0 ? (
|
) : rails.length > 0 ? (
|
||||||
<SortableContext items={rails.map((w) => w.id)} strategy={rectSortingStrategy}>
|
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(280px,1fr))]">
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(280px,1fr))]">
|
||||||
{rails.map((w) => (
|
{rails.map((w) => (
|
||||||
<SortableWidget key={w.id} widget={w} range={range} showHandle={rearranging} />
|
<WidgetCell key={w.id} widget={w} range={range} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<SortableContext items={feed.map((w) => w.id)} strategy={verticalListSortingStrategy}>
|
|
||||||
{feed.map((w) => (
|
{feed.map((w) => (
|
||||||
<SortableWidget key={w.id} widget={w} range={range} showHandle={rearranging} />
|
<WidgetCell key={w.id} widget={w} range={range} />
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
|
|
||||||
{visibleWidgets.length === 0 ? <EmptyDashboardHint /> : null}
|
{visibleWidgets.length === 0 ? <EmptyDashboardHint /> : null}
|
||||||
|
|
||||||
|
<RearrangeWidgetsDialog
|
||||||
|
open={rearrangeOpen}
|
||||||
|
onOpenChange={setRearrangeOpen}
|
||||||
|
widgets={visibleWidgets}
|
||||||
|
onSave={(orderedIds) => setOrder(orderedIds)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -329,45 +273,10 @@ function EmptyDashboardHint() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sortable wrapper around the widget render. Renders the widget as-is when
|
* Plain widget cell. The rearrange affordance lives in
|
||||||
* rearrange mode is off (zero footprint); when on, attaches the dnd-kit
|
* `<RearrangeWidgetsDialog>` (modal-driven), so individual cards no
|
||||||
* sortable hooks and exposes a grip handle in the top-right corner.
|
* longer need a per-cell drag handle or sortable wiring.
|
||||||
* The handle is the only drag activator so a rep can still click inside
|
|
||||||
* the widget without accidentally starting a drag.
|
|
||||||
*/
|
*/
|
||||||
function SortableWidget({
|
function WidgetCell({ widget, range }: { widget: DashboardWidget; range: DateRange }) {
|
||||||
widget,
|
return <WidgetErrorBoundary>{widget.render(range)}</WidgetErrorBoundary>;
|
||||||
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 (
|
|
||||||
<div ref={setNodeRef} style={style} className="relative">
|
|
||||||
{showHandle ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
aria-label={`Drag to reorder ${widget.label}`}
|
|
||||||
className="absolute right-2 top-2 z-10 inline-flex h-7 w-7 cursor-grab items-center justify-center rounded-md border bg-background/80 text-muted-foreground shadow-sm backdrop-blur hover:text-foreground active:cursor-grabbing"
|
|
||||||
>
|
|
||||||
<GripVertical className="h-4 w-4" aria-hidden />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<WidgetErrorBoundary>{widget.render(range)}</WidgetErrorBoundary>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
147
src/components/dashboard/rearrange-widgets-dialog.tsx
Normal file
147
src/components/dashboard/rearrange-widgets-dialog.tsx
Normal file
@@ -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<string[]>(() => 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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Rearrange dashboard widgets</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Drag a row to change its position. The new order applies after you save.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="max-h-[60vh] overflow-y-auto rounded-md border bg-muted/30 p-1.5">
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||||
|
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{ids.map((id, idx) => {
|
||||||
|
const widget = widgetById.get(id);
|
||||||
|
if (!widget) return null;
|
||||||
|
return <Row key={id} id={id} index={idx} label={widget.label} />;
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>Save order</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ id, index, label }: { id: string; index: number; label: string }) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md border bg-background px-3 py-2 text-sm',
|
||||||
|
isDragging && 'opacity-60 shadow-md',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-7 w-7 cursor-grab items-center justify-center rounded text-muted-foreground hover:bg-accent active:cursor-grabbing"
|
||||||
|
aria-label={`Drag handle for ${label}`}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" aria-hidden />
|
||||||
|
</button>
|
||||||
|
<span className="w-6 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="truncate font-medium">{label}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user