From 8c22d66dd98e6e51bca9aeebab20d98e45d11d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 18:06:51 +0200 Subject: [PATCH] feat(settings): apply hero ring choices instantly and make the selection sortable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ring toggles rode the section's draft/Save machine; combined with the server snapshot cache and client staleness this read as 'flipping does nothing, rings appear later'. Ring choices now bypass the draft: a dedicated mutation PUTs the server layout copy with only selectedScoreRings changed (an open draft's other edits stay unsent, its ring slice is kept in sync), optimistically updates the widgets query, rolls back on error, and invalidates the dashboard snapshot on success. The other layout controls keep the draft/Save flow. Selected rings are now reorderable with the same drag-handle + arrow interaction as the widget rows below — the array order is the hero render order, which the server already preserves. Unselected rings stay plain switch rows and disable at the three-ring cap. --- .../settings/dashboard-layout-section.tsx | 293 ++++++++++++++++-- 1 file changed, 263 insertions(+), 30 deletions(-) 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} + /> + + +
+ ); +}