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:
parent
f3bb467918
commit
b1bf5eccf0
9 changed files with 113 additions and 49 deletions
|
@ -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;
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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',
|
||||
|
|
46
packages/ui/src/hooks/use-api.ts
Normal file
46
packages/ui/src/hooks/use-api.ts
Normal 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;
|
4
packages/ui/src/include.d/dom.d.ts
vendored
4
packages/ui/src/include.d/dom.d.ts
vendored
|
@ -69,3 +69,7 @@ type AutoCompleteType =
|
|||
| 'sex'
|
||||
| 'url'
|
||||
| 'photo';
|
||||
|
||||
interface Body {
|
||||
json<T>(): Promise<T>;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue