mirror of https://github.com/coder/coder.git
fix(site): fix resource selection when workspace resources change (#11581)
This commit is contained in:
parent
0e96115d5d
commit
aeb1ab8ad8
|
@ -12,13 +12,13 @@ import { getResourceIconPath } from "utils/workspace";
|
|||
type ResourcesSidebarProps = {
|
||||
failed: boolean;
|
||||
resources: WorkspaceResource[];
|
||||
onChange: (resourceId: string) => void;
|
||||
selected: string;
|
||||
onChange: (resource: WorkspaceResource) => void;
|
||||
isSelected: (resource: WorkspaceResource) => boolean;
|
||||
};
|
||||
|
||||
export const ResourcesSidebar = (props: ResourcesSidebarProps) => {
|
||||
const theme = useTheme();
|
||||
const { failed, onChange, selected, resources } = props;
|
||||
const { failed, onChange, isSelected, resources } = props;
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
|
@ -46,8 +46,8 @@ export const ResourcesSidebar = (props: ResourcesSidebarProps) => {
|
|||
))}
|
||||
{resources.map((r) => (
|
||||
<SidebarItem
|
||||
onClick={() => onChange(r.id)}
|
||||
isActive={r.id === selected}
|
||||
onClick={() => onChange(r)}
|
||||
isActive={isSelected(r)}
|
||||
key={r.id}
|
||||
css={styles.root}
|
||||
>
|
||||
|
|
|
@ -26,6 +26,7 @@ import { SidebarIconButton } from "components/FullPageLayout/Sidebar";
|
|||
import HubOutlined from "@mui/icons-material/HubOutlined";
|
||||
import { ResourcesSidebar } from "./ResourcesSidebar";
|
||||
import { ResourceCard } from "components/Resources/ResourceCard";
|
||||
import { useResourcesNav } from "./useResourcesNav";
|
||||
import { MemoizedInlineMarkdown } from "components/Markdown/Markdown";
|
||||
|
||||
export type WorkspaceError =
|
||||
|
@ -158,18 +159,10 @@ export const Workspace: FC<WorkspaceProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const selectedResourceId = useTab("resources", "");
|
||||
const resources = [...workspace.latest_build.resources].sort(
|
||||
(a, b) => countAgents(b) - countAgents(a),
|
||||
);
|
||||
const selectedResource = workspace.latest_build.resources.find(
|
||||
(r) => r.id === selectedResourceId.value,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (resources.length > 0 && selectedResourceId.value === "") {
|
||||
selectedResourceId.set(resources[0].id);
|
||||
}
|
||||
}, [resources, selectedResourceId]);
|
||||
const resourcesNav = useResourcesNav(resources);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -237,8 +230,8 @@ export const Workspace: FC<WorkspaceProps> = ({
|
|||
<ResourcesSidebar
|
||||
failed={workspace.latest_build.status === "failed"}
|
||||
resources={resources}
|
||||
selected={selectedResourceId.value}
|
||||
onChange={selectedResourceId.set}
|
||||
isSelected={resourcesNav.isSelected}
|
||||
onChange={resourcesNav.select}
|
||||
/>
|
||||
)}
|
||||
{sidebarOption.value === "history" && (
|
||||
|
@ -384,9 +377,9 @@ export const Workspace: FC<WorkspaceProps> = ({
|
|||
|
||||
{buildLogs}
|
||||
|
||||
{selectedResource && (
|
||||
{resourcesNav.selected && (
|
||||
<ResourceCard
|
||||
resource={selectedResource}
|
||||
resource={resourcesNav.selected}
|
||||
agentRow={(agent) => (
|
||||
<AgentRow
|
||||
key={agent.id}
|
||||
|
|
|
@ -0,0 +1,132 @@
|
|||
import { renderHook } from "@testing-library/react";
|
||||
import { resourceOptionId, useResourcesNav } from "./useResourcesNav";
|
||||
import { WorkspaceResource } from "api/typesGenerated";
|
||||
import { MockWorkspaceResource } from "testHelpers/entities";
|
||||
import { RouterProvider, createMemoryRouter } from "react-router-dom";
|
||||
|
||||
describe("useResourcesNav", () => {
|
||||
it("selects the first resource if it has agents and no resource is selected", () => {
|
||||
const resources: WorkspaceResource[] = [
|
||||
MockWorkspaceResource,
|
||||
{
|
||||
...MockWorkspaceResource,
|
||||
agents: [],
|
||||
},
|
||||
];
|
||||
const { result } = renderHook(() => useResourcesNav(resources), {
|
||||
wrapper: ({ children }) => (
|
||||
<RouterProvider
|
||||
router={createMemoryRouter([{ path: "/", element: children }])}
|
||||
/>
|
||||
),
|
||||
});
|
||||
expect(result.current.selected?.id).toBe(MockWorkspaceResource.id);
|
||||
});
|
||||
|
||||
it("selects the first resource if it has agents and selected resource is not find", async () => {
|
||||
const resources: WorkspaceResource[] = [
|
||||
MockWorkspaceResource,
|
||||
{
|
||||
...MockWorkspaceResource,
|
||||
agents: [],
|
||||
},
|
||||
];
|
||||
const { result } = renderHook(() => useResourcesNav(resources), {
|
||||
wrapper: ({ children }) => (
|
||||
<RouterProvider
|
||||
router={createMemoryRouter([{ path: "/", element: children }], {
|
||||
initialEntries: ["/?resources=not_found_resource_id"],
|
||||
})}
|
||||
/>
|
||||
),
|
||||
});
|
||||
expect(result.current.selected?.id).toBe(MockWorkspaceResource.id);
|
||||
});
|
||||
|
||||
it("selects the resource passed in the URL", () => {
|
||||
const resources: WorkspaceResource[] = [
|
||||
{
|
||||
...MockWorkspaceResource,
|
||||
type: "docker_container",
|
||||
name: "coder_python",
|
||||
},
|
||||
{
|
||||
...MockWorkspaceResource,
|
||||
type: "docker_container",
|
||||
name: "coder_java",
|
||||
},
|
||||
{
|
||||
...MockWorkspaceResource,
|
||||
type: "docker_image",
|
||||
name: "coder_image_python",
|
||||
agents: [],
|
||||
},
|
||||
];
|
||||
const { result } = renderHook(() => useResourcesNav(resources), {
|
||||
wrapper: ({ children }) => (
|
||||
<RouterProvider
|
||||
router={createMemoryRouter([{ path: "/", element: children }], {
|
||||
initialEntries: [`/?resources=${resourceOptionId(resources[1])}`],
|
||||
})}
|
||||
/>
|
||||
),
|
||||
});
|
||||
expect(result.current.selected?.id).toBe(resources[1].id);
|
||||
});
|
||||
|
||||
it("selects a resource when resources are updated", () => {
|
||||
const startedResources: WorkspaceResource[] = [
|
||||
{
|
||||
...MockWorkspaceResource,
|
||||
type: "docker_container",
|
||||
name: "coder_python",
|
||||
},
|
||||
{
|
||||
...MockWorkspaceResource,
|
||||
type: "docker_container",
|
||||
name: "coder_java",
|
||||
},
|
||||
{
|
||||
...MockWorkspaceResource,
|
||||
type: "docker_image",
|
||||
name: "coder_image_python",
|
||||
agents: [],
|
||||
},
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
({ resources }) => useResourcesNav(resources),
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<RouterProvider
|
||||
router={createMemoryRouter([{ path: "/", element: children }])}
|
||||
/>
|
||||
),
|
||||
initialProps: { resources: startedResources },
|
||||
},
|
||||
);
|
||||
expect(result.current.selected?.id).toBe(startedResources[0].id);
|
||||
|
||||
// When a workspace is stopped, there are no resources with agents, so we
|
||||
// need to retain the currently selected resource. This ensures consistency
|
||||
// when handling workspace updates that involve a sequence of stopping and
|
||||
// starting. By preserving the resource selection, we maintain the desired
|
||||
// configuration and prevent unintended changes during the stop-and-start
|
||||
// process.
|
||||
const stoppedResources: WorkspaceResource[] = [
|
||||
{
|
||||
...MockWorkspaceResource,
|
||||
type: "docker_image",
|
||||
name: "coder_image_python",
|
||||
agents: [],
|
||||
},
|
||||
];
|
||||
rerender({ resources: stoppedResources });
|
||||
expect(result.current.selectedValue).toBe(
|
||||
resourceOptionId(startedResources[0]),
|
||||
);
|
||||
|
||||
// When a workspace is started again a resource is selected
|
||||
rerender({ resources: startedResources });
|
||||
expect(result.current.selected?.id).toBe(startedResources[0].id);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,55 @@
|
|||
import { WorkspaceResource } from "api/typesGenerated";
|
||||
import { useTab } from "hooks";
|
||||
import { useEffectEvent } from "hooks/hookPolyfills";
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
export const resourceOptionId = (resource: WorkspaceResource) => {
|
||||
return `${resource.type}_${resource.name}`;
|
||||
};
|
||||
|
||||
// TODO: This currently serves as a temporary workaround for synchronizing the
|
||||
// resources tab during workspace transitions. The optimal resolution involves
|
||||
// eliminating the sync and updating the URL within the workspace data update
|
||||
// event in the WebSocket "onData" event. However, this requires substantial
|
||||
// refactoring. Consider revisiting this solution in the future for a more
|
||||
// robust implementation.
|
||||
export const useResourcesNav = (resources: WorkspaceResource[]) => {
|
||||
const resourcesNav = useTab("resources", "");
|
||||
|
||||
const isSelected = useCallback(
|
||||
(resource: WorkspaceResource) => {
|
||||
return resourceOptionId(resource) === resourcesNav.value;
|
||||
},
|
||||
[resourcesNav.value],
|
||||
);
|
||||
|
||||
const selectedResource = resources.find(isSelected);
|
||||
const onSelectedResourceChange = useEffectEvent(
|
||||
(previousResource?: WorkspaceResource) => {
|
||||
const hasResourcesWithAgents =
|
||||
resources.length > 0 &&
|
||||
resources[0].agents &&
|
||||
resources[0].agents.length > 0;
|
||||
if (!previousResource && hasResourcesWithAgents) {
|
||||
resourcesNav.set(resourceOptionId(resources[0]));
|
||||
}
|
||||
},
|
||||
);
|
||||
useEffect(() => {
|
||||
onSelectedResourceChange(selectedResource);
|
||||
}, [onSelectedResourceChange, selectedResource]);
|
||||
|
||||
const select = useCallback(
|
||||
(resource: WorkspaceResource) => {
|
||||
resourcesNav.set(resourceOptionId(resource));
|
||||
},
|
||||
[resourcesNav],
|
||||
);
|
||||
|
||||
return {
|
||||
isSelected,
|
||||
select,
|
||||
selected: selectedResource,
|
||||
selectedValue: resourcesNav.value,
|
||||
};
|
||||
};
|
Loading…
Reference in New Issue