diff --git a/src/components/settings/access/__tests__/managed-profile-edit-patch.test.ts b/src/components/settings/access/__tests__/managed-profile-edit-patch.test.ts new file mode 100644 index 000000000..2de25b373 --- /dev/null +++ b/src/components/settings/access/__tests__/managed-profile-edit-patch.test.ts @@ -0,0 +1,68 @@ +/** + * What the managed-record edit form sends, and what it must not. + * + * The route reads an absent key as "leave it", so the diff this function + * computes is the whole of the promise that a save touches only what somebody + * moved. The case worth pinning is the language: a record with none opens its + * control on the ACTOR's, so comparing the control against the record's column + * made every save of such a record carry `locale` — and a guardian who only + * corrected the timezone wrote their own language into somebody else's record. + */ +import { describe, expect, it } from "vitest"; + +import { managedProfileEditPatch } from "@/components/settings/access/managed-profile-edit-form"; +import type { ManagedProfileView } from "@/lib/queries/use-managed-profiles"; + +const record: ManagedProfileView = { + id: "rec_1", + displayName: "Managed record", + dateOfBirth: null, + gender: null, + locale: null, + timezone: "Europe/Berlin", + recordKind: "managed", +}; + +const draft = { + displayName: "Managed record", + dateOfBirth: "", + locale: "en" as const, + gender: null, + timezone: "Europe/Berlin", +}; + +describe("managedProfileEditPatch", () => { + it("leaves the language out when a record with none only moves its timezone", () => { + const patch = managedProfileEditPatch( + record, + { ...draft, timezone: "Europe/Madrid" }, + "en", + ); + + expect(patch).toEqual({ timezone: "Europe/Madrid" }); + }); + + it("answers null when a record with no language is not touched at all", () => { + expect(managedProfileEditPatch(record, draft, "en")).toBeNull(); + }); + + it("sends the language once somebody picks a different one", () => { + const patch = managedProfileEditPatch( + record, + { ...draft, locale: "de" }, + "en", + ); + + expect(patch).toEqual({ locale: "de" }); + }); + + it("sends the language when a record that has one is moved off it", () => { + const patch = managedProfileEditPatch( + { ...record, locale: "de" }, + { ...draft, locale: "en" }, + "de", + ); + + expect(patch).toEqual({ locale: "en" }); + }); +}); diff --git a/src/components/settings/access/managed-profile-edit-form.tsx b/src/components/settings/access/managed-profile-edit-form.tsx index 44a0f4ba7..5d287647d 100644 --- a/src/components/settings/access/managed-profile-edit-form.tsx +++ b/src/components/settings/access/managed-profile-edit-form.tsx @@ -131,10 +131,12 @@ function ManagedProfileEditFields({ // A record with no recorded language opens on the actor's, which is the same // default the creation form uses. It is a starting point for a control the // person is looking at, not an inference written behind their back — nothing - // is sent until they save. - const [locale, setLocale] = useState( + // is sent unless they move it, which is why the seed is frozen at mount and + // handed to the patch builder as what "moved" is measured against. + const [seededLocale] = useState(() => isLocale(profile.locale) ? profile.locale : actorLocale, ); + const [locale, setLocale] = useState(seededLocale); const [gender, setGender] = useState(profile.gender); const [timezone, setTimezone] = useState(profile.timezone); const [error, setError] = useState(null); @@ -143,13 +145,17 @@ function ManagedProfileEditFields({ const timezoneIssue = isValidTimezone(timezone) ? null : "recordSharing.managed.timezoneInvalid"; - const patch = managedProfileEditPatch(profile, { - displayName, - dateOfBirth, - locale, - gender, - timezone, - }); + const patch = managedProfileEditPatch( + profile, + { + displayName, + dateOfBirth, + locale, + gender, + timezone, + }, + seededLocale, + ); const blocked = nameIssue !== null || timezoneIssue !== null || patch === null; @@ -333,13 +339,22 @@ interface ManagedProfileEditDraft { export function managedProfileEditPatch( profile: ManagedProfileView, draft: ManagedProfileEditDraft, + /** + * The language the control opened on, which is the record's own or — for a + * record that has none — the actor's. Compared against instead of + * `profile.locale` because those two differ exactly when the record has no + * language: comparing against the column made every save of a record like + * that carry `locale`, so moving only the timezone wrote the actor's + * language into somebody else's record. + */ + seededLocale: Locale, ): Omit | null { const displayName = draft.displayName.trim(); const dateOfBirth = draft.dateOfBirth.length > 0 ? draft.dateOfBirth : null; const patch: Omit = { ...(displayName !== (profile.displayName ?? "") ? { displayName } : {}), ...(dateOfBirth !== profile.dateOfBirth ? { dateOfBirth } : {}), - ...(draft.locale !== profile.locale ? { locale: draft.locale } : {}), + ...(draft.locale !== seededLocale ? { locale: draft.locale } : {}), ...(draft.gender !== profile.gender ? { gender: draft.gender } : {}), ...(draft.timezone !== profile.timezone ? { timezone: draft.timezone }