feat(settings): unify prompt editors (#2855)

This commit is contained in:
Carlos Florêncio
2026-08-21 16:40:58 +01:00
committed by GitHub
parent d99092e09c
commit 24462de07d
88 changed files with 3388 additions and 510 deletions
@@ -0,0 +1,64 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SettingsSaveProvider } from "@/components/settings/settings-save-provider";
const mocks = vi.hoisted(() => ({
usePresets: vi.fn(),
promptEditor: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/hooks/domains/gitlab/use-gitlab-action-presets", () => ({
useGitLabActionPresets: mocks.usePresets,
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: (props: unknown) => {
mocks.promptEditor(props);
return null;
},
}));
import { GitLabActionPresetsSection } from "./action-presets-section";
describe("GitLabActionPresetsSection prompt editor", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("uses the shared editor with GitLab action placeholders and saved prompts", () => {
mocks.usePresets.mockReturnValue({
presets: {
mr: [
{
id: "review",
label: "Review",
hint: "Review it",
icon: "eye",
prompt_template: "Review {{title}}",
},
],
issue: [],
},
loading: false,
update: vi.fn(),
reset: vi.fn(),
});
render(
<SettingsSaveProvider>
<GitLabActionPresetsSection workspaceId="workspace-1" />
</SettingsSaveProvider>,
);
const props = mocks.promptEditor.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(props.promptReferences).toBe(true);
expect((props.placeholders as Array<{ key: string }>).map((item) => item.key)).toEqual([
"url",
"title",
]);
});
});
@@ -6,14 +6,32 @@ import { Button } from "@kandev/ui/button";
import { CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs";
import { Textarea } from "@kandev/ui/textarea";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsSection } from "@/components/settings/settings-section";
import { SettingsPromptEditor } from "@/components/settings/settings-prompt-editor";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { useToast } from "@/components/toast-provider";
import { useGitLabActionPresets } from "@/hooks/domains/gitlab/use-gitlab-action-presets";
import type { GitLabActionPreset, GitLabActionPresets } from "@/lib/types/gitlab";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
function actionPromptPlaceholders(t: TFunction) {
return [
{
key: "url",
description: t("gitlab:placeholderActionUrl"),
example: "https://gitlab.com/org/repo/-/merge_requests/42",
executor_types: [],
},
{
key: "title",
description: t("gitlab:placeholderActionTitle"),
example: "Fix login page crash",
executor_types: [],
},
];
}
// `label` is PERSISTED to workspace settings as part of `GitLabActionPreset`, and
// the row below is immediately editable, so it must stay locale-neutral: a preset
@@ -40,6 +58,7 @@ function PresetList({
addLabel: string;
}) {
const { t } = useTranslation();
const placeholders = useMemo(() => actionPromptPlaceholders(t), [t]);
const patch = (index: number, change: Partial<GitLabActionPreset>) =>
onChange(
presets.map((preset, current) => (current === index ? { ...preset, ...change } : preset)),
@@ -72,17 +91,13 @@ function PresetList({
<IconTrash className="h-4 w-4" />
</Button>
</div>
<Textarea
aria-label={t("gitlab:actionPrompt", { index: index + 1 })}
<SettingsPromptEditor
ariaLabel={t("gitlab:actionPrompt", { index: index + 1 })}
value={preset.prompt_template}
// The two `{{…}}` tokens are passed as values so they never reach the
// catalog, where i18next would interpolate them away.
placeholder={t("gitlab:promptUsingUrlAndTitle", {
url: "{{url}}",
title: "{{title}}",
})}
className="min-h-24 font-mono text-xs"
onChange={(event) => patch(index, { prompt_template: event.target.value })}
onChange={(value) => patch(index, { prompt_template: value })}
placeholders={placeholders}
promptReferences
testId={`gitlab-action-prompt-editor-${preset.id}`}
/>
</div>
))}
@@ -13,6 +13,7 @@ const workspaceId = "workspace-1";
const defaultHost = "https://gitlab.com";
const credentialsContributorId = "gitlab-credentials";
const appState = {
prompts: { items: [], loaded: true, loading: false },
gitlabReviewWatches: { items: [] as ReviewWatch[], loaded: false, loading: false },
gitlabIssueWatches: { items: [], loaded: false, loading: false },
gitlabActionPresets: {
@@ -50,6 +51,27 @@ vi.mock("@/components/settings/settings-save-provider", () => ({
useSettingsSaveContributor: vi.fn(),
}));
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: ({
value,
onChange,
ariaLabel,
testId,
}: {
value: string;
onChange: (value: string) => void;
ariaLabel?: string;
testId?: string;
}) => (
<textarea
aria-label={ariaLabel}
data-testid={testId}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
),
}));
vi.mock("@/lib/api/domains/gitlab-api", () => ({
clearGitLabToken: vi.fn(),
configureGitLabHost: vi.fn(),
@@ -2,6 +2,8 @@ import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { IssueWatchDialog } from "./issue-watch-dialog";
const promptEditor = vi.hoisted(() => vi.fn());
const store = {
workspaces: { activeId: "ws-1" },
workflows: { items: [] },
@@ -21,8 +23,17 @@ vi.mock("@/hooks/use-workflow-steps", () => ({
vi.mock("@/components/watcher-repository-fields", () => ({
WatcherRepositoryFields: () => <div>Repository and base branch</div>,
}));
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: (props: Record<string, unknown>) => {
promptEditor(props);
return <div aria-label={props.ariaLabel as string} data-testid={props.testId as string} />;
},
}));
afterEach(cleanup);
afterEach(() => {
cleanup();
promptEditor.mockReset();
});
describe("IssueWatchDialog", () => {
it("renders labels and GitLab query controls", () => {
@@ -39,4 +50,25 @@ describe("IssueWatchDialog", () => {
expect(screen.getByLabelText("Labels")).toBeTruthy();
expect(screen.getByLabelText("GitLab query parameters")).toBeTruthy();
});
it("uses GitLab issue placeholders and saved-prompt references", () => {
render(
<IssueWatchDialog
open
onOpenChange={vi.fn()}
watch={null}
workspaceId="ws-1"
onCreate={vi.fn()}
onUpdate={vi.fn()}
/>,
);
expect(promptEditor).toHaveBeenCalledWith(
expect.objectContaining({
promptReferences: true,
testId: "issue-watch-prompt-editor",
placeholders: expect.arrayContaining([expect.objectContaining({ key: "issue.url" })]),
}),
);
});
});
@@ -1,7 +1,37 @@
export const GITLAB_ISSUE_WATCH_PLACEHOLDERS = [
"issue.url",
"issue.title",
"issue.project",
"issue.iid",
"issue.description",
];
import type { TFunction } from "i18next";
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
export function gitlabIssueWatchPlaceholders(t: TFunction): ScriptPlaceholder[] {
return [
{
key: "issue.url",
description: t("gitlab:placeholderActionUrl"),
example: "https://gitlab.com/group/project/-/issues/42",
executor_types: [],
},
{
key: "issue.title",
description: t("gitlab:placeholderActionTitle"),
example: "Fix login page crash",
executor_types: [],
},
{
key: "issue.project",
description: t("gitlab:placeholderProjectPath"),
example: "group/project",
executor_types: [],
},
{
key: "issue.iid",
description: t("gitlab:placeholderIssueIid"),
example: "42",
executor_types: [],
},
{
key: "issue.description",
description: t("gitlab:placeholderDescription"),
example: "When clicking login...",
executor_types: [],
},
];
}
@@ -2,6 +2,8 @@ import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ReviewWatchDialog } from "./review-watch-dialog";
const promptEditor = vi.hoisted(() => vi.fn());
const store = {
workspaces: { activeId: "ws-1" },
workflows: { items: [{ id: "workflow", name: "Delivery", hidden: false }] },
@@ -21,8 +23,17 @@ vi.mock("@/hooks/use-workflow-steps", () => ({
vi.mock("@/components/watcher-repository-fields", () => ({
WatcherRepositoryFields: () => <div>Repository and base branch</div>,
}));
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: (props: Record<string, unknown>) => {
promptEditor(props);
return <div aria-label={props.ariaLabel as string} data-testid={props.testId as string} />;
},
}));
afterEach(cleanup);
afterEach(() => {
cleanup();
promptEditor.mockReset();
});
describe("ReviewWatchDialog", () => {
it("renders the complete watch controls in a narrow-safe dialog", () => {
@@ -45,4 +56,25 @@ describe("ReviewWatchDialog", () => {
screen.getByText(/leave empty to match merge requests requesting your review/i),
).toBeTruthy();
});
it("uses GitLab merge request placeholders and saved-prompt references", () => {
render(
<ReviewWatchDialog
open
onOpenChange={vi.fn()}
watch={null}
workspaceId="ws-1"
onCreate={vi.fn()}
onUpdate={vi.fn()}
/>,
);
expect(promptEditor).toHaveBeenCalledWith(
expect.objectContaining({
promptReferences: true,
testId: "review-watch-prompt-editor",
placeholders: expect.arrayContaining([expect.objectContaining({ key: "mr.url" })]),
}),
);
});
});
@@ -1,7 +1,37 @@
export const GITLAB_REVIEW_WATCH_PLACEHOLDERS = [
"mr.url",
"mr.title",
"mr.project",
"mr.iid",
"mr.description",
];
import type { TFunction } from "i18next";
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
export function gitlabReviewWatchPlaceholders(t: TFunction): ScriptPlaceholder[] {
return [
{
key: "mr.url",
description: t("gitlab:placeholderActionUrl"),
example: "https://gitlab.com/group/project/-/merge_requests/42",
executor_types: [],
},
{
key: "mr.title",
description: t("gitlab:placeholderActionTitle"),
example: "Fix login page crash",
executor_types: [],
},
{
key: "mr.project",
description: t("gitlab:placeholderProjectPath"),
example: "group/project",
executor_types: [],
},
{
key: "mr.iid",
description: t("gitlab:placeholderMergeRequestIid"),
example: "42",
executor_types: [],
},
{
key: "mr.description",
description: t("gitlab:placeholderDescription"),
example: "When clicking login...",
executor_types: [],
},
];
}
+18 -5
View File
@@ -14,7 +14,6 @@ import {
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Textarea } from "@kandev/ui/textarea";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useAppStore } from "@/components/state-provider";
import { WatcherRepositoryFields } from "@/components/watcher-repository-fields";
@@ -38,6 +37,9 @@ import {
} from "./watch-form";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { SettingsPromptEditor } from "@/components/settings/settings-prompt-editor";
import { gitlabIssueWatchPlaceholders } from "./issue-watch-placeholders";
import { gitlabReviewWatchPlaceholders } from "./review-watch-placeholders";
type Watch = ReviewWatch | IssueWatch;
type CreateRequest = CreateReviewWatchRequest | CreateIssueWatchRequest;
@@ -207,6 +209,10 @@ function AutomationFields({ kind, form, setForm }: FormFieldsProps) {
const { t } = useTranslation();
const stepDefaultLabel = t("common:useStepDefaultOption");
const data = useDialogData(form.workspaceId, form.workflowId);
const placeholders = useMemo(
() => (kind === "review" ? gitlabReviewWatchPlaceholders(t) : gitlabIssueWatchPlaceholders(t)),
[kind, t],
);
return (
<div className="space-y-4">
<SectionTitle>{t("gitlab:taskAutomation")}</SectionTitle>
@@ -271,11 +277,18 @@ function AutomationFields({ kind, form, setForm }: FormFieldsProps) {
<div className="space-y-1.5">
<Label htmlFor={`${kind}-watch-prompt`}>{t("gitlab:taskPrompt")}</Label>
<p className="text-xs text-muted-foreground">{t("gitlab:promptSentToTheSelectedAgent")}</p>
<Textarea
id={`${kind}-watch-prompt`}
<SettingsPromptEditor
value={form.prompt}
onChange={(event) => setForm((current) => ({ ...current, prompt: event.target.value }))}
rows={5}
onChange={(value) => setForm((current) => ({ ...current, prompt: value }))}
placeholders={placeholders}
promptReferences
ariaLabel={t("gitlab:taskPrompt")}
testId={`${kind}-watch-prompt-editor`}
help={
<p className="text-xs text-muted-foreground">
{t("gitlab:promptSentToTheSelectedAgent")}
</p>
}
/>
</div>
</div>