Skip to content

Commit 7469fb8

Browse files
authored
MPDX-9611 Scope PDS autosave disable to per-field saves (#1797)
* Scope PDS autosave disable to per-field saves
1 parent bdb41fd commit 7469fb8

10 files changed

Lines changed: 530 additions & 110 deletions

src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.test.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
DesignationSupportSalaryType,
77
DesignationSupportStatus,
88
} from 'src/graphql/types.generated';
9+
import { UpdatePdsGoalCalculationMutation } from './GoalsList/PdsGoalCalculations.generated';
910
import { PdsGoalCalculator } from './PdsGoalCalculator';
1011
import { PdsGoalCalculatorTestWrapper } from './PdsGoalCalculatorTestWrapper';
1112

@@ -69,7 +70,9 @@ describe('PdsGoalCalculator', () => {
6970

7071
it('re-enables Continue after switching from Hourly to Salaried and entering a new Pay Rate', async () => {
7172
const { findByRole, getByRole, queryByRole } = render(
72-
<PdsGoalCalculatorTestWrapper
73+
<PdsGoalCalculatorTestWrapper<{
74+
UpdatePdsGoalCalculation: UpdatePdsGoalCalculationMutation;
75+
}>
7376
calculationMock={{
7477
salaryOrHourly: DesignationSupportSalaryType.Hourly,
7578
hoursWorkedPerWeek: null,
@@ -78,6 +81,23 @@ describe('PdsGoalCalculator', () => {
7881
benefits: 1500,
7982
name: 'Test Goal',
8083
}}
84+
extraMocks={{
85+
// Pin the mutation response so the auto-generated mock doesn't fill
86+
// salaryOrHourly with HOURLY (the first enum value).
87+
UpdatePdsGoalCalculation: {
88+
updateDesignationSupportCalculation: {
89+
designationSupportCalculation: {
90+
id: 'goal-1',
91+
name: 'Test Goal',
92+
status: DesignationSupportStatus.FullTime,
93+
salaryOrHourly: DesignationSupportSalaryType.Salaried,
94+
payRate: null,
95+
hoursWorkedPerWeek: null,
96+
benefits: 1500,
97+
},
98+
},
99+
},
100+
}}
81101
>
82102
<PdsGoalCalculator />
83103
</PdsGoalCalculatorTestWrapper>,
@@ -106,6 +126,9 @@ describe('PdsGoalCalculator', () => {
106126
const payRateInput = await findByRole('spinbutton', {
107127
name: 'Annual Pay Rate',
108128
});
129+
// Pay Type's atomic save (salaryOrHourly + payRate: null) locks payRate
130+
// until the mutation resolves. Wait for it before typing.
131+
await waitFor(() => expect(payRateInput).not.toBeDisabled());
109132
userEvent.type(payRateInput, '50000');
110133

111134
await waitFor(() => expect(continueButton).not.toBeDisabled());

src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx

Lines changed: 109 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,9 @@ export type GoalCalculatorConstantsMock = DeepPartial<
132132
GoalCalculatorConstantsQuery['constant']
133133
>;
134134

135-
export interface PdsGoalCalculatorTestWrapperProps {
135+
export interface PdsGoalCalculatorTestWrapperProps<
136+
TExtraMocks = Record<string, never>,
137+
> {
136138
children?: React.ReactNode;
137139
withProvider?: boolean;
138140
calculationsMock?: PdsGoalCalculationsMock;
@@ -141,13 +143,25 @@ export interface PdsGoalCalculatorTestWrapperProps {
141143
userMock?: GetUserMock;
142144
constantsMock?: GoalCalculatorConstantsMock;
143145
supportRaisedMock?: number;
146+
/**
147+
* Extra GqlMockedProvider mocks merged on top of the defaults. Pass the
148+
* operation map as a generic argument so mock shapes are type-checked:
149+
* `<PdsGoalCalculatorTestWrapper<{ UpdatePdsGoalCalculation: UpdatePdsGoalCalculationMutation }> extraMocks={...} />`.
150+
* Each value may be a partial mock object or a resolver function (e.g. to
151+
* throw and exercise error paths).
152+
*/
153+
extraMocks?: {
154+
[K in keyof TExtraMocks]?:
155+
| DeepPartial<TExtraMocks[K]>
156+
| ((...args: never[]) => unknown);
157+
};
144158
onCall?: MockLinkCallHandler;
145159
router?: React.ComponentProps<typeof TestRouter>['router'];
146160
}
147161

148-
export const PdsGoalCalculatorTestWrapper: React.FC<
149-
PdsGoalCalculatorTestWrapperProps
150-
> = ({
162+
export const PdsGoalCalculatorTestWrapper = <
163+
TExtraMocks = Record<string, never>,
164+
>({
151165
children,
152166
withProvider = true,
153167
calculationsMock,
@@ -156,9 +170,10 @@ export const PdsGoalCalculatorTestWrapper: React.FC<
156170
userMock,
157171
constantsMock,
158172
supportRaisedMock,
173+
extraMocks,
159174
onCall,
160175
router,
161-
}) => {
176+
}: PdsGoalCalculatorTestWrapperProps<TExtraMocks>): React.ReactElement => {
162177
return (
163178
<ThemeProvider theme={theme}>
164179
<TestRouter
@@ -176,95 +191,98 @@ export const PdsGoalCalculatorTestWrapper: React.FC<
176191
GetUser: GetUserQuery;
177192
AccountListSupportRaised: AccountListSupportRaisedQuery;
178193
}>
179-
mocks={{
180-
PdsGoalCalculations: {
181-
designationSupportCalculations: merge(
182-
{},
183-
calculationsDefault,
184-
calculationsMock,
185-
),
186-
},
187-
PdsGoalCalculation: {
188-
designationSupportCalculation: merge(
189-
{},
190-
calculationDefault,
191-
calculationMock,
192-
),
193-
},
194-
HcmUser: {
195-
hcm:
196-
hcmUserMock === null
197-
? []
198-
: [merge({}, hcmUserDefault.hcm[0], hcmUserMock)],
199-
},
200-
...(userMock ? { GetUser: userMock } : {}),
201-
...(supportRaisedMock !== undefined
202-
? {
203-
AccountListSupportRaised: {
204-
accountList: {
205-
id: 'account-list-1',
206-
receivedPledges: supportRaisedMock,
194+
mocks={merge(
195+
{
196+
PdsGoalCalculations: {
197+
designationSupportCalculations: merge(
198+
{},
199+
calculationsDefault,
200+
calculationsMock,
201+
),
202+
},
203+
PdsGoalCalculation: {
204+
designationSupportCalculation: merge(
205+
{},
206+
calculationDefault,
207+
calculationMock,
208+
),
209+
},
210+
HcmUser: {
211+
hcm:
212+
hcmUserMock === null
213+
? []
214+
: [merge({}, hcmUserDefault.hcm[0], hcmUserMock)],
215+
},
216+
...(userMock ? { GetUser: userMock } : {}),
217+
...(supportRaisedMock !== undefined
218+
? {
219+
AccountListSupportRaised: {
220+
accountList: {
221+
id: 'account-list-1',
222+
receivedPledges: supportRaisedMock,
223+
},
207224
},
225+
}
226+
: {}),
227+
GoalCalculatorConstants: {
228+
constant: mergeWith(
229+
{},
230+
{
231+
mpdGoalBenefitsConstants: [],
232+
mpdGoalGeographicConstants: [
233+
{
234+
location: 'None',
235+
percentageMultiplier: 0,
236+
},
237+
{
238+
location: 'Orlando, FL',
239+
percentageMultiplier: 0.06,
240+
},
241+
{
242+
location: 'New York, NY',
243+
percentageMultiplier: 0.12,
244+
},
245+
],
246+
mpdGoalMiscConstants: [
247+
{
248+
category:
249+
MpdGoalMiscConstantCategoryEnum.AdditionalRates,
250+
label: MpdGoalMiscConstantLabelEnum.EmployerFicaRate,
251+
fee: 0.08,
252+
},
253+
{
254+
category:
255+
MpdGoalMiscConstantCategoryEnum.AdditionalRates,
256+
label:
257+
MpdGoalMiscConstantLabelEnum.PartTimeWorkCompensation,
258+
fee: 0.17,
259+
},
260+
{
261+
category: MpdGoalMiscConstantCategoryEnum.Rates,
262+
label: MpdGoalMiscConstantLabelEnum.AttritionRate,
263+
fee: 0.06,
264+
},
265+
{
266+
category:
267+
MpdGoalMiscConstantCategoryEnum.AdditionalRates,
268+
label: MpdGoalMiscConstantLabelEnum.CreditCardFeeRate,
269+
fee: 0.06,
270+
},
271+
{
272+
category: MpdGoalMiscConstantCategoryEnum.Rates,
273+
label: MpdGoalMiscConstantLabelEnum.AdminRate,
274+
fee: 0.12,
275+
},
276+
],
208277
},
209-
}
210-
: {}),
211-
GoalCalculatorConstants: {
212-
constant: mergeWith(
213-
{},
214-
{
215-
mpdGoalBenefitsConstants: [],
216-
mpdGoalGeographicConstants: [
217-
{
218-
location: 'None',
219-
percentageMultiplier: 0,
220-
},
221-
{
222-
location: 'Orlando, FL',
223-
percentageMultiplier: 0.06,
224-
},
225-
{
226-
location: 'New York, NY',
227-
percentageMultiplier: 0.12,
228-
},
229-
],
230-
mpdGoalMiscConstants: [
231-
{
232-
category:
233-
MpdGoalMiscConstantCategoryEnum.AdditionalRates,
234-
label: MpdGoalMiscConstantLabelEnum.EmployerFicaRate,
235-
fee: 0.08,
236-
},
237-
{
238-
category:
239-
MpdGoalMiscConstantCategoryEnum.AdditionalRates,
240-
label:
241-
MpdGoalMiscConstantLabelEnum.PartTimeWorkCompensation,
242-
fee: 0.17,
243-
},
244-
{
245-
category: MpdGoalMiscConstantCategoryEnum.Rates,
246-
label: MpdGoalMiscConstantLabelEnum.AttritionRate,
247-
fee: 0.06,
248-
},
249-
{
250-
category:
251-
MpdGoalMiscConstantCategoryEnum.AdditionalRates,
252-
label: MpdGoalMiscConstantLabelEnum.CreditCardFeeRate,
253-
fee: 0.06,
254-
},
255-
{
256-
category: MpdGoalMiscConstantCategoryEnum.Rates,
257-
label: MpdGoalMiscConstantLabelEnum.AdminRate,
258-
fee: 0.12,
259-
},
260-
],
261-
},
262-
constantsMock,
263-
(_objValue, srcValue) =>
264-
Array.isArray(srcValue) ? srcValue : undefined,
265-
),
278+
constantsMock,
279+
(_objValue, srcValue) =>
280+
Array.isArray(srcValue) ? srcValue : undefined,
281+
),
282+
},
266283
},
267-
}}
284+
extraMocks,
285+
)}
268286
onCall={onCall}
269287
>
270288
{withProvider ? (

src/components/HrTools/PdsGoalCalculator/Shared/Autosave/AutosaveTextField.test.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,44 @@ describe('AutosaveTextField', () => {
156156
expect(input).toBeDisabled();
157157
});
158158

159+
it('marks the field aria-busy while a save is in flight', async () => {
160+
const { getByRole } = render(<TestComponent />);
161+
162+
const input = getByRole('textbox', { name: 'Test Field' });
163+
await waitFor(() => expect(input).toHaveValue('Test Goal'));
164+
// No save in flight on initial render.
165+
expect(input).not.toHaveAttribute('aria-busy', 'true');
166+
167+
userEvent.clear(input);
168+
userEvent.type(input, 'New Name');
169+
input.blur();
170+
171+
// While the same-field save is in flight, the input is disabled AND
172+
// aria-busy so screen readers announce it as "busy" rather than just
173+
// "unavailable."
174+
await waitFor(() => expect(input).toHaveAttribute('aria-busy', 'true'));
175+
await waitFor(() => expect(input).not.toHaveAttribute('aria-busy', 'true'));
176+
});
177+
178+
it('does not mark aria-busy when disabled because calculation is missing', () => {
179+
const { getByRole } = render(
180+
<PdsGoalCalculatorTestWrapper
181+
calculationMock={undefined}
182+
onCall={mutationSpy}
183+
>
184+
<AutosaveTextField
185+
label="Test Field"
186+
fieldName="name"
187+
schema={schema}
188+
/>
189+
</PdsGoalCalculatorTestWrapper>,
190+
);
191+
192+
const input = getByRole('textbox', { name: 'Test Field' });
193+
expect(input).toBeDisabled();
194+
expect(input).not.toHaveAttribute('aria-busy', 'true');
195+
});
196+
159197
it('shows props helperText when there is no validation error', async () => {
160198
const { findByRole } = render(
161199
<PdsGoalCalculatorTestWrapper

src/components/HrTools/PdsGoalCalculator/Shared/Autosave/AutosaveTextField.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const AutosaveTextField: React.FC<AutosaveTextFieldProps> = ({
1818
schema,
1919
...props
2020
}) => {
21-
const fieldProps = usePdsGoalAutoSave({
21+
const { busy, ...fieldProps } = usePdsGoalAutoSave({
2222
fieldName,
2323
schema,
2424
saveOnChange: props.select,
@@ -34,6 +34,14 @@ export const AutosaveTextField: React.FC<AutosaveTextFieldProps> = ({
3434
{...fieldProps}
3535
{...props}
3636
disabled={fieldProps.disabled || props.disabled}
37+
// Signal in-flight saves as "busy" rather than "unavailable" — the
38+
// disabled state above also covers !calculation and props.disabled,
39+
// which are not saves. Goes on the inner <input> so it travels with
40+
// the role=textbox/combobox/spinbutton element.
41+
inputProps={{
42+
...props.inputProps,
43+
'aria-busy': busy || undefined,
44+
}}
3745
error={showValidationError || undefined}
3846
helperText={
3947
showValidationError ? fieldProps.helperText : props.helperText

0 commit comments

Comments
 (0)