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 ''; }