diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 688ba0432e22b..85a9860bc57c5 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -482,10 +482,10 @@ class ApiMethods { return response.data; }; - checkAuthorization = async ( + checkAuthorization = async ( params: TypesGen.AuthorizationRequest, - ): Promise => { - const response = await this.axios.post( + ) => { + const response = await this.axios.post( "/api/v2/authcheck", params, ); diff --git a/site/src/api/queries/authCheck.ts b/site/src/api/queries/authCheck.ts index 11f5fafa7d25a..49b08a0e869ca 100644 --- a/site/src/api/queries/authCheck.ts +++ b/site/src/api/queries/authCheck.ts @@ -1,14 +1,19 @@ import { API } from "api/api"; -import type { AuthorizationRequest } from "api/typesGenerated"; +import type { + AuthorizationRequest, + AuthorizationResponse, +} from "api/typesGenerated"; const AUTHORIZATION_KEY = "authorization"; export const getAuthorizationKey = (req: AuthorizationRequest) => [AUTHORIZATION_KEY, req] as const; -export const checkAuthorization = (req: AuthorizationRequest) => { +export const checkAuthorization = ( + req: AuthorizationRequest, +) => { return { queryKey: getAuthorizationKey(req), - queryFn: () => API.checkAuthorization(req), + queryFn: () => API.checkAuthorization(req), }; }; diff --git a/site/src/api/queries/deployment.ts b/site/src/api/queries/deployment.ts index 463f555d57761..4b65b20da82cc 100644 --- a/site/src/api/queries/deployment.ts +++ b/site/src/api/queries/deployment.ts @@ -6,6 +6,7 @@ export const deploymentConfig = () => { return { queryKey: deploymentConfigQueryKey, queryFn: API.getDeploymentConfig, + staleTime: Number.POSITIVE_INFINITY, }; }; diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index 72e5deaefc72a..a99eead5f1816 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -139,9 +139,14 @@ export const templateVersionByName = ( }; }; +export const templateVersionsQueryKey = (templateId: string) => [ + "templateVersions", + templateId, +]; + export const templateVersions = (templateId: string) => { return { - queryKey: ["templateVersions", templateId], + queryKey: templateVersionsQueryKey(templateId), queryFn: () => API.getTemplateVersions(templateId), }; }; diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index 39008f5c712a3..4f4b9b80cc8f9 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -11,12 +11,17 @@ import type { WorkspacesResponse, } from "api/typesGenerated"; import type { Dayjs } from "dayjs"; +import { + type WorkspacePermissions, + workspaceChecks, +} from "modules/workspaces/permissions"; import type { ConnectionStatus } from "pages/TerminalPage/types"; import type { QueryClient, QueryOptions, UseMutationOptions, } from "react-query"; +import { checkAuthorization } from "./authCheck"; import { disabledRefetchOptions } from "./util"; import { workspaceBuildsKey } from "./workspaceBuilds"; @@ -390,3 +395,14 @@ export const workspaceUsage = (options: WorkspaceUsageOptions) => { refetchIntervalInBackground: true, }; }; + +export const workspacePermissions = (workspace?: Workspace) => { + return { + ...checkAuthorization({ + checks: workspace ? workspaceChecks(workspace) : {}, + }), + queryKey: ["workspaces", workspace?.id, "permissions"], + enabled: !!workspace, + staleTime: Number.POSITIVE_INFINITY, + }; +}; diff --git a/site/src/components/DropdownMenu/DropdownMenu.tsx b/site/src/components/DropdownMenu/DropdownMenu.tsx index e56fd7cbe4343..8d9fb12d774a3 100644 --- a/site/src/components/DropdownMenu/DropdownMenu.tsx +++ b/site/src/components/DropdownMenu/DropdownMenu.tsx @@ -111,7 +111,7 @@ export const DropdownMenuItem = forwardRef< [ "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-2 text-sm text-content-secondary font-medium outline-none transition-colors", "focus:bg-surface-secondary focus:text-content-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - "[&>svg]:size-4 [&>svg]:shrink-0", + "[&>svg]:size-4 [&>svg]:shrink-0 no-underline", inset && "pl-8", ], className, diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.stories.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.stories.tsx new file mode 100644 index 0000000000000..45e85c3288292 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { templateVersionsQueryKey } from "api/queries/templates"; +import { + MockTemplateVersion, + MockTemplateVersionWithMarkdownMessage, + MockWorkspace, +} from "testHelpers/entities"; +import { ChangeWorkspaceVersionDialog } from "./ChangeWorkspaceVersionDialog"; + +const noMessage = { + ...MockTemplateVersion, + name: "no-message", + id: "no-message", + message: "", +}; + +const meta: Meta = { + title: "modules/workspaces/ChangeWorkspaceVersionDialog", + component: ChangeWorkspaceVersionDialog, + args: { + open: true, + workspace: MockWorkspace, + }, + parameters: { + queries: [ + { + key: templateVersionsQueryKey(MockWorkspace.template_id), + data: [ + MockTemplateVersion, + MockTemplateVersionWithMarkdownMessage, + noMessage, + ], + }, + ], + }, +}; + +export default meta; +type Story = StoryObj; + +export const CurrentVersion: Story = {}; + +export const NoMessage: Story = { + args: { + workspace: { + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + template_version_id: noMessage.id, + }, + }, + }, +}; + +export const TextMessage: Story = { + args: { + workspace: { + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + template_version_id: MockTemplateVersion.id, + }, + }, + }, +}; + +export const MarkdownMessage: Story = { + args: { + workspace: { + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + template_version_id: MockTemplateVersionWithMarkdownMessage.id, + }, + }, + }, +}; diff --git a/site/src/pages/WorkspacePage/ChangeVersionDialog.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.tsx similarity index 71% rename from site/src/pages/WorkspacePage/ChangeVersionDialog.tsx rename to site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.tsx index 7990295620d65..eae14607275e8 100644 --- a/site/src/pages/WorkspacePage/ChangeVersionDialog.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.tsx @@ -3,7 +3,8 @@ import AlertTitle from "@mui/material/AlertTitle"; import Autocomplete from "@mui/material/Autocomplete"; import CircularProgress from "@mui/material/CircularProgress"; import TextField from "@mui/material/TextField"; -import type { Template, TemplateVersion } from "api/typesGenerated"; +import { templateVersions } from "api/queries/templates"; +import type { TemplateVersion, Workspace } from "api/typesGenerated"; import { Alert } from "components/Alert/Alert"; import { Avatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/Avatar/AvatarData"; @@ -15,41 +16,38 @@ import { Pill } from "components/Pill/Pill"; import { Stack } from "components/Stack/Stack"; import { InfoIcon } from "lucide-react"; import { TemplateUpdateMessage } from "modules/templates/TemplateUpdateMessage"; -import { type FC, useRef, useState } from "react"; +import { type FC, useState } from "react"; +import { useQuery } from "react-query"; import { createDayString } from "utils/createDayString"; -export type ChangeVersionDialogProps = DialogProps & { - template: Template | undefined; - templateVersions: TemplateVersion[] | undefined; - defaultTemplateVersion: TemplateVersion | undefined; +export type ChangeWorkspaceVersionDialogProps = DialogProps & { + workspace: Workspace; onClose: () => void; - onConfirm: (templateVersion: TemplateVersion) => void; + onConfirm: (version: TemplateVersion) => void; }; -export const ChangeVersionDialog: FC = ({ - onConfirm, - onClose, - template, - templateVersions, - defaultTemplateVersion, - ...dialogProps -}) => { +export const ChangeWorkspaceVersionDialog: FC< + ChangeWorkspaceVersionDialogProps +> = ({ workspace, onClose, onConfirm, ...dialogProps }) => { + const { data: versions } = useQuery({ + ...templateVersions(workspace.template_id), + select: (data) => [...data].reverse(), + }); const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false); - const selectedTemplateVersion = useRef( - defaultTemplateVersion, + const currentVersion = versions?.find( + (v) => workspace.latest_build.template_version_id === v.id, ); - const version = selectedTemplateVersion.current; - const validTemplateVersions = templateVersions?.filter((version) => { - return version.job.status === "succeeded"; - }); + const [newVersion, setNewVersion] = useState(); + const validVersions = versions?.filter((v) => v.job.status === "succeeded"); + const selectedVersion = newVersion || currentVersion; return ( { - if (selectedTemplateVersion.current) { - onConfirm(selectedTemplateVersion.current); + if (newVersion) { + onConfirm(newVersion); } }} hideCancel={false} @@ -60,18 +58,17 @@ export const ChangeVersionDialog: FC = ({ description={

You are about to change the version of this workspace.

- {validTemplateVersions ? ( + {validVersions ? ( <> { - selectedTemplateVersion.current = - newTemplateVersion ?? undefined; + setNewVersion(newTemplateVersion); }} onOpen={() => { setIsAutocompleteOpen(true); @@ -112,9 +109,8 @@ export const ChangeVersionDialog: FC = ({ /> )}
- {template?.active_version_id === option.id && ( - Active - )} + {workspace.template_active_version_id === + option.id && Active} } subtitle={createDayString(option.created_at)} @@ -131,9 +127,7 @@ export const ChangeVersionDialog: FC = ({ ...params.InputProps, endAdornment: ( <> - {!templateVersions ? ( - - ) : null} + {!versions && } {params.InputProps.endAdornment} ), @@ -144,16 +138,16 @@ export const ChangeVersionDialog: FC = ({ )} /> - {version && ( + {selectedVersion && ( <> - {version.message && ( + {selectedVersion.message && ( - {version.message} + {selectedVersion.message} )} - Published by {version.created_by.username} + Published by {selectedVersion.created_by.username} diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/DownloadLogsDialog.stories.tsx similarity index 97% rename from site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx rename to site/src/modules/workspaces/WorkspaceMoreActions/DownloadLogsDialog.stories.tsx index 14bd1b4a6d6b7..ad925798ac8d3 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/DownloadLogsDialog.stories.tsx @@ -6,7 +6,7 @@ import { withDesktopViewport } from "testHelpers/storybook"; import { DownloadLogsDialog } from "./DownloadLogsDialog"; const meta: Meta = { - title: "pages/WorkspacePage/DownloadLogsDialog", + title: "modules/workspaces/DownloadLogsDialog", component: DownloadLogsDialog, args: { open: true, diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/DownloadLogsDialog.tsx similarity index 98% rename from site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx rename to site/src/modules/workspaces/WorkspaceMoreActions/DownloadLogsDialog.tsx index 0beeb795a09b6..863bcb07061da 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/DownloadLogsDialog.tsx @@ -221,8 +221,9 @@ const DownloadingItem: FC = ({ file, giveUpTimeMs }) => { function humanBlobSize(size: number) { const BLOB_SIZE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const; let i = 0; - while (size > 1024 && i < BLOB_SIZE_UNITS.length) { - size /= 1024; + let sizeIterator = size; + while (sizeIterator > 1024 && i < BLOB_SIZE_UNITS.length) { + sizeIterator /= 1024; i++; } diff --git a/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialog.tsx similarity index 100% rename from site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx rename to site/src/modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialog.tsx diff --git a/site/src/pages/WorkspacePage/WorkspaceDeleteDialog/WorkspaceDeleteDialog.stories.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.stories.tsx similarity index 70% rename from site/src/pages/WorkspacePage/WorkspaceDeleteDialog/WorkspaceDeleteDialog.stories.tsx rename to site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.stories.tsx index 7cce5ec48cf04..e7b168e57e973 100644 --- a/site/src/pages/WorkspacePage/WorkspaceDeleteDialog/WorkspaceDeleteDialog.stories.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.stories.tsx @@ -1,17 +1,21 @@ import type { Meta, StoryObj } from "@storybook/react"; import { MockFailedWorkspace, MockWorkspace } from "testHelpers/entities"; +import { daysAgo } from "utils/time"; import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog"; const meta: Meta = { - title: "pages/WorkspacePage/WorkspaceDeleteDialog", + title: "modules/workspaces/WorkspaceDeleteDialog", component: WorkspaceDeleteDialog, args: { - workspace: MockWorkspace, - canUpdateTemplate: false, + workspace: { + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + created_at: daysAgo(2), + }, + }, + canDeleteFailedWorkspace: false, isOpen: true, - onCancel: () => {}, - onConfirm: () => {}, - workspaceBuildDateStr: "2 days ago", }, }; @@ -30,7 +34,7 @@ export const Unhealthy: Story = { // Should look the same as `Example` export const AdminView: Story = { args: { - canUpdateTemplate: true, + canDeleteFailedWorkspace: true, }, }; @@ -38,6 +42,6 @@ export const AdminView: Story = { export const UnhealthyAdminView: Story = { args: { workspace: MockFailedWorkspace, - canUpdateTemplate: true, + canDeleteFailedWorkspace: true, }, }; diff --git a/site/src/pages/WorkspacePage/WorkspaceDeleteDialog/WorkspaceDeleteDialog.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx similarity index 96% rename from site/src/pages/WorkspacePage/WorkspaceDeleteDialog/WorkspaceDeleteDialog.tsx rename to site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx index 2b2e70c721bf7..8f5179b0b64da 100644 --- a/site/src/pages/WorkspacePage/WorkspaceDeleteDialog/WorkspaceDeleteDialog.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx @@ -7,25 +7,24 @@ import type { Workspace, } from "api/typesGenerated"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; +import dayjs from "dayjs"; import { type FC, type FormEvent, useId, useState } from "react"; import { docs } from "utils/docs"; interface WorkspaceDeleteDialogProps { workspace: Workspace; - canUpdateTemplate: boolean; + canDeleteFailedWorkspace: boolean; isOpen: boolean; onCancel: () => void; onConfirm: (arg: CreateWorkspaceBuildRequest["orphan"]) => void; - workspaceBuildDateStr: string; } export const WorkspaceDeleteDialog: FC = ({ workspace, - canUpdateTemplate, + canDeleteFailedWorkspace, isOpen, onCancel, onConfirm, - workspaceBuildDateStr, }) => { const hookId = useId(); const [userConfirmationText, setUserConfirmationText] = useState(""); @@ -62,7 +61,7 @@ export const WorkspaceDeleteDialog: FC = ({

workspace

-

{workspaceBuildDateStr}

+

{dayjs(workspace.created_at).fromNow()}

created

@@ -102,7 +101,7 @@ export const WorkspaceDeleteDialog: FC = ({ // Orphaning is sort of a "last resort" that should really only // be used if Terraform is failing to apply while deleting, which // usually means that builds are failing as well. - canUpdateTemplate && + canDeleteFailedWorkspace && workspace.latest_build.status === "failed" && (
diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx new file mode 100644 index 0000000000000..22e9638ee7caa --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx @@ -0,0 +1,191 @@ +import { MissingBuildParameters } from "api/api"; +import { + changeVersion, + deleteWorkspace, + workspacePermissions, +} from "api/queries/workspaces"; +import type { Workspace } from "api/typesGenerated"; +import { Button } from "components/Button/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; +import { + CopyIcon, + DownloadIcon, + EllipsisVertical, + HistoryIcon, + SettingsIcon, + TrashIcon, +} from "lucide-react"; +import { type FC, useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { Link as RouterLink } from "react-router-dom"; +import { ChangeWorkspaceVersionDialog } from "./ChangeWorkspaceVersionDialog"; +import { DownloadLogsDialog } from "./DownloadLogsDialog"; +import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog"; +import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog"; +import { useWorkspaceDuplication } from "./useWorkspaceDuplication"; + +type WorkspaceMoreActionsProps = { + workspace: Workspace; + disabled: boolean; +}; + +export const WorkspaceMoreActions: FC = ({ + workspace, + disabled, +}) => { + const queryClient = useQueryClient(); + + // Permissions + const { data: permissions } = useQuery(workspacePermissions(workspace)); + + // Download logs + const [isDownloadDialogOpen, setIsDownloadDialogOpen] = useState(false); + + // Change version + const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false); + const changeVersionMutation = useMutation( + changeVersion(workspace, queryClient), + ); + + // Delete + const [isConfirmingDelete, setIsConfirmingDelete] = useState(false); + const deleteWorkspaceMutation = useMutation( + deleteWorkspace(workspace, queryClient), + ); + + // Duplicate + const { duplicateWorkspace, isDuplicationReady } = + useWorkspaceDuplication(workspace); + + // Since the workspace state is not updated immediately after the mutation, we + // need to be sure the menu is closed when the action gets disabled. + // Reference: https://github.com/coder/coder/pull/17775#discussion_r2087273706 + const [open, setOpen] = useState(false); + useEffect(() => { + setOpen((open) => (disabled ? false : open)); + }); + + return ( + <> + + + + + + + + + + Settings + + + + {permissions?.updateWorkspaceVersion && ( + { + setChangeVersionDialogOpen(true); + }} + > + + Change version… + + )} + + + + Duplicate… + + + setIsDownloadDialogOpen(true)}> + + Download logs… + + + + + { + setIsConfirmingDelete(true); + }} + data-testid="delete-button" + > + + Delete… + + + + + setIsDownloadDialogOpen(false)} + /> + + { + changeVersionMutation.reset(); + }} + onUpdate={(buildParameters) => { + if (changeVersionMutation.error instanceof MissingBuildParameters) { + changeVersionMutation.mutate({ + versionId: changeVersionMutation.error.versionId, + buildParameters, + }); + } + }} + /> + + { + setChangeVersionDialogOpen(false); + }} + onConfirm={(version) => { + setChangeVersionDialogOpen(false); + changeVersionMutation.mutate({ versionId: version.id }); + }} + /> + + { + setIsConfirmingDelete(false); + }} + onConfirm={(orphan) => { + deleteWorkspaceMutation.mutate({ orphan }); + setIsConfirmingDelete(false); + }} + /> + + ); +}; diff --git a/site/src/pages/CreateWorkspacePage/useWorkspaceDuplication.test.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/useWorkspaceDuplication.test.tsx similarity index 97% rename from site/src/pages/CreateWorkspacePage/useWorkspaceDuplication.test.tsx rename to site/src/modules/workspaces/WorkspaceMoreActions/useWorkspaceDuplication.test.tsx index ce2fead417c03..8e06e10136f92 100644 --- a/site/src/pages/CreateWorkspacePage/useWorkspaceDuplication.test.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/useWorkspaceDuplication.test.tsx @@ -5,7 +5,7 @@ import { type GetLocationSnapshot, renderHookWithAuth, } from "testHelpers/hooks"; -import CreateWorkspacePage from "./CreateWorkspacePage"; +import CreateWorkspacePage from "../../../pages/CreateWorkspacePage/CreateWorkspacePage"; import { useWorkspaceDuplication } from "./useWorkspaceDuplication"; function render(workspace?: Workspace) { diff --git a/site/src/pages/CreateWorkspacePage/useWorkspaceDuplication.ts b/site/src/modules/workspaces/WorkspaceMoreActions/useWorkspaceDuplication.ts similarity index 96% rename from site/src/pages/CreateWorkspacePage/useWorkspaceDuplication.ts rename to site/src/modules/workspaces/WorkspaceMoreActions/useWorkspaceDuplication.ts index b98434a0b51f9..cde6707be6f26 100644 --- a/site/src/pages/CreateWorkspacePage/useWorkspaceDuplication.ts +++ b/site/src/modules/workspaces/WorkspaceMoreActions/useWorkspaceDuplication.ts @@ -4,7 +4,7 @@ import { linkToTemplate, useLinks } from "modules/navigation"; import { useCallback } from "react"; import { useQuery } from "react-query"; import { useNavigate } from "react-router-dom"; -import type { CreateWorkspaceMode } from "./CreateWorkspacePage"; +import type { CreateWorkspaceMode } from "../../../pages/CreateWorkspacePage/CreateWorkspacePage"; function getDuplicationUrlParams( workspaceParams: readonly WorkspaceBuildParameter[], diff --git a/site/src/modules/workspaces/WorkspaceUpdateDialogs.tsx b/site/src/modules/workspaces/WorkspaceUpdateDialogs.tsx index 741bc12a6539b..0b4e53cedfb36 100644 --- a/site/src/modules/workspaces/WorkspaceUpdateDialogs.tsx +++ b/site/src/modules/workspaces/WorkspaceUpdateDialogs.tsx @@ -8,7 +8,7 @@ import type { } from "api/typesGenerated"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; import { MemoizedInlineMarkdown } from "components/Markdown/Markdown"; -import { UpdateBuildParametersDialog } from "pages/WorkspacePage/UpdateBuildParametersDialog"; +import { UpdateBuildParametersDialog } from "modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialog"; import { type FC, useState } from "react"; import { useMutation, useQueryClient } from "react-query"; diff --git a/site/src/modules/workspaces/permissions.ts b/site/src/modules/workspaces/permissions.ts new file mode 100644 index 0000000000000..07c4e612cdf61 --- /dev/null +++ b/site/src/modules/workspaces/permissions.ts @@ -0,0 +1,50 @@ +import type { AuthorizationCheck, Workspace } from "api/typesGenerated"; + +export const workspaceChecks = (workspace: Workspace) => + ({ + readWorkspace: { + object: { + resource_type: "workspace", + resource_id: workspace.id, + owner_id: workspace.owner_id, + }, + action: "read", + }, + updateWorkspace: { + object: { + resource_type: "workspace", + resource_id: workspace.id, + owner_id: workspace.owner_id, + }, + action: "update", + }, + updateWorkspaceVersion: { + object: { + resource_type: "template", + resource_id: workspace.template_id, + }, + action: "update", + }, + // We only want to allow template admins to delete failed workspaces since + // they can leave orphaned resources. + deleteFailedWorkspace: { + object: { + resource_type: "template", + resource_id: workspace.template_id, + }, + action: "update", + }, + // To run a build in debug mode we need to be able to read the deployment + // config (enable_terraform_debug_mode). + deploymentConfig: { + object: { + resource_type: "deployment_config", + }, + action: "read", + }, + }) satisfies Record; + +export type WorkspacePermissions = Record< + keyof ReturnType, + boolean +>; diff --git a/site/src/pages/WorkspacePage/ChangeVersionDialog.stories.tsx b/site/src/pages/WorkspacePage/ChangeVersionDialog.stories.tsx deleted file mode 100644 index 2da7ae322c22e..0000000000000 --- a/site/src/pages/WorkspacePage/ChangeVersionDialog.stories.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { - MockTemplate, - MockTemplateVersion, - MockTemplateVersionWithMarkdownMessage, -} from "testHelpers/entities"; -import { ChangeVersionDialog } from "./ChangeVersionDialog"; - -const noMessage = { - ...MockTemplateVersion, - message: "", -}; - -const meta: Meta = { - title: "pages/WorkspacePage/ChangeVersionDialog", - component: ChangeVersionDialog, - args: { - open: true, - template: MockTemplate, - templateVersions: [ - MockTemplateVersion, - MockTemplateVersionWithMarkdownMessage, - noMessage, - ], - }, -}; - -export default meta; -type Story = StoryObj; - -export const NoVersionSelected: Story = {}; - -export const NoMessage: Story = { - args: { - defaultTemplateVersion: noMessage, - }, -}; - -export const ShortMessage: Story = { - args: { - defaultTemplateVersion: MockTemplateVersion, - }, -}; - -export const LongMessage: Story = { - args: { - defaultTemplateVersion: MockTemplateVersionWithMarkdownMessage, - }, -}; diff --git a/site/src/pages/WorkspacePage/Workspace.stories.tsx b/site/src/pages/WorkspacePage/Workspace.stories.tsx index ca782d73b68a0..a59e2f78bcee2 100644 --- a/site/src/pages/WorkspacePage/Workspace.stories.tsx +++ b/site/src/pages/WorkspacePage/Workspace.stories.tsx @@ -4,8 +4,8 @@ import type { ProvisionerJobLog } from "api/typesGenerated"; import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"; import * as Mocks from "testHelpers/entities"; import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook"; +import type { WorkspacePermissions } from "../../modules/workspaces/permissions"; import { Workspace } from "./Workspace"; -import type { WorkspacePermissions } from "./permissions"; // Helper function to create timestamps easily - Copied from AppStatuses.stories.tsx const createTimestamp = ( @@ -21,8 +21,9 @@ const createTimestamp = ( const permissions: WorkspacePermissions = { readWorkspace: true, updateWorkspace: true, - updateTemplate: true, - viewDeploymentConfig: true, + updateWorkspaceVersion: true, + deploymentConfig: true, + deleteFailedWorkspace: true, }; const meta: Meta = { diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index 1c60b8b86b50b..8c874e71beeb3 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -13,6 +13,7 @@ import { AgentRow } from "modules/resources/AgentRow"; import { WorkspaceTimings } from "modules/workspaces/WorkspaceTiming/WorkspaceTimings"; import { type FC, useMemo } from "react"; import { useNavigate } from "react-router-dom"; +import type { WorkspacePermissions } from "../../modules/workspaces/permissions"; import { AppStatuses } from "./AppStatuses"; import { HistorySidebar } from "./HistorySidebar"; import { ResourceMetadata } from "./ResourceMetadata"; @@ -24,68 +25,57 @@ import { } from "./WorkspaceBuildProgress"; import { WorkspaceDeletedBanner } from "./WorkspaceDeletedBanner"; import { WorkspaceTopbar } from "./WorkspaceTopbar"; -import type { WorkspacePermissions } from "./permissions"; import { resourceOptionValue, useResourcesNav } from "./useResourcesNav"; export interface WorkspaceProps { - handleStart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; - handleStop: () => void; - handleRestart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; - handleDelete: () => void; - handleUpdate: () => void; - handleCancel: () => void; - handleSettings: () => void; - handleChangeVersion: () => void; - handleDormantActivate: () => void; - handleToggleFavorite: () => void; + workspace: TypesGen.Workspace; + template: TypesGen.Template; + permissions: WorkspacePermissions; isUpdating: boolean; isRestarting: boolean; - workspace: TypesGen.Workspace; - canChangeVersions: boolean; hideSSHButton?: boolean; hideVSCodeDesktopButton?: boolean; buildInfo?: TypesGen.BuildInfoResponse; sshPrefix?: string; - template: TypesGen.Template; - canDebugMode: boolean; - handleRetry: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; - handleDebug: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; buildLogs?: TypesGen.ProvisionerJobLog[]; latestVersion?: TypesGen.TemplateVersion; - permissions: WorkspacePermissions; timings?: TypesGen.WorkspaceBuildTimings; + handleStart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; + handleStop: () => void; + handleRestart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; + handleUpdate: () => void; + handleCancel: () => void; + handleDormantActivate: () => void; + handleToggleFavorite: () => void; + handleRetry: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; + handleDebug: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; } /** * Workspace is the top-level component for viewing an individual workspace */ export const Workspace: FC = ({ - handleStart, - handleStop, - handleRestart, - handleDelete, - handleUpdate, - handleCancel, - handleSettings, - handleChangeVersion, - handleDormantActivate, - handleToggleFavorite, workspace, isUpdating, isRestarting, - canChangeVersions, hideSSHButton, hideVSCodeDesktopButton, buildInfo, sshPrefix, template, - canDebugMode, - handleRetry, - handleDebug, buildLogs, latestVersion, permissions, timings, + handleStart, + handleStop, + handleRestart, + handleUpdate, + handleCancel, + handleDormantActivate, + handleToggleFavorite, + handleRetry, + handleDebug, }) => { const navigate = useNavigate(); const theme = useTheme(); @@ -142,26 +132,20 @@ export const Workspace: FC = ({ >
= { component: WorkspaceActions, args: { isUpdating: false, + permissions: { + deleteFailedWorkspace: true, + deploymentConfig: true, + readWorkspace: true, + updateWorkspace: true, + updateWorkspaceVersion: true, + }, }, decorators: [withDashboardProvider, withDesktopViewport, withAuthProvider], parameters: { user: Mocks.MockUserOwner, + queries: [ + { + key: deploymentConfigQueryKey, + data: Mocks.MockDeploymentConfig, + }, + ], }, }; @@ -157,7 +171,13 @@ export const Failed: Story = { export const FailedWithDebug: Story = { args: { workspace: Mocks.MockFailedWorkspace, - canDebug: true, + permissions: { + deploymentConfig: true, + deleteFailedWorkspace: true, + readWorkspace: true, + updateWorkspace: true, + updateWorkspaceVersion: true, + }, }, }; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index 85c106202ca20..dd3e135901c84 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -1,25 +1,14 @@ -import DownloadOutlined from "@mui/icons-material/DownloadOutlined"; -import DuplicateIcon from "@mui/icons-material/FileCopyOutlined"; -import HistoryIcon from "@mui/icons-material/HistoryOutlined"; +import { deploymentConfig } from "api/queries/deployment"; import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated"; -import { Button } from "components/Button/Button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "components/DropdownMenu/DropdownMenu"; import { useAuthenticated } from "hooks/useAuthenticated"; -import { SettingsIcon } from "lucide-react"; -import { TrashIcon } from "lucide-react"; -import { EllipsisVertical } from "lucide-react"; +import { WorkspaceMoreActions } from "modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions"; import { type ActionType, abilitiesByWorkspaceStatus, } from "modules/workspaces/actions"; -import { useWorkspaceDuplication } from "pages/CreateWorkspacePage/useWorkspaceDuplication"; -import { type FC, Fragment, type ReactNode, useState } from "react"; +import type { WorkspacePermissions } from "modules/workspaces/permissions"; +import { type FC, Fragment, type ReactNode } from "react"; +import { useQuery } from "react-query"; import { mustUpdateWorkspace } from "utils/workspace"; import { ActivateButton, @@ -34,64 +23,61 @@ import { UpdateButton, } from "./Buttons"; import { DebugButton } from "./DebugButton"; -import { DownloadLogsDialog } from "./DownloadLogsDialog"; import { RetryButton } from "./RetryButton"; export interface WorkspaceActionsProps { workspace: Workspace; + isUpdating: boolean; + isRestarting: boolean; + permissions: WorkspacePermissions; handleToggleFavorite: () => void; handleStart: (buildParameters?: WorkspaceBuildParameter[]) => void; handleStop: () => void; handleRestart: (buildParameters?: WorkspaceBuildParameter[]) => void; - handleDelete: () => void; handleUpdate: () => void; handleCancel: () => void; - handleSettings: () => void; - handleChangeVersion: () => void; handleRetry: (buildParameters?: WorkspaceBuildParameter[]) => void; handleDebug: (buildParameters?: WorkspaceBuildParameter[]) => void; handleDormantActivate: () => void; - isUpdating: boolean; - isRestarting: boolean; - children?: ReactNode; - canChangeVersions: boolean; - canDebug: boolean; } export const WorkspaceActions: FC = ({ workspace, + isUpdating, + isRestarting, + permissions, handleToggleFavorite, handleStart, handleStop, handleRestart, - handleDelete, handleUpdate, handleCancel, - handleSettings, handleRetry, handleDebug, - handleChangeVersion, handleDormantActivate, - isUpdating, - isRestarting, - canChangeVersions, - canDebug, }) => { - const { duplicateWorkspace, isDuplicationReady } = - useWorkspaceDuplication(workspace); - - const [isDownloadDialogOpen, setIsDownloadDialogOpen] = useState(false); - const { user } = useAuthenticated(); - const isOwner = - user.roles.find((role) => role.name === "owner") !== undefined; + const { data: deployment } = useQuery({ + ...deploymentConfig(), + enabled: permissions.deploymentConfig, + }); const { actions, canCancel, canAcceptJobs } = abilitiesByWorkspaceStatus( workspace, - { canDebug, isOwner }, + { + canDebug: !!deployment?.config.enable_terraform_debug_mode, + isOwner: user.roles.some((role) => role.name === "owner"), + }, ); - const mustUpdate = mustUpdateWorkspace(workspace, canChangeVersions); - const tooltipText = getTooltipText(workspace, mustUpdate, canChangeVersions); + const mustUpdate = mustUpdateWorkspace( + workspace, + permissions.updateWorkspaceVersion, + ); + const tooltipText = getTooltipText( + workspace, + mustUpdate, + permissions.updateWorkspaceVersion, + ); // A mapping of button type to the corresponding React component const buttonMapping: Record = { @@ -179,66 +165,7 @@ export const WorkspaceActions: FC = ({ onToggle={handleToggleFavorite} /> - - - - - - - - - Settings - - - {canChangeVersions && ( - - - Change version… - - )} - - - - Duplicate… - - - setIsDownloadDialogOpen(true)}> - - Download logs… - - - - - - - Delete… - - - - - setIsDownloadDialogOpen(false)} - onConfirm={() => {}} - /> +
); }; diff --git a/site/src/pages/WorkspacePage/WorkspaceDeleteDialog/index.ts b/site/src/pages/WorkspacePage/WorkspaceDeleteDialog/index.ts deleted file mode 100644 index cc0251b4a2ce0..0000000000000 --- a/site/src/pages/WorkspacePage/WorkspaceDeleteDialog/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./WorkspaceDeleteDialog"; diff --git a/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.stories.tsx index 6f02d925f6485..a35771971b329 100644 --- a/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { expect, userEvent, waitFor, within } from "@storybook/test"; import { getWorkspaceResolveAutostartQueryKey } from "api/queries/workspaceQuota"; +import type { WorkspacePermissions } from "modules/workspaces/permissions"; import { MockOutdatedWorkspace, MockTemplate, @@ -11,11 +12,12 @@ import { import { withDashboardProvider } from "testHelpers/storybook"; import { WorkspaceNotifications } from "./WorkspaceNotifications"; -const defaultPermissions = { +const defaultPermissions: WorkspacePermissions = { readWorkspace: true, - updateTemplate: true, + updateWorkspaceVersion: true, updateWorkspace: true, - viewDeploymentConfig: true, + deploymentConfig: true, + deleteFailedWorkspace: true, }; const meta: Meta = { diff --git a/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.tsx b/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.tsx index 7c72c9777aaeb..976e7bac4fcbb 100644 --- a/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.tsx @@ -15,7 +15,7 @@ import { useDashboard } from "modules/dashboard/useDashboard"; import { TemplateUpdateMessage } from "modules/templates/TemplateUpdateMessage"; import { type FC, useEffect, useState } from "react"; import { useQuery } from "react-query"; -import type { WorkspacePermissions } from "../permissions"; +import type { WorkspacePermissions } from "../../../modules/workspaces/permissions"; import { NotificationActionButton, type NotificationItem, diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index a9f78f3610983..7489be8772ee4 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -7,6 +7,7 @@ import { DashboardContext, type DashboardProvider, } from "modules/dashboard/DashboardProvider"; +import type { WorkspacePermissions } from "modules/workspaces/permissions"; import { http, HttpResponse } from "msw"; import type { FC } from "react"; import { type Location, useLocation } from "react-router-dom"; @@ -126,11 +127,14 @@ describe("WorkspacePage", () => { // set permissions server.use( http.post("/api/v2/authcheck", async () => { - return HttpResponse.json({ - updateTemplates: true, + const permissions: WorkspacePermissions = { + deleteFailedWorkspace: true, + deploymentConfig: true, + readWorkspace: true, updateWorkspace: true, - updateTemplate: true, - }); + updateWorkspaceVersion: true, + }; + return HttpResponse.json(permissions); }), ); diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index e0b5cf1d14bfe..f4b4af024d06e 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -1,8 +1,10 @@ import { watchWorkspace } from "api/api"; -import { checkAuthorization } from "api/queries/authCheck"; import { template as templateQueryOptions } from "api/queries/templates"; import { workspaceBuildsKey } from "api/queries/workspaceBuilds"; -import { workspaceByOwnerAndName } from "api/queries/workspaces"; +import { + workspaceByOwnerAndName, + workspacePermissions, +} from "api/queries/workspaces"; import type { Workspace } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { displayError } from "components/GlobalSnackbar/utils"; @@ -15,7 +17,6 @@ import { type FC, useEffect } from "react"; import { useQuery, useQueryClient } from "react-query"; import { useParams } from "react-router-dom"; import { WorkspaceReadyPage } from "./WorkspaceReadyPage"; -import { type WorkspacePermissions, workspaceChecks } from "./permissions"; const WorkspacePage: FC = () => { const queryClient = useQueryClient(); @@ -43,13 +44,8 @@ const WorkspacePage: FC = () => { const template = templateQuery.data; // Permissions - const checks = - workspace && template ? workspaceChecks(workspace, template) : {}; - const permissionsQuery = useQuery({ - ...checkAuthorization({ checks }), - enabled: workspace !== undefined && template !== undefined, - }); - const permissions = permissionsQuery.data as WorkspacePermissions | undefined; + const permissionsQuery = useQuery(workspacePermissions(workspace)); + const permissions = permissionsQuery.data; // Watch workspace changes const updateWorkspaceData = useEffectEvent( diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 9ae072e420dd1..5de4eb6b490f7 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -1,13 +1,12 @@ -import { API, MissingBuildParameters } from "api/api"; +import { API } from "api/api"; import { getErrorMessage } from "api/errors"; import { buildInfo } from "api/queries/buildInfo"; -import { deploymentConfig, deploymentSSHConfig } from "api/queries/deployment"; -import { templateVersion, templateVersions } from "api/queries/templates"; +import { deploymentSSHConfig } from "api/queries/deployment"; +import { templateVersion } from "api/queries/templates"; import { workspaceBuildTimings } from "api/queries/workspaceBuilds"; import { activate, cancelBuild, - changeVersion, deleteWorkspace, startWorkspace, stopWorkspace, @@ -19,7 +18,6 @@ import { type ConfirmDialogProps, } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; import { displayError } from "components/GlobalSnackbar/utils"; -import dayjs from "dayjs"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; @@ -27,16 +25,12 @@ import { WorkspaceUpdateDialogs, useWorkspaceUpdate, } from "modules/workspaces/WorkspaceUpdateDialogs"; +import type { WorkspacePermissions } from "modules/workspaces/permissions"; import { type FC, useEffect, useState } from "react"; import { Helmet } from "react-helmet-async"; import { useMutation, useQuery, useQueryClient } from "react-query"; -import { useNavigate } from "react-router-dom"; import { pageTitle } from "utils/page"; -import { ChangeVersionDialog } from "./ChangeVersionDialog"; -import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog"; import { Workspace } from "./Workspace"; -import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog"; -import type { WorkspacePermissions } from "./permissions"; interface WorkspaceReadyPageProps { template: TypesGen.Template; @@ -51,19 +45,8 @@ export const WorkspaceReadyPage: FC = ({ }) => { const { metadata } = useEmbeddedMetadata(); const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); - const navigate = useNavigate(); const queryClient = useQueryClient(); - const featureVisibility = useFeatureVisibility(); - if (workspace === undefined) { - throw Error("Workspace is undefined"); - } - - // Debug mode - const { data: deploymentValues } = useQuery({ - ...deploymentConfig(), - enabled: permissions.viewDeploymentConfig, - }); // Build logs const shouldStreamBuildLogs = workspace.latest_build.status !== "running"; @@ -98,18 +81,7 @@ export const WorkspaceReadyPage: FC = ({ setFaviconTheme(isDark.matches ? "light" : "dark"); }, []); - // Change version - const canChangeVersions = permissions.updateTemplate; - const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false); - const changeVersionMutation = useMutation( - changeVersion(workspace, queryClient), - ); - - // Versions - const { data: allVersions } = useQuery({ - ...templateVersions(workspace.template_id), - enabled: changeVersionDialogOpen, - }); + // Active version const { data: latestVersion } = useQuery({ ...templateVersion(workspace.template_active_version_id), enabled: workspace.outdated, @@ -121,10 +93,7 @@ export const WorkspaceReadyPage: FC = ({ latestVersion, }); - // If a user can update the template then they can force a delete - // (via orphan). - const canUpdateTemplate = Boolean(permissions.updateTemplate); - const [isConfirmingDelete, setIsConfirmingDelete] = useState(false); + // Delete workspace const deleteWorkspaceMutation = useMutation( deleteWorkspace(workspace, queryClient), ); @@ -232,29 +201,27 @@ export const WorkspaceReadyPage: FC = ({ isUpdating={workspaceUpdate.isUpdating} isRestarting={isRestarting} workspace={workspace} + latestVersion={latestVersion} + hideSSHButton={featureVisibility.browser_only} + hideVSCodeDesktopButton={featureVisibility.browser_only} + buildInfo={buildInfoQuery.data} + sshPrefix={sshPrefixQuery.data?.hostname_prefix} + template={template} + buildLogs={buildLogs} + timings={timingsQuery.data} handleStart={(buildParameters) => { startWorkspaceMutation.mutate({ buildParameters }); }} handleStop={() => { stopWorkspaceMutation.mutate({}); }} - handleDelete={() => { - setIsConfirmingDelete(true); - }} handleRestart={(buildParameters) => { setConfirmingRestart({ open: true, buildParameters }); }} handleUpdate={workspaceUpdate.update} handleCancel={cancelBuildMutation.mutate} - handleSettings={() => navigate("settings")} handleRetry={handleRetry} handleDebug={handleDebug} - canDebugMode={ - deploymentValues?.config.enable_terraform_debug_mode ?? false - } - handleChangeVersion={() => { - setChangeVersionDialogOpen(true); - }} handleDormantActivate={async () => { try { await activateWorkspaceMutation.mutateAsync(); @@ -266,65 +233,6 @@ export const WorkspaceReadyPage: FC = ({ handleToggleFavorite={() => { toggleFavoriteMutation.mutate(); }} - latestVersion={latestVersion} - canChangeVersions={canChangeVersions} - hideSSHButton={featureVisibility.browser_only} - hideVSCodeDesktopButton={featureVisibility.browser_only} - buildInfo={buildInfoQuery.data} - sshPrefix={sshPrefixQuery.data?.hostname_prefix} - template={template} - buildLogs={buildLogs} - timings={timingsQuery.data} - /> - - { - setIsConfirmingDelete(false); - }} - onConfirm={(orphan) => { - deleteWorkspaceMutation.mutate({ orphan }); - setIsConfirmingDelete(false); - }} - workspaceBuildDateStr={dayjs(workspace.created_at).fromNow()} - /> - - { - changeVersionMutation.reset(); - }} - onUpdate={(buildParameters) => { - if (changeVersionMutation.error instanceof MissingBuildParameters) { - changeVersionMutation.mutate({ - versionId: changeVersionMutation.error.versionId, - buildParameters, - }); - } - }} - /> - - workspace.latest_build.template_version_id === v.id, - )} - open={changeVersionDialogOpen} - onClose={() => { - setChangeVersionDialogOpen(false); - }} - onConfirm={(templateVersion) => { - setChangeVersionDialogOpen(false); - changeVersionMutation.mutate({ versionId: templateVersion.id }); - }} /> = { workspace: baseWorkspace, template: MockTemplate, latestVersion: MockTemplateVersion, - canUpdateWorkspace: true, + permissions: { + readWorkspace: true, + updateWorkspaceVersion: true, + updateWorkspace: true, + deploymentConfig: true, + deleteFailedWorkspace: true, + }, }, parameters: { layout: "fullscreen", diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx index 2af8d694e120e..32908156c5b5c 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx @@ -25,64 +25,45 @@ import type { FC } from "react"; import { useQuery } from "react-query"; import { Link as RouterLink } from "react-router-dom"; import { displayDormantDeletion } from "utils/dormant"; +import type { WorkspacePermissions } from "../../modules/workspaces/permissions"; import { WorkspaceActions } from "./WorkspaceActions/WorkspaceActions"; import { WorkspaceNotifications } from "./WorkspaceNotifications/WorkspaceNotifications"; import { WorkspaceScheduleControls } from "./WorkspaceScheduleControls"; -import type { WorkspacePermissions } from "./permissions"; - -export type WorkspaceError = - | "getBuildsError" - | "buildError" - | "cancellationError"; - -type WorkspaceErrors = Partial>; export interface WorkspaceProps { + isUpdating: boolean; + isRestarting: boolean; + workspace: TypesGen.Workspace; + template: TypesGen.Template; + permissions: WorkspacePermissions; + latestVersion?: TypesGen.TemplateVersion; handleStart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; handleStop: () => void; handleRestart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; - handleDelete: () => void; handleUpdate: () => void; handleCancel: () => void; - handleSettings: () => void; - handleChangeVersion: () => void; handleDormantActivate: () => void; - isUpdating: boolean; - isRestarting: boolean; - workspace: TypesGen.Workspace; - canUpdateWorkspace: boolean; - canChangeVersions: boolean; - canDebugMode: boolean; handleRetry: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; handleDebug: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; - template: TypesGen.Template; - permissions: WorkspacePermissions; - latestVersion?: TypesGen.TemplateVersion; handleToggleFavorite: () => void; } export const WorkspaceTopbar: FC = ({ + workspace, + template, + latestVersion, + permissions, + isUpdating, + isRestarting, handleStart, handleStop, handleRestart, - handleDelete, handleUpdate, handleCancel, - handleSettings, - handleChangeVersion, handleDormantActivate, handleToggleFavorite, - workspace, - isUpdating, - isRestarting, - canUpdateWorkspace, - canChangeVersions, - canDebugMode, handleRetry, handleDebug, - template, - latestVersion, - permissions, }) => { const { entitlements, organizations, showOrganizations } = useDashboard(); const getLink = useLinks(); @@ -230,7 +211,7 @@ export const WorkspaceTopbar: FC = ({ = ({
)} diff --git a/site/src/pages/WorkspacePage/permissions.ts b/site/src/pages/WorkspacePage/permissions.ts deleted file mode 100644 index 3ac1df5a3a7fd..0000000000000 --- a/site/src/pages/WorkspacePage/permissions.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Template, Workspace } from "api/typesGenerated"; - -export const workspaceChecks = (workspace: Workspace, template: Template) => - ({ - readWorkspace: { - object: { - resource_type: "workspace", - resource_id: workspace.id, - owner_id: workspace.owner_id, - }, - action: "read", - }, - updateWorkspace: { - object: { - resource_type: "workspace", - resource_id: workspace.id, - owner_id: workspace.owner_id, - }, - action: "update", - }, - updateTemplate: { - object: { - resource_type: "template", - resource_id: template.id, - }, - action: "update", - }, - viewDeploymentConfig: { - object: { - resource_type: "deployment_config", - }, - action: "read", - }, - }) as const; - -export type WorkspacePermissions = Record< - keyof ReturnType, - boolean ->; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx index 443e7183cca60..f17bb246966bf 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx @@ -2,7 +2,6 @@ import Button from "@mui/material/Button"; import { API } from "api/api"; import { isApiValidationError } from "api/errors"; import { checkAuthorization } from "api/queries/authCheck"; -import { templateByName } from "api/queries/templates"; import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { EmptyState } from "components/EmptyState/EmptyState"; @@ -18,7 +17,7 @@ import { pageTitle } from "utils/page"; import { type WorkspacePermissions, workspaceChecks, -} from "../../WorkspacePage/permissions"; +} from "../../../modules/workspaces/permissions"; import { useWorkspaceSettings } from "../WorkspaceSettingsLayout"; import { WorkspaceParametersForm, @@ -43,21 +42,14 @@ const WorkspaceParametersPage: FC = () => { }, }); - const templateQuery = useQuery({ - ...templateByName(workspace.organization_id, workspace.template_name ?? ""), - enabled: workspace !== undefined, - }); - const template = templateQuery.data; - // Permissions - const checks = - workspace && template ? workspaceChecks(workspace, template) : {}; + const checks = workspace ? workspaceChecks(workspace) : {}; const permissionsQuery = useQuery({ ...checkAuthorization({ checks }), - enabled: workspace !== undefined && template !== undefined, + enabled: workspace !== undefined, }); const permissions = permissionsQuery.data as WorkspacePermissions | undefined; - const canChangeVersions = Boolean(permissions?.updateTemplate); + const canChangeVersions = Boolean(permissions?.updateWorkspaceVersion); return ( <> @@ -72,14 +64,23 @@ const WorkspaceParametersPage: FC = () => { submitError={updateParameters.error} isSubmitting={updateParameters.isLoading} onSubmit={(values) => { + if (!parameters.data) { + return; + } // When updating the parameters, the API does not accept immutable // values so we need to filter them - const onlyMultableValues = parameters - .data!.templateVersionRichParameters.filter((p) => p.mutable) - .map( - (p) => - values.rich_parameter_values.find((v) => v.name === p.name)!, - ); + const onlyMultableValues = + parameters.data.templateVersionRichParameters + .filter((p) => p.mutable) + .map((p) => { + const value = values.rich_parameter_values.find( + (v) => v.name === p.name, + ); + if (!value) { + throw new Error(`Missing value for parameter ${p.name}`); + } + return value; + }); updateParameters.mutate(onlyMultableValues); }} onCancel={() => { diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index b1ad1d887e53c..c8577f191d47e 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -68,7 +68,7 @@ describe("WorkspacesPage", () => { await user.click(getWorkspaceCheckbox(workspaces[0])); await user.click(getWorkspaceCheckbox(workspaces[1])); - await user.click(screen.getByRole("button", { name: /actions/i })); + await user.click(screen.getByRole("button", { name: /bulk actions/i })); const deleteButton = await screen.findByText(/delete/i); await user.click(deleteButton); @@ -106,7 +106,7 @@ describe("WorkspacesPage", () => { await user.click(getWorkspaceCheckbox(workspace)); } - await user.click(screen.getByRole("button", { name: /actions/i })); + await user.click(screen.getByRole("button", { name: /bulk actions/i })); const updateButton = await screen.findByText(/update/i); await user.click(updateButton); @@ -145,7 +145,7 @@ describe("WorkspacesPage", () => { await user.click(getWorkspaceCheckbox(workspace)); } - await user.click(screen.getByRole("button", { name: /actions/i })); + await user.click(screen.getByRole("button", { name: /bulk actions/i })); const updateButton = await screen.findByText(/update/i); await user.click(updateButton); @@ -183,7 +183,7 @@ describe("WorkspacesPage", () => { await user.click(getWorkspaceCheckbox(workspace)); } - await user.click(screen.getByRole("button", { name: /actions/i })); + await user.click(screen.getByRole("button", { name: /bulk actions/i })); const updateButton = await screen.findByText(/update/i); await user.click(updateButton); @@ -223,7 +223,7 @@ describe("WorkspacesPage", () => { await user.click(getWorkspaceCheckbox(workspace)); } - await user.click(screen.getByRole("button", { name: /actions/i })); + await user.click(screen.getByRole("button", { name: /bulk actions/i })); const updateButton = await screen.findByText(/update/i); await user.click(updateButton); @@ -263,7 +263,7 @@ describe("WorkspacesPage", () => { await user.click(getWorkspaceCheckbox(workspaces[0])); await user.click(getWorkspaceCheckbox(workspaces[1])); - await user.click(screen.getByRole("button", { name: /actions/i })); + await user.click(screen.getByRole("button", { name: /bulk actions/i })); const stopButton = await screen.findByRole("menuitem", { name: /stop/i }); await user.click(stopButton); @@ -290,7 +290,7 @@ describe("WorkspacesPage", () => { await user.click(getWorkspaceCheckbox(workspaces[0])); await user.click(getWorkspaceCheckbox(workspaces[1])); - await user.click(screen.getByRole("button", { name: /actions/i })); + await user.click(screen.getByRole("button", { name: /bulk actions/i })); const startButton = await screen.findByRole("menuitem", { name: /start/i }); await user.click(startButton); diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index a62c99d591d7c..569e3df0d347c 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -143,7 +143,7 @@ export const WorkspacesPageView: FC = ({ css={{ borderRadius: 9999, marginLeft: "auto" }} endIcon={} > - Actions + Bulk actions diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index 4ec1156b7fcd5..b5453e424dfd7 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -52,7 +52,7 @@ import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import { useAuthenticated } from "hooks"; import { useClickableTableRow } from "hooks/useClickableTableRow"; -import { ChevronRightIcon } from "lucide-react"; +import { EllipsisVertical } from "lucide-react"; import { BanIcon, PlayIcon, @@ -68,6 +68,7 @@ import { useAppLink } from "modules/apps/useAppLink"; import { useDashboard } from "modules/dashboard/useDashboard"; import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus"; import { WorkspaceDormantBadge } from "modules/workspaces/WorkspaceDormantBadge/WorkspaceDormantBadge"; +import { WorkspaceMoreActions } from "modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions"; import { WorkspaceOutdatedTooltip } from "modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip"; import { WorkspaceUpdateDialogs, @@ -185,7 +186,6 @@ export const WorkspacesTable: FC = ({ Template Status - @@ -303,11 +303,6 @@ export const WorkspacesTable: FC = ({ onActionSuccess={onActionSuccess} onActionError={onActionError} /> - -
- -
-
); })} @@ -382,12 +377,12 @@ const TableLoader: FC = ({ canCheckWorkspaces }) => { - - - - -
- + +
+ +
@@ -537,7 +532,12 @@ const WorkspaceActionsCell: FC = ({ }; return ( - + { + // Prevent the click in the actions to trigger the row click + e.stopPropagation(); + }} + >
{workspace.latest_build.status === "running" && ( @@ -585,6 +585,11 @@ const WorkspaceActionsCell: FC = ({ )} + +
); @@ -606,14 +611,7 @@ const PrimaryAction: FC = ({ - @@ -731,6 +729,8 @@ const WorkspaceApps: FC = ({ workspace }) => { ); } + buttons.push(); + return buttons; }; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 7fa69c006b8e7..5cc689f0bc01a 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -753,6 +753,8 @@ You can add instructions here export const MockTemplateVersionWithMarkdownMessage: TypesGen.TemplateVersion = { ...MockTemplateVersion, + id: "test-template-version-markdown", + name: "test-version-markdown", message: ` # Abiding Grace ## Enchantment