From 43719b49e94aec0dc992eccc929573e23040da44 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 15:54:41 +0200 Subject: [PATCH] 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) --- .../dashboard/customize-widgets-menu.tsx | 232 +++++++++++++++--- src/components/dashboard/dashboard-shell.tsx | 27 +- .../dashboard/rearrange-widgets-dialog.tsx | 147 ----------- 3 files changed, 195 insertions(+), 211 deletions(-) delete mode 100644 src/components/dashboard/rearrange-widgets-dialog.tsx diff --git a/src/components/dashboard/customize-widgets-menu.tsx b/src/components/dashboard/customize-widgets-menu.tsx index ae7bf57a..eb656310 100644 --- a/src/components/dashboard/customize-widgets-menu.tsx +++ b/src/components/dashboard/customize-widgets-menu.tsx @@ -1,7 +1,24 @@ 'use client'; 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 { @@ -14,29 +31,64 @@ import { DialogTrigger, } from '@/components/ui/dialog'; import { Switch } from '@/components/ui/switch'; +import { cn } from '@/lib/utils'; import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets'; +import type { DashboardWidget } from './widget-registry'; /** - * Modal widget picker for the dashboard header. Replaced the original - * dropdown menu because 13 widgets + 3 footer buttons made the dropdown - * cramped and hid the descriptions reps need to know what each card - * actually shows. + * Combined visibility + reorder picker for the dashboard header. Two + * sections in one modal: * - * Backed by the same `useDashboardWidgets` hook that drives the - * Settings card — toggles update both surfaces optimistically. + * 1. "On dashboard" — visible widgets, each row with a drag handle + * (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() { const [open, setOpen] = useState(false); - const { allWidgets, visibility, setVisible, setAll, resetToDefaults, isSaving } = - useDashboardWidgets(); + const { + allWidgets, + visibleWidgets, + visibility, + setVisible, + setAll, + setOrder, + resetToDefaults, + isSaving, + } = useDashboardWidgets(); const visibleCount = Object.values(visibility).filter(Boolean).length; const allVisible = visibleCount === allWidgets.length; 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); + // 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 ( @@ -49,40 +101,57 @@ export function CustomizeWidgetsMenu() { Customize dashboard - Pick which analytics cards appear on your dashboard. Hidden cards leave no empty space - - the layout reflows to fill the available width. + Drag a visible widget to change its position. Toggle the switch to show or hide. Hidden + widgets leave no empty space - the layout reflows to fill the available width. - {/* Toggle list. Capped at ~60vh with internal scroll so the modal - doesn't push the action footer off-screen on shorter viewports. */} + {/* Toggle + reorder list. Capped at ~60vh with internal scroll so + the modal doesn't push the action footer off-screen. */}
-
- {allWidgets.map((w) => ( -
+ w.id)} + strategy={verticalListSortingStrategy} + > +
    + {visibleWidgets.map((w, idx) => ( + setVisible(w.id, checked)} + /> + ))} +
+
+ + + ) : null} + + {hidden.length > 0 ? ( +
+
    + {hidden.map((w) => ( + setVisible(w.id, checked)} + /> + ))} +
+
+ ) : null}
- {/* 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. */} {visibleCount} of {allWidgets.length} visible @@ -121,3 +190,90 @@ export function CustomizeWidgetsMenu() {
); } + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+ {title} +
+ {children} +
+ ); +} + +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 ( +
  • + + + {position} + +
    +
    {widget.label}
    +

    {widget.description}

    +
    + +
  • + ); +} + +function HiddenRow({ + widget, + disabled, + onToggle, +}: { + widget: DashboardWidget; + disabled: boolean; + onToggle: (checked: boolean) => void; +}) { + return ( +
  • +
    +
    {widget.label}
    +

    {widget.description}

    +
    + +
  • + ); +} diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index 1e36390c..bded6d70 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -2,17 +2,14 @@ import { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Move } from 'lucide-react'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets'; import { apiFetch } from '@/lib/api/client'; import { PageHeader } from '@/components/shared/page-header'; import { ExportDashboardPdfButton } from '@/components/reports/export-dashboard-pdf-button'; -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'; @@ -77,12 +74,8 @@ export function DashboardShell({ initialWidgetVisibility, }: DashboardShellProps = {}) { const [range, setRange] = useState('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, }); @@ -178,17 +171,6 @@ export function DashboardShell({
    -
    } @@ -243,13 +225,6 @@ export function DashboardShell({ ))} {visibleWidgets.length === 0 ? : null} - - setOrder(orderedIds)} - /> ); } diff --git a/src/components/dashboard/rearrange-widgets-dialog.tsx b/src/components/dashboard/rearrange-widgets-dialog.tsx deleted file mode 100644 index 4d67a808..00000000 --- a/src/components/dashboard/rearrange-widgets-dialog.tsx +++ /dev/null @@ -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(() => 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} -
  • - ); -}