Skip to content

Commit 1a6663e

Browse files
Merge pull request #89 from tetrascience/feat/confirm-dialog
feat(pattern): SW-1745 Add ConfirmDialog design pattern
2 parents 271ff65 + 65fd7f8 commit 1a6663e

4 files changed

Lines changed: 367 additions & 0 deletions

File tree

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
import { useState } from "react"
2+
import { expect, userEvent, waitFor, within } from "storybook/test"
3+
4+
import { ConfirmDialog } from "./ConfirmDialog"
5+
6+
import type { Meta, StoryObj } from "@storybook/react-vite"
7+
8+
import { Button } from "@/components/ui/button"
9+
10+
const meta: Meta<typeof ConfirmDialog> = {
11+
title: "Patterns/ConfirmDialog",
12+
component: ConfirmDialog,
13+
parameters: {
14+
layout: "centered",
15+
docs: { story: { inline: false, iframeHeight: 400 } },
16+
},
17+
tags: ["autodocs"],
18+
}
19+
20+
export default meta
21+
22+
type Story = StoryObj<typeof ConfirmDialog>
23+
24+
const defaultArgs = {
25+
title: "Archive workspace",
26+
description:
27+
"This workspace will be archived and hidden from your dashboard. You can restore it later.",
28+
confirmLabel: "Archive",
29+
cancelLabel: "Cancel",
30+
trigger: <Button variant="outline">Archive workspace</Button>,
31+
} satisfies Story["args"]
32+
33+
const destructiveArgs = {
34+
title: "Delete experiment",
35+
description: "JOB-9142 and all its associated data will be permanently removed.",
36+
variant: "destructive",
37+
confirmLabel: "Delete",
38+
trigger: <Button variant="destructive">Delete experiment</Button>,
39+
} satisfies Story["args"]
40+
41+
export const Default: Story = {
42+
args: defaultArgs,
43+
parameters: {
44+
zephyr: { testCaseId: "SW-T5156" },
45+
},
46+
}
47+
48+
export const Destructive: Story = {
49+
args: destructiveArgs,
50+
parameters: {
51+
zephyr: { testCaseId: "SW-T5157" },
52+
},
53+
}
54+
55+
export const WithLoading: Story = {
56+
tags: ["!dev", "!autodocs"],
57+
render: () => {
58+
const [open, setOpen] = useState(true)
59+
60+
return (
61+
<ConfirmDialog
62+
title="Submit pipeline run"
63+
description="JOB-9145 will be queued and start processing shortly."
64+
confirmLabel="Submit"
65+
loading={true}
66+
open={open}
67+
onOpenChange={setOpen}
68+
/>
69+
)
70+
},
71+
parameters: {
72+
zephyr: { testCaseId: "SW-T5158" },
73+
},
74+
play: async ({ canvasElement, step }) => {
75+
const body = within(canvasElement.ownerDocument.body)
76+
77+
await step("Dialog is open with loading state", async () => {
78+
expect(body.getByRole("dialog")).toBeInTheDocument()
79+
})
80+
81+
await step("Confirm button is disabled while loading", async () => {
82+
const confirmBtn = body.getByRole("button", { name: /Submit/ })
83+
expect(confirmBtn).toBeDisabled()
84+
})
85+
86+
await step("Spinner is visible in confirm button", async () => {
87+
const confirmBtn = body.getByRole("button", { name: /Submit/ })
88+
expect(confirmBtn.querySelector("[data-slot='spinner']")).toBeInTheDocument()
89+
})
90+
},
91+
}
92+
93+
export const ControlledOpen: Story = {
94+
tags: ["!dev", "!autodocs"],
95+
render: () => {
96+
const [open, setOpen] = useState(true)
97+
98+
return (
99+
<ConfirmDialog
100+
title="Remove team member"
101+
description="This user will lose access to all shared workspaces and pipelines."
102+
variant="destructive"
103+
confirmLabel="Remove"
104+
open={open}
105+
onOpenChange={setOpen}
106+
/>
107+
)
108+
},
109+
parameters: {
110+
zephyr: { testCaseId: "SW-T5159" },
111+
},
112+
play: async ({ canvasElement, step }) => {
113+
const body = within(canvasElement.ownerDocument.body)
114+
115+
await step("Dialog renders open without a trigger", async () => {
116+
expect(body.getByRole("dialog")).toBeInTheDocument()
117+
expect(body.getByText("Remove team member")).toBeInTheDocument()
118+
})
119+
120+
await step("No trigger button present in canvas", async () => {
121+
const canvas = within(canvasElement)
122+
expect(canvas.queryByRole("button", { name: "Remove team member" })).not.toBeInTheDocument()
123+
})
124+
},
125+
}
126+
127+
export const AsyncConfirm: Story = {
128+
render: () => {
129+
const [open, setOpen] = useState(false)
130+
const [loading, setLoading] = useState(false)
131+
132+
function handleConfirm() {
133+
setLoading(true)
134+
setTimeout(() => {
135+
setLoading(false)
136+
setOpen(false)
137+
}, 1500)
138+
}
139+
140+
return (
141+
<div>
142+
<Button variant="outline" onClick={() => setOpen(true)}>
143+
Delete dataset
144+
</Button>
145+
<ConfirmDialog
146+
title="Delete dataset"
147+
description="This dataset and all linked pipeline results will be permanently removed."
148+
variant="destructive"
149+
confirmLabel="Delete"
150+
open={open}
151+
onOpenChange={setOpen}
152+
onConfirm={handleConfirm}
153+
loading={loading}
154+
/>
155+
</div>
156+
)
157+
},
158+
parameters: {
159+
zephyr: { testCaseId: "SW-T5160" },
160+
},
161+
}
162+
163+
// Keep interaction-only stories out of docs/sidebar so dialogs do not flash
164+
// during manual Storybook browsing, while still preserving play-test coverage.
165+
export const DefaultInteractionTest: Story = {
166+
tags: ["!dev", "!autodocs"],
167+
args: defaultArgs,
168+
parameters: {
169+
zephyr: { testCaseId: "SW-T5161" },
170+
},
171+
play: async ({ canvasElement, step }) => {
172+
const canvas = within(canvasElement)
173+
const body = within(canvasElement.ownerDocument.body)
174+
175+
await step("Trigger button renders", async () => {
176+
expect(
177+
canvas.getByRole("button", { name: "Archive workspace" })
178+
).toBeInTheDocument()
179+
expect(body.queryByRole("dialog")).not.toBeInTheDocument()
180+
})
181+
182+
await step("Clicking trigger opens the dialog", async () => {
183+
await userEvent.click(
184+
canvas.getByRole("button", { name: "Archive workspace" })
185+
)
186+
const dialog = body.getByRole("dialog")
187+
expect(dialog).toBeInTheDocument()
188+
expect(
189+
within(dialog).getByRole("heading", { name: "Archive workspace" })
190+
).toBeInTheDocument()
191+
})
192+
193+
await step("Cancel and confirm buttons are present", async () => {
194+
expect(body.getByRole("button", { name: "Cancel" })).toBeInTheDocument()
195+
expect(
196+
body.getByRole("button", { name: "Archive" })
197+
).toBeInTheDocument()
198+
})
199+
200+
await step("Clicking cancel closes the dialog", async () => {
201+
await userEvent.click(body.getByRole("button", { name: "Cancel" }))
202+
await waitFor(() => {
203+
expect(body.queryByRole("dialog")).not.toBeInTheDocument()
204+
})
205+
})
206+
},
207+
}
208+
209+
export const DestructiveInteractionTest: Story = {
210+
tags: ["!dev", "!autodocs"],
211+
args: destructiveArgs,
212+
parameters: {
213+
zephyr: { testCaseId: "SW-T5162" },
214+
},
215+
play: async ({ canvasElement, step }) => {
216+
const canvas = within(canvasElement)
217+
const body = within(canvasElement.ownerDocument.body)
218+
219+
await step("Trigger opens destructive dialog", async () => {
220+
await userEvent.click(
221+
canvas.getByRole("button", { name: "Delete experiment" })
222+
)
223+
expect(body.getByRole("dialog")).toBeInTheDocument()
224+
})
225+
226+
await step("Warning callout renders in dialog body", async () => {
227+
expect(
228+
body.getByText("This action cannot be undone.")
229+
).toBeInTheDocument()
230+
})
231+
232+
await step("Destructive confirm button is present", async () => {
233+
expect(body.getByRole("button", { name: "Delete" })).toBeInTheDocument()
234+
})
235+
236+
await step("Close dialog for cleanup", async () => {
237+
await userEvent.click(body.getByRole("button", { name: "Cancel" }))
238+
await waitFor(() => {
239+
expect(body.queryByRole("dialog")).not.toBeInTheDocument()
240+
})
241+
})
242+
},
243+
}
244+
245+
export const AsyncConfirmInteractionTest: Story = {
246+
tags: ["!dev", "!autodocs"],
247+
render: AsyncConfirm.render,
248+
parameters: {
249+
zephyr: { testCaseId: "SW-T5163" },
250+
},
251+
play: async ({ canvasElement, step }) => {
252+
const canvas = within(canvasElement)
253+
const body = within(canvasElement.ownerDocument.body)
254+
255+
await step("Trigger button renders", async () => {
256+
expect(
257+
canvas.getByRole("button", { name: "Delete dataset" })
258+
).toBeInTheDocument()
259+
})
260+
261+
await step("Clicking trigger opens dialog", async () => {
262+
await userEvent.click(
263+
canvas.getByRole("button", { name: "Delete dataset" })
264+
)
265+
expect(body.getByRole("dialog")).toBeInTheDocument()
266+
})
267+
268+
await step("Cancel closes dialog", async () => {
269+
await userEvent.click(body.getByRole("button", { name: "Cancel" }))
270+
await waitFor(() => {
271+
expect(body.queryByRole("dialog")).not.toBeInTheDocument()
272+
})
273+
})
274+
},
275+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { TriangleAlertIcon } from "lucide-react"
2+
import * as React from "react"
3+
4+
import { Button } from "@/components/ui/button"
5+
import {
6+
Dialog,
7+
DialogClose,
8+
DialogContent,
9+
DialogDescription,
10+
DialogFooter,
11+
DialogHeader,
12+
DialogTitle,
13+
DialogTrigger,
14+
} from "@/components/ui/dialog"
15+
import { Spinner } from "@/components/ui/spinner"
16+
17+
interface ConfirmDialogBaseProps {
18+
title: string
19+
description?: string
20+
variant?: "default" | "destructive"
21+
confirmLabel?: string
22+
cancelLabel?: string
23+
onConfirm?: () => void
24+
onCancel?: () => void
25+
trigger?: React.ReactElement
26+
loading?: boolean
27+
}
28+
29+
type ConfirmDialogControlledProps = ConfirmDialogBaseProps & {
30+
open: boolean
31+
onOpenChange: (open: boolean) => void
32+
}
33+
34+
type ConfirmDialogUncontrolledProps = ConfirmDialogBaseProps & {
35+
open?: never
36+
onOpenChange?: never
37+
}
38+
39+
export type ConfirmDialogProps =
40+
| ConfirmDialogControlledProps
41+
| ConfirmDialogUncontrolledProps
42+
43+
export function ConfirmDialog({
44+
title,
45+
description,
46+
variant = "default",
47+
confirmLabel = "Confirm",
48+
cancelLabel = "Cancel",
49+
onConfirm,
50+
onCancel,
51+
open,
52+
onOpenChange,
53+
trigger,
54+
loading = false,
55+
}: ConfirmDialogProps) {
56+
return (
57+
<Dialog open={open} onOpenChange={onOpenChange}>
58+
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
59+
<DialogContent>
60+
<DialogHeader>
61+
<DialogTitle>{title}</DialogTitle>
62+
{description && (
63+
<DialogDescription>{description}</DialogDescription>
64+
)}
65+
</DialogHeader>
66+
{variant === "destructive" && (
67+
<div className="flex items-start gap-2 rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
68+
<TriangleAlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
69+
<span>This action cannot be undone.</span>
70+
</div>
71+
)}
72+
<DialogFooter>
73+
<DialogClose asChild>
74+
<Button variant="outline" onClick={onCancel}>
75+
{cancelLabel}
76+
</Button>
77+
</DialogClose>
78+
<Button
79+
variant={variant === "destructive" ? "destructive" : "default"}
80+
disabled={loading}
81+
onClick={onConfirm}
82+
>
83+
{loading && <Spinner size="sm" className="mr-2" />}
84+
{confirmLabel}
85+
</Button>
86+
</DialogFooter>
87+
</DialogContent>
88+
</Dialog>
89+
)
90+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./ConfirmDialog"

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * from "@/components/composed/FormPatterns";
66
export * from "@/components/composed/StatCard";
77
export * from "@/components/composed/DataAppShell";
88
export * from "@/components/composed/Chat";
9+
export * from "@/components/composed/ConfirmDialog"
910
export * from "@/components/composed/RichListItem";
1011
export * from "@/components/composed/EmptyState";
1112

0 commit comments

Comments
 (0)