gem3 flash lint fix

This commit is contained in:
DaKheera47
2026-01-25 13:14:59 +00:00
parent 76957e6f92
commit d4e83c0674
74 changed files with 691 additions and 516 deletions
@@ -287,11 +287,15 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
{step === "paste" && (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<label
htmlFor="fetch-url"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Job URL (optional)
</label>
<div className="flex gap-2">
<Input
id="fetch-url"
value={fetchUrl}
onChange={(event) => setFetchUrl(event.target.value)}
placeholder="https://example.com/job-posting"
@@ -338,10 +342,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
</div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<label
htmlFor="raw-description"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Job description
</label>
<Textarea
id="raw-description"
value={rawDescription}
onChange={(event) => setRawDescription(event.target.value)}
placeholder="Paste the full job description here, or enter a URL above to fetch it..."
@@ -408,10 +416,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-title"
className="text-xs font-medium text-muted-foreground"
>
Title *
</label>
<Input
id="draft-title"
value={draft.title}
onChange={(event) =>
setDraft((prev) => ({
@@ -423,10 +435,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-employer"
className="text-xs font-medium text-muted-foreground"
>
Employer *
</label>
<Input
id="draft-employer"
value={draft.employer}
onChange={(event) =>
setDraft((prev) => ({
@@ -438,10 +454,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-location"
className="text-xs font-medium text-muted-foreground"
>
Location
</label>
<Input
id="draft-location"
value={draft.location}
onChange={(event) =>
setDraft((prev) => ({
@@ -453,10 +473,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-salary"
className="text-xs font-medium text-muted-foreground"
>
Salary
</label>
<Input
id="draft-salary"
value={draft.salary}
onChange={(event) =>
setDraft((prev) => ({
@@ -468,10 +492,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-deadline"
className="text-xs font-medium text-muted-foreground"
>
Deadline
</label>
<Input
id="draft-deadline"
value={draft.deadline}
onChange={(event) =>
setDraft((prev) => ({
@@ -483,10 +511,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-jobType"
className="text-xs font-medium text-muted-foreground"
>
Job type
</label>
<Input
id="draft-jobType"
value={draft.jobType}
onChange={(event) =>
setDraft((prev) => ({
@@ -498,10 +530,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-jobLevel"
className="text-xs font-medium text-muted-foreground"
>
Job level
</label>
<Input
id="draft-jobLevel"
value={draft.jobLevel}
onChange={(event) =>
setDraft((prev) => ({
@@ -513,10 +549,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-jobFunction"
className="text-xs font-medium text-muted-foreground"
>
Job function
</label>
<Input
id="draft-jobFunction"
value={draft.jobFunction}
onChange={(event) =>
setDraft((prev) => ({
@@ -528,10 +568,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-disciplines"
className="text-xs font-medium text-muted-foreground"
>
Disciplines
</label>
<Input
id="draft-disciplines"
value={draft.disciplines}
onChange={(event) =>
setDraft((prev) => ({
@@ -543,10 +587,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-degreeRequired"
className="text-xs font-medium text-muted-foreground"
>
Degree required
</label>
<Input
id="draft-degreeRequired"
value={draft.degreeRequired}
onChange={(event) =>
setDraft((prev) => ({
@@ -558,10 +606,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-starting"
className="text-xs font-medium text-muted-foreground"
>
Starting
</label>
<Input
id="draft-starting"
value={draft.starting}
onChange={(event) =>
setDraft((prev) => ({
@@ -573,10 +625,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-jobUrl"
className="text-xs font-medium text-muted-foreground"
>
Job URL
</label>
<Input
id="draft-jobUrl"
value={draft.jobUrl}
onChange={(event) =>
setDraft((prev) => ({
@@ -588,10 +644,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-applicationLink"
className="text-xs font-medium text-muted-foreground"
>
Application link
</label>
<Input
id="draft-applicationLink"
value={draft.applicationLink}
onChange={(event) =>
setDraft((prev) => ({
@@ -605,10 +665,14 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="draft-jobDescription"
className="text-xs font-medium text-muted-foreground"
>
Job description *
</label>
<Textarea
id="draft-jobDescription"
value={draft.jobDescription}
onChange={(event) =>
setDraft((prev) => ({
@@ -121,7 +121,6 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({
case "completed":
case "failed":
return 100;
case "idle":
default:
return 0;
}
@@ -34,7 +34,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
{children}
</button>
),
DropdownMenuSeparator: () => <div role="separator" />,
DropdownMenuSeparator: () => <hr />,
};
});
@@ -8,8 +8,6 @@
*/
import {
Briefcase,
Building2,
CheckCircle2,
ChevronUp,
Copy,
@@ -19,7 +17,6 @@ import {
FileText,
FolderKanban,
Loader2,
MoreHorizontal,
RefreshCcw,
Undo2,
XCircle,
@@ -87,7 +84,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
// Reset mode when job changes
useEffect(() => {
setMode("ready");
}, [job?.id]);
}, []);
// Compute derived values
const pdfHref = job
@@ -100,12 +97,26 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
return job?.selectedProjectIds?.split(",").filter(Boolean) ?? [];
}, [job?.selectedProjectIds]);
const selectedProjectNames = useMemo(() => {
if (!catalog.length || !selectedProjectIds.length) return [];
return selectedProjectIds
.map((id) => catalog.find((p) => p.id === id)?.name)
.filter(Boolean) as string[];
}, [catalog, selectedProjectIds]);
const handleUndoApplied = useCallback(
async (jobId: string) => {
try {
// Revert to ready status
await api.updateJob(jobId, { status: "ready" });
toast.success("Reverted to Ready");
if (recentlyApplied?.timeoutId) {
clearTimeout(recentlyApplied.timeoutId);
}
setRecentlyApplied(null);
await onJobUpdated();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to undo";
toast.error(message);
}
},
[onJobUpdated, recentlyApplied],
);
// Handle mark as applied with undo capability
const handleMarkApplied = useCallback(async () => {
@@ -146,28 +157,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
} finally {
setIsMarkingApplied(false);
}
}, [job, onJobMoved, onJobUpdated]);
const handleUndoApplied = useCallback(
async (jobId: string) => {
try {
// Revert to ready status
await api.updateJob(jobId, { status: "ready" });
toast.success("Reverted to Ready");
if (recentlyApplied?.timeoutId) {
clearTimeout(recentlyApplied.timeoutId);
}
setRecentlyApplied(null);
await onJobUpdated();
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to undo";
toast.error(message);
}
},
[onJobUpdated, recentlyApplied],
);
}, [job, onJobMoved, onJobUpdated, handleUndoApplied]);
const handleRegenerate = useCallback(async () => {
if (!job) return;
@@ -368,10 +358,12 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
</AccordionTrigger>
<AccordionContent className="pt-1 pl-11">
<ul className="list-disc text-xs text-muted-foreground space-y-1">
{selectedProjectNames.map((name, i) => (
<li key={i}>{name}</li>
))}
{selectedProjectNames.length === 0 && (
{selectedProjectIds.map((id) => {
const name = catalog.find((p) => p.id === id)?.name;
if (!name) return null;
return <li key={id}>{name}</li>;
})}
{selectedProjectIds.length === 0 && (
<li className="list-none italic">No projects selected</li>
)}
</ul>
@@ -10,7 +10,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
@@ -137,7 +136,7 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
}
toast.success("AI Summary & Projects generated");
await onUpdate();
} catch (error) {
} catch (_error) {
toast.error("AI summarization failed");
} finally {
setIsSummarizing(false);
@@ -156,7 +155,7 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
await api.generateJobPdf(job.id);
toast.success("Resume PDF generated");
await onUpdate();
} catch (error) {
} catch (_error) {
toast.error("PDF generation failed");
} finally {
setIsGeneratingPdf(false);
@@ -203,10 +202,11 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
<div className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
<div className="space-y-2">
<label className="text-sm font-medium">
<label htmlFor="tailor-jd" className="text-sm font-medium">
Job Description (Edit to help AI tailoring)
</label>
<textarea
id="tailor-jd"
className="w-full min-h-[120px] max-h-[250px] 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={jobDescription}
onChange={(e) => setJobDescription(e.target.value)}
@@ -217,8 +217,11 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
<Separator />
<div className="space-y-2">
<label className="text-sm font-medium">Tailored Summary</label>
<label htmlFor="tailor-summary" className="text-sm font-medium">
Tailored Summary
</label>
<textarea
id="tailor-summary"
className="w-full min-h-[120px] 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={summary}
onChange={(e) => setSummary(e.target.value)}
@@ -230,7 +233,7 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
<div className="space-y-3">
<div className="flex flex-wrap items-start gap-2 sm:items-center sm:justify-between">
<label className="text-sm font-medium">Selected Projects</label>
<span className="text-sm font-medium">Selected Projects</span>
{tooManyProjects && (
<span className="flex items-center gap-1 text-xs text-amber-600 font-medium">
<AlertTriangle className="h-3 w-3" />
@@ -34,7 +34,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
{children}
</button>
),
DropdownMenuSeparator: () => <div role="separator" />,
DropdownMenuSeparator: () => <hr />,
};
});
@@ -31,7 +31,7 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
setMode("decide");
setIsSkipping(false);
setIsFinalizing(false);
}, [job?.id]);
}, []);
const handleSkip = async () => {
if (!job) return;
@@ -25,9 +25,9 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
return (
<div className="space-y-2">
<div className="flex flex-wrap items-start gap-2 sm:items-center sm:justify-between">
<label className="text-xs font-medium text-muted-foreground">
<span className="text-xs font-medium text-muted-foreground">
Selected Projects
</label>
</span>
{tooManyProjects && (
<span className="flex items-center gap-1 text-[10px] text-amber-500 font-medium">
<AlertTriangle className="h-3 w-3" />
@@ -43,15 +43,16 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
</div>
) : (
catalog.map((project) => (
<div
<label
key={project.id}
htmlFor={`project-${project.id}`}
className={cn(
"flex items-start gap-2.5 rounded-lg border p-2.5 text-xs transition-colors cursor-pointer",
selectedIds.has(project.id)
? "border-primary/40 bg-primary/5"
: "border-border/40 bg-muted/5 hover:bg-muted/10",
disabled && "opacity-50 cursor-not-allowed",
)}
onClick={() => !disabled && onToggle(project.id)}
>
<Checkbox
id={`project-${project.id}`}
@@ -66,7 +67,7 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
{project.description}
</div>
</div>
</div>
</label>
))
)}
</div>
@@ -52,7 +52,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
setSelectedIds(new Set(saved));
setDraftStatus("saved");
}, [job.id, job.tailoredSummary, job.selectedProjectIds, job.jobDescription]);
}, [job.tailoredSummary, job.selectedProjectIds, job.jobDescription]);
const savedSummary = job.tailoredSummary || "";
const savedDescription = job.jobDescription || "";
@@ -248,10 +248,14 @@ export const TailorMode: React.FC<TailorModeProps> = ({
label={`${showDescription ? "Hide" : "Edit"} job description`}
>
<div className="space-y-1">
<label className="text-[10px] font-medium text-muted-foreground/70">
<label
htmlFor="tailor-jd-edit"
className="text-[10px] font-medium text-muted-foreground/70"
>
Edit to help AI tailoring
</label>
<textarea
id="tailor-jd-edit"
className="w-full min-h-[120px] max-h-[250px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={jobDescription}
onChange={(event) => setJobDescription(event.target.value)}
@@ -262,10 +266,14 @@ export const TailorMode: React.FC<TailorModeProps> = ({
</CollapsibleSection>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
<label
htmlFor="tailor-summary-edit"
className="text-xs font-medium text-muted-foreground"
>
Tailored Summary
</label>
<textarea
id="tailor-summary-edit"
className="w-full min-h-[100px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={summary}
onChange={(event) => setSummary(event.target.value)}
@@ -1,5 +1,4 @@
import { stripHtml } from "@/lib/utils";
import type { Job } from "../../../shared/types";
export const getPlainDescription = (jobDescription?: string | null) => {
if (!jobDescription) return "No description available.";
@@ -12,7 +12,7 @@ import {
} from "lucide-react";
import type React from "react";
import { useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+15 -5
View File
@@ -43,11 +43,15 @@ export function useProfile() {
.then((data) => {
profileCache = data;
profileError = null;
subscribers.forEach((sub) => sub(data, null));
subscribers.forEach((sub) => {
sub(data, null);
});
})
.catch((err) => {
profileError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach((sub) => sub(profileCache, profileError));
subscribers.forEach((sub) => {
sub(profileCache, profileError);
});
})
.finally(() => {
isFetching = false;
@@ -62,17 +66,23 @@ export function useProfile() {
const refreshProfile = async () => {
isFetching = true;
profileError = null;
subscribers.forEach((sub) => sub(profileCache, null));
subscribers.forEach((sub) => {
sub(profileCache, null);
});
try {
const data = await api.getProfile();
profileCache = data;
profileError = null;
subscribers.forEach((sub) => sub(data, 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));
subscribers.forEach((sub) => {
sub(profileCache, profileError);
});
throw profileError;
} finally {
isFetching = false;
@@ -15,7 +15,7 @@ describe("useSettings", () => {
it("fetches settings on mount if not already cached", async () => {
const mockSettings = { showSponsorInfo: false };
(api.getSettings as any).mockResolvedValue(mockSettings);
vi.mocked(api.getSettings).mockResolvedValue(mockSettings as any);
const { result } = renderHook(() => useSettings());
@@ -31,7 +31,7 @@ describe("useSettings", () => {
});
it("uses default values when settings are null", async () => {
(api.getSettings as any).mockResolvedValue(null);
vi.mocked(api.getSettings).mockResolvedValue(null as any);
const { result } = renderHook(() => useSettings());
@@ -45,8 +45,8 @@ describe("useSettings", () => {
const initialSettings = { showSponsorInfo: true };
const updatedSettings = { showSponsorInfo: false };
(api.getSettings as any).mockResolvedValueOnce(initialSettings);
(api.getSettings as any).mockResolvedValueOnce(updatedSettings);
vi.mocked(api.getSettings).mockResolvedValueOnce(initialSettings as any);
vi.mocked(api.getSettings).mockResolvedValueOnce(updatedSettings as any);
const { result } = renderHook(() => useSettings());
@@ -54,7 +54,7 @@ describe("useSettings", () => {
expect(result.current.settings).toEqual(initialSettings);
});
let refreshed;
let refreshed: any;
await waitFor(async () => {
refreshed = await result.current.refreshSettings();
});
@@ -66,7 +66,7 @@ describe("useSettings", () => {
it("handles errors when fetching settings", async () => {
const mockError = new Error("Failed to fetch");
(api.getSettings as any).mockRejectedValue(mockError);
vi.mocked(api.getSettings).mockRejectedValue(mockError);
const { result } = renderHook(() => useSettings());
+15 -5
View File
@@ -39,11 +39,15 @@ export function useSettings() {
.then((data) => {
settingsCache = data;
settingsError = null;
subscribers.forEach((sub) => sub(data, null));
subscribers.forEach((sub) => {
sub(data, null);
});
})
.catch((err) => {
settingsError = err instanceof Error ? err : new Error(String(err));
subscribers.forEach((sub) => sub(settingsCache, settingsError));
subscribers.forEach((sub) => {
sub(settingsCache, settingsError);
});
})
.finally(() => {
isFetching = false;
@@ -58,17 +62,23 @@ export function useSettings() {
const refreshSettings = async () => {
isFetching = true;
settingsError = null;
subscribers.forEach((sub) => sub(settingsCache, null));
subscribers.forEach((sub) => {
sub(settingsCache, null);
});
try {
const data = await api.getSettings();
settingsCache = data;
settingsError = null;
subscribers.forEach((sub) => sub(data, 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));
subscribers.forEach((sub) => {
sub(settingsCache, settingsError);
});
throw settingsError;
} finally {
isFetching = false;
+4 -1
View File
@@ -4,7 +4,10 @@ import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import "../index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Failed to find the root element");
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<BrowserRouter>
<App />
@@ -136,11 +136,16 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
}) => (
<div data-testid="filters">
<div data-testid="sources-with-jobs">{sourcesWithJobs.join(",")}</div>
<button onClick={() => onTabChange("discovered")}>To Discovered</button>
<button onClick={() => onSearchQueryChange("test search")}>
<button type="button" onClick={() => onTabChange("discovered")}>
To Discovered
</button>
<button type="button" onClick={() => onSearchQueryChange("test search")}>
Set Search
</button>
<button onClick={() => onSortChange({ key: "title", direction: "asc" })}>
<button
type="button"
onClick={() => onSortChange({ key: "title", direction: "asc" })}
>
Set Sort
</button>
</div>
@@ -58,55 +58,67 @@ export const OrchestratorPage: React.FC = () => {
// Sync searchQuery with URL
const searchQuery = searchParams.get("q") || "";
const setSearchQuery = (q: string) => {
setSearchParams(
(prev) => {
if (q) prev.set("q", q);
else prev.delete("q");
return prev;
},
{ replace: true },
);
};
const setSearchQuery = useCallback(
(q: string) => {
setSearchParams(
(prev) => {
if (q) prev.set("q", q);
else prev.delete("q");
return prev;
},
{ replace: true },
);
},
[setSearchParams],
);
// Sync sourceFilter with URL
const sourceFilter =
(searchParams.get("source") as JobSource | "all") || "all";
const setSourceFilter = (source: JobSource | "all") => {
setSearchParams(
(prev) => {
if (source !== "all") prev.set("source", source);
else prev.delete("source");
return prev;
},
{ replace: true },
);
};
const setSourceFilter = useCallback(
(source: JobSource | "all") => {
setSearchParams(
(prev) => {
if (source !== "all") prev.set("source", source);
else prev.delete("source");
return prev;
},
{ replace: true },
);
},
[setSearchParams],
);
// Sync sort with URL
const sort = useMemo((): JobSort => {
const s = searchParams.get("sort");
if (!s) return DEFAULT_SORT;
const [key, direction] = s.split("-");
return { key: key as any, direction: direction as any };
return {
key: key as JobSort["key"],
direction: direction as JobSort["direction"],
};
}, [searchParams]);
const setSort = (newSort: JobSort) => {
setSearchParams(
(prev) => {
if (
newSort.key === DEFAULT_SORT.key &&
newSort.direction === DEFAULT_SORT.direction
) {
prev.delete("sort");
} else {
prev.set("sort", `${newSort.key}-${newSort.direction}`);
}
return prev;
},
{ replace: true },
);
};
const setSort = useCallback(
(newSort: JobSort) => {
setSearchParams(
(prev) => {
if (
newSort.key === DEFAULT_SORT.key &&
newSort.direction === DEFAULT_SORT.direction
) {
prev.delete("sort");
} else {
prev.set("sort", `${newSort.key}-${newSort.direction}`);
}
return prev;
},
{ replace: true },
);
},
[setSearchParams],
);
// Effect to sync URL if it was invalid
useEffect(() => {
@@ -125,13 +137,19 @@ export const OrchestratorPage: React.FC = () => {
: false,
);
const setActiveTab = (newTab: FilterTab) => {
navigateWithContext(newTab, selectedJobId);
};
const setActiveTab = useCallback(
(newTab: FilterTab) => {
navigateWithContext(newTab, selectedJobId);
},
[navigateWithContext, selectedJobId],
);
const handleSelectJobId = (id: string | null) => {
navigateWithContext(activeTab, id);
};
const handleSelectJobId = useCallback(
(id: string | null) => {
navigateWithContext(activeTab, id);
},
[navigateWithContext, activeTab],
);
const { settings } = useSettings();
const {
@@ -229,7 +247,14 @@ export const OrchestratorPage: React.FC = () => {
navigateWithContext(activeTab, activeJobs[0].id, true);
}
}
}, [activeJobs, selectedJobId, isDesktop, activeTab, navigateWithContext]);
}, [
activeJobs,
selectedJobId,
isDesktop,
activeTab,
navigateWithContext,
handleSelectJobId,
]);
useEffect(() => {
if (!selectedJobId) {
@@ -414,7 +414,6 @@ export const SettingsPage: React.FC = () => {
envSettings,
defaultResumeProjects,
profileProjects,
maxProjectsTotal,
} = derived;
const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects;
@@ -22,7 +22,7 @@ import {
} from "lucide-react";
import type React from "react";
import { useEffect, useMemo, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -120,7 +120,7 @@ export const UkVisaJobsPage: React.FC = () => {
useEffect(() => {
setSelectedJobIds(new Set());
}, [results]);
}, []);
const selectedJob = useMemo(
() =>
@@ -431,10 +431,14 @@ export const UkVisaJobsPage: React.FC = () => {
onSubmit={handleSearch}
>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<label
htmlFor="uk-visa-search"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Job title search term
</label>
<Input
id="uk-visa-search"
value={searchTermInput}
onChange={(event) => setSearchTermInput(event.target.value)}
placeholder="e.g. data analyst"
@@ -569,27 +573,12 @@ export const UkVisaJobsPage: React.FC = () => {
return (
<div
key={key}
role="button"
tabIndex={0}
onClick={() => handleSelectJob(key)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleSelectJob(key);
}
}}
className={cn(
"flex w-full items-start gap-4 px-4 py-3 text-left transition-colors",
"flex w-full items-start gap-4 px-4 py-3 transition-colors",
isSelected ? "bg-muted/40" : "hover:bg-muted/30",
)}
aria-pressed={isSelected}
>
<div
className="mt-1"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
role="presentation"
>
<div className="mt-1 shrink-0">
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => {
@@ -606,60 +595,67 @@ export const UkVisaJobsPage: React.FC = () => {
aria-label={`Select ${job.title}`}
/>
</div>
<span className="mt-1 flex h-8 w-8 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
<Briefcase className="h-4 w-4 text-muted-foreground" />
</span>
<div className="min-w-0 flex-1 space-y-2">
<div className="space-y-1">
<div className="truncate text-sm font-semibold">
{job.title}
<button
type="button"
onClick={() => handleSelectJob(key)}
className="flex flex-1 items-start gap-4 text-left"
aria-pressed={isSelected}
>
<span className="mt-1 flex h-8 w-8 items-center justify-center rounded-lg border border-border/60 bg-muted/30 shrink-0">
<Briefcase className="h-4 w-4 text-muted-foreground" />
</span>
<div className="min-w-0 flex-1 space-y-2">
<div className="space-y-1">
<div className="truncate text-sm font-semibold">
{job.title}
</div>
<div className="text-xs text-muted-foreground">
{job.employer}
</div>
</div>
<div className="text-xs text-muted-foreground">
{job.employer}
{description}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{job.location && (
<span className="flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{job.location}
</span>
)}
{job.salary && (
<span className="flex items-center gap-1">
<DollarSign className="h-3.5 w-3.5" />
{job.salary}
</span>
)}
{job.deadline && (
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{formatDate(job.deadline)}
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
{job.jobType && (
<Badge
variant="outline"
className="text-[11px] uppercase tracking-wide"
>
{job.jobType}
</Badge>
)}
{job.jobLevel && (
<Badge
variant="outline"
className="text-[11px] uppercase tracking-wide"
>
{job.jobLevel}
</Badge>
)}
</div>
</div>
<div className="text-xs text-muted-foreground">
{description}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{job.location && (
<span className="flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{job.location}
</span>
)}
{job.salary && (
<span className="flex items-center gap-1">
<DollarSign className="h-3.5 w-3.5" />
{job.salary}
</span>
)}
{job.deadline && (
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{formatDate(job.deadline)}
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
{job.jobType && (
<Badge
variant="outline"
className="text-[11px] uppercase tracking-wide"
>
{job.jobType}
</Badge>
)}
{job.jobLevel && (
<Badge
variant="outline"
className="text-[11px] uppercase tracking-wide"
>
{job.jobLevel}
</Badge>
)}
</div>
</div>
</button>
</div>
);
})}
@@ -76,12 +76,24 @@ export const VisaSponsorsPage: React.FC = () => {
: false,
);
// Fetch status on mount
useEffect(() => {
fetchStatus();
// Fetch organization details
const fetchOrgDetails = useCallback(async (orgName: string) => {
setIsLoadingDetails(true);
setSelectedOrg(orgName);
try {
const details = await api.getVisaSponsorOrganization(orgName);
setOrgDetails(details);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to fetch details";
toast.error(message);
setOrgDetails([]);
} finally {
setIsLoadingDetails(false);
}
}, []);
const fetchStatus = async () => {
const fetchStatus = useCallback(async () => {
setIsLoadingStatus(true);
try {
const data = await api.getVisaSponsorStatus();
@@ -93,7 +105,12 @@ export const VisaSponsorsPage: React.FC = () => {
} finally {
setIsLoadingStatus(false);
}
};
}, []);
// Fetch status on mount
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
// Search with debounce
const handleSearch = useCallback(async (query: string) => {
@@ -143,7 +160,7 @@ export const VisaSponsorsPage: React.FC = () => {
setSelectedOrg(firstOrg);
fetchOrgDetails(firstOrg);
}
}, [results]);
}, [results, fetchOrgDetails, selectedOrg]);
useEffect(() => {
if (!selectedOrg) {
@@ -170,23 +187,6 @@ export const VisaSponsorsPage: React.FC = () => {
}
}, [isDesktop, isDetailDrawerOpen]);
// Fetch organization details
const fetchOrgDetails = async (orgName: string) => {
setIsLoadingDetails(true);
setSelectedOrg(orgName);
try {
const details = await api.getVisaSponsorOrganization(orgName);
setOrgDetails(details);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to fetch details";
toast.error(message);
setOrgDetails([]);
} finally {
setIsLoadingDetails(false);
}
};
// Trigger manual update
const handleUpdate = async () => {
setIsUpdating(true);
@@ -276,9 +276,9 @@ export const VisaSponsorsPage: React.FC = () => {
Licensed Routes ({orgDetails.length})
</div>
<div className="space-y-2">
{orgDetails.map((entry, index) => (
{orgDetails.map((entry) => (
<div
key={index}
key={`${entry.route}-${entry.typeRating}`}
className="rounded-lg border border-border/60 bg-muted/20 p-3"
>
<div className="flex items-start justify-between gap-2 mb-1">
@@ -355,12 +355,16 @@ export const VisaSponsorsPage: React.FC = () => {
{/* Search section */}
<section className="rounded-xl border border-border/60 bg-card/40 p-4">
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<label
htmlFor="sponsor-search"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Company name
</label>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="sponsor-search"
placeholder="Search for a company name..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
@@ -369,6 +373,7 @@ export const VisaSponsorsPage: React.FC = () => {
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
@@ -33,7 +33,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
{children}
</button>
),
DropdownMenuSeparator: () => <div role="separator" />,
DropdownMenuSeparator: () => <hr />,
};
});
@@ -51,7 +51,6 @@ interface JobDetailPanelProps {
selectedJob: Job | null;
onSelectJobId: (jobId: string | null) => void;
onJobUpdated: () => Promise<void>;
onSetActiveTab: (tab: FilterTab) => void;
}
export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
@@ -60,7 +59,6 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
selectedJob,
onSelectJobId,
onJobUpdated,
onSetActiveTab,
}) => {
const [detailTab, setDetailTab] = useState<
"overview" | "tailoring" | "description"
@@ -77,7 +75,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
useEffect(() => {
setHasUnsavedTailoring(false);
saveTailoringRef.current = null;
}, [selectedJob?.id]);
}, []);
const description = useMemo(() => {
if (!selectedJob?.jobDescription) return "No description available.";
@@ -94,7 +92,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
}
setIsEditingDescription(false);
setEditedDescription(selectedJob.jobDescription || "");
}, [selectedJob?.id]);
}, [selectedJob?.id, selectedJob]);
useEffect(() => {
if (!selectedJob) return;
@@ -97,9 +97,9 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
<span
className={cn(
"text-xs tabular-nums",
job.suitabilityScore! >= 70
(job.suitabilityScore ?? 0) >= 70
? "text-emerald-400/90"
: job.suitabilityScore! >= 50
: (job.suitabilityScore ?? 0) >= 50
? "text-foreground/60"
: "text-muted-foreground/60",
)}
@@ -1,6 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import type { FilterTab, JobSort } from "./constants";
import { OrchestratorFilters } from "./OrchestratorFilters";
@@ -41,7 +40,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuSeparator: () => <div role="separator" />,
DropdownMenuSeparator: () => <hr />,
DropdownMenuRadioGroup: ({
children,
onValueChange,
@@ -56,15 +55,18 @@ vi.mock("@/components/ui/dropdown-menu", () => {
DropdownMenuRadioItem: ({
children,
value,
checked,
}: {
children: React.ReactNode;
value: string;
checked?: boolean;
}) => {
const onValueChange = React.useContext(RadioGroupContext);
return (
<button
type="button"
role="menuitemradio"
aria-checked={checked}
onClick={() => onValueChange?.(value)}
>
{children}
@@ -21,7 +21,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuSeparator: () => <div role="separator" />,
DropdownMenuSeparator: () => <hr />,
DropdownMenuItem: ({
children,
onSelect,
@@ -42,14 +42,17 @@ vi.mock("@/components/ui/dropdown-menu", () => {
DropdownMenuCheckboxItem: ({
children,
onCheckedChange,
checked,
}: {
children: React.ReactNode;
onCheckedChange?: (checked: boolean) => void;
checked?: boolean;
}) => (
<button
type="button"
role="menuitemcheckbox"
onClick={() => onCheckedChange?.(true)}
aria-checked={checked}
onClick={() => onCheckedChange?.(!checked)}
>
{children}
</button>
@@ -1,15 +1,7 @@
import * as api from "@client/api";
import { RefreshCw } from "lucide-react";
import type React from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useCallback, useEffect, useState } from "react";
type BaseResumeSelectionProps = {
value: string | null;
@@ -30,7 +22,7 @@ export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({
const [isFetchingResumes, setIsFetchingResumes] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const fetchResumes = async () => {
const fetchResumes = useCallback(async () => {
if (!hasRxResumeAccess) return;
setIsFetchingResumes(true);
@@ -50,13 +42,13 @@ export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({
} finally {
setIsFetchingResumes(false);
}
};
}, [hasRxResumeAccess, onValueChange, value]);
useEffect(() => {
if (hasRxResumeAccess) {
fetchResumes();
}
}, [hasRxResumeAccess]);
}, [hasRxResumeAccess, fetchResumes]);
return (
<div className="space-y-2">