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
|
||||
|
||||
- [Settings](/docs/features/settings)
|
||||
- [Reactive Resume](/docs/features/reactive-resume)
|
||||
- [Find Jobs and Apply Workflow](/docs/workflows/find-jobs-and-apply-workflow)
|
||||
- [Post-Application Tracking](/docs/features/post-application-tracking)
|
||||
- [Settings](/docs/next/features/settings)
|
||||
- [Reactive Resume](/docs/next/features/reactive-resume)
|
||||
- [Find Jobs and Apply Workflow](/docs/next/workflows/find-jobs-and-apply-workflow)
|
||||
- [Post-Application Tracking](/docs/next/features/post-application-tracking)
|
||||
|
||||
@ -35,6 +35,7 @@ const sidebars: SidebarsConfig = {
|
||||
"features/ghostwriter",
|
||||
"features/post-application-tracking",
|
||||
"features/visa-sponsors",
|
||||
"features/tracer-links",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -32,7 +32,8 @@
|
||||
"features/in-progress-board",
|
||||
"features/ghostwriter",
|
||||
"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-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
import { createJob } from "@shared/testing/factories.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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api";
|
||||
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
|
||||
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||
import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer";
|
||||
|
||||
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||
renderWithQueryClient(ui);
|
||||
|
||||
vi.mock("@/components/ui/sheet", () => ({
|
||||
Sheet: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div>{children}</div> : null,
|
||||
@ -166,7 +170,11 @@ describe("JobDetailsEditDrawer", () => {
|
||||
);
|
||||
|
||||
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 }));
|
||||
|
||||
await waitFor(() =>
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import * as api from "@client/api";
|
||||
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||
import { OnboardingGate } from "./OnboardingGate";
|
||||
|
||||
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||
renderWithQueryClient(ui);
|
||||
|
||||
vi.mock("@client/api", () => ({
|
||||
getDemoInfo: vi.fn(),
|
||||
validateLlm: vi.fn(),
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
import { createJob } from "@shared/testing/factories.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 { MemoryRouter } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api";
|
||||
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||
import { ReadyPanel } from "./ReadyPanel";
|
||||
|
||||
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||
renderWithQueryClient(ui);
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||
return {
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
import { createJob as createBaseJob } from "@shared/testing/factories.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 * as api from "../api";
|
||||
import { useProfile } from "../hooks/useProfile";
|
||||
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
|
||||
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||
import { TailoringEditor } from "./TailoringEditor";
|
||||
|
||||
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||
renderWithQueryClient(ui);
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
getResumeProjectsCatalog: vi.fn().mockResolvedValue([]),
|
||||
updateJob: vi.fn().mockResolvedValue({}),
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
import { createJob } from "@shared/testing/factories.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 { MemoryRouter } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../../api";
|
||||
import { renderWithQueryClient } from "../../test/renderWithQueryClient";
|
||||
import { DiscoveredPanel } from "./DiscoveredPanel";
|
||||
|
||||
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||
renderWithQueryClient(ui);
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||
return {
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
import { createJob as createBaseJob } from "@shared/testing/factories.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 * as api from "../../api";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { _resetTracerReadinessCache } from "../../hooks/useTracerReadiness";
|
||||
import { renderWithQueryClient } from "../../test/renderWithQueryClient";
|
||||
import { TailorMode } from "./TailorMode";
|
||||
|
||||
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||
renderWithQueryClient(ui);
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
getResumeProjectsCatalog: vi.fn().mockResolvedValue([]),
|
||||
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 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() {
|
||||
const [demoInfo, setDemoInfo] = useState<DemoInfoResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
void api
|
||||
.getDemoInfo()
|
||||
.then((info) => {
|
||||
if (!isCancelled) {
|
||||
setDemoInfo(info);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCancelled) {
|
||||
setDemoInfo(null);
|
||||
const { data } = useQuery<DemoInfoResponse | null>({
|
||||
queryKey: queryKeys.demo.info(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await api.getDemoInfo();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return demoInfo;
|
||||
return data ?? null;
|
||||
}
|
||||
|
||||
@ -1,98 +1,35 @@
|
||||
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";
|
||||
|
||||
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.
|
||||
* Caches the result to avoid re-fetching.
|
||||
*/
|
||||
export function useProfile() {
|
||||
const [profile, setProfile] = useState<ResumeProfile | null>(profileCache);
|
||||
const [error, setError] = useState<Error | null>(profileError);
|
||||
|
||||
useEffect(() => {
|
||||
if (profileCache) {
|
||||
setProfile(profileCache);
|
||||
}
|
||||
if (profileError) {
|
||||
setError(profileError);
|
||||
}
|
||||
|
||||
const handleUpdate = (
|
||||
newProfile: ResumeProfile | null,
|
||||
newError: Error | null,
|
||||
) => {
|
||||
setProfile(newProfile);
|
||||
setError(newError);
|
||||
};
|
||||
|
||||
subscribers.add(handleUpdate);
|
||||
|
||||
if (!profileCache && !isFetching) {
|
||||
isFetching = true;
|
||||
profileError = null;
|
||||
api
|
||||
.getProfile()
|
||||
.then((data) => {
|
||||
profileCache = data;
|
||||
profileError = null;
|
||||
subscribers.forEach((sub) => {
|
||||
sub(data, null);
|
||||
const {
|
||||
data: profile = null,
|
||||
error,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useQuery<ResumeProfile | null>({
|
||||
queryKey: queryKeys.profile.current(),
|
||||
queryFn: api.getProfile,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
profileError = err instanceof Error ? err : new Error(String(err));
|
||||
subscribers.forEach((sub) => {
|
||||
sub(profileCache, profileError);
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
isFetching = false;
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscribers.delete(handleUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshProfile = async () => {
|
||||
isFetching = true;
|
||||
profileError = null;
|
||||
subscribers.forEach((sub) => {
|
||||
sub(profileCache, null);
|
||||
});
|
||||
|
||||
try {
|
||||
const data = await api.getProfile();
|
||||
profileCache = data;
|
||||
profileError = null;
|
||||
subscribers.forEach((sub) => {
|
||||
sub(data, null);
|
||||
});
|
||||
return data;
|
||||
} catch (err) {
|
||||
profileError = err instanceof Error ? err : new Error(String(err));
|
||||
subscribers.forEach((sub) => {
|
||||
sub(profileCache, profileError);
|
||||
});
|
||||
throw profileError;
|
||||
} finally {
|
||||
isFetching = false;
|
||||
}
|
||||
const result = await refetch();
|
||||
if (result.error) throw result.error;
|
||||
return result.data ?? null;
|
||||
};
|
||||
|
||||
return {
|
||||
profile,
|
||||
error,
|
||||
isLoading: !profile && isFetching && !error,
|
||||
error: error ?? null,
|
||||
isLoading: isLoading || (!!isFetching && !profile && !error),
|
||||
personName: profile?.basics?.name || "Resume",
|
||||
refreshProfile,
|
||||
};
|
||||
@ -100,8 +37,5 @@ export function useProfile() {
|
||||
|
||||
/** @internal For testing only */
|
||||
export function _resetProfileCache() {
|
||||
profileCache = null;
|
||||
profileError = null;
|
||||
isFetching = false;
|
||||
subscribers.clear();
|
||||
appQueryClient.removeQueries({ queryKey: queryKeys.profile.all });
|
||||
}
|
||||
|
||||
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api";
|
||||
import { renderHookWithQueryClient } from "../test/renderWithQueryClient";
|
||||
import { useRescoreJob } from "./useRescoreJob";
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
@ -24,7 +25,9 @@ describe("useRescoreJob", () => {
|
||||
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(api.rescoreJob).mockResolvedValue({} as any);
|
||||
|
||||
const { result } = renderHook(() => useRescoreJob(onJobUpdated));
|
||||
const { result } = renderHookWithQueryClient(() =>
|
||||
useRescoreJob(onJobUpdated),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.rescoreJob("job-1");
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import * as api from "../api";
|
||||
import { useRescoreJobMutation } from "@/client/hooks/queries/useJobMutations";
|
||||
|
||||
export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
|
||||
const [isRescoring, setIsRescoring] = useState(false);
|
||||
const rescoreMutation = useRescoreJobMutation();
|
||||
|
||||
const rescoreJob = useCallback(
|
||||
async (jobId?: string | null) => {
|
||||
@ -12,7 +12,7 @@ export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
|
||||
|
||||
try {
|
||||
setIsRescoring(true);
|
||||
await api.rescoreJob(jobId);
|
||||
await rescoreMutation.mutateAsync(jobId);
|
||||
toast.success("Match recalculated");
|
||||
await onJobUpdated();
|
||||
} catch (error) {
|
||||
@ -25,7 +25,7 @@ export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
|
||||
setIsRescoring(false);
|
||||
}
|
||||
},
|
||||
[onJobUpdated],
|
||||
[onJobUpdated, rescoreMutation],
|
||||
);
|
||||
|
||||
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 * as api from "../api";
|
||||
import { renderHookWithQueryClient } from "../test/renderWithQueryClient";
|
||||
import { _resetSettingsCache, useSettings } from "./useSettings";
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
@ -17,7 +18,7 @@ describe("useSettings", () => {
|
||||
const mockSettings = { showSponsorInfo: false };
|
||||
vi.mocked(api.getSettings).mockResolvedValue(mockSettings as any);
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
const { result } = renderHookWithQueryClient(() => useSettings());
|
||||
|
||||
// Should start in loading state
|
||||
expect(result.current.settings).toBeNull();
|
||||
@ -33,7 +34,7 @@ describe("useSettings", () => {
|
||||
it("uses default values when settings are null", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(null as any);
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
const { result } = renderHookWithQueryClient(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
// 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(updatedSettings as any);
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
const { result } = renderHookWithQueryClient(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.settings).toEqual(initialSettings);
|
||||
@ -71,7 +72,7 @@ describe("useSettings", () => {
|
||||
const mockError = new Error("Failed to fetch");
|
||||
vi.mocked(api.getSettings).mockRejectedValue(mockError);
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
const { result } = renderHookWithQueryClient(() => useSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toEqual(mockError);
|
||||
|
||||
@ -1,94 +1,31 @@
|
||||
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";
|
||||
|
||||
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() {
|
||||
const [settings, setSettings] = useState<AppSettings | null>(settingsCache);
|
||||
const [error, setError] = useState<Error | null>(settingsError);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsCache) {
|
||||
setSettings(settingsCache);
|
||||
}
|
||||
if (settingsError) {
|
||||
setError(settingsError);
|
||||
}
|
||||
|
||||
const handleUpdate = (
|
||||
newSettings: AppSettings | null,
|
||||
newError: Error | null,
|
||||
) => {
|
||||
setSettings(newSettings);
|
||||
setError(newError);
|
||||
};
|
||||
|
||||
subscribers.add(handleUpdate);
|
||||
|
||||
if (!settingsCache && !isFetching) {
|
||||
isFetching = true;
|
||||
settingsError = null;
|
||||
api
|
||||
.getSettings()
|
||||
.then((data) => {
|
||||
settingsCache = data;
|
||||
settingsError = null;
|
||||
subscribers.forEach((sub) => {
|
||||
sub(data, null);
|
||||
const {
|
||||
data: settings = null,
|
||||
error,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useQuery<AppSettings | null>({
|
||||
queryKey: queryKeys.settings.current(),
|
||||
queryFn: api.getSettings,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
settingsError = err instanceof Error ? err : new Error(String(err));
|
||||
subscribers.forEach((sub) => {
|
||||
sub(settingsCache, settingsError);
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
isFetching = false;
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscribers.delete(handleUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshSettings = async () => {
|
||||
isFetching = true;
|
||||
settingsError = null;
|
||||
subscribers.forEach((sub) => {
|
||||
sub(settingsCache, null);
|
||||
});
|
||||
|
||||
try {
|
||||
const data = await api.getSettings();
|
||||
settingsCache = data;
|
||||
settingsError = null;
|
||||
subscribers.forEach((sub) => {
|
||||
sub(data, null);
|
||||
});
|
||||
return data;
|
||||
} catch (err) {
|
||||
settingsError = err instanceof Error ? err : new Error(String(err));
|
||||
subscribers.forEach((sub) => {
|
||||
sub(settingsCache, settingsError);
|
||||
});
|
||||
throw settingsError;
|
||||
} finally {
|
||||
isFetching = false;
|
||||
}
|
||||
const result = await refetch();
|
||||
if (result.error) throw result.error;
|
||||
return result.data ?? null;
|
||||
};
|
||||
|
||||
return {
|
||||
settings,
|
||||
error,
|
||||
isLoading: !settings && isFetching && !error,
|
||||
error: error ?? null,
|
||||
isLoading: isLoading || (!!isFetching && !settings && !error),
|
||||
showSponsorInfo: settings?.showSponsorInfo ?? true,
|
||||
refreshSettings,
|
||||
};
|
||||
@ -96,8 +33,5 @@ export function useSettings() {
|
||||
|
||||
/** @internal For testing only */
|
||||
export function _resetSettingsCache() {
|
||||
settingsCache = null;
|
||||
settingsError = null;
|
||||
isFetching = false;
|
||||
subscribers.clear();
|
||||
appQueryClient.removeQueries({ queryKey: queryKeys.settings.all });
|
||||
}
|
||||
|
||||
@ -1,101 +1,46 @@
|
||||
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";
|
||||
|
||||
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() {
|
||||
const [readiness, setReadiness] = useState<TracerReadinessResponse | null>(
|
||||
readinessCache,
|
||||
);
|
||||
const [error, setError] = useState<Error | null>(readinessError);
|
||||
const [loading, setLoading] = useState<boolean>(
|
||||
!readinessCache && isFetching,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (readinessCache) setReadiness(readinessCache);
|
||||
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 queryClient = useQueryClient();
|
||||
const {
|
||||
data: readiness = null,
|
||||
error,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useQuery<TracerReadinessResponse | null>({
|
||||
queryKey: queryKeys.tracer.readiness(false),
|
||||
queryFn: () => api.getTracerReadiness({ force: false }),
|
||||
});
|
||||
|
||||
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 {
|
||||
readiness,
|
||||
error,
|
||||
isLoading: loading && !readiness,
|
||||
isChecking: loading,
|
||||
error: error ?? null,
|
||||
isLoading: isLoading && !readiness,
|
||||
isChecking: isFetching,
|
||||
refreshReadiness,
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal For testing only */
|
||||
export function _resetTracerReadinessCache() {
|
||||
readinessCache = null;
|
||||
readinessError = null;
|
||||
isFetching = false;
|
||||
subscribers.clear();
|
||||
appQueryClient.removeQueries({ queryKey: queryKeys.tracer.all });
|
||||
}
|
||||
|
||||
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 ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { queryClient } from "@/client/lib/queryClient";
|
||||
import { App } from "./App";
|
||||
import "../index.css";
|
||||
|
||||
@ -9,8 +11,10 @@ if (!rootElement) throw new Error("Failed to find the root element");
|
||||
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@ -7,10 +7,12 @@ import {
|
||||
} from "@client/components/charts";
|
||||
import { PageHeader, PageMain } from "@client/components/layout";
|
||||
import type { StageEvent } from "@shared/types.js";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChartColumn } from "lucide-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 { queryKeys } from "@/client/lib/queryKeys";
|
||||
|
||||
type JobWithEvents = {
|
||||
id: string;
|
||||
@ -24,11 +26,8 @@ const DURATION_OPTIONS = [7, 14, 30, 90] as const;
|
||||
const DEFAULT_DURATION = 30;
|
||||
|
||||
export const HomePage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
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
|
||||
const initialDuration: DurationValue = (() => {
|
||||
@ -42,17 +41,16 @@ export const HomePage: React.FC = () => {
|
||||
|
||||
const [duration, setDuration] = useState<DurationValue>(initialDuration);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
setIsLoading(true);
|
||||
|
||||
api
|
||||
.getJobs({
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: queryKeys.jobs.list({
|
||||
statuses: ["applied", "in_progress"],
|
||||
view: "list",
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!isMounted) return;
|
||||
}),
|
||||
queryFn: async () => {
|
||||
const response = await api.getJobs({
|
||||
statuses: ["applied", "in_progress"],
|
||||
view: "list",
|
||||
});
|
||||
const appliedDates = response.jobs.map((job) => job.appliedAt);
|
||||
const jobSummaries = response.jobs.map((job) => ({
|
||||
id: job.id,
|
||||
@ -64,7 +62,13 @@ export const HomePage: React.FC = () => {
|
||||
|
||||
const appliedJobs = jobSummaries.filter((job) => job.appliedAt);
|
||||
const results = await Promise.allSettled(
|
||||
appliedJobs.map((job) => api.getJobStageEvents(job.id)),
|
||||
appliedJobs.map((job) =>
|
||||
queryClient.fetchQuery({
|
||||
queryKey: queryKeys.jobs.stageEvents(job.id),
|
||||
queryFn: () => api.getJobStageEvents(job.id),
|
||||
staleTime: 0,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const eventsMap = new Map<string, StageEvent[]>();
|
||||
|
||||
@ -78,34 +82,31 @@ export const HomePage: React.FC = () => {
|
||||
eventsMap.set(jobId, result.value);
|
||||
});
|
||||
|
||||
const resolvedJobsWithEvents: JobWithEvents[] = jobSummaries
|
||||
const jobsWithEvents: 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 { jobsWithEvents, appliedDates };
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
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(
|
||||
(newDuration: DurationValue) => {
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
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 { toast } from "sonner";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api";
|
||||
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||
import { InProgressBoardPage } from "./InProgressBoardPage";
|
||||
|
||||
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||
renderWithQueryClient(ui);
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
getJobs: vi.fn(),
|
||||
getJobStageEvents: vi.fn(),
|
||||
|
||||
@ -6,10 +6,13 @@ import {
|
||||
STAGE_LABELS,
|
||||
type StageEvent,
|
||||
} from "@shared/types.js";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowDownAZ, Columns3, ExternalLink, Plus } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast";
|
||||
import { queryKeys } from "@/client/lib/queryKeys";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@ -76,9 +79,9 @@ const resolveCurrentStage = (
|
||||
};
|
||||
|
||||
export const InProgressBoardPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [cards, setCards] = React.useState<BoardCard[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
|
||||
const [dragging, setDragging] = React.useState<{
|
||||
jobId: string;
|
||||
fromStage: ApplicationStage;
|
||||
@ -90,9 +93,9 @@ export const InProgressBoardPage: React.FC = () => {
|
||||
"updated" | "title" | "company"
|
||||
>("updated");
|
||||
|
||||
const loadBoard = React.useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const boardQuery = useQuery({
|
||||
queryKey: queryKeys.jobs.inProgressBoard(),
|
||||
queryFn: async () => {
|
||||
const response = await api.getJobs({
|
||||
statuses: ["in_progress"],
|
||||
view: "list",
|
||||
@ -103,7 +106,7 @@ export const InProgressBoardPage: React.FC = () => {
|
||||
jobs.map((job) => api.getJobStageEvents(job.id)),
|
||||
);
|
||||
|
||||
const nextCards = jobs.map((job, index) => {
|
||||
return jobs.map((job, index) => {
|
||||
const result = eventResults[index];
|
||||
const events =
|
||||
result?.status === "fulfilled"
|
||||
@ -116,22 +119,31 @@ export const InProgressBoardPage: React.FC = () => {
|
||||
latestEventAt: resolved.latestEventAt,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
setCards(nextCards);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to load in-progress board";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
const transitionMutation = useMutation({
|
||||
mutationFn: ({
|
||||
jobId,
|
||||
toStage,
|
||||
}: {
|
||||
jobId: string;
|
||||
toStage: ApplicationStage;
|
||||
}) =>
|
||||
api.transitionJobStage(jobId, {
|
||||
toStage,
|
||||
metadata: {
|
||||
actor: "user",
|
||||
eventType: "status_update",
|
||||
eventLabel: `Moved to ${STAGE_LABELS[toStage]}`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadBoard();
|
||||
}, [loadBoard]);
|
||||
useQueryErrorToast(boardQuery.error, "Failed to load in-progress board");
|
||||
|
||||
const cards = boardQuery.data ?? [];
|
||||
const isLoading = boardQuery.isPending;
|
||||
|
||||
const lanes = React.useMemo(() => {
|
||||
const sortFn =
|
||||
@ -170,12 +182,17 @@ export const InProgressBoardPage: React.FC = () => {
|
||||
}
|
||||
|
||||
const { jobId } = dragging;
|
||||
const previousCards = cards;
|
||||
const previousCards =
|
||||
queryClient.getQueryData<BoardCard[]>(
|
||||
queryKeys.jobs.inProgressBoard(),
|
||||
) ?? [];
|
||||
const nowEpoch = Math.floor(Date.now() / 1000);
|
||||
|
||||
setMovingJobId(jobId);
|
||||
setCards((current) =>
|
||||
current.map((card) =>
|
||||
queryClient.setQueryData<BoardCard[]>(
|
||||
queryKeys.jobs.inProgressBoard(),
|
||||
(current) =>
|
||||
(current ?? []).map((card) =>
|
||||
card.job.id === jobId
|
||||
? { ...card, stage: toStage, latestEventAt: nowEpoch }
|
||||
: card,
|
||||
@ -183,18 +200,16 @@ export const InProgressBoardPage: React.FC = () => {
|
||||
);
|
||||
|
||||
try {
|
||||
await api.transitionJobStage(jobId, {
|
||||
toStage,
|
||||
metadata: {
|
||||
actor: "user",
|
||||
eventType: "status_update",
|
||||
eventLabel: `Moved to ${STAGE_LABELS[toStage]}`,
|
||||
},
|
||||
});
|
||||
await transitionMutation.mutateAsync({ jobId, toStage });
|
||||
toast.success(`Moved to ${STAGE_LABELS[toStage]}`);
|
||||
await loadBoard();
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.jobs.inProgressBoard(),
|
||||
});
|
||||
} catch (error) {
|
||||
setCards(previousCards);
|
||||
queryClient.setQueryData(
|
||||
queryKeys.jobs.inProgressBoard(),
|
||||
previousCards,
|
||||
);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to move stage";
|
||||
toast.error(message);
|
||||
@ -204,7 +219,7 @@ export const InProgressBoardPage: React.FC = () => {
|
||||
setDropTargetStage(null);
|
||||
}
|
||||
},
|
||||
[cards, dragging, loadBoard],
|
||||
[dragging, queryClient, transitionMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
STAGE_LABELS,
|
||||
type StageEvent,
|
||||
} from "@shared/types.js";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import confetti from "canvas-confetti";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@ -26,6 +27,17 @@ import {
|
||||
import React from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@ -55,10 +67,7 @@ import { JobTimeline } from "./job/Timeline";
|
||||
export const JobPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [job, setJob] = React.useState<Job | null>(null);
|
||||
const [events, setEvents] = React.useState<StageEvent[]>([]);
|
||||
const [tasks, setTasks] = React.useState<ApplicationTask[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const queryClient = useQueryClient();
|
||||
const [isLogModalOpen, setIsLogModalOpen] = React.useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = 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 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 () => {
|
||||
if (!id) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const jobData = await api.getJob(id);
|
||||
setJob(jobData);
|
||||
|
||||
api
|
||||
.getJobStageEvents(id)
|
||||
.then((data) => setEvents(mergeEvents(data, pendingEventRef.current)))
|
||||
.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]);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.jobs.detail(id) }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.jobs.stageEvents(id),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.jobs.tasks(id) }),
|
||||
]);
|
||||
}, [id, queryClient]);
|
||||
|
||||
const handleLogEvent = async (
|
||||
values: LogEventFormValues,
|
||||
@ -153,12 +190,7 @@ export const JobPage: React.FC = () => {
|
||||
pendingEventRef.current = newEvent;
|
||||
}
|
||||
|
||||
const [jobData, eventData] = await Promise.all([
|
||||
api.getJob(job.id),
|
||||
api.getJobStageEvents(job.id),
|
||||
]);
|
||||
setJob(jobData);
|
||||
setEvents(eventData);
|
||||
await invalidateJobData(queryClient, job.id);
|
||||
pendingEventRef.current = null;
|
||||
setEditingEvent(null);
|
||||
toast.success(eventId ? "Event updated" : "Event logged");
|
||||
@ -172,8 +204,9 @@ export const JobPage: React.FC = () => {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to log event:", error);
|
||||
toast.error("Failed to log event");
|
||||
const message =
|
||||
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;
|
||||
try {
|
||||
await api.deleteJobStageEvent(job.id, eventToDelete);
|
||||
const [jobData, eventData] = await Promise.all([
|
||||
api.getJob(job.id),
|
||||
api.getJobStageEvents(job.id),
|
||||
]);
|
||||
setJob(jobData);
|
||||
setEvents(eventData);
|
||||
await invalidateJobData(queryClient, job.id);
|
||||
toast.success("Event deleted");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete event:", error);
|
||||
toast.error("Failed to delete event");
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to delete event";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsDeleteModalOpen(false);
|
||||
setEventToDelete(null);
|
||||
@ -228,7 +257,7 @@ export const JobPage: React.FC = () => {
|
||||
const handleMarkApplied = async () => {
|
||||
await runAction("mark-applied", async () => {
|
||||
if (!job) return;
|
||||
await api.markAsApplied(job.id);
|
||||
await markAsAppliedMutation.mutateAsync(job.id);
|
||||
toast.success("Marked as applied");
|
||||
});
|
||||
};
|
||||
@ -236,7 +265,10 @@ export const JobPage: React.FC = () => {
|
||||
const handleMoveToInProgress = async () => {
|
||||
await runAction("move-in-progress", async () => {
|
||||
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");
|
||||
});
|
||||
};
|
||||
@ -244,7 +276,7 @@ export const JobPage: React.FC = () => {
|
||||
const handleSkip = async () => {
|
||||
await runAction("skip", async () => {
|
||||
if (!job) return;
|
||||
await api.skipJob(job.id);
|
||||
await skipJobMutation.mutateAsync(job.id);
|
||||
toast.message("Job skipped");
|
||||
});
|
||||
};
|
||||
@ -252,7 +284,7 @@ export const JobPage: React.FC = () => {
|
||||
const handleRescore = async () => {
|
||||
await runAction("rescore", async () => {
|
||||
if (!job) return;
|
||||
await api.rescoreJob(job.id);
|
||||
await rescoreJobMutation.mutateAsync(job.id);
|
||||
toast.success("Match recalculated");
|
||||
});
|
||||
};
|
||||
@ -260,7 +292,7 @@ export const JobPage: React.FC = () => {
|
||||
const handleRegeneratePdf = async () => {
|
||||
await runAction("regenerate-pdf", async () => {
|
||||
if (!job) return;
|
||||
await api.generateJobPdf(job.id);
|
||||
await generatePdfMutation.mutateAsync(job.id);
|
||||
toast.success("Resume PDF generated");
|
||||
});
|
||||
};
|
||||
@ -268,7 +300,7 @@ export const JobPage: React.FC = () => {
|
||||
const handleCheckSponsor = async () => {
|
||||
await runAction("check-sponsor", async () => {
|
||||
if (!job) return;
|
||||
await api.checkSponsor(job.id);
|
||||
await checkSponsorMutation.mutateAsync(job.id);
|
||||
toast.success("Sponsor check completed");
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
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 { toast } from "sonner";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api";
|
||||
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||
import { OrchestratorPage } from "./OrchestratorPage";
|
||||
import type { FilterTab } from "./orchestrator/constants";
|
||||
|
||||
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||
renderWithQueryClient(ui);
|
||||
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
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 { toast } from "sonner";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api";
|
||||
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
|
||||
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||
import { SettingsPage } from "./SettingsPage";
|
||||
|
||||
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||
renderWithQueryClient(ui);
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
getSettings: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import * as api from "@client/api";
|
||||
import { PageHeader } from "@client/components/layout";
|
||||
import { useUpdateSettingsMutation } from "@client/hooks/queries/useSettingsMutation";
|
||||
import { useTracerReadiness } from "@client/hooks/useTracerReadiness";
|
||||
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
|
||||
import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection";
|
||||
@ -23,16 +24,18 @@ import {
|
||||
} from "@shared/settings-schema.js";
|
||||
import type {
|
||||
AppSettings,
|
||||
BackupInfo,
|
||||
JobStatus,
|
||||
ResumeProjectCatalogItem,
|
||||
ResumeProjectsSettings,
|
||||
} from "@shared/types.js";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Settings } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FormProvider, type Resolver, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast";
|
||||
import { queryKeys } from "@/client/lib/queryKeys";
|
||||
import { Accordion } from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@ -293,9 +296,9 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
||||
};
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [settings, setSettings] = useState<AppSettings | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>([
|
||||
"discovered",
|
||||
]);
|
||||
@ -309,9 +312,6 @@ export const SettingsPage: React.FC = () => {
|
||||
useState(false);
|
||||
|
||||
// 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 [isDeletingBackup, setIsDeletingBackup] = useState(false);
|
||||
const {
|
||||
@ -339,34 +339,32 @@ export const SettingsPage: React.FC = () => {
|
||||
formState: { isDirty, errors, isValid, dirtyFields },
|
||||
} = 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(
|
||||
settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
setIsLoading(true);
|
||||
api
|
||||
.getSettings()
|
||||
.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);
|
||||
});
|
||||
if (!settingsQuery.data) return;
|
||||
setSettings(settingsQuery.data);
|
||||
reset(mapSettingsToForm(settingsQuery.data));
|
||||
}, [settingsQuery.data, reset]);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [reset]);
|
||||
useQueryErrorToast(settingsQuery.error, "Failed to load settings");
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
@ -442,28 +440,12 @@ export const SettingsPage: React.FC = () => {
|
||||
scoring,
|
||||
} = 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 () => {
|
||||
setIsCreatingBackup(true);
|
||||
try {
|
||||
await api.createManualBackup();
|
||||
toast.success("Backup created successfully");
|
||||
await loadBackups();
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.backups.all });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to create backup";
|
||||
@ -484,7 +466,7 @@ export const SettingsPage: React.FC = () => {
|
||||
try {
|
||||
await api.deleteBackup(filename);
|
||||
toast.success("Backup deleted successfully");
|
||||
await loadBackups();
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.backups.all });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to delete backup";
|
||||
@ -497,7 +479,9 @@ export const SettingsPage: React.FC = () => {
|
||||
const handleVerifyTracerReadiness = useCallback(async () => {
|
||||
try {
|
||||
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");
|
||||
} else {
|
||||
toast.error(
|
||||
@ -514,13 +498,6 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
}, [refreshReadiness]);
|
||||
|
||||
// Load backups when settings are loaded
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
loadBackups();
|
||||
}
|
||||
}, [settings, loadBackups]);
|
||||
|
||||
const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects;
|
||||
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
|
||||
delete payload.enableBasicAuth;
|
||||
|
||||
const updated = await api.updateSettings(payload);
|
||||
const updated = await updateSettingsMutation.mutateAsync(payload);
|
||||
setSettings(updated);
|
||||
reset(mapSettingsToForm(updated));
|
||||
toast.success("Settings saved");
|
||||
@ -758,7 +735,9 @@ export const SettingsPage: React.FC = () => {
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const updated = await api.updateSettings(NULL_SETTINGS_PAYLOAD);
|
||||
const updated = await updateSettingsMutation.mutateAsync(
|
||||
NULL_SETTINGS_PAYLOAD,
|
||||
);
|
||||
setSettings(updated);
|
||||
reset(mapSettingsToForm(updated));
|
||||
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 type {
|
||||
JobTracerLinkAnalyticsItem,
|
||||
JobTracerLinksResponse,
|
||||
TracerAnalyticsResponse,
|
||||
TracerAnalyticsTopJob,
|
||||
} from "@shared/types.js";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { BarChart3, Copy, ExternalLink, Loader2 } from "lucide-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 { toast } from "sonner";
|
||||
import { queryKeys } from "@/client/lib/queryKeys";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@ -113,19 +114,14 @@ function formatRelativeTime(value: number | null): string {
|
||||
}
|
||||
|
||||
export const TracerLinksPage: React.FC = () => {
|
||||
const [analytics, setAnalytics] = useState<TracerAnalyticsResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [jobDrilldown, setJobDrilldown] =
|
||||
useState<JobTracerLinksResponse | null>(null);
|
||||
const [selectedDrilldownJobId, setSelectedDrilldownJobId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [fromDate, setFromDate] = useState("");
|
||||
const [toDate, setToDate] = useState("");
|
||||
const [includeBots, setIncludeBots] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDrilldownLoading, setIsDrilldownLoading] = useState(false);
|
||||
const [isDrilldownOpen, setIsDrilldownOpen] = useState(false);
|
||||
const [drilldownMode, setDrilldownMode] = useState<"human" | "all">("human");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const query = useMemo(
|
||||
() => ({
|
||||
@ -137,62 +133,36 @@ export const TracerLinksPage: React.FC = () => {
|
||||
[fromDate, toDate, includeBots],
|
||||
);
|
||||
|
||||
const loadJobDrilldown = async (targetJobId: string) => {
|
||||
if (!targetJobId) {
|
||||
setError("Enter a Job ID to load link drilldown.");
|
||||
setJobDrilldown(null);
|
||||
return;
|
||||
}
|
||||
const analyticsQuery = useQuery<TracerAnalyticsResponse>({
|
||||
queryKey: queryKeys.tracer.analytics(query),
|
||||
queryFn: () => api.getTracerAnalytics(query),
|
||||
});
|
||||
const analytics = analyticsQuery.data ?? null;
|
||||
const isLoading = analyticsQuery.isPending;
|
||||
|
||||
try {
|
||||
setIsDrilldownLoading(true);
|
||||
setError(null);
|
||||
const response = await api.getJobTracerLinks(targetJobId, {
|
||||
const jobDrilldownQuery = useQuery({
|
||||
queryKey: queryKeys.tracer.jobLinks(selectedDrilldownJobId ?? "", {
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
includeBots,
|
||||
}),
|
||||
queryFn: () =>
|
||||
api.getJobTracerLinks(selectedDrilldownJobId ?? "", {
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
includeBots,
|
||||
}),
|
||||
enabled: Boolean(isDrilldownOpen && selectedDrilldownJobId),
|
||||
});
|
||||
setJobDrilldown(response);
|
||||
} catch (fetchError) {
|
||||
const message =
|
||||
fetchError instanceof Error
|
||||
? fetchError.message
|
||||
: "Failed to load job tracer links.";
|
||||
setError(message);
|
||||
setJobDrilldown(null);
|
||||
} finally {
|
||||
setIsDrilldownLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 jobDrilldown = jobDrilldownQuery.data ?? null;
|
||||
const isDrilldownLoading =
|
||||
jobDrilldownQuery.isPending || jobDrilldownQuery.isFetching;
|
||||
const error =
|
||||
analyticsQuery.error instanceof Error
|
||||
? analyticsQuery.error.message
|
||||
: jobDrilldownQuery.error instanceof Error
|
||||
? jobDrilldownQuery.error.message
|
||||
: null;
|
||||
|
||||
const chartData = analytics?.timeSeries ?? [];
|
||||
const totalViews = analytics?.totals.clicks ?? 0;
|
||||
@ -271,8 +241,8 @@ export const TracerLinksPage: React.FC = () => {
|
||||
drilldownMode === "human" ? row.humanClicks : row.clicks;
|
||||
|
||||
const handleSelectTopJob = (job: TracerAnalyticsTopJob) => {
|
||||
setSelectedDrilldownJobId(job.jobId);
|
||||
setIsDrilldownOpen(true);
|
||||
void loadJobDrilldown(job.jobId);
|
||||
};
|
||||
|
||||
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api";
|
||||
import { renderWithQueryClient } from "../test/renderWithQueryClient";
|
||||
import { TrackingInboxPage } from "./TrackingInboxPage";
|
||||
|
||||
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||
renderWithQueryClient(ui);
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
postApplicationProviderStatus: vi.fn(),
|
||||
getPostApplicationInbox: vi.fn(),
|
||||
|
||||
@ -5,6 +5,7 @@ import type {
|
||||
PostApplicationSyncRun,
|
||||
} from "@shared/types";
|
||||
import { POST_APPLICATION_PROVIDERS } from "@shared/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle,
|
||||
Inbox,
|
||||
@ -18,6 +19,8 @@ import {
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast";
|
||||
import { queryKeys } from "@/client/lib/queryKeys";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@ -57,6 +60,8 @@ const PROVIDER_OPTIONS: PostApplicationProvider[] = [
|
||||
];
|
||||
const GMAIL_OAUTH_RESULT_TYPE = "gmail-oauth-result";
|
||||
const GMAIL_OAUTH_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
const EMPTY_INBOX_ITEMS: PostApplicationInboxItem[] = [];
|
||||
const EMPTY_SYNC_RUNS: PostApplicationSyncRun[] = [];
|
||||
|
||||
type GmailOauthResultMessage = {
|
||||
type: string;
|
||||
@ -76,81 +81,106 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
const [maxMessages, setMaxMessages] = useState("100");
|
||||
const [searchDays, setSearchDays] = useState("90");
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||
const [activeAction, setActiveAction] = useState<
|
||||
"connect" | "sync" | "disconnect" | 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 [isRunMessagesLoading, setIsRunMessagesLoading] = useState(false);
|
||||
const [selectedRun, setSelectedRun] = useState<PostApplicationSyncRun | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedRunItems, setSelectedRunItems] = useState<
|
||||
PostApplicationInboxItem[]
|
||||
>([]);
|
||||
|
||||
const [appliedJobByMessageId, setAppliedJobByMessageId] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [appliedJobs, setAppliedJobs] = useState<JobListItem[]>([]);
|
||||
const [isAppliedJobsLoading, setIsAppliedJobsLoading] = useState(false);
|
||||
const [hasAttemptedAppliedJobsLoad, setHasAttemptedAppliedJobsLoad] =
|
||||
useState(false);
|
||||
const statusQuery = useQuery({
|
||||
queryKey: queryKeys.postApplication.providerStatus(provider, accountKey),
|
||||
queryFn: () => api.postApplicationProviderStatus({ provider, accountKey }),
|
||||
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<{
|
||||
isOpen: boolean;
|
||||
action: "approve" | "deny" | null;
|
||||
itemCount: number;
|
||||
}>({ isOpen: false, action: null, itemCount: 0 });
|
||||
|
||||
const loadAppliedJobs = useCallback(async () => {
|
||||
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 isLoading =
|
||||
statusQuery.isPending || inboxQuery.isPending || runsQuery.isPending;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await loadAll();
|
||||
await Promise.all([
|
||||
statusQuery.refetch(),
|
||||
inboxQuery.refetch(),
|
||||
runsQuery.refetch(),
|
||||
hasReviewItems ? appliedJobsQuery.refetch() : Promise.resolve(),
|
||||
]);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
@ -159,36 +189,19 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [loadAll]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
}, [appliedJobsQuery, hasReviewItems, inboxQuery, runsQuery, statusQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!provider || !accountKey) return;
|
||||
setAppliedJobs([]);
|
||||
setAppliedJobByMessageId({});
|
||||
setHasAttemptedAppliedJobsLoad(false);
|
||||
}, [provider, accountKey]);
|
||||
|
||||
const hasReviewItems = useMemo(
|
||||
() => inbox.length > 0 || selectedRunItems.length > 0,
|
||||
[inbox.length, selectedRunItems.length],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasReviewItems) return;
|
||||
void loadAppliedJobs();
|
||||
}, [hasReviewItems, loadAppliedJobs]);
|
||||
|
||||
useEffect(() => {
|
||||
const defaultAppliedJobId = appliedJobs[0]?.id ?? "";
|
||||
setAppliedJobByMessageId((previous) => {
|
||||
const next = { ...previous };
|
||||
let didChange = false;
|
||||
for (const item of [...inbox, ...selectedRunItems]) {
|
||||
const selectedJobId = next[item.message.id];
|
||||
const hasValidSelection = appliedJobs.some(
|
||||
@ -199,12 +212,16 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
const hasValidMatchedJob = appliedJobs.some(
|
||||
(appliedJob) => appliedJob.id === matchedJobId,
|
||||
);
|
||||
next[item.message.id] = hasValidMatchedJob
|
||||
const nextJobId = hasValidMatchedJob
|
||||
? matchedJobId
|
||||
: defaultAppliedJobId;
|
||||
if (next[item.message.id] !== nextJobId) {
|
||||
next[item.message.id] = nextJobId;
|
||||
didChange = true;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
return didChange ? next : previous;
|
||||
});
|
||||
}, [appliedJobs, inbox, selectedRunItems]);
|
||||
|
||||
@ -487,32 +504,24 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
[inbox],
|
||||
);
|
||||
|
||||
const handleOpenRunMessages = useCallback(
|
||||
async (run: PostApplicationSyncRun) => {
|
||||
const handleOpenRunMessages = useCallback((run: PostApplicationSyncRun) => {
|
||||
setSelectedRun(run);
|
||||
setSelectedRunItems([]);
|
||||
setIsRunModalOpen(true);
|
||||
setIsRunMessagesLoading(true);
|
||||
}, []);
|
||||
|
||||
try {
|
||||
const response = await api.getPostApplicationRunMessages({
|
||||
runId: run.id,
|
||||
provider,
|
||||
accountKey,
|
||||
});
|
||||
setSelectedRun(response.run);
|
||||
setSelectedRunItems(response.items);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to load messages for selected sync run";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsRunMessagesLoading(false);
|
||||
}
|
||||
},
|
||||
[accountKey, provider],
|
||||
useQueryErrorToast(
|
||||
statusQuery.error,
|
||||
"Failed to load provider connection status",
|
||||
);
|
||||
useQueryErrorToast(inboxQuery.error, "Failed to load inbox");
|
||||
useQueryErrorToast(runsQuery.error, "Failed to load sync runs");
|
||||
useQueryErrorToast(
|
||||
appliedJobsQuery.error,
|
||||
"Failed to load jobs for inbox matching",
|
||||
);
|
||||
useQueryErrorToast(
|
||||
runMessagesQuery.error,
|
||||
"Failed to load messages for selected sync run",
|
||||
);
|
||||
|
||||
const pendingCount = inbox.length;
|
||||
@ -789,7 +798,6 @@ export const TrackingInboxPage: React.FC = () => {
|
||||
onOpenChange={(open) => {
|
||||
setIsRunModalOpen(open);
|
||||
if (!open) {
|
||||
setSelectedRunItems([]);
|
||||
setSelectedRun(null);
|
||||
}
|
||||
}}
|
||||
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
VisaSponsorSearchResult,
|
||||
VisaSponsorStatusResponse,
|
||||
} from "@shared/types.js";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
Building2,
|
||||
@ -23,8 +24,10 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast";
|
||||
import { queryKeys } from "@/client/lib/queryKeys";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
|
||||
@ -56,18 +59,13 @@ const getScoreTokens = (score: number) => {
|
||||
};
|
||||
|
||||
export const VisaSponsorsPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
// State
|
||||
const [status, setStatus] = useState<VisaSponsorStatusResponse | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [results, setResults] = useState<VisaSponsorSearchResult[]>([]);
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
|
||||
const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
|
||||
const [orgDetails, setOrgDetails] = useState<VisaSponsor[]>([]);
|
||||
|
||||
// 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 [isDesktop, setIsDesktop] = useState(() =>
|
||||
typeof window !== "undefined"
|
||||
@ -75,80 +73,56 @@ export const VisaSponsorsPage: React.FC = () => {
|
||||
: false,
|
||||
);
|
||||
|
||||
// Fetch organization details
|
||||
const fetchOrgDetails = useCallback(async (orgName: string) => {
|
||||
setIsLoadingDetails(true);
|
||||
setSelectedOrg(orgName);
|
||||
try {
|
||||
const details = await api.getVisaSponsorOrganization(orgName);
|
||||
setOrgDetails(details);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to fetch details";
|
||||
toast.error(message);
|
||||
setOrgDetails([]);
|
||||
} finally {
|
||||
setIsLoadingDetails(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStatus = 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,
|
||||
const statusQuery = useQuery<VisaSponsorStatusResponse>({
|
||||
queryKey: queryKeys.visaSponsors.status(),
|
||||
queryFn: api.getVisaSponsorStatus,
|
||||
});
|
||||
setResults(response.results);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Search failed";
|
||||
toast.error(message);
|
||||
setResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, []);
|
||||
const status = statusQuery.data ?? null;
|
||||
useQueryErrorToast(statusQuery.error, "Failed to fetch status");
|
||||
|
||||
// Debounced search effect
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
handleSearch(searchQuery);
|
||||
setDebouncedSearchQuery(searchQuery);
|
||||
}, 300);
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
if (results.length === 0) {
|
||||
setSelectedOrg(null);
|
||||
setOrgDetails([]);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
@ -157,9 +131,8 @@ export const VisaSponsorsPage: React.FC = () => {
|
||||
) {
|
||||
const firstOrg = results[0].sponsor.organisationName;
|
||||
setSelectedOrg(firstOrg);
|
||||
fetchOrgDetails(firstOrg);
|
||||
}
|
||||
}, [results, fetchOrgDetails, selectedOrg]);
|
||||
}, [results, selectedOrg]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedOrg) {
|
||||
@ -187,25 +160,33 @@ export const VisaSponsorsPage: React.FC = () => {
|
||||
}, [isDesktop, isDetailDrawerOpen]);
|
||||
|
||||
// Trigger manual update
|
||||
const handleUpdate = async () => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const result = await api.updateVisaSponsorList();
|
||||
setStatus(result.status);
|
||||
const updateListMutation = useMutation({
|
||||
mutationFn: api.updateVisaSponsorList,
|
||||
onSuccess: async (result) => {
|
||||
queryClient.setQueryData(queryKeys.visaSponsors.status(), result.status);
|
||||
if (debouncedSearchQuery.trim()) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.visaSponsors.search(
|
||||
debouncedSearchQuery.trim(),
|
||||
100,
|
||||
20,
|
||||
),
|
||||
});
|
||||
}
|
||||
toast.success(result.message);
|
||||
if (searchQuery.trim()) {
|
||||
handleSearch(searchQuery);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Update failed";
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error instanceof Error ? error.message : "Update failed";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdate = async () => {
|
||||
await updateListMutation.mutateAsync();
|
||||
};
|
||||
|
||||
const handleSelectOrg = (orgName: string) => {
|
||||
fetchOrgDetails(orgName);
|
||||
setSelectedOrg(orgName);
|
||||
if (!isDesktop) {
|
||||
setIsDetailDrawerOpen(true);
|
||||
}
|
||||
@ -217,7 +198,10 @@ export const VisaSponsorsPage: React.FC = () => {
|
||||
[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 ? (
|
||||
<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
|
||||
size="sm"
|
||||
onClick={handleUpdate}
|
||||
disabled={isUpdating}
|
||||
disabled={isUpdateInProgress}
|
||||
>
|
||||
{isUpdating ? (
|
||||
{isUpdateInProgress ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Downloading...
|
||||
|
||||
@ -1,17 +1,15 @@
|
||||
import { createJob } from "@shared/testing/factories.js";
|
||||
import type { Job } from "@shared/types.js";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../../api";
|
||||
import { renderWithQueryClient } from "../../test/renderWithQueryClient";
|
||||
import { JobDetailPanel } from "./JobDetailPanel";
|
||||
|
||||
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||
renderWithQueryClient(ui);
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||
return {
|
||||
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 * as api from "../../api";
|
||||
import { renderHookWithQueryClient } from "../../test/renderWithQueryClient";
|
||||
import { useOrchestratorData } from "./useOrchestratorData";
|
||||
|
||||
const renderHook = (callback: () => ReturnType<typeof useOrchestratorData>) =>
|
||||
renderHookWithQueryClient(callback);
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
getJobs: vi.fn(),
|
||||
getJobsRevision: vi.fn(),
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import type { Job, JobListItem, JobStatus } from "@shared/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { queryKeys } from "@/client/lib/queryKeys";
|
||||
import * as api from "../../api";
|
||||
import { subscribeToEventSource } from "../../lib/sse";
|
||||
|
||||
@ -79,6 +81,7 @@ const buildTerminalSignature = ({
|
||||
};
|
||||
|
||||
export const useOrchestratorData = (selectedJobId: string | null) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [jobListItems, setJobListItems] = useState<JobListItem[]>([]);
|
||||
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
|
||||
const [stats, setStats] = useState<Record<JobStatus, number>>(initialStats);
|
||||
@ -166,7 +169,11 @@ export const useOrchestratorData = (selectedJobId: string | null) => {
|
||||
async (jobId: string) => {
|
||||
const seq = ++selectedJobRequestSeqRef.current;
|
||||
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);
|
||||
if (
|
||||
selectedJobId === jobId &&
|
||||
@ -182,7 +189,7 @@ export const useOrchestratorData = (selectedJobId: string | null) => {
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
[selectedJobId],
|
||||
[queryClient, selectedJobId],
|
||||
);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
@ -191,6 +198,7 @@ export const useOrchestratorData = (selectedJobId: string | null) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await api.getJobs({ view: "list" });
|
||||
queryClient.setQueryData(queryKeys.jobs.list({ view: "list" }), data);
|
||||
if (seq >= latestAppliedSeqRef.current) {
|
||||
latestAppliedSeqRef.current = seq;
|
||||
setJobListItems(data.jobs);
|
||||
@ -210,11 +218,15 @@ export const useOrchestratorData = (selectedJobId: string | null) => {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [queryClient]);
|
||||
|
||||
const checkPipelineStatus = useCallback(async () => {
|
||||
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;
|
||||
|
||||
if (status.isRunning) {
|
||||
@ -247,24 +259,31 @@ export const useOrchestratorData = (selectedJobId: string | null) => {
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}, [observePipelineState]);
|
||||
}, [observePipelineState, queryClient]);
|
||||
|
||||
const checkForJobChanges = useCallback(async () => {
|
||||
if (isRefreshPaused || !isDocumentVisible()) return;
|
||||
try {
|
||||
const revision = await api.getJobsRevision();
|
||||
const revision = await queryClient.fetchQuery({
|
||||
queryKey: queryKeys.jobs.revision(),
|
||||
queryFn: () => api.getJobsRevision(),
|
||||
staleTime: 0,
|
||||
});
|
||||
const previousRevision = lastRevisionRef.current;
|
||||
if (previousRevision === null) {
|
||||
lastRevisionRef.current = revision.revision;
|
||||
return;
|
||||
}
|
||||
if (revision.revision !== previousRevision) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.jobs.all,
|
||||
});
|
||||
await loadJobs();
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}, [isRefreshPaused, loadJobs]);
|
||||
}, [isRefreshPaused, loadJobs, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
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_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": {
|
||||
"version": "0.2.7",
|
||||
"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-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user