Migration to tanstack query (#199)
* commit at some point in the middle, WIP * formatting * ci passing * comments * handle no jobid case * better error handling * comments * Update orchestrator/src/client/hooks/queries/useJobMutations.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update orchestrator/src/client/hooks/queries/useSettingsMutation.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * better types * formatter * tracking inbox page * in progress page * tracer links page * invalidate harder * ensure tracer links docs show --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
8b71bef5cf
commit
3640abef2d
@ -145,7 +145,7 @@ Fix:
|
|||||||
|
|
||||||
## Related pages
|
## Related pages
|
||||||
|
|
||||||
- [Settings](/docs/features/settings)
|
- [Settings](/docs/next/features/settings)
|
||||||
- [Reactive Resume](/docs/features/reactive-resume)
|
- [Reactive Resume](/docs/next/features/reactive-resume)
|
||||||
- [Find Jobs and Apply Workflow](/docs/workflows/find-jobs-and-apply-workflow)
|
- [Find Jobs and Apply Workflow](/docs/next/workflows/find-jobs-and-apply-workflow)
|
||||||
- [Post-Application Tracking](/docs/features/post-application-tracking)
|
- [Post-Application Tracking](/docs/next/features/post-application-tracking)
|
||||||
|
|||||||
@ -35,6 +35,7 @@ const sidebars: SidebarsConfig = {
|
|||||||
"features/ghostwriter",
|
"features/ghostwriter",
|
||||||
"features/post-application-tracking",
|
"features/post-application-tracking",
|
||||||
"features/visa-sponsors",
|
"features/visa-sponsors",
|
||||||
|
"features/tracer-links",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -32,7 +32,8 @@
|
|||||||
"features/in-progress-board",
|
"features/in-progress-board",
|
||||||
"features/ghostwriter",
|
"features/ghostwriter",
|
||||||
"features/post-application-tracking",
|
"features/post-application-tracking",
|
||||||
"features/visa-sponsors"
|
"features/visa-sponsors",
|
||||||
|
"features/tracer-links"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -44,6 +44,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"better-sqlite3": "^11.6.0",
|
"better-sqlite3": "^11.6.0",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
import { createJob } from "@shared/testing/factories.js";
|
import { createJob } from "@shared/testing/factories.js";
|
||||||
import type { Job } from "@shared/types.js";
|
import type { Job } from "@shared/types.js";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
|
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
|
||||||
|
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||||
import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer";
|
import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer";
|
||||||
|
|
||||||
|
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||||
|
renderWithQueryClient(ui);
|
||||||
|
|
||||||
vi.mock("@/components/ui/sheet", () => ({
|
vi.mock("@/components/ui/sheet", () => ({
|
||||||
Sheet: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
Sheet: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||||
open ? <div>{children}</div> : null,
|
open ? <div>{children}</div> : null,
|
||||||
@ -166,7 +170,11 @@ describe("JobDetailsEditDrawer", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(api.getTracerReadiness).toHaveBeenCalled());
|
await waitFor(() => expect(api.getTracerReadiness).toHaveBeenCalled());
|
||||||
fireEvent.click(screen.getByLabelText("Enable tracer links for this job"));
|
const tracerToggle = await screen.findByRole("checkbox", {
|
||||||
|
name: "Enable tracer links for this job",
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(tracerToggle).toBeEnabled());
|
||||||
|
fireEvent.click(tracerToggle);
|
||||||
fireEvent.click(screen.getByRole("button", { name: /save details/i }));
|
fireEvent.click(screen.getByRole("button", { name: /save details/i }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
import * as api from "@client/api";
|
import * as api from "@client/api";
|
||||||
import { useSettings } from "@client/hooks/useSettings";
|
import { useSettings } from "@client/hooks/useSettings";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||||
import { OnboardingGate } from "./OnboardingGate";
|
import { OnboardingGate } from "./OnboardingGate";
|
||||||
|
|
||||||
|
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||||
|
renderWithQueryClient(ui);
|
||||||
|
|
||||||
vi.mock("@client/api", () => ({
|
vi.mock("@client/api", () => ({
|
||||||
getDemoInfo: vi.fn(),
|
getDemoInfo: vi.fn(),
|
||||||
validateLlm: vi.fn(),
|
validateLlm: vi.fn(),
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
import { createJob } from "@shared/testing/factories.js";
|
import { createJob } from "@shared/testing/factories.js";
|
||||||
import type { Job } from "@shared/types.js";
|
import type { Job } from "@shared/types.js";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
|
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||||
import { ReadyPanel } from "./ReadyPanel";
|
import { ReadyPanel } from "./ReadyPanel";
|
||||||
|
|
||||||
|
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||||
|
renderWithQueryClient(ui);
|
||||||
|
|
||||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||||
return {
|
return {
|
||||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
import { createJob as createBaseJob } from "@shared/testing/factories.js";
|
import { createJob as createBaseJob } from "@shared/testing/factories.js";
|
||||||
import type { Job } from "@shared/types.js";
|
import type { Job } from "@shared/types.js";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
import { useProfile } from "../hooks/useProfile";
|
import { useProfile } from "../hooks/useProfile";
|
||||||
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
|
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
|
||||||
|
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||||
import { TailoringEditor } from "./TailoringEditor";
|
import { TailoringEditor } from "./TailoringEditor";
|
||||||
|
|
||||||
|
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||||
|
renderWithQueryClient(ui);
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../api", () => ({
|
||||||
getResumeProjectsCatalog: vi.fn().mockResolvedValue([]),
|
getResumeProjectsCatalog: vi.fn().mockResolvedValue([]),
|
||||||
updateJob: vi.fn().mockResolvedValue({}),
|
updateJob: vi.fn().mockResolvedValue({}),
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
import { createJob } from "@shared/testing/factories.js";
|
import { createJob } from "@shared/testing/factories.js";
|
||||||
import type { Job } from "@shared/types.js";
|
import type { Job } from "@shared/types.js";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
|
import { renderWithQueryClient } from "../../test/renderWithQueryClient";
|
||||||
import { DiscoveredPanel } from "./DiscoveredPanel";
|
import { DiscoveredPanel } from "./DiscoveredPanel";
|
||||||
|
|
||||||
|
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||||
|
renderWithQueryClient(ui);
|
||||||
|
|
||||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||||
return {
|
return {
|
||||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
import { createJob as createBaseJob } from "@shared/testing/factories.js";
|
import { createJob as createBaseJob } from "@shared/testing/factories.js";
|
||||||
import type { Job } from "@shared/types.js";
|
import type { Job } from "@shared/types.js";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
import { useProfile } from "../../hooks/useProfile";
|
import { useProfile } from "../../hooks/useProfile";
|
||||||
import { _resetTracerReadinessCache } from "../../hooks/useTracerReadiness";
|
import { _resetTracerReadinessCache } from "../../hooks/useTracerReadiness";
|
||||||
|
import { renderWithQueryClient } from "../../test/renderWithQueryClient";
|
||||||
import { TailorMode } from "./TailorMode";
|
import { TailorMode } from "./TailorMode";
|
||||||
|
|
||||||
|
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||||
|
renderWithQueryClient(ui);
|
||||||
|
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
getResumeProjectsCatalog: vi.fn().mockResolvedValue([]),
|
getResumeProjectsCatalog: vi.fn().mockResolvedValue([]),
|
||||||
updateJob: vi.fn(),
|
updateJob: vi.fn(),
|
||||||
|
|||||||
17
orchestrator/src/client/hooks/queries/invalidate.test.ts
Normal file
17
orchestrator/src/client/hooks/queries/invalidate.test.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { queryKeys } from "@/client/lib/queryKeys";
|
||||||
|
import { invalidateJobData } from "./invalidate";
|
||||||
|
|
||||||
|
describe("invalidateJobData", () => {
|
||||||
|
it("invalidates in-progress board when invalidating a specific job", async () => {
|
||||||
|
const invalidateQueries = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const queryClient = { invalidateQueries } as unknown as QueryClient;
|
||||||
|
|
||||||
|
await invalidateJobData(queryClient, "job-1");
|
||||||
|
|
||||||
|
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||||
|
queryKey: queryKeys.jobs.inProgressBoard(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
38
orchestrator/src/client/hooks/queries/invalidate.ts
Normal file
38
orchestrator/src/client/hooks/queries/invalidate.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { queryKeys } from "@/client/lib/queryKeys";
|
||||||
|
|
||||||
|
export async function invalidateJobData(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
jobId?: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!jobId) {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.jobs.all });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [...queryKeys.jobs.all, "list"] as const,
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [...queryKeys.jobs.all, "revision"] as const,
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.jobs.inProgressBoard(),
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.jobs.detail(jobId),
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.jobs.stageEvents(jobId),
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.jobs.tasks(jobId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function invalidateSettingsData(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
): Promise<void> {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.settings.all });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.tracer.all });
|
||||||
|
}
|
||||||
102
orchestrator/src/client/hooks/queries/useJobMutations.ts
Normal file
102
orchestrator/src/client/hooks/queries/useJobMutations.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import * as api from "@client/api";
|
||||||
|
import type { Job } from "@shared/types";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { queryKeys } from "@/client/lib/queryKeys";
|
||||||
|
import { invalidateJobData } from "./invalidate";
|
||||||
|
|
||||||
|
export function useUpdateJobMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, update }: { id: string; update: Partial<Job> }) =>
|
||||||
|
api.updateJob(id, update),
|
||||||
|
onSuccess: async (_data, variables) => {
|
||||||
|
await invalidateJobData(queryClient, variables.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkAsAppliedMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.markAsApplied(id),
|
||||||
|
onMutate: async (id) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: queryKeys.jobs.detail(id) });
|
||||||
|
const previousJob = queryClient.getQueryData<Job>(
|
||||||
|
queryKeys.jobs.detail(id),
|
||||||
|
);
|
||||||
|
queryClient.setQueryData<Job>(queryKeys.jobs.detail(id), (current) =>
|
||||||
|
current ? { ...current, status: "applied" } : current,
|
||||||
|
);
|
||||||
|
return { previousJob, id };
|
||||||
|
},
|
||||||
|
onError: (_error, _id, context) => {
|
||||||
|
if (context?.id) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
queryKeys.jobs.detail(context.id),
|
||||||
|
context.previousJob,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: async (_data, _error, id) => {
|
||||||
|
await invalidateJobData(queryClient, id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSkipJobMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.skipJob(id),
|
||||||
|
onMutate: async (id) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: queryKeys.jobs.detail(id) });
|
||||||
|
const previousJob = queryClient.getQueryData<Job>(
|
||||||
|
queryKeys.jobs.detail(id),
|
||||||
|
);
|
||||||
|
queryClient.setQueryData<Job>(queryKeys.jobs.detail(id), (current) =>
|
||||||
|
current ? { ...current, status: "skipped" } : current,
|
||||||
|
);
|
||||||
|
return { previousJob, id };
|
||||||
|
},
|
||||||
|
onError: (_error, _id, context) => {
|
||||||
|
if (context?.id) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
queryKeys.jobs.detail(context.id),
|
||||||
|
context.previousJob,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: async (_data, _error, id) => {
|
||||||
|
await invalidateJobData(queryClient, id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRescoreJobMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.rescoreJob(id),
|
||||||
|
onSuccess: async (_data, id) => {
|
||||||
|
await invalidateJobData(queryClient, id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGenerateJobPdfMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.generateJobPdf(id),
|
||||||
|
onSuccess: async (_data, id) => {
|
||||||
|
await invalidateJobData(queryClient, id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCheckSponsorMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.checkSponsor(id),
|
||||||
|
onSuccess: async (_data, id) => {
|
||||||
|
await invalidateJobData(queryClient, id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
14
orchestrator/src/client/hooks/queries/useSettingsMutation.ts
Normal file
14
orchestrator/src/client/hooks/queries/useSettingsMutation.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import * as api from "@client/api";
|
||||||
|
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { invalidateSettingsData } from "./invalidate";
|
||||||
|
|
||||||
|
export function useUpdateSettingsMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: UpdateSettingsInput) => api.updateSettings(payload),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidateSettingsData(queryClient);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,30 +1,18 @@
|
|||||||
import * as api from "@client/api";
|
import * as api from "@client/api";
|
||||||
import type { DemoInfoResponse } from "@shared/types";
|
import type { DemoInfoResponse } from "@shared/types";
|
||||||
import { useEffect, useState } from "react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { queryKeys } from "@/client/lib/queryKeys";
|
||||||
|
|
||||||
export function useDemoInfo() {
|
export function useDemoInfo() {
|
||||||
const [demoInfo, setDemoInfo] = useState<DemoInfoResponse | null>(null);
|
const { data } = useQuery<DemoInfoResponse | null>({
|
||||||
|
queryKey: queryKeys.demo.info(),
|
||||||
useEffect(() => {
|
queryFn: async () => {
|
||||||
let isCancelled = false;
|
try {
|
||||||
|
return await api.getDemoInfo();
|
||||||
void api
|
} catch {
|
||||||
.getDemoInfo()
|
return null;
|
||||||
.then((info) => {
|
}
|
||||||
if (!isCancelled) {
|
},
|
||||||
setDemoInfo(info);
|
});
|
||||||
}
|
return data ?? null;
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!isCancelled) {
|
|
||||||
setDemoInfo(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isCancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return demoInfo;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,98 +1,35 @@
|
|||||||
import type { ResumeProfile } from "@shared/types";
|
import type { ResumeProfile } from "@shared/types";
|
||||||
import { useEffect, useState } from "react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { queryClient as appQueryClient } from "@/client/lib/queryClient";
|
||||||
|
import { queryKeys } from "@/client/lib/queryKeys";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
|
|
||||||
let profileCache: ResumeProfile | null = null;
|
|
||||||
let profileError: Error | null = null;
|
|
||||||
const subscribers: Set<
|
|
||||||
(profile: ResumeProfile | null, error: Error | null) => void
|
|
||||||
> = new Set();
|
|
||||||
let isFetching = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to get the full profile data from base.json.
|
* Hook to get the full profile data from base.json.
|
||||||
* Caches the result to avoid re-fetching.
|
* Caches the result to avoid re-fetching.
|
||||||
*/
|
*/
|
||||||
export function useProfile() {
|
export function useProfile() {
|
||||||
const [profile, setProfile] = useState<ResumeProfile | null>(profileCache);
|
const {
|
||||||
const [error, setError] = useState<Error | null>(profileError);
|
data: profile = null,
|
||||||
|
error,
|
||||||
useEffect(() => {
|
isLoading,
|
||||||
if (profileCache) {
|
isFetching,
|
||||||
setProfile(profileCache);
|
refetch,
|
||||||
}
|
} = useQuery<ResumeProfile | null>({
|
||||||
if (profileError) {
|
queryKey: queryKeys.profile.current(),
|
||||||
setError(profileError);
|
queryFn: api.getProfile,
|
||||||
}
|
});
|
||||||
|
|
||||||
const handleUpdate = (
|
|
||||||
newProfile: ResumeProfile | null,
|
|
||||||
newError: Error | null,
|
|
||||||
) => {
|
|
||||||
setProfile(newProfile);
|
|
||||||
setError(newError);
|
|
||||||
};
|
|
||||||
|
|
||||||
subscribers.add(handleUpdate);
|
|
||||||
|
|
||||||
if (!profileCache && !isFetching) {
|
|
||||||
isFetching = true;
|
|
||||||
profileError = null;
|
|
||||||
api
|
|
||||||
.getProfile()
|
|
||||||
.then((data) => {
|
|
||||||
profileCache = data;
|
|
||||||
profileError = null;
|
|
||||||
subscribers.forEach((sub) => {
|
|
||||||
sub(data, null);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
profileError = err instanceof Error ? err : new Error(String(err));
|
|
||||||
subscribers.forEach((sub) => {
|
|
||||||
sub(profileCache, profileError);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
isFetching = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscribers.delete(handleUpdate);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refreshProfile = async () => {
|
const refreshProfile = async () => {
|
||||||
isFetching = true;
|
const result = await refetch();
|
||||||
profileError = null;
|
if (result.error) throw result.error;
|
||||||
subscribers.forEach((sub) => {
|
return result.data ?? null;
|
||||||
sub(profileCache, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await api.getProfile();
|
|
||||||
profileCache = data;
|
|
||||||
profileError = null;
|
|
||||||
subscribers.forEach((sub) => {
|
|
||||||
sub(data, null);
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
} catch (err) {
|
|
||||||
profileError = err instanceof Error ? err : new Error(String(err));
|
|
||||||
subscribers.forEach((sub) => {
|
|
||||||
sub(profileCache, profileError);
|
|
||||||
});
|
|
||||||
throw profileError;
|
|
||||||
} finally {
|
|
||||||
isFetching = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
profile,
|
profile,
|
||||||
error,
|
error: error ?? null,
|
||||||
isLoading: !profile && isFetching && !error,
|
isLoading: isLoading || (!!isFetching && !profile && !error),
|
||||||
personName: profile?.basics?.name || "Resume",
|
personName: profile?.basics?.name || "Resume",
|
||||||
refreshProfile,
|
refreshProfile,
|
||||||
};
|
};
|
||||||
@ -100,8 +37,5 @@ export function useProfile() {
|
|||||||
|
|
||||||
/** @internal For testing only */
|
/** @internal For testing only */
|
||||||
export function _resetProfileCache() {
|
export function _resetProfileCache() {
|
||||||
profileCache = null;
|
appQueryClient.removeQueries({ queryKey: queryKeys.profile.all });
|
||||||
profileError = null;
|
|
||||||
isFetching = false;
|
|
||||||
subscribers.clear();
|
|
||||||
}
|
}
|
||||||
|
|||||||
25
orchestrator/src/client/hooks/useQueryErrorToast.ts
Normal file
25
orchestrator/src/client/hooks/useQueryErrorToast.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a toast when a React Query `error` becomes non-null.
|
||||||
|
* Deduplicates repeated firings for the same error message so the toast
|
||||||
|
* does not reappear on every re-render while the query stays in error state.
|
||||||
|
*
|
||||||
|
* @param error The `error` value from `useQuery` / `useInfiniteQuery`.
|
||||||
|
* @param fallback Fallback message used when the error is not an Error instance.
|
||||||
|
*/
|
||||||
|
export function useQueryErrorToast(error: unknown, fallback: string): void {
|
||||||
|
const lastKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!error) {
|
||||||
|
lastKeyRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : fallback;
|
||||||
|
if (lastKeyRef.current === message) return;
|
||||||
|
lastKeyRef.current = message;
|
||||||
|
toast.error(message);
|
||||||
|
}, [error, fallback]);
|
||||||
|
}
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import { act, renderHook } from "@testing-library/react";
|
import { act } from "@testing-library/react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
|
import { renderHookWithQueryClient } from "../test/renderWithQueryClient";
|
||||||
import { useRescoreJob } from "./useRescoreJob";
|
import { useRescoreJob } from "./useRescoreJob";
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../api", () => ({
|
||||||
@ -24,7 +25,9 @@ describe("useRescoreJob", () => {
|
|||||||
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||||
vi.mocked(api.rescoreJob).mockResolvedValue({} as any);
|
vi.mocked(api.rescoreJob).mockResolvedValue({} as any);
|
||||||
|
|
||||||
const { result } = renderHook(() => useRescoreJob(onJobUpdated));
|
const { result } = renderHookWithQueryClient(() =>
|
||||||
|
useRescoreJob(onJobUpdated),
|
||||||
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.rescoreJob("job-1");
|
await result.current.rescoreJob("job-1");
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useRescoreJobMutation } from "@/client/hooks/queries/useJobMutations";
|
||||||
import * as api from "../api";
|
|
||||||
|
|
||||||
export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
|
export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
|
||||||
const [isRescoring, setIsRescoring] = useState(false);
|
const [isRescoring, setIsRescoring] = useState(false);
|
||||||
|
const rescoreMutation = useRescoreJobMutation();
|
||||||
|
|
||||||
const rescoreJob = useCallback(
|
const rescoreJob = useCallback(
|
||||||
async (jobId?: string | null) => {
|
async (jobId?: string | null) => {
|
||||||
@ -12,7 +12,7 @@ export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsRescoring(true);
|
setIsRescoring(true);
|
||||||
await api.rescoreJob(jobId);
|
await rescoreMutation.mutateAsync(jobId);
|
||||||
toast.success("Match recalculated");
|
toast.success("Match recalculated");
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -25,7 +25,7 @@ export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
|
|||||||
setIsRescoring(false);
|
setIsRescoring(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onJobUpdated],
|
[onJobUpdated, rescoreMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { isRescoring, rescoreJob };
|
return { isRescoring, rescoreJob };
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
|
import { renderHookWithQueryClient } from "../test/renderWithQueryClient";
|
||||||
import { _resetSettingsCache, useSettings } from "./useSettings";
|
import { _resetSettingsCache, useSettings } from "./useSettings";
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../api", () => ({
|
||||||
@ -17,7 +18,7 @@ describe("useSettings", () => {
|
|||||||
const mockSettings = { showSponsorInfo: false };
|
const mockSettings = { showSponsorInfo: false };
|
||||||
vi.mocked(api.getSettings).mockResolvedValue(mockSettings as any);
|
vi.mocked(api.getSettings).mockResolvedValue(mockSettings as any);
|
||||||
|
|
||||||
const { result } = renderHook(() => useSettings());
|
const { result } = renderHookWithQueryClient(() => useSettings());
|
||||||
|
|
||||||
// Should start in loading state
|
// Should start in loading state
|
||||||
expect(result.current.settings).toBeNull();
|
expect(result.current.settings).toBeNull();
|
||||||
@ -33,7 +34,7 @@ describe("useSettings", () => {
|
|||||||
it("uses default values when settings are null", async () => {
|
it("uses default values when settings are null", async () => {
|
||||||
vi.mocked(api.getSettings).mockResolvedValue(null as any);
|
vi.mocked(api.getSettings).mockResolvedValue(null as any);
|
||||||
|
|
||||||
const { result } = renderHook(() => useSettings());
|
const { result } = renderHookWithQueryClient(() => useSettings());
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// settings is null, so showSponsorInfo should default to true
|
// settings is null, so showSponsorInfo should default to true
|
||||||
@ -48,7 +49,7 @@ describe("useSettings", () => {
|
|||||||
vi.mocked(api.getSettings).mockResolvedValueOnce(initialSettings as any);
|
vi.mocked(api.getSettings).mockResolvedValueOnce(initialSettings as any);
|
||||||
vi.mocked(api.getSettings).mockResolvedValueOnce(updatedSettings as any);
|
vi.mocked(api.getSettings).mockResolvedValueOnce(updatedSettings as any);
|
||||||
|
|
||||||
const { result } = renderHook(() => useSettings());
|
const { result } = renderHookWithQueryClient(() => useSettings());
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.settings).toEqual(initialSettings);
|
expect(result.current.settings).toEqual(initialSettings);
|
||||||
@ -71,7 +72,7 @@ describe("useSettings", () => {
|
|||||||
const mockError = new Error("Failed to fetch");
|
const mockError = new Error("Failed to fetch");
|
||||||
vi.mocked(api.getSettings).mockRejectedValue(mockError);
|
vi.mocked(api.getSettings).mockRejectedValue(mockError);
|
||||||
|
|
||||||
const { result } = renderHook(() => useSettings());
|
const { result } = renderHookWithQueryClient(() => useSettings());
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.error).toEqual(mockError);
|
expect(result.current.error).toEqual(mockError);
|
||||||
|
|||||||
@ -1,94 +1,31 @@
|
|||||||
import type { AppSettings } from "@shared/types";
|
import type { AppSettings } from "@shared/types";
|
||||||
import { useEffect, useState } from "react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { queryClient as appQueryClient } from "@/client/lib/queryClient";
|
||||||
|
import { queryKeys } from "@/client/lib/queryKeys";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
|
|
||||||
let settingsCache: AppSettings | null = null;
|
|
||||||
let settingsError: Error | null = null;
|
|
||||||
const subscribers: Set<
|
|
||||||
(settings: AppSettings | null, error: Error | null) => void
|
|
||||||
> = new Set();
|
|
||||||
let isFetching = false;
|
|
||||||
|
|
||||||
export function useSettings() {
|
export function useSettings() {
|
||||||
const [settings, setSettings] = useState<AppSettings | null>(settingsCache);
|
const {
|
||||||
const [error, setError] = useState<Error | null>(settingsError);
|
data: settings = null,
|
||||||
|
error,
|
||||||
useEffect(() => {
|
isLoading,
|
||||||
if (settingsCache) {
|
isFetching,
|
||||||
setSettings(settingsCache);
|
refetch,
|
||||||
}
|
} = useQuery<AppSettings | null>({
|
||||||
if (settingsError) {
|
queryKey: queryKeys.settings.current(),
|
||||||
setError(settingsError);
|
queryFn: api.getSettings,
|
||||||
}
|
});
|
||||||
|
|
||||||
const handleUpdate = (
|
|
||||||
newSettings: AppSettings | null,
|
|
||||||
newError: Error | null,
|
|
||||||
) => {
|
|
||||||
setSettings(newSettings);
|
|
||||||
setError(newError);
|
|
||||||
};
|
|
||||||
|
|
||||||
subscribers.add(handleUpdate);
|
|
||||||
|
|
||||||
if (!settingsCache && !isFetching) {
|
|
||||||
isFetching = true;
|
|
||||||
settingsError = null;
|
|
||||||
api
|
|
||||||
.getSettings()
|
|
||||||
.then((data) => {
|
|
||||||
settingsCache = data;
|
|
||||||
settingsError = null;
|
|
||||||
subscribers.forEach((sub) => {
|
|
||||||
sub(data, null);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
settingsError = err instanceof Error ? err : new Error(String(err));
|
|
||||||
subscribers.forEach((sub) => {
|
|
||||||
sub(settingsCache, settingsError);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
isFetching = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscribers.delete(handleUpdate);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refreshSettings = async () => {
|
const refreshSettings = async () => {
|
||||||
isFetching = true;
|
const result = await refetch();
|
||||||
settingsError = null;
|
if (result.error) throw result.error;
|
||||||
subscribers.forEach((sub) => {
|
return result.data ?? null;
|
||||||
sub(settingsCache, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await api.getSettings();
|
|
||||||
settingsCache = data;
|
|
||||||
settingsError = null;
|
|
||||||
subscribers.forEach((sub) => {
|
|
||||||
sub(data, null);
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
} catch (err) {
|
|
||||||
settingsError = err instanceof Error ? err : new Error(String(err));
|
|
||||||
subscribers.forEach((sub) => {
|
|
||||||
sub(settingsCache, settingsError);
|
|
||||||
});
|
|
||||||
throw settingsError;
|
|
||||||
} finally {
|
|
||||||
isFetching = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
settings,
|
settings,
|
||||||
error,
|
error: error ?? null,
|
||||||
isLoading: !settings && isFetching && !error,
|
isLoading: isLoading || (!!isFetching && !settings && !error),
|
||||||
showSponsorInfo: settings?.showSponsorInfo ?? true,
|
showSponsorInfo: settings?.showSponsorInfo ?? true,
|
||||||
refreshSettings,
|
refreshSettings,
|
||||||
};
|
};
|
||||||
@ -96,8 +33,5 @@ export function useSettings() {
|
|||||||
|
|
||||||
/** @internal For testing only */
|
/** @internal For testing only */
|
||||||
export function _resetSettingsCache() {
|
export function _resetSettingsCache() {
|
||||||
settingsCache = null;
|
appQueryClient.removeQueries({ queryKey: queryKeys.settings.all });
|
||||||
settingsError = null;
|
|
||||||
isFetching = false;
|
|
||||||
subscribers.clear();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,101 +1,46 @@
|
|||||||
import type { TracerReadinessResponse } from "@shared/types";
|
import type { TracerReadinessResponse } from "@shared/types";
|
||||||
import { useEffect, useState } from "react";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { queryClient as appQueryClient } from "@/client/lib/queryClient";
|
||||||
|
import { queryKeys } from "@/client/lib/queryKeys";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
|
|
||||||
let readinessCache: TracerReadinessResponse | null = null;
|
|
||||||
let readinessError: Error | null = null;
|
|
||||||
let isFetching = false;
|
|
||||||
const subscribers: Set<
|
|
||||||
(
|
|
||||||
readiness: TracerReadinessResponse | null,
|
|
||||||
error: Error | null,
|
|
||||||
loading: boolean,
|
|
||||||
) => void
|
|
||||||
> = new Set();
|
|
||||||
|
|
||||||
function notifySubscribers(
|
|
||||||
readiness: TracerReadinessResponse | null,
|
|
||||||
error: Error | null,
|
|
||||||
loading: boolean,
|
|
||||||
) {
|
|
||||||
for (const subscriber of subscribers) {
|
|
||||||
subscriber(readiness, error, loading);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runReadinessFetch(
|
|
||||||
force: boolean,
|
|
||||||
): Promise<TracerReadinessResponse> {
|
|
||||||
isFetching = true;
|
|
||||||
readinessError = null;
|
|
||||||
notifySubscribers(readinessCache, null, true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await api.getTracerReadiness({ force });
|
|
||||||
readinessCache = data;
|
|
||||||
readinessError = null;
|
|
||||||
notifySubscribers(data, null, false);
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
readinessError = error instanceof Error ? error : new Error(String(error));
|
|
||||||
notifySubscribers(readinessCache, readinessError, false);
|
|
||||||
throw readinessError;
|
|
||||||
} finally {
|
|
||||||
isFetching = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTracerReadiness() {
|
export function useTracerReadiness() {
|
||||||
const [readiness, setReadiness] = useState<TracerReadinessResponse | null>(
|
const queryClient = useQueryClient();
|
||||||
readinessCache,
|
const {
|
||||||
);
|
data: readiness = null,
|
||||||
const [error, setError] = useState<Error | null>(readinessError);
|
error,
|
||||||
const [loading, setLoading] = useState<boolean>(
|
isLoading,
|
||||||
!readinessCache && isFetching,
|
isFetching,
|
||||||
);
|
refetch,
|
||||||
|
} = useQuery<TracerReadinessResponse | null>({
|
||||||
useEffect(() => {
|
queryKey: queryKeys.tracer.readiness(false),
|
||||||
if (readinessCache) setReadiness(readinessCache);
|
queryFn: () => api.getTracerReadiness({ force: false }),
|
||||||
if (readinessError) setError(readinessError);
|
});
|
||||||
|
|
||||||
const handleUpdate = (
|
|
||||||
nextReadiness: TracerReadinessResponse | null,
|
|
||||||
nextError: Error | null,
|
|
||||||
nextLoading: boolean,
|
|
||||||
) => {
|
|
||||||
setReadiness(nextReadiness);
|
|
||||||
setError(nextError);
|
|
||||||
setLoading(nextLoading);
|
|
||||||
};
|
|
||||||
|
|
||||||
subscribers.add(handleUpdate);
|
|
||||||
|
|
||||||
if (!readinessCache && !isFetching) {
|
|
||||||
void runReadinessFetch(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscribers.delete(handleUpdate);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refreshReadiness = async (force = true) => {
|
const refreshReadiness = async (force = true) => {
|
||||||
return await runReadinessFetch(force);
|
if (!force) {
|
||||||
|
const result = await refetch();
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
return result.data ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await api.getTracerReadiness({ force: true });
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.tracer.readiness(false),
|
||||||
|
});
|
||||||
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
readiness,
|
readiness,
|
||||||
error,
|
error: error ?? null,
|
||||||
isLoading: loading && !readiness,
|
isLoading: isLoading && !readiness,
|
||||||
isChecking: loading,
|
isChecking: isFetching,
|
||||||
refreshReadiness,
|
refreshReadiness,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal For testing only */
|
/** @internal For testing only */
|
||||||
export function _resetTracerReadinessCache() {
|
export function _resetTracerReadinessCache() {
|
||||||
readinessCache = null;
|
appQueryClient.removeQueries({ queryKey: queryKeys.tracer.all });
|
||||||
readinessError = null;
|
|
||||||
isFetching = false;
|
|
||||||
subscribers.clear();
|
|
||||||
}
|
}
|
||||||
|
|||||||
18
orchestrator/src/client/lib/queryClient.ts
Normal file
18
orchestrator/src/client/lib/queryClient.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const createQueryClient = () =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
gcTime: 10 * 60_000,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const queryClient = createQueryClient();
|
||||||
103
orchestrator/src/client/lib/queryKeys.ts
Normal file
103
orchestrator/src/client/lib/queryKeys.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import type { JobStatus, PostApplicationProvider } from "@shared/types";
|
||||||
|
|
||||||
|
export const queryKeys = {
|
||||||
|
settings: {
|
||||||
|
all: ["settings"] as const,
|
||||||
|
current: () => [...queryKeys.settings.all, "current"] as const,
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
all: ["profile"] as const,
|
||||||
|
current: () => [...queryKeys.profile.all, "current"] as const,
|
||||||
|
},
|
||||||
|
tracer: {
|
||||||
|
all: ["tracer"] as const,
|
||||||
|
readiness: (force = false) =>
|
||||||
|
[...queryKeys.tracer.all, "readiness", { force }] as const,
|
||||||
|
analytics: (options?: {
|
||||||
|
from?: number;
|
||||||
|
to?: number;
|
||||||
|
includeBots?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
}) => [...queryKeys.tracer.all, "analytics", options ?? {}] as const,
|
||||||
|
jobLinks: (
|
||||||
|
jobId: string,
|
||||||
|
options?: { from?: number; to?: number; includeBots?: boolean },
|
||||||
|
) => [...queryKeys.tracer.all, "job-links", jobId, options ?? {}] as const,
|
||||||
|
},
|
||||||
|
demo: {
|
||||||
|
all: ["demo"] as const,
|
||||||
|
info: () => [...queryKeys.demo.all, "info"] as const,
|
||||||
|
},
|
||||||
|
jobs: {
|
||||||
|
all: ["jobs"] as const,
|
||||||
|
inProgressBoard: () =>
|
||||||
|
[...queryKeys.jobs.all, "in-progress-board"] as const,
|
||||||
|
list: (options?: { statuses?: JobStatus[]; view?: "list" | "full" }) =>
|
||||||
|
[...queryKeys.jobs.all, "list", options ?? {}] as const,
|
||||||
|
revision: (options?: { statuses?: JobStatus[] }) =>
|
||||||
|
[...queryKeys.jobs.all, "revision", options ?? {}] as const,
|
||||||
|
detail: (id: string) => [...queryKeys.jobs.all, "detail", id] as const,
|
||||||
|
stageEvents: (id: string) =>
|
||||||
|
[...queryKeys.jobs.all, "stage-events", id] as const,
|
||||||
|
tasks: (id: string) => [...queryKeys.jobs.all, "tasks", id] as const,
|
||||||
|
},
|
||||||
|
pipeline: {
|
||||||
|
all: ["pipeline"] as const,
|
||||||
|
status: () => [...queryKeys.pipeline.all, "status"] as const,
|
||||||
|
},
|
||||||
|
visaSponsors: {
|
||||||
|
all: ["visa-sponsors"] as const,
|
||||||
|
status: () => [...queryKeys.visaSponsors.all, "status"] as const,
|
||||||
|
search: (query: string, limit: number, minScore: number) =>
|
||||||
|
[
|
||||||
|
...queryKeys.visaSponsors.all,
|
||||||
|
"search",
|
||||||
|
{ query, limit, minScore },
|
||||||
|
] as const,
|
||||||
|
organization: (name: string) =>
|
||||||
|
[...queryKeys.visaSponsors.all, "organization", name] as const,
|
||||||
|
},
|
||||||
|
postApplication: {
|
||||||
|
all: ["post-application"] as const,
|
||||||
|
providerStatus: (provider: PostApplicationProvider, accountKey: string) =>
|
||||||
|
[
|
||||||
|
...queryKeys.postApplication.all,
|
||||||
|
"provider-status",
|
||||||
|
{ provider, accountKey },
|
||||||
|
] as const,
|
||||||
|
inbox: (
|
||||||
|
provider: PostApplicationProvider,
|
||||||
|
accountKey: string,
|
||||||
|
limit: number,
|
||||||
|
) =>
|
||||||
|
[
|
||||||
|
...queryKeys.postApplication.all,
|
||||||
|
"inbox",
|
||||||
|
{ provider, accountKey, limit },
|
||||||
|
] as const,
|
||||||
|
runs: (
|
||||||
|
provider: PostApplicationProvider,
|
||||||
|
accountKey: string,
|
||||||
|
limit: number,
|
||||||
|
) =>
|
||||||
|
[
|
||||||
|
...queryKeys.postApplication.all,
|
||||||
|
"runs",
|
||||||
|
{ provider, accountKey, limit },
|
||||||
|
] as const,
|
||||||
|
runMessages: (
|
||||||
|
runId: string,
|
||||||
|
provider: PostApplicationProvider,
|
||||||
|
accountKey: string,
|
||||||
|
) =>
|
||||||
|
[
|
||||||
|
...queryKeys.postApplication.all,
|
||||||
|
"run-messages",
|
||||||
|
{ runId, provider, accountKey },
|
||||||
|
] as const,
|
||||||
|
},
|
||||||
|
backups: {
|
||||||
|
all: ["backups"] as const,
|
||||||
|
list: () => [...queryKeys.backups.all, "list"] as const,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@ -1,6 +1,8 @@
|
|||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { queryClient } from "@/client/lib/queryClient";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import "../index.css";
|
import "../index.css";
|
||||||
|
|
||||||
@ -9,8 +11,10 @@ if (!rootElement) throw new Error("Failed to find the root element");
|
|||||||
|
|
||||||
ReactDOM.createRoot(rootElement).render(
|
ReactDOM.createRoot(rootElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
</BrowserRouter>
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,10 +7,12 @@ import {
|
|||||||
} from "@client/components/charts";
|
} from "@client/components/charts";
|
||||||
import { PageHeader, PageMain } from "@client/components/layout";
|
import { PageHeader, PageMain } from "@client/components/layout";
|
||||||
import type { StageEvent } from "@shared/types.js";
|
import type { StageEvent } from "@shared/types.js";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ChartColumn } from "lucide-react";
|
import { ChartColumn } from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { queryKeys } from "@/client/lib/queryKeys";
|
||||||
|
|
||||||
type JobWithEvents = {
|
type JobWithEvents = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -24,11 +26,8 @@ const DURATION_OPTIONS = [7, 14, 30, 90] as const;
|
|||||||
const DEFAULT_DURATION = 30;
|
const DEFAULT_DURATION = 30;
|
||||||
|
|
||||||
export const HomePage: React.FC = () => {
|
export const HomePage: React.FC = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [jobsWithEvents, setJobsWithEvents] = useState<JobWithEvents[]>([]);
|
|
||||||
const [appliedDates, setAppliedDates] = useState<Array<string | null>>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Read initial duration from URL
|
// Read initial duration from URL
|
||||||
const initialDuration: DurationValue = (() => {
|
const initialDuration: DurationValue = (() => {
|
||||||
@ -42,70 +41,72 @@ export const HomePage: React.FC = () => {
|
|||||||
|
|
||||||
const [duration, setDuration] = useState<DurationValue>(initialDuration);
|
const [duration, setDuration] = useState<DurationValue>(initialDuration);
|
||||||
|
|
||||||
useEffect(() => {
|
const overviewQuery = useQuery({
|
||||||
let isMounted = true;
|
queryKey: queryKeys.jobs.list({
|
||||||
setIsLoading(true);
|
statuses: ["applied", "in_progress"],
|
||||||
|
view: "list",
|
||||||
api
|
}),
|
||||||
.getJobs({
|
queryFn: async () => {
|
||||||
|
const response = await api.getJobs({
|
||||||
statuses: ["applied", "in_progress"],
|
statuses: ["applied", "in_progress"],
|
||||||
view: "list",
|
view: "list",
|
||||||
})
|
});
|
||||||
.then(async (response) => {
|
const appliedDates = response.jobs.map((job) => job.appliedAt);
|
||||||
if (!isMounted) return;
|
const jobSummaries = response.jobs.map((job) => ({
|
||||||
const appliedDates = response.jobs.map((job) => job.appliedAt);
|
id: job.id,
|
||||||
const jobSummaries = response.jobs.map((job) => ({
|
datePosted: job.datePosted,
|
||||||
id: job.id,
|
discoveredAt: job.discoveredAt,
|
||||||
datePosted: job.datePosted,
|
appliedAt: job.appliedAt,
|
||||||
discoveredAt: job.discoveredAt,
|
positiveResponse: false,
|
||||||
appliedAt: job.appliedAt,
|
}));
|
||||||
positiveResponse: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const appliedJobs = jobSummaries.filter((job) => job.appliedAt);
|
const appliedJobs = jobSummaries.filter((job) => job.appliedAt);
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
appliedJobs.map((job) => api.getJobStageEvents(job.id)),
|
appliedJobs.map((job) =>
|
||||||
);
|
queryClient.fetchQuery({
|
||||||
const eventsMap = new Map<string, StageEvent[]>();
|
queryKey: queryKeys.jobs.stageEvents(job.id),
|
||||||
|
queryFn: () => api.getJobStageEvents(job.id),
|
||||||
|
staleTime: 0,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const eventsMap = new Map<string, StageEvent[]>();
|
||||||
|
|
||||||
results.forEach((result, index) => {
|
results.forEach((result, index) => {
|
||||||
const jobId = appliedJobs[index]?.id;
|
const jobId = appliedJobs[index]?.id;
|
||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
if (result.status !== "fulfilled") {
|
if (result.status !== "fulfilled") {
|
||||||
eventsMap.set(jobId, []);
|
eventsMap.set(jobId, []);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
eventsMap.set(jobId, result.value);
|
eventsMap.set(jobId, result.value);
|
||||||
});
|
|
||||||
|
|
||||||
const resolvedJobsWithEvents: JobWithEvents[] = jobSummaries
|
|
||||||
.filter((job) => job.appliedAt)
|
|
||||||
.map((job) => ({
|
|
||||||
...job,
|
|
||||||
events: eventsMap.get(job.id) ?? [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
setJobsWithEvents(resolvedJobsWithEvents);
|
|
||||||
setAppliedDates(appliedDates);
|
|
||||||
setError(null);
|
|
||||||
})
|
|
||||||
.catch((fetchError) => {
|
|
||||||
if (!isMounted) return;
|
|
||||||
const message =
|
|
||||||
fetchError instanceof Error
|
|
||||||
? fetchError.message
|
|
||||||
: "Failed to load applications";
|
|
||||||
setError(message);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!isMounted) return;
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
const jobsWithEvents: JobWithEvents[] = jobSummaries
|
||||||
isMounted = false;
|
.filter((job) => job.appliedAt)
|
||||||
};
|
.map((job) => ({
|
||||||
}, []);
|
...job,
|
||||||
|
events: eventsMap.get(job.id) ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { jobsWithEvents, appliedDates };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobsWithEvents = useMemo(
|
||||||
|
() => overviewQuery.data?.jobsWithEvents ?? [],
|
||||||
|
[overviewQuery.data],
|
||||||
|
);
|
||||||
|
const appliedDates = useMemo(
|
||||||
|
() => overviewQuery.data?.appliedDates ?? [],
|
||||||
|
[overviewQuery.data],
|
||||||
|
);
|
||||||
|
const error = overviewQuery.error
|
||||||
|
? overviewQuery.error instanceof Error
|
||||||
|
? overviewQuery.error.message
|
||||||
|
: "Failed to load applications"
|
||||||
|
: null;
|
||||||
|
const isLoading = overviewQuery.isLoading;
|
||||||
|
|
||||||
const handleDurationChange = useCallback(
|
const handleDurationChange = useCallback(
|
||||||
(newDuration: DurationValue) => {
|
(newDuration: DurationValue) => {
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
import type { JobListItem, StageEvent } from "@shared/types";
|
import type { JobListItem, StageEvent } from "@shared/types";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
|
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||||
import { InProgressBoardPage } from "./InProgressBoardPage";
|
import { InProgressBoardPage } from "./InProgressBoardPage";
|
||||||
|
|
||||||
|
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||||
|
renderWithQueryClient(ui);
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../api", () => ({
|
||||||
getJobs: vi.fn(),
|
getJobs: vi.fn(),
|
||||||
getJobStageEvents: vi.fn(),
|
getJobStageEvents: vi.fn(),
|
||||||
|
|||||||
@ -6,10 +6,13 @@ import {
|
|||||||
STAGE_LABELS,
|
STAGE_LABELS,
|
||||||
type StageEvent,
|
type StageEvent,
|
||||||
} from "@shared/types.js";
|
} from "@shared/types.js";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowDownAZ, Columns3, ExternalLink, Plus } from "lucide-react";
|
import { ArrowDownAZ, Columns3, ExternalLink, Plus } from "lucide-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast";
|
||||||
|
import { queryKeys } from "@/client/lib/queryKeys";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@ -76,9 +79,9 @@ const resolveCurrentStage = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const InProgressBoardPage: React.FC = () => {
|
export const InProgressBoardPage: React.FC = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [cards, setCards] = React.useState<BoardCard[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
|
||||||
const [dragging, setDragging] = React.useState<{
|
const [dragging, setDragging] = React.useState<{
|
||||||
jobId: string;
|
jobId: string;
|
||||||
fromStage: ApplicationStage;
|
fromStage: ApplicationStage;
|
||||||
@ -90,9 +93,9 @@ export const InProgressBoardPage: React.FC = () => {
|
|||||||
"updated" | "title" | "company"
|
"updated" | "title" | "company"
|
||||||
>("updated");
|
>("updated");
|
||||||
|
|
||||||
const loadBoard = React.useCallback(async () => {
|
const boardQuery = useQuery({
|
||||||
try {
|
queryKey: queryKeys.jobs.inProgressBoard(),
|
||||||
setIsLoading(true);
|
queryFn: async () => {
|
||||||
const response = await api.getJobs({
|
const response = await api.getJobs({
|
||||||
statuses: ["in_progress"],
|
statuses: ["in_progress"],
|
||||||
view: "list",
|
view: "list",
|
||||||
@ -103,7 +106,7 @@ export const InProgressBoardPage: React.FC = () => {
|
|||||||
jobs.map((job) => api.getJobStageEvents(job.id)),
|
jobs.map((job) => api.getJobStageEvents(job.id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const nextCards = jobs.map((job, index) => {
|
return jobs.map((job, index) => {
|
||||||
const result = eventResults[index];
|
const result = eventResults[index];
|
||||||
const events =
|
const events =
|
||||||
result?.status === "fulfilled"
|
result?.status === "fulfilled"
|
||||||
@ -116,22 +119,31 @@ export const InProgressBoardPage: React.FC = () => {
|
|||||||
latestEventAt: resolved.latestEventAt,
|
latestEventAt: resolved.latestEventAt,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
setCards(nextCards);
|
const transitionMutation = useMutation({
|
||||||
} catch (error) {
|
mutationFn: ({
|
||||||
const message =
|
jobId,
|
||||||
error instanceof Error
|
toStage,
|
||||||
? error.message
|
}: {
|
||||||
: "Failed to load in-progress board";
|
jobId: string;
|
||||||
toast.error(message);
|
toStage: ApplicationStage;
|
||||||
} finally {
|
}) =>
|
||||||
setIsLoading(false);
|
api.transitionJobStage(jobId, {
|
||||||
}
|
toStage,
|
||||||
}, []);
|
metadata: {
|
||||||
|
actor: "user",
|
||||||
|
eventType: "status_update",
|
||||||
|
eventLabel: `Moved to ${STAGE_LABELS[toStage]}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
useQueryErrorToast(boardQuery.error, "Failed to load in-progress board");
|
||||||
void loadBoard();
|
|
||||||
}, [loadBoard]);
|
const cards = boardQuery.data ?? [];
|
||||||
|
const isLoading = boardQuery.isPending;
|
||||||
|
|
||||||
const lanes = React.useMemo(() => {
|
const lanes = React.useMemo(() => {
|
||||||
const sortFn =
|
const sortFn =
|
||||||
@ -170,31 +182,34 @@ export const InProgressBoardPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { jobId } = dragging;
|
const { jobId } = dragging;
|
||||||
const previousCards = cards;
|
const previousCards =
|
||||||
|
queryClient.getQueryData<BoardCard[]>(
|
||||||
|
queryKeys.jobs.inProgressBoard(),
|
||||||
|
) ?? [];
|
||||||
const nowEpoch = Math.floor(Date.now() / 1000);
|
const nowEpoch = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
setMovingJobId(jobId);
|
setMovingJobId(jobId);
|
||||||
setCards((current) =>
|
queryClient.setQueryData<BoardCard[]>(
|
||||||
current.map((card) =>
|
queryKeys.jobs.inProgressBoard(),
|
||||||
card.job.id === jobId
|
(current) =>
|
||||||
? { ...card, stage: toStage, latestEventAt: nowEpoch }
|
(current ?? []).map((card) =>
|
||||||
: card,
|
card.job.id === jobId
|
||||||
),
|
? { ...card, stage: toStage, latestEventAt: nowEpoch }
|
||||||
|
: card,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.transitionJobStage(jobId, {
|
await transitionMutation.mutateAsync({ jobId, toStage });
|
||||||
toStage,
|
|
||||||
metadata: {
|
|
||||||
actor: "user",
|
|
||||||
eventType: "status_update",
|
|
||||||
eventLabel: `Moved to ${STAGE_LABELS[toStage]}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
toast.success(`Moved to ${STAGE_LABELS[toStage]}`);
|
toast.success(`Moved to ${STAGE_LABELS[toStage]}`);
|
||||||
await loadBoard();
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.jobs.inProgressBoard(),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setCards(previousCards);
|
queryClient.setQueryData(
|
||||||
|
queryKeys.jobs.inProgressBoard(),
|
||||||
|
previousCards,
|
||||||
|
);
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "Failed to move stage";
|
error instanceof Error ? error.message : "Failed to move stage";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
@ -204,7 +219,7 @@ export const InProgressBoardPage: React.FC = () => {
|
|||||||
setDropTargetStage(null);
|
setDropTargetStage(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[cards, dragging, loadBoard],
|
[dragging, queryClient, transitionMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
STAGE_LABELS,
|
STAGE_LABELS,
|
||||||
type StageEvent,
|
type StageEvent,
|
||||||
} from "@shared/types.js";
|
} from "@shared/types.js";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import confetti from "canvas-confetti";
|
import confetti from "canvas-confetti";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@ -26,6 +27,17 @@ import {
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { invalidateJobData } from "@/client/hooks/queries/invalidate";
|
||||||
|
import {
|
||||||
|
useCheckSponsorMutation,
|
||||||
|
useGenerateJobPdfMutation,
|
||||||
|
useMarkAsAppliedMutation,
|
||||||
|
useRescoreJobMutation,
|
||||||
|
useSkipJobMutation,
|
||||||
|
useUpdateJobMutation,
|
||||||
|
} from "@/client/hooks/queries/useJobMutations";
|
||||||
|
import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast";
|
||||||
|
import { queryKeys } from "@/client/lib/queryKeys";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@ -55,10 +67,7 @@ import { JobTimeline } from "./job/Timeline";
|
|||||||
export const JobPage: React.FC = () => {
|
export const JobPage: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [job, setJob] = React.useState<Job | null>(null);
|
const queryClient = useQueryClient();
|
||||||
const [events, setEvents] = React.useState<StageEvent[]>([]);
|
|
||||||
const [tasks, setTasks] = React.useState<ApplicationTask[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
|
||||||
const [isLogModalOpen, setIsLogModalOpen] = React.useState(false);
|
const [isLogModalOpen, setIsLogModalOpen] = React.useState(false);
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false);
|
||||||
const [isEditDetailsOpen, setIsEditDetailsOpen] = React.useState(false);
|
const [isEditDetailsOpen, setIsEditDetailsOpen] = React.useState(false);
|
||||||
@ -69,30 +78,58 @@ export const JobPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
const pendingEventRef = React.useRef<StageEvent | null>(null);
|
const pendingEventRef = React.useRef<StageEvent | null>(null);
|
||||||
|
|
||||||
|
const jobQuery = useQuery<Job | null>({
|
||||||
|
queryKey: ["jobs", "detail", id ?? null] as const,
|
||||||
|
queryFn: () => (id ? api.getJob(id) : Promise.resolve(null)),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
const eventsQuery = useQuery<StageEvent[]>({
|
||||||
|
queryKey: ["jobs", "stage-events", id ?? null] as const,
|
||||||
|
queryFn: () => (id ? api.getJobStageEvents(id) : Promise.resolve([])),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
const tasksQuery = useQuery<ApplicationTask[]>({
|
||||||
|
queryKey: ["jobs", "tasks", id ?? null] as const,
|
||||||
|
queryFn: () => (id ? api.getJobTasks(id) : Promise.resolve([])),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
useQueryErrorToast(
|
||||||
|
jobQuery.error,
|
||||||
|
"Failed to load job details. Please try again.",
|
||||||
|
);
|
||||||
|
useQueryErrorToast(
|
||||||
|
eventsQuery.error,
|
||||||
|
"Failed to load job timeline. Please try again.",
|
||||||
|
);
|
||||||
|
useQueryErrorToast(
|
||||||
|
tasksQuery.error,
|
||||||
|
"Failed to load job tasks. Please try again.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const markAsAppliedMutation = useMarkAsAppliedMutation();
|
||||||
|
const updateJobMutation = useUpdateJobMutation();
|
||||||
|
const skipJobMutation = useSkipJobMutation();
|
||||||
|
const rescoreJobMutation = useRescoreJobMutation();
|
||||||
|
const generatePdfMutation = useGenerateJobPdfMutation();
|
||||||
|
const checkSponsorMutation = useCheckSponsorMutation();
|
||||||
|
|
||||||
|
const job = jobQuery.data ?? null;
|
||||||
|
const events = mergeEvents(eventsQuery.data ?? [], pendingEventRef.current);
|
||||||
|
const tasks = tasksQuery.data ?? [];
|
||||||
|
const isLoading =
|
||||||
|
jobQuery.isLoading || eventsQuery.isLoading || tasksQuery.isLoading;
|
||||||
|
|
||||||
const loadData = React.useCallback(async () => {
|
const loadData = React.useCallback(async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setIsLoading(true);
|
await Promise.all([
|
||||||
try {
|
queryClient.invalidateQueries({ queryKey: queryKeys.jobs.detail(id) }),
|
||||||
const jobData = await api.getJob(id);
|
queryClient.invalidateQueries({
|
||||||
setJob(jobData);
|
queryKey: queryKeys.jobs.stageEvents(id),
|
||||||
|
}),
|
||||||
api
|
queryClient.invalidateQueries({ queryKey: queryKeys.jobs.tasks(id) }),
|
||||||
.getJobStageEvents(id)
|
]);
|
||||||
.then((data) => setEvents(mergeEvents(data, pendingEventRef.current)))
|
}, [id, queryClient]);
|
||||||
.catch(() => toast.error("Failed to load stage events"));
|
|
||||||
|
|
||||||
api
|
|
||||||
.getJobTasks(id)
|
|
||||||
.then((data) => setTasks(data))
|
|
||||||
.catch(() => toast.error("Failed to load tasks"));
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
loadData();
|
|
||||||
}, [loadData]);
|
|
||||||
|
|
||||||
const handleLogEvent = async (
|
const handleLogEvent = async (
|
||||||
values: LogEventFormValues,
|
values: LogEventFormValues,
|
||||||
@ -153,12 +190,7 @@ export const JobPage: React.FC = () => {
|
|||||||
pendingEventRef.current = newEvent;
|
pendingEventRef.current = newEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [jobData, eventData] = await Promise.all([
|
await invalidateJobData(queryClient, job.id);
|
||||||
api.getJob(job.id),
|
|
||||||
api.getJobStageEvents(job.id),
|
|
||||||
]);
|
|
||||||
setJob(jobData);
|
|
||||||
setEvents(eventData);
|
|
||||||
pendingEventRef.current = null;
|
pendingEventRef.current = null;
|
||||||
setEditingEvent(null);
|
setEditingEvent(null);
|
||||||
toast.success(eventId ? "Event updated" : "Event logged");
|
toast.success(eventId ? "Event updated" : "Event logged");
|
||||||
@ -172,8 +204,9 @@ export const JobPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to log event:", error);
|
const message =
|
||||||
toast.error("Failed to log event");
|
error instanceof Error ? error.message : "Failed to log event";
|
||||||
|
toast.error(message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -186,16 +219,12 @@ export const JobPage: React.FC = () => {
|
|||||||
if (!job || !eventToDelete) return;
|
if (!job || !eventToDelete) return;
|
||||||
try {
|
try {
|
||||||
await api.deleteJobStageEvent(job.id, eventToDelete);
|
await api.deleteJobStageEvent(job.id, eventToDelete);
|
||||||
const [jobData, eventData] = await Promise.all([
|
await invalidateJobData(queryClient, job.id);
|
||||||
api.getJob(job.id),
|
|
||||||
api.getJobStageEvents(job.id),
|
|
||||||
]);
|
|
||||||
setJob(jobData);
|
|
||||||
setEvents(eventData);
|
|
||||||
toast.success("Event deleted");
|
toast.success("Event deleted");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete event:", error);
|
const message =
|
||||||
toast.error("Failed to delete event");
|
error instanceof Error ? error.message : "Failed to delete event";
|
||||||
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleteModalOpen(false);
|
setIsDeleteModalOpen(false);
|
||||||
setEventToDelete(null);
|
setEventToDelete(null);
|
||||||
@ -228,7 +257,7 @@ export const JobPage: React.FC = () => {
|
|||||||
const handleMarkApplied = async () => {
|
const handleMarkApplied = async () => {
|
||||||
await runAction("mark-applied", async () => {
|
await runAction("mark-applied", async () => {
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
await api.markAsApplied(job.id);
|
await markAsAppliedMutation.mutateAsync(job.id);
|
||||||
toast.success("Marked as applied");
|
toast.success("Marked as applied");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -236,7 +265,10 @@ export const JobPage: React.FC = () => {
|
|||||||
const handleMoveToInProgress = async () => {
|
const handleMoveToInProgress = async () => {
|
||||||
await runAction("move-in-progress", async () => {
|
await runAction("move-in-progress", async () => {
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
await api.updateJob(job.id, { status: "in_progress" });
|
await updateJobMutation.mutateAsync({
|
||||||
|
id: job.id,
|
||||||
|
update: { status: "in_progress" },
|
||||||
|
});
|
||||||
toast.success("Moved to in progress");
|
toast.success("Moved to in progress");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -244,7 +276,7 @@ export const JobPage: React.FC = () => {
|
|||||||
const handleSkip = async () => {
|
const handleSkip = async () => {
|
||||||
await runAction("skip", async () => {
|
await runAction("skip", async () => {
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
await api.skipJob(job.id);
|
await skipJobMutation.mutateAsync(job.id);
|
||||||
toast.message("Job skipped");
|
toast.message("Job skipped");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -252,7 +284,7 @@ export const JobPage: React.FC = () => {
|
|||||||
const handleRescore = async () => {
|
const handleRescore = async () => {
|
||||||
await runAction("rescore", async () => {
|
await runAction("rescore", async () => {
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
await api.rescoreJob(job.id);
|
await rescoreJobMutation.mutateAsync(job.id);
|
||||||
toast.success("Match recalculated");
|
toast.success("Match recalculated");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -260,7 +292,7 @@ export const JobPage: React.FC = () => {
|
|||||||
const handleRegeneratePdf = async () => {
|
const handleRegeneratePdf = async () => {
|
||||||
await runAction("regenerate-pdf", async () => {
|
await runAction("regenerate-pdf", async () => {
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
await api.generateJobPdf(job.id);
|
await generatePdfMutation.mutateAsync(job.id);
|
||||||
toast.success("Resume PDF generated");
|
toast.success("Resume PDF generated");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -268,7 +300,7 @@ export const JobPage: React.FC = () => {
|
|||||||
const handleCheckSponsor = async () => {
|
const handleCheckSponsor = async () => {
|
||||||
await runAction("check-sponsor", async () => {
|
await runAction("check-sponsor", async () => {
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
await api.checkSponsor(job.id);
|
await checkSponsorMutation.mutateAsync(job.id);
|
||||||
toast.success("Sponsor check completed");
|
toast.success("Sponsor check completed");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
import { createJob } from "@shared/testing/factories.js";
|
import { createJob } from "@shared/testing/factories.js";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
|
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||||
import { OrchestratorPage } from "./OrchestratorPage";
|
import { OrchestratorPage } from "./OrchestratorPage";
|
||||||
import type { FilterTab } from "./orchestrator/constants";
|
import type { FilterTab } from "./orchestrator/constants";
|
||||||
|
|
||||||
|
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||||
|
renderWithQueryClient(ui);
|
||||||
|
|
||||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
import { createAppSettings } from "@shared/testing/factories.js";
|
import { createAppSettings } from "@shared/testing/factories.js";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
|
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
|
||||||
|
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||||
import { SettingsPage } from "./SettingsPage";
|
import { SettingsPage } from "./SettingsPage";
|
||||||
|
|
||||||
|
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||||
|
renderWithQueryClient(ui);
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../api", () => ({
|
||||||
getSettings: vi.fn(),
|
getSettings: vi.fn(),
|
||||||
updateSettings: vi.fn(),
|
updateSettings: vi.fn(),
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import * as api from "@client/api";
|
import * as api from "@client/api";
|
||||||
import { PageHeader } from "@client/components/layout";
|
import { PageHeader } from "@client/components/layout";
|
||||||
|
import { useUpdateSettingsMutation } from "@client/hooks/queries/useSettingsMutation";
|
||||||
import { useTracerReadiness } from "@client/hooks/useTracerReadiness";
|
import { useTracerReadiness } from "@client/hooks/useTracerReadiness";
|
||||||
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
|
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
|
||||||
import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection";
|
import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection";
|
||||||
@ -23,16 +24,18 @@ import {
|
|||||||
} from "@shared/settings-schema.js";
|
} from "@shared/settings-schema.js";
|
||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
BackupInfo,
|
|
||||||
JobStatus,
|
JobStatus,
|
||||||
ResumeProjectCatalogItem,
|
ResumeProjectCatalogItem,
|
||||||
ResumeProjectsSettings,
|
ResumeProjectsSettings,
|
||||||
} from "@shared/types.js";
|
} from "@shared/types.js";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { FormProvider, type Resolver, useForm } from "react-hook-form";
|
import { FormProvider, type Resolver, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast";
|
||||||
|
import { queryKeys } from "@/client/lib/queryKeys";
|
||||||
import { Accordion } from "@/components/ui/accordion";
|
import { Accordion } from "@/components/ui/accordion";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
@ -293,9 +296,9 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsPage: React.FC = () => {
|
export const SettingsPage: React.FC = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [settings, setSettings] = useState<AppSettings | null>(null);
|
const [settings, setSettings] = useState<AppSettings | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>([
|
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>([
|
||||||
"discovered",
|
"discovered",
|
||||||
]);
|
]);
|
||||||
@ -309,9 +312,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
// Backup state
|
// Backup state
|
||||||
const [backups, setBackups] = useState<BackupInfo[]>([]);
|
|
||||||
const [nextScheduled, setNextScheduled] = useState<string | null>(null);
|
|
||||||
const [isLoadingBackups, setIsLoadingBackups] = useState(false);
|
|
||||||
const [isCreatingBackup, setIsCreatingBackup] = useState(false);
|
const [isCreatingBackup, setIsCreatingBackup] = useState(false);
|
||||||
const [isDeletingBackup, setIsDeletingBackup] = useState(false);
|
const [isDeletingBackup, setIsDeletingBackup] = useState(false);
|
||||||
const {
|
const {
|
||||||
@ -339,34 +339,32 @@ export const SettingsPage: React.FC = () => {
|
|||||||
formState: { isDirty, errors, isValid, dirtyFields },
|
formState: { isDirty, errors, isValid, dirtyFields },
|
||||||
} = methods;
|
} = methods;
|
||||||
|
|
||||||
|
const settingsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.settings.current(),
|
||||||
|
queryFn: api.getSettings,
|
||||||
|
});
|
||||||
|
const backupsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.backups.list(),
|
||||||
|
queryFn: api.getBackups,
|
||||||
|
});
|
||||||
|
const updateSettingsMutation = useUpdateSettingsMutation();
|
||||||
|
const isLoading = settingsQuery.isLoading;
|
||||||
|
const backups = backupsQuery.data?.backups ?? [];
|
||||||
|
const nextScheduled = backupsQuery.data?.nextScheduled ?? null;
|
||||||
|
const isLoadingBackups = backupsQuery.isLoading;
|
||||||
|
useQueryErrorToast(backupsQuery.error, "Failed to load backups");
|
||||||
|
|
||||||
const hasRxResumeAccess = Boolean(
|
const hasRxResumeAccess = Boolean(
|
||||||
settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint,
|
settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
if (!settingsQuery.data) return;
|
||||||
setIsLoading(true);
|
setSettings(settingsQuery.data);
|
||||||
api
|
reset(mapSettingsToForm(settingsQuery.data));
|
||||||
.getSettings()
|
}, [settingsQuery.data, reset]);
|
||||||
.then((data) => {
|
|
||||||
if (!isMounted) return;
|
|
||||||
setSettings(data);
|
|
||||||
reset(mapSettingsToForm(data));
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : "Failed to load settings";
|
|
||||||
toast.error(message);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!isMounted) return;
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
useQueryErrorToast(settingsQuery.error, "Failed to load settings");
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, [reset]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settings) return;
|
if (!settings) return;
|
||||||
@ -442,28 +440,12 @@ export const SettingsPage: React.FC = () => {
|
|||||||
scoring,
|
scoring,
|
||||||
} = derived;
|
} = derived;
|
||||||
|
|
||||||
// Backup functions
|
|
||||||
const loadBackups = useCallback(async () => {
|
|
||||||
setIsLoadingBackups(true);
|
|
||||||
try {
|
|
||||||
const response = await api.getBackups();
|
|
||||||
setBackups(response.backups);
|
|
||||||
setNextScheduled(response.nextScheduled);
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : "Failed to load backups";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingBackups(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCreateBackup = async () => {
|
const handleCreateBackup = async () => {
|
||||||
setIsCreatingBackup(true);
|
setIsCreatingBackup(true);
|
||||||
try {
|
try {
|
||||||
await api.createManualBackup();
|
await api.createManualBackup();
|
||||||
toast.success("Backup created successfully");
|
toast.success("Backup created successfully");
|
||||||
await loadBackups();
|
await queryClient.invalidateQueries({ queryKey: queryKeys.backups.all });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "Failed to create backup";
|
error instanceof Error ? error.message : "Failed to create backup";
|
||||||
@ -484,7 +466,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await api.deleteBackup(filename);
|
await api.deleteBackup(filename);
|
||||||
toast.success("Backup deleted successfully");
|
toast.success("Backup deleted successfully");
|
||||||
await loadBackups();
|
await queryClient.invalidateQueries({ queryKey: queryKeys.backups.all });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "Failed to delete backup";
|
error instanceof Error ? error.message : "Failed to delete backup";
|
||||||
@ -497,7 +479,9 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const handleVerifyTracerReadiness = useCallback(async () => {
|
const handleVerifyTracerReadiness = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const readiness = await refreshReadiness(true);
|
const readiness = await refreshReadiness(true);
|
||||||
if (readiness.canEnable) {
|
if (!readiness) {
|
||||||
|
toast.error("Tracer links are unavailable. Verify your public URL.");
|
||||||
|
} else if (readiness.canEnable) {
|
||||||
toast.success("Tracer links are ready");
|
toast.success("Tracer links are ready");
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
@ -514,13 +498,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [refreshReadiness]);
|
}, [refreshReadiness]);
|
||||||
|
|
||||||
// Load backups when settings are loaded
|
|
||||||
useEffect(() => {
|
|
||||||
if (settings) {
|
|
||||||
loadBackups();
|
|
||||||
}
|
|
||||||
}, [settings, loadBackups]);
|
|
||||||
|
|
||||||
const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects;
|
const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects;
|
||||||
const effectiveMaxProjectsTotal = effectiveProfileProjects.length;
|
const effectiveMaxProjectsTotal = effectiveProfileProjects.length;
|
||||||
|
|
||||||
@ -658,7 +635,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
// need to track it so that the save button is enabled when it changes
|
// need to track it so that the save button is enabled when it changes
|
||||||
delete payload.enableBasicAuth;
|
delete payload.enableBasicAuth;
|
||||||
|
|
||||||
const updated = await api.updateSettings(payload);
|
const updated = await updateSettingsMutation.mutateAsync(payload);
|
||||||
setSettings(updated);
|
setSettings(updated);
|
||||||
reset(mapSettingsToForm(updated));
|
reset(mapSettingsToForm(updated));
|
||||||
toast.success("Settings saved");
|
toast.success("Settings saved");
|
||||||
@ -758,7 +735,9 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
const updated = await api.updateSettings(NULL_SETTINGS_PAYLOAD);
|
const updated = await updateSettingsMutation.mutateAsync(
|
||||||
|
NULL_SETTINGS_PAYLOAD,
|
||||||
|
);
|
||||||
setSettings(updated);
|
setSettings(updated);
|
||||||
reset(mapSettingsToForm(updated));
|
reset(mapSettingsToForm(updated));
|
||||||
toast.success("Reset to default");
|
toast.success("Reset to default");
|
||||||
|
|||||||
159
orchestrator/src/client/pages/TracerLinksPage.test.tsx
Normal file
159
orchestrator/src/client/pages/TracerLinksPage.test.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import * as api from "../api";
|
||||||
|
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||||
|
import { TracerLinksPage } from "./TracerLinksPage";
|
||||||
|
|
||||||
|
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||||
|
renderWithQueryClient(ui);
|
||||||
|
|
||||||
|
vi.mock("../api", () => ({
|
||||||
|
getTracerAnalytics: vi.fn(),
|
||||||
|
getJobTracerLinks: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
vi.mocked(api.getTracerAnalytics).mockResolvedValue({
|
||||||
|
filters: {
|
||||||
|
jobId: null,
|
||||||
|
from: null,
|
||||||
|
to: null,
|
||||||
|
includeBots: false,
|
||||||
|
limit: 20,
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
clicks: 12,
|
||||||
|
uniqueOpens: 10,
|
||||||
|
botClicks: 3,
|
||||||
|
humanClicks: 9,
|
||||||
|
},
|
||||||
|
timeSeries: [
|
||||||
|
{
|
||||||
|
day: "2026-02-01",
|
||||||
|
clicks: 12,
|
||||||
|
uniqueOpens: 10,
|
||||||
|
botClicks: 3,
|
||||||
|
humanClicks: 9,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
topJobs: [
|
||||||
|
{
|
||||||
|
jobId: "job-1",
|
||||||
|
title: "Backend Engineer",
|
||||||
|
employer: "Acme",
|
||||||
|
clicks: 7,
|
||||||
|
uniqueOpens: 6,
|
||||||
|
botClicks: 2,
|
||||||
|
humanClicks: 5,
|
||||||
|
lastClickedAt: 1_700_000_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
topLinks: [
|
||||||
|
{
|
||||||
|
tracerLinkId: "tl-1",
|
||||||
|
token: "token-1",
|
||||||
|
jobId: "job-1",
|
||||||
|
title: "Backend Engineer",
|
||||||
|
employer: "Acme",
|
||||||
|
sourcePath: "resume.pdf",
|
||||||
|
sourceLabel: "Resume",
|
||||||
|
destinationUrl: "https://example.com/apply",
|
||||||
|
clicks: 7,
|
||||||
|
uniqueOpens: 6,
|
||||||
|
botClicks: 2,
|
||||||
|
humanClicks: 5,
|
||||||
|
lastClickedAt: 1_700_000_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(api.getJobTracerLinks).mockResolvedValue({
|
||||||
|
job: {
|
||||||
|
id: "job-1",
|
||||||
|
title: "Backend Engineer",
|
||||||
|
employer: "Acme",
|
||||||
|
tracerLinksEnabled: true,
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
links: 1,
|
||||||
|
clicks: 7,
|
||||||
|
uniqueOpens: 6,
|
||||||
|
botClicks: 2,
|
||||||
|
humanClicks: 5,
|
||||||
|
},
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
tracerLinkId: "tl-1",
|
||||||
|
token: "token-1",
|
||||||
|
sourcePath: "resume.pdf",
|
||||||
|
sourceLabel: "Resume",
|
||||||
|
destinationUrl: "https://example.com/apply",
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
clicks: 7,
|
||||||
|
uniqueOpens: 6,
|
||||||
|
botClicks: 2,
|
||||||
|
humanClicks: 5,
|
||||||
|
lastClickedAt: 1_700_000_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TracerLinksPage", () => {
|
||||||
|
it("renders analytics cards and top job rows", async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<TracerLinksPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Backend Engineer")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("12")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("9")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads job drilldown when selecting a top job", async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<TracerLinksPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = await screen.findByRole("row", { name: /Backend Engineer/i });
|
||||||
|
fireEvent.click(row);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.getJobTracerLinks).toHaveBeenCalledWith(
|
||||||
|
"job-1",
|
||||||
|
expect.objectContaining({ includeBots: false }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/Job Links: Backend Engineer/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refetches analytics when include bots filter changes", async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<TracerLinksPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Filters" }));
|
||||||
|
const includeBotsToggle = await screen.findByText("Include likely bots");
|
||||||
|
fireEvent.click(includeBotsToggle);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.getTracerAnalytics).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ includeBots: true, limit: 20 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -2,15 +2,16 @@ import * as api from "@client/api";
|
|||||||
import { PageHeader, PageMain, SectionCard } from "@client/components/layout";
|
import { PageHeader, PageMain, SectionCard } from "@client/components/layout";
|
||||||
import type {
|
import type {
|
||||||
JobTracerLinkAnalyticsItem,
|
JobTracerLinkAnalyticsItem,
|
||||||
JobTracerLinksResponse,
|
|
||||||
TracerAnalyticsResponse,
|
TracerAnalyticsResponse,
|
||||||
TracerAnalyticsTopJob,
|
TracerAnalyticsTopJob,
|
||||||
} from "@shared/types.js";
|
} from "@shared/types.js";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { BarChart3, Copy, ExternalLink, Loader2 } from "lucide-react";
|
import { BarChart3, Copy, ExternalLink, Loader2 } from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { queryKeys } from "@/client/lib/queryKeys";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
@ -113,19 +114,14 @@ function formatRelativeTime(value: number | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TracerLinksPage: React.FC = () => {
|
export const TracerLinksPage: React.FC = () => {
|
||||||
const [analytics, setAnalytics] = useState<TracerAnalyticsResponse | null>(
|
const [selectedDrilldownJobId, setSelectedDrilldownJobId] = useState<
|
||||||
null,
|
string | null
|
||||||
);
|
>(null);
|
||||||
const [jobDrilldown, setJobDrilldown] =
|
|
||||||
useState<JobTracerLinksResponse | null>(null);
|
|
||||||
const [fromDate, setFromDate] = useState("");
|
const [fromDate, setFromDate] = useState("");
|
||||||
const [toDate, setToDate] = useState("");
|
const [toDate, setToDate] = useState("");
|
||||||
const [includeBots, setIncludeBots] = useState(false);
|
const [includeBots, setIncludeBots] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isDrilldownLoading, setIsDrilldownLoading] = useState(false);
|
|
||||||
const [isDrilldownOpen, setIsDrilldownOpen] = useState(false);
|
const [isDrilldownOpen, setIsDrilldownOpen] = useState(false);
|
||||||
const [drilldownMode, setDrilldownMode] = useState<"human" | "all">("human");
|
const [drilldownMode, setDrilldownMode] = useState<"human" | "all">("human");
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const query = useMemo(
|
const query = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@ -137,62 +133,36 @@ export const TracerLinksPage: React.FC = () => {
|
|||||||
[fromDate, toDate, includeBots],
|
[fromDate, toDate, includeBots],
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadJobDrilldown = async (targetJobId: string) => {
|
const analyticsQuery = useQuery<TracerAnalyticsResponse>({
|
||||||
if (!targetJobId) {
|
queryKey: queryKeys.tracer.analytics(query),
|
||||||
setError("Enter a Job ID to load link drilldown.");
|
queryFn: () => api.getTracerAnalytics(query),
|
||||||
setJobDrilldown(null);
|
});
|
||||||
return;
|
const analytics = analyticsQuery.data ?? null;
|
||||||
}
|
const isLoading = analyticsQuery.isPending;
|
||||||
|
|
||||||
try {
|
const jobDrilldownQuery = useQuery({
|
||||||
setIsDrilldownLoading(true);
|
queryKey: queryKeys.tracer.jobLinks(selectedDrilldownJobId ?? "", {
|
||||||
setError(null);
|
from: query.from,
|
||||||
const response = await api.getJobTracerLinks(targetJobId, {
|
to: query.to,
|
||||||
|
includeBots,
|
||||||
|
}),
|
||||||
|
queryFn: () =>
|
||||||
|
api.getJobTracerLinks(selectedDrilldownJobId ?? "", {
|
||||||
from: query.from,
|
from: query.from,
|
||||||
to: query.to,
|
to: query.to,
|
||||||
includeBots,
|
includeBots,
|
||||||
});
|
}),
|
||||||
setJobDrilldown(response);
|
enabled: Boolean(isDrilldownOpen && selectedDrilldownJobId),
|
||||||
} catch (fetchError) {
|
});
|
||||||
const message =
|
const jobDrilldown = jobDrilldownQuery.data ?? null;
|
||||||
fetchError instanceof Error
|
const isDrilldownLoading =
|
||||||
? fetchError.message
|
jobDrilldownQuery.isPending || jobDrilldownQuery.isFetching;
|
||||||
: "Failed to load job tracer links.";
|
const error =
|
||||||
setError(message);
|
analyticsQuery.error instanceof Error
|
||||||
setJobDrilldown(null);
|
? analyticsQuery.error.message
|
||||||
} finally {
|
: jobDrilldownQuery.error instanceof Error
|
||||||
setIsDrilldownLoading(false);
|
? jobDrilldownQuery.error.message
|
||||||
}
|
: null;
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
api
|
|
||||||
.getTracerAnalytics(query)
|
|
||||||
.then((response) => {
|
|
||||||
if (!isMounted) return;
|
|
||||||
setAnalytics(response);
|
|
||||||
})
|
|
||||||
.catch((fetchError) => {
|
|
||||||
if (!isMounted) return;
|
|
||||||
const message =
|
|
||||||
fetchError instanceof Error
|
|
||||||
? fetchError.message
|
|
||||||
: "Failed to load tracer analytics.";
|
|
||||||
setError(message);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!isMounted) return;
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
const chartData = analytics?.timeSeries ?? [];
|
const chartData = analytics?.timeSeries ?? [];
|
||||||
const totalViews = analytics?.totals.clicks ?? 0;
|
const totalViews = analytics?.totals.clicks ?? 0;
|
||||||
@ -271,8 +241,8 @@ export const TracerLinksPage: React.FC = () => {
|
|||||||
drilldownMode === "human" ? row.humanClicks : row.clicks;
|
drilldownMode === "human" ? row.humanClicks : row.clicks;
|
||||||
|
|
||||||
const handleSelectTopJob = (job: TracerAnalyticsTopJob) => {
|
const handleSelectTopJob = (job: TracerAnalyticsTopJob) => {
|
||||||
|
setSelectedDrilldownJobId(job.jobId);
|
||||||
setIsDrilldownOpen(true);
|
setIsDrilldownOpen(true);
|
||||||
void loadJobDrilldown(job.jobId);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
|
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||||
import { TrackingInboxPage } from "./TrackingInboxPage";
|
import { TrackingInboxPage } from "./TrackingInboxPage";
|
||||||
|
|
||||||
|
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||||
|
renderWithQueryClient(ui);
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../api", () => ({
|
||||||
postApplicationProviderStatus: vi.fn(),
|
postApplicationProviderStatus: vi.fn(),
|
||||||
getPostApplicationInbox: vi.fn(),
|
getPostApplicationInbox: vi.fn(),
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type {
|
|||||||
PostApplicationSyncRun,
|
PostApplicationSyncRun,
|
||||||
} from "@shared/types";
|
} from "@shared/types";
|
||||||
import { POST_APPLICATION_PROVIDERS } from "@shared/types";
|
import { POST_APPLICATION_PROVIDERS } from "@shared/types";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Inbox,
|
Inbox,
|
||||||
@ -18,6 +19,8 @@ import {
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast";
|
||||||
|
import { queryKeys } from "@/client/lib/queryKeys";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -57,6 +60,8 @@ const PROVIDER_OPTIONS: PostApplicationProvider[] = [
|
|||||||
];
|
];
|
||||||
const GMAIL_OAUTH_RESULT_TYPE = "gmail-oauth-result";
|
const GMAIL_OAUTH_RESULT_TYPE = "gmail-oauth-result";
|
||||||
const GMAIL_OAUTH_TIMEOUT_MS = 3 * 60 * 1000;
|
const GMAIL_OAUTH_TIMEOUT_MS = 3 * 60 * 1000;
|
||||||
|
const EMPTY_INBOX_ITEMS: PostApplicationInboxItem[] = [];
|
||||||
|
const EMPTY_SYNC_RUNS: PostApplicationSyncRun[] = [];
|
||||||
|
|
||||||
type GmailOauthResultMessage = {
|
type GmailOauthResultMessage = {
|
||||||
type: string;
|
type: string;
|
||||||
@ -76,81 +81,106 @@ export const TrackingInboxPage: React.FC = () => {
|
|||||||
const [maxMessages, setMaxMessages] = useState("100");
|
const [maxMessages, setMaxMessages] = useState("100");
|
||||||
const [searchDays, setSearchDays] = useState("90");
|
const [searchDays, setSearchDays] = useState("90");
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||||
const [activeAction, setActiveAction] = useState<
|
const [activeAction, setActiveAction] = useState<
|
||||||
"connect" | "sync" | "disconnect" | null
|
"connect" | "sync" | "disconnect" | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const [status, setStatus] = useState<
|
|
||||||
| Awaited<ReturnType<typeof api.postApplicationProviderStatus>>["status"]
|
|
||||||
| null
|
|
||||||
>(null);
|
|
||||||
const [inbox, setInbox] = useState<PostApplicationInboxItem[]>([]);
|
|
||||||
const [runs, setRuns] = useState<PostApplicationSyncRun[]>([]);
|
|
||||||
const [isRunModalOpen, setIsRunModalOpen] = useState(false);
|
const [isRunModalOpen, setIsRunModalOpen] = useState(false);
|
||||||
const [isRunMessagesLoading, setIsRunMessagesLoading] = useState(false);
|
|
||||||
const [selectedRun, setSelectedRun] = useState<PostApplicationSyncRun | null>(
|
const [selectedRun, setSelectedRun] = useState<PostApplicationSyncRun | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [selectedRunItems, setSelectedRunItems] = useState<
|
|
||||||
PostApplicationInboxItem[]
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
const [appliedJobByMessageId, setAppliedJobByMessageId] = useState<
|
const [appliedJobByMessageId, setAppliedJobByMessageId] = useState<
|
||||||
Record<string, string>
|
Record<string, string>
|
||||||
>({});
|
>({});
|
||||||
const [appliedJobs, setAppliedJobs] = useState<JobListItem[]>([]);
|
const statusQuery = useQuery({
|
||||||
const [isAppliedJobsLoading, setIsAppliedJobsLoading] = useState(false);
|
queryKey: queryKeys.postApplication.providerStatus(provider, accountKey),
|
||||||
const [hasAttemptedAppliedJobsLoad, setHasAttemptedAppliedJobsLoad] =
|
queryFn: () => api.postApplicationProviderStatus({ provider, accountKey }),
|
||||||
useState(false);
|
enabled: Boolean(provider && accountKey),
|
||||||
|
});
|
||||||
|
const inboxQuery = useQuery({
|
||||||
|
queryKey: queryKeys.postApplication.inbox(provider, accountKey, 100),
|
||||||
|
queryFn: () =>
|
||||||
|
api.getPostApplicationInbox({ provider, accountKey, limit: 100 }),
|
||||||
|
enabled: Boolean(provider && accountKey),
|
||||||
|
});
|
||||||
|
const runsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.postApplication.runs(provider, accountKey, 20),
|
||||||
|
queryFn: () =>
|
||||||
|
api.getPostApplicationRuns({ provider, accountKey, limit: 20 }),
|
||||||
|
enabled: Boolean(provider && accountKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = statusQuery.data?.status ?? null;
|
||||||
|
const inbox = inboxQuery.data?.items ?? EMPTY_INBOX_ITEMS;
|
||||||
|
const runs = runsQuery.data?.runs ?? EMPTY_SYNC_RUNS;
|
||||||
|
|
||||||
|
const runMessagesQuery = useQuery({
|
||||||
|
queryKey: queryKeys.postApplication.runMessages(
|
||||||
|
selectedRun?.id ?? "",
|
||||||
|
provider,
|
||||||
|
accountKey,
|
||||||
|
),
|
||||||
|
queryFn: () =>
|
||||||
|
api.getPostApplicationRunMessages({
|
||||||
|
runId: selectedRun?.id ?? "",
|
||||||
|
provider,
|
||||||
|
accountKey,
|
||||||
|
}),
|
||||||
|
enabled: Boolean(
|
||||||
|
isRunModalOpen && selectedRun?.id && provider && accountKey,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const selectedRunItems = runMessagesQuery.data?.items ?? EMPTY_INBOX_ITEMS;
|
||||||
|
const isRunMessagesLoading =
|
||||||
|
runMessagesQuery.isPending || runMessagesQuery.isFetching;
|
||||||
|
|
||||||
|
const hasReviewItems = useMemo(
|
||||||
|
() => inbox.length > 0 || selectedRunItems.length > 0,
|
||||||
|
[inbox.length, selectedRunItems.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
const appliedJobsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.jobs.list({
|
||||||
|
statuses: ["applied", "in_progress"],
|
||||||
|
view: "list",
|
||||||
|
}),
|
||||||
|
queryFn: () =>
|
||||||
|
api.getJobs({
|
||||||
|
statuses: ["applied", "in_progress"],
|
||||||
|
view: "list",
|
||||||
|
}),
|
||||||
|
enabled: hasReviewItems,
|
||||||
|
});
|
||||||
|
const appliedJobs = useMemo<JobListItem[]>(
|
||||||
|
() =>
|
||||||
|
(appliedJobsQuery.data?.jobs ?? []).filter(
|
||||||
|
(job) => job.status === "applied" || job.status === "in_progress",
|
||||||
|
),
|
||||||
|
[appliedJobsQuery.data?.jobs],
|
||||||
|
);
|
||||||
|
const isAppliedJobsLoading =
|
||||||
|
appliedJobsQuery.isPending || appliedJobsQuery.isFetching;
|
||||||
|
|
||||||
const [bulkActionDialog, setBulkActionDialog] = useState<{
|
const [bulkActionDialog, setBulkActionDialog] = useState<{
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
action: "approve" | "deny" | null;
|
action: "approve" | "deny" | null;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
}>({ isOpen: false, action: null, itemCount: 0 });
|
}>({ isOpen: false, action: null, itemCount: 0 });
|
||||||
|
const isLoading =
|
||||||
const loadAppliedJobs = useCallback(async () => {
|
statusQuery.isPending || inboxQuery.isPending || runsQuery.isPending;
|
||||||
if (hasAttemptedAppliedJobsLoad || isAppliedJobsLoading) return;
|
|
||||||
setHasAttemptedAppliedJobsLoad(true);
|
|
||||||
setIsAppliedJobsLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await api.getJobs({
|
|
||||||
statuses: ["applied", "in_progress"],
|
|
||||||
view: "list",
|
|
||||||
});
|
|
||||||
setAppliedJobs(
|
|
||||||
response.jobs.filter(
|
|
||||||
(job) => job.status === "applied" || job.status === "in_progress",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : "Failed to load jobs";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setIsAppliedJobsLoading(false);
|
|
||||||
}
|
|
||||||
}, [hasAttemptedAppliedJobsLoad, isAppliedJobsLoading]);
|
|
||||||
|
|
||||||
const loadAll = useCallback(async () => {
|
|
||||||
const [statusRes, inboxRes, runsRes] = await Promise.all([
|
|
||||||
api.postApplicationProviderStatus({ provider, accountKey }),
|
|
||||||
api.getPostApplicationInbox({ provider, accountKey, limit: 100 }),
|
|
||||||
api.getPostApplicationRuns({ provider, accountKey, limit: 20 }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
setStatus(statusRes.status);
|
|
||||||
setInbox(inboxRes.items);
|
|
||||||
setRuns(runsRes.runs);
|
|
||||||
}, [provider, accountKey]);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
try {
|
try {
|
||||||
await loadAll();
|
await Promise.all([
|
||||||
|
statusQuery.refetch(),
|
||||||
|
inboxQuery.refetch(),
|
||||||
|
runsQuery.refetch(),
|
||||||
|
hasReviewItems ? appliedJobsQuery.refetch() : Promise.resolve(),
|
||||||
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
@ -159,36 +189,19 @@ export const TrackingInboxPage: React.FC = () => {
|
|||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [loadAll]);
|
}, [appliedJobsQuery, hasReviewItems, inboxQuery, runsQuery, statusQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsLoading(true);
|
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!provider || !accountKey) return;
|
if (!provider || !accountKey) return;
|
||||||
setAppliedJobs([]);
|
|
||||||
setAppliedJobByMessageId({});
|
setAppliedJobByMessageId({});
|
||||||
setHasAttemptedAppliedJobsLoad(false);
|
|
||||||
}, [provider, accountKey]);
|
}, [provider, accountKey]);
|
||||||
|
|
||||||
const hasReviewItems = useMemo(
|
|
||||||
() => inbox.length > 0 || selectedRunItems.length > 0,
|
|
||||||
[inbox.length, selectedRunItems.length],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasReviewItems) return;
|
|
||||||
void loadAppliedJobs();
|
|
||||||
}, [hasReviewItems, loadAppliedJobs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const defaultAppliedJobId = appliedJobs[0]?.id ?? "";
|
const defaultAppliedJobId = appliedJobs[0]?.id ?? "";
|
||||||
setAppliedJobByMessageId((previous) => {
|
setAppliedJobByMessageId((previous) => {
|
||||||
const next = { ...previous };
|
const next = { ...previous };
|
||||||
|
let didChange = false;
|
||||||
for (const item of [...inbox, ...selectedRunItems]) {
|
for (const item of [...inbox, ...selectedRunItems]) {
|
||||||
const selectedJobId = next[item.message.id];
|
const selectedJobId = next[item.message.id];
|
||||||
const hasValidSelection = appliedJobs.some(
|
const hasValidSelection = appliedJobs.some(
|
||||||
@ -199,12 +212,16 @@ export const TrackingInboxPage: React.FC = () => {
|
|||||||
const hasValidMatchedJob = appliedJobs.some(
|
const hasValidMatchedJob = appliedJobs.some(
|
||||||
(appliedJob) => appliedJob.id === matchedJobId,
|
(appliedJob) => appliedJob.id === matchedJobId,
|
||||||
);
|
);
|
||||||
next[item.message.id] = hasValidMatchedJob
|
const nextJobId = hasValidMatchedJob
|
||||||
? matchedJobId
|
? matchedJobId
|
||||||
: defaultAppliedJobId;
|
: defaultAppliedJobId;
|
||||||
|
if (next[item.message.id] !== nextJobId) {
|
||||||
|
next[item.message.id] = nextJobId;
|
||||||
|
didChange = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return next;
|
return didChange ? next : previous;
|
||||||
});
|
});
|
||||||
}, [appliedJobs, inbox, selectedRunItems]);
|
}, [appliedJobs, inbox, selectedRunItems]);
|
||||||
|
|
||||||
@ -487,32 +504,24 @@ export const TrackingInboxPage: React.FC = () => {
|
|||||||
[inbox],
|
[inbox],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenRunMessages = useCallback(
|
const handleOpenRunMessages = useCallback((run: PostApplicationSyncRun) => {
|
||||||
async (run: PostApplicationSyncRun) => {
|
setSelectedRun(run);
|
||||||
setSelectedRun(run);
|
setIsRunModalOpen(true);
|
||||||
setSelectedRunItems([]);
|
}, []);
|
||||||
setIsRunModalOpen(true);
|
|
||||||
setIsRunMessagesLoading(true);
|
|
||||||
|
|
||||||
try {
|
useQueryErrorToast(
|
||||||
const response = await api.getPostApplicationRunMessages({
|
statusQuery.error,
|
||||||
runId: run.id,
|
"Failed to load provider connection status",
|
||||||
provider,
|
);
|
||||||
accountKey,
|
useQueryErrorToast(inboxQuery.error, "Failed to load inbox");
|
||||||
});
|
useQueryErrorToast(runsQuery.error, "Failed to load sync runs");
|
||||||
setSelectedRun(response.run);
|
useQueryErrorToast(
|
||||||
setSelectedRunItems(response.items);
|
appliedJobsQuery.error,
|
||||||
} catch (error) {
|
"Failed to load jobs for inbox matching",
|
||||||
const message =
|
);
|
||||||
error instanceof Error
|
useQueryErrorToast(
|
||||||
? error.message
|
runMessagesQuery.error,
|
||||||
: "Failed to load messages for selected sync run";
|
"Failed to load messages for selected sync run",
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setIsRunMessagesLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[accountKey, provider],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const pendingCount = inbox.length;
|
const pendingCount = inbox.length;
|
||||||
@ -789,7 +798,6 @@ export const TrackingInboxPage: React.FC = () => {
|
|||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setIsRunModalOpen(open);
|
setIsRunModalOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setSelectedRunItems([]);
|
|
||||||
setSelectedRun(null);
|
setSelectedRun(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import type {
|
|||||||
VisaSponsorSearchResult,
|
VisaSponsorSearchResult,
|
||||||
VisaSponsorStatusResponse,
|
VisaSponsorStatusResponse,
|
||||||
} from "@shared/types.js";
|
} from "@shared/types.js";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Building2,
|
Building2,
|
||||||
@ -23,8 +24,10 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast";
|
||||||
|
import { queryKeys } from "@/client/lib/queryKeys";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
|
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
|
||||||
@ -56,18 +59,13 @@ const getScoreTokens = (score: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const VisaSponsorsPage: React.FC = () => {
|
export const VisaSponsorsPage: React.FC = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
// State
|
// State
|
||||||
const [status, setStatus] = useState<VisaSponsorStatusResponse | null>(null);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [results, setResults] = useState<VisaSponsorSearchResult[]>([]);
|
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
|
||||||
const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
|
const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
|
||||||
const [orgDetails, setOrgDetails] = useState<VisaSponsor[]>([]);
|
|
||||||
|
|
||||||
// Loading states
|
// Loading states
|
||||||
const [isLoadingStatus, setIsLoadingStatus] = useState(true);
|
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
|
||||||
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
|
|
||||||
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
|
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
|
||||||
const [isDesktop, setIsDesktop] = useState(() =>
|
const [isDesktop, setIsDesktop] = useState(() =>
|
||||||
typeof window !== "undefined"
|
typeof window !== "undefined"
|
||||||
@ -75,80 +73,56 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
: false,
|
: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch organization details
|
const statusQuery = useQuery<VisaSponsorStatusResponse>({
|
||||||
const fetchOrgDetails = useCallback(async (orgName: string) => {
|
queryKey: queryKeys.visaSponsors.status(),
|
||||||
setIsLoadingDetails(true);
|
queryFn: api.getVisaSponsorStatus,
|
||||||
setSelectedOrg(orgName);
|
});
|
||||||
try {
|
const status = statusQuery.data ?? null;
|
||||||
const details = await api.getVisaSponsorOrganization(orgName);
|
useQueryErrorToast(statusQuery.error, "Failed to fetch status");
|
||||||
setOrgDetails(details);
|
|
||||||
} catch (err) {
|
|
||||||
const message =
|
|
||||||
err instanceof Error ? err.message : "Failed to fetch details";
|
|
||||||
toast.error(message);
|
|
||||||
setOrgDetails([]);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingDetails(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchStatus = useCallback(async () => {
|
|
||||||
setIsLoadingStatus(true);
|
|
||||||
try {
|
|
||||||
const data = await api.getVisaSponsorStatus();
|
|
||||||
setStatus(data);
|
|
||||||
} catch (err) {
|
|
||||||
const message =
|
|
||||||
err instanceof Error ? err.message : "Failed to fetch status";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingStatus(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch status on mount
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStatus();
|
|
||||||
}, [fetchStatus]);
|
|
||||||
|
|
||||||
// Search with debounce
|
|
||||||
const handleSearch = useCallback(async (query: string) => {
|
|
||||||
if (!query.trim()) {
|
|
||||||
setResults([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSearching(true);
|
|
||||||
try {
|
|
||||||
const response = await api.searchVisaSponsors({
|
|
||||||
query: query.trim(),
|
|
||||||
limit: 100,
|
|
||||||
minScore: 20,
|
|
||||||
});
|
|
||||||
setResults(response.results);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : "Search failed";
|
|
||||||
toast.error(message);
|
|
||||||
setResults([]);
|
|
||||||
} finally {
|
|
||||||
setIsSearching(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Debounced search effect
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
handleSearch(searchQuery);
|
setDebouncedSearchQuery(searchQuery);
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchQuery, handleSearch]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const searchQueryResult = useQuery({
|
||||||
|
queryKey: queryKeys.visaSponsors.search(
|
||||||
|
debouncedSearchQuery.trim(),
|
||||||
|
100,
|
||||||
|
20,
|
||||||
|
),
|
||||||
|
queryFn: () =>
|
||||||
|
api.searchVisaSponsors({
|
||||||
|
query: debouncedSearchQuery.trim(),
|
||||||
|
limit: 100,
|
||||||
|
minScore: 20,
|
||||||
|
}),
|
||||||
|
enabled: Boolean(debouncedSearchQuery.trim()),
|
||||||
|
});
|
||||||
|
useQueryErrorToast(searchQueryResult.error, "Search failed");
|
||||||
|
|
||||||
|
const orgDetailsQuery = useQuery<VisaSponsor[]>({
|
||||||
|
queryKey: queryKeys.visaSponsors.organization(selectedOrg ?? ""),
|
||||||
|
queryFn: () =>
|
||||||
|
selectedOrg
|
||||||
|
? api.getVisaSponsorOrganization(selectedOrg)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
enabled: Boolean(selectedOrg),
|
||||||
|
});
|
||||||
|
const orgDetails = orgDetailsQuery.data ?? [];
|
||||||
|
useQueryErrorToast(orgDetailsQuery.error, "Failed to fetch details");
|
||||||
|
|
||||||
|
const results = useMemo<VisaSponsorSearchResult[]>(() => {
|
||||||
|
if (!debouncedSearchQuery.trim()) return [];
|
||||||
|
return searchQueryResult.data?.results ?? [];
|
||||||
|
}, [debouncedSearchQuery, searchQueryResult.data]);
|
||||||
|
|
||||||
// Auto-select first result
|
// Auto-select first result
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
setSelectedOrg(null);
|
setSelectedOrg(null);
|
||||||
setOrgDetails([]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@ -157,9 +131,8 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
) {
|
) {
|
||||||
const firstOrg = results[0].sponsor.organisationName;
|
const firstOrg = results[0].sponsor.organisationName;
|
||||||
setSelectedOrg(firstOrg);
|
setSelectedOrg(firstOrg);
|
||||||
fetchOrgDetails(firstOrg);
|
|
||||||
}
|
}
|
||||||
}, [results, fetchOrgDetails, selectedOrg]);
|
}, [results, selectedOrg]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedOrg) {
|
if (!selectedOrg) {
|
||||||
@ -187,25 +160,33 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
}, [isDesktop, isDetailDrawerOpen]);
|
}, [isDesktop, isDetailDrawerOpen]);
|
||||||
|
|
||||||
// Trigger manual update
|
// Trigger manual update
|
||||||
const handleUpdate = async () => {
|
const updateListMutation = useMutation({
|
||||||
setIsUpdating(true);
|
mutationFn: api.updateVisaSponsorList,
|
||||||
try {
|
onSuccess: async (result) => {
|
||||||
const result = await api.updateVisaSponsorList();
|
queryClient.setQueryData(queryKeys.visaSponsors.status(), result.status);
|
||||||
setStatus(result.status);
|
if (debouncedSearchQuery.trim()) {
|
||||||
toast.success(result.message);
|
await queryClient.invalidateQueries({
|
||||||
if (searchQuery.trim()) {
|
queryKey: queryKeys.visaSponsors.search(
|
||||||
handleSearch(searchQuery);
|
debouncedSearchQuery.trim(),
|
||||||
|
100,
|
||||||
|
20,
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
toast.success(result.message);
|
||||||
const message = err instanceof Error ? err.message : "Update failed";
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = error instanceof Error ? error.message : "Update failed";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
},
|
||||||
setIsUpdating(false);
|
});
|
||||||
}
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
await updateListMutation.mutateAsync();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectOrg = (orgName: string) => {
|
const handleSelectOrg = (orgName: string) => {
|
||||||
fetchOrgDetails(orgName);
|
setSelectedOrg(orgName);
|
||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
setIsDetailDrawerOpen(true);
|
setIsDetailDrawerOpen(true);
|
||||||
}
|
}
|
||||||
@ -217,7 +198,10 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
[results, selectedOrg],
|
[results, selectedOrg],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isUpdateInProgress = isUpdating || status?.isUpdating;
|
const isUpdateInProgress = updateListMutation.isPending || status?.isUpdating;
|
||||||
|
const isLoadingStatus = statusQuery.isLoading;
|
||||||
|
const isSearching = searchQueryResult.isFetching;
|
||||||
|
const isLoadingDetails = orgDetailsQuery.isLoading;
|
||||||
|
|
||||||
const detailPanelContent = !selectedOrg ? (
|
const detailPanelContent = !selectedOrg ? (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
|
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
|
||||||
@ -412,9 +396,9 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleUpdate}
|
onClick={handleUpdate}
|
||||||
disabled={isUpdating}
|
disabled={isUpdateInProgress}
|
||||||
>
|
>
|
||||||
{isUpdating ? (
|
{isUpdateInProgress ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
Downloading...
|
Downloading...
|
||||||
|
|||||||
@ -1,17 +1,15 @@
|
|||||||
import { createJob } from "@shared/testing/factories.js";
|
import { createJob } from "@shared/testing/factories.js";
|
||||||
import type { Job } from "@shared/types.js";
|
import type { Job } from "@shared/types.js";
|
||||||
import {
|
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
act,
|
|
||||||
fireEvent,
|
|
||||||
render,
|
|
||||||
screen,
|
|
||||||
waitFor,
|
|
||||||
} from "@testing-library/react";
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
|
import { renderWithQueryClient } from "../../test/renderWithQueryClient";
|
||||||
import { JobDetailPanel } from "./JobDetailPanel";
|
import { JobDetailPanel } from "./JobDetailPanel";
|
||||||
|
|
||||||
|
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||||
|
renderWithQueryClient(ui);
|
||||||
|
|
||||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||||
return {
|
return {
|
||||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
|
import { renderHookWithQueryClient } from "../../test/renderWithQueryClient";
|
||||||
import { useOrchestratorData } from "./useOrchestratorData";
|
import { useOrchestratorData } from "./useOrchestratorData";
|
||||||
|
|
||||||
|
const renderHook = (callback: () => ReturnType<typeof useOrchestratorData>) =>
|
||||||
|
renderHookWithQueryClient(callback);
|
||||||
|
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
getJobs: vi.fn(),
|
getJobs: vi.fn(),
|
||||||
getJobsRevision: vi.fn(),
|
getJobsRevision: vi.fn(),
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import type { Job, JobListItem, JobStatus } from "@shared/types";
|
import type { Job, JobListItem, JobStatus } from "@shared/types";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { queryKeys } from "@/client/lib/queryKeys";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
import { subscribeToEventSource } from "../../lib/sse";
|
import { subscribeToEventSource } from "../../lib/sse";
|
||||||
|
|
||||||
@ -79,6 +81,7 @@ const buildTerminalSignature = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useOrchestratorData = (selectedJobId: string | null) => {
|
export const useOrchestratorData = (selectedJobId: string | null) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [jobListItems, setJobListItems] = useState<JobListItem[]>([]);
|
const [jobListItems, setJobListItems] = useState<JobListItem[]>([]);
|
||||||
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
|
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
|
||||||
const [stats, setStats] = useState<Record<JobStatus, number>>(initialStats);
|
const [stats, setStats] = useState<Record<JobStatus, number>>(initialStats);
|
||||||
@ -166,7 +169,11 @@ export const useOrchestratorData = (selectedJobId: string | null) => {
|
|||||||
async (jobId: string) => {
|
async (jobId: string) => {
|
||||||
const seq = ++selectedJobRequestSeqRef.current;
|
const seq = ++selectedJobRequestSeqRef.current;
|
||||||
try {
|
try {
|
||||||
const fullJob = await api.getJob(jobId);
|
const fullJob = await queryClient.fetchQuery({
|
||||||
|
queryKey: queryKeys.jobs.detail(jobId),
|
||||||
|
queryFn: () => api.getJob(jobId),
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
selectedJobCacheRef.current.set(jobId, fullJob);
|
selectedJobCacheRef.current.set(jobId, fullJob);
|
||||||
if (
|
if (
|
||||||
selectedJobId === jobId &&
|
selectedJobId === jobId &&
|
||||||
@ -182,7 +189,7 @@ export const useOrchestratorData = (selectedJobId: string | null) => {
|
|||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedJobId],
|
[queryClient, selectedJobId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadJobs = useCallback(async () => {
|
const loadJobs = useCallback(async () => {
|
||||||
@ -191,6 +198,7 @@ export const useOrchestratorData = (selectedJobId: string | null) => {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const data = await api.getJobs({ view: "list" });
|
const data = await api.getJobs({ view: "list" });
|
||||||
|
queryClient.setQueryData(queryKeys.jobs.list({ view: "list" }), data);
|
||||||
if (seq >= latestAppliedSeqRef.current) {
|
if (seq >= latestAppliedSeqRef.current) {
|
||||||
latestAppliedSeqRef.current = seq;
|
latestAppliedSeqRef.current = seq;
|
||||||
setJobListItems(data.jobs);
|
setJobListItems(data.jobs);
|
||||||
@ -210,11 +218,15 @@ export const useOrchestratorData = (selectedJobId: string | null) => {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, [queryClient]);
|
||||||
|
|
||||||
const checkPipelineStatus = useCallback(async () => {
|
const checkPipelineStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const status = await api.getPipelineStatus();
|
const status = await queryClient.fetchQuery({
|
||||||
|
queryKey: queryKeys.pipeline.status(),
|
||||||
|
queryFn: () => api.getPipelineStatus(),
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
const terminalStatus = status.lastRun?.status;
|
const terminalStatus = status.lastRun?.status;
|
||||||
|
|
||||||
if (status.isRunning) {
|
if (status.isRunning) {
|
||||||
@ -247,24 +259,31 @@ export const useOrchestratorData = (selectedJobId: string | null) => {
|
|||||||
} catch {
|
} catch {
|
||||||
// Ignore errors
|
// Ignore errors
|
||||||
}
|
}
|
||||||
}, [observePipelineState]);
|
}, [observePipelineState, queryClient]);
|
||||||
|
|
||||||
const checkForJobChanges = useCallback(async () => {
|
const checkForJobChanges = useCallback(async () => {
|
||||||
if (isRefreshPaused || !isDocumentVisible()) return;
|
if (isRefreshPaused || !isDocumentVisible()) return;
|
||||||
try {
|
try {
|
||||||
const revision = await api.getJobsRevision();
|
const revision = await queryClient.fetchQuery({
|
||||||
|
queryKey: queryKeys.jobs.revision(),
|
||||||
|
queryFn: () => api.getJobsRevision(),
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
const previousRevision = lastRevisionRef.current;
|
const previousRevision = lastRevisionRef.current;
|
||||||
if (previousRevision === null) {
|
if (previousRevision === null) {
|
||||||
lastRevisionRef.current = revision.revision;
|
lastRevisionRef.current = revision.revision;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (revision.revision !== previousRevision) {
|
if (revision.revision !== previousRevision) {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.jobs.all,
|
||||||
|
});
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore errors
|
// Ignore errors
|
||||||
}
|
}
|
||||||
}, [isRefreshPaused, loadJobs]);
|
}, [isRefreshPaused, loadJobs, queryClient]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadJobs();
|
void loadJobs();
|
||||||
|
|||||||
45
orchestrator/src/client/test/renderWithQueryClient.tsx
Normal file
45
orchestrator/src/client/test/renderWithQueryClient.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { type RenderOptions, render, renderHook } from "@testing-library/react";
|
||||||
|
import type { ReactElement, ReactNode } from "react";
|
||||||
|
|
||||||
|
export function createTestQueryClient() {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
gcTime: 0,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderWithQueryClient(
|
||||||
|
ui: ReactElement,
|
||||||
|
options?: Omit<RenderOptions, "wrapper">,
|
||||||
|
) {
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
|
||||||
|
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryClient,
|
||||||
|
...render(ui, { wrapper: Wrapper, ...options }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderHookWithQueryClient<TResult>(callback: () => TResult) {
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const rendered = renderHook(callback, { wrapper: Wrapper });
|
||||||
|
return {
|
||||||
|
queryClient,
|
||||||
|
...rendered,
|
||||||
|
};
|
||||||
|
}
|
||||||
27
package-lock.json
generated
27
package-lock.json
generated
@ -7913,6 +7913,32 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/query-core": {
|
||||||
|
"version": "5.90.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||||
|
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query": {
|
||||||
|
"version": "5.90.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
||||||
|
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.90.20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tokenizer/inflate": {
|
"node_modules/@tokenizer/inflate": {
|
||||||
"version": "0.2.7",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
|
||||||
@ -24629,6 +24655,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"better-sqlite3": "^11.6.0",
|
"better-sqlite3": "^11.6.0",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user