mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(site): add batch actions to the workspaces page (#9091)
This commit is contained in:
Generated
+4
-2
@@ -8055,7 +8055,8 @@ const docTemplate = `{
|
||||
"single_tailnet",
|
||||
"template_restart_requirement",
|
||||
"deployment_health_page",
|
||||
"template_parameters_insights"
|
||||
"template_parameters_insights",
|
||||
"workspaces_batch_actions"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ExperimentMoons",
|
||||
@@ -8064,7 +8065,8 @@ const docTemplate = `{
|
||||
"ExperimentSingleTailnet",
|
||||
"ExperimentTemplateRestartRequirement",
|
||||
"ExperimentDeploymentHealthPage",
|
||||
"ExperimentTemplateParametersInsights"
|
||||
"ExperimentTemplateParametersInsights",
|
||||
"ExperimentWorkspacesBatchActions"
|
||||
]
|
||||
},
|
||||
"codersdk.Feature": {
|
||||
|
||||
Generated
+4
-2
@@ -7214,7 +7214,8 @@
|
||||
"single_tailnet",
|
||||
"template_restart_requirement",
|
||||
"deployment_health_page",
|
||||
"template_parameters_insights"
|
||||
"template_parameters_insights",
|
||||
"workspaces_batch_actions"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ExperimentMoons",
|
||||
@@ -7223,7 +7224,8 @@
|
||||
"ExperimentSingleTailnet",
|
||||
"ExperimentTemplateRestartRequirement",
|
||||
"ExperimentDeploymentHealthPage",
|
||||
"ExperimentTemplateParametersInsights"
|
||||
"ExperimentTemplateParametersInsights",
|
||||
"ExperimentWorkspacesBatchActions"
|
||||
]
|
||||
},
|
||||
"codersdk.Feature": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+1
@@ -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
|
||||
|
||||
|
||||
Generated
+2
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
mb: 2,
|
||||
mt: 1,
|
||||
color: (theme) => theme.palette.text.secondary,
|
||||
"& strong": { color: (theme) => theme.palette.text.primary },
|
||||
}}
|
||||
>
|
||||
{!isLoading ? (
|
||||
<>
|
||||
Showing <strong>{showing}</strong> of{" "}
|
||||
<strong>{total?.toLocaleString()}</strong> {label}
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ height: 24, display: "flex", alignItems: "center" }}>
|
||||
<Skeleton variant="text" width={160} height={16} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Box sx={{ height: 24, display: "flex", alignItems: "center" }}>
|
||||
<Skeleton variant="text" width={160} height={16} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
Showing <strong>{showing}</strong> of{" "}
|
||||
<strong>{total?.toLocaleString()}</strong> {label}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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<AuditPageViewProps> = ({
|
||||
<Cond condition={isAuditLogVisible}>
|
||||
<AuditFilter {...filterProps} />
|
||||
|
||||
<PaginationStatus
|
||||
isLoading={Boolean(isLoading)}
|
||||
showing={auditLogs?.length ?? 0}
|
||||
total={count ?? 0}
|
||||
label="audit logs"
|
||||
/>
|
||||
<TableToolbar>
|
||||
<PaginationStatus
|
||||
isLoading={Boolean(isLoading)}
|
||||
showing={auditLogs?.length ?? 0}
|
||||
total={count ?? 0}
|
||||
label="audit logs"
|
||||
/>
|
||||
</TableToolbar>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
|
||||
@@ -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 = () => {
|
||||
}}
|
||||
/>
|
||||
</Maybe>
|
||||
<PaginationStatus
|
||||
isLoading={Boolean(isLoading)}
|
||||
showing={group?.members.length ?? 0}
|
||||
total={group?.members.length ?? 0}
|
||||
label="members"
|
||||
/>
|
||||
<TableToolbar>
|
||||
<PaginationStatus
|
||||
isLoading={Boolean(isLoading)}
|
||||
showing={group?.members.length ?? 0}
|
||||
total={group?.members.length ?? 0}
|
||||
label="members"
|
||||
/>
|
||||
</TableToolbar>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
|
||||
@@ -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<React.PropsWithChildren<UsersPageViewProps>> = ({
|
||||
<>
|
||||
<UsersFilter {...filterProps} />
|
||||
|
||||
<PaginationStatus
|
||||
isLoading={Boolean(isLoading)}
|
||||
showing={users?.length ?? 0}
|
||||
total={count ?? 0}
|
||||
label="users"
|
||||
/>
|
||||
<TableToolbar>
|
||||
<PaginationStatus
|
||||
isLoading={Boolean(isLoading)}
|
||||
showing={users?.length ?? 0}
|
||||
total={count ?? 0}
|
||||
label="users"
|
||||
/>
|
||||
</TableToolbar>
|
||||
|
||||
<UsersTable
|
||||
users={users}
|
||||
|
||||
@@ -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(<WorkspacesPage />)
|
||||
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",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<Workspace[]>([])
|
||||
@@ -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<Workspace[]>([])
|
||||
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 = () => {
|
||||
</Helmet>
|
||||
|
||||
<WorkspacesPageView
|
||||
isWorkspaceBatchActionsEnabled={isWorkspaceBatchActionsEnabled}
|
||||
checkedWorkspaces={checkedWorkspaces}
|
||||
onCheckChange={setCheckedWorkspaces}
|
||||
workspaces={data?.workspaces}
|
||||
lockedWorkspaces={lockedWorkspaces}
|
||||
error={error}
|
||||
@@ -76,6 +100,21 @@ const WorkspacesPage: FC = () => {
|
||||
onUpdateWorkspace={(workspace) => {
|
||||
updateWorkspace.mutate(workspace)
|
||||
}}
|
||||
onDeleteAll={() => {
|
||||
setIsDeletingAll(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
<BatchDeleteConfirmation
|
||||
checkedWorkspaces={checkedWorkspaces}
|
||||
open={isDeletingAll}
|
||||
onClose={() => {
|
||||
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 (
|
||||
<ConfirmDialog
|
||||
type="delete"
|
||||
open={open}
|
||||
confirmLoading={isDeleting}
|
||||
onConfirm={confirmDeletion}
|
||||
onClose={() => {
|
||||
onClose()
|
||||
setConfirmValue("")
|
||||
setConfirmError(false)
|
||||
}}
|
||||
title={`Delete ${checkedWorkspaces?.length} ${
|
||||
checkedWorkspaces.length === 1 ? "workspace" : "workspaces"
|
||||
}`}
|
||||
description={
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault()
|
||||
await confirmDeletion()
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
Deleting these workspaces is irreversible! Are you sure you want to
|
||||
proceed? Type{" "}
|
||||
<Box
|
||||
component="code"
|
||||
sx={{
|
||||
fontFamily: MONOSPACE_FONT_FAMILY,
|
||||
color: (theme) => theme.palette.text.primary,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
`DELETE`
|
||||
</Box>{" "}
|
||||
to confirm.
|
||||
</Box>
|
||||
<TextField
|
||||
value={confirmValue}
|
||||
required
|
||||
autoFocus
|
||||
fullWidth
|
||||
inputProps={{
|
||||
"aria-label": "Type DELETE to confirm",
|
||||
}}
|
||||
placeholder="Type DELETE to confirm"
|
||||
sx={{ mt: 2 }}
|
||||
onChange={(e) => {
|
||||
setConfirmValue(e.currentTarget.value)
|
||||
}}
|
||||
error={confirmError}
|
||||
helperText={confirmError && "Please type DELETE to confirm"}
|
||||
/>
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ const meta: Meta<typeof WorkspacesPageView> = {
|
||||
args: {
|
||||
limit: DEFAULT_RECORDS_PER_PAGE,
|
||||
filterProps: defaultFilterProps,
|
||||
checkedWorkspaces: [],
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
|
||||
@@ -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<typeof WorkspacesFilter>
|
||||
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<
|
||||
<WorkspacesFilter error={error} {...filterProps} />
|
||||
</Stack>
|
||||
|
||||
<PaginationStatus
|
||||
isLoading={!workspaces && !error}
|
||||
showing={workspaces?.length ?? 0}
|
||||
total={count ?? 0}
|
||||
label="workspaces"
|
||||
/>
|
||||
<TableToolbar>
|
||||
{checkedWorkspaces.length > 0 ? (
|
||||
<>
|
||||
<Box>
|
||||
Selected <strong>{checkedWorkspaces.length}</strong> of{" "}
|
||||
<strong>{workspaces?.length}</strong>{" "}
|
||||
{workspaces?.length === 1 ? "workspace" : "workspaces"}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ marginLeft: "auto" }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<DeleteOutlined />}
|
||||
onClick={onDeleteAll}
|
||||
>
|
||||
Delete all
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<PaginationStatus
|
||||
isLoading={!workspaces && !error}
|
||||
showing={workspaces?.length ?? 0}
|
||||
total={count ?? 0}
|
||||
label="workspaces"
|
||||
/>
|
||||
)}
|
||||
</TableToolbar>
|
||||
|
||||
<WorkspacesTable
|
||||
workspaces={workspaces}
|
||||
isUsingFilter={filterProps.filter.used}
|
||||
onUpdateWorkspace={onUpdateWorkspace}
|
||||
checkedWorkspaces={checkedWorkspaces}
|
||||
onCheckChange={onCheckChange}
|
||||
isWorkspaceBatchActionsEnabled={isWorkspaceBatchActionsEnabled}
|
||||
/>
|
||||
{count !== undefined && (
|
||||
<PaginationWidgetBase
|
||||
|
||||
@@ -31,18 +31,25 @@ import { LastUsed } from "components/LastUsed/LastUsed"
|
||||
import { WorkspaceOutdatedTooltip } from "components/Tooltips"
|
||||
import { WorkspaceStatusBadge } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge"
|
||||
import { getDisplayWorkspaceTemplateName } from "utils/workspace"
|
||||
import Checkbox from "@mui/material/Checkbox"
|
||||
|
||||
export interface WorkspacesTableProps {
|
||||
workspaces?: Workspace[]
|
||||
isUsingFilter: boolean
|
||||
onUpdateWorkspace: (workspace: Workspace) => void
|
||||
checkedWorkspaces: Workspace[]
|
||||
error?: unknown
|
||||
isUsingFilter: boolean
|
||||
isWorkspaceBatchActionsEnabled?: boolean
|
||||
onUpdateWorkspace: (workspace: Workspace) => void
|
||||
onCheckChange: (checkedWorkspaces: Workspace[]) => void
|
||||
}
|
||||
|
||||
export const WorkspacesTable: FC<WorkspacesTableProps> = ({
|
||||
workspaces,
|
||||
checkedWorkspaces,
|
||||
isUsingFilter,
|
||||
isWorkspaceBatchActionsEnabled,
|
||||
onUpdateWorkspace,
|
||||
onCheckChange,
|
||||
}) => {
|
||||
const { t } = useTranslation("workspacesPage")
|
||||
const styles = useStyles()
|
||||
@@ -52,7 +59,37 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="40%">Name</TableCell>
|
||||
{isWorkspaceBatchActionsEnabled ? (
|
||||
<TableCell
|
||||
width="40%"
|
||||
sx={{
|
||||
paddingLeft: (theme) => `${theme.spacing(1.5)} !important`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Checkbox
|
||||
disabled={!workspaces || workspaces.length === 0}
|
||||
checked={checkedWorkspaces.length === workspaces?.length}
|
||||
size="small"
|
||||
onChange={(_, checked) => {
|
||||
if (!workspaces) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!checked) {
|
||||
onCheckChange([])
|
||||
} else {
|
||||
onCheckChange(workspaces)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
Name
|
||||
</Box>
|
||||
</TableCell>
|
||||
) : (
|
||||
<TableCell width="40%">Name</TableCell>
|
||||
)}
|
||||
|
||||
<TableCell width="25%">Template</TableCell>
|
||||
<TableCell width="20%">Last used</TableCell>
|
||||
<TableCell width="15%">Status</TableCell>
|
||||
@@ -92,71 +129,117 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
|
||||
</ChooseOne>
|
||||
)}
|
||||
{workspaces &&
|
||||
workspaces.map((workspace) => (
|
||||
<WorkspacesRow workspace={workspace} key={workspace.id}>
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
title={
|
||||
<Stack direction="row" spacing={0} alignItems="center">
|
||||
{workspace.name}
|
||||
{workspace.outdated && (
|
||||
<WorkspaceOutdatedTooltip
|
||||
templateName={workspace.template_name}
|
||||
templateId={workspace.template_id}
|
||||
onUpdateVersion={() => {
|
||||
onUpdateWorkspace(workspace)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
subtitle={workspace.owner_name}
|
||||
avatar={
|
||||
<Avatar
|
||||
src={workspace.template_icon}
|
||||
variant={workspace.template_icon ? "square" : undefined}
|
||||
fitImage={Boolean(workspace.template_icon)}
|
||||
>
|
||||
{workspace.name}
|
||||
</Avatar>
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{getDisplayWorkspaceTemplateName(workspace)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<LastUsed lastUsedAt={workspace.last_used_at} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<WorkspaceStatusBadge workspace={workspace} />
|
||||
{workspace.latest_build.status === "running" &&
|
||||
!workspace.health.healthy && <UnhealthyTooltip />}
|
||||
</Box>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Box
|
||||
workspaces.map((workspace) => {
|
||||
const checked = checkedWorkspaces.some(
|
||||
(w) => w.id === workspace.id,
|
||||
)
|
||||
return (
|
||||
<WorkspacesRow
|
||||
workspace={workspace}
|
||||
key={workspace.id}
|
||||
checked={checked}
|
||||
>
|
||||
<TableCell
|
||||
sx={{
|
||||
display: "flex",
|
||||
paddingLeft: (theme) => theme.spacing(2),
|
||||
paddingLeft: (theme) =>
|
||||
isWorkspaceBatchActionsEnabled
|
||||
? `${theme.spacing(1.5)} !important`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<KeyboardArrowRight
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{isWorkspaceBatchActionsEnabled && (
|
||||
<Checkbox
|
||||
data-testid={`checkbox-${workspace.id}`}
|
||||
size="small"
|
||||
disabled={cantBeChecked(workspace)}
|
||||
checked={checked}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (e.currentTarget.checked) {
|
||||
onCheckChange([...checkedWorkspaces, workspace])
|
||||
} else {
|
||||
onCheckChange(
|
||||
checkedWorkspaces.filter(
|
||||
(w) => w.id !== workspace.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<AvatarData
|
||||
title={
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={0}
|
||||
alignItems="center"
|
||||
>
|
||||
{workspace.name}
|
||||
{workspace.outdated && (
|
||||
<WorkspaceOutdatedTooltip
|
||||
templateName={workspace.template_name}
|
||||
templateId={workspace.template_id}
|
||||
onUpdateVersion={() => {
|
||||
onUpdateWorkspace(workspace)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
subtitle={workspace.owner_name}
|
||||
avatar={
|
||||
<Avatar
|
||||
src={workspace.template_icon}
|
||||
variant={
|
||||
workspace.template_icon ? "square" : undefined
|
||||
}
|
||||
fitImage={Boolean(workspace.template_icon)}
|
||||
>
|
||||
{workspace.name}
|
||||
</Avatar>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{getDisplayWorkspaceTemplateName(workspace)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<LastUsed lastUsedAt={workspace.last_used_at} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<WorkspaceStatusBadge workspace={workspace} />
|
||||
{workspace.latest_build.status === "running" &&
|
||||
!workspace.health.healthy && <UnhealthyTooltip />}
|
||||
</Box>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Box
|
||||
sx={{
|
||||
color: (theme) => theme.palette.text.secondary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
display: "flex",
|
||||
paddingLeft: (theme) => theme.spacing(2),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</WorkspacesRow>
|
||||
))}
|
||||
>
|
||||
<KeyboardArrowRight
|
||||
sx={{
|
||||
color: (theme) => theme.palette.text.secondary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</WorkspacesRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
@@ -166,7 +249,8 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
|
||||
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 (
|
||||
<TableRow data-testid={`workspace-${workspace.id}`} {...clickable}>
|
||||
<TableRow
|
||||
data-testid={`workspace-${workspace.id}`}
|
||||
{...clickable}
|
||||
sx={{
|
||||
backgroundColor: (theme) =>
|
||||
checked ? theme.palette.action.hover : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TableRow>
|
||||
)
|
||||
@@ -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],
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -384,6 +384,15 @@ dark = createTheme(dark, {
|
||||
disableRipple: true,
|
||||
},
|
||||
},
|
||||
MuiCheckbox: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
"&.Mui-disabled": {
|
||||
color: colors.gray[11],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiSwitch: {
|
||||
defaultProps: {
|
||||
color: "primary",
|
||||
|
||||
Reference in New Issue
Block a user