mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(insights): add inline edit mode to reorder and hide overview cards
This commit is contained in:
1 parent
e3539d7c94
commit
9b13b41f1a
10 files changed
+915
-11
No files matched your search
@@ -1584,6 +1584,24 @@
|
||||
"emptyTitle": "Noch nicht genug Daten",
|
||||
"emptyDescription": "Insights erscheinen, sobald du ein paar Messwerte erfasst hast. Beginne mit Gewicht oder Blutdruck.",
|
||||
"emptyAddMeasurement": "Messwert erfassen",
|
||||
"editMode": {
|
||||
"open": "Anpassen",
|
||||
"done": "Fertig",
|
||||
"reset": "Zurücksetzen",
|
||||
"title": "Übersicht anpassen",
|
||||
"description": "Zum Sortieren ziehen, zum Ein- oder Ausblenden auf das Auge tippen.",
|
||||
"dragHandle": "Zum Sortieren ziehen",
|
||||
"show": "Einblenden",
|
||||
"hide": "Ausblenden",
|
||||
"manageTiles": "Kacheln verwalten",
|
||||
"gatedHint": "Erscheint, sobald Daten oder die Funktion verfügbar sind",
|
||||
"allHiddenHint": "Alle Bereiche sind ausgeblendet. Blende mindestens einen ein, um deine Übersicht zu sehen.",
|
||||
"saveSuccess": "Übersicht gespeichert",
|
||||
"saveError": "Übersicht konnte nicht gespeichert werden",
|
||||
"resetSuccess": "Übersicht auf Standard zurückgesetzt",
|
||||
"emptyTitle": "Alle Karten ausgeblendet",
|
||||
"emptyDescription": "Du hast alle Bereiche ausgeblendet. Öffne „Anpassen“, um Karten wieder einzublenden."
|
||||
},
|
||||
"bmiEmptyTitle": "Größe hinzufügen für BMI",
|
||||
"bmiEmptyDescription": "Für den BMI wird deine Körpergröße benötigt. Hinterlege sie unter Einstellungen → Account.",
|
||||
"bmiEmptyAction": "Account-Einstellungen öffnen",
|
||||
|
||||
@@ -1586,6 +1586,24 @@
|
||||
"emptyTitle": "Not enough data yet",
|
||||
"emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.",
|
||||
"emptyAddMeasurement": "Add a measurement",
|
||||
"editMode": {
|
||||
"open": "Customize",
|
||||
"done": "Done",
|
||||
"reset": "Reset",
|
||||
"title": "Customize overview",
|
||||
"description": "Drag to reorder, tap the eye to show or hide.",
|
||||
"dragHandle": "Drag to reorder",
|
||||
"show": "Show",
|
||||
"hide": "Hide",
|
||||
"manageTiles": "Manage tiles",
|
||||
"gatedHint": "Appears once data or the feature is available",
|
||||
"allHiddenHint": "Every section is hidden. Show at least one to see your overview.",
|
||||
"saveSuccess": "Overview layout saved",
|
||||
"saveError": "Could not save your overview layout",
|
||||
"resetSuccess": "Overview reset to defaults",
|
||||
"emptyTitle": "Every card is hidden",
|
||||
"emptyDescription": "You've hidden every section. Open customize to bring cards back."
|
||||
},
|
||||
"bmiEmptyTitle": "Set your height to see BMI",
|
||||
"bmiEmptyDescription": "BMI needs your height. Add it under Settings → Account.",
|
||||
"bmiEmptyAction": "Open account settings",
|
||||
|
||||
@@ -1586,6 +1586,24 @@
|
||||
"emptyTitle": "Aún no hay datos suficientes",
|
||||
"emptyDescription": "Los insights aparecen en cuanto registres algunas mediciones. Empieza por peso o tensión arterial.",
|
||||
"emptyAddMeasurement": "Registrar medición",
|
||||
"editMode": {
|
||||
"open": "Personalizar",
|
||||
"done": "Listo",
|
||||
"reset": "Restablecer",
|
||||
"title": "Personalizar resumen",
|
||||
"description": "Arrastra para reordenar, toca el ojo para mostrar u ocultar.",
|
||||
"dragHandle": "Arrastra para reordenar",
|
||||
"show": "Mostrar",
|
||||
"hide": "Ocultar",
|
||||
"manageTiles": "Gestionar tarjetas",
|
||||
"gatedHint": "Aparece cuando haya datos o la función esté disponible",
|
||||
"allHiddenHint": "Todas las secciones están ocultas. Muestra al menos una para ver tu resumen.",
|
||||
"saveSuccess": "Resumen guardado",
|
||||
"saveError": "No se pudo guardar tu resumen",
|
||||
"resetSuccess": "Resumen restablecido a los valores predeterminados",
|
||||
"emptyTitle": "Todas las tarjetas están ocultas",
|
||||
"emptyDescription": "Has ocultado todas las secciones. Abre Personalizar para volver a mostrarlas."
|
||||
},
|
||||
"bmiEmptyTitle": "Añade tu altura para calcular el IMC",
|
||||
"bmiEmptyDescription": "El IMC necesita tu altura. Guárdala en Ajustes → Cuenta.",
|
||||
"bmiEmptyAction": "Abrir ajustes de cuenta",
|
||||
|
||||
@@ -1586,6 +1586,24 @@
|
||||
"emptyTitle": "Pas encore assez de données",
|
||||
"emptyDescription": "Les insights apparaissent dès que tu auras saisi quelques mesures. Commence par le poids ou la tension.",
|
||||
"emptyAddMeasurement": "Saisir une mesure",
|
||||
"editMode": {
|
||||
"open": "Personnaliser",
|
||||
"done": "Terminé",
|
||||
"reset": "Réinitialiser",
|
||||
"title": "Personnaliser l’aperçu",
|
||||
"description": "Fais glisser pour réorganiser, touche l’œil pour afficher ou masquer.",
|
||||
"dragHandle": "Fais glisser pour réorganiser",
|
||||
"show": "Afficher",
|
||||
"hide": "Masquer",
|
||||
"manageTiles": "Gérer les tuiles",
|
||||
"gatedHint": "Apparaît dès que des données ou la fonction sont disponibles",
|
||||
"allHiddenHint": "Toutes les sections sont masquées. Affiches-en au moins une pour voir ton aperçu.",
|
||||
"saveSuccess": "Aperçu enregistré",
|
||||
"saveError": "Impossible d’enregistrer ton aperçu",
|
||||
"resetSuccess": "Aperçu réinitialisé aux valeurs par défaut",
|
||||
"emptyTitle": "Toutes les cartes sont masquées",
|
||||
"emptyDescription": "Tu as masqué toutes les sections. Ouvre Personnaliser pour les réafficher."
|
||||
},
|
||||
"bmiEmptyTitle": "Ajoute ta taille pour l’IMC",
|
||||
"bmiEmptyDescription": "L’IMC nécessite ta taille. Renseigne-la dans Paramètres → Compte.",
|
||||
"bmiEmptyAction": "Ouvrir les paramètres du compte",
|
||||
|
||||
@@ -1586,6 +1586,24 @@
|
||||
"emptyTitle": "Dati ancora insufficienti",
|
||||
"emptyDescription": "Gli insights compaiono appena registri qualche misurazione. Inizia da peso o pressione.",
|
||||
"emptyAddMeasurement": "Registra misurazione",
|
||||
"editMode": {
|
||||
"open": "Personalizza",
|
||||
"done": "Fine",
|
||||
"reset": "Reimposta",
|
||||
"title": "Personalizza panoramica",
|
||||
"description": "Trascina per riordinare, tocca l’occhio per mostrare o nascondere.",
|
||||
"dragHandle": "Trascina per riordinare",
|
||||
"show": "Mostra",
|
||||
"hide": "Nascondi",
|
||||
"manageTiles": "Gestisci riquadri",
|
||||
"gatedHint": "Compare quando i dati o la funzione sono disponibili",
|
||||
"allHiddenHint": "Tutte le sezioni sono nascoste. Mostrane almeno una per vedere la panoramica.",
|
||||
"saveSuccess": "Panoramica salvata",
|
||||
"saveError": "Impossibile salvare la panoramica",
|
||||
"resetSuccess": "Panoramica reimpostata ai valori predefiniti",
|
||||
"emptyTitle": "Tutte le schede sono nascoste",
|
||||
"emptyDescription": "Hai nascosto tutte le sezioni. Apri Personalizza per mostrarle di nuovo."
|
||||
},
|
||||
"bmiEmptyTitle": "Aggiungi l’altezza per il BMI",
|
||||
"bmiEmptyDescription": "Per il BMI serve la tua altezza. Salvala in Impostazioni → Account.",
|
||||
"bmiEmptyAction": "Apri impostazioni account",
|
||||
|
||||
@@ -1586,6 +1586,24 @@
|
||||
"emptyTitle": "Jeszcze za mało danych",
|
||||
"emptyDescription": "Insights pojawią się, gdy zarejestrujesz kilka pomiarów. Zacznij od wagi lub ciśnienia.",
|
||||
"emptyAddMeasurement": "Zarejestruj pomiar",
|
||||
"editMode": {
|
||||
"open": "Dostosuj",
|
||||
"done": "Gotowe",
|
||||
"reset": "Przywróć",
|
||||
"title": "Dostosuj przegląd",
|
||||
"description": "Przeciągnij, aby zmienić kolejność, dotknij oka, aby pokazać lub ukryć.",
|
||||
"dragHandle": "Przeciągnij, aby zmienić kolejność",
|
||||
"show": "Pokaż",
|
||||
"hide": "Ukryj",
|
||||
"manageTiles": "Zarządzaj kafelkami",
|
||||
"gatedHint": "Pojawi się, gdy dane lub funkcja będą dostępne",
|
||||
"allHiddenHint": "Wszystkie sekcje są ukryte. Pokaż co najmniej jedną, aby zobaczyć przegląd.",
|
||||
"saveSuccess": "Przegląd zapisany",
|
||||
"saveError": "Nie udało się zapisać przeglądu",
|
||||
"resetSuccess": "Przegląd przywrócony do ustawień domyślnych",
|
||||
"emptyTitle": "Wszystkie karty są ukryte",
|
||||
"emptyDescription": "Ukryto wszystkie sekcje. Otwórz Dostosuj, aby je ponownie pokazać."
|
||||
},
|
||||
"bmiEmptyTitle": "Dodaj wzrost, aby obliczyć BMI",
|
||||
"bmiEmptyDescription": "BMI wymaga twojego wzrostu. Zapisz go w Ustawieniach → Konto.",
|
||||
"bmiEmptyAction": "Otwórz ustawienia konta",
|
||||
|
||||
+88
-11
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import { Fragment, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { RefreshCw, TrendingUp } from "lucide-react";
|
||||
import { Check, RefreshCw, SlidersHorizontal, TrendingUp } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -21,6 +21,7 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
import { InsightsEditMode } from "@/components/insights/insights-edit-mode";
|
||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
@@ -284,6 +285,12 @@ export default function InsightsPage() {
|
||||
// default order with no flicker.
|
||||
const layout = useInsightsLayout(isAuthenticated);
|
||||
|
||||
// v1.15.11 W3 — inline "Anpassen" edit mode. When on, the customizable
|
||||
// region swaps the live (heavy) sections for lightweight edit cards; the
|
||||
// section data is never refetched (the queries stay mounted via the page's
|
||||
// own hooks but the section JSX is replaced). HeroStrip stays anchored.
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
|
||||
// Error branch — a transient 500 / network drop (after the query's
|
||||
// retries are exhausted) settles the comprehensive query with no data.
|
||||
// Without this gate the page would fall through to the "no data yet —
|
||||
@@ -429,6 +436,24 @@ export default function InsightsPage() {
|
||||
"rhythm-events": <RhythmEventsCard enabled={isAuthenticated} />,
|
||||
};
|
||||
|
||||
// v1.15.11 W3 — sections whose feature/data gate is currently OFF. The edit
|
||||
// row for these renders disabled with a hint so a toggle that does nothing
|
||||
// never confuses the user (it stays orderable, just not enable-able past the
|
||||
// gate). Mirrors the `null` registry entries above: `daily-briefing` +
|
||||
// `period-review` ride the briefing flag; `cycle-summary` rides the cycle
|
||||
// opt-in. Every other section is always available.
|
||||
const gatedOffSectionIds = new Set<InsightsSectionId>();
|
||||
if (!flags.briefing) {
|
||||
gatedOffSectionIds.add("daily-briefing");
|
||||
gatedOffSectionIds.add("period-review");
|
||||
}
|
||||
if (!user?.cycleTrackingEnabled) {
|
||||
gatedOffSectionIds.add("cycle-summary");
|
||||
}
|
||||
|
||||
const orderedSectionIds = orderedVisibleSectionIds(layout);
|
||||
const everySectionHidden = orderedSectionIds.length === 0;
|
||||
|
||||
return (
|
||||
// v1.12.7 (L3) — one consistent vertical rhythm down the overview. The
|
||||
// page used `space-y-8` (32 px) between top-level blocks while the vitals
|
||||
@@ -458,15 +483,67 @@ export default function InsightsPage() {
|
||||
healthScore={analytics?.healthScore ?? undefined}
|
||||
/>
|
||||
|
||||
{/* v1.15.11 W2 — the customizable region: sections render in the
|
||||
resolved layout order, skipping any the user has hidden. A
|
||||
layout-visible-but-gate-off section resolves to `null` from the
|
||||
registry, so the `space-y-6` rhythm closes the gap with no hole.
|
||||
Each registry node is wrapped in a keyed Fragment so React keeps a
|
||||
stable identity across a reorder. */}
|
||||
{orderedVisibleSectionIds(layout).map((id) => (
|
||||
<Fragment key={id}>{SECTION_REGISTRY[id]}</Fragment>
|
||||
))}
|
||||
{/* v1.15.11 W3 — the "Anpassen" toggle sits at the top of the
|
||||
customizable region, below the anchored HeroStrip. In edit mode it
|
||||
reads "Fertig"; the InsightsEditMode component owns the actual
|
||||
Fertig/Zurücksetzen save controls, so this button just enters edit
|
||||
mode (exiting is handled by the component's "Fertig" → onClose). */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditMode((v) => !v)}
|
||||
data-slot="insights-customize-toggle"
|
||||
aria-pressed={editMode}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{editMode ? (
|
||||
<Check className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
)}
|
||||
<span>
|
||||
{editMode ? t("insights.editMode.done") : t("insights.editMode.open")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{editMode ? (
|
||||
/* v1.15.11 W3 — edit mode swaps the live, heavy sections for the
|
||||
lightweight edit cards. The section data queries stay mounted on the
|
||||
page, but their JSX is not rendered, so toggling into edit mode never
|
||||
refetches. */
|
||||
<InsightsEditMode
|
||||
layout={layout}
|
||||
gatedOffSectionIds={gatedOffSectionIds}
|
||||
onClose={() => setEditMode(false)}
|
||||
/>
|
||||
) : everySectionHidden ? (
|
||||
/* v1.15.11 W3 — empty-state: every section hidden. The page is never
|
||||
blank — surface a hint + a button that opens edit mode so the user
|
||||
can bring sections back. */
|
||||
<EmptyState
|
||||
icon={<SlidersHorizontal className="size-6" />}
|
||||
title={t("insights.editMode.emptyTitle")}
|
||||
description={t("insights.editMode.emptyDescription")}
|
||||
action={
|
||||
<Button size="sm" onClick={() => setEditMode(true)}>
|
||||
{t("insights.editMode.open")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
/* v1.15.11 W2 — the customizable region: sections render in the
|
||||
resolved layout order, skipping any the user has hidden. A
|
||||
layout-visible-but-gate-off section resolves to `null` from the
|
||||
registry, so the `space-y-6` rhythm closes the gap with no hole.
|
||||
Each registry node is wrapped in a keyed Fragment so React keeps a
|
||||
stable identity across a reorder. */
|
||||
orderedSectionIds.map((id) => (
|
||||
<Fragment key={id}>{SECTION_REGISTRY[id]}</Fragment>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ChevronDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
GripVertical,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { reorderById } from "@/lib/insights-layout-reorder";
|
||||
import {
|
||||
type InsightsLayout,
|
||||
type InsightsSectionConfig,
|
||||
type InsightsSectionId,
|
||||
type InsightsTileConfig,
|
||||
} from "@/lib/insights-layout";
|
||||
|
||||
/**
|
||||
* v1.15.11 W3 — inline "Anpassen" edit mode for the customizable Insights
|
||||
* overview.
|
||||
*
|
||||
* Renders LIGHTWEIGHT edit cards (drag handle + localized title + eye toggle)
|
||||
* instead of the live, heavy section content, so toggling into edit mode never
|
||||
* refetches the section data — it swaps the JSX, not the queries. Edits mutate a
|
||||
* local draft; "Fertig" PUTs the merged `{ version: 2, sections, tiles }` blob
|
||||
* and invalidates `queryKeys.insightsLayout()`; "Zurücksetzen" DELETEs to
|
||||
* defaults.
|
||||
*
|
||||
* Tile-level depth (the Vitals section's per-metric tiles) ships as the
|
||||
* spec-sanctioned DISCLOSURE fallback rather than nested cross-container drag:
|
||||
* section rows drag in ONE top-level `SortableContext`; the Vitals row carries a
|
||||
* "Kacheln verwalten" disclosure that opens a SEPARATE, independent
|
||||
* `DndContext` + `SortableContext` for its tiles. The two drag contexts never
|
||||
* share collision detection, so a tile drag can never cross-fire into a section
|
||||
* drag — the failure mode flagged in the plan's Risks section. Still inline,
|
||||
* still drag + eye per tile, robust on touch.
|
||||
*/
|
||||
|
||||
/** Localized title key per section id — used for the edit-card label. */
|
||||
const SECTION_TITLE_KEYS: Record<InsightsSectionId, string> = {
|
||||
"wellness-scores": "insights.derived.scores.sectionTitle",
|
||||
"daily-briefing": "insights.dailyBriefing.title",
|
||||
vitals: "insights.derived.vitals.sectionTitle",
|
||||
trends: "insights.trendsRow.title",
|
||||
"period-review": "insights.narrativeTitle",
|
||||
"cycle-summary": "cycle.insightsSummary.title",
|
||||
signals: "insights.derived.coincident.cardTitle",
|
||||
"rhythm-events": "insights.rhythmEvents.sectionTitle",
|
||||
};
|
||||
|
||||
/**
|
||||
* The per-metric tile ids the Vitals section renders on the overview grid,
|
||||
* paired with their localized measurement label key. This is the subset of the
|
||||
* full tile-id universe that the Vitals dashboard actually surfaces (the four
|
||||
* derived re-frames + the per-vital baseline tiles + the mobility sub-grid);
|
||||
* the other layout tile ids drive the routed sub-page strip, not this grid, so
|
||||
* they stay out of the inline tile editor. Order here is the default grid order
|
||||
* (see `vitals-dashboard.tsx`); the user's saved `order` overrides it.
|
||||
*/
|
||||
const VITALS_TILE_LABEL_KEYS: { id: string; labelKey: string }[] = [
|
||||
{ id: "cardio-fitness", labelKey: "measurements.typeVo2Max" },
|
||||
{ id: "vascular-age", labelKey: "measurements.typeVascularAge" },
|
||||
{ id: "hrv", labelKey: "measurements.typeHeartRateVariability" },
|
||||
{ id: "bmi", labelKey: "measurements.typeBodyMassIndex" },
|
||||
{ id: "resting-pulse", labelKey: "measurements.typeRestingHeartRate" },
|
||||
{ id: "respiratory-rate", labelKey: "measurements.typeRespiratoryRate" },
|
||||
{ id: "oxygen", labelKey: "measurements.typeOxygenSaturation" },
|
||||
{ id: "body-temperature", labelKey: "measurements.typeBodyTemperature" },
|
||||
{ id: "blood-glucose", labelKey: "measurements.typeBloodGlucose" },
|
||||
{ id: "six-minute-walk", labelKey: "measurements.typeSixMinuteWalkDistance" },
|
||||
{ id: "stair-ascent-speed", labelKey: "measurements.typeStairAscentSpeed" },
|
||||
{ id: "stair-descent-speed", labelKey: "measurements.typeStairDescentSpeed" },
|
||||
{ id: "wrist-temperature", labelKey: "measurements.typeWristTemperature" },
|
||||
];
|
||||
const VITALS_TILE_IDS = new Set(VITALS_TILE_LABEL_KEYS.map((t) => t.id));
|
||||
|
||||
interface InsightsEditModeProps {
|
||||
/** Resolved layout currently in effect (server copy, defaults while loading). */
|
||||
layout: InsightsLayout;
|
||||
/**
|
||||
* Which section ids are gated off right now (feature flag / data gate), so
|
||||
* the edit row renders disabled with a hint rather than offering a toggle
|
||||
* that does nothing.
|
||||
*/
|
||||
gatedOffSectionIds: ReadonlySet<InsightsSectionId>;
|
||||
/** Close edit mode (the "Fertig" / save-success path calls this). */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function InsightsEditMode({
|
||||
layout,
|
||||
gatedOffSectionIds,
|
||||
onClose,
|
||||
}: InsightsEditModeProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Local draft seeded from the resolved layout. Edits mutate the draft only;
|
||||
// "Fertig" flushes it via the PUT mutation. Sections/tiles are sorted by
|
||||
// order for stable rendering.
|
||||
const [draft, setDraft] = useState<InsightsLayout>(() => ({
|
||||
version: layout.version,
|
||||
sections: [...layout.sections].sort((a, b) => a.order - b.order),
|
||||
tiles: [...layout.tiles].sort((a, b) => a.order - b.order),
|
||||
}));
|
||||
const [tilesOpen, setTilesOpen] = useState(false);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (next: InsightsLayout) => {
|
||||
const res = await fetch("/api/insights/layout", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
version: 2,
|
||||
sections: next.sections,
|
||||
tiles: next.tiles,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("save failed");
|
||||
return (await res.json()).data as InsightsLayout;
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
// Optimistic-style settle: write the server-resolved layout into the
|
||||
// shared cache so the overview repaints in the new order, then close.
|
||||
queryClient.setQueryData(queryKeys.insightsLayout(), saved);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsLayout(),
|
||||
});
|
||||
toast.success(t("insights.editMode.saveSuccess"));
|
||||
onClose();
|
||||
},
|
||||
onError: () => toast.error(t("insights.editMode.saveError")),
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/insights/layout", { method: "DELETE" });
|
||||
if (!res.ok) throw new Error("reset failed");
|
||||
return (await res.json()).data as InsightsLayout;
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(queryKeys.insightsLayout(), saved);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsLayout(),
|
||||
});
|
||||
// Re-seed the draft so the open editor reflects the restored defaults.
|
||||
setDraft({
|
||||
version: saved.version,
|
||||
sections: [...saved.sections].sort((a, b) => a.order - b.order),
|
||||
tiles: [...saved.tiles].sort((a, b) => a.order - b.order),
|
||||
});
|
||||
toast.success(t("insights.editMode.resetSuccess"));
|
||||
},
|
||||
onError: () => toast.error(t("insights.editMode.saveError")),
|
||||
});
|
||||
|
||||
const busy = saveMutation.isPending || resetMutation.isPending;
|
||||
|
||||
const sections = useMemo(
|
||||
() => [...draft.sections].sort((a, b) => a.order - b.order),
|
||||
[draft.sections],
|
||||
);
|
||||
const sectionIds = sections.map((s) => s.id);
|
||||
|
||||
const vitalsTiles = useMemo(() => {
|
||||
const byId = new Map<string, InsightsTileConfig>(
|
||||
draft.tiles.map((tt) => [tt.id, tt]),
|
||||
);
|
||||
return VITALS_TILE_LABEL_KEYS.map(({ id, labelKey }) => {
|
||||
const cfg = byId.get(id);
|
||||
return {
|
||||
id,
|
||||
labelKey,
|
||||
visible: cfg?.visible ?? false,
|
||||
order: cfg?.order ?? Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
}).sort((a, b) => a.order - b.order);
|
||||
// Re-derive on any tile change.
|
||||
}, [draft.tiles]);
|
||||
const vitalsTileIds = vitalsTiles.map((tt) => tt.id);
|
||||
|
||||
function toggleSection(id: InsightsSectionId, visible: boolean) {
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
sections: d.sections.map((s) => (s.id === id ? { ...s, visible } : s)),
|
||||
}));
|
||||
}
|
||||
|
||||
function handleSectionDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const reordered = reorderById<InsightsSectionConfig>(
|
||||
draft.sections,
|
||||
String(active.id),
|
||||
String(over.id),
|
||||
);
|
||||
setDraft((d) => ({ ...d, sections: reordered }));
|
||||
}
|
||||
|
||||
function toggleTile(id: string, visible: boolean) {
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
tiles: d.tiles.map((tt) => (tt.id === id ? { ...tt, visible } : tt)),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder the Vitals tiles WITHIN the draft's full tile list. The drag
|
||||
* operates only over the Vitals subset, but the persisted `tiles` array
|
||||
* carries every tile id (the routed sub-page strip reads it too), so we
|
||||
* splice the reordered subset back into the full array, re-densifying the
|
||||
* order only across the Vitals slots while leaving the non-Vitals tiles'
|
||||
* relative order intact.
|
||||
*/
|
||||
function handleTileDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const subset = vitalsTiles.map((tt) => ({
|
||||
id: tt.id,
|
||||
order: tt.order,
|
||||
visible: tt.visible,
|
||||
}));
|
||||
const reordered = reorderById(subset, String(active.id), String(over.id));
|
||||
const newOrderById = new Map(reordered.map((r, i) => [r.id, i]));
|
||||
|
||||
setDraft((d) => {
|
||||
// Build the next full tile list: Vitals tiles take their new relative
|
||||
// order; everything else keeps its existing order. Then renumber the
|
||||
// whole list densely so the persisted blob stays canonical.
|
||||
const next: InsightsTileConfig[] = [...d.tiles].sort((a, b) => {
|
||||
const aV = VITALS_TILE_IDS.has(a.id);
|
||||
const bV = VITALS_TILE_IDS.has(b.id);
|
||||
if (aV && bV) {
|
||||
return (
|
||||
(newOrderById.get(a.id) ?? 0) - (newOrderById.get(b.id) ?? 0)
|
||||
);
|
||||
}
|
||||
return a.order - b.order;
|
||||
});
|
||||
return {
|
||||
...d,
|
||||
tiles: next.map((tt, i) => ({ ...tt, order: i })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const allHidden = draft.sections.every((s) => !s.visible);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="insights-edit-mode"
|
||||
className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t("insights.editMode.title")}</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.editMode.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={busy}
|
||||
data-slot="insights-edit-reset"
|
||||
>
|
||||
{resetMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("insights.editMode.reset")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate(draft)}
|
||||
disabled={busy}
|
||||
data-slot="insights-edit-done"
|
||||
>
|
||||
{saveMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("insights.editMode.done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{allHidden && (
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="insights-edit-all-hidden"
|
||||
>
|
||||
{t("insights.editMode.allHiddenHint")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleSectionDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sectionIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{sections.map((section) => {
|
||||
const gatedOff = gatedOffSectionIds.has(section.id);
|
||||
const isVitals = section.id === "vitals";
|
||||
return (
|
||||
<SortableSectionRow
|
||||
key={section.id}
|
||||
section={section}
|
||||
title={t(SECTION_TITLE_KEYS[section.id])}
|
||||
gatedOff={gatedOff}
|
||||
disabled={busy}
|
||||
labels={{
|
||||
dragHandle: t("insights.editMode.dragHandle"),
|
||||
show: t("insights.editMode.show"),
|
||||
hide: t("insights.editMode.hide"),
|
||||
gatedHint: t("insights.editMode.gatedHint"),
|
||||
}}
|
||||
onToggle={toggleSection}
|
||||
>
|
||||
{isVitals && (
|
||||
<div className="mt-2 border-t pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTilesOpen((o) => !o)}
|
||||
disabled={busy}
|
||||
aria-expanded={tilesOpen}
|
||||
data-slot="insights-edit-tiles-disclosure"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex min-h-11 items-center gap-1.5 rounded text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform motion-reduce:transition-none",
|
||||
tilesOpen && "rotate-180",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("insights.editMode.manageTiles")}
|
||||
</button>
|
||||
{tilesOpen && (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleTileDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={vitalsTileIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{vitalsTiles.map((tile) => (
|
||||
<SortableTileRow
|
||||
key={tile.id}
|
||||
id={tile.id}
|
||||
title={t(tile.labelKey)}
|
||||
visible={tile.visible}
|
||||
disabled={busy}
|
||||
labels={{
|
||||
dragHandle: t(
|
||||
"insights.editMode.dragHandle",
|
||||
),
|
||||
show: t("insights.editMode.show"),
|
||||
hide: t("insights.editMode.hide"),
|
||||
}}
|
||||
onToggle={toggleTile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SortableSectionRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowLabels {
|
||||
dragHandle: string;
|
||||
show: string;
|
||||
hide: string;
|
||||
}
|
||||
|
||||
const DRAG_HANDLE_CLASS =
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative inline-flex h-11 w-11 shrink-0 cursor-grab touch-none items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none sm:h-9 sm:w-9 sm:before:absolute sm:before:inset-[-6px] sm:before:content-['']";
|
||||
|
||||
function EyeToggle({
|
||||
visible,
|
||||
disabled,
|
||||
label,
|
||||
onClick,
|
||||
slot,
|
||||
}: {
|
||||
visible: boolean;
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
slot: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-pressed={visible}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
data-slot={slot}
|
||||
data-visible={visible ? "true" : "false"}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex h-11 w-11 shrink-0 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none sm:h-9 sm:w-9"
|
||||
>
|
||||
{visible ? (
|
||||
<Eye className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableSectionRowProps {
|
||||
section: InsightsSectionConfig;
|
||||
title: string;
|
||||
gatedOff: boolean;
|
||||
disabled: boolean;
|
||||
labels: RowLabels & { gatedHint: string };
|
||||
onToggle: (id: InsightsSectionId, visible: boolean) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function SortableSectionRow({
|
||||
section,
|
||||
title,
|
||||
gatedOff,
|
||||
disabled,
|
||||
labels,
|
||||
onToggle,
|
||||
children,
|
||||
}: SortableSectionRowProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id: section.id });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: prefersReducedMotion() ? "none" : transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
data-slot="insights-edit-section-row"
|
||||
data-section={section.id}
|
||||
data-dragging={isDragging ? "true" : undefined}
|
||||
data-gated={gatedOff ? "true" : undefined}
|
||||
className={cn(
|
||||
"border-border bg-background/30 w-full rounded-md border px-2 py-2 sm:px-3",
|
||||
isDragging && "ring-primary z-10 opacity-90 shadow-lg ring-2",
|
||||
gatedOff && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={`${labels.dragHandle} — ${title}`}
|
||||
title={labels.dragHandle}
|
||||
disabled={disabled}
|
||||
data-slot="insights-edit-section-handle"
|
||||
className={DRAG_HANDLE_CLASS}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={title}>
|
||||
{title}
|
||||
</span>
|
||||
{gatedOff ? (
|
||||
<span className="text-muted-foreground hidden text-xs sm:inline">
|
||||
{labels.gatedHint}
|
||||
</span>
|
||||
) : (
|
||||
<EyeToggle
|
||||
visible={section.visible}
|
||||
disabled={disabled}
|
||||
label={`${section.visible ? labels.hide : labels.show} — ${title}`}
|
||||
onClick={() => onToggle(section.id, !section.visible)}
|
||||
slot="insights-edit-section-eye"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{gatedOff && (
|
||||
<p className="text-muted-foreground mt-1 pl-13 text-xs sm:hidden">
|
||||
{labels.gatedHint}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableTileRowProps {
|
||||
id: string;
|
||||
title: string;
|
||||
visible: boolean;
|
||||
disabled: boolean;
|
||||
labels: RowLabels;
|
||||
onToggle: (id: string, visible: boolean) => void;
|
||||
}
|
||||
|
||||
function SortableTileRow({
|
||||
id,
|
||||
title,
|
||||
visible,
|
||||
disabled,
|
||||
labels,
|
||||
onToggle,
|
||||
}: SortableTileRowProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: prefersReducedMotion() ? "none" : transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
data-slot="insights-edit-tile-row"
|
||||
data-tile={id}
|
||||
data-dragging={isDragging ? "true" : undefined}
|
||||
className={cn(
|
||||
"border-border bg-card flex items-center gap-2 rounded-md border px-2 py-1.5",
|
||||
isDragging && "ring-primary z-10 opacity-90 shadow-md ring-2",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={`${labels.dragHandle} — ${title}`}
|
||||
title={labels.dragHandle}
|
||||
disabled={disabled}
|
||||
data-slot="insights-edit-tile-handle"
|
||||
className={DRAG_HANDLE_CLASS}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 truncate text-sm" title={title}>
|
||||
{title}
|
||||
</span>
|
||||
<EyeToggle
|
||||
visible={visible}
|
||||
disabled={disabled}
|
||||
label={`${visible ? labels.hide : labels.show} — ${title}`}
|
||||
onClick={() => onToggle(id, !visible)}
|
||||
slot="insights-edit-tile-eye"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { reorderById } from "@/lib/insights-layout-reorder";
|
||||
|
||||
describe("reorderById", () => {
|
||||
const rows = [
|
||||
{ id: "a", order: 0, visible: true },
|
||||
{ id: "b", order: 1, visible: false },
|
||||
{ id: "c", order: 2, visible: true },
|
||||
];
|
||||
|
||||
it("moves a row forward and renumbers densely", () => {
|
||||
const next = reorderById(rows, "a", "c");
|
||||
expect(next.map((r) => r.id)).toEqual(["b", "c", "a"]);
|
||||
expect(next.map((r) => r.order)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("moves a row backward and renumbers densely", () => {
|
||||
const next = reorderById(rows, "c", "a");
|
||||
expect(next.map((r) => r.id)).toEqual(["c", "a", "b"]);
|
||||
expect(next.map((r) => r.order)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("preserves every non-order field on each row", () => {
|
||||
const next = reorderById(rows, "a", "b");
|
||||
const moved = next.find((r) => r.id === "a");
|
||||
expect(moved?.visible).toBe(true);
|
||||
const hidden = next.find((r) => r.id === "b");
|
||||
expect(hidden?.visible).toBe(false);
|
||||
});
|
||||
|
||||
it("is a no-op (but still densely renumbered) when ids match", () => {
|
||||
const next = reorderById(rows, "b", "b");
|
||||
expect(next.map((r) => r.id)).toEqual(["a", "b", "c"]);
|
||||
expect(next.map((r) => r.order)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("returns a densely renumbered copy when an id is missing", () => {
|
||||
const sparse = [
|
||||
{ id: "a", order: 5 },
|
||||
{ id: "b", order: 9 },
|
||||
];
|
||||
const next = reorderById(sparse, "a", "zzz");
|
||||
expect(next).toEqual([
|
||||
{ id: "a", order: 0 },
|
||||
{ id: "b", order: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mutate the input array or its rows", () => {
|
||||
const input = [
|
||||
{ id: "a", order: 0 },
|
||||
{ id: "b", order: 1 },
|
||||
];
|
||||
const snapshot = JSON.parse(JSON.stringify(input));
|
||||
reorderById(input, "a", "b");
|
||||
expect(input).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("sorts an unsorted input by order before reordering", () => {
|
||||
const unsorted = [
|
||||
{ id: "c", order: 2 },
|
||||
{ id: "a", order: 0 },
|
||||
{ id: "b", order: 1 },
|
||||
];
|
||||
const next = reorderById(unsorted, "a", "c");
|
||||
expect(next.map((r) => r.id)).toEqual(["b", "c", "a"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* v1.15.11 W3 — pure reorder helper for the inline Insights edit mode.
|
||||
*
|
||||
* Mirrors the `reorderWidgets` contract in
|
||||
* `src/components/settings/dashboard-layout-section.tsx` (move an item from
|
||||
* `fromId` next to `toId`, renumber the whole list to a dense 0-based
|
||||
* `order`) but is generic over any `{ id, order }` row so the section list
|
||||
* AND the Vitals tile sub-list can both reorder through one tested helper.
|
||||
*
|
||||
* Kept as a standalone module (not imported from the dashboard component) so
|
||||
* the inline-edit surface does not depend on a Settings component, and so the
|
||||
* helper is unit-testable without spinning up a `DndContext`. The dashboard's
|
||||
* own `reorderWidgets` stays untouched — extracting it there would have
|
||||
* touched the stable dashboard surface for no behavioural gain.
|
||||
*/
|
||||
export interface OrderableRow {
|
||||
id: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the row identified by `fromId` to the position of `toId`, then
|
||||
* renumber every row to a dense 0-based `order`. Returns a NEW array of NEW
|
||||
* row objects (every input field is preserved via the spread); the input is
|
||||
* never mutated. A no-op move (same id, or either id missing) still returns a
|
||||
* densely-renumbered copy so the caller can treat the output as canonical.
|
||||
*/
|
||||
export function reorderById<T extends OrderableRow>(
|
||||
rows: readonly T[],
|
||||
fromId: string,
|
||||
toId: string,
|
||||
): T[] {
|
||||
const sorted = [...rows].sort((a, b) => a.order - b.order);
|
||||
const fromIdx = sorted.findIndex((r) => r.id === fromId);
|
||||
const toIdx = sorted.findIndex((r) => r.id === toId);
|
||||
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) {
|
||||
return sorted.map((r, i) => ({ ...r, order: i }));
|
||||
}
|
||||
const next = [...sorted];
|
||||
const [removed] = next.splice(fromIdx, 1);
|
||||
next.splice(toIdx, 0, removed);
|
||||
return next.map((r, i) => ({ ...r, order: i }));
|
||||
}
|
||||
Reference in new issue
Block a user