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..08fdb99 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 (sourceFilter === "all") return;
+ if (!sourcesWithJobs.includes(sourceFilter)) {
+ setSourceFilter("all");
+ }
+ }, [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..4ded3d4 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, orderedSources, 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,14 @@ export const OrchestratorFilters: React.FC = ({
onSearchQueryChange,
sourceFilter,
onSourceFilterChange,
+ sourcesWithJobs,
sort,
onSortChange,
-}) => (
+}) => {
+ const orderedFilterSources: JobSource[] = [...orderedSources, "manual"];
+ const visibleSources = orderedFilterSources.filter((source) => sourcesWithJobs.includes(source));
+
+ return (
onTabChange(value as FilterTab)}>
@@ -85,9 +91,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 +146,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..7eee1fa
--- /dev/null
+++ b/orchestrator/src/client/pages/orchestrator/OrchestratorHeader.test.tsx
@@ -0,0 +1,94 @@
+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("menuitem", { name: /All sources/i }));
+
+ expect(props.onSetPipelineSources).toHaveBeenCalledWith(["gradcracker", "linkedin"]);
+ });
+
+ it("hides jobspy preset when no jobspy sources are enabled", () => {
+ renderHeader({ enabledSources: ["gradcracker"] });
+
+ 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..5bd9a8b 100644
--- a/orchestrator/src/client/pages/orchestrator/OrchestratorHeader.tsx
+++ b/orchestrator/src/client/pages/orchestrator/OrchestratorHeader.tsx
@@ -40,6 +40,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 +59,7 @@ export const OrchestratorHeader: React.FC = ({
onNavOpenChange,
isPipelineRunning,
pipelineSources,
+ enabledSources,
onToggleSource,
onSetPipelineSources,
onRunPipeline,
@@ -65,6 +67,8 @@ export const OrchestratorHeader: React.FC = ({
}) => {
const location = useLocation();
const navigate = useNavigate();
+ const visibleSources = orderedSources.filter((source) => enabledSources.includes(source));
+ const enabledJobSpySources = visibleSources.filter((source) => source === "indeed" || source === "linkedin");
return (
@@ -162,7 +166,7 @@ export const OrchestratorHeader: React.FC = ({
Sources
- {orderedSources.map((source) => (
+ {visibleSources.map((source) => (
= ({
{
event.preventDefault();
- onSetPipelineSources(orderedSources);
+ onSetPipelineSources(visibleSources);
}}
>
All sources
- {
- event.preventDefault();
- onSetPipelineSources(["gradcracker"]);
- }}
- >
- Gradcracker only
-
- {
- event.preventDefault();
- onSetPipelineSources(["indeed", "linkedin"]);
- }}
- >
- Indeed + LinkedIn only
-
+ {visibleSources.includes("gradcracker") && (
+ {
+ event.preventDefault();
+ onSetPipelineSources(["gradcracker"]);
+ }}
+ >
+ Gradcracker only
+
+ )}
+ {enabledJobSpySources.length > 0 && (
+ {
+ event.preventDefault();
+ onSetPipelineSources(enabledJobSpySources);
+ }}
+ >
+ Indeed + LinkedIn only
+
+ )}
diff --git a/orchestrator/src/client/pages/orchestrator/usePipelineSources.test.ts b/orchestrator/src/client/pages/orchestrator/usePipelineSources.test.ts
new file mode 100644
index 0000000..4be48e8
--- /dev/null
+++ b/orchestrator/src/client/pages/orchestrator/usePipelineSources.test.ts
@@ -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"]);
+ });
+});
diff --git a/orchestrator/src/client/pages/orchestrator/usePipelineSources.ts b/orchestrator/src/client/pages/orchestrator/usePipelineSources.ts
index 919036a..7be198d 100644
--- a/orchestrator/src/client/pages/orchestrator/usePipelineSources.ts
+++ b/orchestrator/src/client/pages/orchestrator/usePipelineSources.ts
@@ -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