Merge pull request #29 from DaKheera47/show-enabled-extractors-in-the-ui

Show enabled extractors in the UI
This commit is contained in:
Shaheer Sarfaraz 2026-01-23 13:52:45 +00:00 committed by GitHub
commit f15ecd3f5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 299 additions and 50 deletions

View File

@ -105,6 +105,16 @@ vi.mock("./orchestrator/usePipelineSources", () => ({
}),
}));
vi.mock("../hooks/useSettings", () => ({
useSettings: () => ({
settings: {
jobspySites: ["indeed", "linkedin"],
ukvisajobsEmail: null,
ukvisajobsPasswordHint: null,
},
}),
}));
vi.mock("./orchestrator/OrchestratorHeader", () => ({
OrchestratorHeader: () => <div data-testid="header" />,
}));
@ -118,12 +128,15 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
onTabChange,
onSearchQueryChange,
onSortChange,
sourcesWithJobs,
}: {
onTabChange: (t: FilterTab) => void;
onSearchQueryChange: (q: string) => void;
onSortChange: (s: any) => void;
sourcesWithJobs: string[];
}) => (
<div data-testid="filters">
<div data-testid="sources-with-jobs">{sourcesWithJobs.join(",")}</div>
<button onClick={() => onTabChange("discovered")}>To Discovered</button>
<button onClick={() => onSearchQueryChange("test search")}>Set Search</button>
<button onClick={() => onSortChange({ key: "title", direction: "asc" })}>Set Sort</button>
@ -274,4 +287,21 @@ describe("OrchestratorPage", () => {
expect(screen.getByTestId("detail-panel")).toBeInTheDocument();
});
it("clears source filter when no jobs exist for it", async () => {
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready?source=ukvisajobs"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByTestId("location").textContent).not.toContain("source=ukvisajobs");
});
});
});

View File

@ -22,7 +22,8 @@ import { OrchestratorSummary } from "./orchestrator/OrchestratorSummary";
import { useFilteredJobs } from "./orchestrator/useFilteredJobs";
import { useOrchestratorData } from "./orchestrator/useOrchestratorData";
import { usePipelineSources } from "./orchestrator/usePipelineSources";
import { getJobCounts } from "./orchestrator/utils";
import { useSettings } from "@client/hooks/useSettings";
import { getEnabledSources, getJobCounts, getSourcesWithJobs } from "./orchestrator/utils";
export const OrchestratorPage: React.FC = () => {
const { tab, jobId } = useParams<{ tab: string; jobId?: string }>();
@ -106,6 +107,7 @@ export const OrchestratorPage: React.FC = () => {
}
}, [tab, navigateWithContext]);
const [navOpen, setNavOpen] = useState(false);
const [isManualImportOpen, setIsManualImportOpen] = useState(false);
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
@ -121,16 +123,26 @@ export const OrchestratorPage: React.FC = () => {
navigateWithContext(activeTab, id);
};
const { pipelineSources, setPipelineSources, toggleSource } = usePipelineSources();
const { settings } = useSettings();
const { jobs, stats, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs } = useOrchestratorData();
const enabledSources = useMemo(() => getEnabledSources(settings ?? null), [settings]);
const { pipelineSources, setPipelineSources, toggleSource } = usePipelineSources(enabledSources);
const activeJobs = useFilteredJobs(jobs, activeTab, sourceFilter, searchQuery, sort);
const counts = useMemo(() => getJobCounts(jobs), [jobs]);
const sourcesWithJobs = useMemo(() => getSourcesWithJobs(jobs), [jobs]);
const selectedJob = useMemo(
() => (selectedJobId ? jobs.find((job) => job.id === selectedJobId) ?? null : null),
[jobs, selectedJobId],
);
useEffect(() => {
if (isLoading || sourceFilter === "all") return;
if (!sourcesWithJobs.includes(sourceFilter)) {
setSourceFilter("all");
}
}, [isLoading, sourceFilter, setSourceFilter, sourcesWithJobs]);
const handleManualImported = useCallback(
async (importedJobId: string) => {
// Refresh jobs and navigate to the new job in discovered tab
@ -225,16 +237,17 @@ export const OrchestratorPage: React.FC = () => {
return (
<>
<OrchestratorHeader
navOpen={navOpen}
onNavOpenChange={setNavOpen}
isPipelineRunning={isPipelineRunning}
pipelineSources={pipelineSources}
onToggleSource={toggleSource}
onSetPipelineSources={setPipelineSources}
onRunPipeline={handleRunPipeline}
onOpenManualImport={() => setIsManualImportOpen(true)}
/>
<OrchestratorHeader
navOpen={navOpen}
onNavOpenChange={setNavOpen}
isPipelineRunning={isPipelineRunning}
pipelineSources={pipelineSources}
enabledSources={enabledSources}
onToggleSource={toggleSource}
onSetPipelineSources={setPipelineSources}
onRunPipeline={handleRunPipeline}
onOpenManualImport={() => setIsManualImportOpen(true)}
/>
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
<OrchestratorSummary stats={stats} isPipelineRunning={isPipelineRunning} />
@ -249,6 +262,7 @@ export const OrchestratorPage: React.FC = () => {
onSearchQueryChange={setSearchQuery}
sourceFilter={sourceFilter}
onSourceFilterChange={setSourceFilter}
sourcesWithJobs={sourcesWithJobs}
sort={sort}
onSortChange={setSort}
/>

View File

@ -71,6 +71,7 @@ const renderFilters = (overrides?: Partial<ComponentProps<typeof OrchestratorFil
onSearchQueryChange: vi.fn(),
sourceFilter: "all" as const,
onSourceFilterChange: vi.fn(),
sourcesWithJobs: ["gradcracker", "linkedin", "manual"],
sort: { key: "score", direction: "desc" } as JobSort,
onSortChange: vi.fn(),
...overrides,
@ -104,4 +105,15 @@ describe("OrchestratorFilters", () => {
fireEvent.click(await screen.findByRole("menuitem", { name: /Direction:/i }));
expect(props.onSortChange).toHaveBeenCalledWith({ key: "score", direction: "asc" });
});
it("only shows sources that exist in jobs", async () => {
renderFilters({ sourcesWithJobs: ["gradcracker", "manual"] });
fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i }));
expect(await screen.findByRole("menuitemradio", { name: /Gradcracker/i })).toBeInTheDocument();
expect(screen.queryByRole("menuitemradio", { name: /LinkedIn/i })).not.toBeInTheDocument();
expect(screen.queryByRole("menuitemradio", { name: /UK Visa Jobs/i })).not.toBeInTheDocument();
expect(screen.getByRole("menuitemradio", { name: /Manual/i })).toBeInTheDocument();
});
});

View File

@ -17,7 +17,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { sourceLabel } from "@/lib/utils";
import type { JobSource } from "../../../shared/types";
import { defaultSortDirection, sortLabels, tabs } from "./constants";
import { defaultSortDirection, orderedFilterSources, sortLabels, tabs } from "./constants";
import type { FilterTab, JobSort } from "./constants";
interface OrchestratorFiltersProps {
@ -28,6 +28,7 @@ interface OrchestratorFiltersProps {
onSearchQueryChange: (value: string) => void;
sourceFilter: JobSource | "all";
onSourceFilterChange: (value: JobSource | "all") => void;
sourcesWithJobs: JobSource[];
sort: JobSort;
onSortChange: (sort: JobSort) => void;
}
@ -40,9 +41,13 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
onSearchQueryChange,
sourceFilter,
onSourceFilterChange,
sourcesWithJobs,
sort,
onSortChange,
}) => (
}) => {
const visibleSources = orderedFilterSources.filter((source) => sourcesWithJobs.includes(source));
return (
<Tabs value={activeTab} onValueChange={(value) => onTabChange(value as FilterTab)}>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<TabsList className="h-auto w-full flex-wrap justify-start gap-1 lg:w-auto">
@ -85,9 +90,9 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
onValueChange={(value) => onSourceFilterChange(value as JobSource | "all")}
>
<DropdownMenuRadioItem value="all">All Sources</DropdownMenuRadioItem>
{(Object.keys(sourceLabel) as JobSource[]).map((key) => (
<DropdownMenuRadioItem key={key} value={key}>
{sourceLabel[key]}
{visibleSources.map((source) => (
<DropdownMenuRadioItem key={source} value={source}>
{sourceLabel[source]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
@ -140,3 +145,4 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
</div>
</Tabs>
);
};

View File

@ -0,0 +1,95 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { OrchestratorHeader } from "./OrchestratorHeader";
vi.mock("@/components/ui/dropdown-menu", () => {
const React = require("react") as typeof import("react");
return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuSeparator: () => <div role="separator" />,
DropdownMenuItem: ({ children, onSelect }: { children: React.ReactNode; onSelect?: (event: Event) => void }) => (
<button
type="button"
role="menuitem"
onClick={() => onSelect?.({ preventDefault: () => {} } as unknown as Event)}
>
{children}
</button>
),
DropdownMenuCheckboxItem: ({
children,
onCheckedChange,
}: {
children: React.ReactNode;
onCheckedChange?: (checked: boolean) => void;
}) => (
<button type="button" role="menuitemcheckbox" onClick={() => onCheckedChange?.(true)}>
{children}
</button>
),
};
});
vi.mock("@/components/ui/sheet", () => ({
Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
const renderHeader = (overrides: Partial<React.ComponentProps<typeof OrchestratorHeader>> = {}) => {
const props: React.ComponentProps<typeof OrchestratorHeader> = {
navOpen: false,
onNavOpenChange: vi.fn(),
isPipelineRunning: false,
pipelineSources: ["gradcracker"],
enabledSources: ["gradcracker"],
onToggleSource: vi.fn(),
onSetPipelineSources: vi.fn(),
onRunPipeline: vi.fn(),
onOpenManualImport: vi.fn(),
...overrides,
};
return {
props,
...render(
<MemoryRouter>
<OrchestratorHeader {...props} />
</MemoryRouter>
),
};
};
describe("OrchestratorHeader", () => {
it("renders only enabled sources", () => {
renderHeader({ enabledSources: ["gradcracker", "linkedin"], pipelineSources: ["linkedin"] });
expect(screen.getByRole("menuitemcheckbox", { name: /Gradcracker/i })).toBeInTheDocument();
expect(screen.getByRole("menuitemcheckbox", { name: /LinkedIn/i })).toBeInTheDocument();
expect(screen.queryByRole("menuitemcheckbox", { name: /UK Visa Jobs/i })).not.toBeInTheDocument();
});
it("uses enabled sources for the all sources action", () => {
const { props } = renderHeader({ enabledSources: ["gradcracker", "linkedin"] });
fireEvent.click(screen.getByRole("menuitemcheckbox", { name: /Select all sources/i }));
expect(props.onSetPipelineSources).toHaveBeenCalledWith(["gradcracker", "linkedin"]);
});
it("does not show source presets", () => {
renderHeader({ enabledSources: ["gradcracker", "linkedin"] });
expect(screen.queryByRole("menuitem", { name: /Gradcracker only/i })).not.toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: /Indeed \+ LinkedIn only/i })).not.toBeInTheDocument();
});
});

View File

@ -18,7 +18,6 @@ import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
@ -40,6 +39,7 @@ interface OrchestratorHeaderProps {
onNavOpenChange: (open: boolean) => void;
isPipelineRunning: boolean;
pipelineSources: JobSource[];
enabledSources: JobSource[];
onToggleSource: (source: JobSource, checked: boolean) => void;
onSetPipelineSources: (sources: JobSource[]) => void;
onRunPipeline: () => void;
@ -58,6 +58,7 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
onNavOpenChange,
isPipelineRunning,
pipelineSources,
enabledSources,
onToggleSource,
onSetPipelineSources,
onRunPipeline,
@ -65,6 +66,9 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
}) => {
const location = useLocation();
const navigate = useNavigate();
const visibleSources = orderedSources.filter((source) => enabledSources.includes(source));
const allSourcesSelected =
visibleSources.length > 0 && visibleSources.every((source) => pipelineSources.includes(source));
return (
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
@ -144,7 +148,9 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
className="gap-2"
>
{isPipelineRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
<span className="hidden sm:inline">{isPipelineRunning ? "Running" : "Run pipeline"}</span>
<span className="hidden sm:inline">
{isPipelineRunning ? `Running (${pipelineSources.length})` : `Run pipeline (${pipelineSources.length})`}
</span>
</Button>
<DropdownMenu>
@ -162,7 +168,7 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Sources</DropdownMenuLabel>
<DropdownMenuSeparator />
{orderedSources.map((source) => (
{visibleSources.map((source) => (
<DropdownMenuCheckboxItem
key={source}
checked={pipelineSources.includes(source)}
@ -173,30 +179,15 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onSetPipelineSources(orderedSources);
<DropdownMenuCheckboxItem
checked={allSourcesSelected}
onCheckedChange={(checked) => {
onSetPipelineSources(checked ? visibleSources : visibleSources.slice(0, 1));
}}
onSelect={(event) => event.preventDefault()}
>
All sources
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onSetPipelineSources(["gradcracker"]);
}}
>
Gradcracker only
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onSetPipelineSources(["indeed", "linkedin"]);
}}
>
Indeed + LinkedIn only
</DropdownMenuItem>
Select all sources
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@ -4,6 +4,7 @@ export const DEFAULT_PIPELINE_SOURCES: JobSource[] = ["gradcracker", "indeed", "
export const PIPELINE_SOURCES_STORAGE_KEY = "jobops.pipeline.sources";
export const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
export const orderedFilterSources: JobSource[] = [...orderedSources, "manual"];
export const statusTokens: Record<JobStatus, { label: string; badge: string; dot: string }> = {
discovered: {

View File

@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { PIPELINE_SOURCES_STORAGE_KEY } from "./constants";
import { usePipelineSources } from "./usePipelineSources";
describe("usePipelineSources", () => {
beforeEach(() => {
localStorage.clear();
});
it("filters stored sources to enabled sources", () => {
localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(["gradcracker", "ukvisajobs"]));
const enabledSources = ["gradcracker"] as const;
const { result } = renderHook(() => usePipelineSources(enabledSources));
expect(result.current.pipelineSources).toEqual(["gradcracker"]);
});
it("falls back to the first enabled source", () => {
localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(["ukvisajobs"]));
const enabledSources = ["gradcracker", "linkedin"] as const;
const { result } = renderHook(() => usePipelineSources(enabledSources));
expect(result.current.pipelineSources).toEqual(["gradcracker"]);
});
it("ignores toggles for disabled sources", () => {
localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(["gradcracker"]));
const enabledSources = ["gradcracker"] as const;
const { result } = renderHook(() => usePipelineSources(enabledSources));
act(() => {
result.current.toggleSource("ukvisajobs", true);
});
expect(result.current.pipelineSources).toEqual(["gradcracker"]);
});
});

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { JobSource } from "../../../shared/types";
import {
@ -7,20 +7,39 @@ import {
orderedSources,
} from "./constants";
export const usePipelineSources = () => {
const resolveAllowedSources = (enabledSources?: JobSource[]) =>
enabledSources && enabledSources.length > 0 ? enabledSources : DEFAULT_PIPELINE_SOURCES;
const normalizeSources = (sources: JobSource[], allowedSources: JobSource[]) => {
const filtered = sources.filter((value) => allowedSources.includes(value));
return filtered.length > 0 ? filtered : allowedSources.slice(0, 1);
};
const sourcesMatch = (left: JobSource[], right: JobSource[]) =>
left.length === right.length && left.every((value, index) => value === right[index]);
export const usePipelineSources = (enabledSources?: JobSource[]) => {
const allowedSources = useMemo(() => resolveAllowedSources(enabledSources), [enabledSources]);
const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => {
try {
const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY);
if (!raw) return DEFAULT_PIPELINE_SOURCES;
if (!raw) return normalizeSources(allowedSources, allowedSources);
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return DEFAULT_PIPELINE_SOURCES;
if (!Array.isArray(parsed)) return normalizeSources(allowedSources, allowedSources);
const next = parsed.filter((value): value is JobSource => orderedSources.includes(value as JobSource));
return next.length > 0 ? next : DEFAULT_PIPELINE_SOURCES;
return normalizeSources(next, allowedSources);
} catch {
return DEFAULT_PIPELINE_SOURCES;
return normalizeSources(allowedSources, allowedSources);
}
});
useEffect(() => {
setPipelineSources((current) => {
const normalized = normalizeSources(current, allowedSources);
return sourcesMatch(current, normalized) ? current : normalized;
});
}, [allowedSources]);
useEffect(() => {
try {
localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(pipelineSources));
@ -30,6 +49,7 @@ export const usePipelineSources = () => {
}, [pipelineSources]);
const toggleSource = useCallback((source: JobSource, checked: boolean) => {
if (!allowedSources.includes(source)) return;
setPipelineSources((current) => {
const next = checked
? Array.from(new Set([...current, source]))
@ -37,7 +57,7 @@ export const usePipelineSources = () => {
return next.length === 0 ? current : next;
});
}, []);
}, [allowedSources]);
return { pipelineSources, setPipelineSources, toggleSource };
};

View File

@ -1,4 +1,5 @@
import type { Job } from "../../../shared/types";
import type { AppSettings, Job, JobSource } from "../../../shared/types";
import { orderedFilterSources, orderedSources } from "./constants";
import type { FilterTab, JobSort } from "./constants";
const dateValue = (value: string | null) => {
@ -87,3 +88,37 @@ export const getJobCounts = (jobs: Job[]): Record<FilterTab, number> => {
return byTab;
};
export const getSourcesWithJobs = (jobs: Job[]): JobSource[] => {
const seen = new Set<JobSource>();
for (const job of jobs) {
seen.add(job.source);
}
return orderedFilterSources.filter((source) => seen.has(source));
};
export const getEnabledSources = (settings: AppSettings | null): JobSource[] => {
if (!settings) return [...orderedSources];
const enabled: JobSource[] = [];
const jobspySites = settings.jobspySites ?? [];
const hasUkVisaJobsAuth = Boolean(
settings.ukvisajobsEmail?.trim() && settings.ukvisajobsPasswordHint
);
for (const source of orderedSources) {
if (source === "gradcracker") {
enabled.push(source);
continue;
}
if (source === "ukvisajobs") {
if (hasUkVisaJobsAuth) enabled.push(source);
continue;
}
if (source === "indeed" || source === "linkedin") {
if (jobspySites.includes(source)) enabled.push(source);
}
}
return enabled.length > 0 ? enabled : [...orderedSources];
};