8000 feat: add inline actions into workspaces table by BrunoQuaresma · Pull Request #17636 · coder/coder · GitHub
[go: up one dir, main page]

Skip to content

feat: add inline actions into workspaces table #17636

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 23 commits into from
May 6, 2025
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0ba8372
feat: add inline actions into workspaces table
BrunoQuaresma May 1, 2025
25d986e
Fix is retrying
BrunoQuaresma May 1, 2025
50d9d74
Refactor actions
BrunoQuaresma May 1, 2025
a20a188
Simplify permissions check
BrunoQuaresma May 1, 2025
6f054b9
Handle action errors
BrunoQuaresma May 1, 2025
fcc5a27
Refactor workspace update action
BrunoQuaresma May 1, 2025
10dcd68
Merge branch 'main' of https://github.com/coder/coder into bq/add-bas…
BrunoQuaresma May 1, 2025
766463d
Run fmt
BrunoQuaresma May 1, 2025
c14442c
Merge branch 'main' of https://github.com/coder/coder into bq/add-bas…
BrunoQuaresma May 1, 2025
281cf1a
Fix WorkspacesPage tests
BrunoQuaresma May 1, 2025
71c6369
Run FMT
BrunoQuaresma May 1, 2025
3502a22
Fix WorkspacePage tests
BrunoQuaresma May 1, 2025
7d2b8b3
Fix storybook failed tests
BrunoQuaresma May 1, 2025
61dc162
Fix one more storybook test
BrunoQuaresma May 1, 2025
ddcb1e9
Fixes
BrunoQuaresma May 1, 2025
53c4332
Adjust skeleton
BrunoQuaresma May 1, 2025
86337e2
Align inline actions to the right
BrunoQuaresma May 1, 2025
821f9d0
Merge branch 'main' into bq/add-base-actions
BrunoQuaresma May 2, 2025
b70e28f
Apply improvements from PR review
BrunoQuaresma May 6, 2025
b790963
Merge branch 'bq/add-base-actions' of https://github.com/coder/coder …
BrunoQuaresma May 6, 2025
0987ed1
Merge branch 'main' into bq/add-base-actions
BrunoQuaresma May 6, 2025
c1d3046
Fix lint
BrunoQuaresma May 6, 2025
f797506
Merge branch 'bq/add-base-actions' of https://github.com/coder/coder …
BrunoQuaresma May 6, 2025
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
6 changes: 1 addition & 5 deletions site/src/api/queries/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
}

Expand Down
9 changes: 8 additions & 1 deletion site/src/components/Dialogs/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
)}
Expand Down
6 changes: 5 additions & 1 deletion site/src/hooks/usePagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -23,3 +23,7 @@ export const usePagination = ({
offset,
};
};

export const calcOffset = (page: number, limit: number) => {
return page <= 0 ? 0 : (page - 1) * limit;
Copy link
Member

Choose a reason for hiding this comment

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

This can be turned into Math.max(0, limit * (page - 1))

Copy link
Member
@Parkreiner Parkreiner May 2, 2025

Choose a reason for hiding this comment

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

Also, do we want to put this function in this file? It's being imported by a lot of files that don't care about the hook

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 true. I just didn't want to create a module utils/pagination.ts just for one function, but you have a good point. What would you suggest?

};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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"],
Expand All @@ -58,7 +66,7 @@ export const abilitiesByWorkspaceStatus = (
case "starting": {
return {
actions: ["starting"],
canCancel: true,
canCancel: true && hasPermissionToCancel,
Copy link
Member

Choose a reason for hiding this comment

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

The true literals can be removed here and for stopping. If you AND a boolean with true, you always get back the original value

canAcceptJobs: false,
};
}
Expand All @@ -83,7 +91,7 @@ export const abilitiesByWorkspaceStatus = (
case "stopping": {
return {
actions: ["stopping"],
canCancel: true,
canCancel: true && hasPermissionToCancel,
canAcceptJobs: false,
};
}
Expand Down Expand Up @@ -115,7 +123,7 @@ export const abilitiesByWorkspaceStatus = (
case "failed": {
const actions: ActionType[] = ["retry"];

if (canDebug) {
if (permissions.canDebug) {
actions.push("debug");
}

Expand Down
162 changes: 162 additions & 0 deletions site/src/modules/workspaces/useWorkspaceUpdate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
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 { UpdateBuild F438 ParametersDialog } 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: (
hasConfirmed?: boolean,
buildParameters?: WorkspaceBuildParameter[],
) => void;
isUpdating: boolean;
dialogs: {
updateConfirmation: UpdateConfirmationDialogProps;
missingBuildParameters: MissingBuildParametersDialogProps;
};
};

export const useWorkspaceUpdate = ({
Copy link
Member

Choose a reason for hiding this comment

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

Actually, now that I think about it, do we need to export the custom hook? We could keep it as an implementation detail, but couldn't we update WorkspaceUpdateDialogs to accept the hook inputs directly, and then have it hook everything up?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Could you show me an example? Or a draft the interface/usage of it?

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 = (
hasConfirmed = false,
buildParameters: WorkspaceBuildParameter[] = [],
) => {
if (!hasConfirmed) {
setIsConfirmingUpdate(true);
return;
}

updateWorkspaceMutation.mutate(buildParameters);
setIsConfirmingUpdate(false);
};

return {
update,
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 not opposed to having a general function that handles both the confirmed case and the non-confirmed case, but I'd prefer for that to be kept as an implementation detail. I feel like there's a risk of accidentally calling update the wrong way if we return it out directly

I think it'd be better if we did something like this:

update: () => update()

This also makes sure that the types can't lie to us

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 see... Maybe in this case, we could have two functions askForConfirmation and update. What do you think?

isUpdating: updateWorkspaceMutation.isLoading,
dialogs: {
updateConfirmation: {
open: isConfirmingUpdate,
onClose: () => setIsConfirmingUpdate(false),
onConfirm: () => update(true),
latestVersion,
},
missingBuildParameters: {
error: updateWorkspaceMutation.error,
onClose: () => {
updateWorkspaceMutation.reset();
},
onUpdate: (buildParameters: WorkspaceBuildParameter[]) => {
if (updateWorkspaceMutation.error instanceof MissingBuildParameters) {
update(true, buildParameters);
}
},
},
},
};
};

type WorkspaceUpdateDialogsProps = {
updateConfirmation: UpdateConfirmationDialogProps;
missingBuildParameters: MissingBuildParametersDialogProps;
};

export const WorkspaceUpdateDialogs: FC<WorkspaceUpdateDialogsProps> = ({
Copy link
Member

Choose a reason for hiding this comment

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

Because this file is exporting components that aren't simple pass-through components like context providers, I feel like it's probably better to rename the file to put more emphasis on the components over the hook (could probably name it WorkspaceUpdateDialogs?). The hook just feels like an implementation detail

Copy link
Member

Choose a reason for hiding this comment

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

Especially since this isn't the first useWorkspaceUpdate in the codebase

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Makes total sense 👍

updateConfirmation,
missingBuildParameters,
}) => {
return (
<>
<UpdateConfirmationDialog {...updateConfirmation} />
<MissingBuildParametersDialog {...missingBuildParameters} />
</>
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 also wondering: do we want to make sure that both dialogs are mounted together? I feel like passing two separate, custom objects as props is a bit much, but I don't know if we get away with splitting them up as separate exports

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 was wondering about that too 🤔. Since they are suppose to always working together I thought that would be better to enforce this by exporting only one component with the two dialogs, but I'm open for suggestions.

);
};

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}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@ import type { Template, Workspace } from "api/typesGenerated";
import { compareAsc } from "date-fns";
import { useWorkspacesData } from "pages/WorkspacesPage/data";
import type { TemplateScheduleFormValues } from "./formHelpers";
import { calcOffset } from "hooks/usePagination";

export const useWorkspacesToGoDormant = (
template: Template,
formValues: TemplateScheduleFormValues,
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) => {
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 0 additions & 3 deletions site/src/pages/WorkspacePage/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ export interface WorkspaceProps {
buildLogs?: TypesGen.ProvisionerJobLog[];
latestVersion?: TypesGen.TemplateVersion;
permissions: WorkspacePermissions;
isOwner: boolean;
timings?: TypesGen.WorkspaceBuildTimings;
}

Expand Down Expand Up @@ -86,7 +85,6 @@ export const Workspace: FC<WorkspaceProps> = ({
buildLogs,
latestVersion,
permissions,
isOwner,
timings,
}) => {
const navigate = useNavigate();
Expand Down Expand Up @@ -161,7 +159,6 @@ export const Workspace: FC<WorkspaceProps> = ({
isUpdating={isUpdating}
isRestarting={isRestarting}
canUpdateWorkspace={permissions.updateWorkspace}
isOwner={isOwner}
template={template}
permissions={permissions}
latestVersion={latestVersion}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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,
},
};

Expand All @@ -180,7 +185,9 @@ export const CancelHiddenForUser: Story = {
...Mocks.MockStartingWorkspace,
template_allow_user_cancel_workspace_jobs: false,
},
isOwner: false,
},
parameters: {
user: Mocks.MockUser2,
},
};

Expand Down
Loading
Loading
0