mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 17:03:55 +10:00
feat: add command palette entity search
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
BriefcaseIcon,
|
||||
ChatCircleDotsIcon,
|
||||
GearIcon,
|
||||
HouseSimpleIcon,
|
||||
KeyIcon,
|
||||
OpenAiLogoIcon,
|
||||
PlusIcon,
|
||||
ReadCvLogoIcon,
|
||||
ShieldCheckIcon,
|
||||
UserCircleIcon,
|
||||
@@ -47,12 +49,45 @@ export function NavigationCommandGroup() {
|
||||
|
||||
<CommandItem
|
||||
disabled={!session}
|
||||
keywords={[t`Agent`, t`Artificial Intelligence`]}
|
||||
value="navigation.agent"
|
||||
keywords={[t`Applications`, t`Jobs`]}
|
||||
value="navigation.applications"
|
||||
onSelect={() => onNavigate("/dashboard/applications")}
|
||||
>
|
||||
<BriefcaseIcon />
|
||||
<Trans>Applications</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
disabled={!session}
|
||||
keywords={[t`New Application`, t`Add application`, t`Job`]}
|
||||
value="navigation.applications.new"
|
||||
onSelect={async () => {
|
||||
await navigate({ to: "/dashboard/applications", search: { create: true } });
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
<Trans>New Application</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
disabled={!session}
|
||||
keywords={[t`Threads`, t`Agent`, t`Artificial Intelligence`]}
|
||||
value="navigation.threads"
|
||||
onSelect={() => onNavigate("/agent")}
|
||||
>
|
||||
<ChatCircleDotsIcon />
|
||||
<Trans>Agent</Trans>
|
||||
<Trans>Threads</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
disabled={!session}
|
||||
keywords={[t`New Thread`, t`Agent`, t`Artificial Intelligence`]}
|
||||
value="navigation.threads.new"
|
||||
onSelect={() => onNavigate("/agent/new")}
|
||||
>
|
||||
<PlusIcon />
|
||||
<Trans>New Thread</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
@@ -8,32 +8,53 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Command, CommandList } from "@reactive-resume/ui/components/command";
|
||||
import { useCommandPaletteStore } from "../store";
|
||||
|
||||
const queryOptionsMock = vi.fn((options?: unknown) => options ?? {});
|
||||
const mocks = vi.hoisted(() => ({
|
||||
pathname: "/",
|
||||
resumeQueryOptions: vi.fn((options?: unknown) => ({ entity: "resumes", ...(options ?? {}) })),
|
||||
applicationsListQueryOptions: vi.fn(() => ({ entity: "applications" })),
|
||||
threadsQueryOptions: vi.fn((options?: unknown) => ({ entity: "threads", ...(options ?? {}) })),
|
||||
navigate: vi.fn(),
|
||||
openDialog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useNavigate: () => mocks.navigate,
|
||||
useRouteContext: () => ({ session: { user: { id: "user-1" } } }),
|
||||
useRouterState: ({ select }: { select: (state: { location: { pathname: string } }) => unknown }) =>
|
||||
select({ location: { pathname: mocks.pathname } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/dialogs/store", () => ({
|
||||
useDialogStore: () => ({ openDialog: vi.fn() }),
|
||||
useDialogStore: () => ({ openDialog: mocks.openDialog }),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/applications/queries", () => ({
|
||||
applicationsListQueryOptions: mocks.applicationsListQueryOptions,
|
||||
}));
|
||||
|
||||
vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: {
|
||||
resume: {
|
||||
list: {
|
||||
queryOptions: queryOptionsMock,
|
||||
queryOptions: mocks.resumeQueryOptions,
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
threads: {
|
||||
list: {
|
||||
queryOptions: mocks.threadsQueryOptions,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { ResumesCommandGroup } = await import("./resumes");
|
||||
const { NavigationCommandGroup } = await import("./navigation");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
@@ -41,9 +62,16 @@ beforeAll(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.pathname = "/";
|
||||
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
|
||||
});
|
||||
|
||||
const mockUseQueryData = (getData: (entity: string | undefined) => unknown) => {
|
||||
vi.mocked(useQuery).mockImplementation(((options: unknown) => {
|
||||
return { data: getData((options as { entity?: string }).entity), isLoading: false } as ReturnType<typeof useQuery>;
|
||||
}) as typeof useQuery);
|
||||
};
|
||||
|
||||
const renderGroup = () =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
@@ -57,18 +85,151 @@ const renderGroup = () =>
|
||||
|
||||
describe("ResumesCommandGroup", () => {
|
||||
it("loads resumes with the default list input on the resumes page", () => {
|
||||
useCommandPaletteStore.setState({ pages: ["resumes"] });
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: [{ id: "resume-1", name: "Evil Apricot Pike" }],
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useQuery>);
|
||||
mocks.pathname = "/dashboard/resumes";
|
||||
mockUseQueryData((entity) =>
|
||||
entity === "resumes" ? [{ id: "resume-1", name: "Evil Apricot Pike", slug: "apricot" }] : [],
|
||||
);
|
||||
|
||||
renderGroup();
|
||||
|
||||
expect(queryOptionsMock).toHaveBeenCalledWith({
|
||||
expect(mocks.resumeQueryOptions).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
input: { sort: "lastUpdatedAt", tags: [] },
|
||||
});
|
||||
expect(screen.getByText("Evil Apricot Pike")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters resume results from the command palette search", () => {
|
||||
mocks.pathname = "/dashboard/resumes";
|
||||
useCommandPaletteStore.setState({ search: "apri" });
|
||||
mockUseQueryData((entity) =>
|
||||
entity === "resumes"
|
||||
? [
|
||||
{ id: "resume-1", name: "Evil Apricot Pike", slug: "apricot" },
|
||||
{ id: "resume-2", name: "Quiet Banana", slug: "banana" },
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
renderGroup();
|
||||
|
||||
expect(screen.getByText("Evil Apricot Pike")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Quiet Banana")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads applications on the applications page", () => {
|
||||
mocks.pathname = "/dashboard/applications";
|
||||
mockUseQueryData((entity) =>
|
||||
entity === "applications"
|
||||
? [{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false }]
|
||||
: [],
|
||||
);
|
||||
|
||||
renderGroup();
|
||||
|
||||
expect(screen.getByText("Umbrella")).toBeInTheDocument();
|
||||
expect(screen.getByText("Staff Engineer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters application results from the command palette search", () => {
|
||||
mocks.pathname = "/dashboard/applications";
|
||||
useCommandPaletteStore.setState({ search: "umbrella" });
|
||||
mockUseQueryData((entity) =>
|
||||
entity === "applications"
|
||||
? [
|
||||
{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false },
|
||||
{ id: "application-2", company: "Wayne Enterprises", role: "Product Engineer", archived: false },
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
renderGroup();
|
||||
|
||||
expect(screen.getByText("Umbrella")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Wayne Enterprises")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the selected application by id", () => {
|
||||
mocks.pathname = "/dashboard/applications";
|
||||
mockUseQueryData((entity) =>
|
||||
entity === "applications"
|
||||
? [{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false }]
|
||||
: [],
|
||||
);
|
||||
|
||||
renderGroup();
|
||||
fireEvent.click(screen.getByText("Umbrella"));
|
||||
|
||||
expect(mocks.navigate).toHaveBeenCalledWith({
|
||||
to: "/dashboard/applications",
|
||||
search: { applicationId: "application-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("loads threads on agent pages", () => {
|
||||
mocks.pathname = "/agent";
|
||||
mockUseQueryData((entity) =>
|
||||
entity === "threads" ? [{ id: "thread-1", title: "Cover letter rewrite", resumeName: "Product Resume" }] : [],
|
||||
);
|
||||
|
||||
renderGroup();
|
||||
|
||||
expect(screen.getByText("Cover letter rewrite")).toBeInTheDocument();
|
||||
expect(screen.getByText("Product Resume")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters thread results from the command palette search", () => {
|
||||
mocks.pathname = "/agent";
|
||||
useCommandPaletteStore.setState({ search: "cover" });
|
||||
mockUseQueryData((entity) =>
|
||||
entity === "threads"
|
||||
? [
|
||||
{ id: "thread-1", title: "Cover letter rewrite", resumeName: "Product Resume" },
|
||||
{ id: "thread-2", title: "Resume cleanup", resumeName: "Staff Resume" },
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
renderGroup();
|
||||
|
||||
expect(screen.getByText("Cover letter rewrite")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Resume cleanup")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavigationCommandGroup", () => {
|
||||
it("shows application and thread navigation items", () => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<Command>
|
||||
<CommandList>
|
||||
<NavigationCommandGroup />
|
||||
</CommandList>
|
||||
</Command>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Applications")).toBeInTheDocument();
|
||||
expect(screen.getByText("New Application")).toBeInTheDocument();
|
||||
expect(screen.getByText("Threads")).toBeInTheDocument();
|
||||
expect(screen.getByText("New Thread")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates to new application and new thread destinations", () => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<Command>
|
||||
<CommandList>
|
||||
<NavigationCommandGroup />
|
||||
</CommandList>
|
||||
</Command>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("New Application"));
|
||||
expect(mocks.navigate).toHaveBeenCalledWith({ to: "/dashboard/applications", search: { create: true } });
|
||||
|
||||
fireEvent.click(screen.getByText("New Thread"));
|
||||
expect(mocks.navigate).toHaveBeenCalledWith({ to: "/agent/new" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +1,73 @@
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { PlusIcon, ReadCvLogoIcon } from "@phosphor-icons/react";
|
||||
import { BriefcaseIcon, ChatCircleDotsIcon, PlusIcon, ReadCvLogoIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useRouteContext } from "@tanstack/react-router";
|
||||
import { useNavigate, useRouteContext, useRouterState } from "@tanstack/react-router";
|
||||
import { CommandLoading } from "cmdk";
|
||||
import { CommandItem, CommandShortcut } from "@reactive-resume/ui/components/command";
|
||||
import { Kbd } from "@reactive-resume/ui/components/kbd";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { applicationsListQueryOptions } from "@/features/applications/queries";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { useCommandPaletteStore } from "../store";
|
||||
import { BaseCommandGroup } from "./base";
|
||||
|
||||
type Application = RouterOutput["applications"]["list"][number];
|
||||
type Thread = RouterOutput["agent"]["threads"]["list"][number];
|
||||
type SearchPage = "resumes" | "applications" | "threads";
|
||||
|
||||
const isSearchPage = (page: string | undefined): page is SearchPage =>
|
||||
page === "resumes" || page === "applications" || page === "threads";
|
||||
|
||||
const getRouteSearchPage = (pathname: string): SearchPage | undefined => {
|
||||
if (pathname.startsWith("/dashboard/resumes")) return "resumes";
|
||||
if (pathname.startsWith("/dashboard/applications")) return "applications";
|
||||
if (pathname.startsWith("/agent")) return "threads";
|
||||
};
|
||||
|
||||
const matchesSearch = (search: string, values: Array<string | null | undefined>) => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return !query || values.some((value) => value?.toLowerCase().includes(query));
|
||||
};
|
||||
|
||||
export function ResumesCommandGroup() {
|
||||
const navigate = useNavigate();
|
||||
const { openDialog } = useDialogStore();
|
||||
const { session } = useRouteContext({ strict: false });
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||
const reset = useCommandPaletteStore((state) => state.reset);
|
||||
const peekPage = useCommandPaletteStore((state) => state.peekPage);
|
||||
const pushPage = useCommandPaletteStore((state) => state.pushPage);
|
||||
const search = useCommandPaletteStore((state) => state.search);
|
||||
|
||||
const isResumesPage = peekPage() === "resumes";
|
||||
const commandPage = peekPage();
|
||||
const commandSearchPage = isSearchPage(commandPage) ? commandPage : undefined;
|
||||
const searchPage = commandSearchPage ?? (!commandPage ? getRouteSearchPage(pathname) : undefined);
|
||||
|
||||
const { data: resumes, isLoading } = useQuery(
|
||||
orpc.resume.list.queryOptions({
|
||||
enabled: !!session && isResumesPage,
|
||||
enabled: !!session && searchPage === "resumes",
|
||||
input: { sort: "lastUpdatedAt", tags: [] },
|
||||
}),
|
||||
);
|
||||
const { data: applications, isLoading: isLoadingApplications } = useQuery({
|
||||
...applicationsListQueryOptions(),
|
||||
enabled: !!session && searchPage === "applications",
|
||||
});
|
||||
const { data: threads, isLoading: isLoadingThreads } = useQuery(
|
||||
orpc.agent.threads.list.queryOptions({
|
||||
enabled: !!session && searchPage === "threads",
|
||||
}),
|
||||
);
|
||||
|
||||
const filteredResumes = (resumes ?? []).filter((resume) => matchesSearch(search, [resume.name, resume.slug]));
|
||||
const filteredApplications = (applications ?? []).filter((application) =>
|
||||
matchesSearch(search, [application.company, application.role]),
|
||||
);
|
||||
const filteredThreads = (threads ?? []).filter((thread) =>
|
||||
matchesSearch(search, [thread.title, thread.resumeName, thread.providerLabel]),
|
||||
);
|
||||
|
||||
const onCreate = async () => {
|
||||
await navigate({ to: "/dashboard/resumes" });
|
||||
@@ -39,6 +80,24 @@ export function ResumesCommandGroup() {
|
||||
reset();
|
||||
};
|
||||
|
||||
const onCreateApplication = async () => {
|
||||
await navigate({ to: "/dashboard/applications", search: { create: true } });
|
||||
reset();
|
||||
};
|
||||
|
||||
const onOpenApplication = async (application: Application) => {
|
||||
await navigate({
|
||||
to: "/dashboard/applications",
|
||||
search: { applicationId: application.id },
|
||||
});
|
||||
reset();
|
||||
};
|
||||
|
||||
const onOpenThread = async (thread: Thread) => {
|
||||
await navigate({ to: "/agent/$threadId", params: { threadId: thread.id } });
|
||||
reset();
|
||||
};
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
return (
|
||||
@@ -48,38 +107,112 @@ export function ResumesCommandGroup() {
|
||||
<ReadCvLogoIcon />
|
||||
<Trans>Resumes</Trans>
|
||||
</CommandItem>
|
||||
</BaseCommandGroup>
|
||||
|
||||
<BaseCommandGroup page="resumes" heading={<Trans>Resumes</Trans>}>
|
||||
<CommandItem onSelect={onCreate}>
|
||||
<PlusIcon />
|
||||
<Trans>Create a new resume</Trans>
|
||||
<CommandItem keywords={[t`Applications`]} value="search.applications" onSelect={() => pushPage("applications")}>
|
||||
<BriefcaseIcon />
|
||||
<Trans>Applications</Trans>
|
||||
</CommandItem>
|
||||
|
||||
{isLoading ? (
|
||||
<CommandLoading>
|
||||
<Trans>Loading resumes…</Trans>
|
||||
</CommandLoading>
|
||||
) : (
|
||||
resumes?.map((resume) => (
|
||||
<CommandItem
|
||||
key={resume.id}
|
||||
value={resume.id}
|
||||
keywords={[resume.name]}
|
||||
onSelect={() => onNavigate(`/builder/${resume.id}`)}
|
||||
>
|
||||
<ReadCvLogoIcon />
|
||||
{resume.name}
|
||||
|
||||
<CommandShortcut className="opacity-0 transition-opacity group-data-[selected=true]/command-item:opacity-100">
|
||||
<Trans comment="Command palette hint that pressing Enter opens the selected resume">
|
||||
Press <Kbd>Enter</Kbd> to open
|
||||
</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
))
|
||||
)}
|
||||
<CommandItem keywords={[t`Threads`, t`Agent`]} value="search.threads" onSelect={() => pushPage("threads")}>
|
||||
<ChatCircleDotsIcon />
|
||||
<Trans>Threads</Trans>
|
||||
</CommandItem>
|
||||
</BaseCommandGroup>
|
||||
|
||||
{searchPage === "resumes" ? (
|
||||
<BaseCommandGroup page={commandSearchPage} heading={<Trans>Resumes</Trans>}>
|
||||
<CommandItem value="resumes.create" onSelect={onCreate}>
|
||||
<PlusIcon />
|
||||
<Trans>Create a new resume</Trans>
|
||||
</CommandItem>
|
||||
|
||||
{isLoading ? (
|
||||
<CommandLoading>
|
||||
<Trans>Loading resumes…</Trans>
|
||||
</CommandLoading>
|
||||
) : (
|
||||
filteredResumes.map((resume) => (
|
||||
<CommandItem
|
||||
key={resume.id}
|
||||
value={`resume.${resume.id}`}
|
||||
keywords={[resume.name, resume.slug]}
|
||||
onSelect={() => onNavigate(`/builder/${resume.id}`)}
|
||||
>
|
||||
<ReadCvLogoIcon />
|
||||
{resume.name}
|
||||
|
||||
<CommandShortcut className="opacity-0 transition-opacity group-data-[selected=true]/command-item:opacity-100">
|
||||
<Trans comment="Command palette hint that pressing Enter opens the selected resume">
|
||||
Press <Kbd>Enter</Kbd> to open
|
||||
</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
))
|
||||
)}
|
||||
</BaseCommandGroup>
|
||||
) : null}
|
||||
|
||||
{searchPage === "applications" ? (
|
||||
<BaseCommandGroup page={commandSearchPage} heading={<Trans>Applications</Trans>}>
|
||||
<CommandItem value="applications.create" onSelect={onCreateApplication}>
|
||||
<PlusIcon />
|
||||
<Trans>New Application</Trans>
|
||||
</CommandItem>
|
||||
|
||||
{isLoadingApplications ? (
|
||||
<CommandLoading>
|
||||
<Trans>Loading applications…</Trans>
|
||||
</CommandLoading>
|
||||
) : (
|
||||
filteredApplications.map((application) => (
|
||||
<CommandItem
|
||||
key={application.id}
|
||||
value={`application.${application.id}`}
|
||||
keywords={[application.company, application.role]}
|
||||
onSelect={() => onOpenApplication(application)}
|
||||
>
|
||||
<BriefcaseIcon />
|
||||
<span className="min-w-0 truncate">{application.company}</span>
|
||||
<span className="truncate text-muted-foreground text-xs">{application.role}</span>
|
||||
</CommandItem>
|
||||
))
|
||||
)}
|
||||
</BaseCommandGroup>
|
||||
) : null}
|
||||
|
||||
{searchPage === "threads" ? (
|
||||
<BaseCommandGroup page={commandSearchPage} heading={<Trans>Threads</Trans>}>
|
||||
<CommandItem value="threads.create" onSelect={() => onNavigate("/agent/new")}>
|
||||
<PlusIcon />
|
||||
<Trans>New Thread</Trans>
|
||||
</CommandItem>
|
||||
|
||||
{isLoadingThreads ? (
|
||||
<CommandLoading>
|
||||
<Trans>Loading threads…</Trans>
|
||||
</CommandLoading>
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
const title = thread.title === thread.resumeName ? t`New thread` : thread.title;
|
||||
const resumeName = thread.resumeName ?? "";
|
||||
const providerLabel = thread.providerLabel ?? "";
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={thread.id}
|
||||
value={`thread.${thread.id}`}
|
||||
keywords={[title, resumeName, providerLabel]}
|
||||
onSelect={() => onOpenThread(thread)}
|
||||
>
|
||||
<ChatCircleDotsIcon />
|
||||
<span className="min-w-0 truncate">{title}</span>
|
||||
<span className="truncate text-muted-foreground text-xs">{resumeName}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</BaseCommandGroup>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link, stripSearchParams, useNavigate } from "@tanstack/react-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@reactive-resume/ui/components/input-group";
|
||||
@@ -49,9 +49,11 @@ const searchSchema = z.object({
|
||||
tags: z.array(z.string()).default([]),
|
||||
sort: z.enum(["updated", "applied", "company", "role"]).default("updated"),
|
||||
archived: z.boolean().default(false),
|
||||
create: z.boolean().default(false),
|
||||
applicationId: z.string().optional(),
|
||||
});
|
||||
type Search = z.output<typeof searchSchema>;
|
||||
const defaultSearch: Search = { search: "", view: "board", tags: [], sort: "updated", archived: false };
|
||||
const defaultSearch: Search = { search: "", view: "board", tags: [], sort: "updated", archived: false, create: false };
|
||||
|
||||
export const Route = createFileRoute("/dashboard/applications/")({
|
||||
component: RouteComponent,
|
||||
@@ -61,7 +63,7 @@ export const Route = createFileRoute("/dashboard/applications/")({
|
||||
|
||||
function RouteComponent() {
|
||||
const { i18n } = useLingui();
|
||||
const { search, view, tags, sort, archived } = Route.useSearch();
|
||||
const { search, view, tags, sort, archived, create, applicationId } = Route.useSearch();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
@@ -75,9 +77,23 @@ function RouteComponent() {
|
||||
setEditing(application);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!create) return;
|
||||
|
||||
setAddOpen(true);
|
||||
void navigate({ replace: true, search: (prev: Search) => ({ ...prev, create: false }) });
|
||||
}, [create, navigate]);
|
||||
|
||||
const { data: applications } = useQuery(applicationsListQueryOptions());
|
||||
const { data: allTags } = useQuery(orpc.applications.tags.queryOptions());
|
||||
|
||||
useEffect(() => {
|
||||
if (!applicationId || !applications) return;
|
||||
|
||||
setSelected(applications.find((application) => application.id === applicationId) ?? null);
|
||||
void navigate({ replace: true, search: (prev: Search) => ({ ...prev, applicationId: undefined }) });
|
||||
}, [applicationId, applications, navigate]);
|
||||
|
||||
// Board & table hide archived; tag/search filters + sort are applied client-side.
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user