From 156ed3684a425c639908bef952cf309bb4961b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kayla=20=E3=81=AF=E3=81=AA?= Date: Fri, 8 May 2026 23:46:04 +0000 Subject: [PATCH 1/4] add new `DeleteDialog` component --- .../Dialog/DeleteDialog.stories.tsx | 102 ++++++++++++ site/src/components/Dialog/DeleteDialog.tsx | 155 ++++++++++++++++++ site/src/components/Dialog/Dialog.stories.tsx | 2 +- site/src/components/Dialog/Dialog.tsx | 6 +- site/src/utils/time.ts | 2 +- 5 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 site/src/components/Dialog/DeleteDialog.stories.tsx create mode 100644 site/src/components/Dialog/DeleteDialog.tsx diff --git a/site/src/components/Dialog/DeleteDialog.stories.tsx b/site/src/components/Dialog/DeleteDialog.stories.tsx new file mode 100644 index 0000000000000..b8176f59199d2 --- /dev/null +++ b/site/src/components/Dialog/DeleteDialog.stories.tsx @@ -0,0 +1,102 @@ +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 = { + 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; + +export const Example: Story = {}; + +export const WithTimeAndOwner: Story = { + args: { + resourceLastUsed: dayjs().subtract(5, "minutes"), + resourceOwnedBy: "Pumpkaboo", + }, +}; + +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(); + }, +}; diff --git a/site/src/components/Dialog/DeleteDialog.tsx b/site/src/components/Dialog/DeleteDialog.tsx new file mode 100644 index 0000000000000..8035815416004 --- /dev/null +++ b/site/src/components/Dialog/DeleteDialog.tsx @@ -0,0 +1,155 @@ +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 = ({ + open, + description, + requireAcknowledgingName, + additionalInfo, + + resourceKind, + resourceName, + resourceLastUsed, + resourceOwnedBy, + + deleteAction = "Delete", + deleteLoading, + onDelete, + + onCancel, +}) => { + const [acknowledged, setAcknowledged] = useState(false); + + const includeSubtitle = resourceLastUsed || resourceOwnedBy; + + return ( + { + if (!nextOpen) { + onCancel(); + } + }} + > + + + Delete {resourceName} + {includeSubtitle && ( +
+ {resourceLastUsed && ( +
+ + last used {relativeTime(resourceLastUsed)} +
+ )} + {resourceOwnedBy && ( +
+ + owned by {resourceOwnedBy} +
+ )} +
+ )} +
+
+
+ {description} + {Boolean(additionalInfo) && ( + + {additionalInfo} + + )} +
+ {requireAcknowledgingName && ( + + )} + + + + +
+
+ ); +}; + +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 ( +
+ + { + 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 && ( + + Please enter the name of the {resourceKind} + + )} +
+ ); +}; diff --git a/site/src/components/Dialog/Dialog.stories.tsx b/site/src/components/Dialog/Dialog.stories.tsx index b0f732bf643f3..f45aa4694ddc2 100644 --- a/site/src/components/Dialog/Dialog.stories.tsx +++ b/site/src/components/Dialog/Dialog.stories.tsx @@ -26,7 +26,7 @@ const meta: Meta = { Dialog Description text - + diff --git a/site/src/components/Dialog/Dialog.tsx b/site/src/components/Dialog/Dialog.tsx index d1cbfeb10b814..20cf0e091c513 100644 --- a/site/src/components/Dialog/Dialog.tsx +++ b/site/src/components/Dialog/Dialog.tsx @@ -110,7 +110,7 @@ export const DialogFooter: React.FC> = ({ 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 */ @@ -130,7 +130,7 @@ type DialogActionsProps = { * Quickly handles most modals actions, some combination of a cancel and confirm button */ export const DialogActions: React.FC = ({ - confirmText = "Confirm", + confirmAction = "Confirm", confirmLoading = false, confirmDisabled = false, confirmVariant, @@ -163,7 +163,7 @@ export const DialogActions: React.FC = ({ type="submit" > - {confirmText} + {confirmAction} )} diff --git a/site/src/utils/time.ts b/site/src/utils/time.ts index faa13a20e6403..9ba7a963f75f2 100644 --- a/site/src/utils/time.ts +++ b/site/src/utils/time.ts @@ -24,7 +24,7 @@ const TIME_CONSTANTS = { }; export type TimeUnit = "days" | "hours"; -type DateTimeInput = Date | string | number | Dayjs | null | undefined; +export type DateTimeInput = Date | string | number | Dayjs | null | undefined; // Standard format strings // https://day.js.org/docs/en/display/format From fcc121f1c3a585c584d67a79c07f817ac5709801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kayla=20=E3=81=AF=E3=81=AA?= Date: Sat, 9 May 2026 00:37:34 +0000 Subject: [PATCH 2/4] convert a few usages --- site/src/pages/UsersPage/UsersPage.tsx | 72 +++++++++++-------- .../pages/WorkspacesPage/WorkspacesTable.tsx | 9 ++- 2 files changed, 50 insertions(+), 31 deletions(-) diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 3e70e8f01fd83..812bb4f8f57e0 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -16,7 +16,7 @@ import { } from "#/api/queries/users"; import type { User } from "#/api/typesGenerated"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; -import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog"; +import { DeleteDialog } from "#/components/Dialog/DeleteDialog"; import { useFilter } from "#/components/Filter/Filter"; import { useStatusFilterMenu } from "#/components/Filter/UsersFilter"; import { useAuthenticated } from "#/hooks/useAuthenticated"; @@ -157,36 +157,50 @@ const UsersPage: React.FC = () => { isUpdatingRoles={updateUserRolesMutation.isPending} /> - setUserToDelete(undefined)} - onConfirm={async () => { - if (!userToDelete) { - return; + {userToDelete && ( + + {userToDelete.username} will immediately lose + access once they have been deleted. + } - try { - await deleteUserMutation.mutateAsync(userToDelete.id); - setUserToDelete(undefined); - toast.success( - `User "${userToDelete.username}" deleted successfully.`, - ); - } catch (e) { - toast.error( - getErrorMessage( - e, - `Error deleting user "${userToDelete.username}".`, - ), - { - description: getErrorDetail(e), - }, - ); + additionalInfo={ + <>To delete a user, they must not have any workspaces. } - }} - /> + resourceKind="user" + resourceName={userToDelete.name ?? userToDelete.username} + deleteAction={ + <>Delete {userToDelete.name ?? userToDelete.username} + } + onDelete={async () => { + if (!userToDelete) { + return; + } + try { + await deleteUserMutation.mutateAsync(userToDelete.id); + setUserToDelete(undefined); + toast.success( + `User "${userToDelete.username}" deleted successfully.`, + ); + } catch (e) { + toast.error( + getErrorMessage( + e, + `Error deleting user "${userToDelete.username}".`, + ), + { + description: getErrorDetail(e), + }, + ); + } + }} + deleteLoading={deleteUserMutation.isPending} + onCancel={() => setUserToDelete(undefined)} + /> + )} = ({ + Are you sure you want to stop the workspace "{workspace.name}"? This + will terminate all running processes and disconnect any active + sessions. + + } confirmText="Stop" onClose={() => setIsStopConfirmOpen(false)} onConfirm={() => { stopWorkspaceMutation.mutate({}); setIsStopConfirmOpen(false); }} - type="delete" /> Date: Sat, 9 May 2026 00:43:03 +0000 Subject: [PATCH 3/4] tweaks --- site/src/components/Dialog/DeleteDialog.stories.tsx | 12 ++++++++++++ site/src/components/Dialog/DeleteDialog.tsx | 4 +++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/site/src/components/Dialog/DeleteDialog.stories.tsx b/site/src/components/Dialog/DeleteDialog.stories.tsx index b8176f59199d2..d2a6c86ba5e62 100644 --- a/site/src/components/Dialog/DeleteDialog.stories.tsx +++ b/site/src/components/Dialog/DeleteDialog.stories.tsx @@ -37,6 +37,18 @@ export const WithTimeAndOwner: Story = { }, }; +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, diff --git a/site/src/components/Dialog/DeleteDialog.tsx b/site/src/components/Dialog/DeleteDialog.tsx index 8035815416004..e9c5fb008778a 100644 --- a/site/src/components/Dialog/DeleteDialog.tsx +++ b/site/src/components/Dialog/DeleteDialog.tsx @@ -64,7 +64,9 @@ export const DeleteDialog: React.FC = ({ > - Delete {resourceName} + + {deleteAction} {resourceName}? + {includeSubtitle && (
{resourceLastUsed && ( From 45447b8b19251fbb0b05481b8f2650102f2d19f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kayla=20=E3=81=AF=E3=81=AA?= Date: Sat, 9 May 2026 01:01:32 +0000 Subject: [PATCH 4/4] Update UsersPage.tsx --- site/src/pages/UsersPage/UsersPage.tsx | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 812bb4f8f57e0..cd7baa2380d2b 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -163,18 +163,24 @@ const UsersPage: React.FC = () => { open description={ <> - {userToDelete.username} will immediately lose - access once they have been deleted. + {userToDelete.name ?? userToDelete.username} will + immediately lose access once + {userToDelete.is_service_account ? " it has " : " they have "} + been deleted. } additionalInfo={ - <>To delete a user, they must not have any workspaces. + <> + To delete a + {userToDelete.is_service_account + ? " service account, it " + : " user, they "} + must not have any workspaces. + } resourceKind="user" - resourceName={userToDelete.name ?? userToDelete.username} - deleteAction={ - <>Delete {userToDelete.name ?? userToDelete.username} - } + resourceName={userToDelete.username} + deleteAction="Delete" onDelete={async () => { if (!userToDelete) { return; @@ -182,9 +188,7 @@ const UsersPage: React.FC = () => { try { await deleteUserMutation.mutateAsync(userToDelete.id); setUserToDelete(undefined); - toast.success( - `User "${userToDelete.username}" deleted successfully.`, - ); + toast.success(`${userToDelete.username} has been deleted.`); } catch (e) { toast.error( getErrorMessage( @@ -234,7 +238,7 @@ const UsersPage: React.FC = () => { }} description={ <> - Do you want to suspend the user{" "} + Do you want to suspend{" "} {userToSuspend?.username ?? ""}? }