From 52d6709dd2e5508ab932647b9a9ab91fbb83b6d7 Mon Sep 17 00:00:00 2001 From: Youhao Wei Date: Sun, 17 May 2026 22:46:38 -0700 Subject: [PATCH] fix: make demographics race "Decline to respond" option exclusive The race question on the application demographics step allowed "Decline to respond" to be selected alongside other race options. Wire the race FieldGroup so checking "Decline to respond" clears all other race options, and checking any other option clears "Decline to respond", reusing the shared setExclusive helper that powers the same behavior in the application preferences/programs step. Closes #2884 Co-Authored-By: Claude Opus 4.7 --- .../applications/review/demographics.test.tsx | 58 ++++++++++++++++++ .../applications/review/demographics.tsx | 59 ++++++++++++++++++- 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/sites/public/__tests__/pages/applications/review/demographics.test.tsx b/sites/public/__tests__/pages/applications/review/demographics.test.tsx index 93aaacfe131..935e676c756 100644 --- a/sites/public/__tests__/pages/applications/review/demographics.test.tsx +++ b/sites/public/__tests__/pages/applications/review/demographics.test.tsx @@ -233,6 +233,64 @@ describe("Demographics", () => { fireEvent.click(screen.getByRole("checkbox", { name: "Other / Multiracial" })) expect(await screen.findAllByTestId("otherMultiracial")).toHaveLength(2) }) + + describe("'Decline to respond' race exclusivity", () => { + const renderDemographics = () => { + const conductor = new ApplicationConductor({}, {}) + conductor.config.featureFlags = [ + { name: FeatureFlagEnum.enableLimitedHowDidYouHear, active: false } as FeatureFlag, + ] + conductor.config.raceEthnicityConfiguration = defaultRaceEthnicityConfiguration + + render( + { + return + }, + syncListing: () => { + return + }, + }} + > + + + ) + } + + it("clears other race options when 'Decline to respond' is checked", () => { + renderDemographics() + const asian = screen.getByRole("checkbox", { name: "Asian" }) + const white = screen.getByRole("checkbox", { name: "White" }) + const decline = screen.getByRole("checkbox", { name: "Decline to respond" }) + + fireEvent.click(asian) + fireEvent.click(white) + expect(asian).toBeChecked() + expect(white).toBeChecked() + + fireEvent.click(decline) + expect(decline).toBeChecked() + expect(asian).not.toBeChecked() + expect(white).not.toBeChecked() + }) + + it("clears 'Decline to respond' when another race option is checked", () => { + renderDemographics() + const decline = screen.getByRole("checkbox", { name: "Decline to respond" }) + const white = screen.getByRole("checkbox", { name: "White" }) + + fireEvent.click(decline) + expect(decline).toBeChecked() + + fireEvent.click(white) + expect(white).toBeChecked() + expect(decline).not.toBeChecked() + }) + }) }) it("should show limited list of how did you hear fields when enableLimitedHowDidYouHear is on", () => { diff --git a/sites/public/src/pages/applications/review/demographics.tsx b/sites/public/src/pages/applications/review/demographics.tsx index 7e371365f00..b86f341cccd 100644 --- a/sites/public/src/pages/applications/review/demographics.tsx +++ b/sites/public/src/pages/applications/review/demographics.tsx @@ -20,6 +20,7 @@ import { limitedHowDidYouHear, getRaceEthnicityOptions, genderKeys, + setExclusive, } from "@bloom-housing/shared-helpers" import FormsLayout from "../../../layouts/forms" import { isFeatureFlagOn } from "../../../lib/helpers" @@ -27,6 +28,9 @@ import { useFormConductor } from "../../../lib/hooks" import { UserStatus } from "../../../lib/constants" import ApplicationFormLayout from "../../../layouts/application-form" +// Race option that is mutually exclusive with every other race option. +const RACE_DECLINE_TO_RESPOND = "declineToRespond" + const ApplicationDemographics = () => { const { profile } = useContext(AuthContext) const { conductor, application, listing } = useFormConductor("demographics") @@ -39,7 +43,7 @@ const ApplicationDemographics = () => { currentPageSection += 1 // eslint-disable-next-line @typescript-eslint/unbound-method - const { register, handleSubmit, watch } = useForm({ + const { register, handleSubmit, watch, setValue } = useForm({ defaultValues: { ethnicity: application.demographics?.ethnicity, race: application.demographics?.race, @@ -106,8 +110,45 @@ const ApplicationDemographics = () => { })) } + const raceKeys = useMemo( + () => getRaceEthnicityOptions(conductor.config.raceEthnicityConfiguration), + [conductor.config.raceEthnicityConfiguration] + ) + + // Field names FieldGroup registers for the race group, e.g. "race-white". Because + // the race options have subFields, FieldGroup names every input `${name}-${value}`. + const allRaceFieldNames = useMemo( + () => + raceKeys + ? Object.entries(raceKeys).flatMap(([rootKey, subKeys]) => [ + `race-${rootKey}`, + ...subKeys.map((subKey) => `race-${subKey}`), + ]) + : [], + [raceKeys] + ) + + const exclusiveRaceFieldNames = useMemo( + () => allRaceFieldNames.filter((fieldName) => fieldName === `race-${RACE_DECLINE_TO_RESPOND}`), + [allRaceFieldNames] + ) + + // Checking "Decline to respond" clears every other race option; checking any + // other option clears "Decline to respond". Reuses the shared setExclusive + // helper that powers the same behavior in ApplicationMultiselectQuestionStep. + const handleRaceExclusiveChange = ( + changedFieldName: string, + isExclusiveOption: boolean, + checked: boolean + ) => { + if (isExclusiveOption && checked) { + setExclusive(true, setValue, exclusiveRaceFieldNames, changedFieldName, allRaceFieldNames) + } else if (!isExclusiveOption) { + setExclusive(false, setValue, exclusiveRaceFieldNames, changedFieldName, allRaceFieldNames) + } + } + const raceOptions = useMemo(() => { - const raceKeys = getRaceEthnicityOptions(conductor.config.raceEthnicityConfiguration) if (!raceKeys) return [] return Object.keys(raceKeys).map((rootKey) => ({ id: rootKey, @@ -116,6 +157,14 @@ const ApplicationDemographics = () => { additionalText: rootKey.indexOf("other") >= 0, defaultChecked: isKeyIncluded(rootKey, application.demographics?.race), defaultText: getCustomValue(rootKey, application.demographics?.race), + inputProps: { + onChange: (e: React.ChangeEvent) => + handleRaceExclusiveChange( + `race-${rootKey}`, + rootKey === RACE_DECLINE_TO_RESPOND, + e.target.checked + ), + }, subFields: raceKeys[rootKey].map((subKey) => ({ id: subKey, label: t(`application.review.demographics.raceOptions.${subKey}`), @@ -123,12 +172,16 @@ const ApplicationDemographics = () => { defaultChecked: isKeyIncluded(subKey, application.demographics?.race), additionalText: subKey.indexOf("other") >= 0, defaultText: getCustomValue(subKey, application.demographics?.race), + inputProps: { + onChange: (e: React.ChangeEvent) => + handleRaceExclusiveChange(`race-${subKey}`, false, e.target.checked), + }, dataTestId: subKey, })), dataTestId: rootKey, })) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [register]) + }, [register, raceKeys]) useEffect(() => { pushGtmEvent({