Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit d9b00e4

Browse files
feat: add inline actions into workspaces table (#17636)
Related to #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
1 parent 5f516ed commit d9b00e4

19 files changed

+495
-127
lines changed

site/src/api/queries/workspaces.ts

+5-6
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,9 @@ function workspacesKey(config: WorkspacesRequest = {}) {
139139
}
140140

141141
export function workspaces(config: WorkspacesRequest = {}) {
142-
// Duplicates some of the work from workspacesKey, but that felt better than
143-
// letting invisible properties sneak into the query logic
144-
const { q, limit } = config;
145-
146142
return {
147143
queryKey: workspacesKey(config),
148-
queryFn: () => API.getWorkspaces({ q, limit }),
144+
queryFn: () => API.getWorkspaces(config),
149145
} as const satisfies QueryOptions<WorkspacesResponse>;
150146
}
151147

@@ -281,7 +277,10 @@ const updateWorkspaceBuild = async (
281277
build.workspace_owner_name,
282278
build.workspace_name,
283279
);
284-
const previousData = queryClient.getQueryData(workspaceKey) as Workspace;
280+
const previousData = queryClient.getQueryData<Workspace>(workspaceKey);
281+
if (!previousData) {
282+
return;
283+
}
285284

286285
// Check if the build returned is newer than the previous build that could be
287286
// updated from web socket

site/src/components/Dialogs/Dialog.tsx

+8-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,14 @@ export const DialogActionButtons: FC<DialogActionButtonsProps> = ({
3535
return (
3636
<>
3737
{onCancel && (
38-
<Button disabled={confirmLoading} onClick={onCancel} variant="outline">
38+
<Button
39+
disabled={confirmLoading}
40+
onClick={(e) => {
41+
e.stopPropagation();
42+
onCancel();
43+
}}
44+
variant="outline"
45+
>
3946
{cancelText}
4047
</Button>
4148
)}

site/src/hooks/usePagination.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const usePagination = ({
99
const [searchParams, setSearchParams] = searchParamsResult;
1010
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
1111
const limit = DEFAULT_RECORDS_PER_PAGE;
12-
const offset = page <= 0 ? 0 : (page - 1) * limit;
12+
const offset = calcOffset(page, limit);
1313

1414
const goToPage = (page: number) => {
1515
searchParams.set("page", page.toString());
@@ -23,3 +23,7 @@ export const usePagination = ({
2323
offset,
2424
};
2525
};
26+
27+
export const calcOffset = (page: number, limit: number) => {
28+
return page <= 0 ? 0 : (page - 1) * limit;
29+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { MissingBuildParameters } from "api/api";
2+
import { updateWorkspace } from "api/queries/workspaces";
3+
import type {
4+
TemplateVersion,
5+
Workspace,
6+
WorkspaceBuild,
7+
WorkspaceBuildParameter,
8+
} from "api/typesGenerated";
9+
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
10+
import { MemoizedInlineMarkdown } from "components/Markdown/Markdown";
11+
import { UpdateBuildParametersDialog } from "pages/WorkspacePage/UpdateBuildParametersDialog";
12+
import { type FC, useState } from "react";
13+
import { useMutation, useQueryClient } from "react-query";
14+
15+
type UseWorkspaceUpdateOptions = {
16+
workspace: Workspace;
17+
latestVersion: TemplateVersion | undefined;
18+
onSuccess?: (build: WorkspaceBuild) => void;
19+
onError?: (error: unknown) => void;
20+
};
21+
22+
type UseWorkspaceUpdateResult = {
23+
update: () => void;
24+
isUpdating: boolean;
25+
dialogs: {
26+
updateConfirmation: UpdateConfirmationDialogProps;
27+
missingBuildParameters: MissingBuildParametersDialogProps;
28+
};
29+
};
30+
31+
export const useWorkspaceUpdate = ({
32+
workspace,
33+
latestVersion,
34+
onSuccess,
35+
onError,
36+
}: UseWorkspaceUpdateOptions): UseWorkspaceUpdateResult => {
37+
const queryClient = useQueryClient();
38+
const [isConfirmingUpdate, setIsConfirmingUpdate] = useState(false);
39+
40+
const updateWorkspaceOptions = updateWorkspace(workspace, queryClient);
41+
const updateWorkspaceMutation = useMutation({
42+
...updateWorkspaceOptions,
43+
onSuccess: (build: WorkspaceBuild) => {
44+
updateWorkspaceOptions.onSuccess(build);
45+
onSuccess?.(build);
46+
},
47+
onError,
48+
});
49+
50+
const update = () => {
51+
setIsConfirmingUpdate(true);
52+
};
53+
54+
const confirmUpdate = (buildParameters: WorkspaceBuildParameter[] = []) => {
55+
updateWorkspaceMutation.mutate(buildParameters);
56+
setIsConfirmingUpdate(false);
57+
};
58+
59+
return {
60+
update,
61+
isUpdating: updateWorkspaceMutation.isLoading,
62+
dialogs: {
63+
updateConfirmation: {
64+
open: isConfirmingUpdate,
65+
onClose: () => setIsConfirmingUpdate(false),
66+
onConfirm: () => confirmUpdate(),
67+
latestVersion,
68+
},
69+
missingBuildParameters: {
70+
error: updateWorkspaceMutation.error,
71+
onClose: () => {
72+
updateWorkspaceMutation.reset();
73+
},
74+
onUpdate: (buildParameters: WorkspaceBuildParameter[]) => {
75+
if (updateWorkspaceMutation.error instanceof MissingBuildParameters) {
76+
confirmUpdate(buildParameters);
77+
}
78+
},
79+
},
80+
},
81+
};
82+
};
83+
84+
type WorkspaceUpdateDialogsProps = {
85+
updateConfirmation: UpdateConfirmationDialogProps;
86+
missingBuildParameters: MissingBuildParametersDialogProps;
87+
};
88+
89+
export const WorkspaceUpdateDialogs: FC<WorkspaceUpdateDialogsProps> = ({
90+
updateConfirmation,
91+
missingBuildParameters,
92+
}) => {
93+
return (
94+
<>
95+
<UpdateConfirmationDialog {...updateConfirmation} />
96+
<MissingBuildParametersDialog {...missingBuildParameters} />
97+
</>
98+
);
99+
};
100+
101+
type UpdateConfirmationDialogProps = {
102+
open: boolean;
103+
onClose: () => void;
104+
onConfirm: () => void;
105+
latestVersion?: TemplateVersion;
106+
};
107+
108+
const UpdateConfirmationDialog: FC<UpdateConfirmationDialogProps> = ({
109+
latestVersion,
110+
...dialogProps
111+
}) => {
112+
return (
113+
<ConfirmDialog
114+
{...dialogProps}
115+
hideCancel={false}
116+
title="Update workspace?"
117+
confirmText="Update"
118+
description={
119+
<div className="flex flex-col gap-2">
120+
<p>
121+
Updating your workspace will start the workspace on the latest
122+
template version. This can{" "}
123+
<strong>delete non-persistent data</strong>.
124+
</p>
125+
{latestVersion?.message && (
126+
<MemoizedInlineMarkdown allowedElements={["ol", "ul", "li"]}>
127+
{latestVersion.message}
128+
</MemoizedInlineMarkdown>
129+
)}
130+
</div>
131+
}
132+
/>
133+
);
134+
};
135+
136+
type MissingBuildParametersDialogProps = {
137+
error: unknown;
138+
onClose: () => void;
139+
onUpdate: (buildParameters: WorkspaceBuildParameter[]) => void;
140+
};
141+
142+
const MissingBuildParametersDialog: FC<MissingBuildParametersDialogProps> = ({
143+
error,
144+
...dialogProps
145+
}) => {
146+
return (
147+
<UpdateBuildParametersDialog
148+
missedParameters={
149+
error instanceof MissingBuildParameters ? error.parameters : []
150+
}
151+
open={error instanceof MissingBuildParameters}
152+
{...dialogProps}
153+
/>
154+
);
155+
};

site/src/pages/WorkspacePage/WorkspaceActions/constants.ts renamed to site/src/modules/workspaces/actions.ts

+12-4
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ const actionTypes = [
3434

3535
export type ActionType = (typeof actionTypes)[number];
3636

37+
type ActionPermissions = {
38+
canDebug: boolean;
39+
isOwner: boolean;
40+
};
41+
3742
type WorkspaceAbilities = {
3843
actions: readonly ActionType[];
3944
canCancel: boolean;
@@ -42,8 +47,11 @@ type WorkspaceAbilities = {
4247

4348
export const abilitiesByWorkspaceStatus = (
4449
workspace: Workspace,
45-
canDebug: boolean,
50+
permissions: ActionPermissions,
4651
): WorkspaceAbilities => {
52+
const hasPermissionToCancel =
53+
workspace.template_allow_user_cancel_workspace_jobs || permissions.isOwner;
54+
4755
if (workspace.dormant_at) {
4856
return {
4957
actions: ["activate"],
@@ -58,7 +66,7 @@ export const abilitiesByWorkspaceStatus = (
5866
case "starting": {
5967
return {
6068
actions: ["starting"],
61-
canCancel: true,
69+
canCancel: hasPermissionToCancel,
6270
canAcceptJobs: false,
6371
};
6472
}
@@ -83,7 +91,7 @@ export const abilitiesByWorkspaceStatus = (
8391
case "stopping": {
8492
return {
8593
actions: ["stopping"],
86-
canCancel: true,
94+
canCancel: hasPermissionToCancel,
8795
canAcceptJobs: false,
8896
};
8997
}
@@ -115,7 +123,7 @@ export const abilitiesByWorkspaceStatus = (
115123
case "failed": {
116124
const actions: ActionType[] = ["retry"];
117125

118-
if (canDebug) {
126+
if (permissions.canDebug) {
119127
actions.push("debug");
120128
}
121129

site/src/pages/TemplateSettingsPage/TemplateSchedulePage/useWorkspacesToBeDeleted.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Template, Workspace } from "api/typesGenerated";
22
import { compareAsc } from "date-fns";
3+
import { calcOffset } from "hooks/usePagination";
34
import { useWorkspacesData } from "pages/WorkspacesPage/data";
45
import type { TemplateScheduleFormValues } from "./formHelpers";
56

@@ -9,9 +10,9 @@ export const useWorkspacesToGoDormant = (
910
fromDate: Date,
1011
) => {
1112
const { data } = useWorkspacesData({
12-
page: 0,
13+
offset: calcOffset(0, 0),
1314
limit: 0,
14-
query: `template:${template.name}`,
15+
q: `template:${template.name}`,
1516
});
1617

1718
return data?.workspaces?.filter((workspace: Workspace) => {
@@ -40,9 +41,9 @@ export const useWorkspacesToBeDeleted = (
4041
fromDate: Date,
4142
) => {
4243
const { data } = useWorkspacesData({
43-
page: 0,
44+
offset: calcOffset(0, 0),
4445
limit: 0,
45-
query: `template:${template.name} dormant:true`,
46+
q: `template:${template.name} dormant:true`,
4647
});
4748
return data?.workspaces?.filter((workspace: Workspace) => {
4849
if (!workspace.dormant_at || !formValues.time_til_dormant_autodelete_ms) {

site/src/pages/WorkspacePage/Workspace.stories.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react";
33
import type { ProvisionerJobLog } from "api/typesGenerated";
44
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext";
55
import * as Mocks from "testHelpers/entities";
6-
import { withDashboardProvider } from "testHelpers/storybook";
6+
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
77
import { Workspace } from "./Workspace";
88
import type { WorkspacePermissions } from "./permissions";
99

@@ -40,8 +40,10 @@ const meta: Meta<typeof Workspace> = {
4040
data: Mocks.MockListeningPortsResponse,
4141
},
4242
],
43+
user: Mocks.MockUser,
4344
},
4445
decorators: [
46+
withAuthProvider,
4547
withDashboardProvider,
4648
(Story) => (
4749
<ProxyContext.Provider

site/src/pages/WorkspacePage/Workspace.tsx

-3
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ export interface WorkspaceProps {
5353
buildLogs?: TypesGen.ProvisionerJobLog[];
5454
latestVersion?: TypesGen.TemplateVersion;
5555
permissions: WorkspacePermissions;
56-
isOwner: boolean;
5756
timings?: TypesGen.WorkspaceBuildTimings;
5857
}
5958

@@ -86,7 +85,6 @@ export const Workspace: FC<WorkspaceProps> = ({
8685
buildLogs,
8786
latestVersion,
8887
permissions,
89-
isOwner,
9088
timings,
9189
}) => {
9290
const navigate = useNavigate();
@@ -161,7 +159,6 @@ export const Workspace: FC<WorkspaceProps> = ({
161159
isUpdating={isUpdating}
162160
isRestarting={isRestarting}
163161
canUpdateWorkspace={permissions.updateWorkspace}
164-
isOwner={isOwner}
165162
template={template}
166163
permissions={permissions}
167164
latestVersion={latestVersion}

site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx

+11-4
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { expect, userEvent, within } from "@storybook/test";
33
import { agentLogsKey, buildLogsKey } from "api/queries/workspaces";
44
import * as Mocks from "testHelpers/entities";
55
import {
6+
withAuthProvider,
67
withDashboardProvider,
78
withDesktopViewport,
89
} from "testHelpers/storybook";
@@ -14,7 +15,10 @@ const meta: Meta<typeof WorkspaceActions> = {
1415
args: {
1516
isUpdating: false,
1617
},
17-
decorators: [withDashboardProvider, withDesktopViewport],
18+
decorators: [withDashboardProvider, withDesktopViewport, withAuthProvider],
19+
parameters: {
20+
user: Mocks.MockUser,
21+
},
1822
};
1923

2024
export default meta;
@@ -163,14 +167,15 @@ export const CancelShownForOwner: Story = {
163167
...Mocks.MockStartingWorkspace,
164168
template_allow_user_cancel_workspace_jobs: false,
165169
},
166-
isOwner: true,
167170
},
168171
};
169172

170173
export const CancelShownForUser: Story = {
171174
args: {
172175
workspace: Mocks.MockStartingWorkspace,
173-
isOwner: false,
176+
},
177+
parameters: {
178+
user: Mocks.MockUser2,
174179
},
175180
};
176181

@@ -180,7 +185,9 @@ export const CancelHiddenForUser: Story = {
180185
...Mocks.MockStartingWorkspace,
181186
template_allow_user_cancel_workspace_jobs: false,
182187
},
183-
isOwner: false,
188+
},
189+
parameters: {
190+
user: Mocks.MockUser2,
184191
},
185192
};
186193

0 commit comments

Comments
 (0)