gem3 flash lint fix
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
Reference in New Issue
Block a user