0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

Merge pull request #584 from logto-io/charles-log-2232-button-loading-state

feat(console): button loading state with delay timeout
This commit is contained in:
Charles Zhao 2022-04-20 14:34:02 +08:00 committed by GitHub
commit a0af0584f7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 122 additions and 26 deletions

View file

@ -8,17 +8,42 @@
transition: background 0.2s ease-in-out;
align-items: center;
white-space: nowrap;
user-select: none;
position: relative;
&.withIcon {
display: inline-flex;
}
&.loading {
pointer-events: none;
opacity: 60%;
.spinner {
width: 16px;
height: 16px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
svg {
@include _.rotating-animation;
}
}
.spinner ~ span {
visibility: hidden;
}
}
&:not(:disabled) {
cursor: pointer;
}
&:disabled {
cursor: not-allowed;
pointer-events: none;
}
.icon {
@ -70,7 +95,7 @@
border-color: var(--color-neutral-70);
}
&:focus {
&:focus-visible {
outline: 3px solid var(--color-focused);
}
@ -92,7 +117,7 @@
color: var(--color-neutral-70);
}
&:focus {
&:focus-visible {
outline: 3px solid var(--color-focused-variant);
}
@ -114,7 +139,7 @@
color: var(--color-neutral-70);
}
&:focus {
&:focus-visible {
outline: 3px solid var(--color-danger-focused);
}
@ -139,7 +164,7 @@
color: var(--color-neutral-70);
}
&:focus {
&:focus-visible {
outline: 3px solid var(--color-focused-variant);
}
@ -162,7 +187,7 @@
color: var(--color-disabled);
}
&:focus {
&:focus-visible {
outline: 2px solid var(--color-focused-variant);
}

View file

@ -1,9 +1,10 @@
import { I18nKey } from '@logto/phrases';
import { conditionalString } from '@silverhand/essentials';
import classNames from 'classnames';
import React, { HTMLProps, ReactElement, ReactNode } from 'react';
import React, { HTMLProps, ReactElement, ReactNode, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Spinner from '@/icons/Spinner';
import DangerousRaw from '../DangerousRaw';
import * as styles from './index.module.scss';
@ -11,6 +12,8 @@ type BaseProps = Omit<HTMLProps<HTMLButtonElement>, 'type' | 'size' | 'title'> &
htmlType?: 'button' | 'submit' | 'reset';
type?: 'primary' | 'danger' | 'outline' | 'plain' | 'default';
size?: 'small' | 'medium' | 'large';
isLoading?: boolean;
loadingDelay?: number;
};
type TitleButtonProps = BaseProps & {
@ -32,9 +35,29 @@ const Button = ({
title,
icon,
className,
isLoading = false,
loadingDelay = 500,
onClick,
...rest
}: Props) => {
const { t } = useTranslation();
const [showSpinner, setShowSpinner] = useState(false);
const timerRef = useRef<number>();
useEffect(() => {
// Delay showing the spinner after 'loadingDelay' milliseconds
if (isLoading) {
// eslint-disable-next-line @silverhand/fp/no-mutation
timerRef.current = setTimeout(() => {
setShowSpinner(true);
}, loadingDelay);
}
return () => {
clearTimeout(timerRef.current);
setShowSpinner(false);
};
}, [isLoading, loadingDelay]);
return (
<button
@ -42,12 +65,24 @@ const Button = ({
styles.button,
styles[type],
styles[size],
conditionalString(icon && styles.withIcon),
icon && styles.withIcon,
isLoading && styles.loading,
className
)}
type={htmlType}
onClick={(event) => {
if (isLoading) {
return false;
}
onClick?.(event);
}}
{...rest}
>
{showSpinner && (
<span className={styles.spinner}>
<Spinner />
</span>
)}
{icon && <span className={styles.icon}>{icon}</span>}
{title && (typeof title === 'string' ? <span>{t(title)}</span> : title)}
</button>

View file

@ -0,0 +1,19 @@
import React, { SVGProps } from 'react';
const Spinner = (props: SVGProps<SVGSVGElement>) => (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M9.33566 14.8714C9.44104 15.4135 9.08652 15.9451 8.53547 15.9821C7.4048 16.0579 6.2669 15.8929 5.19834 15.4934C3.81639 14.9767 2.60425 14.0879 1.69591 12.9253C0.78758 11.7627 0.218443 10.3715 0.0514252 8.90563C-0.115592 7.43973 0.126015 5.9562 0.749537 4.61905C1.37306 3.28191 2.35421 2.14323 3.5845 1.32891C4.8148 0.514598 6.24632 0.0563637 7.7208 0.00487344C9.19528 -0.0466168 10.6553 0.310643 11.9394 1.03715C12.9323 1.59891 13.7901 2.36452 14.4588 3.27942C14.7847 3.72531 14.6054 4.33858 14.1223 4.60633C13.6393 4.87408 13.0366 4.69278 12.6924 4.26086C12.2154 3.66218 11.6262 3.15785 10.9545 2.77787C9.99146 2.23298 8.89646 1.96504 7.7906 2.00366C6.68474 2.04227 5.6111 2.38595 4.68838 2.99669C3.76565 3.60742 3.02979 4.46143 2.56215 5.46429C2.09451 6.46715 1.91331 7.5798 2.03857 8.67922C2.16383 9.77864 2.59069 10.822 3.27194 11.694C3.95319 12.5659 4.8623 13.2325 5.89876 13.62C6.62154 13.8903 7.38663 14.0175 8.15188 13.9981C8.70399 13.9841 9.23028 14.3293 9.33566 14.8714Z"
fill="currentColor"
/>
</svg>
);
export default Spinner;

View file

@ -51,7 +51,8 @@ const DeleteForm = ({ id, name, onClose }: Props) => {
onClick={onClose}
/>
<Button
disabled={inputMismatched || loading}
disabled={inputMismatched}
isLoading={loading}
type="danger"
title="admin_console.api_resource_details.delete"
onClick={handleDelete}

View file

@ -163,7 +163,7 @@ const ApiResourceDetails = () => {
</div>
<div className={detailsStyles.footer}>
<Button
disabled={isSubmitting}
isLoading={isSubmitting}
htmlType="submit"
type="primary"
title="admin_console.api_resource_details.save_changes"

View file

@ -41,7 +41,7 @@ const CreateForm = ({ onClose }: Props) => {
subtitle="api_resources.subtitle"
footer={
<Button
disabled={isSubmitting}
isLoading={isSubmitting}
htmlType="submit"
title="admin_console.api_resources.create"
size="large"

View file

@ -51,7 +51,8 @@ const DeleteForm = ({ id, name, onClose }: Props) => {
onClick={onClose}
/>
<Button
disabled={inputMismatched || loading}
disabled={inputMismatched}
isLoading={loading}
type="danger"
title="admin_console.application_details.delete"
onClick={handleDelete}

View file

@ -258,9 +258,10 @@ const ApplicationDetails = () => {
</div>
<div className={detailsStyles.footer}>
<Button
disabled={isSubmitting}
isLoading={isSubmitting}
htmlType="submit"
type="primary"
size="large"
title="admin_console.application_details.save_changes"
/>
</div>

View file

@ -83,7 +83,7 @@ const CreateForm = ({ onClose }: Props) => {
size="large"
footer={
<Button
disabled={isSubmitting}
isLoading={isSubmitting}
htmlType="submit"
title="admin_console.applications.create"
size="large"

View file

@ -24,7 +24,7 @@ type FormData = {
const SenderTester = ({ connectorType }: Props) => {
const buttonPosReference = useRef(null);
const [showTooltip, setShowTooltip] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [isSubmitting, seIsSubmitting] = useState(false);
const {
handleSubmit,
register,
@ -42,7 +42,7 @@ const SenderTester = ({ connectorType }: Props) => {
const tooltipTimeout = setTimeout(() => {
setShowTooltip(false);
setSubmitting(false);
seIsSubmitting(false);
}, 2000);
return () => {
@ -52,7 +52,7 @@ const SenderTester = ({ connectorType }: Props) => {
const onSubmit = handleSubmit(async (formData) => {
const { sendTo } = formData;
setSubmitting(true);
seIsSubmitting(true);
const data = isSms ? { phone: sendTo } : { email: sendTo };
@ -65,7 +65,7 @@ const SenderTester = ({ connectorType }: Props) => {
setShowTooltip(true);
} catch (error: unknown) {
console.error(error);
setSubmitting(false);
seIsSubmitting(false);
}
});
@ -95,7 +95,7 @@ const SenderTester = ({ connectorType }: Props) => {
<div ref={buttonPosReference} className={styles.send}>
<Button
htmlType="submit"
disabled={submitting}
isLoading={isSubmitting}
title="admin_console.connector_details.send"
type="outline"
/>

View file

@ -32,7 +32,7 @@ const ConnectorDetails = () => {
const [isReadMeOpen, setIsReadMeOpen] = useState(false);
const [config, setConfig] = useState<string>();
const [saveError, setSaveError] = useState<string>();
const [isSubmitLoading, setIsSubmitLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSetupOpen, setIsSetupOpen] = useState(false);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data, error } = useSWR<ConnectorDTO, RequestError>(
@ -61,7 +61,7 @@ const ConnectorDetails = () => {
try {
const configJson = JSON.parse(config) as JSON;
setIsSubmitLoading(true);
setIsSubmitting(true);
await api
.patch(`/api/connectors/${connectorId}`, {
json: { config: configJson },
@ -74,7 +74,7 @@ const ConnectorDetails = () => {
}
}
setIsSubmitLoading(false);
setIsSubmitting(false);
};
const handleDelete = async () => {
@ -197,7 +197,7 @@ const ConnectorDetails = () => {
<Button
type="primary"
title="admin_console.connector_details.save_changes"
disabled={isSubmitLoading}
isLoading={isSubmitting}
onClick={handleSave}
/>
</div>

View file

@ -84,7 +84,7 @@ const SignInExperience = () => {
{tab === 'methods' && <SignInMethodsForm />}
<div className={detailsStyles.footer}>
<Button
disabled={isSubmitting}
isLoading={isSubmitting}
type="primary"
htmlType="submit"
title="general.save_changes"

View file

@ -41,7 +41,7 @@ const ResetPasswordForm = ({ onClose, userId }: Props) => {
title="user_details.reset_password.title"
footer={
<Button
disabled={isSubmitting}
isLoading={isSubmitting}
htmlType="submit"
title="admin_console.user_details.reset_password.reset_password"
size="large"

View file

@ -241,7 +241,7 @@ const UserDetails = () => {
</div>
<div className={detailsStyles.footer}>
<Button
disabled={isSubmitting}
isLoading={isSubmitting}
htmlType="submit"
type="primary"
title="admin_console.user_details.save_changes"

View file

@ -25,3 +25,17 @@
border-left: 1px solid var(--color-border);
width: 0;
}
@mixin rotating-animation {
animation: rotating 1s ease-in-out infinite;
}
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}