mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 00:43:29 +10:00
chore: add missing translations
This commit is contained in:
@@ -35,7 +35,11 @@ type ChipItemProps = {
|
||||
onRemove: (index: number) => void;
|
||||
};
|
||||
|
||||
function ChipDragPreview({ chip }: { chip: string }) {
|
||||
type ChipDragPreviewProps = {
|
||||
chip: string;
|
||||
};
|
||||
|
||||
function ChipDragPreview({ chip }: ChipDragPreviewProps) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -46,7 +50,11 @@ function ChipDragPreview({ chip }: { chip: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ChipDragOverlay({ activeChip }: { activeChip: string | null }) {
|
||||
type ChipDragOverlayProps = {
|
||||
activeChip: string | null;
|
||||
};
|
||||
|
||||
function ChipDragOverlay({ activeChip }: ChipDragOverlayProps) {
|
||||
const overlay = (
|
||||
<DragOverlay dropAnimation={null}>{activeChip ? <ChipDragPreview chip={activeChip} /> : null}</DragOverlay>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,10 @@ import { I18nProvider } from "@lingui/react";
|
||||
|
||||
const queryResult = vi.hoisted(() => ({ data: undefined as number | undefined }));
|
||||
|
||||
type CountUpProps = {
|
||||
to: number;
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => queryResult,
|
||||
}));
|
||||
@@ -14,7 +18,7 @@ vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: { statistics: { github: { getStarCount: { queryOptions: () => ({}) } } } },
|
||||
}));
|
||||
vi.mock("../animation/count-up", () => ({
|
||||
CountUp: ({ to }: { to: number }) => <span data-testid="count-up">{to.toLocaleString()}</span>,
|
||||
CountUp: ({ to }: CountUpProps) => <span data-testid="count-up">{to.toLocaleString()}</span>,
|
||||
}));
|
||||
|
||||
const { GithubStarsButton } = await import("./github-stars-button");
|
||||
|
||||
@@ -5,23 +5,20 @@ import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
|
||||
type GridProps = {
|
||||
rowCount: number;
|
||||
columnCount: number;
|
||||
cellComponent: React.ComponentType<
|
||||
{ rowIndex: number; columnIndex: number; style: React.CSSProperties } & Record<string, unknown>
|
||||
>;
|
||||
cellProps: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// react-window's <Grid> uses ResizeObserver / IntersectionObserver heavily and
|
||||
// expects measurable layouts that happy-dom can't provide. Stub it with a simple
|
||||
// pass-through that renders the first row of cells via the supplied cellComponent.
|
||||
vi.mock("react-window", () => ({
|
||||
Grid: ({
|
||||
rowCount,
|
||||
columnCount,
|
||||
cellComponent: CellComponent,
|
||||
cellProps,
|
||||
}: {
|
||||
rowCount: number;
|
||||
columnCount: number;
|
||||
cellComponent: React.ComponentType<
|
||||
{ rowIndex: number; columnIndex: number; style: React.CSSProperties } & Record<string, unknown>
|
||||
>;
|
||||
cellProps: Record<string, unknown>;
|
||||
}) => {
|
||||
Grid: ({ rowCount, columnCount, cellComponent: CellComponent, cellProps }: GridProps) => {
|
||||
const cells: React.ReactNode[] = [];
|
||||
for (let r = 0; r < Math.min(rowCount, 1); r++) {
|
||||
for (let c = 0; c < columnCount; c++) {
|
||||
|
||||
@@ -372,7 +372,12 @@ function useEditorToolbarState(editor: Editor) {
|
||||
|
||||
type EditorToolbarState = ReturnType<typeof useEditorToolbarState>;
|
||||
|
||||
function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen: boolean }) {
|
||||
type EditorToolbarProps = {
|
||||
editor: Editor;
|
||||
isFullscreen: boolean;
|
||||
};
|
||||
|
||||
function EditorToolbar({ editor, isFullscreen }: EditorToolbarProps) {
|
||||
const state = useEditorToolbarState(editor);
|
||||
|
||||
return renderEditorToolbar(state, isFullscreen);
|
||||
|
||||
@@ -8,8 +8,12 @@ const comboboxMock = vi.hoisted(() => ({
|
||||
props: undefined as { onValueChange?: (value: string[] | null) => void } | undefined,
|
||||
}));
|
||||
|
||||
type ComboboxProps = {
|
||||
onValueChange?: (value: string[] | null) => void;
|
||||
};
|
||||
|
||||
vi.mock("@/components/ui/combobox", () => ({
|
||||
Combobox: (props: { onValueChange?: (value: string[] | null) => void }) => {
|
||||
Combobox: (props: ComboboxProps) => {
|
||||
comboboxMock.props = props;
|
||||
return null;
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ComboboxOption } from "./combobox";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
@@ -46,6 +47,26 @@ describe("Combobox", () => {
|
||||
expect(screen.getAllByText(/Gamma/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders grouped options with group labels", async () => {
|
||||
const user = userEvent.setup();
|
||||
const groupedOptions: ComboboxOption[] = [
|
||||
{ value: "alpha", label: "Alpha", group: "Primary" },
|
||||
{ value: "beta", label: "Beta", group: { value: "secondary", label: "Secondary" } },
|
||||
{ value: "gamma", label: "Gamma", group: { value: "secondary", label: "Secondary" } },
|
||||
];
|
||||
|
||||
wrap(<Combobox options={groupedOptions} placeholder="Pick something" />);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
|
||||
expect(screen.getByText("Primary")).toBeInTheDocument();
|
||||
expect(screen.getByText("Secondary")).toBeInTheDocument();
|
||||
expect(screen.getByText("Alpha")).toBeInTheDocument();
|
||||
expect(screen.getByText("Beta")).toBeInTheDocument();
|
||||
expect(screen.getByText("Gamma")).toBeInTheDocument();
|
||||
expect(document.querySelectorAll("[data-slot=combobox-group]")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders nothing extra when given an empty options array (no crash)", () => {
|
||||
expect(() => wrap(<Combobox options={[]} placeholder="Empty" />)).not.toThrow();
|
||||
expect(screen.getByText("Empty")).toBeInTheDocument();
|
||||
|
||||
@@ -5,10 +5,13 @@ import React from "react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
ComboboxClear,
|
||||
ComboboxCollection,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
ComboboxRoot,
|
||||
ComboboxTrigger,
|
||||
@@ -22,10 +25,21 @@ import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
type ComboboxOption<TValue extends string | number = string> = {
|
||||
value: TValue;
|
||||
label: React.ReactNode;
|
||||
group?: string | ComboboxOptionGroup;
|
||||
keywords?: string[];
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type ComboboxOptionGroup = {
|
||||
value: string;
|
||||
label: React.ReactNode;
|
||||
};
|
||||
|
||||
type GroupedComboboxOption<TValue extends string | number = string> = ComboboxOptionGroup & {
|
||||
key: string;
|
||||
items: ComboboxOption<TValue>[];
|
||||
};
|
||||
|
||||
type SingleComboboxProps<TValue extends string | number = string> = {
|
||||
options: ComboboxOption<TValue>[];
|
||||
value?: TValue | null;
|
||||
@@ -81,6 +95,40 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
|
||||
|
||||
const optionMap = React.useMemo(() => new Map(options.map((opt) => [String(opt.value), opt])), [options]);
|
||||
|
||||
const optionGroups = React.useMemo(() => {
|
||||
const groups: GroupedComboboxOption<TValue>[] = [];
|
||||
const groupMap = new Map<string, GroupedComboboxOption<TValue>>();
|
||||
let ungroupedGroup: GroupedComboboxOption<TValue> | null = null;
|
||||
let hasGroupedOptions = false;
|
||||
|
||||
for (const option of options) {
|
||||
if (option.group === undefined) {
|
||||
if (!ungroupedGroup) {
|
||||
ungroupedGroup = { key: "ungrouped", value: "", label: null, items: [] };
|
||||
groups.push(ungroupedGroup);
|
||||
}
|
||||
|
||||
ungroupedGroup.items.push(option);
|
||||
continue;
|
||||
}
|
||||
|
||||
hasGroupedOptions = true;
|
||||
|
||||
const group = typeof option.group === "string" ? { value: option.group, label: option.group } : option.group;
|
||||
let optionGroup = groupMap.get(group.value);
|
||||
|
||||
if (!optionGroup) {
|
||||
optionGroup = { ...group, key: `group:${group.value}`, items: [] };
|
||||
groupMap.set(group.value, optionGroup);
|
||||
groups.push(optionGroup);
|
||||
}
|
||||
|
||||
optionGroup.items.push(option);
|
||||
}
|
||||
|
||||
return hasGroupedOptions ? groups : null;
|
||||
}, [options]);
|
||||
|
||||
const findOption = React.useCallback(
|
||||
(v: TValue | TValue[] | null | undefined) => {
|
||||
if (multiple) {
|
||||
@@ -157,6 +205,13 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
|
||||
</ComboboxItem>
|
||||
);
|
||||
|
||||
const groupedListContent = (group: GroupedComboboxOption<TValue>) => (
|
||||
<ComboboxGroup key={group.key} items={group.items}>
|
||||
{group.label !== null && group.label !== undefined ? <ComboboxLabel>{group.label}</ComboboxLabel> : null}
|
||||
<ComboboxCollection>{listContent}</ComboboxCollection>
|
||||
</ComboboxGroup>
|
||||
);
|
||||
|
||||
const triggerNode = (
|
||||
<ComboboxTrigger
|
||||
id={id}
|
||||
@@ -179,7 +234,7 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
|
||||
return (
|
||||
<ComboboxRoot
|
||||
name={name}
|
||||
items={options}
|
||||
items={optionGroups ?? options}
|
||||
filter={filter}
|
||||
disabled={disabled}
|
||||
value={selectedValue as ComboboxOption<TValue>[] & ComboboxOption<TValue>}
|
||||
@@ -210,7 +265,7 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
|
||||
render={<Input disabled={disabled} className="border-none focus-visible:border-none focus-visible:ring-0" />}
|
||||
/>
|
||||
<ComboboxEmpty>{emptyMessage ?? t`No results found.`}</ComboboxEmpty>
|
||||
<ComboboxList>{listContent}</ComboboxList>
|
||||
<ComboboxList>{optionGroups ? groupedListContent : listContent}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>
|
||||
);
|
||||
|
||||
@@ -36,6 +36,14 @@ const verifyFormSchema = z.object({
|
||||
|
||||
type TwoFactorSetupStep = "backup" | "enable" | "verify";
|
||||
|
||||
type TwoFactorStepProps = {
|
||||
step: TwoFactorSetupStep;
|
||||
};
|
||||
|
||||
type TwoFactorQRCodeProps = {
|
||||
totpUri: string;
|
||||
};
|
||||
|
||||
export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">) {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -345,7 +353,7 @@ function extractSecretFromTotpUri(totpUri: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function TwoFactorDialogTitle({ step }: { step: TwoFactorSetupStep }) {
|
||||
function TwoFactorDialogTitle({ step }: TwoFactorStepProps) {
|
||||
return match(step)
|
||||
.with("enable", () => <Trans>Enable Two-Factor Authentication</Trans>)
|
||||
.with("verify", () => <Trans>Setup Authenticator App</Trans>)
|
||||
@@ -353,7 +361,7 @@ function TwoFactorDialogTitle({ step }: { step: TwoFactorSetupStep }) {
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
function TwoFactorDialogDescription({ step }: { step: TwoFactorSetupStep }) {
|
||||
function TwoFactorDialogDescription({ step }: TwoFactorStepProps) {
|
||||
return match(step)
|
||||
.with("enable", () => (
|
||||
<Trans>
|
||||
@@ -371,7 +379,7 @@ function TwoFactorDialogDescription({ step }: { step: TwoFactorSetupStep }) {
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
function TwoFactorQRCode({ totpUri }: { totpUri: string }) {
|
||||
function TwoFactorQRCode({ totpUri }: TwoFactorQRCodeProps) {
|
||||
return (
|
||||
<QRCodeSVG
|
||||
value={totpUri}
|
||||
|
||||
@@ -42,6 +42,7 @@ vi.mock("pdfjs-dist", () => {
|
||||
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => pdfjsMock.legacyModule);
|
||||
|
||||
const pdfCanvasModule = import("./pdf-canvas");
|
||||
const pdfCanvasModuleTimeoutMs = 15_000;
|
||||
|
||||
describe("PDF.js browser entrypoints", () => {
|
||||
beforeEach(() => {
|
||||
@@ -60,18 +61,22 @@ describe("PDF.js browser entrypoints", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("loads the canvas preview renderer from the legacy PDF.js runtime", async () => {
|
||||
await expect(pdfCanvasModule).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
PdfCanvasDocument: expect.any(Function),
|
||||
PdfCanvasPage: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
it(
|
||||
"loads the canvas preview renderer from the legacy PDF.js runtime",
|
||||
async () => {
|
||||
await expect(pdfCanvasModule).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
PdfCanvasDocument: expect.any(Function),
|
||||
PdfCanvasPage: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(pdfjsMock.legacyModule.GlobalWorkerOptions.workerSrc).toContain(
|
||||
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
|
||||
);
|
||||
});
|
||||
expect(pdfjsMock.legacyModule.GlobalWorkerOptions.workerSrc).toContain(
|
||||
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
|
||||
);
|
||||
},
|
||||
pdfCanvasModuleTimeoutMs,
|
||||
);
|
||||
|
||||
it("creates thumbnails with the legacy PDF.js runtime", async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({} as CanvasRenderingContext2D);
|
||||
|
||||
@@ -11,6 +11,18 @@ const previewMock = vi.hoisted(() => ({
|
||||
toBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
|
||||
}));
|
||||
|
||||
type PdfCanvasDocumentProps = {
|
||||
children: (document: { numPages: number }) => React.ReactNode;
|
||||
onLoadSuccess: (document: { numPages: number }) => void;
|
||||
};
|
||||
|
||||
type PdfCanvasPageProps = {
|
||||
onLoadSuccess: (pageNumber: number, pageSize: { height: number; width: number }) => void;
|
||||
onRenderSuccess?: () => void;
|
||||
pageNumber: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
const resumeDataWithPageCount = (pageCount: number): ResumeData => ({
|
||||
...sampleResumeData,
|
||||
metadata: {
|
||||
@@ -35,30 +47,14 @@ vi.mock("./pdf-canvas", async () => {
|
||||
const pdfDocument = { numPages: 1 };
|
||||
|
||||
return {
|
||||
PdfCanvasDocument: ({
|
||||
children,
|
||||
onLoadSuccess,
|
||||
}: {
|
||||
children: (document: typeof pdfDocument) => React.ReactNode;
|
||||
onLoadSuccess: (document: typeof pdfDocument) => void;
|
||||
}) => {
|
||||
PdfCanvasDocument: ({ children, onLoadSuccess }: PdfCanvasDocumentProps) => {
|
||||
React.useEffect(() => {
|
||||
onLoadSuccess(pdfDocument);
|
||||
}, [onLoadSuccess]);
|
||||
|
||||
return React.createElement(React.Fragment, null, children(pdfDocument));
|
||||
},
|
||||
PdfCanvasPage: ({
|
||||
onLoadSuccess,
|
||||
onRenderSuccess,
|
||||
pageNumber,
|
||||
totalPages,
|
||||
}: {
|
||||
onLoadSuccess: (pageNumber: number, pageSize: { height: number; width: number }) => void;
|
||||
onRenderSuccess?: () => void;
|
||||
pageNumber: number;
|
||||
totalPages: number;
|
||||
}) => {
|
||||
PdfCanvasPage: ({ onLoadSuccess, onRenderSuccess, pageNumber, totalPages }: PdfCanvasPageProps) => {
|
||||
React.useEffect(() => {
|
||||
onLoadSuccess(pageNumber, { height: 200, width: 100 });
|
||||
onRenderSuccess?.();
|
||||
|
||||
@@ -8,11 +8,16 @@ import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
|
||||
type PdfViewerProps = {
|
||||
className?: string;
|
||||
data: ResumeData;
|
||||
};
|
||||
|
||||
const publicResumeMock = vi.hoisted(() => ({
|
||||
createResumePdfBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
|
||||
downloadWithAnchor: vi.fn(),
|
||||
generateFilename: vi.fn((name: string, extension: string) => `${name}.${extension}`),
|
||||
PdfViewer: vi.fn<(_props: { className?: string; data: ResumeData }) => ReactNode>(() => null),
|
||||
PdfViewer: vi.fn<(_props: PdfViewerProps) => ReactNode>(() => null),
|
||||
resume: undefined as
|
||||
| undefined
|
||||
| {
|
||||
|
||||
@@ -23,6 +23,10 @@ import { orpc } from "@/libs/orpc/client";
|
||||
type SavedProvider = RouterOutput["aiProviders"]["list"][number];
|
||||
type AIProviderOption = ComboboxOption<AIProvider> & { defaultBaseURL: string };
|
||||
|
||||
type ProviderRowProps = {
|
||||
provider: SavedProvider;
|
||||
};
|
||||
|
||||
const providerOptions: AIProviderOption[] = [
|
||||
{
|
||||
value: "openai",
|
||||
@@ -110,7 +114,7 @@ function isAiProviderConfigError(error: unknown) {
|
||||
return status === "PRECONDITION_FAILED" || status === 412;
|
||||
}
|
||||
|
||||
function ProviderRow({ provider }: { provider: SavedProvider }) {
|
||||
function ProviderRow({ provider }: ProviderRowProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.queryKey() });
|
||||
const { mutate: testProvider, isPending: isTesting } = useMutation(orpc.aiProviders.test.mutationOptions());
|
||||
|
||||
@@ -4,9 +4,11 @@ import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ConfirmDialogProvider, useConfirm } from "./use-confirm";
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<ConfirmDialogProvider>{children}</ConfirmDialogProvider>
|
||||
);
|
||||
type HookWrapperProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const wrapper = ({ children }: HookWrapperProps) => <ConfirmDialogProvider>{children}</ConfirmDialogProvider>;
|
||||
|
||||
describe("useConfirm", () => {
|
||||
it("throws when used outside ConfirmDialogProvider", () => {
|
||||
|
||||
@@ -27,9 +27,13 @@ type ConfirmContextType = {
|
||||
confirm: (title: string, options?: ConfirmOptions) => Promise<boolean>;
|
||||
};
|
||||
|
||||
type ConfirmDialogProviderProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const ConfirmContext = React.createContext<ConfirmContextType | null>(null);
|
||||
|
||||
export function ConfirmDialogProvider({ children }: { children: React.ReactNode }) {
|
||||
export function ConfirmDialogProvider({ children }: ConfirmDialogProviderProps) {
|
||||
const [state, setState] = React.useState<ConfirmState>({
|
||||
open: false,
|
||||
resolve: null,
|
||||
|
||||
@@ -5,13 +5,15 @@ import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { PromptDialogProvider, usePrompt } from "./use-prompt";
|
||||
|
||||
type HookWrapperProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<PromptDialogProvider>{children}</PromptDialogProvider>
|
||||
);
|
||||
const wrapper = ({ children }: HookWrapperProps) => <PromptDialogProvider>{children}</PromptDialogProvider>;
|
||||
|
||||
const clickButton = (re: RegExp) => {
|
||||
const buttons = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button"));
|
||||
|
||||
@@ -32,9 +32,13 @@ type PromptContextType = {
|
||||
prompt: (title: string, options?: PromptOptions) => Promise<string | null>;
|
||||
};
|
||||
|
||||
type PromptDialogProviderProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const PromptContext = React.createContext<PromptContextType | null>(null);
|
||||
|
||||
export function PromptDialogProvider({ children }: { children: React.ReactNode }) {
|
||||
export function PromptDialogProvider({ children }: PromptDialogProviderProps) {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const [state, setState] = React.useState<PromptState>({
|
||||
|
||||
@@ -5,8 +5,16 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
|
||||
type LinkProps = React.PropsWithChildren<{
|
||||
to: string;
|
||||
}>;
|
||||
|
||||
type LocaleComboboxProps = {
|
||||
render: React.ReactElement;
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: ({ children, to, ...rest }: React.PropsWithChildren<{ to: string }>) => (
|
||||
Link: ({ children, to, ...rest }: LinkProps) => (
|
||||
<a href={typeof to === "string" ? to : "#"} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
@@ -16,7 +24,7 @@ vi.mock("@/components/input/github-stars-button", () => ({
|
||||
GithubStarsButton: () => <div data-testid="github-stars-button" />,
|
||||
}));
|
||||
vi.mock("@/features/locale/combobox", () => ({
|
||||
LocaleCombobox: ({ render: renderProp }: { render: React.ReactElement }) => renderProp,
|
||||
LocaleCombobox: ({ render: renderProp }: LocaleComboboxProps) => renderProp,
|
||||
}));
|
||||
vi.mock("@/features/theme/toggle-button", () => ({
|
||||
ThemeToggleButton: () => <button type="button" data-testid="theme-toggle" />,
|
||||
|
||||
@@ -64,21 +64,106 @@ type AgentAction = AgentThreadDetail["actions"][number];
|
||||
type AgentAttachment = AgentThreadDetail["attachments"][number];
|
||||
type PatchOperation = AgentAction["operations"][number];
|
||||
|
||||
function toRecord(value: unknown) {
|
||||
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function PatchToolCard({
|
||||
part,
|
||||
action,
|
||||
onRevert,
|
||||
isReverting,
|
||||
}: {
|
||||
type PatchToolCardProps = {
|
||||
part: UIMessage["parts"][number];
|
||||
action: AgentAction | undefined;
|
||||
onRevert: (actionId: string) => void;
|
||||
isReverting: boolean;
|
||||
}) {
|
||||
};
|
||||
|
||||
type StarterPromptMarqueeProps = {
|
||||
onSelect: (prompt: string) => void;
|
||||
};
|
||||
|
||||
type AssistantMarkdownProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
type MessagePartProps = {
|
||||
part: UIMessage["parts"][number];
|
||||
isUser: boolean;
|
||||
onAnswer: (toolCallId: string, answer: string) => void;
|
||||
onRevert: (actionId: string) => void;
|
||||
isReverting: boolean;
|
||||
actionsById: Map<string, AgentAction>;
|
||||
};
|
||||
|
||||
type ChatMessageProps = {
|
||||
message: UIMessage;
|
||||
onAnswer: (toolCallId: string, answer: string) => void;
|
||||
onRevert: (actionId: string) => void;
|
||||
isReverting: boolean;
|
||||
actionsById: Map<string, AgentAction>;
|
||||
};
|
||||
|
||||
type AgentChatProps = {
|
||||
threadId: string;
|
||||
initialMessages: UIMessage[];
|
||||
isReadOnly: boolean;
|
||||
readOnlyReason: "archived" | "missing" | null;
|
||||
threadStatus: string;
|
||||
activeRunId: string | null;
|
||||
actions: AgentAction[];
|
||||
onToggleThreads?: () => void;
|
||||
onToggleResume?: () => void;
|
||||
};
|
||||
|
||||
type AgentChatReadOnlyBannerProps = {
|
||||
isReadOnly: boolean;
|
||||
readOnlyReason: "archived" | "missing" | null;
|
||||
};
|
||||
|
||||
type AgentChatMessagesProps = {
|
||||
actionsById: Map<string, AgentAction>;
|
||||
error: Error | undefined;
|
||||
isReadOnly: boolean;
|
||||
isReverting: boolean;
|
||||
isStreaming: boolean;
|
||||
messages: UIMessage[];
|
||||
onAnswer: (toolCallId: string, answer: string) => void;
|
||||
onRevert: (actionId: string) => void;
|
||||
onRetry: () => void;
|
||||
onStarterSelect: (prompt: string) => void;
|
||||
};
|
||||
|
||||
type AgentChatHeaderProps = {
|
||||
isArchived: boolean;
|
||||
isArchivePending: boolean;
|
||||
isDeletePending: boolean;
|
||||
onArchive: () => void;
|
||||
onCopyConversation: () => void;
|
||||
onCopyConversationJson: () => void;
|
||||
onDelete: () => void;
|
||||
onToggleResume?: () => void;
|
||||
onToggleThreads?: () => void;
|
||||
};
|
||||
|
||||
type AgentChatComposerProps = {
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
input: string;
|
||||
isReadOnly: boolean;
|
||||
isStreaming: boolean;
|
||||
isUploading: boolean;
|
||||
pendingAttachments: Array<Pick<AgentAttachment, "filename" | "id" | "mediaType">>;
|
||||
onInputChange: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onStopRun: () => void;
|
||||
onUploadFiles: (files: FileList | null) => void;
|
||||
};
|
||||
|
||||
type ToolbarButtonProps = React.ComponentProps<typeof Button> & {
|
||||
label: string;
|
||||
};
|
||||
|
||||
type ResumePaneProps = {
|
||||
resume: AgentThreadDetail["resume"];
|
||||
};
|
||||
|
||||
function toRecord(value: unknown) {
|
||||
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function PatchToolCard({ part, action, onRevert, isReverting }: PatchToolCardProps) {
|
||||
const partRecord = part as Record<string, unknown>;
|
||||
const state = typeof partRecord.state === "string" ? partRecord.state : null;
|
||||
const input = toRecord(partRecord.input);
|
||||
@@ -235,7 +320,7 @@ function chunkPrompts(prompts: string[], columns: number) {
|
||||
);
|
||||
}
|
||||
|
||||
function StarterPromptMarquee({ onSelect }: { onSelect: (prompt: string) => void }) {
|
||||
function StarterPromptMarquee({ onSelect }: StarterPromptMarqueeProps) {
|
||||
const prompts = [
|
||||
t`Tailor this resume to a product manager job description and emphasize roadmap ownership, stakeholder communication, and measurable launch outcomes.`,
|
||||
t`Compare this resume against this role URL and update keywords while keeping the voice concise and credible.`,
|
||||
@@ -297,7 +382,7 @@ function getMessagePartKey(messageId: string, part: UIMessage["parts"][number])
|
||||
return `${messageId}-${part.type}-${JSON.stringify(part)}`;
|
||||
}
|
||||
|
||||
function AssistantMarkdown({ text }: { text: string }) {
|
||||
function AssistantMarkdown({ text }: AssistantMarkdownProps) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
skipHtml
|
||||
@@ -331,21 +416,7 @@ function AssistantMarkdown({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MessagePart({
|
||||
part,
|
||||
isUser,
|
||||
onAnswer,
|
||||
onRevert,
|
||||
isReverting,
|
||||
actionsById,
|
||||
}: {
|
||||
part: UIMessage["parts"][number];
|
||||
isUser: boolean;
|
||||
onAnswer: (toolCallId: string, answer: string) => void;
|
||||
onRevert: (actionId: string) => void;
|
||||
isReverting: boolean;
|
||||
actionsById: Map<string, AgentAction>;
|
||||
}) {
|
||||
function MessagePart({ part, isUser, onAnswer, onRevert, isReverting, actionsById }: MessagePartProps) {
|
||||
if (part.type === "text") {
|
||||
return isUser ? (
|
||||
<div className="whitespace-pre-wrap leading-relaxed">{part.text}</div>
|
||||
@@ -427,19 +498,7 @@ function MessagePart({
|
||||
return null;
|
||||
}
|
||||
|
||||
function ChatMessage({
|
||||
message,
|
||||
onAnswer,
|
||||
onRevert,
|
||||
isReverting,
|
||||
actionsById,
|
||||
}: {
|
||||
message: UIMessage;
|
||||
onAnswer: (toolCallId: string, answer: string) => void;
|
||||
onRevert: (actionId: string) => void;
|
||||
isReverting: boolean;
|
||||
actionsById: Map<string, AgentAction>;
|
||||
}) {
|
||||
function ChatMessage({ message, onAnswer, onRevert, isReverting, actionsById }: ChatMessageProps) {
|
||||
const isUser = message.role === "user";
|
||||
|
||||
return (
|
||||
@@ -478,17 +537,7 @@ function AgentChat({
|
||||
actions,
|
||||
onToggleThreads,
|
||||
onToggleResume,
|
||||
}: {
|
||||
threadId: string;
|
||||
initialMessages: UIMessage[];
|
||||
isReadOnly: boolean;
|
||||
readOnlyReason: "archived" | "missing" | null;
|
||||
threadStatus: string;
|
||||
activeRunId: string | null;
|
||||
actions: AgentAction[];
|
||||
onToggleThreads?: () => void;
|
||||
onToggleResume?: () => void;
|
||||
}) {
|
||||
}: AgentChatProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const confirm = useConfirm();
|
||||
@@ -767,13 +816,7 @@ function AgentChat({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentChatReadOnlyBanner({
|
||||
isReadOnly,
|
||||
readOnlyReason,
|
||||
}: {
|
||||
isReadOnly: boolean;
|
||||
readOnlyReason: "archived" | "missing" | null;
|
||||
}) {
|
||||
function AgentChatReadOnlyBanner({ isReadOnly, readOnlyReason }: AgentChatReadOnlyBannerProps) {
|
||||
if (!isReadOnly) return null;
|
||||
|
||||
return (
|
||||
@@ -798,18 +841,7 @@ function AgentChatMessages({
|
||||
onRevert,
|
||||
onRetry,
|
||||
onStarterSelect,
|
||||
}: {
|
||||
actionsById: Map<string, AgentAction>;
|
||||
error: Error | undefined;
|
||||
isReadOnly: boolean;
|
||||
isReverting: boolean;
|
||||
isStreaming: boolean;
|
||||
messages: UIMessage[];
|
||||
onAnswer: (toolCallId: string, answer: string) => void;
|
||||
onRevert: (actionId: string) => void;
|
||||
onRetry: () => void;
|
||||
onStarterSelect: (prompt: string) => void;
|
||||
}) {
|
||||
}: AgentChatMessagesProps) {
|
||||
return (
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-4 p-4">
|
||||
@@ -868,17 +900,7 @@ function AgentChatHeader({
|
||||
onDelete,
|
||||
onToggleResume,
|
||||
onToggleThreads,
|
||||
}: {
|
||||
isArchived: boolean;
|
||||
isArchivePending: boolean;
|
||||
isDeletePending: boolean;
|
||||
onArchive: () => void;
|
||||
onCopyConversation: () => void;
|
||||
onCopyConversationJson: () => void;
|
||||
onDelete: () => void;
|
||||
onToggleResume?: () => void;
|
||||
onToggleThreads?: () => void;
|
||||
}) {
|
||||
}: AgentChatHeaderProps) {
|
||||
return (
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
@@ -957,18 +979,7 @@ function AgentChatComposer({
|
||||
onSend,
|
||||
onStopRun,
|
||||
onUploadFiles,
|
||||
}: {
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
input: string;
|
||||
isReadOnly: boolean;
|
||||
isStreaming: boolean;
|
||||
isUploading: boolean;
|
||||
pendingAttachments: Array<Pick<AgentAttachment, "filename" | "id" | "mediaType">>;
|
||||
onInputChange: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onStopRun: () => void;
|
||||
onUploadFiles: (files: FileList | null) => void;
|
||||
}) {
|
||||
}: AgentChatComposerProps) {
|
||||
return (
|
||||
<form
|
||||
className="border-t p-3"
|
||||
@@ -1066,13 +1077,7 @@ function getInitialPreviewZoom() {
|
||||
return Number.isFinite(stored) ? clampPreviewZoom(stored) : DEFAULT_PREVIEW_ZOOM;
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
label,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button> & {
|
||||
label: string;
|
||||
}) {
|
||||
function ToolbarButton({ label, children, ...props }: ToolbarButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
@@ -1089,7 +1094,7 @@ function ToolbarButton({
|
||||
);
|
||||
}
|
||||
|
||||
function ResumePane({ resume }: { resume: AgentThreadDetail["resume"] }) {
|
||||
function ResumePane({ resume }: ResumePaneProps) {
|
||||
const [zoom, setZoom] = useState(getInitialPreviewZoom);
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ import { Combobox } from "@/components/ui/combobox";
|
||||
import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
type NewThreadSetupProps = {
|
||||
resumeId?: string;
|
||||
};
|
||||
|
||||
function providerLabel(provider: { label: string; provider: AIProvider; model: string }) {
|
||||
return `${provider.label} · ${provider.provider} · ${provider.model}`;
|
||||
}
|
||||
@@ -27,7 +31,7 @@ function isAgentConfigError(error: unknown) {
|
||||
return typeof message === "string" && /REDIS_URL|ENCRYPTION_SECRET/.test(message);
|
||||
}
|
||||
|
||||
export function NewThreadSetup({ resumeId }: { resumeId?: string }) {
|
||||
export function NewThreadSetup({ resumeId }: NewThreadSetupProps) {
|
||||
const isClient = useIsClient();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
|
||||
@@ -29,6 +29,18 @@ import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
type AgentThreadSummary = RouterOutput["agent"]["threads"]["list"][number];
|
||||
|
||||
type ThreadActionsProps = {
|
||||
thread: AgentThreadSummary;
|
||||
activeThreadId: string | null;
|
||||
};
|
||||
|
||||
type ThreadRowProps = ThreadActionsProps;
|
||||
|
||||
type AgentThreadSidebarProps = {
|
||||
activeThreadId?: string | null;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const RELATIVE_TIME_DIVISIONS: Array<{ amount: number; unit: Intl.RelativeTimeFormatUnit }> = [
|
||||
{ amount: 31_536_000_000, unit: "year" },
|
||||
{ amount: 2_592_000_000, unit: "month" },
|
||||
@@ -51,7 +63,7 @@ function formatRelativeTime(value: Date | string, formatter: Intl.RelativeTimeFo
|
||||
return formatter.format(Math.round(diffMs / division.amount), division.unit);
|
||||
}
|
||||
|
||||
function ThreadActions({ thread, activeThreadId }: { thread: AgentThreadSummary; activeThreadId: string | null }) {
|
||||
function ThreadActions({ thread, activeThreadId }: ThreadActionsProps) {
|
||||
const navigate = useNavigate();
|
||||
const confirm = useConfirm();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -127,7 +139,7 @@ function ThreadActions({ thread, activeThreadId }: { thread: AgentThreadSummary;
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadRow({ thread, activeThreadId }: { thread: AgentThreadSummary; activeThreadId: string | null }) {
|
||||
function ThreadRow({ thread, activeThreadId }: ThreadRowProps) {
|
||||
const { i18n } = useLingui();
|
||||
const relativeTimeFormatter = useMemo(
|
||||
() => Reflect.construct(Intl.RelativeTimeFormat, [i18n.locale, { numeric: "auto" }]) as Intl.RelativeTimeFormat,
|
||||
@@ -160,13 +172,7 @@ function ThreadRow({ thread, activeThreadId }: { thread: AgentThreadSummary; act
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentThreadSidebar({
|
||||
activeThreadId = null,
|
||||
className,
|
||||
}: {
|
||||
activeThreadId?: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
export function AgentThreadSidebar({ activeThreadId = null, className }: AgentThreadSidebarProps) {
|
||||
const { data: threads, isLoading } = useQuery(orpc.agent.threads.list.queryOptions());
|
||||
|
||||
return (
|
||||
|
||||
@@ -133,7 +133,11 @@ export function CustomSectionBuilder() {
|
||||
);
|
||||
}
|
||||
|
||||
function CustomSectionContainer({ section }: { section: CustomSection }) {
|
||||
type CustomSectionContainerProps = {
|
||||
section: CustomSection;
|
||||
};
|
||||
|
||||
function CustomSectionContainer({ section }: CustomSectionContainerProps) {
|
||||
const { openDialog } = useDialogStore();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
@@ -203,7 +207,11 @@ function CustomSectionContainer({ section }: { section: CustomSection }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CustomSectionDropdownMenu({ section }: { section: CustomSection }) {
|
||||
type CustomSectionDropdownMenuProps = {
|
||||
section: CustomSection;
|
||||
};
|
||||
|
||||
function CustomSectionDropdownMenu({ section }: CustomSectionDropdownMenuProps) {
|
||||
const confirm = useConfirm();
|
||||
const { openDialog } = useDialogStore();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
+16
-3
@@ -61,6 +61,19 @@ const experienceItems = vi.hoisted(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
type SectionBaseProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type SectionAddItemButtonProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type SectionItemProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: {
|
||||
@@ -73,11 +86,11 @@ vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
|
||||
}));
|
||||
vi.mock("../shared/section-item", () => ({
|
||||
SectionAddItemButton: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
||||
SectionItem: ({ title, subtitle }: { title: string; subtitle: string }) => (
|
||||
SectionAddItemButton: ({ children }: SectionAddItemButtonProps) => <button type="button">{children}</button>,
|
||||
SectionItem: ({ title, subtitle }: SectionItemProps) => (
|
||||
<div>
|
||||
<span data-testid="item-title">{title}</span>
|
||||
<span data-testid="item-subtitle">{subtitle}</span>
|
||||
|
||||
+17
-3
@@ -72,6 +72,20 @@ const sections = vi.hoisted(() => ({
|
||||
],
|
||||
}));
|
||||
|
||||
type SectionBaseProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type SectionAddItemButtonProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type SectionItemProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: {
|
||||
@@ -89,19 +103,19 @@ vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
SectionBase: ({ children, className }: SectionBaseProps) => (
|
||||
<div className={className} data-testid="section-base">
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../shared/section-item", () => ({
|
||||
SectionAddItemButton: ({ children }: { children: React.ReactNode }) => (
|
||||
SectionAddItemButton: ({ children }: SectionAddItemButtonProps) => (
|
||||
<button type="button" data-testid="add-button">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
SectionItem: ({ title, subtitle }: { title: string; subtitle: string }) => (
|
||||
SectionItem: ({ title, subtitle }: SectionItemProps) => (
|
||||
<div>
|
||||
<span data-testid="item-title">{title}</span>
|
||||
<span data-testid="item-subtitle">{subtitle}</span>
|
||||
|
||||
@@ -32,6 +32,18 @@ export function PictureSectionBuilder() {
|
||||
);
|
||||
}
|
||||
|
||||
type PicturePreviewControlsProps = {
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
form: PictureSettingsForm;
|
||||
normalizedPictureUrl: string;
|
||||
picture: PictureValues;
|
||||
pictureSrc: string;
|
||||
onAutoSave: () => void;
|
||||
onDeletePicture: () => void;
|
||||
onSelectPicture: () => void;
|
||||
onUploadPicture: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
function PicturePreviewControls({
|
||||
fileInputRef,
|
||||
form,
|
||||
@@ -42,17 +54,7 @@ function PicturePreviewControls({
|
||||
onDeletePicture,
|
||||
onSelectPicture,
|
||||
onUploadPicture,
|
||||
}: {
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
form: PictureSettingsForm;
|
||||
normalizedPictureUrl: string;
|
||||
picture: PictureValues;
|
||||
pictureSrc: string;
|
||||
onAutoSave: () => void;
|
||||
onDeletePicture: () => void;
|
||||
onSelectPicture: () => void;
|
||||
onUploadPicture: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}) {
|
||||
}: PicturePreviewControlsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<input
|
||||
@@ -122,7 +124,12 @@ function PicturePreviewControls({
|
||||
);
|
||||
}
|
||||
|
||||
function PictureGeometryFields({ form, onAutoSave }: { form: PictureSettingsForm; onAutoSave: () => void }) {
|
||||
type PictureGeometryFieldsProps = {
|
||||
form: PictureSettingsForm;
|
||||
onAutoSave: () => void;
|
||||
};
|
||||
|
||||
function PictureGeometryFields({ form, onAutoSave }: PictureGeometryFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<form.Field name="size">
|
||||
|
||||
@@ -26,6 +26,20 @@ const sectionItems = vi.hoisted(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
type SectionBaseProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type SectionAddItemButtonProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type SectionItemProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: {
|
||||
@@ -35,15 +49,15 @@ vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
SectionBase: ({ children, className }: SectionBaseProps) => (
|
||||
<div className={className} data-testid="section-base">
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../shared/section-item", () => ({
|
||||
SectionAddItemButton: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
||||
SectionItem: ({ title, subtitle }: { title: string; subtitle: string }) => (
|
||||
SectionAddItemButton: ({ children }: SectionAddItemButtonProps) => <button type="button">{children}</button>,
|
||||
SectionItem: ({ title, subtitle }: SectionItemProps) => (
|
||||
<div>
|
||||
<span data-testid="item-title">{title}</span>
|
||||
<span data-testid="item-subtitle">{subtitle}</span>
|
||||
|
||||
@@ -35,6 +35,19 @@ const items = vi.hoisted(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
type SectionBaseProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type SectionAddItemButtonProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type SectionItemProps = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: { sections: { projects: { title: "Projects", columns: 1, hidden: false, items } } },
|
||||
@@ -42,11 +55,11 @@ vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
|
||||
}));
|
||||
vi.mock("../shared/section-item", () => ({
|
||||
SectionAddItemButton: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
||||
SectionItem: ({ title, subtitle }: { title: string; subtitle?: string }) => (
|
||||
SectionAddItemButton: ({ children }: SectionAddItemButtonProps) => <button type="button">{children}</button>,
|
||||
SectionItem: ({ title, subtitle }: SectionItemProps) => (
|
||||
<div>
|
||||
<span data-testid="item-title">{title}</span>
|
||||
<span data-testid="item-subtitle">{subtitle ?? "<undefined>"}</span>
|
||||
|
||||
@@ -10,6 +10,20 @@ const sectionItems = vi.hoisted(() => [
|
||||
{ id: "s2", name: "Go", proficiency: "Intermediate", level: 3, keywords: [], description: "", hidden: false },
|
||||
]);
|
||||
|
||||
type SectionBaseProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type SectionAddItemButtonProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type SectionItemProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: { sections: { skills: { title: "Skills", columns: 1, hidden: false, items: sectionItems } } },
|
||||
@@ -17,15 +31,15 @@ vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
SectionBase: ({ children, className }: SectionBaseProps) => (
|
||||
<div className={className} data-testid="section-base">
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../shared/section-item", () => ({
|
||||
SectionAddItemButton: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
||||
SectionItem: ({ title, subtitle }: { title: string; subtitle: string }) => (
|
||||
SectionAddItemButton: ({ children }: SectionAddItemButtonProps) => <button type="button">{children}</button>,
|
||||
SectionItem: ({ title, subtitle }: SectionItemProps) => (
|
||||
<div>
|
||||
<span data-testid="item-title">{title}</span>
|
||||
<span data-testid="item-subtitle">{subtitle}</span>
|
||||
|
||||
+35
-5
@@ -18,8 +18,12 @@ const styleRules = vi.hoisted<StyleRule[]>(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
type SectionBaseProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
@@ -77,6 +81,15 @@ describe("CustomStylesSectionBuilder", () => {
|
||||
expect(screen.getByLabelText("Target Scope")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Style Slot")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Text Color")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Text Color").parentElement).toHaveClass("gap-3");
|
||||
expect(screen.getByLabelText("Text Color").parentElement?.parentElement?.parentElement).toHaveClass(
|
||||
"grid-cols-1",
|
||||
"@min-[20rem]:grid-cols-2",
|
||||
"@min-[35rem]:grid-cols-4",
|
||||
);
|
||||
expect(screen.getByLabelText("Text Color").parentElement?.parentElement?.parentElement).not.toHaveClass(
|
||||
"grid-cols-[repeat(auto-fit,minmax(8rem,1fr))]",
|
||||
);
|
||||
expect(screen.getByLabelText("Text Decoration Color")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Color" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Text" })).toBeInTheDocument();
|
||||
@@ -90,6 +103,7 @@ describe("CustomStylesSectionBuilder", () => {
|
||||
expect(screen.getByLabelText("Text Align")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Text Transform")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Opacity")).toBeInTheDocument();
|
||||
expect(screen.getByText("Padding")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Margin Top")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Margin Right")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Margin Bottom")).toBeInTheDocument();
|
||||
@@ -97,7 +111,14 @@ describe("CustomStylesSectionBuilder", () => {
|
||||
expect(screen.getByLabelText("Row Gap")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Column Gap")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Border Style")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Border Width").parentElement?.parentElement).toHaveClass(
|
||||
"grid-cols-1",
|
||||
"@min-[20rem]:grid-cols-2",
|
||||
"@min-[35rem]:grid-cols-4",
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("Style Slot"));
|
||||
expect(await screen.findByText("Section")).toBeInTheDocument();
|
||||
expect(screen.getByText("Rich text")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("option", { name: "Section heading" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "List" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "List item content" })).toBeInTheDocument();
|
||||
@@ -147,14 +168,16 @@ describe("CustomStylesSectionBuilder", () => {
|
||||
styleRules.splice(0, styleRules.length);
|
||||
renderCustomStyles();
|
||||
|
||||
expect(screen.queryByLabelText("Padding")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Padding")).toBeInTheDocument();
|
||||
expect(screen.getByText("Padding")).toHaveClass("shrink-0");
|
||||
expect(screen.getByText("Padding").parentElement).toHaveClass("flex");
|
||||
expect(screen.queryByText("Padding Top")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Padding Top")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Padding Right")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Padding Bottom")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Padding Left")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Padding Top").closest("div")?.parentElement).toHaveClass(
|
||||
"grid-cols-[repeat(auto-fit,minmax(7rem,1fr))]",
|
||||
);
|
||||
expect(screen.getByLabelText("Padding Top")).toHaveAttribute("placeholder", "top");
|
||||
expect(screen.getByLabelText("Padding Right")).toHaveClass("text-center", "tabular-nums");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Padding Top"), { target: { value: "12" } });
|
||||
|
||||
@@ -200,8 +223,15 @@ describe("CustomStylesSectionBuilder", () => {
|
||||
styleRules.splice(0, styleRules.length);
|
||||
renderCustomStyles();
|
||||
|
||||
expect(screen.getByText("Margin")).toBeInTheDocument();
|
||||
expect(screen.getByText("Margin")).toHaveClass("shrink-0");
|
||||
expect(screen.queryByText("Margin Bottom")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Margin Bottom")).toHaveAttribute("min", "-72");
|
||||
expect(screen.getByLabelText("Margin Bottom")).toHaveAttribute("placeholder", "bottom");
|
||||
expect(screen.getByText("Gap")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Row Gap")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Row Gap")).toHaveAttribute("min", "-72");
|
||||
expect(screen.getByLabelText("Row Gap")).toHaveAttribute("placeholder", "row");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Margin Bottom"), { target: { value: "-10" } });
|
||||
fireEvent.change(screen.getByLabelText("Row Gap"), { target: { value: "-6" } });
|
||||
|
||||
@@ -57,6 +57,7 @@ const styleSlotOptions: StyleSlotOption[] = [
|
||||
const styleSlotComboboxOptions: ComboboxOption<StyleSlot>[] = styleSlotOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
group: option.group,
|
||||
keywords: [option.group],
|
||||
}));
|
||||
|
||||
@@ -94,7 +95,9 @@ const borderStyleOptions = [
|
||||
] as const satisfies readonly { value: NonNullable<StyleIntent["borderStyle"]>; label: string }[];
|
||||
|
||||
const controlGridClassName = "grid grid-cols-[repeat(auto-fit,minmax(8rem,1fr))] gap-3";
|
||||
const compactControlGridClassName = "grid grid-cols-[repeat(auto-fit,minmax(7rem,1fr))] gap-3";
|
||||
const exactFourControlGridClassName = "grid grid-cols-1 gap-3 @min-[20rem]:grid-cols-2 @min-[35rem]:grid-cols-4";
|
||||
const compactSpacingInputClassName =
|
||||
"h-8 w-18 max-w-18 min-w-0 px-1.5 text-center text-xs tabular-nums placeholder:text-[0.68rem] placeholder:uppercase placeholder:tracking-wide";
|
||||
|
||||
export function CustomStylesSectionBuilder() {
|
||||
return (
|
||||
@@ -270,7 +273,13 @@ function CustomStylesSectionForm() {
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
|
||||
type FieldProps = {
|
||||
label: string;
|
||||
id: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function Field({ label, id, children }: FieldProps) {
|
||||
return (
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Label htmlFor={id} className="block min-w-0 text-pretty leading-snug">
|
||||
@@ -281,24 +290,19 @@ function Field({ label, id, children }: { label: string; id: string; children: R
|
||||
);
|
||||
}
|
||||
|
||||
function ColorField({
|
||||
label,
|
||||
id,
|
||||
value,
|
||||
placeholder,
|
||||
fallback,
|
||||
onChange,
|
||||
}: {
|
||||
type ColorFieldProps = {
|
||||
label: string;
|
||||
id: string;
|
||||
value: string | undefined;
|
||||
placeholder?: string;
|
||||
fallback: string;
|
||||
onChange: (value: string | undefined) => void;
|
||||
}) {
|
||||
};
|
||||
|
||||
function ColorField({ label, id, value, placeholder, fallback, onChange }: ColorFieldProps) {
|
||||
return (
|
||||
<Field label={label} id={id}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<ColorPicker value={value ?? fallback} defaultValue={fallback} onChange={(color) => onChange(color)} />
|
||||
<Input
|
||||
id={id}
|
||||
@@ -312,15 +316,7 @@ function ColorField({
|
||||
);
|
||||
}
|
||||
|
||||
function NumberInput({
|
||||
label,
|
||||
id,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
onChange,
|
||||
}: {
|
||||
type NumberInputProps = {
|
||||
label: string;
|
||||
id?: string;
|
||||
value: number | undefined;
|
||||
@@ -328,7 +324,9 @@ function NumberInput({
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (value: number | undefined) => void;
|
||||
}) {
|
||||
};
|
||||
|
||||
function NumberInput({ label, id, value, min, max, step = 1, onChange }: NumberInputProps) {
|
||||
const inputId = id ?? `style-${label.toLowerCase().replaceAll(" ", "-")}`;
|
||||
|
||||
return (
|
||||
@@ -350,19 +348,15 @@ function NumberInput({
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedRulesList({
|
||||
data,
|
||||
rules,
|
||||
onToggleRule,
|
||||
onEditRule,
|
||||
onDeleteRule,
|
||||
}: {
|
||||
type AppliedRulesListProps = {
|
||||
data: ResumeData;
|
||||
rules: StyleRule[];
|
||||
onToggleRule: (ruleId: string, enabled: boolean) => void;
|
||||
onEditRule: (rule: StyleRule) => void;
|
||||
onDeleteRule: (ruleId: string) => void;
|
||||
}) {
|
||||
};
|
||||
|
||||
function AppliedRulesList({ data, rules, onToggleRule, onEditRule, onDeleteRule }: AppliedRulesListProps) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
@@ -396,19 +390,15 @@ function AppliedRulesList({
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedRuleCard({
|
||||
data,
|
||||
rule,
|
||||
onToggleRule,
|
||||
onEditRule,
|
||||
onDeleteRule,
|
||||
}: {
|
||||
type AppliedRuleCardProps = {
|
||||
data: ResumeData;
|
||||
rule: StyleRule;
|
||||
onToggleRule: (ruleId: string, enabled: boolean) => void;
|
||||
onEditRule: (rule: StyleRule) => void;
|
||||
onDeleteRule: (ruleId: string) => void;
|
||||
}) {
|
||||
};
|
||||
|
||||
function AppliedRuleCard({ data, rule, onToggleRule, onEditRule, onDeleteRule }: AppliedRuleCardProps) {
|
||||
const slots = getConfiguredSlots(rule);
|
||||
const primaryIntent = slots[0] ? rule.slots[slots[0]] : undefined;
|
||||
const fallbackLabel = getRuleFallbackLabel(data, rule);
|
||||
@@ -462,7 +452,12 @@ function AppliedRuleCard({
|
||||
);
|
||||
}
|
||||
|
||||
function RuleScopePill({ target, slot }: { target: string; slot: string }) {
|
||||
type RuleScopePillProps = {
|
||||
target: string;
|
||||
slot: string;
|
||||
};
|
||||
|
||||
function RuleScopePill({ target, slot }: RuleScopePillProps) {
|
||||
return (
|
||||
<div className="inline-flex max-w-full overflow-hidden rounded-sm border border-border/80 bg-background text-xs shadow-xs">
|
||||
<span className="min-w-0 max-w-32 truncate bg-secondary px-2.5 py-1 font-medium text-secondary-foreground">
|
||||
@@ -473,23 +468,20 @@ function RuleScopePill({ target, slot }: { target: string; slot: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RuleIntentEditor({
|
||||
idPrefix,
|
||||
intent,
|
||||
labelPrefix,
|
||||
onChange,
|
||||
}: {
|
||||
type RuleIntentEditorProps = {
|
||||
idPrefix: string;
|
||||
intent: StyleIntent;
|
||||
labelPrefix?: string;
|
||||
onChange: (patch: Partial<StyleIntent>) => void;
|
||||
}) {
|
||||
};
|
||||
|
||||
function RuleIntentEditor({ idPrefix, intent, labelPrefix, onChange }: RuleIntentEditorProps) {
|
||||
const labelStart = labelPrefix ? `${labelPrefix} ` : "";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<ControlPanel title="Color">
|
||||
<div className={controlGridClassName}>
|
||||
<div className={exactFourControlGridClassName}>
|
||||
<ColorField
|
||||
label={`${labelStart}Text Color`}
|
||||
id={`${idPrefix}-color`}
|
||||
@@ -599,38 +591,34 @@ function RuleIntentEditor({
|
||||
</ControlPanel>
|
||||
|
||||
<ControlPanel title="Spacing">
|
||||
<div className="space-y-4">
|
||||
<ControlSubsection title="Padding">
|
||||
<PaddingSideInputs idPrefix={idPrefix} intent={intent} labelPrefix={labelPrefix} onChange={onChange} />
|
||||
</ControlSubsection>
|
||||
<ControlSubsection title="Margin">
|
||||
<MarginSideInputs idPrefix={idPrefix} intent={intent} labelPrefix={labelPrefix} onChange={onChange} />
|
||||
</ControlSubsection>
|
||||
<ControlSubsection title="Gap">
|
||||
<div className={controlGridClassName}>
|
||||
<NumberInput
|
||||
label={`${labelStart}Row Gap`}
|
||||
id={`${idPrefix}-row-gap`}
|
||||
value={intent.rowGap}
|
||||
min={-72}
|
||||
max={72}
|
||||
onChange={(rowGap) => onChange({ rowGap })}
|
||||
/>
|
||||
<NumberInput
|
||||
label={`${labelStart}Column Gap`}
|
||||
id={`${idPrefix}-column-gap`}
|
||||
value={intent.columnGap}
|
||||
min={-72}
|
||||
max={72}
|
||||
onChange={(columnGap) => onChange({ columnGap })}
|
||||
/>
|
||||
</div>
|
||||
</ControlSubsection>
|
||||
<div className="space-y-3">
|
||||
<PaddingSideInputs idPrefix={idPrefix} intent={intent} labelPrefix={labelPrefix} onChange={onChange} />
|
||||
<MarginSideInputs idPrefix={idPrefix} intent={intent} labelPrefix={labelPrefix} onChange={onChange} />
|
||||
<SpacingInputGroup label="Gap">
|
||||
<CompactNumberInput
|
||||
ariaLabel={`${labelStart}Row Gap`}
|
||||
id={`${idPrefix}-row-gap`}
|
||||
placeholder="row"
|
||||
value={intent.rowGap}
|
||||
min={-72}
|
||||
max={72}
|
||||
onChange={(rowGap) => onChange({ rowGap })}
|
||||
/>
|
||||
<CompactNumberInput
|
||||
ariaLabel={`${labelStart}Column Gap`}
|
||||
id={`${idPrefix}-column-gap`}
|
||||
placeholder="column"
|
||||
value={intent.columnGap}
|
||||
min={-72}
|
||||
max={72}
|
||||
onChange={(columnGap) => onChange({ columnGap })}
|
||||
/>
|
||||
</SpacingInputGroup>
|
||||
</div>
|
||||
</ControlPanel>
|
||||
|
||||
<ControlPanel title="Border">
|
||||
<div className={controlGridClassName}>
|
||||
<div className={exactFourControlGridClassName}>
|
||||
<IntentSelectField
|
||||
label={`${labelStart}Border Style`}
|
||||
id={`${idPrefix}-border-style`}
|
||||
@@ -668,23 +656,27 @@ function RuleIntentEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function ControlPanel({ title, children }: { title: string; children: ReactNode }) {
|
||||
type ControlPanelProps = {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function ControlPanel({ title, children }: ControlPanelProps) {
|
||||
return (
|
||||
<section className="space-y-3 rounded-lg border bg-muted/10 p-3">
|
||||
<section className="@container space-y-3 rounded-lg border bg-muted/10 p-3">
|
||||
<h3 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlSubsection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-muted-foreground text-xs">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
type IntentSelectFieldProps<TValue extends string> = {
|
||||
label: string;
|
||||
id: string;
|
||||
value: TValue | undefined;
|
||||
options: readonly ComboboxOption<TValue>[];
|
||||
onChange: (value: TValue | undefined) => void;
|
||||
};
|
||||
|
||||
function IntentSelectField<TValue extends string>({
|
||||
label,
|
||||
@@ -692,13 +684,7 @@ function IntentSelectField<TValue extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
id: string;
|
||||
value: TValue | undefined;
|
||||
options: readonly ComboboxOption<TValue>[];
|
||||
onChange: (value: TValue | undefined) => void;
|
||||
}) {
|
||||
}: IntentSelectFieldProps<TValue>) {
|
||||
return (
|
||||
<Field label={label} id={id}>
|
||||
<Combobox
|
||||
@@ -715,17 +701,14 @@ function IntentSelectField<TValue extends string>({
|
||||
);
|
||||
}
|
||||
|
||||
function FontWeightField({
|
||||
label,
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
type FontWeightFieldProps = {
|
||||
label: string;
|
||||
id: string;
|
||||
value: StyleIntent["fontWeight"] | undefined;
|
||||
onChange: (value: StyleIntent["fontWeight"] | undefined) => void;
|
||||
}) {
|
||||
};
|
||||
|
||||
function FontWeightField({ label, id, value, onChange }: FontWeightFieldProps) {
|
||||
const options: ComboboxOption<NonNullable<StyleIntent["fontWeight"]>>[] = fontWeightOptions.map((weight) => ({
|
||||
value: weight,
|
||||
label: weight,
|
||||
@@ -765,66 +748,116 @@ const marginSideOptions = [
|
||||
|
||||
type MarginSideProperty = (typeof marginSideOptions)[number]["property"];
|
||||
|
||||
function PaddingSideInputs({
|
||||
idPrefix,
|
||||
intent,
|
||||
labelPrefix,
|
||||
onChange,
|
||||
}: {
|
||||
type PaddingSideInputsProps = {
|
||||
idPrefix: string;
|
||||
intent: StyleIntent;
|
||||
labelPrefix?: string;
|
||||
onChange: (patch: Partial<StyleIntent>) => void;
|
||||
}) {
|
||||
};
|
||||
|
||||
function PaddingSideInputs({ idPrefix, intent, labelPrefix, onChange }: PaddingSideInputsProps) {
|
||||
const labelStart = labelPrefix ? `${labelPrefix} ` : "";
|
||||
|
||||
return (
|
||||
<div className={compactControlGridClassName}>
|
||||
<SpacingInputGroup label="Padding">
|
||||
{paddingSideOptions.map((side) => (
|
||||
<NumberInput
|
||||
<CompactNumberInput
|
||||
key={side.property}
|
||||
label={`${labelStart}Padding ${side.label}`}
|
||||
ariaLabel={`${labelStart}Padding ${side.label}`}
|
||||
id={`${idPrefix}-${side.property}`}
|
||||
placeholder={side.label.toLowerCase()}
|
||||
value={getPaddingSideValue(intent, side.property)}
|
||||
min={-72}
|
||||
max={72}
|
||||
onChange={(value) => onChange(createPaddingSidePatch(intent, side.property, value))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SpacingInputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function MarginSideInputs({
|
||||
idPrefix,
|
||||
intent,
|
||||
labelPrefix,
|
||||
onChange,
|
||||
}: {
|
||||
type MarginSideInputsProps = {
|
||||
idPrefix: string;
|
||||
intent: StyleIntent;
|
||||
labelPrefix?: string;
|
||||
onChange: (patch: Partial<StyleIntent>) => void;
|
||||
}) {
|
||||
};
|
||||
|
||||
function MarginSideInputs({ idPrefix, intent, labelPrefix, onChange }: MarginSideInputsProps) {
|
||||
const labelStart = labelPrefix ? `${labelPrefix} ` : "";
|
||||
|
||||
return (
|
||||
<div className={compactControlGridClassName}>
|
||||
<SpacingInputGroup label="Margin">
|
||||
{marginSideOptions.map((side) => (
|
||||
<NumberInput
|
||||
<CompactNumberInput
|
||||
key={side.property}
|
||||
label={`${labelStart}Margin ${side.label}`}
|
||||
ariaLabel={`${labelStart}Margin ${side.label}`}
|
||||
id={`${idPrefix}-${side.property}`}
|
||||
placeholder={side.label.toLowerCase()}
|
||||
value={intent[side.property]}
|
||||
min={-72}
|
||||
max={72}
|
||||
onChange={(value) => onChange(createMarginSidePatch(side.property, value))}
|
||||
/>
|
||||
))}
|
||||
</SpacingInputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
type SpacingInputGroupProps = {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function SpacingInputGroup({ label, children }: SpacingInputGroupProps) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<div className="w-20 shrink-0 font-medium text-muted-foreground text-xs">{label}</div>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap gap-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CompactNumberInputProps = {
|
||||
ariaLabel: string;
|
||||
id: string;
|
||||
placeholder: string;
|
||||
value: number | undefined;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (value: number | undefined) => void;
|
||||
};
|
||||
|
||||
function CompactNumberInput({
|
||||
ariaLabel,
|
||||
id,
|
||||
placeholder,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
onChange,
|
||||
}: CompactNumberInputProps) {
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
className={compactSpacingInputClassName}
|
||||
value={value ?? ""}
|
||||
placeholder={placeholder}
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
onChange(value === "" ? undefined : Number(value));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getPaddingSideValue(intent: StyleIntent, property: PaddingSideProperty) {
|
||||
return intent[property] ?? intent.padding;
|
||||
}
|
||||
@@ -851,7 +884,11 @@ function createMarginSidePatch(property: MarginSideProperty, value: number | und
|
||||
return { [property]: value };
|
||||
}
|
||||
|
||||
function RulePropertySummary({ intent }: { intent: StyleIntent }) {
|
||||
type RulePropertySummaryProps = {
|
||||
intent: StyleIntent;
|
||||
};
|
||||
|
||||
function RulePropertySummary({ intent }: RulePropertySummaryProps) {
|
||||
const properties = [
|
||||
intent.color && { label: "Text", value: intent.color, color: intent.color },
|
||||
intent.backgroundColor && { label: "Background", value: intent.backgroundColor, color: intent.backgroundColor },
|
||||
@@ -935,15 +972,13 @@ function getGapSummary(intent: StyleIntent) {
|
||||
return values.length > 0 ? values.join(" / ") : undefined;
|
||||
}
|
||||
|
||||
function createTarget({
|
||||
targetScope,
|
||||
sectionType,
|
||||
sectionId,
|
||||
}: {
|
||||
type CreateTargetParams = {
|
||||
targetScope: TargetScope;
|
||||
sectionType: string;
|
||||
sectionId: string;
|
||||
}): StyleRuleTarget {
|
||||
};
|
||||
|
||||
function createTarget({ targetScope, sectionType, sectionId }: CreateTargetParams): StyleRuleTarget {
|
||||
if (targetScope === "sectionType") {
|
||||
return {
|
||||
scope: "sectionType",
|
||||
|
||||
@@ -10,8 +10,12 @@ const downloadWithAnchor = vi.hoisted(() => vi.fn());
|
||||
const buildDocx = vi.hoisted(() => vi.fn().mockResolvedValue(new Blob(["x"], { type: "application/x-docx" })));
|
||||
const createResumePdfBlob = vi.hoisted(() => vi.fn().mockResolvedValue(new Blob(["x"], { type: "application/pdf" })));
|
||||
|
||||
type SectionBaseProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
|
||||
}));
|
||||
vi.mock("@reactive-resume/utils/file", () => ({
|
||||
downloadWithAnchor,
|
||||
|
||||
@@ -5,8 +5,12 @@ import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
|
||||
type SectionBaseProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
const { InformationSectionBuilder } = await import("./information");
|
||||
|
||||
@@ -11,11 +11,20 @@ const richInputProps = vi.hoisted(() => ({
|
||||
onChange: undefined as ((value: string) => void) | undefined,
|
||||
}));
|
||||
|
||||
type SectionBaseProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type RichInputProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: { children: React.ReactNode }) => <div data-testid="section-base">{children}</div>,
|
||||
SectionBase: ({ children }: SectionBaseProps) => <div data-testid="section-base">{children}</div>,
|
||||
}));
|
||||
vi.mock("@/components/input/rich-input", () => ({
|
||||
RichInput: (props: { value: string; onChange: (value: string) => void }) => {
|
||||
RichInput: (props: RichInputProps) => {
|
||||
richInputProps.value = props.value;
|
||||
richInputProps.onChange = props.onChange;
|
||||
return <textarea data-testid="rich-input" value={props.value} readOnly />;
|
||||
|
||||
@@ -17,6 +17,10 @@ const queryResult = vi.hoisted(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
type SectionBaseProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => queryResult,
|
||||
}));
|
||||
@@ -27,7 +31,7 @@ vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: { resume: { statistics: { getById: { queryOptions: () => ({}) } } } },
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
const { StatisticsSectionBuilder } = await import("./statistics");
|
||||
|
||||
@@ -6,8 +6,12 @@ import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
|
||||
type SectionBaseProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
|
||||
}));
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
|
||||
@@ -322,7 +322,11 @@ function TypographySectionForm() {
|
||||
);
|
||||
}
|
||||
|
||||
function TypographyFieldGroup({ label }: { label: ReactNode }) {
|
||||
type TypographyFieldGroupProps = {
|
||||
label: ReactNode;
|
||||
};
|
||||
|
||||
function TypographyFieldGroup({ label }: TypographyFieldGroupProps) {
|
||||
return (
|
||||
<div className="col-span-full flex items-center gap-x-2">
|
||||
<Separator className="basis-[16px]" />
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_BUILDER_LAYOUT,
|
||||
DESKTOP_BUILDER_SIDEBAR_COLLAPSED_SIZE,
|
||||
DESKTOP_BUILDER_SIDEBAR_MIN_SIZE,
|
||||
getBuilderSidebarResizeConfig,
|
||||
mapPanelLayoutToBuilderLayout,
|
||||
parseBuilderLayoutCookie,
|
||||
useBuilderSidebarStore,
|
||||
@@ -64,6 +67,25 @@ describe("mapPanelLayoutToBuilderLayout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBuilderSidebarResizeConfig", () => {
|
||||
it("uses a desktop minimum width that is larger than the collapsed rail", () => {
|
||||
const config = getBuilderSidebarResizeConfig({ isMobile: false, width: 1280 });
|
||||
|
||||
expect(config.minSidebarSize).toBe(DESKTOP_BUILDER_SIDEBAR_MIN_SIZE);
|
||||
expect(config.collapsedSidebarSize).toBe(DESKTOP_BUILDER_SIDEBAR_COLLAPSED_SIZE);
|
||||
expect(config.minSidebarSize).toBeGreaterThan(config.collapsedSidebarSize);
|
||||
expect(config.groupResizeBehavior).toBe("preserve-pixel-size");
|
||||
});
|
||||
|
||||
it("allows mobile sidebars to collapse fully without a desktop minimum", () => {
|
||||
const config = getBuilderSidebarResizeConfig({ isMobile: true, width: 390 });
|
||||
|
||||
expect(config.minSidebarSize).toBe(0);
|
||||
expect(config.collapsedSidebarSize).toBe(0);
|
||||
expect(config.maxSidebarSize).toBe("95%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useBuilderSidebarStore", () => {
|
||||
it("starts with default layout and null panel refs", () => {
|
||||
const state = useBuilderSidebarStore.getState();
|
||||
|
||||
@@ -20,6 +20,22 @@ export const DEFAULT_BUILDER_LAYOUT: BuilderLayout = {
|
||||
right: 22,
|
||||
};
|
||||
|
||||
export const DESKTOP_BUILDER_SIDEBAR_COLLAPSED_SIZE = 48;
|
||||
export const DESKTOP_BUILDER_SIDEBAR_MIN_SIZE = 320;
|
||||
|
||||
type BuilderSidebarResizeConfigInput = {
|
||||
isMobile: boolean;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export const getBuilderSidebarResizeConfig = ({ isMobile, width }: BuilderSidebarResizeConfigInput) => ({
|
||||
maxSidebarSize: !width ? 0 : isMobile ? "95%" : "45%",
|
||||
minSidebarSize: !width ? 0 : isMobile ? 0 : DESKTOP_BUILDER_SIDEBAR_MIN_SIZE,
|
||||
collapsedSidebarSize: !width ? 0 : isMobile ? 0 : DESKTOP_BUILDER_SIDEBAR_COLLAPSED_SIZE,
|
||||
expandSize: isMobile ? "95%" : "30%",
|
||||
groupResizeBehavior: "preserve-pixel-size" as const,
|
||||
});
|
||||
|
||||
export const mapPanelLayoutToBuilderLayout = (layout: Layout): BuilderLayout => {
|
||||
const left = layout.left;
|
||||
const artboard = layout.artboard;
|
||||
@@ -78,7 +94,9 @@ export const useBuilderSidebarStore = create<BuilderSidebar>((set) => ({
|
||||
|
||||
type UseBuilderSidebarReturn = {
|
||||
maxSidebarSize: string | number;
|
||||
minSidebarSize: number;
|
||||
collapsedSidebarSize: number;
|
||||
groupResizeBehavior: "preserve-pixel-size";
|
||||
isCollapsed: (side: "left" | "right") => boolean;
|
||||
toggleSidebar: (side: "left" | "right", forceState?: boolean) => void;
|
||||
};
|
||||
@@ -87,9 +105,8 @@ export function useBuilderSidebar<T = UseBuilderSidebarReturn>(selector?: (build
|
||||
const isMobile = useIsMobile();
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const maxSidebarSize: string | number = !width ? 0 : isMobile ? "95%" : "45%";
|
||||
const collapsedSidebarSize = !width ? 0 : isMobile ? 0 : 48;
|
||||
const expandSize = isMobile ? "95%" : "30%";
|
||||
const { maxSidebarSize, minSidebarSize, collapsedSidebarSize, expandSize, groupResizeBehavior } =
|
||||
getBuilderSidebarResizeConfig({ isMobile, width });
|
||||
|
||||
const isCollapsed = useCallback((side: "left" | "right") => {
|
||||
const sidebar =
|
||||
@@ -121,11 +138,13 @@ export function useBuilderSidebar<T = UseBuilderSidebarReturn>(selector?: (build
|
||||
const state = useMemo(() => {
|
||||
return {
|
||||
maxSidebarSize,
|
||||
minSidebarSize,
|
||||
collapsedSidebarSize,
|
||||
groupResizeBehavior,
|
||||
isCollapsed,
|
||||
toggleSidebar,
|
||||
};
|
||||
}, [maxSidebarSize, collapsedSidebarSize, isCollapsed, toggleSidebar]);
|
||||
}, [maxSidebarSize, minSidebarSize, collapsedSidebarSize, groupResizeBehavior, isCollapsed, toggleSidebar]);
|
||||
|
||||
return selector ? selector(state) : (state as T);
|
||||
}
|
||||
|
||||
@@ -104,9 +104,11 @@ function BuilderLayoutShell({ initialLayout }: BuilderLayoutShellProps) {
|
||||
const setRightSidebar = useBuilderSidebarStore((state) => state.setRightSidebar);
|
||||
const setLayout = useBuilderSidebarStore((state) => state.setLayout);
|
||||
|
||||
const { maxSidebarSize, collapsedSidebarSize } = useBuilderSidebar((state) => ({
|
||||
const { maxSidebarSize, minSidebarSize, collapsedSidebarSize, groupResizeBehavior } = useBuilderSidebar((state) => ({
|
||||
maxSidebarSize: state.maxSidebarSize,
|
||||
minSidebarSize: state.minSidebarSize,
|
||||
collapsedSidebarSize: state.collapsedSidebarSize,
|
||||
groupResizeBehavior: state.groupResizeBehavior,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -128,7 +130,7 @@ function BuilderLayoutShell({ initialLayout }: BuilderLayoutShellProps) {
|
||||
setRightSidebar(rightSidebarRef);
|
||||
}, [leftSidebarRef, rightSidebarRef, setLeftSidebar, setRightSidebar]);
|
||||
|
||||
const sidebarMinSize = isMobile ? "0%" : `${collapsedSidebarSize * 2}px`;
|
||||
const sidebarMinSize = isMobile ? "0%" : `${minSidebarSize}px`;
|
||||
const sidebarCollapsedSize = isMobile ? "0%" : `${collapsedSidebarSize}px`;
|
||||
const leftSidebarSize = isMobile ? "0%" : `${initialLayout.left}%`;
|
||||
const rightSidebarSize = isMobile ? "0%" : `${initialLayout.right}%`;
|
||||
@@ -143,6 +145,7 @@ function BuilderLayoutShell({ initialLayout }: BuilderLayoutShellProps) {
|
||||
collapsible
|
||||
id="left"
|
||||
panelRef={leftSidebarRef}
|
||||
groupResizeBehavior={groupResizeBehavior}
|
||||
maxSize={maxSidebarSize}
|
||||
minSize={sidebarMinSize}
|
||||
collapsedSize={sidebarCollapsedSize}
|
||||
@@ -160,6 +163,7 @@ function BuilderLayoutShell({ initialLayout }: BuilderLayoutShellProps) {
|
||||
collapsible
|
||||
id="right"
|
||||
panelRef={rightSidebarRef}
|
||||
groupResizeBehavior={groupResizeBehavior}
|
||||
maxSize={maxSidebarSize}
|
||||
minSize={sidebarMinSize}
|
||||
collapsedSize={sidebarCollapsedSize}
|
||||
|
||||
@@ -13,6 +13,10 @@ type ResumeCardProps = {
|
||||
resume: RouterOutput["resume"]["list"][number];
|
||||
};
|
||||
|
||||
type ResumeLockOverlayProps = {
|
||||
isLocked: boolean;
|
||||
};
|
||||
|
||||
export function ResumeCard({ resume }: ResumeCardProps) {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
@@ -40,7 +44,7 @@ export function ResumeCard({ resume }: ResumeCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function ResumeLockOverlay({ isLocked }: { isLocked: boolean }) {
|
||||
function ResumeLockOverlay({ isLocked }: ResumeLockOverlayProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isLocked && (
|
||||
|
||||
@@ -15,6 +15,11 @@ type ResumeListItem = RouterOutput["resume"]["list"][number];
|
||||
|
||||
type ThumbnailState = { status: "error" | "idle" | "loading" } | { status: "ready"; url: string };
|
||||
|
||||
type ResumeThumbnailProps = {
|
||||
isLocked: boolean;
|
||||
resume: ResumeListItem;
|
||||
};
|
||||
|
||||
const throwIfAborted = (signal: AbortSignal) => {
|
||||
if (signal.aborted) throw new DOMException("Thumbnail generation aborted.", "AbortError");
|
||||
};
|
||||
@@ -63,7 +68,7 @@ function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | und
|
||||
return { status: "loading" };
|
||||
}
|
||||
|
||||
export function ResumeThumbnail({ isLocked, resume }: { isLocked: boolean; resume: ResumeListItem }) {
|
||||
export function ResumeThumbnail({ isLocked, resume }: ResumeThumbnailProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(containerRef, { amount: 0.1, margin: "240px", once: true });
|
||||
const resumeQuery = useQuery({
|
||||
|
||||
@@ -11,11 +11,15 @@ import { ResumeDropdownMenu } from "./menus/dropdown-menu";
|
||||
|
||||
type Resume = RouterOutput["resume"]["list"][number];
|
||||
|
||||
type Props = {
|
||||
type ListViewProps = {
|
||||
resumes: Resume[];
|
||||
};
|
||||
|
||||
export function ListView({ resumes }: Props) {
|
||||
type ResumeListItemProps = {
|
||||
resume: Resume;
|
||||
};
|
||||
|
||||
export function ListView({ resumes }: ListViewProps) {
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
const handleCreateResume = () => {
|
||||
@@ -96,7 +100,7 @@ export function ListView({ resumes }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function ResumeListItem({ resume }: { resume: Resume }) {
|
||||
function ResumeListItem({ resume }: ResumeListItemProps) {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const updatedAt = useMemo(() => {
|
||||
|
||||
Reference in New Issue
Block a user