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({