mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
refactor(settings): split the dashboard layout section into draft, lists and hero settings
DashboardLayoutSection was a 620-line component. Its parts move to components/settings/dashboard-layout/: - layout-model: the reorder and hero-kind helpers and the label maps - use-dashboard-layout-draft: the tokened read, the draft, save and reset (with the snapshot and digest refresh) and the draft edits - hero-settings-fieldset: the top card's switch, lead and highlights - widget-lists: the sortable web list, the native-only group and rows The section composes them. Markup and behaviour are unchanged.
This commit is contained in:
1 parent
58215f3338
commit
25b5833b11
12 files changed
+1053
-920
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 117,
|
||||
"total": 135490,
|
||||
"count": 116,
|
||||
"total": 134457,
|
||||
"entries": {
|
||||
"app/api/dashboard/summary/route.ts": 1032,
|
||||
"app/api/documents/inbound/route.ts": 811,
|
||||
@@ -30,7 +30,6 @@
|
||||
"components/medications/wizard/wizard-payload.ts": 1414,
|
||||
"components/mood/mood-list.tsx": 1408,
|
||||
"components/records/life-circumstances-manager.tsx": 1080,
|
||||
"components/settings/dashboard-layout-section.tsx": 1033,
|
||||
"components/settings/managed-record-settings-section.tsx": 1071,
|
||||
"components/timeline/timeline-chart.tsx": 1174,
|
||||
"components/timeline/timeline-geometry.ts": 1395,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 403,
|
||||
"total": 139587,
|
||||
"count": 402,
|
||||
"total": 138967,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
|
||||
@@ -231,7 +231,6 @@
|
||||
"components/settings/api-section.tsx :: ApiTokensCard": 366,
|
||||
"components/settings/coach-nudge-card.tsx :: CoachNudgeCard": 251,
|
||||
"components/settings/coach-prefs-section.tsx :: CoachPrefsSection": 350,
|
||||
"components/settings/dashboard-layout-section.tsx :: DashboardLayoutSection": 620,
|
||||
"components/settings/environment-section.tsx :: EnvironmentSection": 666,
|
||||
"components/settings/import-panel/apple-health-import-card.tsx :: AppleHealthImportCard": 298,
|
||||
"components/settings/import-panel/csv-import-card.tsx :: CsvImportCard": 212,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2949,
|
||||
"total": 10287,
|
||||
"count": 2948,
|
||||
"total": 10257,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -1376,7 +1376,6 @@
|
||||
"components/settings/coach-prefs-section.tsx": 2,
|
||||
"components/settings/coach-reminders-section.tsx": 2,
|
||||
"components/settings/coach-section.tsx": 3,
|
||||
"components/settings/dashboard-layout-section.tsx": 30,
|
||||
"components/settings/dashboard-section.tsx": 1,
|
||||
"components/settings/environment-section.tsx": 3,
|
||||
"components/settings/export-section.tsx": 4,
|
||||
|
||||
@@ -316,7 +316,8 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/dashboard-layout-section.tsx",
|
||||
file: "components/settings/dashboard-layout/use-dashboard-layout-draft.ts",
|
||||
triggers: ["components/settings/dashboard-layout-section.tsx"],
|
||||
destroys:
|
||||
"the dashboard arrangement: tile order, hidden tiles, ring choice",
|
||||
recovery: "permanent",
|
||||
|
||||
@@ -360,7 +360,7 @@ const PINNED_AFFORDANCES: Record<
|
||||
},
|
||||
"src/components/settings/coach-memory-section.tsx": { "toast.success": 2 },
|
||||
"src/components/settings/coach-prefs-section.tsx": { "toast.success": 1 },
|
||||
"src/components/settings/dashboard-layout-section.tsx": {
|
||||
"src/components/settings/dashboard-layout/use-dashboard-layout-draft.ts": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/settings/environment-section.tsx": { "toast.success": 4 },
|
||||
|
||||
@@ -80,11 +80,11 @@ vi.mock("@/hooks/use-mounted", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { DashboardLayoutSection } from "../dashboard-layout-section";
|
||||
import {
|
||||
DashboardLayoutSection,
|
||||
reorderWidgets,
|
||||
setHeroItemKindEnabled,
|
||||
} from "../dashboard-layout-section";
|
||||
} from "../dashboard-layout/layout-model";
|
||||
import { primeLocales } from "@/__tests__/helpers/prime-locales";
|
||||
|
||||
primeLocales("de");
|
||||
|
||||
@@ -128,7 +128,10 @@ describe("dashboard snapshot invalidation — same-tab staleness mechanism", ()
|
||||
|
||||
describe("dashboard layout save — every layout source is covered (source pins)", () => {
|
||||
const sectionSrc = readFileSync(
|
||||
join(process.cwd(), "src/components/settings/dashboard-layout-section.tsx"),
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/components/settings/dashboard-layout/use-dashboard-layout-draft.ts",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
|
||||
import type { DashboardLayout } from "@/lib/dashboard-layout";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { HERO_ITEM_LABEL_KEYS } from "./layout-model";
|
||||
import type { DashboardLayoutDraft } from "./use-dashboard-layout-draft";
|
||||
|
||||
/**
|
||||
* The top card's settings: whether it shows, what leads it (the health score
|
||||
* or the reminders rail), and which highlights may appear in it.
|
||||
*/
|
||||
export function HeroSettingsFieldset({
|
||||
layout,
|
||||
draft,
|
||||
}: {
|
||||
layout: DashboardLayout;
|
||||
draft: DashboardLayoutDraft;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const heroDescriptionId = useId();
|
||||
const heroNotificationNoteId = useId();
|
||||
const heroContentSelectId = useId();
|
||||
const todayCardSwitchId = useId();
|
||||
const { saveMutation, setTodayCardVisible, setHeroContent, toggleHeroItem } =
|
||||
draft;
|
||||
return (
|
||||
<fieldset
|
||||
aria-describedby={`${heroDescriptionId} ${heroNotificationNoteId}`}
|
||||
data-slot="hero-content-settings"
|
||||
className="border-border bg-background/30 space-y-3 rounded-lg border px-3 py-3"
|
||||
>
|
||||
<legend className="text-foreground text-sm font-medium">
|
||||
{t("dashboard.heroItemsTitle")}
|
||||
</legend>
|
||||
<p id={heroDescriptionId} className="text-muted-foreground text-xs">
|
||||
{t("dashboard.heroItemsDescription")}
|
||||
</p>
|
||||
{/* The top card itself can be switched off. Shown is the
|
||||
default; the choices below shape the card while it is shown. */}
|
||||
<div className="flex min-h-11 items-center justify-between gap-3">
|
||||
<Label htmlFor={todayCardSwitchId} className="text-sm font-medium">
|
||||
{t("dashboard.todayCardVisible")}
|
||||
</Label>
|
||||
<Switch
|
||||
id={todayCardSwitchId}
|
||||
checked={layout.todayCardVisible !== false}
|
||||
onCheckedChange={setTodayCardVisible}
|
||||
disabled={saveMutation.isPending}
|
||||
data-slot="today-card-switch"
|
||||
/>
|
||||
</div>
|
||||
{/* Hero primary content — a single labelled select beside the
|
||||
highlight toggles. "score" keeps the health-score read;
|
||||
"reminders" promotes the highlight rail into the hero slot. */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={heroContentSelectId} className="text-sm font-medium">
|
||||
{t("dashboard.heroContentTitle")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id={heroContentSelectId}
|
||||
value={layout.hero ?? "score"}
|
||||
onChange={(event) => setHeroContent(event.target.value)}
|
||||
disabled={saveMutation.isPending}
|
||||
data-slot="hero-content-select"
|
||||
className="sm:max-w-xs"
|
||||
>
|
||||
<option value="score">{t("dashboard.heroContentScore")}</option>
|
||||
<option value="reminders">
|
||||
{t("dashboard.heroContentReminders")}
|
||||
</option>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="divide-border divide-y">
|
||||
{PRIORITY_ITEM_KINDS.map((kind) => {
|
||||
const inputId = `hero-item-${kind}`;
|
||||
const checked = (
|
||||
layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS
|
||||
).includes(kind);
|
||||
return (
|
||||
<div
|
||||
key={kind}
|
||||
className="flex min-h-11 items-center justify-between gap-3 py-2"
|
||||
>
|
||||
<Label htmlFor={inputId} className="text-sm font-medium">
|
||||
{t(HERO_ITEM_LABEL_KEYS[kind])}
|
||||
</Label>
|
||||
<Switch
|
||||
id={inputId}
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => toggleHeroItem(kind, value)}
|
||||
disabled={saveMutation.isPending}
|
||||
aria-label={t(HERO_ITEM_LABEL_KEYS[kind])}
|
||||
data-slot="hero-item-switch"
|
||||
data-kind={kind}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p id={heroNotificationNoteId} className="text-foreground text-sm">
|
||||
{t("dashboard.heroItemsNotificationNote")}
|
||||
</p>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type {
|
||||
DashboardLayout,
|
||||
DashboardWidgetId,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import {
|
||||
PRIORITY_ITEM_KINDS,
|
||||
type PriorityItemKind,
|
||||
} from "@/lib/daily/priority-item";
|
||||
|
||||
/**
|
||||
* Pure reorder helper shared by the arrow buttons and the
|
||||
* @dnd-kit drag-end handler. Both surfaces produce the same `widgets[]`
|
||||
* shape (`order: 0..n-1`) so the existing PUT contract stays untouched
|
||||
* and either input mode flushes via the same Save mutation. Exported so
|
||||
* the unit test can pin the contract without spinning up a DndContext.
|
||||
*
|
||||
* Also drives the arrow-button `move()` handler below;
|
||||
* the previous in-file swap-and-renumber implementation was a second
|
||||
* copy of the same logic.
|
||||
*/
|
||||
export function reorderWidgets(
|
||||
widgets: readonly { id: string; order: number }[],
|
||||
fromId: string,
|
||||
toId: string,
|
||||
): { id: string; order: number }[] {
|
||||
const sorted = [...widgets].sort((a, b) => a.order - b.order);
|
||||
const fromIdx = sorted.findIndex((w) => w.id === fromId);
|
||||
const toIdx = sorted.findIndex((w) => w.id === toId);
|
||||
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) {
|
||||
return sorted.map((w, i) => ({ ...w, order: i }));
|
||||
}
|
||||
const next = [...sorted];
|
||||
const [removed] = next.splice(fromIdx, 1);
|
||||
next.splice(toIdx, 0, removed);
|
||||
return next.map((w, i) => ({ ...w, order: i }));
|
||||
}
|
||||
|
||||
export function setHeroItemKindEnabled(
|
||||
enabledKinds: readonly PriorityItemKind[] | undefined,
|
||||
kind: PriorityItemKind,
|
||||
enabled: boolean,
|
||||
): PriorityItemKind[] {
|
||||
const next = new Set(enabledKinds ?? PRIORITY_ITEM_KINDS);
|
||||
if (enabled) next.add(kind);
|
||||
else next.delete(kind);
|
||||
return PRIORITY_ITEM_KINDS.filter((itemKind) => next.has(itemKind));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the `{ id, order }` shape returned by
|
||||
* `reorderWidgets()` back into the full `DashboardWidgetConfig[]` so
|
||||
* the section's draft state keeps every per-row flag (`visible`,
|
||||
* `tileVisible`) while the order is rewritten. The arrow buttons and
|
||||
* the @dnd-kit drag-end handler both flow through this helper so
|
||||
* neither surface can silently drop a flag.
|
||||
*/
|
||||
export function mergeReorderIntoLayout(
|
||||
widgets: DashboardLayout["widgets"],
|
||||
reordered: readonly { id: string; order: number }[],
|
||||
): DashboardLayout["widgets"] {
|
||||
const byId = new Map(widgets.map((w) => [w.id, w]));
|
||||
return reordered.map((r, i) => {
|
||||
const original = byId.get(r.id as DashboardWidgetId);
|
||||
if (!original) {
|
||||
// Defence-in-depth dev warning for the orphan branch.
|
||||
// Today this branch is statically unreachable: every id in
|
||||
// `reordered` is sourced from `layout.widgets`, so `byId.get`
|
||||
// always hits. The upcoming per-tile Suspense refactor will
|
||||
// introduce dynamic widgets where this invariant could break;
|
||||
// the warning fires in dev only so a regression surfaces in the
|
||||
// console instead of silently dropping the row via the cast.
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
process.env.NODE_ENV === "development"
|
||||
) {
|
||||
console.warn(
|
||||
`mergeReorderIntoLayout: orphan widget id "${r.id}" dropped`,
|
||||
);
|
||||
}
|
||||
return { ...r, order: i } as never;
|
||||
}
|
||||
return { ...original, order: i };
|
||||
});
|
||||
}
|
||||
|
||||
/** Serialise layout saves and resets against the same endpoint. */
|
||||
export const DASHBOARD_WIDGETS_MUTATION_SCOPE = "dashboard-widgets" as const;
|
||||
|
||||
export const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
|
||||
weight: "dashboard.weight",
|
||||
bp: "dashboard.bloodPressure",
|
||||
pulse: "dashboard.pulse",
|
||||
bodyFat: "dashboard.bodyFat",
|
||||
mood: "dashboard.mood",
|
||||
medications: "dashboard.medications",
|
||||
sleep: "measurements.typeSleep",
|
||||
steps: "measurements.typeSteps",
|
||||
glucose: "measurements.typeBloodGlucose",
|
||||
totalBodyWater: "measurements.typeTotalBodyWater",
|
||||
boneMass: "measurements.typeBoneMass",
|
||||
// Muscle mass joins the body-composition strip tiles.
|
||||
muscleMass: "measurements.typeMuscleMass",
|
||||
bpInTarget: "dashboard.bpInTarget",
|
||||
oxygenSaturation: "measurements.typeOxygenSaturation",
|
||||
// HRV + respiratory rate graduate to web-writable widgets.
|
||||
hrv: "measurements.typeHeartRateVariability",
|
||||
respiratoryRate: "measurements.typeRespiratoryRate",
|
||||
achievements: "achievements.title",
|
||||
// VO2 max secondary-metric tile (opt-in).
|
||||
vo2Max: "dashboard.vo2Max",
|
||||
// Recent workouts tile (default-on).
|
||||
recentWorkouts: "dashboard.recentWorkouts.title",
|
||||
// V1.10 additive HealthKit signals, now pinnable. Each
|
||||
// reuses the existing measurement-type label key.
|
||||
cardioRecovery: "measurements.typeCardioRecovery",
|
||||
sixMinuteWalk: "measurements.typeSixMinuteWalkDistance",
|
||||
stairAscentSpeed: "measurements.typeStairAscentSpeed",
|
||||
stairDescentSpeed: "measurements.typeStairDescentSpeed",
|
||||
breathingDisturbances: "measurements.typeBreathingDisturbances",
|
||||
wristTemperature: "measurements.typeWristTemperature",
|
||||
falls: "measurements.typeFallCount",
|
||||
walkingSteadiness: "measurements.typeWalkingSteadiness",
|
||||
// Vorsorge preventive-care summary card.
|
||||
vorsorge: "measurementReminders.sectionTitle",
|
||||
// Fluid intake strip tile (nutrients-store-backed).
|
||||
waterIntake: "nutrients.names.water",
|
||||
};
|
||||
|
||||
export const HERO_ITEM_LABEL_KEYS: Record<PriorityItemKind, string> = {
|
||||
coach_checkin: "dashboard.heroItems.coach_checkin",
|
||||
dose_window: "dashboard.heroItems.dose_window",
|
||||
preventive_care: "dashboard.heroItems.preventive_care",
|
||||
sync_issue: "dashboard.heroItems.sync_issue",
|
||||
milestone: "dashboard.heroItems.milestone",
|
||||
ecg_new_recording: "dashboard.heroItems.ecg_new_recording",
|
||||
tension_window: "dashboard.heroItems.tension_window",
|
||||
same_time_baseline: "dashboard.heroItems.same_time_baseline",
|
||||
upcoming_visit: "dashboard.heroItems.upcoming_visit",
|
||||
};
|
||||
@@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { apiDelete, apiGet, apiPut, ApiError } from "@/lib/api/api-fetch";
|
||||
import type { PriorityItemKind } from "@/lib/daily/priority-item";
|
||||
import type {
|
||||
DashboardLayout,
|
||||
DashboardLayoutWithToken,
|
||||
DashboardWidgetCatalogueId,
|
||||
DashboardWidgetId,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import {
|
||||
DASHBOARD_WIDGETS_MUTATION_SCOPE,
|
||||
mergeReorderIntoLayout,
|
||||
reorderWidgets,
|
||||
setHeroItemKindEnabled,
|
||||
} from "./layout-model";
|
||||
|
||||
/**
|
||||
* The layout editor's state: the tokened server copy, a local draft that
|
||||
* every toggle and move edits (null means "use the server copy"), and the
|
||||
* save and reset writes, which also refresh the dashboard snapshot and the
|
||||
* daily digest so the dashboard mounts onto the saved layout.
|
||||
*/
|
||||
export function useDashboardLayoutDraft() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: remote, isLoading } = useQuery({
|
||||
queryKey: queryKeys.dashboardWidgets(),
|
||||
queryFn: async () => {
|
||||
return apiGet<DashboardLayoutWithToken>("/api/dashboard/widgets");
|
||||
},
|
||||
// The home RSC seeds
|
||||
// `dashboardWidgets()` from the dashboard SNAPSHOT layout, which carries
|
||||
// NO `updatedAt` optimistic-concurrency token (it is a token-less
|
||||
// `DashboardLayout`, not the `DashboardLayoutWithToken` this section's GET
|
||||
// returns). Within the global 5-min `staleTime` a visit to `/` then Settings
|
||||
// read that seeded token-less value with no mount refetch, so `readBaseToken()`
|
||||
// returned undefined, the Save sent no `baseUpdatedAt`, and the route's
|
||||
// compat fallback silently reverted to last-write-wins — disabling the
|
||||
// just-shipped 409 belt. Force a mount refetch so this section always holds
|
||||
// its own tokened GET response and every write carries the base token. The
|
||||
// smallest correct option: the alternatives (a distinct seed key, or
|
||||
// threading the token through the snapshot layout) touch the home client and
|
||||
// the server snapshot shape; this is one line, config-independent, and makes
|
||||
// the settings query authoritative for its own tokened read.
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
// Toggles the SERVER resolved as unavailable for this account — a switch
|
||||
// that is on but can never paint a tile is exactly the confusion this
|
||||
// screen must not create. Decided server-side (see `unavailableWidgetIds`)
|
||||
// so this screen needs no dashboard data of its own. Absent field, or a
|
||||
// query still in flight, means nothing is dead: the row shows, which is
|
||||
// the fail-open direction the module gate below also takes.
|
||||
const unavailableIds = useMemo(
|
||||
() => new Set(remote?.unavailableWidgetIds ?? []),
|
||||
[remote?.unavailableWidgetIds],
|
||||
);
|
||||
|
||||
/**
|
||||
* The freshest optimistic-concurrency token the
|
||||
* client knows. Read straight from the widgets query cache at mutate time
|
||||
* (not a render snapshot) so it reflects whatever the last settled write
|
||||
* or refetch advanced it to. Every write sends it as `baseUpdatedAt`.
|
||||
*/
|
||||
const readBaseToken = () =>
|
||||
queryClient.getQueryData<DashboardLayoutWithToken>(
|
||||
queryKeys.dashboardWidgets(),
|
||||
)?.updatedAt;
|
||||
|
||||
// Local draft state — null means "use server copy". User edits create the
|
||||
// draft so reordering/toggling doesn't fire a network call per click; Save
|
||||
// flushes it, Cancel clears it. Avoids a setState-in-effect (eslint
|
||||
// react-hooks/set-state-in-effect is strict in this repo).
|
||||
const [draft, setDraft] = useState<DashboardLayout | null>(null);
|
||||
const layout = draft ?? remote ?? null;
|
||||
|
||||
const saveMutation = useMutation({
|
||||
scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE },
|
||||
mutationFn: async (next: DashboardLayout) => {
|
||||
return apiPut<DashboardLayoutWithToken>("/api/dashboard/widgets", {
|
||||
...next,
|
||||
// Guard the write on the token this edit was based on.
|
||||
baseUpdatedAt: readBaseToken(),
|
||||
});
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(queryKeys.dashboardWidgets(), saved);
|
||||
// The Startseite reads its layout (tile/chart visibility,
|
||||
// order, comparison baseline, hero toggle) from the dashboard
|
||||
// SNAPSHOT, not from this `dashboardWidgets` key. Without an
|
||||
// invalidation the snapshot keeps its cached layout and the dashboard
|
||||
// looks stale until a manual reload. Invalidate the snapshot so the
|
||||
// next visit (or open tab) re-reads the saved layout immediately.
|
||||
//
|
||||
// `refetchType: "all"` is load-bearing: while the user sits on this
|
||||
// settings page the dashboard is UNMOUNTED, so its snapshot cell is
|
||||
// inactive — the default `refetchType: "active"` only marks it stale,
|
||||
// and the snapshot query's deliberate `refetchOnMount: false`
|
||||
// (use-dashboard-snapshot.ts) then suppresses the mount-time refetch
|
||||
// on a same-tab navigation back to `/`. The saved tile selection only
|
||||
// surfaced on the 120 s poll or a window-focus flick ("saved but the
|
||||
// dashboard shows old tiles"). Refetching ALL matching cells fires the
|
||||
// request right here, so the dashboard mounts onto fresh data.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.dashboardSnapshot(),
|
||||
refetchType: "all",
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.dailyDigest(),
|
||||
refetchType: "all",
|
||||
});
|
||||
setDraft(null);
|
||||
toast.success(t("dashboard.layoutSaveSuccess"));
|
||||
},
|
||||
onError: (err) => {
|
||||
// A 409 means another client committed since this edit was based.
|
||||
// Refetch the winning layout so the base token advances, keep the draft,
|
||||
// and let the user save the change again.
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.dashboardWidgets(),
|
||||
});
|
||||
toast.message(t("dashboard.layoutConflictReloaded"));
|
||||
return;
|
||||
}
|
||||
showErrorToast(t("dashboard.layoutSaveError"), { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE },
|
||||
mutationFn: async () => {
|
||||
return apiDelete<DashboardLayoutWithToken>("/api/dashboard/widgets");
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(queryKeys.dashboardWidgets(), saved);
|
||||
// Same snapshot invalidation as the save path so a reset
|
||||
// to defaults reflects on the Startseite without a manual reload.
|
||||
// `refetchType: "all"` for the same reason as the save path: the
|
||||
// unmounted snapshot cell must refetch NOW, not on its next poll.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.dashboardSnapshot(),
|
||||
refetchType: "all",
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.dailyDigest(),
|
||||
refetchType: "all",
|
||||
});
|
||||
setDraft(null);
|
||||
toast.success(t("dashboard.layoutResetSuccess"));
|
||||
},
|
||||
});
|
||||
|
||||
const edits = layoutEdits(layout, setDraft);
|
||||
|
||||
// Presence of a draft implies dirty — no JSON comparison needed.
|
||||
const dirty = draft !== null && layout !== null;
|
||||
|
||||
return {
|
||||
remote,
|
||||
isLoading,
|
||||
unavailableIds,
|
||||
layout,
|
||||
setDraft,
|
||||
dirty,
|
||||
saveMutation,
|
||||
resetMutation,
|
||||
...edits,
|
||||
};
|
||||
}
|
||||
|
||||
export type DashboardLayoutDraft = ReturnType<typeof useDashboardLayoutDraft>;
|
||||
|
||||
/**
|
||||
* The draft edits: the tile and chart switches, the top card's settings, and
|
||||
* reordering by arrow or drag. Each writes a new draft from the current
|
||||
* layout; nothing is sent until Save.
|
||||
*/
|
||||
function layoutEdits(
|
||||
layout: DashboardLayout | null,
|
||||
setDraft: (next: DashboardLayout | null) => void,
|
||||
) {
|
||||
function toggle(widgetId: DashboardWidgetCatalogueId, visible: boolean) {
|
||||
if (!layout) return;
|
||||
setDraft({
|
||||
...layout,
|
||||
widgets: layout.widgets.map((w) =>
|
||||
w.id === widgetId ? { ...w, visible } : w,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Independent toggle for the *strip tile* (the upper
|
||||
* row of trend cards). Until v1.4.14 a single switch controlled both
|
||||
* the tile AND the chart for the same metric, which the maintainer found too
|
||||
* coarse: they wanted a chart visible without the tile (for metrics they
|
||||
* tracks without wanting the at-a-glance number) or vice versa.
|
||||
*/
|
||||
function toggleTile(
|
||||
widgetId: DashboardWidgetCatalogueId,
|
||||
tileVisible: boolean,
|
||||
) {
|
||||
if (!layout) return;
|
||||
setDraft({
|
||||
...layout,
|
||||
widgets: layout.widgets.map((w) =>
|
||||
w.id === widgetId ? { ...w, tileVisible } : w,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function toggleHeroItem(kind: PriorityItemKind, enabled: boolean) {
|
||||
if (!layout) return;
|
||||
setDraft({
|
||||
...layout,
|
||||
enabledHeroItemKinds: setHeroItemKindEnabled(
|
||||
layout.enabledHeroItemKinds,
|
||||
kind,
|
||||
enabled,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** The server-persisted on/off switch for the dashboard's top card. */
|
||||
function setTodayCardVisible(visible: boolean) {
|
||||
if (!layout) return;
|
||||
setDraft({ ...layout, todayCardVisible: visible });
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero primary content — the server-persisted `hero` field on the layout
|
||||
* blob. The value set is closed ("score" | "reminders"); anything else a
|
||||
* DOM event could carry collapses to the default rather than a cast.
|
||||
*/
|
||||
function setHeroContent(value: string) {
|
||||
if (!layout) return;
|
||||
setDraft({
|
||||
...layout,
|
||||
hero: value === "reminders" ? "reminders" : "score",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The arrow buttons now delegate to `reorderWidgets()`
|
||||
* so the swap-and-renumber logic lives in exactly one place. The
|
||||
* neighbour id is derived from the sorted layout index + delta;
|
||||
* out-of-bounds clicks (top row + ArrowUp, bottom row + ArrowDown)
|
||||
* short-circuit before the helper sees them so the button disabled
|
||||
* state stays the single source of truth for the boundary.
|
||||
*/
|
||||
function move(widgetId: DashboardWidgetId, delta: -1 | 1) {
|
||||
if (!layout) return;
|
||||
const sorted = [...layout.widgets].sort((a, b) => a.order - b.order);
|
||||
const idx = sorted.findIndex((w) => w.id === widgetId);
|
||||
const targetIdx = idx + delta;
|
||||
if (idx < 0 || targetIdx < 0 || targetIdx >= sorted.length) return;
|
||||
const neighbourId = sorted[targetIdx].id;
|
||||
const reordered = reorderWidgets(layout.widgets, widgetId, neighbourId);
|
||||
setDraft({
|
||||
...layout,
|
||||
widgets: mergeReorderIntoLayout(layout.widgets, reordered),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag-and-drop reorder via @dnd-kit. Persists the same
|
||||
* `order` rewrite shape the arrow buttons already use, so save / cancel
|
||||
* / reset and the existing draft state machine work unchanged. The
|
||||
* pointer + keyboard sensors are wired in the same `useSensors` call;
|
||||
* keyboard a11y still also works through the legacy arrow buttons that
|
||||
* remain on every row.
|
||||
*/
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
if (!layout) return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const reordered = reorderWidgets(
|
||||
layout.widgets,
|
||||
String(active.id),
|
||||
String(over.id),
|
||||
);
|
||||
setDraft({
|
||||
...layout,
|
||||
widgets: mergeReorderIntoLayout(layout.widgets, reordered),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
toggle,
|
||||
toggleTile,
|
||||
toggleHeroItem,
|
||||
setTodayCardVisible,
|
||||
setHeroContent,
|
||||
move,
|
||||
handleDragEnd,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { ArrowDown, ArrowUp, GripVertical } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import {
|
||||
DASHBOARD_IOS_ONLY_WIDGET_IDS,
|
||||
DASHBOARD_WIDGET_IDS,
|
||||
IOS_PIN_ONLY_WIDGET_IDS,
|
||||
type DashboardLayout,
|
||||
type DashboardWidgetCatalogueId,
|
||||
type DashboardWidgetId,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { NATIVE_ONLY_WIDGET_LABEL_KEYS } from "@/lib/dashboard/widget-modules";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
|
||||
import { WIDGET_LABEL_KEYS } from "./layout-model";
|
||||
import type { DashboardLayoutDraft } from "./use-dashboard-layout-draft";
|
||||
|
||||
/**
|
||||
* The widgets the web dashboard draws, in their order, each with its tile
|
||||
* and chart switches, arrow buttons and a drag handle.
|
||||
*/
|
||||
export function WebWidgetList({
|
||||
layout,
|
||||
draft,
|
||||
dragHintId,
|
||||
}: {
|
||||
layout: DashboardLayout;
|
||||
draft: DashboardLayoutDraft;
|
||||
dragHintId: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
// A widget toggle whose owning module is disabled is a dead control (the
|
||||
// snapshot gates the tile and chart out regardless of the switch), so the
|
||||
// resolved module map hides those rows. Fail-open: only an explicit
|
||||
// `false` hides one.
|
||||
const { user } = useAuth();
|
||||
const modules = user?.modules;
|
||||
const {
|
||||
unavailableIds,
|
||||
saveMutation,
|
||||
toggleTile,
|
||||
toggle,
|
||||
move,
|
||||
handleDragEnd,
|
||||
} = draft;
|
||||
// Pointer for mouse and touch, keyboard for Tab + Space + arrows. The
|
||||
// arrow buttons on every row stay the primary keyboard surface.
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
// A 6 px move before a drag starts, so the row switches keep their
|
||||
// clicks.
|
||||
activationConstraint: { distance: 6 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
// The stored layout now round-trips the 11 iOS-only
|
||||
// widget ids so the native client can drop its local merge
|
||||
// workarounds. The web Settings list has no tile/chart
|
||||
// surface for them, so skip any id outside the web-known
|
||||
// ids rather than render an unlabelled row with dead toggles.
|
||||
// The skipped ids stay untouched in the persisted layout
|
||||
// because the Save mutation PUTs `layout.widgets` whole and
|
||||
// the server retains every catalogue id.
|
||||
//
|
||||
// The 8 B5 ids are WRITABLE (in
|
||||
// `DASHBOARD_WIDGET_IDS` so the iOS pin PUT validates) but
|
||||
// have no web render path either, so exclude
|
||||
// `IOS_PIN_ONLY_WIDGET_IDS` too: a web toggle for them would
|
||||
// be a silent no-op on the web dashboard.
|
||||
const iosPinOnly = new Set<string>(IOS_PIN_ONLY_WIDGET_IDS);
|
||||
const webWidgetIds = new Set<string>(
|
||||
DASHBOARD_WIDGET_IDS.filter((wid) => !iosPinOnly.has(wid)),
|
||||
);
|
||||
const sortedWidgets = [...layout.widgets]
|
||||
.filter((w): w is typeof w & { id: DashboardWidgetId } =>
|
||||
webWidgetIds.has(w.id),
|
||||
)
|
||||
// Hide a widget toggle whose owning module is off, through the
|
||||
// one surface map. A core widget has no owner and always shows;
|
||||
// only an explicit `false` hides the row.
|
||||
.filter((w) => isSurfaceVisible(`widget:${w.id}`, modules))
|
||||
.filter((w) => !unavailableIds.has(w.id))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
const sortedIds = sortedWidgets.map((w) => w.id);
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={sortedIds} strategy={verticalListSortingStrategy}>
|
||||
{sortedWidgets.map((widget, index, arr) => (
|
||||
<SortableWidgetRow
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
labelKey={WIDGET_LABEL_KEYS[widget.id] ?? widget.id}
|
||||
index={index}
|
||||
total={arr.length}
|
||||
dragHintId={dragHintId}
|
||||
disabled={saveMutation.isPending}
|
||||
labels={{
|
||||
tileColumn: t("dashboard.layoutTileColumn"),
|
||||
chartColumn: t("dashboard.layoutChartColumn"),
|
||||
moveUp: t("dashboard.moveUp"),
|
||||
moveDown: t("dashboard.moveDown"),
|
||||
dragHandle: t("dashboard.dragHandle"),
|
||||
widgetLabel: t(WIDGET_LABEL_KEYS[widget.id] ?? widget.id),
|
||||
}}
|
||||
onToggleTile={toggleTile}
|
||||
onToggleChart={toggle}
|
||||
onMove={move}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Widgets the mobile app draws and this page does not (#581). The native
|
||||
* client materialises them into the same stored layout, so they get their
|
||||
* own labelled group: the same two flags, no reordering, and only for ids the
|
||||
* layout holds.
|
||||
*/
|
||||
export function NativeWidgetList({
|
||||
layout,
|
||||
draft,
|
||||
}: {
|
||||
layout: DashboardLayout;
|
||||
draft: DashboardLayoutDraft;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const modules = user?.modules;
|
||||
const { unavailableIds, saveMutation, toggleTile, toggle } = draft;
|
||||
const nativeOnlyIds = new Set<string>([
|
||||
...IOS_PIN_ONLY_WIDGET_IDS,
|
||||
...DASHBOARD_IOS_ONLY_WIDGET_IDS,
|
||||
]);
|
||||
const nativeWidgets = layout.widgets
|
||||
.filter((w) => nativeOnlyIds.has(w.id))
|
||||
// Same fail-open module gate as the web list: only an explicit
|
||||
// `false` hides the row.
|
||||
.filter((w) => isSurfaceVisible(`widget:${w.id}`, modules))
|
||||
.filter((w) => !unavailableIds.has(w.id))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
if (nativeWidgets.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
data-slot="native-only-widgets"
|
||||
className="border-border/60 mt-6 space-y-2 border-t pt-4"
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{t("dashboard.layoutNativeOnlyTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("dashboard.layoutNativeOnlyHint")}
|
||||
</p>
|
||||
<div className="text-muted-foreground flex items-center gap-2 px-3 pt-2 pb-1 text-xs font-medium tracking-wide uppercase">
|
||||
<span className="flex-1" aria-hidden="true" />
|
||||
<span className="w-12 text-center">
|
||||
{t("dashboard.layoutTileColumn")}
|
||||
</span>
|
||||
<span className="w-12 text-center">
|
||||
{t("dashboard.layoutChartColumn")}
|
||||
</span>
|
||||
</div>
|
||||
{nativeWidgets.map((widget) => {
|
||||
const label = t(NATIVE_ONLY_WIDGET_LABEL_KEYS[widget.id] ?? widget.id);
|
||||
return (
|
||||
<StaticWidgetRow
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
disabled={saveMutation.isPending}
|
||||
labels={{
|
||||
tileColumn: t("dashboard.layoutTileColumn"),
|
||||
chartColumn: t("dashboard.layoutChartColumn"),
|
||||
widgetLabel: label,
|
||||
}}
|
||||
onToggleTile={toggleTile}
|
||||
onToggleChart={toggle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sortable row primitive extracted from the section render
|
||||
* so the @dnd-kit `useSortable` hook stays scoped to one row. Translation
|
||||
* strings are passed in pre-resolved (rather than calling `useTranslations`
|
||||
* inside) so this component stays cheap to re-render for the 13+ rows.
|
||||
*
|
||||
* The drag handle is the only listener-bearing surface — the row body
|
||||
* stays click-through so the switches and arrow buttons keep working.
|
||||
* Pointer activation has a 6 px distance constraint (configured on the
|
||||
* parent sensor) so a tap on the handle never accidentally drags.
|
||||
*/
|
||||
interface SortableWidgetRowProps {
|
||||
widget: {
|
||||
id: DashboardWidgetId;
|
||||
visible: boolean;
|
||||
tileVisible?: boolean;
|
||||
order: number;
|
||||
};
|
||||
labelKey: string;
|
||||
index: number;
|
||||
total: number;
|
||||
dragHintId: string;
|
||||
disabled: boolean;
|
||||
labels: {
|
||||
tileColumn: string;
|
||||
chartColumn: string;
|
||||
moveUp: string;
|
||||
moveDown: string;
|
||||
dragHandle: string;
|
||||
widgetLabel: string;
|
||||
};
|
||||
onToggleTile: (id: DashboardWidgetId, value: boolean) => void;
|
||||
onToggleChart: (id: DashboardWidgetId, value: boolean) => void;
|
||||
onMove: (id: DashboardWidgetId, delta: -1 | 1) => void;
|
||||
}
|
||||
|
||||
function SortableWidgetRow({
|
||||
widget,
|
||||
index,
|
||||
total,
|
||||
dragHintId,
|
||||
disabled,
|
||||
labels,
|
||||
onToggleTile,
|
||||
onToggleChart,
|
||||
onMove,
|
||||
}: SortableWidgetRowProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: widget.id });
|
||||
|
||||
// Honour the OS `prefers-reduced-motion` preference. The
|
||||
// rest of HealthLog pairs every transition with a `motion-reduce`
|
||||
// companion; dnd-kit's default `transform 250ms ease` was the lone
|
||||
// surface that ignored it. Short-circuit to `none` when reduced
|
||||
// motion is requested so dragged rows snap to place instead of
|
||||
// sliding through the list.
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: prefersReducedMotion() ? "none" : transition,
|
||||
};
|
||||
|
||||
const tileChecked =
|
||||
typeof widget.tileVisible === "boolean"
|
||||
? widget.tileVisible
|
||||
: widget.visible;
|
||||
|
||||
return (
|
||||
// Row sized at 48 px (`min-h-12`) with 44-px mobile tap
|
||||
// targets preserved on the trailing arrow buttons (`size-11`),
|
||||
// shrunk to `sm:size-9` on desktop. v1.4.47 W4 — `isDragging`
|
||||
// raises the row visually (elevation + accent ring) so the ghost
|
||||
// overlay is unambiguous during a drag.
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
data-slot="widget-row"
|
||||
data-dragging={isDragging ? "true" : undefined}
|
||||
className={`border-border bg-background/30 flex min-h-12 flex-wrap items-center gap-2 rounded-md border px-3 py-2 sm:flex-nowrap ${
|
||||
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={`${labels.dragHandle}: ${labels.widgetLabel}`}
|
||||
// `aria-describedby` is set after `{...attributes}`
|
||||
// so our shared hint paragraph wins over dnd-kit's own announcer
|
||||
// hookup. The announcer still fires on drag-start / drag-over /
|
||||
// drag-end via the screenReaderInstructions slot below.
|
||||
aria-describedby={dragHintId}
|
||||
title={labels.dragHandle}
|
||||
disabled={disabled}
|
||||
data-slot="widget-drag-handle"
|
||||
// W10 design-H1 — extend the WCAG 2.5.5 hit target to
|
||||
// 44 × 44 px via a `::before` pseudo-element while keeping the
|
||||
// visible GripVertical at 28 px (matches the Switch primitive
|
||||
// pattern from v1.4.43 W5-H1). v1.4.47 W10 design-M1 — drop
|
||||
// dnd-kit's CSS transition under prefers-reduced-motion.
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative -m-1 inline-flex h-7 w-7 cursor-grab touch-none items-center justify-center rounded transition-colors before:absolute before:inset-[-8px] before:content-[''] 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"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="flex-1 truncate text-sm" title={labels.widgetLabel}>
|
||||
{labels.widgetLabel}
|
||||
</span>
|
||||
{/* On a phone the two switches and two arrows (~250 px) left the name
|
||||
five characters ("Gewic…"). Below `sm` the controls wrap onto a
|
||||
second line, right-aligned under the column heads, and the name
|
||||
keeps the first line to itself. */}
|
||||
<span className="basis-full sm:hidden" aria-hidden="true" />
|
||||
<div className="flex w-12 justify-center max-sm:ml-auto">
|
||||
<Switch
|
||||
checked={tileChecked}
|
||||
onCheckedChange={(v) => onToggleTile(widget.id, v)}
|
||||
aria-label={`${labels.widgetLabel}: ${labels.tileColumn}`}
|
||||
disabled={disabled}
|
||||
data-slot="widget-tile-switch"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-12 justify-center">
|
||||
<Switch
|
||||
checked={widget.visible}
|
||||
onCheckedChange={(v) => onToggleChart(widget.id, v)}
|
||||
aria-label={`${labels.widgetLabel}: ${labels.chartColumn}`}
|
||||
disabled={disabled}
|
||||
data-slot="widget-chart-switch"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => onMove(widget.id, -1)}
|
||||
disabled={index === 0 || disabled}
|
||||
aria-label={labels.moveUp}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => onMove(widget.id, 1)}
|
||||
disabled={index === total - 1 || disabled}
|
||||
aria-label={labels.moveDown}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* issue #581 — one row in the native-only group: the same two switches as a
|
||||
* widget row above, without the drag handle or the arrow buttons. Placement
|
||||
* of these widgets belongs to the client that draws them, so the web offers
|
||||
* visibility and nothing it cannot honour.
|
||||
*/
|
||||
function StaticWidgetRow({
|
||||
widget,
|
||||
disabled,
|
||||
labels,
|
||||
onToggleTile,
|
||||
onToggleChart,
|
||||
}: {
|
||||
widget: {
|
||||
id: DashboardWidgetCatalogueId;
|
||||
visible: boolean;
|
||||
tileVisible?: boolean;
|
||||
order: number;
|
||||
};
|
||||
disabled: boolean;
|
||||
labels: { tileColumn: string; chartColumn: string; widgetLabel: string };
|
||||
onToggleTile: (id: DashboardWidgetCatalogueId, value: boolean) => void;
|
||||
onToggleChart: (id: DashboardWidgetCatalogueId, value: boolean) => void;
|
||||
}) {
|
||||
const tileChecked =
|
||||
typeof widget.tileVisible === "boolean"
|
||||
? widget.tileVisible
|
||||
: widget.visible;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="native-widget-row"
|
||||
data-widget-id={widget.id}
|
||||
className="border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2"
|
||||
>
|
||||
<span className="flex-1 truncate text-sm" title={labels.widgetLabel}>
|
||||
{labels.widgetLabel}
|
||||
</span>
|
||||
<div className="flex w-12 justify-center">
|
||||
<Switch
|
||||
checked={tileChecked}
|
||||
onCheckedChange={(v) => onToggleTile(widget.id, v)}
|
||||
aria-label={`${labels.widgetLabel}: ${labels.tileColumn}`}
|
||||
disabled={disabled}
|
||||
data-slot="widget-tile-switch"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-12 justify-center">
|
||||
<Switch
|
||||
checked={widget.visible}
|
||||
onCheckedChange={(v) => onToggleChart(widget.id, v)}
|
||||
aria-label={`${labels.widgetLabel}: ${labels.chartColumn}`}
|
||||
disabled={disabled}
|
||||
data-slot="widget-chart-switch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user