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 88f7941d0..4b58f4e43 100644 --- a/src/components/settings/__tests__/managed-record-settings-section.test.tsx +++ b/src/components/settings/__tests__/managed-record-settings-section.test.tsx @@ -5,7 +5,10 @@ import { I18nProvider } from "@/lib/i18n/context"; import { MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS } from "@/lib/record-settings"; import { ALL_METRICS } from "@/lib/validations/thresholds"; -import { ManagedRecordSettingsForm } from "../managed-record-settings-section"; +import { + managedModulesPatch, + ManagedRecordSettingsForm, +} from "../managed-record-settings-section"; const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const; @@ -201,3 +204,60 @@ describe("managed record settings forms", () => { }, ); }); + +describe("what a managed Modules save sends", () => { + const moduleKeys = Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS); + + /** The form as it stands after somebody flips one switch. */ + const formWith = (checked: Record) => { + const form = new FormData(); + for (const [name, on] of Object.entries(checked)) { + if (on) form.set(name, "on"); + } + return form; + }; + + it("omits the cycle flag when only a direct module moved", () => { + // The record tracks cycles and the guardian is turning Labs off. Sending + // the cycle switch's position here would write the sex-derived default as + // an explicit answer and freeze it against a later change of sex. + const settings = { + modulePreferences: {}, + cycleTrackingEnabled: true, + }; + const checked = Object.fromEntries( + moduleKeys.map((key) => [key, key !== "labs"]), + ); + + const patch = managedModulesPatch( + settings, + formWith({ ...checked, cycleTrackingEnabled: true }), + ); + + expect(patch.modulePreferences.labs).toBe(false); + expect("cycleTrackingEnabled" in patch).toBe(false); + }); + + it("sends the cycle flag when the cycle switch itself moved", () => { + const patch = managedModulesPatch( + { modulePreferences: {}, cycleTrackingEnabled: true }, + formWith(Object.fromEntries(moduleKeys.map((key) => [key, true]))), + ); + + expect(patch.cycleTrackingEnabled).toBe(false); + }); + + it("sends the cycle flag when a record with none turns it on", () => { + // `cycleTrackingEnabled` absent is the record that never answered; the + // switch opens off, so turning it on is a change and has to ride. + const patch = managedModulesPatch( + { modulePreferences: {} }, + formWith({ + ...Object.fromEntries(moduleKeys.map((key) => [key, true])), + cycleTrackingEnabled: true, + }), + ); + + expect(patch.cycleTrackingEnabled).toBe(true); + }); +}); diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx index f7970da7e..e612daae2 100644 --- a/src/components/settings/managed-record-settings-section.tsx +++ b/src/components/settings/managed-record-settings-section.tsx @@ -484,6 +484,49 @@ function ProfileSettingsForm({ ); } +/** + * What a Modules save actually sends. + * + * The direct modules ride along whether or not they moved — they are one blob + * the route merges, and a key that did not change writes the value it already + * had. `cycleTrackingEnabled` is not one of them: it is a DELEGATED module + * whose column the cycle gate honours OVER the sex-derived default, so sending + * the switch's current position on every save freezes the derivation. A record + * created with no recorded sex derives cycle OFF; a guardian who turns Labs off + * and saves would write that OFF as an explicit answer, and the record's sex + * being set to FEMALE afterwards would then leave cycle off with nothing on + * screen to say why. It also put `cycleTrackingEnabled` in the record's audit + * trail on every save of any module. + * + * So it is sent only when it differs from the value the form opened on, which + * is what the managed-record edit form's `managedProfileEditPatch` already does + * for its own five fields. + * + * Exported and pure because it is the decision this form makes, and a decision + * about what NOT to send is invisible in the rendered markup. + */ +export function managedModulesPatch( + settings: Record, + form: FormData, +): { + modulePreferences: Record; + cycleTrackingEnabled?: boolean; +} { + const cycleTrackingEnabled = form.get("cycleTrackingEnabled") === "on"; + // The same expression the switch is seeded from, so "differs from what the + // form opened on" is one statement rather than two that can drift. + const loaded = settings.cycleTrackingEnabled === true; + return { + modulePreferences: Object.fromEntries( + Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS).map((key) => [ + key, + form.get(key) === "on", + ]), + ), + ...(cycleTrackingEnabled !== loaded ? { cycleTrackingEnabled } : {}), + }; +} + function ModulesSettingsForm({ settings, disabled, @@ -500,13 +543,7 @@ function ModulesSettingsForm({ function submit(event: FormEvent) { event.preventDefault(); - const form = new FormData(event.currentTarget); - onSave({ - modulePreferences: Object.fromEntries( - entries.map(([key]) => [key, form.get(key) === "on"]), - ), - cycleTrackingEnabled: form.get("cycleTrackingEnabled") === "on", - }); + onSave(managedModulesPatch(settings, new FormData(event.currentTarget))); } return (