From 6c702401e649a80a2b373ecac9f83aa0ec408b6b Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Mon, 13 Apr 2026 21:17:43 +0100 Subject: [PATCH 1/5] Virtualize large client lists across jobs, command bar, and dropdowns --- orchestrator/package.json | 1 + orchestrator/src/client/lib/virtual-list.ts | 176 ++++++ .../client/pages/OrchestratorPage.test.tsx | 176 +++--- .../src/client/pages/OrchestratorPage.tsx | 6 +- .../pages/orchestrator/JobCommandBar.test.tsx | 165 ++++-- .../pages/orchestrator/JobCommandBar.tsx | 505 +++++++++++++++--- .../pages/orchestrator/JobCommandBar.utils.ts | 88 +++ .../pages/orchestrator/JobListPanel.test.tsx | 65 ++- .../pages/orchestrator/JobListPanel.tsx | 334 +++++++----- .../pages/orchestrator/useScrollToJobItem.ts | 19 +- .../virtualizedList.test-utils.ts | 94 ++++ .../pages/orchestrator/virtualizedList.ts | 50 ++ .../src/client/test/dom-measurement.ts | 185 +++++++ .../src/client/test/virtualization.ts | 57 ++ .../ui/searchable-dropdown.test.tsx | 165 ++++++ .../src/components/ui/searchable-dropdown.tsx | 330 ++++++++++-- .../src/components/ui/virtualized-listbox.tsx | 74 +++ orchestrator/src/setupTests.ts | 11 +- package-lock.json | 28 + 19 files changed, 2130 insertions(+), 399 deletions(-) create mode 100644 orchestrator/src/client/lib/virtual-list.ts create mode 100644 orchestrator/src/client/pages/orchestrator/virtualizedList.test-utils.ts create mode 100644 orchestrator/src/client/pages/orchestrator/virtualizedList.ts create mode 100644 orchestrator/src/client/test/dom-measurement.ts create mode 100644 orchestrator/src/client/test/virtualization.ts create mode 100644 orchestrator/src/components/ui/searchable-dropdown.test.tsx create mode 100644 orchestrator/src/components/ui/virtualized-listbox.tsx diff --git a/orchestrator/package.json b/orchestrator/package.json index b22374659..9f91473ed 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -46,6 +46,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.21", + "@tanstack/react-virtual": "^3.13.23", "@tiptap/extension-link": "^3.22.2", "@tiptap/react": "^3.22.2", "@tiptap/starter-kit": "^3.22.2", diff --git a/orchestrator/src/client/lib/virtual-list.ts b/orchestrator/src/client/lib/virtual-list.ts new file mode 100644 index 000000000..29a0f2311 --- /dev/null +++ b/orchestrator/src/client/lib/virtual-list.ts @@ -0,0 +1,176 @@ +import { + elementScroll, + useVirtualizer, + useWindowVirtualizer, + type Virtualizer, + windowScroll, +} from "@tanstack/react-virtual"; + +export type VirtualListScrollAlignment = "auto" | "center" | "end" | "start"; +export type VirtualListScrollBehavior = "auto" | "instant" | "smooth"; + +export interface VirtualListHandle { + scrollToIndex: ( + index: number, + options?: { + align?: VirtualListScrollAlignment; + behavior?: VirtualListScrollBehavior; + }, + ) => void; +} + +type VirtualizedListBaseOptions = { + count: number; + estimateSize?: (index: number) => number; + getItemKey?: (index: number) => string | number | bigint; + overscan?: number; + enabled?: boolean; + initialRect?: { + height: number; + width: number; + }; +}; + +type WindowVirtualizedListOptions = VirtualizedListBaseOptions & { + mode?: "window"; +}; + +type ElementVirtualizedListOptions = + VirtualizedListBaseOptions & { + mode: "element"; + scrollElement: TScrollElement | null; + }; + +export function useVirtualizedList< + TItemElement extends Element = HTMLDivElement, +>(options: WindowVirtualizedListOptions): Virtualizer; +export function useVirtualizedList< + TScrollElement extends Element, + TItemElement extends Element = HTMLDivElement, +>( + options: ElementVirtualizedListOptions, +): Virtualizer; +export function useVirtualizedList< + TScrollElement extends Element, + TItemElement extends Element, +>( + options: + | WindowVirtualizedListOptions + | ElementVirtualizedListOptions, +) { + const { + count, + estimateSize = () => 84, + getItemKey, + overscan = 8, + enabled = true, + initialRect, + } = options; + const isElementMode = options.mode === "element"; + const isJsdom = + typeof navigator !== "undefined" && navigator.userAgent.includes("jsdom"); + + const scrollWindow = ( + offset: number, + { + adjustments = 0, + behavior, + }: { + adjustments?: number; + behavior?: VirtualListScrollBehavior; + }, + instance: Virtualizer, + ) => { + if (!isJsdom) { + windowScroll(offset, { adjustments, behavior }, instance); + return; + } + + const scrollElement = instance.scrollElement; + if (!scrollElement) return; + + const nextOffset = offset + adjustments; + const property = instance.options.horizontal ? "scrollX" : "scrollY"; + + try { + (scrollElement as unknown as Record)[property] = + nextOffset; + } catch { + try { + Object.defineProperty(scrollElement, property, { + configurable: true, + value: nextOffset, + }); + } catch { + // JSDOM exposes scroll offsets through read-only accessors, so fall + // back to a scroll event only when we cannot patch the property. + } + } + + scrollElement.dispatchEvent(new Event("scroll")); + }; + + const scrollElement = ( + offset: number, + { + adjustments = 0, + behavior, + }: { + adjustments?: number; + behavior?: VirtualListScrollBehavior; + }, + instance: Virtualizer, + ) => { + if (!isJsdom) { + elementScroll(offset, { adjustments, behavior }, instance); + return; + } + + const nextScrollElement = instance.scrollElement; + if (!nextScrollElement) return; + + const nextOffset = offset + adjustments; + const property = instance.options.horizontal ? "scrollLeft" : "scrollTop"; + + try { + (nextScrollElement as Record)[property] = nextOffset; + } catch { + try { + Object.defineProperty(nextScrollElement, property, { + configurable: true, + value: nextOffset, + }); + } catch { + // Same JSDOM fallback as above. + } + } + + nextScrollElement.dispatchEvent(new Event("scroll")); + }; + + const elementVirtualizer = useVirtualizer({ + count, + estimateSize, + getItemKey, + overscan, + enabled: enabled && isElementMode, + initialRect, + getScrollElement: () => + options.mode === "element" ? options.scrollElement : null, + scrollToFn: scrollElement, + useFlushSync: false, + }); + + const windowVirtualizer = useWindowVirtualizer({ + count, + estimateSize, + getItemKey, + overscan, + enabled: enabled && !isElementMode, + initialRect, + scrollToFn: scrollWindow, + useFlushSync: false, + }); + + return isElementMode ? elementVirtualizer : windowVirtualizer; +} diff --git a/orchestrator/src/client/pages/OrchestratorPage.test.tsx b/orchestrator/src/client/pages/OrchestratorPage.test.tsx index 9a8ccbf96..73dcba201 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.test.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.test.tsx @@ -1,6 +1,7 @@ import { createJob } from "@shared/testing/factories.js"; import type { Job } from "@shared/types.js"; import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { forwardRef, useImperativeHandle } from "react"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { toast } from "sonner"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -66,6 +67,7 @@ let mockAutomaticRunValues: AutomaticRunValues = { cityLocations: [], workplaceTypes: ["remote", "hybrid", "onsite"], }; +const mockJobListScrollToIndex = vi.fn(); const jobFixture = createJob({ id: "job-1", @@ -296,77 +298,88 @@ vi.mock("./orchestrator/JobDetailPanel", () => ({ })); vi.mock("./orchestrator/JobListPanel", () => ({ - JobListPanel: ({ - activeJobs, - onSelectJob, - onToggleSelectJob, - onToggleSelectAll, - selectedJobId, - }: { - onSelectJob: (id: string) => void; - onToggleSelectJob: (id: string) => void; - onToggleSelectAll: (checked: boolean) => void; - selectedJobId: string | null; - activeJobs: Job[]; - }) => ( -
-
-
-
-
{selectedJobId ?? "none"}
-
- {activeJobs.filter((job) => job.appliedDuplicateMatch).length} -
- - - - - - - -
+ JobListPanel: forwardRef( + ( + { + activeJobs, + onSelectJob, + onToggleSelectJob, + onToggleSelectAll, + selectedJobId, + }: { + onSelectJob: (id: string) => void; + onToggleSelectJob: (id: string) => void; + onToggleSelectAll: (checked: boolean) => void; + selectedJobId: string | null; + activeJobs: Job[]; + }, + ref, + ) => { + useImperativeHandle(ref, () => ({ + scrollToIndex: mockJobListScrollToIndex, + })); + + return ( +
+
+
+
+
{selectedJobId ?? "none"}
+
+ {activeJobs.filter((job) => job.appliedDuplicateMatch).length} +
+ + + + + + + +
+ ); + }, ), })); @@ -645,6 +658,13 @@ describe("OrchestratorPage", () => { expect(locationText).not.toContain("salaryMax="); expect(locationText).not.toContain("q="); }); + expect(mockJobListScrollToIndex).toHaveBeenCalledWith( + 0, + expect.objectContaining({ + align: "center", + behavior: "smooth", + }), + ); }); it("removes legacy q query params on load", async () => { @@ -1177,11 +1197,25 @@ describe("OrchestratorPage", () => { await waitFor(() => { expect(screen.getByTestId("selected-job")).toHaveTextContent("job-2"); }); + expect(mockJobListScrollToIndex).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + align: "center", + behavior: "smooth", + }), + ); pressKey("k"); await waitFor(() => { expect(screen.getByTestId("selected-job")).toHaveTextContent("job-1"); }); + expect(mockJobListScrollToIndex).toHaveBeenLastCalledWith( + 0, + expect.objectContaining({ + align: "center", + behavior: "smooth", + }), + ); pressKey("2"); await waitFor(() => { diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index 1fe693f14..1bf1d5335 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -1,8 +1,9 @@ import { useKeyboardAvailability } from "@client/hooks/useKeyboardAvailability"; import { useSettings } from "@client/hooks/useSettings"; import type React from "react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import type { VirtualListHandle } from "@/client/lib/virtual-list"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"; import { KeyboardShortcutBar } from "../components/KeyboardShortcutBar"; @@ -71,6 +72,7 @@ export const OrchestratorPage: React.FC = () => { ); const selectedJobId = jobId || null; + const jobListHandleRef = useRef(null); // Effect to sync URL if it was invalid useEffect(() => { @@ -219,6 +221,7 @@ export const OrchestratorPage: React.FC = () => { selectedJobId, isDesktop, onEnsureJobSelected: (id) => navigateWithContext(activeTab, id, true), + listHandle: jobListHandleRef.current, }); const isAnyModalOpen = @@ -449,6 +452,7 @@ export const OrchestratorPage: React.FC = () => {
{/* Primary region: Job list with highest visual weight */} { - Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { - configurable: true, - value: vi.fn(), - }); +import { installVirtualizerSizeMock } from "./virtualizedList.test-utils"; + +const cleanupVirtualizerMock = installVirtualizerSizeMock(); + +const createJob = (overrides: Partial = {}): JobListItem => ({ + id: "job-1", + source: "indeed", + title: "Backend Engineer", + employer: "Acme", + jobUrl: "https://example.com/jobs/job-1", + applicationLink: null, + datePosted: null, + deadline: null, + salary: null, + location: null, + status: "ready", + outcome: null, + closedAt: null, + suitabilityScore: null, + sponsorMatchScore: null, + appliedDuplicateMatch: null, + jobType: null, + jobFunction: null, + salaryMinAmount: null, + salaryMaxAmount: null, + salaryCurrency: null, + discoveredAt: "2025-01-01T00:00:00Z", + readyAt: null, + appliedAt: null, + updatedAt: "2025-01-01T00:00:00Z", + ...overrides, }); afterAll(() => { - Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { - configurable: true, - value: originalScrollIntoView, - }); + cleanupVirtualizerMock(); + vi.restoreAllMocks(); }); describe("JobCommandBar", () => { @@ -102,7 +122,7 @@ describe("JobCommandBar", () => { ); }); - it("shows selectable filter suggestion for @ tokens", () => { + it("shows selectable filter suggestion for @ tokens", async () => { render( { ); fireEvent.change(input, { target: { value: "@ready" } }); - expect(screen.getByText("Lock to @ready")).toBeInTheDocument(); + const lockSuggestion = await screen.findByText("Lock to @ready"); + expect(lockSuggestion).toBeInTheDocument(); expect(screen.queryByText("No jobs found.")).not.toBeInTheDocument(); - fireEvent.click(screen.getByText("Lock to @ready")); + fireEvent.click(lockSuggestion); expect(screen.getByText("@ready")).toBeInTheDocument(); expect(screen.getByText("Ready Engineer")).toBeInTheDocument(); }); - it("shows all lock suggestions for bare @", () => { + it("shows all lock suggestions for bare @", async () => { render( { ); fireEvent.change(input, { target: { value: "@" } }); - expect(screen.getByText("Lock to @ready")).toBeInTheDocument(); - expect(screen.getByText("Lock to @discovered")).toBeInTheDocument(); - expect(screen.getByText("Lock to @applied")).toBeInTheDocument(); - expect(screen.getByText("Lock to @in-progress")).toBeInTheDocument(); - expect(screen.getByText("Lock to @skipped")).toBeInTheDocument(); - expect(screen.getByText("Lock to @expired")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("Lock to @ready")).toBeInTheDocument(); + expect(screen.getByText("Lock to @discovered")).toBeInTheDocument(); + expect(screen.getByText("Lock to @applied")).toBeInTheDocument(); + expect(screen.getByText("Lock to @in-progress")).toBeInTheDocument(); + expect(screen.getByText("Lock to @skipped")).toBeInTheDocument(); + expect(screen.getByText("Lock to @expired")).toBeInTheDocument(); + }); }); it("creates in-progress lock from @prog + Tab", () => { @@ -171,9 +194,9 @@ describe("JobCommandBar", () => { expect(screen.getByText("@in-progress")).toBeInTheDocument(); }); - it("searches by company name and routes to the matched state", () => { + it("searches by company name and routes to the matched state", async () => { const onSelectJob = vi.fn(); - const jobs: Job[] = [ + const jobs: JobListItem[] = [ createJob({ id: "ready-job", title: "Backend Engineer", @@ -198,13 +221,13 @@ describe("JobCommandBar", () => { target: { value: "Globex" }, }, ); - fireEvent.click(screen.getByText("Platform Engineer")); + fireEvent.click(await screen.findByText("Platform Engineer")); expect(onSelectJob).toHaveBeenCalledWith("applied", "applied-job"); }); - it("returns only locked status results", () => { - const jobs: Job[] = [ + it("returns only locked status results", async () => { + const jobs: JobListItem[] = [ createJob({ id: "disc-1", title: "Frontend Engineer", @@ -226,13 +249,13 @@ describe("JobCommandBar", () => { fireEvent.keyDown(input, { key: "Tab" }); fireEvent.change(input, { target: { value: "Frontend" } }); - expect(screen.getByText("Frontend Engineer")).toBeInTheDocument(); + expect(await screen.findByText("Frontend Engineer")).toBeInTheDocument(); expect(screen.queryByText("@applied")).not.toBeInTheDocument(); expect(screen.queryByText("Applied")).not.toBeInTheDocument(); }); - it("ranks closest match first within a lock", () => { - const jobs: Job[] = [ + it("ranks closest match first within a lock", async () => { + const jobs: JobListItem[] = [ createJob({ id: "ready-job", title: "Junior Software Engineer (Data Products)", @@ -265,7 +288,7 @@ describe("JobCommandBar", () => { target: { value: "joinrs" }, }); - const options = screen.getAllByRole("option"); + const options = await screen.findAllByRole("option"); expect(options[0]).toHaveTextContent("Joinrs"); expect(options[0]).toHaveTextContent("Junior Web Developer"); }); @@ -403,7 +426,7 @@ describe("JobCommandBar", () => { expect((input as HTMLInputElement).value).toBe("@all"); }); - it("routes in-progress jobs to the all jobs view", () => { + it("routes in-progress jobs to the all jobs view", async () => { const onSelectJob = vi.fn(); render( @@ -429,13 +452,13 @@ describe("JobCommandBar", () => { target: { value: "Globex" }, }, ); - fireEvent.click(screen.getByText("Staff Engineer")); + fireEvent.click(await screen.findByText("Staff Engineer")); expect(onSelectJob).toHaveBeenCalledWith("all", "in-progress-job"); }); it("excludes processing jobs from every lock scope", () => { - const jobs: Job[] = [ + const jobs: JobListItem[] = [ createJob({ id: "processing-job", title: "Processing-only keyword", @@ -485,4 +508,70 @@ describe("JobCommandBar", () => { ).not.toBeInTheDocument(); } }); + + it("virtualizes large result sets without mounting every matching option", async () => { + const jobs = Array.from({ length: 24 }, (_, index) => + createJob({ + id: `job-${index}`, + title: `Engineer ${index}`, + employer: "Acme", + status: "ready", + discoveredAt: `2025-01-${String(index + 1).padStart(2, "0")}T00:00:00Z`, + }), + ); + + render(); + + openWithKeyboard(); + fireEvent.change( + screen.getByPlaceholderText( + "Search jobs by job title or company name...", + ), + { + target: { value: "Engineer" }, + }, + ); + + await waitFor(() => { + expect(screen.getAllByRole("option").length).toBeLessThan(jobs.length); + }); + expect(screen.queryByText("Engineer 0")).not.toBeInTheDocument(); + expect(await screen.findByText("Engineer 23")).toBeInTheDocument(); + }); + + it("scrolls to and selects an offscreen result with keyboard navigation", async () => { + const onSelectJob = vi.fn(); + const jobs = Array.from({ length: 20 }, (_, index) => + createJob({ + id: `job-${index}`, + title: `Engineer ${index}`, + employer: "Acme", + status: "ready", + discoveredAt: `2025-02-${String(index + 1).padStart(2, "0")}T00:00:00Z`, + }), + ); + + render(); + + openWithKeyboard(); + const input = screen.getByPlaceholderText( + "Search jobs by job title or company name...", + ); + fireEvent.change(input, { target: { value: "Engineer" } }); + + await screen.findByText("Engineer 19"); + expect(screen.queryByText("Engineer 0")).not.toBeInTheDocument(); + + for (let index = 0; index < 19; index += 1) { + fireEvent.keyDown(input, { key: "ArrowDown" }); + } + + await waitFor(() => { + expect(screen.getByText("Engineer 0")).toBeInTheDocument(); + }); + + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onSelectJob).toHaveBeenCalledWith("ready", "job-0"); + }); }); diff --git a/orchestrator/src/client/pages/orchestrator/JobCommandBar.tsx b/orchestrator/src/client/pages/orchestrator/JobCommandBar.tsx index e340b9af3..5e4db3dcd 100644 --- a/orchestrator/src/client/pages/orchestrator/JobCommandBar.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobCommandBar.tsx @@ -1,33 +1,36 @@ import { useHotkeys } from "@client/hooks/useHotkeys"; import type { JobListItem } from "@shared/types.js"; import type React from "react"; -import { useCallback, useEffect, useMemo, useState } from "react"; import { - CommandDialog, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from "@/components/ui/command"; + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { CommandDialog, CommandInput } from "@/components/ui/command"; import { DialogDescription, DialogTitle } from "@/components/ui/dialog"; import { bucketQueryLength, trackProductEvent } from "@/lib/analytics"; +import { cn } from "@/lib/utils"; import type { FilterTab } from "./constants"; import { + buildCommandBarRows, + type CommandBarRow, extractLeadingAtToken, getFilterTab, getLockMatchesFromAliasPrefix, groupJobsForCommandBar, jobMatchesLock, + lockLabel, orderCommandGroups, resolveLockFromAliasPrefix, type StatusLock, stripLeadingAtToken, } from "./JobCommandBar.utils"; import { JobCommandBarLockBadge } from "./JobCommandBarLockBadge"; -import { JobCommandBarLockSuggestions } from "./JobCommandBarLockSuggestions"; import { JobRowContent } from "./JobRowContent"; +import { useVirtualizedList } from "./virtualizedList"; interface JobCommandBarProps { jobs: JobListItem[]; @@ -37,6 +40,83 @@ interface JobCommandBarProps { enabled?: boolean; } +const ROW_HEIGHT_ESTIMATES: Record = { + groupHeading: 28, + separator: 1, + option: 72, +}; + +const LOCK_ROW_HEIGHT_ESTIMATE = 56; +const RESULTS_LIST_ID = "job-command-bar-results"; + +const lockDialogAccentClass: Record = { + ready: + "border-emerald-500/50 shadow-[0_0_0_1px_rgba(16,185,129,0.2),0_0_36px_-12px_rgba(16,185,129,0.55)]", + discovered: + "border-sky-500/50 shadow-[0_0_0_1px_rgba(14,165,233,0.2),0_0_36px_-12px_rgba(14,165,233,0.55)]", + applied: + "border-emerald-500/50 shadow-[0_0_0_1px_rgba(16,185,129,0.2),0_0_36px_-12px_rgba(16,185,129,0.55)]", + in_progress: + "border-cyan-500/50 shadow-[0_0_0_1px_rgba(6,182,212,0.2),0_0_36px_-12px_rgba(6,182,212,0.55)]", + skipped: + "border-rose-500/50 shadow-[0_0_0_1px_rgba(244,63,94,0.2),0_0_36px_-12px_rgba(244,63,94,0.55)]", + expired: + "border-zinc-400/40 shadow-[0_0_0_1px_rgba(161,161,170,0.2),0_0_32px_-12px_rgba(161,161,170,0.45)]", +}; + +const buildSelectableRows = (rows: CommandBarRow[]) => + rows.filter( + (row): row is Extract => + row.kind === "option", + ); + +const FALLBACK_OVERSCAN_PX = 240; + +const getEstimatedRowHeight = (row: CommandBarRow) => { + if (row.kind === "option" && row.optionKind === "lockSuggestion") { + return LOCK_ROW_HEIGHT_ESTIMATE; + } + + return ROW_HEIGHT_ESTIMATES[row.kind]; +}; + +const buildFallbackVirtualItems = ( + rows: CommandBarRow[], + scrollTop: number, + viewportHeight: number, +) => { + const offsets: number[] = []; + let runningOffset = 0; + + for (const row of rows) { + offsets.push(runningOffset); + runningOffset += getEstimatedRowHeight(row); + } + + const startThreshold = Math.max(0, scrollTop - FALLBACK_OVERSCAN_PX); + const endThreshold = scrollTop + viewportHeight + FALLBACK_OVERSCAN_PX; + + let startIndex = 0; + while ( + startIndex < rows.length && + offsets[startIndex] + getEstimatedRowHeight(rows[startIndex]) < + startThreshold + ) { + startIndex += 1; + } + + let endIndex = startIndex; + while (endIndex < rows.length && offsets[endIndex] < endThreshold) { + endIndex += 1; + } + + return rows.slice(startIndex, endIndex).map((row, localIndex) => ({ + index: startIndex + localIndex, + start: offsets[startIndex + localIndex] ?? 0, + row, + })); +}; + export const JobCommandBar: React.FC = ({ jobs, onSelectJob, @@ -44,23 +124,12 @@ export const JobCommandBar: React.FC = ({ onOpenChange, enabled = true, }) => { - const lockDialogAccentClass: Record = { - ready: - "border-emerald-500/50 shadow-[0_0_0_1px_rgba(16,185,129,0.2),0_0_36px_-12px_rgba(16,185,129,0.55)]", - discovered: - "border-sky-500/50 shadow-[0_0_0_1px_rgba(14,165,233,0.2),0_0_36px_-12px_rgba(14,165,233,0.55)]", - applied: - "border-emerald-500/50 shadow-[0_0_0_1px_rgba(16,185,129,0.2),0_0_36px_-12px_rgba(16,185,129,0.55)]", - in_progress: - "border-cyan-500/50 shadow-[0_0_0_1px_rgba(6,182,212,0.2),0_0_36px_-12px_rgba(6,182,212,0.55)]", - skipped: - "border-rose-500/50 shadow-[0_0_0_1px_rgba(244,63,94,0.2),0_0_36px_-12px_rgba(244,63,94,0.55)]", - expired: - "border-zinc-400/40 shadow-[0_0_0_1px_rgba(161,161,170,0.2),0_0_32px_-12px_rgba(161,161,170,0.45)]", - }; const [internalOpen, setInternalOpen] = useState(false); const [query, setQuery] = useState(""); const [activeLock, setActiveLock] = useState(null); + const [activeRowId, setActiveRowId] = useState(null); + const [scrollTop, setScrollTop] = useState(0); + const resultsScrollRef = useRef(null); const isOpenControlled = typeof open === "boolean"; const isOpen = isOpenControlled ? open : internalOpen; @@ -77,6 +146,7 @@ export const JobCommandBar: React.FC = ({ const closeDialog = useCallback(() => { setDialogOpen(false); setActiveLock(null); + setActiveRowId(null); }, [setDialogOpen]); useHotkeys( @@ -93,7 +163,11 @@ export const JobCommandBar: React.FC = ({ { enabled }, ); - const normalizedQuery = query.trim().toLowerCase(); + const deferredQuery = useDeferredValue(query); + const normalizedQuery = (jobs.length > 250 ? deferredQuery : query) + .trim() + .toLowerCase(); + const scopedJobs = useMemo(() => { if (!activeLock) return jobs; return jobs.filter((job) => jobMatchesLock(job, activeLock)); @@ -109,22 +183,177 @@ export const JobCommandBar: React.FC = ({ [groupedJobs, normalizedQuery], ); - const applyLock = (lock: StatusLock) => { + const lockSuggestions = useMemo(() => { + if (activeLock) return []; + const token = extractLeadingAtToken(query); + if (token === null) return []; + return getLockMatchesFromAliasPrefix(token); + }, [activeLock, query]); + + const rows = useMemo( + () => + buildCommandBarRows({ + activeLock, + groupedJobs, + lockSuggestions, + orderedGroups, + }), + [activeLock, groupedJobs, lockSuggestions, orderedGroups], + ); + + const selectableRows = useMemo(() => buildSelectableRows(rows), [rows]); + const selectableRowIds = useMemo( + () => selectableRows.map((row) => row.id), + [selectableRows], + ); + + useEffect(() => { + if (!isOpen) { + setActiveRowId(null); + return; + } + + if (selectableRowIds.length === 0) { + setActiveRowId(null); + return; + } + + setActiveRowId((current) => { + if (current && selectableRowIds.includes(current)) return current; + return selectableRowIds[0]; + }); + }, [isOpen, selectableRowIds]); + + const activeRowIndex = useMemo(() => { + if (!activeRowId) return -1; + return rows.findIndex((row) => row.id === activeRowId); + }, [activeRowId, rows]); + + const { + scrollElementRef, + virtualItems, + totalSize, + measureElement, + scrollToIndex, + } = useVirtualizedList({ + count: rows.length, + enabled: isOpen, + estimateSize: (index) => { + const row = rows[index]; + if (!row) return 0; + if (row.kind === "option" && row.optionKind === "lockSuggestion") { + return LOCK_ROW_HEIGHT_ESTIMATE; + } + return ROW_HEIGHT_ESTIMATES[row.kind]; + }, + getItemKey: (index) => rows[index]?.id ?? `row-${index}`, + overscan: 8, + initialRect: { + height: Math.max(240, Math.round(window.innerHeight * 0.65)), + width: window.innerWidth, + }, + }); + + const viewportHeight = Math.max(240, Math.round(window.innerHeight * 0.65)); + + const estimatedLayout = useMemo( + () => buildFallbackVirtualItems(rows, scrollTop, viewportHeight), + [rows, scrollTop, viewportHeight], + ); + + const renderedVirtualItems = + virtualItems.length > 0 ? virtualItems : estimatedLayout; + const renderedTotalSize = + virtualItems.length > 0 + ? totalSize + : estimatedLayout.reduce( + (currentMax, item) => + Math.max( + currentMax, + item.start + getEstimatedRowHeight(rows[item.index] ?? item.row), + ), + 0, + ); + + const setResultsScrollElement = useCallback( + (element: HTMLDivElement | null) => { + resultsScrollRef.current = element; + scrollElementRef(element); + }, + [scrollElementRef], + ); + + useEffect(() => { + if (activeRowIndex < 0) return; + scrollToIndex(activeRowIndex, { align: "auto" }); + if (virtualItems.length === 0) { + const nextScrollTop = estimatedLayout.find( + (item) => item.index === activeRowIndex, + )?.start; + if (typeof nextScrollTop === "number") { + if (resultsScrollRef.current) { + resultsScrollRef.current.scrollTop = nextScrollTop; + } + setScrollTop(nextScrollTop); + } + } + }, [activeRowIndex, estimatedLayout, scrollToIndex, virtualItems.length]); + + const applyLock = useCallback((lock: StatusLock) => { setActiveLock(lock); setQuery((current) => stripLeadingAtToken(current)); - }; + }, []); useEffect(() => { if (isOpen) return; setActiveLock(null); }, [isOpen]); - const lockSuggestions = useMemo(() => { - if (activeLock) return []; - const token = extractLeadingAtToken(query); - if (token === null) return []; - return getLockMatchesFromAliasPrefix(token); - }, [activeLock, query]); + const moveActiveSelection = useCallback( + (direction: 1 | -1) => { + if (selectableRows.length === 0) return; + + setActiveRowId((current) => { + const currentIndex = current ? selectableRowIds.indexOf(current) : -1; + + if (currentIndex < 0) { + return direction > 0 + ? selectableRowIds[0] + : selectableRowIds[selectableRowIds.length - 1]; + } + + const nextIndex = Math.min( + selectableRowIds.length - 1, + Math.max(0, currentIndex + direction), + ); + return selectableRowIds[nextIndex]; + }); + }, + [selectableRowIds, selectableRows.length], + ); + + const selectRow = useCallback( + (row: Extract) => { + if (row.optionKind === "lockSuggestion" && row.lock) { + applyLock(row.lock); + return; + } + + if (!row.job) return; + + trackProductEvent("jobs_command_bar_job_selected", { + had_status_lock: Boolean(activeLock), + status_lock: activeLock ?? "none", + result_group: row.groupId, + query_length_bucket: bucketQueryLength( + stripLeadingAtToken(query).trim(), + ), + }); + closeDialog(); + onSelectJob(getFilterTab(row.job.status), row.job.id); + }, + [activeLock, applyLock, closeDialog, onSelectJob, query], + ); const handleInputKeyDown = (event: React.KeyboardEvent) => { if ( @@ -133,7 +362,20 @@ export const JobCommandBar: React.FC = ({ !event.altKey ) { const token = extractLeadingAtToken(query); - if (!token) return; + if (!token) { + if (event.key === "Enter" && activeRowId) { + const selectedRow = rows.find( + (row): row is Extract => + row.kind === "option" && row.id === activeRowId, + ); + if (selectedRow) { + event.preventDefault(); + selectRow(selectedRow); + } + } + return; + } + const nextLock = resolveLockFromAliasPrefix(token); if (!nextLock) return; @@ -142,6 +384,34 @@ export const JobCommandBar: React.FC = ({ return; } + if (event.key === "ArrowDown") { + event.preventDefault(); + moveActiveSelection(1); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + moveActiveSelection(-1); + return; + } + + if (event.key === "Home") { + event.preventDefault(); + if (selectableRowIds.length > 0) { + setActiveRowId(selectableRowIds[0]); + } + return; + } + + if (event.key === "End") { + event.preventDefault(); + if (selectableRowIds.length > 0) { + setActiveRowId(selectableRowIds[selectableRowIds.length - 1]); + } + return; + } + if (event.key === "Backspace" && query.length === 0 && activeLock) { event.preventDefault(); setActiveLock(null); @@ -181,54 +451,139 @@ export const JobCommandBar: React.FC = ({ ) : undefined } + aria-controls={RESULTS_LIST_ID} + aria-activedescendant={activeRowId ?? undefined} + aria-autocomplete="list" + role="combobox" + aria-expanded={isOpen} />
Use @ + status + Tab/Enter to lock a status. Backspace on empty search clears the lock.
- - No jobs found. - {!activeLock && ( - - )} - {orderedGroups.map((group, index) => { - const items = groupedJobs[group.id]; - if (items.length === 0) return null; - return ( -
- {index > 0 && } - - {items.map((job) => { - return ( - { - trackProductEvent("jobs_command_bar_job_selected", { - had_status_lock: Boolean(activeLock), - status_lock: activeLock ?? "none", - result_group: group.id, - query_length_bucket: bucketQueryLength( - stripLeadingAtToken(query).trim(), - ), - }); - closeDialog(); - onSelectJob(getFilterTab(job.status), job.id); + + {rows.length === 0 ? ( + + No jobs found. + + ) : ( +
{ + setScrollTop(event.currentTarget.scrollTop); + }} + > +
+ {renderedVirtualItems.map((virtualItem) => { + const row = rows[virtualItem.index]; + if (!row) return null; + + return ( +
+ {row.kind === "groupHeading" ? ( +
+ {row.heading} +
+ ) : row.kind === "separator" ? ( +
+ ) : row.optionKind === "lockSuggestion" && row.lock ? ( +
setActiveRowId(row.id)} + onMouseDown={(event) => event.preventDefault()} + onClick={() => selectRow(row)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + selectRow(row); }} > - - - ); - })} - -
- ); - })} - +
+ + + Lock to @{lockLabel[row.lock]} + +
+
+ ) : row.job ? ( +
setActiveRowId(row.id)} + onMouseDown={(event) => event.preventDefault()} + onClick={() => selectRow(row)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + selectRow(row); + }} + > + +
+ ) : null} +
+ ); + })} +
+
+ )} ); }; diff --git a/orchestrator/src/client/pages/orchestrator/JobCommandBar.utils.ts b/orchestrator/src/client/pages/orchestrator/JobCommandBar.utils.ts index c5228a414..6f9f9ddc9 100644 --- a/orchestrator/src/client/pages/orchestrator/JobCommandBar.utils.ts +++ b/orchestrator/src/client/pages/orchestrator/JobCommandBar.utils.ts @@ -10,6 +10,27 @@ export type StatusLock = | "skipped" | "expired"; +export type CommandBarRow = + | { + id: string; + kind: "groupHeading"; + heading: string; + groupId: CommandGroupId | "filters"; + } + | { + id: string; + kind: "separator"; + groupId: CommandGroupId; + } + | { + id: string; + kind: "option"; + optionKind: "lockSuggestion" | "job"; + groupId: CommandGroupId | "filters"; + lock?: StatusLock; + job?: JobListItem; + }; + export const commandGroupMeta: Array<{ id: CommandGroupId; heading: string }> = [ { id: "ready", heading: "Ready" }, @@ -217,3 +238,70 @@ export const orderCommandGroups = ( ); }); }; + +export const getCommandBarRowId = (row: CommandBarRow) => row.id; + +export const buildCommandBarRows = ({ + activeLock, + groupedJobs, + lockSuggestions, + orderedGroups, +}: { + activeLock: StatusLock | null; + groupedJobs: Record; + lockSuggestions: StatusLock[]; + orderedGroups: Array<{ id: CommandGroupId; heading: string }>; +}): CommandBarRow[] => { + const rows: CommandBarRow[] = []; + + if (!activeLock && lockSuggestions.length > 0) { + rows.push({ + id: "command-bar-filters-heading", + kind: "groupHeading", + heading: "Filters", + groupId: "filters", + }); + + for (const lock of lockSuggestions) { + rows.push({ + id: `command-bar-lock-${lock}`, + kind: "option", + optionKind: "lockSuggestion", + groupId: "filters", + lock, + }); + } + } + + for (const [index, group] of orderedGroups.entries()) { + const items = groupedJobs[group.id]; + if (items.length === 0) continue; + + if (index > 0) { + rows.push({ + id: `command-bar-separator-${group.id}`, + kind: "separator", + groupId: group.id, + }); + } + + rows.push({ + id: `command-bar-group-${group.id}-heading`, + kind: "groupHeading", + heading: group.heading, + groupId: group.id, + }); + + for (const job of items) { + rows.push({ + id: `command-bar-job-${job.id}`, + kind: "option", + optionKind: "job", + groupId: group.id, + job, + }); + } + } + + return rows; +}; diff --git a/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx index 998233980..e5ce988db 100644 --- a/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx @@ -1,8 +1,33 @@ +import { setupWindowVirtualizerTestEnvironment } from "@client/test/virtualization"; import { createJob } from "@shared/testing/factories.js"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { JobListPanel } from "./JobListPanel"; +const createJobs = (count: number) => + Array.from({ length: count }, (_, index) => + createJob({ + id: `job-${index + 1}`, + title: `Job ${index + 1}`, + employer: `Employer ${index + 1}`, + }), + ); + +let virtualizationEnvironment: ReturnType< + typeof setupWindowVirtualizerTestEnvironment +> | null = null; + +afterEach(() => { + virtualizationEnvironment?.cleanup(); + virtualizationEnvironment = null; +}); + describe("JobListPanel", () => { it("shows a loading state when fetching jobs", () => { render( @@ -270,4 +295,40 @@ describe("JobListPanel", () => { "opacity-100", ); }); + + it("keeps large lists virtualized and scrolls offscreen rows into view", async () => { + virtualizationEnvironment = setupWindowVirtualizerTestEnvironment({ + viewportHeight: 240, + rowHeight: 72, + }); + const jobs = createJobs(40); + + render( + , + ); + + expect(screen.queryByTestId("select-job-35")).not.toBeInTheDocument(); + const renderedRows = screen.getAllByTestId(/select-job-/); + expect(renderedRows.length).toBeGreaterThan(0); + expect(renderedRows.length).toBeLessThan(jobs.length); + + act(() => { + window.scrollY = 2800; + window.dispatchEvent(new Event("scroll")); + }); + + await waitFor(() => { + expect(screen.getByTestId("select-job-35")).toBeInTheDocument(); + }); + }); }); diff --git a/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx index 7021a8629..2a65e1b5c 100644 --- a/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobListPanel.tsx @@ -1,6 +1,10 @@ import type { JobListItem } from "@shared/types.js"; import { Loader2 } from "lucide-react"; -import type React from "react"; +import { forwardRef, useImperativeHandle } from "react"; +import { + useVirtualizedList, + type VirtualListHandle, +} from "@/client/lib/virtual-list"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; @@ -33,146 +37,204 @@ interface JobListPanelProps { emptyStateMessage?: string; } -export const JobListPanel: React.FC = ({ - isLoading, - jobs, - activeJobs, - selectedJobId, - selectedJobIds, - activeTab, - onSelectJob, - onToggleSelectJob, - onToggleSelectAll, - primaryEmptyStateAction, - secondaryEmptyStateAction, - emptyStateMessage, -}) => ( -
- {isLoading && jobs.length === 0 ? ( -
- -
Loading jobs...
-
- ) : activeJobs.length === 0 ? ( -
-
No jobs found
-

- {emptyStateMessage ?? emptyStateCopy[activeTab]} -

- {(primaryEmptyStateAction || secondaryEmptyStateAction) && ( -
- {primaryEmptyStateAction && ( - - )} - {secondaryEmptyStateAction && ( - +const ROW_ESTIMATE = 84; + +export const JobListPanel = forwardRef( + ( + { + isLoading, + jobs, + activeJobs, + selectedJobId, + selectedJobIds, + activeTab, + onSelectJob, + onToggleSelectJob, + onToggleSelectAll, + primaryEmptyStateAction, + secondaryEmptyStateAction, + emptyStateMessage, + }, + ref, + ) => { + const virtualizer = useVirtualizedList({ + count: activeJobs.length, + mode: "window", + estimateSize: () => ROW_ESTIMATE, + overscan: 8, + getItemKey: (index) => activeJobs[index]?.id ?? index, + }); + + useImperativeHandle( + ref, + () => ({ + scrollToIndex: (index, options) => + virtualizer.scrollToIndex(index, options), + }), + [virtualizer], + ); + + if (isLoading && jobs.length === 0) { + return ( +
+
+ +
Loading jobs...
+
+
+ ); + } + + if (activeJobs.length === 0) { + return ( +
+
+
No jobs found
+

+ {emptyStateMessage ?? emptyStateCopy[activeTab]} +

+ {(primaryEmptyStateAction || secondaryEmptyStateAction) && ( +
+ {primaryEmptyStateAction && ( + + )} + {secondaryEmptyStateAction && ( + + )} +
)}
- )} -
- ) : ( -
-
- - - {selectedJobIds.size} selected -
- {activeJobs.map((job) => { - const isSelected = job.id === selectedJobId; - const isChecked = selectedJobIds.has(job.id); - const statusToken = statusTokens[job.status] ?? defaultStatusToken; - const statusDotClassName = job.appliedDuplicateMatch - ? appliedDuplicateIndicator.dot - : statusToken.dot; - const statusDotTitle = job.appliedDuplicateMatch - ? appliedDuplicateIndicator.label - : statusToken.label; - return ( -
+
+
+
+ ); + })} +
+
- )} -
+ ); + }, ); + +JobListPanel.displayName = "JobListPanel"; diff --git a/orchestrator/src/client/pages/orchestrator/useScrollToJobItem.ts b/orchestrator/src/client/pages/orchestrator/useScrollToJobItem.ts index 7e2e6bb7f..2368ea334 100644 --- a/orchestrator/src/client/pages/orchestrator/useScrollToJobItem.ts +++ b/orchestrator/src/client/pages/orchestrator/useScrollToJobItem.ts @@ -1,8 +1,6 @@ import type { JobListItem } from "@shared/types.js"; import { useCallback, useEffect, useState } from "react"; - -const escapeCssAttributeValue = (value: string) => - value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +import type { VirtualListHandle } from "@/client/lib/virtual-list"; type PendingScrollTarget = { jobId: string; @@ -15,6 +13,7 @@ type UseScrollToJobItemParams = { selectedJobId: string | null; isDesktop: boolean; onEnsureJobSelected: (jobId: string) => void; + listHandle: VirtualListHandle | null; }; export const useScrollToJobItem = ({ @@ -22,6 +21,7 @@ export const useScrollToJobItem = ({ selectedJobId, isDesktop, onEnsureJobSelected, + listHandle, }: UseScrollToJobItemParams) => { const [pendingTarget, setPendingTarget] = useState(null); @@ -56,19 +56,20 @@ export const useScrollToJobItem = ({ return; } - if (typeof document === "undefined") return; - const selector = `[data-job-id="${escapeCssAttributeValue(pendingTarget.jobId)}"]`; - const target = document.querySelector(selector); - if (!target) return; + const targetIndex = activeJobs.findIndex( + (job) => job.id === pendingTarget.jobId, + ); + if (targetIndex === -1 || !listHandle) return; - target.scrollIntoView({ + listHandle.scrollToIndex(targetIndex, { + align: "center", behavior: isDesktop ? "smooth" : "auto", - block: "center", }); setPendingTarget(null); }, [ activeJobs, isDesktop, + listHandle, onEnsureJobSelected, pendingTarget, selectedJobId, diff --git a/orchestrator/src/client/pages/orchestrator/virtualizedList.test-utils.ts b/orchestrator/src/client/pages/orchestrator/virtualizedList.test-utils.ts new file mode 100644 index 000000000..36035aa1b --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/virtualizedList.test-utils.ts @@ -0,0 +1,94 @@ +type VirtualElementSize = { + height?: number; + width?: number; +}; + +const DEFAULT_RECT = { + height: 0, + width: 0, +}; + +const readRectSize = (element: Element) => { + const htmlElement = element as HTMLElement; + const height = + Number(htmlElement.dataset.virtualHeight) || DEFAULT_RECT.height; + const width = Number(htmlElement.dataset.virtualWidth) || DEFAULT_RECT.width; + return { height, width }; +}; + +export const setVirtualElementSize = ( + element: HTMLElement, + size: VirtualElementSize, +) => { + if (size.height != null) { + element.dataset.virtualHeight = String(size.height); + } + + if (size.width != null) { + element.dataset.virtualWidth = String(size.width); + } +}; + +export const installVirtualizerSizeMock = () => { + const originalGetBoundingClientRect = + HTMLElement.prototype.getBoundingClientRect; + const originalResizeObserver = globalThis.ResizeObserver; + + Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", { + configurable: true, + value(this: HTMLElement) { + const { height, width } = readRectSize(this); + return { + bottom: height, + height, + left: 0, + right: width, + top: 0, + width, + x: 0, + y: 0, + toJSON() { + return this; + }, + } as DOMRect; + }, + }); + + class VirtualResizeObserver { + private readonly callback: ResizeObserverCallback; + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + + observe(target: Element) { + const contentRect = target.getBoundingClientRect(); + this.callback( + [ + { + target, + contentRect, + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + } as ResizeObserverEntry, + ], + this as unknown as ResizeObserver, + ); + } + + unobserve() {} + + disconnect() {} + } + + globalThis.ResizeObserver = VirtualResizeObserver as typeof ResizeObserver; + + return () => { + Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", { + configurable: true, + value: originalGetBoundingClientRect, + }); + globalThis.ResizeObserver = originalResizeObserver; + }; +}; diff --git a/orchestrator/src/client/pages/orchestrator/virtualizedList.ts b/orchestrator/src/client/pages/orchestrator/virtualizedList.ts new file mode 100644 index 000000000..038e83f33 --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/virtualizedList.ts @@ -0,0 +1,50 @@ +import { useCallback, useState } from "react"; +import { useVirtualizedList as useSharedVirtualizedList } from "@/client/lib/virtual-list"; + +export interface UseVirtualizedListOptions { + count: number; + estimateSize: (index: number) => number; + getItemKey: (index: number) => string | number | bigint; + overscan?: number; + enabled?: boolean; + initialRect?: { + height: number; + width: number; + }; +} + +export const useVirtualizedList = ({ + count, + enabled = true, + estimateSize, + getItemKey, + initialRect, + overscan = 8, +}: UseVirtualizedListOptions) => { + const [scrollElement, setScrollElement] = useState( + null, + ); + + const scrollElementRef = useCallback((element: HTMLDivElement | null) => { + setScrollElement(element); + }, []); + + const virtualizer = useSharedVirtualizedList({ + count, + mode: "element", + scrollElement, + estimateSize, + getItemKey, + enabled, + initialRect, + overscan, + }); + + return { + scrollElementRef, + virtualItems: virtualizer.getVirtualItems(), + totalSize: virtualizer.getTotalSize(), + measureElement: virtualizer.measureElement, + scrollToIndex: virtualizer.scrollToIndex, + }; +}; diff --git a/orchestrator/src/client/test/dom-measurement.ts b/orchestrator/src/client/test/dom-measurement.ts new file mode 100644 index 000000000..489e38038 --- /dev/null +++ b/orchestrator/src/client/test/dom-measurement.ts @@ -0,0 +1,185 @@ +type ElementMeasurement = { + width: number; + height: number; + top?: number; + left?: number; +}; + +type ResizeObserverCallbackEntry = { + callback: ResizeObserverCallback; + elements: Set; +}; + +const resizeObserverEntries = new Set(); +const elementMeasurements = new WeakMap(); + +class MockResizeObserver implements ResizeObserver { + readonly #entry: ResizeObserverCallbackEntry; + + constructor(callback: ResizeObserverCallback) { + this.#entry = { + callback, + elements: new Set(), + }; + resizeObserverEntries.add(this.#entry); + } + + disconnect() { + this.#entry.elements.clear(); + resizeObserverEntries.delete(this.#entry); + } + + observe(target: Element) { + this.#entry.elements.add(target); + } + + unobserve(target: Element) { + this.#entry.elements.delete(target); + } +} + +const defineDimensionProperty = ( + target: object, + key: string, + value: number, +) => { + Object.defineProperty(target, key, { + configurable: true, + get: () => value, + }); +}; + +const getMeasurement = (element: Element): ElementMeasurement => { + return ( + elementMeasurements.get(element) ?? { + width: 0, + height: 0, + top: 0, + left: 0, + } + ); +}; + +export const mockElementMeasurement = ( + element: Element, + measurement: ElementMeasurement, +) => { + const next = { + top: 0, + left: 0, + ...measurement, + }; + + elementMeasurements.set(element, next); + + if (element instanceof HTMLElement) { + defineDimensionProperty(element, "offsetWidth", next.width); + defineDimensionProperty(element, "offsetHeight", next.height); + defineDimensionProperty(element, "clientWidth", next.width); + defineDimensionProperty(element, "clientHeight", next.height); + defineDimensionProperty(element, "scrollWidth", next.width); + defineDimensionProperty(element, "scrollHeight", next.height); + } + + Object.defineProperty(element, "getBoundingClientRect", { + configurable: true, + value: () => + ({ + x: next.left, + y: next.top, + top: next.top, + left: next.left, + width: next.width, + height: next.height, + right: next.left + next.width, + bottom: next.top + next.height, + toJSON: () => undefined, + }) satisfies DOMRect, + }); +}; + +export const triggerElementResize = ( + element: Element, + measurement?: ElementMeasurement, +) => { + if (measurement) { + mockElementMeasurement(element, measurement); + } + + const current = getMeasurement(element); + const entry = { + target: element, + contentRect: { + x: current.left ?? 0, + y: current.top ?? 0, + top: current.top ?? 0, + left: current.left ?? 0, + width: current.width, + height: current.height, + right: (current.left ?? 0) + current.width, + bottom: (current.top ?? 0) + current.height, + toJSON: () => undefined, + } satisfies DOMRectReadOnly, + borderBoxSize: [ + { + inlineSize: current.width, + blockSize: current.height, + }, + ], + contentBoxSize: [], + devicePixelContentBoxSize: [], + } satisfies Partial; + + for (const resizeObserverEntry of resizeObserverEntries) { + if (!resizeObserverEntry.elements.has(element)) continue; + resizeObserverEntry.callback([entry as unknown as ResizeObserverEntry], { + disconnect() {}, + observe() {}, + unobserve() {}, + } as ResizeObserver); + } +}; + +export const mockWindowRect = ({ + width, + height, +}: { + width: number; + height: number; +}) => { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: width, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: height, + }); + window.dispatchEvent(new Event("resize")); +}; + +export const mockWindowScroll = ({ + x = 0, + y = 0, +}: { + x?: number; + y?: number; +}) => { + Object.defineProperty(window, "scrollX", { + configurable: true, + value: x, + }); + Object.defineProperty(window, "scrollY", { + configurable: true, + value: y, + }); + window.dispatchEvent(new Event("scroll")); +}; + +export const installDomMeasurementMocks = () => { + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + writable: true, + value: MockResizeObserver, + }); +}; diff --git a/orchestrator/src/client/test/virtualization.ts b/orchestrator/src/client/test/virtualization.ts new file mode 100644 index 000000000..b19db21db --- /dev/null +++ b/orchestrator/src/client/test/virtualization.ts @@ -0,0 +1,57 @@ +import { vi } from "vitest"; + +type WindowVirtualizerTestEnvironmentOptions = { + viewportHeight?: number; + rowHeight?: number; +}; + +export const setupWindowVirtualizerTestEnvironment = ( + options: WindowVirtualizerTestEnvironmentOptions = {}, +) => { + const { viewportHeight = 240, rowHeight = 84 } = options; + const innerHeightDescriptor = Object.getOwnPropertyDescriptor( + window, + "innerHeight", + ); + const scrollYDescriptor = Object.getOwnPropertyDescriptor(window, "scrollY"); + const scrollY = window.scrollY ?? 0; + + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: viewportHeight, + }); + Object.defineProperty(window, "scrollY", { + configurable: true, + value: scrollY, + writable: true, + }); + + const offsetHeightSpy = vi + .spyOn(HTMLElement.prototype, "offsetHeight", "get") + .mockImplementation(function (this: HTMLElement) { + if (this.dataset.virtualRow === "true") { + return rowHeight; + } + return 0; + }); + + const cleanup = () => { + offsetHeightSpy.mockRestore(); + + if (innerHeightDescriptor) { + Object.defineProperty(window, "innerHeight", innerHeightDescriptor); + } else { + Reflect.deleteProperty(window, "innerHeight"); + } + + if (scrollYDescriptor) { + Object.defineProperty(window, "scrollY", scrollYDescriptor); + } else { + Reflect.deleteProperty(window, "scrollY"); + } + }; + + return { + cleanup, + }; +}; diff --git a/orchestrator/src/components/ui/searchable-dropdown.test.tsx b/orchestrator/src/components/ui/searchable-dropdown.test.tsx new file mode 100644 index 000000000..db77d606a --- /dev/null +++ b/orchestrator/src/components/ui/searchable-dropdown.test.tsx @@ -0,0 +1,165 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ButtonHTMLAttributes } from "react"; +import * as React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SearchableDropdown } from "./searchable-dropdown"; + +vi.mock("@/components/ui/button", () => ({ + Button: React.forwardRef< + HTMLButtonElement, + ButtonHTMLAttributes + >(({ className, children, ...props }, ref) => ( + + )), +})); +vi.mock("@/components/ui/virtualized-listbox", () => { + const React = require("react") as typeof import("react"); + const windowSize = 8; + + return { + useVirtualizedListbox: ({ count }: { count: number }) => { + const [startIndex, setStartIndex] = React.useState(0); + const visibleCount = Math.min(windowSize, count); + const maxStart = Math.max(0, count - visibleCount); + const virtualItems = React.useMemo( + () => + Array.from({ length: visibleCount }, (_, offset) => { + const index = startIndex + offset; + return { + key: index, + index, + lane: 0, + start: offset * 40, + end: (offset + 1) * 40, + size: 40, + }; + }), + [startIndex, visibleCount], + ); + + return { + getTotalSize: () => count * 40, + getVirtualItems: () => virtualItems, + measureElement: vi.fn(), + scrollToIndex: (index: number) => { + const nextStart = Math.min( + Math.max(0, index - Math.floor(windowSize / 2)), + maxStart, + ); + setStartIndex(nextStart); + }, + }; + }, + }; +}); +vi.mock("@/lib/utils", () => ({ + cn: (...inputs: Array) => + inputs.filter(Boolean).join(" "), +})); + +const buildOptions = (count: number) => + Array.from({ length: count }, (_, index) => ({ + value: `option-${index}`, + label: `Option ${index}`, + })); + +describe("SearchableDropdown", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("only mounts the visible window for large option sets", async () => { + render( + , + ); + + fireEvent.click(screen.getByRole("combobox", { name: "Choose option" })); + + await screen.findByRole("listbox"); + + await waitFor(() => { + expect( + screen.getByRole("option", { name: "Option 0" }), + ).toBeInTheDocument(); + }); + + expect( + screen.queryByRole("option", { name: "Option 90" }), + ).not.toBeInTheDocument(); + }); + + it("selects an offscreen result after scrolling to it", async () => { + const onValueChange = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("combobox", { name: "Choose option" })); + + await screen.findByRole("listbox"); + + const input = screen.getByPlaceholderText("Search..."); + fireEvent.keyDown(input, { key: "End" }); + + await waitFor(() => { + expect( + screen.getByRole("option", { name: "Option 119" }), + ).toBeInTheDocument(); + }); + + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onValueChange).toHaveBeenCalledWith("option-119"); + }); + + it("preserves custom value entry and disabled option handling", () => { + const onValueChange = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("combobox", { name: "Choose option" })); + fireEvent.change(screen.getByPlaceholderText("Search..."), { + target: { value: "Custom company" }, + }); + + const listbox = screen.getByRole("listbox"); + expect(listbox).toBeInTheDocument(); + + fireEvent.click( + screen.getByRole("option", { name: 'Use "Custom company"' }), + ); + + expect(onValueChange).toHaveBeenCalledWith("Custom company"); + + fireEvent.click(screen.getByRole("combobox", { name: "Choose option" })); + fireEvent.click(screen.getByRole("option", { name: "Disabled option" })); + + expect(onValueChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/orchestrator/src/components/ui/searchable-dropdown.tsx b/orchestrator/src/components/ui/searchable-dropdown.tsx index 0dabbe3a6..9911d1191 100644 --- a/orchestrator/src/components/ui/searchable-dropdown.tsx +++ b/orchestrator/src/components/ui/searchable-dropdown.tsx @@ -1,19 +1,12 @@ -import { Check, ChevronsUpDown } from "lucide-react"; +import { Check, ChevronsUpDown, Search } from "lucide-react"; import * as React from "react"; import { Button } from "@/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { useVirtualizedListbox } from "@/components/ui/virtualized-listbox"; import { cn } from "@/lib/utils"; export interface SearchableDropdownOption { @@ -38,6 +31,25 @@ interface SearchableDropdownProps { listClassName?: string; } +type SearchableDropdownRow = + | { + id: string; + type: "custom"; + label: string; + value: string; + } + | { + id: string; + type: "option"; + disabled: boolean; + option: SearchableDropdownOption; + searchableValue: string; + }; + +function getSearchableValue(option: SearchableDropdownOption): string { + return [option.label, option.searchText ?? "", option.value].join(" ").trim(); +} + export const SearchableDropdown: React.FC = ({ inputId, value, @@ -54,8 +66,13 @@ export const SearchableDropdown: React.FC = ({ }) => { const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(""); + const listRef = React.useRef(null); + const inputRef = React.useRef(null); + const deferredQuery = React.useDeferredValue(query); + const listId = React.useId(); const selectedOption = options.find((option) => option.value === value); const trimmedQuery = query.trim(); + const deferredTrimmedQuery = deferredQuery.trim(); const hasCustomValue = trimmedQuery.length > 0 && !options.some( @@ -63,6 +80,177 @@ export const SearchableDropdown: React.FC = ({ option.value === trimmedQuery || option.label.trim() === trimmedQuery, ); const triggerLabel = selectedOption?.label ?? (value || placeholder); + const filteredOptions = React.useMemo(() => { + if (!deferredTrimmedQuery) return options; + + const normalizedQuery = deferredTrimmedQuery.toLowerCase(); + return options.filter((option) => + getSearchableValue(option).toLowerCase().includes(normalizedQuery), + ); + }, [deferredTrimmedQuery, options]); + + const rows = React.useMemo(() => { + const nextRows: SearchableDropdownRow[] = []; + + if (hasCustomValue) { + nextRows.push({ + id: `custom:${listId}:${trimmedQuery}`, + type: "custom", + label: `Use "${trimmedQuery}"`, + value: trimmedQuery, + }); + } + + for (const option of filteredOptions) { + nextRows.push({ + id: option.value, + type: "option", + disabled: Boolean(option.disabled), + option, + searchableValue: getSearchableValue(option), + }); + } + + return nextRows; + }, [filteredOptions, hasCustomValue, listId, trimmedQuery]); + + const selectedRowId = React.useMemo( + () => + rows.find( + (row) => + row.type === "option" && row.option.value === value && !row.disabled, + )?.id ?? null, + [rows, value], + ); + const rowIds = React.useMemo(() => rows.map((row) => row.id), [rows]); + const selectableRowIndexes = React.useMemo(() => { + const indexes: number[] = []; + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]; + if (row.type === "custom" || !row.disabled) { + indexes.push(index); + } + } + return indexes; + }, [rows]); + + const [activeRowId, setActiveRowId] = React.useState(null); + const activeRowIndex = activeRowId ? rowIds.indexOf(activeRowId) : -1; + const activeRow = activeRowIndex >= 0 ? rows[activeRowIndex] : null; + + const { scrollToIndex, measureElement, getVirtualItems, getTotalSize } = + useVirtualizedListbox({ + count: rows.length, + estimateSize: () => 40, + getItemKey: (index: number) => rows[index]?.id ?? index, + initialRect: { width: 320, height: 256 }, + overscan: 8, + scrollElementRef: listRef, + }); + + React.useEffect(() => { + if (!open) return; + + setActiveRowId((current) => { + if (current) { + const currentRow = rows.find((row) => row.id === current); + if ( + currentRow && + (currentRow.type === "custom" || !currentRow.disabled) + ) { + return current; + } + } + + if (!rows.length) return null; + if (!trimmedQuery && selectedRowId) return selectedRowId; + return rows[selectableRowIndexes[0]]?.id ?? null; + }); + }, [open, rows, selectedRowId, selectableRowIndexes, trimmedQuery]); + + React.useEffect(() => { + if (!open) return; + if (activeRowIndex < 0) return; + scrollToIndex(activeRowIndex, { align: "auto" }); + }, [activeRowIndex, open, scrollToIndex]); + + React.useEffect(() => { + if (!open) return; + inputRef.current?.focus(); + }, [open]); + + const selectRow = React.useCallback( + (row: SearchableDropdownRow) => { + if (row.type === "option") { + if (row.disabled) return; + onValueChange(row.option.value); + } else { + onValueChange(row.value); + } + setOpen(false); + setQuery(""); + setActiveRowId(null); + }, + [onValueChange], + ); + + const moveActive = React.useCallback( + (direction: 1 | -1) => { + if (!selectableRowIndexes.length) return; + + const currentSelectableIndex = + selectableRowIndexes.indexOf(activeRowIndex); + + let nextSelectableIndex = currentSelectableIndex; + if (nextSelectableIndex < 0) { + nextSelectableIndex = direction === 1 ? -1 : 0; + } + + nextSelectableIndex = + (nextSelectableIndex + direction + selectableRowIndexes.length) % + selectableRowIndexes.length; + + setActiveRowId( + rows[selectableRowIndexes[nextSelectableIndex]]?.id ?? null, + ); + }, + [activeRowIndex, rows, selectableRowIndexes], + ); + + const handleInputKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + moveActive(1); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + moveActive(-1); + return; + } + + if (event.key === "Home") { + event.preventDefault(); + setActiveRowId(rows[selectableRowIndexes[0]]?.id ?? null); + return; + } + + if (event.key === "End") { + event.preventDefault(); + setActiveRowId( + rows[selectableRowIndexes[selectableRowIndexes.length - 1]]?.id ?? null, + ); + return; + } + + if (event.key === "Enter" && activeRow) { + event.preventDefault(); + selectRow(activeRow); + } + }; + + const virtualItems = getVirtualItems(); return ( = ({ setOpen(nextOpen); if (!nextOpen) { setQuery(""); + setActiveRowId(null); } }} > @@ -104,64 +293,89 @@ export const SearchableDropdown: React.FC = ({ align="start" className={cn("w-[320px] p-0", contentClassName)} > - - + + setQuery(event.target.value)} + onKeyDown={handleInputKeyDown} + className="flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50" /> - event.stopPropagation()} - > - {emptyText} - - {hasCustomValue ? ( - { - onValueChange(trimmedQuery); - setOpen(false); - setQuery(""); - }} - > - {`Use "${trimmedQuery}"`} - - ) : null} - {options.map((option) => { - const selected = value === option.value; - const searchableValue = [ - option.label, - option.searchText ?? "", - option.value, - ] - .join(" ") - .trim(); +
+
event.stopPropagation()} + > + {rows.length === 0 ? ( +
{emptyText}
+ ) : ( +
+ {virtualItems.map((virtualItem) => { + const row = rows[virtualItem.index]; + if (!row) return null; + + const selected = + row.type === "option" && value === row.option.value; + const isActive = row.id === activeRowId; return ( - { - onValueChange(option.value); - setOpen(false); - setQuery(""); + ); })} - - - +
+ )} +
); diff --git a/orchestrator/src/components/ui/virtualized-listbox.tsx b/orchestrator/src/components/ui/virtualized-listbox.tsx new file mode 100644 index 000000000..78dfda99f --- /dev/null +++ b/orchestrator/src/components/ui/virtualized-listbox.tsx @@ -0,0 +1,74 @@ +"use client"; + +import type { Key, RefObject } from "react"; +import { useLayoutEffect, useState } from "react"; +import { + useVirtualizedList, + type VirtualListScrollAlignment, + type VirtualListScrollBehavior, +} from "@/client/lib/virtual-list"; + +export type VirtualizedListHandle = { + scrollToIndex: ( + index: number, + options?: { + align?: VirtualListScrollAlignment; + behavior?: VirtualListScrollBehavior; + }, + ) => void; +}; + +export type UseVirtualizedListboxOptions = { + count: number; + estimateSize?: (index: number) => number; + enabled?: boolean; + getItemKey?: (index: number) => Key; + initialRect?: { + height: number; + width: number; + }; + overscan?: number; + scrollElementRef?: RefObject; +}; + +export function useVirtualizedListbox({ + count, + estimateSize = () => 40, + enabled = true, + getItemKey, + initialRect, + overscan = 8, + scrollElementRef, +}: UseVirtualizedListboxOptions) { + const [scrollElement, setScrollElement] = useState(null); + + useLayoutEffect(() => { + const nextElement = scrollElementRef?.current ?? null; + setScrollElement((current) => + current === nextElement ? current : nextElement, + ); + }); + + const virtualizer = useVirtualizedList({ + count, + mode: "element", + scrollElement, + estimateSize, + enabled, + getItemKey, + initialRect, + overscan, + }); + + return { + getTotalSize: () => virtualizer.getTotalSize(), + getVirtualItems: () => virtualizer.getVirtualItems(), + measureElement: (node: Element | null) => { + virtualizer.measureElement(node as HTMLDivElement | null); + }, + scrollToIndex: ( + index: number, + options?: Parameters[1], + ) => virtualizer.scrollToIndex(index, options), + }; +} diff --git a/orchestrator/src/setupTests.ts b/orchestrator/src/setupTests.ts index 8469f7ace..878a632d4 100644 --- a/orchestrator/src/setupTests.ts +++ b/orchestrator/src/setupTests.ts @@ -3,16 +3,9 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import "@testing-library/jest-dom"; +import { installDomMeasurementMocks } from "@/client/test/dom-measurement"; -if (typeof globalThis.ResizeObserver === "undefined") { - class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} - } - - globalThis.ResizeObserver = ResizeObserver; -} +installDomMeasurementMocks(); const hasStorageShape = (value: unknown): value is Storage => { if (!value || typeof value !== "object") return false; diff --git a/package-lock.json b/package-lock.json index 76e63e95c..e99a03db8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8998,6 +8998,33 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", + "integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.23" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", + "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tiptap/core": { "version": "3.22.2", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.2.tgz", @@ -26161,6 +26188,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.21", + "@tanstack/react-virtual": "^3.13.23", "@tiptap/extension-link": "^3.22.2", "@tiptap/react": "^3.22.2", "@tiptap/starter-kit": "^3.22.2", From 6ead8e7f6c86ec19f1f2390b7415b46cab57c619 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Mon, 13 Apr 2026 21:46:57 +0100 Subject: [PATCH 2/5] Fix dropdown accessibility and virtual list scrolling --- .../src/client/pages/OrchestratorPage.tsx | 2 +- .../pages/orchestrator/JobCommandBar.tsx | 19 ++++---- .../orchestrator/useScrollToJobItem.test.ts | 46 +++++++++++++++++++ .../pages/orchestrator/useScrollToJobItem.ts | 8 ++-- .../ui/searchable-dropdown.test.tsx | 45 ++++++++++++++---- .../src/components/ui/searchable-dropdown.tsx | 40 ++++++++++++---- .../src/components/ui/virtualized-listbox.tsx | 28 +++++------ 7 files changed, 139 insertions(+), 49 deletions(-) create mode 100644 orchestrator/src/client/pages/orchestrator/useScrollToJobItem.test.ts diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index 1bf1d5335..ba67a49a8 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -221,7 +221,7 @@ export const OrchestratorPage: React.FC = () => { selectedJobId, isDesktop, onEnsureJobSelected: (id) => navigateWithContext(activeTab, id, true), - listHandle: jobListHandleRef.current, + listHandleRef: jobListHandleRef, }); const isAnyModalOpen = diff --git a/orchestrator/src/client/pages/orchestrator/JobCommandBar.tsx b/orchestrator/src/client/pages/orchestrator/JobCommandBar.tsx index 5e4db3dcd..5645f3b90 100644 --- a/orchestrator/src/client/pages/orchestrator/JobCommandBar.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobCommandBar.tsx @@ -260,20 +260,19 @@ export const JobCommandBar: React.FC = ({ () => buildFallbackVirtualItems(rows, scrollTop, viewportHeight), [rows, scrollTop, viewportHeight], ); + const estimatedTotalSize = useMemo( + () => + rows.reduce( + (currentTotal, row) => currentTotal + getEstimatedRowHeight(row), + 0, + ), + [rows], + ); const renderedVirtualItems = virtualItems.length > 0 ? virtualItems : estimatedLayout; const renderedTotalSize = - virtualItems.length > 0 - ? totalSize - : estimatedLayout.reduce( - (currentMax, item) => - Math.max( - currentMax, - item.start + getEstimatedRowHeight(rows[item.index] ?? item.row), - ), - 0, - ); + virtualItems.length > 0 ? totalSize : estimatedTotalSize; const setResultsScrollElement = useCallback( (element: HTMLDivElement | null) => { diff --git a/orchestrator/src/client/pages/orchestrator/useScrollToJobItem.test.ts b/orchestrator/src/client/pages/orchestrator/useScrollToJobItem.test.ts new file mode 100644 index 000000000..fd9162db1 --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/useScrollToJobItem.test.ts @@ -0,0 +1,46 @@ +import { createJob } from "@shared/testing/factories.js"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { MutableRefObject } from "react"; +import { describe, expect, it, vi } from "vitest"; +import type { VirtualListHandle } from "@/client/lib/virtual-list"; +import { useScrollToJobItem } from "./useScrollToJobItem"; + +describe("useScrollToJobItem", () => { + it("scrolls once the list handle is available", async () => { + const activeJobs = [ + createJob({ id: "job-1", status: "ready" }), + createJob({ id: "job-2", status: "ready" }), + createJob({ id: "job-3", status: "ready" }), + ]; + const scrollToIndex = vi.fn(); + const listHandleRef = { + current: null, + } as MutableRefObject; + const onEnsureJobSelected = vi.fn(); + + const { result } = renderHook(() => + useScrollToJobItem({ + activeJobs, + selectedJobId: "job-2", + isDesktop: true, + onEnsureJobSelected, + listHandleRef, + }), + ); + + await act(async () => { + result.current.requestScrollToJob("job-2"); + listHandleRef.current = { + scrollToIndex, + }; + }); + + await waitFor(() => { + expect(scrollToIndex).toHaveBeenCalledWith(1, { + align: "center", + behavior: "smooth", + }); + }); + expect(onEnsureJobSelected).not.toHaveBeenCalled(); + }); +}); diff --git a/orchestrator/src/client/pages/orchestrator/useScrollToJobItem.ts b/orchestrator/src/client/pages/orchestrator/useScrollToJobItem.ts index 2368ea334..5a11a58b3 100644 --- a/orchestrator/src/client/pages/orchestrator/useScrollToJobItem.ts +++ b/orchestrator/src/client/pages/orchestrator/useScrollToJobItem.ts @@ -1,4 +1,5 @@ import type { JobListItem } from "@shared/types.js"; +import type { RefObject } from "react"; import { useCallback, useEffect, useState } from "react"; import type { VirtualListHandle } from "@/client/lib/virtual-list"; @@ -13,7 +14,7 @@ type UseScrollToJobItemParams = { selectedJobId: string | null; isDesktop: boolean; onEnsureJobSelected: (jobId: string) => void; - listHandle: VirtualListHandle | null; + listHandleRef: RefObject; }; export const useScrollToJobItem = ({ @@ -21,7 +22,7 @@ export const useScrollToJobItem = ({ selectedJobId, isDesktop, onEnsureJobSelected, - listHandle, + listHandleRef, }: UseScrollToJobItemParams) => { const [pendingTarget, setPendingTarget] = useState(null); @@ -59,6 +60,7 @@ export const useScrollToJobItem = ({ const targetIndex = activeJobs.findIndex( (job) => job.id === pendingTarget.jobId, ); + const listHandle = listHandleRef.current; if (targetIndex === -1 || !listHandle) return; listHandle.scrollToIndex(targetIndex, { @@ -69,10 +71,10 @@ export const useScrollToJobItem = ({ }, [ activeJobs, isDesktop, - listHandle, onEnsureJobSelected, pendingTarget, selectedJobId, + listHandleRef, ]); return { requestScrollToJob }; diff --git a/orchestrator/src/components/ui/searchable-dropdown.test.tsx b/orchestrator/src/components/ui/searchable-dropdown.test.tsx index db77d606a..361b8905d 100644 --- a/orchestrator/src/components/ui/searchable-dropdown.test.tsx +++ b/orchestrator/src/components/ui/searchable-dropdown.test.tsx @@ -81,19 +81,19 @@ describe("SearchableDropdown", () => { />, ); - fireEvent.click(screen.getByRole("combobox", { name: "Choose option" })); + fireEvent.click(screen.getByRole("button", { name: "Choose option" })); await screen.findByRole("listbox"); + expect(screen.getAllByRole("combobox")).toHaveLength(1); await waitFor(() => { - expect( - screen.getByRole("option", { name: "Option 0" }), - ).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Option 0" })).toBeVisible(); }); - expect( - screen.queryByRole("option", { name: "Option 90" }), - ).not.toBeInTheDocument(); + const firstOption = screen.getByRole("option", { name: "Option 0" }); + expect(firstOption.id).toContain("-option-"); + expect(firstOption.id).not.toContain(" "); + expect(screen.queryByRole("option", { name: "Option 90" })).toBeNull(); }); it("selects an offscreen result after scrolling to it", async () => { @@ -109,7 +109,7 @@ describe("SearchableDropdown", () => { />, ); - fireEvent.click(screen.getByRole("combobox", { name: "Choose option" })); + fireEvent.click(screen.getByRole("button", { name: "Choose option" })); await screen.findByRole("listbox"); @@ -143,7 +143,7 @@ describe("SearchableDropdown", () => { />, ); - fireEvent.click(screen.getByRole("combobox", { name: "Choose option" })); + fireEvent.click(screen.getByRole("button", { name: "Choose option" })); fireEvent.change(screen.getByPlaceholderText("Search..."), { target: { value: "Custom company" }, }); @@ -157,9 +157,34 @@ describe("SearchableDropdown", () => { expect(onValueChange).toHaveBeenCalledWith("Custom company"); - fireEvent.click(screen.getByRole("combobox", { name: "Choose option" })); + fireEvent.click(screen.getByRole("button", { name: "Choose option" })); fireEvent.click(screen.getByRole("option", { name: "Disabled option" })); expect(onValueChange).toHaveBeenCalledTimes(1); }); + + it("keeps aria-selected tied to the selected value instead of focus", async () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Choose option" })); + + await screen.findByRole("listbox"); + + const input = screen.getByPlaceholderText("Search..."); + fireEvent.keyDown(input, { key: "ArrowDown" }); + + const selectedOption = screen.getByRole("option", { name: "Option 1" }); + const activeOption = screen.getByRole("option", { name: "Option 2" }); + + expect(selectedOption).toHaveAttribute("aria-selected", "true"); + expect(activeOption).toHaveAttribute("aria-selected", "false"); + }); }); diff --git a/orchestrator/src/components/ui/searchable-dropdown.tsx b/orchestrator/src/components/ui/searchable-dropdown.tsx index 9911d1191..01885727d 100644 --- a/orchestrator/src/components/ui/searchable-dropdown.tsx +++ b/orchestrator/src/components/ui/searchable-dropdown.tsx @@ -50,6 +50,23 @@ function getSearchableValue(option: SearchableDropdownOption): string { return [option.label, option.searchText ?? "", option.value].join(" ").trim(); } +function toDomIdSegment(value: string): string { + return Array.from(value) + .map((character) => { + if (/^[A-Za-z0-9_-]$/.test(character)) return character; + return `_${character.codePointAt(0)?.toString(36) ?? "0"}_`; + }) + .join(""); +} + +function createRowDomId( + listId: string, + type: SearchableDropdownRow["type"], + value: string, +): string { + return `${toDomIdSegment(listId)}-${type}-${toDomIdSegment(value)}`; +} + export const SearchableDropdown: React.FC = ({ inputId, value, @@ -66,7 +83,9 @@ export const SearchableDropdown: React.FC = ({ }) => { const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(""); - const listRef = React.useRef(null); + const [listElement, setListElement] = React.useState( + null, + ); const inputRef = React.useRef(null); const deferredQuery = React.useDeferredValue(query); const listId = React.useId(); @@ -94,7 +113,7 @@ export const SearchableDropdown: React.FC = ({ if (hasCustomValue) { nextRows.push({ - id: `custom:${listId}:${trimmedQuery}`, + id: createRowDomId(listId, "custom", trimmedQuery), type: "custom", label: `Use "${trimmedQuery}"`, value: trimmedQuery, @@ -103,7 +122,7 @@ export const SearchableDropdown: React.FC = ({ for (const option of filteredOptions) { nextRows.push({ - id: option.value, + id: createRowDomId(listId, "option", option.value), type: "option", disabled: Boolean(option.disabled), option, @@ -138,14 +157,18 @@ export const SearchableDropdown: React.FC = ({ const activeRowIndex = activeRowId ? rowIds.indexOf(activeRowId) : -1; const activeRow = activeRowIndex >= 0 ? rows[activeRowIndex] : null; + const setListRef = React.useCallback((element: HTMLDivElement | null) => { + setListElement(element); + }, []); + const { scrollToIndex, measureElement, getVirtualItems, getTotalSize } = - useVirtualizedListbox({ + useVirtualizedListbox({ count: rows.length, estimateSize: () => 40, getItemKey: (index: number) => rows[index]?.id ?? index, initialRect: { width: 320, height: 256 }, overscan: 8, - scrollElementRef: listRef, + scrollElement: listElement, }); React.useEffect(() => { @@ -279,8 +302,9 @@ export const SearchableDropdown: React.FC = ({
= ({ data-index={virtualItem.index} role="option" tabIndex={-1} - aria-selected={isActive} + aria-selected={selected} aria-disabled={row.type === "option" ? row.disabled : false} id={row.id} className={cn( diff --git a/orchestrator/src/components/ui/virtualized-listbox.tsx b/orchestrator/src/components/ui/virtualized-listbox.tsx index 78dfda99f..b343b444b 100644 --- a/orchestrator/src/components/ui/virtualized-listbox.tsx +++ b/orchestrator/src/components/ui/virtualized-listbox.tsx @@ -1,7 +1,6 @@ "use client"; -import type { Key, RefObject } from "react"; -import { useLayoutEffect, useState } from "react"; +import type { Key } from "react"; import { useVirtualizedList, type VirtualListScrollAlignment, @@ -28,28 +27,21 @@ export type UseVirtualizedListboxOptions = { width: number; }; overscan?: number; - scrollElementRef?: RefObject; + scrollElement?: HTMLElement | null; }; -export function useVirtualizedListbox({ +export function useVirtualizedListbox< + TItemElement extends HTMLElement = HTMLElement, +>({ count, estimateSize = () => 40, enabled = true, getItemKey, initialRect, overscan = 8, - scrollElementRef, + scrollElement = null, }: UseVirtualizedListboxOptions) { - const [scrollElement, setScrollElement] = useState(null); - - useLayoutEffect(() => { - const nextElement = scrollElementRef?.current ?? null; - setScrollElement((current) => - current === nextElement ? current : nextElement, - ); - }); - - const virtualizer = useVirtualizedList({ + const virtualizer = useVirtualizedList({ count, mode: "element", scrollElement, @@ -63,8 +55,10 @@ export function useVirtualizedListbox({ return { getTotalSize: () => virtualizer.getTotalSize(), getVirtualItems: () => virtualizer.getVirtualItems(), - measureElement: (node: Element | null) => { - virtualizer.measureElement(node as HTMLDivElement | null); + measureElement: ( + node: Parameters[0], + ) => { + virtualizer.measureElement(node); }, scrollToIndex: ( index: number, From 9d56248a8fc4066f975b9944da1ccf96c50f2ea4 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Mon, 13 Apr 2026 22:18:37 +0100 Subject: [PATCH 3/5] Fix PR test harness and extractor test config --- apify-tsconfig-0.1.1.tgz | Bin 0 -> 4962 bytes extractors/ukvisajobs/tsconfig.json | 7 ++- .../orchestrator/AutomaticRunTab.test.tsx | 6 +- .../src/server/api/routes/test-utils.ts | 53 ++++++++++++++++-- 4 files changed, 55 insertions(+), 11 deletions(-) create mode 100644 apify-tsconfig-0.1.1.tgz diff --git a/apify-tsconfig-0.1.1.tgz b/apify-tsconfig-0.1.1.tgz new file mode 100644 index 0000000000000000000000000000000000000000..82286eb275195a7945663a8725a620c1d5dbc293 GIT binary patch literal 4962 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 literal 0 HcmV?d00001 diff --git a/extractors/ukvisajobs/tsconfig.json b/extractors/ukvisajobs/tsconfig.json index 743951d39..8143c45ed 100644 --- a/extractors/ukvisajobs/tsconfig.json +++ b/extractors/ukvisajobs/tsconfig.json @@ -1,17 +1,18 @@ { - "extends": "@apify/tsconfig", "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext", "target": "ES2022", "outDir": "dist", + "strict": true, "noUnusedLocals": false, - "lib": ["DOM"], + "lib": ["ES2022", "DOM"], + "types": ["node"], "skipLibCheck": true, "baseUrl": ".", "paths": { "@shared/*": ["../../shared/src/*"] } }, - "include": ["./src/**/*"] + "include": ["./src/**/*", "./tests/**/*", "./manifest.ts"] } diff --git a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx index e2b638992..e41fe0ae6 100644 --- a/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/AutomaticRunTab.test.tsx @@ -54,7 +54,7 @@ describe("AutomaticRunTab", () => { ); expect( - screen.getByRole("combobox", { name: "United States" }), + screen.getByRole("button", { name: "United States" }), ).toBeInTheDocument(); }); @@ -85,7 +85,7 @@ describe("AutomaticRunTab", () => { ); expect( - screen.getByRole("combobox", { name: "United States" }), + screen.getByRole("button", { name: "United States" }), ).toBeInTheDocument(); }); @@ -116,7 +116,7 @@ describe("AutomaticRunTab", () => { ); expect( - screen.getByRole("combobox", { name: "United States" }), + screen.getByRole("button", { name: "United States" }), ).toBeInTheDocument(); }); diff --git a/orchestrator/src/server/api/routes/test-utils.ts b/orchestrator/src/server/api/routes/test-utils.ts index 5237f83be..e242358aa 100644 --- a/orchestrator/src/server/api/routes/test-utils.ts +++ b/orchestrator/src/server/api/routes/test-utils.ts @@ -2,6 +2,12 @@ import { mkdtemp, rm } from "node:fs/promises"; import type { Server } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { ExtractorRegistry } from "@server/extractors/registry"; +import { + type ExtractorSourceId, + PIPELINE_EXTRACTOR_SOURCE_IDS, +} from "@shared/extractors"; +import type { ExtractorManifest } from "@shared/types"; import { vi } from "vitest"; vi.mock("@server/pipeline/index", () => { @@ -102,9 +108,35 @@ const isolatedEnvKeys = [ "ADZUNA_APP_KEY", ] as const; -async function restoreNativeFetch(): Promise { - const { fetch: undiciFetch } = await import("undici"); - global.fetch = undiciFetch as typeof global.fetch; +const nativeFetch = globalThis.fetch; + +function createTestExtractorRegistry(): ExtractorRegistry { + const manifests = new Map(); + const manifestBySource = new Map(); + + for (const source of PIPELINE_EXTRACTOR_SOURCE_IDS) { + const manifest: ExtractorManifest = { + id: `test-${source}`, + displayName: `Test ${source}`, + providesSources: [source], + run: vi.fn().mockResolvedValue({ + success: true, + jobs: [], + }), + }; + manifests.set(manifest.id, manifest); + manifestBySource.set(source, manifest); + } + + return { + manifests, + manifestBySource, + availableSources: [...PIPELINE_EXTRACTOR_SOURCE_IDS], + }; +} + +function restoreNativeFetch(): void { + globalThis.fetch = nativeFetch; } export async function startServer(options?: { @@ -116,7 +148,7 @@ export async function startServer(options?: { tempDir: string; }> { vi.unstubAllGlobals(); - await restoreNativeFetch(); + restoreNativeFetch(); vi.resetModules(); const tempDir = await mkdtemp(join(tmpdir(), "job-ops-api-test-")); const envOverrides = options?.env ?? {}; @@ -137,6 +169,17 @@ export async function startServer(options?: { const { applyStoredEnvOverrides } = await import( "@server/services/envSettings" ); + const registryModule = await import("@server/extractors/registry"); + const defaultRegistry = createTestExtractorRegistry(); + if (vi.isMockFunction(registryModule.getExtractorRegistry)) { + vi.mocked(registryModule.getExtractorRegistry).mockResolvedValue( + defaultRegistry, + ); + } else { + vi + .spyOn(registryModule, "getExtractorRegistry") + .mockResolvedValue(defaultRegistry); + } const { createApp } = await import("../../app"); const { closeDb } = await import("@server/db/index"); const { getPipelineStatus } = await import("@server/pipeline/index"); @@ -178,6 +221,6 @@ export async function stopServer(args: { } process.env = { ...originalEnv }; vi.unstubAllGlobals(); - await restoreNativeFetch(); + restoreNativeFetch(); vi.clearAllMocks(); } From 35d3f01af20167d2bba51c9f459cba5c0994139c Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Mon, 13 Apr 2026 22:32:27 +0100 Subject: [PATCH 4/5] Format extractor registry mock setup --- orchestrator/src/server/api/routes/test-utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/orchestrator/src/server/api/routes/test-utils.ts b/orchestrator/src/server/api/routes/test-utils.ts index e242358aa..5ad82e116 100644 --- a/orchestrator/src/server/api/routes/test-utils.ts +++ b/orchestrator/src/server/api/routes/test-utils.ts @@ -176,9 +176,9 @@ export async function startServer(options?: { defaultRegistry, ); } else { - vi - .spyOn(registryModule, "getExtractorRegistry") - .mockResolvedValue(defaultRegistry); + vi.spyOn(registryModule, "getExtractorRegistry").mockResolvedValue( + defaultRegistry, + ); } const { createApp } = await import("../../app"); const { closeDb } = await import("@server/db/index"); From 87ddd061ac334bcdf040cdf27e38a9160e65b2f2 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Mon, 13 Apr 2026 22:49:40 +0100 Subject: [PATCH 5/5] revert tsconfig --- extractors/ukvisajobs/tsconfig.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/extractors/ukvisajobs/tsconfig.json b/extractors/ukvisajobs/tsconfig.json index 8143c45ed..743951d39 100644 --- a/extractors/ukvisajobs/tsconfig.json +++ b/extractors/ukvisajobs/tsconfig.json @@ -1,18 +1,17 @@ { + "extends": "@apify/tsconfig", "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext", "target": "ES2022", "outDir": "dist", - "strict": true, "noUnusedLocals": false, - "lib": ["ES2022", "DOM"], - "types": ["node"], + "lib": ["DOM"], "skipLibCheck": true, "baseUrl": ".", "paths": { "@shared/*": ["../../shared/src/*"] } }, - "include": ["./src/**/*", "./tests/**/*", "./manifest.ts"] + "include": ["./src/**/*"] }