feat(site): make workspace batch deletion GA (#9313)

This commit is contained in:
Bruno Quaresma
2023-08-30 10:08:42 -03:00
committed by GitHub
parent 90acf998bf
commit 2399063a56
15 changed files with 192 additions and 92 deletions

6
coderd/apidoc/docs.go generated
View File

@ -8125,8 +8125,7 @@ const docTemplate = `{
"tailnet_pg_coordinator",
"single_tailnet",
"template_autostop_requirement",
"deployment_health_page",
"workspaces_batch_actions"
"deployment_health_page"
],
"x-enum-varnames": [
"ExperimentMoons",
@ -8134,8 +8133,7 @@ const docTemplate = `{
"ExperimentTailnetPGCoordinator",
"ExperimentSingleTailnet",
"ExperimentTemplateAutostopRequirement",
"ExperimentDeploymentHealthPage",
"ExperimentWorkspacesBatchActions"
"ExperimentDeploymentHealthPage"
]
},
"codersdk.Feature": {

View File

@ -7276,8 +7276,7 @@
"tailnet_pg_coordinator",
"single_tailnet",
"template_autostop_requirement",
"deployment_health_page",
"workspaces_batch_actions"
"deployment_health_page"
],
"x-enum-varnames": [
"ExperimentMoons",
@ -7285,8 +7284,7 @@
"ExperimentTailnetPGCoordinator",
"ExperimentSingleTailnet",
"ExperimentTemplateAutostopRequirement",
"ExperimentDeploymentHealthPage",
"ExperimentWorkspacesBatchActions"
"ExperimentDeploymentHealthPage"
]
},
"codersdk.Feature": {

View File

@ -48,6 +48,7 @@ const (
FeatureAdvancedTemplateScheduling FeatureName = "advanced_template_scheduling"
FeatureTemplateAutostopRequirement FeatureName = "template_autostop_requirement"
FeatureWorkspaceProxy FeatureName = "workspace_proxy"
FeatureWorkspaceBatchActions FeatureName = "workspace_batch_actions"
)
// FeatureNames must be kept in-sync with the Feature enum above.
@ -64,6 +65,7 @@ var FeatureNames = []FeatureName{
FeatureAdvancedTemplateScheduling,
FeatureWorkspaceProxy,
FeatureUserRoleManagement,
FeatureWorkspaceBatchActions,
}
// Humanize returns the feature name in a human-readable format.
@ -1958,9 +1960,6 @@ const (
// Deployment health page
ExperimentDeploymentHealthPage Experiment = "deployment_health_page"
// Workspaces batch actions
ExperimentWorkspacesBatchActions Experiment = "workspaces_batch_actions"
// Add new experiments here!
// ExperimentExample Experiment = "example"
)
@ -1971,7 +1970,6 @@ const (
// not be included here and will be essentially hidden.
var ExperimentsAll = Experiments{
ExperimentDeploymentHealthPage,
ExperimentWorkspacesBatchActions,
}
// Experiments is a list of experiments that are enabled for the deployment.

1
docs/api/schemas.md generated
View File

@ -2721,7 +2721,6 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `single_tailnet` |
| `template_autostop_requirement` |
| `deployment_health_page` |
| `workspaces_batch_actions` |
## codersdk.Feature

View File

@ -1606,7 +1606,6 @@ export type Experiment =
| "tailnet_pg_coordinator"
| "template_autostop_requirement"
| "workspace_actions"
| "workspaces_batch_actions"
export const Experiments: Experiment[] = [
"deployment_health_page",
"moons",
@ -1614,7 +1613,6 @@ export const Experiments: Experiment[] = [
"tailnet_pg_coordinator",
"template_autostop_requirement",
"workspace_actions",
"workspaces_batch_actions",
]
// From codersdk/deployment.go
@ -1631,6 +1629,7 @@ export type FeatureName =
| "template_rbac"
| "user_limit"
| "user_role_management"
| "workspace_batch_actions"
| "workspace_proxy"
export const FeatureNames: FeatureName[] = [
"advanced_template_scheduling",
@ -1645,6 +1644,7 @@ export const FeatureNames: FeatureName[] = [
"template_rbac",
"user_limit",
"user_role_management",
"workspace_batch_actions",
"workspace_proxy",
]

View File

@ -1,9 +1,7 @@
import { makeStyles } from "@mui/styles"
import TableCell from "@mui/material/TableCell"
import TableRow from "@mui/material/TableRow"
import Skeleton from "@mui/material/Skeleton"
import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton"
import { FC } from "react"
import TableRow, { TableRowProps } from "@mui/material/TableRow"
import { FC, ReactNode, cloneElement, isValidElement } from "react"
import { Loader } from "../Loader/Loader"
export const TableLoader: FC = () => {
@ -25,35 +23,27 @@ const useStyles = makeStyles((theme) => ({
},
}))
export const TableLoaderSkeleton: FC<{
columns: number
export const TableLoaderSkeleton = ({
rows = 4,
children,
}: {
rows?: number
useAvatarData?: boolean
}> = ({ columns, rows = 4, useAvatarData = false }) => {
const placeholderColumns = Array(columns).fill(undefined)
const placeholderRows = Array(rows).fill(undefined)
return (
<>
{placeholderRows.map((_, rowIndex) => (
<TableRow key={rowIndex} role="progressbar" data-testid="loader">
{placeholderColumns.map((_, columnIndex) => {
if (useAvatarData && columnIndex === 0) {
return (
<TableCell key={columnIndex}>
<AvatarDataSkeleton />
</TableCell>
children: ReactNode
}) => {
if (!isValidElement(children)) {
throw new Error(
"TableLoaderSkeleton children must be a valid React element",
)
}
return (
<TableCell key={columnIndex}>
<Skeleton variant="text" width="25%" />
</TableCell>
)
})}
</TableRow>
))}
<>
{Array.from({ length: rows }, (_, i) =>
cloneElement(children, { key: i }),
)}
</>
)
}
export const TableRowSkeleton = (props: TableRowProps) => {
return <TableRow role="progressbar" data-testid="loader" {...props} />
}

View File

@ -10,7 +10,10 @@ import * as TypesGen from "../../api/typesGenerated"
import { combineClasses } from "../../utils/combineClasses"
import { AvatarData } from "../AvatarData/AvatarData"
import { EmptyState } from "../EmptyState/EmptyState"
import { TableLoaderSkeleton } from "../TableLoader/TableLoader"
import {
TableLoaderSkeleton,
TableRowSkeleton,
} from "../TableLoader/TableLoader"
import { TableRowMenu } from "../TableRowMenu/TableRowMenu"
import { EditRolesButton } from "components/EditRolesButton/EditRolesButton"
import { Stack } from "components/Stack/Stack"
@ -23,6 +26,8 @@ import GitHub from "@mui/icons-material/GitHub"
import PasswordOutlined from "@mui/icons-material/PasswordOutlined"
import relativeTime from "dayjs/plugin/relativeTime"
import ShieldOutlined from "@mui/icons-material/ShieldOutlined"
import Skeleton from "@mui/material/Skeleton"
import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton"
dayjs.extend(relativeTime)
@ -91,7 +96,29 @@ export const UsersTableBody: FC<
return (
<ChooseOne>
<Cond condition={Boolean(isLoading)}>
<TableLoaderSkeleton columns={canEditUsers ? 5 : 4} useAvatarData />
<TableLoaderSkeleton>
<TableRowSkeleton>
<TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<AvatarDataSkeleton />
</Box>
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
{canEditUsers && (
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
)}
</TableRowSkeleton>
</TableLoaderSkeleton>
</Cond>
<Cond condition={!users || users.length === 0}>
<ChooseOne>

View File

@ -15,7 +15,10 @@ import { AvatarData } from "components/AvatarData/AvatarData"
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
import { EmptyState } from "components/EmptyState/EmptyState"
import { Stack } from "components/Stack/Stack"
import { TableLoaderSkeleton } from "components/TableLoader/TableLoader"
import {
TableLoaderSkeleton,
TableRowSkeleton,
} from "components/TableLoader/TableLoader"
import { UserAvatar } from "components/UserAvatar/UserAvatar"
import { FC } from "react"
import { Link as RouterLink, useNavigate } from "react-router-dom"
@ -23,6 +26,9 @@ import { Paywall } from "components/Paywall/Paywall"
import { Group } from "api/typesGenerated"
import { GroupAvatar } from "components/GroupAvatar/GroupAvatar"
import { docs } from "utils/docs"
import Skeleton from "@mui/material/Skeleton"
import { Box } from "@mui/system"
import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton"
export type GroupsPageViewProps = {
groups: Group[] | undefined
@ -83,7 +89,7 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
<TableBody>
<ChooseOne>
<Cond condition={isLoading}>
<TableLoaderSkeleton columns={3} useAvatarData />
<TableLoader />
</Cond>
<Cond condition={isEmpty}>
@ -184,6 +190,26 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
)
}
const TableLoader = () => {
return (
<TableLoaderSkeleton>
<TableRowSkeleton>
<TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<AvatarDataSkeleton />
</Box>
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
</TableRowSkeleton>
</TableLoaderSkeleton>
)
}
const useStyles = makeStyles((theme) => ({
clickableTableRow: {
cursor: "pointer",

View File

@ -24,7 +24,10 @@ import {
PageHeaderTitle,
} from "../../components/PageHeader/PageHeader"
import { Stack } from "../../components/Stack/Stack"
import { TableLoaderSkeleton } from "../../components/TableLoader/TableLoader"
import {
TableLoaderSkeleton,
TableRowSkeleton,
} from "../../components/TableLoader/TableLoader"
import {
HelpTooltip,
HelpTooltipLink,
@ -42,6 +45,9 @@ import ArrowForwardOutlined from "@mui/icons-material/ArrowForwardOutlined"
import { Avatar } from "components/Avatar/Avatar"
import { ErrorAlert } from "components/Alert/ErrorAlert"
import { docs } from "utils/docs"
import Skeleton from "@mui/material/Skeleton"
import { Box } from "@mui/system"
import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton"
export const Language = {
developerCount: (activeCount: number): string => {
@ -196,7 +202,7 @@ export const TemplatesPageView: FC<
</TableHead>
<TableBody>
<Maybe condition={isLoading}>
<TableLoaderSkeleton columns={5} useAvatarData />
<TableLoader />
</Maybe>
<ChooseOne>
@ -222,6 +228,32 @@ export const TemplatesPageView: FC<
)
}
const TableLoader = () => {
return (
<TableLoaderSkeleton>
<TableRowSkeleton>
<TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<AvatarDataSkeleton />
</Box>
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
</TableRowSkeleton>
</TableLoaderSkeleton>
)
}
const useStyles = makeStyles((theme) => ({
templateIconWrapper: {
// Same size then the avatar component

View File

@ -63,7 +63,7 @@ describe("WorkspacesPage", () => {
await user.click(getWorkspaceCheckbox(workspaces[0]))
await user.click(getWorkspaceCheckbox(workspaces[1]))
await user.click(screen.getByRole("button", { name: /delete all/i }))
await user.click(screen.getByRole("button", { name: /delete selected/i }))
await user.type(screen.getByLabelText(/type delete to confirm/i), "DELETE")
await user.click(screen.getByTestId("confirm-button"))

View File

@ -68,10 +68,9 @@ const WorkspacesPage: FC = () => {
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"
const { entitlements } = useDashboard()
const canCheckWorkspaces =
entitlements.features["workspace_batch_actions"].enabled
// We want to uncheck the selected workspaces always when the url changes
// because of filtering or pagination
@ -86,9 +85,9 @@ const WorkspacesPage: FC = () => {
</Helmet>
<WorkspacesPageView
isWorkspaceBatchActionsEnabled={isWorkspaceBatchActionsEnabled}
checkedWorkspaces={checkedWorkspaces}
onCheckChange={setCheckedWorkspaces}
canCheckWorkspaces={canCheckWorkspaces}
workspaces={data?.workspaces}
dormantWorkspaces={dormantWorkspaces}
error={error}
@ -198,7 +197,7 @@ const BatchDeleteConfirmation = ({
const confirmDeletion = async () => {
setConfirmError(false)
if (confirmValue.toLowerCase() !== "delete") {
if (confirmValue !== "DELETE") {
setConfirmError(true)
return
}

View File

@ -97,6 +97,7 @@ const meta: Meta<typeof WorkspacesPageView> = {
limit: DEFAULT_RECORDS_PER_PAGE,
filterProps: defaultFilterProps,
checkedWorkspaces: [],
canCheckWorkspaces: true,
},
decorators: [
(Story) => (

View File

@ -44,11 +44,11 @@ export interface WorkspacesPageViewProps {
filterProps: ComponentProps<typeof WorkspacesFilter>
page: number
limit: number
isWorkspaceBatchActionsEnabled?: boolean
onPageChange: (page: number) => void
onUpdateWorkspace: (workspace: Workspace) => void
onCheckChange: (checkedWorkspaces: Workspace[]) => void
onDeleteAll: () => void
canCheckWorkspaces: boolean
}
export const WorkspacesPageView: FC<
@ -64,9 +64,9 @@ export const WorkspacesPageView: FC<
onUpdateWorkspace,
page,
checkedWorkspaces,
isWorkspaceBatchActionsEnabled,
onCheckChange,
onDeleteAll,
canCheckWorkspaces,
}) => {
const { saveLocal } = useLocalStorage()
@ -131,7 +131,7 @@ export const WorkspacesPageView: FC<
startIcon={<DeleteOutlined />}
onClick={onDeleteAll}
>
Delete all
Delete selected
</Button>
</Box>
</>
@ -151,7 +151,7 @@ export const WorkspacesPageView: FC<
onUpdateWorkspace={onUpdateWorkspace}
checkedWorkspaces={checkedWorkspaces}
onCheckChange={onCheckChange}
isWorkspaceBatchActionsEnabled={isWorkspaceBatchActionsEnabled}
canCheckWorkspaces={canCheckWorkspaces}
/>
{count !== undefined && (
<PaginationWidgetBase

View File

@ -8,7 +8,10 @@ import { Workspace } from "api/typesGenerated"
import { FC, ReactNode } from "react"
import { TableEmpty } from "components/TableEmpty/TableEmpty"
import { useTranslation } from "react-i18next"
import { TableLoaderSkeleton } from "components/TableLoader/TableLoader"
import {
TableLoaderSkeleton,
TableRowSkeleton,
} from "components/TableLoader/TableLoader"
import AddOutlined from "@mui/icons-material/AddOutlined"
import Button from "@mui/material/Button"
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
@ -32,24 +35,26 @@ import { WorkspaceOutdatedTooltip } from "components/Tooltips"
import { WorkspaceStatusBadge } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge"
import { getDisplayWorkspaceTemplateName } from "utils/workspace"
import Checkbox from "@mui/material/Checkbox"
import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton"
import Skeleton from "@mui/material/Skeleton"
export interface WorkspacesTableProps {
workspaces?: Workspace[]
checkedWorkspaces: Workspace[]
error?: unknown
isUsingFilter: boolean
isWorkspaceBatchActionsEnabled?: boolean
onUpdateWorkspace: (workspace: Workspace) => void
onCheckChange: (checkedWorkspaces: Workspace[]) => void
canCheckWorkspaces: boolean
}
export const WorkspacesTable: FC<WorkspacesTableProps> = ({
workspaces,
checkedWorkspaces,
isUsingFilter,
isWorkspaceBatchActionsEnabled,
onUpdateWorkspace,
onCheckChange,
canCheckWorkspaces,
}) => {
const { t } = useTranslation("workspacesPage")
const styles = useStyles()
@ -59,15 +64,13 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
<Table>
<TableHead>
<TableRow>
{isWorkspaceBatchActionsEnabled ? (
<TableCell
width="40%"
sx={{
paddingLeft: (theme) => `${theme.spacing(1.5)} !important`,
}}
>
<TableCell width="40%">
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{canCheckWorkspaces && (
<Checkbox
// Remove the extra padding added for the first cell in the
// table
sx={{ marginLeft: "-20px" }}
disabled={!workspaces || workspaces.length === 0}
checked={checkedWorkspaces.length === workspaces?.length}
size="small"
@ -83,13 +86,10 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
}
}}
/>
)}
Name
</Box>
</TableCell>
) : (
<TableCell width="40%">Name</TableCell>
)}
<TableCell width="25%">Template</TableCell>
<TableCell width="20%">Last used</TableCell>
<TableCell width="15%">Status</TableCell>
@ -97,7 +97,7 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
</TableRow>
</TableHead>
<TableBody>
{!workspaces && <TableLoaderSkeleton columns={5} useAvatarData />}
{!workspaces && <TableLoader />}
{workspaces && workspaces.length === 0 && (
<ChooseOne>
<Cond condition={isUsingFilter}>
@ -139,17 +139,13 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
key={workspace.id}
checked={checked}
>
<TableCell
sx={{
paddingLeft: (theme) =>
isWorkspaceBatchActionsEnabled
? `${theme.spacing(1.5)} !important`
: undefined,
}}
>
<TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{isWorkspaceBatchActionsEnabled && (
{canCheckWorkspaces && (
<Checkbox
// Remove the extra padding added for the first cell in the
// table
sx={{ marginLeft: "-20px" }}
data-testid={`checkbox-${workspace.id}`}
size="small"
disabled={cantBeChecked(workspace)}
@ -289,6 +285,38 @@ export const UnhealthyTooltip = () => {
)
}
const TableLoader = () => {
return (
<TableLoaderSkeleton>
<TableRowSkeleton>
<TableCell
width="40%"
sx={{
paddingLeft: (theme) => `${theme.spacing(1.5)} !important`,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Checkbox size="small" disabled />
<AvatarDataSkeleton />
</Box>
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
</TableRowSkeleton>
</TableLoaderSkeleton>
)
}
const cantBeChecked = (workspace: Workspace) => {
return ["deleting", "pending"].includes(workspace.latest_build.status)
}

View File

@ -1757,7 +1757,12 @@ export const MockEntitlements: TypesGen.Entitlements = {
errors: [],
warnings: [],
has_license: false,
features: withDefaultFeatures({}),
features: withDefaultFeatures({
workspace_batch_actions: {
enabled: true,
entitlement: "entitled",
},
}),
require_telemetry: false,
trial: false,
refreshed_at: "2022-05-20T16:45:57.122Z",
@ -1821,7 +1826,6 @@ export const MockEntitlementsWithScheduling: TypesGen.Entitlements = {
export const MockExperiments: TypesGen.Experiment[] = [
"workspace_actions",
"moons",
"workspaces_batch_actions",
]
export const MockAuditLog: TypesGen.AuditLog = {