AcadeDoc/apps/client/src/features/acadenice/slash-commands-admin/components/slash-command-form.tsx
Corentin 4cf04080cf fix(acadenice): resolve test suite failures across R3 sub-blocks (Patch 017)
- Convert 17 server spec files from vitest to Jest (vi -> jest globals)
- Add jest.mock stubs for ESM-only prosemirror/html and collaboration modules
- Fix Zod v4 strict UUID validation failures in test fixtures (version byte [1-8] required)
- Add JwtAuthGuard.overrideGuard in all controller specs that lacked it
- Fix jest.Mock type inference (ReturnType<typeof jest.fn> -> jest.Mock) to prevent 'never' arg errors
- Delete vitest.config.ts (CJS), keep vitest.config.mts (ESM-compatible) on client
- Add global mocks for @excalidraw/excalidraw and @/main.tsx in client test-setup
- Result: client 38/38 suites 313/313 tests, server acadenice 21/21 suites 210/210 tests, 0 TS errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 10:36:19 +02:00

334 lines
10 KiB
TypeScript

import React, { useEffect } from "react";
import {
Modal,
TextInput,
Textarea,
Select,
NumberInput,
Button,
Stack,
Group,
Divider,
Switch,
Text,
Alert,
Code,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { useTranslation } from "react-i18next";
import { SlashCommandDto, CreateSlashCommandPayload } from "../services/slash-commands-client";
const ACTION_TYPE_OPTIONS = [
{ value: "insert-template", label: "Insert Template" },
{ value: "insert-table", label: "Insert Table" },
{ value: "embed-url", label: "Embed URL" },
{ value: "run-webhook", label: "Run Webhook" },
{ value: "insert-snippet", label: "Insert Code Snippet" },
];
interface Props {
opened: boolean;
onClose: () => void;
onSubmit: (payload: CreateSlashCommandPayload) => void;
initialValues?: SlashCommandDto | null;
isLoading?: boolean;
}
interface FormValues {
keyword: string;
label: string;
description: string;
icon: string;
actionType: string;
isEnabled: boolean;
// insert-template
template: string;
// insert-table
rows: number;
cols: number;
withHeaderRow: boolean;
// embed-url
url: string;
// run-webhook
webhookUrl: string;
webhookHeaders: string;
// insert-snippet
language: string;
code: string;
}
/**
* Polymorphic create/edit form for custom slash commands.
*
* The action_config section renders different fields depending on the selected
* actionType (discriminated union pattern). Validation is done client-side
* before submission; backend validates again with Zod.
*/
export function SlashCommandForm({
opened,
onClose,
onSubmit,
initialValues,
isLoading,
}: Props) {
const { t } = useTranslation();
const form = useForm<FormValues>({
initialValues: {
keyword: "",
label: "",
description: "",
icon: "",
actionType: "insert-template",
isEnabled: true,
template: "# Title\n\n",
rows: 3,
cols: 3,
withHeaderRow: true,
url: "",
webhookUrl: "",
webhookHeaders: "",
language: "typescript",
code: "",
},
validate: {
keyword: (v) =>
/^[a-z0-9-]+$/.test(v)
? null
: t("slash_commands.keyword_format_error"),
label: (v) => (v.trim().length > 0 ? null : t("slash_commands.label_required")),
url: (v, values) =>
values.actionType === "embed-url" && !v.startsWith("http")
? t("slash_commands.url_required")
: null,
webhookUrl: (v, values) =>
values.actionType === "run-webhook" && !v.startsWith("https://")
? t("slash_commands.webhook_https_required")
: null,
language: (v, values) =>
values.actionType === "insert-snippet" && !v.trim()
? t("slash_commands.language_required")
: null,
},
});
// Populate form when editing an existing command
useEffect(() => {
if (!initialValues) {
form.reset();
return;
}
const cfg = initialValues.actionConfig as Record<string, unknown>;
form.setValues({
keyword: initialValues.keyword,
label: initialValues.label,
description: initialValues.description ?? "",
icon: initialValues.icon ?? "",
actionType: initialValues.actionType,
isEnabled: initialValues.isEnabled,
template:
typeof cfg["template"] === "string"
? cfg["template"]
: JSON.stringify(cfg["template"], null, 2),
rows: (cfg["rows"] as number) ?? 3,
cols: (cfg["cols"] as number) ?? 3,
withHeaderRow: (cfg["withHeaderRow"] as boolean) ?? true,
url: (cfg["url"] as string) ?? "",
webhookUrl: (cfg["webhookUrl"] as string) ?? "",
webhookHeaders: cfg["headers"]
? JSON.stringify(cfg["headers"], null, 2)
: "",
language: (cfg["language"] as string) ?? "typescript",
code: (cfg["code"] as string) ?? "",
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValues?.id]);
function buildActionConfig(values: FormValues): Record<string, unknown> {
switch (values.actionType) {
case "insert-template":
return { template: values.template };
case "insert-table":
return {
rows: values.rows,
cols: values.cols,
withHeaderRow: values.withHeaderRow,
};
case "embed-url":
return { url: values.url };
case "run-webhook": {
let headers: Record<string, string> | undefined;
if (values.webhookHeaders.trim()) {
try {
headers = JSON.parse(values.webhookHeaders) as Record<string, string>;
} catch {
// Headers JSON is invalid — backend Zod will reject cleanly
}
}
return { webhookUrl: values.webhookUrl, ...(headers ? { headers } : {}) };
}
case "insert-snippet":
return { language: values.language, code: values.code };
default:
return {};
}
}
function handleSubmit(values: FormValues) {
onSubmit({
keyword: values.keyword,
label: values.label,
description: values.description || undefined,
icon: values.icon || undefined,
actionType: values.actionType as CreateSlashCommandPayload["actionType"],
actionConfig: buildActionConfig(values),
isEnabled: values.isEnabled,
});
}
const actionType = form.values.actionType;
return (
<Modal
opened={opened}
onClose={onClose}
title={
initialValues
? t("slash_commands.edit_title")
: t("slash_commands.create_title")
}
size="lg"
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="sm">
<TextInput
label={t("slash_commands.keyword_label")}
description={t("slash_commands.keyword_description")}
placeholder="meeting-note"
{...form.getInputProps("keyword")}
disabled={!!initialValues}
/>
<TextInput
label={t("slash_commands.label_label")}
placeholder="Meeting Note"
{...form.getInputProps("label")}
/>
<Textarea
label={t("slash_commands.description_label")}
placeholder={t("slash_commands.description_placeholder")}
autosize
minRows={2}
{...form.getInputProps("description")}
/>
<TextInput
label={t("slash_commands.icon_label")}
description={t("slash_commands.icon_description")}
placeholder="IconNotes"
{...form.getInputProps("icon")}
/>
<Select
label={t("slash_commands.action_type_label")}
data={ACTION_TYPE_OPTIONS}
{...form.getInputProps("actionType")}
/>
<Switch
label={t("slash_commands.enabled_label")}
{...form.getInputProps("isEnabled", { type: "checkbox" })}
/>
<Divider label={t("slash_commands.action_config_section")} />
{/* Polymorphic config fields */}
{actionType === "insert-template" && (
<Textarea
label={t("slash_commands.template_label")}
description={t("slash_commands.template_description")}
autosize
minRows={4}
styles={{ input: { fontFamily: "monospace" } }}
{...form.getInputProps("template")}
/>
)}
{actionType === "insert-table" && (
<>
<NumberInput
label={t("slash_commands.rows_label")}
min={1}
max={50}
{...form.getInputProps("rows")}
/>
<NumberInput
label={t("slash_commands.cols_label")}
min={1}
max={20}
{...form.getInputProps("cols")}
/>
<Switch
label={t("slash_commands.header_row_label")}
{...form.getInputProps("withHeaderRow", { type: "checkbox" })}
/>
</>
)}
{actionType === "embed-url" && (
<TextInput
label={t("slash_commands.url_label")}
placeholder="https://example.com/embed"
{...form.getInputProps("url")}
/>
)}
{actionType === "run-webhook" && (
<>
<Alert color="yellow" title={t("slash_commands.webhook_security_title")}>
{t("slash_commands.webhook_security_note")}
</Alert>
<TextInput
label={t("slash_commands.webhook_url_label")}
placeholder="https://hooks.example.com/trigger"
{...form.getInputProps("webhookUrl")}
/>
<Textarea
label={t("slash_commands.webhook_headers_label")}
description={t("slash_commands.webhook_headers_description")}
placeholder={'{"X-Tenant": "acadenice"}'}
autosize
minRows={2}
styles={{ input: { fontFamily: "monospace" } }}
{...form.getInputProps("webhookHeaders")}
/>
</>
)}
{actionType === "insert-snippet" && (
<>
<TextInput
label={t("slash_commands.language_label")}
placeholder="typescript"
{...form.getInputProps("language")}
/>
<Textarea
label={t("slash_commands.snippet_code_label")}
description={t("slash_commands.snippet_code_description")}
autosize
minRows={3}
styles={{ input: { fontFamily: "monospace" } }}
{...form.getInputProps("code")}
/>
</>
)}
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose} disabled={isLoading}>
{t("Cancel")}
</Button>
<Button type="submit" loading={isLoading}>
{initialValues ? t("Save") : t("Create")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}