mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-25 01:15:26 +10:00
chore: integrate improve-integration
This commit is contained in:
@@ -5,25 +5,25 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { FontDisplay } from "./font-display";
|
||||
|
||||
// Options depend only on the static font list, so compute them once per process
|
||||
// instead of per component instance (the body + heading pickers rendered identical output twice).
|
||||
const FONT_FAMILY_OPTIONS = fontList.map((font) => ({
|
||||
value: font.family,
|
||||
keywords: getFontSearchKeywords(font.family),
|
||||
label: (
|
||||
<FontDisplay
|
||||
family={font.family}
|
||||
label={getFontDisplayName(font.family)}
|
||||
type={font.type}
|
||||
url={"preview" in font ? font.preview : undefined}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
type FontFamilyComboboxProps = Omit<SingleComboboxProps, "options">;
|
||||
|
||||
export function FontFamilyCombobox({ className, ...props }: FontFamilyComboboxProps) {
|
||||
const options = useMemo(() => {
|
||||
return fontList.map((font) => ({
|
||||
value: font.family,
|
||||
keywords: getFontSearchKeywords(font.family),
|
||||
label: (
|
||||
<FontDisplay
|
||||
family={font.family}
|
||||
label={getFontDisplayName(font.family)}
|
||||
type={font.type}
|
||||
url={"preview" in font ? font.preview : undefined}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return <Combobox {...props} options={options} className={cn("w-full", className)} />;
|
||||
return <Combobox {...props} options={FONT_FAMILY_OPTIONS} className={cn("w-full", className)} />;
|
||||
}
|
||||
|
||||
type FontWeightComboboxProps = Omit<MultiComboboxProps, "options" | "multiple"> & { fontFamily: string };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useHotkeys } from "@tanstack/react-hotkeys";
|
||||
import { useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Command, CommandEmpty, CommandInput, CommandList } from "@reactive-resume/ui/components/command";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -17,10 +17,13 @@ import { useCommandPaletteStore } from "./store";
|
||||
|
||||
export function CommandPalette() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const commandRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedValue, setSelectedValue] = useState<string>();
|
||||
const { open, search, pages, setOpen, setSearch, goBack } = useCommandPaletteStore();
|
||||
|
||||
const isFirstPage = pages.length === 0;
|
||||
const currentPage = pages[pages.length - 1];
|
||||
const commandListPage = currentPage ?? "root";
|
||||
|
||||
// Toggle command palette with Cmd+K / Ctrl+K
|
||||
useHotkeys([
|
||||
@@ -67,6 +70,18 @@ export function CommandPalette() {
|
||||
setSearch(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const firstItem = commandRef.current?.querySelector<HTMLElement>(
|
||||
`[data-command-page="${commandListPage}"] [cmdk-item]:not([aria-disabled="true"])`,
|
||||
);
|
||||
const value = firstItem?.getAttribute("data-value");
|
||||
|
||||
if (value) setSelectedValue(value);
|
||||
inputRef.current?.focus();
|
||||
}, [open, commandListPage]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogHeader className="sr-only print:hidden">
|
||||
@@ -97,7 +112,10 @@ export function CommandPalette() {
|
||||
}
|
||||
>
|
||||
<Command
|
||||
ref={commandRef}
|
||||
loop
|
||||
value={selectedValue}
|
||||
onValueChange={setSelectedValue}
|
||||
aria-label={t({
|
||||
comment: "Accessible label for command list region inside command palette",
|
||||
message: "Command Palette",
|
||||
@@ -125,7 +143,7 @@ export function CommandPalette() {
|
||||
})}
|
||||
/>
|
||||
|
||||
<CommandList>
|
||||
<CommandList key={commandListPage} data-command-page={commandListPage}>
|
||||
<CommandEmpty>
|
||||
<Trans comment="Empty-state message when no command palette results match the search query">
|
||||
The command you're looking for doesn't exist.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
@@ -21,6 +22,10 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-hotkeys", () => ({
|
||||
useHotkeys: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
useNavigate: () => mocks.navigate,
|
||||
useRouteContext: () => ({ session: { user: { id: "user-1" } } }),
|
||||
@@ -36,6 +41,10 @@ vi.mock("@/features/applications/queries", () => ({
|
||||
applicationsListQueryOptions: mocks.applicationsListQueryOptions,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/theme/provider", () => ({
|
||||
useTheme: () => ({ setTheme: vi.fn(), theme: "light", toggleTheme: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: {
|
||||
resume: {
|
||||
@@ -53,6 +62,7 @@ vi.mock("@/libs/orpc/client", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const { CommandPalette } = await import("../index");
|
||||
const { ResumesCommandGroup } = await import("./resumes");
|
||||
const { NavigationCommandGroup } = await import("./navigation");
|
||||
|
||||
@@ -83,8 +93,67 @@ const renderGroup = () =>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
function TestCommandPalette() {
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<CommandPalette />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ResumesCommandGroup", () => {
|
||||
it("loads resumes with the default list input on the resumes page", () => {
|
||||
it.each([
|
||||
["resumes", "", "Create a new resume"],
|
||||
["applications", "{ArrowDown}", "New Application"],
|
||||
["threads", "{ArrowDown}{ArrowDown}", "New Thread"],
|
||||
])("opens the %s list page from the root palette with Enter", async (page, keys, createLabel) => {
|
||||
mockUseQueryData((entity) => {
|
||||
if (entity === "resumes") return [{ id: "resume-1", name: "Evil Apricot Pike", slug: "apricot" }];
|
||||
if (entity === "applications")
|
||||
return [{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false }];
|
||||
if (entity === "threads")
|
||||
return [{ id: "thread-1", title: "Cover letter rewrite", resumeName: "Product Resume" }];
|
||||
return [];
|
||||
});
|
||||
|
||||
useCommandPaletteStore.setState({ open: true });
|
||||
render(<TestCommandPalette />);
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
await userEvent.keyboard(`${keys}{Enter}`);
|
||||
|
||||
expect(useCommandPaletteStore.getState().pages).toEqual([page]);
|
||||
expect(await screen.findByText(createLabel)).toBeInTheDocument();
|
||||
expect(screen.queryByText("The command you're looking for doesn't exist.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["resumes", "", "Create a new resume", "Evil Apricot Pike"],
|
||||
["applications", "{ArrowDown}", "New Application", "Umbrella"],
|
||||
["threads", "{ArrowDown}{ArrowDown}", "New Thread", "Cover letter rewrite"],
|
||||
])("keeps arrow-key navigation active on the %s list page", async (_page, keys, createLabel, itemLabel) => {
|
||||
mockUseQueryData((entity) => {
|
||||
if (entity === "resumes") return [{ id: "resume-1", name: "Evil Apricot Pike", slug: "apricot" }];
|
||||
if (entity === "applications")
|
||||
return [{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false }];
|
||||
if (entity === "threads")
|
||||
return [{ id: "thread-1", title: "Cover letter rewrite", resumeName: "Product Resume" }];
|
||||
return [];
|
||||
});
|
||||
|
||||
useCommandPaletteStore.setState({ open: true });
|
||||
render(<TestCommandPalette />);
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
await userEvent.keyboard(`${keys}{Enter}`);
|
||||
const createItem = await screen.findByText(createLabel);
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
|
||||
expect((await screen.findByText(itemLabel)).closest("[cmdk-item]")).toHaveAttribute("aria-selected", "true");
|
||||
await userEvent.keyboard("{ArrowUp}");
|
||||
|
||||
expect(createItem.closest("[cmdk-item]")).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("does not show resume results beside the root categories on the resumes route", () => {
|
||||
mocks.pathname = "/dashboard/resumes";
|
||||
mockUseQueryData((entity) =>
|
||||
entity === "resumes" ? [{ id: "resume-1", name: "Evil Apricot Pike", slug: "apricot" }] : [],
|
||||
@@ -92,6 +161,19 @@ describe("ResumesCommandGroup", () => {
|
||||
|
||||
renderGroup();
|
||||
|
||||
expect(screen.getAllByText("Resumes")).toHaveLength(1);
|
||||
expect(screen.queryByText("Create a new resume")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Evil Apricot Pike")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads resumes with the default list input after opening the resumes command page", () => {
|
||||
useCommandPaletteStore.setState({ pages: ["resumes"] });
|
||||
mockUseQueryData((entity) =>
|
||||
entity === "resumes" ? [{ id: "resume-1", name: "Evil Apricot Pike", slug: "apricot" }] : [],
|
||||
);
|
||||
|
||||
renderGroup();
|
||||
|
||||
expect(mocks.resumeQueryOptions).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
input: { sort: "lastUpdatedAt", tags: [] },
|
||||
@@ -100,8 +182,7 @@ describe("ResumesCommandGroup", () => {
|
||||
});
|
||||
|
||||
it("filters resume results from the command palette search", () => {
|
||||
mocks.pathname = "/dashboard/resumes";
|
||||
useCommandPaletteStore.setState({ search: "apri" });
|
||||
useCommandPaletteStore.setState({ pages: ["resumes"], search: "apri" });
|
||||
mockUseQueryData((entity) =>
|
||||
entity === "resumes"
|
||||
? [
|
||||
@@ -118,7 +199,7 @@ describe("ResumesCommandGroup", () => {
|
||||
});
|
||||
|
||||
it("loads applications on the applications page", () => {
|
||||
mocks.pathname = "/dashboard/applications";
|
||||
useCommandPaletteStore.setState({ pages: ["applications"] });
|
||||
mockUseQueryData((entity) =>
|
||||
entity === "applications"
|
||||
? [{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false }]
|
||||
@@ -132,8 +213,7 @@ describe("ResumesCommandGroup", () => {
|
||||
});
|
||||
|
||||
it("filters application results from the command palette search", () => {
|
||||
mocks.pathname = "/dashboard/applications";
|
||||
useCommandPaletteStore.setState({ search: "umbrella" });
|
||||
useCommandPaletteStore.setState({ pages: ["applications"], search: "umbrella" });
|
||||
mockUseQueryData((entity) =>
|
||||
entity === "applications"
|
||||
? [
|
||||
@@ -150,7 +230,7 @@ describe("ResumesCommandGroup", () => {
|
||||
});
|
||||
|
||||
it("opens the selected application by id", () => {
|
||||
mocks.pathname = "/dashboard/applications";
|
||||
useCommandPaletteStore.setState({ pages: ["applications"] });
|
||||
mockUseQueryData((entity) =>
|
||||
entity === "applications"
|
||||
? [{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false }]
|
||||
@@ -167,7 +247,7 @@ describe("ResumesCommandGroup", () => {
|
||||
});
|
||||
|
||||
it("loads threads on agent pages", () => {
|
||||
mocks.pathname = "/agent";
|
||||
useCommandPaletteStore.setState({ pages: ["threads"] });
|
||||
mockUseQueryData((entity) =>
|
||||
entity === "threads" ? [{ id: "thread-1", title: "Cover letter rewrite", resumeName: "Product Resume" }] : [],
|
||||
);
|
||||
@@ -179,8 +259,7 @@ describe("ResumesCommandGroup", () => {
|
||||
});
|
||||
|
||||
it("filters thread results from the command palette search", () => {
|
||||
mocks.pathname = "/agent";
|
||||
useCommandPaletteStore.setState({ search: "cover" });
|
||||
useCommandPaletteStore.setState({ pages: ["threads"], search: "cover" });
|
||||
mockUseQueryData((entity) =>
|
||||
entity === "threads"
|
||||
? [
|
||||
|
||||
@@ -3,7 +3,7 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { BriefcaseIcon, ChatCircleDotsIcon, PlusIcon, ReadCvLogoIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useRouteContext, useRouterState } from "@tanstack/react-router";
|
||||
import { useNavigate, useRouteContext } 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";
|
||||
@@ -20,12 +20,6 @@ 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));
|
||||
@@ -35,7 +29,6 @@ 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);
|
||||
@@ -43,7 +36,7 @@ export function ResumesCommandGroup() {
|
||||
|
||||
const commandPage = peekPage();
|
||||
const commandSearchPage = isSearchPage(commandPage) ? commandPage : undefined;
|
||||
const searchPage = commandSearchPage ?? (!commandPage ? getRouteSearchPage(pathname) : undefined);
|
||||
const searchPage = commandSearchPage;
|
||||
|
||||
const { data: resumes, isLoading } = useQuery(
|
||||
orpc.resume.list.queryOptions({
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
FileTextIcon,
|
||||
MarkdownLogoIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useResumeExport } from "./use-resume-export";
|
||||
@@ -63,6 +64,8 @@ function FormatRow({ action, description, disabled, icon, title }: FormatRowProp
|
||||
export function ResumeDownloadDialog({ resume, trigger }: ResumeDownloadDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [scope, setScope] = useState<ResumeExportTarget>("resume");
|
||||
const [includeCoverLetterHeader, setIncludeCoverLetterHeader] = useState(false);
|
||||
const includeHeaderSwitchId = useId();
|
||||
const { hasCoverLetter, isExporting, onDownloadDOCX, onDownloadJSON, onDownloadMarkdown, onDownloadPDF } =
|
||||
useResumeExport(resume);
|
||||
const disabled = !resume || isExporting;
|
||||
@@ -102,6 +105,28 @@ export function ResumeDownloadDialog({ resume, trigger }: ResumeDownloadDialogPr
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{activeScope === "cover-letter" && (
|
||||
<label
|
||||
htmlFor={includeHeaderSwitchId}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-lg border bg-background p-3"
|
||||
>
|
||||
<Switch
|
||||
id={includeHeaderSwitchId}
|
||||
checked={includeCoverLetterHeader}
|
||||
onCheckedChange={setIncludeCoverLetterHeader}
|
||||
/>
|
||||
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="font-medium text-sm">
|
||||
<Trans>Include resume header</Trans>
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs leading-normal">
|
||||
<Trans>Show the same first-page header on the cover letter.</Trans>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<FormatRow
|
||||
icon={
|
||||
@@ -114,7 +139,7 @@ export function ResumeDownloadDialog({ resume, trigger }: ResumeDownloadDialogPr
|
||||
size="sm"
|
||||
aria-label="Download PDF"
|
||||
disabled={isExporting}
|
||||
onClick={() => run(() => onDownloadPDF(activeScope))}
|
||||
onClick={() => run(() => onDownloadPDF(activeScope, { includeCoverLetterHeader }))}
|
||||
>
|
||||
<DownloadSimpleIcon />
|
||||
<Trans>Download</Trans>
|
||||
|
||||
@@ -5,6 +5,10 @@ import { createResumePdfBlob as createPdfBlob } from "@reactive-resume/pdf/brows
|
||||
import { ResumeDocument } from "@reactive-resume/pdf/document";
|
||||
import { createSectionTitleResolverForLocale, useSectionTitleResolver } from "@/libs/resume/section-title-locale";
|
||||
|
||||
type ResumePdfRenderOptions = {
|
||||
includeCoverLetterHeader?: boolean;
|
||||
};
|
||||
|
||||
export const useLocalizedResumeDocument = (data?: ResumeData, template?: Template) => {
|
||||
const sectionTitleResolver = useSectionTitleResolver(data?.metadata.page.locale);
|
||||
|
||||
@@ -21,12 +25,17 @@ export const useLocalizedResumeDocument = (data?: ResumeData, template?: Templat
|
||||
}, [data, template, sectionTitleResolver]);
|
||||
};
|
||||
|
||||
export const createResumePdfBlob = async (data: ResumeData, template?: Template) => {
|
||||
export const createResumePdfBlob = async (
|
||||
data: ResumeData,
|
||||
template?: Template,
|
||||
renderOptions?: ResumePdfRenderOptions,
|
||||
) => {
|
||||
const sectionTitleResolver = await createSectionTitleResolverForLocale(data.metadata.page.locale);
|
||||
|
||||
return createPdfBlob({
|
||||
data,
|
||||
template,
|
||||
...(renderOptions ? { renderOptions } : {}),
|
||||
resolveSectionTitle: sectionTitleResolver,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -33,6 +33,10 @@ const getExportName = (resume: ExportableResume) => resume.name || resume.data.b
|
||||
const getTargetExportName = (resume: ExportableResume, target: ResumeExportTarget) =>
|
||||
target === "cover-letter" ? `${getExportName(resume)} Cover Letter` : getExportName(resume);
|
||||
|
||||
type DownloadPdfOptions = {
|
||||
includeCoverLetterHeader?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Single source of truth for resume export (PDF / DOCX / JSON / Print). Previously duplicated verbatim
|
||||
* between the builder dock and the right-panel Export section (#17).
|
||||
@@ -76,14 +80,18 @@ export function useResumeExport(resume: ExportableResume | undefined) {
|
||||
);
|
||||
|
||||
const onDownloadPDF = useCallback(
|
||||
async (target: ResumeExportTarget = "resume") => {
|
||||
async (target: ResumeExportTarget = "resume", options?: DownloadPdfOptions) => {
|
||||
if (!resume) return;
|
||||
if (target === "cover-letter" && !resumeHasCoverLetter(resume.data)) return;
|
||||
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const data = getResumeExportData(resume.data, target);
|
||||
const blob = await createResumePdfBlob(data);
|
||||
const blob = await createResumePdfBlob(
|
||||
data,
|
||||
undefined,
|
||||
target === "cover-letter" ? { includeCoverLetterHeader: options?.includeCoverLetterHeader } : undefined,
|
||||
);
|
||||
downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "pdf"));
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the PDF, please try again.`);
|
||||
|
||||
@@ -142,6 +142,27 @@ describe("AISettingsSection", () => {
|
||||
mutations.delete.mockReset();
|
||||
});
|
||||
|
||||
it("offers popular AI SDK providers and labels Ollama as cloud-hosted", () => {
|
||||
renderSection();
|
||||
|
||||
for (const label of [
|
||||
"Mistral AI",
|
||||
"Cohere",
|
||||
"xAI Grok",
|
||||
"Groq",
|
||||
"DeepSeek",
|
||||
"Together.ai",
|
||||
"Fireworks",
|
||||
"Cerebras",
|
||||
"Perplexity",
|
||||
"Ollama Cloud",
|
||||
]) {
|
||||
expect(screen.getByRole("option", { name: label })).toBeInTheDocument();
|
||||
}
|
||||
|
||||
expect(screen.queryByRole("option", { name: "Ollama" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the save-and-test result as the connected provider row", async () => {
|
||||
const created = provider({});
|
||||
const tested = provider({ enabled: true, testStatus: "success", lastTestedAt: new Date("2026-07-04T01:00:00Z") });
|
||||
|
||||
@@ -64,10 +64,73 @@ const providerOptions: AIProviderOption[] = [
|
||||
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.openrouter,
|
||||
defaultModel: "openai/gpt-4.1",
|
||||
},
|
||||
{
|
||||
value: "mistral",
|
||||
label: t`Mistral AI`,
|
||||
keywords: ["mistral", "magistral"],
|
||||
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.mistral,
|
||||
defaultModel: "mistral-large-latest",
|
||||
},
|
||||
{
|
||||
value: "cohere",
|
||||
label: t`Cohere`,
|
||||
keywords: ["cohere", "command"],
|
||||
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.cohere,
|
||||
defaultModel: "command-a-03-2025",
|
||||
},
|
||||
{
|
||||
value: "xai",
|
||||
label: t`xAI Grok`,
|
||||
keywords: ["xai", "grok"],
|
||||
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.xai,
|
||||
defaultModel: "grok-4",
|
||||
},
|
||||
{
|
||||
value: "groq",
|
||||
label: t`Groq`,
|
||||
keywords: ["groq", "llama"],
|
||||
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.groq,
|
||||
defaultModel: "llama-3.3-70b-versatile",
|
||||
},
|
||||
{
|
||||
value: "deepseek",
|
||||
label: t`DeepSeek`,
|
||||
keywords: ["deepseek"],
|
||||
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.deepseek,
|
||||
defaultModel: "deepseek-chat",
|
||||
},
|
||||
{
|
||||
value: "togetherai",
|
||||
label: t`Together.ai`,
|
||||
keywords: ["together", "togetherai", "llama"],
|
||||
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.togetherai,
|
||||
defaultModel: "meta-llama/Meta-Llama-3.3-70B-Instruct-Turbo",
|
||||
},
|
||||
{
|
||||
value: "fireworks",
|
||||
label: t`Fireworks`,
|
||||
keywords: ["fireworks", "llama", "deepseek"],
|
||||
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.fireworks,
|
||||
defaultModel: "accounts/fireworks/models/llama-v3p3-70b-instruct",
|
||||
},
|
||||
{
|
||||
value: "cerebras",
|
||||
label: t`Cerebras`,
|
||||
keywords: ["cerebras", "llama"],
|
||||
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.cerebras,
|
||||
defaultModel: "llama3.3-70b",
|
||||
},
|
||||
{
|
||||
value: "perplexity",
|
||||
label: t`Perplexity`,
|
||||
keywords: ["perplexity", "sonar"],
|
||||
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.perplexity,
|
||||
defaultModel: "sonar-pro",
|
||||
},
|
||||
{
|
||||
value: "ollama",
|
||||
label: t`Ollama`,
|
||||
keywords: ["ollama", "local"],
|
||||
label: t`Ollama Cloud`,
|
||||
keywords: ["ollama", "cloud"],
|
||||
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.ollama,
|
||||
defaultModel: "llama3.1",
|
||||
},
|
||||
|
||||
@@ -92,7 +92,8 @@ describe("updateSectionItem", () => {
|
||||
});
|
||||
|
||||
const customSection = result.customSections.find((s) => s.id === "custom-1");
|
||||
expect((customSection?.items[0] as { value?: string }).value).toBe("new");
|
||||
if (!customSection) throw new Error("Custom section not found");
|
||||
expect((customSection.items[0] as { value?: string }).value).toBe("new");
|
||||
});
|
||||
|
||||
it("does nothing when custom section is not found", () => {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync("src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx", "utf8");
|
||||
|
||||
describe("layout page header", () => {
|
||||
it("uses container queries to prevent narrow sidebar control collisions", () => {
|
||||
expect(source).toContain("@container bg-secondary/50");
|
||||
expect(source).toContain("grid-cols-[minmax(0,1fr)_auto]");
|
||||
expect(source).toContain("@max-[22rem]:grid-cols-1");
|
||||
expect(source).toContain("flex min-w-0 flex-wrap");
|
||||
});
|
||||
});
|
||||
@@ -297,31 +297,37 @@ function PageContainer({
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-dashed bg-background/40">
|
||||
<div className="flex items-center justify-between bg-secondary/50 px-4 py-3">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<span className="font-medium text-xs">
|
||||
<Trans comment="Layout editor page label with 1-based page number">Page {pageIndex + 1}</Trans>
|
||||
</span>
|
||||
|
||||
<label htmlFor={fullWidthSwitchId} className="flex cursor-pointer items-center gap-2">
|
||||
<Switch
|
||||
id={fullWidthSwitchId}
|
||||
checked={page.fullWidth}
|
||||
onCheckedChange={(checked) => onToggleFullWidth(pageIndex, checked)}
|
||||
/>
|
||||
|
||||
<span className="font-medium text-muted-foreground text-xs">
|
||||
<Trans comment="Layout editor toggle label that makes a page single-column">Full Width</Trans>
|
||||
<div className="@container bg-secondary/50 px-4 py-3">
|
||||
<div className="grid @max-[22rem]:grid-cols-1 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<span className="font-medium text-xs">
|
||||
<Trans comment="Layout editor page label with 1-based page number">Page {pageIndex + 1}</Trans>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{canDelete && (
|
||||
<Button variant="ghost" onClick={() => onDelete(pageIndex)} className="h-5 w-auto gap-x-2.5 px-0!">
|
||||
<TrashIcon />
|
||||
<Trans>Delete Page</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<label htmlFor={fullWidthSwitchId} className="flex min-w-0 cursor-pointer items-center gap-2">
|
||||
<Switch
|
||||
id={fullWidthSwitchId}
|
||||
checked={page.fullWidth}
|
||||
onCheckedChange={(checked) => onToggleFullWidth(pageIndex, checked)}
|
||||
/>
|
||||
|
||||
<span className="font-medium text-muted-foreground text-xs">
|
||||
<Trans comment="Layout editor toggle label that makes a page single-column">Full Width</Trans>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onDelete(pageIndex)}
|
||||
className="size-auto gap-x-2.5 justify-self-end p-0!"
|
||||
>
|
||||
<TrashIcon />
|
||||
<Trans>Delete Page</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
@@ -64,4 +65,11 @@ describe("TemplateSectionBuilder", () => {
|
||||
const img = screen.getByAltText("Ditto") as HTMLImageElement;
|
||||
expect(img.src).toContain("/templates/jpg/ditto.jpg");
|
||||
});
|
||||
|
||||
it("keeps the template preview inline instead of mounting a hover card", () => {
|
||||
const source = readFileSync("src/routes/builder/$resumeId/-sidebar/right/sections/template.tsx", "utf8");
|
||||
|
||||
expect(source).not.toContain("@reactive-resume/ui/components/hover-card");
|
||||
expect(source).not.toContain("HoverCardContent");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { SwapIcon } from "@phosphor-icons/react";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@reactive-resume/ui/components/hover-card";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useCurrentResume } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
// Lazy so the browser PDF pipeline (pdf.js) loads only when a preview card actually opens, keeping it out
|
||||
// of the SSR/module graph — mirrors the `ResumePreview` entry convention.
|
||||
const TemplateLivePreview = lazy(() =>
|
||||
import("@/features/resume/preview/template-live-preview").then((module) => ({
|
||||
default: module.TemplateLivePreview,
|
||||
})),
|
||||
);
|
||||
|
||||
export function TemplateSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="template">
|
||||
@@ -40,43 +29,19 @@ function TemplateSectionForm() {
|
||||
|
||||
return (
|
||||
<div className="flex @md:flex-row flex-col items-stretch gap-x-4 gap-y-2">
|
||||
<HoverCard>
|
||||
<HoverCardTrigger
|
||||
delay={300}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onOpenTemplateGallery}
|
||||
className="group/preview relative h-auto w-40 shrink-0 cursor-pointer p-0"
|
||||
>
|
||||
<div className="relative z-10 aspect-page size-full overflow-hidden rounded-md opacity-100 transition-opacity group-hover/preview:opacity-50">
|
||||
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onOpenTemplateGallery}
|
||||
className="group/preview relative h-auto w-40 shrink-0 cursor-pointer p-0"
|
||||
>
|
||||
<div className="relative z-10 aspect-page size-full overflow-hidden rounded-md opacity-100 transition-opacity group-hover/preview:opacity-50">
|
||||
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<SwapIcon size={48} weight="thin" className="size-12" />
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<HoverCardContent side="right" align="start" className="w-64 p-1.5">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="aspect-page w-full overflow-hidden rounded-md bg-white">
|
||||
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-contain" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TemplateLivePreview
|
||||
data={resume.data}
|
||||
template={template}
|
||||
fallbackSrc={metadata.imageUrl}
|
||||
alt={t`Live preview of your resume in the ${metadata.name} template`}
|
||||
/>
|
||||
</Suspense>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<SwapIcon size={48} weight="thin" className="size-12" />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-y-4 @md:pt-1 @md:pb-3">
|
||||
<div className="space-y-1">
|
||||
|
||||
Reference in New Issue
Block a user