diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9a156fd3131b3..90c3b3865b224 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 83b0ccb481940..6bcce9d2da651 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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": { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 75fca76863eef..eda1f615766a5 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -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. @@ -1941,9 +1943,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" ) @@ -1954,7 +1953,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. diff --git a/docs/api/schemas.md b/docs/api/schemas.md index c004bca3e5dec..d331772814d28 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2719,7 +2719,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 diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0df30b3ab14b5..3b0bc68d915f8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1605,7 +1605,6 @@ export type Experiment = | "tailnet_pg_coordinator" | "template_autostop_requirement" | "workspace_actions" - | "workspaces_batch_actions" export const Experiments: Experiment[] = [ "deployment_health_page", "moons", @@ -1613,7 +1612,6 @@ export const Experiments: Experiment[] = [ "tailnet_pg_coordinator", "template_autostop_requirement", "workspace_actions", - "workspaces_batch_actions", ] // From codersdk/deployment.go @@ -1630,6 +1628,7 @@ export type FeatureName = | "template_rbac" | "user_limit" | "user_role_management" + | "workspace_batch_actions" | "workspace_proxy" export const FeatureNames: FeatureName[] = [ "advanced_template_scheduling", @@ -1644,6 +1643,7 @@ export const FeatureNames: FeatureName[] = [ "template_rbac", "user_limit", "user_role_management", + "workspace_batch_actions", "workspace_proxy", ] diff --git a/site/src/components/TableLoader/TableLoader.tsx b/site/src/components/TableLoader/TableLoader.tsx index 9c569f08b208f..70851ce61af03 100644 --- a/site/src/components/TableLoader/TableLoader.tsx +++ b/site/src/components/TableLoader/TableLoader.tsx @@ -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) - + children: ReactNode +}) => { + if (!isValidElement(children)) { + throw new Error( + "TableLoaderSkeleton children must be a valid React element", + ) + } return ( <> - {placeholderRows.map((_, rowIndex) => ( - - {placeholderColumns.map((_, columnIndex) => { - if (useAvatarData && columnIndex === 0) { - return ( - - - - ) - } - - return ( - - - - ) - })} - - ))} + {Array.from({ length: rows }, (_, i) => + cloneElement(children, { key: i }), + )} ) } + +export const TableRowSkeleton = (props: TableRowProps) => { + return +} diff --git a/site/src/components/UsersTable/UsersTableBody.tsx b/site/src/components/UsersTable/UsersTableBody.tsx index 73abfd639a273..c5bcae9c147ee 100644 --- a/site/src/components/UsersTable/UsersTableBody.tsx +++ b/site/src/components/UsersTable/UsersTableBody.tsx @@ -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 ( - + + + + + + + + + + + + + + + + + {canEditUsers && ( + + + + )} + + diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx index 1acb5e79cf769..5cac6e3bce238 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -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 = ({ - + @@ -184,6 +190,26 @@ export const GroupsPageView: FC = ({ ) } +const TableLoader = () => { + return ( + + + + + + + + + + + + + + + + ) +} + const useStyles = makeStyles((theme) => ({ clickableTableRow: { cursor: "pointer", diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index 77a8edad916a1..4aad4c16b6603 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -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< - + @@ -222,6 +228,32 @@ export const TemplatesPageView: FC< ) } +const TableLoader = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} + const useStyles = makeStyles((theme) => ({ templateIconWrapper: { // Same size then the avatar component diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index 6abb3d06e2310..9b03d98d80d0f 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -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")) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 9684e156c8ce7..ad3bf1d00aa6b 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -68,10 +68,9 @@ 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" + 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 = () => { { setConfirmError(false) - if (confirmValue.toLowerCase() !== "delete") { + if (confirmValue !== "DELETE") { setConfirmError(true) return } diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index f688cf3508ce1..42f9b83e5ad27 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -97,6 +97,7 @@ const meta: Meta = { limit: DEFAULT_RECORDS_PER_PAGE, filterProps: defaultFilterProps, checkedWorkspaces: [], + canCheckWorkspaces: true, }, decorators: [ (Story) => ( diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index cb3a8cd2f2e62..16563d1a0235c 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -44,11 +44,11 @@ export interface WorkspacesPageViewProps { filterProps: ComponentProps 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={} onClick={onDeleteAll} > - Delete all + Delete selected @@ -151,7 +151,7 @@ export const WorkspacesPageView: FC< onUpdateWorkspace={onUpdateWorkspace} checkedWorkspaces={checkedWorkspaces} onCheckChange={onCheckChange} - isWorkspaceBatchActionsEnabled={isWorkspaceBatchActionsEnabled} + canCheckWorkspaces={canCheckWorkspaces} /> {count !== undefined && ( void onCheckChange: (checkedWorkspaces: Workspace[]) => void + canCheckWorkspaces: boolean } export const WorkspacesTable: FC = ({ workspaces, checkedWorkspaces, isUsingFilter, - isWorkspaceBatchActionsEnabled, onUpdateWorkspace, onCheckChange, + canCheckWorkspaces, }) => { const { t } = useTranslation("workspacesPage") const styles = useStyles() @@ -59,15 +64,13 @@ export const WorkspacesTable: FC = ({ - {isWorkspaceBatchActionsEnabled ? ( - `${theme.spacing(1.5)} !important`, - }} - > - + + + {canCheckWorkspaces && ( = ({ } }} /> - Name - - - ) : ( - Name - )} - + )} + Name + + Template Last used Status @@ -97,7 +97,7 @@ export const WorkspacesTable: FC = ({ - {!workspaces && } + {!workspaces && } {workspaces && workspaces.length === 0 && ( @@ -139,17 +139,13 @@ export const WorkspacesTable: FC = ({ key={workspace.id} checked={checked} > - - isWorkspaceBatchActionsEnabled - ? `${theme.spacing(1.5)} !important` - : undefined, - }} - > + - {isWorkspaceBatchActionsEnabled && ( + {canCheckWorkspaces && ( { ) } +const TableLoader = () => { + return ( + + + `${theme.spacing(1.5)} !important`, + }} + > + + + + + + + + + + + + + + + + + + + + ) +} + const cantBeChecked = (workspace: Workspace) => { return ["deleting", "pending"].includes(workspace.latest_build.status) } diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 78ff1537c525d..3c34379864910 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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 = {