mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Async onClick for button
This commit is contained in:
parent
677f39f786
commit
3df1def41e
42
packages/stack-shared/src/hooks/use-async-callback.tsx
Normal file
42
packages/stack-shared/src/hooks/use-async-callback.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
|
||||
export function useAsyncCallback<A extends any[], R>(
|
||||
callback: (...args: A) => Promise<R>,
|
||||
deps: React.DependencyList
|
||||
): [cb: (...args: A) => Promise<R>, loading: boolean, error: unknown | undefined] {
|
||||
const [error, setError] = React.useState<unknown | undefined>(undefined);
|
||||
const [loadingCount, setLoadingCount] = React.useState(0);
|
||||
|
||||
const cb = React.useCallback(
|
||||
async (...args: A) => {
|
||||
setLoadingCount((c) => c + 1);
|
||||
try {
|
||||
return await callback(...args);
|
||||
} catch(e) {
|
||||
setError(e);
|
||||
throw e;
|
||||
} finally {
|
||||
setLoadingCount((c) => c - 1);
|
||||
}
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
return [cb, loadingCount > 0, error];
|
||||
}
|
||||
|
||||
export function useAsyncCallbackWithLoggedError<A extends any[], R>(
|
||||
callback: (...args: A) => Promise<R>,
|
||||
deps: React.DependencyList
|
||||
): [cb: (...args: A) => Promise<R>, loading: boolean] {
|
||||
const [newCallback, loading] = useAsyncCallback<A, R>(async (...args) => {
|
||||
try {
|
||||
return await callback(...args);
|
||||
} catch (e) {
|
||||
console.error("Uncaught error in async callback", e);
|
||||
throw e;
|
||||
}
|
||||
}, deps);
|
||||
|
||||
return [newCallback, loading];
|
||||
}
|
||||
@ -7,8 +7,6 @@ import {
|
||||
ReadonlyTokenStore,
|
||||
} from "./clientInterface";
|
||||
import { Result } from "../utils/results";
|
||||
import { AsyncCache } from "../utils/caches";
|
||||
import { runAsynchronously } from "../utils/promises";
|
||||
import { ReadonlyJson } from "../utils/json";
|
||||
|
||||
export type ServerUserJson = UserJson & {
|
||||
|
||||
@ -32,7 +32,7 @@ export const Button = React.forwardRef<
|
||||
).toString();
|
||||
};
|
||||
|
||||
return <JoyButton
|
||||
return <JoyButton
|
||||
color={muiVariant}
|
||||
variant={variant === 'link' ? 'plain' : 'solid'}
|
||||
sx={color ? {
|
||||
@ -52,4 +52,4 @@ export const Button = React.forwardRef<
|
||||
>
|
||||
{children}
|
||||
</JoyButton>;
|
||||
});
|
||||
});
|
||||
|
||||
@ -96,7 +96,8 @@ export type ButtonProps = {
|
||||
color?: string,
|
||||
size?: 'sm' | 'md' | 'lg',
|
||||
loading?: boolean,
|
||||
} & Omit<React.HTMLProps<HTMLButtonElement>, 'size' | 'type'>
|
||||
onClick?: (() => void) | (() => Promise<void>),
|
||||
} & Omit<React.HTMLProps<HTMLButtonElement>, 'size' | 'type' | 'onClick'>
|
||||
|
||||
const StyledButton = styled.button<{
|
||||
$size: 'sm' | 'md' | 'lg',
|
||||
@ -180,4 +181,6 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button };
|
||||
export {
|
||||
Button,
|
||||
};
|
||||
|
||||
@ -3,26 +3,26 @@
|
||||
import React from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { Components, useComponents } from '../providers/component-provider';
|
||||
import { Button as StaticButton } from './button';
|
||||
import { Container as StaticContainer } from './container';
|
||||
import { Separator as StaticSeparator } from './separator';
|
||||
import { Input as StaticInput } from './input';
|
||||
import { Label as StaticLabel } from './label';
|
||||
import { Link as StaticLink } from './link';
|
||||
import { Text as StaticText } from './text';
|
||||
import {
|
||||
import type { Button as StaticButton } from './button';
|
||||
import type { Container as StaticContainer } from './container';
|
||||
import type { Separator as StaticSeparator } from './separator';
|
||||
import type { Input as StaticInput } from './input';
|
||||
import type { Label as StaticLabel } from './label';
|
||||
import type { Link as StaticLink } from './link';
|
||||
import type { Text as StaticText } from './text';
|
||||
import type {
|
||||
Card as StaticCard,
|
||||
CardHeader as StaticCardHeader,
|
||||
CardContent as StaticCardContent,
|
||||
CardFooter as StaticCardFooter,
|
||||
CardDescription as StaticCardDescription,
|
||||
} from './card';
|
||||
import {
|
||||
import type {
|
||||
Popover as StaticPopover,
|
||||
PopoverContent as StaticPopoverContent,
|
||||
PopoverTrigger as StaticPopoverTrigger,
|
||||
} from './popover';
|
||||
import {
|
||||
import type {
|
||||
DropdownMenu as StaticDropdownMenu,
|
||||
DropdownMenuTrigger as StaticDropdownMenuTrigger,
|
||||
DropdownMenuContent as StaticDropdownMenuContent,
|
||||
@ -30,23 +30,34 @@ import {
|
||||
DropdownMenuLabel as StaticDropdownMenuLabel,
|
||||
DropdownMenuSeparator as StaticDropdownMenuSeparator,
|
||||
} from './dropdown';
|
||||
import {
|
||||
import type {
|
||||
Avatar as StaticAvatar,
|
||||
AvatarFallback as StaticAvatarFallback,
|
||||
AvatarImage as StaticAvatarImage,
|
||||
} from './avatar';
|
||||
import {
|
||||
import type {
|
||||
Collapsible as StaticCollapsible,
|
||||
CollapsibleTrigger as StaticCollapsibleTrigger,
|
||||
CollapsibleContent as StaticCollapsibleContent,
|
||||
} from './collapsible';
|
||||
import { useAsyncCallbackWithLoggedError } from '@stackframe/stack-shared/dist/hooks/use-async-callback';
|
||||
|
||||
export const Button = forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof StaticButton>
|
||||
>((props, ref) => {
|
||||
const { Button } = useComponents();
|
||||
return <Button {...props} ref={ref} />;
|
||||
const [onClick, onClickLoading] = useAsyncCallbackWithLoggedError(async () => {
|
||||
return await props.onClick?.();
|
||||
}, [props.onClick]);
|
||||
|
||||
return <Button
|
||||
{...props}
|
||||
onClick={props.onClick && onClick}
|
||||
loading={props.loading || onClickLoading}
|
||||
disabled={props.disabled || onClickLoading}
|
||||
ref={ref}
|
||||
/>;
|
||||
});
|
||||
|
||||
export const Container = forwardRef<
|
||||
|
||||
@ -1,23 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { PasswordField, useStackApp, useUser } from '..';
|
||||
import { PasswordField, useUser } from '..';
|
||||
import RedirectMessageCard from '../components/redirect-message-card';
|
||||
import { Text, Label, Input, Button, Card, CardHeader, CardDescription, CardContent, CardFooter, Container } from "../components-core";
|
||||
import UserAvatar from '../components/user-avatar';
|
||||
import { useState } from 'react';
|
||||
import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises';
|
||||
import FormWarningText from '../components/form-warning';
|
||||
import { CardTitle } from '../components-core/card';
|
||||
import { getPasswordError } from '@stackframe/stack-shared/dist/helpers/password';
|
||||
|
||||
function SettingSection(props: {
|
||||
function SettingSection(props: {
|
||||
title: string,
|
||||
desc: string,
|
||||
buttonText?: string,
|
||||
buttonDisabled?: boolean,
|
||||
buttonLoading?: boolean,
|
||||
onButtonClick?: () => void,
|
||||
onButtonClick?: React.ComponentProps<typeof Button>["onClick"],
|
||||
buttonVariant?: 'primary' | 'secondary',
|
||||
children?: React.ReactNode,
|
||||
}) {
|
||||
@ -34,10 +33,10 @@ function SettingSection(props: {
|
||||
</CardContent>}
|
||||
{props.buttonText && <CardFooter>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
|
||||
<Button
|
||||
disabled={props.buttonDisabled}
|
||||
onClick={props.onButtonClick}
|
||||
loading={props.buttonLoading}
|
||||
<Button
|
||||
disabled={props.buttonDisabled}
|
||||
onClick={props.onButtonClick}
|
||||
loading={props.buttonLoading}
|
||||
variant={props.buttonVariant}
|
||||
>
|
||||
{props.buttonText}
|
||||
@ -46,7 +45,7 @@ function SettingSection(props: {
|
||||
</CardFooter>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ProfileSection() {
|
||||
const user = useUser();
|
||||
@ -59,10 +58,10 @@ function ProfileSection() {
|
||||
desc='Your profile information'
|
||||
buttonDisabled={!changed}
|
||||
buttonText='Save'
|
||||
onButtonClick={() => runAsynchronously(async () => {
|
||||
onButtonClick={async () => {
|
||||
await user?.update(userInfo);
|
||||
setChanged(false);
|
||||
})}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||
<UserAvatar size={60}/>
|
||||
@ -90,7 +89,7 @@ function ProfileSection() {
|
||||
function EmailVerificationSection() {
|
||||
const user = useUser();
|
||||
const [emailSent, setEmailSent] = useState(false);
|
||||
const [sedingEmail, setSendingEmail] = useState(false);
|
||||
const [sendingEmail, setSendingEmail] = useState(false);
|
||||
|
||||
return (
|
||||
<SettingSection
|
||||
@ -104,17 +103,17 @@ function EmailVerificationSection() {
|
||||
'Send Email'
|
||||
: undefined
|
||||
}
|
||||
buttonLoading={sedingEmail}
|
||||
onButtonClick={() => runAsynchronously(async () => {
|
||||
buttonLoading={sendingEmail}
|
||||
onButtonClick={async () => {
|
||||
setSendingEmail(true);
|
||||
await user?.sendVerificationEmail();
|
||||
setEmailSent(true);
|
||||
setSendingEmail(false);
|
||||
})}
|
||||
}}
|
||||
>
|
||||
{user?.primaryEmailVerified ?
|
||||
<Text variant='success'>Your email has been verified</Text> :
|
||||
<Text variant='warning'>Your Email has not been verified</Text>}
|
||||
<Text variant='warning'>Your email has not been verified</Text>}
|
||||
</SettingSection>
|
||||
);
|
||||
}
|
||||
@ -138,7 +137,7 @@ function PasswordSection() {
|
||||
buttonDisabled={!oldPassword || !newPassword}
|
||||
buttonText='Save'
|
||||
buttonLoading={saving}
|
||||
onButtonClick={() => runAsynchronously(async () => {
|
||||
onButtonClick={async () => {
|
||||
if (oldPassword && newPassword) {
|
||||
const errorMessage = getPasswordError(newPassword);
|
||||
if (errorMessage) {
|
||||
@ -157,7 +156,7 @@ function PasswordSection() {
|
||||
} else if (newPassword && !oldPassword) {
|
||||
setOldPasswordError('Please enter your old password');
|
||||
}
|
||||
})}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Label htmlFor='old-password'>Old Password</Label>
|
||||
@ -195,7 +194,7 @@ function SignOutSection() {
|
||||
desc='Sign out of your account on this device'
|
||||
buttonVariant='secondary'
|
||||
buttonText='Sign Out'
|
||||
onButtonClick={() => runAsynchronously(user?.signOut)}
|
||||
onButtonClick={() => user?.signOut()}
|
||||
>
|
||||
</SettingSection>
|
||||
);
|
||||
|
||||
@ -5,7 +5,6 @@ import FormWarningText from "./form-warning";
|
||||
import PasswordField from "./password-field";
|
||||
import { validateEmail } from "../utils/email";
|
||||
import { useStackApp } from "..";
|
||||
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
import { Button, Input, Label, Link } from "../components-core";
|
||||
import { KnownErrors } from "@stackframe/stack-shared";
|
||||
|
||||
@ -79,7 +78,7 @@ export default function CredentialSignIn() {
|
||||
|
||||
<Button
|
||||
style={{ marginTop: '1.5rem' }}
|
||||
onClick={() => runAsynchronously(onSubmit)}
|
||||
onClick={onSubmit}
|
||||
loading={loading}
|
||||
>
|
||||
Sign In
|
||||
|
||||
@ -6,8 +6,7 @@ import FormWarningText from "./form-warning";
|
||||
import { validateEmail } from "../utils/email";
|
||||
import { getPasswordError } from "@stackframe/stack-shared/dist/helpers/password";
|
||||
import { useStackApp } from "..";
|
||||
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
import { Button, Label, Input } from "../components-core";
|
||||
import { Label, Input, AsyncButton } from "../components-core";
|
||||
import { KnownErrors } from "@stackframe/stack-shared";
|
||||
|
||||
export default function CredentialSignUp() {
|
||||
@ -17,7 +16,6 @@ export default function CredentialSignUp() {
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [passwordRepeat, setPasswordRepeat] = useState('');
|
||||
const [passwordRepeatError, setPasswordRepeatError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const app = useStackApp();
|
||||
|
||||
const onSubmit = async () => {
|
||||
@ -48,13 +46,8 @@ export default function CredentialSignUp() {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
let error;
|
||||
try {
|
||||
error = await app.signUpWithCredential({ email, password });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
error = await app.signUpWithCredential({ email, password });
|
||||
|
||||
if (error instanceof KnownErrors.UserEmailAlreadyExists) {
|
||||
setEmailError('User already exists');
|
||||
@ -106,8 +99,7 @@ export default function CredentialSignUp() {
|
||||
|
||||
<Button
|
||||
style={{ marginTop: '1.5rem' }}
|
||||
onClick={() => runAsynchronously(onSubmit)}
|
||||
loading={loading}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
Sign Up
|
||||
</Button>
|
||||
|
||||
@ -4,14 +4,12 @@ import { useState } from "react";
|
||||
import FormWarningText from "./form-warning";
|
||||
import { validateEmail } from "../utils/email";
|
||||
import { useStackApp } from "..";
|
||||
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
import { Button, Input, Label } from "../components-core";
|
||||
|
||||
|
||||
export default function ForgotPassword({ onSent }: { onSent?: () => void }) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [emailError, setEmailError] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const stackApp = useStackApp();
|
||||
|
||||
const onSubmit = async () => {
|
||||
@ -23,9 +21,7 @@ export default function ForgotPassword({ onSent }: { onSent?: () => void }) {
|
||||
setEmailError('Please enter a valid email');
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
await stackApp.sendForgotPasswordEmail(email);
|
||||
setSending(false);
|
||||
|
||||
onSent?.();
|
||||
};
|
||||
@ -47,8 +43,7 @@ export default function ForgotPassword({ onSent }: { onSent?: () => void }) {
|
||||
|
||||
<Button
|
||||
style={{ marginTop: '1.5rem'}}
|
||||
onClick={() => runAsynchronously(onSubmit())}
|
||||
loading={sending}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
Send Email
|
||||
</Button>
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
import { FaGithub, FaFacebook, FaApple } from 'react-icons/fa';
|
||||
import { useStackApp } from '..';
|
||||
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
import { Button } from "../components-core";
|
||||
import { useDesign } from "../providers/design-provider";
|
||||
import Color from 'color';
|
||||
@ -115,7 +114,7 @@ export default function OAuthButton({
|
||||
<Button
|
||||
color={style.backgroundColor}
|
||||
style={{ border: style.border }}
|
||||
onClick={() => runAsynchronously(stackApp.signInWithOAuth(provider))}
|
||||
onClick={() => stackApp.signInWithOAuth(provider)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||
{style.icon}
|
||||
|
||||
@ -9,7 +9,6 @@ import RedirectMessageCard from "./redirect-message-card";
|
||||
import MessageCard from "./message-card";
|
||||
import CardFrame from "./card-frame";
|
||||
import { Button, Label, Text } from "../components-core";
|
||||
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
|
||||
|
||||
export default function PasswordResetInner(
|
||||
@ -100,7 +99,7 @@ export default function PasswordResetInner(
|
||||
/>
|
||||
<FormWarningText text={passwordRepeatError} />
|
||||
|
||||
<Button style={{ marginTop: '1.5rem' }} onClick={() => runAsynchronously(onSubmit())}>
|
||||
<Button style={{ marginTop: '1.5rem' }} onClick={() => onSubmit()}>
|
||||
Reset Password
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -47,4 +47,4 @@ export default function UserButton() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { StackClientInterface } from "@stackframe/stack-shared";
|
||||
import { saveVerifierAndState, getVerifierAndState } from "./cookie";
|
||||
import { constructRedirectUrl } from "../utils/url";
|
||||
import { TokenStore } from "@stackframe/stack-shared/dist/interface/clientInterface";
|
||||
import { neverResolve } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
|
||||
export async function signInWithOAuth(
|
||||
iface: StackClientInterface,
|
||||
@ -22,6 +23,7 @@ export async function signInWithOAuth(
|
||||
state,
|
||||
);
|
||||
window.location.assign(location);
|
||||
await neverResolve();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -71,4 +71,4 @@ export function StackComponentProvider(props: { children?: React.ReactNode } & C
|
||||
{props.children}
|
||||
</ComponentContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user