feat: add support for indicating workplaceTypes (#296)

This commit is contained in:
Ryan Foote
2026-03-21 20:43:43 +00:00
committed by GitHub
parent 8274ec4e14
commit 0b22c08d7d
25 changed files with 497 additions and 5 deletions
@@ -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,