0
Fork 0
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:
simeng-li 2022-04-08 10:12:26 +08:00 committed by GitHub
parent acacd456c4
commit b78a9cf82e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 123 additions and 68 deletions

View 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

View file

@ -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};

View file

@ -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>
);

View 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;

View file

@ -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';

View 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);
}
}

View 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;

View file

@ -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', () => {

View file

@ -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) {

View file

@ -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';

View file

@ -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', () => {

View file

@ -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);

View file

@ -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';

View file

@ -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) => ({

View file

@ -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', () => {

View file

@ -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) {

View file

@ -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,