- 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>
334 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|