diff --git a/.changeset/mosaic-confirmation-handle.md b/.changeset/mosaic-confirmation-handle.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-confirmation-handle.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/stories/confirmation.mdx b/packages/swingset/src/stories/confirmation.mdx index 8a44b928bdd..e46640bbf8f 100644 --- a/packages/swingset/src/stories/confirmation.mdx +++ b/packages/swingset/src/stories/confirmation.mdx @@ -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(); + + removeMember.open(member)}>Remove + + <>{member.name} 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. + + + ## Props +Controlled: + | Prop | Type | Default | Description | | -------------- | ------------------------- | ------------ | ------------------------------------------------------------------------------ | | `open` | `boolean` | — (required) | Whether the confirmation is showing. Controlled, the way any dialog is. | @@ -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` | — (required) | From `Confirmation.createHandle()`. `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` | — (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: diff --git a/packages/swingset/src/stories/confirmation.stories.tsx b/packages/swingset/src/stories/confirmation.stories.tsx index 264cc1114b8..8a2abe1f503 100644 --- a/packages/swingset/src/stories/confirmation.stories.tsx +++ b/packages/swingset/src/stories/confirmation.stories.tsx @@ -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', }; @@ -97,3 +97,66 @@ export function WithError() { /> ); } + +interface ConnectedAccount { + id: string; + provider: string; +} + +const removeAccount = Confirmation.createHandle(); + +const describeRemoval = (account: ConnectedAccount) => ( + <> + {account.provider} 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([ + { 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 ( + <> +
    + {accounts.map(account => ( +
  • + {account.provider} + +
  • + ))} +
+ + + ); +} diff --git a/packages/ui/src/mosaic/blocks/confirmation/confirmation.controller.test.ts b/packages/ui/src/mosaic/blocks/confirmation/confirmation.controller.test.ts new file mode 100644 index 00000000000..32886f03f25 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/confirmation/confirmation.controller.test.ts @@ -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); + }); +}); diff --git a/packages/ui/src/mosaic/blocks/confirmation/confirmation.controller.ts b/packages/ui/src/mosaic/blocks/confirmation/confirmation.controller.ts new file mode 100644 index 00000000000..7108ed5d433 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/confirmation/confirmation.controller.ts @@ -0,0 +1,70 @@ +import { setup } from '../../machine/setup'; +import { useMachine } from '../../machine/useMachine'; + +export interface ConfirmationContext { + run: () => Promise; + error: string | undefined; +} + +export type ConfirmationEvent = { type: 'OPEN' } | { type: 'CONFIRM'; run: () => Promise } | { type: 'CANCEL' }; + +const { createMachine, assign, fromPromise } = setup(); + +function notSeated(): Promise { + 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; + 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, + }; +} diff --git a/packages/ui/src/mosaic/blocks/confirmation/confirmation.test.tsx b/packages/ui/src/mosaic/blocks/confirmation/confirmation.test.tsx index b80b0fda194..d63c261ca3c 100644 --- a/packages/ui/src/mosaic/blocks/confirmation/confirmation.test.tsx +++ b/packages/ui/src/mosaic/blocks/confirmation/confirmation.test.tsx @@ -1,13 +1,13 @@ -import { render, screen } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../components/button'; import { MosaicProvider } from '../../MosaicProvider'; -import type { ConfirmationProps } from './confirmation'; +import type { ConfirmationControlledProps, ConfirmationHandleProps } from './confirmation'; import { Confirmation } from './confirmation'; -function renderBlock(overrides: Partial = {}) { +function renderBlock(overrides: Partial = {}) { return render( { expect(onConfirm).not.toHaveBeenCalled(); }); }); + +interface Member { + id: string; + name: string; +} + +const preston: Member = { id: 'mem_1', name: 'Preston Booth' }; + +function renderWithHandle(onConfirm: ConfirmationHandleProps['onConfirm'] = () => Promise.resolve()) { + const handle = Confirmation.createHandle(); + render( + + ( + <> + {member.name} will be removed from the organization. + + )} + actionLabel={member => `Remove ${member.name}`} + onConfirm={onConfirm} + /> + , + ); + return handle; +} + +const removeButton = () => screen.getByRole('button', { name: 'Remove Preston Booth' }); + +describe('Confirmation with a handle', () => { + it('renders nothing until opened through the handle', () => { + renderWithHandle(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('opens with a payload and renders the copy from it', () => { + const handle = renderWithHandle(); + + act(() => { + handle.open(preston); + }); + + expect(screen.getByRole('dialog')).toHaveAccessibleName('Remove member'); + expect(screen.getByRole('dialog')).toHaveAccessibleDescription( + 'Preston Booth will be removed from the organization.', + ); + expect(screen.getByText('Preston Booth').tagName).toBe('STRONG'); + expect(removeButton()).toBeInTheDocument(); + }); + + it('confirms with the payload and closes once the action lands', async () => { + const onConfirm = vi.fn(() => Promise.resolve()); + const user = userEvent.setup(); + const handle = renderWithHandle(onConfirm); + act(() => { + handle.open(preston); + }); + + await user.click(removeButton()); + + expect(onConfirm).toHaveBeenCalledWith(preston); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('stays inert and open while the action is pending', async () => { + const user = userEvent.setup(); + const handle = renderWithHandle(() => new Promise(() => {})); + act(() => { + handle.open(preston); + }); + + await user.click(removeButton()); + + expect(removeButton()).toHaveAttribute('aria-busy', 'true'); + await user.keyboard('{Escape}'); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('keeps the dialog open and explains a failed attempt', async () => { + const user = userEvent.setup(); + const handle = renderWithHandle(() => Promise.reject(new Error('Preston Booth is the last admin.'))); + act(() => { + handle.open(preston); + }); + + await user.click(removeButton()); + + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Preston Booth is the last admin.')); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(removeButton()).not.toHaveAttribute('aria-busy', 'true'); + }); + + it('starts the next open clean after a failure', async () => { + const user = userEvent.setup(); + const handle = renderWithHandle(() => Promise.reject(new Error('nope'))); + act(() => { + handle.open(preston); + }); + await user.click(removeButton()); + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + act(() => { + handle.open(preston); + }); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('closes through the handle', async () => { + const handle = renderWithHandle(); + act(() => { + handle.open(preston); + }); + expect(handle.isOpen).toBe(true); + + act(() => { + handle.close(); + }); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(handle.isOpen).toBe(false); + }); +}); diff --git a/packages/ui/src/mosaic/blocks/confirmation/confirmation.tsx b/packages/ui/src/mosaic/blocks/confirmation/confirmation.tsx index cacca8455ab..6d3690c13cc 100644 --- a/packages/ui/src/mosaic/blocks/confirmation/confirmation.tsx +++ b/packages/ui/src/mosaic/blocks/confirmation/confirmation.tsx @@ -3,10 +3,76 @@ import type { ReactNode } from 'react'; import { Banner } from '../../components/banner'; import { Button, SubmitButton } from '../../components/button'; import { Card } from '../../components/card'; -import type { DialogTriggerProps } from '../../components/dialog'; +import type { DialogHandle, DialogTriggerProps } from '../../components/dialog'; import { Dialog } from '../../components/dialog'; +import { useConfirmationController } from './confirmation.controller'; -export interface ConfirmationProps { +interface ConfirmationCardProps { + title: string; + description: ReactNode; + actionLabel: string; + cancelLabel: string; + onConfirm: () => void; + isConfirming: boolean; + errorMessage: string | undefined; +} + +function ConfirmationCard({ + title, + description, + actionLabel, + cancelLabel, + onConfirm, + isConfirming, + errorMessage, +}: ConfirmationCardProps) { + return ( + + + + {title} + {description} + + {errorMessage ? ( + + + {errorMessage} + + + ) : null} + + + {cancelLabel} + + } + /> + + {actionLabel} + + + + + ); +} + +export interface ConfirmationControlledProps { /** Whether the dialog is open */ open: boolean; /** Callback when open state changes */ @@ -29,27 +95,7 @@ export interface ConfirmationProps { errorMessage?: string; } -/** - * Confirmation dialog for a destructive action that is worth a second look but not worth - * making the user type for. Use `Destructive` for the actions that are. - * - * Controlled: the caller owns `open`, `isConfirming`, and `errorMessage`. The block holds - * nothing of its own. - * - * @example - * send({ type: open ? 'OPEN' : 'CANCEL' })} - * trigger={} - * title='Remove connected account' - * description='Google 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.' - * actionLabel='Remove' - * onConfirm={() => send({ type: 'CONFIRM' })} - * isConfirming={snapshot.value === 'removing'} - * errorMessage={snapshot.context.errorMessage} - * /> - */ -export function Confirmation({ +function ControlledConfirmation({ open, onOpenChange, trigger, @@ -60,7 +106,7 @@ export function Confirmation({ onConfirm, isConfirming = false, errorMessage, -}: ConfirmationProps) { +}: ConfirmationControlledProps) { return ( {trigger ? : null} - - - - {title} - {description} - - {errorMessage ? ( - - - {errorMessage} - - - ) : null} - - - {cancelLabel} - - } - /> - - {actionLabel} - - - - + + + ); +} + +/** Opens the block from anywhere with the payload the confirmation is about. Create with `Confirmation.createHandle()` */ +export type ConfirmationHandle = DialogHandle; + +type FromPayload = Value | ((payload: Payload) => Value); + +function isFromPayload(value: FromPayload): value is (payload: Payload) => Value { + return typeof value === 'function'; +} + +function resolve(value: FromPayload, payload: Payload): Value { + return isFromPayload(value) ? value(payload) : value; +} + +export interface ConfirmationHandleProps { + /** Opens the dialog with a payload from anywhere: `handle.open(payload)` */ + handle: ConfirmationHandle; + /** Dialog heading, or a function of the payload */ + title: FromPayload; + /** What the action does and why it warrants a second look, or a function of the payload. Takes markup, for a name to emphasise */ + description: FromPayload; + /** Text of the confirming button, or a function of the payload */ + actionLabel: FromPayload; + /** Text of the cancel button (default: "Cancel") */ + cancelLabel?: string; + /** Runs the action for the payload. Resolve to close the dialog; reject with an `Error` to keep it open showing why */ + onConfirm: (payload: Payload) => Promise | void; +} + +function HandleConfirmation({ + handle, + title, + description, + actionLabel, + cancelLabel = 'Cancel', + onConfirm, +}: ConfirmationHandleProps) { + const controller = useConfirmationController(); + + return ( + + {({ payload }) => + payload === undefined ? null : ( + + controller.onConfirm(async () => { + await onConfirm(payload); + }) + } + isConfirming={controller.isConfirming} + errorMessage={controller.errorMessage} + /> + ) + } ); } + +export type ConfirmationProps = ConfirmationControlledProps | ConfirmationHandleProps; + +/** + * Confirmation dialog for a destructive action that is worth a second look but not worth + * making the user type for. Use `Destructive` for the actions that are. + * + * Two forms. Controlled: the caller owns `open`, `isConfirming`, and `errorMessage`, and the + * block holds nothing of its own. With a `handle`: the block owns all three. Mount it once, + * open it from anywhere with `handle.open(payload)`, and derive the copy and the action from + * that payload; the promise `onConfirm` returns decides whether it closes or explains a failure. + * + * @example + * send({ type: open ? 'OPEN' : 'CANCEL' })} + * trigger={} + * title='Remove connected account' + * description='Google 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.' + * actionLabel='Remove' + * onConfirm={() => send({ type: 'CONFIRM' })} + * isConfirming={snapshot.value === 'removing'} + * errorMessage={snapshot.context.errorMessage} + * /> + * + * @example + * const removeMember = Confirmation.createHandle(); + * + * <>{member.name} will be removed from the organization.} + * actionLabel='Remove' + * onConfirm={member => removeMember(member.id)} + * /> + * + * removeMember.open(member)}>Remove + */ +export function Confirmation(props: ConfirmationProps) { + return 'handle' in props ? {...props} /> : ; +} + +Confirmation.createHandle = Dialog.createHandle; diff --git a/packages/ui/src/mosaic/blocks/confirmation/index.ts b/packages/ui/src/mosaic/blocks/confirmation/index.ts index 5b00b65ea13..75e24b29ed1 100644 --- a/packages/ui/src/mosaic/blocks/confirmation/index.ts +++ b/packages/ui/src/mosaic/blocks/confirmation/index.ts @@ -1,2 +1,7 @@ export { Confirmation } from './confirmation'; -export type { ConfirmationProps } from './confirmation'; +export type { + ConfirmationControlledProps, + ConfirmationHandle, + ConfirmationHandleProps, + ConfirmationProps, +} from './confirmation';