diff --git a/site/src/pages/WorkspacePage/ResourcesSidebar.tsx b/site/src/pages/WorkspacePage/ResourcesSidebar.tsx index f6354a6bb9ed9..4cf6d9c2ac971 100644 --- a/site/src/pages/WorkspacePage/ResourcesSidebar.tsx +++ b/site/src/pages/WorkspacePage/ResourcesSidebar.tsx @@ -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 ( @@ -46,8 +46,8 @@ export const ResourcesSidebar = (props: ResourcesSidebarProps) => { ))} {resources.map((r) => ( onChange(r.id)} - isActive={r.id === selected} + onClick={() => onChange(r)} + isActive={isSelected(r)} key={r.id} css={styles.root} > diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index 0154defc3c527..472b777a9952c 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -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 = ({ } }; - 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 (
= ({ )} {sidebarOption.value === "history" && ( @@ -384,9 +377,9 @@ export const Workspace: FC = ({ {buildLogs} - {selectedResource && ( + {resourcesNav.selected && ( ( { + 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 }) => ( + + ), + }); + 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 }) => ( + + ), + }); + 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 }) => ( + + ), + }); + 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 }) => ( + + ), + 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); + }); +}); diff --git a/site/src/pages/WorkspacePage/useResourcesNav.ts b/site/src/pages/WorkspacePage/useResourcesNav.ts new file mode 100644 index 0000000000000..7774d1073240c --- /dev/null +++ b/site/src/pages/WorkspacePage/useResourcesNav.ts @@ -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, + }; +};