diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index fd840d067821a..39008f5c712a3 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -139,13 +139,9 @@ function workspacesKey(config: WorkspacesRequest = {}) { } export function workspaces(config: WorkspacesRequest = {}) { - // Duplicates some of the work from workspacesKey, but that felt better than - // letting invisible properties sneak into the query logic - const { q, limit } = config; - return { queryKey: workspacesKey(config), - queryFn: () => API.getWorkspaces({ q, limit }), + queryFn: () => API.getWorkspaces(config), } as const satisfies QueryOptions; } @@ -281,7 +277,10 @@ const updateWorkspaceBuild = async ( build.workspace_owner_name, build.workspace_name, ); - const previousData = queryClient.getQueryData(workspaceKey) as Workspace; + const previousData = queryClient.getQueryData(workspaceKey); + if (!previousData) { + return; + } // Check if the build returned is newer than the previous build that could be // updated from web socket diff --git a/site/src/components/Dialogs/Dialog.tsx b/site/src/components/Dialogs/Dialog.tsx index cdc271697c680..532b47a1339dc 100644 --- a/site/src/components/Dialogs/Dialog.tsx +++ b/site/src/components/Dialogs/Dialog.tsx @@ -35,7 +35,14 @@ export const DialogActionButtons: FC = ({ return ( <> {onCancel && ( - )} diff --git a/site/src/hooks/usePagination.ts b/site/src/hooks/usePagination.ts index 72ea70868fb30..2ab409e85c9d8 100644 --- a/site/src/hooks/usePagination.ts +++ b/site/src/hooks/usePagination.ts @@ -9,7 +9,7 @@ export const usePagination = ({ const [searchParams, setSearchParams] = searchParamsResult; const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1; const limit = DEFAULT_RECORDS_PER_PAGE; - const offset = page <= 0 ? 0 : (page - 1) * limit; + const offset = calcOffset(page, limit); const goToPage = (page: number) => { searchParams.set("page", page.toString()); @@ -23,3 +23,7 @@ export const usePagination = ({ offset, }; }; + +export const calcOffset = (page: number, limit: number) => { + return page <= 0 ? 0 : (page - 1) * limit; +}; diff --git a/site/src/modules/workspaces/WorkspaceUpdateDialogs.tsx b/site/src/modules/workspaces/WorkspaceUpdateDialogs.tsx new file mode 100644 index 0000000000000..741bc12a6539b --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceUpdateDialogs.tsx @@ -0,0 +1,155 @@ +import { MissingBuildParameters } from "api/api"; +import { updateWorkspace } from "api/queries/workspaces"; +import type { + TemplateVersion, + Workspace, + WorkspaceBuild, + WorkspaceBuildParameter, +} from "api/typesGenerated"; +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; +import { MemoizedInlineMarkdown } from "components/Markdown/Markdown"; +import { UpdateBuildParametersDialog } from "pages/WorkspacePage/UpdateBuildParametersDialog"; +import { type FC, useState } from "react"; +import { useMutation, useQueryClient } from "react-query"; + +type UseWorkspaceUpdateOptions = { + workspace: Workspace; + latestVersion: TemplateVersion | undefined; + onSuccess?: (build: WorkspaceBuild) => void; + onError?: (error: unknown) => void; +}; + +type UseWorkspaceUpdateResult = { + update: () => void; + isUpdating: boolean; + dialogs: { + updateConfirmation: UpdateConfirmationDialogProps; + missingBuildParameters: MissingBuildParametersDialogProps; + }; +}; + +export const useWorkspaceUpdate = ({ + workspace, + latestVersion, + onSuccess, + onError, +}: UseWorkspaceUpdateOptions): UseWorkspaceUpdateResult => { + const queryClient = useQueryClient(); + const [isConfirmingUpdate, setIsConfirmingUpdate] = useState(false); + + const updateWorkspaceOptions = updateWorkspace(workspace, queryClient); + const updateWorkspaceMutation = useMutation({ + ...updateWorkspaceOptions, + onSuccess: (build: WorkspaceBuild) => { + updateWorkspaceOptions.onSuccess(build); + onSuccess?.(build); + }, + onError, + }); + + const update = () => { + setIsConfirmingUpdate(true); + }; + + const confirmUpdate = (buildParameters: WorkspaceBuildParameter[] = []) => { + updateWorkspaceMutation.mutate(buildParameters); + setIsConfirmingUpdate(false); + }; + + return { + update, + isUpdating: updateWorkspaceMutation.isLoading, + dialogs: { + updateConfirmation: { + open: isConfirmingUpdate, + onClose: () => setIsConfirmingUpdate(false), + onConfirm: () => confirmUpdate(), + latestVersion, + }, + missingBuildParameters: { + error: updateWorkspaceMutation.error, + onClose: () => { + updateWorkspaceMutation.reset(); + }, + onUpdate: (buildParameters: WorkspaceBuildParameter[]) => { + if (updateWorkspaceMutation.error instanceof MissingBuildParameters) { + confirmUpdate(buildParameters); + } + }, + }, + }, + }; +}; + +type WorkspaceUpdateDialogsProps = { + updateConfirmation: UpdateConfirmationDialogProps; + missingBuildParameters: MissingBuildParametersDialogProps; +}; + +export const WorkspaceUpdateDialogs: FC = ({ + updateConfirmation, + missingBuildParameters, +}) => { + return ( + <> + + + + ); +}; + +type UpdateConfirmationDialogProps = { + open: boolean; + onClose: () => void; + onConfirm: () => void; + latestVersion?: TemplateVersion; +}; + +const UpdateConfirmationDialog: FC = ({ + latestVersion, + ...dialogProps +}) => { + return ( + +

+ Updating your workspace will start the workspace on the latest + template version. This can{" "} + delete non-persistent data. +

+ {latestVersion?.message && ( + + {latestVersion.message} + + )} + + } + /> + ); +}; + +type MissingBuildParametersDialogProps = { + error: unknown; + onClose: () => void; + onUpdate: (buildParameters: WorkspaceBuildParameter[]) => void; +}; + +const MissingBuildParametersDialog: FC = ({ + error, + ...dialogProps +}) => { + return ( + + ); +}; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts b/site/src/modules/workspaces/actions.ts similarity index 90% rename from site/src/pages/WorkspacePage/WorkspaceActions/constants.ts rename to site/src/modules/workspaces/actions.ts index a327f0277d4f5..6a255e2cd2c88 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts +++ b/site/src/modules/workspaces/actions.ts @@ -34,6 +34,11 @@ const actionTypes = [ export type ActionType = (typeof actionTypes)[number]; +type ActionPermissions = { + canDebug: boolean; + isOwner: boolean; +}; + type WorkspaceAbilities = { actions: readonly ActionType[]; canCancel: boolean; @@ -42,8 +47,11 @@ type WorkspaceAbilities = { export const abilitiesByWorkspaceStatus = ( workspace: Workspace, - canDebug: boolean, + permissions: ActionPermissions, ): WorkspaceAbilities => { + const hasPermissionToCancel = + workspace.template_allow_user_cancel_workspace_jobs || permissions.isOwner; + if (workspace.dormant_at) { return { actions: ["activate"], @@ -58,7 +66,7 @@ export const abilitiesByWorkspaceStatus = ( case "starting": { return { actions: ["starting"], - canCancel: true, + canCancel: hasPermissionToCancel, canAcceptJobs: false, }; } @@ -83,7 +91,7 @@ export const abilitiesByWorkspaceStatus = ( case "stopping": { return { actions: ["stopping"], - canCancel: true, + canCancel: hasPermissionToCancel, canAcceptJobs: false, }; } @@ -115,7 +123,7 @@ export const abilitiesByWorkspaceStatus = ( case "failed": { const actions: ActionType[] = ["retry"]; - if (canDebug) { + if (permissions.canDebug) { actions.push("debug"); } diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/useWorkspacesToBeDeleted.ts b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/useWorkspacesToBeDeleted.ts index 338c157f4f791..5a974d5d8fe31 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/useWorkspacesToBeDeleted.ts +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/useWorkspacesToBeDeleted.ts @@ -1,5 +1,6 @@ import type { Template, Workspace } from "api/typesGenerated"; import { compareAsc } from "date-fns"; +import { calcOffset } from "hooks/usePagination"; import { useWorkspacesData } from "pages/WorkspacesPage/data"; import type { TemplateScheduleFormValues } from "./formHelpers"; @@ -9,9 +10,9 @@ export const useWorkspacesToGoDormant = ( fromDate: Date, ) => { const { data } = useWorkspacesData({ - page: 0, + offset: calcOffset(0, 0), limit: 0, - query: `template:${template.name}`, + q: `template:${template.name}`, }); return data?.workspaces?.filter((workspace: Workspace) => { @@ -40,9 +41,9 @@ export const useWorkspacesToBeDeleted = ( fromDate: Date, ) => { const { data } = useWorkspacesData({ - page: 0, + offset: calcOffset(0, 0), limit: 0, - query: `template:${template.name} dormant:true`, + q: `template:${template.name} dormant:true`, }); return data?.workspaces?.filter((workspace: Workspace) => { if (!workspace.dormant_at || !formValues.time_til_dormant_autodelete_ms) { diff --git a/site/src/pages/WorkspacePage/Workspace.stories.tsx b/site/src/pages/WorkspacePage/Workspace.stories.tsx index 88198bdb7b09a..7d29b02c11cb6 100644 --- a/site/src/pages/WorkspacePage/Workspace.stories.tsx +++ b/site/src/pages/WorkspacePage/Workspace.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import type { ProvisionerJobLog } from "api/typesGenerated"; import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"; import * as Mocks from "testHelpers/entities"; -import { withDashboardProvider } from "testHelpers/storybook"; +import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook"; import { Workspace } from "./Workspace"; import type { WorkspacePermissions } from "./permissions"; @@ -40,8 +40,10 @@ const meta: Meta = { data: Mocks.MockListeningPortsResponse, }, ], + user: Mocks.MockUser, }, decorators: [ + withAuthProvider, withDashboardProvider, (Story) => ( = ({ buildLogs, latestVersion, permissions, - isOwner, timings, }) => { const navigate = useNavigate(); @@ -161,7 +159,6 @@ export const Workspace: FC = ({ isUpdating={isUpdating} isRestarting={isRestarting} canUpdateWorkspace={permissions.updateWorkspace} - isOwner={isOwner} template={template} permissions={permissions} latestVersion={latestVersion} diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx index d726a047b7c57..48dda92b49503 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx @@ -3,6 +3,7 @@ import { expect, userEvent, within } from "@storybook/test"; import { agentLogsKey, buildLogsKey } from "api/queries/workspaces"; import * as Mocks from "testHelpers/entities"; import { + withAuthProvider, withDashboardProvider, withDesktopViewport, } from "testHelpers/storybook"; @@ -14,7 +15,10 @@ const meta: Meta = { args: { isUpdating: false, }, - decorators: [withDashboardProvider, withDesktopViewport], + decorators: [withDashboardProvider, withDesktopViewport, withAuthProvider], + parameters: { + user: Mocks.MockUser, + }, }; export default meta; @@ -163,14 +167,15 @@ export const CancelShownForOwner: Story = { ...Mocks.MockStartingWorkspace, template_allow_user_cancel_workspace_jobs: false, }, - isOwner: true, }, }; export const CancelShownForUser: Story = { args: { workspace: Mocks.MockStartingWorkspace, - isOwner: false, + }, + parameters: { + user: Mocks.MockUser2, }, }; @@ -180,7 +185,9 @@ export const CancelHiddenForUser: Story = { ...Mocks.MockStartingWorkspace, template_allow_user_cancel_workspace_jobs: false, }, - isOwner: false, + }, + parameters: { + user: Mocks.MockUser2, }, }; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index 71f890cff3a5b..b65407806ed69 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -12,7 +12,12 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "components/DropdownMenu/DropdownMenu"; +import { useAuthenticated } from "hooks/useAuthenticated"; import { EllipsisVertical } from "lucide-react"; +import { + type ActionType, + abilitiesByWorkspaceStatus, +} from "modules/workspaces/actions"; import { useWorkspaceDuplication } from "pages/CreateWorkspacePage/useWorkspaceDuplication"; import { type FC, Fragment, type ReactNode, useState } from "react"; import { mustUpdateWorkspace } from "utils/workspace"; @@ -31,7 +36,6 @@ import { import { DebugButton } from "./DebugButton"; import { DownloadLogsDialog } from "./DownloadLogsDialog"; import { RetryButton } from "./RetryButton"; -import { type ActionType, abilitiesByWorkspaceStatus } from "./constants"; export interface WorkspaceActionsProps { workspace: Workspace; @@ -52,7 +56,6 @@ export interface WorkspaceActionsProps { children?: ReactNode; canChangeVersions: boolean; canDebug: boolean; - isOwner: boolean; } export const WorkspaceActions: FC = ({ @@ -73,20 +76,19 @@ export const WorkspaceActions: FC = ({ isRestarting, canChangeVersions, canDebug, - isOwner, }) => { const { duplicateWorkspace, isDuplicationReady } = useWorkspaceDuplication(workspace); const [isDownloadDialogOpen, setIsDownloadDialogOpen] = useState(false); + const { user } = useAuthenticated(); + const isOwner = + user.roles.find((role) => role.name === "owner") !== undefined; const { actions, canCancel, canAcceptJobs } = abilitiesByWorkspaceStatus( workspace, - canDebug, + { canDebug, isOwner }, ); - const showCancel = - canCancel && - (workspace.template_allow_user_cancel_workspace_jobs || isOwner); const mustUpdate = mustUpdateWorkspace(workspace, canChangeVersions); const tooltipText = getTooltipText(workspace, mustUpdate, canChangeVersions); @@ -169,7 +171,7 @@ export const WorkspaceActions: FC = ({ {buttonMapping[action]} ))} - {showCancel && } + {canCancel && } = ({ throw Error("Workspace is undefined"); } - // Owner - const { user: me } = useAuthenticated(); - const isOwner = me.roles.find((role) => role.name === "owner") !== undefined; - // Debug mode const { data: deploymentValues } = useQuery({ ...deploymentConfig(), @@ -120,10 +116,10 @@ export const WorkspaceReadyPage: FC = ({ }); // Update workspace - const [isConfirmingUpdate, setIsConfirmingUpdate] = useState(false); - const updateWorkspaceMutation = useMutation( - updateWorkspace(workspace, queryClient), - ); + const workspaceUpdate = useWorkspaceUpdate({ + workspace, + latestVersion, + }); // If a user can update the template then they can force a delete // (via orphan). @@ -233,7 +229,7 @@ export const WorkspaceReadyPage: FC = ({ { @@ -248,9 +244,7 @@ export const WorkspaceReadyPage: FC = ({ handleRestart={(buildParameters) => { setConfirmingRestart({ open: true, buildParameters }); }} - handleUpdate={() => { - setIsConfirmingUpdate(true); - }} + handleUpdate={workspaceUpdate.update} handleCancel={cancelBuildMutation.mutate} handleSettings={() => navigate("settings")} handleRetry={handleRetry} @@ -280,7 +274,6 @@ export const WorkspaceReadyPage: FC = ({ sshPrefix={sshPrefixQuery.data?.hostname_prefix} template={template} buildLogs={buildLogs} - isOwner={isOwner} timings={timingsQuery.data} /> @@ -318,23 +311,6 @@ export const WorkspaceReadyPage: FC = ({ }} /> - { - updateWorkspaceMutation.reset(); - }} - onUpdate={(buildParameters) => { - if (updateWorkspaceMutation.error instanceof MissingBuildParameters) { - updateWorkspaceMutation.mutate(buildParameters); - } - }} - /> - = ({ }} /> - { - updateWorkspaceMutation.mutate(undefined); - setIsConfirmingUpdate(false); - }} - onClose={() => setIsConfirmingUpdate(false)} - title="Update workspace?" - confirmText="Update" - description={ - -

- Updating your workspace will start the workspace on the latest - template version. This can{" "} - delete non-persistent data. -

- {latestVersion?.message && ( - - {latestVersion.message} - - )} -
- } - /> - { @@ -395,6 +346,8 @@ export const WorkspaceReadyPage: FC = ({ } /> + + ); }; diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx index ce2ad840a1df0..84af8a518acd8 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx @@ -10,7 +10,7 @@ import { MockUser, MockWorkspace, } from "testHelpers/entities"; -import { withDashboardProvider } from "testHelpers/storybook"; +import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook"; import { WorkspaceTopbar } from "./WorkspaceTopbar"; // We want a workspace without a deadline to not pollute the screenshot. Also @@ -28,7 +28,7 @@ const baseWorkspace: Workspace = { const meta: Meta = { title: "pages/WorkspacePage/WorkspaceTopbar", component: WorkspaceTopbar, - decorators: [withDashboardProvider], + decorators: [withAuthProvider, withDashboardProvider], args: { workspace: baseWorkspace, template: MockTemplate, @@ -41,6 +41,7 @@ const meta: Meta = { chromatic: { diffThreshold: 0.6, }, + user: MockUser, }, }; diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx index 2492e50e7c5d6..a39cf6d8b13ca 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx @@ -55,7 +55,6 @@ export interface WorkspaceProps { canDebugMode: boolean; handleRetry: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; handleDebug: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; - isOwner: boolean; template: TypesGen.Template; permissions: WorkspacePermissions; latestVersion?: TypesGen.TemplateVersion; @@ -81,7 +80,6 @@ export const WorkspaceTopbar: FC = ({ canDebugMode, handleRetry, handleDebug, - isOwner, template, latestVersion, permissions, @@ -262,7 +260,6 @@ export const WorkspaceTopbar: FC = ({ canChangeVersions={canChangeVersions} isUpdating={isUpdating} isRestarting={isRestarting} - isOwner={isOwner} /> )} diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index 66388eb3f7dd1..b1ad1d887e53c 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -264,7 +264,7 @@ describe("WorkspacesPage", () => { await user.click(getWorkspaceCheckbox(workspaces[0])); await user.click(getWorkspaceCheckbox(workspaces[1])); await user.click(screen.getByRole("button", { name: /actions/i })); - const stopButton = await screen.findByText(/stop/i); + const stopButton = await screen.findByRole("menuitem", { name: /stop/i }); await user.click(stopButton); await waitFor(() => { @@ -291,7 +291,7 @@ describe("WorkspacesPage", () => { await user.click(getWorkspaceCheckbox(workspaces[0])); await user.click(getWorkspaceCheckbox(workspaces[1])); await user.click(screen.getByRole("button", { name: /actions/i })); - const startButton = await screen.findByText(/start/i); + const startButton = await screen.findByRole("menuitem", { name: /start/i }); await user.click(startButton); await waitFor(() => { diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index ba380905adda2..551c554fd5ee3 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -1,8 +1,10 @@ +import { getErrorDetail, getErrorMessage } from "api/errors"; import { workspacePermissionsByOrganization } from "api/queries/organizations"; import { templates } from "api/queries/templates"; import type { Workspace } from "api/typesGenerated"; import { useFilter } from "components/Filter/Filter"; import { useUserFilterMenu } from "components/Filter/UserFilter"; +import { displayError } from "components/GlobalSnackbar/utils"; import { useAuthenticated } from "hooks"; import { useEffectEvent } from "hooks/hookPolyfills"; import { usePagination } from "hooks/usePagination"; @@ -10,7 +12,7 @@ import { useDashboard } from "modules/dashboard/useDashboard"; import { useOrganizationsFilterMenu } from "modules/tableFiltering/options"; import { type FC, useEffect, useMemo, useState } from "react"; import { Helmet } from "react-helmet-async"; -import { useQuery } from "react-query"; +import { useQuery, useQueryClient } from "react-query"; import { useSearchParams } from "react-router-dom"; import { pageTitle } from "utils/page"; import { BatchDeleteConfirmation } from "./BatchDeleteConfirmation"; @@ -35,6 +37,7 @@ function useSafeSearchParams() { } const WorkspacesPage: FC = () => { + const queryClient = useQueryClient(); // If we use a useSearchParams for each hook, the values will not be in sync. // So we have to use a single one, centralizing the values, and pass it to // each hook. @@ -72,7 +75,7 @@ const WorkspacesPage: FC = () => { const { data, error, queryKey, refetch } = useWorkspacesData({ ...pagination, - query: filterProps.filter.query, + q: filterProps.filter.query, }); const updateWorkspace = useWorkspaceUpdate(queryKey); @@ -128,6 +131,17 @@ const WorkspacesPage: FC = () => { onUpdateAll={() => setConfirmingBatchAction("update")} onStartAll={() => batchActions.startAll(checkedWorkspaces)} onStopAll={() => batchActions.stopAll(checkedWorkspaces)} + onActionSuccess={async () => { + await queryClient.invalidateQueries({ + queryKey, + }); + }} + onActionError={(error) => { + displayError( + getErrorMessage(error, "Failed to perform action"), + getErrorDetail(error), + ); + }} /> = { data: MockBuildInfo, }, ], + user: MockUser, }, decorators: [ + withAuthProvider, withDashboardProvider, (Story) => ( Promise; + onActionError: (error: unknown) => void; } export const WorkspacesPageView: FC = ({ @@ -88,6 +90,8 @@ export const WorkspacesPageView: FC = ({ templatesFetchStatus, canCreateTemplate, canChangeVersions, + onActionSuccess, + onActionError, }) => { // Let's say the user has 5 workspaces, but tried to hit page 100, which does // not exist. In this case, the page is not valid and we want to show a better @@ -224,6 +228,8 @@ export const WorkspacesPageView: FC = ({ onCheckChange={onCheckChange} canCheckWorkspaces={canCheckWorkspaces} templates={templates} + onActionSuccess={onActionSuccess} + onActionError={onActionError} /> )} diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index a9d585fccf58c..2fe94e0260a8f 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -2,6 +2,13 @@ import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight"; import Star from "@mui/icons-material/Star"; import Checkbox from "@mui/material/Checkbox"; import Skeleton from "@mui/material/Skeleton"; +import { templateVersion } from "api/queries/templates"; +import { + cancelBuild, + deleteWorkspace, + startWorkspace, + stopWorkspace, +} from "api/queries/workspaces"; import type { Template, Workspace, @@ -11,7 +18,9 @@ import type { import { Avatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/Avatar/AvatarData"; import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton"; +import { Button } from "components/Button/Button"; import { InfoTooltip } from "components/InfoTooltip/InfoTooltip"; +import { Spinner } from "components/Spinner/Spinner"; import { Stack } from "components/Stack/Stack"; import { StatusIndicator, @@ -30,14 +39,33 @@ import { TableLoaderSkeleton, TableRowSkeleton, } from "components/TableLoader/TableLoader"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; +import { useAuthenticated } from "hooks"; import { useClickableTableRow } from "hooks/useClickableTableRow"; +import { BanIcon, PlayIcon, RefreshCcwIcon, SquareIcon } from "lucide-react"; import { useDashboard } from "modules/dashboard/useDashboard"; import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus"; import { WorkspaceDormantBadge } from "modules/workspaces/WorkspaceDormantBadge/WorkspaceDormantBadge"; import { WorkspaceOutdatedTooltip } from "modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip"; -import { type FC, type ReactNode, useMemo } from "react"; +import { + WorkspaceUpdateDialogs, + useWorkspaceUpdate, +} from "modules/workspaces/WorkspaceUpdateDialogs"; +import { abilitiesByWorkspaceStatus } from "modules/workspaces/actions"; +import { + type FC, + type PropsWithChildren, + type ReactNode, + useMemo, +} from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate } from "react-router-dom"; import { cn } from "utils/cn"; import { @@ -60,6 +88,8 @@ export interface WorkspacesTableProps { canCheckWorkspaces: boolean; templates?: Template[]; canCreateTemplate: boolean; + onActionSuccess: () => Promise; + onActionError: (error: unknown) => void; } export const WorkspacesTable: FC = ({ @@ -71,6 +101,8 @@ export const WorkspacesTable: FC = ({ canCheckWorkspaces, templates, canCreateTemplate, + onActionSuccess, + onActionError, }) => { const dashboard = useDashboard(); const workspaceIDToAppByStatus = useMemo(() => { @@ -139,6 +171,7 @@ export const WorkspacesTable: FC = ({ Template Status + @@ -260,10 +293,14 @@ export const WorkspacesTable: FC = ({ - + -
- +
+
@@ -289,7 +326,7 @@ const WorkspacesRow: FC = ({ const workspacePageLink = `/@${workspace.owner_name}/${workspace.name}`; const openLinkInNewTab = () => window.open(workspacePageLink, "_blank"); - const clickableProps = useClickableTableRow({ + const { role, hover, ...clickableProps } = useClickableTableRow({ onMiddleClick: openLinkInNewTab, onClick: (event) => { // Order of booleans actually matters here for Windows-Mac compatibility; @@ -341,7 +378,12 @@ const TableLoader: FC = ({ canCheckWorkspaces }) => { - + + + +
+ +
@@ -399,3 +441,188 @@ const WorkspaceStatusCell: FC = ({ workspace }) => { ); }; + +type WorkspaceActionsCellProps = { + workspace: Workspace; + onActionSuccess: () => Promise; + onActionError: (error: unknown) => void; +}; + +const WorkspaceActionsCell: FC = ({ + workspace, + onActionSuccess, + onActionError, +}) => { + const { user } = useAuthenticated(); + + const queryClient = useQueryClient(); + const abilities = abilitiesByWorkspaceStatus(workspace, { + canDebug: false, + isOwner: user.roles.find((role) => role.name === "owner") !== undefined, + }); + + const startWorkspaceOptions = startWorkspace(workspace, queryClient); + const startWorkspaceMutation = useMutation({ + ...startWorkspaceOptions, + onSuccess: async (build) => { + startWorkspaceOptions.onSuccess(build); + await onActionSuccess(); + }, + onError: onActionError, + }); + + const stopWorkspaceOptions = stopWorkspace(workspace, queryClient); + const stopWorkspaceMutation = useMutation({ + ...stopWorkspaceOptions, + onSuccess: async (build) => { + stopWorkspaceOptions.onSuccess(build); + await onActionSuccess(); + }, + onError: onActionError, + }); + + const cancelJobOptions = cancelBuild(workspace, queryClient); + const cancelBuildMutation = useMutation({ + ...cancelJobOptions, + onSuccess: async () => { + cancelJobOptions.onSuccess(); + await onActionSuccess(); + }, + onError: onActionError, + }); + + const { data: latestVersion } = useQuery({ + ...templateVersion(workspace.template_active_version_id), + enabled: workspace.outdated, + }); + const workspaceUpdate = useWorkspaceUpdate({ + workspace, + latestVersion, + onSuccess: onActionSuccess, + onError: onActionError, + }); + + const deleteWorkspaceOptions = deleteWorkspace(workspace, queryClient); + const deleteWorkspaceMutation = useMutation({ + ...deleteWorkspaceOptions, + onSuccess: async (build) => { + deleteWorkspaceOptions.onSuccess(build); + await onActionSuccess(); + }, + onError: onActionError, + }); + + const isRetrying = + startWorkspaceMutation.isLoading || + stopWorkspaceMutation.isLoading || + deleteWorkspaceMutation.isLoading; + + const retry = () => { + switch (workspace.latest_build.transition) { + case "start": + startWorkspaceMutation.mutate({}); + break; + case "stop": + stopWorkspaceMutation.mutate({}); + break; + case "delete": + deleteWorkspaceMutation.mutate({}); + break; + } + }; + + return ( + +
+ {abilities.actions.includes("start") && ( + startWorkspaceMutation.mutate({})} + isLoading={startWorkspaceMutation.isLoading} + label="Start workspace" + > + + + )} + + {abilities.actions.includes("updateAndStart") && ( + <> + + + + + + )} + + {abilities.actions.includes("stop") && ( + { + stopWorkspaceMutation.mutate({}); + }} + isLoading={stopWorkspaceMutation.isLoading} + label="Stop workspace" + > + + + )} + + {abilities.canCancel && ( + + + + )} + + {abilities.actions.includes("retry") && ( + + + + )} +
+
+ ); +}; + +type PrimaryActionProps = PropsWithChildren<{ + onClick: () => void; + isLoading: boolean; + label: string; +}>; + +const PrimaryAction: FC = ({ + onClick, + isLoading, + label, + children, +}) => { + return ( + + + + + + {label} + + + ); +}; diff --git a/site/src/pages/WorkspacesPage/data.ts b/site/src/pages/WorkspacesPage/data.ts index 9b46ab7fed05b..764ea218aa96c 100644 --- a/site/src/pages/WorkspacesPage/data.ts +++ b/site/src/pages/WorkspacesPage/data.ts @@ -1,8 +1,10 @@ import { API } from "api/api"; import { getErrorMessage } from "api/errors"; +import { workspaces } from "api/queries/workspaces"; import type { Workspace, WorkspaceBuild, + WorkspacesRequest, WorkspacesResponse, } from "api/typesGenerated"; import { displayError } from "components/GlobalSnackbar/utils"; @@ -14,27 +16,11 @@ import { useQueryClient, } from "react-query"; -type UseWorkspacesDataParams = { - page: number; - limit: number; - query: string; -}; - -export const useWorkspacesData = ({ - page, - limit, - query, -}: UseWorkspacesDataParams) => { - const queryKey = ["workspaces", query, page]; +export const useWorkspacesData = (req: WorkspacesRequest) => { const [shouldRefetch, setShouldRefetch] = useState(true); + const workspacesQueryOptions = workspaces(req); const result = useQuery({ - queryKey, - queryFn: () => - API.getWorkspaces({ - q: query, - limit: limit, - offset: page <= 0 ? 0 : (page - 1) * limit, - }), + ...workspacesQueryOptions, onSuccess: () => { setShouldRefetch(true); }, @@ -46,7 +32,7 @@ export const useWorkspacesData = ({ return { ...result, - queryKey, + queryKey: workspacesQueryOptions.queryKey, }; };