diff --git a/packages/pn-commons/src/components/CodeModal/CodeInput.tsx b/packages/pn-commons/src/components/CodeModal/CodeInput.tsx index 9f2e3afd01..a57e75adbe 100644 --- a/packages/pn-commons/src/components/CodeModal/CodeInput.tsx +++ b/packages/pn-commons/src/components/CodeModal/CodeInput.tsx @@ -1,5 +1,6 @@ import { ChangeEvent, + ClipboardEvent, Fragment, KeyboardEvent, memo, @@ -46,11 +47,14 @@ const CodeInput = ({ initialValues, isReadOnly, hasError, onChange }: Props) => return; } if (index > initialValues.length - 1) { - // the variable is to prevent test fail - const input = inputsRef.current[index - 1]; - setTimeout(() => { - input.blur(); - }, 25); + for (const input of inputsRef.current) { + if (input === document.activeElement) { + setTimeout(() => { + input.blur(); + }, 25); + break; + } + } return; } // the variable is to prevent test fail @@ -102,8 +106,8 @@ const CodeInput = ({ initialValues, isReadOnly, hasError, onChange }: Props) => changeInputValue(value, index); return; } - // remove non numeric char from value - value = value.replace(/[^\d]/g, ''); + // remove from value those characters that aren't letters neither numbers + value = value.replace(/[^a-z\d]/gi, ''); if (value !== '') { // case maxLength 2 if (value.length > 1) { @@ -116,6 +120,20 @@ const CodeInput = ({ initialValues, isReadOnly, hasError, onChange }: Props) => } }; + const pasteHandler = (event: ClipboardEvent) => { + event.preventDefault(); + // eslint-disable-next-line functional/no-let + let pastedCode = event.clipboardData.getData('text'); + pastedCode = pastedCode.replace(/[^a-z\d]/gi, ''); + const maxLengthRequiredCode = pastedCode.slice(0, initialValues.length); + const values = maxLengthRequiredCode.split(''); + // we create an array with empty values for those cases in which the copied values are less than required ones + // initialValues.length - values.length can be only >= 0 because of the slice of pastedCode + const emptyValues = new Array(initialValues.length - values.length).fill(''); + setCurrentValues(values.concat(emptyValues)); + focusInput(values.length); + }; + useEffect(() => { onChange(currentValues); }, [currentValues]); @@ -136,13 +154,13 @@ const CodeInput = ({ initialValues, isReadOnly, hasError, onChange }: Props) => maxLength: 2, sx: { padding: '16.5px 10px', textAlign: 'center' }, readOnly: isReadOnly, - pattern: '^[0-9]{1}$', - inputMode: 'numeric', + pattern: '^[0-9a-zA-Z]{1}$', 'data-testid': `code-input-${index}`, }} onKeyDown={(event) => keyDownHandler(event, index)} onChange={(event) => changeHandler(event, index)} onFocus={(event) => event.target.select()} + onPaste={(event) => pasteHandler(event)} value={currentValues[index]} // eslint-disable-next-line functional/immutable-data inputRef={(node) => (inputsRef.current[index] = node)} diff --git a/packages/pn-commons/src/components/CodeModal/CodeModal.tsx b/packages/pn-commons/src/components/CodeModal/CodeModal.tsx index f1594f8083..3c3c62258f 100644 --- a/packages/pn-commons/src/components/CodeModal/CodeModal.tsx +++ b/packages/pn-commons/src/components/CodeModal/CodeModal.tsx @@ -1,4 +1,4 @@ -import { ReactNode, memo, useCallback, useState } from 'react'; +import { ReactNode, memo, useCallback, useEffect, useState } from 'react'; import { Alert, @@ -68,10 +68,37 @@ const CodeModal = memo( errorMessage, }: Props) => { const [code, setCode] = useState(initialValues); - const codeIsValid = code.every((v) => v); + const [internalError, setInternalError] = useState({ + internalHasError: hasError, + internalErrorTitle: errorTitle, + internalErrorMessage: errorMessage, + }); + + const { internalHasError, internalErrorTitle, internalErrorMessage } = internalError; + + const codeIsValid = code.every((v) => (!isNaN(Number(v)) ? v : false)); const changeHandler = useCallback((inputsValues: Array) => { setCode(inputsValues); + if (isNaN(Number(inputsValues.join('')))) { + setInternalError({ + internalHasError: true, + internalErrorTitle: getLocalizedOrDefaultLabel( + 'recapiti', + `errors.invalid_type_code.title` + ), + internalErrorMessage: getLocalizedOrDefaultLabel( + 'recapiti', + `errors.invalid_type_code.message` + ), + }); + } else { + setInternalError({ + internalHasError: false, + internalErrorTitle: '', + internalErrorMessage: '', + }); + } }, []); const confirmHandler = () => { @@ -81,10 +108,17 @@ const CodeModal = memo( confirmCallback(code); }; + useEffect(() => { + setInternalError({ + internalHasError: hasError, + internalErrorTitle: errorTitle, + internalErrorMessage: errorMessage, + }); + }, [hasError, errorTitle, errorMessage]); + return ( {isReadOnly && ( @@ -119,12 +153,12 @@ const CodeModal = memo( )} {codeSectionAdditional && {codeSectionAdditional}} - {hasError && ( + {internalHasError && ( - {errorTitle} + {internalErrorTitle} - {errorMessage} + {internalErrorMessage} )} diff --git a/packages/pn-commons/src/components/CodeModal/__test__/CodeInput.test.tsx b/packages/pn-commons/src/components/CodeModal/__test__/CodeInput.test.tsx index fe58883811..8357f7ed1e 100644 --- a/packages/pn-commons/src/components/CodeModal/__test__/CodeInput.test.tsx +++ b/packages/pn-commons/src/components/CodeModal/__test__/CodeInput.test.tsx @@ -182,4 +182,44 @@ describe('CodeInput Component', () => { expect(codeInputs[0]).toHaveFocus(); }); }); + + it('handles paste event', async () => { + // render component + const { getAllByTestId } = render( + + ); + const codeInputs = getAllByTestId(/code-input-[0-4]/); + + // paste the value of the input and check that it is updated correctly + // set the cursor position to the beggining + act(() => (codeInputs[2] as HTMLInputElement).focus()); + (codeInputs[2] as HTMLInputElement).setSelectionRange(0, 0); + + const codePasted = '12345'; + await user.paste(codePasted); + + await waitFor(() => { + for (let i = 0; i < 5; i++) { + expect(codeInputs[i]).toHaveValue(codePasted[i]); + } + }); + }); + + it('accepts first 5 digits in case of too long code (pasted)', async () => { + // render component + const { getAllByTestId } = render( + + ); + const codeInputs = getAllByTestId(/code-input-[0-4]/); + act(() => (codeInputs[2] as HTMLInputElement).focus()); + (codeInputs[2] as HTMLInputElement).setSelectionRange(0, 0); + const codePasted = '123456'; // too long code + await user.paste(codePasted); + await waitFor(() => { + for (let i = 0; i < 5; i++) { + expect(codeInputs[i]).toHaveValue(codePasted[i]); + } + }); + expect(handleChangeMock).toHaveBeenCalledWith(['1', '2', '3', '4', '5']); + }); }); diff --git a/packages/pn-commons/src/components/CodeModal/__test__/CodeModal.test.tsx b/packages/pn-commons/src/components/CodeModal/__test__/CodeModal.test.tsx index 1c741eb6ce..27037c3318 100644 --- a/packages/pn-commons/src/components/CodeModal/__test__/CodeModal.test.tsx +++ b/packages/pn-commons/src/components/CodeModal/__test__/CodeModal.test.tsx @@ -1,17 +1,19 @@ import { vi } from 'vitest'; -import { fireEvent, render, screen, waitFor, within } from '../../../test-utils'; +import userEvent from '@testing-library/user-event'; + +import { act, fireEvent, render, screen, waitFor, within } from '../../../test-utils'; import CodeModal from '../CodeModal'; const cancelButtonMock = vi.fn(); const confirmButtonMock = vi.fn(); -const openedModalComponent = ( - open: boolean, - hasError: boolean = false, - readonly: boolean = false, - initialValues: string[] = new Array(5).fill('') -) => ( +const CodeModalWrapper: React.FC<{ + open: boolean; + hasError?: boolean; + readonly?: boolean; + initialValues?: string[]; +}> = ({ open, hasError = false, readonly = false, initialValues = new Array(5).fill('') }) => ( { it('renders CodeModal (closed)', () => { // render component - render(openedModalComponent(false)); + render(); const dialog = screen.queryByTestId('codeDialog'); expect(dialog).not.toBeInTheDocument(); }); it('renders CodeModal (opened)', () => { // render component - render(openedModalComponent(true)); + render(); const dialog = screen.getByTestId('codeDialog'); expect(dialog).toBeInTheDocument(); expect(dialog).toHaveTextContent(/mocked-title/i); @@ -65,13 +67,13 @@ describe('CodeModal Component', () => { }); const buttons = within(dialog).getAllByRole('button'); expect(buttons).toHaveLength(2); - expect(buttons![0]).toHaveTextContent('mocked-cancel'); - expect(buttons![1]).toHaveTextContent('mocked-confirm'); + expect(buttons[0]).toHaveTextContent('mocked-cancel'); + expect(buttons[1]).toHaveTextContent('mocked-confirm'); }); it('renders CodeModal (read only)', async () => { // render component - render(openedModalComponent(true, false, true, ['0', '1', '2', '3', '4'])); + render(); const dialog = screen.getByTestId('codeDialog'); expect(dialog).toBeInTheDocument(); expect(dialog).toHaveTextContent(/mocked-title/i); @@ -93,10 +95,10 @@ describe('CodeModal Component', () => { it('clicks on cancel', async () => { // render component - render(openedModalComponent(true)); + render(); const dialog = screen.getByTestId('codeDialog'); const button = within(dialog).getByTestId('codeCancelButton'); - fireEvent.click(button!); + fireEvent.click(button); await waitFor(() => { expect(cancelButtonMock).toBeCalledTimes(1); }); @@ -104,10 +106,10 @@ describe('CodeModal Component', () => { it('clicks on confirm', async () => { // render component - render(openedModalComponent(true)); + render(); const dialog = screen.getByTestId('codeDialog'); const button = within(dialog).getByTestId('codeConfirmButton'); - expect(button!).toBeDisabled(); + expect(button).toBeDisabled(); const codeInputs = within(dialog).getAllByTestId(/code-input-[0-4]/); // fill inputs with values codeInputs?.forEach((input, index) => { @@ -117,19 +119,63 @@ describe('CodeModal Component', () => { codeInputs?.forEach((input, index) => { expect(input).toHaveValue(index.toString()); }); - expect(button!).toBeEnabled(); + expect(button).toBeEnabled(); }); - fireEvent.click(button!); + fireEvent.click(button); expect(confirmButtonMock).toBeCalledTimes(1); expect(confirmButtonMock).toBeCalledWith(['0', '1', '2', '3', '4']); }); it('shows error', () => { // render component - render(openedModalComponent(true, true)); + const { rerender } = render( + + ); const dialog = screen.getByTestId('codeDialog'); - const errorAlert = within(dialog).getByTestId('errorAlert'); + let errorAlert = within(dialog).queryByTestId('errorAlert'); + expect(errorAlert).not.toBeInTheDocument(); + // simulate error from external + rerender(); + errorAlert = within(dialog).getByTestId('errorAlert'); expect(errorAlert).toBeInTheDocument(); expect(errorAlert).toHaveTextContent('mocked-errorMessage'); }); + + it('shows error in case of letters as input values', async () => { + // render component + render(); + const dialog = screen.getByTestId('codeDialog'); + const codeInputs = within(dialog).getAllByTestId(/code-input-[0-4]/); + fireEvent.change(codeInputs[0], { target: { value: 'A' } }); + const errorAlert = screen.getByTestId('errorAlert'); + expect(errorAlert).toBeInTheDocument(); + expect(errorAlert).toHaveTextContent('errors.invalid_type_code.message'); + }); + + it('error in case of letters (pasted)', async () => { + // render component + render(); + const dialog = screen.getByTestId('codeDialog'); + const codeInputs = within(dialog).getAllByTestId(/code-input-[0-4]/); + act(() => (codeInputs[2] as HTMLInputElement).focus()); + (codeInputs[2] as HTMLInputElement).setSelectionRange(0, 0); + const codePasted = 'abcd'; + await userEvent.paste(codePasted); + const errorAlert = screen.getByTestId('errorAlert'); + expect(errorAlert).toBeInTheDocument(); + expect(errorAlert).toHaveTextContent('errors.invalid_type_code.message'); + }); + + it('short code (pasted) - confirm disabled', async () => { + // render component + render(); + const dialog = screen.getByTestId('codeDialog'); + const codeInputs = within(dialog).getAllByTestId(/code-input-[0-4]/); + act(() => (codeInputs[2] as HTMLInputElement).focus()); + (codeInputs[2] as HTMLInputElement).setSelectionRange(0, 0); + const codePasted = '123'; + await userEvent.paste(codePasted); + const button = screen.getByTestId('codeConfirmButton'); + expect(button).toBeDisabled(); + }); }); diff --git a/packages/pn-commons/src/utility/__test__/localization.utility.test.ts b/packages/pn-commons/src/utility/__test__/localization.utility.test.ts index 8b69987ca0..8c37f1888c 100644 --- a/packages/pn-commons/src/utility/__test__/localization.utility.test.ts +++ b/packages/pn-commons/src/utility/__test__/localization.utility.test.ts @@ -18,6 +18,7 @@ describe('localization service', () => { notifications: 'different-namespace', appStatus: 'appStatus', delegations: 'deleghe', + recapiti: 'recapiti', }); const label = getLocalizedOrDefaultLabel('notifications', 'mocked.path', 'default label'); expect(label).toBe('different-namespace mocked.path'); diff --git a/packages/pn-commons/src/utility/localization.utility.ts b/packages/pn-commons/src/utility/localization.utility.ts index 3cb078b754..59d1006cf9 100644 --- a/packages/pn-commons/src/utility/localization.utility.ts +++ b/packages/pn-commons/src/utility/localization.utility.ts @@ -1,4 +1,9 @@ -type LocalizationNamespacesNames = 'common' | 'notifications' | 'appStatus' | 'delegations'; +type LocalizationNamespacesNames = + | 'common' + | 'notifications' + | 'appStatus' + | 'delegations' + | 'recapiti'; type LocalizationNamespaces = { [key in LocalizationNamespacesNames]: string; @@ -16,6 +21,7 @@ let localizationNamespaces: LocalizationNamespaces = { notifications: 'notifiche', appStatus: 'appStatus', delegations: 'deleghe', + recapiti: 'recapiti', }; /* eslint-disable-next-line functional/no-let */ diff --git a/packages/pn-personafisica-webapp/public/locales/it/recapiti.json b/packages/pn-personafisica-webapp/public/locales/it/recapiti.json index 0ba17b0a3d..8aed9da46f 100644 --- a/packages/pn-personafisica-webapp/public/locales/it/recapiti.json +++ b/packages/pn-personafisica-webapp/public/locales/it/recapiti.json @@ -132,6 +132,10 @@ "expired_verification_code": { "title": "Il codice è scaduto", "message": "Generane uno nuovo e inseriscilo." + }, + "invalid_type_code": { + "title": "Questo campo accetta solo valori numerici", + "message": "Controlla il codice di verifica e inseriscilo di nuovo" } } } diff --git a/packages/pn-personafisica-webapp/src/pages/Deleghe.page.tsx b/packages/pn-personafisica-webapp/src/pages/Deleghe.page.tsx index 0c0736c0ee..0378be71ee 100644 --- a/packages/pn-personafisica-webapp/src/pages/Deleghe.page.tsx +++ b/packages/pn-personafisica-webapp/src/pages/Deleghe.page.tsx @@ -77,10 +77,14 @@ const Deleghe = () => { dispatch(closeAcceptModal()); }; - const handleAccept = async (code: Array) => { + const handleAccept = (code: Array) => { PFEventStrategyFactory.triggerEvent(PFEventsType.SEND_MANDATE_ACCEPTED); - await dispatch(acceptDelegation({ id: acceptId, code: code.join('') })); - void dispatch(getSidemenuInformation()); + dispatch(acceptDelegation({ id: acceptId, code: code.join('') })) + .unwrap() + .then(() => { + void dispatch(getSidemenuInformation()); + }) + .catch(() => {}); }; const delegates = useAppSelector( diff --git a/packages/pn-personafisica-webapp/src/pages/__test__/Deleghe.page.test.tsx b/packages/pn-personafisica-webapp/src/pages/__test__/Deleghe.page.test.tsx index 331693520b..f13979ea1f 100644 --- a/packages/pn-personafisica-webapp/src/pages/__test__/Deleghe.page.test.tsx +++ b/packages/pn-personafisica-webapp/src/pages/__test__/Deleghe.page.test.tsx @@ -243,7 +243,7 @@ describe('Deleghe page', async () => { fireEvent.change(codeInput, { target: { value: codes[index] } }); }); const dialogButtons = within(dialog).getByRole('button', { name: 'deleghe.accept' }); - // confirm rejection + // confirm accept fireEvent.click(dialogButtons); await waitFor(() => { expect(mock.history.patch).toHaveLength(1); @@ -252,7 +252,9 @@ describe('Deleghe page', async () => { verificationCode: arrayOfDelegators[0].verificationCode, }); }); - // check that nothing is changed + const error = await waitFor(() => within(dialog).getByTestId('errorAlert')); + expect(error).toBeInTheDocument(); + // check that accept button is still active in deleghe page delegatorsRows = result.getAllByTestId('delegatorsTable.body.row'); expect(delegatorsRows).toHaveLength(arrayOfDelegators.length); delegatorsRows.forEach((row, index) => { @@ -260,7 +262,5 @@ describe('Deleghe page', async () => { }); acceptButton = within(delegatorsRows[0]).getByTestId('acceptButton'); expect(acceptButton).toBeInTheDocument(); - const error = await waitFor(() => within(dialog).queryByTestId('errorAlert')); - expect(error).toBeInTheDocument(); }); }); diff --git a/packages/pn-personagiuridica-webapp/public/locales/it/recapiti.json b/packages/pn-personagiuridica-webapp/public/locales/it/recapiti.json index a429fdfdf1..05a36cd24d 100644 --- a/packages/pn-personagiuridica-webapp/public/locales/it/recapiti.json +++ b/packages/pn-personagiuridica-webapp/public/locales/it/recapiti.json @@ -117,6 +117,10 @@ "expired_verification_code": { "title": "Il codice è scaduto", "message": "Generane uno nuovo e inseriscilo." + }, + "invalid_type_code": { + "title": "Questo campo accetta solo valori numerici", + "message": "Controlla il codice di verifica e inseriscilo di nuovo" } } }