Hotfix location in pipeline search (#108)
* feat(shared): centralize supported country list and source-country rules * feat(orchestrator): add country selector and UK-only source gating in automatic run modal * feat(orchestrator): persist country selection and run only compatible extractors * fix(pipeline): enforce country-source compatibility during discovery * test(orchestrator): cover country-based source gating and pipeline enforcement * formatting * test fix * lint * comments * prevent auto focus grab * verification * command and popover * make sure scroll is working
This commit is contained in:
parent
9b80c2e05d
commit
bd6834f99e
29
AGENTS.md
29
AGENTS.md
@ -56,3 +56,32 @@ Use consistent status/code mapping:
|
||||
- Request/correlation IDs appear in logs and async workflows.
|
||||
- No raw sensitive payload logging or raw upstream body throws.
|
||||
- New/changed webhook or LLM payloads are sanitized and documented.
|
||||
|
||||
## Validation / Verification
|
||||
|
||||
Before marking work complete, verify changes with the same checks used by CI.
|
||||
|
||||
### Required CI-parity checks
|
||||
|
||||
Run from repository root:
|
||||
|
||||
1. `./orchestrator/node_modules/.bin/biome ci .`
|
||||
2. `npm run check:types:shared`
|
||||
3. `npm --workspace orchestrator run check:types`
|
||||
4. `npm --workspace gradcracker-extractor run check:types`
|
||||
5. `npm --workspace ukvisajobs-extractor run check:types`
|
||||
6. `npm --workspace orchestrator run build:client`
|
||||
7. `npm --workspace orchestrator run test:run`
|
||||
|
||||
### Native module note (better-sqlite3)
|
||||
|
||||
If tests fail with a Node ABI mismatch for `better-sqlite3`, rebuild it before running tests:
|
||||
|
||||
- `npm --workspace orchestrator rebuild better-sqlite3`
|
||||
|
||||
CI runs on Node 22. If local behavior differs, verify with Node 22 before concluding a change is valid.
|
||||
|
||||
### Scope-specific checks
|
||||
|
||||
- For focused changes, run targeted tests first (for touched files/modules), then still run the full CI-parity list above before finalizing.
|
||||
- A change is considered valid only when all required checks pass without ignored failures.
|
||||
|
||||
@ -30,11 +30,12 @@
|
||||
"@paralleldrive/cuid2": "^3.0.6",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-checkbox": "1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
@ -48,23 +49,24 @@
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.38.2",
|
||||
"express": "^4.18.2",
|
||||
"get-tsconfig": "^4.10.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"tsx": "^4.19.2",
|
||||
"express": "^4.18.2",
|
||||
"lucide-react": "^0.561.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^2.12.5",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"recharts": "^2.12.5",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tsx": "^4.19.2",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@ -78,8 +80,8 @@
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@types/react-transition-group": "^4.4.12",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.22",
|
||||
@ -96,11 +98,11 @@
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-linux-x64-gnu": "^1.29.3",
|
||||
"lightningcss-linux-arm64-gnu": "^1.29.3",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
|
||||
"@rollup/rollup-linux-arm64-gnu": "^4.30.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "^4.30.0"
|
||||
"@rollup/rollup-linux-x64-gnu": "^4.30.0",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
|
||||
"lightningcss-linux-arm64-gnu": "^1.29.3",
|
||||
"lightningcss-linux-x64-gnu": "^1.29.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,16 @@ vi.mock("../api", () => ({
|
||||
}));
|
||||
|
||||
let mockIsPipelineRunning = false;
|
||||
let mockPipelineSources = ["linkedin"] as Array<
|
||||
"gradcracker" | "indeed" | "linkedin" | "ukvisajobs"
|
||||
>;
|
||||
let mockAutomaticRunValues = {
|
||||
topN: 12,
|
||||
minSuitabilityScore: 55,
|
||||
searchTerms: ["backend"],
|
||||
runBudget: 150,
|
||||
country: "united kingdom",
|
||||
};
|
||||
|
||||
const jobFixture: Job = {
|
||||
id: "job-1",
|
||||
@ -119,7 +129,7 @@ vi.mock("./orchestrator/useOrchestratorData", () => ({
|
||||
|
||||
vi.mock("./orchestrator/usePipelineSources", () => ({
|
||||
usePipelineSources: () => ({
|
||||
pipelineSources: ["linkedin"],
|
||||
pipelineSources: mockPipelineSources,
|
||||
setPipelineSources: vi.fn(),
|
||||
toggleSource: vi.fn(),
|
||||
}),
|
||||
@ -300,19 +310,13 @@ vi.mock("./orchestrator/RunModeModal", () => ({
|
||||
minSuitabilityScore: number;
|
||||
searchTerms: string[];
|
||||
runBudget: number;
|
||||
country: string;
|
||||
}) => Promise<void>;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="run-automatic"
|
||||
onClick={() =>
|
||||
void onSaveAndRunAutomatic({
|
||||
topN: 12,
|
||||
minSuitabilityScore: 55,
|
||||
searchTerms: ["backend"],
|
||||
runBudget: 150,
|
||||
})
|
||||
}
|
||||
onClick={() => void onSaveAndRunAutomatic(mockAutomaticRunValues)}
|
||||
>
|
||||
Run automatic
|
||||
</button>
|
||||
@ -334,6 +338,14 @@ describe("OrchestratorPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsPipelineRunning = false;
|
||||
mockPipelineSources = ["linkedin"];
|
||||
mockAutomaticRunValues = {
|
||||
topN: 12,
|
||||
minSuitabilityScore: 55,
|
||||
searchTerms: ["backend"],
|
||||
runBudget: 150,
|
||||
country: "united kingdom",
|
||||
};
|
||||
});
|
||||
|
||||
it("syncs tab selection to the URL", () => {
|
||||
@ -583,12 +595,49 @@ describe("OrchestratorPage", () => {
|
||||
jobspyResultsWanted: 150,
|
||||
gradcrackerMaxJobsPerTerm: 150,
|
||||
ukvisajobsMaxJobs: 150,
|
||||
jobspyCountryIndeed: "united kingdom",
|
||||
jobspyLocation: "United Kingdom",
|
||||
});
|
||||
});
|
||||
expect(api.runPipeline).toHaveBeenCalledWith({
|
||||
topN: 12,
|
||||
minSuitabilityScore: 55,
|
||||
sources: ["linkedin"],
|
||||
});
|
||||
|
||||
setIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("blocks automatic run when no sources are compatible for selected country", async () => {
|
||||
window.matchMedia = createMatchMedia(
|
||||
true,
|
||||
) as unknown as typeof window.matchMedia;
|
||||
mockPipelineSources = ["gradcracker", "ukvisajobs"];
|
||||
mockAutomaticRunValues = {
|
||||
topN: 12,
|
||||
minSuitabilityScore: 55,
|
||||
searchTerms: ["backend"],
|
||||
runBudget: 150,
|
||||
country: "united states",
|
||||
};
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("run-automatic"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.updateSettings).not.toHaveBeenCalled();
|
||||
expect(api.runPipeline).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows and hides bulk Recalculate match based on selected statuses", async () => {
|
||||
window.matchMedia = createMatchMedia(
|
||||
true,
|
||||
|
||||
@ -3,6 +3,10 @@
|
||||
*/
|
||||
|
||||
import { useSettings } from "@client/hooks/useSettings";
|
||||
import {
|
||||
formatCountryLabel,
|
||||
getCompatibleSourcesForCountry,
|
||||
} from "@shared/location-support.js";
|
||||
import type { JobSource } from "@shared/types.js";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
@ -121,7 +125,8 @@ export const OrchestratorPage: React.FC = () => {
|
||||
() => getEnabledSources(settings ?? null),
|
||||
[settings],
|
||||
);
|
||||
const { pipelineSources, toggleSource } = usePipelineSources(enabledSources);
|
||||
const { pipelineSources, setPipelineSources, toggleSource } =
|
||||
usePipelineSources(enabledSources);
|
||||
|
||||
const activeJobs = useFilteredJobs(
|
||||
jobs,
|
||||
@ -240,22 +245,35 @@ export const OrchestratorPage: React.FC = () => {
|
||||
|
||||
const handleSaveAndRunAutomatic = useCallback(
|
||||
async (values: AutomaticRunValues) => {
|
||||
const compatibleSources = getCompatibleSourcesForCountry(
|
||||
pipelineSources,
|
||||
values.country,
|
||||
);
|
||||
if (compatibleSources.length === 0) {
|
||||
toast.error(
|
||||
"No compatible sources for the selected country. Choose another country or source.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const limits = deriveExtractorLimits({
|
||||
budget: values.runBudget,
|
||||
searchTerms: values.searchTerms,
|
||||
sources: pipelineSources,
|
||||
sources: compatibleSources,
|
||||
});
|
||||
await api.updateSettings({
|
||||
searchTerms: values.searchTerms,
|
||||
jobspyResultsWanted: limits.jobspyResultsWanted,
|
||||
gradcrackerMaxJobsPerTerm: limits.gradcrackerMaxJobsPerTerm,
|
||||
ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs,
|
||||
jobspyCountryIndeed: values.country,
|
||||
jobspyLocation: formatCountryLabel(values.country),
|
||||
});
|
||||
await refreshSettings();
|
||||
await startPipelineRun({
|
||||
topN: values.topN,
|
||||
minSuitabilityScore: values.minSuitabilityScore,
|
||||
sources: pipelineSources,
|
||||
sources: compatibleSources,
|
||||
});
|
||||
setIsRunModeModalOpen(false);
|
||||
},
|
||||
@ -419,6 +437,7 @@ export const OrchestratorPage: React.FC = () => {
|
||||
enabledSources={enabledSources}
|
||||
pipelineSources={pipelineSources}
|
||||
onToggleSource={toggleSource}
|
||||
onSetPipelineSources={setPipelineSources}
|
||||
isPipelineRunning={isPipelineRunning}
|
||||
onOpenChange={setIsRunModeModalOpen}
|
||||
onModeChange={setRunMode}
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
import type { AppSettings } from "@shared/types";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AutomaticRunTab } from "./AutomaticRunTab";
|
||||
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("AutomaticRunTab", () => {
|
||||
it("loads persisted country from settings", () => {
|
||||
render(
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={
|
||||
{
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "us",
|
||||
} as AppSettings
|
||||
}
|
||||
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
onToggleSource={vi.fn()}
|
||||
onSetPipelineSources={vi.fn()}
|
||||
isPipelineRunning={false}
|
||||
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "United States" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables and prunes UK-only sources for non-UK country", async () => {
|
||||
const onSetPipelineSources = vi.fn();
|
||||
|
||||
render(
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={
|
||||
{
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united states",
|
||||
} as AppSettings
|
||||
}
|
||||
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
pipelineSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
onToggleSource={vi.fn()}
|
||||
onSetPipelineSources={onSetPipelineSources}
|
||||
isPipelineRunning={false}
|
||||
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSetPipelineSources).toHaveBeenCalledWith(["linkedin"]);
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "Gradcracker" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "UK Visa Jobs" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows disabled source guidance copy for UK-only source", () => {
|
||||
render(
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={
|
||||
{
|
||||
searchTerms: ["backend engineer"],
|
||||
jobspyCountryIndeed: "united states",
|
||||
} as AppSettings
|
||||
}
|
||||
enabledSources={["linkedin", "gradcracker", "ukvisajobs"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
onToggleSource={vi.fn()}
|
||||
onSetPipelineSources={vi.fn()}
|
||||
isPipelineRunning={false}
|
||||
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTitle(
|
||||
"Gradcracker is available only when country is United Kingdom.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,13 @@
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import {
|
||||
formatCountryLabel,
|
||||
getCompatibleSourcesForCountry,
|
||||
isSourceAllowedForCountry,
|
||||
normalizeCountryKey,
|
||||
SUPPORTED_COUNTRY_KEYS,
|
||||
} from "@shared/location-support.js";
|
||||
import type { AppSettings, JobSource } from "@shared/types";
|
||||
import { Loader2, Sparkles, X } from "lucide-react";
|
||||
import { Check, ChevronsUpDown, Loader2, Sparkles, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
@ -10,10 +18,25 @@ import {
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { sourceLabel } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn, sourceLabel } from "@/lib/utils";
|
||||
import {
|
||||
AUTOMATIC_PRESETS,
|
||||
type AutomaticPresetId,
|
||||
@ -30,6 +53,7 @@ interface AutomaticRunTabProps {
|
||||
enabledSources: JobSource[];
|
||||
pipelineSources: JobSource[];
|
||||
onToggleSource: (source: JobSource, checked: boolean) => void;
|
||||
onSetPipelineSources: (sources: JobSource[]) => void;
|
||||
isPipelineRunning: boolean;
|
||||
onSaveAndRun: (values: AutomaticRunValues) => Promise<void>;
|
||||
}
|
||||
@ -39,12 +63,14 @@ const DEFAULT_VALUES: AutomaticRunValues = {
|
||||
minSuitabilityScore: 50,
|
||||
searchTerms: ["web developer"],
|
||||
runBudget: 200,
|
||||
country: "united kingdom",
|
||||
};
|
||||
|
||||
interface AutomaticRunFormValues {
|
||||
topN: string;
|
||||
minSuitabilityScore: string;
|
||||
runBudget: string;
|
||||
country: string;
|
||||
searchTerms: string[];
|
||||
searchTermDraft: string;
|
||||
}
|
||||
@ -94,17 +120,20 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
enabledSources,
|
||||
pipelineSources,
|
||||
onToggleSource,
|
||||
onSetPipelineSources,
|
||||
isPipelineRunning,
|
||||
onSaveAndRun,
|
||||
}) => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [countryMenuOpen, setCountryMenuOpen] = useState(false);
|
||||
const { watch, reset, setValue, getValues } = useForm<AutomaticRunFormValues>(
|
||||
{
|
||||
defaultValues: {
|
||||
topN: String(DEFAULT_VALUES.topN),
|
||||
minSuitabilityScore: String(DEFAULT_VALUES.minSuitabilityScore),
|
||||
runBudget: String(DEFAULT_VALUES.runBudget),
|
||||
country: DEFAULT_VALUES.country,
|
||||
searchTerms: DEFAULT_VALUES.searchTerms,
|
||||
searchTermDraft: "",
|
||||
},
|
||||
@ -114,6 +143,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
const topNInput = watch("topN");
|
||||
const minScoreInput = watch("minSuitabilityScore");
|
||||
const runBudgetInput = watch("runBudget");
|
||||
const countryInput = watch("country");
|
||||
const searchTerms = watch("searchTerms");
|
||||
const searchTermDraft = watch("searchTermDraft");
|
||||
|
||||
@ -129,14 +159,22 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
settings?.gradcrackerMaxJobsPerTerm ??
|
||||
settings?.ukvisajobsMaxJobs ??
|
||||
DEFAULT_VALUES.runBudget;
|
||||
const rememberedCountry = normalizeCountryKey(
|
||||
settings?.jobspyCountryIndeed ??
|
||||
settings?.jobspyLocation ??
|
||||
DEFAULT_VALUES.country,
|
||||
);
|
||||
|
||||
reset({
|
||||
topN: String(topN),
|
||||
minSuitabilityScore: String(minSuitabilityScore),
|
||||
runBudget: String(rememberedRunBudget),
|
||||
country: rememberedCountry || DEFAULT_VALUES.country,
|
||||
searchTerms: settings?.searchTerms ?? DEFAULT_VALUES.searchTerms,
|
||||
searchTermDraft: "",
|
||||
});
|
||||
setAdvancedOpen(false);
|
||||
setCountryMenuOpen(false);
|
||||
}, [open, settings, reset]);
|
||||
|
||||
const addSearchTerms = (input: string) => {
|
||||
@ -151,6 +189,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
};
|
||||
|
||||
const values = useMemo<AutomaticRunValues>(() => {
|
||||
const normalizedCountry = normalizeCountryKey(countryInput);
|
||||
return {
|
||||
topN: toNumber(topNInput, 1, 50, DEFAULT_VALUES.topN),
|
||||
minSuitabilityScore: toNumber(
|
||||
@ -159,15 +198,54 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
100,
|
||||
DEFAULT_VALUES.minSuitabilityScore,
|
||||
),
|
||||
searchTerms,
|
||||
runBudget: toNumber(runBudgetInput, 1, 1000, DEFAULT_VALUES.runBudget),
|
||||
country: normalizedCountry || DEFAULT_VALUES.country,
|
||||
searchTerms,
|
||||
};
|
||||
}, [topNInput, minScoreInput, searchTerms, runBudgetInput]);
|
||||
}, [topNInput, minScoreInput, runBudgetInput, countryInput, searchTerms]);
|
||||
|
||||
const compatibleEnabledSources = useMemo(
|
||||
() =>
|
||||
enabledSources.filter((source) =>
|
||||
isSourceAllowedForCountry(source, values.country),
|
||||
),
|
||||
[enabledSources, values.country],
|
||||
);
|
||||
|
||||
const compatiblePipelineSources = useMemo(
|
||||
() => getCompatibleSourcesForCountry(pipelineSources, values.country),
|
||||
[pipelineSources, values.country],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = getCompatibleSourcesForCountry(
|
||||
pipelineSources,
|
||||
values.country,
|
||||
);
|
||||
if (filtered.length === pipelineSources.length) return;
|
||||
if (filtered.length > 0) {
|
||||
onSetPipelineSources(filtered);
|
||||
return;
|
||||
}
|
||||
if (compatibleEnabledSources.length > 0) {
|
||||
onSetPipelineSources([compatibleEnabledSources[0]]);
|
||||
}
|
||||
}, [
|
||||
compatibleEnabledSources,
|
||||
onSetPipelineSources,
|
||||
pipelineSources,
|
||||
values.country,
|
||||
]);
|
||||
|
||||
const estimate = useMemo(
|
||||
() => calculateAutomaticEstimate({ values, sources: pipelineSources }),
|
||||
[values, pipelineSources],
|
||||
() =>
|
||||
calculateAutomaticEstimate({
|
||||
values,
|
||||
sources: compatiblePipelineSources,
|
||||
}),
|
||||
[values, compatiblePipelineSources],
|
||||
);
|
||||
|
||||
const activePreset = useMemo<AutomaticPresetSelection>(
|
||||
() => getPresetSelection(values),
|
||||
[values],
|
||||
@ -176,7 +254,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
const runDisabled =
|
||||
isPipelineRunning ||
|
||||
isSaving ||
|
||||
pipelineSources.length === 0 ||
|
||||
compatiblePipelineSources.length === 0 ||
|
||||
values.searchTerms.length === 0;
|
||||
|
||||
const applyPreset = (presetId: AutomaticPresetId) => {
|
||||
@ -201,6 +279,8 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const countryOptions = SUPPORTED_COUNTRY_KEYS;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 space-y-4 overflow-y-auto pr-1">
|
||||
@ -242,6 +322,73 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid items-center gap-3 md:grid-cols-[120px_1fr]">
|
||||
<Label className="text-base font-semibold">Country</Label>
|
||||
<Popover open={countryMenuOpen} onOpenChange={setCountryMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={countryMenuOpen}
|
||||
aria-label={formatCountryLabel(values.country)}
|
||||
className="h-9 w-full justify-between md:max-w-xs"
|
||||
>
|
||||
{formatCountryLabel(values.country)}
|
||||
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverPrimitive.Content
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className={cn(
|
||||
"z-50 w-[320px] rounded-md border bg-popover p-0 text-popover-foreground shadow-md outline-none",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"origin-[--radix-popover-content-transform-origin]",
|
||||
)}
|
||||
>
|
||||
<Command loop>
|
||||
<CommandInput placeholder="Search country..." />
|
||||
<CommandList
|
||||
className="max-h-56"
|
||||
onWheelCapture={(event) => event.stopPropagation()}
|
||||
>
|
||||
<CommandEmpty>No matching countries.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{countryOptions.map((country) => {
|
||||
const selected = values.country === country;
|
||||
const label = formatCountryLabel(country);
|
||||
return (
|
||||
<CommandItem
|
||||
key={country}
|
||||
value={`${country} ${label}`}
|
||||
onSelect={() => {
|
||||
setValue("country", country, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
setCountryMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
selected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverPrimitive.Content>
|
||||
</Popover>
|
||||
</div>
|
||||
<Separator />
|
||||
<Accordion
|
||||
type="single"
|
||||
@ -372,25 +519,49 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>
|
||||
Sources ({pipelineSources.length}/{enabledSources.length})
|
||||
Sources ({compatiblePipelineSources.length}/
|
||||
{compatibleEnabledSources.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
{enabledSources.map((source) => (
|
||||
<Button
|
||||
key={source}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={
|
||||
pipelineSources.includes(source) ? "default" : "outline"
|
||||
<TooltipProvider>
|
||||
{enabledSources.map((source) => {
|
||||
const allowed = isSourceAllowedForCountry(
|
||||
source,
|
||||
values.country,
|
||||
);
|
||||
const selected = compatiblePipelineSources.includes(source);
|
||||
const disabledReason = `${sourceLabel[source]} is available only when country is United Kingdom.`;
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
key={source}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? "default" : "outline"}
|
||||
disabled={!allowed}
|
||||
onClick={() => onToggleSource(source, !selected)}
|
||||
>
|
||||
{sourceLabel[source]}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (allowed) {
|
||||
return button;
|
||||
}
|
||||
onClick={() =>
|
||||
onToggleSource(source, !pipelineSources.includes(source))
|
||||
}
|
||||
>
|
||||
{sourceLabel[source]}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
return (
|
||||
<Tooltip key={source}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex" title={disabledReason}>
|
||||
{button}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{disabledReason}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</TooltipProvider>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -22,6 +22,7 @@ describe("RunModeModal", () => {
|
||||
enabledSources={["linkedin"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
onToggleSource={vi.fn()}
|
||||
onSetPipelineSources={vi.fn()}
|
||||
isPipelineRunning={false}
|
||||
onOpenChange={vi.fn()}
|
||||
onModeChange={vi.fn()}
|
||||
|
||||
@ -21,6 +21,7 @@ interface RunModeModalProps {
|
||||
enabledSources: JobSource[];
|
||||
pipelineSources: JobSource[];
|
||||
onToggleSource: (source: JobSource, checked: boolean) => void;
|
||||
onSetPipelineSources: (sources: JobSource[]) => void;
|
||||
isPipelineRunning: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onModeChange: (mode: RunMode) => void;
|
||||
@ -35,6 +36,7 @@ export const RunModeModal: React.FC<RunModeModalProps> = ({
|
||||
enabledSources,
|
||||
pipelineSources,
|
||||
onToggleSource,
|
||||
onSetPipelineSources,
|
||||
isPipelineRunning,
|
||||
onOpenChange,
|
||||
onModeChange,
|
||||
@ -73,6 +75,7 @@ export const RunModeModal: React.FC<RunModeModalProps> = ({
|
||||
enabledSources={enabledSources}
|
||||
pipelineSources={pipelineSources}
|
||||
onToggleSource={onToggleSource}
|
||||
onSetPipelineSources={onSetPipelineSources}
|
||||
isPipelineRunning={isPipelineRunning}
|
||||
onSaveAndRun={onSaveAndRunAutomatic}
|
||||
/>
|
||||
|
||||
@ -26,6 +26,7 @@ describe("automatic-run utilities", () => {
|
||||
minSuitabilityScore: 50,
|
||||
searchTerms: ["backend", "platform"],
|
||||
runBudget: 100,
|
||||
country: "united kingdom",
|
||||
},
|
||||
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
|
||||
});
|
||||
@ -57,6 +58,7 @@ describe("automatic-run utilities", () => {
|
||||
minSuitabilityScore: 50,
|
||||
searchTerms: [],
|
||||
runBudget: 750,
|
||||
country: "united kingdom",
|
||||
},
|
||||
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
|
||||
});
|
||||
|
||||
@ -7,6 +7,7 @@ export interface AutomaticRunValues {
|
||||
minSuitabilityScore: number;
|
||||
searchTerms: string[];
|
||||
runBudget: number;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export interface AutomaticPresetValues {
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||
import type { JobspyValues } from "@client/pages/settings/types";
|
||||
import {
|
||||
formatCountryLabel,
|
||||
normalizeCountryKey,
|
||||
SUPPORTED_COUNTRY_KEYS,
|
||||
} from "@shared/location-support.js";
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||
import type React from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
@ -24,119 +29,6 @@ type JobspySectionProps = {
|
||||
isSaving: boolean;
|
||||
};
|
||||
|
||||
const JOBSPY_INDEED_COUNTRIES = [
|
||||
"argentina",
|
||||
"australia",
|
||||
"austria",
|
||||
"bahrain",
|
||||
"bangladesh",
|
||||
"belgium",
|
||||
"bulgaria",
|
||||
"brazil",
|
||||
"canada",
|
||||
"chile",
|
||||
"china",
|
||||
"colombia",
|
||||
"costa rica",
|
||||
"croatia",
|
||||
"cyprus",
|
||||
"czech republic",
|
||||
"czechia",
|
||||
"denmark",
|
||||
"ecuador",
|
||||
"egypt",
|
||||
"estonia",
|
||||
"finland",
|
||||
"france",
|
||||
"germany",
|
||||
"greece",
|
||||
"hong kong",
|
||||
"hungary",
|
||||
"india",
|
||||
"indonesia",
|
||||
"ireland",
|
||||
"israel",
|
||||
"italy",
|
||||
"japan",
|
||||
"kuwait",
|
||||
"latvia",
|
||||
"lithuania",
|
||||
"luxembourg",
|
||||
"malaysia",
|
||||
"malta",
|
||||
"mexico",
|
||||
"morocco",
|
||||
"netherlands",
|
||||
"new zealand",
|
||||
"nigeria",
|
||||
"norway",
|
||||
"oman",
|
||||
"pakistan",
|
||||
"panama",
|
||||
"peru",
|
||||
"philippines",
|
||||
"poland",
|
||||
"portugal",
|
||||
"qatar",
|
||||
"romania",
|
||||
"saudi arabia",
|
||||
"singapore",
|
||||
"slovakia",
|
||||
"slovenia",
|
||||
"south africa",
|
||||
"south korea",
|
||||
"spain",
|
||||
"sweden",
|
||||
"switzerland",
|
||||
"taiwan",
|
||||
"thailand",
|
||||
"türkiye",
|
||||
"turkey",
|
||||
"ukraine",
|
||||
"united arab emirates",
|
||||
"uk",
|
||||
"united kingdom",
|
||||
"usa",
|
||||
"us",
|
||||
"united states",
|
||||
"uruguay",
|
||||
"venezuela",
|
||||
"vietnam",
|
||||
"usa/ca",
|
||||
"worldwide",
|
||||
];
|
||||
|
||||
const COUNTRY_ALIASES: Record<string, string> = {
|
||||
uk: "united kingdom",
|
||||
us: "united states",
|
||||
usa: "united states",
|
||||
türkiye: "turkey",
|
||||
"czech republic": "czechia",
|
||||
};
|
||||
|
||||
const COUNTRY_LABELS: Record<string, string> = {
|
||||
"united kingdom": "United Kingdom",
|
||||
"united states": "United States",
|
||||
"usa/ca": "USA/CA",
|
||||
turkey: "Turkey",
|
||||
czechia: "Czechia",
|
||||
};
|
||||
|
||||
const normalizeCountryValue = (value: string) =>
|
||||
COUNTRY_ALIASES[value] ?? value;
|
||||
|
||||
const formatCountryLabel = (value: string) =>
|
||||
COUNTRY_LABELS[value] || value.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
|
||||
const JOBSPY_INDEED_COUNTRY_OPTIONS = Array.from(
|
||||
new Map(
|
||||
JOBSPY_INDEED_COUNTRIES.map((country) => {
|
||||
const normalized = normalizeCountryValue(country);
|
||||
return [normalized, normalized];
|
||||
}),
|
||||
).values(),
|
||||
);
|
||||
|
||||
export const JobspySection: React.FC<JobspySectionProps> = ({
|
||||
values,
|
||||
isLoading,
|
||||
@ -332,8 +224,8 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
|
||||
countryIndeed.default ??
|
||||
""
|
||||
).toLowerCase();
|
||||
const normalizedValue = normalizeCountryValue(currentValue);
|
||||
const displayValue = JOBSPY_INDEED_COUNTRY_OPTIONS.includes(
|
||||
const normalizedValue = normalizeCountryKey(currentValue);
|
||||
const displayValue = SUPPORTED_COUNTRY_KEYS.includes(
|
||||
normalizedValue,
|
||||
)
|
||||
? normalizedValue
|
||||
@ -365,7 +257,7 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
|
||||
<SelectItem value="__default__">
|
||||
{`Use default (${countryIndeed.default || "UK"})`}
|
||||
</SelectItem>
|
||||
{JOBSPY_INDEED_COUNTRY_OPTIONS.map((country) => (
|
||||
{SUPPORTED_COUNTRY_KEYS.map((country) => (
|
||||
<SelectItem key={country} value={country}>
|
||||
{formatCountryLabel(country)}
|
||||
</SelectItem>
|
||||
|
||||
150
orchestrator/src/components/ui/command.tsx
Normal file
150
orchestrator/src/components/ui/command.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import type { DialogProps } from "@radix-ui/react-dialog";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
CommandShortcut.displayName = "CommandShortcut";
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
122
orchestrator/src/components/ui/dialog.tsx
Normal file
122
orchestrator/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
31
orchestrator/src/components/ui/popover.tsx
Normal file
31
orchestrator/src/components/ui/popover.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
@ -225,6 +225,87 @@ describe("discoverJobsStep", () => {
|
||||
expect(progress.crawlingJobPagesProcessed).toBe(18);
|
||||
});
|
||||
|
||||
it("skips UK-only sources for non-UK country and runs compatible sources", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const jobSpy = await import("../../services/jobspy");
|
||||
const crawler = await import("../../services/crawler");
|
||||
const ukVisa = await import("../../services/ukvisajobs");
|
||||
|
||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
|
||||
searchTerms: JSON.stringify(["engineer"]),
|
||||
jobspyCountryIndeed: "united states",
|
||||
} as any);
|
||||
|
||||
vi.mocked(jobSpy.runJobSpy).mockResolvedValue({
|
||||
success: true,
|
||||
jobs: [
|
||||
{
|
||||
source: "linkedin",
|
||||
title: "Engineer",
|
||||
employer: "ACME",
|
||||
jobUrl: "https://example.com/job",
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
const result = await discoverJobsStep({
|
||||
mergedConfig: {
|
||||
...config,
|
||||
sources: ["linkedin", "gradcracker", "ukvisajobs"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.discoveredJobs).toHaveLength(1);
|
||||
expect(vi.mocked(jobSpy.runJobSpy)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(crawler.runCrawler)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(ukVisa.runUkVisaJobs)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when all requested sources are incompatible for country", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
|
||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
|
||||
searchTerms: JSON.stringify(["engineer"]),
|
||||
jobspyCountryIndeed: "united states",
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
discoverJobsStep({
|
||||
mergedConfig: {
|
||||
...config,
|
||||
sources: ["gradcracker", "ukvisajobs"],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"No compatible sources for selected country: United States",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not throw when no sources are requested", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const jobSpy = await import("../../services/jobspy");
|
||||
const crawler = await import("../../services/crawler");
|
||||
const ukVisa = await import("../../services/ukvisajobs");
|
||||
|
||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
|
||||
searchTerms: JSON.stringify(["engineer"]),
|
||||
jobspyCountryIndeed: "united states",
|
||||
} as any);
|
||||
|
||||
const result = await discoverJobsStep({
|
||||
mergedConfig: {
|
||||
...config,
|
||||
sources: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.discoveredJobs).toEqual([]);
|
||||
expect(result.sourceErrors).toEqual([]);
|
||||
expect(vi.mocked(jobSpy.runJobSpy)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(crawler.runCrawler)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(ukVisa.runUkVisaJobs)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tracks source completion counters across source transitions", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const jobSpy = await import("../../services/jobspy");
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { logger } from "@infra/logger";
|
||||
import {
|
||||
formatCountryLabel,
|
||||
isSourceAllowedForCountry,
|
||||
normalizeCountryKey,
|
||||
} from "@shared/location-support.js";
|
||||
import type { CreateJobInput, PipelineConfig } from "@shared/types";
|
||||
import * as jobsRepo from "../../repositories/jobs";
|
||||
import * as settingsRepo from "../../repositories/settings";
|
||||
@ -35,7 +40,33 @@ export async function discoverJobsStep(args: {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
let jobSpySites = args.mergedConfig.sources.filter(
|
||||
const selectedCountry = normalizeCountryKey(
|
||||
settings.jobspyCountryIndeed ?? settings.jobspyLocation ?? "united kingdom",
|
||||
);
|
||||
const compatibleSources = args.mergedConfig.sources.filter((source) =>
|
||||
isSourceAllowedForCountry(source, selectedCountry),
|
||||
);
|
||||
const skippedSources = args.mergedConfig.sources.filter(
|
||||
(source) => !compatibleSources.includes(source),
|
||||
);
|
||||
|
||||
if (skippedSources.length > 0) {
|
||||
logger.info("Skipping incompatible sources for selected country", {
|
||||
step: "discover-jobs",
|
||||
country: selectedCountry,
|
||||
countryLabel: formatCountryLabel(selectedCountry),
|
||||
requestedSources: args.mergedConfig.sources,
|
||||
skippedSources,
|
||||
});
|
||||
}
|
||||
|
||||
if (args.mergedConfig.sources.length > 0 && compatibleSources.length === 0) {
|
||||
throw new Error(
|
||||
`No compatible sources for selected country: ${formatCountryLabel(selectedCountry)}`,
|
||||
);
|
||||
}
|
||||
|
||||
let jobSpySites = compatibleSources.filter(
|
||||
(source): source is "indeed" | "linkedin" =>
|
||||
source === "indeed" || source === "linkedin",
|
||||
);
|
||||
@ -53,9 +84,8 @@ export async function discoverJobsStep(args: {
|
||||
}
|
||||
|
||||
const shouldRunJobSpy = jobSpySites.length > 0;
|
||||
const shouldRunGradcracker =
|
||||
args.mergedConfig.sources.includes("gradcracker");
|
||||
const shouldRunUkVisaJobs = args.mergedConfig.sources.includes("ukvisajobs");
|
||||
const shouldRunGradcracker = compatibleSources.includes("gradcracker");
|
||||
const shouldRunUkVisaJobs = compatibleSources.includes("ukvisajobs");
|
||||
|
||||
const totalSources =
|
||||
Number(shouldRunJobSpy) +
|
||||
|
||||
1912
package-lock.json
generated
1912
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,2 +1,3 @@
|
||||
export * from "./location-support";
|
||||
export * from "./types";
|
||||
export * from "./utils/type-conversion";
|
||||
|
||||
62
shared/src/location-support.test.ts
Normal file
62
shared/src/location-support.test.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCountryLabel,
|
||||
getCompatibleSourcesForCountry,
|
||||
isSourceAllowedForCountry,
|
||||
isUkCountry,
|
||||
normalizeCountryKey,
|
||||
SUPPORTED_COUNTRY_KEYS,
|
||||
} from "./location-support";
|
||||
|
||||
describe("location-support", () => {
|
||||
it("normalizes country aliases", () => {
|
||||
expect(normalizeCountryKey("UK")).toBe("united kingdom");
|
||||
expect(normalizeCountryKey("us")).toBe("united states");
|
||||
expect(normalizeCountryKey("usa")).toBe("united states");
|
||||
expect(normalizeCountryKey("czech republic")).toBe("czechia");
|
||||
});
|
||||
|
||||
it("formats country labels", () => {
|
||||
expect(formatCountryLabel("united kingdom")).toBe("United Kingdom");
|
||||
expect(formatCountryLabel("usa/ca")).toBe("USA/CA");
|
||||
expect(formatCountryLabel("south korea")).toBe("South Korea");
|
||||
});
|
||||
|
||||
it("keeps supported country keys unique and canonical", () => {
|
||||
expect(SUPPORTED_COUNTRY_KEYS).toContain("united kingdom");
|
||||
expect(SUPPORTED_COUNTRY_KEYS).toContain("united states");
|
||||
expect(SUPPORTED_COUNTRY_KEYS).toContain("worldwide");
|
||||
expect(SUPPORTED_COUNTRY_KEYS).not.toContain("uk");
|
||||
expect(SUPPORTED_COUNTRY_KEYS).not.toContain("us");
|
||||
});
|
||||
|
||||
it("treats only united kingdom as UK country", () => {
|
||||
expect(isUkCountry("united kingdom")).toBe(true);
|
||||
expect(isUkCountry("UK")).toBe(true);
|
||||
expect(isUkCountry("worldwide")).toBe(false);
|
||||
expect(isUkCountry("usa/ca")).toBe(false);
|
||||
expect(isUkCountry("united states")).toBe(false);
|
||||
});
|
||||
|
||||
it("applies source compatibility rules by country", () => {
|
||||
expect(isSourceAllowedForCountry("gradcracker", "united kingdom")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isSourceAllowedForCountry("ukvisajobs", "uk")).toBe(true);
|
||||
expect(isSourceAllowedForCountry("gradcracker", "united states")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isSourceAllowedForCountry("ukvisajobs", "worldwide")).toBe(false);
|
||||
expect(isSourceAllowedForCountry("indeed", "united states")).toBe(true);
|
||||
expect(isSourceAllowedForCountry("linkedin", "worldwide")).toBe(true);
|
||||
});
|
||||
|
||||
it("filters incompatible sources while preserving compatible order", () => {
|
||||
expect(
|
||||
getCompatibleSourcesForCountry(
|
||||
["gradcracker", "indeed", "ukvisajobs", "linkedin"],
|
||||
"united states",
|
||||
),
|
||||
).toEqual(["indeed", "linkedin"]);
|
||||
});
|
||||
});
|
||||
141
shared/src/location-support.ts
Normal file
141
shared/src/location-support.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import type { JobSource } from "./types";
|
||||
|
||||
const COUNTRY_ALIASES: Record<string, string> = {
|
||||
uk: "united kingdom",
|
||||
us: "united states",
|
||||
usa: "united states",
|
||||
türkiye: "turkey",
|
||||
"czech republic": "czechia",
|
||||
};
|
||||
|
||||
const COUNTRY_LABELS: Record<string, string> = {
|
||||
"united kingdom": "United Kingdom",
|
||||
"united states": "United States",
|
||||
"usa/ca": "USA/CA",
|
||||
turkey: "Turkey",
|
||||
czechia: "Czechia",
|
||||
};
|
||||
|
||||
// Keep this list aligned with the JobSpy supported country inputs.
|
||||
export const SUPPORTED_COUNTRY_INPUTS = [
|
||||
"argentina",
|
||||
"australia",
|
||||
"austria",
|
||||
"bahrain",
|
||||
"bangladesh",
|
||||
"belgium",
|
||||
"bulgaria",
|
||||
"brazil",
|
||||
"canada",
|
||||
"chile",
|
||||
"china",
|
||||
"colombia",
|
||||
"costa rica",
|
||||
"croatia",
|
||||
"cyprus",
|
||||
"czech republic",
|
||||
"czechia",
|
||||
"denmark",
|
||||
"ecuador",
|
||||
"egypt",
|
||||
"estonia",
|
||||
"finland",
|
||||
"france",
|
||||
"germany",
|
||||
"greece",
|
||||
"hong kong",
|
||||
"hungary",
|
||||
"india",
|
||||
"indonesia",
|
||||
"ireland",
|
||||
"israel",
|
||||
"italy",
|
||||
"japan",
|
||||
"kuwait",
|
||||
"latvia",
|
||||
"lithuania",
|
||||
"luxembourg",
|
||||
"malaysia",
|
||||
"malta",
|
||||
"mexico",
|
||||
"morocco",
|
||||
"netherlands",
|
||||
"new zealand",
|
||||
"nigeria",
|
||||
"norway",
|
||||
"oman",
|
||||
"pakistan",
|
||||
"panama",
|
||||
"peru",
|
||||
"philippines",
|
||||
"poland",
|
||||
"portugal",
|
||||
"qatar",
|
||||
"romania",
|
||||
"saudi arabia",
|
||||
"singapore",
|
||||
"slovakia",
|
||||
"slovenia",
|
||||
"south africa",
|
||||
"south korea",
|
||||
"spain",
|
||||
"sweden",
|
||||
"switzerland",
|
||||
"taiwan",
|
||||
"thailand",
|
||||
"türkiye",
|
||||
"turkey",
|
||||
"ukraine",
|
||||
"united arab emirates",
|
||||
"uk",
|
||||
"united kingdom",
|
||||
"usa",
|
||||
"us",
|
||||
"united states",
|
||||
"uruguay",
|
||||
"venezuela",
|
||||
"vietnam",
|
||||
"usa/ca",
|
||||
"worldwide",
|
||||
] as const;
|
||||
|
||||
const UK_ONLY_SOURCES = new Set<JobSource>(["gradcracker", "ukvisajobs"]);
|
||||
|
||||
export function normalizeCountryKey(value: string | null | undefined): string {
|
||||
const normalized = value?.trim().toLowerCase() ?? "";
|
||||
return COUNTRY_ALIASES[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
export function formatCountryLabel(value: string): string {
|
||||
const normalized = normalizeCountryKey(value);
|
||||
if (!normalized) return "";
|
||||
return (
|
||||
COUNTRY_LABELS[normalized] ||
|
||||
normalized.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
export const SUPPORTED_COUNTRY_KEYS = Array.from(
|
||||
new Set(
|
||||
SUPPORTED_COUNTRY_INPUTS.map((country) => normalizeCountryKey(country)),
|
||||
),
|
||||
).filter(Boolean);
|
||||
|
||||
export function isUkCountry(country: string | null | undefined): boolean {
|
||||
return normalizeCountryKey(country) === "united kingdom";
|
||||
}
|
||||
|
||||
export function isSourceAllowedForCountry(
|
||||
source: JobSource,
|
||||
country: string | null | undefined,
|
||||
): boolean {
|
||||
if (!UK_ONLY_SOURCES.has(source)) return true;
|
||||
return isUkCountry(country);
|
||||
}
|
||||
|
||||
export function getCompatibleSourcesForCountry(
|
||||
sources: JobSource[],
|
||||
country: string | null | undefined,
|
||||
): JobSource[] {
|
||||
return sources.filter((source) => isSourceAllowedForCountry(source, country));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user