0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(ui): update set password form (#3020)

This commit is contained in:
simeng-li 2023-02-06 17:02:07 +08:00 committed by GitHub
parent 4a407ad4de
commit 4a84162722
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 336 additions and 73 deletions

View file

@ -35,9 +35,9 @@ describe('smoke testing', () => {
expect(page.url()).toBe(new URL('register/username/password', logtoUrl).href);
const passwordField = await page.waitForSelector('input[name=new-password]');
const confirmPasswordField = await page.waitForSelector('input[name=confirm-new-password]');
const saveButton = await page.waitForSelector('button');
const passwordField = await page.waitForSelector('input[name=newPassword]');
const confirmPasswordField = await page.waitForSelector('input[name=confirmPassword]');
const saveButton = await page.waitForSelector('button[name=submit]');
await passwordField.type(consolePassword);
await confirmPasswordField.type(consolePassword);

View file

@ -38,6 +38,7 @@ const translation = {
link_another_email: 'Link another email', // UNTRANSLATED
link_another_phone: 'Link another phone', // UNTRANSLATED
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
show_password: 'Show password', // UNTRANSLATED
},
description: {
email: 'Email',

View file

@ -36,6 +36,7 @@ const translation = {
link_another_email: 'Link another email',
link_another_phone: 'Link another phone',
link_another_email_or_phone: 'Link another email or phone',
show_password: 'Show password',
},
description: {
email: 'email',

View file

@ -38,6 +38,7 @@ const translation = {
link_another_email: 'Link another email', // UNTRANSLATED
link_another_phone: 'Link another phone', // UNTRANSLATED
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
show_password: 'Show password', // UNTRANSLATED
},
description: {
email: 'email',

View file

@ -38,6 +38,7 @@ const translation = {
link_another_email: 'Link another email', // UNTRANSLATED
link_another_phone: 'Link another phone', // UNTRANSLATED
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
show_password: 'Show password', // UNTRANSLATED
},
description: {
email: '이메일',

View file

@ -38,6 +38,7 @@ const translation = {
link_another_email: 'Link another email', // UNTRANSLATED
link_another_phone: 'Link another phone', // UNTRANSLATED
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
show_password: 'Show password', // UNTRANSLATED
},
description: {
email: 'e-mail',

View file

@ -38,6 +38,7 @@ const translation = {
link_another_email: 'Link another email', // UNTRANSLATED
link_another_phone: 'Link another phone', // UNTRANSLATED
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
show_password: 'Show password', // UNTRANSLATED
},
description: {
email: 'email',

View file

@ -38,6 +38,7 @@ const translation = {
link_another_email: 'Link another email', // UNTRANSLATED
link_another_phone: 'Link another phone', // UNTRANSLATED
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
show_password: 'Show password', // UNTRANSLATED
},
description: {
email: 'e-posta adresi',

View file

@ -38,6 +38,7 @@ const translation = {
link_another_email: 'Link another email', // UNTRANSLATED
link_another_phone: 'Link another phone', // UNTRANSLATED
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
show_password: 'Show password', // UNTRANSLATED
},
description: {
email: '邮箱',

View file

@ -61,6 +61,7 @@
"react": "^18.0.0",
"react-device-detect": "^2.2.2",
"react-dom": "^18.0.0",
"react-hook-form": "^7.34.0",
"react-i18next": "^11.18.3",
"react-modal": "^3.15.1",
"react-router-dom": "^6.2.2",

View file

@ -0,0 +1,90 @@
@use '@/scss/underscore' as _;
.inputField {
position: relative;
@include _.flex-row;
border: _.border(var(--color-line-border));
border-radius: var(--radius);
overflow: hidden;
transition-property: outline, border;
transition-timing-function: ease-in-out;
transition-duration: 0.2s;
// fix in safari input field line-height issue
height: 44px;
input {
padding: 0 _.unit(4);
flex: 1;
background: none;
caret-color: var(--color-brand-default);
font: var(--font-body-1);
color: var(--color-type-primary);
align-self: stretch;
&::placeholder {
color: var(--color-type-secondary);
}
&:not(:last-child) {
padding-right: _.unit(10);
}
}
.suffix {
position: absolute;
right: _.unit(4);
top: 50%;
transform: translateY(-50%);
width: _.unit(8);
height: _.unit(8);
display: none;
&.visible {
display: flex;
}
}
&:focus-within {
border: _.border(var(--color-brand-default));
input {
outline: none;
}
.focusVisible {
display: flex;
}
}
&.danger {
border: _.border(var(--color-danger-default));
input {
caret-color: var(--color-danger-default);
}
}
}
.errorMessage {
margin-top: _.unit(1);
margin-left: _.unit(0.5);
}
:global(body.desktop) {
.inputField {
outline: 3px solid transparent;
input {
font: var(--font-body-2);
}
&:focus-within {
outline-color: var(--color-overlay-brand-focused);
}
&.danger:focus-within {
outline-color: var(--color-overlay-danger-focused);
}
}
}

View file

@ -0,0 +1,40 @@
import classNames from 'classnames';
import type { ForwardedRef, HTMLProps, ReactElement } from 'react';
import { forwardRef, cloneElement } from 'react';
import type { ErrorType } from '@/components/ErrorMessage';
import ErrorMessage from '@/components/ErrorMessage';
import * as styles from './index.module.scss';
type Props = HTMLProps<HTMLInputElement> & {
className?: string;
error?: ErrorType;
isDanger?: boolean;
suffix?: ReactElement;
isSuffixFocusVisible?: boolean;
isSuffixVisible?: boolean;
};
const InputField = (
{ className, error, isDanger, suffix, isSuffixFocusVisible, isSuffixVisible, ...props }: Props,
reference: ForwardedRef<HTMLInputElement>
) => (
<div className={className}>
<div className={classNames(styles.inputField, isDanger && styles.danger)}>
<input {...props} ref={reference} />
{suffix &&
cloneElement(suffix, {
className: classNames([
suffix.props.className,
styles.suffix,
isSuffixFocusVisible && styles.focusVisible,
isSuffixVisible && styles.visible,
]),
})}
</div>
{error && <ErrorMessage error={error} className={styles.errorMessage} />}
</div>
);
export default forwardRef(InputField);

View file

@ -7,7 +7,7 @@
cursor: pointer;
}
.checkBox {
.checkbox {
margin-right: _.unit(2);
fill: var(--color-type-secondary);
cursor: pointer;

View file

@ -40,7 +40,7 @@ const TermsOfUse = ({ name, className, termsUrl, isChecked, onChange, onTermsCli
' ': toggle,
})}
>
<Checkbox name={name} checked={isChecked} className={styles.checkBox} />
<Checkbox name={name} checked={isChecked} className={styles.checkbox} />
<div className={styles.content}>
{prefix}
<TextLink

View file

@ -0,0 +1,41 @@
import { useTranslation } from 'react-i18next';
import Checkbox from '@/components/Checkbox';
import { onKeyDownHandler } from '@/utils/a11y';
import * as styles from './index.module.scss';
type Props = {
isChecked?: boolean;
onChange: (checked: boolean) => void;
};
const TogglePassword = ({ isChecked, onChange }: Props) => {
const { t } = useTranslation();
const toggle = () => {
onChange(!isChecked);
};
return (
<div
role="radio"
aria-checked={isChecked}
tabIndex={0}
className={styles.passwordSwitch}
onClick={toggle}
onKeyDown={onKeyDownHandler({
Escape: () => {
onChange(false);
},
Enter: toggle,
' ': toggle,
})}
>
<Checkbox name="toggle-password" checked={isChecked} className={styles.checkbox} />
<div>{t('action.show_password')}</div>
</div>
);
};
export default TogglePassword;

View file

@ -8,7 +8,8 @@
}
.inputField,
.formErrors {
.formErrors,
.passwordSwitch {
margin-bottom: _.unit(4);
}
@ -17,3 +18,16 @@
margin-top: _.unit(-3);
}
}
.passwordSwitch {
@include _.flex-row;
width: 100%;
user-select: none;
cursor: pointer;
.checkbox {
margin-right: _.unit(2);
fill: var(--color-type-secondary);
cursor: pointer;
}
}

View file

@ -14,28 +14,36 @@ describe('<SetPassword />', () => {
const { queryByText, container } = render(
<SetPassword errorMessage="error" onSubmit={submit} />
);
expect(container.querySelector('input[name="new-password"]')).not.toBeNull();
expect(container.querySelector('input[name="confirm-new-password"]')).not.toBeNull();
expect(container.querySelector('input[name="newPassword"]')).not.toBeNull();
expect(container.querySelector('input[name="confirmPassword"]')).not.toBeNull();
expect(queryByText('error')).not.toBeNull();
expect(queryByText('action.save_password')).not.toBeNull();
});
test('password are required', () => {
test('password is required', async () => {
const { queryByText, getByText } = render(
<SetPassword clearErrorMessage={clearError} onSubmit={submit} />
);
const submitButton = getByText('action.save_password');
fireEvent.click(submitButton);
act(() => {
fireEvent.click(submitButton);
});
expect(clearError).toBeCalled();
expect(queryByText('password_required')).not.toBeNull();
await waitFor(() => {
expect(queryByText('password_required')).not.toBeNull();
});
expect(submit).not.toBeCalled();
});
test('password less than 6 chars should throw', () => {
test('password less than 6 chars should throw', async () => {
const { queryByText, getByText, container } = render(<SetPassword onSubmit={submit} />);
const submitButton = getByText('action.save_password');
const passwordInput = container.querySelector('input[name="new-password"]');
const passwordInput = container.querySelector('input[name="newPassword"]');
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: '12345' } });
@ -45,7 +53,9 @@ describe('<SetPassword />', () => {
fireEvent.click(submitButton);
});
expect(queryByText('password_min_length')).not.toBeNull();
await waitFor(() => {
expect(queryByText('password_min_length')).not.toBeNull();
});
expect(submit).not.toBeCalled();
@ -56,14 +66,16 @@ describe('<SetPassword />', () => {
}
});
expect(queryByText('password_min_length')).toBeNull();
await waitFor(() => {
expect(queryByText('password_min_length')).toBeNull();
});
});
test('password mismatch with confirmPassword should throw', () => {
test('password mismatch with confirmPassword should throw', async () => {
const { queryByText, getByText, container } = render(<SetPassword onSubmit={submit} />);
const submitButton = getByText('action.save_password');
const passwordInput = container.querySelector('input[name="new-password"]');
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
const passwordInput = container.querySelector('input[name="newPassword"]');
const confirmPasswordInput = container.querySelector('input[name="confirmPassword"]');
act(() => {
if (passwordInput) {
@ -77,7 +89,9 @@ describe('<SetPassword />', () => {
fireEvent.click(submitButton);
});
expect(queryByText('passwords_do_not_match')).not.toBeNull();
await waitFor(() => {
expect(queryByText('passwords_do_not_match')).not.toBeNull();
});
expect(submit).not.toBeCalled();
@ -88,14 +102,16 @@ describe('<SetPassword />', () => {
}
});
expect(queryByText('passwords_do_not_match')).toBeNull();
await waitFor(() => {
expect(queryByText('passwords_do_not_match')).toBeNull();
});
});
test('should submit properly', async () => {
const { queryByText, getByText, container } = render(<SetPassword onSubmit={submit} />);
const submitButton = getByText('action.save_password');
const passwordInput = container.querySelector('input[name="new-password"]');
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
const passwordInput = container.querySelector('input[name="newPassword"]');
const confirmPasswordInput = container.querySelector('input[name="confirmPassword"]');
act(() => {
if (passwordInput) {

View file

@ -1,13 +1,16 @@
import classNames from 'classnames';
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import ClearIcon from '@/assets/icons/clear-icon.svg';
import Button from '@/components/Button';
import IconButton from '@/components/Button/IconButton';
import ErrorMessage from '@/components/ErrorMessage';
import Input from '@/components/Input';
import useForm from '@/hooks/use-form';
import { passwordValidation, confirmPasswordValidation } from '@/utils/field-validations';
import InputField from '@/components/InputField';
import { passwordErrorWatcher } from '@/utils/form';
import TogglePassword from './TogglePassword';
import * as styles from './index.module.scss';
type Props = {
@ -20,15 +23,10 @@ type Props = {
};
type FieldState = {
password: string;
newPassword: string;
confirmPassword: string;
};
const defaultState: FieldState = {
password: '',
confirmPassword: '',
};
const SetPassword = ({
className,
autoFocus,
@ -37,53 +35,93 @@ const SetPassword = ({
clearErrorMessage,
}: Props) => {
const { t } = useTranslation();
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
const [showPassword, setShowPassword] = useState(false);
const {
register,
watch,
resetField,
handleSubmit,
formState: { errors },
} = useForm<FieldState>({
reValidateMode: 'onChange',
defaultValues: { newPassword: '', confirmPassword: '' },
});
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
(event?: React.FormEvent<HTMLFormElement>) => {
clearErrorMessage?.();
if (!validateForm()) {
return;
}
onSubmit(fieldValue.password);
void handleSubmit((data, event) => {
onSubmit(data.newPassword);
event?.preventDefault();
})(event);
},
[clearErrorMessage, validateForm, onSubmit, fieldValue.password]
[clearErrorMessage, handleSubmit, onSubmit]
);
const newPasswordError = passwordErrorWatcher(errors.newPassword);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<Input
<InputField
required
className={styles.inputField}
name="new-password"
type="password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
placeholder={t('input.password')}
autoFocus={autoFocus}
{...register('password', passwordValidation)}
onClear={() => {
setFieldValue((state) => ({ ...state, password: '' }));
}}
isDanger={!!newPasswordError}
error={newPasswordError}
aria-invalid={!!newPasswordError}
{...register('newPassword', { required: true, minLength: 6 })}
isSuffixFocusVisible={!!watch('newPassword')}
suffix={
<IconButton
onClick={() => {
resetField('newPassword');
}}
>
<ClearIcon />
</IconButton>
}
/>
<Input
<InputField
required
className={styles.inputField}
name="confirm-new-password"
type="password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
placeholder={t('input.confirm_password')}
{...register('confirmPassword', (confirmPassword) =>
confirmPasswordValidation(fieldValue.password, confirmPassword)
)}
isErrorStyling={false}
onClear={() => {
setFieldValue((state) => ({ ...state, confirmPassword: '' }));
}}
error={errors.confirmPassword && 'passwords_do_not_match'}
aria-invalid={!!errors.confirmPassword}
{...register('confirmPassword', {
validate: (value) => value === watch('newPassword'),
})}
isSuffixFocusVisible={!!watch('confirmPassword')}
suffix={
<IconButton
onClick={() => {
resetField('confirmPassword');
}}
>
<ClearIcon />
</IconButton>
}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
<Button title="action.save_password" onClick={async () => onSubmitHandler()} />
<TogglePassword isChecked={showPassword} onChange={setShowPassword} />
<Button
name="submit"
title="action.save_password"
onClick={async () => {
onSubmitHandler();
}}
/>
<input hidden type="submit" />
</form>

View file

@ -24,8 +24,8 @@ describe('SetPassword', () => {
<SetPassword />
</SettingsProvider>
);
expect(container.querySelector('input[name="new-password"]')).not.toBeNull();
expect(container.querySelector('input[name="confirm-new-password"]')).not.toBeNull();
expect(container.querySelector('input[name="newPassword"]')).not.toBeNull();
expect(container.querySelector('input[name="confirmPassword"]')).not.toBeNull();
expect(queryByText('action.save_password')).not.toBeNull();
});
@ -36,8 +36,8 @@ describe('SetPassword', () => {
</SettingsProvider>
);
const submitButton = getByText('action.save_password');
const passwordInput = container.querySelector('input[name="new-password"]');
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
const passwordInput = container.querySelector('input[name="newPassword"]');
const confirmPasswordInput = container.querySelector('input[name="confirmPassword"]');
act(() => {
if (passwordInput) {

View file

@ -36,8 +36,8 @@ describe('<PasswordRegisterWithUsername />', () => {
</SettingsProvider>
);
expect(container.querySelector('input[name="new-password"]')).not.toBeNull();
expect(container.querySelector('input[name="confirm-new-password"]')).not.toBeNull();
expect(container.querySelector('input[name="newPassword"]')).not.toBeNull();
expect(container.querySelector('input[name="confirmPassword"]')).not.toBeNull();
expect(queryByText('action.save_password')).not.toBeNull();
});
@ -56,7 +56,7 @@ describe('<PasswordRegisterWithUsername />', () => {
</SettingsProvider>
);
expect(container.querySelector('input[name="new-password"]')).toBeNull();
expect(container.querySelector('input[name="newPassword"]')).toBeNull();
expect(queryByText('description.not_found')).not.toBeNull();
});
@ -68,8 +68,8 @@ describe('<PasswordRegisterWithUsername />', () => {
);
const submitButton = getByText('action.save_password');
const passwordInput = container.querySelector('input[name="new-password"]');
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
const passwordInput = container.querySelector('input[name="newPassword"]');
const confirmPasswordInput = container.querySelector('input[name="confirmPassword"]');
act(() => {
if (passwordInput) {

View file

@ -27,16 +27,16 @@ describe('ForgotPassword', () => {
</MemoryRouter>
);
expect(container.querySelector('input[name="new-password"]')).not.toBeNull();
expect(container.querySelector('input[name="confirm-new-password"]')).not.toBeNull();
expect(container.querySelector('input[name="newPassword"]')).not.toBeNull();
expect(container.querySelector('input[name="confirmPassword"]')).not.toBeNull();
expect(queryByText('action.save_password')).not.toBeNull();
});
test('should submit properly', async () => {
const { getByText, container } = renderWithPageContext(<ResetPassword />);
const submitButton = getByText('action.save_password');
const passwordInput = container.querySelector('input[name="new-password"]');
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
const passwordInput = container.querySelector('input[name="newPassword"]');
const confirmPasswordInput = container.querySelector('input[name="confirmPassword"]');
act(() => {
if (passwordInput) {

View file

@ -0,0 +1,13 @@
import type { FieldError } from 'react-hook-form';
import type { ErrorType } from '@/components/ErrorMessage';
export const passwordErrorWatcher = (error?: FieldError): ErrorType | undefined => {
switch (error?.type) {
case 'required':
return 'password_required';
case 'minLength':
return { code: 'password_min_length', data: { min: 6 } };
default:
}
};

View file

@ -813,6 +813,7 @@ importers:
react: ^18.0.0
react-device-detect: ^2.2.2
react-dom: ^18.0.0
react-hook-form: ^7.34.0
react-i18next: ^11.18.3
react-modal: ^3.15.1
react-router-dom: ^6.2.2
@ -868,6 +869,7 @@ importers:
react: 18.2.0
react-device-detect: 2.2.2_biqbaboplfbrettd7655fr4n2y
react-dom: 18.2.0_react@18.2.0
react-hook-form: 7.34.0_react@18.2.0
react-i18next: 11.18.3_shxxmfhtk2bc4pbx5cyq3uoph4
react-modal: 3.15.1_biqbaboplfbrettd7655fr4n2y
react-router-dom: 6.2.2_biqbaboplfbrettd7655fr4n2y