feat(settings): unify prompt editors (#2855)
This commit is contained in:
@@ -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: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user