chore: add missing translations

This commit is contained in:
Amruth Pillai
2026-05-27 23:31:58 +02:00
parent c6a654191c
commit b491582637
125 changed files with 2807 additions and 606 deletions
+10 -2
View File
@@ -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++) {
+6 -1
View File
@@ -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();
+57 -2
View File
@@ -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());
+5 -3
View File
@@ -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", () => {
+5 -1
View File
@@ -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 -3
View File
@@ -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"));
+5 -1
View File
@@ -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" />,
+107 -102
View File
@@ -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();
@@ -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>
@@ -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>
@@ -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(() => {