0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

Merge pull request #59 from logto-io/simeng-ui-moweb-dev0

feat(ui): Add Register Page
This commit is contained in:
simeng-li 2021-08-02 22:17:15 +08:00 committed by GitHub
commit c760d53e25
17 changed files with 360 additions and 47 deletions

View file

@ -8,6 +8,9 @@ const translation = {
},
register: {
create_account: 'Create an Account',
title: 'Sign Up',
loading: 'Signing Up...',
have_account: 'Already have an account?',
},
};

View file

@ -10,6 +10,9 @@ const translation = {
},
register: {
create_account: '新用户注册',
title: '注册',
loading: '注册中...',
have_account: '已经有账户?',
},
};

View file

@ -33,6 +33,7 @@
"@logto/eslint-config-react": "^0.1.0-rc.14",
"@logto/ts-config": "^0.1.0-rc.14",
"@logto/ts-config-react": "^0.1.0-rc.14",
"@testing-library/react": "^12.0.0",
"@types/jest": "^26.0.24",
"@types/react": "^17.0.14",
"@types/react-dom": "^17.0.9",

View file

@ -4,7 +4,7 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="<%= process.env.PUBLIC_PATH %>favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no user-scalable=0" />
<meta name="theme-color" content="#000000" />
<%= htmlWebpackPlugin.tags.headTags %>
<!--

View file

@ -4,6 +4,7 @@ import AppContent from './components/AppContent';
import initI18n from './init/i18n';
import Consent from './pages/Consent';
import SignIn from './pages/SignIn';
import Register from './pages/Register';
import './scss/normalized.scss';
initI18n();
@ -13,6 +14,7 @@ const App = () => (
<Switch>
<Route exact path="/sign-in" component={SignIn} />
<Route exact path="/sign-in/consent" component={Consent} />
<Route exact path="/Register" component={Register} />
</Switch>
</AppContent>
);

View file

@ -0,0 +1,15 @@
import ky from 'ky';
export const register = async (username: string, password: string) => {
type Response = {
redirectTo: string;
};
return ky
.post('/api/register', {
json: {
username,
password,
},
})
.json<Response>();
};

View file

@ -13,6 +13,7 @@
box-shadow: var(--shadow-button);
transition: var(--transition-default-control);
cursor: pointer;
-webkit-appearance: none;
&:disabled {
background: var(--color-button-background-disabled);

View file

@ -3,6 +3,7 @@ import React from 'react';
import styles from './index.module.scss';
export type Props = {
name: string;
autoComplete?: AutoCompleteType;
isDisabled?: boolean;
className?: string;
@ -13,6 +14,7 @@ export type Props = {
};
const Input = ({
name,
autoComplete,
isDisabled,
className,
@ -23,6 +25,7 @@ const Input = ({
}: Props) => {
return (
<input
name={name}
disabled={isDisabled}
className={classNames(styles.input, className)}
placeholder={placeholder}

View file

@ -0,0 +1,22 @@
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import Register from '@/pages/Register';
import { register } from '@/apis/register';
jest.mock('@/apis/register', () => ({ register: jest.fn(async () => Promise.resolve()) }));
describe('<Register />', () => {
test('renders without exploding', async () => {
const { queryByText, getByText } = render(<Register />);
expect(queryByText('register.create_account')).not.toBeNull();
expect(queryByText('register.have_account')).not.toBeNull();
const submit = getByText('register.title');
fireEvent.click(submit);
await waitFor(() => {
expect(register).toBeCalled();
expect(queryByText('register.loading')).not.toBeNull();
});
});
});

View file

@ -0,0 +1,50 @@
@use '/src/scss/underscore' as _;
.wrapper {
position: relative;
padding: _.unit(8);
height: 100%;
@include _.flex-colomn;
}
.form {
width: 100%;
@include _.flex-colomn;
> * {
margin-bottom: _.unit(1.5);
}
.title {
font: var(--font-heading-1);
color: var(--color-heading);
margin-bottom: _.unit(9);
}
.box {
margin-bottom: _.unit(-6);
}
.box,
> input:not([type='button']) {
margin-top: _.unit(3);
width: 100%;
max-width: 320px;
}
> input[type='button'] {
margin-top: _.unit(12);
}
.haveAccount {
position: absolute;
bottom: _.unit(10);
}
.prefix {
font: var(--font-body-bold);
color: var(--color-placeholder);
margin-right: _.unit(0.5);
}
}

View file

@ -0,0 +1,73 @@
import React, { FC, FormEventHandler, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { register } from '@/apis/register';
import Button from '@/components/Button';
import Input from '@/components/Input';
import MessageBox from '@/components/MessageBox';
import TextLink from '@/components/TextLink';
import styles from './index.module.scss';
export type PageState = 'idle' | 'loading' | 'error';
const App: FC = () => {
const { t } = useTranslation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [pageState, setPageState] = useState<PageState>('idle');
const isLoading = pageState === 'loading';
const signUp: FormEventHandler = useCallback(
async (event) => {
event.preventDefault();
setPageState('loading');
try {
window.location.href = (await register(username, password)).redirectTo;
} catch {
// TODO: Show specific error after merge into monorepo
setPageState('error');
}
},
[username, password]
);
return (
<div className={classNames(styles.wrapper)}>
<form className={classNames(styles.form)}>
<div className={styles.title}>{t('register.create_account')}</div>
<Input
name="username"
isDisabled={isLoading}
placeholder={t('sign_in.username')}
value={username}
onChange={setUsername} // TODO: account validation
/>
<Input
name="password"
isDisabled={isLoading}
placeholder={t('sign_in.password')}
type="password"
value={password}
onChange={setPassword} // TODO: password validation
/>
{pageState === 'error' && (
<MessageBox className={styles.box}>{t('sign_in.error')}</MessageBox>
)}
<Button
isDisabled={isLoading}
value={isLoading ? t('register.loading') : t('register.title')}
onClick={signUp}
/>
<div className={styles.haveAccount}>
<span className={styles.prefix}>{t('register.have_account')}</span>
<TextLink href="/sign-in">{t('sign_in.title')}</TextLink>
</div>
</form>
</div>
);
};
export default App;

View file

@ -0,0 +1,21 @@
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import SignIn from '@/pages/SignIn';
import { signInBasic } from '@/apis/sign-in';
jest.mock('@/apis/sign-in', () => ({ signInBasic: jest.fn(async () => Promise.resolve()) }));
describe('<SignIn />', () => {
test('renders without exploding', async () => {
const { queryByText, getByText } = render(<SignIn />);
expect(queryByText('Sign in to Logto')).not.toBeNull();
const submit = getByText('sign_in.title');
fireEvent.click(submit);
await waitFor(() => {
expect(signInBasic).toBeCalled();
expect(queryByText('sign_in.loading')).not.toBeNull();
});
});
});

View file

@ -1,13 +1,27 @@
@use '/src/scss/underscore' as _;
.wrapper {
position: relative;
padding: _.unit(8);
@mixin flex-colomn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.wrapper {
position: relative;
padding: _.unit(8);
height: 100%;
@include flex-colomn;
}
.form {
width: 100%;
@include flex-colomn;
> * {
margin-bottom: _.unit(1.5);
}
.title {
font: var(--font-heading-1);

View file

@ -1,62 +1,73 @@
import React, { FC, FormEventHandler, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { signInBasic } from '@/apis/sign-in';
import Button from '@/components/Button';
import Input from '@/components/Input';
import MessageBox from '@/components/MessageBox';
import TextLink from '@/components/TextLink';
import React, { FormEventHandler, useState } from 'react';
import { useTranslation } from 'react-i18next';
import styles from './index.module.scss';
export type PageState = 'idle' | 'loading' | 'error';
const Home = () => {
const Home: FC = () => {
// TODO: Consider creading cross page data modal
const { t } = useTranslation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [pageState, setPageState] = useState<PageState>('idle');
const isLoading = pageState === 'loading';
const signIn: FormEventHandler = async (event) => {
event.preventDefault();
setPageState('loading');
try {
window.location.href = (await signInBasic(username, password)).redirectTo;
} catch {
// TODO: Show specific error after merge into monorepo
setPageState('error');
}
};
const signIn: FormEventHandler = useCallback(
async (event) => {
event.preventDefault();
setPageState('loading');
try {
window.location.href = (await signInBasic(username, password)).redirectTo;
} catch {
// TODO: Show specific error after merge into monorepo
setPageState('error');
}
},
[username, password]
);
return (
<form className={styles.wrapper}>
<div className={styles.title}>Sign in to Logto</div>
<Input
autoComplete="username"
isDisabled={isLoading}
placeholder={t('sign_in.password')}
value={username}
onChange={setUsername}
/>
<Input
autoComplete="current-password"
isDisabled={isLoading}
placeholder={t('sign_in.password')}
type="password"
value={password}
onChange={setPassword}
/>
{pageState === 'error' && (
<MessageBox className={styles.box}>{t('sign_in.error')}</MessageBox>
)}
<Button
isDisabled={isLoading}
value={isLoading ? t('sign_in.loading') : t('sign_in.title')}
onClick={signIn}
/>
<TextLink className={styles.createAccount} href="/register">
{t('register.create_account')}
</TextLink>
</form>
<div className={classNames(styles.wrapper)}>
<form className={classNames(styles.form)}>
<div className={styles.title}>Sign in to Logto</div>
<Input
name="username"
autoComplete="username"
isDisabled={isLoading}
placeholder={t('sign_in.username')}
value={username}
onChange={setUsername}
/>
<Input
name="password"
autoComplete="current-password"
isDisabled={isLoading}
placeholder={t('sign_in.password')}
type="password"
value={password}
onChange={setPassword}
/>
{pageState === 'error' && (
<MessageBox className={styles.box}>{t('sign_in.error')}</MessageBox>
)}
<Button
isDisabled={isLoading}
value={isLoading ? t('sign_in.loading') : t('sign_in.title')}
onClick={signIn}
/>
<TextLink className={styles.createAccount} href="/register">
{t('register.create_account')}
</TextLink>
</form>
</div>
);
};

View file

@ -1,3 +1,10 @@
@function unit($factor: 1, $unit: 'px') {
@return #{$factor * 4}#{$unit};
}
@mixin flex-colomn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

View file

@ -8,6 +8,7 @@
}
},
"include": [
"src"
"src",
"App.test.tsx"
]
}

86
pnpm-lock.yaml generated
View file

@ -163,6 +163,7 @@ importers:
'@logto/phrases': ^0.1.0
'@logto/ts-config': ^0.1.0-rc.14
'@logto/ts-config-react': ^0.1.0-rc.14
'@testing-library/react': ^12.0.0
'@types/jest': ^26.0.24
'@types/react': ^17.0.14
'@types/react-dom': ^17.0.9
@ -210,6 +211,7 @@ importers:
'@logto/eslint-config-react': 0.1.0-rc.14_0b4fa7c4abbcdb6140ac6718cc7d2571
'@logto/ts-config': 0.1.0-rc.14_f847e35c67ce67b1737c27c823675243
'@logto/ts-config-react': 0.1.0-rc.14_885243f2ccfa42cc3bca8b90895b55f6
'@testing-library/react': 12.0.0_react-dom@17.0.2+react@17.0.2
'@types/jest': 26.0.24
'@types/react': 17.0.15
'@types/react-dom': 17.0.9
@ -1462,6 +1464,14 @@ packages:
- supports-color
dev: true
/@babel/runtime-corejs3/7.14.9:
resolution: {integrity: sha512-64RiH2ON4/y8qYtoa8rUiyam/tUVyGqRyNYhe+vCRGmjnV4bUlZvY+mwd0RrmLoCpJpdq3RsrNqKb7SJdw/4kw==}
engines: {node: '>=6.9.0'}
dependencies:
core-js-pure: 3.16.0
regenerator-runtime: 0.13.9
dev: true
/@babel/runtime/7.14.8:
resolution: {integrity: sha512-twj3L8Og5SaCRCErB4x4ajbvBIVV77CGeFglHpeg5WC5FF8TZzBWXtTJ4MqaD9QszLYTtr+IsaAL2rEUevb+eg==}
engines: {node: '>=6.9.0'}
@ -2935,6 +2945,33 @@ packages:
defer-to-connect: 2.0.1
dev: false
/@testing-library/dom/8.1.0:
resolution: {integrity: sha512-kmW9alndr19qd6DABzQ978zKQ+J65gU2Rzkl8hriIetPnwpesRaK4//jEQyYh8fEALmGhomD/LBQqt+o+DL95Q==}
engines: {node: '>=12'}
dependencies:
'@babel/code-frame': 7.14.5
'@babel/runtime': 7.14.8
'@types/aria-query': 4.2.2
aria-query: 4.2.2
chalk: 4.1.2
dom-accessibility-api: 0.5.6
lz-string: 1.4.4
pretty-format: 27.0.6
dev: true
/@testing-library/react/12.0.0_react-dom@17.0.2+react@17.0.2:
resolution: {integrity: sha512-sh3jhFgEshFyJ/0IxGltRhwZv2kFKfJ3fN1vTZ6hhMXzz9ZbbcTgmDYM4e+zJv+oiVKKEWZPyqPAh4MQBI65gA==}
engines: {node: '>=12'}
peerDependencies:
react: '*'
react-dom: '*'
dependencies:
'@babel/runtime': 7.14.8
'@testing-library/dom': 8.1.0
react: 17.0.2
react-dom: 17.0.2_react@17.0.2
dev: true
/@tootallnate/once/1.1.2:
resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==}
engines: {node: '>= 6'}
@ -2962,6 +2999,10 @@ packages:
'@types/node': 16.4.6
dev: true
/@types/aria-query/4.2.2:
resolution: {integrity: sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==}
dev: true
/@types/babel__core/7.1.15:
resolution: {integrity: sha512-bxlMKPDbY8x5h6HBwVzEOk2C8fb6SLfYQ5Jw3uBYuYF1lfWk/kbLd81la82vrIkBb0l+JdmrZaDikPrNxpS/Ew==}
dependencies:
@ -3800,6 +3841,11 @@ packages:
dependencies:
color-convert: 2.0.1
/ansi-styles/5.2.0:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
dev: true
/any-promise/1.3.0:
resolution: {integrity: sha1-q8av7tzqUugJzcA3au0845Y10X8=}
dev: false
@ -3844,6 +3890,14 @@ packages:
sprintf-js: 1.0.3
dev: true
/aria-query/4.2.2:
resolution: {integrity: sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==}
engines: {node: '>=6.0'}
dependencies:
'@babel/runtime': 7.14.8
'@babel/runtime-corejs3': 7.14.9
dev: true
/arity-n/1.0.4:
resolution: {integrity: sha1-2edrEXM+CFacCEeuezmyhgswt0U=}
dev: true
@ -4754,6 +4808,14 @@ packages:
supports-color: 7.2.0
dev: true
/chalk/4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
dev: true
/char-regex/1.0.2:
resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
engines: {node: '>=10'}
@ -5354,6 +5416,11 @@ packages:
semver: 7.0.0
dev: true
/core-js-pure/3.16.0:
resolution: {integrity: sha512-wzlhZNepF/QA9yvx3ePDgNGudU5KDB8lu/TRPKelYA/QtSnkS/cLl2W+TIdEX1FAFcBr0YpY7tPDlcmXJ7AyiQ==}
requiresBuild: true
dev: true
/core-js/3.15.2:
resolution: {integrity: sha512-tKs41J7NJVuaya8DxIOCnl8QuPHx5/ZVbFo1oKgVl1qHFBBrDctzQGtuLjPpRdNTWmKPH6oEvgN/MUID+l485Q==}
requiresBuild: true
@ -6017,6 +6084,10 @@ packages:
esutils: 2.0.3
dev: true
/dom-accessibility-api/0.5.6:
resolution: {integrity: sha512-DplGLZd8L1lN64jlT27N9TVSESFR5STaEJvX+thCby7fuCHonfPpAlodYc3vuUYbDuDec5w8AMP7oCM5TWFsqw==}
dev: true
/dom-converter/0.2.0:
resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==}
dependencies:
@ -9917,6 +9988,11 @@ packages:
yallist: 4.0.0
dev: true
/lz-string/1.4.4:
resolution: {integrity: sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=}
hasBin: true
dev: true
/make-dir/2.1.0:
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
engines: {node: '>=6'}
@ -12219,6 +12295,16 @@ packages:
react-is: 17.0.2
dev: true
/pretty-format/27.0.6:
resolution: {integrity: sha512-8tGD7gBIENgzqA+UBzObyWqQ5B778VIFZA/S66cclyd5YkFLYs2Js7gxDKf0MXtTc9zcS7t1xhdfcElJ3YIvkQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
dependencies:
'@jest/types': 27.0.6
ansi-regex: 5.0.0
ansi-styles: 5.2.0
react-is: 17.0.2
dev: true
/pretty-ms/6.0.1:
resolution: {integrity: sha512-ke4njoVmlotekHlHyCZ3wI/c5AMT8peuHs8rKJqekj/oR5G8lND2dVpicFlUz5cbZgE290vvkMuDwfj/OcW1kw==}
engines: {node: '>=10'}