Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions apps/web/src/components/settings/PromptsSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import type { SavedPrompt } from "@t3tools/contracts/settings";
import type { ReactElement } from "react";
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { visitElements } from "../../test/reactElementTree";
import { reactHookHarness as hooks } from "../../test/reactHookHarness";

vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
const { reactHookHarness } = await import("../../test/reactHookHarness");
return {
...actual,
useCallback: reactHookHarness.useCallback,
useId: () => "saved-prompt-content",
useMemo: reactHookHarness.useMemo,
useRef: reactHookHarness.useRef,
useState: reactHookHarness.useState,
};
});

vi.mock("react/compiler-runtime", async () => {
const { reactHookHarness } = await import("../../test/reactHookHarness");
return { c: reactHookHarness.useMemoCache };
});

vi.mock("~/hooks/useSavedPrompts", () => ({
useSavedPrompts: vi.fn(),
}));

import { useSavedPrompts, type SavedPrompts } from "~/hooks/useSavedPrompts";
import { Collapsible, CollapsibleContent } from "../ui/collapsible";
import { PromptsSettings, SavedPromptRow } from "./PromptsSettings";

type Elem = ReactElement<Record<string, unknown>>;

const multilinePrompt: SavedPrompt = {
id: "prompt-1",
title: "Review checklist",
content: "First line of the prompt\nsecond line with more detail\n\nfourth line",
};

function mockSavedPrompts(overrides: Partial<SavedPrompts> = {}): void {
vi.mocked(useSavedPrompts).mockReturnValue({
prompts: [multilinePrompt],
hasConnectedEnvironment: true,
canEdit: true,
saveAll: vi.fn(),
...overrides,
});
}

function renderRow(props: Parameters<typeof SavedPromptRow>[0]): Elem {
hooks.beginRender();
return SavedPromptRow(props) as Elem;
}

function findChevron(row: Elem): Elem | null {
return visitElements(row, (element) => typeof element.props["aria-expanded"] === "boolean");
}

function findPreview(row: Elem): Elem | null {
return visitElements(row, (element) => element.props.children === "First line of the prompt");
}

function findEdit(row: Elem): Elem | null {
return visitElements(
row,
(element) => element.props["aria-label"] === `Edit ${multilinePrompt.title}`,
);
}

describe("SavedPromptRow", () => {
beforeEach(() => {
hooks.reset();
});

it("starts collapsed and expands in place to the exact multiline content", () => {
const onEdit = vi.fn();
let row = renderRow({ prompt: multilinePrompt, canEdit: true, onEdit });

const collapsedChevron = findChevron(row);
expect(collapsedChevron?.props["aria-controls"]).toBe("saved-prompt-content");
expect(collapsedChevron?.props["aria-expanded"]).toBe(false);
expect(collapsedChevron?.props["aria-label"]).toBe("Expand Review checklist");
expect(visitElements(row, (element) => element.type === Collapsible)?.props.open).toBe(false);

const collapsedPanel = visitElements(row, (element) => element.type === CollapsibleContent);
expect(collapsedPanel?.props.id).toBe("saved-prompt-content");
expect(collapsedPanel?.props.keepMounted).toBe(true);
expect(findPreview(row)).not.toBeNull();

(collapsedChevron?.props.onClick as (() => void) | undefined)?.();
row = renderRow({ prompt: multilinePrompt, canEdit: true, onEdit });

const openChevron = findChevron(row);
expect(openChevron?.props["aria-expanded"]).toBe(true);
expect(openChevron?.props["aria-label"]).toBe("Collapse Review checklist");
expect(visitElements(row, (element) => element.type === Collapsible)?.props.open).toBe(true);
// The full content includes the first line, so the inline preview hides
// while expanded to avoid reading it twice.
expect(findPreview(row)).toBeNull();

const panel = visitElements(row, (element) => element.type === CollapsibleContent);
const content = visitElements(
panel,
(element) => element.props.children === multilinePrompt.content,
);
expect(content?.type).toBe("p");
expect(content?.props.className).toContain("whitespace-pre-wrap");

// Expanding is view-only: the editor was never requested.
expect(onEdit).not.toHaveBeenCalled();

(openChevron?.props.onClick as (() => void) | undefined)?.();
row = renderRow({ prompt: multilinePrompt, canEdit: true, onEdit });
expect(findChevron(row)?.props["aria-expanded"]).toBe(false);
});

it("renders the chevron immediately before Edit, with Edit last", () => {
const row = renderRow({ prompt: multilinePrompt, canEdit: true, onEdit: vi.fn() });
const chevron = findChevron(row);
const edit = findEdit(row);
const rail = visitElements(row, (element) => {
const children = element.props.children;
return Array.isArray(children) && children.includes(edit);
});

const actions = rail?.props.children as ReadonlyArray<Elem>;
expect(actions.at(-1)).toBe(edit);
expect(actions.indexOf(chevron as Elem)).toBe(actions.length - 2);
});

it("keeps expansion enabled while Edit is disabled in read-only mode", () => {
const onEdit = vi.fn();
let row = renderRow({ prompt: multilinePrompt, canEdit: false, onEdit });

expect(findEdit(row)?.props.disabled).toBe(true);
const chevron = findChevron(row);
expect(chevron?.props.disabled).toBeUndefined();

(chevron?.props.onClick as (() => void) | undefined)?.();
row = renderRow({ prompt: multilinePrompt, canEdit: false, onEdit });
expect(findChevron(row)?.props["aria-expanded"]).toBe(true);
expect(onEdit).not.toHaveBeenCalled();
});
});

describe("PromptsSettings", () => {
beforeEach(() => {
hooks.reset();
vi.mocked(useSavedPrompts).mockReset();
});

it("renders one row per prompt and Edit still opens the editor dialog", () => {
const second: SavedPrompt = { id: "prompt-2", title: "Second", content: "Body" };
mockSavedPrompts({ prompts: [multilinePrompt, second] });

hooks.beginRender();
let tree = PromptsSettings() as Elem;
const rows: Elem[] = [];
visitElements(tree, (element) => {
if (element.type === SavedPromptRow) rows.push(element);
return false;
});
expect(rows.map((row) => (row.props.prompt as SavedPrompt).id)).toEqual([
"prompt-1",
"prompt-2",
]);
expect(rows.map((row) => row.props.canEdit)).toEqual([true, true]);

(rows[0]?.props.onEdit as (() => void) | undefined)?.();
hooks.beginRender();
tree = PromptsSettings() as Elem;
const dialog = visitElements(
tree,
(element) => "request" in element.props && element.props.request !== null,
);
expect(dialog?.props.request).toEqual({
promptId: "prompt-1",
initial: { title: multilinePrompt.title, content: multilinePrompt.content },
});
});
});
106 changes: 76 additions & 30 deletions apps/web/src/components/settings/PromptsSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { SavedPrompt } from "@t3tools/contracts/settings";
import { PencilIcon, PlusIcon } from "lucide-react";
import React, { type FormEvent, useEffect, useState } from "react";
import { ChevronDownIcon, PencilIcon, PlusIcon } from "lucide-react";
import React, { type FormEvent, useEffect, useId, useState } from "react";

import { useSavedPrompts } from "~/hooks/useSavedPrompts";
import { randomUUID } from "~/lib/utils";
import { cn, randomUUID } from "~/lib/utils";
import { searchableSetting } from "./settingsSearch";
import {
AlertDialog,
Expand All @@ -15,6 +15,7 @@ import {
AlertDialogTitle,
} from "../ui/alert-dialog";
import { Button } from "../ui/button";
import { Collapsible, CollapsibleContent } from "../ui/collapsible";
import {
Dialog,
DialogDescription,
Expand All @@ -27,7 +28,7 @@ import {
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Textarea } from "../ui/textarea";
import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
import { SettingsPageContainer, SettingsSection } from "./settingsLayout";

interface SavedPromptInput {
readonly title: string;
Expand Down Expand Up @@ -91,33 +92,15 @@ export function PromptsSettings() {
</p>
) : (
prompts.map((prompt) => (
<SettingsRow
<SavedPromptRow
key={prompt.id}
className="group py-2"
title={
<span className="flex min-w-0 items-baseline gap-2">
<span className="max-w-60 shrink-0 truncate">{prompt.title}</span>
<span className="min-w-0 flex-1 truncate font-normal text-muted-foreground">
{promptPreview(prompt)}
</span>
</span>
}
control={
<Button
size="icon-xs"
variant="ghost"
className="shrink-0 text-muted-foreground opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"
aria-label={`Edit ${prompt.title}`}
disabled={!canEdit}
onClick={() =>
setEditorRequest({
promptId: prompt.id,
initial: { title: prompt.title, content: prompt.content },
})
}
>
<PencilIcon className="size-3.5" />
</Button>
prompt={prompt}
canEdit={canEdit}
onEdit={() =>
setEditorRequest({
promptId: prompt.id,
initial: { title: prompt.title, content: prompt.content },
})
}
/>
))
Expand All @@ -137,6 +120,69 @@ function promptPreview(prompt: SavedPrompt): string {
return prompt.content.split("\n", 1)[0] ?? "";
}

/**
* Compact saved-prompt row (title + first-line preview), expandable in place
* to the full content even while the library is read-only. Exported for tests.
*/
export function SavedPromptRow({
prompt,
canEdit,
onEdit,
}: {
prompt: SavedPrompt;
canEdit: boolean;
onEdit: () => void;
}) {
const [isExpanded, setIsExpanded] = useState(false);
const contentId = useId();
return (
<div className="group rounded-xl px-3 py-2 sm:px-4">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
<h3 className="flex min-h-5 min-w-0 flex-1 items-baseline gap-2 text-sm font-medium tracking-[-0.005em] text-foreground">
<span className="max-w-60 shrink-0 truncate">{prompt.title}</span>
{!isExpanded && (
<span className="min-w-0 flex-1 truncate font-normal text-muted-foreground">
{promptPreview(prompt)}
</span>
)}
</h3>
<div className="flex shrink-0 items-center gap-1">
<Button
size="icon-xs"
variant="ghost-muted"
className="shrink-0"
aria-controls={contentId}
aria-expanded={isExpanded}
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${prompt.title}`}
onClick={() => setIsExpanded((open) => !open)}
>
<ChevronDownIcon
className={cn("size-3.5 transition-transform", isExpanded && "rotate-180")}
/>
</Button>
<Button
size="icon-xs"
variant="ghost"
className="shrink-0 text-muted-foreground opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"
aria-label={`Edit ${prompt.title}`}
disabled={!canEdit}
onClick={onEdit}
>
<PencilIcon className="size-3.5" />
</Button>
</div>
</div>
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CollapsibleContent id={contentId} keepMounted>
<p className="pt-1.5 pb-1 text-[13px] leading-[1.45] break-words whitespace-pre-wrap text-muted-foreground">
{prompt.content}
</p>
</CollapsibleContent>
</Collapsible>
</div>
);
}

/**
* Add/edit dialog for a saved prompt. The parent owns which prompt (if any)
* is being edited via `request`; the dialog owns form state and validation.
Expand Down
1 change: 1 addition & 0 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ const ISOLATED_TEST_FILES = [
"src/components/ProviderUpdateEnvironmentRows.test.tsx",
"src/components/settings/AddProviderInstanceDialog.environment.test.tsx",
"src/components/settings/ProjectFaviconPickerDialog.test.tsx",
"src/components/settings/PromptsSettings.test.tsx",
"src/components/settings/ProviderSettingsPanel.environment.test.tsx",
"src/components/ServerUpdateAction.test.tsx",
"src/components/Sidebar.logic.test.ts",
Expand Down