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.
|
- Request/correlation IDs appear in logs and async workflows.
|
||||||
- No raw sensitive payload logging or raw upstream body throws.
|
- No raw sensitive payload logging or raw upstream body throws.
|
||||||
- New/changed webhook or LLM payloads are sanitized and documented.
|
- 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",
|
"@paralleldrive/cuid2": "^3.0.6",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@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-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@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-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "1.1.15",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
@ -48,23 +49,24 @@
|
|||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.38.2",
|
"drizzle-orm": "^0.38.2",
|
||||||
|
"express": "^4.18.2",
|
||||||
"get-tsconfig": "^4.10.0",
|
"get-tsconfig": "^4.10.0",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"tsx": "^4.19.2",
|
|
||||||
"express": "^4.18.2",
|
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^2.12.5",
|
|
||||||
"react-transition-group": "^4.4.5",
|
"react-transition-group": "^4.4.5",
|
||||||
|
"recharts": "^2.12.5",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
@ -78,8 +80,8 @@
|
|||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jsdom": "^27.0.0",
|
"@types/jsdom": "^27.0.0",
|
||||||
"@types/node": "^22.10.1",
|
"@types/node": "^22.10.1",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "18.3.1",
|
||||||
"@types/react-transition-group": "^4.4.12",
|
"@types/react-transition-group": "^4.4.12",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
@ -96,11 +98,11 @@
|
|||||||
"vitest": "^4.0.16"
|
"vitest": "^4.0.16"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"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-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 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 = {
|
const jobFixture: Job = {
|
||||||
id: "job-1",
|
id: "job-1",
|
||||||
@ -119,7 +129,7 @@ vi.mock("./orchestrator/useOrchestratorData", () => ({
|
|||||||
|
|
||||||
vi.mock("./orchestrator/usePipelineSources", () => ({
|
vi.mock("./orchestrator/usePipelineSources", () => ({
|
||||||
usePipelineSources: () => ({
|
usePipelineSources: () => ({
|
||||||
pipelineSources: ["linkedin"],
|
pipelineSources: mockPipelineSources,
|
||||||
setPipelineSources: vi.fn(),
|
setPipelineSources: vi.fn(),
|
||||||
toggleSource: vi.fn(),
|
toggleSource: vi.fn(),
|
||||||
}),
|
}),
|
||||||
@ -300,19 +310,13 @@ vi.mock("./orchestrator/RunModeModal", () => ({
|
|||||||
minSuitabilityScore: number;
|
minSuitabilityScore: number;
|
||||||
searchTerms: string[];
|
searchTerms: string[];
|
||||||
runBudget: number;
|
runBudget: number;
|
||||||
|
country: string;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
}) => (
|
}) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="run-automatic"
|
data-testid="run-automatic"
|
||||||
onClick={() =>
|
onClick={() => void onSaveAndRunAutomatic(mockAutomaticRunValues)}
|
||||||
void onSaveAndRunAutomatic({
|
|
||||||
topN: 12,
|
|
||||||
minSuitabilityScore: 55,
|
|
||||||
searchTerms: ["backend"],
|
|
||||||
runBudget: 150,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Run automatic
|
Run automatic
|
||||||
</button>
|
</button>
|
||||||
@ -334,6 +338,14 @@ describe("OrchestratorPage", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockIsPipelineRunning = false;
|
mockIsPipelineRunning = false;
|
||||||
|
mockPipelineSources = ["linkedin"];
|
||||||
|
mockAutomaticRunValues = {
|
||||||
|
topN: 12,
|
||||||
|
minSuitabilityScore: 55,
|
||||||
|
searchTerms: ["backend"],
|
||||||
|
runBudget: 150,
|
||||||
|
country: "united kingdom",
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it("syncs tab selection to the URL", () => {
|
it("syncs tab selection to the URL", () => {
|
||||||
@ -583,12 +595,49 @@ describe("OrchestratorPage", () => {
|
|||||||
jobspyResultsWanted: 150,
|
jobspyResultsWanted: 150,
|
||||||
gradcrackerMaxJobsPerTerm: 150,
|
gradcrackerMaxJobsPerTerm: 150,
|
||||||
ukvisajobsMaxJobs: 150,
|
ukvisajobsMaxJobs: 150,
|
||||||
|
jobspyCountryIndeed: "united kingdom",
|
||||||
|
jobspyLocation: "United Kingdom",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
expect(api.runPipeline).toHaveBeenCalledWith({
|
||||||
|
topN: 12,
|
||||||
|
minSuitabilityScore: 55,
|
||||||
|
sources: ["linkedin"],
|
||||||
|
});
|
||||||
|
|
||||||
setIntervalSpy.mockRestore();
|
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 () => {
|
it("shows and hides bulk Recalculate match based on selected statuses", async () => {
|
||||||
window.matchMedia = createMatchMedia(
|
window.matchMedia = createMatchMedia(
|
||||||
true,
|
true,
|
||||||
|
|||||||
@ -3,6 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useSettings } from "@client/hooks/useSettings";
|
import { useSettings } from "@client/hooks/useSettings";
|
||||||
|
import {
|
||||||
|
formatCountryLabel,
|
||||||
|
getCompatibleSourcesForCountry,
|
||||||
|
} from "@shared/location-support.js";
|
||||||
import type { JobSource } from "@shared/types.js";
|
import type { JobSource } from "@shared/types.js";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
@ -121,7 +125,8 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
() => getEnabledSources(settings ?? null),
|
() => getEnabledSources(settings ?? null),
|
||||||
[settings],
|
[settings],
|
||||||
);
|
);
|
||||||
const { pipelineSources, toggleSource } = usePipelineSources(enabledSources);
|
const { pipelineSources, setPipelineSources, toggleSource } =
|
||||||
|
usePipelineSources(enabledSources);
|
||||||
|
|
||||||
const activeJobs = useFilteredJobs(
|
const activeJobs = useFilteredJobs(
|
||||||
jobs,
|
jobs,
|
||||||
@ -240,22 +245,35 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleSaveAndRunAutomatic = useCallback(
|
const handleSaveAndRunAutomatic = useCallback(
|
||||||
async (values: AutomaticRunValues) => {
|
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({
|
const limits = deriveExtractorLimits({
|
||||||
budget: values.runBudget,
|
budget: values.runBudget,
|
||||||
searchTerms: values.searchTerms,
|
searchTerms: values.searchTerms,
|
||||||
sources: pipelineSources,
|
sources: compatibleSources,
|
||||||
});
|
});
|
||||||
await api.updateSettings({
|
await api.updateSettings({
|
||||||
searchTerms: values.searchTerms,
|
searchTerms: values.searchTerms,
|
||||||
jobspyResultsWanted: limits.jobspyResultsWanted,
|
jobspyResultsWanted: limits.jobspyResultsWanted,
|
||||||
gradcrackerMaxJobsPerTerm: limits.gradcrackerMaxJobsPerTerm,
|
gradcrackerMaxJobsPerTerm: limits.gradcrackerMaxJobsPerTerm,
|
||||||
ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs,
|
ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs,
|
||||||
|
jobspyCountryIndeed: values.country,
|
||||||
|
jobspyLocation: formatCountryLabel(values.country),
|
||||||
});
|
});
|
||||||
await refreshSettings();
|
await refreshSettings();
|
||||||
await startPipelineRun({
|
await startPipelineRun({
|
||||||
topN: values.topN,
|
topN: values.topN,
|
||||||
minSuitabilityScore: values.minSuitabilityScore,
|
minSuitabilityScore: values.minSuitabilityScore,
|
||||||
sources: pipelineSources,
|
sources: compatibleSources,
|
||||||
});
|
});
|
||||||
setIsRunModeModalOpen(false);
|
setIsRunModeModalOpen(false);
|
||||||
},
|
},
|
||||||
@ -419,6 +437,7 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
enabledSources={enabledSources}
|
enabledSources={enabledSources}
|
||||||
pipelineSources={pipelineSources}
|
pipelineSources={pipelineSources}
|
||||||
onToggleSource={toggleSource}
|
onToggleSource={toggleSource}
|
||||||
|
onSetPipelineSources={setPipelineSources}
|
||||||
isPipelineRunning={isPipelineRunning}
|
isPipelineRunning={isPipelineRunning}
|
||||||
onOpenChange={setIsRunModeModalOpen}
|
onOpenChange={setIsRunModeModalOpen}
|
||||||
onModeChange={setRunMode}
|
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 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 { useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
@ -10,10 +18,25 @@ import {
|
|||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Popover, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Separator } from "@/components/ui/separator";
|
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 {
|
import {
|
||||||
AUTOMATIC_PRESETS,
|
AUTOMATIC_PRESETS,
|
||||||
type AutomaticPresetId,
|
type AutomaticPresetId,
|
||||||
@ -30,6 +53,7 @@ interface AutomaticRunTabProps {
|
|||||||
enabledSources: JobSource[];
|
enabledSources: JobSource[];
|
||||||
pipelineSources: JobSource[];
|
pipelineSources: JobSource[];
|
||||||
onToggleSource: (source: JobSource, checked: boolean) => void;
|
onToggleSource: (source: JobSource, checked: boolean) => void;
|
||||||
|
onSetPipelineSources: (sources: JobSource[]) => void;
|
||||||
isPipelineRunning: boolean;
|
isPipelineRunning: boolean;
|
||||||
onSaveAndRun: (values: AutomaticRunValues) => Promise<void>;
|
onSaveAndRun: (values: AutomaticRunValues) => Promise<void>;
|
||||||
}
|
}
|
||||||
@ -39,12 +63,14 @@ const DEFAULT_VALUES: AutomaticRunValues = {
|
|||||||
minSuitabilityScore: 50,
|
minSuitabilityScore: 50,
|
||||||
searchTerms: ["web developer"],
|
searchTerms: ["web developer"],
|
||||||
runBudget: 200,
|
runBudget: 200,
|
||||||
|
country: "united kingdom",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AutomaticRunFormValues {
|
interface AutomaticRunFormValues {
|
||||||
topN: string;
|
topN: string;
|
||||||
minSuitabilityScore: string;
|
minSuitabilityScore: string;
|
||||||
runBudget: string;
|
runBudget: string;
|
||||||
|
country: string;
|
||||||
searchTerms: string[];
|
searchTerms: string[];
|
||||||
searchTermDraft: string;
|
searchTermDraft: string;
|
||||||
}
|
}
|
||||||
@ -94,17 +120,20 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
enabledSources,
|
enabledSources,
|
||||||
pipelineSources,
|
pipelineSources,
|
||||||
onToggleSource,
|
onToggleSource,
|
||||||
|
onSetPipelineSources,
|
||||||
isPipelineRunning,
|
isPipelineRunning,
|
||||||
onSaveAndRun,
|
onSaveAndRun,
|
||||||
}) => {
|
}) => {
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
|
const [countryMenuOpen, setCountryMenuOpen] = useState(false);
|
||||||
const { watch, reset, setValue, getValues } = useForm<AutomaticRunFormValues>(
|
const { watch, reset, setValue, getValues } = useForm<AutomaticRunFormValues>(
|
||||||
{
|
{
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
topN: String(DEFAULT_VALUES.topN),
|
topN: String(DEFAULT_VALUES.topN),
|
||||||
minSuitabilityScore: String(DEFAULT_VALUES.minSuitabilityScore),
|
minSuitabilityScore: String(DEFAULT_VALUES.minSuitabilityScore),
|
||||||
runBudget: String(DEFAULT_VALUES.runBudget),
|
runBudget: String(DEFAULT_VALUES.runBudget),
|
||||||
|
country: DEFAULT_VALUES.country,
|
||||||
searchTerms: DEFAULT_VALUES.searchTerms,
|
searchTerms: DEFAULT_VALUES.searchTerms,
|
||||||
searchTermDraft: "",
|
searchTermDraft: "",
|
||||||
},
|
},
|
||||||
@ -114,6 +143,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
const topNInput = watch("topN");
|
const topNInput = watch("topN");
|
||||||
const minScoreInput = watch("minSuitabilityScore");
|
const minScoreInput = watch("minSuitabilityScore");
|
||||||
const runBudgetInput = watch("runBudget");
|
const runBudgetInput = watch("runBudget");
|
||||||
|
const countryInput = watch("country");
|
||||||
const searchTerms = watch("searchTerms");
|
const searchTerms = watch("searchTerms");
|
||||||
const searchTermDraft = watch("searchTermDraft");
|
const searchTermDraft = watch("searchTermDraft");
|
||||||
|
|
||||||
@ -129,14 +159,22 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
settings?.gradcrackerMaxJobsPerTerm ??
|
settings?.gradcrackerMaxJobsPerTerm ??
|
||||||
settings?.ukvisajobsMaxJobs ??
|
settings?.ukvisajobsMaxJobs ??
|
||||||
DEFAULT_VALUES.runBudget;
|
DEFAULT_VALUES.runBudget;
|
||||||
|
const rememberedCountry = normalizeCountryKey(
|
||||||
|
settings?.jobspyCountryIndeed ??
|
||||||
|
settings?.jobspyLocation ??
|
||||||
|
DEFAULT_VALUES.country,
|
||||||
|
);
|
||||||
|
|
||||||
reset({
|
reset({
|
||||||
topN: String(topN),
|
topN: String(topN),
|
||||||
minSuitabilityScore: String(minSuitabilityScore),
|
minSuitabilityScore: String(minSuitabilityScore),
|
||||||
runBudget: String(rememberedRunBudget),
|
runBudget: String(rememberedRunBudget),
|
||||||
|
country: rememberedCountry || DEFAULT_VALUES.country,
|
||||||
searchTerms: settings?.searchTerms ?? DEFAULT_VALUES.searchTerms,
|
searchTerms: settings?.searchTerms ?? DEFAULT_VALUES.searchTerms,
|
||||||
searchTermDraft: "",
|
searchTermDraft: "",
|
||||||
});
|
});
|
||||||
setAdvancedOpen(false);
|
setAdvancedOpen(false);
|
||||||
|
setCountryMenuOpen(false);
|
||||||
}, [open, settings, reset]);
|
}, [open, settings, reset]);
|
||||||
|
|
||||||
const addSearchTerms = (input: string) => {
|
const addSearchTerms = (input: string) => {
|
||||||
@ -151,6 +189,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const values = useMemo<AutomaticRunValues>(() => {
|
const values = useMemo<AutomaticRunValues>(() => {
|
||||||
|
const normalizedCountry = normalizeCountryKey(countryInput);
|
||||||
return {
|
return {
|
||||||
topN: toNumber(topNInput, 1, 50, DEFAULT_VALUES.topN),
|
topN: toNumber(topNInput, 1, 50, DEFAULT_VALUES.topN),
|
||||||
minSuitabilityScore: toNumber(
|
minSuitabilityScore: toNumber(
|
||||||
@ -159,15 +198,54 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
100,
|
100,
|
||||||
DEFAULT_VALUES.minSuitabilityScore,
|
DEFAULT_VALUES.minSuitabilityScore,
|
||||||
),
|
),
|
||||||
searchTerms,
|
|
||||||
runBudget: toNumber(runBudgetInput, 1, 1000, DEFAULT_VALUES.runBudget),
|
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(
|
const estimate = useMemo(
|
||||||
() => calculateAutomaticEstimate({ values, sources: pipelineSources }),
|
() =>
|
||||||
[values, pipelineSources],
|
calculateAutomaticEstimate({
|
||||||
|
values,
|
||||||
|
sources: compatiblePipelineSources,
|
||||||
|
}),
|
||||||
|
[values, compatiblePipelineSources],
|
||||||
);
|
);
|
||||||
|
|
||||||
const activePreset = useMemo<AutomaticPresetSelection>(
|
const activePreset = useMemo<AutomaticPresetSelection>(
|
||||||
() => getPresetSelection(values),
|
() => getPresetSelection(values),
|
||||||
[values],
|
[values],
|
||||||
@ -176,7 +254,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
const runDisabled =
|
const runDisabled =
|
||||||
isPipelineRunning ||
|
isPipelineRunning ||
|
||||||
isSaving ||
|
isSaving ||
|
||||||
pipelineSources.length === 0 ||
|
compatiblePipelineSources.length === 0 ||
|
||||||
values.searchTerms.length === 0;
|
values.searchTerms.length === 0;
|
||||||
|
|
||||||
const applyPreset = (presetId: AutomaticPresetId) => {
|
const applyPreset = (presetId: AutomaticPresetId) => {
|
||||||
@ -201,6 +279,8 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const countryOptions = SUPPORTED_COUNTRY_KEYS;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
<div className="min-h-0 space-y-4 overflow-y-auto pr-1">
|
<div className="min-h-0 space-y-4 overflow-y-auto pr-1">
|
||||||
@ -242,6 +322,73 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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 />
|
<Separator />
|
||||||
<Accordion
|
<Accordion
|
||||||
type="single"
|
type="single"
|
||||||
@ -372,25 +519,49 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
Sources ({pipelineSources.length}/{enabledSources.length})
|
Sources ({compatiblePipelineSources.length}/
|
||||||
|
{compatibleEnabledSources.length})
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
<CardContent className="flex flex-wrap gap-2">
|
||||||
{enabledSources.map((source) => (
|
<TooltipProvider>
|
||||||
<Button
|
{enabledSources.map((source) => {
|
||||||
key={source}
|
const allowed = isSourceAllowedForCountry(
|
||||||
type="button"
|
source,
|
||||||
size="sm"
|
values.country,
|
||||||
variant={
|
);
|
||||||
pipelineSources.includes(source) ? "default" : "outline"
|
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))
|
return (
|
||||||
}
|
<Tooltip key={source}>
|
||||||
>
|
<TooltipTrigger asChild>
|
||||||
{sourceLabel[source]}
|
<span className="inline-flex" title={disabledReason}>
|
||||||
</Button>
|
{button}
|
||||||
))}
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">{disabledReason}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TooltipProvider>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -22,6 +22,7 @@ describe("RunModeModal", () => {
|
|||||||
enabledSources={["linkedin"]}
|
enabledSources={["linkedin"]}
|
||||||
pipelineSources={["linkedin"]}
|
pipelineSources={["linkedin"]}
|
||||||
onToggleSource={vi.fn()}
|
onToggleSource={vi.fn()}
|
||||||
|
onSetPipelineSources={vi.fn()}
|
||||||
isPipelineRunning={false}
|
isPipelineRunning={false}
|
||||||
onOpenChange={vi.fn()}
|
onOpenChange={vi.fn()}
|
||||||
onModeChange={vi.fn()}
|
onModeChange={vi.fn()}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ interface RunModeModalProps {
|
|||||||
enabledSources: JobSource[];
|
enabledSources: JobSource[];
|
||||||
pipelineSources: JobSource[];
|
pipelineSources: JobSource[];
|
||||||
onToggleSource: (source: JobSource, checked: boolean) => void;
|
onToggleSource: (source: JobSource, checked: boolean) => void;
|
||||||
|
onSetPipelineSources: (sources: JobSource[]) => void;
|
||||||
isPipelineRunning: boolean;
|
isPipelineRunning: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onModeChange: (mode: RunMode) => void;
|
onModeChange: (mode: RunMode) => void;
|
||||||
@ -35,6 +36,7 @@ export const RunModeModal: React.FC<RunModeModalProps> = ({
|
|||||||
enabledSources,
|
enabledSources,
|
||||||
pipelineSources,
|
pipelineSources,
|
||||||
onToggleSource,
|
onToggleSource,
|
||||||
|
onSetPipelineSources,
|
||||||
isPipelineRunning,
|
isPipelineRunning,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
@ -73,6 +75,7 @@ export const RunModeModal: React.FC<RunModeModalProps> = ({
|
|||||||
enabledSources={enabledSources}
|
enabledSources={enabledSources}
|
||||||
pipelineSources={pipelineSources}
|
pipelineSources={pipelineSources}
|
||||||
onToggleSource={onToggleSource}
|
onToggleSource={onToggleSource}
|
||||||
|
onSetPipelineSources={onSetPipelineSources}
|
||||||
isPipelineRunning={isPipelineRunning}
|
isPipelineRunning={isPipelineRunning}
|
||||||
onSaveAndRun={onSaveAndRunAutomatic}
|
onSaveAndRun={onSaveAndRunAutomatic}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -26,6 +26,7 @@ describe("automatic-run utilities", () => {
|
|||||||
minSuitabilityScore: 50,
|
minSuitabilityScore: 50,
|
||||||
searchTerms: ["backend", "platform"],
|
searchTerms: ["backend", "platform"],
|
||||||
runBudget: 100,
|
runBudget: 100,
|
||||||
|
country: "united kingdom",
|
||||||
},
|
},
|
||||||
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
|
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
|
||||||
});
|
});
|
||||||
@ -57,6 +58,7 @@ describe("automatic-run utilities", () => {
|
|||||||
minSuitabilityScore: 50,
|
minSuitabilityScore: 50,
|
||||||
searchTerms: [],
|
searchTerms: [],
|
||||||
runBudget: 750,
|
runBudget: 750,
|
||||||
|
country: "united kingdom",
|
||||||
},
|
},
|
||||||
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
|
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export interface AutomaticRunValues {
|
|||||||
minSuitabilityScore: number;
|
minSuitabilityScore: number;
|
||||||
searchTerms: string[];
|
searchTerms: string[];
|
||||||
runBudget: number;
|
runBudget: number;
|
||||||
|
country: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutomaticPresetValues {
|
export interface AutomaticPresetValues {
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||||
import type { JobspyValues } from "@client/pages/settings/types";
|
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 { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
@ -24,119 +29,6 @@ type JobspySectionProps = {
|
|||||||
isSaving: boolean;
|
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> = ({
|
export const JobspySection: React.FC<JobspySectionProps> = ({
|
||||||
values,
|
values,
|
||||||
isLoading,
|
isLoading,
|
||||||
@ -332,8 +224,8 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
|
|||||||
countryIndeed.default ??
|
countryIndeed.default ??
|
||||||
""
|
""
|
||||||
).toLowerCase();
|
).toLowerCase();
|
||||||
const normalizedValue = normalizeCountryValue(currentValue);
|
const normalizedValue = normalizeCountryKey(currentValue);
|
||||||
const displayValue = JOBSPY_INDEED_COUNTRY_OPTIONS.includes(
|
const displayValue = SUPPORTED_COUNTRY_KEYS.includes(
|
||||||
normalizedValue,
|
normalizedValue,
|
||||||
)
|
)
|
||||||
? normalizedValue
|
? normalizedValue
|
||||||
@ -365,7 +257,7 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
|
|||||||
<SelectItem value="__default__">
|
<SelectItem value="__default__">
|
||||||
{`Use default (${countryIndeed.default || "UK"})`}
|
{`Use default (${countryIndeed.default || "UK"})`}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
{JOBSPY_INDEED_COUNTRY_OPTIONS.map((country) => (
|
{SUPPORTED_COUNTRY_KEYS.map((country) => (
|
||||||
<SelectItem key={country} value={country}>
|
<SelectItem key={country} value={country}>
|
||||||
{formatCountryLabel(country)}
|
{formatCountryLabel(country)}
|
||||||
</SelectItem>
|
</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);
|
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 () => {
|
it("tracks source completion counters across source transitions", async () => {
|
||||||
const settingsRepo = await import("../../repositories/settings");
|
const settingsRepo = await import("../../repositories/settings");
|
||||||
const jobSpy = await import("../../services/jobspy");
|
const jobSpy = await import("../../services/jobspy");
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
import { logger } from "@infra/logger";
|
import { logger } from "@infra/logger";
|
||||||
|
import {
|
||||||
|
formatCountryLabel,
|
||||||
|
isSourceAllowedForCountry,
|
||||||
|
normalizeCountryKey,
|
||||||
|
} from "@shared/location-support.js";
|
||||||
import type { CreateJobInput, PipelineConfig } from "@shared/types";
|
import type { CreateJobInput, PipelineConfig } from "@shared/types";
|
||||||
import * as jobsRepo from "../../repositories/jobs";
|
import * as jobsRepo from "../../repositories/jobs";
|
||||||
import * as settingsRepo from "../../repositories/settings";
|
import * as settingsRepo from "../../repositories/settings";
|
||||||
@ -35,7 +40,33 @@ export async function discoverJobsStep(args: {
|
|||||||
.filter(Boolean);
|
.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): source is "indeed" | "linkedin" =>
|
||||||
source === "indeed" || source === "linkedin",
|
source === "indeed" || source === "linkedin",
|
||||||
);
|
);
|
||||||
@ -53,9 +84,8 @@ export async function discoverJobsStep(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shouldRunJobSpy = jobSpySites.length > 0;
|
const shouldRunJobSpy = jobSpySites.length > 0;
|
||||||
const shouldRunGradcracker =
|
const shouldRunGradcracker = compatibleSources.includes("gradcracker");
|
||||||
args.mergedConfig.sources.includes("gradcracker");
|
const shouldRunUkVisaJobs = compatibleSources.includes("ukvisajobs");
|
||||||
const shouldRunUkVisaJobs = args.mergedConfig.sources.includes("ukvisajobs");
|
|
||||||
|
|
||||||
const totalSources =
|
const totalSources =
|
||||||
Number(shouldRunJobSpy) +
|
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 "./types";
|
||||||
export * from "./utils/type-conversion";
|
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