fix(site): fix resource selection when workspace resources change (#11581)

This commit is contained in:
Bruno Quaresma 2024-01-12 10:14:31 -03:00 committed by GitHub
parent 0e96115d5d
commit aeb1ab8ad8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 198 additions and 18 deletions

View File

@ -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}
>

View File

@ -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}

View File

@ -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);
});
});

View File

@ -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,
};
};