feat(dashboard): merge rearrange into the Customize modal
Two days, two modals, both touching widget layout - collapsed into
one. The separate "Rearrange" button + RearrangeWidgetsDialog from
54c5d0f are gone; the Customize modal now does both jobs:
- Two sections in the body: "On dashboard (N)" and "Hidden (N)"
- Visible rows are sortable (drag handle on the left, position number,
switch on the right). Single SortableContext, vertical strategy.
- Hidden rows are toggle-only (no drag handle - order doesn't matter
for off-dashboard widgets). Flipping the switch on appends to the
bottom of the visible section.
- Both visibility toggles and reorder commits optimistically via
useDashboardWidgets so the dashboard reflows in the background.
dashboard-shell: removes the Rearrange button + RearrangeWidgetsDialog
import + setOrder destructure. rearrange-widgets-dialog.tsx deleted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { LayoutGrid } from 'lucide-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, LayoutGrid } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -14,29 +31,64 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
|
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
|
||||||
|
import type { DashboardWidget } from './widget-registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modal widget picker for the dashboard header. Replaced the original
|
* Combined visibility + reorder picker for the dashboard header. Two
|
||||||
* dropdown menu because 13 widgets + 3 footer buttons made the dropdown
|
* sections in one modal:
|
||||||
* cramped and hid the descriptions reps need to know what each card
|
|
||||||
* actually shows.
|
|
||||||
*
|
*
|
||||||
* Backed by the same `useDashboardWidgets` hook that drives the
|
* 1. "On dashboard" — visible widgets, each row with a drag handle
|
||||||
* Settings card — toggles update both surfaces optimistically.
|
* (reorder via dnd-kit single SortableContext, no buckets); flipping
|
||||||
|
* a switch off moves the row to section 2.
|
||||||
|
* 2. "Hidden" — widgets currently off; flipping a switch on appends to
|
||||||
|
* the bottom of section 1.
|
||||||
|
*
|
||||||
|
* Both visibility toggles and order changes commit optimistically via
|
||||||
|
* `useDashboardWidgets` so the dashboard reflows in the background and
|
||||||
|
* the rep can keep editing. The "Rearrange" button on the header is
|
||||||
|
* gone — order lives here too now, keeping all dashboard layout
|
||||||
|
* controls in one place.
|
||||||
*/
|
*/
|
||||||
export function CustomizeWidgetsMenu() {
|
export function CustomizeWidgetsMenu() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { allWidgets, visibility, setVisible, setAll, resetToDefaults, isSaving } =
|
const {
|
||||||
useDashboardWidgets();
|
allWidgets,
|
||||||
|
visibleWidgets,
|
||||||
|
visibility,
|
||||||
|
setVisible,
|
||||||
|
setAll,
|
||||||
|
setOrder,
|
||||||
|
resetToDefaults,
|
||||||
|
isSaving,
|
||||||
|
} = useDashboardWidgets();
|
||||||
|
|
||||||
const visibleCount = Object.values(visibility).filter(Boolean).length;
|
const visibleCount = Object.values(visibility).filter(Boolean).length;
|
||||||
const allVisible = visibleCount === allWidgets.length;
|
const allVisible = visibleCount === allWidgets.length;
|
||||||
const allHidden = visibleCount === 0;
|
const allHidden = visibleCount === 0;
|
||||||
// Reset is a no-op when state already matches the registry defaults —
|
|
||||||
// disable in that case to avoid pointless API round-trips.
|
|
||||||
const matchesDefaults = allWidgets.every((w) => (visibility[w.id] ?? false) === w.defaultVisible);
|
const matchesDefaults = allWidgets.every((w) => (visibility[w.id] ?? false) === w.defaultVisible);
|
||||||
|
|
||||||
|
// Hidden = everything not currently rendered. Sorted by registry order
|
||||||
|
// so it reads predictably (newly-added widgets appear at the bottom
|
||||||
|
// until the rep explicitly enables them).
|
||||||
|
const hidden = allWidgets.filter((w) => !visibility[w.id]);
|
||||||
|
|
||||||
|
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 ids = visibleWidgets.map((w) => w.id);
|
||||||
|
const oldIndex = ids.indexOf(String(active.id));
|
||||||
|
const newIndex = ids.indexOf(String(over.id));
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return;
|
||||||
|
setOrder(arrayMove(ids, oldIndex, newIndex));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -49,40 +101,57 @@ export function CustomizeWidgetsMenu() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Customize dashboard</DialogTitle>
|
<DialogTitle>Customize dashboard</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Pick which analytics cards appear on your dashboard. Hidden cards leave no empty space -
|
Drag a visible widget to change its position. Toggle the switch to show or hide. Hidden
|
||||||
the layout reflows to fill the available width.
|
widgets leave no empty space - the layout reflows to fill the available width.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Toggle list. Capped at ~60vh with internal scroll so the modal
|
{/* Toggle + reorder list. Capped at ~60vh with internal scroll so
|
||||||
doesn't push the action footer off-screen on shorter viewports. */}
|
the modal doesn't push the action footer off-screen. */}
|
||||||
<div className="max-h-[60vh] -mx-2 overflow-y-auto px-2">
|
<div className="max-h-[60vh] -mx-2 overflow-y-auto px-2">
|
||||||
<div className="space-y-1 py-1">
|
{visibleWidgets.length > 0 ? (
|
||||||
{allWidgets.map((w) => (
|
<Section title={`On dashboard (${visibleWidgets.length})`}>
|
||||||
<label
|
<DndContext
|
||||||
key={w.id}
|
sensors={sensors}
|
||||||
className="flex cursor-pointer items-start justify-between gap-4 rounded-md px-3 py-2.5 hover:bg-accent/40"
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<SortableContext
|
||||||
<div className="text-sm font-medium text-foreground">{w.label}</div>
|
items={visibleWidgets.map((w) => w.id)}
|
||||||
<p className="text-xs text-muted-foreground">{w.description}</p>
|
strategy={verticalListSortingStrategy}
|
||||||
</div>
|
>
|
||||||
<Switch
|
<ul className="space-y-1">
|
||||||
aria-label={`Show ${w.label}`}
|
{visibleWidgets.map((w, idx) => (
|
||||||
checked={visibility[w.id] ?? false}
|
<SortableVisibleRow
|
||||||
disabled={isSaving}
|
key={w.id}
|
||||||
onCheckedChange={(checked) => setVisible(w.id, checked)}
|
widget={w}
|
||||||
className="mt-0.5 shrink-0"
|
position={idx + 1}
|
||||||
/>
|
disabled={isSaving}
|
||||||
</label>
|
onToggle={(checked) => setVisible(w.id, checked)}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
|
</ul>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hidden.length > 0 ? (
|
||||||
|
<Section title={`Hidden (${hidden.length})`}>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{hidden.map((w) => (
|
||||||
|
<HiddenRow
|
||||||
|
key={w.id}
|
||||||
|
widget={w}
|
||||||
|
disabled={isSaving}
|
||||||
|
onToggle={(checked) => setVisible(w.id, checked)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer: stacks vertically on mobile (counter row, secondary
|
|
||||||
buttons row, full-width primary "Done") so no button gets
|
|
||||||
orphaned beneath the others. Reverts to single inline row at
|
|
||||||
sm+ where there's space. */}
|
|
||||||
<DialogFooter className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
|
<DialogFooter className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
|
||||||
<span className="text-xs text-muted-foreground sm:order-first">
|
<span className="text-xs text-muted-foreground sm:order-first">
|
||||||
{visibleCount} of {allWidgets.length} visible
|
{visibleCount} of {allWidgets.length} visible
|
||||||
@@ -121,3 +190,90 @@ export function CustomizeWidgetsMenu() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="py-2 first:pt-1">
|
||||||
|
<div className="px-1 pb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableVisibleRow({
|
||||||
|
widget,
|
||||||
|
position,
|
||||||
|
disabled,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
widget: DashboardWidget;
|
||||||
|
position: number;
|
||||||
|
disabled: boolean;
|
||||||
|
onToggle: (checked: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: widget.id,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-2 rounded-md border bg-background px-2 py-2.5',
|
||||||
|
isDragging && 'opacity-60 shadow-md',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-0.5 inline-flex h-7 w-7 shrink-0 cursor-grab items-center justify-center rounded text-muted-foreground hover:bg-accent active:cursor-grabbing"
|
||||||
|
aria-label={`Drag handle for ${widget.label}`}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" aria-hidden />
|
||||||
|
</button>
|
||||||
|
<span className="mt-1 w-5 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
|
||||||
|
{position}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-foreground">{widget.label}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{widget.description}</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
aria-label={`Show ${widget.label}`}
|
||||||
|
checked
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={onToggle}
|
||||||
|
className="mt-0.5 shrink-0"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HiddenRow({
|
||||||
|
widget,
|
||||||
|
disabled,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
widget: DashboardWidget;
|
||||||
|
disabled: boolean;
|
||||||
|
onToggle: (checked: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li className="flex items-start gap-3 rounded-md px-3 py-2.5 hover:bg-accent/40">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-foreground">{widget.label}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{widget.description}</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
aria-label={`Show ${widget.label}`}
|
||||||
|
checked={false}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={onToggle}
|
||||||
|
className="mt-0.5 shrink-0"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,17 +2,14 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { 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';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { ExportDashboardPdfButton } from '@/components/reports/export-dashboard-pdf-button';
|
import { ExportDashboardPdfButton } from '@/components/reports/export-dashboard-pdf-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';
|
||||||
@@ -77,12 +74,8 @@ export function DashboardShell({
|
|||||||
initialWidgetVisibility,
|
initialWidgetVisibility,
|
||||||
}: DashboardShellProps = {}) {
|
}: DashboardShellProps = {}) {
|
||||||
const [range, setRange] = useState<DateRange>('30d');
|
const [range, setRange] = useState<DateRange>('30d');
|
||||||
// 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({
|
const { visibleWidgets } = useDashboardWidgets({
|
||||||
initialVisibility: initialWidgetVisibility ?? null,
|
initialVisibility: initialWidgetVisibility ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -178,17 +171,6 @@ export function DashboardShell({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DateRangePicker value={range} onChange={setRange} />
|
<DateRangePicker value={range} onChange={setRange} />
|
||||||
<ExportDashboardPdfButton />
|
<ExportDashboardPdfButton />
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setRearrangeOpen(true)}
|
|
||||||
title="Rearrange widgets"
|
|
||||||
disabled={visibleWidgets.length < 2}
|
|
||||||
>
|
|
||||||
<Move className="mr-1.5 h-4 w-4" aria-hidden />
|
|
||||||
Rearrange
|
|
||||||
</Button>
|
|
||||||
<CustomizeWidgetsMenu />
|
<CustomizeWidgetsMenu />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -243,13 +225,6 @@ export function DashboardShell({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{visibleWidgets.length === 0 ? <EmptyDashboardHint /> : null}
|
{visibleWidgets.length === 0 ? <EmptyDashboardHint /> : null}
|
||||||
|
|
||||||
<RearrangeWidgetsDialog
|
|
||||||
open={rearrangeOpen}
|
|
||||||
onOpenChange={setRearrangeOpen}
|
|
||||||
widgets={visibleWidgets}
|
|
||||||
onSave={(orderedIds) => setOrder(orderedIds)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
'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