diff --git a/orchestrator/src/client/pages/OrchestratorPage.test.tsx b/orchestrator/src/client/pages/OrchestratorPage.test.tsx
index 186700f..32a9fb2 100644
--- a/orchestrator/src/client/pages/OrchestratorPage.test.tsx
+++ b/orchestrator/src/client/pages/OrchestratorPage.test.tsx
@@ -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: () =>
+
{sourcesWithJobs.join(",")}
@@ -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(
+
+
+
+ } />
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("location").textContent).not.toContain("source=ukvisajobs");
+ });
+ });
});
diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx
index c6fc6ee..b09df32 100644
--- a/orchestrator/src/client/pages/OrchestratorPage.tsx
+++ b/orchestrator/src/client/pages/OrchestratorPage.tsx
@@ -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 (
<>
-
setIsManualImportOpen(true)}
- />
+ setIsManualImportOpen(true)}
+ />
@@ -249,6 +262,7 @@ export const OrchestratorPage: React.FC = () => {
onSearchQueryChange={setSearchQuery}
sourceFilter={sourceFilter}
onSourceFilterChange={setSourceFilter}
+ sourcesWithJobs={sourcesWithJobs}
sort={sort}
onSortChange={setSort}
/>
diff --git a/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.test.tsx b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.test.tsx
index 92f8bef..aaacea6 100644
--- a/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.test.tsx
+++ b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.test.tsx
@@ -71,6 +71,7 @@ const renderFilters = (overrides?: Partial {
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();
+ });
});
diff --git a/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx
index 3303e10..20269e9 100644
--- a/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx
+++ b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.tsx
@@ -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 = ({
onSearchQueryChange,
sourceFilter,
onSourceFilterChange,
+ sourcesWithJobs,
sort,
onSortChange,
-}) => (
+}) => {
+ const visibleSources = orderedFilterSources.filter((source) => sourcesWithJobs.includes(source));
+
+ return (
onTabChange(value as FilterTab)}>
@@ -85,9 +90,9 @@ export const OrchestratorFilters: React.FC = ({
onValueChange={(value) => onSourceFilterChange(value as JobSource | "all")}
>
All Sources
- {(Object.keys(sourceLabel) as JobSource[]).map((key) => (
-
- {sourceLabel[key]}
+ {visibleSources.map((source) => (
+
+ {sourceLabel[source]}
))}
@@ -140,3 +145,4 @@ export const OrchestratorFilters: React.FC = ({
);
+};
diff --git a/orchestrator/src/client/pages/orchestrator/OrchestratorHeader.test.tsx b/orchestrator/src/client/pages/orchestrator/OrchestratorHeader.test.tsx
new file mode 100644
index 0000000..0e215a3
--- /dev/null
+++ b/orchestrator/src/client/pages/orchestrator/OrchestratorHeader.test.tsx
@@ -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 }) => {children}
,
+ DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {children}
,
+ DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => {children}
,
+ DropdownMenuSeparator: () => ,
+ DropdownMenuItem: ({ children, onSelect }: { children: React.ReactNode; onSelect?: (event: Event) => void }) => (
+
+ ),
+ DropdownMenuCheckboxItem: ({
+ children,
+ onCheckedChange,
+ }: {
+ children: React.ReactNode;
+ onCheckedChange?: (checked: boolean) => void;
+ }) => (
+
+ ),
+ };
+});
+
+vi.mock("@/components/ui/sheet", () => ({
+ Sheet: ({ children }: { children: React.ReactNode }) => {children}
,
+ SheetTrigger: ({ children }: { children: React.ReactNode }) => {children}
,
+ SheetContent: ({ children }: { children: React.ReactNode }) => {children}
,
+ SheetHeader: ({ children }: { children: React.ReactNode }) => {children}
,
+ SheetTitle: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+const renderHeader = (overrides: Partial> = {}) => {
+ const props: React.ComponentProps = {
+ 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(
+
+
+
+ ),
+ };
+};
+
+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();
+ });
+});
diff --git a/orchestrator/src/client/pages/orchestrator/OrchestratorHeader.tsx b/orchestrator/src/client/pages/orchestrator/OrchestratorHeader.tsx
index a620965..ddfed4a 100644
--- a/orchestrator/src/client/pages/orchestrator/OrchestratorHeader.tsx
+++ b/orchestrator/src/client/pages/orchestrator/OrchestratorHeader.tsx
@@ -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 = ({
onNavOpenChange,
isPipelineRunning,
pipelineSources,
+ enabledSources,
onToggleSource,
onSetPipelineSources,
onRunPipeline,
@@ -65,6 +66,9 @@ export const OrchestratorHeader: React.FC = ({
}) => {
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 (
@@ -144,7 +148,9 @@ export const OrchestratorHeader: React.FC = ({
className="gap-2"
>
{isPipelineRunning ? : }
- {isPipelineRunning ? "Running" : "Run pipeline"}
+
+ {isPipelineRunning ? `Running (${pipelineSources.length})` : `Run pipeline (${pipelineSources.length})`}
+
@@ -162,7 +168,7 @@ export const OrchestratorHeader: React.FC = ({
Sources
- {orderedSources.map((source) => (
+ {visibleSources.map((source) => (
= ({
))}
- {
- event.preventDefault();
- onSetPipelineSources(orderedSources);
+ {
+ onSetPipelineSources(checked ? visibleSources : visibleSources.slice(0, 1));
}}
+ onSelect={(event) => event.preventDefault()}
>
- All sources
-
- {
- event.preventDefault();
- onSetPipelineSources(["gradcracker"]);
- }}
- >
- Gradcracker only
-
- {
- event.preventDefault();
- onSetPipelineSources(["indeed", "linkedin"]);
- }}
- >
- Indeed + LinkedIn only
-
+ Select all sources
+
diff --git a/orchestrator/src/client/pages/orchestrator/constants.ts b/orchestrator/src/client/pages/orchestrator/constants.ts
index 0e05337..b79398a 100644
--- a/orchestrator/src/client/pages/orchestrator/constants.ts
+++ b/orchestrator/src/client/pages/orchestrator/constants.ts
@@ -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