Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(PN-10282): implement pasteHandler for OTP #1159

Merged
merged 27 commits into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cdc8cd7
fix(PN-10282): fix paste OTP code in CodeInput component
SarahDonvito Mar 15, 2024
cc5344c
fix(PN-10282): add test on pasteHandler
SarahDonvito Mar 15, 2024
7ff4b73
Merge branch 'develop' of github.com:pagopa/pn-frontend into fix/PN-1…
SarahDonvito Mar 19, 2024
258d429
fix(PN-10282): change request - improve pasteHandler function and fi…
SarahDonvito Mar 20, 2024
b18d4bb
Merge branch 'develop' of github.com:pagopa/pn-frontend into fix/PN-1…
SarahDonvito Mar 20, 2024
bb02875
fix(PN-10282): change request
SarahDonvito Mar 20, 2024
2783364
Merge branch 'develop' of github.com:pagopa/pn-frontend into fix/PN-1…
SarahDonvito Mar 26, 2024
ec464f0
fix(PN-10282): wip on code input paste handler
SarahDonvito Mar 26, 2024
2040387
Merge branch 'develop' of github.com:pagopa/pn-frontend into fix/PN-1…
SarahDonvito Mar 26, 2024
363d3dd
fix(PN-10282): update ux when user pastes wrong code (longer, shorter…
SarahDonvito Mar 27, 2024
20c8a8c
fix(PN-10282): fix focus input when less than 5 char
SarahDonvito Mar 27, 2024
13912e2
fix(PN-10282): wip on tests
SarahDonvito Mar 27, 2024
f8ef93b
fix(PN-102828): wip on tests
SarahDonvito Mar 27, 2024
244a4bb
fix(PN-10282): implement tests on Code Input and Code Modal
SarahDonvito Mar 27, 2024
63d092c
fix(PN-10282): change request applied
SarahDonvito Mar 29, 2024
97b749c
Merge branch 'develop' of github.com:pagopa/pn-frontend into fix/PN-1…
SarahDonvito Mar 29, 2024
4240209
fix(PN-10282): fix tests, rename key for error message in case of let…
SarahDonvito Mar 29, 2024
7bbc83f
enforced check on code value inserted
AndreaCimini90 Mar 29, 2024
6493a30
added some comment
AndreaCimini90 Mar 29, 2024
9b11e50
fix(PN-10282): fix test and wip on fix bug on input 0 and backspace a…
SarahDonvito Mar 29, 2024
a6710c4
Merge branch 'fix/PN-10282' of github.com:pagopa/pn-frontend into fix…
SarahDonvito Mar 29, 2024
5541107
fix(PN-10282): change requests
SarahDonvito Mar 29, 2024
7b09ad9
fix(PN-10282): fix test
SarahDonvito Mar 29, 2024
9657172
fix(PN-10282): fix tests
SarahDonvito Mar 29, 2024
449c78e
Merge branch 'develop' of github.com:pagopa/pn-frontend into fix/PN-1…
SarahDonvito Mar 29, 2024
a386aa6
fix(PN-10282): update copy for error, fix change handler and wip on test
SarahDonvito Mar 29, 2024
25b19dd
fixed tests
AndreaCimini90 Apr 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 27 additions & 9 deletions packages/pn-commons/src/components/CodeModal/CodeInput.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ChangeEvent,
ClipboardEvent,
Fragment,
KeyboardEvent,
memo,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -116,6 +120,20 @@ const CodeInput = ({ initialValues, isReadOnly, hasError, onChange }: Props) =>
}
};

const pasteHandler = (event: ClipboardEvent<HTMLDivElement>) => {
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]);
Expand All @@ -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)}
Expand Down
48 changes: 41 additions & 7 deletions packages/pn-commons/src/components/CodeModal/CodeModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ReactNode, memo, useCallback, useState } from 'react';
import { ReactNode, memo, useCallback, useEffect, useState } from 'react';

import {
Alert,
Expand Down Expand Up @@ -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<string>) => {
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 = () => {
Expand All @@ -81,10 +108,17 @@ const CodeModal = memo(
confirmCallback(code);
};

useEffect(() => {
setInternalError({
internalHasError: hasError,
internalErrorTitle: errorTitle,
internalErrorMessage: errorMessage,
});
}, [hasError, errorTitle, errorMessage]);

return (
<PnDialog
open={open}
// onClose={handleClose}
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
data-testid="codeDialog"
Expand All @@ -101,7 +135,7 @@ const CodeModal = memo(
<CodeInput
initialValues={initialValues}
isReadOnly={isReadOnly}
hasError={hasError}
hasError={internalHasError}
onChange={changeHandler}
/>
{isReadOnly && (
Expand All @@ -119,12 +153,12 @@ const CodeModal = memo(
)}
</Box>
{codeSectionAdditional && <Box sx={{ mt: 2 }}>{codeSectionAdditional}</Box>}
{hasError && (
{internalHasError && (
<Alert id="error-alert" data-testid="errorAlert" severity="error" sx={{ mt: 2 }}>
<AlertTitle id="codeModalErrorTitle" data-testid="CodeModal error title">
{errorTitle}
{internalErrorTitle}
</AlertTitle>
{errorMessage}
{internalErrorMessage}
</Alert>
)}
</PnDialogContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,44 @@ describe('CodeInput Component', () => {
expect(codeInputs[0]).toHaveFocus();
});
});

it('handles paste event', async () => {
// render component
const { getAllByTestId } = render(
<CodeInput initialValues={new Array(5).fill('')} onChange={handleChangeMock} />
);
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(
<CodeInput initialValues={new Array(5).fill('')} onChange={handleChangeMock} />
);
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']);
});
});
Original file line number Diff line number Diff line change
@@ -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('') }) => (
<CodeModal
title="mocked-title"
subtitle="mocked-subtitle"
Expand Down Expand Up @@ -45,14 +47,14 @@ describe('CodeModal Component', () => {

it('renders CodeModal (closed)', () => {
// render component
render(openedModalComponent(false));
render(<CodeModalWrapper open={false} />);
const dialog = screen.queryByTestId('codeDialog');
expect(dialog).not.toBeInTheDocument();
});

it('renders CodeModal (opened)', () => {
// render component
render(openedModalComponent(true));
render(<CodeModalWrapper open />);
const dialog = screen.getByTestId('codeDialog');
expect(dialog).toBeInTheDocument();
expect(dialog).toHaveTextContent(/mocked-title/i);
Expand All @@ -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(<CodeModalWrapper open readonly initialValues={['0', '1', '2', '3', '4']} />);
const dialog = screen.getByTestId('codeDialog');
expect(dialog).toBeInTheDocument();
expect(dialog).toHaveTextContent(/mocked-title/i);
Expand All @@ -93,21 +95,21 @@ describe('CodeModal Component', () => {

it('clicks on cancel', async () => {
// render component
render(openedModalComponent(true));
render(<CodeModalWrapper open />);
const dialog = screen.getByTestId('codeDialog');
const button = within(dialog).getByTestId('codeCancelButton');
fireEvent.click(button!);
fireEvent.click(button);
await waitFor(() => {
expect(cancelButtonMock).toBeCalledTimes(1);
});
});

it('clicks on confirm', async () => {
// render component
render(openedModalComponent(true));
render(<CodeModalWrapper open />);
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) => {
Expand All @@ -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(
<CodeModalWrapper open initialValues={['0', '1', '2', '3', '4']} />
);
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(<CodeModalWrapper open hasError initialValues={['0', '1', '2', '3', '4']} />);
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(<CodeModalWrapper open />);
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(<CodeModalWrapper open />);
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(<CodeModalWrapper open />);
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
8 changes: 7 additions & 1 deletion packages/pn-commons/src/utility/localization.utility.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
type LocalizationNamespacesNames = 'common' | 'notifications' | 'appStatus' | 'delegations';
type LocalizationNamespacesNames =
| 'common'
| 'notifications'
| 'appStatus'
| 'delegations'
| 'recapiti';

type LocalizationNamespaces = {
[key in LocalizationNamespacesNames]: string;
Expand All @@ -16,6 +21,7 @@ let localizationNamespaces: LocalizationNamespaces = {
notifications: 'notifiche',
appStatus: 'appStatus',
delegations: 'deleghe',
recapiti: 'recapiti',
};

/* eslint-disable-next-line functional/no-let */
Expand Down
Loading
Loading