fix(settings): send a managed record's cycle flag only when it moved

The Modules save posted `cycleTrackingEnabled` on every submit, and the route
writes it whenever it is present. An explicit boolean beats the sex-derived
default in the cycle gate, so a guardian turning Labs off for a record created
without a recorded sex wrote cycle OFF as a deliberate answer; setting the
record's sex to FEMALE afterwards then left cycle off with nothing on screen
saying why. It also listed `cycleTrackingEnabled` as changed in the record's
audit trail on every save of any module.

The flag now rides only when it differs from the value the form opened on,
which is what the edit form's `managedProfileEditPatch` already does. The
decision moved into a pure `managedModulesPatch` so a test can see it — what a
form does NOT send is invisible in its markup.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 01:56:17 +02:00
1 parent 0cccb4de99
commit 8ff1b865fc
2 files changed
+105 -8

No files matched your search

@@ -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<string, boolean>) => {
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);
});
});
@@ -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<string, unknown>,
form: FormData,
): {
modulePreferences: Record<string, boolean>;
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<HTMLFormElement>) {
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 (