diff --git a/src/components/settings/__tests__/managed-record-settings-section.test.tsx b/src/components/settings/__tests__/managed-record-settings-section.test.tsx index 21d85d4b4..d40aaa14f 100644 --- a/src/components/settings/__tests__/managed-record-settings-section.test.tsx +++ b/src/components/settings/__tests__/managed-record-settings-section.test.tsx @@ -13,6 +13,7 @@ const render = ( family: Parameters[0]["family"], settings: Record, locale: (typeof LOCALES)[number] = "en", + error?: string, ) => renderToStaticMarkup( @@ -22,6 +23,7 @@ const render = ( onSave={vi.fn()} saveLabel="Save" settings={settings} + error={error} /> , ); @@ -72,6 +74,53 @@ describe("managed record settings forms", () => { }, ); + it("gives the profile date of birth a DateField, never a native date input", () => { + const html = render("profile", { timezone: "Europe/Berlin" }); + // The DateField primitive marks itself; the browser-locale native + // `type="date"` trap (v1.25.4) must be gone. + expect(html).toContain('data-slot="date-field"'); + expect(html).not.toContain('type="date"'); + // Its hidden mirror still carries the submit name. + expect(html).toContain('name="dateOfBirth"'); + }); + + it.each([ + "profile", + "modules", + "notifications", + "thresholds", + "coach", + ] as const)( + "orders the %s action row last, with the error above it (§12)", + (family) => { + const settingsByFamily: Record> = { + profile: { timezone: "Europe/Berlin" }, + modules: { modulePreferences: {} }, + notifications: {}, + thresholds: { overrides: {}, effective: {} }, + coach: {}, + }; + const html = render(family, settingsByFamily[family], "en", "Boom"); + const actionAt = html.indexOf('data-slot="settings-card-actions"'); + const alertAt = html.indexOf('role="alert"'); + // The action row exists and is a real SettingsCardActions. + expect(actionAt).toBeGreaterThan(-1); + // The error line renders, and it sits ABOVE the action row. + expect(alertAt).toBeGreaterThan(-1); + expect(alertAt).toBeLessThan(actionAt); + // Nothing renders after the action row inside the form. + expect(html.indexOf("")).toBeGreaterThan(actionAt); + }, + ); + + it("toggles the managed modules with a Switch, matching the owner section", () => { + const html = render("modules", { modulePreferences: { mood: true } }); + // The Switch primitive (role="switch") replaces the bare checkbox the + // owner module section never used; the hidden mirror keeps the submit name. + expect(html).toContain('role="switch"'); + expect(html).toContain('name="mood"'); + }); + it("renders every direct module and threshold metric for a fresh record", () => { const modules = render("modules", { modulePreferences: {} }); const thresholds = render("thresholds", { diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx index b72d0708d..be5bcdfa4 100644 --- a/src/components/settings/managed-record-settings-section.tsx +++ b/src/components/settings/managed-record-settings-section.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, type FormEvent } from "react"; +import { useState, type FormEvent, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Settings } from "lucide-react"; @@ -26,7 +26,13 @@ import { import { ALL_METRICS } from "@/lib/validations/thresholds"; import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip"; import { Button } from "@/components/ui/button"; +import { DateField } from "@/components/ui/date-field"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { Switch } from "@/components/ui/switch"; +import { SettingsCardActions } from "./_card-actions"; import { SettingsCardHeader } from "./_card-header"; import { SettingsCard } from "./settings-card"; @@ -45,6 +51,8 @@ type SettingsFormProps = { disabled: boolean; onSave: (patch: unknown) => void; saveLabel: string; + /** Save/validation error, rendered ABOVE the action row per §12. */ + error?: ReactNode; }; const COACH_EXCLUDE_METRICS = [ @@ -222,11 +230,76 @@ function insightItemLabel( : t("settings.sharedRecord.managedSettings.insights.unknownItem"); } -function SaveButton({ disabled, label }: { disabled: boolean; label: string }) { +/** + * The form footer, §12: the error line sits ABOVE the action row, and the + * action row (`SettingsCardActions`) is the last thing in the form. `span` + * lets a grid form put the footer in a full-width cell. + */ +function FormFooter({ + disabled, + label, + error, + span, +}: { + disabled: boolean; + label: string; + error?: ReactNode; + span?: string; +}) { return ( - +
+ {error ? ( +

+ {error} +

+ ) : null} + + + +
+ ); +} + +/** + * A managed-settings toggle row — a `` (the same primitive the owner + * module section uses) paired with a hidden mirror input so the FormData + * submit still reads `name === "on"`. Uncontrolled at the DOM level, controlled + * in React so the switch and its mirror stay in lockstep. + */ +function ManagedSwitchRow({ + name, + label, + defaultChecked, + disabled, + className, +}: { + name: string; + label: string; + defaultChecked: boolean; + disabled: boolean; + className?: string; +}) { + const id = `managed-switch-${name}`; + const [on, setOn] = useState(defaultChecked); + return ( +
+ + + +
); } @@ -235,6 +308,7 @@ function ProfileSettingsForm({ disabled, onSave, saveLabel, + error, }: SettingsFormProps) { const { t } = useTranslations(); function submit(event: FormEvent) { @@ -259,8 +333,7 @@ function ProfileSettingsForm({