Cmdk based command bar (#110)
* initial commit * use colours! * match by scoring * scroll job card into view * introduce @ based 'locks' to restrict search to specific statuses * clear lock states on close * split up component * inline pill * resuse job row content * fix intro anim * larger size, instruction * refactor existing search feature * lock colour border * if active, clear active on escape * remove query param * documenration update * scoring logic * check exists before scroll * status dot and checkbox occupy the same space!
This commit is contained in:
parent
a24522437c
commit
4cca521cd1
@ -94,6 +94,7 @@ POST /api/jobs/:id/generate-pdf
|
||||
- `processing` is transient. If PDF generation fails, the job is reverted back to `discovered`.
|
||||
- The PDF is served at `/pdfs/resume_<jobId>.pdf` and cache-busted with the job?s `updatedAt` timestamp.
|
||||
- If a job is `skipped` or `applied` and you want to re-open it, you can PATCH its `status` back to `discovered`.
|
||||
- Job text search is handled through the command bar (`Cmd/Ctrl+K`) and is not persisted as a URL filter.
|
||||
|
||||
## External payload and sanitization defaults
|
||||
|
||||
|
||||
@ -89,6 +89,7 @@ orchestrator/
|
||||
2. **You review in the UI:**
|
||||
- See jobs at `http://localhost:5173`
|
||||
- "Ready" tab shows jobs with generated PDFs
|
||||
- Use command bar search (`Cmd/Ctrl+K`) to quickly find and open jobs
|
||||
- Click "View Job" to open application
|
||||
- Download PDF and apply manually
|
||||
- Click "Mark Applied" → syncs to Notion
|
||||
|
||||
39
orchestrator/src/client/lib/meta-key.test.ts
Normal file
39
orchestrator/src/client/lib/meta-key.test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getMetaKeyLabel,
|
||||
getMetaShortcutLabel,
|
||||
isMetaKeyPressed,
|
||||
} from "./meta-key";
|
||||
|
||||
describe("meta-key helper", () => {
|
||||
it("returns command symbol for apple platforms", () => {
|
||||
expect(
|
||||
getMetaKeyLabel({
|
||||
platform: "MacIntel",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)",
|
||||
}),
|
||||
).toBe("⌘");
|
||||
});
|
||||
|
||||
it("returns ctrl label for non-apple platforms", () => {
|
||||
expect(
|
||||
getMetaKeyLabel({
|
||||
platform: "Win32",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
}),
|
||||
).toBe("Ctrl");
|
||||
});
|
||||
|
||||
it("formats shortcut labels by platform", () => {
|
||||
expect(getMetaShortcutLabel("k", { platform: "MacIntel" })).toBe("⌘K");
|
||||
expect(getMetaShortcutLabel("k", { platform: "Linux x86_64" })).toBe(
|
||||
"Ctrl+K",
|
||||
);
|
||||
});
|
||||
|
||||
it("detects pressed meta/ctrl modifier", () => {
|
||||
expect(isMetaKeyPressed({ metaKey: true, ctrlKey: false })).toBe(true);
|
||||
expect(isMetaKeyPressed({ metaKey: false, ctrlKey: true })).toBe(true);
|
||||
expect(isMetaKeyPressed({ metaKey: false, ctrlKey: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
34
orchestrator/src/client/lib/meta-key.ts
Normal file
34
orchestrator/src/client/lib/meta-key.ts
Normal file
@ -0,0 +1,34 @@
|
||||
interface NavigatorLike {
|
||||
platform?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
const isApplePlatform = (value: string) =>
|
||||
/(mac|iphone|ipad|ipod)/i.test(value);
|
||||
|
||||
const getRuntimeNavigator = (): NavigatorLike | null => {
|
||||
if (typeof navigator === "undefined") return null;
|
||||
return navigator;
|
||||
};
|
||||
|
||||
export const getMetaKeyLabel = (
|
||||
nav: NavigatorLike | null = getRuntimeNavigator(),
|
||||
) => {
|
||||
const platform = nav?.platform ?? "";
|
||||
const userAgent = nav?.userAgent ?? "";
|
||||
return isApplePlatform(`${platform} ${userAgent}`) ? "⌘" : "Ctrl";
|
||||
};
|
||||
|
||||
export const getMetaShortcutLabel = (
|
||||
key: string,
|
||||
nav: NavigatorLike | null = getRuntimeNavigator(),
|
||||
) => {
|
||||
const normalizedKey = key.trim().toUpperCase();
|
||||
const meta = getMetaKeyLabel(nav);
|
||||
return meta === "⌘" ? `${meta}${normalizedKey}` : `${meta}+${normalizedKey}`;
|
||||
};
|
||||
|
||||
export const isMetaKeyPressed = (event: {
|
||||
metaKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
}) => event.metaKey || event.ctrlKey;
|
||||
@ -164,10 +164,32 @@ vi.mock("./orchestrator/OrchestratorSummary", () => ({
|
||||
OrchestratorSummary: () => <div data-testid="summary" />,
|
||||
}));
|
||||
|
||||
vi.mock("./orchestrator/JobCommandBar", () => ({
|
||||
JobCommandBar: ({
|
||||
onSelectJob,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
onSelectJob: (tab: FilterTab, id: string) => void;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="command-open">{open ? "open" : "closed"}</div>
|
||||
<button type="button" onClick={() => onSelectJob("discovered", "job-2")}>
|
||||
Command Select Job
|
||||
</button>
|
||||
<button type="button" onClick={() => onOpenChange?.(false)}>
|
||||
Close Command Bar
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./orchestrator/OrchestratorFilters", () => ({
|
||||
OrchestratorFilters: ({
|
||||
onTabChange,
|
||||
onSearchQueryChange,
|
||||
onOpenCommandBar,
|
||||
onSourceFilterChange,
|
||||
onSponsorFilterChange,
|
||||
onSalaryFilterChange,
|
||||
@ -177,7 +199,7 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
|
||||
filteredCount,
|
||||
}: {
|
||||
onTabChange: (t: FilterTab) => void;
|
||||
onSearchQueryChange: (q: string) => void;
|
||||
onOpenCommandBar: () => void;
|
||||
onSourceFilterChange: (source: string) => void;
|
||||
onSponsorFilterChange: (value: string) => void;
|
||||
onSalaryFilterChange: (value: {
|
||||
@ -196,8 +218,8 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
|
||||
<button type="button" onClick={() => onTabChange("discovered")}>
|
||||
To Discovered
|
||||
</button>
|
||||
<button type="button" onClick={() => onSearchQueryChange("test search")}>
|
||||
Set Search
|
||||
<button type="button" onClick={onOpenCommandBar}>
|
||||
Open Command Bar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -247,6 +269,9 @@ vi.mock("./orchestrator/JobListPanel", () => ({
|
||||
selectedJobId: string | null;
|
||||
}) => (
|
||||
<div>
|
||||
<div data-job-id="job-1" />
|
||||
<div data-job-id="job-2" />
|
||||
<div data-job-id="job-3" />
|
||||
<div data-testid="selected-job">{selectedJobId ?? "none"}</div>
|
||||
<button
|
||||
data-testid="toggle-select-all-on"
|
||||
@ -418,13 +443,45 @@ describe("OrchestratorPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("syncs search query to URL as a parameter", () => {
|
||||
it("opens the command bar when the filters search button is clicked", () => {
|
||||
window.matchMedia = createMatchMedia(
|
||||
true,
|
||||
) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready"]}>
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("command-open")).toHaveTextContent("closed");
|
||||
fireEvent.click(screen.getByText("Open Command Bar"));
|
||||
expect(screen.getByTestId("command-open")).toHaveTextContent("open");
|
||||
fireEvent.click(screen.getByText("Close Command Bar"));
|
||||
expect(screen.getByTestId("command-open")).toHaveTextContent("closed");
|
||||
});
|
||||
|
||||
it("navigates from command search across states and clears active filters", async () => {
|
||||
window.matchMedia = createMatchMedia(
|
||||
true,
|
||||
) as unknown as typeof window.matchMedia;
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
const scrollIntoViewMock = vi.fn();
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: scrollIntoViewMock,
|
||||
});
|
||||
|
||||
try {
|
||||
render(
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
"/ready?source=linkedin&sponsor=confirmed&salaryMode=between&salaryMin=60000&salaryMax=90000&q=backend&sort=title-asc",
|
||||
]}
|
||||
>
|
||||
<LocationWatcher />
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
@ -433,10 +490,48 @@ describe("OrchestratorPage", () => {
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Set Search"));
|
||||
expect(screen.getByTestId("location").textContent).toContain(
|
||||
"q=test+search",
|
||||
fireEvent.click(screen.getByText("Command Select Job"));
|
||||
|
||||
await waitFor(() => {
|
||||
const locationText = screen.getByTestId("location").textContent || "";
|
||||
expect(locationText).toContain("/discovered/job-2");
|
||||
expect(locationText).toContain("sort=title-asc");
|
||||
expect(locationText).not.toContain("source=");
|
||||
expect(locationText).not.toContain("sponsor=");
|
||||
expect(locationText).not.toContain("salaryMode=");
|
||||
expect(locationText).not.toContain("salaryMin=");
|
||||
expect(locationText).not.toContain("salaryMax=");
|
||||
expect(locationText).not.toContain("q=");
|
||||
});
|
||||
expect(scrollIntoViewMock).toHaveBeenCalled();
|
||||
} finally {
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: originalScrollIntoView,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("removes legacy q query params on load", async () => {
|
||||
window.matchMedia = createMatchMedia(
|
||||
true,
|
||||
) as unknown as typeof window.matchMedia;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/ready?q=backend&sort=title-asc"]}>
|
||||
<LocationWatcher />
|
||||
<Routes>
|
||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const locationText = screen.getByTestId("location").textContent || "";
|
||||
expect(locationText).toContain("sort=title-asc");
|
||||
expect(locationText).not.toContain("q=");
|
||||
});
|
||||
});
|
||||
|
||||
it("syncs sorting to URL and removes it when default", () => {
|
||||
|
||||
@ -19,6 +19,7 @@ import type { AutomaticRunValues } from "./orchestrator/automatic-run";
|
||||
import { deriveExtractorLimits } from "./orchestrator/automatic-run";
|
||||
import type { FilterTab } from "./orchestrator/constants";
|
||||
import { FloatingBulkActionsBar } from "./orchestrator/FloatingBulkActionsBar";
|
||||
import { JobCommandBar } from "./orchestrator/JobCommandBar";
|
||||
import { JobDetailPanel } from "./orchestrator/JobDetailPanel";
|
||||
import { JobListPanel } from "./orchestrator/JobListPanel";
|
||||
import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters";
|
||||
@ -37,13 +38,14 @@ import {
|
||||
getSourcesWithJobs,
|
||||
} from "./orchestrator/utils";
|
||||
|
||||
const escapeCssAttributeValue = (value: string) =>
|
||||
value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
||||
|
||||
export const OrchestratorPage: React.FC = () => {
|
||||
const { tab, jobId } = useParams<{ tab: string; jobId?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
searchParams,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
sourceFilter,
|
||||
setSourceFilter,
|
||||
sponsorFilter,
|
||||
@ -89,7 +91,11 @@ export const OrchestratorPage: React.FC = () => {
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [isRunModeModalOpen, setIsRunModeModalOpen] = useState(false);
|
||||
const [runMode, setRunMode] = useState<RunMode>("automatic");
|
||||
const [isCommandBarOpen, setIsCommandBarOpen] = useState(false);
|
||||
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
|
||||
const [pendingCommandScrollJobId, setPendingCommandScrollJobId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [isDesktop, setIsDesktop] = useState(() =>
|
||||
typeof window !== "undefined"
|
||||
@ -134,7 +140,6 @@ export const OrchestratorPage: React.FC = () => {
|
||||
sourceFilter,
|
||||
sponsorFilter,
|
||||
salaryFilter,
|
||||
searchQuery,
|
||||
sort,
|
||||
);
|
||||
const counts = useMemo(() => getJobCounts(jobs), [jobs]);
|
||||
@ -287,6 +292,51 @@ export const OrchestratorPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommandSelectJob = useCallback(
|
||||
(targetTab: FilterTab, id: string) => {
|
||||
setPendingCommandScrollJobId(id);
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
for (const key of [
|
||||
"source",
|
||||
"sponsor",
|
||||
"salaryMode",
|
||||
"salaryMin",
|
||||
"salaryMax",
|
||||
"minSalary",
|
||||
]) {
|
||||
nextParams.delete(key);
|
||||
}
|
||||
const query = nextParams.toString();
|
||||
navigate(`/${targetTab}/${id}${query ? `?${query}` : ""}`);
|
||||
if (!isDesktop) {
|
||||
setIsDetailDrawerOpen(true);
|
||||
}
|
||||
},
|
||||
[isDesktop, navigate, searchParams],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingCommandScrollJobId) return;
|
||||
if (selectedJobId !== pendingCommandScrollJobId) return;
|
||||
const hasPendingTargetInList = activeJobs.some(
|
||||
(job) => job.id === pendingCommandScrollJobId,
|
||||
);
|
||||
if (!hasPendingTargetInList) return;
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const selector = `[data-job-id="${escapeCssAttributeValue(
|
||||
pendingCommandScrollJobId,
|
||||
)}"]`;
|
||||
const target = document.querySelector<HTMLElement>(selector);
|
||||
if (!target) return;
|
||||
|
||||
target.scrollIntoView({
|
||||
behavior: isDesktop ? "smooth" : "auto",
|
||||
block: "center",
|
||||
});
|
||||
setPendingCommandScrollJobId(null);
|
||||
}, [activeJobs, isDesktop, pendingCommandScrollJobId, selectedJobId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeJobs.length === 0) {
|
||||
if (selectedJobId) handleSelectJobId(null);
|
||||
@ -366,12 +416,17 @@ export const OrchestratorPage: React.FC = () => {
|
||||
|
||||
{/* Main content: tabs/filters -> list/detail */}
|
||||
<section className="space-y-4">
|
||||
<JobCommandBar
|
||||
jobs={jobs}
|
||||
onSelectJob={handleCommandSelectJob}
|
||||
open={isCommandBarOpen}
|
||||
onOpenChange={setIsCommandBarOpen}
|
||||
/>
|
||||
<OrchestratorFilters
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
counts={counts}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
onOpenCommandBar={() => setIsCommandBarOpen(true)}
|
||||
sourceFilter={sourceFilter}
|
||||
onSourceFilterChange={setSourceFilter}
|
||||
sponsorFilter={sponsorFilter}
|
||||
@ -395,7 +450,6 @@ export const OrchestratorPage: React.FC = () => {
|
||||
selectedJobId={selectedJobId}
|
||||
selectedJobIds={selectedJobIds}
|
||||
activeTab={activeTab}
|
||||
searchQuery={searchQuery}
|
||||
onSelectJob={handleSelectJob}
|
||||
onToggleSelectJob={toggleSelectJob}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
|
||||
@ -0,0 +1,498 @@
|
||||
import type { Job } from "@shared/types.js";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { JobCommandBar } from "./JobCommandBar";
|
||||
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: originalScrollIntoView,
|
||||
});
|
||||
});
|
||||
|
||||
const createJob = (overrides: Partial<Job>): Job => ({
|
||||
id: "job-1",
|
||||
source: "linkedin",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme Labs",
|
||||
employerUrl: null,
|
||||
jobUrl: "https://example.com/job-1",
|
||||
applicationLink: null,
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: "California",
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: null,
|
||||
status: "ready",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: 90,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("JobCommandBar", () => {
|
||||
const openWithKeyboard = () => {
|
||||
fireEvent.keyDown(window, { key: "k", ctrlKey: true });
|
||||
};
|
||||
|
||||
it("opens the command dialog with keyboard shortcut", () => {
|
||||
render(
|
||||
<JobCommandBar
|
||||
jobs={[createJob({ id: "job-1" })]}
|
||||
onSelectJob={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openWithKeyboard();
|
||||
|
||||
expect(
|
||||
screen.getByPlaceholderText(
|
||||
"Search jobs by job title or company name...",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates discovered lock from @disc + Tab", () => {
|
||||
render(
|
||||
<JobCommandBar
|
||||
jobs={[createJob({ id: "job-1", status: "discovered" })]}
|
||||
onSelectJob={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openWithKeyboard();
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Search jobs by job title or company name...",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "@disc" } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
|
||||
expect(screen.getByText("@discovered")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates lock from @ready + Enter", () => {
|
||||
render(
|
||||
<JobCommandBar
|
||||
jobs={[createJob({ id: "job-1", status: "ready" })]}
|
||||
onSelectJob={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openWithKeyboard();
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Search jobs by job title or company name...",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "@ready" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(screen.getByText("@ready")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds lock-colored border and shadow to dialog when a lock is active", () => {
|
||||
render(
|
||||
<JobCommandBar
|
||||
jobs={[createJob({ id: "job-1", status: "discovered" })]}
|
||||
onSelectJob={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openWithKeyboard();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.className).not.toContain("border-sky-500/50");
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Search jobs by job title or company name...",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "@disc" } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
|
||||
expect(dialog).toHaveClass("border-sky-500/50");
|
||||
expect(dialog.className).toContain(
|
||||
"shadow-[0_0_0_1px_rgba(14,165,233,0.2),0_0_36px_-12px_rgba(14,165,233,0.55)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows selectable filter suggestion for @ tokens", () => {
|
||||
render(
|
||||
<JobCommandBar
|
||||
jobs={[
|
||||
createJob({
|
||||
id: "ready-job",
|
||||
title: "Ready Engineer",
|
||||
status: "ready",
|
||||
}),
|
||||
]}
|
||||
onSelectJob={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openWithKeyboard();
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Search jobs by job title or company name...",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "@ready" } });
|
||||
|
||||
expect(screen.getByText("Lock to @ready")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No jobs found.")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("Lock to @ready"));
|
||||
|
||||
expect(screen.getByText("@ready")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ready Engineer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows all lock suggestions for bare @", () => {
|
||||
render(
|
||||
<JobCommandBar
|
||||
jobs={[createJob({ id: "ready-job", status: "ready" })]}
|
||||
onSelectJob={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openWithKeyboard();
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Search jobs by job title or company name...",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "@" } });
|
||||
|
||||
expect(screen.getByText("Lock to @ready")).toBeInTheDocument();
|
||||
expect(screen.getByText("Lock to @discovered")).toBeInTheDocument();
|
||||
expect(screen.getByText("Lock to @applied")).toBeInTheDocument();
|
||||
expect(screen.getByText("Lock to @skipped")).toBeInTheDocument();
|
||||
expect(screen.getByText("Lock to @expired")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("searches by company name and routes to the matched state", () => {
|
||||
const onSelectJob = vi.fn();
|
||||
const jobs: Job[] = [
|
||||
createJob({
|
||||
id: "ready-job",
|
||||
title: "Backend Engineer",
|
||||
status: "ready",
|
||||
}),
|
||||
createJob({
|
||||
id: "applied-job",
|
||||
title: "Platform Engineer",
|
||||
employer: "Globex",
|
||||
status: "applied",
|
||||
}),
|
||||
];
|
||||
|
||||
render(<JobCommandBar jobs={jobs} onSelectJob={onSelectJob} />);
|
||||
|
||||
openWithKeyboard();
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText(
|
||||
"Search jobs by job title or company name...",
|
||||
),
|
||||
{
|
||||
target: { value: "Globex" },
|
||||
},
|
||||
);
|
||||
fireEvent.click(screen.getByText("Platform Engineer"));
|
||||
|
||||
expect(onSelectJob).toHaveBeenCalledWith("applied", "applied-job");
|
||||
});
|
||||
|
||||
it("returns only locked status results", () => {
|
||||
const jobs: Job[] = [
|
||||
createJob({
|
||||
id: "disc-1",
|
||||
title: "Frontend Engineer",
|
||||
status: "discovered",
|
||||
}),
|
||||
createJob({
|
||||
id: "applied-1",
|
||||
title: "Frontend Engineer",
|
||||
status: "applied",
|
||||
}),
|
||||
];
|
||||
render(<JobCommandBar jobs={jobs} onSelectJob={vi.fn()} />);
|
||||
|
||||
openWithKeyboard();
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Search jobs by job title or company name...",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "@disc" } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
fireEvent.change(input, { target: { value: "Frontend" } });
|
||||
|
||||
expect(screen.getByText("Frontend Engineer")).toBeInTheDocument();
|
||||
expect(screen.queryByText("@applied")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Applied")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ranks closest match first within a lock", () => {
|
||||
const jobs: Job[] = [
|
||||
createJob({
|
||||
id: "ready-job",
|
||||
title: "Junior Software Engineer (Data Products)",
|
||||
employer: "Yapily",
|
||||
status: "ready",
|
||||
}),
|
||||
createJob({
|
||||
id: "discovered-job",
|
||||
title: "Junior Web Developer",
|
||||
employer: "Joinrs",
|
||||
status: "discovered",
|
||||
}),
|
||||
createJob({
|
||||
id: "discovered-job-2",
|
||||
title: "Junior Software Engineer",
|
||||
employer: "Nestle",
|
||||
status: "discovered",
|
||||
}),
|
||||
];
|
||||
|
||||
render(<JobCommandBar jobs={jobs} onSelectJob={vi.fn()} />);
|
||||
|
||||
openWithKeyboard();
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Search jobs by job title or company name...",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "@disc" } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
fireEvent.change(input, {
|
||||
target: { value: "joinrs" },
|
||||
});
|
||||
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options[0]).toHaveTextContent("Joinrs");
|
||||
expect(options[0]).toHaveTextContent("Junior Web Developer");
|
||||
});
|
||||
|
||||
it("replaces an existing lock when new @ token is tab-completed", () => {
|
||||
render(
|
||||
<JobCommandBar
|
||||
jobs={[createJob({ id: "job-1", status: "ready" })]}
|
||||
onSelectJob={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openWithKeyboard();
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Search jobs by job title or company name...",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "@ready" } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
expect(screen.getByText("@ready")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, { target: { value: "@app" } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
expect(screen.getByText("@applied")).toBeInTheDocument();
|
||||
expect(screen.queryByText("@ready")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show an x remove button on the lock chip", () => {
|
||||
render(
|
||||
<JobCommandBar
|
||||
jobs={[createJob({ id: "job-1", status: "ready" })]}
|
||||
onSelectJob={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openWithKeyboard();
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Search jobs by job title or company name...",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "@ready" } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
|
||||
expect(screen.getByText("@ready")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByLabelText("Remove ready filter"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("removes lock with Backspace when query is empty", () => {
|
||||
render(
|
||||
<JobCommandBar
|
||||
jobs={[createJob({ id: "job-1", status: "ready" })]}
|
||||
onSelectJob={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openWithKeyboard();
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Search jobs by job title or company name...",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "@ready" } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
fireEvent.keyDown(input, { key: "Backspace" });
|
||||
|
||||
expect(screen.queryByText("@ready")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears lock on Escape and keeps dialog open", () => {
|
||||
render(
|
||||
<JobCommandBar
|
||||
jobs={[createJob({ id: "job-1", status: "ready" })]}
|
||||
onSelectJob={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openWithKeyboard();
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Search jobs by job title or company name...",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "@ready" } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
expect(screen.getByText("@ready")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
|
||||
expect(screen.queryByText("@ready")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText(
|
||||
"Search jobs by job title or company name...",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears active lock when the dialog closes", () => {
|
||||
render(
|
||||
<JobCommandBar
|
||||
jobs={[createJob({ id: "job-1", status: "ready" })]}
|
||||
onSelectJob={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openWithKeyboard();
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Search jobs by job title or company name...",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "@ready" } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
expect(screen.getByText("@ready")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
openWithKeyboard();
|
||||
|
||||
expect(screen.queryByText("@ready")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats @all as invalid and does not lock", () => {
|
||||
render(
|
||||
<JobCommandBar
|
||||
jobs={[createJob({ id: "job-1", status: "ready" })]}
|
||||
onSelectJob={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openWithKeyboard();
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Search jobs by job title or company name...",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "@all" } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
|
||||
expect(
|
||||
screen.queryByText(/^@(ready|discovered|applied|skipped|expired)$/),
|
||||
).not.toBeInTheDocument();
|
||||
expect((input as HTMLInputElement).value).toBe("@all");
|
||||
});
|
||||
|
||||
it("excludes processing jobs from every lock scope", () => {
|
||||
const jobs: Job[] = [
|
||||
createJob({
|
||||
id: "processing-job",
|
||||
title: "Processing-only keyword",
|
||||
employer: "Queue Corp",
|
||||
status: "processing",
|
||||
}),
|
||||
createJob({
|
||||
id: "ready-job",
|
||||
title: "Ready Engineer",
|
||||
status: "ready",
|
||||
}),
|
||||
createJob({
|
||||
id: "disc-job",
|
||||
title: "Discovered Engineer",
|
||||
status: "discovered",
|
||||
}),
|
||||
createJob({
|
||||
id: "applied-job",
|
||||
title: "Applied Engineer",
|
||||
status: "applied",
|
||||
}),
|
||||
createJob({
|
||||
id: "skipped-job",
|
||||
title: "Skipped Engineer",
|
||||
status: "skipped",
|
||||
}),
|
||||
createJob({
|
||||
id: "expired-job",
|
||||
title: "Expired Engineer",
|
||||
status: "expired",
|
||||
}),
|
||||
];
|
||||
render(<JobCommandBar jobs={jobs} onSelectJob={vi.fn()} />);
|
||||
|
||||
openWithKeyboard();
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Search jobs by job title or company name...",
|
||||
);
|
||||
const lockTokens = ["@ready", "@disc", "@applied", "@skip", "@exp"];
|
||||
|
||||
for (const token of lockTokens) {
|
||||
fireEvent.change(input, { target: { value: token } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
fireEvent.change(input, { target: { value: "Processing-only keyword" } });
|
||||
expect(
|
||||
screen.queryByText("Processing-only keyword"),
|
||||
).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
223
orchestrator/src/client/pages/orchestrator/JobCommandBar.tsx
Normal file
223
orchestrator/src/client/pages/orchestrator/JobCommandBar.tsx
Normal file
@ -0,0 +1,223 @@
|
||||
import { isMetaKeyPressed } from "@client/lib/meta-key";
|
||||
import type { Job } from "@shared/types.js";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
|
||||
import type { FilterTab } from "./constants";
|
||||
import {
|
||||
extractLeadingAtToken,
|
||||
getFilterTab,
|
||||
getLockMatchesFromAliasPrefix,
|
||||
groupJobsForCommandBar,
|
||||
jobMatchesLock,
|
||||
orderCommandGroups,
|
||||
resolveLockFromAliasPrefix,
|
||||
type StatusLock,
|
||||
stripLeadingAtToken,
|
||||
} from "./JobCommandBar.utils";
|
||||
import { JobCommandBarLockBadge } from "./JobCommandBarLockBadge";
|
||||
import { JobCommandBarLockSuggestions } from "./JobCommandBarLockSuggestions";
|
||||
import { JobRowContent } from "./JobRowContent";
|
||||
|
||||
interface JobCommandBarProps {
|
||||
jobs: Job[];
|
||||
onSelectJob: (tab: FilterTab, jobId: string) => void;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const JobCommandBar: React.FC<JobCommandBarProps> = ({
|
||||
jobs,
|
||||
onSelectJob,
|
||||
open,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const lockDialogAccentClass: Record<StatusLock, string> = {
|
||||
ready:
|
||||
"border-emerald-500/50 shadow-[0_0_0_1px_rgba(16,185,129,0.2),0_0_36px_-12px_rgba(16,185,129,0.55)]",
|
||||
discovered:
|
||||
"border-sky-500/50 shadow-[0_0_0_1px_rgba(14,165,233,0.2),0_0_36px_-12px_rgba(14,165,233,0.55)]",
|
||||
applied:
|
||||
"border-emerald-500/50 shadow-[0_0_0_1px_rgba(16,185,129,0.2),0_0_36px_-12px_rgba(16,185,129,0.55)]",
|
||||
skipped:
|
||||
"border-rose-500/50 shadow-[0_0_0_1px_rgba(244,63,94,0.2),0_0_36px_-12px_rgba(244,63,94,0.55)]",
|
||||
expired:
|
||||
"border-zinc-400/40 shadow-[0_0_0_1px_rgba(161,161,170,0.2),0_0_32px_-12px_rgba(161,161,170,0.45)]",
|
||||
};
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeLock, setActiveLock] = useState<StatusLock | null>(null);
|
||||
const isOpenControlled = typeof open === "boolean";
|
||||
const isOpen = isOpenControlled ? open : internalOpen;
|
||||
|
||||
const setDialogOpen = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!isOpenControlled) {
|
||||
setInternalOpen(nextOpen);
|
||||
}
|
||||
onOpenChange?.(nextOpen);
|
||||
},
|
||||
[isOpenControlled, onOpenChange],
|
||||
);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
setActiveLock(null);
|
||||
}, [setDialogOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() !== "k") return;
|
||||
if (!isMetaKeyPressed(event)) return;
|
||||
event.preventDefault();
|
||||
if (isOpen) {
|
||||
closeDialog();
|
||||
return;
|
||||
}
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [closeDialog, isOpen, setDialogOpen]);
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const scopedJobs = useMemo(() => {
|
||||
if (!activeLock) return jobs;
|
||||
return jobs.filter((job) => jobMatchesLock(job, activeLock));
|
||||
}, [activeLock, jobs]);
|
||||
|
||||
const groupedJobs = useMemo(
|
||||
() => groupJobsForCommandBar(scopedJobs, normalizedQuery),
|
||||
[normalizedQuery, scopedJobs],
|
||||
);
|
||||
|
||||
const orderedGroups = useMemo(
|
||||
() => orderCommandGroups(groupedJobs, normalizedQuery),
|
||||
[groupedJobs, normalizedQuery],
|
||||
);
|
||||
|
||||
const applyLock = (lock: StatusLock) => {
|
||||
setActiveLock(lock);
|
||||
setQuery((current) => stripLeadingAtToken(current));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) return;
|
||||
setActiveLock(null);
|
||||
}, [isOpen]);
|
||||
|
||||
const lockSuggestions = useMemo(() => {
|
||||
if (activeLock) return [];
|
||||
const token = extractLeadingAtToken(query);
|
||||
if (token === null) return [];
|
||||
return getLockMatchesFromAliasPrefix(token);
|
||||
}, [activeLock, query]);
|
||||
|
||||
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (
|
||||
(event.key === "Tab" || event.key === "Enter") &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey
|
||||
) {
|
||||
const token = extractLeadingAtToken(query);
|
||||
if (!token) return;
|
||||
const nextLock = resolveLockFromAliasPrefix(token);
|
||||
if (!nextLock) return;
|
||||
|
||||
event.preventDefault();
|
||||
applyLock(nextLock);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Backspace" && query.length === 0 && activeLock) {
|
||||
event.preventDefault();
|
||||
setActiveLock(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
setDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (!activeLock) return;
|
||||
event.preventDefault();
|
||||
setActiveLock(null);
|
||||
}}
|
||||
contentClassName={`max-w-4xl transition-[border-color,box-shadow] duration-200 ${activeLock ? lockDialogAccentClass[activeLock] : ""}`}
|
||||
>
|
||||
<DialogTitle className="sr-only">Job Search</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Search jobs across all states by job title or company name.
|
||||
</DialogDescription>
|
||||
<CommandInput
|
||||
placeholder="Search jobs by job title or company name..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
prefix={
|
||||
activeLock ? (
|
||||
<JobCommandBarLockBadge activeLock={activeLock} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="px-3 py-1 text-[11px] text-muted-foreground border-b">
|
||||
Use <span className="font-mono">@</span> + status + Tab/Enter to lock a
|
||||
status. Backspace on empty search clears the lock.
|
||||
</div>
|
||||
<CommandList className="max-h-[65vh]">
|
||||
<CommandEmpty>No jobs found.</CommandEmpty>
|
||||
{!activeLock && (
|
||||
<JobCommandBarLockSuggestions
|
||||
suggestions={lockSuggestions}
|
||||
onSelect={applyLock}
|
||||
/>
|
||||
)}
|
||||
{orderedGroups.map((group, index) => {
|
||||
const items = groupedJobs[group.id];
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={group.id}>
|
||||
{index > 0 && <CommandSeparator />}
|
||||
<CommandGroup heading={group.heading}>
|
||||
{items.map((job) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={job.id}
|
||||
value={`${job.id} ${job.title} ${job.employer}`}
|
||||
keywords={[job.title, job.employer]}
|
||||
onSelect={() => {
|
||||
closeDialog();
|
||||
onSelectJob(getFilterTab(job.status), job.id);
|
||||
}}
|
||||
>
|
||||
<JobRowContent job={job} />
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,116 @@
|
||||
import type { Job } from "@shared/types.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeJobMatchScore,
|
||||
groupJobsForCommandBar,
|
||||
} from "./JobCommandBar.utils";
|
||||
|
||||
const createJob = (overrides: Partial<Job>): Job => ({
|
||||
id: "job-1",
|
||||
source: "linkedin",
|
||||
sourceJobId: null,
|
||||
jobUrlDirect: null,
|
||||
datePosted: null,
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme Labs",
|
||||
employerUrl: null,
|
||||
jobUrl: "https://example.com/job-1",
|
||||
applicationLink: null,
|
||||
disciplines: null,
|
||||
deadline: null,
|
||||
salary: null,
|
||||
location: "California",
|
||||
degreeRequired: null,
|
||||
starting: null,
|
||||
jobDescription: null,
|
||||
status: "ready",
|
||||
outcome: null,
|
||||
closedAt: null,
|
||||
suitabilityScore: 90,
|
||||
suitabilityReason: null,
|
||||
tailoredSummary: null,
|
||||
tailoredHeadline: null,
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
notionPageId: null,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
salarySource: null,
|
||||
salaryInterval: null,
|
||||
salaryMinAmount: null,
|
||||
salaryMaxAmount: null,
|
||||
salaryCurrency: null,
|
||||
isRemote: null,
|
||||
jobLevel: null,
|
||||
jobFunction: null,
|
||||
listingType: null,
|
||||
emails: null,
|
||||
companyIndustry: null,
|
||||
companyLogo: null,
|
||||
companyUrlDirect: null,
|
||||
companyAddresses: null,
|
||||
companyNumEmployees: null,
|
||||
companyRevenue: null,
|
||||
companyDescription: null,
|
||||
skills: null,
|
||||
experienceRange: null,
|
||||
companyRating: null,
|
||||
companyReviewsCount: null,
|
||||
vacancyCount: null,
|
||||
workFromHomeType: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
processedAt: null,
|
||||
appliedAt: null,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("JobCommandBar score helpers", () => {
|
||||
it("returns zero when no title, employer, or location matches", () => {
|
||||
const score = computeJobMatchScore(
|
||||
createJob({
|
||||
title: "Backend Engineer",
|
||||
employer: "Acme",
|
||||
location: "London",
|
||||
}),
|
||||
"kubernetes",
|
||||
);
|
||||
|
||||
expect(score).toBe(0);
|
||||
});
|
||||
|
||||
it("ranks exact and fuzzy matches above non-matches for a query", () => {
|
||||
const grouped = groupJobsForCommandBar(
|
||||
[
|
||||
createJob({
|
||||
id: "no-match",
|
||||
title: "Visual Designer",
|
||||
employer: "Studio Co",
|
||||
discoveredAt: "2025-02-01T00:00:00Z",
|
||||
}),
|
||||
createJob({
|
||||
id: "fuzzy",
|
||||
title: "Backender Engineer",
|
||||
employer: "Platform Co",
|
||||
discoveredAt: "2025-01-02T00:00:00Z",
|
||||
}),
|
||||
createJob({
|
||||
id: "exact",
|
||||
title: "Backend",
|
||||
employer: "Infra Co",
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
}),
|
||||
],
|
||||
"backend",
|
||||
);
|
||||
|
||||
expect(grouped.ready.map((job) => job.id)).toEqual([
|
||||
"exact",
|
||||
"fuzzy",
|
||||
"no-match",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,206 @@
|
||||
import type { Job, JobStatus } from "@shared/types.js";
|
||||
import type { FilterTab } from "./constants";
|
||||
|
||||
export type CommandGroupId = "ready" | "discovered" | "applied" | "other";
|
||||
export type StatusLock =
|
||||
| "ready"
|
||||
| "discovered"
|
||||
| "applied"
|
||||
| "skipped"
|
||||
| "expired";
|
||||
|
||||
export const commandGroupMeta: Array<{ id: CommandGroupId; heading: string }> =
|
||||
[
|
||||
{ id: "ready", heading: "Ready" },
|
||||
{ id: "discovered", heading: "Discovered" },
|
||||
{ id: "applied", heading: "Applied" },
|
||||
{ id: "other", heading: "Other" },
|
||||
];
|
||||
|
||||
const lockAliases: Record<StatusLock, string[]> = {
|
||||
ready: ["ready", "rdy"],
|
||||
discovered: ["discovered", "discover", "disc"],
|
||||
applied: ["applied", "apply", "app"],
|
||||
skipped: ["skipped", "skip", "skp"],
|
||||
expired: ["expired", "expire", "exp"],
|
||||
};
|
||||
|
||||
export const lockLabel: Record<StatusLock, string> = {
|
||||
ready: "ready",
|
||||
discovered: "discovered",
|
||||
applied: "applied",
|
||||
skipped: "skipped",
|
||||
expired: "expired",
|
||||
};
|
||||
|
||||
const tokenRegex = /^\s*@([a-z-]*)/i;
|
||||
|
||||
const parseTime = (value: string | null) => {
|
||||
if (!value) return Number.NaN;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
||||
};
|
||||
|
||||
const computeFieldMatchScore = (fieldRaw: string, needleRaw: string) => {
|
||||
const field = fieldRaw.trim().toLowerCase();
|
||||
const needle = needleRaw.trim().toLowerCase();
|
||||
if (!field || !needle) return 0;
|
||||
if (field === needle) return 1000;
|
||||
|
||||
const words = field.split(/\s+/).filter(Boolean);
|
||||
if (words.includes(needle)) return 920;
|
||||
if (field.startsWith(needle)) return 880;
|
||||
if (words.some((word) => word.startsWith(needle))) return 820;
|
||||
if (field.includes(needle)) return 760;
|
||||
|
||||
const compactField = field.replace(/\s+/g, "");
|
||||
if (compactField.includes(needle)) return 700;
|
||||
|
||||
// Light typo-tolerance via ordered-character subsequence matching.
|
||||
let matchIndex = 0;
|
||||
for (const character of compactField) {
|
||||
if (character === needle[matchIndex]) {
|
||||
matchIndex += 1;
|
||||
if (matchIndex === needle.length) break;
|
||||
}
|
||||
}
|
||||
if (matchIndex === needle.length) {
|
||||
const density = needle.length / compactField.length;
|
||||
return Math.round(500 + density * 100);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const getCommandGroup = (status: JobStatus): CommandGroupId => {
|
||||
if (status === "ready") return "ready";
|
||||
if (status === "discovered" || status === "processing") return "discovered";
|
||||
if (status === "applied") return "applied";
|
||||
return "other";
|
||||
};
|
||||
|
||||
export const getFilterTab = (status: JobStatus): FilterTab => {
|
||||
if (status === "ready") return "ready";
|
||||
if (status === "discovered" || status === "processing") return "discovered";
|
||||
if (status === "applied") return "applied";
|
||||
return "all";
|
||||
};
|
||||
|
||||
export const extractLeadingAtToken = (input: string) => {
|
||||
const match = tokenRegex.exec(input);
|
||||
if (!match) return null;
|
||||
return match[1].toLowerCase();
|
||||
};
|
||||
|
||||
export const stripLeadingAtToken = (input: string) =>
|
||||
input.replace(tokenRegex, "").trimStart();
|
||||
|
||||
export const getLockMatchesFromAliasPrefix = (
|
||||
rawToken: string,
|
||||
): StatusLock[] => {
|
||||
const token = rawToken.trim().toLowerCase();
|
||||
if (!token) return Object.keys(lockAliases) as StatusLock[];
|
||||
|
||||
const matches: StatusLock[] = [];
|
||||
for (const [status, aliases] of Object.entries(lockAliases) as Array<
|
||||
[StatusLock, string[]]
|
||||
>) {
|
||||
if (aliases.some((alias) => alias.startsWith(token))) {
|
||||
matches.push(status);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
|
||||
export const resolveLockFromAliasPrefix = (
|
||||
rawToken: string,
|
||||
): StatusLock | null => {
|
||||
const matches = getLockMatchesFromAliasPrefix(rawToken);
|
||||
if (matches.length !== 1) return null;
|
||||
return matches[0];
|
||||
};
|
||||
|
||||
export const jobMatchesLock = (job: Job, lock: StatusLock) => {
|
||||
if (lock === "ready") return job.status === "ready";
|
||||
if (lock === "discovered") return job.status === "discovered";
|
||||
if (lock === "applied") return job.status === "applied";
|
||||
if (lock === "skipped") return job.status === "skipped";
|
||||
if (lock === "expired") return job.status === "expired";
|
||||
return false;
|
||||
};
|
||||
|
||||
export const computeJobMatchScore = (job: Job, normalizedQuery: string) => {
|
||||
if (!normalizedQuery) return 0;
|
||||
const titleScore = computeFieldMatchScore(job.title, normalizedQuery);
|
||||
const employerScore = computeFieldMatchScore(job.employer, normalizedQuery);
|
||||
const locationScore = computeFieldMatchScore(
|
||||
job.location ?? "",
|
||||
normalizedQuery,
|
||||
);
|
||||
|
||||
// Prefer title/company matches over location when scores tie.
|
||||
// Only apply bias when a field actually matched.
|
||||
const titleRankedScore = titleScore > 0 ? titleScore + 8 : 0;
|
||||
const employerRankedScore = employerScore > 0 ? employerScore + 12 : 0;
|
||||
return Math.max(titleRankedScore, employerRankedScore, locationScore);
|
||||
};
|
||||
|
||||
export const groupJobsForCommandBar = (
|
||||
scopedJobs: Job[],
|
||||
normalizedQuery: string,
|
||||
): Record<CommandGroupId, Job[]> => {
|
||||
const groups: Record<CommandGroupId, Job[]> = {
|
||||
ready: [],
|
||||
discovered: [],
|
||||
applied: [],
|
||||
other: [],
|
||||
};
|
||||
|
||||
const sorted = [...scopedJobs].sort((a, b) => {
|
||||
if (normalizedQuery) {
|
||||
const firstScore = computeJobMatchScore(a, normalizedQuery);
|
||||
const secondScore = computeJobMatchScore(b, normalizedQuery);
|
||||
if (firstScore !== secondScore) return secondScore - firstScore;
|
||||
}
|
||||
|
||||
const first = parseTime(a.discoveredAt);
|
||||
const second = parseTime(b.discoveredAt);
|
||||
if (!Number.isNaN(first) && !Number.isNaN(second)) {
|
||||
return second - first;
|
||||
}
|
||||
if (!Number.isNaN(first)) return -1;
|
||||
if (!Number.isNaN(second)) return 1;
|
||||
return b.id.localeCompare(a.id);
|
||||
});
|
||||
|
||||
for (const job of sorted) {
|
||||
groups[getCommandGroup(job.status)].push(job);
|
||||
}
|
||||
return groups;
|
||||
};
|
||||
|
||||
export const orderCommandGroups = (
|
||||
groupedJobs: Record<CommandGroupId, Job[]>,
|
||||
normalizedQuery: string,
|
||||
) => {
|
||||
if (!normalizedQuery) return commandGroupMeta;
|
||||
|
||||
const withScores = commandGroupMeta.map((group) => {
|
||||
const maxScore = groupedJobs[group.id].reduce(
|
||||
(currentMax, job) =>
|
||||
Math.max(currentMax, computeJobMatchScore(job, normalizedQuery)),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
...group,
|
||||
maxScore,
|
||||
};
|
||||
});
|
||||
|
||||
return withScores.sort((a, b) => {
|
||||
if (a.maxScore !== b.maxScore) return b.maxScore - a.maxScore;
|
||||
return (
|
||||
commandGroupMeta.findIndex((group) => group.id === a.id) -
|
||||
commandGroupMeta.findIndex((group) => group.id === b.id)
|
||||
);
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,12 @@
|
||||
import { lockLabel, type StatusLock } from "./JobCommandBar.utils";
|
||||
import { JobStatusBadge } from "./JobStatusBadge";
|
||||
|
||||
interface JobCommandBarLockBadgeProps {
|
||||
activeLock: StatusLock;
|
||||
}
|
||||
|
||||
export const JobCommandBarLockBadge = ({
|
||||
activeLock,
|
||||
}: JobCommandBarLockBadgeProps) => (
|
||||
<JobStatusBadge status={activeLock} label={`@${lockLabel[activeLock]}`} />
|
||||
);
|
||||
@ -0,0 +1,38 @@
|
||||
import { CommandGroup, CommandItem } from "@/components/ui/command";
|
||||
import { defaultStatusToken, statusTokens } from "./constants";
|
||||
import { lockLabel, type StatusLock } from "./JobCommandBar.utils";
|
||||
|
||||
interface JobCommandBarLockSuggestionsProps {
|
||||
suggestions: StatusLock[];
|
||||
onSelect: (lock: StatusLock) => void;
|
||||
}
|
||||
|
||||
export const JobCommandBarLockSuggestions = ({
|
||||
suggestions,
|
||||
onSelect,
|
||||
}: JobCommandBarLockSuggestionsProps) => {
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<CommandGroup heading="Filters">
|
||||
{suggestions.map((lock) => {
|
||||
const token = statusTokens[lock] ?? defaultStatusToken;
|
||||
return (
|
||||
<CommandItem
|
||||
key={lock}
|
||||
value={`@${lockLabel[lock]} filter`}
|
||||
keywords={[`@${lockLabel[lock]}`, lockLabel[lock]]}
|
||||
onSelect={() => onSelect(lock)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${token.dot}`} />
|
||||
<span className="truncate text-sm font-medium">
|
||||
Lock to @{lockLabel[lock]}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
);
|
||||
};
|
||||
@ -76,7 +76,6 @@ describe("JobListPanel", () => {
|
||||
selectedJobId={null}
|
||||
selectedJobIds={new Set()}
|
||||
activeTab="ready"
|
||||
searchQuery=""
|
||||
onSelectJob={vi.fn()}
|
||||
onToggleSelectJob={vi.fn()}
|
||||
onToggleSelectAll={vi.fn()}
|
||||
@ -95,7 +94,6 @@ describe("JobListPanel", () => {
|
||||
selectedJobId={null}
|
||||
selectedJobIds={new Set()}
|
||||
activeTab="ready"
|
||||
searchQuery=""
|
||||
onSelectJob={vi.fn()}
|
||||
onToggleSelectJob={vi.fn()}
|
||||
onToggleSelectAll={vi.fn()}
|
||||
@ -108,25 +106,6 @@ describe("JobListPanel", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the query-specific empty state when searching", () => {
|
||||
render(
|
||||
<JobListPanel
|
||||
isLoading={false}
|
||||
jobs={[]}
|
||||
activeJobs={[]}
|
||||
selectedJobId={null}
|
||||
selectedJobIds={new Set()}
|
||||
activeTab="ready"
|
||||
searchQuery="iOS"
|
||||
onSelectJob={vi.fn()}
|
||||
onToggleSelectJob={vi.fn()}
|
||||
onToggleSelectAll={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('No jobs match "iOS".')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders jobs and notifies when a job is selected", () => {
|
||||
const onSelectJob = vi.fn();
|
||||
const onToggleSelectJob = vi.fn();
|
||||
@ -148,7 +127,6 @@ describe("JobListPanel", () => {
|
||||
selectedJobId="job-1"
|
||||
selectedJobIds={new Set()}
|
||||
activeTab="ready"
|
||||
searchQuery=""
|
||||
onSelectJob={onSelectJob}
|
||||
onToggleSelectJob={onToggleSelectJob}
|
||||
onToggleSelectAll={onToggleSelectAll}
|
||||
@ -179,7 +157,6 @@ describe("JobListPanel", () => {
|
||||
selectedJobId="job-1"
|
||||
selectedJobIds={new Set(["job-1"])}
|
||||
activeTab="ready"
|
||||
searchQuery=""
|
||||
onSelectJob={vi.fn()}
|
||||
onToggleSelectJob={onToggleSelectJob}
|
||||
onToggleSelectAll={onToggleSelectAll}
|
||||
@ -192,4 +169,61 @@ describe("JobListPanel", () => {
|
||||
fireEvent.click(screen.getByLabelText("Select all filtered jobs"));
|
||||
expect(onToggleSelectAll).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("shows checkbox only for selected or checked rows", () => {
|
||||
const jobs = [createJob({ id: "job-1", title: "Backend Engineer" })];
|
||||
const { rerender } = render(
|
||||
<JobListPanel
|
||||
isLoading={false}
|
||||
jobs={jobs}
|
||||
activeJobs={jobs}
|
||||
selectedJobId={null}
|
||||
selectedJobIds={new Set()}
|
||||
activeTab="ready"
|
||||
onSelectJob={vi.fn()}
|
||||
onToggleSelectJob={vi.fn()}
|
||||
onToggleSelectAll={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Select Backend Engineer")).toHaveClass(
|
||||
"opacity-0",
|
||||
);
|
||||
|
||||
rerender(
|
||||
<JobListPanel
|
||||
isLoading={false}
|
||||
jobs={jobs}
|
||||
activeJobs={jobs}
|
||||
selectedJobId="job-1"
|
||||
selectedJobIds={new Set()}
|
||||
activeTab="ready"
|
||||
onSelectJob={vi.fn()}
|
||||
onToggleSelectJob={vi.fn()}
|
||||
onToggleSelectAll={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Select Backend Engineer")).toHaveClass(
|
||||
"opacity-100",
|
||||
);
|
||||
|
||||
rerender(
|
||||
<JobListPanel
|
||||
isLoading={false}
|
||||
jobs={jobs}
|
||||
activeJobs={jobs}
|
||||
selectedJobId={null}
|
||||
selectedJobIds={new Set(["job-1"])}
|
||||
activeTab="ready"
|
||||
onSelectJob={vi.fn()}
|
||||
onToggleSelectJob={vi.fn()}
|
||||
onToggleSelectAll={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Select Backend Engineer")).toHaveClass(
|
||||
"opacity-100",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,6 +5,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FilterTab } from "./constants";
|
||||
import { defaultStatusToken, emptyStateCopy, statusTokens } from "./constants";
|
||||
import { JobRowContent } from "./JobRowContent";
|
||||
|
||||
interface JobListPanelProps {
|
||||
isLoading: boolean;
|
||||
@ -13,7 +14,6 @@ interface JobListPanelProps {
|
||||
selectedJobId: string | null;
|
||||
selectedJobIds: Set<string>;
|
||||
activeTab: FilterTab;
|
||||
searchQuery: string;
|
||||
onSelectJob: (jobId: string) => void;
|
||||
onToggleSelectJob: (jobId: string) => void;
|
||||
onToggleSelectAll: (checked: boolean) => void;
|
||||
@ -26,7 +26,6 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
|
||||
selectedJobId,
|
||||
selectedJobIds,
|
||||
activeTab,
|
||||
searchQuery,
|
||||
onSelectJob,
|
||||
onToggleSelectJob,
|
||||
onToggleSelectAll,
|
||||
@ -41,9 +40,7 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
|
||||
<div className="text-base font-semibold">No jobs found</div>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
{searchQuery.trim()
|
||||
? `No jobs match "${searchQuery.trim()}".`
|
||||
: emptyStateCopy[activeTab]}
|
||||
{emptyStateCopy[activeTab]}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@ -76,95 +73,60 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
|
||||
{activeJobs.map((job) => {
|
||||
const isSelected = job.id === selectedJobId;
|
||||
const isChecked = selectedJobIds.has(job.id);
|
||||
const hasScore = job.suitabilityScore != null;
|
||||
const statusToken = statusTokens[job.status] ?? defaultStatusToken;
|
||||
return (
|
||||
<div
|
||||
key={job.id}
|
||||
data-job-id={job.id}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 px-4 py-3 transition-colors cursor-pointer border-l-2 border-b",
|
||||
isChecked
|
||||
? "!border-l !border-l-primary !bg-muted/40"
|
||||
: "border-l border-l-border/40",
|
||||
isSelected
|
||||
? "bg-primary/5"
|
||||
? "bg-primary/15"
|
||||
: "border-b-border/40 hover:bg-muted/20",
|
||||
isChecked && isSelected && "outline-2 outline-primary/30",
|
||||
)}
|
||||
>
|
||||
<div className="relative h-4 w-4 shrink-0">
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inset-0 m-auto h-2 w-2 rounded-full transition-opacity duration-150 ease-out",
|
||||
statusToken.dot,
|
||||
isChecked || isSelected
|
||||
? "opacity-0"
|
||||
: "opacity-100 group-hover:opacity-0",
|
||||
)}
|
||||
title={statusToken.label}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => onToggleSelectJob(job.id)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
aria-label={`Select ${job.title}`}
|
||||
className={cn(
|
||||
"border-border/80 cursor-pointer text-muted-foreground/70 transition-opacity",
|
||||
"absolute inset-0 m-0 border-border/80 cursor-pointer text-muted-foreground/70 transition-opacity duration-150 ease-out",
|
||||
"data-[state=checked]:border-primary data-[state=checked]:bg-primary/20 data-[state=checked]:text-primary",
|
||||
"data-[state=checked]:shadow-[0_0_0_1px_hsl(var(--primary)/0.35)]",
|
||||
isChecked || isSelected
|
||||
? "opacity-100"
|
||||
: "opacity-100 pointer-events-auto sm:opacity-0 sm:pointer-events-none sm:group-hover:pointer-events-auto sm:group-hover:opacity-100",
|
||||
? "opacity-100 pointer-events-auto"
|
||||
: "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto",
|
||||
)}
|
||||
/>
|
||||
{/* Single status indicator: subtle dot */}
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0",
|
||||
statusToken.dot,
|
||||
!isSelected && "opacity-70",
|
||||
)}
|
||||
title={statusToken.label}
|
||||
/>
|
||||
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectJob(job.id)}
|
||||
data-testid={`select-${job.id}`}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left"
|
||||
className="flex min-w-0 flex-1 cursor-pointer text-left"
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{/* Primary content: title strongest, company secondary */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-sm leading-tight",
|
||||
isSelected ? "font-semibold" : "font-medium",
|
||||
)}
|
||||
>
|
||||
{job.title}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground mt-0.5">
|
||||
{job.employer}
|
||||
{job.location && (
|
||||
<span className="before:content-['_in_']">
|
||||
{job.location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{job.salary?.trim() && (
|
||||
<div className="truncate text-xs text-muted-foreground mt-0.5">
|
||||
{job.salary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Single triage cue: score only (status shown via dot) */}
|
||||
{hasScore && (
|
||||
<div className="shrink-0 text-right">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs tabular-nums",
|
||||
(job.suitabilityScore ?? 0) >= 70
|
||||
? "text-emerald-400/90"
|
||||
: (job.suitabilityScore ?? 0) >= 50
|
||||
? "text-foreground/60"
|
||||
: "text-muted-foreground/60",
|
||||
)}
|
||||
>
|
||||
{job.suitabilityScore}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<JobRowContent
|
||||
job={job}
|
||||
isSelected={isSelected}
|
||||
showStatusDot={false}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
76
orchestrator/src/client/pages/orchestrator/JobRowContent.tsx
Normal file
76
orchestrator/src/client/pages/orchestrator/JobRowContent.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import type { Job } from "@shared/types.js";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { defaultStatusToken, statusTokens } from "./constants";
|
||||
|
||||
interface JobRowContentProps {
|
||||
job: Job;
|
||||
isSelected?: boolean;
|
||||
showStatusDot?: boolean;
|
||||
statusDotClassName?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const JobRowContent = ({
|
||||
job,
|
||||
isSelected = false,
|
||||
showStatusDot = true,
|
||||
statusDotClassName,
|
||||
className,
|
||||
}: JobRowContentProps) => {
|
||||
const hasScore = job.suitabilityScore != null;
|
||||
const statusToken = statusTokens[job.status] ?? defaultStatusToken;
|
||||
|
||||
return (
|
||||
<div className={cn("flex min-w-0 flex-1 items-center gap-3", className)}>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0",
|
||||
statusToken.dot,
|
||||
!isSelected && "opacity-70",
|
||||
statusDotClassName,
|
||||
!showStatusDot && "hidden",
|
||||
)}
|
||||
title={statusToken.label}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-sm leading-tight",
|
||||
isSelected ? "font-semibold" : "font-medium",
|
||||
)}
|
||||
>
|
||||
{job.title}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground mt-0.5">
|
||||
{job.employer}
|
||||
{job.location && (
|
||||
<span className="before:content-['_in_']">{job.location}</span>
|
||||
)}
|
||||
</div>
|
||||
{job.salary?.trim() && (
|
||||
<div className="truncate text-xs text-muted-foreground mt-0.5">
|
||||
{job.salary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasScore && (
|
||||
<div className="shrink-0 text-right">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs tabular-nums",
|
||||
(job.suitabilityScore ?? 0) >= 70
|
||||
? "text-emerald-400/90"
|
||||
: (job.suitabilityScore ?? 0) >= 50
|
||||
? "text-foreground/60"
|
||||
: "text-muted-foreground/60",
|
||||
)}
|
||||
>
|
||||
{job.suitabilityScore}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
import type { JobStatus } from "@shared/types.js";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { defaultStatusToken, statusTokens } from "./constants";
|
||||
|
||||
interface JobStatusBadgeProps {
|
||||
status: JobStatus;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const JobStatusBadge = ({
|
||||
status,
|
||||
label,
|
||||
className,
|
||||
}: JobStatusBadgeProps) => {
|
||||
const statusToken = statusTokens[status] ?? defaultStatusToken;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold tracking-wide",
|
||||
statusToken.badge,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", statusToken.dot)} />
|
||||
{label ?? statusToken.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@ -33,8 +33,7 @@ const renderFilters = (
|
||||
applied: 3,
|
||||
all: 6,
|
||||
},
|
||||
searchQuery: "",
|
||||
onSearchQueryChange: vi.fn(),
|
||||
onOpenCommandBar: vi.fn(),
|
||||
sourceFilter: "all" as const,
|
||||
onSourceFilterChange: vi.fn(),
|
||||
sponsorFilter: "all" as SponsorFilter,
|
||||
@ -60,16 +59,14 @@ const renderFilters = (
|
||||
};
|
||||
|
||||
describe("OrchestratorFilters", () => {
|
||||
it("notifies when tabs and search are updated", () => {
|
||||
it("notifies when tabs and command search shortcut are used", () => {
|
||||
const { props } = renderFilters();
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole("tab", { name: /applied/i }));
|
||||
expect(props.onTabChange).toHaveBeenCalledWith("applied");
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Search..."), {
|
||||
target: { value: "Design" },
|
||||
});
|
||||
expect(props.onSearchQueryChange).toHaveBeenCalledWith("Design");
|
||||
fireEvent.click(screen.getByRole("button", { name: /search jobs/i }));
|
||||
expect(props.onOpenCommandBar).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates source, sponsor, salary range, and sort from the drawer", async () => {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { getMetaShortcutLabel } from "@client/lib/meta-key";
|
||||
import type { JobSource } from "@shared/types.js";
|
||||
import { Filter, Search } from "lucide-react";
|
||||
import type React from "react";
|
||||
@ -37,8 +38,7 @@ interface OrchestratorFiltersProps {
|
||||
activeTab: FilterTab;
|
||||
onTabChange: (value: FilterTab) => void;
|
||||
counts: Record<FilterTab, number>;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (value: string) => void;
|
||||
onOpenCommandBar: () => void;
|
||||
sourceFilter: JobSource | "all";
|
||||
onSourceFilterChange: (value: JobSource | "all") => void;
|
||||
sponsorFilter: SponsorFilter;
|
||||
@ -113,8 +113,7 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
counts,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
onOpenCommandBar,
|
||||
sourceFilter,
|
||||
onSourceFilterChange,
|
||||
sponsorFilter,
|
||||
@ -146,6 +145,7 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
salaryFilter.mode === "at_least" || salaryFilter.mode === "between";
|
||||
const showSalaryMax =
|
||||
salaryFilter.mode === "at_most" || salaryFilter.mode === "between";
|
||||
const commandShortcutLabel = getMetaShortcutLabel("K");
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
@ -171,15 +171,20 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
||||
</TabsList>
|
||||
|
||||
<div className="flex lg:flex-nowrap flex-wrap items-center justify-end gap-2">
|
||||
<div className="relative w-full flex-1 min-w-[180px] lg:max-w-[240px] lg:flex-none">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(event) => onSearchQueryChange(event.target.value)}
|
||||
placeholder="Search..."
|
||||
className="h-8 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onOpenCommandBar}
|
||||
aria-label="Search jobs"
|
||||
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
Search
|
||||
<span className="rounded border border-border/70 px-1 py-0.5 font-mono text-xs leading-none text-muted-foreground">
|
||||
{commandShortcutLabel}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Sheet open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<SheetTrigger asChild>
|
||||
|
||||
@ -81,7 +81,6 @@ describe("useFilteredJobs", () => {
|
||||
"all",
|
||||
"confirmed",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
"",
|
||||
{
|
||||
key: "score",
|
||||
direction: "desc",
|
||||
@ -107,7 +106,6 @@ describe("useFilteredJobs", () => {
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "between", min: 60000, max: 80000 },
|
||||
"",
|
||||
{
|
||||
key: "score",
|
||||
direction: "desc",
|
||||
@ -136,7 +134,6 @@ describe("useFilteredJobs", () => {
|
||||
"all",
|
||||
"all",
|
||||
{ mode: "at_least", min: null, max: null },
|
||||
"",
|
||||
{
|
||||
key: "salary",
|
||||
direction: "desc",
|
||||
|
||||
@ -6,7 +6,7 @@ import type {
|
||||
SalaryFilter,
|
||||
SponsorFilter,
|
||||
} from "./constants";
|
||||
import { compareJobs, jobMatchesQuery, parseSalaryBounds } from "./utils";
|
||||
import { compareJobs, parseSalaryBounds } from "./utils";
|
||||
|
||||
const getSponsorCategory = (score: number | null): SponsorFilter => {
|
||||
if (score == null) return "unknown";
|
||||
@ -21,7 +21,6 @@ export const useFilteredJobs = (
|
||||
sourceFilter: JobSource | "all",
|
||||
sponsorFilter: SponsorFilter,
|
||||
salaryFilter: SalaryFilter,
|
||||
searchQuery: string,
|
||||
sort: JobSort,
|
||||
) =>
|
||||
useMemo(() => {
|
||||
@ -85,17 +84,5 @@ export const useFilteredJobs = (
|
||||
});
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
filtered = filtered.filter((job) => jobMatchesQuery(job, searchQuery));
|
||||
}
|
||||
|
||||
return [...filtered].sort((a, b) => compareJobs(a, b, sort));
|
||||
}, [
|
||||
jobs,
|
||||
activeTab,
|
||||
sourceFilter,
|
||||
sponsorFilter,
|
||||
salaryFilter,
|
||||
searchQuery,
|
||||
sort,
|
||||
]);
|
||||
}, [jobs, activeTab, sourceFilter, sponsorFilter, salaryFilter, sort]);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { JobSource } from "@shared/types.js";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type {
|
||||
JobSort,
|
||||
@ -33,20 +33,16 @@ const allowedSortDirections: JobSort["direction"][] = ["asc", "desc"];
|
||||
export const useOrchestratorFilters = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const searchQuery = searchParams.get("q") || "";
|
||||
const setSearchQuery = useCallback(
|
||||
(query: string) => {
|
||||
useEffect(() => {
|
||||
if (!searchParams.has("q")) return;
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
if (query) prev.set("q", query);
|
||||
else prev.delete("q");
|
||||
prev.delete("q");
|
||||
return prev;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const sourceFilter =
|
||||
(searchParams.get("source") as JobSource | "all") || "all";
|
||||
@ -181,8 +177,6 @@ export const useOrchestratorFilters = () => {
|
||||
|
||||
return {
|
||||
searchParams,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
sourceFilter,
|
||||
setSourceFilter,
|
||||
sponsorFilter,
|
||||
|
||||
@ -20,11 +20,31 @@ const Command = React.forwardRef<
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
interface CommandDialogProps extends DialogProps {
|
||||
contentClassName?: string;
|
||||
commandClassName?: string;
|
||||
onEscapeKeyDown?: (event: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
const CommandDialog = ({
|
||||
children,
|
||||
contentClassName,
|
||||
commandClassName,
|
||||
onEscapeKeyDown,
|
||||
...props
|
||||
}: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", contentClassName)}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
>
|
||||
<Command
|
||||
className={cn(
|
||||
"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5",
|
||||
commandClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
@ -32,12 +52,27 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface CommandInputProps
|
||||
extends Omit<
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
|
||||
"prefix"
|
||||
> {
|
||||
prefix?: React.ReactNode;
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
CommandInputProps
|
||||
>(({ className, prefix, wrapperClassName, ...props }, ref) => (
|
||||
<div
|
||||
className={cn("flex items-center border-b px-3", wrapperClassName)}
|
||||
cmdk-input-wrapper=""
|
||||
>
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
{prefix ? (
|
||||
<div className="mr-2 flex shrink-0 items-center">{prefix}</div>
|
||||
) : null}
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
||||
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user