Additional Filter support (#103)
* Initial commit * drawer initial implementation * fix resetting * formattign * sort by salary * sentence reading * salary bounds * responsiveness * fix copy * floating bar on mobile * allow selecting on mobile * formatting * refactor * don't count test files * validate url after parsing
This commit is contained in:
parent
2fe0dc2c2f
commit
cf7032ce5e
@ -158,16 +158,31 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
|
||||
OrchestratorFilters: ({
|
||||
onTabChange,
|
||||
onSearchQueryChange,
|
||||
onSourceFilterChange,
|
||||
onSponsorFilterChange,
|
||||
onSalaryFilterChange,
|
||||
onResetFilters,
|
||||
onSortChange,
|
||||
sourcesWithJobs,
|
||||
filteredCount,
|
||||
}: {
|
||||
onTabChange: (t: FilterTab) => void;
|
||||
onSearchQueryChange: (q: string) => void;
|
||||
onSourceFilterChange: (source: string) => void;
|
||||
onSponsorFilterChange: (value: string) => void;
|
||||
onSalaryFilterChange: (value: {
|
||||
mode: "at_least" | "at_most" | "between";
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
}) => void;
|
||||
onResetFilters: () => void;
|
||||
onSortChange: (s: any) => void;
|
||||
sourcesWithJobs: string[];
|
||||
filteredCount: number;
|
||||
}) => (
|
||||
<div data-testid="filters">
|
||||
<div data-testid="sources-with-jobs">{sourcesWithJobs.join(",")}</div>
|
||||
<div data-testid="filtered-count">{filteredCount}</div>
|
||||
<button type="button" onClick={() => onTabChange("discovered")}>
|
||||
To Discovered
|
||||
</button>
|
||||
@ -180,6 +195,27 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
|
||||
>
|
||||
Set Sort
|
||||
</button>
|
||||
<button type="button" onClick={() => onSourceFilterChange("linkedin")}>
|
||||
Set Source
|
||||
</button>
|
||||
<button type="button" onClick={() => onSponsorFilterChange("confirmed")}>
|
||||
Set Sponsor
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSalaryFilterChange({
|
||||
mode: "between",
|
||||
min: 60000,
|
||||
max: 90000,
|
||||
})
|
||||
}
|
||||
>
|
||||
Set Salary Range
|
||||
</button>
|
||||
<button type="button" onClick={onResetFilters}>
|
||||
Reset Filters
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@ -412,6 +448,57 @@ describe("OrchestratorPage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("syncs source, sponsor, and salary range filters to URL and resets them", () => {
|
||||
window.matchMedia = createMatchMedia(
|
||||
true,
|
||||
) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<LocationWatcher />
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Set Source"));
|
||||
expect(screen.getByTestId("location").textContent).toContain(
|
||||
"source=linkedin",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Set Sponsor"));
|
||||
expect(screen.getByTestId("location").textContent).toContain(
|
||||
"sponsor=confirmed",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Set Salary Range"));
|
||||
expect(screen.getByTestId("location").textContent).toContain(
|
||||
"salaryMode=between",
|
||||
);
|
||||
expect(screen.getByTestId("location").textContent).toContain(
|
||||
"salaryMin=60000",
|
||||
);
|
||||
expect(screen.getByTestId("location").textContent).toContain(
|
||||
"salaryMax=90000",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Set Sort"));
|
||||
expect(screen.getByTestId("location").textContent).toContain(
|
||||
"sort=title-asc",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Reset Filters"));
|
||||
const locationText = screen.getByTestId("location").textContent || "";
|
||||
expect(locationText).not.toContain("source=");
|
||||
expect(locationText).not.toContain("sponsor=");
|
||||
expect(locationText).not.toContain("salaryMode=");
|
||||
expect(locationText).not.toContain("salaryMin=");
|
||||
expect(locationText).not.toContain("salaryMax=");
|
||||
expect(locationText).not.toContain("sort=");
|
||||
});
|
||||
|
||||
it("opens the detail drawer on mobile when a job is selected", () => {
|
||||
window.matchMedia = createMatchMedia(
|
||||
false,
|
||||
|
||||
@ -6,15 +6,14 @@ import { useSettings } from "@client/hooks/useSettings";
|
||||
import type { JobSource } from "@shared/types.js";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
|
||||
import * as api from "../api";
|
||||
import type { AutomaticRunValues } from "./orchestrator/automatic-run";
|
||||
import { deriveExtractorLimits } from "./orchestrator/automatic-run";
|
||||
import type { FilterTab, JobSort } from "./orchestrator/constants";
|
||||
import { DEFAULT_SORT } from "./orchestrator/constants";
|
||||
import type { FilterTab } from "./orchestrator/constants";
|
||||
import { FloatingBulkActionsBar } from "./orchestrator/FloatingBulkActionsBar";
|
||||
import { JobDetailPanel } from "./orchestrator/JobDetailPanel";
|
||||
import { JobListPanel } from "./orchestrator/JobListPanel";
|
||||
@ -26,6 +25,7 @@ import type { RunMode } from "./orchestrator/run-mode";
|
||||
import { useBulkJobSelection } from "./orchestrator/useBulkJobSelection";
|
||||
import { useFilteredJobs } from "./orchestrator/useFilteredJobs";
|
||||
import { useOrchestratorData } from "./orchestrator/useOrchestratorData";
|
||||
import { useOrchestratorFilters } from "./orchestrator/useOrchestratorFilters";
|
||||
import { usePipelineSources } from "./orchestrator/usePipelineSources";
|
||||
import {
|
||||
getEnabledSources,
|
||||
@ -36,7 +36,20 @@ import {
|
||||
export const OrchestratorPage: React.FC = () => {
|
||||
const { tab, jobId } = useParams<{ tab: string; jobId?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const {
|
||||
searchParams,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
sourceFilter,
|
||||
setSourceFilter,
|
||||
sponsorFilter,
|
||||
setSponsorFilter,
|
||||
salaryFilter,
|
||||
setSalaryFilter,
|
||||
sort,
|
||||
setSort,
|
||||
resetFilters,
|
||||
} = useOrchestratorFilters();
|
||||
|
||||
const activeTab = useMemo(() => {
|
||||
const validTabs: FilterTab[] = ["ready", "discovered", "applied", "all"];
|
||||
@ -61,70 +74,6 @@ export const OrchestratorPage: React.FC = () => {
|
||||
|
||||
const selectedJobId = jobId || null;
|
||||
|
||||
// Sync searchQuery with URL
|
||||
const searchQuery = searchParams.get("q") || "";
|
||||
const setSearchQuery = useCallback(
|
||||
(q: string) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
if (q) prev.set("q", q);
|
||||
else prev.delete("q");
|
||||
return prev;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
// Sync sourceFilter with URL
|
||||
const sourceFilter =
|
||||
(searchParams.get("source") as JobSource | "all") || "all";
|
||||
const setSourceFilter = useCallback(
|
||||
(source: JobSource | "all") => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
if (source !== "all") prev.set("source", source);
|
||||
else prev.delete("source");
|
||||
return prev;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
// Sync sort with URL
|
||||
const sort = useMemo((): JobSort => {
|
||||
const s = searchParams.get("sort");
|
||||
if (!s) return DEFAULT_SORT;
|
||||
const [key, direction] = s.split("-");
|
||||
return {
|
||||
key: key as JobSort["key"],
|
||||
direction: direction as JobSort["direction"],
|
||||
};
|
||||
}, [searchParams]);
|
||||
|
||||
const setSort = useCallback(
|
||||
(newSort: JobSort) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
if (
|
||||
newSort.key === DEFAULT_SORT.key &&
|
||||
newSort.direction === DEFAULT_SORT.direction
|
||||
) {
|
||||
prev.delete("sort");
|
||||
} else {
|
||||
prev.set("sort", `${newSort.key}-${newSort.direction}`);
|
||||
}
|
||||
return prev;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
// Effect to sync URL if it was invalid
|
||||
useEffect(() => {
|
||||
const validTabs: FilterTab[] = ["ready", "discovered", "applied", "all"];
|
||||
@ -178,6 +127,8 @@ export const OrchestratorPage: React.FC = () => {
|
||||
jobs,
|
||||
activeTab,
|
||||
sourceFilter,
|
||||
sponsorFilter,
|
||||
salaryFilter,
|
||||
searchQuery,
|
||||
sort,
|
||||
);
|
||||
@ -385,7 +336,11 @@ export const OrchestratorPage: React.FC = () => {
|
||||
onCancelPipeline={handleCancelPipeline}
|
||||
/>
|
||||
|
||||
<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 ${
|
||||
selectedJobIds.size > 0 ? "pb-36 lg:pb-12" : "pb-12"
|
||||
}`}
|
||||
>
|
||||
<OrchestratorSummary
|
||||
stats={stats}
|
||||
isPipelineRunning={isPipelineRunning}
|
||||
@ -401,9 +356,15 @@ export const OrchestratorPage: React.FC = () => {
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
sourceFilter={sourceFilter}
|
||||
onSourceFilterChange={setSourceFilter}
|
||||
sponsorFilter={sponsorFilter}
|
||||
onSponsorFilterChange={setSponsorFilter}
|
||||
salaryFilter={salaryFilter}
|
||||
onSalaryFilterChange={setSalaryFilter}
|
||||
sourcesWithJobs={sourcesWithJobs}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
onResetFilters={resetFilters}
|
||||
filteredCount={activeJobs.length}
|
||||
/>
|
||||
|
||||
{/* List/Detail grid - directly under tabs, no extra section */}
|
||||
|
||||
@ -44,22 +44,24 @@ export const FloatingBulkActionsBar: React.FC<FloatingBulkActionsBarProps> = ({
|
||||
if (!isMounted) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-50 flex justify-center px-3 sm:px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto flex flex-wrap items-center gap-2 rounded-xl border border-border/70 bg-card/95 px-3 py-2 shadow-xl backdrop-blur supports-[backdrop-filter]:bg-card/85",
|
||||
"pointer-events-auto flex w-full max-w-md flex-col items-stretch gap-2 rounded-xl border border-border/70 bg-card/95 px-3 py-2 shadow-xl backdrop-blur supports-[backdrop-filter]:bg-card/85 sm:w-auto sm:max-w-none sm:flex-row sm:flex-wrap sm:items-center",
|
||||
"transition-all duration-200 ease-out",
|
||||
isVisible ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
<div className="text-xs text-muted-foreground tabular-nums sm:mr-1">
|
||||
{selectedCount} selected
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
|
||||
{canMoveSelected && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={bulkActionInFlight}
|
||||
onClick={onMoveToReady}
|
||||
>
|
||||
@ -71,6 +73,7 @@ export const FloatingBulkActionsBar: React.FC<FloatingBulkActionsBarProps> = ({
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={bulkActionInFlight}
|
||||
onClick={onSkipSelected}
|
||||
>
|
||||
@ -82,6 +85,7 @@ export const FloatingBulkActionsBar: React.FC<FloatingBulkActionsBarProps> = ({
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={bulkActionInFlight}
|
||||
onClick={onRescoreSelected}
|
||||
>
|
||||
@ -92,6 +96,7 @@ export const FloatingBulkActionsBar: React.FC<FloatingBulkActionsBarProps> = ({
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={onClear}
|
||||
disabled={bulkActionInFlight}
|
||||
>
|
||||
@ -99,5 +104,6 @@ export const FloatingBulkActionsBar: React.FC<FloatingBulkActionsBarProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -48,7 +48,7 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/40">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2 opacity-50 hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2 opacity-100 transition-opacity sm:opacity-50 sm:hover:opacity-100">
|
||||
<label
|
||||
htmlFor="job-list-select-all"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground"
|
||||
@ -103,7 +103,7 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
|
||||
"data-[state=checked]:shadow-[0_0_0_1px_hsl(var(--primary)/0.35)]",
|
||||
isChecked || isSelected
|
||||
? "opacity-100"
|
||||
: "opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100",
|
||||
: "opacity-100 pointer-events-auto sm:opacity-0 sm:pointer-events-none sm:group-hover:pointer-events-auto sm:group-hover:opacity-100",
|
||||
)}
|
||||
/>
|
||||
{/* Single status indicator: subtle dot */}
|
||||
|
||||
@ -1,80 +1,24 @@
|
||||
import type { JobSource } from "@shared/types.js";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FilterTab, JobSort } from "./constants";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { FilterTab, JobSort, SponsorFilter } from "./constants";
|
||||
import { OrchestratorFilters } from "./OrchestratorFilters";
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||
const React = require("react") as typeof import("react");
|
||||
const RadioGroupContext = React.createContext<
|
||||
((value: string) => void) | null
|
||||
>(null);
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
|
||||
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>
|
||||
),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onSelect?.()}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuRadioGroup: ({
|
||||
children,
|
||||
onValueChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onValueChange?: (value: string) => void;
|
||||
}) => (
|
||||
<RadioGroupContext.Provider value={onValueChange ?? null}>
|
||||
<div role="radiogroup">{children}</div>
|
||||
</RadioGroupContext.Provider>
|
||||
),
|
||||
DropdownMenuRadioItem: ({
|
||||
children,
|
||||
value,
|
||||
checked,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
checked?: boolean;
|
||||
}) => {
|
||||
const onValueChange = React.useContext(RadioGroupContext);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={checked}
|
||||
onClick={() => onValueChange?.(value)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
};
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: originalScrollIntoView,
|
||||
});
|
||||
});
|
||||
|
||||
const renderFilters = (
|
||||
@ -93,9 +37,19 @@ const renderFilters = (
|
||||
onSearchQueryChange: vi.fn(),
|
||||
sourceFilter: "all" as const,
|
||||
onSourceFilterChange: vi.fn(),
|
||||
sponsorFilter: "all" as SponsorFilter,
|
||||
onSponsorFilterChange: vi.fn(),
|
||||
salaryFilter: {
|
||||
mode: "at_least" as const,
|
||||
min: null,
|
||||
max: null,
|
||||
},
|
||||
onSalaryFilterChange: vi.fn(),
|
||||
sourcesWithJobs: ["gradcracker", "linkedin", "manual"] as JobSource[],
|
||||
sort: { key: "score", direction: "desc" } as JobSort,
|
||||
onSortChange: vi.fn(),
|
||||
onResetFilters: vi.fn(),
|
||||
filteredCount: 5,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@ -118,41 +72,74 @@ describe("OrchestratorFilters", () => {
|
||||
expect(props.onSearchQueryChange).toHaveBeenCalledWith("Design");
|
||||
});
|
||||
|
||||
it("updates source and sort selections", async () => {
|
||||
it("updates source, sponsor, salary range, and sort from the drawer", async () => {
|
||||
const { props } = renderFilters();
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("menuitemradio", { name: /LinkedIn/i }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^filters/i }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "LinkedIn" }));
|
||||
expect(props.onSourceFilterChange).toHaveBeenCalledWith("linkedin");
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: /score/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Potential sponsor" }));
|
||||
expect(props.onSponsorFilterChange).toHaveBeenCalledWith("potential");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Minimum"), {
|
||||
target: { value: "65000" },
|
||||
});
|
||||
expect(props.onSalaryFilterChange).toHaveBeenCalledWith({
|
||||
mode: "at_least",
|
||||
min: 65000,
|
||||
max: null,
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("menuitem", { name: /Direction:/i }),
|
||||
screen.getByRole("combobox", { name: "Salary range specifier" }),
|
||||
);
|
||||
fireEvent.click(await screen.findByText("between"));
|
||||
expect(props.onSalaryFilterChange).toHaveBeenCalledWith({
|
||||
mode: "between",
|
||||
min: null,
|
||||
max: null,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("combobox", { name: "Sort field" }));
|
||||
fireEvent.click(await screen.findByText("Title"));
|
||||
expect(props.onSortChange).toHaveBeenCalledWith({
|
||||
key: "title",
|
||||
direction: "asc",
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("combobox", { name: "Sort field" }));
|
||||
fireEvent.click(await screen.findByText("Company"));
|
||||
expect(props.onSortChange).toHaveBeenCalledWith({
|
||||
key: "employer",
|
||||
direction: "asc",
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("combobox", { name: "Sort order" }));
|
||||
fireEvent.click(await screen.findByText("smallest first"));
|
||||
expect(props.onSortChange).toHaveBeenCalledWith({
|
||||
key: "score",
|
||||
direction: "asc",
|
||||
});
|
||||
});
|
||||
|
||||
it("only shows sources that exist in jobs", async () => {
|
||||
renderFilters({ sourcesWithJobs: ["gradcracker", "manual"] });
|
||||
it("resets filters and only shows sources present in jobs", () => {
|
||||
const { props } = renderFilters({
|
||||
sourcesWithJobs: ["gradcracker", "manual"],
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^filters/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("menuitemradio", { name: /Gradcracker/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("menuitemradio", { name: /LinkedIn/i }),
|
||||
screen.queryByRole("button", { name: "LinkedIn" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("menuitemradio", { name: /UK Visa Jobs/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("menuitemradio", { name: /Manual/i }),
|
||||
screen.getByRole("button", { name: "Gradcracker" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Manual" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reset" }));
|
||||
expect(props.onResetFilters).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,27 +1,37 @@
|
||||
import type { JobSource } from "@shared/types.js";
|
||||
import { ArrowUpDown, Filter, Search } from "lucide-react";
|
||||
import { Filter, Search } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { sourceLabel } from "@/lib/utils";
|
||||
import type { FilterTab, JobSort } from "./constants";
|
||||
import {
|
||||
defaultSortDirection,
|
||||
orderedFilterSources,
|
||||
sortLabels,
|
||||
tabs,
|
||||
import type {
|
||||
FilterTab,
|
||||
JobSort,
|
||||
SalaryFilter,
|
||||
SalaryFilterMode,
|
||||
SponsorFilter,
|
||||
} from "./constants";
|
||||
import { defaultSortDirection, orderedFilterSources, tabs } from "./constants";
|
||||
|
||||
interface OrchestratorFiltersProps {
|
||||
activeTab: FilterTab;
|
||||
@ -31,11 +41,74 @@ interface OrchestratorFiltersProps {
|
||||
onSearchQueryChange: (value: string) => void;
|
||||
sourceFilter: JobSource | "all";
|
||||
onSourceFilterChange: (value: JobSource | "all") => void;
|
||||
sponsorFilter: SponsorFilter;
|
||||
onSponsorFilterChange: (value: SponsorFilter) => void;
|
||||
salaryFilter: SalaryFilter;
|
||||
onSalaryFilterChange: (value: SalaryFilter) => void;
|
||||
sourcesWithJobs: JobSource[];
|
||||
sort: JobSort;
|
||||
onSortChange: (sort: JobSort) => void;
|
||||
onResetFilters: () => void;
|
||||
filteredCount: number;
|
||||
}
|
||||
|
||||
const sponsorOptions: Array<{
|
||||
value: SponsorFilter;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "all", label: "All statuses" },
|
||||
{ value: "confirmed", label: "Confirmed sponsor" },
|
||||
{ value: "potential", label: "Potential sponsor" },
|
||||
{ value: "not_found", label: "Sponsor not found" },
|
||||
{ value: "unknown", label: "Unchecked sponsor" },
|
||||
];
|
||||
|
||||
const salaryModeOptions: Array<{
|
||||
value: SalaryFilterMode;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "at_least", label: "at least" },
|
||||
{ value: "at_most", label: "at most" },
|
||||
{ value: "between", label: "between" },
|
||||
];
|
||||
|
||||
const sortFieldOrder: JobSort["key"][] = [
|
||||
"score",
|
||||
"discoveredAt",
|
||||
"salary",
|
||||
"title",
|
||||
"employer",
|
||||
];
|
||||
|
||||
const sortFieldLabels: Record<JobSort["key"], string> = {
|
||||
score: "Score",
|
||||
discoveredAt: "Discovered",
|
||||
salary: "Salary",
|
||||
title: "Title",
|
||||
employer: "Company",
|
||||
};
|
||||
|
||||
const getDirectionOptions = (
|
||||
key: JobSort["key"],
|
||||
): Array<{ value: JobSort["direction"]; label: string }> => {
|
||||
if (key === "discoveredAt") {
|
||||
return [
|
||||
{ value: "desc", label: "newest first" },
|
||||
{ value: "asc", label: "oldest first" },
|
||||
];
|
||||
}
|
||||
if (key === "score" || key === "salary") {
|
||||
return [
|
||||
{ value: "desc", label: "largest first" },
|
||||
{ value: "asc", label: "smallest first" },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ value: "asc", label: "A to Z" },
|
||||
{ value: "desc", label: "Z to A" },
|
||||
];
|
||||
};
|
||||
|
||||
export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
@ -44,14 +117,36 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
onSearchQueryChange,
|
||||
sourceFilter,
|
||||
onSourceFilterChange,
|
||||
sponsorFilter,
|
||||
onSponsorFilterChange,
|
||||
salaryFilter,
|
||||
onSalaryFilterChange,
|
||||
sourcesWithJobs,
|
||||
sort,
|
||||
onSortChange,
|
||||
onResetFilters,
|
||||
filteredCount,
|
||||
}) => {
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const visibleSources = orderedFilterSources.filter((source) =>
|
||||
sourcesWithJobs.includes(source),
|
||||
);
|
||||
|
||||
const activeFilterCount = useMemo(
|
||||
() =>
|
||||
Number(sourceFilter !== "all") +
|
||||
Number(sponsorFilter !== "all") +
|
||||
Number(
|
||||
(typeof salaryFilter.min === "number" && salaryFilter.min > 0) ||
|
||||
(typeof salaryFilter.max === "number" && salaryFilter.max > 0),
|
||||
),
|
||||
[sourceFilter, sponsorFilter, salaryFilter.min, salaryFilter.max],
|
||||
);
|
||||
const showSalaryMin =
|
||||
salaryFilter.mode === "at_least" || salaryFilter.mode === "between";
|
||||
const showSalaryMax =
|
||||
salaryFilter.mode === "at_most" || salaryFilter.mode === "between";
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
@ -85,85 +180,307 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
className="h-8 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
<Sheet open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
{sourceFilter === "all"
|
||||
? "All sources"
|
||||
: sourceLabel[sourceFilter]}
|
||||
Filters
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary/20 px-1 text-[10px] font-semibold tabular-nums text-primary">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Filter by source</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={sourceFilter}
|
||||
onValueChange={(value) =>
|
||||
onSourceFilterChange(value as JobSource | "all")
|
||||
</SheetTrigger>
|
||||
|
||||
<SheetContent side="right" className="w-full sm:max-w-2xl">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
Filters
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary/20 px-1 text-[11px] font-semibold tabular-nums text-primary">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Refine sources, sponsor status, salary, and sorting.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Sources</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={sourceFilter === "all" ? "default" : "outline"}
|
||||
onClick={() => onSourceFilterChange("all")}
|
||||
>
|
||||
All sources
|
||||
</Button>
|
||||
{visibleSources.map((source) => (
|
||||
<Button
|
||||
key={source}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={
|
||||
sourceFilter === source ? "default" : "outline"
|
||||
}
|
||||
onClick={() => onSourceFilterChange(source)}
|
||||
>
|
||||
{sourceLabel[source]}
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Sponsor status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
{sponsorOptions.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={
|
||||
sponsorFilter === option.value
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => onSponsorFilterChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Salary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Salary is</span>
|
||||
<Select
|
||||
value={salaryFilter.mode}
|
||||
onValueChange={(value) => {
|
||||
const nextMode = value as SalaryFilterMode;
|
||||
if (nextMode === "at_least") {
|
||||
onSalaryFilterChange({
|
||||
mode: nextMode,
|
||||
min: salaryFilter.min,
|
||||
max: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (nextMode === "at_most") {
|
||||
onSalaryFilterChange({
|
||||
mode: nextMode,
|
||||
min: null,
|
||||
max: salaryFilter.max,
|
||||
});
|
||||
return;
|
||||
}
|
||||
onSalaryFilterChange({
|
||||
mode: nextMode,
|
||||
min: salaryFilter.min,
|
||||
max: salaryFilter.max,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="salary-mode"
|
||||
aria-label="Salary range specifier"
|
||||
className="h-8 w-[170px] text-foreground"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{salaryModeOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
showSalaryMin && showSalaryMax
|
||||
? "grid gap-3 md:grid-cols-2"
|
||||
: "space-y-3"
|
||||
}
|
||||
>
|
||||
<DropdownMenuRadioItem value="all">
|
||||
All Sources
|
||||
</DropdownMenuRadioItem>
|
||||
{visibleSources.map((source) => (
|
||||
<DropdownMenuRadioItem key={source} value={source}>
|
||||
{sourceLabel[source]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{showSalaryMin && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="salary-min-filter">Minimum</Label>
|
||||
<Input
|
||||
id="salary-min-filter"
|
||||
value={
|
||||
salaryFilter.min == null
|
||||
? ""
|
||||
: String(salaryFilter.min)
|
||||
}
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value.trim();
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
onSalaryFilterChange({
|
||||
...salaryFilter,
|
||||
min:
|
||||
Number.isFinite(parsed) && parsed > 0
|
||||
? parsed
|
||||
: null,
|
||||
});
|
||||
}}
|
||||
inputMode="numeric"
|
||||
placeholder="e.g. 60000"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
|
||||
>
|
||||
<ArrowUpDown className="h-3.5 w-3.5" />
|
||||
{sortLabels[sort.key]}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
{showSalaryMax && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="salary-max-filter">Maximum</Label>
|
||||
<Input
|
||||
id="salary-max-filter"
|
||||
value={
|
||||
salaryFilter.max == null
|
||||
? ""
|
||||
: String(salaryFilter.max)
|
||||
}
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value.trim();
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
onSalaryFilterChange({
|
||||
...salaryFilter,
|
||||
max:
|
||||
Number.isFinite(parsed) && parsed > 0
|
||||
? parsed
|
||||
: null,
|
||||
});
|
||||
}}
|
||||
inputMode="numeric"
|
||||
placeholder="e.g. 100000"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Sort</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-col gap-2 text-sm text-muted-foreground sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap">Sort by</span>
|
||||
<Select
|
||||
value={sort.key}
|
||||
onValueChange={(value) =>
|
||||
onSortChange({
|
||||
key: value as JobSort["key"],
|
||||
direction: defaultSortDirection[value as JobSort["key"]],
|
||||
direction:
|
||||
defaultSortDirection[value as JobSort["key"]],
|
||||
})
|
||||
}
|
||||
>
|
||||
{(Object.keys(sortLabels) as Array<JobSort["key"]>).map(
|
||||
(key) => (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
{sortLabels[key]}
|
||||
</DropdownMenuRadioItem>
|
||||
),
|
||||
)}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
<SelectTrigger
|
||||
id="sort-key"
|
||||
aria-label="Sort field"
|
||||
className="h-8 flex-1 sm:w-[180px] text-foreground"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={sortFieldLabels[sort.key]}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortFieldOrder.map((key) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{sortFieldLabels[key]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap">and</span>
|
||||
<Select
|
||||
value={sort.direction}
|
||||
onValueChange={(value) =>
|
||||
onSortChange({
|
||||
...sort,
|
||||
direction: sort.direction === "asc" ? "desc" : "asc",
|
||||
direction: value as JobSort["direction"],
|
||||
})
|
||||
}
|
||||
>
|
||||
Direction:{" "}
|
||||
{sort.direction === "asc" ? "Ascending" : "Descending"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<SelectTrigger
|
||||
id="sort-direction"
|
||||
aria-label="Sort order"
|
||||
className="h-8 flex-1 sm:w-[180px] text-foreground"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
getDirectionOptions(sort.key).find(
|
||||
(option) => option.value === sort.direction,
|
||||
)?.label
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{getDirectionOptions(sort.key).map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex shrink-0 items-center justify-between border-t border-border/60 bg-background pt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onResetFilters}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button type="button" onClick={() => setIsDrawerOpen(false)}>
|
||||
Show {filteredCount.toLocaleString()}{" "}
|
||||
{filteredCount === 1 ? "job" : "jobs"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
@ -60,8 +60,26 @@ export const defaultStatusToken = {
|
||||
|
||||
export type FilterTab = "ready" | "discovered" | "applied" | "all";
|
||||
|
||||
export type SortKey = "discoveredAt" | "score" | "title" | "employer";
|
||||
export type SortKey =
|
||||
| "discoveredAt"
|
||||
| "score"
|
||||
| "salary"
|
||||
| "title"
|
||||
| "employer";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
export type SponsorFilter =
|
||||
| "all"
|
||||
| "confirmed"
|
||||
| "potential"
|
||||
| "not_found"
|
||||
| "unknown";
|
||||
export type SalaryFilterMode = "at_least" | "at_most" | "between";
|
||||
|
||||
export interface SalaryFilter {
|
||||
mode: SalaryFilterMode;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
}
|
||||
|
||||
export interface JobSort {
|
||||
key: SortKey;
|
||||
@ -73,6 +91,7 @@ export const DEFAULT_SORT: JobSort = { key: "score", direction: "desc" };
|
||||
export const sortLabels: Record<JobSort["key"], string> = {
|
||||
discoveredAt: "Discovered",
|
||||
score: "Score",
|
||||
salary: "Salary",
|
||||
title: "Title",
|
||||
employer: "Company",
|
||||
};
|
||||
@ -80,6 +99,7 @@ export const sortLabels: Record<JobSort["key"], string> = {
|
||||
export const defaultSortDirection: Record<JobSort["key"], SortDirection> = {
|
||||
discoveredAt: "desc",
|
||||
score: "desc",
|
||||
salary: "desc",
|
||||
title: "asc",
|
||||
employer: "asc",
|
||||
};
|
||||
|
||||
@ -0,0 +1,154 @@
|
||||
import type { Job } from "@shared/types";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useFilteredJobs } from "./useFilteredJobs";
|
||||
|
||||
const baseJob: Job = {
|
||||
id: "job-1",
|
||||
source: "linkedin",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Engineer",
|
||||
employer: "Acme",
|
||||
employerUrl: null,
|
||||
jobUrl: "https://example.com/job-1",
|
||||
applicationLink: null,
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: "London",
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: "Desc",
|
||||
status: "ready",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: 90,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("useFilteredJobs", () => {
|
||||
it("filters by sponsor status categories", () => {
|
||||
const jobs: Job[] = [
|
||||
{ ...baseJob, id: "confirmed", sponsorMatchScore: 99 },
|
||||
{ ...baseJob, id: "potential", sponsorMatchScore: 82 },
|
||||
{ ...baseJob, id: "not-found", sponsorMatchScore: 45 },
|
||||
{ ...baseJob, id: "unknown", sponsorMatchScore: null },
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
"all",
|
||||
"confirmed",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
"",
|
||||
{
|
||||
key: "score",
|
||||
direction: "desc",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.map((job) => job.id)).toEqual(["confirmed"]);
|
||||
});
|
||||
|
||||
it("filters by salary range using structured and text salary fields", () => {
|
||||
const jobs: Job[] = [
|
||||
{ ...baseJob, id: "structured", salaryMinAmount: 70000 },
|
||||
{ ...baseJob, id: "k-format", salary: "GBP 65k" },
|
||||
{ ...baseJob, id: "below", salary: "GBP 55k" },
|
||||
{ ...baseJob, id: "none", salary: null },
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "between", min: 60000, max: 80000 },
|
||||
"",
|
||||
{
|
||||
key: "score",
|
||||
direction: "desc",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.map((job) => job.id)).toEqual(
|
||||
expect.arrayContaining(["structured", "k-format"]),
|
||||
);
|
||||
expect(result.current).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sorts by salary with highest first and missing salaries last", () => {
|
||||
const jobs: Job[] = [
|
||||
{ ...baseJob, id: "max", salaryMinAmount: 120000 },
|
||||
{ ...baseJob, id: "mid", salary: "GBP 65k" },
|
||||
{ ...baseJob, id: "low", salaryMinAmount: 50000 },
|
||||
{ ...baseJob, id: "none", salary: null, salaryMinAmount: null },
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFilteredJobs(
|
||||
jobs,
|
||||
"all",
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
"",
|
||||
{
|
||||
key: "salary",
|
||||
direction: "desc",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.map((job) => job.id)).toEqual([
|
||||
"max",
|
||||
"mid",
|
||||
"low",
|
||||
"none",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -1,12 +1,26 @@
|
||||
import type { Job, JobSource } from "@shared/types";
|
||||
import { useMemo } from "react";
|
||||
import type { FilterTab, JobSort } from "./constants";
|
||||
import { compareJobs, jobMatchesQuery } from "./utils";
|
||||
import type {
|
||||
FilterTab,
|
||||
JobSort,
|
||||
SalaryFilter,
|
||||
SponsorFilter,
|
||||
} from "./constants";
|
||||
import { compareJobs, jobMatchesQuery, parseSalaryBounds } from "./utils";
|
||||
|
||||
const getSponsorCategory = (score: number | null): SponsorFilter => {
|
||||
if (score == null) return "unknown";
|
||||
if (score >= 95) return "confirmed";
|
||||
if (score >= 80) return "potential";
|
||||
return "not_found";
|
||||
};
|
||||
|
||||
export const useFilteredJobs = (
|
||||
jobs: Job[],
|
||||
activeTab: FilterTab,
|
||||
sourceFilter: JobSource | "all",
|
||||
sponsorFilter: SponsorFilter,
|
||||
salaryFilter: SalaryFilter,
|
||||
searchQuery: string,
|
||||
sort: JobSort,
|
||||
) =>
|
||||
@ -27,9 +41,61 @@ export const useFilteredJobs = (
|
||||
filtered = filtered.filter((job) => job.source === sourceFilter);
|
||||
}
|
||||
|
||||
if (sponsorFilter !== "all") {
|
||||
filtered = filtered.filter(
|
||||
(job) => getSponsorCategory(job.sponsorMatchScore) === sponsorFilter,
|
||||
);
|
||||
}
|
||||
|
||||
const hasMin =
|
||||
typeof salaryFilter.min === "number" &&
|
||||
Number.isFinite(salaryFilter.min) &&
|
||||
salaryFilter.min > 0;
|
||||
const hasMax =
|
||||
typeof salaryFilter.max === "number" &&
|
||||
Number.isFinite(salaryFilter.max) &&
|
||||
salaryFilter.max > 0;
|
||||
|
||||
if (
|
||||
(salaryFilter.mode === "at_least" && hasMin) ||
|
||||
(salaryFilter.mode === "at_most" && hasMax) ||
|
||||
(salaryFilter.mode === "between" && (hasMin || hasMax))
|
||||
) {
|
||||
filtered = filtered.filter((job) => {
|
||||
const bounds = parseSalaryBounds(job);
|
||||
if (!bounds) return false;
|
||||
|
||||
if (salaryFilter.mode === "at_least") {
|
||||
return hasMin ? bounds.max >= (salaryFilter.min as number) : true;
|
||||
}
|
||||
|
||||
if (salaryFilter.mode === "at_most") {
|
||||
return hasMax ? bounds.min <= (salaryFilter.max as number) : true;
|
||||
}
|
||||
|
||||
const min = hasMin ? (salaryFilter.min as number) : null;
|
||||
const max = hasMax ? (salaryFilter.max as number) : null;
|
||||
|
||||
if (min != null && max != null) {
|
||||
return bounds.max >= min && bounds.min <= max;
|
||||
}
|
||||
if (min != null) return bounds.max >= min;
|
||||
if (max != null) return bounds.min <= max;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
filtered = filtered.filter((job) => jobMatchesQuery(job, searchQuery));
|
||||
}
|
||||
|
||||
return [...filtered].sort((a, b) => compareJobs(a, b, sort));
|
||||
}, [jobs, activeTab, sourceFilter, searchQuery, sort]);
|
||||
}, [
|
||||
jobs,
|
||||
activeTab,
|
||||
sourceFilter,
|
||||
sponsorFilter,
|
||||
salaryFilter,
|
||||
searchQuery,
|
||||
sort,
|
||||
]);
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SORT } from "./constants";
|
||||
import { useOrchestratorFilters } from "./useOrchestratorFilters";
|
||||
|
||||
const createWrapper = (initialEntry: string) => {
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<MemoryRouter initialEntries={[initialEntry]}>{children}</MemoryRouter>
|
||||
);
|
||||
Wrapper.displayName = "RouterWrapper";
|
||||
return Wrapper;
|
||||
};
|
||||
|
||||
describe("useOrchestratorFilters", () => {
|
||||
it("parses a valid sort query param", () => {
|
||||
const { result } = renderHook(() => useOrchestratorFilters(), {
|
||||
wrapper: createWrapper("/ready?sort=title-asc"),
|
||||
});
|
||||
|
||||
expect(result.current.sort).toEqual({
|
||||
key: "title",
|
||||
direction: "asc",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to default sort for invalid sort query params", () => {
|
||||
const cases = [
|
||||
"/ready?sort=title",
|
||||
"/ready?sort=invalid-asc",
|
||||
"/ready?sort=title-sideways",
|
||||
];
|
||||
|
||||
for (const entry of cases) {
|
||||
const { result } = renderHook(() => useOrchestratorFilters(), {
|
||||
wrapper: createWrapper(entry),
|
||||
});
|
||||
expect(result.current.sort).toEqual(DEFAULT_SORT);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,196 @@
|
||||
import type { JobSource } from "@shared/types.js";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type {
|
||||
JobSort,
|
||||
SalaryFilter,
|
||||
SalaryFilterMode,
|
||||
SponsorFilter,
|
||||
} from "./constants";
|
||||
import { DEFAULT_SORT } from "./constants";
|
||||
|
||||
const allowedSponsorFilters: SponsorFilter[] = [
|
||||
"all",
|
||||
"confirmed",
|
||||
"potential",
|
||||
"not_found",
|
||||
"unknown",
|
||||
];
|
||||
const allowedSalaryModes: SalaryFilterMode[] = [
|
||||
"at_least",
|
||||
"at_most",
|
||||
"between",
|
||||
];
|
||||
const allowedSortKeys: JobSort["key"][] = [
|
||||
"discoveredAt",
|
||||
"score",
|
||||
"salary",
|
||||
"title",
|
||||
"employer",
|
||||
];
|
||||
const allowedSortDirections: JobSort["direction"][] = ["asc", "desc"];
|
||||
|
||||
export const useOrchestratorFilters = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const searchQuery = searchParams.get("q") || "";
|
||||
const setSearchQuery = useCallback(
|
||||
(query: string) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
if (query) prev.set("q", query);
|
||||
else prev.delete("q");
|
||||
return prev;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const sourceFilter =
|
||||
(searchParams.get("source") as JobSource | "all") || "all";
|
||||
const setSourceFilter = useCallback(
|
||||
(source: JobSource | "all") => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
if (source !== "all") prev.set("source", source);
|
||||
else prev.delete("source");
|
||||
return prev;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const sponsorFilter = useMemo((): SponsorFilter => {
|
||||
const raw = searchParams.get("sponsor") ?? "all";
|
||||
return allowedSponsorFilters.includes(raw as SponsorFilter)
|
||||
? (raw as SponsorFilter)
|
||||
: "all";
|
||||
}, [searchParams]);
|
||||
|
||||
const setSponsorFilter = useCallback(
|
||||
(value: SponsorFilter) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
if (value === "all") prev.delete("sponsor");
|
||||
else prev.set("sponsor", value);
|
||||
return prev;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const salaryFilter = useMemo((): SalaryFilter => {
|
||||
const modeRaw = searchParams.get("salaryMode") ?? "at_least";
|
||||
const mode = allowedSalaryModes.includes(modeRaw as SalaryFilterMode)
|
||||
? (modeRaw as SalaryFilterMode)
|
||||
: "at_least";
|
||||
|
||||
const minRaw =
|
||||
searchParams.get("salaryMin") ?? searchParams.get("minSalary");
|
||||
const minParsed = minRaw == null ? Number.NaN : Number.parseInt(minRaw, 10);
|
||||
const min = Number.isFinite(minParsed) && minParsed > 0 ? minParsed : null;
|
||||
|
||||
const maxRaw = searchParams.get("salaryMax");
|
||||
const maxParsed = maxRaw == null ? Number.NaN : Number.parseInt(maxRaw, 10);
|
||||
const max = Number.isFinite(maxParsed) && maxParsed > 0 ? maxParsed : null;
|
||||
|
||||
return { mode, min, max };
|
||||
}, [searchParams]);
|
||||
|
||||
const setSalaryFilter = useCallback(
|
||||
(value: SalaryFilter) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
if (value.mode === "at_least") prev.delete("salaryMode");
|
||||
else prev.set("salaryMode", value.mode);
|
||||
|
||||
if (value.min == null || value.min <= 0) prev.delete("salaryMin");
|
||||
else prev.set("salaryMin", String(value.min));
|
||||
|
||||
if (value.max == null || value.max <= 0) prev.delete("salaryMax");
|
||||
else prev.set("salaryMax", String(value.max));
|
||||
|
||||
prev.delete("minSalary");
|
||||
return prev;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const sort = useMemo((): JobSort => {
|
||||
const sortValue = searchParams.get("sort");
|
||||
if (!sortValue) return DEFAULT_SORT;
|
||||
|
||||
const [key, direction] = sortValue.split("-");
|
||||
if (
|
||||
!allowedSortKeys.includes(key as JobSort["key"]) ||
|
||||
!allowedSortDirections.includes(direction as JobSort["direction"])
|
||||
) {
|
||||
return DEFAULT_SORT;
|
||||
}
|
||||
|
||||
return {
|
||||
key: key as JobSort["key"],
|
||||
direction: direction as JobSort["direction"],
|
||||
};
|
||||
}, [searchParams]);
|
||||
|
||||
const setSort = useCallback(
|
||||
(newSort: JobSort) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
if (
|
||||
newSort.key === DEFAULT_SORT.key &&
|
||||
newSort.direction === DEFAULT_SORT.direction
|
||||
) {
|
||||
prev.delete("sort");
|
||||
} else {
|
||||
prev.set("sort", `${newSort.key}-${newSort.direction}`);
|
||||
}
|
||||
return prev;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
prev.delete("source");
|
||||
prev.delete("sponsor");
|
||||
prev.delete("salaryMode");
|
||||
prev.delete("salaryMin");
|
||||
prev.delete("salaryMax");
|
||||
prev.delete("minSalary");
|
||||
prev.delete("sort");
|
||||
return prev;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [setSearchParams]);
|
||||
|
||||
return {
|
||||
searchParams,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
sourceFilter,
|
||||
setSourceFilter,
|
||||
sponsorFilter,
|
||||
setSponsorFilter,
|
||||
salaryFilter,
|
||||
setSalaryFilter,
|
||||
sort,
|
||||
setSort,
|
||||
resetFilters,
|
||||
};
|
||||
};
|
||||
@ -12,6 +12,46 @@ const compareString = (a: string, b: string) =>
|
||||
a.localeCompare(b, undefined, { sensitivity: "base" });
|
||||
const compareNumber = (a: number, b: number) => a - b;
|
||||
|
||||
export const parseSalaryBounds = (
|
||||
job: Job,
|
||||
): { min: number; max: number } | null => {
|
||||
if (
|
||||
typeof job.salaryMinAmount === "number" &&
|
||||
Number.isFinite(job.salaryMinAmount)
|
||||
) {
|
||||
if (
|
||||
typeof job.salaryMaxAmount === "number" &&
|
||||
Number.isFinite(job.salaryMaxAmount)
|
||||
) {
|
||||
return { min: job.salaryMinAmount, max: job.salaryMaxAmount };
|
||||
}
|
||||
return { min: job.salaryMinAmount, max: job.salaryMinAmount };
|
||||
}
|
||||
if (
|
||||
typeof job.salaryMaxAmount === "number" &&
|
||||
Number.isFinite(job.salaryMaxAmount)
|
||||
) {
|
||||
return { min: job.salaryMaxAmount, max: job.salaryMaxAmount };
|
||||
}
|
||||
if (!job.salary) return null;
|
||||
|
||||
const normalized = job.salary.toLowerCase().replace(/,/g, "");
|
||||
const values: number[] = [];
|
||||
|
||||
const kPattern = /(\d+(?:\.\d+)?)\s*k\b/g;
|
||||
for (const match of normalized.matchAll(kPattern)) {
|
||||
values.push(Math.round(Number.parseFloat(match[1]) * 1000));
|
||||
}
|
||||
|
||||
const plainPattern = /(\d{4,6}(?:\.\d+)?)/g;
|
||||
for (const match of normalized.matchAll(plainPattern)) {
|
||||
values.push(Math.round(Number.parseFloat(match[1])));
|
||||
}
|
||||
|
||||
if (values.length === 0) return null;
|
||||
return { min: Math.min(...values), max: Math.max(...values) };
|
||||
};
|
||||
|
||||
export const compareJobs = (a: Job, b: Job, sort: JobSort) => {
|
||||
let value = 0;
|
||||
|
||||
@ -35,6 +75,21 @@ export const compareJobs = (a: Job, b: Job, sort: JobSort) => {
|
||||
value = compareNumber(aScore, bScore);
|
||||
break;
|
||||
}
|
||||
case "salary": {
|
||||
const aSalary = parseSalaryBounds(a);
|
||||
const bSalary = parseSalaryBounds(b);
|
||||
if (aSalary == null && bSalary == null) {
|
||||
value = 0;
|
||||
break;
|
||||
}
|
||||
if (aSalary == null) return 1;
|
||||
if (bSalary == null) return -1;
|
||||
value = compareNumber(aSalary.max, bSalary.max);
|
||||
if (value === 0) {
|
||||
value = compareNumber(aSalary.min, bSalary.min);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "discoveredAt": {
|
||||
const aDate = dateValue(a.discoveredAt);
|
||||
const bDate = dateValue(b.discoveredAt);
|
||||
|
||||
@ -21,6 +21,12 @@ SKIP_DIRS_DEFAULT = {
|
||||
".idea",
|
||||
".vscode",
|
||||
"data",
|
||||
"tests",
|
||||
"__tests__",
|
||||
"spec",
|
||||
"specs",
|
||||
"cypress",
|
||||
"e2e",
|
||||
}
|
||||
|
||||
ALLOWED_EXTS = {
|
||||
@ -140,6 +146,23 @@ def count_file(path: Path):
|
||||
return code, comment, blank
|
||||
|
||||
|
||||
def is_test_file(path: Path) -> bool:
|
||||
stem = path.stem.lower()
|
||||
return (
|
||||
stem.startswith("test_")
|
||||
or stem.startswith("test-")
|
||||
or stem.endswith("_test")
|
||||
or stem.endswith("-test")
|
||||
or stem.endswith(".test")
|
||||
or stem.endswith(".spec")
|
||||
or stem == "test"
|
||||
or "setuptests" in stem
|
||||
or "vitest.setup" in stem
|
||||
or "jest.setup" in stem
|
||||
or "test-utils" in stem
|
||||
)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Count code/comment/blank lines by extension."
|
||||
@ -162,6 +185,16 @@ def parse_args():
|
||||
default=[],
|
||||
help="Directory name to exclude (repeatable).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list-files",
|
||||
action="store_true",
|
||||
help="List all files that are counted.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-tests",
|
||||
action="store_true",
|
||||
help="Count test files (normally skipped).",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@ -186,18 +219,15 @@ def main():
|
||||
if ext not in ALLOWED_EXTS:
|
||||
continue
|
||||
|
||||
stem = path.stem.lower()
|
||||
if (
|
||||
stem.startswith("test_")
|
||||
or stem.endswith("_test")
|
||||
or stem.endswith(".test")
|
||||
or stem.endswith(".spec")
|
||||
):
|
||||
if not args.include_tests and is_test_file(path):
|
||||
continue
|
||||
|
||||
if is_binary(str(path)):
|
||||
continue
|
||||
|
||||
if args.list_files:
|
||||
print(f"Counting: {path.relative_to(root)}")
|
||||
|
||||
code, comment, blank = count_file(path)
|
||||
|
||||
key = ext if ext else "<none>"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user