Skip to content

feat: add extra workspace actions in the workspaces table #17775

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
May 13, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Refactor base menu component
  • Loading branch information
BrunoQuaresma committed May 12, 2025
commit 496473f425fc5a44c7781c0031150c2d80e555ce
6 changes: 3 additions & 3 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,10 +482,10 @@ class ApiMethods {
return response.data;
};

checkAuthorization = async (
checkAuthorization = async <TResponse extends TypesGen.AuthorizationResponse>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm concerned about why we have a generic for a function that checks anything auth-based. Does the server really give back multiple return types based on what parameters we give it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I'm a little concerned that with how the function is set up right now, we force anyone calling this function to pass in the correct type parameter. To some degree that's okay, since we have such a specific type constraint on here, I have to wonder if this is asking for trouble, not just as far as complexity, but also in terms of some other employee passing the wrong type in a few months from now

Two things come to mind:

  • I'm wondering if there's a way to redefine params so that we can infer the return type automatically. With less need to pass in types manually, there's less room for things to go wrong
  • I'm also wondering if it's worth updating the generic definition to TResponse extends TypesGen.AuthorizationResponse = TypesGen.AuthorizationResponse. That way, most users get back the base AuthorizationResponse type by default (which I'd imagine would be okay 99% of the time, since we haven't needed this constraint until now), and then if someone needs something more specific, they have the ability to pass in the type, and override the default return value

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is not possible right now since the response is dynamic and depends on the request parameters. However, I do agree it would be better to have something more predictable to avoid these kinda of errors.

params: TypesGen.AuthorizationRequest,
): Promise<TypesGen.AuthorizationResponse> => {
const response = await this.axios.post<TypesGen.AuthorizationResponse>(
) => {
const response = await this.axios.post<TResponse>(
"/api/v2/authcheck",
params,
);
Expand Down
11 changes: 8 additions & 3 deletions site/src/api/queries/authCheck.ts
Original file line number Diff line number Diff line change
@@ -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 = <TResponse extends AuthorizationResponse>(
req: AuthorizationRequest,
) => {
return {
queryKey: getAuthorizationKey(req),
queryFn: () => API.checkAuthorization(req),
queryFn: () => API.checkAuthorization<TResponse>(req),
};
};
16 changes: 16 additions & 0 deletions site/src/api/queries/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import type {
} from "react-query";
import { disabledRefetchOptions } from "./util";
import { workspaceBuildsKey } from "./workspaceBuilds";
import {
workspaceChecks,
type WorkspacePermissions,
} from "modules/workspaces/permissions";
import { checkAuthorization } from "./authCheck";

export const workspaceByOwnerAndNameKey = (owner: string, name: string) => [
"workspace",
Expand Down Expand Up @@ -390,3 +395,14 @@ export const workspaceUsage = (options: WorkspaceUsageOptions) => {
refetchIntervalInBackground: true,
};
};

export const workspacePermissions = (workspace?: Workspace) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add an explicit return type here? There's enough going on that I think it'd help communicate what this is supposed to be

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see too much value on that since it is dynamic and other parts of the application would throw an error. But, if it is a must for you, I can do it for sure.

return {
...checkAuthorization<WorkspacePermissions>({
checks: workspace ? workspaceChecks(workspace) : {},
}),
queryKey: ["workspaces", workspace?.id, "permissions"],
enabled: !!workspace,
staleTime: Number.POSITIVE_INFINITY,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Button } from "components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import {
EllipsisVertical,
SettingsIcon,
HistoryIcon,
TrashIcon,
CopyIcon,
DownloadIcon,
} from "lucide-react";
import { useState, type FC } from "react";
import { Link as RouterLink } from "react-router-dom";

type WorkspaceMoreActionsProps = {
isDuplicationReady: boolean;
disabled?: boolean;
Copy link
Member

@Parkreiner Parkreiner May 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two points of feedback on this prop:

  1. I don't think a disabled prop like this should ever be optional. I think that whoever is using this component should be forced to think though when it's allowed to be used. Otherwise, there's a risk that someone forgets the prop, and that'd let the user do an operation on a workspace while it's going through a state transition (which I don't think is ever valid)
  2. But I think that we can actually do better, because even with the prop being required, there's still this race condition that I was able to reproduce reliably:
image The dropdown is able to be open while the button is disabled. Steps to reproduce:
  1. Find a workspace that's running
  2. Go into the workspace details page
  3. Stop the workspace
  4. Quickly go back to the Workspaces directory page, and click the More Actions button
  5. The UI will eventually update to prevent the dropdown from opening, but if it's already open, there's nothing in place to make sure it closes

You have more context than I do, but I was thinking:

  1. We remove the disabled prop entirely
  2. We make it so that when a workspace is having a transition, the MoreActions component unmounts entirely. That way, the user still can't click a button they shouldn't be able to, but that also causes any open dropdowns and messages to close (because they no longer exist)
  3. If the user had a dropdown open already, we display a toast message explaining why the dropdown suddenly closed without them doing anything

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though I guess if we want to make sure the dots always appear for aesthetic consistency, no matter what transitions we have, another option is to make it so that the dropdowns have conditional rendering tied to the disabled prop

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be solved not in the component level, but in the data level. What I want to say is, when stopping the workspace, or triggering a build, we should revalidate the workspaces data to represent the correct state of each workspace. I'm going to take a look into that 👍

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lol, update the data is more complex than I thought because of pagination so I will just close the menu when the button becomes disabled.

onDuplicate: () => void;
onDelete: () => void;
onChangeVersion?: () => void;
permissions?: {
changeWorkspaceVersion?: boolean;
};
};

export const WorkspaceMoreActions: FC<WorkspaceMoreActionsProps> = ({
disabled,
isDuplicationReady,
onDuplicate,
onDelete,
onChangeVersion,
permissions,
}) => {
// Download logs
const [isDownloadDialogOpen, setIsDownloadDialogOpen] = useState(false);
const canChangeVersion = permissions?.changeWorkspaceVersion !== false;

return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
variant="subtle"
data-testid="workspace-options-button"
aria-controls="workspace-options"
disabled={disabled}
>
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Workspace actions</span>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appreciate the screenreader support text!

</Button>
</DropdownMenuTrigger>

<DropdownMenuContent id="workspace-options" align="end">
<DropdownMenuItem asChild>
<RouterLink to="settings">
<SettingsIcon />
Settings
</RouterLink>
</DropdownMenuItem>

{onChangeVersion && canChangeVersion && (
<DropdownMenuItem onClick={onChangeVersion}>
<HistoryIcon />
Change version&hellip;
</DropdownMenuItem>
)}

<DropdownMenuItem
onClick={onDuplicate}
disabled={!isDuplicationReady}
>
<CopyIcon />
Duplicate&hellip;
</DropdownMenuItem>

<DropdownMenuItem onClick={() => setIsDownloadDialogOpen(true)}>
<DownloadIcon />
Download logs&hellip;
</DropdownMenuItem>

<DropdownMenuSeparator />

<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={onDelete}
data-testid="delete-button"
>
<TrashIcon />
Delete&hellip;
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
};
50 changes: 50 additions & 0 deletions site/src/modules/workspaces/permissions.ts
Original file line number Diff line number Diff line change
@@ -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<string, AuthorizationCheck>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was skeptical of whether we needed this (as opposed to explicitly returningRecord<string, AuthorizationCheck>) , but after thinking it through, I think your approach is better. Big fan


export type WorkspacePermissions = Record<
keyof ReturnType<typeof workspaceChecks>,
boolean
>;
2 changes: 1 addition & 1 deletion site/src/pages/WorkspacePage/Workspace.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext";
import * as Mocks from "testHelpers/entities";
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
import { Workspace } from "./Workspace";
import type { WorkspacePermissions } from "./permissions";
import type { WorkspacePermissions } from "../../modules/workspaces/permissions";

// Helper function to create timestamps easily - Copied from AppStatuses.stories.tsx
const createTimestamp = (
Expand Down
5 changes: 1 addition & 4 deletions site/src/pages/WorkspacePage/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
} from "./WorkspaceBuildProgress";
import { WorkspaceDeletedBanner } from "./WorkspaceDeletedBanner";
import { WorkspaceTopbar } from "./WorkspaceTopbar";
import type { WorkspacePermissions } from "./permissions";
import type { WorkspacePermissions } from "../../modules/workspaces/permissions";
import { resourceOptionValue, useResourcesNav } from "./useResourcesNav";

export interface WorkspaceProps {
Expand All @@ -41,7 +41,6 @@ export interface WorkspaceProps {
isUpdating: boolean;
isRestarting: boolean;
workspace: TypesGen.Workspace;
canChangeVersions: boolean;
hideSSHButton?: boolean;
hideVSCodeDesktopButton?: boolean;
buildInfo?: TypesGen.BuildInfoResponse;
Expand Down Expand Up @@ -73,7 +72,6 @@ export const Workspace: FC<WorkspaceProps> = ({
workspace,
isUpdating,
isRestarting,
canChangeVersions,
hideSSHButton,
hideVSCodeDesktopButton,
buildInfo,
Expand Down Expand Up @@ -155,7 +153,6 @@ export const Workspace: FC<WorkspaceProps> = ({
handleDormantActivate={handleDormantActivate}
handleToggleFavorite={handleToggleFavorite}
canDebugMode={canDebugMode}
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}
isRestarting={isRestarting}
canUpdateWorkspace={permissions.updateWorkspace}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { docs } from "utils/docs";

interface WorkspaceDeleteDialogProps {
workspace: Workspace;
canUpdateTemplate: boolean;
canDeleteFailedWorkspace: boolean;
isOpen: boolean;
onCancel: () => void;
onConfirm: (arg: CreateWorkspaceBuildRequest["orphan"]) => void;
Expand All @@ -21,7 +21,7 @@ interface WorkspaceDeleteDialogProps {

export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
workspace,
canUpdateTemplate,
canDeleteFailedWorkspace,
isOpen,
onCancel,
onConfirm,
Expand Down Expand Up @@ -102,7 +102,7 @@ export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
// 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" && (
<div css={styles.orphanContainer}>
<div css={{ flexDirection: "column" }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 6 additions & 9 deletions site/src/pages/WorkspacePage/WorkspacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,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";
Expand All @@ -15,7 +18,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();
Expand Down Expand Up @@ -43,13 +45,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(
Expand Down
11 changes: 3 additions & 8 deletions site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { ChangeVersionDialog } from "./ChangeVersionDialog";
import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog";
import { Workspace } from "./Workspace";
import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog";
import type { WorkspacePermissions } from "./permissions";
import type { WorkspacePermissions } from "modules/workspaces/permissions";

interface WorkspaceReadyPageProps {
template: TypesGen.Template;
Expand All @@ -62,7 +62,7 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
// Debug mode
const { data: deploymentValues } = useQuery({
...deploymentConfig(),
enabled: permissions.viewDeploymentConfig,
enabled: permissions.deploymentConfig,
});

// Build logs
Expand Down Expand Up @@ -99,7 +99,6 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
}, []);

// Change version
const canChangeVersions = permissions.updateTemplate;
const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false);
const changeVersionMutation = useMutation(
changeVersion(workspace, queryClient),
Expand All @@ -121,9 +120,6 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
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);
const deleteWorkspaceMutation = useMutation(
deleteWorkspace(workspace, queryClient),
Expand Down Expand Up @@ -267,7 +263,6 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
toggleFavoriteMutation.mutate();
}}
latestVersion={latestVersion}
canChangeVersions={canChangeVersions}
hideSSHButton={featureVisibility.browser_only}
hideVSCodeDesktopButton={featureVisibility.browser_only}
buildInfo={buildInfoQuery.data}
Expand All @@ -279,7 +274,7 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({

<WorkspaceDeleteDialog
workspace={workspace}
canUpdateTemplate={canUpdateTemplate}
canDeleteFailedWorkspace={permissions.deleteFailedWorkspace}
isOpen={isConfirmingDelete}
onCancel={() => {
setIsConfirmingDelete(false);
Expand Down
Loading