Skip to content

Commit f545bfc

Browse files
feat(ui): Add Destructive block and wire the delete account section (#9555)
1 parent 9c5e93c commit f545bfc

30 files changed

Lines changed: 992 additions & 64 deletions

.changeset/wild-mangos-clap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

.claude/skills/mosaic/references/views.md

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,41 @@ The view renders a snapshot and emits events. Nothing else.
99
- **Take derived booleans from the controller.** `actor.can(...)` results (e.g.
1010
`canSubmit`) are passed in — the view never re-implements a machine guard.
1111

12+
```tsx
13+
<Form onSubmit={() => send({ type: 'SUBMIT' })}>
14+
<Input
15+
value={snapshot.context.name}
16+
disabled={snapshot.value === 'saving'}
17+
onChange={event => send({ type: 'TYPE_NAME', value: event.target.value })}
18+
/>
19+
<SubmitButton
20+
isPending={snapshot.value === 'saving'}
21+
disabled={!canSubmit}
22+
>
23+
Save
24+
</SubmitButton>
25+
</Form>
26+
```
27+
28+
A **block** takes the flow's state as props. It owns only what nothing outside
29+
it can use. `Destructive` is the example: it holds the half-typed confirmation
30+
phrase and compares it, while `open`, `isDeleting`, and `errorMessage` come from
31+
the machine, because those are what decide whether the dialog closes or explains
32+
itself.
33+
1234
```tsx
1335
<Destructive
1436
open={snapshot.value === 'confirming' || snapshot.value === 'deleting'}
15-
resourceName={snapshot.context.organizationName}
16-
confirmationValue={snapshot.context.confirmationValue}
17-
onConfirmationValueChange={value => send({ type: 'TYPE_CONFIRMATION', value })}
37+
onOpenChange={open => send({ type: open ? 'OPEN' : 'CANCEL' })}
38+
trigger={<Button color='negative'>Delete organization</Button>}
39+
title='Delete organization?'
40+
description="All of this organization's data will be permanently deleted."
41+
fieldLabel='Type the organization name below to continue'
42+
confirmationValue={organizationName}
43+
actionLabel='Delete organization'
1844
onDelete={() => send({ type: 'CONFIRM' })}
19-
canSubmit={canSubmit}
2045
isDeleting={snapshot.value === 'deleting'}
21-
error={snapshot.context.error}
46+
errorMessage={snapshot.context.errorMessage}
2247
/>
2348
```
2449

packages/swingset/src/components/DocsViewer.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
3737
'user-profile-web3wallets-section': dynamic(() => import('../stories/user-profile-web3-wallets-section.mdx')),
3838
'user-profile-delete-section': dynamic(() => import('../stories/user-profile-delete-section.mdx')),
3939
},
40+
blocks: {
41+
destructive: dynamic(() => import('../stories/destructive.mdx')),
42+
},
4043
components: {
4144
avatar: dynamic(() => import('../stories/avatar.mdx')),
4245
badge: dynamic(() => import('../stories/badge.mdx')),

packages/swingset/src/components/app-sidebar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { getSidebarGroups } from '@/lib/registry';
2424

2525
const groups = getSidebarGroups();
2626

27-
const COLLAPSED_BY_DEFAULT = new Set(['Primitives', 'Components', 'Styles', 'Hooks']);
27+
const COLLAPSED_BY_DEFAULT = new Set(['Blocks', 'Primitives', 'Components', 'Styles', 'Hooks']);
2828

2929
type SidebarEntry = ReturnType<typeof getSidebarGroups>[number]['components'][number];
3030

@@ -167,7 +167,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
167167
<SidebarContent className='gap-0'>
168168
{groups.map(({ group, groupSlug, components }) => (
169169
<React.Fragment key={group}>
170-
{group === 'Components' && <SidebarSeparator className='data-horizontal:w-auto my-1' />}
170+
{group === 'Blocks' && <SidebarSeparator className='data-horizontal:w-auto my-1' />}
171171
<Collapsible
172172
defaultOpen={!COLLAPSED_BY_DEFAULT.has(group)}
173173
className='group/collapsible'

packages/swingset/src/lib/registry.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ import {
2727
meta as cardComponentMeta,
2828
} from '../stories/card.component.stories';
2929
import { meta as collapsibleMeta } from '../stories/collapsible.stories';
30+
import {
31+
Default as DestructiveDefault,
32+
meta as destructiveMeta,
33+
WithError as DestructiveWithError,
34+
} from '../stories/destructive.stories';
3035
import { Default as DialogDefault, meta as dialogComponentMeta } from '../stories/dialog.component.stories';
3136
import { meta as dialogMeta } from '../stories/dialog.stories';
3237
import { meta as drawerMeta } from '../stories/drawer.stories';
@@ -132,6 +137,7 @@ import {
132137
import {
133138
Default as UserProfileDeleteSectionDefault,
134139
meta as userProfileDeleteSectionMeta,
140+
WithError as UserProfileDeleteSectionWithError,
135141
} from '../stories/user-profile-delete-section.stories';
136142
import {
137143
Default as UserProfileMfaSectionDefault,
@@ -352,6 +358,13 @@ const userProfileWeb3WalletsSectionModule: StoryModule = {
352358
const userProfileDeleteSectionModule: StoryModule = {
353359
meta: userProfileDeleteSectionMeta,
354360
Default: UserProfileDeleteSectionDefault,
361+
WithError: UserProfileDeleteSectionWithError,
362+
};
363+
364+
const destructiveModule: StoryModule = {
365+
meta: destructiveMeta,
366+
Default: DestructiveDefault,
367+
WithError: DestructiveWithError,
355368
};
356369

357370
export const registry: StoryModule[] = [
@@ -376,6 +389,8 @@ export const registry: StoryModule[] = [
376389
userProfileConnectedAccountsSectionModule,
377390
userProfileWeb3WalletsSectionModule,
378391
userProfileDeleteSectionModule,
392+
// Blocks — flows assembled from components, wired by the caller's machine.
393+
destructiveModule,
379394
// Components
380395
avatarModule,
381396
badgeModule,
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import * as Stories from './destructive.stories';
2+
3+
# Destructive
4+
5+
A type-to-confirm dialog for an action that cannot be undone. The action stays inert until the user types the confirmation phrase back.
6+
7+
## Example
8+
9+
<Story
10+
name='Default'
11+
storyModule={Stories}
12+
composition={[
13+
{ name: 'Dialog', href: '/components/dialog', layer: 'Components' },
14+
{ name: 'Card', href: '/components/card', layer: 'Components' },
15+
{ name: 'Field', href: '/components/field', layer: 'Components' },
16+
{ name: 'Button', href: '/components/button', layer: 'Components' },
17+
]}
18+
/>
19+
20+
## Usage
21+
22+
The block holds one thing: the phrase the user types. Nothing outside the dialog can use a half-typed string, so keeping it inside removes the keystroke plumbing a caller would otherwise write.
23+
24+
Everything that decides what the dialog does next belongs to the caller. `open` closes it, `isDeleting` marks it busy, `errorMessage` explains a failure.
25+
26+
```tsx
27+
import { Destructive } from '@clerk/ui/mosaic/blocks/destructive';
28+
import { Button } from '@clerk/ui/mosaic/components/button';
29+
30+
const [open, setOpen] = useState(false);
31+
const [isDeleting, setIsDeleting] = useState(false);
32+
33+
const handleDelete = async () => {
34+
setIsDeleting(true);
35+
await deleteAccount();
36+
setIsDeleting(false);
37+
setOpen(false);
38+
};
39+
40+
<Destructive
41+
open={open}
42+
onOpenChange={setOpen}
43+
trigger={<Button color='negative' variant='outline'>Delete account</Button>}
44+
title='Delete account?'
45+
description='Are you sure you want to delete your account? All of your data will be permanently deleted.'
46+
fieldLabel='Type “Delete account” below to continue'
47+
confirmationValue='Delete account'
48+
actionLabel='Delete account'
49+
onDelete={() => void handleDelete()}
50+
isDeleting={isDeleting}
51+
/>;
52+
```
53+
54+
## Failure
55+
56+
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 field is marked invalid for as long as a message is set.
57+
58+
<Story
59+
name='WithError'
60+
storyModule={Stories}
61+
/>
62+
63+
## Props
64+
65+
| Prop | Type | Description |
66+
| ------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------- |
67+
| `open` | `boolean` | Whether the confirmation is showing. Controlled, the way any dialog is. |
68+
| `onOpenChange` | `(open: boolean) => void` | Asks to open or close. Fired by the trigger, Cancel, Escape, and the backdrop. |
69+
| `trigger` | `ReactNode` | Optional. The button that asks to open the dialog. |
70+
| `title` | `string` | Names what is about to be destroyed. |
71+
| `description` | `string` | Spells out what is lost. Sits above the confirmation field. |
72+
| `fieldLabel` | `string` | Labels the confirmation field. |
73+
| `confirmationValue` | `string` | The phrase the user has to type back. Also the field's placeholder. |
74+
| `actionLabel` | `string` | The destructive button's label. |
75+
| `cancelLabel` | `string` | Optional. Defaults to `Cancel`. |
76+
| `onDelete` | `() => void` | Asks the caller to run the action. Reached by the button or by Enter in the field, once the typed phrase matches. |
77+
| `isDeleting` | `boolean` | Optional. Disables the field and renders the action pending. |
78+
| `errorMessage` | `string` | Optional. Marks the field invalid and renders under it. |
79+
80+
## Driving it from a machine
81+
82+
`UserProfileDeleteSection` wires the same block to a state machine rather than to `useState`. The machine's state maps onto the same props:
83+
84+
```tsx
85+
<Destructive
86+
open={snapshot.value === 'confirming' || snapshot.value === 'deleting'}
87+
onOpenChange={open => send({ type: open ? 'OPEN' : 'CANCEL' })}
88+
onDelete={() => send({ type: 'CONFIRM' })}
89+
isDeleting={snapshot.value === 'deleting'}
90+
errorMessage={snapshot.context.errorMessage}
91+
{...copy}
92+
/>
93+
```
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { Destructive } from '@clerk/ui/mosaic/blocks/destructive';
2+
import { Button } from '@clerk/ui/mosaic/components/button';
3+
import React from 'react';
4+
5+
import type { StoryMeta } from '@/lib/types';
6+
7+
// Exposes this file's own source (via the `?raw` webpack rule) so each `<Story>` example
8+
// renders a code footer with its function's source. See `StoryModule.__source`.
9+
export { default as __source } from './destructive.stories?raw';
10+
11+
export const meta: StoryMeta = {
12+
group: 'Blocks',
13+
title: 'Destructive',
14+
source: 'packages/ui/src/mosaic/blocks/destructive/destructive.tsx',
15+
};
16+
17+
// A real delete is a network round trip. Without one the action never renders its pending
18+
// state, so both stories wait before they settle.
19+
const settleAfter = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
20+
21+
const trigger = (
22+
<Button
23+
color='negative'
24+
variant='outline'
25+
>
26+
Delete account
27+
</Button>
28+
);
29+
30+
/**
31+
* The block holds the typed phrase and compares it to `confirmationValue`. Everything that
32+
* decides what the dialog does next stays with the caller: `open` closes it, `isDeleting`
33+
* marks it busy, `errorMessage` explains a failure.
34+
*/
35+
export function Default() {
36+
const [open, setOpen] = React.useState(false);
37+
const [isDeleting, setIsDeleting] = React.useState(false);
38+
39+
const handleDelete = async () => {
40+
setIsDeleting(true);
41+
await settleAfter(2000);
42+
setIsDeleting(false);
43+
setOpen(false);
44+
};
45+
46+
return (
47+
<Destructive
48+
open={open}
49+
onOpenChange={setOpen}
50+
trigger={trigger}
51+
title='Delete account?'
52+
description='Are you sure you want to delete your account? All of your data will be permanently deleted.'
53+
fieldLabel='Type “Delete account” below to continue'
54+
confirmationValue='Delete account'
55+
actionLabel='Delete account'
56+
onDelete={() => void handleDelete()}
57+
isDeleting={isDeleting}
58+
/>
59+
);
60+
}
61+
62+
/**
63+
* A failed attempt leaves the dialog up. Pass the sentence the user should read as
64+
* `errorMessage`, and clear it when the next attempt starts.
65+
*/
66+
export function WithError() {
67+
const [open, setOpen] = React.useState(false);
68+
const [isDeleting, setIsDeleting] = React.useState(false);
69+
const [errorMessage, setErrorMessage] = React.useState<string | undefined>(undefined);
70+
71+
const handleDelete = async () => {
72+
setErrorMessage(undefined);
73+
setIsDeleting(true);
74+
await settleAfter(2000);
75+
setIsDeleting(false);
76+
setErrorMessage('Your subscription is still active. Cancel it before you delete your account.');
77+
};
78+
79+
// The error belongs to the caller, so the caller drops it. Without this a reopened dialog
80+
// still shows why the last attempt failed.
81+
const handleOpenChange = (next: boolean) => {
82+
setOpen(next);
83+
if (!next) {
84+
setErrorMessage(undefined);
85+
}
86+
};
87+
88+
return (
89+
<Destructive
90+
open={open}
91+
onOpenChange={handleOpenChange}
92+
trigger={trigger}
93+
title='Delete account?'
94+
description='Are you sure you want to delete your account? All of your data will be permanently deleted.'
95+
fieldLabel='Type “Delete account” below to continue'
96+
confirmationValue='Delete account'
97+
actionLabel='Delete account'
98+
onDelete={() => void handleDelete()}
99+
isDeleting={isDeleting}
100+
errorMessage={errorMessage}
101+
/>
102+
);
103+
}

packages/swingset/src/stories/user-page.stories.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export function Default() {
121121
isVerified: true,
122122
},
123123
]),
124-
onDeleteAccount: () => undefined,
124+
onDeleteAccount: () => Promise.resolve(),
125125
onEditProfilePicture: () => undefined,
126126
onManageEmail: () => undefined,
127127
onManagePhone: () => undefined,
@@ -162,7 +162,7 @@ export function Default() {
162162
{ id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' },
163163
]),
164164
onChangePassword: () => undefined,
165-
onDeleteAccount: () => undefined,
165+
onDeleteAccount: () => Promise.resolve(),
166166
onManageDevice: () => undefined,
167167
onManagePasskey: () => undefined,
168168
onRegenerateBackupCodes: () =>

packages/swingset/src/stories/user-profile-delete-section.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,10 @@ The terminal destructive action for deleting the current user account.
1212
{ name: 'Button', href: '/components/button', layer: 'Components' },
1313
]}
1414
/>
15+
16+
A failed delete keeps the dialog up and renders the reason under the confirmation field.
17+
18+
<Story
19+
name='WithError'
20+
storyModule={Stories}
21+
/>
Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { UserProfileDeleteSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-delete-section.view';
1+
import { UserProfileDeleteSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.view';
2+
import { useState } from 'react';
23

34
import type { StoryMeta } from '@/lib/types';
45

@@ -9,9 +10,38 @@ export const meta: StoryMeta = {
910
title: 'UserProfileDeleteSection',
1011
label: 'Danger zone',
1112
navigation: { category: 'Sections' },
12-
source: 'packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx',
13+
source: 'packages/ui/src/mosaic/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx',
1314
};
1415

16+
// A real delete is a network round trip. Without one the button never renders its pending
17+
// state, so both stories wait before they settle.
18+
const settleAfter = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
19+
1520
export function Default() {
16-
return <UserProfileDeleteSectionView onDelete={() => undefined} />;
21+
const [runId, setRunId] = useState(0);
22+
23+
// Deleting is terminal: the real flow signs the user out and the section goes away with the
24+
// page. Nothing unmounts it here, so the story remounts it to make the demo repeatable.
25+
const handleDelete = async () => {
26+
await settleAfter(2000);
27+
setRunId(current => current + 1);
28+
};
29+
30+
return (
31+
<UserProfileDeleteSectionView
32+
key={runId}
33+
onDelete={handleDelete}
34+
/>
35+
);
36+
}
37+
38+
export function WithError() {
39+
return (
40+
<UserProfileDeleteSectionView
41+
onDelete={async () => {
42+
await settleAfter(2000);
43+
throw new Error('Your subscription is still active. Cancel it before you delete your account.');
44+
}}
45+
/>
46+
);
1747
}

0 commit comments

Comments
 (0)