Skip to content
Draft
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/friendly-identifier-lock.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 @@ -34,6 +34,7 @@ Most problems a feature hits already have a shared answer. Find the row for your
| Problem | Use |
| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| An async action from a button or row: pending, an error, one lock | `usePendingAction` (`src/hooks/`) |
| Direct actions and confirmation dialogs that must exclude each other | Section controller wraps `confirm` with `usePendingAction.runExternal` (`src/hooks/`, `src/blocks/confirm/`) |
| A form: values, validation, submit, field errors | `useForm` (`src/components/form/`), with `validateAsync` for a server-side check |
| A `<form>` element, without the browser's validation popup | `Form` (`src/primitives/form/`) |
| A model save whose Clerk errors should land on form fields | `save` (`src/utils/errors.ts`) in the model, or reject with `FormSubmitError` |
Expand Down
32 changes: 32 additions & 0 deletions packages/mosaic/docs/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,38 @@ return {
};
```

When a direct action and a confirmation must not run together, give both
operations one `usePendingAction` owner:

```ts
const actions = usePendingAction();
const remove = useConfirmController<UserProfileEmail>({
onConfirm: contact => removalFocus.remove(contact.id),
});

return {
isDisabled: actions.isPending,
pendingPrimaryId: actions.pendingKey,
remove: {
...remove,
confirm: async contact => (await actions.runExternal(() => remove.confirm(contact))) ?? false,
},
};
```

Keep this coordination in the section controller. Run direct actions with
`actions.run(key, action)` and wrap the returned confirmation's `confirm` callback
with `actions.runExternal`. Pass `actions.isPending` to the section controls'
`disabled` props, including `Confirmation.disabled`.

`confirm` resolves `true` when the operation succeeds, or `false` when it fails or
the dialog ignores the call. Its promise follows the operation even if the
dialog closes during cancellation. The shared pending owner waits for that
promise, so closing the dialog does not enable another action before cancellation
settles. Removal keeps its dialog errors, and inline row errors and focus keys
stay unchanged. Views receive the pending and disabled props without coordinating
the operations.

`run` resolves `true` on success, so the caller can close a dialog or move
focus. To hold pending through an OAuth redirect, await a delay inside the
action (`user-profile-connected-accounts-section.controller.ts`). When the user
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,45 @@ describe('confirmMachine', () => {
});

describe('useConfirmController', () => {
it.each(['resolve', 'reject'])(
'keeps its completion pending after cancellation until the action can %s',
async outcome => {
const pending = deferred<void>();
const { result } = renderHook(() =>
useConfirmController({
onConfirm: () => pending.promise,
reverification: { status: 'loading', visible: true, onCancel: vi.fn() },
}),
);
const completed = vi.fn();
act(() => result.current.onOpenChange(true));
let completion: ReturnType<typeof result.current.confirm> | undefined;
act(() => {
completion = result.current.confirm();
});
expect(completion).toBeInstanceOf(Promise);
void completion?.then(completed);
act(() => result.current.onOpenChange(false));
await act(async () => {
await Promise.resolve();
});
expect(result.current.isOpen).toBe(false);
expect(completed).not.toHaveBeenCalled();

await act(async () => {
if (outcome === 'reject') {
pending.reject(cancelled);
} else {
pending.resolve();
}
await completion;
});

expect(completed).toHaveBeenCalledWith(outcome === 'resolve');
expect(result.current.errorMessage).toBeUndefined();
},
);

it('ignores open while no block is mounted on the controller, and keeps one handle across renders', () => {
const { result, rerender } = renderHook(() => useConfirmController({ onConfirm: () => Promise.resolve() }));
const { handle } = result.current;
Expand All @@ -107,17 +146,23 @@ describe('useConfirmController', () => {
const onConfirm = vi.fn(() => pending.promise);
const { result } = renderHook(() => useConfirmController({ onConfirm }));

act(() => result.current.confirm());
await act(async () => expect(await result.current.confirm()).toBe(false));
expect(onConfirm).not.toHaveBeenCalled();

act(() => result.current.onOpenChange(true));
act(() => result.current.confirm());
act(() => result.current.confirm());
const confirm = result.current.confirm;
let completion: ReturnType<typeof confirm> | undefined;
let duplicate: ReturnType<typeof confirm> | undefined;
act(() => {
completion = confirm();
duplicate = confirm();
});
expect(onConfirm).toHaveBeenCalledOnce();
expect(await duplicate).toBe(false);

await act(async () => {
pending.resolve();
await pending.promise;
expect(await completion).toBe(true);
});
await waitFor(() => expect(result.current.isOpen).toBe(false));
});
Expand Down
2 changes: 1 addition & 1 deletion packages/mosaic/src/blocks/confirm/confirm-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ function ConfirmCard<Payload>({
cancelLabel,
isPending: controller.isPending,
errorMessage: controller.errorMessage,
confirm: () => controller.confirm(payload),
confirm: () => void controller.confirm(payload),
});

return (
Expand Down
20 changes: 13 additions & 7 deletions packages/mosaic/src/blocks/confirm/confirm.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ export interface ConfirmController<Payload = void> {
/** Opens the dialog about `payload` */
open: (payload: Payload) => void;
onOpenChange: (open: boolean) => void;
/** Runs the action for `payload`. The dialog closes when it resolves and shows why when it rejects. */
confirm: (payload: Payload) => void;
/** Runs the action for `payload`. The dialog closes when it resolves and shows why when it rejects. Resolves whether the action succeeded. */
confirm: (payload: Payload) => Promise<boolean>;
}

export interface ConfirmControllerOptions<Payload = void> {
Expand Down Expand Up @@ -136,15 +136,21 @@ export function useConfirmController<Payload = void>({
send({ type: 'CANCEL' });
},
confirm: payload => {
if (snapshot.value !== 'confirming') {
return;
}
let completion: Promise<boolean> | undefined;
send({
type: 'CONFIRM',
run: async () => {
await onConfirm(payload);
run: () => {
const operation = (async () => {
await onConfirm(payload);
})();
completion = operation.then(
() => true,
() => false,
);
return operation;
},
});
return completion ?? Promise.resolve(false);
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ function Host<Payload>({

async function renderAccount(
options: ConfirmControllerOptions<void>,
{ localization, description }: { localization?: MosaicLocalization; description?: ReactNode } = {},
{
localization,
description,
disabled,
}: { localization?: MosaicLocalization; description?: ReactNode; disabled?: boolean } = {},
) {
const ref: { current: ConfirmController | undefined } = { current: undefined };
serveFapi({ client: fapiClient([session]) });
Expand All @@ -53,6 +57,7 @@ async function renderAccount(
title={TITLE}
description={description ?? 'Google will be removed from this account.'}
actionLabel='Remove'
disabled={disabled}
/>
);
}}
Expand All @@ -70,6 +75,19 @@ async function renderAccount(
const confirmButton = () => screen.getByRole('button', { name: 'Remove' });

describe('Confirmation', () => {
it('disables confirmation while leaving Cancel usable', async () => {
const onConfirm = vi.fn(() => Promise.resolve());
const { user, open, closed } = await renderAccount({ onConfirm }, { disabled: true });
await open();

expect(confirmButton()).toBeDisabled();
await user.click(confirmButton());
expect(onConfirm).not.toHaveBeenCalled();

await user.click(screen.getByRole('button', { name: 'Cancel' }));
await closed();
});

it('opens from its trigger, confirms, closes once the action lands, and hands focus back', async () => {
const run = deferred<void>();
const onConfirm = vi.fn(() => run.promise);
Expand All @@ -82,6 +100,7 @@ describe('Confirmation', () => {
await user.click(confirmButton());
expect(onConfirm).toHaveBeenCalledOnce();
expect(confirmButton()).toHaveAttribute('aria-busy', 'true');
expect(confirmButton()).toHaveFocus();

run.resolve();
await closed();
Expand Down
4 changes: 3 additions & 1 deletion packages/mosaic/src/blocks/confirmation/confirmation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type ConfirmationColor = 'negative' | 'primary';
export interface ConfirmationProps<Payload = void> extends ConfirmBlockProps<Payload> {
/** The weight the confirming button carries. An action that can be undone takes `primary` (default: `negative`) */
color?: ConfirmationColor;
disabled?: boolean;
}

/**
Expand All @@ -36,7 +37,7 @@ export interface ConfirmationProps<Payload = void> extends ConfirmBlockProps<Pay
* actionLabel='Remove'
* />
*/
export function Confirmation<Payload = void>({ color = 'negative', ...props }: ConfirmationProps<Payload>) {
export function Confirmation<Payload = void>({ color = 'negative', disabled, ...props }: ConfirmationProps<Payload>) {
return (
<ConfirmShell {...props}>
{({ title, description, actionLabel, cancelLabel, isPending, errorMessage, confirm }) => (
Expand Down Expand Up @@ -67,6 +68,7 @@ export function Confirmation<Payload = void>({ color = 'negative', ...props }: C
fullWidth
color={color}
isPending={isPending}
disabled={disabled && !isPending}
onClick={confirm}
>
{actionLabel}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,30 @@ function contact(id: string, status: Contact['verification']['status'], expireAt
}

describe('toContacts', () => {
it('orders primary first, then verified, then pending by expiry, then never started', () => {
it('orders verified, then pending by expiry, then never started regardless of primary', () => {
const items = [
contact('idn_unstarted', null),
contact('idn_pending_late', 'unverified', new Date(2026, 0, 2)),
contact('idn_verified_b', 'verified'),
contact('idn_primary', 'verified'),
contact('idn_verified_z', 'verified'),
contact('idn_pending_soon', 'unverified', new Date(2026, 0, 1)),
contact('idn_verified_a', 'verified'),
];

const contacts = toContacts(items, 'idn_primary', item => item.id);
const contacts = toContacts(items, 'idn_verified_z', item => item.id);
const changedPrimary = toContacts(items, 'idn_verified_b', item => item.id);

expect(contacts.map(item => item.id)).toEqual([
'idn_primary',
'idn_verified_a',
'idn_verified_b',
'idn_verified_z',
'idn_pending_soon',
'idn_pending_late',
'idn_unstarted',
]);
expect(changedPrimary.map(item => item.id)).toEqual(contacts.map(item => item.id));
expect(changedPrimary.find(item => item.id === 'idn_verified_b')?.isDefault).toBe(true);
expect(changedPrimary.find(item => item.id === 'idn_verified_z')?.isDefault).toBe(false);
});

it('marks which contact is the primary one and which are verified', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export function useUserProfileActiveDevicesController({
registerTrigger: removalFocus.registerTrigger,
finalFocus: removalFocus.finalFocus,
details,
signOutFromDetails: revoke ? device => details.confirm(device) : undefined,
signOutFromDetails: revoke ? device => void details.confirm(device) : undefined,
signOut: revoke ? signOut : undefined,
signOutAll: revokeAll ? signOutAll : undefined,
// Confirming removes the other devices and the trigger with them — hence the dialog mounted
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface UserProfileContactListRowViewProps {
onAdd?: () => void;
onVerify?: (id: string) => void;
pendingVerifyId?: string;
pendingPrimaryId?: string;
isDisabled?: boolean;
onSetPrimary?: (id: string) => void;
onRemove?: (item: UserProfileContact) => void;
children?: ReactNode;
Expand All @@ -35,6 +37,8 @@ export function UserProfileContactListRowView({
onAdd,
onVerify,
pendingVerifyId,
pendingPrimaryId,
isDisabled = false,
onSetPrimary,
onRemove,
addAction,
Expand Down Expand Up @@ -64,6 +68,7 @@ export function UserProfileContactListRowView({
size='sm'
variant='outline'
onClick={onAdd}
disabled={isDisabled}
>
<Icon
glyph={Plus}
Expand Down Expand Up @@ -94,7 +99,11 @@ export function UserProfileContactListRowView({
status: pendingVerifyId === item.id ? 'pending' : 'idle',
});
} else if (item.isVerified && !item.isDefault && onSetPrimary) {
actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(item.id) });
actions.push({
label: m.setPrimary,
onClick: () => onSetPrimary(item.id),
status: pendingPrimaryId === item.id ? 'pending' : 'idle',
});
}

if (onRemove) {
Expand All @@ -119,6 +128,8 @@ export function UserProfileContactListRowView({
<ActionMenu
triggerRef={triggerRef?.(item.id)}
actions={actions}
disabled={isDisabled}
focusableWhenDisabled
label={fill(m.manageValue, { value: item.value })}
/>
</Section.Actions>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,10 @@ export function byId<T extends { id: string }>(items: T[], id: string, kind: str

type Identification = { id: string; verification: Pick<VerificationResource, 'status' | 'expireAt'> };

export function sortByVerification<T extends Identification>(items: T[], primaryId: string | null): T[] {
const others = items.filter(item => item.id !== primaryId);
const verified = others.filter(item => item.verification.status === 'verified');
const pending = others.filter(item => item.verification.status && item.verification.status !== 'verified');
const unstarted = others.filter(item => !item.verification.status);
export function sortByVerification<T extends Identification>(items: T[]): T[] {
const verified = items.filter(item => item.verification.status === 'verified');
const pending = items.filter(item => item.verification.status && item.verification.status !== 'verified');
const unstarted = items.filter(item => !item.verification.status);

verified.sort((a, b) => a.id.localeCompare(b.id));
pending.sort((a, b) => {
Expand All @@ -43,15 +42,15 @@ export function sortByVerification<T extends Identification>(items: T[], primary
return aExpireAt && bExpireAt ? aExpireAt.getTime() - bExpireAt.getTime() : 0;
});

return [...items.filter(item => item.id === primaryId), ...verified, ...pending, ...unstarted];
return [...verified, ...pending, ...unstarted];
}

export function toContacts<T extends Identification>(
items: T[],
primaryId: string | null,
value: (item: T) => string,
): UserProfileContact[] {
return sortByVerification(items, primaryId).map(item => ({
return sortByVerification(items).map(item => ({
id: item.id,
value: value(item),
isDefault: item.id === primaryId,
Expand Down
Loading
Loading