Initial commit for refactor

This commit is contained in:
DaKheera47
2026-01-21 19:01:25 +00:00
parent a4ed912de1
commit b0a8fbcde4
19 changed files with 1154 additions and 679 deletions
@@ -141,6 +141,28 @@ describe("SettingsPage", () => {
expect(toast.success).toHaveBeenCalledWith("Settings saved")
})
it("shows validation error for too long model override", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
renderPage()
const modelTrigger = await screen.findByRole("button", { name: /model/i })
fireEvent.click(modelTrigger)
const modelField = screen.getByText("Override model").parentElement ?? screen.getByRole("main")
const modelInput = within(modelField).getByRole("textbox")
// Change to > 200 chars
fireEvent.change(modelInput, { target: { value: "a".repeat(201) } })
// Should see error message
expect(await screen.findByText(/String must contain at most 200 character\(s\)/i)).toBeInTheDocument()
// Save button should be disabled due to validation error (isValid will be false)
const saveButton = screen.getByRole("button", { name: /^save$/i })
expect(saveButton).toBeDisabled()
})
it("clears jobs by status and summarizes results", async () => {
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
vi.mocked(api.deleteJobsByStatus).mockResolvedValue({ message: "", count: 2 })
+132 -272
View File
@@ -1,52 +1,101 @@
/**
* Settings page.
*/
import React, { useEffect, useMemo, useState } from "react"
import React, { useEffect, useState } from "react"
import { Settings } from "lucide-react"
import { toast } from "sonner"
import { useForm, FormProvider } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { PageHeader } from "../components/layout"
import { PageHeader } from "@client/components/layout"
import { Accordion } from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"
import type { AppSettings, JobStatus, ResumeProjectsSettings } from "../../shared/types"
import * as api from "../api"
import { arraysEqual } from "@/lib/utils"
import { resumeProjectsEqual } from "./settings/utils"
import { DangerZoneSection } from "./settings/components/DangerZoneSection"
import { DisplaySettingsSection } from "./settings/components/DisplaySettingsSection"
import { GradcrackerSection } from "./settings/components/GradcrackerSection"
import { JobCompleteWebhookSection } from "./settings/components/JobCompleteWebhookSection"
import { JobspySection } from "./settings/components/JobspySection"
import { ModelSettingsSection } from "./settings/components/ModelSettingsSection"
import { PipelineWebhookSection } from "./settings/components/PipelineWebhookSection"
import { ResumeProjectsSection } from "./settings/components/ResumeProjectsSection"
import { SearchTermsSection } from "./settings/components/SearchTermsSection"
import { UkvisajobsSection } from "./settings/components/UkvisajobsSection"
import type { AppSettings, JobStatus } from "@shared/types"
import { updateSettingsSchema, type UpdateSettingsInput } from "@shared/settings-schema"
import * as api from "@client/api"
import { resumeProjectsEqual } from "@client/pages/settings/utils"
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection"
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection"
import { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection"
import { JobCompleteWebhookSection } from "@client/pages/settings/components/JobCompleteWebhookSection"
import { JobspySection } from "@client/pages/settings/components/JobspySection"
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection"
import { PipelineWebhookSection } from "@client/pages/settings/components/PipelineWebhookSection"
import { ResumeProjectsSection } from "@client/pages/settings/components/ResumeProjectsSection"
import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection"
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection"
const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
model: "",
modelScorer: "",
modelTailoring: "",
modelProjectSelection: "",
pipelineWebhookUrl: "",
jobCompleteWebhookUrl: "",
resumeProjects: null,
ukvisajobsMaxJobs: null,
gradcrackerMaxJobsPerTerm: null,
searchTerms: null,
jobspyLocation: null,
jobspyResultsWanted: null,
jobspyHoursOld: null,
jobspyCountryIndeed: null,
jobspySites: null,
jobspyLinkedinFetchDescription: null,
showSponsorInfo: null,
}
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
model: null,
modelScorer: null,
modelTailoring: null,
modelProjectSelection: null,
pipelineWebhookUrl: null,
jobCompleteWebhookUrl: null,
resumeProjects: null,
ukvisajobsMaxJobs: null,
gradcrackerMaxJobsPerTerm: null,
searchTerms: null,
jobspyLocation: null,
jobspyResultsWanted: null,
jobspyHoursOld: null,
jobspyCountryIndeed: null,
jobspySites: null,
jobspyLinkedinFetchDescription: null,
showSponsorInfo: null,
}
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
model: data.overrideModel ?? "",
modelScorer: data.overrideModelScorer ?? "",
modelTailoring: data.overrideModelTailoring ?? "",
modelProjectSelection: data.overrideModelProjectSelection ?? "",
pipelineWebhookUrl: data.overridePipelineWebhookUrl ?? "",
jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "",
resumeProjects: data.resumeProjects,
ukvisajobsMaxJobs: data.overrideUkvisajobsMaxJobs,
gradcrackerMaxJobsPerTerm: data.overrideGradcrackerMaxJobsPerTerm,
searchTerms: data.overrideSearchTerms,
jobspyLocation: data.overrideJobspyLocation,
jobspyResultsWanted: data.overrideJobspyResultsWanted,
jobspyHoursOld: data.overrideJobspyHoursOld,
jobspyCountryIndeed: data.overrideJobspyCountryIndeed,
jobspySites: data.overrideJobspySites,
jobspyLinkedinFetchDescription: data.overrideJobspyLinkedinFetchDescription,
showSponsorInfo: data.overrideShowSponsorInfo,
})
export const SettingsPage: React.FC = () => {
const [settings, setSettings] = useState<AppSettings | null>(null)
const [modelDraft, setModelDraft] = useState("")
const [modelScorerDraft, setModelScorerDraft] = useState("")
const [modelTailoringDraft, setModelTailoringDraft] = useState("")
const [modelProjectSelectionDraft, setModelProjectSelectionDraft] = useState("")
const [pipelineWebhookUrlDraft, setPipelineWebhookUrlDraft] = useState("")
const [jobCompleteWebhookUrlDraft, setJobCompleteWebhookUrlDraft] = useState("")
const [resumeProjectsDraft, setResumeProjectsDraft] = useState<ResumeProjectsSettings | null>(null)
const [ukvisajobsMaxJobsDraft, setUkvisajobsMaxJobsDraft] = useState<number | null>(null)
const [gradcrackerMaxJobsPerTermDraft, setGradcrackerMaxJobsPerTermDraft] = useState<number | null>(null)
const [searchTermsDraft, setSearchTermsDraft] = useState<string[] | null>(null)
const [jobspyLocationDraft, setJobspyLocationDraft] = useState<string | null>(null)
const [jobspyResultsWantedDraft, setJobspyResultsWantedDraft] = useState<number | null>(null)
const [jobspyHoursOldDraft, setJobspyHoursOldDraft] = useState<number | null>(null)
const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState<string | null>(null)
const [jobspySitesDraft, setJobspySitesDraft] = useState<string[] | null>(null)
const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState<boolean | null>(null)
const [showSponsorInfoDraft, setShowSponsorInfoDraft] = useState<boolean | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(['discovered'])
const methods = useForm<UpdateSettingsInput>({
resolver: zodResolver(updateSettingsSchema),
mode: "onChange",
defaultValues: DEFAULT_FORM_VALUES,
})
const { handleSubmit, reset, watch, formState: { isDirty, errors, isValid } } = methods
useEffect(() => {
let isMounted = true
setIsLoading(true)
@@ -55,23 +104,7 @@ export const SettingsPage: React.FC = () => {
.then((data) => {
if (!isMounted) return
setSettings(data)
setModelDraft(data.overrideModel ?? "")
setModelScorerDraft(data.overrideModelScorer ?? "")
setModelTailoringDraft(data.overrideModelTailoring ?? "")
setModelProjectSelectionDraft(data.overrideModelProjectSelection ?? "")
setPipelineWebhookUrlDraft(data.overridePipelineWebhookUrl ?? "")
setJobCompleteWebhookUrlDraft(data.overrideJobCompleteWebhookUrl ?? "")
setResumeProjectsDraft(data.resumeProjects)
setUkvisajobsMaxJobsDraft(data.overrideUkvisajobsMaxJobs)
setGradcrackerMaxJobsPerTermDraft(data.overrideGradcrackerMaxJobsPerTerm)
setSearchTermsDraft(data.overrideSearchTerms)
setJobspyLocationDraft(data.overrideJobspyLocation)
setJobspyResultsWantedDraft(data.overrideJobspyResultsWanted)
setJobspyHoursOldDraft(data.overrideJobspyHoursOld)
setJobspyCountryIndeedDraft(data.overrideJobspyCountryIndeed)
setJobspySitesDraft(data.overrideJobspySites)
setJobspyLinkedinFetchDescriptionDraft(data.overrideJobspyLinkedinFetchDescription)
setShowSponsorInfoDraft(data.overrideShowSponsorInfo)
reset(mapSettingsToForm(data))
})
.catch((error) => {
const message = error instanceof Error ? error.message : "Failed to load settings"
@@ -85,190 +118,79 @@ export const SettingsPage: React.FC = () => {
return () => {
isMounted = false
}
}, [])
}, [reset])
const effectiveModel = settings?.model ?? ""
const defaultModel = settings?.defaultModel ?? ""
const overrideModel = settings?.overrideModel
const effectiveModelScorer = settings?.modelScorer ?? ""
const overrideModelScorer = settings?.overrideModelScorer
const effectiveModelTailoring = settings?.modelTailoring ?? ""
const overrideModelTailoring = settings?.overrideModelTailoring
const effectiveModelProjectSelection = settings?.modelProjectSelection ?? ""
const overrideModelProjectSelection = settings?.overrideModelProjectSelection
const effectivePipelineWebhookUrl = settings?.pipelineWebhookUrl ?? ""
const defaultPipelineWebhookUrl = settings?.defaultPipelineWebhookUrl ?? ""
const overridePipelineWebhookUrl = settings?.overridePipelineWebhookUrl
const effectiveJobCompleteWebhookUrl = settings?.jobCompleteWebhookUrl ?? ""
const defaultJobCompleteWebhookUrl = settings?.defaultJobCompleteWebhookUrl ?? ""
const overrideJobCompleteWebhookUrl = settings?.overrideJobCompleteWebhookUrl
const effectiveUkvisajobsMaxJobs = settings?.ukvisajobsMaxJobs ?? 50
const defaultUkvisajobsMaxJobs = settings?.defaultUkvisajobsMaxJobs ?? 50
const overrideUkvisajobsMaxJobs = settings?.overrideUkvisajobsMaxJobs
const effectiveGradcrackerMaxJobsPerTerm = settings?.gradcrackerMaxJobsPerTerm ?? 50
const defaultGradcrackerMaxJobsPerTerm = settings?.defaultGradcrackerMaxJobsPerTerm ?? 50
const overrideGradcrackerMaxJobsPerTerm = settings?.overrideGradcrackerMaxJobsPerTerm
const effectiveSearchTerms = settings?.searchTerms ?? []
const defaultSearchTerms = settings?.defaultSearchTerms ?? []
const overrideSearchTerms = settings?.overrideSearchTerms
const effectiveJobspyLocation = settings?.jobspyLocation ?? ""
const defaultJobspyLocation = settings?.defaultJobspyLocation ?? ""
const overrideJobspyLocation = settings?.overrideJobspyLocation
const effectiveJobspyResultsWanted = settings?.jobspyResultsWanted ?? 200
const defaultJobspyResultsWanted = settings?.defaultJobspyResultsWanted ?? 200
const overrideJobspyResultsWanted = settings?.overrideJobspyResultsWanted
const effectiveJobspyHoursOld = settings?.jobspyHoursOld ?? 72
const defaultJobspyHoursOld = settings?.defaultJobspyHoursOld ?? 72
const overrideJobspyHoursOld = settings?.overrideJobspyHoursOld
const effectiveJobspyCountryIndeed = settings?.jobspyCountryIndeed ?? ""
const defaultJobspyCountryIndeed = settings?.defaultJobspyCountryIndeed ?? ""
const overrideJobspyCountryIndeed = settings?.overrideJobspyCountryIndeed
const effectiveJobspySites = settings?.jobspySites ?? ["indeed", "linkedin"]
const defaultJobspySites = settings?.defaultJobspySites ?? ["indeed", "linkedin"]
const overrideJobspySites = settings?.overrideJobspySites
const effectiveJobspyLinkedinFetchDescription = settings?.jobspyLinkedinFetchDescription ?? true
const defaultJobspyLinkedinFetchDescription = settings?.defaultJobspyLinkedinFetchDescription ?? true
const overrideJobspyLinkedinFetchDescription = settings?.overrideJobspyLinkedinFetchDescription
const effectiveShowSponsorInfo = settings?.showSponsorInfo ?? true
const defaultShowSponsorInfo = settings?.defaultShowSponsorInfo ?? true
const overrideShowSponsorInfo = settings?.overrideShowSponsorInfo
const profileProjects = settings?.profileProjects ?? []
const maxProjectsTotal = profileProjects.length
const lockedCount = resumeProjectsDraft?.lockedProjectIds.length ?? 0
const canSave = useMemo(() => {
if (!settings || !resumeProjectsDraft) return false
const next = modelDraft.trim()
const current = (overrideModel ?? "").trim()
const nextScorer = modelScorerDraft.trim()
const currentScorer = (overrideModelScorer ?? "").trim()
const nextTailoring = modelTailoringDraft.trim()
const currentTailoring = (overrideModelTailoring ?? "").trim()
const nextProjectSelection = modelProjectSelectionDraft.trim()
const currentProjectSelection = (overrideModelProjectSelection ?? "").trim()
const nextWebhook = pipelineWebhookUrlDraft.trim()
const currentWebhook = (overridePipelineWebhookUrl ?? "").trim()
const nextJobCompleteWebhook = jobCompleteWebhookUrlDraft.trim()
const currentJobCompleteWebhook = (overrideJobCompleteWebhookUrl ?? "").trim()
const ukvisajobsChanged = ukvisajobsMaxJobsDraft !== (overrideUkvisajobsMaxJobs ?? null)
const gradcrackerChanged = gradcrackerMaxJobsPerTermDraft !== (overrideGradcrackerMaxJobsPerTerm ?? null)
const searchTermsChanged = JSON.stringify(searchTermsDraft) !== JSON.stringify(overrideSearchTerms ?? null)
return (
next !== current ||
nextScorer !== currentScorer ||
nextTailoring !== currentTailoring ||
nextProjectSelection !== currentProjectSelection ||
nextWebhook !== currentWebhook ||
nextJobCompleteWebhook !== currentJobCompleteWebhook ||
!resumeProjectsEqual(resumeProjectsDraft, settings.resumeProjects) ||
ukvisajobsChanged ||
gradcrackerChanged ||
searchTermsChanged ||
jobspyLocationDraft !== (overrideJobspyLocation ?? null) ||
jobspyResultsWantedDraft !== (overrideJobspyResultsWanted ?? null) ||
jobspyHoursOldDraft !== (overrideJobspyHoursOld ?? null) ||
jobspyCountryIndeedDraft !== (overrideJobspyCountryIndeed ?? null) ||
JSON.stringify((jobspySitesDraft ?? []).slice().sort()) !== JSON.stringify((overrideJobspySites ?? []).slice().sort()) ||
jobspyLinkedinFetchDescriptionDraft !== (overrideJobspyLinkedinFetchDescription ?? null) ||
showSponsorInfoDraft !== (overrideShowSponsorInfo ?? null)
)
}, [
settings,
modelDraft,
modelScorerDraft,
modelTailoringDraft,
modelProjectSelectionDraft,
pipelineWebhookUrlDraft,
jobCompleteWebhookUrlDraft,
overrideModel,
overrideModelScorer,
overrideModelTailoring,
overrideModelProjectSelection,
overridePipelineWebhookUrl,
overrideJobCompleteWebhookUrl,
resumeProjectsDraft,
ukvisajobsMaxJobsDraft,
overrideUkvisajobsMaxJobs,
gradcrackerMaxJobsPerTermDraft,
overrideGradcrackerMaxJobsPerTerm,
searchTermsDraft,
overrideSearchTerms,
jobspyLocationDraft,
jobspyResultsWantedDraft,
jobspyHoursOldDraft,
jobspyCountryIndeedDraft,
jobspySitesDraft,
jobspyLinkedinFetchDescriptionDraft,
showSponsorInfoDraft,
overrideJobspyLocation,
overrideJobspyResultsWanted,
overrideJobspyHoursOld,
overrideJobspyCountryIndeed,
overrideJobspySites,
overrideJobspyLinkedinFetchDescription,
overrideShowSponsorInfo,
])
const watchedValues = watch()
const lockedCount = watchedValues.resumeProjects?.lockedProjectIds.length ?? 0
const handleSave = async () => {
if (!settings || !resumeProjectsDraft) return
const canSave = isDirty && isValid
const onSave = async (data: UpdateSettingsInput) => {
if (!settings) return
try {
setIsSaving(true)
const trimmed = modelDraft.trim()
const trimmedScorer = modelScorerDraft.trim()
const trimmedTailoring = modelTailoringDraft.trim()
const trimmedProjectSelection = modelProjectSelectionDraft.trim()
const webhookTrimmed = pipelineWebhookUrlDraft.trim()
const jobCompleteTrimmed = jobCompleteWebhookUrlDraft.trim()
const resumeProjectsOverride = resumeProjectsEqual(resumeProjectsDraft, settings.defaultResumeProjects)
// Prepare payload: nullify if equal to default
const resumeProjectsData = data.resumeProjects
const resumeProjectsOverride = (resumeProjectsData && settings.defaultResumeProjects && resumeProjectsEqual(resumeProjectsData, settings.defaultResumeProjects))
? null
: resumeProjectsDraft
const ukvisajobsMaxJobsOverride = ukvisajobsMaxJobsDraft === defaultUkvisajobsMaxJobs ? null : ukvisajobsMaxJobsDraft
const gradcrackerMaxJobsPerTermOverride = gradcrackerMaxJobsPerTermDraft === defaultGradcrackerMaxJobsPerTerm ? null : gradcrackerMaxJobsPerTermDraft
const searchTermsOverride = arraysEqual(searchTermsDraft ?? [], defaultSearchTerms) ? null : searchTermsDraft
const jobspyLocationOverride = jobspyLocationDraft === defaultJobspyLocation ? null : jobspyLocationDraft
const jobspyResultsWantedOverride = jobspyResultsWantedDraft === defaultJobspyResultsWanted ? null : jobspyResultsWantedDraft
const jobspyHoursOldOverride = jobspyHoursOldDraft === defaultJobspyHoursOld ? null : jobspyHoursOldDraft
const jobspyCountryIndeedOverride = jobspyCountryIndeedDraft === defaultJobspyCountryIndeed ? null : jobspyCountryIndeedDraft
const jobspySitesOverride = arraysEqual((jobspySitesDraft ?? []).slice().sort(), (defaultJobspySites ?? []).slice().sort()) ? null : jobspySitesDraft
const jobspyLinkedinFetchDescriptionOverride = jobspyLinkedinFetchDescriptionDraft === defaultJobspyLinkedinFetchDescription ? null : jobspyLinkedinFetchDescriptionDraft
const showSponsorInfoOverride = showSponsorInfoDraft === defaultShowSponsorInfo ? null : showSponsorInfoDraft
const updated = await api.updateSettings({
model: trimmed.length > 0 ? trimmed : null,
modelScorer: trimmedScorer.length > 0 ? trimmedScorer : null,
modelTailoring: trimmedTailoring.length > 0 ? trimmedTailoring : null,
modelProjectSelection: trimmedProjectSelection.length > 0 ? trimmedProjectSelection : null,
pipelineWebhookUrl: webhookTrimmed.length > 0 ? webhookTrimmed : null,
jobCompleteWebhookUrl: jobCompleteTrimmed.length > 0 ? jobCompleteTrimmed : null,
: resumeProjectsData
const payload: UpdateSettingsInput = {
model: data.model?.trim() || null,
modelScorer: data.modelScorer?.trim() || null,
modelTailoring: data.modelTailoring?.trim() || null,
modelProjectSelection: data.modelProjectSelection?.trim() || null,
pipelineWebhookUrl: data.pipelineWebhookUrl?.trim() || null,
jobCompleteWebhookUrl: data.jobCompleteWebhookUrl?.trim() || null,
resumeProjects: resumeProjectsOverride,
ukvisajobsMaxJobs: ukvisajobsMaxJobsOverride,
gradcrackerMaxJobsPerTerm: gradcrackerMaxJobsPerTermOverride,
searchTerms: searchTermsOverride,
jobspyLocation: jobspyLocationOverride,
jobspyResultsWanted: jobspyResultsWantedOverride,
jobspyHoursOld: jobspyHoursOldOverride,
jobspyCountryIndeed: jobspyCountryIndeedOverride,
jobspySites: jobspySitesOverride,
jobspyLinkedinFetchDescription: jobspyLinkedinFetchDescriptionOverride,
showSponsorInfo: showSponsorInfoOverride,
})
ukvisajobsMaxJobs: data.ukvisajobsMaxJobs === defaultUkvisajobsMaxJobs ? null : data.ukvisajobsMaxJobs,
gradcrackerMaxJobsPerTerm: data.gradcrackerMaxJobsPerTerm === defaultGradcrackerMaxJobsPerTerm ? null : data.gradcrackerMaxJobsPerTerm,
searchTerms: JSON.stringify(data.searchTerms) === JSON.stringify(defaultSearchTerms) ? null : data.searchTerms,
jobspyLocation: data.jobspyLocation === defaultJobspyLocation ? null : data.jobspyLocation,
jobspyResultsWanted: data.jobspyResultsWanted === defaultJobspyResultsWanted ? null : data.jobspyResultsWanted,
jobspyHoursOld: data.jobspyHoursOld === defaultJobspyHoursOld ? null : data.jobspyHoursOld,
jobspyCountryIndeed: data.jobspyCountryIndeed === defaultJobspyCountryIndeed ? null : data.jobspyCountryIndeed,
jobspySites: JSON.stringify((data.jobspySites ?? []).slice().sort()) === JSON.stringify((defaultJobspySites ?? []).slice().sort()) ? null : data.jobspySites,
jobspyLinkedinFetchDescription: data.jobspyLinkedinFetchDescription === defaultJobspyLinkedinFetchDescription ? null : data.jobspyLinkedinFetchDescription,
showSponsorInfo: data.showSponsorInfo === defaultShowSponsorInfo ? null : data.showSponsorInfo,
}
const updated = await api.updateSettings(payload)
setSettings(updated)
setModelDraft(updated.overrideModel ?? "")
setModelScorerDraft(updated.overrideModelScorer ?? "")
setModelTailoringDraft(updated.overrideModelTailoring ?? "")
setModelProjectSelectionDraft(updated.overrideModelProjectSelection ?? "")
setPipelineWebhookUrlDraft(updated.overridePipelineWebhookUrl ?? "")
setJobCompleteWebhookUrlDraft(updated.overrideJobCompleteWebhookUrl ?? "")
setResumeProjectsDraft(updated.resumeProjects)
setUkvisajobsMaxJobsDraft(updated.overrideUkvisajobsMaxJobs)
setGradcrackerMaxJobsPerTermDraft(updated.overrideGradcrackerMaxJobsPerTerm)
setSearchTermsDraft(updated.overrideSearchTerms)
setJobspyLocationDraft(updated.overrideJobspyLocation)
setJobspyResultsWantedDraft(updated.overrideJobspyResultsWanted)
setJobspyHoursOldDraft(updated.overrideJobspyHoursOld)
setJobspyCountryIndeedDraft(updated.overrideJobspyCountryIndeed)
setJobspySitesDraft(updated.overrideJobspySites)
setJobspyLinkedinFetchDescriptionDraft(updated.overrideJobspyLinkedinFetchDescription)
setShowSponsorInfoDraft(updated.overrideShowSponsorInfo)
reset(mapSettingsToForm(updated))
toast.success("Settings saved")
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to save settings"
@@ -277,6 +199,7 @@ export const SettingsPage: React.FC = () => {
setIsSaving(false)
}
}
const handleClearDatabase = async () => {
try {
setIsSaving(true)
@@ -335,43 +258,9 @@ export const SettingsPage: React.FC = () => {
const handleReset = async () => {
try {
setIsSaving(true)
const updated = await api.updateSettings({
model: null,
modelScorer: null,
modelTailoring: null,
modelProjectSelection: null,
pipelineWebhookUrl: null,
jobCompleteWebhookUrl: null,
resumeProjects: null,
ukvisajobsMaxJobs: null,
gradcrackerMaxJobsPerTerm: null,
searchTerms: null,
jobspyLocation: null,
jobspyResultsWanted: null,
jobspyHoursOld: null,
jobspyCountryIndeed: null,
jobspySites: null,
jobspyLinkedinFetchDescription: null,
showSponsorInfo: null,
})
const updated = await api.updateSettings(NULL_SETTINGS_PAYLOAD)
setSettings(updated)
setModelDraft("")
setModelScorerDraft("")
setModelTailoringDraft("")
setModelProjectSelectionDraft("")
setPipelineWebhookUrlDraft("")
setJobCompleteWebhookUrlDraft("")
setResumeProjectsDraft(updated.resumeProjects)
setUkvisajobsMaxJobsDraft(null)
setGradcrackerMaxJobsPerTermDraft(null)
setSearchTermsDraft(null)
setJobspyLocationDraft(null)
setJobspyResultsWantedDraft(null)
setJobspyHoursOldDraft(null)
setJobspyCountryIndeedDraft(null)
setJobspySitesDraft(null)
setJobspyLinkedinFetchDescriptionDraft(null)
setShowSponsorInfoDraft(null)
reset(mapSettingsToForm(updated))
toast.success("Reset to default")
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to reset settings"
@@ -382,7 +271,7 @@ export const SettingsPage: React.FC = () => {
}
return (
<>
<FormProvider {...methods}>
<PageHeader
icon={Settings}
title="Settings"
@@ -392,14 +281,6 @@ export const SettingsPage: React.FC = () => {
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6 pb-12">
<Accordion type="multiple" className="w-full space-y-4">
<ModelSettingsSection
modelDraft={modelDraft}
setModelDraft={setModelDraft}
modelScorerDraft={modelScorerDraft}
setModelScorerDraft={setModelScorerDraft}
modelTailoringDraft={modelTailoringDraft}
setModelTailoringDraft={setModelTailoringDraft}
modelProjectSelectionDraft={modelProjectSelectionDraft}
setModelProjectSelectionDraft={setModelProjectSelectionDraft}
effectiveModel={effectiveModel}
effectiveModelScorer={effectiveModelScorer}
effectiveModelTailoring={effectiveModelTailoring}
@@ -409,76 +290,52 @@ export const SettingsPage: React.FC = () => {
isSaving={isSaving}
/>
<PipelineWebhookSection
pipelineWebhookUrlDraft={pipelineWebhookUrlDraft}
setPipelineWebhookUrlDraft={setPipelineWebhookUrlDraft}
defaultPipelineWebhookUrl={defaultPipelineWebhookUrl}
effectivePipelineWebhookUrl={effectivePipelineWebhookUrl}
isLoading={isLoading}
isSaving={isSaving}
/>
<JobCompleteWebhookSection
jobCompleteWebhookUrlDraft={jobCompleteWebhookUrlDraft}
setJobCompleteWebhookUrlDraft={setJobCompleteWebhookUrlDraft}
defaultJobCompleteWebhookUrl={defaultJobCompleteWebhookUrl}
effectiveJobCompleteWebhookUrl={effectiveJobCompleteWebhookUrl}
isLoading={isLoading}
isSaving={isSaving}
/>
<UkvisajobsSection
ukvisajobsMaxJobsDraft={ukvisajobsMaxJobsDraft}
setUkvisajobsMaxJobsDraft={setUkvisajobsMaxJobsDraft}
defaultUkvisajobsMaxJobs={defaultUkvisajobsMaxJobs}
effectiveUkvisajobsMaxJobs={effectiveUkvisajobsMaxJobs}
isLoading={isLoading}
isSaving={isSaving}
/>
<GradcrackerSection
gradcrackerMaxJobsPerTermDraft={gradcrackerMaxJobsPerTermDraft}
setGradcrackerMaxJobsPerTermDraft={setGradcrackerMaxJobsPerTermDraft}
defaultGradcrackerMaxJobsPerTerm={defaultGradcrackerMaxJobsPerTerm}
effectiveGradcrackerMaxJobsPerTerm={effectiveGradcrackerMaxJobsPerTerm}
isLoading={isLoading}
isSaving={isSaving}
/>
<SearchTermsSection
searchTermsDraft={searchTermsDraft}
setSearchTermsDraft={setSearchTermsDraft}
defaultSearchTerms={defaultSearchTerms}
effectiveSearchTerms={effectiveSearchTerms}
isLoading={isLoading}
isSaving={isSaving}
/>
<JobspySection
jobspySitesDraft={jobspySitesDraft}
setJobspySitesDraft={setJobspySitesDraft}
defaultJobspySites={defaultJobspySites}
effectiveJobspySites={effectiveJobspySites}
jobspyLocationDraft={jobspyLocationDraft}
setJobspyLocationDraft={setJobspyLocationDraft}
defaultJobspyLocation={defaultJobspyLocation}
effectiveJobspyLocation={effectiveJobspyLocation}
jobspyResultsWantedDraft={jobspyResultsWantedDraft}
setJobspyResultsWantedDraft={setJobspyResultsWantedDraft}
defaultJobspyResultsWanted={defaultJobspyResultsWanted}
effectiveJobspyResultsWanted={effectiveJobspyResultsWanted}
jobspyHoursOldDraft={jobspyHoursOldDraft}
setJobspyHoursOldDraft={setJobspyHoursOldDraft}
defaultJobspyHoursOld={defaultJobspyHoursOld}
effectiveJobspyHoursOld={effectiveJobspyHoursOld}
jobspyCountryIndeedDraft={jobspyCountryIndeedDraft}
setJobspyCountryIndeedDraft={setJobspyCountryIndeedDraft}
defaultJobspyCountryIndeed={defaultJobspyCountryIndeed}
effectiveJobspyCountryIndeed={effectiveJobspyCountryIndeed}
jobspyLinkedinFetchDescriptionDraft={jobspyLinkedinFetchDescriptionDraft}
setJobspyLinkedinFetchDescriptionDraft={setJobspyLinkedinFetchDescriptionDraft}
defaultJobspyLinkedinFetchDescription={defaultJobspyLinkedinFetchDescription}
effectiveJobspyLinkedinFetchDescription={effectiveJobspyLinkedinFetchDescription}
isLoading={isLoading}
isSaving={isSaving}
/>
<ResumeProjectsSection
resumeProjectsDraft={resumeProjectsDraft}
setResumeProjectsDraft={setResumeProjectsDraft}
profileProjects={profileProjects}
lockedCount={lockedCount}
maxProjectsTotal={maxProjectsTotal}
@@ -486,8 +343,6 @@ export const SettingsPage: React.FC = () => {
isSaving={isSaving}
/>
<DisplaySettingsSection
showSponsorInfoDraft={showSponsorInfoDraft}
setShowSponsorInfoDraft={setShowSponsorInfoDraft}
defaultShowSponsorInfo={defaultShowSponsorInfo}
effectiveShowSponsorInfo={effectiveShowSponsorInfo}
isLoading={isLoading}
@@ -504,14 +359,19 @@ export const SettingsPage: React.FC = () => {
</Accordion>
<div className="flex flex-wrap gap-2">
<Button onClick={handleSave} disabled={isLoading || isSaving || !canSave}>
<Button onClick={handleSubmit(onSave)} disabled={isLoading || isSaving || !canSave}>
{isSaving ? "Saving..." : "Save"}
</Button>
<Button variant="outline" onClick={handleReset} disabled={isLoading || isSaving || !settings}>
Reset to default
</Button>
</div>
{Object.keys(errors).length > 0 && (
<div className="text-destructive text-sm mt-2">
Please fix the errors before saving.
</div>
)}
</main>
</>
</FormProvider>
)
}
@@ -16,7 +16,7 @@ import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import type { JobStatus } from "@shared/types"
import { ALL_JOB_STATUSES, STATUS_DESCRIPTIONS } from "../constants"
import { ALL_JOB_STATUSES, STATUS_DESCRIPTIONS } from "@client/pages/settings/constants"
type DangerZoneSectionProps = {
statusesToClear: JobStatus[]
@@ -1,12 +1,12 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
type DisplaySettingsSectionProps = {
showSponsorInfoDraft: boolean | null
setShowSponsorInfoDraft: (value: boolean | null) => void
defaultShowSponsorInfo: boolean
effectiveShowSponsorInfo: boolean
isLoading: boolean
@@ -14,14 +14,12 @@ type DisplaySettingsSectionProps = {
}
export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
showSponsorInfoDraft,
setShowSponsorInfoDraft,
defaultShowSponsorInfo,
effectiveShowSponsorInfo,
isLoading,
isSaving,
}) => {
const isChecked = showSponsorInfoDraft ?? defaultShowSponsorInfo
const { control } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="display" className="border rounded-lg px-4">
@@ -31,13 +29,19 @@ export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Checkbox
id="showSponsorInfo"
checked={isChecked}
onCheckedChange={(checked) => {
setShowSponsorInfoDraft(checked === "indeterminate" ? null : checked === true)
}}
disabled={isLoading || isSaving}
<Controller
name="showSponsorInfo"
control={control}
render={({ field }) => (
<Checkbox
id="showSponsorInfo"
checked={field.value ?? defaultShowSponsorInfo}
onCheckedChange={(checked) => {
field.onChange(checked === "indeterminate" ? null : checked === true)
}}
disabled={isLoading || isSaving}
/>
)}
/>
<div className="flex flex-col gap-1.5">
<label
@@ -75,3 +79,4 @@ export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
</AccordionItem>
)
}
@@ -1,12 +1,12 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
type GradcrackerSectionProps = {
gradcrackerMaxJobsPerTermDraft: number | null
setGradcrackerMaxJobsPerTermDraft: (value: number | null) => void
defaultGradcrackerMaxJobsPerTerm: number
effectiveGradcrackerMaxJobsPerTerm: number
isLoading: boolean
@@ -14,13 +14,13 @@ type GradcrackerSectionProps = {
}
export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
gradcrackerMaxJobsPerTermDraft,
setGradcrackerMaxJobsPerTermDraft,
defaultGradcrackerMaxJobsPerTerm,
effectiveGradcrackerMaxJobsPerTerm,
isLoading,
isSaving,
}) => {
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="gradcracker" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@@ -30,22 +30,29 @@ export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max jobs per search term</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={gradcrackerMaxJobsPerTermDraft ?? defaultGradcrackerMaxJobsPerTerm}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setGradcrackerMaxJobsPerTermDraft(null)
} else {
setGradcrackerMaxJobsPerTermDraft(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
<Controller
name="gradcrackerMaxJobsPerTerm"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={field.value ?? ""}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
field.onChange(null)
} else {
field.onChange(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
)}
/>
{errors.gradcrackerMaxJobsPerTerm && <p className="text-xs text-destructive">{errors.gradcrackerMaxJobsPerTerm.message}</p>}
<div className="text-xs text-muted-foreground">
Maximum number of jobs to fetch for EACH search term from Gradcracker. Range: 1-1000.
</div>
@@ -68,3 +75,4 @@ export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
</AccordionItem>
)
}
@@ -1,12 +1,12 @@
import React from "react"
import { useFormContext } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
type JobCompleteWebhookSectionProps = {
jobCompleteWebhookUrlDraft: string
setJobCompleteWebhookUrlDraft: (value: string) => void
defaultJobCompleteWebhookUrl: string
effectiveJobCompleteWebhookUrl: string
isLoading: boolean
@@ -14,13 +14,13 @@ type JobCompleteWebhookSectionProps = {
}
export const JobCompleteWebhookSection: React.FC<JobCompleteWebhookSectionProps> = ({
jobCompleteWebhookUrlDraft,
setJobCompleteWebhookUrlDraft,
defaultJobCompleteWebhookUrl,
effectiveJobCompleteWebhookUrl,
isLoading,
isSaving,
}) => {
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="job-complete-webhook" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@@ -31,11 +31,11 @@ export const JobCompleteWebhookSection: React.FC<JobCompleteWebhookSectionProps>
<div className="space-y-2">
<div className="text-sm font-medium">Job completion webhook URL</div>
<Input
value={jobCompleteWebhookUrlDraft}
onChange={(event) => setJobCompleteWebhookUrlDraft(event.target.value)}
{...register("jobCompleteWebhookUrl")}
placeholder={defaultJobCompleteWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
{errors.jobCompleteWebhookUrl && <p className="text-xs text-destructive">{errors.jobCompleteWebhookUrl.message}</p>}
<div className="text-xs text-muted-foreground">
When set, the server sends a POST when you mark a job as applied (includes the job description).
</div>
@@ -58,3 +58,4 @@ export const JobCompleteWebhookSection: React.FC<JobCompleteWebhookSectionProps>
</AccordionItem>
)
}
@@ -1,52 +1,48 @@
import { describe, it, expect } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import { useState } from "react"
import { useForm, FormProvider } from "react-hook-form"
import { Accordion } from "@/components/ui/accordion"
import { JobspySection } from "./JobspySection"
import { UpdateSettingsInput } from "@shared/settings-schema"
const JobspyHarness = () => {
const [jobspySitesDraft, setJobspySitesDraft] = useState<string[] | null>(null)
const [jobspyLocationDraft, setJobspyLocationDraft] = useState<string | null>(null)
const [jobspyResultsWantedDraft, setJobspyResultsWantedDraft] = useState<number | null>(null)
const [jobspyHoursOldDraft, setJobspyHoursOldDraft] = useState<number | null>(null)
const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState<string | null>(null)
const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState<boolean | null>(null)
const methods = useForm<UpdateSettingsInput>({
defaultValues: {
jobspySites: ["indeed", "linkedin"],
jobspyLocation: "UK",
jobspyResultsWanted: 200,
jobspyHoursOld: 72,
jobspyCountryIndeed: "UK",
jobspyLinkedinFetchDescription: true,
}
})
return (
<Accordion type="multiple" defaultValue={["jobspy"]}>
<JobspySection
jobspySitesDraft={jobspySitesDraft}
setJobspySitesDraft={setJobspySitesDraft}
defaultJobspySites={["indeed", "linkedin"]}
effectiveJobspySites={["indeed", "linkedin"]}
jobspyLocationDraft={jobspyLocationDraft}
setJobspyLocationDraft={setJobspyLocationDraft}
defaultJobspyLocation="UK"
effectiveJobspyLocation="UK"
jobspyResultsWantedDraft={jobspyResultsWantedDraft}
setJobspyResultsWantedDraft={setJobspyResultsWantedDraft}
defaultJobspyResultsWanted={200}
effectiveJobspyResultsWanted={200}
jobspyHoursOldDraft={jobspyHoursOldDraft}
setJobspyHoursOldDraft={setJobspyHoursOldDraft}
defaultJobspyHoursOld={72}
effectiveJobspyHoursOld={72}
jobspyCountryIndeedDraft={jobspyCountryIndeedDraft}
setJobspyCountryIndeedDraft={setJobspyCountryIndeedDraft}
defaultJobspyCountryIndeed="UK"
effectiveJobspyCountryIndeed="UK"
jobspyLinkedinFetchDescriptionDraft={jobspyLinkedinFetchDescriptionDraft}
setJobspyLinkedinFetchDescriptionDraft={setJobspyLinkedinFetchDescriptionDraft}
defaultJobspyLinkedinFetchDescription={true}
effectiveJobspyLinkedinFetchDescription={true}
isLoading={false}
isSaving={false}
/>
</Accordion>
<FormProvider {...methods}>
<Accordion type="multiple" defaultValue={["jobspy"]}>
<JobspySection
defaultJobspySites={["indeed", "linkedin"]}
effectiveJobspySites={["indeed", "linkedin"]}
defaultJobspyLocation="UK"
effectiveJobspyLocation="UK"
defaultJobspyResultsWanted={200}
effectiveJobspyResultsWanted={200}
defaultJobspyHoursOld={72}
effectiveJobspyHoursOld={72}
defaultJobspyCountryIndeed="UK"
effectiveJobspyCountryIndeed="UK"
defaultJobspyLinkedinFetchDescription={true}
effectiveJobspyLinkedinFetchDescription={true}
isLoading={false}
isSaving={false}
/>
</Accordion>
</FormProvider>
)
}
describe("JobspySection", () => {
it("toggles scraped sites and keeps checkboxes in sync", () => {
render(<JobspyHarness />)
@@ -72,8 +68,8 @@ describe("JobspySection", () => {
const resultsWantedInput = numericInputs[0]
const hoursOldInput = numericInputs[1]
fireEvent.change(resultsWantedInput, { target: { value: "999" } })
expect(resultsWantedInput).toHaveValue(500)
fireEvent.change(resultsWantedInput, { target: { value: "1001" } })
expect(resultsWantedInput).toHaveValue(1000)
fireEvent.change(hoursOldInput, { target: { value: "0" } })
expect(hoursOldInput).toHaveValue(1)
@@ -1,33 +1,23 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
type JobspySectionProps = {
jobspySitesDraft: string[] | null
setJobspySitesDraft: (value: string[] | null) => void
defaultJobspySites: string[]
effectiveJobspySites: string[]
jobspyLocationDraft: string | null
setJobspyLocationDraft: (value: string | null) => void
defaultJobspyLocation: string
effectiveJobspyLocation: string
jobspyResultsWantedDraft: number | null
setJobspyResultsWantedDraft: (value: number | null) => void
defaultJobspyResultsWanted: number
effectiveJobspyResultsWanted: number
jobspyHoursOldDraft: number | null
setJobspyHoursOldDraft: (value: number | null) => void
defaultJobspyHoursOld: number
effectiveJobspyHoursOld: number
jobspyCountryIndeedDraft: string | null
setJobspyCountryIndeedDraft: (value: string | null) => void
defaultJobspyCountryIndeed: string
effectiveJobspyCountryIndeed: string
jobspyLinkedinFetchDescriptionDraft: boolean | null
setJobspyLinkedinFetchDescriptionDraft: (value: boolean | null) => void
defaultJobspyLinkedinFetchDescription: boolean
effectiveJobspyLinkedinFetchDescription: boolean
isLoading: boolean
@@ -35,33 +25,23 @@ type JobspySectionProps = {
}
export const JobspySection: React.FC<JobspySectionProps> = ({
jobspySitesDraft,
setJobspySitesDraft,
defaultJobspySites,
effectiveJobspySites,
jobspyLocationDraft,
setJobspyLocationDraft,
defaultJobspyLocation,
effectiveJobspyLocation,
jobspyResultsWantedDraft,
setJobspyResultsWantedDraft,
defaultJobspyResultsWanted,
effectiveJobspyResultsWanted,
jobspyHoursOldDraft,
setJobspyHoursOldDraft,
defaultJobspyHoursOld,
effectiveJobspyHoursOld,
jobspyCountryIndeedDraft,
setJobspyCountryIndeedDraft,
defaultJobspyCountryIndeed,
effectiveJobspyCountryIndeed,
jobspyLinkedinFetchDescriptionDraft,
setJobspyLinkedinFetchDescriptionDraft,
defaultJobspyLinkedinFetchDescription,
effectiveJobspyLinkedinFetchDescription,
isLoading,
isSaving,
}) => {
const { control, register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="jobspy" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@@ -73,42 +53,55 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
<div className="text-sm font-medium">Scraped Sites</div>
<div className="flex gap-6">
<div className="flex items-center space-x-2">
<Checkbox
id="site-indeed"
checked={jobspySitesDraft?.includes('indeed') ?? defaultJobspySites.includes('indeed')}
onCheckedChange={(checked) => {
const current = jobspySitesDraft ?? defaultJobspySites
let next = [...current]
if (checked) {
if (!next.includes('indeed')) next.push('indeed')
} else {
next = next.filter(s => s !== 'indeed')
}
setJobspySitesDraft(next)
}}
disabled={isLoading || isSaving}
<Controller
name="jobspySites"
control={control}
render={({ field }) => (
<Checkbox
id="site-indeed"
checked={field.value?.includes('indeed') ?? defaultJobspySites.includes('indeed')}
onCheckedChange={(checked) => {
const current = field.value ?? defaultJobspySites
let next = [...current]
if (checked) {
if (!next.includes('indeed')) next.push('indeed')
} else {
next = next.filter(s => s !== 'indeed')
}
field.onChange(next)
}}
disabled={isLoading || isSaving}
/>
)}
/>
<label htmlFor="site-indeed" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Indeed</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="site-linkedin"
checked={jobspySitesDraft?.includes('linkedin') ?? defaultJobspySites.includes('linkedin')}
onCheckedChange={(checked) => {
const current = jobspySitesDraft ?? defaultJobspySites
let next = [...current]
if (checked) {
if (!next.includes('linkedin')) next.push('linkedin')
} else {
next = next.filter(s => s !== 'linkedin')
}
setJobspySitesDraft(next)
}}
disabled={isLoading || isSaving}
<Controller
name="jobspySites"
control={control}
render={({ field }) => (
<Checkbox
id="site-linkedin"
checked={field.value?.includes('linkedin') ?? defaultJobspySites.includes('linkedin')}
onCheckedChange={(checked) => {
const current = field.value ?? defaultJobspySites
let next = [...current]
if (checked) {
if (!next.includes('linkedin')) next.push('linkedin')
} else {
next = next.filter(s => s !== 'linkedin')
}
field.onChange(next)
}}
disabled={isLoading || isSaving}
/>
)}
/>
<label htmlFor="site-linkedin" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">LinkedIn</label>
</div>
</div>
{errors.jobspySites && <p className="text-xs text-destructive">{errors.jobspySites.message}</p>}
<div className="text-xs text-muted-foreground">
Select which sites JobSpy should scrape.
</div>
@@ -122,11 +115,11 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
<div className="space-y-2">
<div className="text-sm font-medium">Location</div>
<Input
value={jobspyLocationDraft ?? defaultJobspyLocation}
onChange={(event) => setJobspyLocationDraft(event.target.value)}
{...register("jobspyLocation")}
placeholder={defaultJobspyLocation || "UK"}
disabled={isLoading || isSaving}
/>
{errors.jobspyLocation && <p className="text-xs text-destructive">{errors.jobspyLocation.message}</p>}
<div className="text-xs text-muted-foreground">
Location to search for jobs (e.g. "UK", "London", "Remote").
</div>
@@ -138,24 +131,31 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
<div className="space-y-2">
<div className="text-sm font-medium">Results Wanted</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={500}
value={jobspyResultsWantedDraft ?? defaultJobspyResultsWanted}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setJobspyResultsWantedDraft(null)
} else {
setJobspyResultsWantedDraft(Math.min(500, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
<Controller
name="jobspyResultsWanted"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={field.value ?? ""}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
field.onChange(null)
} else {
field.onChange(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
)}
/>
{errors.jobspyResultsWanted && <p className="text-xs text-destructive">{errors.jobspyResultsWanted.message}</p>}
<div className="text-xs text-muted-foreground">
Number of results to fetch per term per site. Max 500.
Number of results to fetch per term per site. Max 1000.
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyResultsWanted}</span>
@@ -165,24 +165,31 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
<div className="space-y-2">
<div className="text-sm font-medium">Hours Old</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={168}
value={jobspyHoursOldDraft ?? defaultJobspyHoursOld}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setJobspyHoursOldDraft(null)
} else {
setJobspyHoursOldDraft(Math.min(168, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
<Controller
name="jobspyHoursOld"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={1}
max={720}
value={field.value ?? ""}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
field.onChange(null)
} else {
field.onChange(Math.min(720, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
)}
/>
{errors.jobspyHoursOld && <p className="text-xs text-destructive">{errors.jobspyHoursOld.message}</p>}
<div className="text-xs text-muted-foreground">
Max age of jobs in hours (e.g. 72 for 3 days).
Max age of jobs in hours (e.g. 72 for 3 days). Max 720 (30 days).
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {effectiveJobspyHoursOld}h</span>
@@ -193,11 +200,11 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
<div className="space-y-2">
<div className="text-sm font-medium">Indeed Country</div>
<Input
value={jobspyCountryIndeedDraft ?? defaultJobspyCountryIndeed}
onChange={(event) => setJobspyCountryIndeedDraft(event.target.value)}
{...register("jobspyCountryIndeed")}
placeholder={defaultJobspyCountryIndeed || "UK"}
disabled={isLoading || isSaving}
/>
{errors.jobspyCountryIndeed && <p className="text-xs text-destructive">{errors.jobspyCountryIndeed.message}</p>}
<div className="text-xs text-muted-foreground">
Country domain for Indeed (e.g. "UK" for indeed.co.uk).
</div>
@@ -211,11 +218,17 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
<Separator />
<div className="flex items-center space-x-2">
<Checkbox
id="linkedin-desc"
checked={jobspyLinkedinFetchDescriptionDraft ?? defaultJobspyLinkedinFetchDescription}
onCheckedChange={(checked) => setJobspyLinkedinFetchDescriptionDraft(!!checked)}
disabled={isLoading || isSaving}
<Controller
name="jobspyLinkedinFetchDescription"
control={control}
render={({ field }) => (
<Checkbox
id="linkedin-desc"
checked={field.value ?? defaultJobspyLinkedinFetchDescription}
onCheckedChange={(checked) => field.onChange(!!checked)}
disabled={isLoading || isSaving}
/>
)}
/>
<div className="grid gap-1.5 leading-none">
<label
@@ -238,3 +251,4 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
</AccordionItem>
)
}
@@ -1,18 +1,12 @@
import React from "react"
import { useFormContext } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
type ModelSettingsSectionProps = {
modelDraft: string
setModelDraft: (value: string) => void
modelScorerDraft: string
setModelScorerDraft: (value: string) => void
modelTailoringDraft: string
setModelTailoringDraft: (value: string) => void
modelProjectSelectionDraft: string
setModelProjectSelectionDraft: (value: string) => void
effectiveModel: string
effectiveModelScorer: string
effectiveModelTailoring: string
@@ -23,14 +17,6 @@ type ModelSettingsSectionProps = {
}
export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
modelDraft,
setModelDraft,
modelScorerDraft,
setModelScorerDraft,
modelTailoringDraft,
setModelTailoringDraft,
modelProjectSelectionDraft,
setModelProjectSelectionDraft,
effectiveModel,
effectiveModelScorer,
effectiveModelTailoring,
@@ -39,6 +25,8 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
isLoading,
isSaving,
}) => {
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="model" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@@ -49,11 +37,11 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
<div className="space-y-2">
<div className="text-sm font-medium">Override model</div>
<Input
value={modelDraft}
onChange={(event) => setModelDraft(event.target.value)}
{...register("model")}
placeholder={defaultModel || "openai/gpt-4o-mini"}
disabled={isLoading || isSaving}
/>
{errors.model && <p className="text-xs text-destructive">{errors.model.message}</p>}
<div className="text-xs text-muted-foreground">
Leave blank to use the default from server env (`MODEL`).
</div>
@@ -68,11 +56,11 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
<div className="space-y-2">
<div className="text-sm">Scoring Model</div>
<Input
value={modelScorerDraft}
onChange={(event) => setModelScorerDraft(event.target.value)}
{...register("modelScorer")}
placeholder={effectiveModel || "inherit"}
disabled={isLoading || isSaving}
/>
{errors.modelScorer && <p className="text-xs text-destructive">{errors.modelScorer.message}</p>}
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelScorer || effectiveModel}</span>
</div>
@@ -81,11 +69,11 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
<div className="space-y-2">
<div className="text-sm">Tailoring Model</div>
<Input
value={modelTailoringDraft}
onChange={(event) => setModelTailoringDraft(event.target.value)}
{...register("modelTailoring")}
placeholder={effectiveModel || "inherit"}
disabled={isLoading || isSaving}
/>
{errors.modelTailoring && <p className="text-xs text-destructive">{errors.modelTailoring.message}</p>}
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelTailoring || effectiveModel}</span>
</div>
@@ -94,11 +82,11 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
<div className="space-y-2">
<div className="text-sm">Project Selection Model</div>
<Input
value={modelProjectSelectionDraft}
onChange={(event) => setModelProjectSelectionDraft(event.target.value)}
{...register("modelProjectSelection")}
placeholder={effectiveModel || "inherit"}
disabled={isLoading || isSaving}
/>
{errors.modelProjectSelection && <p className="text-xs text-destructive">{errors.modelProjectSelection.message}</p>}
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{effectiveModelProjectSelection || effectiveModel}</span>
</div>
@@ -123,3 +111,4 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
</AccordionItem>
)
}
@@ -1,12 +1,12 @@
import React from "react"
import { useFormContext } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
type PipelineWebhookSectionProps = {
pipelineWebhookUrlDraft: string
setPipelineWebhookUrlDraft: (value: string) => void
defaultPipelineWebhookUrl: string
effectivePipelineWebhookUrl: string
isLoading: boolean
@@ -14,13 +14,13 @@ type PipelineWebhookSectionProps = {
}
export const PipelineWebhookSection: React.FC<PipelineWebhookSectionProps> = ({
pipelineWebhookUrlDraft,
setPipelineWebhookUrlDraft,
defaultPipelineWebhookUrl,
effectivePipelineWebhookUrl,
isLoading,
isSaving,
}) => {
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="pipeline-webhook" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@@ -31,11 +31,11 @@ export const PipelineWebhookSection: React.FC<PipelineWebhookSectionProps> = ({
<div className="space-y-2">
<div className="text-sm font-medium">Pipeline status webhook URL</div>
<Input
value={pipelineWebhookUrlDraft}
onChange={(event) => setPipelineWebhookUrlDraft(event.target.value)}
{...register("pipelineWebhookUrl")}
placeholder={defaultPipelineWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
{errors.pipelineWebhookUrl && <p className="text-xs text-destructive">{errors.pipelineWebhookUrl.message}</p>}
<div className="text-xs text-muted-foreground">
When set, the server sends a POST on pipeline completion/failure. Leave blank to disable.
</div>
@@ -58,3 +58,4 @@ export const PipelineWebhookSection: React.FC<PipelineWebhookSectionProps> = ({
</AccordionItem>
)
}
@@ -1,10 +1,11 @@
import { describe, it, expect } from "vitest"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { useState } from "react"
import { useForm, FormProvider } from "react-hook-form"
import { Accordion } from "@/components/ui/accordion"
import { ResumeProjectsSection } from "./ResumeProjectsSection"
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types"
import type { ResumeProjectCatalogItem } from "@shared/types"
import { UpdateSettingsInput } from "@shared/settings-schema"
const profileProjects: ResumeProjectCatalogItem[] = [
{
@@ -23,25 +24,31 @@ const profileProjects: ResumeProjectCatalogItem[] = [
},
]
const ResumeProjectsHarness = ({ initialDraft }: { initialDraft: ResumeProjectsSettings | null }) => {
const [draft, setDraft] = useState<ResumeProjectsSettings | null>(initialDraft)
const lockedCount = draft?.lockedProjectIds.length ?? 0
const ResumeProjectsHarness = ({ initialDraft }: { initialDraft: UpdateSettingsInput["resumeProjects"] }) => {
const methods = useForm<UpdateSettingsInput>({
defaultValues: {
resumeProjects: initialDraft
}
})
const watched = methods.watch()
const lockedCount = watched.resumeProjects?.lockedProjectIds.length ?? 0
return (
<Accordion type="multiple" defaultValue={["resume-projects"]}>
<ResumeProjectsSection
resumeProjectsDraft={draft}
setResumeProjectsDraft={setDraft}
profileProjects={profileProjects}
lockedCount={lockedCount}
maxProjectsTotal={profileProjects.length}
isLoading={false}
isSaving={false}
/>
</Accordion>
<FormProvider {...methods}>
<Accordion type="multiple" defaultValue={["resume-projects"]}>
<ResumeProjectsSection
profileProjects={profileProjects}
lockedCount={lockedCount}
maxProjectsTotal={profileProjects.length}
isLoading={false}
isSaving={false}
/>
</Accordion>
</FormProvider>
)
}
describe("ResumeProjectsSection", () => {
it("clamps max projects to the locked count", async () => {
render(
@@ -1,16 +1,16 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types"
import type { ResumeProjectCatalogItem } from "@shared/types"
import { clampInt } from "@/lib/utils"
import { UpdateSettingsInput } from "@shared/settings-schema"
type ResumeProjectsSectionProps = {
resumeProjectsDraft: ResumeProjectsSettings | null
setResumeProjectsDraft: (value: ResumeProjectsSettings | null) => void
profileProjects: ResumeProjectCatalogItem[]
lockedCount: number
maxProjectsTotal: number
@@ -19,14 +19,14 @@ type ResumeProjectsSectionProps = {
}
export const ResumeProjectsSection: React.FC<ResumeProjectsSectionProps> = ({
resumeProjectsDraft,
setResumeProjectsDraft,
profileProjects,
lockedCount,
maxProjectsTotal,
isLoading,
isSaving,
}) => {
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="resume-projects" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@@ -36,113 +36,126 @@ export const ResumeProjectsSection: React.FC<ResumeProjectsSectionProps> = ({
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max projects included</div>
<Input
type="number"
inputMode="numeric"
min={lockedCount}
max={maxProjectsTotal}
value={resumeProjectsDraft?.maxProjects ?? 0}
onChange={(event) => {
if (!resumeProjectsDraft) return
const next = Number(event.target.value)
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped })
}}
disabled={isLoading || isSaving || !resumeProjectsDraft}
<Controller
name="resumeProjects"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={lockedCount}
max={maxProjectsTotal}
value={field.value?.maxProjects ?? 0}
onChange={(event) => {
if (!field.value) return
const next = Number(event.target.value)
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
field.onChange({ ...field.value, maxProjects: clamped })
}}
disabled={isLoading || isSaving || !field.value}
/>
)}
/>
{errors.resumeProjects?.maxProjects && <p className="text-xs text-destructive">{errors.resumeProjects.maxProjects.message}</p>}
<div className="text-xs text-muted-foreground">
Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "}
{resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal}
Locked projects always count towards this cap. Locked: {lockedCount} · Total projects: {maxProjectsTotal}
</div>
</div>
<Separator />
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead className="w-[110px]">Base visible</TableHead>
<TableHead className="w-[90px]">Locked</TableHead>
<TableHead className="w-[140px]">AI selectable</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{profileProjects.map((project) => {
const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id))
const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id))
const excluded = !locked && !aiSelectable
return (
<TableRow key={project.id}>
<TableCell>
<div className="space-y-0.5">
<div className="font-medium">{project.name || project.id}</div>
<div className="text-xs text-muted-foreground">
{[project.description, project.date].filter(Boolean).join(" · ")}
{excluded ? " · Excluded" : ""}
</div>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
<TableCell>
<Checkbox
checked={locked}
disabled={isLoading || isSaving || !resumeProjectsDraft}
onCheckedChange={(checked) => {
if (!resumeProjectsDraft) return
const isChecked = checked === true
const lockedIds = resumeProjectsDraft.lockedProjectIds.slice()
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
if (isChecked) {
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
const nextSelectable = selectableIds.filter((id) => id !== project.id)
const minCap = lockedIds.length
setResumeProjectsDraft({
...resumeProjectsDraft,
lockedProjectIds: lockedIds,
aiSelectableProjectIds: nextSelectable,
maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap),
})
return
}
const nextLocked = lockedIds.filter((id) => id !== project.id)
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
setResumeProjectsDraft({
...resumeProjectsDraft,
lockedProjectIds: nextLocked,
aiSelectableProjectIds: selectableIds,
maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal),
})
}}
/>
</TableCell>
<TableCell>
<Checkbox
checked={locked ? true : aiSelectable}
disabled={locked || isLoading || isSaving || !resumeProjectsDraft}
onCheckedChange={(checked) => {
if (!resumeProjectsDraft) return
const isChecked = checked === true
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
const nextSelectable = isChecked
? selectableIds.includes(project.id)
? selectableIds
: [...selectableIds, project.id]
: selectableIds.filter((id) => id !== project.id)
setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable })
}}
/>
</TableCell>
<Controller
name="resumeProjects"
control={control}
render={({ field }) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead className="w-[110px]">Base visible</TableHead>
<TableHead className="w-[90px]">Locked</TableHead>
<TableHead className="w-[140px]">AI selectable</TableHead>
</TableRow>
)
})}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{profileProjects.map((project) => {
const locked = Boolean(field.value?.lockedProjectIds.includes(project.id))
const aiSelectable = Boolean(field.value?.aiSelectableProjectIds.includes(project.id))
const excluded = !locked && !aiSelectable
return (
<TableRow key={project.id}>
<TableCell>
<div className="space-y-0.5">
<div className="font-medium">{project.name || project.id}</div>
<div className="text-xs text-muted-foreground">
{[project.description, project.date].filter(Boolean).join(" · ")}
{excluded ? " · Excluded" : ""}
</div>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
<TableCell>
<Checkbox
checked={locked}
disabled={isLoading || isSaving || !field.value}
onCheckedChange={(checked) => {
if (!field.value) return
const isChecked = checked === true
const lockedIds = field.value.lockedProjectIds.slice()
const selectableIds = field.value.aiSelectableProjectIds.slice()
if (isChecked) {
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
const nextSelectable = selectableIds.filter((id) => id !== project.id)
const minCap = lockedIds.length
field.onChange({
...field.value,
lockedProjectIds: lockedIds,
aiSelectableProjectIds: nextSelectable,
maxProjects: Math.max(field.value.maxProjects, minCap),
})
return
}
const nextLocked = lockedIds.filter((id) => id !== project.id)
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
field.onChange({
...field.value,
lockedProjectIds: nextLocked,
aiSelectableProjectIds: selectableIds,
maxProjects: clampInt(field.value.maxProjects, nextLocked.length, maxProjectsTotal),
})
}}
/>
</TableCell>
<TableCell>
<Checkbox
checked={locked ? true : aiSelectable}
disabled={locked || isLoading || isSaving || !field.value}
onCheckedChange={(checked) => {
if (!field.value) return
const isChecked = checked === true
const selectableIds = field.value.aiSelectableProjectIds.slice()
const nextSelectable = isChecked
? selectableIds.includes(project.id)
? selectableIds
: [...selectableIds, project.id]
: selectableIds.filter((id) => id !== project.id)
field.onChange({ ...field.value, aiSelectableProjectIds: nextSelectable })
}}
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
)
}
@@ -1,11 +1,11 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
type SearchTermsSectionProps = {
searchTermsDraft: string[] | null
setSearchTermsDraft: (value: string[] | null) => void
defaultSearchTerms: string[]
effectiveSearchTerms: string[]
isLoading: boolean
@@ -13,13 +13,13 @@ type SearchTermsSectionProps = {
}
export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
searchTermsDraft,
setSearchTermsDraft,
defaultSearchTerms,
effectiveSearchTerms,
isLoading,
isSaving,
}) => {
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="search-terms" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@@ -29,24 +29,30 @@ export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Global search terms</div>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={searchTermsDraft ? searchTermsDraft.join('\n') : (defaultSearchTerms ?? []).join('\n')}
onChange={(event) => {
const text = event.target.value
const terms = text.split('\n') // Don't filter here to allow empty lines while typing
setSearchTermsDraft(terms)
}}
onBlur={() => {
// Clean up on blur
if (searchTermsDraft) {
setSearchTermsDraft(searchTermsDraft.map(t => t.trim()).filter(Boolean))
}
}}
placeholder="e.g. web developer"
disabled={isLoading || isSaving}
rows={5}
<Controller
name="searchTerms"
control={control}
render={({ field }) => (
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={field.value ? field.value.join('\n') : (defaultSearchTerms ?? []).join('\n')}
onChange={(event) => {
const text = event.target.value
const terms = text.split('\n')
field.onChange(terms)
}}
onBlur={() => {
if (field.value) {
field.onChange(field.value.map(t => t.trim()).filter(Boolean))
}
}}
placeholder="e.g. web developer"
disabled={isLoading || isSaving}
rows={5}
/>
)}
/>
{errors.searchTerms && <p className="text-xs text-destructive">{errors.searchTerms.message}</p>}
<div className="text-xs text-muted-foreground">
One term per line. Applies to UKVisaJobs and other supported extractors.
</div>
@@ -69,3 +75,4 @@ export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
</AccordionItem>
)
}
@@ -1,12 +1,12 @@
import React from "react"
import { useFormContext, Controller } from "react-hook-form"
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { UpdateSettingsInput } from "@shared/settings-schema"
type UkvisajobsSectionProps = {
ukvisajobsMaxJobsDraft: number | null
setUkvisajobsMaxJobsDraft: (value: number | null) => void
defaultUkvisajobsMaxJobs: number
effectiveUkvisajobsMaxJobs: number
isLoading: boolean
@@ -14,13 +14,13 @@ type UkvisajobsSectionProps = {
}
export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
ukvisajobsMaxJobsDraft,
setUkvisajobsMaxJobsDraft,
defaultUkvisajobsMaxJobs,
effectiveUkvisajobsMaxJobs,
isLoading,
isSaving,
}) => {
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@@ -30,22 +30,29 @@ export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max jobs to fetch</div>
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={ukvisajobsMaxJobsDraft ?? defaultUkvisajobsMaxJobs}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
setUkvisajobsMaxJobsDraft(null)
} else {
setUkvisajobsMaxJobsDraft(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
<Controller
name="ukvisajobsMaxJobs"
control={control}
render={({ field }) => (
<Input
type="number"
inputMode="numeric"
min={1}
max={1000}
value={field.value ?? ""}
onChange={(event) => {
const value = parseInt(event.target.value, 10)
if (Number.isNaN(value)) {
field.onChange(null)
} else {
field.onChange(Math.min(1000, Math.max(1, value)))
}
}}
disabled={isLoading || isSaving}
/>
)}
/>
{errors.ukvisajobsMaxJobs && <p className="text-xs text-destructive">{errors.ukvisajobsMaxJobs.message}</p>}
<div className="text-xs text-muted-foreground">
Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-1000.
</div>
@@ -68,3 +75,4 @@ export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
</AccordionItem>
)
}
+4 -28
View File
@@ -1,12 +1,12 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import * as settingsRepo from '../../repositories/settings.js';
import { updateSettingsSchema } from '@shared/settings-schema.js';
import * as settingsRepo from '@server/repositories/settings.js';
import {
extractProjectsFromProfile,
normalizeResumeProjectsSettings,
resolveResumeProjectsSettings,
} from '../../services/resumeProjects.js';
import { getProfile } from '../../services/profile.js';
} from '@server/services/resumeProjects.js';
import { getProfile } from '@server/services/profile.js';
export const settingsRouter = Router();
@@ -154,30 +154,6 @@ settingsRouter.get('/', async (_req: Request, res: Response) => {
}
});
const updateSettingsSchema = z.object({
model: z.string().trim().min(1).max(200).nullable().optional(),
modelScorer: z.string().trim().min(1).max(200).nullable().optional(),
modelTailoring: z.string().trim().min(1).max(200).nullable().optional(),
modelProjectSelection: z.string().trim().min(1).max(200).nullable().optional(),
pipelineWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
jobCompleteWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
resumeProjects: z.object({
maxProjects: z.number().int().min(0).max(50),
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
}).nullable().optional(),
ukvisajobsMaxJobs: z.number().int().min(1).max(200).nullable().optional(),
gradcrackerMaxJobsPerTerm: z.number().int().min(1).max(200).nullable().optional(),
searchTerms: z.array(z.string().trim().min(1).max(200)).max(50).nullable().optional(),
jobspyLocation: z.string().trim().min(1).max(100).nullable().optional(),
jobspyResultsWanted: z.number().int().min(1).max(500).nullable().optional(),
jobspyHoursOld: z.number().int().min(1).max(168).nullable().optional(),
jobspyCountryIndeed: z.string().trim().min(1).max(100).nullable().optional(),
jobspySites: z.array(z.string().trim().min(1).max(50)).max(10).nullable().optional(),
jobspyLinkedinFetchDescription: z.boolean().nullable().optional(),
showSponsorInfo: z.boolean().nullable().optional(),
});
/**
* PATCH /api/settings - Update settings overrides
*/
@@ -0,0 +1,30 @@
import { z } from "zod";
export const resumeProjectsSchema = z.object({
maxProjects: z.number().int().min(0).max(100),
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
});
export const updateSettingsSchema = z.object({
model: z.string().trim().max(200).nullable().optional(),
modelScorer: z.string().trim().max(200).nullable().optional(),
modelTailoring: z.string().trim().max(200).nullable().optional(),
modelProjectSelection: z.string().trim().max(200).nullable().optional(),
pipelineWebhookUrl: z.string().trim().max(2000).nullable().optional(),
jobCompleteWebhookUrl: z.string().trim().max(2000).nullable().optional(),
resumeProjects: resumeProjectsSchema.nullable().optional(),
ukvisajobsMaxJobs: z.number().int().min(1).max(1000).nullable().optional(),
gradcrackerMaxJobsPerTerm: z.number().int().min(1).max(1000).nullable().optional(),
searchTerms: z.array(z.string().trim().min(1).max(200)).max(100).nullable().optional(),
jobspyLocation: z.string().trim().max(100).nullable().optional(),
jobspyResultsWanted: z.number().int().min(1).max(1000).nullable().optional(),
jobspyHoursOld: z.number().int().min(1).max(720).nullable().optional(),
jobspyCountryIndeed: z.string().trim().max(100).nullable().optional(),
jobspySites: z.array(z.string().trim().min(1).max(50)).max(20).nullable().optional(),
jobspyLinkedinFetchDescription: z.boolean().nullable().optional(),
showSponsorInfo: z.boolean().nullable().optional(),
});
export type UpdateSettingsInput = z.infer<typeof updateSettingsSchema>;
export type ResumeProjectsSettingsInput = z.infer<typeof resumeProjectsSchema>;