diff --git a/src/components/Project/AddToProjectDialog.test.tsx b/src/components/Project/AddToProjectDialog.test.tsx index ebe1dd2c95..ea8ccee3a7 100644 --- a/src/components/Project/AddToProjectDialog.test.tsx +++ b/src/components/Project/AddToProjectDialog.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { pointerTo } from "@/services/localPipelines/localPipelinesService"; +import type { ProjectPipelineRef } from "@/services/projects/pipelineResource"; import { useAddResourceToProject } from "@/services/projects/useProjectResources"; import { useReachableProjects } from "@/services/projects/useReachableProjects"; @@ -40,10 +41,13 @@ function mockProjects(items = [{ id: "project-1", name: "Churn work" }]) { } as unknown as ReturnType); } -const dialog = (memberProjectIds: string[] = []) => +const dialog = ( + memberProjectIds: string[] = [], + pipeline: ProjectPipelineRef = { source: "local", name: "Churn model" }, +) => render( { }); }); + it("files a backend pipeline by its id rather than as a browser pointer", async () => { + dialog([], { + source: "remote", + pipelineId: "pipeline-7", + name: "Churn model", + }); + + await userEvent.click(screen.getByRole("button", { name: /Churn work/ })); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(mutate.mock.calls[0][0]).toEqual({ + projectId: "project-1", + input: { + entity: "pipeline", + entityId: "pipeline-7", + name: "Churn model", + }, + }); + expect(pointerTo).not.toHaveBeenCalled(); + }); + /** Attribution follows membership, so the project just joined is where runs go. */ it("sends the runs of this tab to the project it joined", async () => { dialog(); diff --git a/src/components/Project/AddToProjectDialog.tsx b/src/components/Project/AddToProjectDialog.tsx index 8fba31499a..271d378f6d 100644 --- a/src/components/Project/AddToProjectDialog.tsx +++ b/src/components/Project/AddToProjectDialog.tsx @@ -3,24 +3,37 @@ import { useEffect } from "react"; import useToastNotification from "@/hooks/useToastNotification"; import { useAnalytics } from "@/providers/AnalyticsProvider"; import { pointerTo } from "@/services/localPipelines/localPipelinesService"; +import { + type ProjectPipelineRef, + remotePipelineResourceInput, +} from "@/services/projects/pipelineResource"; import { DescriptorTooLargeError, localPipelineResourceInput, } from "@/services/projects/resourceDescriptor"; +import type { CreateResourceInput } from "@/services/projects/types"; import { useAddResourceToProject } from "@/services/projects/useProjectResources"; import { useReachableProjects } from "@/services/projects/useReachableProjects"; import { PickFromListDialog } from "./PickFromListDialog"; interface AddToProjectDialogProps { - pipelineName: string; + pipeline: ProjectPipelineRef; memberProjectIds: readonly string[]; onOpenChange: (open: boolean) => void; onAdded: (projectId: string) => void; } +async function resourceInputFor( + pipeline: ProjectPipelineRef, +): Promise { + return pipeline.source === "remote" + ? remotePipelineResourceInput(pipeline.pipelineId, pipeline.name) + : localPipelineResourceInput(await pointerTo(pipeline.name)); +} + export function AddToProjectDialog({ - pipelineName, + pipeline, memberProjectIds, onOpenChange, onAdded, @@ -44,7 +57,7 @@ export function AddToProjectDialog({ const add = async (projectId: string) => { let input; try { - input = localPipelineResourceInput(await pointerTo(pipelineName)); + input = await resourceInputFor(pipeline); } catch (problem) { notify( problem instanceof DescriptorTooLargeError @@ -71,7 +84,7 @@ export function AddToProjectDialog({ return ( { it("shows nothing at all while projects are switched off", () => { given({ enabled: false }); - const { container } = render(); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); /** The old chip only appeared once a project was set, so there was nothing to click. */ it("offers itself on a pipeline that belongs to no project", () => { - render(); + render(); expect( screen.getByRole("button", { name: "Project for runs: No project" }), @@ -110,7 +116,7 @@ describe("ProjectPicker", () => { it("names the project the runs of this tab go to", () => { given({ projectId: "project-1", projectName: "Churn work" }); - render(); + render(); expect( screen.getByRole("button", { name: "Project for runs: Churn work" }), @@ -127,7 +133,7 @@ describe("ProjectPicker", () => { ], }); - render(); + render(); await open(); expect( @@ -148,7 +154,7 @@ describe("ProjectPicker", () => { ], }); - render(); + render(); await open(); await userEvent.click( screen.getByRole("menuitemradio", { name: "Q3 models" }), @@ -160,7 +166,7 @@ describe("ProjectPicker", () => { it("stops sending them anywhere", async () => { given({ projectId: "project-1", projectName: "Churn work" }); - render(); + render(); await open(); await userEvent.click( screen.getByRole("menuitemradio", { name: "No project" }), @@ -176,7 +182,7 @@ describe("ProjectPicker", () => { it("lists a chosen project the pipeline is not in", async () => { given({ projectId: "project-9", projectName: "Somebody else's" }); - render(); + render(); await open(); expect( @@ -187,7 +193,7 @@ describe("ProjectPicker", () => { it("offers no removal from a project the pipeline is not in", async () => { given({ projectId: "project-9", projectName: "Somebody else's" }); - render(); + render(); await open(); expect(screen.queryByText(/^Remove from/)).not.toBeInTheDocument(); @@ -200,7 +206,7 @@ describe("ProjectPicker", () => { memberships: [membership("project-1", "Churn work")], }); - render(); + render(); await open(); await userEvent.click(screen.getByText("Remove from Churn work")); await userEvent.click( @@ -223,7 +229,7 @@ describe("ProjectPicker", () => { memberships: [membership("project-1", "Churn work")], }); - render(); + render(); await open(); await userEvent.click(screen.getByText("Remove from Churn work")); @@ -234,10 +240,31 @@ describe("ProjectPicker", () => { }); it("waits to be asked before reading every project's contents", () => { - render(); + render(); - expect(usePipelineProjects).toHaveBeenCalledWith("Churn model", { + expect(usePipelineProjects).toHaveBeenCalledWith(CHURN_MODEL, { enabled: false, }); }); + + it("keeps looking up the pipeline's projects while it is being added to one", async () => { + render(); + await open(); + await userEvent.click(screen.getByText("Add to a project...")); + + expect(screen.getByText("add to project dialog")).toBeInTheDocument(); + expect( + screen.queryByRole("menuitem", { name: /Add to a project/ }), + ).not.toBeInTheDocument(); + expect(usePipelineProjects).toHaveBeenLastCalledWith(CHURN_MODEL, { + enabled: true, + }); + }); + + it("offers no adding while there is no pipeline to add", async () => { + render(); + await open(); + + expect(screen.queryByText("Add to a project...")).not.toBeInTheDocument(); + }); }); diff --git a/src/components/Project/ProjectPicker.tsx b/src/components/Project/ProjectPicker.tsx index e7cbccdda5..2dc013bf08 100644 --- a/src/components/Project/ProjectPicker.tsx +++ b/src/components/Project/ProjectPicker.tsx @@ -16,6 +16,7 @@ import { Icon } from "@/components/ui/icon"; import { InlineStack } from "@/components/ui/layout"; import { Spinner } from "@/components/ui/spinner"; import { Text } from "@/components/ui/typography"; +import type { ProjectPipelineRef } from "@/services/projects/pipelineResource"; import { usePipelineProjects } from "@/services/projects/usePipelineProjects"; import { tracking } from "@/utils/tracking"; @@ -25,16 +26,16 @@ import { useConfirmedResourceRemoval } from "./useConfirmedResourceRemoval"; import { useRunProjectContext } from "./useRunProjectContext"; interface ProjectPickerProps { - pipelineName: string | undefined; + pipeline: ProjectPipelineRef | undefined; } -export function ProjectPicker({ pipelineName }: ProjectPickerProps) { +export function ProjectPicker({ pipeline }: ProjectPickerProps) { const { enabled, projectId, projectName, setProjectId } = useRunProjectContext(); const [open, setOpen] = useState(false); const [addOpen, setAddOpen] = useState(false); - const { memberships, isPending, error } = usePipelineProjects(pipelineName, { - enabled: open, + const { memberships, isPending, error } = usePipelineProjects(pipeline, { + enabled: open || addOpen, }); const { confirmAndRemove, confirmation: confirmationProps } = useConfirmedResourceRemoval({ @@ -118,7 +119,7 @@ export function ProjectPicker({ pipelineName }: ProjectPickerProps) { )} - {pipelineName && ( + {pipeline && ( <> - {addOpen && pipelineName && ( + {addOpen && pipeline && ( membership.project.id, )} diff --git a/src/components/Project/RunProjectField.tsx b/src/components/Project/RunProjectField.tsx index ee79a0d450..429accfb56 100644 --- a/src/components/Project/RunProjectField.tsx +++ b/src/components/Project/RunProjectField.tsx @@ -7,6 +7,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Paragraph } from "@/components/ui/typography"; +import { localProjectPipeline } from "@/services/projects/pipelineResource"; import { usePipelineProjects } from "@/services/projects/usePipelineProjects"; import { NO_PROJECT, projectOptions } from "./projectOptions"; @@ -30,7 +31,7 @@ export function RunProjectField({ }: RunProjectFieldProps) { const { enabled, projectName } = useRunProjectContext(); const { memberships } = usePipelineProjects( - enabled ? pipelineName : undefined, + enabled ? localProjectPipeline(pipelineName) : undefined, ); const options = projectOptions(memberships, value, projectName); diff --git a/src/components/shared/ReactFlow/FlowSidebar/sections/RunsAndSubmission.tsx b/src/components/shared/ReactFlow/FlowSidebar/sections/RunsAndSubmission.tsx index 808d35a770..7518ae35b7 100644 --- a/src/components/shared/ReactFlow/FlowSidebar/sections/RunsAndSubmission.tsx +++ b/src/components/shared/ReactFlow/FlowSidebar/sections/RunsAndSubmission.tsx @@ -5,6 +5,7 @@ import GoogleCloudSubmissionDialog from "@/components/shared/Submitters/GoogleCl import TangleSubmitter from "@/components/shared/Submitters/Tangle/TangleSubmitter"; import { BlockStack } from "@/components/ui/layout"; import { useComponentSpec } from "@/providers/ComponentSpecProvider"; +import { localProjectPipeline } from "@/services/projects/pipelineResource"; import { isFixableIssue } from "@/utils/validations"; import { RecentExecutionsButton } from "../components/RecentExecutionsButton"; @@ -30,7 +31,7 @@ const RunsAndSubmission = () => {
  • - +
  • {isAuthorized ? ( diff --git a/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx b/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx index c285f8e125..bdd5e9b97d 100644 --- a/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx +++ b/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx @@ -20,6 +20,7 @@ import { CanvasMenuBar, } from "@/routes/v2/shared/components/CanvasMenuBar"; import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; +import { projectPipelineOf } from "@/services/projects/pipelineResource"; import { tracking } from "@/utils/tracking"; import { AutoSaveIndicator } from "./components/AutoSaveIndicator"; @@ -158,9 +159,11 @@ export const EditorMenuBar = observer(function EditorMenuBar() { > {displayMenu && ( <> - {!isRemote && ( - - )} +
    diff --git a/src/services/projects/pipelineProjects.test.ts b/src/services/projects/pipelineProjects.test.ts index 706d7213d7..b23d684cd9 100644 --- a/src/services/projects/pipelineProjects.test.ts +++ b/src/services/projects/pipelineProjects.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { holdsPipeline, pipelineResourceIn } from "./pipelineProjects"; +import { + holdsPipeline, + pipelineResourceIn, + remotePipelineResourceIn, +} from "./pipelineProjects"; import { localPipelineResourceInput } from "./resourceDescriptor"; import type { ProjectResourceSummary } from "./types"; @@ -105,3 +109,41 @@ describe("pipelineResourceIn", () => { ).toBeUndefined(); }); }); + +describe("remotePipelineResourceIn", () => { + const PIPELINE_ID = "0f8fad5b-d9cb-469f-a165-70867728950e"; + const remoteRow = (entityId: string) => + row({ id: "resource-7", entity: "pipeline", entityId }); + + it("hands back the row that names the backend pipeline", () => { + const wanted = remoteRow(PIPELINE_ID); + + expect( + remotePipelineResourceIn( + [row({ id: "resource-1" }), wanted], + PIPELINE_ID, + ), + ).toBe(wanted); + }); + + it("recognises the id however the backend spelled it", () => { + const wanted = remoteRow(PIPELINE_ID.toUpperCase().replaceAll("-", "")); + + expect(remotePipelineResourceIn([wanted], PIPELINE_ID)).toBe(wanted); + }); + + it("does not take a browser pointer for it, even under the same name", () => { + expect( + remotePipelineResourceIn([pointerRow("Churn model")], PIPELINE_ID), + ).toBeUndefined(); + }); + + it("does not claim a different backend pipeline", () => { + expect( + remotePipelineResourceIn( + [remoteRow("1b4e28ba-2fa1-11d2-883f-0016d3cca427")], + PIPELINE_ID, + ), + ).toBeUndefined(); + }); +}); diff --git a/src/services/projects/pipelineProjects.ts b/src/services/projects/pipelineProjects.ts index 55f3485405..bb9e279f6c 100644 --- a/src/services/projects/pipelineProjects.ts +++ b/src/services/projects/pipelineProjects.ts @@ -1,4 +1,5 @@ import type { LocalPipelinePointer } from "@/services/localPipelines/types"; +import { normalizePipelineId } from "@/services/pipelineId"; import { localPipelinePointerOf } from "./resourceDescriptor"; import type { @@ -38,6 +39,19 @@ export function pipelineResourceIn( return resources.find((resource) => pointsAt(resource, pipeline)); } +export function remotePipelineResourceIn( + resources: readonly ProjectResourceSummary[], + pipelineId: string, +) { + const wanted = normalizePipelineId(pipelineId); + return resources.find( + (resource) => + resource.entity === "pipeline" && + resource.entityId !== null && + normalizePipelineId(resource.entityId) === wanted, + ); +} + export function holdsPipeline( resources: readonly ProjectResourceSummary[], pipeline: LocalPipelinePointer, diff --git a/src/services/projects/pipelineResource.test.ts b/src/services/projects/pipelineResource.test.ts new file mode 100644 index 0000000000..3d06269a64 --- /dev/null +++ b/src/services/projects/pipelineResource.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { projectPipelineOf } from "./pipelineResource"; + +const BACKEND_URL = "https://backend.example.com"; +const PIPELINE_ID = "0f8fad5b-d9cb-469f-a165-70867728950e"; + +describe("projectPipelineOf", () => { + it("names a browser pipeline by its storage key", () => { + expect( + projectPipelineOf({ + storageKey: "Churn model", + remote: undefined, + isDraft: false, + }), + ).toEqual({ source: "local", name: "Churn model" }); + }); + + it("names a backend pipeline by its id", () => { + expect( + projectPipelineOf({ + storageKey: "Churn model", + remote: { backendUrl: BACKEND_URL, pipelineId: PIPELINE_ID }, + isDraft: false, + }), + ).toEqual({ + source: "remote", + pipelineId: PIPELINE_ID, + name: "Churn model", + }); + }); + + it("has nothing to name for a draft that has not been saved yet", () => { + expect( + projectPipelineOf({ + storageKey: "Untitled pipeline", + remote: undefined, + isDraft: true, + }), + ).toBeUndefined(); + }); +}); diff --git a/src/services/projects/pipelineResource.ts b/src/services/projects/pipelineResource.ts index 6d0af0b759..d37f900cca 100644 --- a/src/services/projects/pipelineResource.ts +++ b/src/services/projects/pipelineResource.ts @@ -9,6 +9,38 @@ import { } from "./resourceTarget"; import type { CreateResourceInput, ProjectResourceSummary } from "./types"; +interface LocalProjectPipeline { + source: "local"; + name: string; +} + +interface RemoteProjectPipeline { + source: "remote"; + pipelineId: string; + name: string; +} + +export type ProjectPipelineRef = LocalProjectPipeline | RemoteProjectPipeline; + +export function localProjectPipeline( + name: string | undefined, +): ProjectPipelineRef | undefined { + return name ? { source: "local", name } : undefined; +} + +export function projectPipelineOf( + file: Pick, +): ProjectPipelineRef | undefined { + if (file.remote) { + return { + source: "remote", + pipelineId: file.remote.pipelineId, + name: file.storageKey, + }; + } + return file.isDraft ? undefined : localProjectPipeline(file.storageKey); +} + export function pipelineFileTarget(file: PipelineFile): WorkareaTarget { return { type: "pipeline", diff --git a/src/services/projects/usePipelineProjects.test.tsx b/src/services/projects/usePipelineProjects.test.tsx new file mode 100644 index 0000000000..015d37c15b --- /dev/null +++ b/src/services/projects/usePipelineProjects.test.tsx @@ -0,0 +1,131 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { pointerTo } from "@/services/localPipelines/localPipelinesService"; + +import type { ProjectPipelineRef } from "./pipelineResource"; +import { listProjectResources } from "./projectResourcesService"; +import { localPipelineResourceInput } from "./resourceDescriptor"; +import type { ProjectResourceSummary, ProjectSummary } from "./types"; +import { usePipelineProjects } from "./usePipelineProjects"; +import { useReachableProjects } from "./useReachableProjects"; + +const PIPELINE_ID = "0f8fad5b-d9cb-469f-a165-70867728950e"; + +vi.mock("@/providers/BackendProvider", () => ({ + useBackend: () => ({ configured: true, available: true }), +})); + +vi.mock("@/services/localPipelines/localPipelinesService", () => ({ + pointerTo: vi.fn(), +})); + +vi.mock("./projectResourcesService", () => ({ + listProjectResources: vi.fn(), +})); + +vi.mock("./useReachableProjects", () => ({ + useReachableProjects: vi.fn(), +})); + +let queryClient: QueryClient; + +function row( + overrides: Partial = {}, +): ProjectResourceSummary { + return { + id: "resource-1", + projectId: "project-1", + entity: "pipeline", + name: "Churn model", + entityId: null, + metadata: null, + createdBy: null, + createdAt: new Date("2026-10-30T10:00:00Z"), + updatedAt: new Date("2026-10-30T10:00:00Z"), + ...overrides, + }; +} + +function givenProjects( + resourcesByProject: Record, +) { + vi.mocked(useReachableProjects).mockReturnValue({ + projects: Object.keys(resourcesByProject).map( + (id) => ({ id, name: `Project ${id}` }) as ProjectSummary, + ), + isPending: false, + error: null, + } as unknown as ReturnType); + vi.mocked(listProjectResources).mockImplementation(async (projectId) => ({ + items: resourcesByProject[projectId] ?? [], + nextPageToken: null, + totalCount: resourcesByProject[projectId]?.length ?? 0, + })); +} + +const lookUp = (pipeline: ProjectPipelineRef) => + renderHook(() => usePipelineProjects(pipeline), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + +describe("usePipelineProjects", () => { + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + }); + + afterEach(() => { + cleanup(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it("finds the projects that hold a backend pipeline by its id", async () => { + givenProjects({ + "project-1": [row({ id: "resource-7", entityId: PIPELINE_ID })], + "project-2": [row({ entityId: "1b4e28ba-2fa1-11d2-883f-0016d3cca427" })], + }); + + const { result } = lookUp({ + source: "remote", + pipelineId: PIPELINE_ID, + name: "Churn model", + }); + + await waitFor(() => expect(result.current.isPending).toBe(false)); + expect( + result.current.memberships.map(({ project, resource }) => [ + project.id, + resource.id, + ]), + ).toEqual([["project-1", "resource-7"]]); + expect(pointerTo).not.toHaveBeenCalled(); + }); + + it("finds the projects that point at a browser pipeline", async () => { + vi.mocked(pointerTo).mockResolvedValue({ localName: "Churn model" }); + givenProjects({ + "project-1": [ + row({ + id: "resource-3", + ...localPipelineResourceInput({ localName: "Churn model" }), + entityId: null, + }), + ], + "project-2": [], + }); + + const { result } = lookUp({ source: "local", name: "Churn model" }); + + await waitFor(() => expect(result.current.isPending).toBe(false)); + expect(result.current.memberships.map(({ project }) => project.id)).toEqual( + ["project-1"], + ); + }); +}); diff --git a/src/services/projects/usePipelineProjects.ts b/src/services/projects/usePipelineProjects.ts index 555231d721..47468473ab 100644 --- a/src/services/projects/usePipelineProjects.ts +++ b/src/services/projects/usePipelineProjects.ts @@ -2,13 +2,18 @@ import { useQueries, useQuery } from "@tanstack/react-query"; import { useBackend } from "@/providers/BackendProvider"; import { pointerTo } from "@/services/localPipelines/localPipelinesService"; -import { LocalPipelinesQueryKeys } from "@/services/localPipelines/types"; +import { + type LocalPipelinePointer, + LocalPipelinesQueryKeys, +} from "@/services/localPipelines/types"; import { MINUTES } from "@/utils/constants"; import { PIPELINE_RESOURCE_PARAMS, pipelineResourceIn, + remotePipelineResourceIn, } from "./pipelineProjects"; +import type { ProjectPipelineRef } from "./pipelineResource"; import { listProjectResources } from "./projectResourcesService"; import type { ProjectResourceSummary, ProjectSummary } from "./types"; import { ProjectResourcesQueryKeys } from "./types"; @@ -25,6 +30,21 @@ export interface PipelineProjects { error: Error | null; } +type ResourceFinder = ( + resources: readonly ProjectResourceSummary[], +) => ProjectResourceSummary | undefined; + +function resourceFinder( + pipeline: ProjectPipelineRef | undefined, + pointer: LocalPipelinePointer | undefined, +): ResourceFinder | undefined { + if (pipeline?.source === "remote") { + return (resources) => + remotePipelineResourceIn(resources, pipeline.pipelineId); + } + return pointer && ((resources) => pipelineResourceIn(resources, pointer)); +} + /** * Which of the reader's projects hold a given pipeline. There is no asking the * backend that question — no endpoint answers it and resources cannot be @@ -39,18 +59,19 @@ export interface PipelineProjects { * answer on demand should pass `enabled: false` until then. */ export function usePipelineProjects( - pipelineName: string | undefined, + pipeline: ProjectPipelineRef | undefined, { enabled: wanted = true }: { enabled?: boolean } = {}, ): PipelineProjects { const { configured, available } = useBackend(); - const enabled = wanted && configured && available && Boolean(pipelineName); + const enabled = wanted && configured && available && Boolean(pipeline); + const localName = pipeline?.source === "local" ? pipeline.name : undefined; const { data: pointer, error: pointerError } = useQuery({ queryKey: LocalPipelinesQueryKeys.Pointer({ - localName: pipelineName ?? "", + localName: localName ?? "", }), - queryFn: () => pointerTo(pipelineName ?? ""), - enabled, + queryFn: () => pointerTo(localName ?? ""), + enabled: enabled && localName !== undefined, staleTime: 1 * MINUTES, }); @@ -82,18 +103,22 @@ export function usePipelineProjects( resources.find((query) => query.error)?.error ?? null; + const findResource = resourceFinder(pipeline, pointer); + const isPending = enabled && error === null && - (isListPending || !pointer || resources.some((query) => query.isPending)); + (isListPending || + !findResource || + resources.some((query) => query.isPending)); - if (!pointer) { + if (!findResource) { return { memberships: [], isPending, error }; } const memberships = candidates.flatMap((project, index) => { const items = resources[index]?.data?.items; - const resource = items && pipelineResourceIn(items, pointer); + const resource = items && findResource(items); return resource ? [{ project, resource }] : []; });