Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions site/src/components/Dialog/DeleteDialog.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { DeleteDialog } from "./DeleteDialog";
import dayjs from "dayjs";

const meta: Meta<typeof DeleteDialog> = {
title: "components/Dialog/DeleteDialog",
component: DeleteDialog,
args: {
open: true,
description: (
<>
Deleting this workspace will permanently destroy all of its Terraform
resources.
</>
),

resourceKind: "workspace",
resourceName: "coffee-tahr-38",

onDelete: fn(),

onCancel: fn(),
},
};

export default meta;

type Story = StoryObj<typeof DeleteDialog>;

export const Example: Story = {};

export const WithTimeAndOwner: Story = {
args: {
resourceLastUsed: dayjs().subtract(5, "minutes"),
resourceOwnedBy: "Pumpkaboo",
},
};

export const Archive: Story = {
args: {
description: (
<>
Archiving this workspace will permanently destroy all of its Terraform
resources.
</>
),
deleteAction: "Archive",
},
};

export const Deleting: Story = {
args: {
deleteLoading: true,
},
};

export const AdditionalInfo: Story = {
args: {
additionalInfo: "Oh hell yeah",
},
};

export const WithNameInput: Story = {
args: {
requireAcknowledgingName: true,
},
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const body = within(canvasElement.ownerDocument.body);
const confirmButton = await body.findByRole("button", { name: "Delete" });
await expect(confirmButton).toBeDisabled();
const input = await body.findByLabelText(
`Confirm the name of the workspace`,
);
await user.type(input, "coffee-tahr-38");
await expect(confirmButton).toBeEnabled();
},
};

export const AdditionalInfoWithNameInput: Story = {
args: {
requireAcknowledgingName: true,
additionalInfo: "Oh hell yeah",
},
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const body = within(canvasElement.ownerDocument.body);
const confirmButton = await body.findByRole("button", { name: "Delete" });
await expect(confirmButton).toBeDisabled();
const input = await body.findByLabelText(
`Confirm the name of the workspace`,
);
await user.type(input, "coffee-tahr-38");
await expect(confirmButton).toBeEnabled();
},
};

export const WithNameInputFilledWrong: Story = {
args: {
requireAcknowledgingName: true,
},
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const body = within(canvasElement.ownerDocument.body);
const confirmButton = await body.findByRole("button", { name: "Delete" });
await expect(confirmButton).toBeDisabled();
const input = await body.findByLabelText(
`Confirm the name of the workspace`,
);
await user.type(input, "tahr-coffee-83");
await expect(confirmButton).toBeEnabled();
},
};
157 changes: 157 additions & 0 deletions site/src/components/Dialog/DeleteDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { TimerIcon, UserIcon } from "lucide-react";
import { useId, useState } from "react";
import { Alert } from "#/components/Alert/Alert";
import { Input } from "#/components/Input/Input";
import { Label } from "#/components/Label/Label";
import {
Dialog,
DialogActions,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./Dialog";
import { type DateTimeInput, relativeTime } from "#/utils/time";

type DeleteDialogProps = {
open: boolean;
description: React.ReactNode;
requireAcknowledgingName?: boolean;
additionalInfo?: React.ReactNode;

resourceKind: string;
resourceName: string;
resourceLastUsed?: DateTimeInput;
resourceOwnedBy?: string;

deleteAction: React.ReactNode;
deleteLoading?: boolean;
onDelete: () => void;

onCancel: () => void;
};

export const DeleteDialog: React.FC<DeleteDialogProps> = ({
open,
description,
requireAcknowledgingName,
additionalInfo,

resourceKind,
resourceName,
resourceLastUsed,
resourceOwnedBy,

deleteAction = "Delete",
deleteLoading,
onDelete,

onCancel,
}) => {
const [acknowledged, setAcknowledged] = useState(false);

const includeSubtitle = resourceLastUsed || resourceOwnedBy;

return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
onCancel();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{deleteAction} {resourceName}?
</DialogTitle>
{includeSubtitle && (
<div className="flex flex-row gap-3 text-sm">
{resourceLastUsed && (
<div className="flex flex-row items-center gap-1">
<TimerIcon className="size-4" />
<span>last used {relativeTime(resourceLastUsed)}</span>
</div>
)}
{resourceOwnedBy && (
<div className="flex flex-row items-center gap-1">
<UserIcon className="size-4" />
<span>owned by {resourceOwnedBy}</span>
</div>
)}
</div>
)}
</DialogHeader>
<form action={onDelete} className="flex flex-col gap-6 [&_*]:m-0">
<div className="flex flex-col gap-3">
<DialogDescription>{description}</DialogDescription>
{Boolean(additionalInfo) && (
<Alert severity="warning" prominent>
{additionalInfo}
</Alert>
)}
</div>
{requireAcknowledgingName && (
<DeleteDialogAcknowledgmentInput
resourceName={resourceName}
resourceKind={resourceKind}
setAcknowledged={setAcknowledged}
/>
)}
<DialogFooter>
<DialogActions
confirmAction={deleteAction}
confirmLoading={deleteLoading}
confirmDisabled={requireAcknowledgingName && !acknowledged}
confirmVariant="destructive"
onConfirm={onDelete}
onCancel={onCancel}
/>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

type DeleteDialogAcknowledgmentInputProps = {
resourceName: string;
resourceKind: string;
setAcknowledged: (acknowledged: boolean) => void;
};

const DeleteDialogAcknowledgmentInput: React.FC<
DeleteDialogAcknowledgmentInputProps
> = ({ resourceName, resourceKind, setAcknowledged }) => {
const inputId = useId();
const errorId = useId();
const [showError, setShowError] = useState(false);

return (
<div className="flex flex-col gap-3">
<Label htmlFor={inputId}>Confirm the name of the {resourceKind}</Label>
<Input
id={inputId}
onChange={(event) => {
const inputValue = event.target.value;
setAcknowledged(inputValue === resourceName);
}}
onFocus={() => setShowError(false)}
onBlur={(event) => {
const inputValue = event.target.value;
setShowError(inputValue.length > 0 && inputValue !== resourceName);
}}
aria-invalid={showError}
aria-describedby={showError ? errorId : undefined}
autoFocus
/>
{showError && (
<Alert severity="error">
Please enter the name of the {resourceKind}
</Alert>
)}
</div>
);
};
2 changes: 1 addition & 1 deletion site/src/components/Dialog/Dialog.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const meta: Meta<typeof Dialog> = {
<DialogDescription>Dialog Description text</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button>Ok</Button>
<Button>OK</Button>
</DialogFooter>
</DialogContent>
</>
Expand Down
6 changes: 3 additions & 3 deletions site/src/components/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export const DialogFooter: React.FC<React.ComponentPropsWithRef<"div">> = ({

type DialogActionsProps = {
/** Text to display in the confirm button */
confirmText?: React.ReactNode;
confirmAction?: React.ReactNode;
/** Whether or not confirm is loading, also disables cancel when true */
confirmLoading?: boolean;
/** Whether or not the submit button is disabled */
Expand All @@ -130,7 +130,7 @@ type DialogActionsProps = {
* Quickly handles most modals actions, some combination of a cancel and confirm button
*/
export const DialogActions: React.FC<DialogActionsProps> = ({
confirmText = "Confirm",
confirmAction = "Confirm",
confirmLoading = false,
confirmDisabled = false,
confirmVariant,
Expand Down Expand Up @@ -163,7 +163,7 @@ export const DialogActions: React.FC<DialogActionsProps> = ({
type="submit"
>
<Spinner loading={confirmLoading} />
{confirmText}
{confirmAction}
</Button>
)}
</>
Expand Down
Loading
Loading