Merge pull request #29 from DaKheera47/show-enabled-extractors-in-the-ui
Show enabled extractors in the UI
This commit is contained in:
commit
f15ecd3f5f
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 {
|
||||
@ -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 };
|
||||
};
|
||||
|
||||
@ -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];
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user