diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 0a5c09176..a52a1d4bb 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -300,12 +300,71 @@ export function DashboardLayoutSection({ id }: { id: string }) { setDraft({ ...layout, heroVisible: value }); } + /** + * v1.27.8 — the ring choices apply INSTANTLY, outside the draft/Save + * machine the rest of the section keeps. The first cut routed ring + * toggles through the draft, and the combination of an unflushed + * draft, the server snapshot cache, and client staleness read as + * "flipping does nothing, rings appear later". The mutation PUTs the + * SERVER layout copy with only `selectedScoreRings` changed (an open + * draft's other edits stay unsent and untouched), optimistically + * updates the widgets query, and invalidates the dashboard snapshot on + * settle so the hero reflects the choice on the next visit. + */ + const ringMutation = useMutation({ + mutationFn: async (next: ScoreRingId[]) => { + if (!remote) throw new Error("layout not loaded"); + return apiPut("/api/dashboard/widgets", { + ...remote, + selectedScoreRings: next, + }); + }, + onMutate: async (next) => { + await queryClient.cancelQueries({ + queryKey: queryKeys.dashboardWidgets(), + }); + const previous = queryClient.getQueryData( + queryKeys.dashboardWidgets(), + ); + if (previous) { + queryClient.setQueryData(queryKeys.dashboardWidgets(), { + ...previous, + selectedScoreRings: next, + }); + } + return { previous }; + }, + onError: (_err, _next, context) => { + if (context?.previous) { + queryClient.setQueryData( + queryKeys.dashboardWidgets(), + context.previous, + ); + } + toast.error(t("dashboard.layoutSaveError")); + }, + onSuccess: (saved) => { + queryClient.setQueryData(queryKeys.dashboardWidgets(), saved); + queryClient.invalidateQueries({ + queryKey: queryKeys.dashboardSnapshot(), + }); + }, + }); + + /** Apply a new ring selection/order instantly (see `ringMutation`). */ + function applyScoreRings(next: ScoreRingId[]) { + if (!remote) return; + // Keep an open draft's ring slice in sync so a later Save of the + // OTHER layout edits can't revert the instantly-applied choice. + if (draft) setDraft({ ...draft, selectedScoreRings: next }); + ringMutation.mutate(next); + } + /** * v1.27.7 — toggle one hero score ring. Selection order is preserved * (a newly-enabled ring appends), the count is capped at * `MAX_SELECTED_SCORE_RINGS` (the remaining switches disable at the - * cap), and the choice persists as `selectedScoreRings` on the layout - * blob through the same PUT the widget toggles use. + * cap). v1.27.8 — applies instantly via `applyScoreRings`. */ function toggleScoreRing(ringId: ScoreRingId, enabled: boolean) { if (!layout) return; @@ -315,7 +374,37 @@ export function DashboardLayoutSection({ id }: { id: string }) { ? current : [...current, ringId].slice(0, MAX_SELECTED_SCORE_RINGS) : current.filter((id) => id !== ringId); - setDraft({ ...layout, selectedScoreRings: next }); + applyScoreRings(next); + } + + /** + * v1.27.8 — reorder the selected rings (the array order IS the render + * order on the hero; the server preserves it). Same interaction pair + * as the widget rows below: drag handle + arrow buttons. + */ + function moveScoreRing(ringId: ScoreRingId, delta: -1 | 1) { + if (!layout) return; + const current = layout.selectedScoreRings ?? []; + const idx = current.indexOf(ringId); + const target = idx + delta; + if (idx < 0 || target < 0 || target >= current.length) return; + const next = [...current]; + [next[idx], next[target]] = [next[target], next[idx]]; + applyScoreRings(next); + } + + function handleRingDragEnd(event: DragEndEvent) { + if (!layout) return; + const { active, over } = event; + if (!over || active.id === over.id) return; + const current = layout.selectedScoreRings ?? []; + const from = current.indexOf(active.id as ScoreRingId); + const to = current.indexOf(over.id as ScoreRingId); + if (from < 0 || to < 0) return; + const next = [...current]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + applyScoreRings(next); } /** @@ -480,7 +569,10 @@ export function DashboardLayoutSection({ id }: { id: string }) { module is enabled — the WIDGET_MODULE_BY_ID gating pattern; the three derived rings additionally sit behind the insights module, mirroring the derived routes. Selection caps at - MAX_SELECTED_SCORE_RINGS; unchecked switches disable at the cap. */} + MAX_SELECTED_SCORE_RINGS; unchecked switches disable at the cap. + v1.27.8 — choices apply instantly (no Save round) and the + selected rings reorder with the same drag-handle + arrow pair + as the widget rows below; the array order is the hero order. */} {layout && layout.heroVisible === true && (
-
- {SCORE_RING_IDS.filter((ringId) => { + {(() => { + const available = SCORE_RING_IDS.filter((ringId) => { if (modules?.[SCORE_RING_MODULE[ringId]] === false) return false; if (ringId !== "MED_COMPLIANCE" && modules?.insights === false) return false; return true; - }).map((ringId) => { - const selected = layout.selectedScoreRings ?? []; - const checked = selected.includes(ringId); - const atCap = - !checked && selected.length >= MAX_SELECTED_SCORE_RINGS; - return ( -
available.includes(ringId), + ); + const unselected = available.filter( + (ringId) => !selected.includes(ringId), + ); + const atCap = selected.length >= MAX_SELECTED_SCORE_RINGS; + const busy = ringMutation.isPending; + return ( +
+ - - {t(SCORE_RING_LABEL_KEYS[ringId])} - - toggleScoreRing(ringId, v)} - disabled={saveMutation.isPending || atCap} - aria-label={t(SCORE_RING_LABEL_KEYS[ringId])} - data-slot="score-ring-switch" - data-ring={ringId} - /> -
- ); - })} -
+ + {selected.map((ringId, index) => ( + + ))} + + + {unselected.map((ringId) => ( +
+ + {t(SCORE_RING_LABEL_KEYS[ringId])} + + toggleScoreRing(ringId, v)} + disabled={busy || atCap} + aria-label={t(SCORE_RING_LABEL_KEYS[ringId])} + data-slot="score-ring-switch" + data-ring={ringId} + /> +
+ ))} +
+ ); + })()} )} @@ -838,3 +964,110 @@ function SortableWidgetRow({ ); } + +/** + * v1.27.8 — sortable row for one SELECTED hero score ring. Mirrors the + * `SortableWidgetRow` interaction exactly (drag handle + arrow buttons + + * switch) so the two lists in this section share one sorting language; + * only the payload differs — the ring order is the `selectedScoreRings` + * array itself, applied instantly through the ring mutation. + */ +function SortableRingRow({ + ringId, + label, + index, + total, + dragHintId, + disabled, + moveUpLabel, + moveDownLabel, + dragHandleLabel, + onToggle, + onMove, +}: { + ringId: ScoreRingId; + label: string; + index: number; + total: number; + dragHintId: string; + disabled: boolean; + moveUpLabel: string; + moveDownLabel: string; + dragHandleLabel: string; + onToggle: (id: ScoreRingId, value: boolean) => void; + onMove: (id: ScoreRingId, delta: -1 | 1) => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: ringId }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition: prefersReducedMotion() ? "none" : transition, + }; + + return ( +
+ + + {label} + + onToggle(ringId, v)} + disabled={disabled} + aria-label={label} + data-slot="score-ring-switch" + data-ring={ringId} + /> + + +
+ ); +}