Files
HealthLog/src/components/settings/module-toggle-row.tsx
T
Marc-André Bombeck 91d01d154a fix(settings): make delegated module rows read-only managed deep-links
The Module hub rendered live switches for the delegated keys cycle and coach,
but the gate resolves those from their own sources (cycle from CycleProfile,
coach from the operator flag + disableCoach), so a toggle wrote an inert entry
to modulePreferencesJson, snapped back, and still fired a success toast. Render
delegated modules as read-only rows that deep-link to their real control, strip
delegated keys from the modules PATCH schema so no inert value is stored, drop
the duplicate cycle control, and surface an operator-disabled state with the
previously orphaned hint string.
2026-06-16 00:51:16 +02:00

141 lines
5.1 KiB
TypeScript

"use client";
/**
* `<ModuleToggleRow>` — one row in the Settings → Module ("Was du trackst")
* hub. v1.18.0.
*
* Renders a single module: a neutral icon, the i18n label + one-line
* description, and a `<Switch>` reflecting the resolved enabled-state. The
* row is presentation-only — the parent `<ModulesSection>` owns the mutation
* and cache invalidation, so this component stays a pure, easily-tested leaf.
*
* Four modes:
* - toggleable: the Switch is live; flipping it calls `onToggle(next)`.
* - locked (core domains): the Switch is checked + disabled with a short
* "always on" note, so the always-on measurement engine + meds read as
* deliberately fixed rather than broken.
* - managed elsewhere (delegated modules cycle/coach): no Switch at all —
* the real on/off lives in another section, so the row renders a
* read-only deep-link ("Manage in X") rather than a dead toggle that
* no-ops and snaps back behind a misleading "saved" toast.
* - operator-disabled: the operator turned the module off server-wide, so
* the Switch is replaced by a read-only "disabled server-wide" note —
* a per-user toggle could not re-enable it anyway.
*
* Palette stays neutral throughout — no alarming green-when-on /
* red-when-off tint.
*/
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
export interface ModuleToggleRowProps {
/** Stable key — used for the input id + the test query handle. */
moduleKey: string;
icon: LucideIcon;
label: string;
description: string;
/** Whether the Switch reads on. Core rows pass `true`. */
enabled: boolean;
/**
* When set the row is read-only: the Switch is locked checked and the
* `lockedNote` line renders beneath the description. Core domains use this.
*/
locked?: boolean;
/** One-line "core / always on" note, only shown when `locked`. */
lockedNote?: string;
/**
* Delegated modules (cycle/coach): the real control lives elsewhere. When
* set, the row renders a read-only deep-link instead of a Switch.
* `label` is the destination name; `href` the in-app link.
*/
managedAt?: { href: string; label: string };
/** Localised "Manage in {section}" link text (managed-elsewhere rows). */
manageLinkLabel?: string;
/**
* Operator turned this module off server-wide. Replaces the Switch with a
* read-only note; a per-user toggle cannot re-enable it. `operatorNote`
* is the localised "disabled server-wide" copy.
*/
operatorDisabled?: boolean;
operatorNote?: string;
/** Disable the Switch while a mutation is in flight (toggleable rows). */
pending?: boolean;
/** Fired with the next desired state on a user toggle (toggleable rows). */
onToggle?: (next: boolean) => void;
}
export function ModuleToggleRow({
moduleKey,
icon: Icon,
label,
description,
enabled,
locked = false,
lockedNote,
managedAt,
manageLinkLabel,
operatorDisabled = false,
operatorNote,
pending = false,
onToggle,
}: ModuleToggleRowProps) {
const inputId = `module-toggle-${moduleKey}`;
// A delegated module whose real control lives elsewhere, or an
// operator-disabled module: either way no live Switch — the per-user
// toggle would be a dead no-op.
const managed = managedAt != null;
return (
<div className="flex items-start justify-between gap-4 py-3">
<div className="flex min-w-0 items-start gap-3">
<Icon
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 space-y-0.5">
{managed ? (
<p className="text-sm font-medium">{label}</p>
) : (
<Label htmlFor={inputId} className="text-sm font-medium">
{label}
</Label>
)}
<p className="text-muted-foreground text-xs">{description}</p>
{locked && lockedNote ? (
<p className="text-muted-foreground/80 text-xs">{lockedNote}</p>
) : null}
</div>
</div>
{operatorDisabled ? (
// Operator kill-switch: read-only note, no toggle.
<span className="text-muted-foreground/80 mt-0.5 shrink-0 text-xs">
{operatorNote}
</span>
) : managed ? (
// Delegated module: deep-link to the real control instead of a
// dead toggle.
<Link
href={managedAt.href}
className="text-muted-foreground hover:text-foreground mt-0.5 inline-flex shrink-0 items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{manageLinkLabel}
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
) : (
<Switch
id={inputId}
checked={locked ? true : enabled}
disabled={locked || pending}
onCheckedChange={locked ? undefined : (v) => onToggle?.(v)}
className="mt-0.5 shrink-0"
aria-label={label}
/>
)}
</div>
);
}