feat: add support for indicating workplaceTypes (#296)
This commit is contained in:
@@ -63,6 +63,7 @@ let mockAutomaticRunValues: AutomaticRunValues = {
|
||||
runBudget: 150,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||
};
|
||||
|
||||
const jobFixture = createJob({
|
||||
@@ -402,6 +403,7 @@ describe("OrchestratorPage", () => {
|
||||
runBudget: 150,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -749,6 +751,7 @@ describe("OrchestratorPage", () => {
|
||||
await waitFor(() => {
|
||||
expect(api.updateSettings).toHaveBeenCalledWith({
|
||||
searchTerms: ["backend"],
|
||||
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||
jobspyResultsWanted: 150,
|
||||
gradcrackerMaxJobsPerTerm: 150,
|
||||
ukvisajobsMaxJobs: 150,
|
||||
@@ -780,6 +783,7 @@ describe("OrchestratorPage", () => {
|
||||
runBudget: 150,
|
||||
country: "united kingdom",
|
||||
cityLocations: ["London", "Manchester"],
|
||||
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||
};
|
||||
|
||||
render(
|
||||
@@ -814,6 +818,7 @@ describe("OrchestratorPage", () => {
|
||||
runBudget: 150,
|
||||
country: "united kingdom",
|
||||
cityLocations: ["Leeds", "Manchester"],
|
||||
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||
};
|
||||
|
||||
render(
|
||||
@@ -848,6 +853,7 @@ describe("OrchestratorPage", () => {
|
||||
runBudget: 150,
|
||||
country: "united kingdom",
|
||||
cityLocations: ["Leeds", "Manchester"],
|
||||
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||
};
|
||||
|
||||
render(
|
||||
@@ -954,6 +960,7 @@ describe("OrchestratorPage", () => {
|
||||
runBudget: 150,
|
||||
country: "united states",
|
||||
cityLocations: [],
|
||||
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||
};
|
||||
|
||||
render(
|
||||
|
||||
@@ -344,4 +344,117 @@ describe("AutomaticRunTab", () => {
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Glassdoor" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("loads saved workplace types from settings", () => {
|
||||
render(
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={createAppSettings({
|
||||
workplaceTypes: {
|
||||
value: ["remote", "onsite"],
|
||||
default: ["remote", "hybrid", "onsite"],
|
||||
override: ["remote", "onsite"],
|
||||
},
|
||||
})}
|
||||
enabledSources={["linkedin"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
onToggleSource={vi.fn()}
|
||||
onSetPipelineSources={vi.fn()}
|
||||
isPipelineRunning={false}
|
||||
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced settings" }));
|
||||
|
||||
expect(screen.getByLabelText("Remote")).toBeChecked();
|
||||
expect(screen.getByLabelText("Onsite")).toBeChecked();
|
||||
expect(screen.getByLabelText("Hybrid")).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("requires at least one workplace type", async () => {
|
||||
render(
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={createAppSettings()}
|
||||
enabledSources={["linkedin"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
onToggleSource={vi.fn()}
|
||||
onSetPipelineSources={vi.fn()}
|
||||
isPipelineRunning={false}
|
||||
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced settings" }));
|
||||
fireEvent.click(screen.getByLabelText("Remote"));
|
||||
fireEvent.click(screen.getByLabelText("Hybrid"));
|
||||
fireEvent.click(screen.getByLabelText("Onsite"));
|
||||
|
||||
expect(
|
||||
screen.getByText("Select at least one workplace type."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Start run now" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows JobSpy guidance when non-remote workplace types are selected", () => {
|
||||
render(
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={createAppSettings({
|
||||
workplaceTypes: {
|
||||
value: ["remote", "hybrid"],
|
||||
default: ["remote", "hybrid", "onsite"],
|
||||
override: ["remote", "hybrid"],
|
||||
},
|
||||
})}
|
||||
enabledSources={["linkedin"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
onToggleSource={vi.fn()}
|
||||
onSetPipelineSources={vi.fn()}
|
||||
isPipelineRunning={false}
|
||||
onSaveAndRun={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced settings" }));
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Indeed, LinkedIn, and Glassdoor only support strict remote filtering\./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits workplace types in onSaveAndRun values", async () => {
|
||||
const onSaveAndRun = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<AutomaticRunTab
|
||||
open
|
||||
settings={createAppSettings()}
|
||||
enabledSources={["linkedin"]}
|
||||
pipelineSources={["linkedin"]}
|
||||
onToggleSource={vi.fn()}
|
||||
onSetPipelineSources={vi.fn()}
|
||||
isPipelineRunning={false}
|
||||
onSaveAndRun={onSaveAndRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced settings" }));
|
||||
fireEvent.click(screen.getByLabelText("Hybrid"));
|
||||
fireEvent.click(screen.getByLabelText("Onsite"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Start run now" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSaveAndRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workplaceTypes: ["remote"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SearchableDropdown } from "@/components/ui/searchable-dropdown";
|
||||
@@ -35,10 +36,13 @@ import {
|
||||
type AutomaticRunValues,
|
||||
calculateAutomaticEstimate,
|
||||
loadAutomaticRunMemory,
|
||||
normalizeWorkplaceTypes,
|
||||
parseCityLocationsInput,
|
||||
parseCityLocationsSetting,
|
||||
parseSearchTermsInput,
|
||||
saveAutomaticRunMemory,
|
||||
WORKPLACE_TYPE_OPTIONS,
|
||||
type WorkplaceType,
|
||||
} from "./automatic-run";
|
||||
import { TokenizedInput } from "./TokenizedInput";
|
||||
|
||||
@@ -60,6 +64,7 @@ const DEFAULT_VALUES: AutomaticRunValues = {
|
||||
runBudget: 200,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||
};
|
||||
|
||||
interface AutomaticRunFormValues {
|
||||
@@ -69,6 +74,7 @@ interface AutomaticRunFormValues {
|
||||
country: string;
|
||||
cityLocations: string[];
|
||||
cityLocationDraft: string;
|
||||
workplaceTypes: WorkplaceType[];
|
||||
searchTerms: string[];
|
||||
searchTermDraft: string;
|
||||
}
|
||||
@@ -108,6 +114,11 @@ function toNumber(input: string, min: number, max: number, fallback: number) {
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
|
||||
function formatWorkplaceTypeLabel(workplaceType: WorkplaceType): string {
|
||||
if (workplaceType === "onsite") return "Onsite";
|
||||
return workplaceType.charAt(0).toUpperCase() + workplaceType.slice(1);
|
||||
}
|
||||
|
||||
function getPresetSelection(values: {
|
||||
topN: number;
|
||||
minSuitabilityScore: number;
|
||||
@@ -159,6 +170,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
country: DEFAULT_VALUES.country,
|
||||
cityLocations: [],
|
||||
cityLocationDraft: "",
|
||||
workplaceTypes: DEFAULT_VALUES.workplaceTypes,
|
||||
searchTerms: DEFAULT_VALUES.searchTerms,
|
||||
searchTermDraft: "",
|
||||
},
|
||||
@@ -170,6 +182,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
const countryInput = watch("country");
|
||||
const cityLocations = watch("cityLocations");
|
||||
const cityLocationDraft = watch("cityLocationDraft");
|
||||
const workplaceTypes = watch("workplaceTypes");
|
||||
const searchTerms = watch("searchTerms");
|
||||
const searchTermDraft = watch("searchTermDraft");
|
||||
|
||||
@@ -212,6 +225,9 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
normalizeCountryKey(location) !==
|
||||
normalizeCountryKey(rememberedCountryKey),
|
||||
);
|
||||
const rememberedWorkplaceTypes = normalizeWorkplaceTypes(
|
||||
settings?.workplaceTypes?.value,
|
||||
);
|
||||
|
||||
reset({
|
||||
topN: String(topN),
|
||||
@@ -220,6 +236,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
country: rememberedCountry || DEFAULT_VALUES.country,
|
||||
cityLocations: rememberedLocations,
|
||||
cityLocationDraft: "",
|
||||
workplaceTypes: rememberedWorkplaceTypes,
|
||||
searchTerms: settings?.searchTerms?.value ?? DEFAULT_VALUES.searchTerms,
|
||||
searchTermDraft: "",
|
||||
});
|
||||
@@ -239,6 +256,7 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
runBudget: toNumber(runBudgetInput, 1, 1000, DEFAULT_VALUES.runBudget),
|
||||
country: normalizedCountry || DEFAULT_VALUES.country,
|
||||
cityLocations,
|
||||
workplaceTypes: normalizeWorkplaceTypes(workplaceTypes),
|
||||
searchTerms,
|
||||
};
|
||||
}, [
|
||||
@@ -247,9 +265,12 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
runBudgetInput,
|
||||
countryInput,
|
||||
cityLocations,
|
||||
workplaceTypes,
|
||||
searchTerms,
|
||||
]);
|
||||
|
||||
const workplaceTypeSelectionInvalid = workplaceTypes.length === 0;
|
||||
|
||||
const isSourceAvailableForRun = useCallback(
|
||||
(source: JobSource) => {
|
||||
if (!isSourceAllowedForCountry(source, values.country)) return false;
|
||||
@@ -270,6 +291,15 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
[pipelineSources, isSourceAvailableForRun],
|
||||
);
|
||||
|
||||
const hasOnlyRemoteWorkplaceType =
|
||||
workplaceTypes.length === 1 && workplaceTypes[0] === "remote";
|
||||
const hasJobSpySourceSelected = compatiblePipelineSources.some(
|
||||
(source) =>
|
||||
source === "indeed" || source === "linkedin" || source === "glassdoor",
|
||||
);
|
||||
const showJobSpyWorkplaceWarning =
|
||||
hasJobSpySourceSelected && !hasOnlyRemoteWorkplaceType;
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = pipelineSources.filter((source) =>
|
||||
isSourceAvailableForRun(source),
|
||||
@@ -307,7 +337,19 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
isPipelineRunning ||
|
||||
isSaving ||
|
||||
compatiblePipelineSources.length === 0 ||
|
||||
values.searchTerms.length === 0;
|
||||
values.searchTerms.length === 0 ||
|
||||
workplaceTypeSelectionInvalid;
|
||||
|
||||
const toggleWorkplaceType = (
|
||||
workplaceType: WorkplaceType,
|
||||
checked: boolean,
|
||||
) => {
|
||||
const next = checked
|
||||
? normalizeWorkplaceTypes([...workplaceTypes, workplaceType])
|
||||
: workplaceTypes.filter((value) => value !== workplaceType);
|
||||
|
||||
setValue("workplaceTypes", next, { shouldDirty: true });
|
||||
};
|
||||
|
||||
const applyPreset = (presetId: AutomaticPresetId) => {
|
||||
const preset = AUTOMATIC_PRESETS[presetId];
|
||||
@@ -473,6 +515,55 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
removeLabelPrefix="Remove city"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-3">
|
||||
<Label>Workplace type</Label>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{WORKPLACE_TYPE_OPTIONS.map((workplaceType) => {
|
||||
const checkboxId = `workplace-type-${workplaceType}`;
|
||||
const checked =
|
||||
workplaceTypes.includes(workplaceType);
|
||||
return (
|
||||
<div
|
||||
key={workplaceType}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={checked}
|
||||
onCheckedChange={(nextChecked) => {
|
||||
toggleWorkplaceType(
|
||||
workplaceType,
|
||||
nextChecked === true,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={checkboxId}
|
||||
className="cursor-pointer text-sm font-medium"
|
||||
>
|
||||
{formatWorkplaceTypeLabel(workplaceType)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Applies globally to all search terms and locations in
|
||||
this run.
|
||||
</p>
|
||||
{workplaceTypeSelectionInvalid ? (
|
||||
<p className="text-xs text-destructive">
|
||||
Select at least one workplace type.
|
||||
</p>
|
||||
) : null}
|
||||
{showJobSpyWorkplaceWarning ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
Indeed, LinkedIn, and Glassdoor only support strict
|
||||
remote filtering. Hybrid or Onsite selections will
|
||||
broaden those source results.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -28,6 +28,7 @@ describe("automatic-run utilities", () => {
|
||||
runBudget: 100,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||
},
|
||||
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
|
||||
});
|
||||
@@ -72,6 +73,7 @@ describe("automatic-run utilities", () => {
|
||||
runBudget: 750,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||
},
|
||||
sources: ["indeed", "linkedin", "gradcracker", "ukvisajobs"],
|
||||
});
|
||||
@@ -99,6 +101,7 @@ describe("automatic-run utilities", () => {
|
||||
runBudget: 120,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||
},
|
||||
sources: ["adzuna"],
|
||||
});
|
||||
@@ -116,6 +119,7 @@ describe("automatic-run utilities", () => {
|
||||
runBudget: 120,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||
},
|
||||
sources: ["hiringcafe"],
|
||||
});
|
||||
@@ -133,6 +137,7 @@ describe("automatic-run utilities", () => {
|
||||
runBudget: 120,
|
||||
country: "united kingdom",
|
||||
cityLocations: [],
|
||||
workplaceTypes: ["remote", "hybrid", "onsite"],
|
||||
},
|
||||
sources: ["startupjobs"],
|
||||
});
|
||||
|
||||
@@ -5,6 +5,12 @@ import {
|
||||
import type { JobSource } from "@shared/types";
|
||||
|
||||
export type AutomaticPresetId = "fast" | "balanced" | "detailed";
|
||||
export type WorkplaceType = "remote" | "hybrid" | "onsite";
|
||||
export const WORKPLACE_TYPE_OPTIONS: WorkplaceType[] = [
|
||||
"remote",
|
||||
"hybrid",
|
||||
"onsite",
|
||||
];
|
||||
|
||||
export interface AutomaticRunValues {
|
||||
topN: number;
|
||||
@@ -13,6 +19,7 @@ export interface AutomaticRunValues {
|
||||
runBudget: number;
|
||||
country: string;
|
||||
cityLocations: string[];
|
||||
workplaceTypes: WorkplaceType[];
|
||||
}
|
||||
|
||||
export interface AutomaticPresetValues {
|
||||
@@ -61,6 +68,22 @@ export interface AutomaticRunMemory {
|
||||
minSuitabilityScore: number;
|
||||
}
|
||||
|
||||
export function normalizeWorkplaceTypes(
|
||||
workplaceTypes: WorkplaceType[] | null | undefined,
|
||||
): WorkplaceType[] {
|
||||
const seen = new Set<WorkplaceType>();
|
||||
const out: WorkplaceType[] = [];
|
||||
|
||||
for (const workplaceType of workplaceTypes ?? []) {
|
||||
if (!WORKPLACE_TYPE_OPTIONS.includes(workplaceType)) continue;
|
||||
if (seen.has(workplaceType)) continue;
|
||||
seen.add(workplaceType);
|
||||
out.push(workplaceType);
|
||||
}
|
||||
|
||||
return out.length > 0 ? out : [...WORKPLACE_TYPE_OPTIONS];
|
||||
}
|
||||
|
||||
export interface ExtractorLimits {
|
||||
jobspyResultsWanted: number;
|
||||
gradcrackerMaxJobsPerTerm: number;
|
||||
|
||||
@@ -192,6 +192,7 @@ export function usePipelineControls(
|
||||
: formatCountryLabel(values.country);
|
||||
await api.updateSettings({
|
||||
searchTerms: values.searchTerms,
|
||||
workplaceTypes: values.workplaceTypes,
|
||||
jobspyResultsWanted: limits.jobspyResultsWanted,
|
||||
gradcrackerMaxJobsPerTerm: limits.gradcrackerMaxJobsPerTerm,
|
||||
ukvisajobsMaxJobs: limits.ukvisajobsMaxJobs,
|
||||
|
||||
Reference in New Issue
Block a user