diff --git a/.changeset/mosaic-confirmation-block.md b/.changeset/mosaic-confirmation-block.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-confirmation-block.md @@ -0,0 +1,2 @@ +--- +--- 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/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 784e648e294..25d1249854b 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -37,6 +37,7 @@ const docModules: Record> = { 'user-profile-delete-section': dynamic(() => import('../stories/user-profile-delete-section.mdx')), }, blocks: { + confirmation: dynamic(() => import('../stories/confirmation.mdx')), destructive: dynamic(() => import('../stories/destructive.mdx')), reverification: dynamic(() => import('../stories/reverification.mdx')), }, diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 939241dfe69..158e69af818 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -31,6 +31,11 @@ import { meta as comboboxMeta, Scrolling as ComboboxScrolling, } from '../stories/combobox.stories'; +import { + Default as ConfirmationDefault, + meta as confirmationMeta, + WithError as ConfirmationWithError, +} from '../stories/confirmation.stories'; import { Default as DestructiveDefault, meta as destructiveMeta, @@ -528,6 +533,12 @@ const userProfileDeleteSectionModule: StoryModule = { WithError: UserProfileDeleteSectionWithError, }; +const confirmationModule: StoryModule = { + meta: confirmationMeta, + Default: ConfirmationDefault, + WithError: ConfirmationWithError, +}; + const destructiveModule: StoryModule = { meta: destructiveMeta, Default: DestructiveDefault, @@ -578,6 +589,7 @@ export const registry: StoryModule[] = [ userProfileWeb3WalletsSectionModule, userProfileDeleteSectionModule, // Blocks — flows assembled from components, wired by the caller's machine. + confirmationModule, destructiveModule, reverificationModule, // Components diff --git a/packages/swingset/src/stories/confirmation.mdx b/packages/swingset/src/stories/confirmation.mdx new file mode 100644 index 00000000000..04c12f681c0 --- /dev/null +++ b/packages/swingset/src/stories/confirmation.mdx @@ -0,0 +1,134 @@ +import * as Stories from './confirmation.stories'; + +# Confirmation + +## Example + + + +## Usage + +A confirmation for a destructive action that is worth a second look but not worth making the user type for. Removing a connected account, revoking a session, signing out everywhere. For the actions that do warrant typing, use [Destructive](/components/destructive). + +The block holds nothing of its own. Everything that decides what the dialog does next belongs to the caller. `open` closes it, `isConfirming` marks it busy, `errorMessage` explains a failure. + +```tsx +import { Confirmation } from '@clerk/ui/mosaic/blocks/confirmation'; +import { Button } from '@clerk/ui/mosaic/components/button'; +import { useState } from 'react'; + +const [open, setOpen] = useState(false); +const [isConfirming, setIsConfirming] = useState(false); +const [errorMessage, setErrorMessage] = useState(); + +const handleConfirm = async () => { + setIsConfirming(true); + setErrorMessage(undefined); + try { + await removeConnectedAccount(); + setOpen(false); + } catch { + setErrorMessage('Google could not be removed. Please try again.'); + } finally { + setIsConfirming(false); + } +}; + +Remove} + 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={() => void handleConfirm()} + isConfirming={isConfirming} + errorMessage={errorMessage} +/>; +``` + +## Failure + +A failed attempt leaves the dialog up. Pass the sentence the user should read as `errorMessage`, and clear it when the next attempt starts. The message renders as a banner between the description and the actions. + + + +## 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. | +| `onOpenChange` | `(open: boolean) => void` | — (required) | Asks to open or close. Fired by the trigger, Cancel, Escape, and the backdrop. | +| `trigger` | `ReactNode` | — | The button that asks to open the dialog. | +| `title` | `string` | — (required) | Names what is about to happen. | +| `description` | `ReactNode` | — (required) | Spells out what it means. Takes markup, for a name to emphasise. | +| `actionLabel` | `string` | — (required) | The destructive button's label. | +| `cancelLabel` | `string` | `'Cancel'` | The cancel button's label. | +| `onConfirm` | `() => void` | — (required) | Asks the caller to run the action. | +| `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: + +```tsx + send({ type: open ? 'OPEN' : 'CANCEL' })} + onConfirm={() => send({ type: 'CONFIRM' })} + isConfirming={snapshot.value === 'removing'} + errorMessage={snapshot.context.errorMessage} + {...copy} +/> +``` diff --git a/packages/swingset/src/stories/confirmation.stories.tsx b/packages/swingset/src/stories/confirmation.stories.tsx new file mode 100644 index 00000000000..8a2abe1f503 --- /dev/null +++ b/packages/swingset/src/stories/confirmation.stories.tsx @@ -0,0 +1,162 @@ +import { Confirmation } from '@clerk/ui/mosaic/blocks/confirmation'; +import { Button } from '@clerk/ui/mosaic/components/button'; +import React from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './confirmation.stories?raw'; + +export const meta: StoryMeta = { + group: 'Blocks', + status: 'wip', + title: 'Confirmation', + source: 'packages/ui/src/mosaic/blocks/confirmation/confirmation.tsx', +}; + +// A real removal is a network round trip. Without one the action never renders its pending +// state, so both stories wait before they settle. +const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +const trigger = ( + +); + +/** + * The block holds nothing of its own. `open` closes it, `isConfirming` marks it busy, + * `errorMessage` explains a failure. + */ +export function Default() { + const [open, setOpen] = React.useState(false); + const [isConfirming, setIsConfirming] = React.useState(false); + + const handleConfirm = async () => { + setIsConfirming(true); + await settleAfter(2000); + setIsConfirming(false); + setOpen(false); + }; + + return ( + void handleConfirm()} + isConfirming={isConfirming} + /> + ); +} + +/** + * A failed attempt leaves the dialog up. Pass the sentence the user should read as + * `errorMessage`, and clear it when the next attempt starts. + */ +export function WithError() { + const [open, setOpen] = React.useState(false); + const [isConfirming, setIsConfirming] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(undefined); + + const handleConfirm = async () => { + setErrorMessage(undefined); + setIsConfirming(true); + await settleAfter(2000); + setIsConfirming(false); + setErrorMessage('Google is your only way to sign in. Add a password or another account first.'); + }; + + // The error belongs to the caller, so the caller drops it. Without this a reopened dialog + // still shows why the last attempt failed. + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (!next) { + setErrorMessage(undefined); + } + }; + + return ( + void handleConfirm()} + isConfirming={isConfirming} + errorMessage={errorMessage} + /> + ); +} + +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 new file mode 100644 index 00000000000..d63c261ca3c --- /dev/null +++ b/packages/ui/src/mosaic/blocks/confirmation/confirmation.test.tsx @@ -0,0 +1,223 @@ +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 { ConfirmationControlledProps, ConfirmationHandleProps } from './confirmation'; +import { Confirmation } from './confirmation'; + +function renderBlock(overrides: Partial = {}) { + return render( + + + , + ); +} + +const confirmButton = () => screen.getByRole('button', { name: 'Remove' }); + +describe('Confirmation', () => { + it('renders nothing until the caller opens it', () => { + renderBlock({ open: false }); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('asks to open from the trigger', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderBlock({ open: false, onOpenChange, trigger: }); + + await user.click(confirmButton()); + + expect(onOpenChange).toHaveBeenCalledWith(true, expect.anything()); + }); + + it('confirms from the action', async () => { + const onConfirm = vi.fn(); + const user = userEvent.setup(); + renderBlock({ onConfirm }); + + await user.click(confirmButton()); + + expect(onConfirm).toHaveBeenCalledOnce(); + }); + + it('renders markup in the description', () => { + renderBlock({ + description: ( + <> + preston@clerk.dev will be removed from this account. + + ), + }); + + expect(screen.getByRole('dialog')).toHaveAccessibleDescription( + 'preston@clerk.dev will be removed from this account.', + ); + expect(screen.getByText('preston@clerk.dev').tagName).toBe('STRONG'); + }); + + it('asks to close from cancel', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderBlock({ onOpenChange }); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); + + it('explains a failed attempt', () => { + renderBlock({ errorMessage: 'Google is your only way to sign in.' }); + + expect(screen.getByRole('alert')).toHaveTextContent('Google is your only way to sign in.'); + }); + + it('stays inert while the caller is confirming', async () => { + const onConfirm = vi.fn(); + const user = userEvent.setup(); + renderBlock({ isConfirming: true, onConfirm }); + + expect(confirmButton()).toHaveAttribute('aria-busy', 'true'); + await user.click(confirmButton()); + 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 new file mode 100644 index 00000000000..f3d1f86789c --- /dev/null +++ b/packages/ui/src/mosaic/blocks/confirmation/confirmation.tsx @@ -0,0 +1,246 @@ +import type { ReactNode } from 'react'; + +import { Banner } from '../../components/banner'; +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import type { DialogHandle, DialogTriggerProps } from '../../components/dialog'; +import { Dialog } from '../../components/dialog'; +import { useConfirmationController } from './confirmation.controller'; + +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 */ + onOpenChange: (open: boolean) => void; + /** Element that opens the dialog */ + trigger?: DialogTriggerProps['render']; + /** Dialog heading */ + title: string; + /** What the action does and why it warrants a second look. Takes markup, for a name to emphasise */ + description: ReactNode; + /** Text of the confirming button */ + actionLabel: string; + /** Text of the cancel button (default: "Cancel") */ + cancelLabel?: string; + /** Callback when the action is confirmed */ + onConfirm: () => void; + /** Whether the confirmed action is in progress */ + isConfirming?: boolean; + /** Error message to display if the confirmed action fails */ + errorMessage?: string; +} + +function ControlledConfirmation({ + open, + onOpenChange, + trigger, + title, + description, + actionLabel, + cancelLabel = 'Cancel', + onConfirm, + isConfirming = false, + errorMessage, +}: ConfirmationControlledProps) { + return ( + + {trigger ? : null} + + + ); +} + +/** + * Opens the block from anywhere with the payload the confirmation is about. Create with + * `Confirmation.createHandle()`. The copy is derived from the payload, so `open` requires one. + */ +export interface ConfirmationHandle extends DialogHandle { + open(payload: Payload): void; +} + +function createHandle(): ConfirmationHandle { + return Dialog.createHandle(); +} + +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 => api.removeMember(member.id)} + * /> + * + * removeMember.open(member)}>Remove + */ +export function Confirmation(props: ConfirmationProps) { + return 'handle' in props ? {...props} /> : ; +} + +Confirmation.createHandle = createHandle; diff --git a/packages/ui/src/mosaic/blocks/confirmation/index.ts b/packages/ui/src/mosaic/blocks/confirmation/index.ts new file mode 100644 index 00000000000..75e24b29ed1 --- /dev/null +++ b/packages/ui/src/mosaic/blocks/confirmation/index.ts @@ -0,0 +1,7 @@ +export { Confirmation } from './confirmation'; +export type { + ConfirmationControlledProps, + ConfirmationHandle, + ConfirmationHandleProps, + ConfirmationProps, +} from './confirmation';