From af95a221a13a2065a989ad9dbb6d03983fe11f13 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 7 Dec 2023 23:15:56 +0000 Subject: [PATCH 1/7] feat(site): improve bulk delete flow --- .../components/Resources/ResourceAvatar.tsx | 10 +- .../TemplateSchedulePage/ScheduleDialog.tsx | 11 +- .../src/pages/WorkspacesPage/BatchActions.tsx | 242 +++++++++++++----- site/static/icon/container.svg | 1 + site/static/icon/database.svg | 2 +- site/static/icon/memory.svg | 20 +- 6 files changed, 212 insertions(+), 74 deletions(-) create mode 100644 site/static/icon/container.svg 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 +70,189 @@ 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} + + ); + } + 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" && ( + + )} + } /> ); }; + +interface StageProps { + workspaces: Workspace[]; +} + +const Consequences: FC = () => { + return ( + <> +

Deleting workspaces is irreversible!

+
    +
  • All data will be permanently deleted.
  • +
  • + All resources belonging to these workspaces will be permanently + destroyed. +
  • +
  • All users will be disconnect and unable to retrieve
  • +
+ + ); +}; + +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((workspace) => workspace.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()} +
  • + ))} +
+ + + {/* This size doesn't match the rest of the icons + because MUI is just really inconsistent */} + + {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 these workspaces will also permanently destroy…

+ + {Object.entries(resources).map(([type, summary]) => ( + + + + {summary.count} {type} + + + ))} + +
+ ); +}; 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 @@ - + + + + + + + + + + + + + + + + + + + From e89e068f91f5c6e9f502f4bf7dcb1a2c27e68ffe Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 7 Dec 2023 23:37:17 +0000 Subject: [PATCH 2/7] reword for the singular --- site/src/pages/WorkspacesPage/BatchActions.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/site/src/pages/WorkspacesPage/BatchActions.tsx b/site/src/pages/WorkspacesPage/BatchActions.tsx index e6b8b79ea8ddc..b6975a125b12e 100644 --- a/site/src/pages/WorkspacesPage/BatchActions.tsx +++ b/site/src/pages/WorkspacesPage/BatchActions.tsx @@ -237,7 +237,11 @@ const Resources: FC = ({ workspaces }) => { return ( -

Deleting these workspaces will also permanently destroy…

+

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

Date: Fri, 8 Dec 2023 19:29:49 +0000 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/WorkspacesPage/BatchActions.tsx | 86 +++++++++++++++---- .../WorkspacesPage/BatchDelete.stories.tsx | 38 ++++++++ .../WorkspacesPageView.stories.tsx | 2 +- 3 files changed, 110 insertions(+), 16 deletions(-) create mode 100644 site/src/pages/WorkspacesPage/BatchDelete.stories.tsx diff --git a/site/src/pages/WorkspacesPage/BatchActions.tsx b/site/src/pages/WorkspacesPage/BatchActions.tsx index b6975a125b12e..4f5587b623855 100644 --- a/site/src/pages/WorkspacesPage/BatchActions.tsx +++ b/site/src/pages/WorkspacesPage/BatchActions.tsx @@ -1,6 +1,8 @@ import PersonOutlinedIcon from "@mui/icons-material/PersonOutlined"; import ScheduleIcon from "@mui/icons-material/Schedule"; import dayjs from "dayjs"; +import "dayjs/plugin/relativeTime"; +import { useTheme } from "@emotion/react"; import { type FC, type ReactNode, useState } from "react"; import { useMutation } from "react-query"; import { deleteWorkspace, startWorkspace, stopWorkspace } from "api/api"; @@ -155,25 +157,25 @@ const Consequences: FC = () => {

Deleting workspaces is irreversible!

    -
  • All data will be permanently deleted.
  • - All resources belonging to these workspaces will be permanently - destroyed. + Terraform resources belonging to deleted workspaces will be destroyed.
  • -
  • All users will be disconnect and unable to retrieve
  • +
  • Any data stored in the workspace permanently deleted.
); }; const Workspaces: FC = ({ workspaces }) => { + const theme = useTheme(); + const mostRecent = workspaces.reduce( (latestSoFar, against) => { if (!latestSoFar) { @@ -188,25 +190,79 @@ const Workspaces: FC = ({ workspaces }) => { undefined as Workspace | undefined, ); - const owners = new Set(workspaces.map((workspace) => workspace.owner_id)) - .size; + 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()} +
    • + + + {workspace.name} + + + + {/* This size doesn't match the rest of the icons because MUI is just really + inconsistent. We have to pull things in on the right to compensate. */} + + + {workspace.owner_name} + + + + + + {dayjs(workspace.last_used_at).fromNow()} + + + +
    • ))}
    - {/* This size doesn't match the rest of the icons - because MUI is just really inconsistent */} - + {/* This size doesn't match the rest of the icons because MUI is just really + inconsistent. We have to pull things in on the right to compensate. */} + {ownersCount} {mostRecent && ( 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/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index 03699c2779a5a..470149897d7f3 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -107,7 +107,7 @@ const mockTemplates = [ ]; const meta: Meta = { - title: "pages/WorkspacesPageView", + title: "pages/WorkspacesPage", component: WorkspacesPageView, args: { limit: DEFAULT_RECORDS_PER_PAGE, From 57187cc50ea6b54205aef2c05bb6938f6fcd3f77 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 8 Dec 2023 23:18:44 +0000 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/WorkspacesPage/BatchActions.tsx | 115 ++++++++++-------- 1 file changed, 63 insertions(+), 52 deletions(-) diff --git a/site/src/pages/WorkspacesPage/BatchActions.tsx b/site/src/pages/WorkspacesPage/BatchActions.tsx index 4f5587b623855..b16f790e47f45 100644 --- a/site/src/pages/WorkspacesPage/BatchActions.tsx +++ b/site/src/pages/WorkspacesPage/BatchActions.tsx @@ -1,8 +1,9 @@ import PersonOutlinedIcon from "@mui/icons-material/PersonOutlined"; import ScheduleIcon from "@mui/icons-material/Schedule"; +import { visuallyHidden } from "@mui/utils"; import dayjs from "dayjs"; import "dayjs/plugin/relativeTime"; -import { useTheme } from "@emotion/react"; +import { type Interpolation, type Theme } from "@emotion/react"; import { type FC, type ReactNode, useState } from "react"; import { useMutation } from "react-query"; import { deleteWorkspace, startWorkspace, stopWorkspace } from "api/api"; @@ -119,6 +120,20 @@ export const BatchDeleteConfirmation: FC = ({ ); } + // 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 ( = ({ {stage === "resources" && ( )} + {resourceIconPreloads} } /> @@ -155,27 +171,17 @@ const Consequences: FC = () => { return ( <>

    Deleting workspaces is irreversible!

    -
      +
      • Terraform resources belonging to deleted workspaces will be destroyed.
      • -
      • Any data stored in the workspace permanently deleted.
      • +
      • Any data stored in the workspace will be permanently deleted.
      ); }; const Workspaces: FC = ({ workspaces }) => { - const theme = useTheme(); - const mostRecent = workspaces.reduce( (latestSoFar, against) => { if (!latestSoFar) { @@ -195,28 +201,9 @@ const Workspaces: FC = ({ workspaces }) => { return ( <> -
        +
          {workspaces.map((workspace) => ( -
        • +
        • = ({ workspaces }) => { - {/* This size doesn't match the rest of the icons because MUI is just really - inconsistent. We have to pull things in on the right to compensate. */} - + @@ -244,7 +222,7 @@ const Workspaces: FC = ({ workspaces }) => { - + @@ -258,16 +236,12 @@ const Workspaces: FC = ({ workspaces }) => {
        - {/* This size doesn't match the rest of the icons because MUI is just really - inconsistent. We have to pull things in on the right to compensate. */} - + {ownersCount} {mostRecent && ( - + Last used {dayjs(mostRecent.last_used_at).fromNow()} )} @@ -306,7 +280,7 @@ const Resources: FC = ({ workspaces }) => { > {Object.entries(resources).map(([type, summary]) => ( - + {summary.count} {type} @@ -316,3 +290,40 @@ const Resources: FC = ({ workspaces }) => { ); }; + +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>; From 66df10f5f2056003c563da0e52ee30b1639dcb3a Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 8 Dec 2023 23:25:56 +0000 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=92=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/pages/WorkspacesPage/BatchActions.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/site/src/pages/WorkspacesPage/BatchActions.tsx b/site/src/pages/WorkspacesPage/BatchActions.tsx index b16f790e47f45..24f304cccdcf1 100644 --- a/site/src/pages/WorkspacesPage/BatchActions.tsx +++ b/site/src/pages/WorkspacesPage/BatchActions.tsx @@ -131,7 +131,7 @@ export const BatchDeleteConfirmation: FC = ({ ), ), ].map((url) => ( - + )); return ( @@ -203,7 +203,7 @@ const Workspaces: FC = ({ workspaces }) => { <>
          {workspaces.map((workspace) => ( -
        • +
        • = ({ workspaces }) => { css={{ gap: "6px 20px", fontSize: 14 }} > {Object.entries(resources).map(([type, summary]) => ( - + {summary.count} {type} From ceca8a0e9e66978b78f4a75e475fa300b8b4a5bd Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 11 Dec 2023 17:32:23 +0000 Subject: [PATCH 6/7] fix test --- site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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); From 6a986f1780824c4af6a2942413e7f22da9b8dee8 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 11 Dec 2023 17:40:22 +0000 Subject: [PATCH 7/7] update icons.json --- site/src/theme/icons.json | 1 + 1 file changed, 1 insertion(+) 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",