diff --git a/site/src/components/Resources/ResourceAvatar.tsx b/site/src/components/Resources/ResourceAvatar.tsx index 8e84f6487159f..b74a75b754c80 100644 --- a/site/src/components/Resources/ResourceAvatar.tsx +++ b/site/src/components/Resources/ResourceAvatar.tsx @@ -10,19 +10,19 @@ const FALLBACK_ICON = "/icon/widgets.svg"; const BUILT_IN_ICON_PATHS: { [resourceType: WorkspaceResource["type"]]: string; } = { - docker_volume: "/icon/folder.svg", + docker_volume: "/icon/database.svg", docker_container: "/icon/memory.svg", - docker_image: "/icon/image.svg", - kubernetes_persistent_volume_claim: "/icon/folder.svg", + docker_image: "/icon/container.svg", + kubernetes_persistent_volume_claim: "/icon/database.svg", kubernetes_pod: "/icon/memory.svg", - google_compute_disk: "/icon/folder.svg", + google_compute_disk: "/icon/database.svg", google_compute_instance: "/icon/memory.svg", aws_instance: "/icon/memory.svg", kubernetes_deployment: "/icon/memory.svg", null_resource: FALLBACK_ICON, }; -const getIconPathResource = (resourceType: string): string => { +export const getIconPathResource = (resourceType: string): string => { if (resourceType in BUILT_IN_ICON_PATHS) { return BUILT_IN_ICON_PATHS[resourceType]; } diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/ScheduleDialog.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/ScheduleDialog.tsx index 4eeb687a0d8b1..40224a7e8b60b 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/ScheduleDialog.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/ScheduleDialog.tsx @@ -66,10 +66,15 @@ export const ScheduleDialog: FC> = ({ <> {showDormancyWarning && ( <> -

{"Dormancy Threshold"}

+

Dormancy Threshold

-
{` - This change will result in ${inactiveWorkspacesToGoDormant} workspaces being immediately transitioned to the dormant state and ${inactiveWorkspacesToGoDormantInWeek} over the next seven days. To prevent this, do you want to reset the inactivity period for all template workspaces?`}
+
+ This change will result in {inactiveWorkspacesToGoDormant}{" "} + workspaces being immediately transitioned to the dormant state + and {inactiveWorkspacesToGoDormantInWeek} over the next seven + days. To prevent this, do you want to reset the inactivity + period for all template workspaces? +
Promise; @@ -68,77 +73,257 @@ type BatchDeleteConfirmationProps = { onConfirm: () => void; }; -export const BatchDeleteConfirmation: FC = ( - props, -) => { - const { checkedWorkspaces, open, onClose, onConfirm, isLoading } = props; - const theme = useTheme(); - const [confirmation, setConfirmation] = useState({ value: "", error: false }); - - const confirmDeletion = () => { - setConfirmation((c) => ({ ...c, error: false })); +export const BatchDeleteConfirmation: FC = ({ + checkedWorkspaces, + open, + onClose, + onConfirm, + isLoading, +}) => { + const [stage, setStage] = useState< + "consequences" | "workspaces" | "resources" + >("consequences"); - if (confirmation.value !== "DELETE") { - setConfirmation((c) => ({ ...c, error: true })); - return; + const onProceed = () => { + switch (stage) { + case "resources": + onConfirm(); + break; + case "workspaces": + setStage("resources"); + break; + case "consequences": + setStage("workspaces"); + break; } - - onConfirm(); }; + const workspaceCount = `${checkedWorkspaces.length} ${ + checkedWorkspaces.length === 1 ? "workspace" : "workspaces" + }`; + + let confirmText: ReactNode = <>Review selected workspaces…; + if (stage === "workspaces") { + confirmText = <>Confirm {workspaceCount}…; + } + if (stage === "resources") { + const resources = checkedWorkspaces + .map((workspace) => workspace.latest_build.resources.length) + .reduce((a, b) => a + b, 0); + const resourceCount = `${resources} ${ + resources === 1 ? "resource" : "resources" + }`; + confirmText = ( + <> + Delete {workspaceCount} and {resourceCount} + + ); + } + + // The flicker of these icons is quit noticeable if they aren't loaded in advance, + // so we insert them into the document without actually displaying them yet. + const resourceIconPreloads = [ + ...new Set( + checkedWorkspaces.flatMap((workspace) => + workspace.latest_build.resources.map( + (resource) => resource.icon || getIconPathResource(resource.type), + ), + ), + ), + ].map((url) => ( + + )); + return ( { + setStage("consequences"); onClose(); - setConfirmation({ value: "", error: false }); }} - title={`Delete ${checkedWorkspaces?.length} ${ - checkedWorkspaces.length === 1 ? "workspace" : "workspaces" - }`} + title={`Delete ${workspaceCount}`} + hideCancel + confirmLoading={isLoading} + confirmText={confirmText} + onConfirm={onProceed} + type="delete" description={ -
{ - e.preventDefault(); - confirmDeletion(); - }} - > -
- Deleting these workspaces is irreversible! Are you sure you want to - proceed? Type{" "} - - `DELETE` - {" "} - to confirm. -
- { - const value = e.currentTarget?.value; - setConfirmation((c) => ({ ...c, value })); - }} - error={confirmation.error} - helperText={confirmation.error && "Please type DELETE to confirm"} - /> - + <> + {stage === "consequences" && } + {stage === "workspaces" && ( + + )} + {stage === "resources" && ( + + )} + {resourceIconPreloads} + } /> ); }; + +interface StageProps { + workspaces: Workspace[]; +} + +const Consequences: FC = () => { + return ( + <> +

Deleting workspaces is irreversible!

+
    +
  • + Terraform resources belonging to deleted workspaces will be destroyed. +
  • +
  • Any data stored in the workspace will be permanently deleted.
  • +
+ + ); +}; + +const Workspaces: FC = ({ workspaces }) => { + const mostRecent = workspaces.reduce( + (latestSoFar, against) => { + if (!latestSoFar) { + return against; + } + + return new Date(against.last_used_at).getTime() > + new Date(latestSoFar.last_used_at).getTime() + ? against + : latestSoFar; + }, + undefined as Workspace | undefined, + ); + + const owners = new Set(workspaces.map((it) => it.owner_id)).size; + const ownersCount = `${owners} ${owners === 1 ? "owner" : "owners"}`; + + return ( + <> +
    + {workspaces.map((workspace) => ( +
  • + + + {workspace.name} + + + + + + {workspace.owner_name} + + + + + + {dayjs(workspace.last_used_at).fromNow()} + + + + +
  • + ))} +
+ + + + {ownersCount} + + {mostRecent && ( + + + Last used {dayjs(mostRecent.last_used_at).fromNow()} + + )} + + + ); +}; + +const Resources: FC = ({ workspaces }) => { + const resources: Record = {}; + workspaces.forEach((workspace) => + workspace.latest_build.resources.forEach((resource) => { + if (!resources[resource.type]) { + resources[resource.type] = { + count: 0, + icon: resource.icon || getIconPathResource(resource.type), + }; + } + + resources[resource.type].count++; + }), + ); + + return ( + +

+ Deleting{" "} + {workspaces.length === 1 ? "this workspace" : "these workspaces"} will + also permanently destroy… +

+ + {Object.entries(resources).map(([type, summary]) => ( + + + + {summary.count} {type} + + + ))} + +
+ ); +}; + +const PersonIcon: FC = () => { + // This size doesn't match the rest of the icons because MUI is just really + // inconsistent. We have to make it bigger than the rest, and pull things in + // on the sides to compensate. + return ; +}; + +const styles = { + summaryIcon: { width: 16, height: 16 }, + + consequences: { + display: "flex", + flexDirection: "column", + gap: 8, + paddingLeft: 16, + marginBottom: 0, + }, + + workspacesList: (theme) => ({ + listStyleType: "none", + padding: 0, + border: `1px solid ${theme.palette.divider}`, + borderRadius: 8, + overflow: "hidden auto", + maxHeight: 184, + }), + + workspace: (theme) => ({ + padding: "8px 16px", + borderBottom: `1px solid ${theme.palette.divider}`, + + "&:last-child": { + border: "none", + }, + }), +} satisfies Record>; diff --git a/site/src/pages/WorkspacesPage/BatchDelete.stories.tsx b/site/src/pages/WorkspacesPage/BatchDelete.stories.tsx new file mode 100644 index 0000000000000..cd5f89762de2c --- /dev/null +++ b/site/src/pages/WorkspacesPage/BatchDelete.stories.tsx @@ -0,0 +1,38 @@ +import { action } from "@storybook/addon-actions"; +import type { Meta, StoryObj } from "@storybook/react"; +import { MockWorkspace, MockUser2 } from "testHelpers/entities"; +import { BatchDeleteConfirmation } from "./BatchActions"; + +const meta: Meta = { + title: "pages/WorkspacesPage/BatchDelete", + component: BatchDeleteConfirmation, + args: { + onClose: action("onClose"), + onConfirm: action("onConfirm"), + open: true, + checkedWorkspaces: [ + MockWorkspace, + { + ...MockWorkspace, + name: "Test-Workspace-2", + last_used_at: "2023-08-16T15:29:10.302441433Z", + owner_id: MockUser2.id, + owner_name: MockUser2.username, + }, + { + ...MockWorkspace, + name: "Test-Workspace-3", + last_used_at: "2023-11-16T15:29:10.302441433Z", + owner_id: MockUser2.id, + owner_name: MockUser2.username, + }, + ], + }, +}; + +export default meta; +type Story = StoryObj; + +const Example: Story = {}; + +export { Example as BatchDelete }; diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index 50451e18b50a3..0bc0ca1ec1233 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -63,11 +63,17 @@ describe("WorkspacesPage", () => { await user.click(getWorkspaceCheckbox(workspaces[0])); await user.click(getWorkspaceCheckbox(workspaces[1])); + await user.click(screen.getByRole("button", { name: /actions/i })); const deleteButton = await screen.findByText(/delete/i); await user.click(deleteButton); - await user.type(screen.getByLabelText(/type delete to confirm/i), "DELETE"); - await user.click(screen.getByTestId("confirm-button")); + + // The button changes its text, and advances the content of the modal, + // but it is technically the same button being clicked 3 times. + const confirmButton = await screen.findByTestId("confirm-button"); + await user.click(confirmButton); + await user.click(confirmButton); + await user.click(confirmButton); await waitFor(() => { expect(deleteWorkspace).toHaveBeenCalledTimes(2); diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index 1598d56e9749e..ea71dc8d0bf96 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -127,7 +127,7 @@ const mockTemplates = [ ]; const meta: Meta = { - title: "pages/WorkspacesPageView", + title: "pages/WorkspacesPage", component: WorkspacesPageView, args: { limit: DEFAULT_RECORDS_PER_PAGE, diff --git a/site/src/theme/icons.json b/site/src/theme/icons.json index fe8011d7e8685..285ed2b168d60 100644 --- a/site/src/theme/icons.json +++ b/site/src/theme/icons.json @@ -12,6 +12,7 @@ "code.svg", "coder.svg", "conda.svg", + "container.svg", "database.svg", "datagrip.svg", "dataspell.svg", diff --git a/site/static/icon/container.svg b/site/static/icon/container.svg new file mode 100644 index 0000000000000..448320f26ed11 --- /dev/null +++ b/site/static/icon/container.svg @@ -0,0 +1 @@ + diff --git a/site/static/icon/database.svg b/site/static/icon/database.svg index f17b64163fb50..460ff13073671 100644 --- a/site/static/icon/database.svg +++ b/site/static/icon/database.svg @@ -1 +1 @@ - + diff --git a/site/static/icon/memory.svg b/site/static/icon/memory.svg index 7e13668312667..24981efb30aff 100644 --- a/site/static/icon/memory.svg +++ b/site/static/icon/memory.svg @@ -1 +1,19 @@ - + + + + + + + + + + + + + + + + + + +