feat: add inline actions into workspaces table (#17636)

Related to https://github.com/coder/coder/issues/17311

This PR adds inline actions in the workspaces page. It is a bit
different of the [original
design](https://www.figma.com/design/OR75XeUI0Z3ksqt1mHsNQw/Workspace-views?node-id=656-3979&m=dev)
because I'm splitting the work into three phases that I will explain in
more details in the demo.



https://github.com/user-attachments/assets/6383375e-ed10-45d1-b5d5-b4421e86d158
This commit is contained in:
Bruno Quaresma
2025-05-06 11:28:14 -03:00
committed by GitHub
parent 5f516ed135
commit d9b00e4849
19 changed files with 495 additions and 127 deletions
+5 -6
View File
@@ -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<WorkspacesResponse>;
}
@@ -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<Workspace>(workspaceKey);
if (!previousData) {
return;
}
// Check if the build returned is newer than the previous build that could be
// updated from web socket
+8 -1
View File
@@ -35,7 +35,14 @@ export const DialogActionButtons: FC<DialogActionButtonsProps> = ({
return (
<>
{onCancel && (
<Button disabled={confirmLoading} onClick={onCancel} variant="outline">
<Button
disabled={confirmLoading}
onClick={(e) => {
e.stopPropagation();
onCancel();
}}
variant="outline"
>
{cancelText}
</Button>
)}
+5 -1
View File
@@ -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;
};
@@ -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<WorkspaceUpdateDialogsProps> = ({
updateConfirmation,
missingBuildParameters,
}) => {
return (
<>
<UpdateConfirmationDialog {...updateConfirmation} />
<MissingBuildParametersDialog {...missingBuildParameters} />
</>
);
};
type UpdateConfirmationDialogProps = {
open: boolean;
onClose: () => void;
onConfirm: () => void;
latestVersion?: TemplateVersion;
};
const UpdateConfirmationDialog: FC<UpdateConfirmationDialogProps> = ({
latestVersion,
...dialogProps
}) => {
return (
<ConfirmDialog
{...dialogProps}
hideCancel={false}
title="Update workspace?"
confirmText="Update"
description={
<div className="flex flex-col gap-2">
<p>
Updating your workspace will start the workspace on the latest
template version. This can{" "}
<strong>delete non-persistent data</strong>.
</p>
{latestVersion?.message && (
<MemoizedInlineMarkdown allowedElements={["ol", "ul", "li"]}>
{latestVersion.message}
</MemoizedInlineMarkdown>
)}
</div>
}
/>
);
};
type MissingBuildParametersDialogProps = {
error: unknown;
onClose: () => void;
onUpdate: (buildParameters: WorkspaceBuildParameter[]) => void;
};
const MissingBuildParametersDialog: FC<MissingBuildParametersDialogProps> = ({
error,
...dialogProps
}) => {
return (
<UpdateBuildParametersDialog
missedParameters={
error instanceof MissingBuildParameters ? error.parameters : []
}
open={error instanceof MissingBuildParameters}
{...dialogProps}
/>
);
};
@@ -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");
}
@@ -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) {
@@ -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<typeof Workspace> = {
data: Mocks.MockListeningPortsResponse,
},
],
user: Mocks.MockUser,
},
decorators: [
withAuthProvider,
withDashboardProvider,
(Story) => (
<ProxyContext.Provider
@@ -53,7 +53,6 @@ export interface WorkspaceProps {
buildLogs?: TypesGen.ProvisionerJobLog[];
latestVersion?: TypesGen.TemplateVersion;
permissions: WorkspacePermissions;
isOwner: boolean;
timings?: TypesGen.WorkspaceBuildTimings;
}
@@ -86,7 +85,6 @@ export const Workspace: FC<WorkspaceProps> = ({
buildLogs,
latestVersion,
permissions,
isOwner,
timings,
}) => {
const navigate = useNavigate();
@@ -161,7 +159,6 @@ export const Workspace: FC<WorkspaceProps> = ({
isUpdating={isUpdating}
isRestarting={isRestarting}
canUpdateWorkspace={permissions.updateWorkspace}
isOwner={isOwner}
template={template}
permissions={permissions}
latestVersion={latestVersion}
@@ -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<typeof WorkspaceActions> = {
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,
},
};
@@ -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<WorkspaceActionsProps> = ({
@@ -73,20 +76,19 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
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<WorkspaceActionsProps> = ({
<Fragment key={action}>{buttonMapping[action]}</Fragment>
))}
{showCancel && <CancelButton handleAction={handleCancel} />}
{canCancel && <CancelButton handleAction={handleCancel} />}
<FavoriteButton
workspaceID={workspace.id}
@@ -12,7 +12,6 @@ import {
startWorkspace,
stopWorkspace,
toggleFavorite,
updateWorkspace,
} from "api/queries/workspaces";
import type * as TypesGen from "api/typesGenerated";
import {
@@ -20,13 +19,14 @@ import {
type ConfirmDialogProps,
} from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { displayError } from "components/GlobalSnackbar/utils";
import { MemoizedInlineMarkdown } from "components/Markdown/Markdown";
import { Stack } from "components/Stack/Stack";
import dayjs from "dayjs";
import { useAuthenticated } from "hooks";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import {
WorkspaceUpdateDialogs,
useWorkspaceUpdate,
} from "modules/workspaces/WorkspaceUpdateDialogs";
import { type FC, useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQuery, useQueryClient } from "react-query";
@@ -59,10 +59,6 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
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<WorkspaceReadyPageProps> = ({
});
// 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<WorkspaceReadyPageProps> = ({
<Workspace
permissions={permissions}
isUpdating={updateWorkspaceMutation.isLoading}
isUpdating={workspaceUpdate.isUpdating}
isRestarting={isRestarting}
workspace={workspace}
handleStart={(buildParameters) => {
@@ -248,9 +244,7 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
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<WorkspaceReadyPageProps> = ({
sshPrefix={sshPrefixQuery.data?.hostname_prefix}
template={template}
buildLogs={buildLogs}
isOwner={isOwner}
timings={timingsQuery.data}
/>
@@ -318,23 +311,6 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
}}
/>
<UpdateBuildParametersDialog
missedParameters={
updateWorkspaceMutation.error instanceof MissingBuildParameters
? updateWorkspaceMutation.error.parameters
: []
}
open={updateWorkspaceMutation.error instanceof MissingBuildParameters}
onClose={() => {
updateWorkspaceMutation.reset();
}}
onUpdate={(buildParameters) => {
if (updateWorkspaceMutation.error instanceof MissingBuildParameters) {
updateWorkspaceMutation.mutate(buildParameters);
}
}}
/>
<ChangeVersionDialog
templateVersions={allVersions?.reverse()}
template={template}
@@ -351,31 +327,6 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
}}
/>
<WarningDialog
open={isConfirmingUpdate}
onConfirm={() => {
updateWorkspaceMutation.mutate(undefined);
setIsConfirmingUpdate(false);
}}
onClose={() => setIsConfirmingUpdate(false)}
title="Update workspace?"
confirmText="Update"
description={
<Stack>
<p>
Updating your workspace will start the workspace on the latest
template version. This can{" "}
<strong>delete non-persistent data</strong>.
</p>
{latestVersion?.message && (
<MemoizedInlineMarkdown allowedElements={["ol", "ul", "li"]}>
{latestVersion.message}
</MemoizedInlineMarkdown>
)}
</Stack>
}
/>
<WarningDialog
open={confirmingRestart.open}
onConfirm={() => {
@@ -395,6 +346,8 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
</>
}
/>
<WorkspaceUpdateDialogs {...workspaceUpdate.dialogs} />
</>
);
};
@@ -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<typeof WorkspaceTopbar> = {
title: "pages/WorkspacePage/WorkspaceTopbar",
component: WorkspaceTopbar,
decorators: [withDashboardProvider],
decorators: [withAuthProvider, withDashboardProvider],
args: {
workspace: baseWorkspace,
template: MockTemplate,
@@ -41,6 +41,7 @@ const meta: Meta<typeof WorkspaceTopbar> = {
chromatic: {
diffThreshold: 0.6,
},
user: MockUser,
},
};
@@ -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<WorkspaceProps> = ({
canDebugMode,
handleRetry,
handleDebug,
isOwner,
template,
latestVersion,
permissions,
@@ -262,7 +260,6 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}
isRestarting={isRestarting}
isOwner={isOwner}
/>
</div>
)}
@@ -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(() => {
@@ -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),
);
}}
/>
<BatchDeleteConfirmation
@@ -26,7 +26,7 @@ import {
MockWorkspaceAppStatus,
mockApiError,
} from "testHelpers/entities";
import { withDashboardProvider } from "testHelpers/storybook";
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
import { WorkspacesPageView } from "./WorkspacesPageView";
const createWorkspace = (
@@ -144,8 +144,10 @@ const meta: Meta<typeof WorkspacesPageView> = {
data: MockBuildInfo,
},
],
user: MockUser,
},
decorators: [
withAuthProvider,
withDashboardProvider,
(Story) => (
<ProxyContext.Provider
@@ -65,6 +65,8 @@ export interface WorkspacesPageViewProps {
templates: TemplateQuery["data"];
canCreateTemplate: boolean;
canChangeVersions: boolean;
onActionSuccess: () => Promise<void>;
onActionError: (error: unknown) => void;
}
export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
@@ -88,6 +90,8 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
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<WorkspacesPageViewProps> = ({
onCheckChange={onCheckChange}
canCheckWorkspaces={canCheckWorkspaces}
templates={templates}
onActionSuccess={onActionSuccess}
onActionError={onActionError}
/>
)}
@@ -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<void>;
onActionError: (error: unknown) => void;
}
export const WorkspacesTable: FC<WorkspacesTableProps> = ({
@@ -71,6 +101,8 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
canCheckWorkspaces,
templates,
canCreateTemplate,
onActionSuccess,
onActionError,
}) => {
const dashboard = useDashboard();
const workspaceIDToAppByStatus = useMemo(() => {
@@ -139,6 +171,7 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
<TableHead className="w-2/6">Template</TableHead>
<TableHead className="w-2/6">Status</TableHead>
<TableHead className="w-0" />
<TableHead className="w-0" />
</TableRow>
</TableHeader>
<TableBody className="[&_td]:h-[72px]">
@@ -260,10 +293,14 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
</TableCell>
<WorkspaceStatusCell workspace={workspace} />
<WorkspaceActionsCell
workspace={workspace}
onActionSuccess={onActionSuccess}
onActionError={onActionError}
/>
<TableCell>
<div className="flex pl-4">
<KeyboardArrowRight className="text-content-secondary w-5 h-5" />
<div className="flex">
<KeyboardArrowRight className="text-content-secondary size-icon-sm" />
</div>
</TableCell>
</WorkspacesRow>
@@ -289,7 +326,7 @@ const WorkspacesRow: FC<WorkspacesRowProps> = ({
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<TableLoaderProps> = ({ canCheckWorkspaces }) => {
<Skeleton variant="text" width="50%" />
</TableCell>
<TableCell className="w-0">
<Skeleton variant="text" width="25%" />
<Skeleton variant="rounded" width={40} height={40} />
</TableCell>
<TableCell>
<div className="flex">
<KeyboardArrowRight className="text-content-disabled size-icon-sm" />
</div>
</TableCell>
</TableRowSkeleton>
</TableLoaderSkeleton>
@@ -399,3 +441,188 @@ const WorkspaceStatusCell: FC<WorkspaceStatusCellProps> = ({ workspace }) => {
</TableCell>
);
};
type WorkspaceActionsCellProps = {
workspace: Workspace;
onActionSuccess: () => Promise<void>;
onActionError: (error: unknown) => void;
};
const WorkspaceActionsCell: FC<WorkspaceActionsCellProps> = ({
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 (
<TableCell>
<div className="flex gap-1 justify-end">
{abilities.actions.includes("start") && (
<PrimaryAction
onClick={() => startWorkspaceMutation.mutate({})}
isLoading={startWorkspaceMutation.isLoading}
label="Start workspace"
>
<PlayIcon />
</PrimaryAction>
)}
{abilities.actions.includes("updateAndStart") && (
<>
<PrimaryAction
onClick={workspaceUpdate.update}
isLoading={workspaceUpdate.isUpdating}
label="Update and start workspace"
>
<PlayIcon />
</PrimaryAction>
<WorkspaceUpdateDialogs {...workspaceUpdate.dialogs} />
</>
)}
{abilities.actions.includes("stop") && (
<PrimaryAction
onClick={() => {
stopWorkspaceMutation.mutate({});
}}
isLoading={stopWorkspaceMutation.isLoading}
label="Stop workspace"
>
<SquareIcon />
</PrimaryAction>
)}
{abilities.canCancel && (
<PrimaryAction
onClick={cancelBuildMutation.mutate}
isLoading={cancelBuildMutation.isLoading}
label="Cancel build"
>
<BanIcon />
</PrimaryAction>
)}
{abilities.actions.includes("retry") && (
<PrimaryAction
onClick={retry}
isLoading={isRetrying}
label="Retry build"
>
<RefreshCcwIcon />
</PrimaryAction>
)}
</div>
</TableCell>
);
};
type PrimaryActionProps = PropsWithChildren<{
onClick: () => void;
isLoading: boolean;
label: string;
}>;
const PrimaryAction: FC<PrimaryActionProps> = ({
onClick,
isLoading,
label,
children,
}) => {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon-lg"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
<Spinner loading={isLoading}>{children}</Spinner>
<span className="sr-only">{label}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
+6 -20
View File
@@ -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,
};
};