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={
+
+ }
+ />
+ )
+}
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"}
+
+
+
+ }
+ onClick={onDeleteAll}
+ >
+ Delete all
+
+
+ >
+ ) : (
+
+ )}
+
{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",