From 039d0baf7fd6ecc88948db92f4ac55f86e1d06bb Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 10 Aug 2023 14:28:24 +0000 Subject: [PATCH 01/13] Workspace batch actions --- .../pages/WorkspacesPage/WorkspacesPage.tsx | 25 +++- .../WorkspacesPage/WorkspacesPageView.tsx | 54 +++++++- .../pages/WorkspacesPage/WorkspacesTable.tsx | 115 +++++++++++++----- 3 files changed, 158 insertions(+), 36 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 6194f1fc4add8..4e5ecf004bc22 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -12,6 +12,7 @@ import { useSearchParams } from "react-router-dom" import { useFilter } from "components/Filter/filter" import { useUserFilterMenu } from "components/Filter/UserFilter" import { getWorkspaces } from "api/api" +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" const WorkspacesPage: FC = () => { const [lockedWorkspaces, setLockedWorkspaces] = useState([]) @@ -55,8 +56,9 @@ const WorkspacesPage: FC = () => { setLockedWorkspaces([]) } }, [experimentEnabled, data, filterProps.filter.query]) - const updateWorkspace = useWorkspaceUpdate(queryKey) + const [checkedWorkspaces, setCheckedWorkspaces] = useState([]) + const [isDeletingAll, setIsDeletingAll] = useState(false) return ( <> @@ -65,6 +67,8 @@ const WorkspacesPage: FC = () => { { onUpdateWorkspace={(workspace) => { updateWorkspace.mutate(workspace) }} + onDeleteAll={() => { + setIsDeletingAll(true) + }} + /> + + { + alert("DO IT!") + }} + onClose={() => { + setIsDeletingAll(false) + }} /> ) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index ea1139e72deac..32bdbe210e96b 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -19,6 +19,9 @@ import { ErrorAlert } from "components/Alert/ErrorAlert" import { WorkspacesFilter } from "./filter/filter" import { hasError, isApiValidationError } from "api/errors" import { PaginationStatus } from "components/PaginationStatus/PaginationStatus" +import Box from "@mui/material/Box" +import Button from "@mui/material/Button" +import DeleteOutlined from "@mui/icons-material/DeleteOutlined" export const Language = { pageTitle: "Workspaces", @@ -33,12 +36,15 @@ export interface WorkspacesPageViewProps { error: unknown workspaces?: Workspace[] lockedWorkspaces?: Workspace[] + checkedWorkspaces: Workspace[] count?: number filterProps: ComponentProps page: number limit: number onPageChange: (page: number) => void onUpdateWorkspace: (workspace: Workspace) => void + onCheckChange: (checkedWorkspaces: Workspace[]) => void + onDeleteAll: () => void } export const WorkspacesPageView: FC< @@ -53,6 +59,9 @@ export const WorkspacesPageView: FC< onPageChange, onUpdateWorkspace, page, + checkedWorkspaces, + onCheckChange, + onDeleteAll, }) => { const { saveLocal } = useLocalStorage() @@ -102,17 +111,50 @@ export const WorkspacesPageView: FC< - + {checkedWorkspaces.length > 0 ? ( + theme.palette.text.secondary, + "& strong": { color: (theme) => theme.palette.text.primary }, + }} + > + + Selected {checkedWorkspaces.length} of{" "} + {workspaces?.length}{" "} + {checkedWorkspaces.length === 1 ? "workspace" : "workspaces"} + + + + + + + ) : ( + + )} {count !== undefined && ( void - error?: unknown + onCheckChange: (checkedWorkspaces: Workspace[]) => void } export const WorkspacesTable: FC = ({ workspaces, + checkedWorkspaces, isUsingFilter, onUpdateWorkspace, + onCheckChange, }) => { const { t } = useTranslation("workspacesPage") const styles = useStyles() @@ -52,7 +57,31 @@ export const WorkspacesTable: FC = ({ - Name + `${theme.spacing(1.5)} !important`, + }} + > + + { + if (!workspaces) { + return + } + + if (!checked) { + onCheckChange([]) + } else { + onCheckChange(workspaces) + } + }} + /> + Name + + Template Last used Status @@ -94,33 +123,61 @@ export const WorkspacesTable: FC = ({ {workspaces && workspaces.map((workspace) => ( - - - {workspace.name} - {workspace.outdated && ( - { - onUpdateWorkspace(workspace) - }} - /> - )} - - } - subtitle={workspace.owner_name} - avatar={ - - {workspace.name} - - } - /> + `${theme.spacing(1.5)} !important`, + }} + > + + w.id === workspace.id, + )} + onClick={(e) => { + e.stopPropagation() + }} + onChange={(e) => { + if (e.currentTarget.checked) { + onCheckChange([...checkedWorkspaces, workspace]) + } else { + onCheckChange( + checkedWorkspaces.filter( + (w) => w.id !== workspace.id, + ), + ) + } + }} + /> + + {workspace.name} + {workspace.outdated && ( + { + onUpdateWorkspace(workspace) + }} + /> + )} + + } + subtitle={workspace.owner_name} + avatar={ + + {workspace.name} + + } + /> + From e3b7c9d847e8d1da6006bb1b94075146f3e42c49 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 10 Aug 2023 16:38:59 +0000 Subject: [PATCH 02/13] Uncheck all workspaces when pagination or filter change --- site/src/pages/WorkspacesPage/WorkspacesPage.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 4e5ecf004bc22..fc4513a161b68 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -59,6 +59,13 @@ const WorkspacesPage: FC = () => { const updateWorkspace = useWorkspaceUpdate(queryKey) const [checkedWorkspaces, setCheckedWorkspaces] = useState([]) const [isDeletingAll, setIsDeletingAll] = useState(false) + const [urlSearchParams] = searchParamsResult + + // We want to uncheck the selected workspaces always when the url changes + // because of filtering or pagination + useEffect(() => { + setCheckedWorkspaces([]) + }, [urlSearchParams]) return ( <> From 32d9025a7dd6b3270e893895d793d340d65d8b96 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 10 Aug 2023 16:53:54 +0000 Subject: [PATCH 03/13] Improve delete dialog --- .../pages/WorkspacesPage/WorkspacesPage.tsx | 89 ++++++++++++++++--- 1 file changed, 79 insertions(+), 10 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index fc4513a161b68..5d10efeabd684 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -13,6 +13,9 @@ import { useFilter } from "components/Filter/filter" import { useUserFilterMenu } from "components/Filter/UserFilter" import { getWorkspaces } from "api/api" import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" +import Box from "@mui/material/Box" +import { MONOSPACE_FONT_FAMILY } from "theme/constants" +import TextField from "@mui/material/TextField" const WorkspacesPage: FC = () => { const [lockedWorkspaces, setLockedWorkspaces] = useState([]) @@ -92,17 +95,9 @@ const WorkspacesPage: FC = () => { }} /> - { - alert("DO IT!") - }} onClose={() => { setIsDeletingAll(false) }} @@ -159,3 +154,77 @@ const useWorkspacesFilter = ({ }, } } + +const BatchDeleteConfirmation = ({ + checkedWorkspaces, + open, + onClose, +}: { + checkedWorkspaces: Workspace[] + open: boolean + onClose: () => void +}) => { + const [confirmValue, setConfirmValue] = useState("") + const [confirmError, setConfirmError] = useState(false) + + const confirmDeletion = () => { + if (confirmValue.toLowerCase() !== "delete") { + setConfirmError(true) + return + } + } + + return ( + { + onClose() + setConfirmValue("") + setConfirmError(false) + }} + type="delete" + title={`Delete ${checkedWorkspaces?.length} ${ + checkedWorkspaces.length === 1 ? "workspace" : "workspaces" + }`} + description={ +
{ + e.preventDefault() + confirmDeletion() + }} + > + + Deleting these workspaces is irreversible! Are you sure you want to + proceed? Type{" "} + theme.palette.text.primary, + fontWeight: 600, + }} + > + `DELETE` + {" "} + to confirm. + + { + setConfirmValue(e.currentTarget.value) + }} + error={confirmError} + helperText={confirmError && "Please type DELETE to confirm"} + /> + + } + confirmLoading={false} + onConfirm={confirmDeletion} + /> + ) +} From 18965e0fa876fcfcb76a3eb8f886d82c96ebc879 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 10 Aug 2023 17:07:20 +0000 Subject: [PATCH 04/13] Improve delete --- .../pages/WorkspacesPage/WorkspacesPage.tsx | 51 ++++++++++++++++--- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 5d10efeabd684..ccfb1674fd37b 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -11,11 +11,13 @@ import { useTemplateFilterMenu, useStatusFilterMenu } from "./filter/menus" import { useSearchParams } from "react-router-dom" import { useFilter } from "components/Filter/filter" import { useUserFilterMenu } from "components/Filter/UserFilter" -import { getWorkspaces } from "api/api" +import { deleteWorkspace, getWorkspaces } from "api/api" import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" import Box from "@mui/material/Box" import { MONOSPACE_FONT_FAMILY } from "theme/constants" import TextField from "@mui/material/TextField" +import { displayError } from "components/GlobalSnackbar/utils" +import { getErrorMessage } from "api/errors" const WorkspacesPage: FC = () => { const [lockedWorkspaces, setLockedWorkspaces] = useState([]) @@ -25,7 +27,7 @@ const WorkspacesPage: FC = () => { const searchParamsResult = useSearchParams() const pagination = usePagination({ searchParamsResult }) const filterProps = useWorkspacesFilter({ searchParamsResult, pagination }) - const { data, error, queryKey } = useWorkspacesData({ + const { data, error, queryKey, refetch } = useWorkspacesData({ ...pagination, query: filterProps.filter.query, }) @@ -101,6 +103,10 @@ const WorkspacesPage: FC = () => { onClose={() => { setIsDeletingAll(false) }} + onDelete={async () => { + await refetch() + setCheckedWorkspaces([]) + }} /> ) @@ -159,38 +165,69 @@ const BatchDeleteConfirmation = ({ checkedWorkspaces, open, onClose, + onDelete, }: { checkedWorkspaces: Workspace[] open: boolean onClose: () => void + onDelete: () => void }) => { const [confirmValue, setConfirmValue] = useState("") const [confirmError, setConfirmError] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + + const close = () => { + if (isDeleting) { + return + } + + onClose() + setConfirmValue("") + setConfirmError(false) + setIsDeleting(false) + } + + const confirmDeletion = async () => { + setConfirmError(false) - const confirmDeletion = () => { if (confirmValue.toLowerCase() !== "delete") { setConfirmError(true) return } + + try { + setIsDeleting(true) + await Promise.all(checkedWorkspaces.map((w) => deleteWorkspace(w.id))) + } catch (e) { + displayError( + "Error on deleting workspaces", + getErrorMessage(e, "An error occurred while deleting the workspaces"), + ) + } finally { + close() + onDelete() + } } return ( { onClose() setConfirmValue("") setConfirmError(false) }} - type="delete" title={`Delete ${checkedWorkspaces?.length} ${ checkedWorkspaces.length === 1 ? "workspace" : "workspaces" }`} description={
{ + onSubmit={async (e) => { e.preventDefault() - confirmDeletion() + await confirmDeletion() }} > @@ -223,8 +260,6 @@ const BatchDeleteConfirmation = ({ /> } - confirmLoading={false} - onConfirm={confirmDeletion} /> ) } From ce38d74d22cd9201cf49f2439bbd330b10e57d09 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 10 Aug 2023 17:11:45 +0000 Subject: [PATCH 05/13] Fix verbiage --- site/src/pages/WorkspacesPage/WorkspacesPageView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 32bdbe210e96b..31f82d3660be4 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -127,7 +127,7 @@ export const WorkspacesPageView: FC< Selected {checkedWorkspaces.length} of{" "} {workspaces?.length}{" "} - {checkedWorkspaces.length === 1 ? "workspace" : "workspaces"} + {workspaces?.length === 1 ? "workspace" : "workspaces"} From 0c42242f3d9f4f95b34010043e38c1ac84b799ff Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 10 Aug 2023 17:23:15 +0000 Subject: [PATCH 06/13] Create table tool bar component --- .../PaginationStatus/PaginationStatus.tsx | 45 ------------ .../components/TableToolbar/TableToolbar.tsx | 48 +++++++++++++ site/src/pages/AuditPage/AuditPageView.tsx | 19 ++++-- site/src/pages/GroupsPage/GroupPage.tsx | 19 ++++-- site/src/pages/UsersPage/UsersPageView.tsx | 19 ++++-- .../WorkspacesPage/WorkspacesPageView.tsx | 68 +++++++++---------- 6 files changed, 115 insertions(+), 103 deletions(-) delete mode 100644 site/src/components/PaginationStatus/PaginationStatus.tsx create mode 100644 site/src/components/TableToolbar/TableToolbar.tsx diff --git a/site/src/components/PaginationStatus/PaginationStatus.tsx b/site/src/components/PaginationStatus/PaginationStatus.tsx deleted file mode 100644 index cd870f54d0b69..0000000000000 --- a/site/src/components/PaginationStatus/PaginationStatus.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import Box from "@mui/material/Box" -import Skeleton from "@mui/material/Skeleton" - -type BasePaginationStatusProps = { - label: string - isLoading: boolean - showing?: number - total?: number -} - -type LoadedPaginationStatusProps = BasePaginationStatusProps & { - isLoading: false - showing: number - total: number -} - -export const PaginationStatus = ({ - isLoading, - showing, - total, - label, -}: BasePaginationStatusProps | LoadedPaginationStatusProps) => { - return ( - theme.palette.text.secondary, - "& strong": { color: (theme) => theme.palette.text.primary }, - }} - > - {!isLoading ? ( - <> - Showing {showing} of{" "} - {total?.toLocaleString()} {label} - - ) : ( - - - - )} - - ) -} diff --git a/site/src/components/TableToolbar/TableToolbar.tsx b/site/src/components/TableToolbar/TableToolbar.tsx new file mode 100644 index 0000000000000..0b38b66640696 --- /dev/null +++ b/site/src/components/TableToolbar/TableToolbar.tsx @@ -0,0 +1,48 @@ +import { styled } from "@mui/material/styles" +import Box from "@mui/material/Box" +import Skeleton from "@mui/material/Skeleton" + +export const TableToolbar = styled(Box)(({ theme }) => ({ + fontSize: 13, + marginBottom: theme.spacing(1), + marginTop: theme.spacing(0), + height: 36, // The size of a small button + color: theme.palette.text.secondary, + "& strong": { color: theme.palette.text.primary }, + display: "flex", + alignItems: "center", +})) + +type BasePaginationStatusProps = { + label: string + isLoading: boolean + showing?: number + total?: number +} + +type LoadedPaginationStatusProps = BasePaginationStatusProps & { + isLoading: false + showing: number + total: number +} + +export const PaginationStatus = ({ + isLoading, + showing, + total, + label, +}: BasePaginationStatusProps | LoadedPaginationStatusProps) => { + if (isLoading) { + return ( + + + + ) + } + return ( + + Showing {showing} of{" "} + {total?.toLocaleString()} {label} + + ) +} diff --git a/site/src/pages/AuditPage/AuditPageView.tsx b/site/src/pages/AuditPage/AuditPageView.tsx index e011a8dc8e7dd..65435d9a5da38 100644 --- a/site/src/pages/AuditPage/AuditPageView.tsx +++ b/site/src/pages/AuditPage/AuditPageView.tsx @@ -21,7 +21,10 @@ import { ComponentProps, FC } from "react" import { useTranslation } from "react-i18next" import { AuditPaywall } from "./AuditPaywall" import { AuditFilter } from "./AuditFilter" -import { PaginationStatus } from "components/PaginationStatus/PaginationStatus" +import { + PaginationStatus, + TableToolbar, +} from "components/TableToolbar/TableToolbar" import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidgetBase" export const Language = { @@ -73,12 +76,14 @@ export const AuditPageView: FC = ({ - + + +
diff --git a/site/src/pages/GroupsPage/GroupPage.tsx b/site/src/pages/GroupsPage/GroupPage.tsx index 30801f2823362..967d4b8df066a 100644 --- a/site/src/pages/GroupsPage/GroupPage.tsx +++ b/site/src/pages/GroupsPage/GroupPage.tsx @@ -33,7 +33,10 @@ import { pageTitle } from "utils/page" import { groupMachine } from "xServices/groups/groupXService" import { Maybe } from "components/Conditionals/Maybe" import { makeStyles } from "@mui/styles" -import { PaginationStatus } from "components/PaginationStatus/PaginationStatus" +import { + PaginationStatus, + TableToolbar, +} from "components/TableToolbar/TableToolbar" import { UserAvatar } from "components/UserAvatar/UserAvatar" const AddGroupMember: React.FC<{ @@ -155,12 +158,14 @@ export const GroupPage: React.FC = () => { }} /> - + + +
diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index a25d79391cfc5..f11224eb609f8 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -4,7 +4,10 @@ import { PaginationMachineRef } from "xServices/pagination/paginationXService" import * as TypesGen from "../../api/typesGenerated" import { UsersTable } from "../../components/UsersTable/UsersTable" import { UsersFilter } from "./UsersFilter" -import { PaginationStatus } from "components/PaginationStatus/PaginationStatus" +import { + PaginationStatus, + TableToolbar, +} from "components/TableToolbar/TableToolbar" export const Language = { activeUsersFilterName: "Active users", @@ -60,12 +63,14 @@ export const UsersPageView: FC> = ({ <> - + + + - {checkedWorkspaces.length > 0 ? ( - theme.palette.text.secondary, - "& strong": { color: (theme) => theme.palette.text.primary }, - }} - > - - Selected {checkedWorkspaces.length} of{" "} - {workspaces?.length}{" "} - {workspaces?.length === 1 ? "workspace" : "workspaces"} - + + {checkedWorkspaces.length > 0 ? ( + <> + + Selected {checkedWorkspaces.length} of{" "} + {workspaces?.length}{" "} + {workspaces?.length === 1 ? "workspace" : "workspaces"} + - - - - - ) : ( - - )} + + + + + ) : ( + + )} + Date: Mon, 14 Aug 2023 18:42:33 +0000 Subject: [PATCH 07/13] Add experiment --- coderd/apidoc/docs.go | 6 ++++-- coderd/apidoc/swagger.json | 6 ++++-- codersdk/deployment.go | 4 ++++ docs/api/schemas.md | 1 + site/src/api/typesGenerated.ts | 2 ++ 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2f04b8be2a3d2..b32114a3aba9a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8043,7 +8043,8 @@ const docTemplate = `{ "single_tailnet", "template_restart_requirement", "deployment_health_page", - "template_parameters_insights" + "template_parameters_insights", + "workspaces_batch_actions" ], "x-enum-varnames": [ "ExperimentMoons", @@ -8052,7 +8053,8 @@ const docTemplate = `{ "ExperimentSingleTailnet", "ExperimentTemplateRestartRequirement", "ExperimentDeploymentHealthPage", - "ExperimentTemplateParametersInsights" + "ExperimentTemplateParametersInsights", + "ExperimentWorkspacesBatchActions" ] }, "codersdk.Feature": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 997bdca3ade64..c86f7130f90a1 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7204,7 +7204,8 @@ "single_tailnet", "template_restart_requirement", "deployment_health_page", - "template_parameters_insights" + "template_parameters_insights", + "workspaces_batch_actions" ], "x-enum-varnames": [ "ExperimentMoons", @@ -7213,7 +7214,8 @@ "ExperimentSingleTailnet", "ExperimentTemplateRestartRequirement", "ExperimentDeploymentHealthPage", - "ExperimentTemplateParametersInsights" + "ExperimentTemplateParametersInsights", + "ExperimentWorkspacesBatchActions" ] }, "codersdk.Feature": { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index a7dd2a5af50a0..78047b9158061 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -1908,6 +1908,9 @@ const ( // Template parameters insights ExperimentTemplateParametersInsights Experiment = "template_parameters_insights" + // Workspaces batch actions + ExperimentWorkspacesBatchActions Experiment = "workspaces_batch_actions" + // Add new experiments here! // ExperimentExample Experiment = "example" ) @@ -1919,6 +1922,7 @@ const ( var ExperimentsAll = Experiments{ ExperimentDeploymentHealthPage, ExperimentTemplateParametersInsights, + ExperimentWorkspacesBatchActions, } // Experiments is a list of experiments that are enabled for the deployment. diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 11aad4ba5f814..ab1237e87c6d0 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2707,6 +2707,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `template_restart_requirement` | | `deployment_health_page` | | `template_parameters_insights` | +| `workspaces_batch_actions` | ## codersdk.Feature diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 019fc0f60beb0..6ed5eb7c3a9c5 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1599,6 +1599,7 @@ export type Experiment = | "template_parameters_insights" | "template_restart_requirement" | "workspace_actions" + | "workspaces_batch_actions" export const Experiments: Experiment[] = [ "deployment_health_page", "moons", @@ -1607,6 +1608,7 @@ export const Experiments: Experiment[] = [ "template_parameters_insights", "template_restart_requirement", "workspace_actions", + "workspaces_batch_actions", ] // From codersdk/deployment.go From e8a4f18c369846b54b9fb49e6b6fb1ee4135c870 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 14 Aug 2023 18:49:47 +0000 Subject: [PATCH 08/13] Add experiment in the FE side --- .../pages/WorkspacesPage/WorkspacesPage.tsx | 10 +- .../WorkspacesPage/WorkspacesPageView.tsx | 5 +- .../pages/WorkspacesPage/WorkspacesTable.tsx | 102 ++++++++++-------- 3 files changed, 70 insertions(+), 47 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index ccfb1674fd37b..d49fcdca4a594 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -1,6 +1,9 @@ import { usePagination } from "hooks/usePagination" import { Workspace } from "api/typesGenerated" -import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider" +import { + useDashboard, + useIsWorkspaceActionsEnabled, +} from "components/Dashboard/DashboardProvider" import { FC, useEffect, useState } from "react" import { Helmet } from "react-helmet-async" import { pageTitle } from "utils/page" @@ -65,6 +68,10 @@ const WorkspacesPage: FC = () => { const [checkedWorkspaces, setCheckedWorkspaces] = useState([]) const [isDeletingAll, setIsDeletingAll] = useState(false) const [urlSearchParams] = searchParamsResult + const dashboard = useDashboard() + const isWorkspaceBatchActionsEnabled = + dashboard.experiments.includes("workspaces_batch_actions") || + process.env.NODE_ENV === "development" // We want to uncheck the selected workspaces always when the url changes // because of filtering or pagination @@ -79,6 +86,7 @@ const WorkspacesPage: FC = () => { page: number limit: number + isWorkspaceBatchActionsEnabled?: boolean onPageChange: (page: number) => void onUpdateWorkspace: (workspace: Workspace) => void onCheckChange: (checkedWorkspaces: Workspace[]) => void @@ -63,6 +64,7 @@ export const WorkspacesPageView: FC< onUpdateWorkspace, page, checkedWorkspaces, + isWorkspaceBatchActionsEnabled, onCheckChange, onDeleteAll, }) => { @@ -115,7 +117,7 @@ export const WorkspacesPageView: FC< - {checkedWorkspaces.length > 0 ? ( + {checkedWorkspaces.length > 0 && isWorkspaceBatchActionsEnabled ? ( <> Selected {checkedWorkspaces.length} of{" "} @@ -149,6 +151,7 @@ export const WorkspacesPageView: FC< onUpdateWorkspace={onUpdateWorkspace} checkedWorkspaces={checkedWorkspaces} onCheckChange={onCheckChange} + isWorkspaceBatchActionsEnabled={isWorkspaceBatchActionsEnabled} /> {count !== undefined && ( void onCheckChange: (checkedWorkspaces: Workspace[]) => void } @@ -46,6 +47,7 @@ export const WorkspacesTable: FC = ({ workspaces, checkedWorkspaces, isUsingFilter, + isWorkspaceBatchActionsEnabled, onUpdateWorkspace, onCheckChange, }) => { @@ -57,31 +59,36 @@ export const WorkspacesTable: FC = ({
- `${theme.spacing(1.5)} !important`, - }} - > - - { - if (!workspaces) { - return - } + {isWorkspaceBatchActionsEnabled ? ( + `${theme.spacing(1.5)} !important`, + }} + > + + { + if (!workspaces) { + return + } + + if (!checked) { + onCheckChange([]) + } else { + onCheckChange(workspaces) + } + }} + /> + Name + + + ) : ( + Name + )} - if (!checked) { - onCheckChange([]) - } else { - onCheckChange(workspaces) - } - }} - /> - Name - - Template Last used Status @@ -125,30 +132,35 @@ export const WorkspacesTable: FC = ({ `${theme.spacing(1.5)} !important`, + paddingLeft: (theme) => + isWorkspaceBatchActionsEnabled + ? `${theme.spacing(1.5)} !important` + : undefined, }} > - w.id === workspace.id, - )} - onClick={(e) => { - e.stopPropagation() - }} - onChange={(e) => { - if (e.currentTarget.checked) { - onCheckChange([...checkedWorkspaces, workspace]) - } else { - onCheckChange( - checkedWorkspaces.filter( - (w) => w.id !== workspace.id, - ), - ) - } - }} - /> + {isWorkspaceBatchActionsEnabled && ( + w.id === workspace.id, + )} + onClick={(e) => { + e.stopPropagation() + }} + onChange={(e) => { + if (e.currentTarget.checked) { + onCheckChange([...checkedWorkspaces, workspace]) + } else { + onCheckChange( + checkedWorkspaces.filter( + (w) => w.id !== workspace.id, + ), + ) + } + }} + /> + )} From db1201b914e3976b005f1f781f5ee6e828756a4c Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 14 Aug 2023 19:06:03 +0000 Subject: [PATCH 09/13] Minor UI improvements --- .../pages/WorkspacesPage/WorkspacesTable.tsx | 208 ++++++++++-------- site/src/theme/theme.ts | 9 + 2 files changed, 125 insertions(+), 92 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index 62055ef281c75..c440bf149eb73 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -128,104 +128,116 @@ export const WorkspacesTable: FC = ({ )} {workspaces && - workspaces.map((workspace) => ( - - - isWorkspaceBatchActionsEnabled - ? `${theme.spacing(1.5)} !important` - : undefined, - }} + workspaces.map((workspace) => { + const checked = checkedWorkspaces.some( + (w) => w.id === workspace.id, + ) + return ( + - - {isWorkspaceBatchActionsEnabled && ( - w.id === workspace.id, - )} - onClick={(e) => { - e.stopPropagation() - }} - onChange={(e) => { - if (e.currentTarget.checked) { - onCheckChange([...checkedWorkspaces, workspace]) - } else { - onCheckChange( - checkedWorkspaces.filter( - (w) => w.id !== workspace.id, - ), - ) - } - }} + + isWorkspaceBatchActionsEnabled + ? `${theme.spacing(1.5)} !important` + : undefined, + }} + > + + {isWorkspaceBatchActionsEnabled && ( + { + e.stopPropagation() + }} + onChange={(e) => { + if (e.currentTarget.checked) { + onCheckChange([...checkedWorkspaces, workspace]) + } else { + onCheckChange( + checkedWorkspaces.filter( + (w) => w.id !== workspace.id, + ), + ) + } + }} + /> + )} + + {workspace.name} + {workspace.outdated && ( + { + onUpdateWorkspace(workspace) + }} + /> + )} + + } + subtitle={workspace.owner_name} + avatar={ + + {workspace.name} + + } /> - )} - - {workspace.name} - {workspace.outdated && ( - { - onUpdateWorkspace(workspace) - }} - /> - )} - - } - subtitle={workspace.owner_name} - avatar={ - - {workspace.name} - - } - /> - - + + - - {getDisplayWorkspaceTemplateName(workspace)} - + + {getDisplayWorkspaceTemplateName(workspace)} + - - - + + + - - - - {workspace.latest_build.status === "running" && - !workspace.health.healthy && } - - + + + + {workspace.latest_build.status === "running" && + !workspace.health.healthy && } + + - - theme.spacing(2), - }} - > - + theme.palette.text.secondary, - width: 20, - height: 20, + display: "flex", + paddingLeft: (theme) => theme.spacing(2), }} - /> - - - - ))} + > + theme.palette.text.secondary, + width: 20, + height: 20, + }} + /> + + + + ) + })}
@@ -235,7 +247,8 @@ export const WorkspacesTable: FC = ({ const WorkspacesRow: FC<{ workspace: Workspace children: ReactNode -}> = ({ workspace, children }) => { + checked: boolean +}> = ({ workspace, children, checked }) => { const navigate = useNavigate() const workspacePageLink = `/@${workspace.owner_name}/${workspace.name}` const clickable = useClickableTableRow(() => { @@ -243,7 +256,14 @@ const WorkspacesRow: FC<{ }) return ( - + + checked ? theme.palette.action.hover : undefined, + }} + > {children} ) @@ -267,6 +287,10 @@ export const UnhealthyTooltip = () => { ) } +const cantBeChecked = (workspace: Workspace) => { + return ["deleting", "pending"].includes(workspace.latest_build.status) +} + const useUnhealthyTooltipStyles = makeStyles(() => ({ unhealthyIcon: { color: colors.yellow[5], diff --git a/site/src/theme/theme.ts b/site/src/theme/theme.ts index 354ade445fee9..d397fc776b141 100644 --- a/site/src/theme/theme.ts +++ b/site/src/theme/theme.ts @@ -384,6 +384,15 @@ dark = createTheme(dark, { disableRipple: true, }, }, + MuiCheckbox: { + styleOverrides: { + root: { + "&.Mui-disabled": { + color: colors.gray[11], + }, + }, + }, + }, MuiSwitch: { defaultProps: { color: "primary", From 42ee67c65d5831c6b2ea118ce0ee5c5b875cc2e8 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 14 Aug 2023 19:15:41 +0000 Subject: [PATCH 10/13] Add minor improvements --- site/src/pages/WorkspacesPage/WorkspacesTable.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index c440bf149eb73..343ccd4d7d766 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -68,6 +68,7 @@ export const WorkspacesTable: FC = ({ > { From 420819ef5d15e711063421bb8a228a225dff4f62 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 15 Aug 2023 13:52:06 +0000 Subject: [PATCH 11/13] Simplify logi --- site/src/pages/WorkspacesPage/WorkspacesPageView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 4e448c2100962..78e61d7be39b4 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -117,7 +117,7 @@ export const WorkspacesPageView: FC< - {checkedWorkspaces.length > 0 && isWorkspaceBatchActionsEnabled ? ( + {checkedWorkspaces.length > 0 ? ( <> Selected {checkedWorkspaces.length} of{" "} From 2a6c18fdc42e60a9639e45b4df137a32a7026e54 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 15 Aug 2023 14:14:40 +0000 Subject: [PATCH 12/13] Add tests for the delete flow --- .../WorkspacesPage/WorkspacesPage.test.tsx | 43 ++++++++++++++++++- .../pages/WorkspacesPage/WorkspacesPage.tsx | 3 ++ .../pages/WorkspacesPage/WorkspacesTable.tsx | 1 + site/src/testHelpers/entities.ts | 1 + 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index d030f0bd8901d..6abb3d06e2310 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -1,11 +1,17 @@ -import { screen } from "@testing-library/react" +import { screen, waitFor, within } from "@testing-library/react" import { rest } from "msw" import * as CreateDayString from "utils/createDayString" import { MockWorkspace, MockWorkspacesResponse } from "testHelpers/entities" -import { renderWithAuth } from "testHelpers/renderHelpers" +import { + renderWithAuth, + waitForLoaderToBeRemoved, +} from "testHelpers/renderHelpers" import { server } from "testHelpers/server" import WorkspacesPage from "./WorkspacesPage" import { i18n } from "i18n" +import userEvent from "@testing-library/user-event" +import * as API from "api/api" +import { Workspace } from "api/typesGenerated" const { t } = i18n @@ -40,4 +46,37 @@ describe("WorkspacesPage", () => { ) expect(templateDisplayNames).toHaveLength(MockWorkspacesResponse.count) }) + + it("deletes only the selected workspaces", async () => { + const workspaces = [ + { ...MockWorkspace, id: "1" }, + { ...MockWorkspace, id: "2" }, + { ...MockWorkspace, id: "3" }, + ] + jest + .spyOn(API, "getWorkspaces") + .mockResolvedValue({ workspaces, count: workspaces.length }) + const deleteWorkspace = jest.spyOn(API, "deleteWorkspace") + const user = userEvent.setup() + renderWithAuth() + await waitForLoaderToBeRemoved() + + await user.click(getWorkspaceCheckbox(workspaces[0])) + await user.click(getWorkspaceCheckbox(workspaces[1])) + await user.click(screen.getByRole("button", { name: /delete all/i })) + await user.type(screen.getByLabelText(/type delete to confirm/i), "DELETE") + await user.click(screen.getByTestId("confirm-button")) + + await waitFor(() => { + expect(deleteWorkspace).toHaveBeenCalledTimes(2) + }) + expect(deleteWorkspace).toHaveBeenCalledWith(workspaces[0].id) + expect(deleteWorkspace).toHaveBeenCalledWith(workspaces[1].id) + }) }) + +const getWorkspaceCheckbox = (workspace: Workspace) => { + return within(screen.getByTestId(`checkbox-${workspace.id}`)).getByRole( + "checkbox", + ) +} diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index d49fcdca4a594..ed810e36769b3 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -258,6 +258,9 @@ const BatchDeleteConfirmation = ({ required autoFocus fullWidth + inputProps={{ + "aria-label": "Type DELETE to confirm", + }} placeholder="Type DELETE to confirm" sx={{ mt: 2 }} onChange={(e) => { diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index 343ccd4d7d766..1e15f4c362d4f 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -150,6 +150,7 @@ export const WorkspacesTable: FC = ({ {isWorkspaceBatchActionsEnabled && ( Date: Tue, 15 Aug 2023 15:46:15 +0000 Subject: [PATCH 13/13] Fix workspaces page view stories --- site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index d9d8c0786a6f9..f688cf3508ce1 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -96,6 +96,7 @@ const meta: Meta = { args: { limit: DEFAULT_RECORDS_PER_PAGE, filterProps: defaultFilterProps, + checkedWorkspaces: [], }, decorators: [ (Story) => (