diff --git a/src/__tests__/unit/components/TargetSelector.test.tsx b/src/__tests__/unit/components/TargetSelector.test.tsx index 2155b8cbce..d517b877db 100644 --- a/src/__tests__/unit/components/TargetSelector.test.tsx +++ b/src/__tests__/unit/components/TargetSelector.test.tsx @@ -3,14 +3,13 @@ */ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; // --------------------------------------------------------------------------- // Mocks // --------------------------------------------------------------------------- -let mockSlug = "other-workspace"; let mockWorkspace: { slug: string; repositories: { id: string; name: string }[] } = { slug: "other-workspace", repositories: [{ id: "repo-1", name: "my-repo" }], @@ -20,14 +19,15 @@ vi.mock("@/hooks/useWorkspace", () => ({ useWorkspace: () => ({ workspace: mockWorkspace }), })); -let mockWorkflows: { id: number; name: string; updated_at: string | null; last_modified_by: string | null }[] = []; +let mockWorkflows: { ref_id: string; node_type: "Workflow"; properties: { workflow_id: number; workflow_name?: string; workflow_json: string } }[] = []; let mockWorkflowsLoading = false; -vi.mock("@/hooks/useRecentWorkflows", () => ({ - useRecentWorkflows: () => ({ +vi.mock("@/hooks/useWorkflowNodes", () => ({ + useWorkflowNodes: () => ({ workflows: mockWorkflows, isLoading: mockWorkflowsLoading, error: null, + refetch: vi.fn(), }), })); @@ -36,7 +36,7 @@ vi.mock("@/lib/runtime", () => ({ isDevelopmentMode: () => false, })); -// Minimal Select UI mock — must include every named export used by TargetSelector +// Minimal Select UI mock vi.mock("@/components/ui/select", () => ({ Select: ({ children, onValueChange, value }: any) => (
@@ -75,6 +75,56 @@ vi.mock("@/components/ui/select", () => ({ SelectScrollDownButton: () => null, })); +// Minimal Popover mock +vi.mock("@/components/ui/popover", () => ({ + Popover: ({ children, open, onOpenChange }: any) => ( +
+ {React.Children.map(children, (child) => + child ? React.cloneElement(child, { open, onOpenChange }) : null + )} +
+ ), + PopoverTrigger: ({ children, open, onOpenChange, disabled, asChild }: any) => { + const child = React.Children.only(children) as React.ReactElement; + return React.cloneElement(child, { + onClick: () => !disabled && onOpenChange?.(!open), + disabled, + }); + }, + PopoverContent: ({ children, open }: any) => + open ?
{children}
: null, +})); + +// Minimal Command mock +vi.mock("@/components/ui/command", () => ({ + Command: ({ children }: any) =>
{children}
, + CommandInput: ({ value, onValueChange, placeholder }: any) => ( + onValueChange?.(e.target.value)} + placeholder={placeholder} + /> + ), + CommandList: ({ children }: any) =>
{children}
, + CommandEmpty: ({ children }: any) =>
{children}
, + CommandGroup: ({ children, heading }: any) => ( +
+
{heading}
+ {children} +
+ ), + CommandItem: ({ children, onSelect, value, ...props }: any) => ( + + ), + CommandSeparator: () =>
, +})); + // --------------------------------------------------------------------------- // Subject under test // --------------------------------------------------------------------------- @@ -84,6 +134,28 @@ import { decodeTargetValue, type TargetSelection, } from "@/components/shared/TargetSelector"; +import type { WorkflowNode } from "@/hooks/useWorkflowNodes"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeWorkflowNode(id: number, name?: string): WorkflowNode { + return { + ref_id: `ref-${id}`, + node_type: "Workflow", + properties: { + workflow_id: id, + workflow_name: name, + workflow_json: "{}", + }, + }; +} + +// Opens the popover by clicking the trigger +async function openPopover() { + await userEvent.click(screen.getByTestId("target-selector-trigger")); +} // --------------------------------------------------------------------------- // Tests @@ -92,7 +164,6 @@ import { describe("TargetSelector", () => { beforeEach(() => { vi.clearAllMocks(); - mockSlug = "other-workspace"; mockWorkspace = { slug: "other-workspace", repositories: [{ id: "repo-1", name: "my-repo" }], @@ -101,8 +172,11 @@ describe("TargetSelector", () => { mockWorkflowsLoading = false; }); + // ------------------------------------------------------------------------- + // Non-stakwork workspace — uses plain Select + // ------------------------------------------------------------------------- describe("non-stakwork workspace", () => { - it("renders only the Repositories group", () => { + it("renders only the Repositories group via Select", () => { mockWorkspace = { slug: "other-workspace", repositories: [ @@ -110,25 +184,32 @@ describe("TargetSelector", () => { { id: "repo-2", name: "repo-beta" }, ], }; - mockWorkflows = [{ id: 10, name: "wf-foo", updated_at: null, last_modified_by: null }]; + mockWorkflows = [makeWorkflowNode(10, "wf-foo")]; render(); - // No "Stak Workflows" label - expect(screen.queryByText("Stak Workflows")).toBeNull(); + // Uses plain Select (no popover) + expect(screen.getByTestId("select")).toBeTruthy(); + expect(screen.queryByTestId("popover")).toBeNull(); + // Repos rendered expect(screen.getByTestId("target-repo-repo-1")).toBeTruthy(); expect(screen.getByTestId("target-repo-repo-2")).toBeTruthy(); - // Workflow NOT rendered + + // No workflow items expect(screen.queryByTestId("target-workflow-10")).toBeNull(); + expect(screen.queryByText("Stak Workflows")).toBeNull(); }); - it("does NOT render Repositories label when no workflow section", () => { + it("does NOT render Repositories label (no workflow section)", () => { render(); expect(screen.queryByText("Repositories")).toBeNull(); }); }); + // ------------------------------------------------------------------------- + // Stakwork workspace — uses Popover + Command combobox + // ------------------------------------------------------------------------- describe("stakwork workspace", () => { beforeEach(() => { mockWorkspace = { @@ -136,24 +217,38 @@ describe("TargetSelector", () => { repositories: [{ id: "repo-1", name: "hive" }], }; mockWorkflows = [ - { id: 42, name: "my-workflow", updated_at: null, last_modified_by: null }, - { id: 99, name: "another-flow", updated_at: null, last_modified_by: null }, + makeWorkflowNode(42, "my-workflow"), + makeWorkflowNode(99, "another-flow"), ]; }); - it("renders both Repositories and Stak Workflows sections", () => { + it("renders Popover trigger (not plain Select)", () => { render(); + expect(screen.getByTestId("popover")).toBeTruthy(); + expect(screen.queryByTestId("select")).toBeNull(); + }); + + it("renders both Repositories and Stak Workflows sections after opening", async () => { + render(); + await openPopover(); - expect(screen.getByText("Repositories")).toBeTruthy(); - expect(screen.getByText("Stak Workflows")).toBeTruthy(); expect(screen.getByTestId("target-repo-repo-1")).toBeTruthy(); expect(screen.getByTestId("target-workflow-42")).toBeTruthy(); expect(screen.getByTestId("target-workflow-99")).toBeTruthy(); }); + it("shows workflow ID (#42) alongside workflow name", async () => { + render(); + await openPopover(); + + expect(screen.getByText("#42")).toBeTruthy(); + expect(screen.getByText("#99")).toBeTruthy(); + }); + it("emits correct repo selection shape on repo click", async () => { const onChange = vi.fn(); render(); + await openPopover(); await userEvent.click(screen.getByTestId("target-repo-repo-1")); @@ -163,9 +258,10 @@ describe("TargetSelector", () => { }); }); - it("emits correct workflow selection shape on workflow click", async () => { + it("emits correct workflow selection shape with workflowRefId from ref_id", async () => { const onChange = vi.fn(); render(); + await openPopover(); await userEvent.click(screen.getByTestId("target-workflow-42")); @@ -173,20 +269,94 @@ describe("TargetSelector", () => { type: "workflow", workflowId: 42, workflowName: "my-workflow", - workflowRefId: "", // refId not in RecentWorkflow; caller fetches separately + workflowRefId: "ref-42", }); }); - it("shows loading text while workflows are loading", () => { + it("shows loading text while workflows are loading", async () => { mockWorkflowsLoading = true; mockWorkflows = []; render(); + await openPopover(); expect(screen.getByText("Loading workflows…")).toBeTruthy(); }); + + // ----------------------------------------------------------------------- + // Search / filtering + // ----------------------------------------------------------------------- + it("filters workflows by name (case-insensitive substring)", async () => { + render(); + await openPopover(); + + const input = screen.getByTestId("command-input"); + fireEvent.change(input, { target: { value: "another" } }); + + // only "another-flow" (99) should be visible + expect(screen.queryByTestId("target-workflow-99")).toBeTruthy(); + expect(screen.queryByTestId("target-workflow-42")).toBeNull(); + }); + + it("filters workflows by partial numeric ID", async () => { + mockWorkflows = [makeWorkflowNode(12345, "alpha"), makeWorkflowNode(999, "beta")]; + + render(); + await openPopover(); + + const input = screen.getByTestId("command-input"); + fireEvent.change(input, { target: { value: "123" } }); + + expect(screen.queryByTestId("target-workflow-12345")).toBeTruthy(); + expect(screen.queryByTestId("target-workflow-999")).toBeNull(); + }); + + it("renders CommandEmpty when no workflows match search", async () => { + render(); + await openPopover(); + + const input = screen.getByTestId("command-input"); + fireEvent.change(input, { target: { value: "xyzzy-no-match" } }); + + // Both workflow items hidden + expect(screen.queryByTestId("target-workflow-42")).toBeNull(); + expect(screen.queryByTestId("target-workflow-99")).toBeNull(); + // CommandEmpty present + expect(screen.getByTestId("command-empty")).toBeTruthy(); + }); + + it("shows all workflows when search is empty", async () => { + render(); + await openPopover(); + + const input = screen.getByTestId("command-input"); + fireEvent.change(input, { target: { value: "my" } }); + fireEvent.change(input, { target: { value: "" } }); + + expect(screen.getByTestId("target-workflow-42")).toBeTruthy(); + expect(screen.getByTestId("target-workflow-99")).toBeTruthy(); + }); + + it("uses fallback name 'Workflow N' for unnamed workflows", async () => { + mockWorkflows = [makeWorkflowNode(777)]; // no name + + render(); + await openPopover(); + + expect(screen.getByText("Workflow 777")).toBeTruthy(); + }); + + it("disabled prop prevents popover from opening", async () => { + render(); + + const trigger = screen.getByTestId("target-selector-trigger"); + expect(trigger).toHaveProperty("disabled", true); + }); }); + // ------------------------------------------------------------------------- + // Helper functions + // ------------------------------------------------------------------------- describe("helper functions", () => { it("encodeTargetValue for repo", () => { const sel: TargetSelection = { type: "repo", repositoryId: "abc" }; @@ -208,10 +378,25 @@ describe("TargetSelector", () => { expect(result).toEqual({ type: "repo", repositoryId: "xyz" }); }); - it("decodeTargetValue for workflow string with lookup", () => { - const workflows = [{ id: 5, name: "flow-five", updated_at: null, last_modified_by: null }]; + it("decodeTargetValue for workflow string with WorkflowNode lookup", () => { + const workflows: WorkflowNode[] = [makeWorkflowNode(5, "flow-five")]; const result = decodeTargetValue("workflow:5", workflows); - expect(result).toMatchObject({ type: "workflow", workflowId: 5, workflowName: "flow-five" }); + expect(result).toMatchObject({ + type: "workflow", + workflowId: 5, + workflowName: "flow-five", + workflowRefId: "ref-5", + }); + }); + + it("decodeTargetValue uses fallback name when workflow not in list", () => { + const result = decodeTargetValue("workflow:999", []); + expect(result).toMatchObject({ + type: "workflow", + workflowId: 999, + workflowName: "Workflow 999", + workflowRefId: "", + }); }); it("decodeTargetValue returns null for unknown prefix", () => { diff --git a/src/components/shared/TargetSelector.tsx b/src/components/shared/TargetSelector.tsx index 409d376ac3..6a29574cf1 100644 --- a/src/components/shared/TargetSelector.tsx +++ b/src/components/shared/TargetSelector.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useMemo } from "react"; +import React, { useMemo, useState } from "react"; import { FolderOpen, GitBranch } from "lucide-react"; import { Select, @@ -11,8 +11,18 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; import { useWorkspace } from "@/hooks/useWorkspace"; -import { useRecentWorkflows, type RecentWorkflow } from "@/hooks/useRecentWorkflows"; +import { useWorkflowNodes, type WorkflowNode } from "@/hooks/useWorkflowNodes"; import { isDevelopmentMode } from "@/lib/runtime"; export type TargetSelection = @@ -43,10 +53,10 @@ export function encodeTargetValue(selection: TargetSelection): string { return `workflow:${selection.workflowId}`; } -/** Decode a Select value string back to a TargetSelection, using workflows list for lookup */ +/** Decode a Select value string back to a TargetSelection, using WorkflowNode[] for lookup */ export function decodeTargetValue( value: string, - workflows: RecentWorkflow[] + workflows: WorkflowNode[] ): TargetSelection | null { if (value.startsWith("repo:")) { const repositoryId = value.slice(5); @@ -55,12 +65,12 @@ export function decodeTargetValue( if (value.startsWith("workflow:")) { const workflowId = parseInt(value.slice(9), 10); if (isNaN(workflowId)) return null; - const wf = workflows.find((w) => w.id === workflowId); + const wf = workflows.find((w) => w.properties.workflow_id === workflowId); return { type: "workflow", workflowId, - workflowName: wf?.name ?? `Workflow ${workflowId}`, - workflowRefId: "", // ref is not available in RecentWorkflow; caller should update separately + workflowName: wf?.properties.workflow_name ?? `Workflow ${workflowId}`, + workflowRefId: wf?.ref_id ?? "", }; } return null; @@ -70,6 +80,9 @@ export function decodeTargetValue( * A unified target selector that lists repositories always, * and additionally lists Stak workflows when in the stakwork workspace or dev mode. * + * In stakwork/dev mode: renders a Popover + Command combobox with search. + * Otherwise: renders a plain Select for repos only. + * * Emits typed onChange payloads: `{ type: 'repo', repositoryId }` or * `{ type: 'workflow', workflowId, workflowName, workflowRefId }`. */ @@ -84,62 +97,180 @@ export function TargetSelector({ }: TargetSelectorProps) { const { workspace } = useWorkspace(); const isStakwork = workspace?.slug === "stakwork" || isDevelopmentMode(); + const showWorkflows = isStakwork; const repos = useMemo( () => repoProp ?? (workspace?.repositories ?? []).map((r) => ({ id: r.id, name: r.name })), [repoProp, workspace?.repositories] ); - const { workflows, isLoading: isLoadingWorkflows } = useRecentWorkflows(); + const { workflows, isLoading: isLoadingWorkflows } = useWorkflowNodes( + workspace?.slug ?? null, + showWorkflows + ); - // Only fetch / show workflows in stakwork workspace - const showWorkflows = isStakwork; + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); - const handleValueChange = (raw: string) => { - if (raw.startsWith("repo:")) { - const repositoryId = raw.slice(5); - onChange({ type: "repo", repositoryId }); - } else if (raw.startsWith("workflow:")) { - const workflowId = parseInt(raw.slice(9), 10); - if (!isNaN(workflowId)) { - const wf = workflows.find((w) => w.id === workflowId); - onChange({ - type: "workflow", - workflowId, - workflowName: wf?.name ?? `Workflow ${workflowId}`, - workflowRefId: "", // refId must be fetched separately by caller - }); - } - } - }; + const filteredWorkflows = useMemo(() => { + if (!search.trim()) return workflows; + const lower = search.toLowerCase(); + const numericSearch = search.trim(); + return workflows.filter((wf) => { + const name = (wf.properties.workflow_name ?? "").toLowerCase(); + const id = String(wf.properties.workflow_id); + return name.includes(lower) || id.startsWith(numericSearch); + }); + }, [workflows, search]); const triggerClass = size === "sm" ? "h-5 text-[10px] px-1.5 py-0 w-auto max-w-[140px] border-muted bg-muted/50 gap-1 [&>svg]:h-3 [&>svg]:w-3" : "w-[200px] h-8 text-xs rounded-lg shadow-sm"; - const selectedWorkflow = - value?.startsWith("workflow:") - ? workflows.find((w) => w.id === parseInt(value.slice(9), 10)) + // --- Combobox mode (stakwork / dev) --- + if (showWorkflows) { + const selectedWorkflow = value?.startsWith("workflow:") + ? workflows.find((w) => w.properties.workflow_id === parseInt(value.slice(9), 10)) + : null; + const selectedWorkflowId = value?.startsWith("workflow:") + ? parseInt(value.slice(9), 10) : null; - const selectedRepo = - value?.startsWith("repo:") ? repos.find((r) => r.id === value.slice(5)) : null; + const selectedRepo = value?.startsWith("repo:") + ? repos.find((r) => r.id === value.slice(5)) + : null; + + const label = selectedWorkflow + ? (selectedWorkflow.properties.workflow_name ?? `Workflow ${selectedWorkflow.properties.workflow_id}`) + : selectedWorkflowId && !selectedWorkflow + ? `Workflow ${selectedWorkflowId}` + : selectedRepo + ? selectedRepo.name + : placeholder ?? "Select target"; + + const isWorkflowSelected = !!selectedWorkflow || !!selectedWorkflowId; + + return ( + { + setOpen(v); + if (!v) setSearch(""); + }} + > + + + + + + + + + No workflows found. + + {repos.length > 0 && ( + + {repos.map((repo) => ( + { + onChange({ type: "repo", repositoryId: repo.id }); + setOpen(false); + setSearch(""); + }} + data-testid={`target-repo-${repo.id}`} + > + + {repo.name} + + ))} + + )} + + {repos.length > 0 && filteredWorkflows.length > 0 && } + + {isLoadingWorkflows ? ( +
+ Loading workflows… +
+ ) : ( + + {filteredWorkflows.map((wf) => ( + { + onChange({ + type: "workflow", + workflowId: wf.properties.workflow_id, + workflowName: + wf.properties.workflow_name ?? `Workflow ${wf.properties.workflow_id}`, + workflowRefId: wf.ref_id, + }); + setOpen(false); + setSearch(""); + }} + data-testid={`target-workflow-${wf.properties.workflow_id}`} + > + + + {wf.properties.workflow_name ?? `Workflow ${wf.properties.workflow_id}`} + + + #{wf.properties.workflow_id} + + + ))} + + )} +
+
+
+
+ ); + } + + // --- Plain Select mode (non-stakwork) --- + const selectedRepo = value?.startsWith("repo:") ? repos.find((r) => r.id === value.slice(5)) : null; + + const handleValueChange = (raw: string) => { + if (raw.startsWith("repo:")) { + onChange({ type: "repo", repositoryId: raw.slice(5) }); + } + }; return (