Merge branch 'main' into reactive-resume-v5
This commit is contained in:
@@ -11,6 +11,7 @@ import { OrchestratorPage } from "./pages/OrchestratorPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { UkVisaJobsPage } from "./pages/UkVisaJobsPage";
|
||||
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
|
||||
import { OnboardingGate } from "./components/OnboardingGate";
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const location = useLocation();
|
||||
@@ -27,6 +28,7 @@ export const App: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<OnboardingGate />
|
||||
<SwitchTransition mode="out-in">
|
||||
<CSSTransition
|
||||
key={pageKey}
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
JobsListResponse,
|
||||
PipelineStatusResponse,
|
||||
JobSource,
|
||||
PipelineRun,
|
||||
AppSettings,
|
||||
ResumeProjectsSettings,
|
||||
ResumeProjectCatalogItem,
|
||||
@@ -20,6 +19,9 @@ import type {
|
||||
VisaSponsorSearchResponse,
|
||||
VisaSponsorStatusResponse,
|
||||
VisaSponsor,
|
||||
ResumeProfile,
|
||||
ProfileStatusResponse,
|
||||
ValidationResult,
|
||||
} from '../../shared/types';
|
||||
import { trackEvent } from "@/lib/analytics";
|
||||
|
||||
@@ -86,6 +88,12 @@ export async function generateJobPdf(id: string): Promise<Job> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkSponsor(id: string): Promise<Job> {
|
||||
return fetchApi<Job>(`/jobs/${id}/check-sponsor`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function markAsApplied(id: string): Promise<Job> {
|
||||
return fetchApi<Job>(`/jobs/${id}/apply`, {
|
||||
method: 'POST',
|
||||
@@ -168,6 +176,38 @@ export async function getProfileProjects(): Promise<ResumeProjectCatalogItem[]>
|
||||
return fetchApi<ResumeProjectCatalogItem[]>('/profile/projects');
|
||||
}
|
||||
|
||||
export async function getProfile(): Promise<ResumeProfile> {
|
||||
return fetchApi<ResumeProfile>('/profile');
|
||||
}
|
||||
|
||||
export async function getProfileStatus(): Promise<ProfileStatusResponse> {
|
||||
return fetchApi<ProfileStatusResponse>('/profile/status');
|
||||
}
|
||||
|
||||
export async function uploadProfile(profile: ResumeProfile): Promise<ProfileStatusResponse> {
|
||||
return fetchApi<ProfileStatusResponse>('/profile/upload', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ profile }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateOpenrouter(apiKey?: string): Promise<ValidationResult> {
|
||||
return fetchApi<ValidationResult>('/onboarding/validate/openrouter', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ apiKey }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateRxresume(email?: string, password?: string): Promise<ValidationResult> {
|
||||
return fetchApi<ValidationResult>('/onboarding/validate/rxresume', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateResumeJson(): Promise<ValidationResult> {
|
||||
return fetchApi<ValidationResult>('/onboarding/validate/resume');
|
||||
}
|
||||
|
||||
export async function updateSettings(update: {
|
||||
model?: string | null
|
||||
@@ -186,7 +226,15 @@ export async function updateSettings(update: {
|
||||
jobspyCountryIndeed?: string | null
|
||||
jobspySites?: string[] | null
|
||||
jobspyLinkedinFetchDescription?: boolean | null
|
||||
rxResumeBaseResumeId?: string | null
|
||||
showSponsorInfo?: boolean | null
|
||||
openrouterApiKey?: string | null
|
||||
rxresumeEmail?: string | null
|
||||
rxresumePassword?: string | null
|
||||
basicAuthUser?: string | null
|
||||
basicAuthPassword?: string | null
|
||||
ukvisajobsEmail?: string | null
|
||||
ukvisajobsPassword?: string | null
|
||||
webhookSecret?: string | null
|
||||
}): Promise<AppSettings> {
|
||||
return fetchApi<AppSettings>('/settings', {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { JobHeader } from "./JobHeader";
|
||||
import { useSettings } from "../hooks/useSettings";
|
||||
import type { Job } from "../../shared/types";
|
||||
|
||||
// Mock useSettings
|
||||
vi.mock("../hooks/useSettings", () => ({
|
||||
useSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock api
|
||||
vi.mock("../api", () => ({
|
||||
checkSponsor: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock Tooltip components to simplify testing
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="tooltip-content">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockJob: Job = {
|
||||
id: "job-1",
|
||||
title: "Software Engineer",
|
||||
employer: "Tech Corp",
|
||||
location: "London",
|
||||
salary: "£60,000",
|
||||
deadline: "2025-12-31",
|
||||
status: "discovered",
|
||||
source: "linkedin",
|
||||
suitabilityScore: 85,
|
||||
suitabilityReason: "Strong match",
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
// Other fields...
|
||||
} as Job;
|
||||
|
||||
describe("JobHeader", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(useSettings as any).mockReturnValue({
|
||||
showSponsorInfo: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders basic job information", () => {
|
||||
render(<JobHeader job={mockJob} />);
|
||||
expect(screen.getByText("Software Engineer")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tech Corp")).toBeInTheDocument();
|
||||
expect(screen.getByText("London")).toBeInTheDocument();
|
||||
expect(screen.getByText("£60,000")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Check Sponsorship Status' button when sponsorMatchScore is null", async () => {
|
||||
const onCheckSponsor = vi.fn().mockResolvedValue(undefined);
|
||||
render(<JobHeader job={mockJob} onCheckSponsor={onCheckSponsor} />);
|
||||
|
||||
const button = screen.getByText("Check Sponsorship Status");
|
||||
expect(button).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onCheckSponsor).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows 'Confirmed Sponsor' when score >= 95", () => {
|
||||
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98, sponsorMatchNames: '["Tech Corp Ltd"]' };
|
||||
render(<JobHeader job={jobWithSponsor} />);
|
||||
|
||||
expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Potential Sponsor' when score is between 80 and 94", () => {
|
||||
const jobWithPotential = { ...mockJob, sponsorMatchScore: 85, sponsorMatchNames: '["Techy Corp"]' };
|
||||
render(<JobHeader job={jobWithPotential} />);
|
||||
|
||||
expect(screen.getByText("Potential Sponsor")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Sponsor Not Found' when score < 80", () => {
|
||||
const jobNoSponsor = { ...mockJob, sponsorMatchScore: 40, sponsorMatchNames: '["Other Corp"]' };
|
||||
render(<JobHeader job={jobNoSponsor} />);
|
||||
|
||||
expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides sponsor info when showSponsorInfo is false", () => {
|
||||
(useSettings as any).mockReturnValue({
|
||||
showSponsorInfo: false,
|
||||
});
|
||||
|
||||
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 };
|
||||
render(<JobHeader job={jobWithSponsor} />);
|
||||
|
||||
expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Check Sponsorship Status")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,18 @@
|
||||
import React from "react";
|
||||
import { Calendar, DollarSign, MapPin } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Calendar, DollarSign, Loader2, MapPin, Search } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn, formatDate, sourceLabel } from "@/lib/utils";
|
||||
import type { Job, JobStatus } from "../../shared/types";
|
||||
import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constants";
|
||||
|
||||
import { useSettings } from "../hooks/useSettings";
|
||||
|
||||
interface JobHeaderProps {
|
||||
job: Job;
|
||||
className?: string;
|
||||
onCheckSponsor?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => {
|
||||
@@ -42,7 +47,101 @@ const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
|
||||
interface SponsorPillProps {
|
||||
score: number | null;
|
||||
names: string | null;
|
||||
onCheck?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SponsorPill: React.FC<SponsorPillProps> = ({ score, names, onCheck }) => {
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
|
||||
const parsedNames = useMemo(() => {
|
||||
if (!names) return [];
|
||||
try {
|
||||
return JSON.parse(names) as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [names]);
|
||||
|
||||
const handleCheck = async () => {
|
||||
if (!onCheck) return;
|
||||
setIsChecking(true);
|
||||
try {
|
||||
await onCheck();
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Show "Check" button if no score and callback provided
|
||||
if (score == null && onCheck) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 px-1.5 text-xs font-medium text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
onClick={handleCheck}
|
||||
disabled={isChecking}
|
||||
>
|
||||
{isChecking ? (
|
||||
<Loader2 className="h-2 w-2 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-2 w-2" />
|
||||
)}
|
||||
<span>{isChecking ? "Checking..." : "Check Sponsorship Status"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">Check if employer is a visa sponsor</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (score == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getStatus = (s: number) => {
|
||||
if (s >= 95) return { label: "Confirmed Sponsor", dot: "bg-emerald-500", color: "text-emerald-400" };
|
||||
if (s >= 80) return { label: "Potential Sponsor", dot: "bg-amber-500", color: "text-amber-400" };
|
||||
return { label: "Sponsor Not Found", dot: "bg-slate-500", color: "text-slate-400" };
|
||||
};
|
||||
|
||||
const status = getStatus(score);
|
||||
const tooltipContent = `${score}% match`;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80 cursor-help">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full opacity-80", status.dot)} />
|
||||
{status.label}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{parsedNames.length > 0 && (
|
||||
<p className="text-xs font-medium space-x-1">
|
||||
<span className="opacity-70">Matched</span>
|
||||
<span>{parsedNames.join(", ")}</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="opacity-80 mt-1 text-[10px]">{tooltipContent}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const JobHeader: React.FC<JobHeaderProps> = ({ job, className, onCheckSponsor }) => {
|
||||
const { showSponsorInfo } = useSettings();
|
||||
const deadline = formatDate(job.deadline);
|
||||
|
||||
return (
|
||||
@@ -51,7 +150,9 @@ export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-base font-semibold text-foreground/90">{job.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{job.employer}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{job.employer}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50">
|
||||
{sourceLabel[job.source]}
|
||||
@@ -82,7 +183,16 @@ export const JobHeader: React.FC<JobHeaderProps> = ({ job, className }) => {
|
||||
|
||||
{/* Status and score: single line, subdued */}
|
||||
<div className="flex items-center justify-between gap-2 py-1 border-y border-border/30">
|
||||
<StatusPill status={job.status} />
|
||||
<div className="flex items-center gap-4">
|
||||
<StatusPill status={job.status} />
|
||||
{showSponsorInfo && (
|
||||
<SponsorPill
|
||||
score={job.sponsorMatchScore}
|
||||
names={job.sponsorMatchNames}
|
||||
onCheck={onCheckSponsor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ScoreMeter score={job.suitabilityScore} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Check } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as api from "@client/api"
|
||||
import { useSettings } from "@client/hooks/useSettings"
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||
import { formatSecretHint } from "@client/pages/settings/utils"
|
||||
import type { ResumeProfile, ValidationResult } from "@shared/types"
|
||||
|
||||
type ValidationState = ValidationResult & { checked: boolean }
|
||||
|
||||
export const OnboardingGate: React.FC = () => {
|
||||
const { settings, isLoading: settingsLoading, refreshSettings } = useSettings()
|
||||
const [isSavingEnv, setIsSavingEnv] = useState(false)
|
||||
const [isUploadingResume, setIsUploadingResume] = useState(false)
|
||||
const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false)
|
||||
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false)
|
||||
const [isValidatingResume, setIsValidatingResume] = useState(false)
|
||||
const [openrouterValidation, setOpenrouterValidation] = useState<ValidationState>({
|
||||
valid: false,
|
||||
message: null,
|
||||
checked: false,
|
||||
})
|
||||
const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>({
|
||||
valid: false,
|
||||
message: null,
|
||||
checked: false,
|
||||
})
|
||||
const [resumeValidation, setResumeValidation] = useState<ValidationState>({
|
||||
valid: false,
|
||||
message: null,
|
||||
checked: false,
|
||||
})
|
||||
const [currentStep, setCurrentStep] = useState<string | null>(null)
|
||||
|
||||
const [openrouterApiKey, setOpenrouterApiKey] = useState("")
|
||||
const [rxresumeEmail, setRxresumeEmail] = useState("")
|
||||
const [rxresumePassword, setRxresumePassword] = useState("")
|
||||
const [resumeFile, setResumeFile] = useState<File | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const validateResume = useCallback(async () => {
|
||||
setIsValidatingResume(true)
|
||||
try {
|
||||
const result = await api.validateResumeJson()
|
||||
setResumeValidation({ ...result, checked: true })
|
||||
return result
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Resume validation failed"
|
||||
const result = { valid: false, message }
|
||||
setResumeValidation({ ...result, checked: true })
|
||||
return result
|
||||
} finally {
|
||||
setIsValidatingResume(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const validateOpenrouter = useCallback(async (apiKey?: string) => {
|
||||
setIsValidatingOpenrouter(true)
|
||||
try {
|
||||
const result = await api.validateOpenrouter(apiKey)
|
||||
setOpenrouterValidation({ ...result, checked: true })
|
||||
return result
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "OpenRouter validation failed"
|
||||
const result = { valid: false, message }
|
||||
setOpenrouterValidation({ ...result, checked: true })
|
||||
return result
|
||||
} finally {
|
||||
setIsValidatingOpenrouter(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const validateRxresume = useCallback(async (email?: string, password?: string) => {
|
||||
setIsValidatingRxresume(true)
|
||||
try {
|
||||
const result = await api.validateRxresume(email, password)
|
||||
setRxresumeValidation({ ...result, checked: true })
|
||||
return result
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "RxResume validation failed"
|
||||
const result = { valid: false, message }
|
||||
setRxresumeValidation({ ...result, checked: true })
|
||||
return result
|
||||
} finally {
|
||||
setIsValidatingRxresume(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint)
|
||||
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim())
|
||||
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint)
|
||||
const hasBaseResume = resumeValidation.valid
|
||||
|
||||
const shouldOpen = Boolean(settings && !settingsLoading)
|
||||
&& !(openrouterValidation.valid && rxresumeValidation.valid && resumeValidation.valid)
|
||||
|
||||
const openrouterCurrent = settings?.openrouterApiKeyHint
|
||||
? formatSecretHint(settings.openrouterApiKeyHint)
|
||||
: undefined
|
||||
const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim()
|
||||
? settings.rxresumeEmail
|
||||
: undefined
|
||||
const rxresumePasswordCurrent = settings?.rxresumePasswordHint
|
||||
? formatSecretHint(settings.rxresumePasswordHint)
|
||||
: undefined
|
||||
|
||||
const steps = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "openrouter",
|
||||
label: "Connect AI",
|
||||
subtitle: "OpenRouter key",
|
||||
complete: openrouterValidation.valid,
|
||||
},
|
||||
{
|
||||
id: "rxresume",
|
||||
label: "PDF Export",
|
||||
subtitle: "RxResume login",
|
||||
complete: rxresumeValidation.valid,
|
||||
},
|
||||
{
|
||||
id: "resume",
|
||||
label: "Resume JSON",
|
||||
subtitle: "Upload your file",
|
||||
complete: resumeValidation.valid,
|
||||
},
|
||||
],
|
||||
[openrouterValidation.valid, resumeValidation.valid, rxresumeValidation.valid]
|
||||
)
|
||||
|
||||
const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldOpen) return
|
||||
if (!currentStep && defaultStep) {
|
||||
setCurrentStep(defaultStep)
|
||||
}
|
||||
}, [currentStep, defaultStep, shouldOpen])
|
||||
|
||||
const runAllValidations = useCallback(async () => {
|
||||
if (!settings) return
|
||||
const results = await Promise.allSettled([
|
||||
validateOpenrouter(),
|
||||
validateRxresume(),
|
||||
validateResume(),
|
||||
])
|
||||
|
||||
const failed = results.find((result) => result.status === "rejected")
|
||||
if (failed) {
|
||||
const reason = failed.status === "rejected" ? failed.reason : null
|
||||
const message = reason instanceof Error ? reason.message : "Validation checks failed"
|
||||
toast.error(message)
|
||||
}
|
||||
}, [settings, validateOpenrouter, validateRxresume, validateResume])
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings || settingsLoading) return
|
||||
if (openrouterValidation.checked || rxresumeValidation.checked || resumeValidation.checked) return
|
||||
void runAllValidations()
|
||||
}, [settings, settingsLoading, openrouterValidation.checked, rxresumeValidation.checked, resumeValidation.checked, runAllValidations])
|
||||
|
||||
const handleRefresh = async () => {
|
||||
const results = await Promise.allSettled([refreshSettings(), runAllValidations()])
|
||||
const failed = results.find((result) => result.status === "rejected")
|
||||
if (failed) {
|
||||
const reason = failed.status === "rejected" ? failed.reason : null
|
||||
const message = reason instanceof Error ? reason.message : "Failed to refresh setup"
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveOpenrouter = async (): Promise<boolean> => {
|
||||
const openrouterValue = openrouterApiKey.trim()
|
||||
if (!openrouterValue && !hasOpenrouterKey) {
|
||||
toast.info("Add your OpenRouter API key to continue")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = await validateOpenrouter(openrouterValue || undefined)
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.message || "OpenRouter validation failed")
|
||||
return false
|
||||
}
|
||||
|
||||
if (openrouterValue) {
|
||||
setIsSavingEnv(true)
|
||||
await api.updateSettings({ openrouterApiKey: openrouterValue })
|
||||
await refreshSettings()
|
||||
setOpenrouterApiKey("")
|
||||
}
|
||||
|
||||
toast.success("OpenRouter connected")
|
||||
return true
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to save OpenRouter key"
|
||||
toast.error(message)
|
||||
return false
|
||||
} finally {
|
||||
setIsSavingEnv(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveRxresume = async (): Promise<boolean> => {
|
||||
const emailValue = rxresumeEmail.trim()
|
||||
const passwordValue = rxresumePassword.trim()
|
||||
const missing: string[] = []
|
||||
|
||||
if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email")
|
||||
if (!hasRxresumePassword && !passwordValue) missing.push("RxResume password")
|
||||
|
||||
if (missing.length > 0) {
|
||||
toast.info("Almost there", {
|
||||
description: `Missing: ${missing.join(", ")}`,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = await validateRxresume(emailValue || undefined, passwordValue || undefined)
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.message || "RxResume validation failed")
|
||||
return false
|
||||
}
|
||||
|
||||
const update: { rxresumeEmail?: string; rxresumePassword?: string } = {}
|
||||
if (emailValue) update.rxresumeEmail = emailValue
|
||||
if (passwordValue) update.rxresumePassword = passwordValue
|
||||
|
||||
if (Object.keys(update).length > 0) {
|
||||
setIsSavingEnv(true)
|
||||
await api.updateSettings(update)
|
||||
await refreshSettings()
|
||||
setRxresumePassword("")
|
||||
}
|
||||
|
||||
toast.success("RxResume connected")
|
||||
return true
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to save RxResume credentials"
|
||||
toast.error(message)
|
||||
return false
|
||||
} finally {
|
||||
setIsSavingEnv(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadResume = async (): Promise<boolean> => {
|
||||
if (!resumeFile) {
|
||||
const validation = await validateResume()
|
||||
if (!validation.valid) {
|
||||
toast.info(validation.message || "Upload your resume JSON to continue")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploadingResume(true)
|
||||
const text = await resumeFile.text()
|
||||
let parsed: ResumeProfile
|
||||
try {
|
||||
parsed = JSON.parse(text) as ResumeProfile
|
||||
} catch {
|
||||
throw new Error("Resume JSON is invalid. Export the base.json from RxResume.")
|
||||
}
|
||||
|
||||
await api.uploadProfile(parsed)
|
||||
await validateResume()
|
||||
setResumeFile(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
toast.success("Resume uploaded")
|
||||
return true
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to upload resume"
|
||||
toast.error(message)
|
||||
return false
|
||||
} finally {
|
||||
setIsUploadingResume(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resumeFileName = resumeFile?.name || ""
|
||||
const resolvedStepIndex = currentStep ? steps.findIndex((step) => step.id === currentStep) : 0
|
||||
const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0
|
||||
const completedSteps = steps.filter((step) => step.complete).length
|
||||
const progressValue = steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0
|
||||
const isBusy = isSavingEnv || isUploadingResume || settingsLoading || isValidatingOpenrouter || isValidatingRxresume || isValidatingResume
|
||||
const canGoBack = stepIndex > 0
|
||||
const primaryLabel = currentStep === "resume"
|
||||
? (resumeValidation.valid ? "Finish" : "Upload and validate")
|
||||
: currentStep === "openrouter"
|
||||
? (openrouterValidation.valid ? "Revalidate" : "Validate")
|
||||
: currentStep === "rxresume"
|
||||
? (rxresumeValidation.valid ? "Revalidate" : "Validate")
|
||||
: "Validate"
|
||||
|
||||
const handlePrimaryAction = async () => {
|
||||
if (!currentStep) return
|
||||
if (currentStep === "openrouter") {
|
||||
await handleSaveOpenrouter()
|
||||
return
|
||||
}
|
||||
if (currentStep === "rxresume") {
|
||||
await handleSaveRxresume()
|
||||
return
|
||||
}
|
||||
if (currentStep === "resume") {
|
||||
if (hasBaseResume) {
|
||||
await handleRefresh()
|
||||
return
|
||||
}
|
||||
await handleUploadResume()
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (!canGoBack) return
|
||||
setCurrentStep(steps[stepIndex - 1]?.id ?? currentStep)
|
||||
}
|
||||
|
||||
if (!shouldOpen || !currentStep) return null
|
||||
|
||||
return (
|
||||
<AlertDialog open>
|
||||
<AlertDialogContent
|
||||
className="max-w-3xl max-h-[90vh] overflow-hidden p-0"
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="space-y-6 px-6 py-6 max-h-[calc(90vh-3.5rem)] overflow-y-auto">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Welcome to Job Ops</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Let’s get your workspace ready. Add your keys and resume once, then the pipeline can run end-to-end.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<Tabs value={currentStep} onValueChange={setCurrentStep}>
|
||||
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 border-b border-border/60 bg-transparent p-0 text-left sm:grid-cols-3">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = step.id === currentStep
|
||||
const isComplete = step.complete
|
||||
|
||||
return (
|
||||
<FieldLabel
|
||||
key={step.id}
|
||||
className="w-full [&>[data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none"
|
||||
>
|
||||
<TabsTrigger
|
||||
value={step.id}
|
||||
className={cn(
|
||||
"w-full rounded-none border-b-2 border-transparent px-3 py-4 text-left shadow-none",
|
||||
isActive ? "border-primary bg-muted/60 text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Field orientation="horizontal" className="items-start">
|
||||
<FieldContent>
|
||||
<FieldTitle>{step.label}</FieldTitle>
|
||||
<FieldDescription>{step.subtitle}</FieldDescription>
|
||||
</FieldContent>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex h-6 w-6 items-center justify-center rounded-md text-xs font-semibold",
|
||||
isComplete
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isComplete ? <Check className="h-3.5 w-3.5" /> : index + 1}
|
||||
</span>
|
||||
</Field>
|
||||
</TabsTrigger>
|
||||
</FieldLabel>
|
||||
)
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="openrouter" className="space-y-4 pt-6">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Connect OpenRouter</p>
|
||||
<p className="text-xs text-muted-foreground">Used for job scoring, summaries, and tailoring.</p>
|
||||
</div>
|
||||
<SettingsInput
|
||||
label="OpenRouter API key"
|
||||
inputProps={{
|
||||
name: "openrouterApiKey",
|
||||
value: openrouterApiKey,
|
||||
onChange: (event) => setOpenrouterApiKey(event.target.value),
|
||||
}}
|
||||
type="password"
|
||||
placeholder="sk-or-v1..."
|
||||
current={openrouterCurrent}
|
||||
helper="Create a key at openrouter.ai"
|
||||
disabled={isSavingEnv}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rxresume" className="space-y-4 pt-6">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Link your RxResume account</p>
|
||||
<p className="text-xs text-muted-foreground">Used to export tailored PDFs.</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SettingsInput
|
||||
label="Email"
|
||||
inputProps={{
|
||||
name: "rxresumeEmail",
|
||||
value: rxresumeEmail,
|
||||
onChange: (event) => setRxresumeEmail(event.target.value),
|
||||
}}
|
||||
placeholder="you@example.com"
|
||||
current={rxresumeEmailCurrent}
|
||||
disabled={isSavingEnv}
|
||||
/>
|
||||
<SettingsInput
|
||||
label="Password"
|
||||
inputProps={{
|
||||
name: "rxresumePassword",
|
||||
value: rxresumePassword,
|
||||
onChange: (event) => setRxresumePassword(event.target.value),
|
||||
}}
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
current={rxresumePasswordCurrent}
|
||||
disabled={isSavingEnv}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="resume" className="space-y-4 pt-6">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Upload your resume JSON</p>
|
||||
<p className="text-xs text-muted-foreground">Use the JSON export you downloaded from v4.rxresu.me.</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="resumeFile" className="text-sm font-medium">
|
||||
Resume JSON
|
||||
</label>
|
||||
<Input
|
||||
id="resumeFile"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={(event) => setResumeFile(event.target.files?.[0] ?? null)}
|
||||
disabled={isUploadingResume}
|
||||
/>
|
||||
{resumeFileName && (
|
||||
<p className="text-xs text-muted-foreground">Selected: {resumeFileName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={handleBack} disabled={!canGoBack || isBusy}>
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" onClick={handleRefresh} disabled={isBusy}>
|
||||
Refresh status
|
||||
</Button>
|
||||
<Button onClick={handlePrimaryAction} disabled={isBusy}>
|
||||
{isBusy ? "Working..." : primaryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Progress value={progressValue} className="h-2" />
|
||||
|
||||
<div className="rounded-lg border border-muted bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
Friendly heads-up: pipelines can be slow or a little flaky in alpha. If anything feels off, open a GitHub issue and
|
||||
we will take a look.{" "}
|
||||
<a
|
||||
className="font-semibold text-foreground underline underline-offset-2"
|
||||
href="https://github.com/DaKheera47/job-ops/issues"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Open an issue
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
*
|
||||
* Designed for a single, fast, repeatable workflow: verify → download → apply → mark applied.
|
||||
* The PDF is the primary artifact, represented abstractly through an Application Kit summary.
|
||||
*
|
||||
* Now includes inline tailoring mode for editing and regenerating PDFs without switching tabs.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
@@ -42,14 +44,16 @@ import {
|
||||
import { cn, copyTextToClipboard, formatJobForWebhook } from "@/lib/utils";
|
||||
import * as api from "../api";
|
||||
import { FitAssessment, JobHeader, TailoredSummary } from ".";
|
||||
import { TailorMode } from "./discovered-panel/TailorMode";
|
||||
import { useProfile } from "../hooks/useProfile";
|
||||
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
|
||||
|
||||
type PanelMode = "ready" | "tailor";
|
||||
|
||||
interface ReadyPanelProps {
|
||||
job: Job | null;
|
||||
onJobUpdated: () => void | Promise<void>;
|
||||
onJobMoved: (jobId: string) => void;
|
||||
onEditTailoring: () => void;
|
||||
onEditDescription: () => void;
|
||||
}
|
||||
|
||||
const safeFilenamePart = (value: string | null | undefined) =>
|
||||
@@ -59,9 +63,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
job,
|
||||
onJobUpdated,
|
||||
onJobMoved,
|
||||
onEditTailoring,
|
||||
onEditDescription,
|
||||
}) => {
|
||||
const [mode, setMode] = useState<PanelMode>("ready");
|
||||
const [isMarkingApplied, setIsMarkingApplied] = useState(false);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||
@@ -72,11 +75,18 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
timeoutId: ReturnType<typeof setTimeout>;
|
||||
} | null>(null);
|
||||
|
||||
const { personName } = useProfile();
|
||||
|
||||
// Load project catalog once
|
||||
useEffect(() => {
|
||||
api.getProfileProjects().then(setCatalog).catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Reset mode when job changes
|
||||
useEffect(() => {
|
||||
setMode("ready");
|
||||
}, [job?.id]);
|
||||
|
||||
// Compute derived values
|
||||
const pdfHref = job
|
||||
? `/pdfs/resume_${job.id}.pdf?v=${encodeURIComponent(job.updatedAt)}`
|
||||
@@ -141,7 +151,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
// Revert to ready status
|
||||
await api.updateJob(jobId, { status: "ready" });
|
||||
toast.success("Reverted to Ready");
|
||||
|
||||
|
||||
if (recentlyApplied?.timeoutId) {
|
||||
clearTimeout(recentlyApplied.timeoutId);
|
||||
}
|
||||
@@ -198,6 +208,23 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
}
|
||||
}, [job]);
|
||||
|
||||
// Handler for regenerating PDF after tailoring edits
|
||||
const handleTailorFinalize = useCallback(async () => {
|
||||
if (!job) return;
|
||||
try {
|
||||
setIsRegenerating(true);
|
||||
await api.generateJobPdf(job.id);
|
||||
toast.success("PDF regenerated");
|
||||
await onJobUpdated();
|
||||
setMode("ready");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to regenerate PDF";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsRegenerating(false);
|
||||
}
|
||||
}, [job, onJobUpdated]);
|
||||
|
||||
// Empty state
|
||||
if (!job) {
|
||||
return (
|
||||
@@ -213,9 +240,29 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Tailor mode - reuse the same TailorMode component with 'ready' variant
|
||||
if (mode === "tailor") {
|
||||
return (
|
||||
<TailorMode
|
||||
job={job}
|
||||
onBack={() => setMode("ready")}
|
||||
onFinalize={handleTailorFinalize}
|
||||
isFinalizing={isRegenerating}
|
||||
variant="ready"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<JobHeader job={job} className="pb-4 border-b border-border/40" />
|
||||
<JobHeader
|
||||
job={job}
|
||||
className="pb-4 border-b border-border/40"
|
||||
onCheckSponsor={async () => {
|
||||
await api.checkSponsor(job.id);
|
||||
await onJobUpdated();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ─────────────────────────────────────────────────────────────────────
|
||||
PRIMARY ACTION CLUSTER
|
||||
@@ -235,7 +282,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
<Button asChild variant="outline" className="h-9 w-full gap-1 px-2 text-xs">
|
||||
<a
|
||||
href={pdfHref}
|
||||
download={`Shaheer_Sarfaraz_${safeFilenamePart(job.employer)}.pdf`}
|
||||
download={`${safeFilenamePart(personName)}_${safeFilenamePart(job.employer)}.pdf`}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">Download PDF</span>
|
||||
@@ -325,7 +372,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-56">
|
||||
{/* Fix/Edit actions */}
|
||||
<DropdownMenuItem onSelect={onEditTailoring}>
|
||||
<DropdownMenuItem onSelect={() => setMode("tailor")}>
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
Edit tailoring
|
||||
</DropdownMenuItem>
|
||||
@@ -338,11 +385,6 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
{isRegenerating ? "Regenerating..." : "Regenerate PDF"}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onSelect={onEditDescription}>
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
Edit job description
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Utility actions */}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface DecideModeProps {
|
||||
onTailor: () => void;
|
||||
onSkip: () => void;
|
||||
isSkipping: boolean;
|
||||
onCheckSponsor?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const DecideMode: React.FC<DecideModeProps> = ({
|
||||
@@ -21,6 +22,7 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
||||
onTailor,
|
||||
onSkip,
|
||||
isSkipping,
|
||||
onCheckSponsor,
|
||||
}) => {
|
||||
const [showDescription, setShowDescription] = useState(false);
|
||||
const jobLink = job.applicationLink || job.jobUrl;
|
||||
@@ -33,7 +35,10 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
||||
return (
|
||||
<div className='flex flex-col h-full'>
|
||||
<div className='space-y-4 pb-4'>
|
||||
<JobHeader job={job} />
|
||||
<JobHeader
|
||||
job={job}
|
||||
onCheckSponsor={onCheckSponsor}
|
||||
/>
|
||||
|
||||
<div className='flex flex-col gap-2.5 pt-2 sm:flex-row'>
|
||||
<Button
|
||||
|
||||
@@ -85,6 +85,10 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
||||
onTailor={() => setMode("tailor")}
|
||||
onSkip={handleSkip}
|
||||
isSkipping={isSkipping}
|
||||
onCheckSponsor={async () => {
|
||||
await api.checkSponsor(job.id);
|
||||
await onJobUpdated();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TailorMode
|
||||
|
||||
@@ -15,6 +15,8 @@ interface TailorModeProps {
|
||||
onBack: () => void;
|
||||
onFinalize: () => void;
|
||||
isFinalizing: boolean;
|
||||
/** Variant controls the finalize button text. Default is 'discovered'. */
|
||||
variant?: 'discovered' | 'ready';
|
||||
}
|
||||
|
||||
export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
@@ -22,6 +24,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
onBack,
|
||||
onFinalize,
|
||||
isFinalizing,
|
||||
variant = 'discovered',
|
||||
}) => {
|
||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
||||
@@ -274,7 +277,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
<div className='space-y-2'>
|
||||
{!canFinalize && (
|
||||
<p className='text-[10px] text-center text-muted-foreground'>
|
||||
Add a summary and select at least one project to finalize.
|
||||
Add a summary and select at least one project to {variant === 'ready' ? 'regenerate' : 'finalize'}.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
@@ -285,17 +288,19 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
{isFinalizing ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Finalizing & generating PDF...
|
||||
{variant === 'ready' ? 'Regenerating PDF...' : 'Finalizing & generating PDF...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className='mr-2 h-4 w-4' />
|
||||
Finalize & Move to Ready
|
||||
{variant === 'ready' ? 'Regenerate PDF' : 'Finalize & Move to Ready'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className='text-[10px] text-center text-muted-foreground/70'>
|
||||
This will generate your tailored PDF and move the job to Ready.
|
||||
{variant === 'ready'
|
||||
? 'This will save your changes and regenerate the tailored PDF.'
|
||||
: 'This will generate your tailored PDF and move the job to Ready.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as api from '../api';
|
||||
import type { ResumeProfile } from '../../shared/types';
|
||||
|
||||
let profileCache: ResumeProfile | null = null;
|
||||
let profileError: Error | null = null;
|
||||
let subscribers: Set<(profile: ResumeProfile | null, error: Error | null) => void> = new Set();
|
||||
let isFetching = false;
|
||||
|
||||
/**
|
||||
* Hook to get the full profile data from base.json.
|
||||
* Caches the result to avoid re-fetching.
|
||||
*/
|
||||
export function useProfile() {
|
||||
const [profile, setProfile] = useState<ResumeProfile | null>(profileCache);
|
||||
const [error, setError] = useState<Error | null>(profileError);
|
||||
|
||||
useEffect(() => {
|
||||
if (profileCache) {
|
||||
setProfile(profileCache);
|
||||
}
|
||||
if (profileError) {
|
||||
setError(profileError);
|
||||
}
|
||||
|
||||
const handleUpdate = (newProfile: ResumeProfile | null, newError: Error | null) => {
|
||||
setProfile(newProfile);
|
||||
setError(newError);
|
||||
};
|
||||
|
||||
subscribers.add(handleUpdate);
|
||||
|
||||
if (!profileCache && !isFetching) {
|
||||
isFetching = true;
|
||||
profileError = null;
|
||||
api.getProfile()
|
||||
.then((data) => {
|
||||
profileCache = data;
|
||||
profileError = null;
|
||||
subscribers.forEach(sub => sub(data, null));
|
||||
})
|
||||
.catch((err) => {
|
||||
profileError = err instanceof Error ? err : new Error(String(err));
|
||||
subscribers.forEach(sub => sub(profileCache, profileError));
|
||||
})
|
||||
.finally(() => {
|
||||
isFetching = false;
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscribers.delete(handleUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshProfile = async () => {
|
||||
isFetching = true;
|
||||
profileError = null;
|
||||
subscribers.forEach(sub => sub(profileCache, null));
|
||||
|
||||
try {
|
||||
const data = await api.getProfile();
|
||||
profileCache = data;
|
||||
profileError = null;
|
||||
subscribers.forEach(sub => sub(data, null));
|
||||
return data;
|
||||
} catch (err) {
|
||||
profileError = err instanceof Error ? err : new Error(String(err));
|
||||
subscribers.forEach(sub => sub(profileCache, profileError));
|
||||
throw profileError;
|
||||
} finally {
|
||||
isFetching = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
profile,
|
||||
error,
|
||||
isLoading: !profile && isFetching && !error,
|
||||
personName: profile?.basics?.name || 'Resume',
|
||||
refreshProfile,
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal For testing only */
|
||||
export function _resetProfileCache() {
|
||||
profileCache = null;
|
||||
profileError = null;
|
||||
isFetching = false;
|
||||
subscribers.clear();
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useSettings, _resetSettingsCache } from './useSettings';
|
||||
import * as api from '../api';
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
getSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
_resetSettingsCache();
|
||||
});
|
||||
|
||||
it('fetches settings on mount if not already cached', async () => {
|
||||
const mockSettings = { showSponsorInfo: false };
|
||||
(api.getSettings as any).mockResolvedValue(mockSettings);
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
// Should start in loading state
|
||||
expect(result.current.settings).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settings).toEqual(mockSettings);
|
||||
});
|
||||
|
||||
expect(result.current.showSponsorInfo).toBe(false);
|
||||
expect(api.getSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses default values when settings are null', async () => {
|
||||
(api.getSettings as any).mockResolvedValue(null);
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
// settings is null, so showSponsorInfo should default to true
|
||||
expect(result.current.showSponsorInfo).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('provides a refresh function that updates settings', async () => {
|
||||
const initialSettings = { showSponsorInfo: true };
|
||||
const updatedSettings = { showSponsorInfo: false };
|
||||
|
||||
(api.getSettings as any).mockResolvedValueOnce(initialSettings);
|
||||
(api.getSettings as any).mockResolvedValueOnce(updatedSettings);
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settings).toEqual(initialSettings);
|
||||
});
|
||||
|
||||
let refreshed;
|
||||
await waitFor(async () => {
|
||||
refreshed = await result.current.refreshSettings();
|
||||
});
|
||||
|
||||
expect(refreshed).toEqual(updatedSettings);
|
||||
expect(result.current.settings).toEqual(updatedSettings);
|
||||
expect(result.current.showSponsorInfo).toBe(false);
|
||||
});
|
||||
|
||||
it('handles errors when fetching settings', async () => {
|
||||
const mockError = new Error('Failed to fetch');
|
||||
(api.getSettings as any).mockRejectedValue(mockError);
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toEqual(mockError);
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.settings).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { AppSettings } from '../../shared/types';
|
||||
import * as api from '../api';
|
||||
|
||||
let settingsCache: AppSettings | null = null;
|
||||
let settingsError: Error | null = null;
|
||||
let subscribers: Set<(settings: AppSettings | null, error: Error | null) => void> = new Set();
|
||||
let isFetching = false;
|
||||
|
||||
export function useSettings() {
|
||||
const [settings, setSettings] = useState<AppSettings | null>(settingsCache);
|
||||
const [error, setError] = useState<Error | null>(settingsError);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsCache) {
|
||||
setSettings(settingsCache);
|
||||
}
|
||||
if (settingsError) {
|
||||
setError(settingsError);
|
||||
}
|
||||
|
||||
const handleUpdate = (newSettings: AppSettings | null, newError: Error | null) => {
|
||||
setSettings(newSettings);
|
||||
setError(newError);
|
||||
};
|
||||
|
||||
subscribers.add(handleUpdate);
|
||||
|
||||
if (!settingsCache && !isFetching) {
|
||||
isFetching = true;
|
||||
settingsError = null;
|
||||
api.getSettings()
|
||||
.then((data) => {
|
||||
settingsCache = data;
|
||||
settingsError = null;
|
||||
subscribers.forEach(sub => sub(data, null));
|
||||
})
|
||||
.catch((err) => {
|
||||
settingsError = err instanceof Error ? err : new Error(String(err));
|
||||
subscribers.forEach(sub => sub(settingsCache, settingsError));
|
||||
})
|
||||
.finally(() => {
|
||||
isFetching = false;
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscribers.delete(handleUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshSettings = async () => {
|
||||
isFetching = true;
|
||||
settingsError = null;
|
||||
subscribers.forEach(sub => sub(settingsCache, null));
|
||||
|
||||
try {
|
||||
const data = await api.getSettings();
|
||||
settingsCache = data;
|
||||
settingsError = null;
|
||||
subscribers.forEach(sub => sub(data, null));
|
||||
return data;
|
||||
} catch (err) {
|
||||
settingsError = err instanceof Error ? err : new Error(String(err));
|
||||
subscribers.forEach(sub => sub(settingsCache, settingsError));
|
||||
throw settingsError;
|
||||
} finally {
|
||||
isFetching = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
settings,
|
||||
error,
|
||||
isLoading: !settings && isFetching && !error,
|
||||
showSponsorInfo: settings?.showSponsorInfo ?? true,
|
||||
refreshSettings,
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal For testing only */
|
||||
export function _resetSettingsCache() {
|
||||
settingsCache = null;
|
||||
settingsError = null;
|
||||
isFetching = false;
|
||||
subscribers.clear();
|
||||
}
|
||||
@@ -33,6 +33,8 @@ const jobFixture: Job = {
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
@@ -198,7 +200,7 @@ describe("OrchestratorPage", () => {
|
||||
// Clicking job-2 should update URL
|
||||
const job2Button = screen.getByTestId("select-job-2");
|
||||
fireEvent.click(job2Button);
|
||||
|
||||
|
||||
// Wait for URL to update
|
||||
await waitFor(() => {
|
||||
expect(locationText()).toContain("/all/job-2");
|
||||
|
||||
@@ -23,14 +23,14 @@ vi.mock("sonner", () => ({
|
||||
}))
|
||||
|
||||
const baseSettings: AppSettings = {
|
||||
model: "openai/gpt-4o-mini",
|
||||
defaultModel: "openai/gpt-4o-mini",
|
||||
model: "google/gemini-3-flash-preview",
|
||||
defaultModel: "google/gemini-3-flash-preview",
|
||||
overrideModel: null,
|
||||
modelScorer: "openai/gpt-4o-mini",
|
||||
modelScorer: "google/gemini-3-flash-preview",
|
||||
overrideModelScorer: null,
|
||||
modelTailoring: "openai/gpt-4o-mini",
|
||||
modelTailoring: "google/gemini-3-flash-preview",
|
||||
overrideModelTailoring: null,
|
||||
modelProjectSelection: "openai/gpt-4o-mini",
|
||||
modelProjectSelection: "google/gemini-3-flash-preview",
|
||||
overrideModelProjectSelection: null,
|
||||
pipelineWebhookUrl: "",
|
||||
defaultPipelineWebhookUrl: "",
|
||||
@@ -92,6 +92,18 @@ const baseSettings: AppSettings = {
|
||||
jobspyLinkedinFetchDescription: true,
|
||||
defaultJobspyLinkedinFetchDescription: true,
|
||||
overrideJobspyLinkedinFetchDescription: null,
|
||||
showSponsorInfo: true,
|
||||
defaultShowSponsorInfo: true,
|
||||
overrideShowSponsorInfo: null,
|
||||
openrouterApiKeyHint: null,
|
||||
rxresumeEmail: "",
|
||||
rxresumePasswordHint: null,
|
||||
basicAuthUser: "",
|
||||
basicAuthPasswordHint: null,
|
||||
ukvisajobsEmail: "",
|
||||
ukvisajobsPasswordHint: null,
|
||||
webhookSecretHint: null,
|
||||
basicAuthActive: false,
|
||||
}
|
||||
|
||||
const renderPage = () => {
|
||||
@@ -138,6 +150,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 })
|
||||
@@ -161,4 +195,89 @@ describe("SettingsPage", () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("enables save button when model is changed", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
||||
renderPage()
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
||||
expect(saveButton).toBeDisabled()
|
||||
|
||||
const modelTrigger = await screen.findByRole("button", { name: /model/i })
|
||||
fireEvent.click(modelTrigger)
|
||||
const modelInput = screen.getByLabelText(/override model/i)
|
||||
fireEvent.change(modelInput, { target: { value: "new-model" } })
|
||||
expect(saveButton).toBeEnabled()
|
||||
})
|
||||
|
||||
it("enables save button when numeric setting is changed", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
||||
renderPage()
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
||||
|
||||
const visaTrigger = await screen.findByRole("button", { name: /ukvisajobs extractor/i })
|
||||
fireEvent.click(visaTrigger)
|
||||
const maxJobsInput = screen.getByLabelText(/max jobs to fetch/i)
|
||||
fireEvent.change(maxJobsInput, { target: { value: "100" } })
|
||||
expect(saveButton).toBeEnabled()
|
||||
})
|
||||
|
||||
it("enables save button when display setting is changed", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
||||
renderPage()
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
||||
|
||||
const displayTrigger = await screen.findByRole("button", { name: /display settings/i })
|
||||
fireEvent.click(displayTrigger)
|
||||
const sponsorCheckbox = screen.getByLabelText(/show visa sponsor information/i)
|
||||
fireEvent.click(sponsorCheckbox)
|
||||
expect(saveButton).toBeEnabled()
|
||||
})
|
||||
|
||||
it("enables save button when basic auth toggle is changed", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
||||
renderPage()
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
||||
|
||||
const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i })
|
||||
fireEvent.click(envTrigger)
|
||||
const authCheckbox = screen.getByLabelText(/enable basic authentication/i)
|
||||
fireEvent.click(authCheckbox)
|
||||
expect(saveButton).toBeEnabled()
|
||||
})
|
||||
|
||||
it("wipes basic auth credentials when toggle is disabled and saved", async () => {
|
||||
// Initial state: Basic Auth is active
|
||||
const activeSettings = {
|
||||
...baseSettings,
|
||||
basicAuthActive: true,
|
||||
basicAuthUser: "admin",
|
||||
basicAuthPasswordHint: "pass",
|
||||
}
|
||||
vi.mocked(api.getSettings).mockResolvedValue(activeSettings)
|
||||
vi.mocked(api.updateSettings).mockResolvedValue(baseSettings)
|
||||
|
||||
renderPage()
|
||||
|
||||
const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i })
|
||||
fireEvent.click(envTrigger)
|
||||
|
||||
const authCheckbox = screen.getByLabelText(/enable basic authentication/i)
|
||||
expect(authCheckbox).toBeChecked()
|
||||
|
||||
// Disable it
|
||||
fireEvent.click(authCheckbox)
|
||||
expect(authCheckbox).not.toBeChecked()
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
||||
expect(saveButton).toBeEnabled()
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled())
|
||||
expect(api.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
basicAuthUser: null,
|
||||
basicAuthPassword: null,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,52 +1,244 @@
|
||||
/**
|
||||
* 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 type { AppSettings, JobStatus } from "@shared/types"
|
||||
import { updateSettingsSchema, type UpdateSettingsInput } from "@shared/settings-schema"
|
||||
import * as api from "@client/api"
|
||||
import { arraysEqual } from "@/lib/utils"
|
||||
import { resumeProjectsEqual } from "./settings/utils"
|
||||
import { DangerZoneSection } from "./settings/components/DangerZoneSection"
|
||||
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 { ReactiveResumeSection } from "./settings/components/ReactiveResumeSection"
|
||||
import { resumeProjectsEqual } from "@client/pages/settings/utils"
|
||||
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection"
|
||||
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection"
|
||||
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection"
|
||||
import { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection"
|
||||
import { JobspySection } from "@client/pages/settings/components/JobspySection"
|
||||
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection"
|
||||
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection"
|
||||
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,
|
||||
openrouterApiKey: "",
|
||||
rxresumeEmail: "",
|
||||
rxresumePassword: "",
|
||||
basicAuthUser: "",
|
||||
basicAuthPassword: "",
|
||||
ukvisajobsEmail: "",
|
||||
ukvisajobsPassword: "",
|
||||
webhookSecret: "",
|
||||
enableBasicAuth: false,
|
||||
}
|
||||
|
||||
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,
|
||||
openrouterApiKey: null,
|
||||
rxresumeEmail: null,
|
||||
rxresumePassword: null,
|
||||
basicAuthUser: null,
|
||||
basicAuthPassword: null,
|
||||
ukvisajobsEmail: null,
|
||||
ukvisajobsPassword: null,
|
||||
webhookSecret: null,
|
||||
enableBasicAuth: undefined,
|
||||
}
|
||||
|
||||
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,
|
||||
openrouterApiKey: "",
|
||||
rxresumeEmail: data.rxresumeEmail ?? "",
|
||||
rxresumePassword: "",
|
||||
basicAuthUser: data.basicAuthUser ?? "",
|
||||
basicAuthPassword: "",
|
||||
ukvisajobsEmail: data.ukvisajobsEmail ?? "",
|
||||
ukvisajobsPassword: "",
|
||||
webhookSecret: "",
|
||||
enableBasicAuth: data.basicAuthActive,
|
||||
})
|
||||
|
||||
const normalizeString = (value: string | null | undefined) => {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
const normalizePrivateInput = (value: string | null | undefined) => {
|
||||
const trimmed = value?.trim()
|
||||
if (trimmed === "") return null
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
const isSameStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => {
|
||||
if (!left && !right) return true
|
||||
if (!left || !right) return false
|
||||
return arraysEqual(left, right)
|
||||
}
|
||||
|
||||
const isSameSortedStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => {
|
||||
if (!left && !right) return true
|
||||
if (!left || !right) return false
|
||||
return arraysEqual(left.slice().sort(), right.slice().sort())
|
||||
}
|
||||
|
||||
const nullIfSame = <T,>(value: T | null | undefined, defaultValue: T) =>
|
||||
value === defaultValue ? null : value ?? null
|
||||
|
||||
const nullIfSameList = (value: string[] | null | undefined, defaultValue: string[]) =>
|
||||
isSameStringList(value, defaultValue) ? null : value ?? null
|
||||
|
||||
const nullIfSameSortedList = (value: string[] | null | undefined, defaultValue: string[]) =>
|
||||
isSameSortedStringList(value, defaultValue) ? null : value ?? null
|
||||
|
||||
const getDerivedSettings = (settings: AppSettings | null) => {
|
||||
const profileProjects = settings?.profileProjects ?? []
|
||||
|
||||
return {
|
||||
model: {
|
||||
effective: settings?.model ?? "",
|
||||
default: settings?.defaultModel ?? "",
|
||||
scorer: settings?.modelScorer ?? "",
|
||||
tailoring: settings?.modelTailoring ?? "",
|
||||
projectSelection: settings?.modelProjectSelection ?? "",
|
||||
},
|
||||
pipelineWebhook: {
|
||||
effective: settings?.pipelineWebhookUrl ?? "",
|
||||
default: settings?.defaultPipelineWebhookUrl ?? "",
|
||||
},
|
||||
jobCompleteWebhook: {
|
||||
effective: settings?.jobCompleteWebhookUrl ?? "",
|
||||
default: settings?.defaultJobCompleteWebhookUrl ?? "",
|
||||
},
|
||||
ukvisajobs: {
|
||||
effective: settings?.ukvisajobsMaxJobs ?? 50,
|
||||
default: settings?.defaultUkvisajobsMaxJobs ?? 50,
|
||||
},
|
||||
gradcracker: {
|
||||
effective: settings?.gradcrackerMaxJobsPerTerm ?? 50,
|
||||
default: settings?.defaultGradcrackerMaxJobsPerTerm ?? 50,
|
||||
},
|
||||
searchTerms: {
|
||||
effective: settings?.searchTerms ?? [],
|
||||
default: settings?.defaultSearchTerms ?? [],
|
||||
},
|
||||
jobspy: {
|
||||
location: {
|
||||
effective: settings?.jobspyLocation ?? "",
|
||||
default: settings?.defaultJobspyLocation ?? "",
|
||||
},
|
||||
resultsWanted: {
|
||||
effective: settings?.jobspyResultsWanted ?? 200,
|
||||
default: settings?.defaultJobspyResultsWanted ?? 200,
|
||||
},
|
||||
hoursOld: {
|
||||
effective: settings?.jobspyHoursOld ?? 72,
|
||||
default: settings?.defaultJobspyHoursOld ?? 72,
|
||||
},
|
||||
countryIndeed: {
|
||||
effective: settings?.jobspyCountryIndeed ?? "",
|
||||
default: settings?.defaultJobspyCountryIndeed ?? "",
|
||||
},
|
||||
sites: {
|
||||
effective: settings?.jobspySites ?? ["indeed", "linkedin"],
|
||||
default: settings?.defaultJobspySites ?? ["indeed", "linkedin"],
|
||||
},
|
||||
linkedinFetchDescription: {
|
||||
effective: settings?.jobspyLinkedinFetchDescription ?? true,
|
||||
default: settings?.defaultJobspyLinkedinFetchDescription ?? true,
|
||||
},
|
||||
},
|
||||
display: {
|
||||
effective: settings?.showSponsorInfo ?? true,
|
||||
default: settings?.defaultShowSponsorInfo ?? true,
|
||||
},
|
||||
envSettings: {
|
||||
readable: {
|
||||
rxresumeEmail: settings?.rxresumeEmail ?? "",
|
||||
ukvisajobsEmail: settings?.ukvisajobsEmail ?? "",
|
||||
basicAuthUser: settings?.basicAuthUser ?? "",
|
||||
},
|
||||
private: {
|
||||
openrouterApiKeyHint: settings?.openrouterApiKeyHint ?? null,
|
||||
rxresumePasswordHint: settings?.rxresumePasswordHint ?? null,
|
||||
ukvisajobsPasswordHint: settings?.ukvisajobsPasswordHint ?? null,
|
||||
basicAuthPasswordHint: settings?.basicAuthPasswordHint ?? null,
|
||||
webhookSecretHint: settings?.webhookSecretHint ?? null,
|
||||
},
|
||||
basicAuthActive: settings?.basicAuthActive ?? false,
|
||||
},
|
||||
defaultResumeProjects: settings?.defaultResumeProjects ?? null,
|
||||
|
||||
profileProjects,
|
||||
maxProjectsTotal: profileProjects.length,
|
||||
}
|
||||
}
|
||||
|
||||
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 [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState<string | 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, setError, watch, formState: { isDirty, errors, isValid, dirtyFields } } = methods
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
setIsLoading(true)
|
||||
@@ -55,23 +247,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)
|
||||
setRxResumeBaseResumeIdDraft(data.rxResumeBaseResumeId)
|
||||
reset(mapSettingsToForm(data))
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : "Failed to load settings"
|
||||
@@ -85,186 +261,126 @@ 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 profileProjects = settings?.profileProjects ?? []
|
||||
const maxProjectsTotal = profileProjects.length
|
||||
const lockedCount = resumeProjectsDraft?.lockedProjectIds.length ?? 0
|
||||
const derived = getDerivedSettings(settings)
|
||||
const {
|
||||
model,
|
||||
pipelineWebhook,
|
||||
jobCompleteWebhook,
|
||||
ukvisajobs,
|
||||
gradcracker,
|
||||
searchTerms,
|
||||
jobspy,
|
||||
display,
|
||||
envSettings,
|
||||
defaultResumeProjects,
|
||||
profileProjects,
|
||||
maxProjectsTotal,
|
||||
} = derived
|
||||
|
||||
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) ||
|
||||
rxResumeBaseResumeIdDraft !== (settings.rxResumeBaseResumeId ?? 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,
|
||||
overrideJobspyLocation,
|
||||
overrideJobspyResultsWanted,
|
||||
overrideJobspyHoursOld,
|
||||
overrideJobspyCountryIndeed,
|
||||
overrideJobspySites,
|
||||
overrideJobspyLinkedinFetchDescription,
|
||||
rxResumeBaseResumeIdDraft,
|
||||
])
|
||||
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
|
||||
if (data.enableBasicAuth && !settings.basicAuthActive) {
|
||||
const password = data.basicAuthPassword?.trim() ?? ""
|
||||
if (!password) {
|
||||
setError("basicAuthPassword", {
|
||||
type: "manual",
|
||||
message: "Password is required when basic auth is enabled",
|
||||
})
|
||||
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 && defaultResumeProjects && resumeProjectsEqual(resumeProjectsData, 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 rxResumeBaseResumeIdOverride = rxResumeBaseResumeIdDraft
|
||||
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 envPayload: Partial<UpdateSettingsInput> = {}
|
||||
|
||||
if (dirtyFields.rxresumeEmail || dirtyFields.rxresumePassword) {
|
||||
envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail)
|
||||
}
|
||||
|
||||
if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) {
|
||||
envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail)
|
||||
}
|
||||
|
||||
if (data.enableBasicAuth === false) {
|
||||
envPayload.basicAuthUser = null
|
||||
envPayload.basicAuthPassword = null
|
||||
} else if (dirtyFields.enableBasicAuth || dirtyFields.basicAuthUser || dirtyFields.basicAuthPassword) {
|
||||
// If enabling basic auth or changing either field, ensure we send at least the username
|
||||
// to keep the pair consistent in the backend.
|
||||
envPayload.basicAuthUser = normalizeString(data.basicAuthUser)
|
||||
|
||||
if (dirtyFields.basicAuthPassword) {
|
||||
const value = normalizePrivateInput(data.basicAuthPassword)
|
||||
if (value !== undefined) envPayload.basicAuthPassword = value
|
||||
}
|
||||
}
|
||||
|
||||
if (dirtyFields.openrouterApiKey) {
|
||||
const value = normalizePrivateInput(data.openrouterApiKey)
|
||||
if (value !== undefined) envPayload.openrouterApiKey = value
|
||||
}
|
||||
|
||||
if (dirtyFields.rxresumePassword) {
|
||||
const value = normalizePrivateInput(data.rxresumePassword)
|
||||
if (value !== undefined) envPayload.rxresumePassword = value
|
||||
}
|
||||
|
||||
if (dirtyFields.ukvisajobsPassword) {
|
||||
const value = normalizePrivateInput(data.ukvisajobsPassword)
|
||||
if (value !== undefined) envPayload.ukvisajobsPassword = value
|
||||
}
|
||||
|
||||
if (dirtyFields.webhookSecret) {
|
||||
const value = normalizePrivateInput(data.webhookSecret)
|
||||
if (value !== undefined) envPayload.webhookSecret = value
|
||||
}
|
||||
|
||||
const payload: UpdateSettingsInput = {
|
||||
model: normalizeString(data.model),
|
||||
modelScorer: normalizeString(data.modelScorer),
|
||||
modelTailoring: normalizeString(data.modelTailoring),
|
||||
modelProjectSelection: normalizeString(data.modelProjectSelection),
|
||||
pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl),
|
||||
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
|
||||
resumeProjects: resumeProjectsOverride,
|
||||
ukvisajobsMaxJobs: ukvisajobsMaxJobsOverride,
|
||||
gradcrackerMaxJobsPerTerm: gradcrackerMaxJobsPerTermOverride,
|
||||
searchTerms: searchTermsOverride,
|
||||
jobspyLocation: jobspyLocationOverride,
|
||||
jobspyResultsWanted: jobspyResultsWantedOverride,
|
||||
jobspyHoursOld: jobspyHoursOldOverride,
|
||||
jobspyCountryIndeed: jobspyCountryIndeedOverride,
|
||||
jobspySites: jobspySitesOverride,
|
||||
jobspyLinkedinFetchDescription: jobspyLinkedinFetchDescriptionOverride,
|
||||
rxResumeBaseResumeId: rxResumeBaseResumeIdOverride,
|
||||
})
|
||||
ukvisajobsMaxJobs: nullIfSame(data.ukvisajobsMaxJobs, ukvisajobs.default),
|
||||
gradcrackerMaxJobsPerTerm: nullIfSame(data.gradcrackerMaxJobsPerTerm, gradcracker.default),
|
||||
searchTerms: nullIfSameList(data.searchTerms, searchTerms.default),
|
||||
jobspyLocation: nullIfSame(data.jobspyLocation, jobspy.location.default),
|
||||
jobspyResultsWanted: nullIfSame(data.jobspyResultsWanted, jobspy.resultsWanted.default),
|
||||
jobspyHoursOld: nullIfSame(data.jobspyHoursOld, jobspy.hoursOld.default),
|
||||
jobspyCountryIndeed: nullIfSame(data.jobspyCountryIndeed, jobspy.countryIndeed.default),
|
||||
jobspySites: nullIfSameSortedList(data.jobspySites, jobspy.sites.default),
|
||||
jobspyLinkedinFetchDescription: nullIfSame(
|
||||
data.jobspyLinkedinFetchDescription,
|
||||
jobspy.linkedinFetchDescription.default
|
||||
),
|
||||
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
|
||||
...envPayload,
|
||||
}
|
||||
|
||||
// Remove virtual field because the backend doesn't expect it
|
||||
// this exists only to toggle the UI
|
||||
// need to track it so that the save button is enabled when it changes
|
||||
delete payload.enableBasicAuth
|
||||
|
||||
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)
|
||||
setRxResumeBaseResumeIdDraft(updated.rxResumeBaseResumeId)
|
||||
reset(mapSettingsToForm(updated))
|
||||
toast.success("Settings saved")
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to save settings"
|
||||
@@ -273,6 +389,7 @@ export const SettingsPage: React.FC = () => {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearDatabase = async () => {
|
||||
try {
|
||||
setIsSaving(true)
|
||||
@@ -331,43 +448,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,
|
||||
rxResumeBaseResumeId: 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)
|
||||
setRxResumeBaseResumeIdDraft(null)
|
||||
reset(mapSettingsToForm(updated))
|
||||
toast.success("Reset to default")
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to reset settings"
|
||||
@@ -378,7 +461,7 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormProvider {...methods}>
|
||||
<PageHeader
|
||||
icon={Settings}
|
||||
title="Settings"
|
||||
@@ -388,103 +471,51 @@ 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}
|
||||
effectiveModelProjectSelection={effectiveModelProjectSelection}
|
||||
defaultModel={defaultModel}
|
||||
values={model}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<PipelineWebhookSection
|
||||
pipelineWebhookUrlDraft={pipelineWebhookUrlDraft}
|
||||
setPipelineWebhookUrlDraft={setPipelineWebhookUrlDraft}
|
||||
defaultPipelineWebhookUrl={defaultPipelineWebhookUrl}
|
||||
effectivePipelineWebhookUrl={effectivePipelineWebhookUrl}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<JobCompleteWebhookSection
|
||||
jobCompleteWebhookUrlDraft={jobCompleteWebhookUrlDraft}
|
||||
setJobCompleteWebhookUrlDraft={setJobCompleteWebhookUrlDraft}
|
||||
defaultJobCompleteWebhookUrl={defaultJobCompleteWebhookUrl}
|
||||
effectiveJobCompleteWebhookUrl={effectiveJobCompleteWebhookUrl}
|
||||
<WebhooksSection
|
||||
pipelineWebhook={pipelineWebhook}
|
||||
jobCompleteWebhook={jobCompleteWebhook}
|
||||
webhookSecretHint={envSettings.private.webhookSecretHint}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<UkvisajobsSection
|
||||
ukvisajobsMaxJobsDraft={ukvisajobsMaxJobsDraft}
|
||||
setUkvisajobsMaxJobsDraft={setUkvisajobsMaxJobsDraft}
|
||||
defaultUkvisajobsMaxJobs={defaultUkvisajobsMaxJobs}
|
||||
effectiveUkvisajobsMaxJobs={effectiveUkvisajobsMaxJobs}
|
||||
values={ukvisajobs}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<GradcrackerSection
|
||||
gradcrackerMaxJobsPerTermDraft={gradcrackerMaxJobsPerTermDraft}
|
||||
setGradcrackerMaxJobsPerTermDraft={setGradcrackerMaxJobsPerTermDraft}
|
||||
defaultGradcrackerMaxJobsPerTerm={defaultGradcrackerMaxJobsPerTerm}
|
||||
effectiveGradcrackerMaxJobsPerTerm={effectiveGradcrackerMaxJobsPerTerm}
|
||||
values={gradcracker}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<SearchTermsSection
|
||||
searchTermsDraft={searchTermsDraft}
|
||||
setSearchTermsDraft={setSearchTermsDraft}
|
||||
defaultSearchTerms={defaultSearchTerms}
|
||||
effectiveSearchTerms={effectiveSearchTerms}
|
||||
values={searchTerms}
|
||||
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}
|
||||
values={jobspy}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<ResumeProjectsSection
|
||||
resumeProjectsDraft={resumeProjectsDraft}
|
||||
setResumeProjectsDraft={setResumeProjectsDraft}
|
||||
profileProjects={profileProjects}
|
||||
lockedCount={lockedCount}
|
||||
maxProjectsTotal={maxProjectsTotal}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<ReactiveResumeSection
|
||||
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
|
||||
setRxResumeBaseResumeIdDraft={setRxResumeBaseResumeIdDraft}
|
||||
hasRxResumeApiKey={settings?.hasRxResumeApiKey ?? false}
|
||||
<DisplaySettingsSection
|
||||
values={display}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<EnvironmentSettingsSection
|
||||
values={envSettings}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
@@ -499,14 +530,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ vi.mock("../../components", () => ({
|
||||
DiscoveredPanel: ({ job }: { job: Job | null }) => (
|
||||
<div data-testid="discovered-panel">{job?.id ?? "no-job"}</div>
|
||||
),
|
||||
JobHeader: () => <div data-testid="job-header" />,
|
||||
FitAssessment: () => <div data-testid="fit-assessment" />,
|
||||
TailoredSummary: () => <div data-testid="tailored-summary" />,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/ReadyPanel", () => ({
|
||||
@@ -63,6 +66,7 @@ vi.mock("../../api", () => ({
|
||||
generateJobPdf: vi.fn(),
|
||||
markAsApplied: vi.fn(),
|
||||
skipJob: vi.fn(),
|
||||
getProfile: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
@@ -100,6 +104,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
@@ -154,23 +160,7 @@ describe("JobDetailPanel", () => {
|
||||
expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99");
|
||||
});
|
||||
|
||||
it("wires ready panel edit actions back to the page", () => {
|
||||
const onSetActiveTab = vi.fn();
|
||||
|
||||
render(
|
||||
<JobDetailPanel
|
||||
activeTab="ready"
|
||||
activeJobs={[]}
|
||||
selectedJob={createJob({ status: "ready" })}
|
||||
onSelectJobId={vi.fn()}
|
||||
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
||||
onSetActiveTab={onSetActiveTab}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit description/i }));
|
||||
expect(onSetActiveTab).toHaveBeenCalledWith("discovered");
|
||||
});
|
||||
|
||||
it("shows an empty state when no job is selected", () => {
|
||||
render(
|
||||
|
||||
@@ -30,6 +30,7 @@ import { copyTextToClipboard, formatJobForWebhook, safeFilenamePart, stripHtml }
|
||||
import { DiscoveredPanel, FitAssessment, JobHeader, TailoredSummary } from "../../components";
|
||||
import { ReadyPanel } from "../../components/ReadyPanel";
|
||||
import { TailoringEditor } from "../../components/TailoringEditor";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import * as api from "../../api";
|
||||
import type { Job } from "../../../shared/types";
|
||||
import type { FilterTab } from "./constants";
|
||||
@@ -59,6 +60,8 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
const [processingJobId, setProcessingJobId] = useState<string | null>(null);
|
||||
const saveTailoringRef = useRef<null | (() => Promise<void>)>(null);
|
||||
|
||||
const { personName } = useProfile();
|
||||
|
||||
useEffect(() => {
|
||||
setHasUnsavedTailoring(false);
|
||||
saveTailoringRef.current = null;
|
||||
@@ -243,17 +246,6 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
job={selectedJob}
|
||||
onJobUpdated={onJobUpdated}
|
||||
onJobMoved={handleJobMoved}
|
||||
onEditTailoring={() => {
|
||||
onSetActiveTab("discovered");
|
||||
setTimeout(() => setDetailTab("tailoring"), 50);
|
||||
}}
|
||||
onEditDescription={() => {
|
||||
onSetActiveTab("discovered");
|
||||
setTimeout(() => {
|
||||
setDetailTab("description");
|
||||
setIsEditingDescription(true);
|
||||
}, 50);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -269,7 +261,13 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<JobHeader job={selectedJob} />
|
||||
<JobHeader
|
||||
job={selectedJob}
|
||||
onCheckSponsor={async () => {
|
||||
await api.checkSponsor(selectedJob.id);
|
||||
await onJobUpdated();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
|
||||
@@ -368,7 +366,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={selectedPdfHref}
|
||||
download={`Shaheer_Sarfaraz_${safeFilenamePart(selectedJob.employer)}.pdf`}
|
||||
download={`${personName.replace(/\s+/g, '_')}_${safeFilenamePart(selectedJob.employer)}.pdf`}
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Download PDF
|
||||
|
||||
@@ -31,6 +31,8 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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"
|
||||
import type { DisplayValues } from "@client/pages/settings/types"
|
||||
|
||||
type DisplaySettingsSectionProps = {
|
||||
values: DisplayValues
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { default: defaultShowSponsorInfo, effective: effectiveShowSponsorInfo } = values
|
||||
const { control } = useFormContext<UpdateSettingsInput>()
|
||||
|
||||
return (
|
||||
<AccordionItem value="display" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Display Settings</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<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
|
||||
htmlFor="showSponsorInfo"
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
Show visa sponsor information
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Display a badge next to the employer name showing the match
|
||||
percentage with the UK visa sponsor list. This helps identify
|
||||
employers that are licensed to sponsor work visas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Effective</div>
|
||||
<div className="break-words font-mono text-xs">
|
||||
{effectiveShowSponsorInfo ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
<div className="break-words font-mono text-xs font-semibold">
|
||||
{defaultShowSponsorInfo ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { useForm, FormProvider } from "react-hook-form"
|
||||
|
||||
import { Accordion } from "@/components/ui/accordion"
|
||||
import { EnvironmentSettingsSection } from "./EnvironmentSettingsSection"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
|
||||
const EnvironmentSettingsHarness = () => {
|
||||
const methods = useForm<UpdateSettingsInput>({
|
||||
defaultValues: {
|
||||
rxresumeEmail: "resume@example.com",
|
||||
ukvisajobsEmail: "visa@example.com",
|
||||
basicAuthUser: "admin",
|
||||
openrouterApiKey: "",
|
||||
rxresumePassword: "",
|
||||
ukvisajobsPassword: "",
|
||||
basicAuthPassword: "",
|
||||
webhookSecret: "",
|
||||
enableBasicAuth: true,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Accordion type="multiple" defaultValue={["environment"]}>
|
||||
<EnvironmentSettingsSection
|
||||
values={{
|
||||
readable: {
|
||||
rxresumeEmail: "resume@example.com",
|
||||
ukvisajobsEmail: "visa@example.com",
|
||||
basicAuthUser: "admin",
|
||||
},
|
||||
private: {
|
||||
openrouterApiKeyHint: "sk-1",
|
||||
rxresumePasswordHint: null,
|
||||
ukvisajobsPasswordHint: "pass",
|
||||
basicAuthPasswordHint: "abcd",
|
||||
webhookSecretHint: "sec-",
|
||||
},
|
||||
basicAuthActive: true,
|
||||
}}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe("EnvironmentSettingsSection", () => {
|
||||
it("renders values grouped logically and masks private secrets with hints", () => {
|
||||
render(<EnvironmentSettingsHarness />)
|
||||
|
||||
expect(screen.getByDisplayValue("resume@example.com")).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue("visa@example.com")).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText(/sk-1\*{8}/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/pass\*{8}/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/abcd\*{8}/)).toBeInTheDocument()
|
||||
expect(screen.getByText("Not set")).toBeInTheDocument()
|
||||
|
||||
// Basic Auth
|
||||
expect(screen.getByLabelText("Enable basic authentication")).toBeChecked()
|
||||
expect(screen.getByDisplayValue("admin")).toBeInTheDocument()
|
||||
|
||||
// Sections
|
||||
expect(screen.getByText("External Services")).toBeInTheDocument()
|
||||
expect(screen.getByText("Service Accounts")).toBeInTheDocument()
|
||||
expect(screen.getByText("Security")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
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"
|
||||
import type { EnvSettingsValues } from "@client/pages/settings/types"
|
||||
import { formatSecretHint } from "@client/pages/settings/utils"
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||
|
||||
type EnvironmentSettingsSectionProps = {
|
||||
values: EnvSettingsValues
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProps> = ({
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { register, control, watch, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
||||
const { private: privateValues } = values
|
||||
|
||||
const isBasicAuthEnabled = watch("enableBasicAuth")
|
||||
|
||||
return (
|
||||
<AccordionItem value="environment" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Environment & Accounts</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-8">
|
||||
{/* External Services */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">External Services</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SettingsInput
|
||||
label="OpenRouter API key"
|
||||
inputProps={register("openrouterApiKey")}
|
||||
type="password"
|
||||
placeholder="Enter new key"
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.openrouterApiKey?.message as string | undefined}
|
||||
current={formatSecretHint(privateValues.openrouterApiKeyHint)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Service Accounts */}
|
||||
<div className="space-y-6">
|
||||
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">Service Accounts</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-semibold">RxResume</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SettingsInput
|
||||
label="Email"
|
||||
inputProps={register("rxresumeEmail")}
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.rxresumeEmail?.message as string | undefined}
|
||||
/>
|
||||
<SettingsInput
|
||||
label="Password"
|
||||
inputProps={register("rxresumePassword")}
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.rxresumePassword?.message as string | undefined}
|
||||
current={formatSecretHint(privateValues.rxresumePasswordHint)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-semibold">UKVisaJobs</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SettingsInput
|
||||
label="Email"
|
||||
inputProps={register("ukvisajobsEmail")}
|
||||
placeholder="you@example.com"
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.ukvisajobsEmail?.message as string | undefined}
|
||||
/>
|
||||
<SettingsInput
|
||||
label="Password"
|
||||
inputProps={register("ukvisajobsPassword")}
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.ukvisajobsPassword?.message as string | undefined}
|
||||
current={formatSecretHint(privateValues.ukvisajobsPasswordHint)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Security */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">Security</div>
|
||||
<div className="flex items-start space-x-3">
|
||||
<Controller
|
||||
name="enableBasicAuth"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="enableBasicAuth"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="enableBasicAuth"
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
Enable basic authentication
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Require a username and password for write operations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isBasicAuthEnabled && (
|
||||
<div className="grid gap-4 md:grid-cols-2 pt-2">
|
||||
<SettingsInput
|
||||
label="Username"
|
||||
inputProps={register("basicAuthUser")}
|
||||
placeholder="username"
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.basicAuthUser?.message as string | undefined}
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
label="Password"
|
||||
inputProps={register("basicAuthPassword")}
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.basicAuthPassword?.message as string | undefined}
|
||||
current={formatSecretHint(privateValues.basicAuthPasswordHint)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,25 @@
|
||||
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"
|
||||
import type { NumericSettingValues } from "@client/pages/settings/types"
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||
|
||||
type GradcrackerSectionProps = {
|
||||
gradcrackerMaxJobsPerTermDraft: number | null
|
||||
setGradcrackerMaxJobsPerTermDraft: (value: number | null) => void
|
||||
defaultGradcrackerMaxJobsPerTerm: number
|
||||
effectiveGradcrackerMaxJobsPerTerm: number
|
||||
values: NumericSettingValues
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
|
||||
gradcrackerMaxJobsPerTermDraft,
|
||||
setGradcrackerMaxJobsPerTermDraft,
|
||||
defaultGradcrackerMaxJobsPerTerm,
|
||||
effectiveGradcrackerMaxJobsPerTerm,
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { effective: effectiveGradcrackerMaxJobsPerTerm, default: defaultGradcrackerMaxJobsPerTerm } = values
|
||||
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
||||
|
||||
return (
|
||||
<AccordionItem value="gradcracker" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
@@ -28,41 +27,35 @@ export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<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}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Maximum number of jobs to fetch for EACH search term from Gradcracker. Range: 1-1000.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Effective</div>
|
||||
<div className="break-words font-mono text-xs">{effectiveGradcrackerMaxJobsPerTerm}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
<div className="break-words font-mono text-xs font-semibold">{defaultGradcrackerMaxJobsPerTerm}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Controller
|
||||
name="gradcrackerMaxJobsPerTerm"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Max jobs per search term"
|
||||
type="number"
|
||||
inputProps={{
|
||||
...field,
|
||||
inputMode: "numeric",
|
||||
min: 1,
|
||||
max: 1000,
|
||||
value: field.value ?? defaultGradcrackerMaxJobsPerTerm,
|
||||
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}
|
||||
error={errors.gradcrackerMaxJobsPerTerm?.message as string | undefined}
|
||||
helper={`Maximum number of jobs to fetch for EACH search term from Gradcracker. Default: ${defaultGradcrackerMaxJobsPerTerm}. Range: 1-1000.`}
|
||||
current={String(effectiveGradcrackerMaxJobsPerTerm)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type JobCompleteWebhookSectionProps = {
|
||||
jobCompleteWebhookUrlDraft: string
|
||||
setJobCompleteWebhookUrlDraft: (value: string) => void
|
||||
defaultJobCompleteWebhookUrl: string
|
||||
effectiveJobCompleteWebhookUrl: string
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const JobCompleteWebhookSection: React.FC<JobCompleteWebhookSectionProps> = ({
|
||||
jobCompleteWebhookUrlDraft,
|
||||
setJobCompleteWebhookUrlDraft,
|
||||
defaultJobCompleteWebhookUrl,
|
||||
effectiveJobCompleteWebhookUrl,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="job-complete-webhook" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Job Complete Webhook</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<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)}
|
||||
placeholder={defaultJobCompleteWebhookUrl || "https://..."}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Effective</div>
|
||||
<div className="break-words font-mono text-xs">{effectiveJobCompleteWebhookUrl || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||
<div className="break-words font-mono text-xs">{defaultJobCompleteWebhookUrl || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +1,44 @@
|
||||
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
|
||||
values={{
|
||||
sites: { default: ["indeed", "linkedin"], effective: ["indeed", "linkedin"] },
|
||||
location: { default: "UK", effective: "UK" },
|
||||
resultsWanted: { default: 200, effective: 200 },
|
||||
hoursOld: { default: 72, effective: 72 },
|
||||
countryIndeed: { default: "UK", effective: "UK" },
|
||||
linkedinFetchDescription: { default: true, effective: true },
|
||||
}}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
describe("JobspySection", () => {
|
||||
it("toggles scraped sites and keeps checkboxes in sync", () => {
|
||||
render(<JobspyHarness />)
|
||||
@@ -72,8 +64,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,67 +1,34 @@
|
||||
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"
|
||||
import type { JobspyValues } from "@client/pages/settings/types"
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||
|
||||
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
|
||||
values: JobspyValues
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
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,
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const {
|
||||
sites,
|
||||
location,
|
||||
resultsWanted,
|
||||
hoursOld,
|
||||
countryIndeed,
|
||||
linkedinFetchDescription,
|
||||
} = values
|
||||
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,149 +40,160 @@ 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') ?? sites.default.includes('indeed')}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = field.value ?? sites.default
|
||||
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') ?? sites.default.includes('linkedin')}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = field.value ?? sites.default
|
||||
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>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {(effectiveJobspySites || []).join(', ') || "None"}</span>
|
||||
<span>Default: {(defaultJobspySites || []).join(', ')}</span>
|
||||
<span>Effective: {(sites.effective || []).join(', ') || "None"}</span>
|
||||
<span>Default: {(sites.default || []).join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Location</div>
|
||||
<Input
|
||||
value={jobspyLocationDraft ?? defaultJobspyLocation}
|
||||
onChange={(event) => setJobspyLocationDraft(event.target.value)}
|
||||
placeholder={defaultJobspyLocation || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Location to search for jobs (e.g. "UK", "London", "Remote").
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyLocation || "—"}</span>
|
||||
<span>Default: {defaultJobspyLocation || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsInput
|
||||
label="Location"
|
||||
inputProps={register("jobspyLocation")}
|
||||
placeholder={location.default || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.jobspyLocation?.message as string | undefined}
|
||||
helper={'Location to search for jobs (e.g. "UK", "London", "Remote").'}
|
||||
current={`Effective: ${location.effective || "—"} | Default: ${location.default || "—"}`}
|
||||
/>
|
||||
|
||||
<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}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Number of results to fetch per term per site. Max 500.
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyResultsWanted}</span>
|
||||
<span>Default: {defaultJobspyResultsWanted}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Controller
|
||||
name="jobspyResultsWanted"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Results Wanted"
|
||||
type="number"
|
||||
inputProps={{
|
||||
...field,
|
||||
inputMode: "numeric",
|
||||
min: 1,
|
||||
max: 1000,
|
||||
value: field.value ?? resultsWanted.default,
|
||||
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}
|
||||
error={errors.jobspyResultsWanted?.message as string | undefined}
|
||||
helper={`Number of results to fetch per term per site. Default: ${resultsWanted.default}. Max 1000.`}
|
||||
current={`Effective: ${resultsWanted.effective} | Default: ${resultsWanted.default}`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Max age of jobs in hours (e.g. 72 for 3 days).
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyHoursOld}h</span>
|
||||
<span>Default: {defaultJobspyHoursOld}h</span>
|
||||
</div>
|
||||
</div>
|
||||
<Controller
|
||||
name="jobspyHoursOld"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Hours Old"
|
||||
type="number"
|
||||
inputProps={{
|
||||
...field,
|
||||
inputMode: "numeric",
|
||||
min: 1,
|
||||
max: 720,
|
||||
value: field.value ?? hoursOld.default,
|
||||
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}
|
||||
error={errors.jobspyHoursOld?.message as string | undefined}
|
||||
helper={`Max age of jobs in hours (e.g. 72 for 3 days). Default: ${hoursOld.default}. Max 720.`}
|
||||
current={`Effective: ${hoursOld.effective}h | Default: ${hoursOld.default}h`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Indeed Country</div>
|
||||
<Input
|
||||
value={jobspyCountryIndeedDraft ?? defaultJobspyCountryIndeed}
|
||||
onChange={(event) => setJobspyCountryIndeedDraft(event.target.value)}
|
||||
placeholder={defaultJobspyCountryIndeed || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Country domain for Indeed (e.g. "UK" for indeed.co.uk).
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyCountryIndeed || "—"}</span>
|
||||
<span>Default: {defaultJobspyCountryIndeed || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsInput
|
||||
label="Indeed Country"
|
||||
inputProps={register("jobspyCountryIndeed")}
|
||||
placeholder={countryIndeed.default || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.jobspyCountryIndeed?.message as string | undefined}
|
||||
helper={'Country domain for Indeed (e.g. "UK" for indeed.co.uk).'}
|
||||
current={`Effective: ${countryIndeed.effective || "—"} | Default: ${countryIndeed.default || "—"}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 ?? linkedinFetchDescription.default}
|
||||
onCheckedChange={(checked) => field.onChange(!!checked)}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
@@ -228,8 +206,8 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
|
||||
If enabled, JobSpy will make extra requests to fetch full descriptions. Slower but better data.
|
||||
</p>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<span>Effective: {effectiveJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
|
||||
<span>Default: {defaultJobspyLinkedinFetchDescription ? "Yes" : "No"}</span>
|
||||
<span>Effective: {linkedinFetchDescription.effective ? "Yes" : "No"}</span>
|
||||
<span>Default: {linkedinFetchDescription.default ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,26 @@
|
||||
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"
|
||||
import type { ModelValues } from "@client/pages/settings/types"
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||
|
||||
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
|
||||
effectiveModelProjectSelection: string
|
||||
defaultModel: string
|
||||
values: ModelValues
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||
modelDraft,
|
||||
setModelDraft,
|
||||
modelScorerDraft,
|
||||
setModelScorerDraft,
|
||||
modelTailoringDraft,
|
||||
setModelTailoringDraft,
|
||||
modelProjectSelectionDraft,
|
||||
setModelProjectSelectionDraft,
|
||||
effectiveModel,
|
||||
effectiveModelScorer,
|
||||
effectiveModelTailoring,
|
||||
effectiveModelProjectSelection,
|
||||
defaultModel,
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { effective, default: defaultModel, scorer, tailoring, projectSelection } = values
|
||||
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
||||
|
||||
return (
|
||||
<AccordionItem value="model" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
@@ -46,18 +28,15 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Override model</div>
|
||||
<Input
|
||||
value={modelDraft}
|
||||
onChange={(event) => setModelDraft(event.target.value)}
|
||||
placeholder={defaultModel || "openai/gpt-4o-mini"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Leave blank to use the default from server env (`MODEL`).
|
||||
</div>
|
||||
</div>
|
||||
<SettingsInput
|
||||
label="Override model"
|
||||
inputProps={register("model")}
|
||||
placeholder={defaultModel || "google/gemini-3-flash-preview"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.model?.message as string | undefined}
|
||||
helper="Leave blank to use the default from server env (`MODEL`)."
|
||||
current={effective || "—"}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -65,44 +44,32 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||
<div className="text-sm font-medium">Task-Specific Overrides</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Scoring Model</div>
|
||||
<Input
|
||||
value={modelScorerDraft}
|
||||
onChange={(event) => setModelScorerDraft(event.target.value)}
|
||||
placeholder={effectiveModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Effective: <span className="font-mono">{effectiveModelScorer || effectiveModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsInput
|
||||
label="Scoring Model"
|
||||
inputProps={register("modelScorer")}
|
||||
placeholder={effective || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.modelScorer?.message as string | undefined}
|
||||
current={scorer || effective || "—"}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Tailoring Model</div>
|
||||
<Input
|
||||
value={modelTailoringDraft}
|
||||
onChange={(event) => setModelTailoringDraft(event.target.value)}
|
||||
placeholder={effectiveModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Effective: <span className="font-mono">{effectiveModelTailoring || effectiveModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsInput
|
||||
label="Tailoring Model"
|
||||
inputProps={register("modelTailoring")}
|
||||
placeholder={effective || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.modelTailoring?.message as string | undefined}
|
||||
current={tailoring || effective || "—"}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Project Selection Model</div>
|
||||
<Input
|
||||
value={modelProjectSelectionDraft}
|
||||
onChange={(event) => setModelProjectSelectionDraft(event.target.value)}
|
||||
placeholder={effectiveModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Effective: <span className="font-mono">{effectiveModelProjectSelection || effectiveModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsInput
|
||||
label="Project Selection Model"
|
||||
inputProps={register("modelProjectSelection")}
|
||||
placeholder={effective || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.modelProjectSelection?.message as string | undefined}
|
||||
current={projectSelection || effective || "—"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,7 +78,7 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Global Effective</div>
|
||||
<div className="break-words font-mono text-xs">{effectiveModel || "—"}</div>
|
||||
<div className="break-words font-mono text-xs">{effective || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
type PipelineWebhookSectionProps = {
|
||||
pipelineWebhookUrlDraft: string
|
||||
setPipelineWebhookUrlDraft: (value: string) => void
|
||||
defaultPipelineWebhookUrl: string
|
||||
effectivePipelineWebhookUrl: string
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const PipelineWebhookSection: React.FC<PipelineWebhookSectionProps> = ({
|
||||
pipelineWebhookUrlDraft,
|
||||
setPipelineWebhookUrlDraft,
|
||||
defaultPipelineWebhookUrl,
|
||||
effectivePipelineWebhookUrl,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
return (
|
||||
<AccordionItem value="pipeline-webhook" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Pipeline Webhook</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<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)}
|
||||
placeholder={defaultPipelineWebhookUrl || "https://..."}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
When set, the server sends a POST on pipeline completion/failure. Leave blank to disable.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Effective</div>
|
||||
<div className="break-words font-mono text-xs">{effectivePipelineWebhookUrl || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||
<div className="break-words font-mono text-xs">{defaultPipelineWebhookUrl || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</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}
|
||||
AI pool (max projects AI can use): {maxProjectsTotal}. Locked projects always count towards this cap. Locked: {lockedCount} · Total profile projects: {profileProjects.length}
|
||||
</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,25 +1,25 @@
|
||||
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"
|
||||
import type { SearchTermsValues } from "@client/pages/settings/types"
|
||||
|
||||
type SearchTermsSectionProps = {
|
||||
searchTermsDraft: string[] | null
|
||||
setSearchTermsDraft: (value: string[] | null) => void
|
||||
defaultSearchTerms: string[]
|
||||
effectiveSearchTerms: string[]
|
||||
values: SearchTermsValues
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
|
||||
searchTermsDraft,
|
||||
setSearchTermsDraft,
|
||||
defaultSearchTerms,
|
||||
effectiveSearchTerms,
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { default: defaultSearchTerms, effective: effectiveSearchTerms } = values
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react"
|
||||
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
type SettingsInputProps = {
|
||||
label: string
|
||||
inputProps: React.InputHTMLAttributes<HTMLInputElement>
|
||||
placeholder?: string
|
||||
type?: React.HTMLInputTypeAttribute
|
||||
disabled?: boolean
|
||||
error?: string
|
||||
helper?: string
|
||||
current?: string | null
|
||||
}
|
||||
|
||||
export const SettingsInput: React.FC<SettingsInputProps> = ({
|
||||
label,
|
||||
inputProps,
|
||||
placeholder,
|
||||
type = "text",
|
||||
disabled,
|
||||
error,
|
||||
helper,
|
||||
current,
|
||||
}) => {
|
||||
const id = inputProps.id || inputProps.name
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<Input {...inputProps} id={id} type={type} placeholder={placeholder} disabled={disabled} />
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
{helper && <div className="text-xs text-muted-foreground">{helper}</div>}
|
||||
{current !== undefined && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Current: <span className="font-mono">{current}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,25 @@
|
||||
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"
|
||||
import type { NumericSettingValues } from "@client/pages/settings/types"
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||
|
||||
type UkvisajobsSectionProps = {
|
||||
ukvisajobsMaxJobsDraft: number | null
|
||||
setUkvisajobsMaxJobsDraft: (value: number | null) => void
|
||||
defaultUkvisajobsMaxJobs: number
|
||||
effectiveUkvisajobsMaxJobs: number
|
||||
values: NumericSettingValues
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
|
||||
ukvisajobsMaxJobsDraft,
|
||||
setUkvisajobsMaxJobsDraft,
|
||||
defaultUkvisajobsMaxJobs,
|
||||
effectiveUkvisajobsMaxJobs,
|
||||
values,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { effective: effectiveUkvisajobsMaxJobs, default: defaultUkvisajobsMaxJobs } = values
|
||||
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
||||
|
||||
return (
|
||||
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
@@ -28,41 +27,35 @@ export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<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}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-1000.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Effective</div>
|
||||
<div className="break-words font-mono text-xs">{effectiveUkvisajobsMaxJobs}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
<div className="break-words font-mono text-xs font-semibold">{defaultUkvisajobsMaxJobs}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Controller
|
||||
name="ukvisajobsMaxJobs"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Max jobs to fetch"
|
||||
type="number"
|
||||
inputProps={{
|
||||
...field,
|
||||
inputMode: "numeric",
|
||||
min: 1,
|
||||
max: 1000,
|
||||
value: field.value ?? defaultUkvisajobsMaxJobs,
|
||||
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}
|
||||
error={errors.ukvisajobsMaxJobs?.message as string | undefined}
|
||||
helper={`Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Default: ${defaultUkvisajobsMaxJobs}. Range: 1-1000.`}
|
||||
current={String(effectiveUkvisajobsMaxJobs)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { useForm, FormProvider } from "react-hook-form"
|
||||
|
||||
import { Accordion } from "@/components/ui/accordion"
|
||||
import { WebhooksSection } from "./WebhooksSection"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
|
||||
const WebhooksHarness = () => {
|
||||
const methods = useForm<UpdateSettingsInput>({
|
||||
defaultValues: {
|
||||
pipelineWebhookUrl: "https://pipeline.com",
|
||||
jobCompleteWebhookUrl: "https://job.com",
|
||||
webhookSecret: "",
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Accordion type="multiple" defaultValue={["webhooks"]}>
|
||||
<WebhooksSection
|
||||
pipelineWebhook={{
|
||||
default: "https://default-p.com",
|
||||
effective: "https://pipeline.com",
|
||||
}}
|
||||
jobCompleteWebhook={{
|
||||
default: "https://default-j.com",
|
||||
effective: "https://job.com",
|
||||
}}
|
||||
webhookSecretHint="sec-"
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe("WebhooksSection", () => {
|
||||
it("renders both webhook sections and the secret", () => {
|
||||
render(<WebhooksHarness />)
|
||||
|
||||
expect(screen.getByText("Pipeline Status")).toBeInTheDocument()
|
||||
expect(screen.getByText("Job Completion")).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByDisplayValue("https://pipeline.com")).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue("https://job.com")).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText("sec-********")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
import type { WebhookValues } from "@client/pages/settings/types"
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||
import { formatSecretHint } from "@client/pages/settings/utils"
|
||||
|
||||
type WebhooksSectionProps = {
|
||||
pipelineWebhook: WebhookValues
|
||||
jobCompleteWebhook: WebhookValues
|
||||
webhookSecretHint: string | null
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const WebhooksSection: React.FC<WebhooksSectionProps> = ({
|
||||
pipelineWebhook,
|
||||
jobCompleteWebhook,
|
||||
webhookSecretHint,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
||||
|
||||
return (
|
||||
<AccordionItem value="webhooks" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Webhooks</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">Pipeline Status</div>
|
||||
<SettingsInput
|
||||
label="Webhook URL"
|
||||
inputProps={register("pipelineWebhookUrl")}
|
||||
placeholder={pipelineWebhook.default || "https://..."}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.pipelineWebhookUrl?.message as string | undefined}
|
||||
helper={`When set, the server sends a POST on pipeline completion/failure. Default: ${pipelineWebhook.default || "—"}.`}
|
||||
current={pipelineWebhook.effective || "—"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">Job Completion</div>
|
||||
<div className="space-y-4">
|
||||
<SettingsInput
|
||||
label="Webhook URL"
|
||||
inputProps={register("jobCompleteWebhookUrl")}
|
||||
placeholder={jobCompleteWebhook.default || "https://..."}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.jobCompleteWebhookUrl?.message as string | undefined}
|
||||
helper={`When set, the server sends a POST when you mark a job as applied (includes the job description). Default: ${jobCompleteWebhook.default || "—"}.`}
|
||||
current={jobCompleteWebhook.effective || "—"}
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
label="Webhook Secret"
|
||||
inputProps={register("webhookSecret")}
|
||||
type="password"
|
||||
placeholder="Enter new secret"
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.webhookSecret?.message as string | undefined}
|
||||
helper="Secret sent to webhook (Bearer token)"
|
||||
current={formatSecretHint(webhookSecretHint)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export type EffectiveDefault<T> = {
|
||||
effective: T
|
||||
default: T
|
||||
}
|
||||
|
||||
export type ModelValues = EffectiveDefault<string> & {
|
||||
scorer: string
|
||||
tailoring: string
|
||||
projectSelection: string
|
||||
}
|
||||
|
||||
export type WebhookValues = EffectiveDefault<string>
|
||||
export type NumericSettingValues = EffectiveDefault<number>
|
||||
export type SearchTermsValues = EffectiveDefault<string[]>
|
||||
export type DisplayValues = EffectiveDefault<boolean>
|
||||
|
||||
export type JobspyValues = {
|
||||
sites: EffectiveDefault<string[]>
|
||||
location: EffectiveDefault<string>
|
||||
resultsWanted: EffectiveDefault<number>
|
||||
hoursOld: EffectiveDefault<number>
|
||||
countryIndeed: EffectiveDefault<string>
|
||||
linkedinFetchDescription: EffectiveDefault<boolean>
|
||||
}
|
||||
|
||||
export type EnvSettingsValues = {
|
||||
readable: {
|
||||
rxresumeEmail: string
|
||||
ukvisajobsEmail: string
|
||||
basicAuthUser: string
|
||||
}
|
||||
private: {
|
||||
openrouterApiKeyHint: string | null
|
||||
rxresumePasswordHint: string | null
|
||||
ukvisajobsPasswordHint: string | null
|
||||
basicAuthPasswordHint: string | null
|
||||
webhookSecretHint: string | null
|
||||
}
|
||||
basicAuthActive: boolean
|
||||
}
|
||||
@@ -12,3 +12,5 @@ export function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjects
|
||||
arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds)
|
||||
)
|
||||
}
|
||||
|
||||
export const formatSecretHint = (hint: string | null) => (hint ? `${hint}********` : "Not set")
|
||||
|
||||
Reference in New Issue
Block a user