codex oneshots ftw!

This commit is contained in:
DaKheera47 2026-01-23 13:39:28 +00:00
parent ba9beaf01e
commit 3652abab3e
9 changed files with 310 additions and 43 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", () => ({ vi.mock("./orchestrator/OrchestratorHeader", () => ({
OrchestratorHeader: () => <div data-testid="header" />, OrchestratorHeader: () => <div data-testid="header" />,
})); }));
@ -118,12 +128,15 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
onTabChange, onTabChange,
onSearchQueryChange, onSearchQueryChange,
onSortChange, onSortChange,
sourcesWithJobs,
}: { }: {
onTabChange: (t: FilterTab) => void; onTabChange: (t: FilterTab) => void;
onSearchQueryChange: (q: string) => void; onSearchQueryChange: (q: string) => void;
onSortChange: (s: any) => void; onSortChange: (s: any) => void;
sourcesWithJobs: string[];
}) => ( }) => (
<div data-testid="filters"> <div data-testid="filters">
<div data-testid="sources-with-jobs">{sourcesWithJobs.join(",")}</div>
<button onClick={() => onTabChange("discovered")}>To Discovered</button> <button onClick={() => onTabChange("discovered")}>To Discovered</button>
<button onClick={() => onSearchQueryChange("test search")}>Set Search</button> <button onClick={() => onSearchQueryChange("test search")}>Set Search</button>
<button onClick={() => onSortChange({ key: "title", direction: "asc" })}>Set Sort</button> <button onClick={() => onSortChange({ key: "title", direction: "asc" })}>Set Sort</button>
@ -274,4 +287,21 @@ describe("OrchestratorPage", () => {
expect(screen.getByTestId("detail-panel")).toBeInTheDocument(); 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 { useFilteredJobs } from "./orchestrator/useFilteredJobs";
import { useOrchestratorData } from "./orchestrator/useOrchestratorData"; import { useOrchestratorData } from "./orchestrator/useOrchestratorData";
import { usePipelineSources } from "./orchestrator/usePipelineSources"; 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 = () => { export const OrchestratorPage: React.FC = () => {
const { tab, jobId } = useParams<{ tab: string; jobId?: string }>(); const { tab, jobId } = useParams<{ tab: string; jobId?: string }>();
@ -106,6 +107,7 @@ export const OrchestratorPage: React.FC = () => {
} }
}, [tab, navigateWithContext]); }, [tab, navigateWithContext]);
const [navOpen, setNavOpen] = useState(false); const [navOpen, setNavOpen] = useState(false);
const [isManualImportOpen, setIsManualImportOpen] = useState(false); const [isManualImportOpen, setIsManualImportOpen] = useState(false);
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false); const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
@ -121,16 +123,26 @@ export const OrchestratorPage: React.FC = () => {
navigateWithContext(activeTab, id); navigateWithContext(activeTab, id);
}; };
const { pipelineSources, setPipelineSources, toggleSource } = usePipelineSources(); const { settings } = useSettings();
const { jobs, stats, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs } = useOrchestratorData(); 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 activeJobs = useFilteredJobs(jobs, activeTab, sourceFilter, searchQuery, sort);
const counts = useMemo(() => getJobCounts(jobs), [jobs]); const counts = useMemo(() => getJobCounts(jobs), [jobs]);
const sourcesWithJobs = useMemo(() => getSourcesWithJobs(jobs), [jobs]);
const selectedJob = useMemo( const selectedJob = useMemo(
() => (selectedJobId ? jobs.find((job) => job.id === selectedJobId) ?? null : null), () => (selectedJobId ? jobs.find((job) => job.id === selectedJobId) ?? null : null),
[jobs, selectedJobId], [jobs, selectedJobId],
); );
useEffect(() => {
if (sourceFilter === "all") return;
if (!sourcesWithJobs.includes(sourceFilter)) {
setSourceFilter("all");
}
}, [sourceFilter, setSourceFilter, sourcesWithJobs]);
const handleManualImported = useCallback( const handleManualImported = useCallback(
async (importedJobId: string) => { async (importedJobId: string) => {
// Refresh jobs and navigate to the new job in discovered tab // Refresh jobs and navigate to the new job in discovered tab
@ -225,16 +237,17 @@ export const OrchestratorPage: React.FC = () => {
return ( return (
<> <>
<OrchestratorHeader <OrchestratorHeader
navOpen={navOpen} navOpen={navOpen}
onNavOpenChange={setNavOpen} onNavOpenChange={setNavOpen}
isPipelineRunning={isPipelineRunning} isPipelineRunning={isPipelineRunning}
pipelineSources={pipelineSources} pipelineSources={pipelineSources}
onToggleSource={toggleSource} enabledSources={enabledSources}
onSetPipelineSources={setPipelineSources} onToggleSource={toggleSource}
onRunPipeline={handleRunPipeline} onSetPipelineSources={setPipelineSources}
onOpenManualImport={() => setIsManualImportOpen(true)} onRunPipeline={handleRunPipeline}
/> onOpenManualImport={() => setIsManualImportOpen(true)}
/>
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12"> <main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
<OrchestratorSummary stats={stats} isPipelineRunning={isPipelineRunning} /> <OrchestratorSummary stats={stats} isPipelineRunning={isPipelineRunning} />
@ -249,6 +262,7 @@ export const OrchestratorPage: React.FC = () => {
onSearchQueryChange={setSearchQuery} onSearchQueryChange={setSearchQuery}
sourceFilter={sourceFilter} sourceFilter={sourceFilter}
onSourceFilterChange={setSourceFilter} onSourceFilterChange={setSourceFilter}
sourcesWithJobs={sourcesWithJobs}
sort={sort} sort={sort}
onSortChange={setSort} onSortChange={setSort}
/> />

View File

@ -71,6 +71,7 @@ const renderFilters = (overrides?: Partial<ComponentProps<typeof OrchestratorFil
onSearchQueryChange: vi.fn(), onSearchQueryChange: vi.fn(),
sourceFilter: "all" as const, sourceFilter: "all" as const,
onSourceFilterChange: vi.fn(), onSourceFilterChange: vi.fn(),
sourcesWithJobs: ["gradcracker", "linkedin", "manual"],
sort: { key: "score", direction: "desc" } as JobSort, sort: { key: "score", direction: "desc" } as JobSort,
onSortChange: vi.fn(), onSortChange: vi.fn(),
...overrides, ...overrides,
@ -104,4 +105,15 @@ describe("OrchestratorFilters", () => {
fireEvent.click(await screen.findByRole("menuitem", { name: /Direction:/i })); fireEvent.click(await screen.findByRole("menuitem", { name: /Direction:/i }));
expect(props.onSortChange).toHaveBeenCalledWith({ key: "score", direction: "asc" }); 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 { sourceLabel } from "@/lib/utils";
import type { JobSource } from "../../../shared/types"; 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"; import type { FilterTab, JobSort } from "./constants";
interface OrchestratorFiltersProps { interface OrchestratorFiltersProps {
@ -28,6 +28,7 @@ interface OrchestratorFiltersProps {
onSearchQueryChange: (value: string) => void; onSearchQueryChange: (value: string) => void;
sourceFilter: JobSource | "all"; sourceFilter: JobSource | "all";
onSourceFilterChange: (value: JobSource | "all") => void; onSourceFilterChange: (value: JobSource | "all") => void;
sourcesWithJobs: JobSource[];
sort: JobSort; sort: JobSort;
onSortChange: (sort: JobSort) => void; onSortChange: (sort: JobSort) => void;
} }
@ -40,9 +41,14 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
onSearchQueryChange, onSearchQueryChange,
sourceFilter, sourceFilter,
onSourceFilterChange, onSourceFilterChange,
sourcesWithJobs,
sort, sort,
onSortChange, onSortChange,
}) => ( }) => {
const orderedFilterSources: JobSource[] = [...orderedSources, "manual"];
const visibleSources = orderedFilterSources.filter((source) => sourcesWithJobs.includes(source));
return (
<Tabs value={activeTab} onValueChange={(value) => onTabChange(value as FilterTab)}> <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"> <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"> <TabsList className="h-auto w-full flex-wrap justify-start gap-1 lg:w-auto">
@ -85,9 +91,9 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
onValueChange={(value) => onSourceFilterChange(value as JobSource | "all")} onValueChange={(value) => onSourceFilterChange(value as JobSource | "all")}
> >
<DropdownMenuRadioItem value="all">All Sources</DropdownMenuRadioItem> <DropdownMenuRadioItem value="all">All Sources</DropdownMenuRadioItem>
{(Object.keys(sourceLabel) as JobSource[]).map((key) => ( {visibleSources.map((source) => (
<DropdownMenuRadioItem key={key} value={key}> <DropdownMenuRadioItem key={source} value={source}>
{sourceLabel[key]} {sourceLabel[source]}
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
))} ))}
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
@ -140,3 +146,4 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
</div> </div>
</Tabs> </Tabs>
); );
};

View File

@ -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 }) => <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("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();
});
});

View File

@ -40,6 +40,7 @@ interface OrchestratorHeaderProps {
onNavOpenChange: (open: boolean) => void; onNavOpenChange: (open: boolean) => void;
isPipelineRunning: boolean; isPipelineRunning: boolean;
pipelineSources: JobSource[]; pipelineSources: JobSource[];
enabledSources: JobSource[];
onToggleSource: (source: JobSource, checked: boolean) => void; onToggleSource: (source: JobSource, checked: boolean) => void;
onSetPipelineSources: (sources: JobSource[]) => void; onSetPipelineSources: (sources: JobSource[]) => void;
onRunPipeline: () => void; onRunPipeline: () => void;
@ -58,6 +59,7 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
onNavOpenChange, onNavOpenChange,
isPipelineRunning, isPipelineRunning,
pipelineSources, pipelineSources,
enabledSources,
onToggleSource, onToggleSource,
onSetPipelineSources, onSetPipelineSources,
onRunPipeline, onRunPipeline,
@ -65,6 +67,8 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
}) => { }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const visibleSources = orderedSources.filter((source) => enabledSources.includes(source));
const enabledJobSpySources = visibleSources.filter((source) => source === "indeed" || source === "linkedin");
return ( return (
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
@ -162,7 +166,7 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
<DropdownMenuContent align="end" className="w-56"> <DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Sources</DropdownMenuLabel> <DropdownMenuLabel>Sources</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{orderedSources.map((source) => ( {visibleSources.map((source) => (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
key={source} key={source}
checked={pipelineSources.includes(source)} checked={pipelineSources.includes(source)}
@ -176,27 +180,31 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
<DropdownMenuItem <DropdownMenuItem
onSelect={(event) => { onSelect={(event) => {
event.preventDefault(); event.preventDefault();
onSetPipelineSources(orderedSources); onSetPipelineSources(visibleSources);
}} }}
> >
All sources All sources
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem {visibleSources.includes("gradcracker") && (
onSelect={(event) => { <DropdownMenuItem
event.preventDefault(); onSelect={(event) => {
onSetPipelineSources(["gradcracker"]); event.preventDefault();
}} onSetPipelineSources(["gradcracker"]);
> }}
Gradcracker only >
</DropdownMenuItem> Gradcracker only
<DropdownMenuItem </DropdownMenuItem>
onSelect={(event) => { )}
event.preventDefault(); {enabledJobSpySources.length > 0 && (
onSetPipelineSources(["indeed", "linkedin"]); <DropdownMenuItem
}} onSelect={(event) => {
> event.preventDefault();
Indeed + LinkedIn only onSetPipelineSources(enabledJobSpySources);
</DropdownMenuItem> }}
>
Indeed + LinkedIn only
</DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>

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 type { JobSource } from "../../../shared/types";
import { import {
@ -7,20 +7,39 @@ import {
orderedSources, orderedSources,
} from "./constants"; } 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[]>(() => { const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => {
try { try {
const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY); 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; 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)); 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 { } 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(() => { useEffect(() => {
try { try {
localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(pipelineSources)); localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(pipelineSources));
@ -30,6 +49,7 @@ export const usePipelineSources = () => {
}, [pipelineSources]); }, [pipelineSources]);
const toggleSource = useCallback((source: JobSource, checked: boolean) => { const toggleSource = useCallback((source: JobSource, checked: boolean) => {
if (!allowedSources.includes(source)) return;
setPipelineSources((current) => { setPipelineSources((current) => {
const next = checked const next = checked
? Array.from(new Set([...current, source])) ? Array.from(new Set([...current, source]))
@ -37,7 +57,7 @@ export const usePipelineSources = () => {
return next.length === 0 ? current : next; return next.length === 0 ? current : next;
}); });
}, []); }, [allowedSources]);
return { pipelineSources, setPipelineSources, toggleSource }; 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 { orderedSources } from "./constants";
import type { FilterTab, JobSort } from "./constants"; import type { FilterTab, JobSort } from "./constants";
const dateValue = (value: string | null) => { const dateValue = (value: string | null) => {
@ -87,3 +88,39 @@ export const getJobCounts = (jobs: Job[]): Record<FilterTab, number> => {
return byTab; return byTab;
}; };
const orderedSourceFilters: JobSource[] = [...orderedSources, "manual"];
export const getSourcesWithJobs = (jobs: Job[]): JobSource[] => {
const seen = new Set<JobSource>();
for (const job of jobs) {
seen.add(job.source);
}
return orderedSourceFilters.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];
};