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:
commit
a0af0584f7
15 changed files with 122 additions and 26 deletions
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
19
packages/console/src/icons/Spinner.tsx
Normal file
19
packages/console/src/icons/Spinner.tsx
Normal 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;
|
|
@ -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}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue