diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 585a70ab55..621349c334 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -957,7 +957,6 @@
"Counseling": "Counseling",
"Counseling that is not for the treatment of a medical condition": "Counseling that is not for the treatment of a medical condition",
"Country": "Country",
- "Couple in hotel/dorm room": "Couple in hotel/dorm room",
"Couples": "Couples",
"Create": "Create",
"Create a new contact for \"{{ name }}\"": "Create a new contact for \"{{ name }}\"",
@@ -1297,7 +1296,7 @@
"Fair Rental Value": "Fair Rental Value",
"Falkland Islands (Malvinas)": "Falkland Islands (Malvinas)",
"false": "false",
- "Family in a hotel/room": "Family in a hotel/room",
+ "Family in hotel/suite dorm room": "Family in hotel/suite dorm room",
"Family Size": "Family Size",
"Family Size must be one of the options": "Family Size must be one of the options",
"Family Status": "Family Status",
@@ -1758,7 +1757,6 @@
"Loading donations graph": "Loading donations graph",
"Loading MPD goals": "Loading MPD goals",
"Loading...": "Loading...",
- "Local / Commuting": "Local / Commuting",
"Locale": "Locale",
"Location": "Location",
"Log": "Log",
@@ -1805,6 +1803,7 @@
"Mark all as read": "Mark all as read",
"Marked Do Not Ask": "Marked Do Not Ask",
"Married": "Married",
+ "Married couple in hotel/suite dorm room": "Married couple in hotel/suite dorm room",
"Marshall Islands": "Marshall Islands",
"Martinique": "Martinique",
"Mauritania": "Mauritania",
@@ -2701,7 +2700,7 @@
"Share Account": "Share Account",
"Share this ministry account with other team members": "Share this ministry account with other team members",
"Share this organization with other team members": "Share this organization with other team members",
- "Sharing 2 in hotel/dorm room": "Sharing 2 in hotel/dorm room",
+ "Sharing 2 in hotel/suite dorm room": "Sharing 2 in hotel/suite dorm room",
"Short Term Assignment": "Short Term Assignment",
"Show {{amount}} More": "Show {{amount}} More",
"Show {{partnerCount}} Partners": "Show {{partnerCount}} Partners",
@@ -3282,6 +3281,7 @@
"View/Edit {{name}}": "View/Edit {{name}}",
"Virgin Islands, British": "Virgin Islands, British",
"Virgin Islands, U.S.": "Virgin Islands, U.S.",
+ "Virtual / Local / Commuting": "Virtual / Local / Commuting",
"Voluntary 403b Retirement Plan": "Voluntary 403b Retirement Plan",
"Wallis and Futuna": "Wallis and Futuna",
"Want your monthly transfer to end at a certain point? You can set an end date—super handy! Just a heads-up: once it’s there, it can’t be removed, but you can change it to a different date if needed.": "Want your monthly transfer to end at a certain point? You can set an end date—super handy! Just a heads-up: once it’s there, it can’t be removed, but you can change it to a different date if needed.",
@@ -3394,11 +3394,13 @@
"You have a Board approved Maximum Allowable Salary (CAP) and your Additional Salary Request exceeds that amount. As a result we need to get their approval for this request. We'll forward your request to them and get back to you with their decision.": "You have a Board approved Maximum Allowable Salary (CAP) and your Additional Salary Request exceeds that amount. As a result we need to get their approval for this request. We'll forward your request to them and get back to you with their decision.",
"You have a Board approved Maximum Allowable Salary (CAP) and your salary request exceeds that amount. As a result we need to get their approval for this request. We'll forward your request to them and get back to you with their decision.": "You have a Board approved Maximum Allowable Salary (CAP) and your salary request exceeds that amount. As a result we need to get their approval for this request. We'll forward your request to them and get back to you with their decision.",
"You have a previously approved Maximum Allowable Salary (CAP). Any adjustment that may exceed this cap must be submitted for further approval.": "You have a previously approved Maximum Allowable Salary (CAP). Any adjustment that may exceed this cap must be submitted for further approval.",
+ "You have a roommate, or your own bedroom in a shared suite.": "You have a roommate, or your own bedroom in a shared suite.",
"You have a saved Additional Salary Request in progress that has not been submitted.": "You have a saved Additional Salary Request in progress that has not been submitted.",
"You have no designation accounts": "You have no designation accounts",
"You have no expected donations this month": "You have no expected donations this month",
"You have no Google Contact groups/labels": "You have no Google Contact groups/labels",
"You have no responsibility centers.": "You have no responsibility centers.",
+ "You have no roommate and no suitemates.": "You have no roommate and no suitemates.",
"You have reached the end of the goal calculator.": "You have reached the end of the goal calculator.",
"You have received no donations in the last fourteen months": "You have received no donations in the last fourteen months",
"You haven't connected a Google account yet": "You haven't connected a Google account yet",
@@ -3419,7 +3421,9 @@
"You'll also need to search by contact name, email address, phone number, or partner number.": "You'll also need to search by contact name, email address, phone number, or partner number.",
"You're all done": "You're all done",
"You're all set!": "You're all set!",
+ "You're attending virtually or commuting daily and not using NSO housing.": "You're attending virtually or commuting daily and not using NSO housing.",
"You're currently being logged out.": "You're currently being logged out.",
+ "You're staying together as a couple or family.": "You're staying together as a couple or family.",
"You've successfully submitted your {{formTitle}}!": "You've successfully submitted your {{formTitle}}!",
"You've successfully submitted your Salary Calculation Form!": "You've successfully submitted your Salary Calculation Form!",
"You've successfully updated your {{formTitle}}!": "You've successfully updated your {{formTitle}}!",
diff --git a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx
index 9f058764dd..ad5de1ff15 100644
--- a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx
+++ b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx
@@ -28,23 +28,57 @@ describe('NsoDetails', () => {
getByRole('radio', { name: 'Single in hotel/dorm room' }),
).toBeInTheDocument();
expect(
- getByRole('radio', { name: 'Sharing 2 in hotel/dorm room' }),
+ getByRole('radio', { name: 'Sharing 2 in hotel/suite dorm room' }),
).toBeInTheDocument();
expect(
- getByRole('radio', { name: 'Couple in hotel/dorm room' }),
+ getByRole('radio', { name: 'Married couple in hotel/suite dorm room' }),
).toBeInTheDocument();
expect(
- getByRole('radio', { name: 'Family in a hotel/room' }),
+ getByRole('radio', { name: 'Family in hotel/suite dorm room' }),
).toBeInTheDocument();
expect(
- getByRole('radio', { name: 'Local / Commuting' }),
+ getByRole('radio', { name: 'Virtual / Local / Commuting' }),
).toBeInTheDocument();
});
+ it.each([
+ ['Single in hotel/dorm room', 'You have no roommate and no suitemates.'],
+ [
+ 'Sharing 2 in hotel/suite dorm room',
+ 'You have a roommate, or your own bedroom in a shared suite.',
+ ],
+ [
+ 'Married couple in hotel/suite dorm room',
+ "You're staying together as a couple or family.",
+ ],
+ [
+ 'Family in hotel/suite dorm room',
+ "You're staying together as a couple or family.",
+ ],
+ [
+ 'Virtual / Local / Commuting',
+ "You're attending virtually or commuting daily and not using NSO housing.",
+ ],
+ ])(
+ 'explains the "%s" housing option under its label',
+ (label, description) => {
+ const { getByRole } = render();
+
+ const descriptionId = getByRole('radio', { name: label }).getAttribute(
+ 'aria-describedby',
+ );
+ expect(document.getElementById(descriptionId ?? '')).toHaveTextContent(
+ description,
+ );
+ },
+ );
+
it('saves the housing enum constant', async () => {
const { getByRole } = render();
- userEvent.click(getByRole('radio', { name: 'Family in a hotel/room' }));
+ userEvent.click(
+ getByRole('radio', { name: 'Family in hotel/suite dorm room' }),
+ );
await waitFor(() =>
expect(mutationSpy).toHaveGraphqlOperation(
diff --git a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx
index 68a42ebb58..3ae92f545d 100644
--- a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx
+++ b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx
@@ -3,6 +3,15 @@ import { Stack } from '@mui/material';
import { TFunction, useTranslation } from 'react-i18next';
import * as yup from 'yup';
import { CurrencyAdornment } from 'src/components/HrTools/Shared/Adornments';
+import {
+ NewStaffQuestionnaireNsoHousingEnum,
+ NewStaffQuestionnaireNsoSessionsEnum,
+} from 'src/graphql/types.generated';
+import {
+ getLocalizedNsoHousing,
+ getLocalizedNsoHousingDescription,
+} from 'src/lib/functions/getLocalizedNsoHousing';
+import { getLocalizedNsoSessions } from 'src/lib/functions/getLocalizedNsoSessions';
import { NumberQuestion } from '../Shared/NumberQuestion';
import { RadioOption, RadioQuestion } from '../Shared/RadioQuestion';
import { getAmountSchema } from '../Shared/helpers/getAmountSchema';
@@ -24,18 +33,30 @@ export const NsoDetails: React.FC = () => {
const schema = useMemo(() => getNsoDetailsSchema(t), [t]);
- const housingOptions: RadioOption[] = [
- { value: 'SINGLE_ROOM', label: t('Single in hotel/dorm room') },
- { value: 'SHARED_ROOM', label: t('Sharing 2 in hotel/dorm room') },
- { value: 'COUPLE_ROOM', label: t('Couple in hotel/dorm room') },
- { value: 'FAMILY_ROOM', label: t('Family in a hotel/room') },
- { value: 'LOCAL_COMMUTING', label: t('Local / Commuting') },
- ];
+ const housingOptions = useMemo(
+ () =>
+ [
+ NewStaffQuestionnaireNsoHousingEnum.SingleRoom,
+ NewStaffQuestionnaireNsoHousingEnum.SharedRoom,
+ NewStaffQuestionnaireNsoHousingEnum.CoupleRoom,
+ NewStaffQuestionnaireNsoHousingEnum.FamilyRoom,
+ NewStaffQuestionnaireNsoHousingEnum.LocalCommuting,
+ ].map((value) => ({
+ value,
+ label: getLocalizedNsoHousing(t, value),
+ description: getLocalizedNsoHousingDescription(t, value),
+ })),
+ [t],
+ );
- const sessionOptions: RadioOption[] = [
- { value: 'IBS_AND_NSO', label: t('IBS and NSO') },
- { value: 'NSO', label: t('NSO') },
- ];
+ const sessionOptions = useMemo(
+ () =>
+ [
+ NewStaffQuestionnaireNsoSessionsEnum.IbsAndNso,
+ NewStaffQuestionnaireNsoSessionsEnum.Nso,
+ ].map((value) => ({ value, label: getLocalizedNsoSessions(t, value) })),
+ [t],
+ );
return (
diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.test.tsx
index 37de9b32a2..a2c13f6758 100644
--- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.test.tsx
+++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.test.tsx
@@ -77,6 +77,31 @@ describe('RadioQuestion', () => {
);
});
+ it('shows an option description under its label and links it to the radio', () => {
+ const { getByRole, getByText } = render(
+
+
+ ,
+ );
+
+ const radio = getByRole('radio', { name: 'Option A' });
+ expect(getByText('The first choice')).toHaveAttribute(
+ 'id',
+ radio.getAttribute('aria-describedby'),
+ );
+ expect(getByRole('radio', { name: 'Option B' })).not.toHaveAttribute(
+ 'aria-describedby',
+ );
+ });
+
it('lays options out in a row when row is set', () => {
const { getByRole } = render();
diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx b/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx
index 6bda031648..302a9c6fa7 100644
--- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx
+++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx
@@ -1,6 +1,7 @@
import React from 'react';
-import { FormControlLabel, Radio, RadioGroup } from '@mui/material';
+import { RadioGroup } from '@mui/material';
import * as yup from 'yup';
+import { DescribedRadioOption } from 'src/components/HrTools/Shared/DescribedRadioOption';
import { LabeledField } from './LabeledField';
import {
QuestionnaireField,
@@ -10,6 +11,8 @@ import {
export interface RadioOption {
value: string;
label: string;
+ /** Shown under the label in muted text to clarify what the option means. */
+ description?: string;
}
interface RadioQuestionProps {
@@ -38,23 +41,20 @@ export const RadioQuestion: React.FC = ({
saveOnChange: true,
});
+ const hasDescriptions = options.some((option) => option.description);
+
return (
{(aria) => (
{options.map((option) => (
- }
- label={option.label}
- />
+
))}
)}
diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx
index 327337b9b3..bcdd3f1f69 100644
--- a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx
+++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx
@@ -7,18 +7,16 @@ import {
DialogContent,
DialogTitle,
FormControl,
- FormControlLabel,
FormLabel,
- Radio,
RadioGroup,
SxProps,
Theme,
- Typography,
} from '@mui/material';
import { visuallyHidden } from '@mui/utils';
import { Formik, FormikProps } from 'formik';
import { useTranslation } from 'react-i18next';
import * as yup from 'yup';
+import { DescribedRadioOption } from 'src/components/HrTools/Shared/DescribedRadioOption';
import { DesignationSupportFormType } from 'src/graphql/types.generated';
export interface CreateGoalDialogProps {
@@ -110,48 +108,18 @@ export const CreateGoalDialog: React.FC = ({
name="formType"
value={values.formType}
onChange={handleChange}
+ sx={{ gap: 2 }}
>
- {formTypeOptions.map(
- ({ value, title, description }, index) => (
-
- }
- label={
- <>
-
- {title}
-
-
- {description}
-
- >
- }
- sx={{
- alignItems: 'flex-start',
- mb: index < formTypeOptions.length - 1 ? 2 : 0,
- }}
- />
- ),
- )}
+ {formTypeOptions.map(({ value, title, description }) => (
+
+ ))}
diff --git a/src/components/HrTools/Shared/DescribedRadioOption.test.tsx b/src/components/HrTools/Shared/DescribedRadioOption.test.tsx
new file mode 100644
index 0000000000..8c0cc38aad
--- /dev/null
+++ b/src/components/HrTools/Shared/DescribedRadioOption.test.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { RadioGroup } from '@mui/material';
+import { render } from '@testing-library/react';
+import { DescribedRadioOption } from './DescribedRadioOption';
+
+describe('DescribedRadioOption', () => {
+ it('names the radio by its label alone and links the description via aria-describedby', () => {
+ const { getByRole, getByText } = render(
+
+
+ ,
+ );
+
+ const radio = getByRole('radio', { name: 'Option A' });
+ expect(getByText('The first choice')).toHaveAttribute(
+ 'id',
+ radio.getAttribute('aria-describedby'),
+ );
+ });
+
+ it('omits aria-describedby when there is no description', () => {
+ const { getByRole } = render(
+
+
+ ,
+ );
+
+ expect(getByRole('radio', { name: 'Option B' })).not.toHaveAttribute(
+ 'aria-describedby',
+ );
+ });
+});
diff --git a/src/components/HrTools/Shared/DescribedRadioOption.tsx b/src/components/HrTools/Shared/DescribedRadioOption.tsx
new file mode 100644
index 0000000000..4e42211641
--- /dev/null
+++ b/src/components/HrTools/Shared/DescribedRadioOption.tsx
@@ -0,0 +1,74 @@
+import React, { useId } from 'react';
+import {
+ Box,
+ FormControlLabel,
+ Radio,
+ Typography,
+ TypographyProps,
+} from '@mui/material';
+
+interface DescribedRadioOptionProps {
+ value: string;
+ label: string;
+ /** Shown under the label in muted text to clarify what the option means. */
+ description?: string;
+ labelVariant?: TypographyProps['variant'];
+ descriptionVariant?: TypographyProps['variant'];
+}
+
+// Matches the Radio's own padding so the first label line lines up with the circle.
+const radioPadding = '9px';
+
+/**
+ * A radio option whose accessible name is the label alone, with the optional description exposed
+ * through aria-describedby. Must be rendered inside a RadioGroup.
+ */
+export const DescribedRadioOption: React.FC = ({
+ value,
+ label,
+ description,
+ labelVariant = 'body1',
+ descriptionVariant = 'caption',
+}) => {
+ const labelId = useId();
+ const descriptionId = useId();
+
+ return (
+
+ }
+ disableTypography
+ label={
+
+
+ {label}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ }
+ sx={{ alignItems: 'flex-start' }}
+ />
+ );
+};
diff --git a/src/lib/functions/getLocalizedNsoHousing.test.ts b/src/lib/functions/getLocalizedNsoHousing.test.ts
index a724ccf57c..cdeba9d5ba 100644
--- a/src/lib/functions/getLocalizedNsoHousing.test.ts
+++ b/src/lib/functions/getLocalizedNsoHousing.test.ts
@@ -1,5 +1,8 @@
import { NewStaffQuestionnaireNsoHousingEnum } from 'src/graphql/types.generated';
-import { getLocalizedNsoHousing } from './getLocalizedNsoHousing';
+import {
+ getLocalizedNsoHousing,
+ getLocalizedNsoHousingDescription,
+} from './getLocalizedNsoHousing';
const t = (key: string) => key;
@@ -11,14 +14,20 @@ describe('getLocalizedNsoHousing', () => {
],
[
NewStaffQuestionnaireNsoHousingEnum.SharedRoom,
- 'Sharing 2 in hotel/dorm room',
+ 'Sharing 2 in hotel/suite dorm room',
],
[
NewStaffQuestionnaireNsoHousingEnum.CoupleRoom,
- 'Couple in hotel/dorm room',
+ 'Married couple in hotel/suite dorm room',
+ ],
+ [
+ NewStaffQuestionnaireNsoHousingEnum.FamilyRoom,
+ 'Family in hotel/suite dorm room',
+ ],
+ [
+ NewStaffQuestionnaireNsoHousingEnum.LocalCommuting,
+ 'Virtual / Local / Commuting',
],
- [NewStaffQuestionnaireNsoHousingEnum.FamilyRoom, 'Family in a hotel/room'],
- [NewStaffQuestionnaireNsoHousingEnum.LocalCommuting, 'Local / Commuting'],
])('maps %s to "%s"', (housing, expected) => {
expect(getLocalizedNsoHousing(t, housing)).toBe(expected);
});
@@ -34,3 +43,41 @@ describe('getLocalizedNsoHousing', () => {
).toBe('');
});
});
+
+describe('getLocalizedNsoHousingDescription', () => {
+ it.each([
+ [
+ NewStaffQuestionnaireNsoHousingEnum.SingleRoom,
+ 'You have no roommate and no suitemates.',
+ ],
+ [
+ NewStaffQuestionnaireNsoHousingEnum.SharedRoom,
+ 'You have a roommate, or your own bedroom in a shared suite.',
+ ],
+ [
+ NewStaffQuestionnaireNsoHousingEnum.CoupleRoom,
+ "You're staying together as a couple or family.",
+ ],
+ [
+ NewStaffQuestionnaireNsoHousingEnum.FamilyRoom,
+ "You're staying together as a couple or family.",
+ ],
+ [
+ NewStaffQuestionnaireNsoHousingEnum.LocalCommuting,
+ "You're attending virtually or commuting daily and not using NSO housing.",
+ ],
+ ])('describes %s as "%s"', (housing, expected) => {
+ expect(getLocalizedNsoHousingDescription(t, housing)).toBe(expected);
+ });
+
+ it('returns an empty string for null, undefined, or an unrecognized value', () => {
+ expect(getLocalizedNsoHousingDescription(t, null)).toBe('');
+ expect(getLocalizedNsoHousingDescription(t, undefined)).toBe('');
+ expect(
+ getLocalizedNsoHousingDescription(
+ t,
+ 'nonsense' as NewStaffQuestionnaireNsoHousingEnum,
+ ),
+ ).toBe('');
+ });
+});
diff --git a/src/lib/functions/getLocalizedNsoHousing.ts b/src/lib/functions/getLocalizedNsoHousing.ts
index 9260d14374..808544dd6f 100644
--- a/src/lib/functions/getLocalizedNsoHousing.ts
+++ b/src/lib/functions/getLocalizedNsoHousing.ts
@@ -13,13 +13,38 @@ export const getLocalizedNsoHousing = (
case NewStaffQuestionnaireNsoHousingEnum.SingleRoom:
return t('Single in hotel/dorm room');
case NewStaffQuestionnaireNsoHousingEnum.SharedRoom:
- return t('Sharing 2 in hotel/dorm room');
+ return t('Sharing 2 in hotel/suite dorm room');
case NewStaffQuestionnaireNsoHousingEnum.CoupleRoom:
- return t('Couple in hotel/dorm room');
+ return t('Married couple in hotel/suite dorm room');
case NewStaffQuestionnaireNsoHousingEnum.FamilyRoom:
- return t('Family in a hotel/room');
+ return t('Family in hotel/suite dorm room');
case NewStaffQuestionnaireNsoHousingEnum.LocalCommuting:
- return t('Local / Commuting');
+ return t('Virtual / Local / Commuting');
+ default:
+ return '';
+ }
+};
+
+/**
+ * Explains what each New Staff Orientation housing option means, shown under
+ * the option's label to help new staff pick the right one.
+ */
+export const getLocalizedNsoHousingDescription = (
+ t: TFunction,
+ housing: NewStaffQuestionnaireNsoHousingEnum | null | undefined,
+): string => {
+ switch (housing) {
+ case NewStaffQuestionnaireNsoHousingEnum.SingleRoom:
+ return t('You have no roommate and no suitemates.');
+ case NewStaffQuestionnaireNsoHousingEnum.SharedRoom:
+ return t('You have a roommate, or your own bedroom in a shared suite.');
+ case NewStaffQuestionnaireNsoHousingEnum.CoupleRoom:
+ case NewStaffQuestionnaireNsoHousingEnum.FamilyRoom:
+ return t("You're staying together as a couple or family.");
+ case NewStaffQuestionnaireNsoHousingEnum.LocalCommuting:
+ return t(
+ "You're attending virtually or commuting daily and not using NSO housing.",
+ );
default:
return '';
}