Skip to content

Commit aeb1ab8

Browse files
fix(site): fix resource selection when workspace resources change (coder#11581)
1 parent 0e96115 commit aeb1ab8

File tree

4 files changed

+198
-18
lines changed

4 files changed

+198
-18
lines changed

site/src/pages/WorkspacePage/ResourcesSidebar.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ import { getResourceIconPath } from "utils/workspace";
1212
type ResourcesSidebarProps = {
1313
failed: boolean;
1414
resources: WorkspaceResource[];
15-
onChange: (resourceId: string) => void;
16-
selected: string;
15+
onChange: (resource: WorkspaceResource) => void;
16+
isSelected: (resource: WorkspaceResource) => boolean;
1717
};
1818

1919
export const ResourcesSidebar = (props: ResourcesSidebarProps) => {
2020
const theme = useTheme();
21-
const { failed, onChange, selected, resources } = props;
21+
const { failed, onChange, isSelected, resources } = props;
2222

2323
return (
2424
<Sidebar>
@@ -46,8 +46,8 @@ export const ResourcesSidebar = (props: ResourcesSidebarProps) => {
4646
))}
4747
{resources.map((r) => (
4848
<SidebarItem
49-
onClick={() => onChange(r.id)}
50-
isActive={r.id === selected}
49+
onClick={() => onChange(r)}
50+
isActive={isSelected(r)}
5151
key={r.id}
5252
css={styles.root}
5353
>

site/src/pages/WorkspacePage/Workspace.tsx

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { SidebarIconButton } from "components/FullPageLayout/Sidebar";
2626
import HubOutlined from "@mui/icons-material/HubOutlined";
2727
import { ResourcesSidebar } from "./ResourcesSidebar";
2828
import { ResourceCard } from "components/Resources/ResourceCard";
29+
import { useResourcesNav } from "./useResourcesNav";
2930
import { MemoizedInlineMarkdown } from "components/Markdown/Markdown";
3031

3132
export type WorkspaceError =
@@ -158,18 +159,10 @@ export const Workspace: FC<WorkspaceProps> = ({
158159
}
159160
};
160161

161-
const selectedResourceId = useTab("resources", "");
162162
const resources = [...workspace.latest_build.resources].sort(
163163
(a, b) => countAgents(b) - countAgents(a),
164164
);
165-
const selectedResource = workspace.latest_build.resources.find(
166-
(r) => r.id === selectedResourceId.value,
167-
);
168-
useEffect(() => {
169-
if (resources.length > 0 && selectedResourceId.value === "") {
170-
selectedResourceId.set(resources[0].id);
171-
}
172-
}, [resources, selectedResourceId]);
165+
const resourcesNav = useResourcesNav(resources);
173166

174167
return (
175168
<div
@@ -237,8 +230,8 @@ export const Workspace: FC<WorkspaceProps> = ({
237230
<ResourcesSidebar
238231
failed={workspace.latest_build.status === "failed"}
239232
resources={resources}
240-
selected={selectedResourceId.value}
241-
onChange={selectedResourceId.set}
233+
isSelected={resourcesNav.isSelected}
234+
onChange={resourcesNav.select}
242235
/>
243236
)}
244237
{sidebarOption.value === "history" && (
@@ -384,9 +377,9 @@ export const Workspace: FC<WorkspaceProps> = ({
384377

385378
{buildLogs}
386379

387-
{selectedResource && (
380+
{resourcesNav.selected && (
388381
<ResourceCard
389-
resource={selectedResource}
382+
resource={resourcesNav.selected}
390383
agentRow={(agent) => (
391384
<AgentRow
392385
key={agent.id}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { renderHook } from "@testing-library/react";
2+
import { resourceOptionId, useResourcesNav } from "./useResourcesNav";
3+
import { WorkspaceResource } from "api/typesGenerated";
4+
import { MockWorkspaceResource } from "testHelpers/entities";
5+
import { RouterProvider, createMemoryRouter } from "react-router-dom";
6+
7+
describe("useResourcesNav", () => {
8+
it("selects the first resource if it has agents and no resource is selected", () => {
9+
const resources: WorkspaceResource[] = [
10+
MockWorkspaceResource,
11+
{
12+
...MockWorkspaceResource,
13+
agents: [],
14+
},
15+
];
16+
const { result } = renderHook(() => useResourcesNav(resources), {
17+
wrapper: ({ children }) => (
18+
<RouterProvider
19+
router={createMemoryRouter([{ path: "/", element: children }])}
20+
/>
21+
),
22+
});
23+
expect(result.current.selected?.id).toBe(MockWorkspaceResource.id);
24+
});
25+
26+
it("selects the first resource if it has agents and selected resource is not find", async () => {
27+
const resources: WorkspaceResource[] = [
28+
MockWorkspaceResource,
29+
{
30+
...MockWorkspaceResource,
31+
agents: [],
32+
},
33+
];
34+
const { result } = renderHook(() => useResourcesNav(resources), {
35+
wrapper: ({ children }) => (
36+
<RouterProvider
37+
router={createMemoryRouter([{ path: "/", element: children }], {
38+
initialEntries: ["/?resources=not_found_resource_id"],
39+
})}
40+
/>
41+
),
42+
});
43+
expect(result.current.selected?.id).toBe(MockWorkspaceResource.id);
44+
});
45+
46+
it("selects the resource passed in the URL", () => {
47+
const resources: WorkspaceResource[] = [
48+
{
49+
...MockWorkspaceResource,
50+
type: "docker_container",
51+
name: "coder_python",
52+
},
53+
{
54+
...MockWorkspaceResource,
55+
type: "docker_container",
56+
name: "coder_java",
57+
},
58+
{
59+
...MockWorkspaceResource,
60+
type: "docker_image",
61+
name: "coder_image_python",
62+
agents: [],
63+
},
64+
];
65+
const { result } = renderHook(() => useResourcesNav(resources), {
66+
wrapper: ({ children }) => (
67+
<RouterProvider
68+
router={createMemoryRouter([{ path: "/", element: children }], {
69+
initialEntries: [`/?resources=${resourceOptionId(resources[1])}`],
70+
})}
71+
/>
72+
),
73+
});
74+
expect(result.current.selected?.id).toBe(resources[1].id);
75+
});
76+
77+
it("selects a resource when resources are updated", () => {
78+
const startedResources: WorkspaceResource[] = [
79+
{
80+
...MockWorkspaceResource,
81+
type: "docker_container",
82+
name: "coder_python",
83+
},
84+
{
85+
...MockWorkspaceResource,
86+
type: "docker_container",
87+
name: "coder_java",
88+
},
89+
{
90+
...MockWorkspaceResource,
91+
type: "docker_image",
92+
name: "coder_image_python",
93+
agents: [],
94+
},
95+
];
96+
const { result, rerender } = renderHook(
97+
({ resources }) => useResourcesNav(resources),
98+
{
99+
wrapper: ({ children }) => (
100+
<RouterProvider
101+
router={createMemoryRouter([{ path: "/", element: children }])}
102+
/>
103+
),
104+
initialProps: { resources: startedResources },
105+
},
106+
);
107+
expect(result.current.selected?.id).toBe(startedResources[0].id);
108+
109+
// When a workspace is stopped, there are no resources with agents, so we
110+
// need to retain the currently selected resource. This ensures consistency
111+
// when handling workspace updates that involve a sequence of stopping and
112+
// starting. By preserving the resource selection, we maintain the desired
113+
// configuration and prevent unintended changes during the stop-and-start
114+
// process.
115+
const stoppedResources: WorkspaceResource[] = [
116+
{
117+
...MockWorkspaceResource,
118+
type: "docker_image",
119+
name: "coder_image_python",
120+
agents: [],
121+
},
122+
];
123+
rerender({ resources: stoppedResources });
124+
expect(result.current.selectedValue).toBe(
125+
resourceOptionId(startedResources[0]),
126+
);
127+
128+
// When a workspace is started again a resource is selected
129+
rerender({ resources: startedResources });
130+
expect(result.current.selected?.id).toBe(startedResources[0].id);
131+
});
132+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { WorkspaceResource } from "api/typesGenerated";
2+
import { useTab } from "hooks";
3+
import { useEffectEvent } from "hooks/hookPolyfills";
4+
import { useCallback, useEffect } from "react";
5+
6+
export const resourceOptionId = (resource: WorkspaceResource) => {
7+
return `${resource.type}_${resource.name}`;
8+
};
9+
10+
// TODO: This currently serves as a temporary workaround for synchronizing the
11+
// resources tab during workspace transitions. The optimal resolution involves
12+
// eliminating the sync and updating the URL within the workspace data update
13+
// event in the WebSocket "onData" event. However, this requires substantial
14+
// refactoring. Consider revisiting this solution in the future for a more
15+
// robust implementation.
16+
export const useResourcesNav = (resources: WorkspaceResource[]) => {
17+
const resourcesNav = useTab("resources", "");
18+
19+
const isSelected = useCallback(
20+
(resource: WorkspaceResource) => {
21+
return resourceOptionId(resource) === resourcesNav.value;
22+
},
23+
[resourcesNav.value],
24+
);
25+
26+
const selectedResource = resources.find(isSelected);
27+
const onSelectedResourceChange = useEffectEvent(
28+
(previousResource?: WorkspaceResource) => {
29+
const hasResourcesWithAgents =
30+
resources.length > 0 &&
31+
resources[0].agents &&
32+
resources[0].agents.length > 0;
33+
if (!previousResource && hasResourcesWithAgents) {
34+
resourcesNav.set(resourceOptionId(resources[0]));
35+
}
36+
},
37+
);
38+
useEffect(() => {
39+
onSelectedResourceChange(selectedResource);
40+
}, [onSelectedResourceChange, selectedResource]);
41+
42+
const select = useCallback(
43+
(resource: WorkspaceResource) => {
44+
resourcesNav.set(resourceOptionId(resource));
45+
},
46+
[resourcesNav],
47+
);
48+
49+
return {
50+
isSelected,
51+
select,
52+
selected: selectedResource,
53+
selectedValue: resourcesNav.value,
54+
};
55+
};

0 commit comments

Comments
 (0)