diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 406fe15e422fa..dba1f5026c234 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 c0c71d2888337..2718f2b36cbfa 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 1563a6eb1225d..d49297dbf8f3f 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -1931,6 +1931,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" ) @@ -1942,6 +1945,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 f40e8e65486f0..dc9a41d56c0cd 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2711,6 +2711,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 04a2847b6c363..909111cf5e740 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1601,6 +1601,7 @@ export type Experiment = | "template_parameters_insights" | "template_restart_requirement" | "workspace_actions" + | "workspaces_batch_actions" export const Experiments: Experiment[] = [ "deployment_health_page", "moons", @@ -1609,6 +1610,7 @@ export const Experiments: Experiment[] = [ "template_parameters_insights", "template_restart_requirement", "workspace_actions", + "workspaces_batch_actions", ] // From codersdk/deployment.go 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> = ({ <> - + + + { ) 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 6194f1fc4add8..ed810e36769b3 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" @@ -11,7 +14,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([]) @@ -21,7 +30,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, }) @@ -55,8 +64,20 @@ const WorkspacesPage: FC = () => { setLockedWorkspaces([]) } }, [experimentEnabled, data, filterProps.filter.query]) - const updateWorkspace = useWorkspaceUpdate(queryKey) + 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 + useEffect(() => { + setCheckedWorkspaces([]) + }, [urlSearchParams]) return ( <> @@ -65,6 +86,9 @@ const WorkspacesPage: FC = () => { { onUpdateWorkspace={(workspace) => { updateWorkspace.mutate(workspace) }} + onDeleteAll={() => { + setIsDeletingAll(true) + }} + /> + + { + setIsDeletingAll(false) + }} + onDelete={async () => { + await refetch() + setCheckedWorkspaces([]) + }} /> ) @@ -129,3 +168,109 @@ const useWorkspacesFilter = ({ }, } } + +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) + + 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) + }} + title={`Delete ${checkedWorkspaces?.length} ${ + checkedWorkspaces.length === 1 ? "workspace" : "workspaces" + }`} + description={ +
{ + e.preventDefault() + await 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"} + /> + + } + /> + ) +} 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) => ( diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index ea1139e72deac..78e61d7be39b4 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -18,7 +18,13 @@ import { LockedWorkspaceBanner, Count } from "components/WorkspaceDeletion" import { ErrorAlert } from "components/Alert/ErrorAlert" import { WorkspacesFilter } from "./filter/filter" import { hasError, isApiValidationError } from "api/errors" -import { PaginationStatus } from "components/PaginationStatus/PaginationStatus" +import { + PaginationStatus, + TableToolbar, +} from "components/TableToolbar/TableToolbar" +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 +39,16 @@ export interface WorkspacesPageViewProps { error: unknown workspaces?: Workspace[] lockedWorkspaces?: Workspace[] + checkedWorkspaces: Workspace[] count?: number filterProps: ComponentProps page: number limit: number + isWorkspaceBatchActionsEnabled?: boolean onPageChange: (page: number) => void onUpdateWorkspace: (workspace: Workspace) => void + onCheckChange: (checkedWorkspaces: Workspace[]) => void + onDeleteAll: () => void } export const WorkspacesPageView: FC< @@ -53,6 +63,10 @@ export const WorkspacesPageView: FC< onPageChange, onUpdateWorkspace, page, + checkedWorkspaces, + isWorkspaceBatchActionsEnabled, + onCheckChange, + onDeleteAll, }) => { const { saveLocal } = useLocalStorage() @@ -102,17 +116,42 @@ export const WorkspacesPageView: FC< - + + {checkedWorkspaces.length > 0 ? ( + <> + + Selected {checkedWorkspaces.length} of{" "} + {workspaces?.length}{" "} + {workspaces?.length === 1 ? "workspace" : "workspaces"} + + + + + + + ) : ( + + )} + {count !== undefined && ( void - error?: unknown + onCheckChange: (checkedWorkspaces: Workspace[]) => void } export const WorkspacesTable: FC = ({ workspaces, + checkedWorkspaces, isUsingFilter, + isWorkspaceBatchActionsEnabled, onUpdateWorkspace, + onCheckChange, }) => { const { t } = useTranslation("workspacesPage") const styles = useStyles() @@ -52,7 +59,37 @@ export const WorkspacesTable: FC = ({
- Name + {isWorkspaceBatchActionsEnabled ? ( + `${theme.spacing(1.5)} !important`, + }} + > + + { + if (!workspaces) { + return + } + + if (!checked) { + onCheckChange([]) + } else { + onCheckChange(workspaces) + } + }} + /> + Name + + + ) : ( + Name + )} + Template Last used Status @@ -92,71 +129,117 @@ export const WorkspacesTable: FC = ({ )} {workspaces && - workspaces.map((workspace) => ( - - - - {workspace.name} - {workspace.outdated && ( - { - onUpdateWorkspace(workspace) - }} - /> - )} - - } - subtitle={workspace.owner_name} - avatar={ - - {workspace.name} - - } - /> - - - - {getDisplayWorkspaceTemplateName(workspace)} - - - - - - - - - - {workspace.latest_build.status === "running" && - !workspace.health.healthy && } - - - - - { + const checked = checkedWorkspaces.some( + (w) => w.id === workspace.id, + ) + return ( + + theme.spacing(2), + paddingLeft: (theme) => + 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} + + } + /> + + + + + {getDisplayWorkspaceTemplateName(workspace)} + + + + + + + + + + {workspace.latest_build.status === "running" && + !workspace.health.healthy && } + + + + + theme.palette.text.secondary, - width: 20, - height: 20, + display: "flex", + paddingLeft: (theme) => theme.spacing(2), }} - /> - - - - ))} + > + theme.palette.text.secondary, + width: 20, + height: 20, + }} + /> + + + + ) + })}
@@ -166,7 +249,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(() => { @@ -174,7 +258,14 @@ const WorkspacesRow: FC<{ }) return ( - + + checked ? theme.palette.action.hover : undefined, + }} + > {children} ) @@ -198,6 +289,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/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index d8ed5bbe20f58..7f163f489d9ff 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1507,6 +1507,7 @@ export const MockEntitlementsWithScheduling: TypesGen.Entitlements = { export const MockExperiments: TypesGen.Experiment[] = [ "workspace_actions", "moons", + "workspaces_batch_actions", ] export const MockAuditLog: TypesGen.AuditLog = { 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",