-
Notifications
You must be signed in to change notification settings - Fork 474
feat(ui): add Confirmation block #9734
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import * as Stories from './confirmation.stories'; | ||
|
|
||
| # Confirmation | ||
|
|
||
| ## Example | ||
|
|
||
| <Story | ||
| name='Default' | ||
| storyModule={Stories} | ||
| composition={[ | ||
| { name: 'Dialog', href: '/components/dialog', layer: 'Components' }, | ||
| { name: 'Card', href: '/components/card', layer: 'Components' }, | ||
| { name: 'Banner', href: '/components/banner', layer: 'Components' }, | ||
| { name: 'Button', href: '/components/button', layer: 'Components' }, | ||
| ]} | ||
| /> | ||
|
|
||
| ## 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'; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| import { Button } from '@clerk/ui/mosaic/components/button'; | ||
| import { useState } from 'react'; | ||
|
|
||
| const [open, setOpen] = useState(false); | ||
| const [isConfirming, setIsConfirming] = useState(false); | ||
|
|
||
| const handleConfirm = async () => { | ||
| setIsConfirming(true); | ||
| await removeConnectedAccount(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Handle removal failures and always clear pending state. When 🤖 Prompt for AI Agents |
||
| setIsConfirming(false); | ||
| setOpen(false); | ||
| }; | ||
|
|
||
| <Confirmation | ||
| open={open} | ||
| onOpenChange={setOpen} | ||
| trigger={<Button color='negative' variant='outline'>Remove</Button>} | ||
| 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} | ||
| />; | ||
| ``` | ||
|
|
||
| ## 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. | ||
|
|
||
| <Story | ||
| name='WithError' | ||
| storyModule={Stories} | ||
| /> | ||
|
|
||
| ## Props | ||
|
|
||
| | 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. | | ||
|
|
||
| ## 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 | ||
| <Confirmation | ||
| open={snapshot.value === 'confirming' || snapshot.value === 'removing'} | ||
| onOpenChange={open => send({ type: open ? 'OPEN' : 'CANCEL' })} | ||
| onConfirm={() => send({ type: 'CONFIRM' })} | ||
| isConfirming={snapshot.value === 'removing'} | ||
| errorMessage={snapshot.context.errorMessage} | ||
| {...copy} | ||
| /> | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| 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 `<Story>` 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: 'stable', | ||
| 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<void>(resolve => setTimeout(resolve, ms)); | ||
|
|
||
| const trigger = ( | ||
| <Button | ||
| color='negative' | ||
| variant='outline' | ||
| > | ||
| Remove | ||
| </Button> | ||
| ); | ||
|
|
||
| /** | ||
| * 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 ( | ||
| <Confirmation | ||
| open={open} | ||
| onOpenChange={setOpen} | ||
| trigger={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={() => 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<string | undefined>(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 ( | ||
| <Confirmation | ||
| open={open} | ||
| onOpenChange={handleOpenChange} | ||
| trigger={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={() => void handleConfirm()} | ||
| isConfirming={isConfirming} | ||
| errorMessage={errorMessage} | ||
| /> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import { render, screen } 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 { Confirmation } from './confirmation'; | ||
|
|
||
| function renderBlock(overrides: Partial<ConfirmationProps> = {}) { | ||
| return render( | ||
| <MosaicProvider> | ||
| <Confirmation | ||
| open | ||
| onOpenChange={vi.fn()} | ||
| 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={vi.fn()} | ||
| {...overrides} | ||
| /> | ||
| </MosaicProvider>, | ||
| ); | ||
| } | ||
|
|
||
| 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: <Button>Remove</Button> }); | ||
|
|
||
| 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: ( | ||
| <> | ||
| <strong>preston@clerk.dev</strong> 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(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Use the required MDX section order.
Line 5 starts an
Examplesection. UsePlayground,Props, thenUsagein that order. Place the failure guidance afterUsage.As per coding guidelines: “Playground / Props / Usage are mandatory and always in this order.”
🤖 Prompt for AI Agents
Source: Coding guidelines