mirror of
https://github.com/logto-io/logto.git
synced 2025-02-10 21:58:23 -05:00
feat(ui): add page level loading layer (#513)
* feat(ui): add page level loading layer add page level loading layer * fix(ui): cr fix remove loading status in api-hook remove loading status in api-hook
This commit is contained in:
parent
acacd456c4
commit
b78a9cf82e
17 changed files with 123 additions and 68 deletions
14
packages/ui/src/assets/icons/loading.svg
Normal file
14
packages/ui/src/assets/icons/loading.svg
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<svg id="loading" width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="15" width="2" height="8" rx="1" fill="white" fill-opacity="0.44"/>
|
||||||
|
<rect x="15" y="24" width="2" height="8" rx="1" fill="white"/>
|
||||||
|
<rect y="17" width="2" height="8" rx="1" transform="rotate(-90 0 17)" fill="white" fill-opacity="0.76"/>
|
||||||
|
<rect x="24" y="17" width="2" height="8" rx="1" transform="rotate(-90 24 17)" fill="white" fill-opacity="0.2"/>
|
||||||
|
<rect x="29.3564" y="7.13403" width="2" height="8" rx="1" transform="rotate(60 29.3564 7.13403)" fill="white" fill-opacity="0.28"/>
|
||||||
|
<rect x="8.57227" y="19.134" width="2" height="8" rx="1" transform="rotate(60 8.57227 19.134)" fill="white" fill-opacity="0.84"/>
|
||||||
|
<rect x="1.64355" y="8.86597" width="2" height="8" rx="1" transform="rotate(-60 1.64355 8.86597)" fill="white" fill-opacity="0.64"/>
|
||||||
|
<rect x="22.4277" y="20.866" width="2" height="8" rx="1" transform="rotate(-60 22.4277 20.866)" fill="white" fill-opacity="0.16"/>
|
||||||
|
<rect x="23.1338" y="1.64355" width="2" height="8" rx="1" transform="rotate(30 23.1338 1.64355)" fill="white" fill-opacity="0.36"/>
|
||||||
|
<rect x="11.1338" y="22.4282" width="2" height="8" rx="1" transform="rotate(30 11.1338 22.4282)" fill="white" fill-opacity="0.96"/>
|
||||||
|
<rect x="7.13379" y="2.64355" width="2" height="8" rx="1" transform="rotate(-30 7.13379 2.64355)" fill="white" fill-opacity="0.52"/>
|
||||||
|
<rect x="19.1338" y="23.4282" width="2" height="8" rx="1" transform="rotate(-30 19.1338 23.4282)" fill="white" fill-opacity="0.04"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
|
@ -24,41 +24,6 @@ $font-family: 'PingFang SC', 'SF Pro Text', sans-serif;
|
||||||
|
|
||||||
.universal {
|
.universal {
|
||||||
--color-error: #ea0000;
|
--color-error: #ea0000;
|
||||||
|
|
||||||
/* ===== Legacy Styling ===== */
|
|
||||||
/* Color */
|
|
||||||
--color-button-background: #3c4ce3;
|
|
||||||
--color-button-background-disabled: #626fe8;
|
|
||||||
--color-button-background-hover: #2234df;
|
|
||||||
--color-button-text: #f5f5f5;
|
|
||||||
--color-button-text-disabled: #eee;
|
|
||||||
--color-error-background: #{rgba(#ff6b66, 0.15)};
|
|
||||||
--color-error-border: #{rgba(#ff6b66, 0.35)};
|
|
||||||
|
|
||||||
/* Shadow */
|
|
||||||
--shadow-button: 2px 2px 8px rgb(60, 76, 227, 25%);
|
|
||||||
|
|
||||||
/* Transition */
|
|
||||||
--transition-default-function: 0.2s ease-in-out;
|
|
||||||
--transition-default-control:
|
|
||||||
background var(--transition-default-function),
|
|
||||||
color var(--transition-default-function);
|
|
||||||
}
|
|
||||||
|
|
||||||
.darkLegacy {
|
|
||||||
/* ===== Legacy Styling ===== */
|
|
||||||
/* Color */
|
|
||||||
--color-heading: #f3f6f9;
|
|
||||||
--color-body: #dadae0;
|
|
||||||
--color-secondary: #b3b7ba;
|
|
||||||
--color-placeholder: #888;
|
|
||||||
--color-control-background: #45484a;
|
|
||||||
--color-control-background-disabled: #65686a;
|
|
||||||
--color-background: #101419;
|
|
||||||
|
|
||||||
/* Shadow */
|
|
||||||
--shadow-card: 2px 2px 24px rgb(59, 61, 63, 25%);
|
|
||||||
--shadow-control: 1px 1px 2px rgb(221, 221, 221, 25%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.light,
|
.light,
|
||||||
|
@ -77,6 +42,7 @@ $font-family: 'PingFang SC', 'SF Pro Text', sans-serif;
|
||||||
--color-checkbox-border: #{$color-neutral-30};
|
--color-checkbox-border: #{$color-neutral-30};
|
||||||
--color-divider: #dbdbdb;
|
--color-divider: #dbdbdb;
|
||||||
--color-dark-background: #{rgba($color-neutral-100, 0.8)};
|
--color-dark-background: #{rgba($color-neutral-100, 0.8)};
|
||||||
|
--color-loading-layer: rgba(0, 0, 0, 8%);
|
||||||
|
|
||||||
/* Font Color */
|
/* Font Color */
|
||||||
--color-font-primary: #{$color-neutral-100};
|
--color-font-primary: #{$color-neutral-100};
|
||||||
|
|
|
@ -3,6 +3,7 @@ import React, { ReactNode, useState, useMemo, useCallback } from 'react';
|
||||||
|
|
||||||
import PageContext from '@/hooks/page-context';
|
import PageContext from '@/hooks/page-context';
|
||||||
|
|
||||||
|
import LoadingLayer from '../LoadingLayer';
|
||||||
import Toast from '../Toast';
|
import Toast from '../Toast';
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
@ -29,6 +30,7 @@ const AppContent = ({ children, theme }: Props) => {
|
||||||
<main className={classNames(styles.content, styles.universal, styles.mobile, styles[theme])}>
|
<main className={classNames(styles.content, styles.universal, styles.mobile, styles[theme])}>
|
||||||
{children}
|
{children}
|
||||||
<Toast message={toast} isVisible={Boolean(toast)} callback={hideToast} />
|
<Toast message={toast} isVisible={Boolean(toast)} callback={hideToast} />
|
||||||
|
{loading && <LoadingLayer />}
|
||||||
</main>
|
</main>
|
||||||
</PageContext.Provider>
|
</PageContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
13
packages/ui/src/components/Icons/LoadingIcon.tsx
Normal file
13
packages/ui/src/components/Icons/LoadingIcon.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import React, { SVGProps } from 'react';
|
||||||
|
|
||||||
|
import Loading from '@/assets/icons/loading.svg';
|
||||||
|
|
||||||
|
const LoadingIcon = (props: SVGProps<SVGSVGElement>) => {
|
||||||
|
return (
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<use href={`${Loading}#loading`} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingIcon;
|
|
@ -1,3 +1,4 @@
|
||||||
export { default as ClearIcon } from './ClearIcon';
|
export { default as ClearIcon } from './ClearIcon';
|
||||||
export { default as PrivacyIcon } from './PrivacyIcon';
|
export { default as PrivacyIcon } from './PrivacyIcon';
|
||||||
export { default as DownArrowIcon } from './DownArrowIcon';
|
export { default as DownArrowIcon } from './DownArrowIcon';
|
||||||
|
export { default as LoadingIcon } from './LoadingIcon';
|
||||||
|
|
33
packages/ui/src/components/LoadingLayer/index.module.scss
Normal file
33
packages/ui/src/components/LoadingLayer/index.module.scss
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
@use '@/scss/underscore' as _;
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: var(--color-loading-layer);
|
||||||
|
@include _.flex-column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--color-dark-background);
|
||||||
|
@include _.flex-column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingIcon {
|
||||||
|
animation: rotating 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotating {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
14
packages/ui/src/components/LoadingLayer/index.tsx
Normal file
14
packages/ui/src/components/LoadingLayer/index.tsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { LoadingIcon } from '../Icons';
|
||||||
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
const LoadingLayer = () => (
|
||||||
|
<div className={styles.overlay}>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<LoadingIcon className={styles.loadingIcon} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default LoadingLayer;
|
|
@ -6,6 +6,12 @@ import { register } from '@/apis/register';
|
||||||
import CreateAccount from '.';
|
import CreateAccount from '.';
|
||||||
|
|
||||||
jest.mock('@/apis/register', () => ({ register: jest.fn(async () => Promise.resolve()) }));
|
jest.mock('@/apis/register', () => ({ register: jest.fn(async () => Promise.resolve()) }));
|
||||||
|
jest.mock('@/hooks/page-context', () =>
|
||||||
|
React.createContext({
|
||||||
|
loading: false,
|
||||||
|
setLoading: jest.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
describe('<CreateAccount/>', () => {
|
describe('<CreateAccount/>', () => {
|
||||||
test('default render', () => {
|
test('default render', () => {
|
||||||
|
|
|
@ -52,7 +52,7 @@ const CreateAccount = () => {
|
||||||
|
|
||||||
const { setToast } = useContext(PageContext);
|
const { setToast } = useContext(PageContext);
|
||||||
|
|
||||||
const { loading, error, result, run: asyncRegister } = useApi(register);
|
const { error, result, run: asyncRegister } = useApi(register);
|
||||||
|
|
||||||
const validations = useMemo<FieldValidations>(
|
const validations = useMemo<FieldValidations>(
|
||||||
() => ({
|
() => ({
|
||||||
|
@ -93,11 +93,6 @@ const CreateAccount = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSubmitHandler = useCallback(() => {
|
const onSubmitHandler = useCallback(() => {
|
||||||
// Should be removed after api redesign
|
|
||||||
if (loading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validates
|
// Validates
|
||||||
const usernameError = validations.username?.(fieldState);
|
const usernameError = validations.username?.(fieldState);
|
||||||
const passwordError = validations.password?.(fieldState);
|
const passwordError = validations.password?.(fieldState);
|
||||||
|
@ -132,7 +127,7 @@ const CreateAccount = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
void asyncRegister(fieldState.username, fieldState.password);
|
void asyncRegister(fieldState.username, fieldState.password);
|
||||||
}, [fieldState, loading, validations, asyncRegister]);
|
}, [fieldState, validations, asyncRegister]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (result?.redirectTo) {
|
if (result?.redirectTo) {
|
||||||
|
|
|
@ -13,6 +13,13 @@ jest.mock('@/apis/utils', () => ({
|
||||||
getVerifyPasscodeApi: () => verifyPasscodeApi,
|
getVerifyPasscodeApi: () => verifyPasscodeApi,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/hooks/page-context', () =>
|
||||||
|
React.createContext({
|
||||||
|
loading: false,
|
||||||
|
setLoading: jest.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
describe('<PasscodeValidation />', () => {
|
describe('<PasscodeValidation />', () => {
|
||||||
const email = 'foo@logto.io';
|
const email = 'foo@logto.io';
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,12 @@ import EmailPasswordless from './EmailPasswordless';
|
||||||
|
|
||||||
jest.mock('@/apis/sign-in', () => ({ sendEmailPasscode: jest.fn(async () => Promise.resolve()) }));
|
jest.mock('@/apis/sign-in', () => ({ sendEmailPasscode: jest.fn(async () => Promise.resolve()) }));
|
||||||
jest.mock('@/apis/register', () => ({ sendEmailPasscode: jest.fn(async () => Promise.resolve()) }));
|
jest.mock('@/apis/register', () => ({ sendEmailPasscode: jest.fn(async () => Promise.resolve()) }));
|
||||||
|
jest.mock('@/hooks/page-context', () =>
|
||||||
|
React.createContext({
|
||||||
|
loading: false,
|
||||||
|
setLoading: jest.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
describe('<EmailPasswordless/>', () => {
|
describe('<EmailPasswordless/>', () => {
|
||||||
test('render', () => {
|
test('render', () => {
|
||||||
|
|
|
@ -50,7 +50,7 @@ const EmailPasswordless = ({ type }: Props) => {
|
||||||
|
|
||||||
const sendPasscode = getSendPasscodeApi(type, 'email');
|
const sendPasscode = getSendPasscodeApi(type, 'email');
|
||||||
|
|
||||||
const { loading, error, result, run: asyncSendPasscode } = useApi(sendPasscode);
|
const { error, result, run: asyncSendPasscode } = useApi(sendPasscode);
|
||||||
|
|
||||||
const validations = useMemo<FieldValidations>(
|
const validations = useMemo<FieldValidations>(
|
||||||
() => ({
|
() => ({
|
||||||
|
@ -69,11 +69,6 @@ const EmailPasswordless = ({ type }: Props) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSubmitHandler = useCallback(() => {
|
const onSubmitHandler = useCallback(() => {
|
||||||
// Should be removed after api redesign
|
|
||||||
if (loading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailError = validations.email(fieldState);
|
const emailError = validations.email(fieldState);
|
||||||
|
|
||||||
if (emailError) {
|
if (emailError) {
|
||||||
|
@ -91,7 +86,7 @@ const EmailPasswordless = ({ type }: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
void asyncSendPasscode(fieldState.email);
|
void asyncSendPasscode(fieldState.email);
|
||||||
}, [loading, validations, fieldState, asyncSendPasscode]);
|
}, [validations, fieldState, asyncSendPasscode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(result);
|
console.log(result);
|
||||||
|
|
|
@ -10,6 +10,12 @@ import PhonePasswordless from './PhonePasswordless';
|
||||||
|
|
||||||
jest.mock('@/apis/sign-in', () => ({ sendPhonePasscode: jest.fn(async () => Promise.resolve()) }));
|
jest.mock('@/apis/sign-in', () => ({ sendPhonePasscode: jest.fn(async () => Promise.resolve()) }));
|
||||||
jest.mock('@/apis/register', () => ({ sendPhonePasscode: jest.fn(async () => Promise.resolve()) }));
|
jest.mock('@/apis/register', () => ({ sendPhonePasscode: jest.fn(async () => Promise.resolve()) }));
|
||||||
|
jest.mock('@/hooks/page-context', () =>
|
||||||
|
React.createContext({
|
||||||
|
loading: false,
|
||||||
|
setLoading: jest.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
describe('<PhonePasswordless/>', () => {
|
describe('<PhonePasswordless/>', () => {
|
||||||
const phoneNumber = '18888888888';
|
const phoneNumber = '18888888888';
|
||||||
|
|
|
@ -50,7 +50,7 @@ const PhonePasswordless = ({ type }: Props) => {
|
||||||
const { phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber();
|
const { phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber();
|
||||||
|
|
||||||
const sendPasscode = getSendPasscodeApi(type, 'phone');
|
const sendPasscode = getSendPasscodeApi(type, 'phone');
|
||||||
const { loading, error, result, run: asyncSendPasscode } = useApi(sendPasscode);
|
const { error, result, run: asyncSendPasscode } = useApi(sendPasscode);
|
||||||
|
|
||||||
const validations = useMemo<FieldValidations>(
|
const validations = useMemo<FieldValidations>(
|
||||||
() => ({
|
() => ({
|
||||||
|
@ -69,11 +69,6 @@ const PhonePasswordless = ({ type }: Props) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSubmitHandler = useCallback(() => {
|
const onSubmitHandler = useCallback(() => {
|
||||||
// Should be removed after api redesign
|
|
||||||
if (loading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const phoneError = validations.phone(fieldState);
|
const phoneError = validations.phone(fieldState);
|
||||||
|
|
||||||
if (phoneError) {
|
if (phoneError) {
|
||||||
|
@ -91,7 +86,7 @@ const PhonePasswordless = ({ type }: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
void asyncSendPasscode(fieldState.phone);
|
void asyncSendPasscode(fieldState.phone);
|
||||||
}, [loading, validations, fieldState, asyncSendPasscode]);
|
}, [validations, fieldState, asyncSendPasscode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFieldState((previous) => ({
|
setFieldState((previous) => ({
|
||||||
|
|
|
@ -6,6 +6,12 @@ import { signInBasic } from '@/apis/sign-in';
|
||||||
import UsernameSignin from '.';
|
import UsernameSignin from '.';
|
||||||
|
|
||||||
jest.mock('@/apis/sign-in', () => ({ signInBasic: jest.fn(async () => Promise.resolve()) }));
|
jest.mock('@/apis/sign-in', () => ({ signInBasic: jest.fn(async () => Promise.resolve()) }));
|
||||||
|
jest.mock('@/hooks/page-context', () =>
|
||||||
|
React.createContext({
|
||||||
|
loading: false,
|
||||||
|
setLoading: jest.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
describe('<UsernameSignin>', () => {
|
describe('<UsernameSignin>', () => {
|
||||||
test('render', () => {
|
test('render', () => {
|
||||||
|
|
|
@ -49,7 +49,7 @@ const UsernameSignin: FC = () => {
|
||||||
|
|
||||||
const { setToast } = useContext(PageContext);
|
const { setToast } = useContext(PageContext);
|
||||||
|
|
||||||
const { error, loading, result, run: asyncSignInBasic } = useApi(signInBasic);
|
const { error, result, run: asyncSignInBasic } = useApi(signInBasic);
|
||||||
|
|
||||||
const validations = useMemo<FieldValidations>(
|
const validations = useMemo<FieldValidations>(
|
||||||
() => ({
|
() => ({
|
||||||
|
@ -73,11 +73,6 @@ const UsernameSignin: FC = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSubmitHandler = useCallback(async () => {
|
const onSubmitHandler = useCallback(async () => {
|
||||||
// Should be removed after api redesign
|
|
||||||
if (loading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validates
|
// Validates
|
||||||
const usernameError = validations.username?.(fieldState);
|
const usernameError = validations.username?.(fieldState);
|
||||||
const passwordError = validations.password?.(fieldState);
|
const passwordError = validations.password?.(fieldState);
|
||||||
|
@ -104,7 +99,7 @@ const UsernameSignin: FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
void asyncSignInBasic(fieldState.username, fieldState.password);
|
void asyncSignInBasic(fieldState.username, fieldState.password);
|
||||||
}, [loading, validations, fieldState, asyncSignInBasic]);
|
}, [validations, fieldState, asyncSignInBasic]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (result?.redirectTo) {
|
if (result?.redirectTo) {
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import { RequestErrorBody } from '@logto/schemas';
|
import { RequestErrorBody } from '@logto/schemas';
|
||||||
import { HTTPError } from 'ky';
|
import { HTTPError } from 'ky';
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useContext } from 'react';
|
||||||
|
|
||||||
|
import PageContext from '@/hooks/page-context';
|
||||||
|
|
||||||
type UseApi<T extends any[], U> = {
|
type UseApi<T extends any[], U> = {
|
||||||
result?: U;
|
result?: U;
|
||||||
loading: boolean;
|
|
||||||
error: RequestErrorBody | undefined;
|
error: RequestErrorBody | undefined;
|
||||||
run: (...args: T) => Promise<void>;
|
run: (...args: T) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
@ -12,10 +13,11 @@ type UseApi<T extends any[], U> = {
|
||||||
function useApi<Args extends any[], Response>(
|
function useApi<Args extends any[], Response>(
|
||||||
api: (...args: Args) => Promise<Response>
|
api: (...args: Args) => Promise<Response>
|
||||||
): UseApi<Args, Response> {
|
): UseApi<Args, Response> {
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<RequestErrorBody>();
|
const [error, setError] = useState<RequestErrorBody>();
|
||||||
const [result, setResult] = useState<Response>();
|
const [result, setResult] = useState<Response>();
|
||||||
|
|
||||||
|
const { setLoading } = useContext(PageContext);
|
||||||
|
|
||||||
const run = useCallback(
|
const run = useCallback(
|
||||||
async (...args: Args) => {
|
async (...args: Args) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
@ -39,11 +41,10 @@ function useApi<Args extends any[], Response>(
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api]
|
[api, setLoading]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
|
||||||
error,
|
error,
|
||||||
result,
|
result,
|
||||||
run,
|
run,
|
||||||
|
|
Loading…
Add table
Reference in a new issue