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

feat: (ui) Read and show error from API response (#84)

Implemet useApi hooks to handle client side request
read loading & error status from the useApi hook
This commit is contained in:
simeng-li 2021-08-23 21:29:58 +08:00 committed by GitHub
parent f3bb467918
commit b1bf5eccf0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 113 additions and 49 deletions

View file

@ -1,5 +1,6 @@
import RequestError from '@/errors/RequestError';
import { RequestErrorBody } from '@logto/schemas';
import { LogtoErrorCode } from '@logto/phrases';
import decamelize from 'decamelize';
import { Middleware } from 'koa';
import { errors } from 'oidc-provider';
@ -23,7 +24,8 @@ export default function koaErrorHandler<StateT, ContextT>(): Middleware<
ctx.status = error.status;
ctx.body = {
message: error.error_description ?? error.message,
code: `oidc.${decamelize(error.name)}`,
// Assert error type of OIDCProviderError, code key should all covered in @logto/phrases
code: `oidc.${decamelize(error.name)}` as LogtoErrorCode,
data: error.error_detail,
};
return;

View file

@ -5,4 +5,4 @@ export type RequestErrorMetadata = Record<string, unknown> & {
status?: number;
};
export type RequestErrorBody = { message: string; data: unknown; code: string };
export type RequestErrorBody = { message: string; data: unknown; code: LogtoErrorCode };

View file

@ -18,6 +18,7 @@
},
"dependencies": {
"@logto/phrases": "^0.1.0",
"@logto/schemas": "^0.1.0",
"classnames": "^2.3.1",
"i18next": "^20.3.3",
"i18next-browser-languagedetector": "^6.1.2",

View file

@ -21,6 +21,11 @@ module.exports = {
/** @type {import('@jest/types').Config.InitialOptions} **/
const config = { ...jestConfig };
config.transformIgnorePatterns = [
'^.+\\.module\\.(css|sass|scss)$',
'[/\\\\]node_modules[/\\\\]((?!ky[/\\\\]).)+\\.(js|jsx|mjs|cjs|ts|tsx)$',
];
config.moduleNameMapper = {
...config.moduleNameMapper,
'^.+\\.(css|less|scss)$': 'babel-jest',

View file

@ -0,0 +1,46 @@
import { useState } from 'react';
import { HTTPError } from 'ky';
import { RequestErrorBody } from '@logto/schemas';
type UseApi<T extends any[], U> = {
result?: U;
loading: boolean;
error: RequestErrorBody | null;
run: (...args: T) => Promise<void>;
};
function useApi<Args extends any[], Response>(
api: (...args: Args) => Promise<Response>
): UseApi<Args, Response> {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<RequestErrorBody | null>(null);
const [result, setResult] = useState<Response>();
const run = async (...args: Args) => {
setLoading(true);
setError(null);
try {
const result = await api(...args);
setResult(result);
setLoading(false);
} catch (error: unknown) {
if (error instanceof HTTPError) {
const kyError = await error.response.json<RequestErrorBody>();
setError(kyError);
}
setLoading(false);
throw error;
}
};
return {
loading,
error,
result,
run,
};
}
export default useApi;

View file

@ -69,3 +69,7 @@ type AutoCompleteType =
| 'sex'
| 'url'
| 'photo';
interface Body {
json<T>(): Promise<T>;
}

View file

@ -1,63 +1,65 @@
import React, { FC, FormEventHandler, useState, useCallback } from 'react';
import React, { FC, FormEventHandler, useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { LogtoErrorI18nKey } from '@logto/phrases';
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 useApi from '@/hooks/use-api';
import styles from './index.module.scss';
export type PageState = 'idle' | 'loading' | 'error';
const App: FC = () => {
const { t } = useTranslation();
const Register: FC = () => {
const { t, i18n } = useTranslation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [pageState, setPageState] = useState<PageState>('idle');
const isLoading = pageState === 'loading';
const { loading, error, result, run: asyncRegister } = useApi(register);
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');
}
await asyncRegister(username, password);
},
[username, password]
[username, password, asyncRegister]
);
useEffect(() => {
if (result?.redirectTo) {
window.location.href = result.redirectTo;
}
}, [result]);
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}
isDisabled={loading}
placeholder={t('sign_in.username')}
value={username}
onChange={setUsername} // TODO: account validation
/>
<Input
name="password"
isDisabled={isLoading}
isDisabled={loading}
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>
{error && (
<MessageBox className={styles.box}>
{i18n.t<string, LogtoErrorI18nKey>(`errors:${error.code}`)}
</MessageBox>
)}
<Button
isDisabled={isLoading}
value={isLoading ? t('register.loading') : t('register.action')}
isDisabled={loading}
value={loading ? t('register.loading') : t('register.action')}
onClick={signUp}
/>
@ -70,4 +72,4 @@ const App: FC = () => {
);
};
export default App;
export default Register;

View file

@ -1,39 +1,39 @@
import React, { FC, FormEventHandler, useState, useCallback } from 'react';
import React, { FC, FormEventHandler, useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { LogtoErrorI18nKey } from '@logto/phrases';
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 useApi from '@/hooks/use-api';
import styles from './index.module.scss';
export type PageState = 'idle' | 'loading' | 'error';
const Home: FC = () => {
// TODO: Consider creading cross page data modal
const { t } = useTranslation();
const SignIn: FC = () => {
// TODO: Consider creating cross page data modal
const { t, i18n } = useTranslation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [pageState, setPageState] = useState<PageState>('idle');
const isLoading = pageState === 'loading';
const signIn: FormEventHandler = useCallback(
const { loading, error, result, run: asyncSignInBasic } = useApi(signInBasic);
const signInHandler: 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');
}
await asyncSignInBasic(username, password);
},
[username, password]
[username, password, asyncSignInBasic]
);
useEffect(() => {
if (result?.redirectTo) {
window.location.href = result.redirectTo;
}
}, [result]);
return (
<div className={classNames(styles.wrapper)}>
<form className={classNames(styles.form)}>
@ -41,7 +41,7 @@ const Home: FC = () => {
<Input
name="username"
autoComplete="username"
isDisabled={isLoading}
isDisabled={loading}
placeholder={t('sign_in.username')}
value={username}
onChange={setUsername}
@ -49,19 +49,21 @@ const Home: FC = () => {
<Input
name="password"
autoComplete="current-password"
isDisabled={isLoading}
isDisabled={loading}
placeholder={t('sign_in.password')}
type="password"
value={password}
onChange={setPassword}
/>
{pageState === 'error' && (
<MessageBox className={styles.box}>{t('sign_in.error')}</MessageBox>
{error && (
<MessageBox className={styles.box}>
{i18n.t<string, LogtoErrorI18nKey>(`errors:${error.code}`)}
</MessageBox>
)}
<Button
isDisabled={isLoading}
value={isLoading ? t('sign_in.loading') : t('sign_in.action')}
onClick={signIn}
isDisabled={loading}
value={loading ? t('sign_in.loading') : t('sign_in.action')}
onClick={signInHandler}
/>
<TextLink className={styles.createAccount} href="/register">
{t('register.create_account')}
@ -71,4 +73,4 @@ const Home: FC = () => {
);
};
export default Home;
export default SignIn;

View file

@ -161,6 +161,7 @@ importers:
'@logto/eslint-config': ^0.1.0-rc.18
'@logto/eslint-config-react': ^0.1.0-rc.18
'@logto/phrases': ^0.1.0
'@logto/schemas': ^0.1.0
'@logto/ts-config': ^0.1.0-rc.18
'@logto/ts-config-react': ^0.1.0-rc.18
'@testing-library/react': ^12.0.0
@ -195,6 +196,7 @@ importers:
webpack-dev-server: ^3.11.2
dependencies:
'@logto/phrases': link:../phrases
'@logto/schemas': link:../schemas
classnames: 2.3.1
i18next: 20.3.5
i18next-browser-languagedetector: 6.1.2