Skip to content
Merged
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/mosaic-confirmation-handle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
38 changes: 38 additions & 0 deletions packages/swingset/src/stories/confirmation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,35 @@ A failed attempt leaves the dialog up. Pass the sentence the user should read as
storyModule={Stories}
/>

## One block, many rows

A table of members has a Remove in every row, but it needs one confirmation, not one per row. Create a handle, mount the block once after the table, and open it from any row with the member it is about. The copy props take a function of that payload, and `onConfirm` receives it. The block owns `open`, the pending state, and the error: a resolved promise closes it, a rejected one keeps it open showing why.

```tsx
const removeMember = Confirmation.createHandle<Member>();

<Menu.Item color='negative' onClick={() => removeMember.open(member)}>Remove</Menu.Item>

<Confirmation
handle={removeMember}
title='Remove member'
description={member => <><strong>{member.name}</strong> will be removed from the organization.</>}
actionLabel='Remove'
onConfirm={member => api.removeMember(member.id)}
/>
```

Opened from a menu item, focus returns to that menu's trigger when the dialog closes.

<Story
name='WithHandle'
storyModule={Stories}
/>

## Props

Controlled:

| Prop | Type | Default | Description |
| -------------- | ------------------------- | ------------ | ------------------------------------------------------------------------------ |
| `open` | `boolean` | — (required) | Whether the confirmation is showing. Controlled, the way any dialog is. |
Expand All @@ -72,6 +99,17 @@ A failed attempt leaves the dialog up. Pass the sentence the user should read as
| `isConfirming` | `boolean` | `false` | Renders the action pending and ignores further presses. |
| `errorMessage` | `string` | — | Renders as a negative banner above the actions. |

With a handle:

| Prop | Type | Default | Description |
| ------------- | ---------------------------------------------- | ------------ | -------------------------------------------------------------------------------------- |
| `handle` | `ConfirmationHandle<Payload>` | — (required) | From `Confirmation.createHandle<Payload>()`. `handle.open(payload)` opens the block. |
| `title` | `string \| (payload: Payload) => string` | — (required) | Names what is about to happen. |
| `description` | `ReactNode \| (payload: Payload) => ReactNode` | — (required) | Spells out what it means. Takes markup, for a name to emphasise. |
| `actionLabel` | `string \| (payload: Payload) => string` | — (required) | The destructive button's label. |
| `cancelLabel` | `string` | `'Cancel'` | The cancel button's label. |
| `onConfirm` | `(payload: Payload) => Promise<void> \| void` | — (required) | Runs the action. Resolve to close; reject with an `Error` to keep it open showing why. |

## Driving it from a machine

A section that wires the block to a state machine maps the machine's state onto the same props:
Expand Down
65 changes: 64 additions & 1 deletion packages/swingset/src/stories/confirmation.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export { default as __source } from './confirmation.stories?raw';

export const meta: StoryMeta = {
group: 'Blocks',
status: 'stable',
status: 'wip',
title: 'Confirmation',
source: 'packages/ui/src/mosaic/blocks/confirmation/confirmation.tsx',
};
Expand Down Expand Up @@ -97,3 +97,66 @@ export function WithError() {
/>
);
}

interface ConnectedAccount {
id: string;
provider: string;
}

const removeAccount = Confirmation.createHandle<ConnectedAccount>();

const describeRemoval = (account: ConnectedAccount) => (
<>
<strong>{account.provider}</strong> will be removed from this account. You will no longer be able to use this
connected account and any dependent features will no longer work.
</>
);

/**
* One block for many rows. `handle.open(account)` opens it with the account it is about, and the
* promise `onConfirm` returns closes it or explains the failure. The block owns the rest.
*/
export function WithHandle() {
const [accounts, setAccounts] = React.useState<ConnectedAccount[]>([
{ id: 'eac_1', provider: 'Google' },
{ id: 'eac_2', provider: 'GitHub' },
{ id: 'eac_3', provider: 'Microsoft' },
]);

const handleConfirm = async (account: ConnectedAccount) => {
await settleAfter(2000);
if (account.provider === 'Google') {
throw new Error('Google is your only way to sign in. Add a password or another account first.');
}
setAccounts(current => current.filter(item => item.id !== account.id));
};

return (
<>
<ul style={{ display: 'grid', gap: 8, margin: 0, padding: 0, listStyle: 'none' }}>
{accounts.map(account => (
<li
key={account.id}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}
>
{account.provider}
<Button
color='negative'
variant='outline'
onClick={() => removeAccount.open(account)}
>
Remove
</Button>
</li>
))}
</ul>
<Confirmation
handle={removeAccount}
title='Remove connected account'
description={describeRemoval}
actionLabel='Remove'
onConfirm={handleConfirm}
/>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import { createActor } from '../../machine/createActor';
import { confirmationMachine, useConfirmationController } from './confirmation.controller';

function start() {
const actor = createActor(confirmationMachine).start();
actor.send({ type: 'OPEN' });
return actor;
}

describe('confirmationMachine', () => {
it('opens into confirming and cancels back to idle', () => {
const actor = start();
expect(actor.getSnapshot().value).toBe('confirming');

actor.send({ type: 'CANCEL' });

expect(actor.getSnapshot().value).toBe('idle');
});

it('runs the confirmed action and returns to idle when it lands', async () => {
const run = vi.fn(() => Promise.resolve());
const actor = start();

actor.send({ type: 'CONFIRM', run });
expect(actor.getSnapshot().value).toBe('pending');

await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('idle'));
expect(run).toHaveBeenCalledOnce();
expect(actor.getSnapshot().status).toBe('active');
});

it('holds the dialog open while the action is pending', () => {
const actor = start();
actor.send({ type: 'CONFIRM', run: () => new Promise(() => {}) });

actor.send({ type: 'CANCEL' });

expect(actor.getSnapshot().value).toBe('pending');
});

it('lands back on confirming with the reason when the action fails', async () => {
const actor = start();
actor.send({ type: 'CONFIRM', run: () => Promise.reject(new Error('Google is your only way to sign in.')) });

await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('confirming'));
expect(actor.getSnapshot().context.error).toBe('Google is your only way to sign in.');
});

it('falls back to generic copy when the rejection is not an Error', async () => {
const actor = start();
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test
actor.send({ type: 'CONFIRM', run: () => Promise.reject('nope') });

await vi.waitFor(() => expect(actor.getSnapshot().context.error).toBe('Something went wrong. Please try again.'));
});

it('drops the error when cancelled, so the next open starts clean', async () => {
const actor = start();
actor.send({ type: 'CONFIRM', run: () => Promise.reject(new Error('nope')) });
await vi.waitFor(() => expect(actor.getSnapshot().context.error).toBe('nope'));

actor.send({ type: 'CANCEL' });

expect(actor.getSnapshot().value).toBe('idle');
expect(actor.getSnapshot().context.error).toBeUndefined();
});
});

describe('useConfirmationController', () => {
it('holds the dialog open across confirming and pending, then closes on success', async () => {
const { result } = renderHook(() => useConfirmationController());
expect(result.current.isOpen).toBe(false);

act(() => result.current.onOpenChange(true));
expect(result.current.isOpen).toBe(true);
expect(result.current.isConfirming).toBe(false);

act(() => result.current.onConfirm(() => Promise.resolve()));
expect(result.current.isOpen).toBe(true);
expect(result.current.isConfirming).toBe(true);

await waitFor(() => expect(result.current.isOpen).toBe(false));
});

it('surfaces a failure as the error message and stays open', async () => {
const { result } = renderHook(() => useConfirmationController());
act(() => result.current.onOpenChange(true));

act(() => result.current.onConfirm(() => Promise.reject(new Error('nope'))));

await waitFor(() => expect(result.current.errorMessage).toBe('nope'));
expect(result.current.isOpen).toBe(true);
expect(result.current.isConfirming).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { setup } from '../../machine/setup';
import { useMachine } from '../../machine/useMachine';

export interface ConfirmationContext {
run: () => Promise<void>;
error: string | undefined;
}

export type ConfirmationEvent = { type: 'OPEN' } | { type: 'CONFIRM'; run: () => Promise<void> } | { type: 'CANCEL' };

const { createMachine, assign, fromPromise } = setup<ConfirmationContext, ConfirmationEvent>();

function notSeated(): Promise<never> {
return Promise.reject(new Error('confirmation run is not seated'));
}

function toMessage(cause: unknown): string {
return cause instanceof Error ? cause.message : 'Something went wrong. Please try again.';
}

export const confirmationMachine = createMachine({
id: 'confirmation',
initial: 'idle',
context: {
run: notSeated,
error: undefined,
},
states: {
idle: {
on: {
OPEN: { target: 'confirming', actions: assign(() => ({ error: undefined })) },
},
},
confirming: {
on: {
CONFIRM: { target: 'pending', actions: assign((_, event) => ({ run: event.run })) },
CANCEL: { target: 'idle', actions: assign(() => ({ error: undefined })) },
},
},
pending: {
invoke: fromPromise(context => context.run(), {
onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) },
onError: {
target: 'confirming',
actions: assign((_, event) => ({ error: toMessage(event.error) })),
},
}),
},
},
});

export interface ConfirmationController {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (run: () => Promise<void>) => void;
isConfirming: boolean;
errorMessage: string | undefined;
}

export function useConfirmationController(): ConfirmationController {
const [snapshot, send] = useMachine(confirmationMachine);

return {
isOpen: snapshot.value === 'confirming' || snapshot.value === 'pending',
onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }),
onConfirm: run => send({ type: 'CONFIRM', run }),
isConfirming: snapshot.value === 'pending',
errorMessage: snapshot.context.error,
};
}
Loading
Loading