Async onClick for button

This commit is contained in:
Stan Wohlwend 2024-04-14 08:28:40 +02:00
parent 677f39f786
commit 3df1def41e
14 changed files with 102 additions and 63 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -47,4 +47,4 @@ export default function UserButton() {
</DropdownMenuContent>
</DropdownMenu>
);
}
}

View File

@ -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();
}
/**

View File

@ -71,4 +71,4 @@ export function StackComponentProvider(props: { children?: React.ReactNode } & C
{props.children}
</ComponentContext.Provider>
);
}
}