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 {
|
||||
--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,
|
||||
|
@ -77,6 +42,7 @@ $font-family: 'PingFang SC', 'SF Pro Text', sans-serif;
|
|||
--color-checkbox-border: #{$color-neutral-30};
|
||||
--color-divider: #dbdbdb;
|
||||
--color-dark-background: #{rgba($color-neutral-100, 0.8)};
|
||||
--color-loading-layer: rgba(0, 0, 0, 8%);
|
||||
|
||||
/* Font Color */
|
||||
--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 LoadingLayer from '../LoadingLayer';
|
||||
import Toast from '../Toast';
|
||||
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])}>
|
||||
{children}
|
||||
<Toast message={toast} isVisible={Boolean(toast)} callback={hideToast} />
|
||||
{loading && <LoadingLayer />}
|
||||
</main>
|
||||
</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 PrivacyIcon } from './PrivacyIcon';
|
||||
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 '.';
|
||||
|
||||
jest.mock('@/apis/register', () => ({ register: jest.fn(async () => Promise.resolve()) }));
|
||||
jest.mock('@/hooks/page-context', () =>
|
||||
React.createContext({
|
||||
loading: false,
|
||||
setLoading: jest.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
describe('<CreateAccount/>', () => {
|
||||
test('default render', () => {
|
||||
|
|
|
@ -52,7 +52,7 @@ const CreateAccount = () => {
|
|||
|
||||
const { setToast } = useContext(PageContext);
|
||||
|
||||
const { loading, error, result, run: asyncRegister } = useApi(register);
|
||||
const { error, result, run: asyncRegister } = useApi(register);
|
||||
|
||||
const validations = useMemo<FieldValidations>(
|
||||
() => ({
|
||||
|
@ -93,11 +93,6 @@ const CreateAccount = () => {
|
|||
);
|
||||
|
||||
const onSubmitHandler = useCallback(() => {
|
||||
// Should be removed after api redesign
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validates
|
||||
const usernameError = validations.username?.(fieldState);
|
||||
const passwordError = validations.password?.(fieldState);
|
||||
|
@ -132,7 +127,7 @@ const CreateAccount = () => {
|
|||
}
|
||||
|
||||
void asyncRegister(fieldState.username, fieldState.password);
|
||||
}, [fieldState, loading, validations, asyncRegister]);
|
||||
}, [fieldState, validations, asyncRegister]);
|
||||
|
||||
useEffect(() => {
|
||||
if (result?.redirectTo) {
|
||||
|
|
|
@ -13,6 +13,13 @@ jest.mock('@/apis/utils', () => ({
|
|||
getVerifyPasscodeApi: () => verifyPasscodeApi,
|
||||
}));
|
||||
|
||||
jest.mock('@/hooks/page-context', () =>
|
||||
React.createContext({
|
||||
loading: false,
|
||||
setLoading: jest.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
describe('<PasscodeValidation />', () => {
|
||||
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/register', () => ({ sendEmailPasscode: jest.fn(async () => Promise.resolve()) }));
|
||||
jest.mock('@/hooks/page-context', () =>
|
||||
React.createContext({
|
||||
loading: false,
|
||||
setLoading: jest.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
describe('<EmailPasswordless/>', () => {
|
||||
test('render', () => {
|
||||
|
|
|
@ -50,7 +50,7 @@ const EmailPasswordless = ({ type }: Props) => {
|
|||
|
||||
const sendPasscode = getSendPasscodeApi(type, 'email');
|
||||
|
||||
const { loading, error, result, run: asyncSendPasscode } = useApi(sendPasscode);
|
||||
const { error, result, run: asyncSendPasscode } = useApi(sendPasscode);
|
||||
|
||||
const validations = useMemo<FieldValidations>(
|
||||
() => ({
|
||||
|
@ -69,11 +69,6 @@ const EmailPasswordless = ({ type }: Props) => {
|
|||
);
|
||||
|
||||
const onSubmitHandler = useCallback(() => {
|
||||
// Should be removed after api redesign
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emailError = validations.email(fieldState);
|
||||
|
||||
if (emailError) {
|
||||
|
@ -91,7 +86,7 @@ const EmailPasswordless = ({ type }: Props) => {
|
|||
}
|
||||
|
||||
void asyncSendPasscode(fieldState.email);
|
||||
}, [loading, validations, fieldState, asyncSendPasscode]);
|
||||
}, [validations, fieldState, asyncSendPasscode]);
|
||||
|
||||
useEffect(() => {
|
||||
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/register', () => ({ sendPhonePasscode: jest.fn(async () => Promise.resolve()) }));
|
||||
jest.mock('@/hooks/page-context', () =>
|
||||
React.createContext({
|
||||
loading: false,
|
||||
setLoading: jest.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
describe('<PhonePasswordless/>', () => {
|
||||
const phoneNumber = '18888888888';
|
||||
|
|
|
@ -50,7 +50,7 @@ const PhonePasswordless = ({ type }: Props) => {
|
|||
const { phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber();
|
||||
|
||||
const sendPasscode = getSendPasscodeApi(type, 'phone');
|
||||
const { loading, error, result, run: asyncSendPasscode } = useApi(sendPasscode);
|
||||
const { error, result, run: asyncSendPasscode } = useApi(sendPasscode);
|
||||
|
||||
const validations = useMemo<FieldValidations>(
|
||||
() => ({
|
||||
|
@ -69,11 +69,6 @@ const PhonePasswordless = ({ type }: Props) => {
|
|||
);
|
||||
|
||||
const onSubmitHandler = useCallback(() => {
|
||||
// Should be removed after api redesign
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const phoneError = validations.phone(fieldState);
|
||||
|
||||
if (phoneError) {
|
||||
|
@ -91,7 +86,7 @@ const PhonePasswordless = ({ type }: Props) => {
|
|||
}
|
||||
|
||||
void asyncSendPasscode(fieldState.phone);
|
||||
}, [loading, validations, fieldState, asyncSendPasscode]);
|
||||
}, [validations, fieldState, asyncSendPasscode]);
|
||||
|
||||
useEffect(() => {
|
||||
setFieldState((previous) => ({
|
||||
|
|
|
@ -6,6 +6,12 @@ import { signInBasic } from '@/apis/sign-in';
|
|||
import UsernameSignin from '.';
|
||||
|
||||
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>', () => {
|
||||
test('render', () => {
|
||||
|
|
|
@ -49,7 +49,7 @@ const UsernameSignin: FC = () => {
|
|||
|
||||
const { setToast } = useContext(PageContext);
|
||||
|
||||
const { error, loading, result, run: asyncSignInBasic } = useApi(signInBasic);
|
||||
const { error, result, run: asyncSignInBasic } = useApi(signInBasic);
|
||||
|
||||
const validations = useMemo<FieldValidations>(
|
||||
() => ({
|
||||
|
@ -73,11 +73,6 @@ const UsernameSignin: FC = () => {
|
|||
);
|
||||
|
||||
const onSubmitHandler = useCallback(async () => {
|
||||
// Should be removed after api redesign
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validates
|
||||
const usernameError = validations.username?.(fieldState);
|
||||
const passwordError = validations.password?.(fieldState);
|
||||
|
@ -104,7 +99,7 @@ const UsernameSignin: FC = () => {
|
|||
}
|
||||
|
||||
void asyncSignInBasic(fieldState.username, fieldState.password);
|
||||
}, [loading, validations, fieldState, asyncSignInBasic]);
|
||||
}, [validations, fieldState, asyncSignInBasic]);
|
||||
|
||||
useEffect(() => {
|
||||
if (result?.redirectTo) {
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { RequestErrorBody } from '@logto/schemas';
|
||||
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> = {
|
||||
result?: U;
|
||||
loading: boolean;
|
||||
error: RequestErrorBody | undefined;
|
||||
run: (...args: T) => Promise<void>;
|
||||
};
|
||||
|
@ -12,10 +13,11 @@ type UseApi<T extends any[], U> = {
|
|||
function useApi<Args extends any[], Response>(
|
||||
api: (...args: Args) => Promise<Response>
|
||||
): UseApi<Args, Response> {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<RequestErrorBody>();
|
||||
const [result, setResult] = useState<Response>();
|
||||
|
||||
const { setLoading } = useContext(PageContext);
|
||||
|
||||
const run = useCallback(
|
||||
async (...args: Args) => {
|
||||
setLoading(true);
|
||||
|
@ -39,11 +41,10 @@ function useApi<Args extends any[], Response>(
|
|||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[api]
|
||||
[api, setLoading]
|
||||
);
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
result,
|
||||
run,
|
||||
|
|
Loading…
Add table
Reference in a new issue