diff --git a/src/components/Company/PaySchedule/shared/usePayScheduleForm/usePayScheduleForm.test.tsx b/src/components/Company/PaySchedule/shared/usePayScheduleForm/usePayScheduleForm.test.tsx index 171ec2ec7..074b2a3f8 100644 --- a/src/components/Company/PaySchedule/shared/usePayScheduleForm/usePayScheduleForm.test.tsx +++ b/src/components/Company/PaySchedule/shared/usePayScheduleForm/usePayScheduleForm.test.tsx @@ -351,6 +351,130 @@ describe('usePayScheduleForm', () => { expect(submitResult).toEqual(expect.objectContaining({ mode: 'update' })) }) }) + + describe('clearing server-side errors on edit (SDK-923)', () => { + it('clears a custom (server-side) error on a date field when the user edits it', async () => { + const { result } = renderHook(() => usePayScheduleForm({ companyId: 'company-1' }), { + wrapper: GustoTestProvider, + }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + assertReady(result.current) + const { formMethods } = result.current.form.hookFormInternals + + act(() => { + formMethods.setError('anchorPayDate', { + type: 'custom', + message: 'Pay date must be after start date', + }) + }) + + await waitFor(() => { + expect(formMethods.getFieldState('anchorPayDate').error).toMatchObject({ + type: 'custom', + }) + }) + + act(() => { + formMethods.setValue('anchorPayDate', '2026-07-15', { shouldDirty: true }) + }) + + await waitFor(() => { + expect(formMethods.getFieldState('anchorPayDate').error).toBeUndefined() + }) + }) + + it('clears a custom error on the anchorEndOfPayPeriod date field when the user edits it', async () => { + const { result } = renderHook(() => usePayScheduleForm({ companyId: 'company-1' }), { + wrapper: GustoTestProvider, + }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + assertReady(result.current) + const { formMethods } = result.current.form.hookFormInternals + + act(() => { + formMethods.setError('anchorEndOfPayPeriod', { + type: 'custom', + message: 'End of pay period invalid', + }) + }) + + await waitFor(() => { + expect(formMethods.getFieldState('anchorEndOfPayPeriod').error).toMatchObject({ + type: 'custom', + }) + }) + + act(() => { + formMethods.setValue('anchorEndOfPayPeriod', '2026-07-22', { shouldDirty: true }) + }) + + await waitFor(() => { + expect(formMethods.getFieldState('anchorEndOfPayPeriod').error).toBeUndefined() + }) + }) + + it('does not clear non-custom (client-side) validation errors on edit', async () => { + const { result } = renderHook(() => usePayScheduleForm({ companyId: 'company-1' }), { + wrapper: GustoTestProvider, + }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + assertReady(result.current) + const { formMethods } = result.current.form.hookFormInternals + + act(() => { + formMethods.setError('anchorPayDate', { type: 'required', message: 'Required' }) + }) + + await waitFor(() => { + expect(formMethods.getFieldState('anchorPayDate').error?.type).toBe('required') + }) + + act(() => { + formMethods.setValue('anchorPayDate', '2026-07-15', { shouldDirty: true }) + }) + + // Subscribe handler is scoped to type: 'custom' — RHF's normal validation + // loop owns client-side errors. + expect(formMethods.getFieldState('anchorPayDate').error?.type).toBe('required') + }) + + it('does not clear errors on non-date fields when they change', async () => { + const { result } = renderHook(() => usePayScheduleForm({ companyId: 'company-1' }), { + wrapper: GustoTestProvider, + }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + assertReady(result.current) + const { formMethods } = result.current.form.hookFormInternals + + act(() => { + formMethods.setError('customName', { type: 'custom', message: 'Name taken' }) + }) + + await waitFor(() => { + expect(formMethods.getFieldState('customName').error?.type).toBe('custom') + }) + + act(() => { + formMethods.setValue('customName', 'Updated', { shouldDirty: true }) + }) + + // Subscribe is scoped to the two date fields; editing customName must + // not clear its custom error. + expect(formMethods.getFieldState('customName').error?.type).toBe('custom') + }) + }) }) // ── Schema-level tests ────────────────────────────────────────────────── diff --git a/src/components/Company/PaySchedule/shared/usePayScheduleForm/usePayScheduleForm.tsx b/src/components/Company/PaySchedule/shared/usePayScheduleForm/usePayScheduleForm.tsx index 05c8b4e57..80d96ffb9 100644 --- a/src/components/Company/PaySchedule/shared/usePayScheduleForm/usePayScheduleForm.tsx +++ b/src/components/Company/PaySchedule/shared/usePayScheduleForm/usePayScheduleForm.tsx @@ -346,6 +346,27 @@ export function usePayScheduleForm({ const queries = payScheduleId ? [payScheduleQuery, paymentConfigsQuery] : [paymentConfigsQuery] const errorHandling = composeErrorHandler(queries, { submitError, setSubmitError }) + // SDK-923: server-side validation errors (mirrored by SDKFormProvider as + // `type: 'custom'`) on the date fields block resubmission until refresh. + // Subscribe ONLY to the two date fields that can receive server errors and + // clear them on edit. Clearing the upstream submitError too stops the + // provider's sync effect from re-applying the error on the next render. + useEffect(() => { + const dateFields = ['anchorPayDate', 'anchorEndOfPayPeriod'] as const + const unsubscribe = formMethods.subscribe({ + name: dateFields, + formState: { values: true }, + callback: ({ name }) => { + if (!name || !(dateFields as readonly string[]).includes(name)) return + const fieldName = name as (typeof dateFields)[number] + if (formMethods.getFieldState(fieldName).error?.type !== 'custom') return + formMethods.clearErrors(fieldName) + setSubmitError(null) + }, + }) + return unsubscribe + }, [formMethods, setSubmitError]) + const showCustomTwicePerMonth = watchedFrequency === 'Twice per month' const showDay1 = watchedFrequency === 'Monthly' ||