Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/goofy-parents-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ Most problems a feature hits already have a shared answer. Find the line for you
- One screen replacing another on the same card or dialog → `Flow` (`src/components/flow/`), see [Views](docs/views.md)
- An interaction with an async lifecycle or two values that move together → A machine in the controller file, via `setup()` (`src/machine/`)
- Styles several components share → `src/styles/*.styles.ts`
- A machine-driven step that a lock or caller must await → `waitFor` (`src/machine/wait-for.ts`), inside `usePendingAction.run`
- Anything else small: a permission, verification codes, backup codes, avatar files, a list patch → List `src/utils/`, `src/hooks/` and `src/primitives/hooks/` first; most of these already have a helper

## Signs you are about to invent a pattern
Expand Down
1 change: 1 addition & 0 deletions packages/mosaic/docs/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Pass `isDisabled` to every control that starts one of the actions, including the

- `run` resolves `true` on success, so the caller can close a dialog or move focus. A cancelled reverification resolves `false`, shows no error and releases the lock.
- To hold pending through an OAuth redirect, await a delay inside the action (`user-profile-connected-accounts-section.controller.ts`).
- To hold it through a step a machine drives, run an action that awaits `waitFor` (`machine/wait-for.ts`) until the machine leaves that state (`user-profile-add-email.controller.ts`).
- It is not for a form (`useForm`), a confirm step (`useConfirmController`), an action whose result drives other state (a machine), or a check that reruns as the user types (`useDebouncedAsync`, or a field's `validateAsync`).

For a direct action in `ActionMenu`, opt in with `status: 'idle'` and set it to `'pending'` in the click event while the request runs and no dialog is open yet; the menu closes on the next render unless an action is pending. While pending, the menu reserves room for a spinner, disables its actions and blocks dismissal. It closes when the status returns to `'idle'`. Leave `status` undefined for ordinary actions that close through the menu's own click.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,27 @@ describe('connected accounts', () => {
request.fail();
});

it('holds connected account menus while a connection starts', async () => {
await renderSection([google]);
const request = holdRequests('post', '/v1/me/external_accounts');
const user = userEvent.setup();

try {
await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
await waitFor(() => expect(request.requests).toHaveLength(1));
const manage = screen.getByRole('button', { name: 'Manage Google' });
expect(manage).toHaveAttribute('aria-disabled', 'true');
await user.click(manage);
expect(screen.queryByRole('menu')).toBeNull();
} finally {
request.fail();
}

await waitFor(() =>
expect(screen.getByRole('button', { name: 'Manage Google' })).not.toHaveAttribute('aria-disabled', 'true'),
);
});

it('verifies the session before connecting a provider, then continues to it', async () => {
serveFapi(signedIn([], { verification: { secrets: { password: 'hunter2' } } }));
const view = await renderWithClerk(<UserProfileConnectedAccountsSection />);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,11 @@ import type {

export function UserProfileConnectProviderRowView({
provider,
isPending,
isDisabled,
onConnect,
triggerRef,
}: {
provider: UserProfileConnectionProvider;
isPending: boolean;
isDisabled: boolean;
onConnect: (id: string) => void;
triggerRef?: Ref<HTMLButtonElement>;
Expand Down Expand Up @@ -58,8 +56,8 @@ export function UserProfileConnectProviderRowView({
variant='outline'
color='neutral'
aria-label={fill(m.connectLabel, { provider: provider.provider })}
isPending={isPending}
disabled={isDisabled && !isPending}
isPending={provider.isPending}
disabled={isDisabled && !provider.isPending}
onClick={() => onConnect(provider.id)}
>
{m.connect}
Expand Down Expand Up @@ -91,7 +89,7 @@ export function UserProfileConnectedAccountRowView({
}) {
const m = useMessages('userProfile').connectedAccounts;
const actions: ActionMenuAction[] = [];
if ((account.status === 'reconnect' || account.status === 'error') && onReconnect && !isDisabled) {
if ((account.status === 'reconnect' || account.status === 'error') && onReconnect) {
actions.push({
label: account.status === 'reconnect' ? m.reconnect : m.tryAgain,
onClick: () => onReconnect(account.id),
Expand Down Expand Up @@ -131,6 +129,8 @@ export function UserProfileConnectedAccountRowView({
{actions.length > 0 ? (
<Section.Actions>
<ActionMenu
disabled={isDisabled}
focusableWhenDisabled
triggerRef={triggerRef}
label={fill(m.manageLabel, { provider: account.provider })}
actions={actions}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export interface UserProfileConnectedAccountsControllerOptions {
export interface UserProfileConnectedAccountsController {
accounts: UserProfileConnectedAccount[];
availableProviders: UserProfileConnectionProvider[];
pendingId: string | undefined;
isDisabled: boolean;
onConnect: (id: string) => void;
onReconnect: (id: string) => void;
sectionRef: Ref<HTMLDivElement>;
Expand Down Expand Up @@ -81,10 +81,12 @@ export function useUserProfileConnectedAccountsController({
accounts: accounts.map(account =>
actions.errorKey === account.id ? { ...account, reconnectError: actions.error } : account,
),
availableProviders: availableProviders.map(provider =>
actions.errorKey === provider.id ? { ...provider, connectError: actions.error } : provider,
),
pendingId: actions.pendingKey,
availableProviders: availableProviders.map(provider => ({
...provider,
isPending: actions.pendingKey === provider.id,
connectError: actions.errorKey === provider.id ? actions.error : undefined,
})),
isDisabled: actions.isPending || remove.isPending,
onConnect: id => run(id, onConnect),
onReconnect: id => run(id, onReconnect),
sectionRef,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface ConnectedAccountProviderDisplay {
export interface UserProfileConnectionProvider extends ConnectedAccountProviderDisplay {
id: string;
connectError?: string;
isPending?: boolean;
}

export interface UserProfileConnectedAccount extends ConnectedAccountProviderDisplay {
Expand All @@ -30,7 +31,7 @@ export interface UserProfileConnectedAccountsSectionViewProps {
accounts: UserProfileConnectedAccount[];
sectionRef?: Ref<HTMLDivElement>;
availableProviders?: UserProfileConnectionProvider[];
pendingId?: string;
isDisabled?: boolean;
onConnect?: (id: string) => void;
onReconnect?: (id: string) => void;
registerTrigger: (id: string) => Ref<HTMLButtonElement>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function UserProfileConnectedAccountsSectionView({
accounts,
sectionRef,
availableProviders = [],
pendingId,
isDisabled = false,
onConnect,
onReconnect,
registerTrigger,
Expand All @@ -31,7 +31,6 @@ export function UserProfileConnectedAccountsSectionView({
}: UserProfileConnectedAccountsSectionViewProps) {
const m = useMessages('userProfile').connectedAccounts;
const hasRows = accounts.length > 0 || (availableProviders.length > 0 && Boolean(onConnect));
const isBusy = pendingId !== undefined;

return (
<>
Expand All @@ -52,7 +51,7 @@ export function UserProfileConnectedAccountsSectionView({
key={account.id}
account={account}
triggerRef={registerTrigger(account.id)}
isDisabled={isBusy}
isDisabled={isDisabled}
onReconnect={onReconnect}
onRemove={remove ? account => remove.open(account) : undefined}
/>
Expand All @@ -63,8 +62,7 @@ export function UserProfileConnectedAccountsSectionView({
key={provider.id}
provider={provider}
triggerRef={registerTrigger(provider.id)}
isPending={pendingId === provider.id}
isDisabled={isBusy}
isDisabled={isDisabled}
onConnect={onConnect}
/>
))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ describe('useUserProfileAddEmailController', () => {
useUserProfileAddEmailController({ reverification: idleReverification, onCreate }),
);

act(() => result.current.onVerifyEmail(other, email));
act(() => void result.current.onVerifyEmail(other, email));
expect(email.start).toHaveBeenCalledOnce();
expect(result.current.open).toBe(false);
expect(result.current.pendingVerifyId).toBe('email_2');
Expand All @@ -249,11 +249,11 @@ describe('useUserProfileAddEmailController', () => {
);
expect(result.current.verifyingId).toBeUndefined();

act(() => result.current.onVerifyEmail(other, email));
act(() => void result.current.onVerifyEmail(other, email));
expect(result.current.verifyingId).toBe('email_2');
await waitFor(() => expect(result.current.step).toBe('verify'));

act(() => result.current.onVerifyEmail({ id: 'email_3', value: 'person@acme.co' }, email));
act(() => void result.current.onVerifyEmail({ id: 'email_3', value: 'person@acme.co' }, email));
expect(result.current.verifyingId).toBe('email_2');
expect(result.current.emailAddress).toBe('other@example.com');

Expand All @@ -275,7 +275,7 @@ describe('useUserProfileAddEmailController', () => {
useUserProfileAddEmailController({ reverification: idleReverification, onCreate: created }),
);

act(() => result.current.onVerifyEmail(other, email));
act(() => void result.current.onVerifyEmail(other, email));
await waitFor(() => expect(result.current.errorMessage).toBe('Too many requests'));
expect(result.current.step).toBe('verify');
expect(result.current.resendSeconds).toBe(0);
Expand All @@ -301,7 +301,7 @@ describe('useUserProfileAddEmailController', () => {
const verified = createDeferredPromise();
const { result } = renderHook(() => useUserProfileAddEmailController({ reverification: idleReverification }));

act(() => result.current.onVerifyEmail(other, verifier(sentLink(verified.promise.then(() => undefined)))));
act(() => void result.current.onVerifyEmail(other, verifier(sentLink(verified.promise.then(() => undefined)))));
expect(result.current.step).toBe('link');
expect(result.current.resendSeconds).toBe(60);

Expand All @@ -319,12 +319,12 @@ describe('useUserProfileAddEmailController', () => {
useUserProfileAddEmailController({ reverification: idleReverification }),
);

act(() => result.current.onVerifyEmail(other, email));
act(() => void result.current.onVerifyEmail(other, email));
await waitFor(() => expect(result.current.step).toBe('link'));
act(() => result.current.onOpenChange(false));
expect(cancel).toHaveBeenCalledOnce();

act(() => result.current.onVerifyEmail(other, email));
act(() => void result.current.onVerifyEmail(other, email));
await waitFor(() => expect(result.current.step).toBe('link'));
unmount();
expect(cancel).toHaveBeenCalledTimes(2);
Expand All @@ -334,7 +334,7 @@ describe('useUserProfileAddEmailController', () => {
const email = verifier(sentLink(Promise.reject(saveError('Link expired'))));
const { result } = renderHook(() => useUserProfileAddEmailController({ reverification: idleReverification }));

act(() => result.current.onVerifyEmail(other, email));
act(() => void result.current.onVerifyEmail(other, email));
await waitFor(() => expect(result.current.errorMessage).toBe('Link expired'));
expect(result.current.step).toBe('link');
expect(result.current.open).toBe(true);
Expand All @@ -345,7 +345,7 @@ describe('useUserProfileAddEmailController', () => {
const email = verifier(() => ({ method: 'sso', connect }));
const { result } = renderHook(() => useUserProfileAddEmailController({ reverification: idleReverification }));

act(() => result.current.onVerifyEmail({ id: 'email_3', value: 'person@acme.co' }, email));
act(() => void result.current.onVerifyEmail({ id: 'email_3', value: 'person@acme.co' }, email));
expect(result.current.step).toBe('sso');
expect(result.current.resendSeconds).toBe(0);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,18 @@ describe('the user profile email section', () => {
expect(screen.getByRole('menuitem', { name: 'Verify' })).toHaveAttribute('aria-busy', 'true');
expect(await screen.findByRole('progressbar', { name: 'Verify' })).toBeVisible();
expect(screen.queryByRole('dialog')).toBeNull();
expect(screen.getByRole('button', { name: 'Manage alice@example.com' })).toHaveAttribute('aria-disabled', 'true');
expect(screen.getByRole('button', { name: 'Add email' })).toBeDisabled();
} finally {
prepare.release();
}

await enterCode(actor, VERIFICATION_CODE);
expect(screen.queryByRole('menu')).toBeNull();
expect(screen.getByRole('button', { name: 'Manage alice@example.com' })).not.toHaveAttribute(
'aria-disabled',
'true',
);
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useErrorText } from '../../../localization';
import { setup } from '../../../machine/setup';
import type { ErrorInvokeEvent } from '../../../machine/types';
import { useMachine } from '../../../machine/use-machine';
import { waitFor } from '../../../machine/wait-for';
import type { FormError } from '../../../utils/errors';
import { toFormError } from '../../../utils/errors';
import { CODE_LENGTH, CODE_RESEND_SECONDS, isResendUnlocked, resendUnlockTime } from '../../../utils/verification';
Expand All @@ -32,7 +33,7 @@ export interface UserProfileAddEmailController extends UserProfileAddEmailDialog
handle: DialogHandle<unknown>;
verifyingId: string | undefined;
pendingVerifyId: string | undefined;
onVerifyEmail: (email: Pick<UserProfileEmail, 'id' | 'value'>, verifier: UserProfileEmailVerifier) => void;
onVerifyEmail: (email: Pick<UserProfileEmail, 'id' | 'value'>, verifier: UserProfileEmailVerifier) => Promise<void>;
}

interface Context {
Expand Down Expand Up @@ -268,9 +269,10 @@ export function useUserProfileAddEmailController({
onConnect: () => send({ type: 'CONNECT' }),
verifyingId: snapshot.context.verifyingId,
pendingVerifyId: state === 'preparing' ? snapshot.context.verifyingId : undefined,
onVerifyEmail: (email, verifier) => {
onVerifyEmail: async (email, verifier) => {
codeForm.reset();
send({ type: 'START', id: email.id, emailAddress: email.value, verifier });
await waitFor(actor, current => current.value !== 'preparing');
},
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function useUserProfileEmailSectionController({
onVerify: id => {
const email = emails.find(email => email.id === id);
if (email) {
verification.onVerifyEmail(email, getEmailVerifier(id));
void actions.run(id, () => verification.onVerifyEmail(email, getEmailVerifier(id)));
}
},
onSetPrimary: id => void actions.run(id, () => onSetPrimaryEmail(id)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,10 @@ import type { UserProfileEnterpriseAccount } from './user-profile-enterprise-acc
export function UserProfileEnterpriseAccountRowView({
account,
onConnect,
isPending,
disabled,
}: {
account: UserProfileEnterpriseAccount;
onConnect?: (id: string) => void;
isPending?: boolean;
disabled?: boolean;
}) {
const m = useMessages('userProfile').enterpriseAccounts;
Expand Down Expand Up @@ -58,7 +56,7 @@ export function UserProfileEnterpriseAccountRowView({
size='sm'
variant='outline'
aria-label={fill(m.connectProvider, { provider: account.name })}
isPending={isPending}
isPending={account.isPending}
pendingLabel={m.connecting}
disabled={disabled}
onClick={() => onConnect(account.id)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface UserProfileEnterpriseAccountsControllerOptions {
export interface UserProfileEnterpriseAccountsController {
accounts: UserProfileEnterpriseAccount[];
connections: UserProfileEnterpriseConnection[];
pendingId: string | undefined;
isDisabled: boolean;
onConnect: (connectionId: string) => void;
connectReverification: ReverificationController;
}
Expand All @@ -34,10 +34,12 @@ export function useUserProfileEnterpriseAccountsController({

return {
accounts,
connections: connections.map(connection =>
actions.errorKey === connection.id ? { ...connection, connectError: actions.error } : connection,
),
pendingId: actions.pendingKey,
connections: connections.map(connection => ({
...connection,
isPending: actions.pendingKey === connection.id,
connectError: actions.errorKey === connection.id ? actions.error : undefined,
})),
isDisabled: actions.isPending,
onConnect: id =>
void actions.run(id, async () => {
if ((await onConnect(id)) === 'redirecting') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface UserProfileEnterpriseConnection {
iconUrl?: string;
monochromeIcon?: boolean;
connectError?: string;
isPending?: boolean;
}

export interface UserProfileEnterpriseAccount extends UserProfileEnterpriseConnection {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ import type {
export interface UserProfileEnterpriseAccountsSectionViewProps {
accounts: UserProfileEnterpriseAccount[];
connections?: UserProfileEnterpriseConnection[];
pendingId?: string;
isDisabled?: boolean;
onConnect?: (id: string) => void;
connectReverification?: ReverificationController;
}

export function UserProfileEnterpriseAccountsSectionView({
accounts,
connections = [],
pendingId,
isDisabled = false,
onConnect,
connectReverification,
}: UserProfileEnterpriseAccountsSectionViewProps) {
Expand Down Expand Up @@ -49,8 +49,7 @@ export function UserProfileEnterpriseAccountsSectionView({
key={connection.id}
account={connection}
onConnect={onConnect}
isPending={pendingId === connection.id}
disabled={pendingId !== undefined}
disabled={isDisabled}
/>
))
: null}
Expand Down
Loading
Loading