feat(orchestrator): job list filters — multi source/country, URL sync, exclude
- Parse location strings into country keys (shared search-cities helper). - URL params: source, sourceExclude, countries, countriesExclude. - Chip cycle: off → include → exclude (destructive); remote bypasses country rules. - README: document filter behaviour and query keys. Unrelated local changes (scorer, notes, schema, etc.) remain unstaged. Made-with: Cursor
This commit is contained in:
@@ -204,7 +204,7 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
|
||||
OrchestratorFilters: ({
|
||||
onTabChange,
|
||||
onOpenCommandBar,
|
||||
onSourceFilterChange,
|
||||
onSourceSelectionChange,
|
||||
onSponsorFilterChange,
|
||||
onSalaryFilterChange,
|
||||
onResetFilters,
|
||||
@@ -214,7 +214,7 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
|
||||
}: {
|
||||
onTabChange: (t: FilterTab) => void;
|
||||
onOpenCommandBar: () => void;
|
||||
onSourceFilterChange: (source: string) => void;
|
||||
onSourceSelectionChange: (include: string[], exclude: string[]) => void;
|
||||
onSponsorFilterChange: (value: string) => void;
|
||||
onSalaryFilterChange: (value: {
|
||||
mode: "at_least" | "at_most" | "between";
|
||||
@@ -241,7 +241,10 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
|
||||
>
|
||||
Set Sort
|
||||
</button>
|
||||
<button type="button" onClick={() => onSourceFilterChange("linkedin")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSourceSelectionChange(["linkedin"], [])}
|
||||
>
|
||||
Set Source
|
||||
</button>
|
||||
<button type="button" onClick={() => onSponsorFilterChange("confirmed")}>
|
||||
@@ -663,6 +666,9 @@ describe("OrchestratorPage", () => {
|
||||
fireEvent.click(screen.getByText("Reset Filters"));
|
||||
const locationText = screen.getByTestId("location").textContent || "";
|
||||
expect(locationText).not.toContain("source=");
|
||||
expect(locationText).not.toContain("sourceExclude=");
|
||||
expect(locationText).not.toContain("countries=");
|
||||
expect(locationText).not.toContain("countriesExclude=");
|
||||
expect(locationText).not.toContain("sponsor=");
|
||||
expect(locationText).not.toContain("salaryMode=");
|
||||
expect(locationText).not.toContain("salaryMin=");
|
||||
|
||||
@@ -25,6 +25,7 @@ import { usePipelineControls } from "./orchestrator/usePipelineControls";
|
||||
import { usePipelineSources } from "./orchestrator/usePipelineSources";
|
||||
import { useScrollToJobItem } from "./orchestrator/useScrollToJobItem";
|
||||
import {
|
||||
getCountriesWithJobs,
|
||||
getEnabledSources,
|
||||
getJobCounts,
|
||||
getSourcesWithJobs,
|
||||
@@ -35,8 +36,12 @@ export const OrchestratorPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
searchParams,
|
||||
sourceFilter,
|
||||
setSourceFilter,
|
||||
sourcesFilter,
|
||||
sourcesExcludeFilter,
|
||||
setSourceSelection,
|
||||
countriesFilter,
|
||||
countriesExcludeFilter,
|
||||
setCountrySelection,
|
||||
sponsorFilter,
|
||||
setSponsorFilter,
|
||||
workplaceFilter,
|
||||
@@ -144,7 +149,10 @@ export const OrchestratorPage: React.FC = () => {
|
||||
const activeJobs = useFilteredJobs(
|
||||
jobs,
|
||||
activeTab,
|
||||
sourceFilter,
|
||||
sourcesFilter,
|
||||
sourcesExcludeFilter,
|
||||
countriesFilter,
|
||||
countriesExcludeFilter,
|
||||
sponsorFilter,
|
||||
workplaceFilter,
|
||||
salaryFilter,
|
||||
@@ -181,6 +189,31 @@ export const OrchestratorPage: React.FC = () => {
|
||||
|
||||
const counts = useMemo(() => getJobCounts(jobs), [jobs]);
|
||||
const sourcesWithJobs = useMemo(() => getSourcesWithJobs(jobs), [jobs]);
|
||||
const countriesWithJobs = useMemo(() => getCountriesWithJobs(jobs), [jobs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (countriesFilter.length === 0 && countriesExcludeFilter.length === 0)
|
||||
return;
|
||||
const validI = countriesFilter.filter((key) =>
|
||||
countriesWithJobs.includes(key),
|
||||
);
|
||||
const validE = countriesExcludeFilter.filter((key) =>
|
||||
countriesWithJobs.includes(key),
|
||||
);
|
||||
if (
|
||||
validI.length === countriesFilter.length &&
|
||||
validE.length === countriesExcludeFilter.length
|
||||
)
|
||||
return;
|
||||
setCountrySelection(validI, validE);
|
||||
}, [
|
||||
isLoading,
|
||||
countriesFilter,
|
||||
countriesExcludeFilter,
|
||||
countriesWithJobs,
|
||||
setCountrySelection,
|
||||
]);
|
||||
const {
|
||||
selectedJobIds,
|
||||
canSkipSelected,
|
||||
@@ -199,11 +232,27 @@ export const OrchestratorPage: React.FC = () => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || sourceFilter === "all") return;
|
||||
if (!sourcesWithJobs.includes(sourceFilter)) {
|
||||
setSourceFilter("all");
|
||||
}
|
||||
}, [isLoading, sourceFilter, setSourceFilter, sourcesWithJobs]);
|
||||
if (isLoading) return;
|
||||
if (sourcesFilter.length === 0 && sourcesExcludeFilter.length === 0) return;
|
||||
const validI = sourcesFilter.filter((source) =>
|
||||
sourcesWithJobs.includes(source),
|
||||
);
|
||||
const validE = sourcesExcludeFilter.filter((source) =>
|
||||
sourcesWithJobs.includes(source),
|
||||
);
|
||||
if (
|
||||
validI.length === sourcesFilter.length &&
|
||||
validE.length === sourcesExcludeFilter.length
|
||||
)
|
||||
return;
|
||||
setSourceSelection(validI, validE);
|
||||
}, [
|
||||
isLoading,
|
||||
sourcesFilter,
|
||||
sourcesExcludeFilter,
|
||||
setSourceSelection,
|
||||
sourcesWithJobs,
|
||||
]);
|
||||
|
||||
const handleSelectJob = (id: string) => {
|
||||
handleSelectJobId(id);
|
||||
@@ -268,6 +317,9 @@ export const OrchestratorPage: React.FC = () => {
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
for (const key of [
|
||||
"source",
|
||||
"sourceExclude",
|
||||
"countries",
|
||||
"countriesExclude",
|
||||
"sponsor",
|
||||
"salaryMode",
|
||||
"salaryMin",
|
||||
@@ -386,8 +438,12 @@ export const OrchestratorPage: React.FC = () => {
|
||||
onOpenCommandBar={() => setIsCommandBarOpen(true)}
|
||||
isFiltersOpen={isFiltersOpen}
|
||||
onFiltersOpenChange={setIsFiltersOpen}
|
||||
sourceFilter={sourceFilter}
|
||||
onSourceFilterChange={setSourceFilter}
|
||||
sourcesFilter={sourcesFilter}
|
||||
sourcesExcludeFilter={sourcesExcludeFilter}
|
||||
onSourceSelectionChange={setSourceSelection}
|
||||
countriesFilter={countriesFilter}
|
||||
countriesExcludeFilter={countriesExcludeFilter}
|
||||
onCountrySelectionChange={setCountrySelection}
|
||||
sponsorFilter={sponsorFilter}
|
||||
onSponsorFilterChange={setSponsorFilter}
|
||||
workplaceFilter={workplaceFilter}
|
||||
@@ -395,6 +451,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
salaryFilter={salaryFilter}
|
||||
onSalaryFilterChange={setSalaryFilter}
|
||||
sourcesWithJobs={sourcesWithJobs}
|
||||
countriesWithJobs={countriesWithJobs}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
onResetFilters={resetFilters}
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
} from "./constants";
|
||||
import { OrchestratorFilters } from "./OrchestratorFilters";
|
||||
|
||||
type FiltersProps = ComponentProps<typeof OrchestratorFilters>;
|
||||
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -26,10 +28,8 @@ afterAll(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const renderFilters = (
|
||||
overrides?: Partial<ComponentProps<typeof OrchestratorFilters>>,
|
||||
) => {
|
||||
const props = {
|
||||
const renderFilters = (overrides?: Partial<FiltersProps>) => {
|
||||
const props: FiltersProps = {
|
||||
activeTab: "ready" as FilterTab,
|
||||
onTabChange: vi.fn(),
|
||||
counts: {
|
||||
@@ -39,8 +39,12 @@ const renderFilters = (
|
||||
all: 6,
|
||||
},
|
||||
onOpenCommandBar: vi.fn(),
|
||||
sourceFilter: "all" as const,
|
||||
onSourceFilterChange: vi.fn(),
|
||||
sourcesFilter: [] as JobSource[],
|
||||
sourcesExcludeFilter: [] as JobSource[],
|
||||
onSourceSelectionChange: vi.fn(),
|
||||
countriesFilter: [] as string[],
|
||||
countriesExcludeFilter: [] as string[],
|
||||
onCountrySelectionChange: vi.fn(),
|
||||
sponsorFilter: "all" as SponsorFilter,
|
||||
onSponsorFilterChange: vi.fn(),
|
||||
workplaceFilter: "all" as WorkplaceFilter,
|
||||
@@ -52,6 +56,7 @@ const renderFilters = (
|
||||
},
|
||||
onSalaryFilterChange: vi.fn(),
|
||||
sourcesWithJobs: ["gradcracker", "linkedin", "manual"] as JobSource[],
|
||||
countriesWithJobs: ["united kingdom"] as string[],
|
||||
sort: { key: "score", direction: "desc" } as JobSort,
|
||||
onSortChange: vi.fn(),
|
||||
onResetFilters: vi.fn(),
|
||||
@@ -77,12 +82,23 @@ describe("OrchestratorFilters", () => {
|
||||
});
|
||||
|
||||
it("updates source, sponsor, salary range, and sort from the drawer", async () => {
|
||||
const { props } = renderFilters();
|
||||
const { props, rerender } = renderFilters();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^filters/i }));
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /linkedin/i }));
|
||||
expect(props.onSourceFilterChange).toHaveBeenCalledWith("linkedin");
|
||||
expect(props.onSourceSelectionChange).toHaveBeenCalledWith(
|
||||
["linkedin"],
|
||||
[],
|
||||
);
|
||||
|
||||
props.onSourceSelectionChange.mockClear();
|
||||
rerender(<OrchestratorFilters {...props} sourcesFilter={["linkedin"]} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /linkedin/i }));
|
||||
expect(props.onSourceSelectionChange).toHaveBeenCalledWith(
|
||||
[],
|
||||
["linkedin"],
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Potential sponsor" }));
|
||||
expect(props.onSponsorFilterChange).toHaveBeenCalledWith("potential");
|
||||
@@ -134,6 +150,7 @@ describe("OrchestratorFilters", () => {
|
||||
it("resets filters and only shows sources present in jobs", async () => {
|
||||
const { props } = renderFilters({
|
||||
sourcesWithJobs: ["gradcracker", "manual"],
|
||||
countriesWithJobs: [],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^filters/i }));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { KbdHint } from "@client/components/KbdHint";
|
||||
import { getDisplayKey, SHORTCUTS } from "@client/lib/shortcut-map";
|
||||
import { formatCountryLabel } from "@shared/location-support";
|
||||
import type { JobSource } from "@shared/types.js";
|
||||
import { Filter, Search } from "lucide-react";
|
||||
import type React from "react";
|
||||
@@ -41,8 +42,12 @@ interface OrchestratorFiltersProps {
|
||||
onTabChange: (value: FilterTab) => void;
|
||||
counts: Record<FilterTab, number>;
|
||||
onOpenCommandBar: () => void;
|
||||
sourceFilter: JobSource | "all";
|
||||
onSourceFilterChange: (value: JobSource | "all") => void;
|
||||
sourcesFilter: JobSource[];
|
||||
sourcesExcludeFilter: JobSource[];
|
||||
onSourceSelectionChange: (include: JobSource[], exclude: JobSource[]) => void;
|
||||
countriesFilter: string[];
|
||||
countriesExcludeFilter: string[];
|
||||
onCountrySelectionChange: (include: string[], exclude: string[]) => void;
|
||||
sponsorFilter: SponsorFilter;
|
||||
onSponsorFilterChange: (value: SponsorFilter) => void;
|
||||
workplaceFilter: WorkplaceFilter;
|
||||
@@ -50,6 +55,7 @@ interface OrchestratorFiltersProps {
|
||||
salaryFilter: SalaryFilter;
|
||||
onSalaryFilterChange: (value: SalaryFilter) => void;
|
||||
sourcesWithJobs: JobSource[];
|
||||
countriesWithJobs: string[];
|
||||
sort: JobSort;
|
||||
onSortChange: (sort: JobSort) => void;
|
||||
onResetFilters: () => void;
|
||||
@@ -130,8 +136,12 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
onTabChange,
|
||||
counts,
|
||||
onOpenCommandBar,
|
||||
sourceFilter,
|
||||
onSourceFilterChange,
|
||||
sourcesFilter,
|
||||
sourcesExcludeFilter,
|
||||
onSourceSelectionChange,
|
||||
countriesFilter,
|
||||
countriesExcludeFilter,
|
||||
onCountrySelectionChange,
|
||||
sponsorFilter,
|
||||
onSponsorFilterChange,
|
||||
workplaceFilter,
|
||||
@@ -139,6 +149,7 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
salaryFilter,
|
||||
onSalaryFilterChange,
|
||||
sourcesWithJobs,
|
||||
countriesWithJobs,
|
||||
sort,
|
||||
onSortChange,
|
||||
onResetFilters,
|
||||
@@ -154,9 +165,42 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
sourcesWithJobs.includes(source),
|
||||
);
|
||||
|
||||
const cycleSource = (source: JobSource) => {
|
||||
const inInclude = sourcesFilter.includes(source);
|
||||
const inExclude = sourcesExcludeFilter.includes(source);
|
||||
let nextInclude = [...sourcesFilter];
|
||||
let nextExclude = [...sourcesExcludeFilter];
|
||||
if (!inInclude && !inExclude) {
|
||||
nextInclude.push(source);
|
||||
} else if (inInclude) {
|
||||
nextInclude = nextInclude.filter((value) => value !== source);
|
||||
nextExclude.push(source);
|
||||
} else {
|
||||
nextExclude = nextExclude.filter((value) => value !== source);
|
||||
}
|
||||
onSourceSelectionChange(nextInclude, nextExclude);
|
||||
};
|
||||
|
||||
const cycleCountry = (countryKey: string) => {
|
||||
const inInclude = countriesFilter.includes(countryKey);
|
||||
const inExclude = countriesExcludeFilter.includes(countryKey);
|
||||
let nextInclude = [...countriesFilter];
|
||||
let nextExclude = [...countriesExcludeFilter];
|
||||
if (!inInclude && !inExclude) {
|
||||
nextInclude.push(countryKey);
|
||||
} else if (inInclude) {
|
||||
nextInclude = nextInclude.filter((value) => value !== countryKey);
|
||||
nextExclude.push(countryKey);
|
||||
} else {
|
||||
nextExclude = nextExclude.filter((value) => value !== countryKey);
|
||||
}
|
||||
onCountrySelectionChange(nextInclude, nextExclude);
|
||||
};
|
||||
|
||||
const activeFilterCount = useMemo(
|
||||
() =>
|
||||
Number(sourceFilter !== "all") +
|
||||
Number(sourcesFilter.length > 0 || sourcesExcludeFilter.length > 0) +
|
||||
Number(countriesFilter.length > 0 || countriesExcludeFilter.length > 0) +
|
||||
Number(sponsorFilter !== "all") +
|
||||
Number(workplaceFilter !== "all") +
|
||||
Number(
|
||||
@@ -164,7 +208,10 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
(typeof salaryFilter.max === "number" && salaryFilter.max > 0),
|
||||
),
|
||||
[
|
||||
sourceFilter,
|
||||
sourcesFilter.length,
|
||||
sourcesExcludeFilter.length,
|
||||
countriesFilter.length,
|
||||
countriesExcludeFilter.length,
|
||||
sponsorFilter,
|
||||
workplaceFilter,
|
||||
salaryFilter.min,
|
||||
@@ -246,8 +293,9 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
)}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Refine sources, sponsor status, workplace (remote), salary,
|
||||
and sorting.
|
||||
Refine sources and country: each button cycles off → include
|
||||
→ exclude (red). Remote listings always bypass country
|
||||
filters. Sponsor, workplace, salary, and sort below.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -262,24 +310,87 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={sourceFilter === "all" ? "default" : "outline"}
|
||||
onClick={() => onSourceFilterChange("all")}
|
||||
variant={
|
||||
sourcesFilter.length === 0 &&
|
||||
sourcesExcludeFilter.length === 0
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => onSourceSelectionChange([], [])}
|
||||
>
|
||||
All sources
|
||||
</Button>
|
||||
{visibleSources.map((source) => (
|
||||
{visibleSources.map((source) => {
|
||||
const excluded = sourcesExcludeFilter.includes(source);
|
||||
const included = sourcesFilter.includes(source);
|
||||
return (
|
||||
<Button
|
||||
key={source}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={
|
||||
excluded
|
||||
? "destructive"
|
||||
: included
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => cycleSource(source)}
|
||||
>
|
||||
{sourceLabel[source]}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Country</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Parsed from each job's location. Jobs marked remote
|
||||
in the listing always match include and exclude country
|
||||
rules.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
key={source}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={
|
||||
sourceFilter === source ? "default" : "outline"
|
||||
countriesFilter.length === 0 &&
|
||||
countriesExcludeFilter.length === 0
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => onSourceFilterChange(source)}
|
||||
onClick={() => onCountrySelectionChange([], [])}
|
||||
>
|
||||
{sourceLabel[source]}
|
||||
All countries
|
||||
</Button>
|
||||
))}
|
||||
{countriesWithJobs.map((countryKey) => {
|
||||
const excluded =
|
||||
countriesExcludeFilter.includes(countryKey);
|
||||
const included = countriesFilter.includes(countryKey);
|
||||
return (
|
||||
<Button
|
||||
key={countryKey}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={
|
||||
excluded
|
||||
? "destructive"
|
||||
: included
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => cycleCountry(countryKey)}
|
||||
>
|
||||
{formatCountryLabel(countryKey)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -31,7 +31,10 @@ describe("useFilteredJobs", () => {
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
@@ -59,7 +62,10 @@ describe("useFilteredJobs", () => {
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"ready",
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
@@ -88,7 +94,10 @@ describe("useFilteredJobs", () => {
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"confirmed",
|
||||
"all",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
@@ -114,7 +123,10 @@ describe("useFilteredJobs", () => {
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "between", min: 60000, max: 80000 },
|
||||
@@ -143,7 +155,10 @@ describe("useFilteredJobs", () => {
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
@@ -173,7 +188,10 @@ describe("useFilteredJobs", () => {
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"remote",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
@@ -186,7 +204,10 @@ describe("useFilteredJobs", () => {
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"not_remote",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
@@ -199,7 +220,10 @@ describe("useFilteredJobs", () => {
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"unknown",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
@@ -208,4 +232,134 @@ describe("useFilteredJobs", () => {
|
||||
);
|
||||
expect(unknown.current.map((j) => j.id)).toEqual(["unknown"]);
|
||||
});
|
||||
|
||||
it("allows multiple source filters", () => {
|
||||
const jobs: Job[] = [
|
||||
{ ...baseJob, id: "li", source: "linkedin" },
|
||||
{ ...baseJob, id: "in", source: "indeed" },
|
||||
{ ...baseJob, id: "gc", source: "gradcracker" },
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
["linkedin", "indeed"],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
{ key: "score", direction: "desc" },
|
||||
),
|
||||
);
|
||||
expect(result.current.map((j) => j.id).sort()).toEqual(["in", "li"]);
|
||||
});
|
||||
|
||||
it("filters by country but always keeps remote listings", () => {
|
||||
const jobs: Job[] = [
|
||||
{
|
||||
...baseJob,
|
||||
id: "uk-onsite",
|
||||
location: "London, UK",
|
||||
isRemote: false,
|
||||
},
|
||||
{
|
||||
...baseJob,
|
||||
id: "us-remote",
|
||||
location: "New York, NY, United States",
|
||||
isRemote: true,
|
||||
},
|
||||
{
|
||||
...baseJob,
|
||||
id: "us-onsite",
|
||||
location: "New York, NY, United States",
|
||||
isRemote: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
["united kingdom"],
|
||||
[],
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
{ key: "score", direction: "desc" },
|
||||
),
|
||||
);
|
||||
expect(result.current.map((j) => j.id).sort()).toEqual([
|
||||
"uk-onsite",
|
||||
"us-remote",
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes by country but keeps remote listings", () => {
|
||||
const jobs: Job[] = [
|
||||
{
|
||||
...baseJob,
|
||||
id: "uk-onsite",
|
||||
location: "London, UK",
|
||||
isRemote: false,
|
||||
},
|
||||
{
|
||||
...baseJob,
|
||||
id: "uk-remote",
|
||||
location: "London, UK",
|
||||
isRemote: true,
|
||||
},
|
||||
{
|
||||
...baseJob,
|
||||
id: "us-onsite",
|
||||
location: "New York, United States",
|
||||
isRemote: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
["united kingdom"],
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
{ key: "score", direction: "desc" },
|
||||
),
|
||||
);
|
||||
expect(result.current.map((j) => j.id).sort()).toEqual([
|
||||
"uk-remote",
|
||||
"us-onsite",
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes sources", () => {
|
||||
const jobs: Job[] = [
|
||||
{ ...baseJob, id: "li", source: "linkedin" },
|
||||
{ ...baseJob, id: "in", source: "indeed" },
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
[],
|
||||
["linkedin"],
|
||||
[],
|
||||
[],
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
{ key: "score", direction: "desc" },
|
||||
),
|
||||
);
|
||||
expect(result.current.map((j) => j.id)).toEqual(["in"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { inferCountryKeysFromJobLocation } from "@shared/search-cities";
|
||||
import type { JobListItem, JobSource } from "@shared/types";
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
@@ -19,7 +20,10 @@ const getSponsorCategory = (score: number | null): SponsorFilter => {
|
||||
export const useFilteredJobs = (
|
||||
jobs: JobListItem[],
|
||||
activeTab: FilterTab,
|
||||
sourceFilter: JobSource | "all",
|
||||
sourcesFilter: JobSource[],
|
||||
sourcesExcludeFilter: JobSource[],
|
||||
countriesFilter: string[],
|
||||
countriesExcludeFilter: string[],
|
||||
sponsorFilter: SponsorFilter,
|
||||
workplaceFilter: WorkplaceFilter,
|
||||
salaryFilter: SalaryFilter,
|
||||
@@ -46,8 +50,32 @@ export const useFilteredJobs = (
|
||||
filtered = filtered.filter((job) => job.closedAt == null);
|
||||
}
|
||||
|
||||
if (sourceFilter !== "all") {
|
||||
filtered = filtered.filter((job) => job.source === sourceFilter);
|
||||
if (sourcesFilter.length > 0) {
|
||||
const allow = new Set(sourcesFilter);
|
||||
filtered = filtered.filter((job) => allow.has(job.source));
|
||||
}
|
||||
|
||||
if (sourcesExcludeFilter.length > 0) {
|
||||
const deny = new Set(sourcesExcludeFilter);
|
||||
filtered = filtered.filter((job) => !deny.has(job.source));
|
||||
}
|
||||
|
||||
if (countriesFilter.length > 0) {
|
||||
const allowCountries = new Set(countriesFilter);
|
||||
filtered = filtered.filter((job) => {
|
||||
if (job.isRemote === true) return true;
|
||||
const jobCountries = inferCountryKeysFromJobLocation(job.location);
|
||||
return jobCountries.some((key) => allowCountries.has(key));
|
||||
});
|
||||
}
|
||||
|
||||
if (countriesExcludeFilter.length > 0) {
|
||||
const denyCountries = new Set(countriesExcludeFilter);
|
||||
filtered = filtered.filter((job) => {
|
||||
if (job.isRemote === true) return true;
|
||||
const jobCountries = inferCountryKeysFromJobLocation(job.location);
|
||||
return !jobCountries.some((key) => denyCountries.has(key));
|
||||
});
|
||||
}
|
||||
|
||||
if (sponsorFilter !== "all") {
|
||||
@@ -106,7 +134,10 @@ export const useFilteredJobs = (
|
||||
}, [
|
||||
jobs,
|
||||
activeTab,
|
||||
sourceFilter,
|
||||
sourcesFilter,
|
||||
sourcesExcludeFilter,
|
||||
countriesFilter,
|
||||
countriesExcludeFilter,
|
||||
sponsorFilter,
|
||||
workplaceFilter,
|
||||
salaryFilter,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -39,4 +39,54 @@ describe("useOrchestratorFilters", () => {
|
||||
expect(result.current.sort).toEqual(DEFAULT_SORT);
|
||||
}
|
||||
});
|
||||
|
||||
it("parses multiple sources and countries from the query string", () => {
|
||||
const { result } = renderHook(() => useOrchestratorFilters(), {
|
||||
wrapper: createWrapper(
|
||||
"/ready?source=indeed&source=linkedin&countries=united%20kingdom",
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current.sourcesFilter).toEqual(["indeed", "linkedin"]);
|
||||
expect(result.current.countriesFilter).toEqual(["united kingdom"]);
|
||||
});
|
||||
|
||||
it("writes multiple sources and countries to the query string", () => {
|
||||
const { result } = renderHook(() => useOrchestratorFilters(), {
|
||||
wrapper: createWrapper("/ready"),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setSourceSelection(["indeed", "linkedin"], []);
|
||||
});
|
||||
act(() => {
|
||||
result.current.setCountrySelection(["united kingdom", "canada"], []);
|
||||
});
|
||||
|
||||
const params = result.current.searchParams;
|
||||
expect(params.getAll("source").sort()).toEqual(["indeed", "linkedin"]);
|
||||
expect(params.get("countries")).toBe("canada,united kingdom");
|
||||
});
|
||||
|
||||
it("parses and writes country and source excludes", () => {
|
||||
const { result } = renderHook(() => useOrchestratorFilters(), {
|
||||
wrapper: createWrapper(
|
||||
"/ready?sourceExclude=linkedin&countriesExclude=united%20kingdom",
|
||||
),
|
||||
});
|
||||
expect(result.current.sourcesExcludeFilter).toEqual(["linkedin"]);
|
||||
expect(result.current.countriesExcludeFilter).toEqual(["united kingdom"]);
|
||||
|
||||
act(() => {
|
||||
result.current.setSourceSelection(["indeed"], ["linkedin"]);
|
||||
});
|
||||
act(() => {
|
||||
result.current.setCountrySelection(["canada"], ["united kingdom"]);
|
||||
});
|
||||
const params = result.current.searchParams;
|
||||
expect(params.getAll("source")).toEqual(["indeed"]);
|
||||
expect(params.getAll("sourceExclude")).toEqual(["linkedin"]);
|
||||
expect(params.get("countries")).toBe("canada");
|
||||
expect(params.get("countriesExclude")).toBe("united kingdom");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { JobSource } from "@shared/types.js";
|
||||
import { EXTRACTOR_SOURCE_IDS } from "@shared/extractors";
|
||||
import {
|
||||
normalizeCountryKey,
|
||||
SUPPORTED_COUNTRY_KEYS,
|
||||
} from "@shared/location-support";
|
||||
import type { JobSource } from "@shared/types";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type {
|
||||
@@ -38,6 +43,9 @@ const allowedWorkplaceFilters: WorkplaceFilter[] = [
|
||||
"unknown",
|
||||
];
|
||||
|
||||
const allowedJobSources = new Set<string>(EXTRACTOR_SOURCE_IDS);
|
||||
const allowedCountryKeys = new Set(SUPPORTED_COUNTRY_KEYS);
|
||||
|
||||
export const useOrchestratorFilters = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -52,14 +60,109 @@ export const useOrchestratorFilters = () => {
|
||||
);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const sourceFilter =
|
||||
(searchParams.get("source") as JobSource | "all") || "all";
|
||||
const setSourceFilter = useCallback(
|
||||
(source: JobSource | "all") => {
|
||||
const { sourcesFilter, sourcesExcludeFilter } = useMemo(() => {
|
||||
const rawInc = searchParams.getAll("source");
|
||||
const include = rawInc
|
||||
.map((value) => value.trim())
|
||||
.filter(
|
||||
(value): value is JobSource =>
|
||||
Boolean(value) && allowedJobSources.has(value),
|
||||
);
|
||||
const includeSet = new Set(include);
|
||||
const rawExc = searchParams.getAll("sourceExclude");
|
||||
const exclude = rawExc
|
||||
.map((value) => value.trim())
|
||||
.filter(
|
||||
(value): value is JobSource =>
|
||||
Boolean(value) &&
|
||||
allowedJobSources.has(value) &&
|
||||
!includeSet.has(value),
|
||||
);
|
||||
return {
|
||||
sourcesFilter: [...new Set(include)],
|
||||
sourcesExcludeFilter: [...new Set(exclude)],
|
||||
};
|
||||
}, [searchParams]);
|
||||
|
||||
const setSourceSelection = useCallback(
|
||||
(include: JobSource[], exclude: JobSource[]) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
if (source !== "all") prev.set("source", source);
|
||||
else prev.delete("source");
|
||||
prev.delete("source");
|
||||
prev.delete("sourceExclude");
|
||||
const inc = [
|
||||
...new Set(
|
||||
include.filter((source) => allowedJobSources.has(source)),
|
||||
),
|
||||
].sort((left, right) => left.localeCompare(right));
|
||||
const incSet = new Set(inc);
|
||||
const exc = [
|
||||
...new Set(
|
||||
exclude.filter(
|
||||
(source) =>
|
||||
allowedJobSources.has(source) && !incSet.has(source),
|
||||
),
|
||||
),
|
||||
].sort((left, right) => left.localeCompare(right));
|
||||
for (const source of inc) prev.append("source", source);
|
||||
for (const source of exc) prev.append("sourceExclude", source);
|
||||
return prev;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const { countriesFilter, countriesExcludeFilter } = useMemo(() => {
|
||||
const parseList = (raw: string | null) => {
|
||||
if (!raw?.trim()) return [];
|
||||
return raw
|
||||
.split(",")
|
||||
.map((segment) => normalizeCountryKey(segment.trim()))
|
||||
.filter((key) => key && allowedCountryKeys.has(key));
|
||||
};
|
||||
|
||||
const include = [...new Set(parseList(searchParams.get("countries")))];
|
||||
const includeSet = new Set(include);
|
||||
const excludeRaw = parseList(searchParams.get("countriesExclude"));
|
||||
const exclude = [
|
||||
...new Set(excludeRaw.filter((key) => !includeSet.has(key))),
|
||||
];
|
||||
return {
|
||||
countriesFilter: include.sort((left, right) => left.localeCompare(right)),
|
||||
countriesExcludeFilter: exclude.sort((left, right) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
};
|
||||
}, [searchParams]);
|
||||
|
||||
const setCountrySelection = useCallback(
|
||||
(includeKeys: string[], excludeKeys: string[]) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const inc = [
|
||||
...new Set(
|
||||
includeKeys
|
||||
.map((key) => normalizeCountryKey(key))
|
||||
.filter((key) => key && allowedCountryKeys.has(key)),
|
||||
),
|
||||
].sort((left, right) => left.localeCompare(right));
|
||||
const incSet = new Set(inc);
|
||||
const exc = [
|
||||
...new Set(
|
||||
excludeKeys
|
||||
.map((key) => normalizeCountryKey(key))
|
||||
.filter(
|
||||
(key) =>
|
||||
key && allowedCountryKeys.has(key) && !incSet.has(key),
|
||||
),
|
||||
),
|
||||
].sort((left, right) => left.localeCompare(right));
|
||||
if (inc.length === 0) prev.delete("countries");
|
||||
else prev.set("countries", inc.join(","));
|
||||
if (exc.length === 0) prev.delete("countriesExclude");
|
||||
else prev.set("countriesExclude", exc.join(","));
|
||||
return prev;
|
||||
},
|
||||
{ replace: true },
|
||||
@@ -192,6 +295,9 @@ export const useOrchestratorFilters = () => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
prev.delete("source");
|
||||
prev.delete("sourceExclude");
|
||||
prev.delete("countries");
|
||||
prev.delete("countriesExclude");
|
||||
prev.delete("sponsor");
|
||||
prev.delete("workplace");
|
||||
prev.delete("salaryMode");
|
||||
@@ -207,8 +313,12 @@ export const useOrchestratorFilters = () => {
|
||||
|
||||
return {
|
||||
searchParams,
|
||||
sourceFilter,
|
||||
setSourceFilter,
|
||||
sourcesFilter,
|
||||
sourcesExcludeFilter,
|
||||
setSourceSelection,
|
||||
countriesFilter,
|
||||
countriesExcludeFilter,
|
||||
setCountrySelection,
|
||||
sponsorFilter,
|
||||
setSponsorFilter,
|
||||
workplaceFilter,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { formatCountryLabel } from "@shared/location-support";
|
||||
import { inferCountryKeysFromJobLocation } from "@shared/search-cities";
|
||||
import type { AppSettings, JobListItem, JobSource } from "@shared/types";
|
||||
import type { FilterTab, JobSort } from "./constants";
|
||||
import {
|
||||
@@ -165,6 +167,22 @@ export const getSourcesWithJobs = (jobs: JobListItem[]): JobSource[] => {
|
||||
return orderedFilterSources.filter((source) => seen.has(source));
|
||||
};
|
||||
|
||||
export const getCountriesWithJobs = (jobs: JobListItem[]): string[] => {
|
||||
const seen = new Set<string>();
|
||||
for (const job of jobs) {
|
||||
for (const key of inferCountryKeysFromJobLocation(job.location)) {
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
return [...seen].sort((left, right) =>
|
||||
formatCountryLabel(left).localeCompare(
|
||||
formatCountryLabel(right),
|
||||
undefined,
|
||||
{ sensitivity: "base" },
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const getEnabledSources = (
|
||||
settings: AppSettings | null,
|
||||
): JobSource[] => {
|
||||
|
||||
Reference in New Issue
Block a user