codex oneshots ftw!
This commit is contained in:
parent
ba9beaf01e
commit
3652abab3e
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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];
|
||||||
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user