Skip to content

Commit 1222a24

Browse files
committed
fix(site): fix resource selection when workspace resources change
1 parent d708ac7 commit 1222a24

File tree

4 files changed

+180
-18
lines changed

4 files changed

+180
-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

3031
export type WorkspaceError =
3132
| "getBuildsError"
@@ -155,18 +156,10 @@ export const Workspace: FC<WorkspaceProps> = ({
155156
}
156157
};
157158

158-
const selectedResourceId = useTab("resources", "");
159159
const resources = [...workspace.latest_build.resources].sort(
160160
(a, b) => countAgents(b) - countAgents(a),
161161
);
162-
const selectedResource = workspace.latest_build.resources.find(
163-
(r) => r.id === selectedResourceId.value,
164-
);
165-
useEffect(() => {
166-
if (resources.length > 0 && selectedResourceId.value === "") {
167-
selectedResourceId.set(resources[0].id);
168-
}
169-
}, [resources, selectedResourceId]);
162+
const resourcesNav = useResourcesNav(resources);
170163

171164
return (
172165
<div
@@ -233,8 +226,8 @@ export const Workspace: FC<WorkspaceProps> = ({
233226
<ResourcesSidebar
234227
failed={workspace.latest_build.status === "failed"}
235228
resources={resources}
236-
selected={selectedResourceId.value}
237-
onChange={selectedResourceId.set}
229+
isSelected={resourcesNav.isSelected}
230+
onChange={resourcesNav.select}
238231
/>
239232
)}
240233
{sidebarOption.value === "history" && (
@@ -374,9 +367,9 @@ export const Workspace: FC<WorkspaceProps> = ({
374367

375368
{buildLogs}
376369

377-
{selectedResource && (
370+
{resourcesNav.selected && (
378371
<ResourceCard
379-
resource={selectedResource}
372+
resource={resourcesNav.selected}
380373
agentRow={(agent) => (
381374
<AgentRow
382375
key={agent.id}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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 is no resource with agents
110+
const stoppedResources: WorkspaceResource[] = [
111+
{
112+
...MockWorkspaceResource,
113+
type: "docker_image",
114+
name: "coder_image_python",
115+
agents: [],
116+
},
117+
];
118+
rerender({ resources: stoppedResources });
119+
expect(result.current.selected).toBe(undefined);
120+
121+
// When a workspace is started again a resource is selected
122+
rerender({ resources: startedResources });
123+
expect(result.current.selected?.id).toBe(startedResources[0].id);
124+
});
125+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { WorkspaceResource } from "api/typesGenerated";
2+
import { useTab } from "hooks";
3+
import { useCallback, useEffect } from "react";
4+
5+
export const resourceOptionId = (resource: WorkspaceResource) => {
6+
return `${resource.type}_${resource.name}`;
7+
};
8+
9+
export const useResourcesNav = (resources: WorkspaceResource[]) => {
10+
const resourcesNav = useTab("resources", "");
11+
const selectedResource = resources.find(
12+
(r) => resourceOptionId(r) === resourcesNav.value,
13+
);
14+
15+
useEffect(() => {
16+
const hasResourcesWithAgents =
17+
resources.length > 0 &&
18+
resources[0].agents &&
19+
resources[0].agents.length > 0;
20+
if (!selectedResource && hasResourcesWithAgents) {
21+
resourcesNav.set(resourceOptionId(resources[0]));
22+
}
23+
}, [resources, selectedResource, resourcesNav]);
24+
25+
const select = useCallback(
26+
(resource: WorkspaceResource) => {
27+
resourcesNav.set(resourceOptionId(resource));
28+
},
29+
[resourcesNav],
30+
);
31+
32+
const isSelected = useCallback(
33+
(resource: WorkspaceResource) => {
34+
return resourceOptionId(resource) === resourcesNav.value;
35+
},
36+
[resourcesNav.value],
37+
);
38+
39+
return {
40+
isSelected,
41+
select,
42+
selected: selectedResource,
43+
};
44+
};

0 commit comments

Comments
 (0)