feat: implement ui v1

This commit is contained in:
a.tolmachev
2026-03-25 19:27:31 +03:00
parent 39c88456b4
commit 85ffda67d3
32 changed files with 5084 additions and 62 deletions
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
buildOperationPayload,
defaultOperationFormValues,
} from "./model";
describe("buildOperationPayload", () => {
it("creates a REST operation payload from form values", () => {
const payload = buildOperationPayload(defaultOperationFormValues);
expect(payload.protocol).toBe("rest");
expect(payload.target.method).toBe("POST");
expect(payload.input_mapping).toEqual({
rules: [
{
source: "$.mcp.email",
target: "$.request.body.email",
required: true,
},
],
});
});
it("throws when JSON fields are invalid", () => {
expect(() =>
buildOperationPayload({
...defaultOperationFormValues,
inputSchemaText: "{invalid",
}),
).toThrow(/Input schema contains invalid JSON/);
});
});
@@ -0,0 +1,130 @@
import { z } from "zod";
import { safeParseJson } from "../../shared/lib/json";
const operationFormSchema = z.object({
name: z
.string()
.min(3, "Name is too short")
.regex(/^[a-z0-9_]+$/, "Use lowercase snake_case"),
displayName: z.string().min(3, "Display name is too short"),
baseUrl: z.string().url("Base URL must be valid"),
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
pathTemplate: z.string().min(1, "Path is required"),
toolTitle: z.string().min(3, "Tool title is too short"),
toolDescription: z.string().min(8, "Tool description is too short"),
inputSchemaText: z.string().min(2, "Input schema is required"),
outputSchemaText: z.string().min(2, "Output schema is required"),
inputMappingText: z.string().min(2, "Input mapping is required"),
outputMappingText: z.string().min(2, "Output mapping is required"),
executionConfigText: z.string().min(2, "Execution config is required"),
});
export type OperationFormValues = z.infer<typeof operationFormSchema>;
export const defaultOperationFormValues: OperationFormValues = {
name: "crm_create_lead",
displayName: "Create Lead",
baseUrl: "https://api.example.com",
method: "POST",
pathTemplate: "/v1/leads",
toolTitle: "Create CRM lead",
toolDescription: "Creates a CRM lead from MCP input fields.",
inputSchemaText: JSON.stringify(
{
type: "object",
required: true,
nullable: false,
fields: {
email: {
type: "string",
required: true,
nullable: false,
},
},
},
null,
2,
),
outputSchemaText: JSON.stringify(
{
type: "object",
required: true,
nullable: false,
fields: {
id: {
type: "string",
required: true,
nullable: false,
},
},
},
null,
2,
),
inputMappingText: JSON.stringify(
{
rules: [
{
source: "$.mcp.email",
target: "$.request.body.email",
required: true,
},
],
},
null,
2,
),
outputMappingText: JSON.stringify(
{
rules: [
{
source: "$.response.body.id",
target: "$.output.id",
required: true,
},
],
},
null,
2,
),
executionConfigText: JSON.stringify(
{
timeout_ms: 10000,
headers: {},
},
null,
2,
),
};
export function buildOperationPayload(rawValues: OperationFormValues) {
const values = operationFormSchema.parse(rawValues);
return {
name: values.name,
display_name: values.displayName,
protocol: "rest",
target: {
kind: "rest",
base_url: values.baseUrl,
method: values.method,
path_template: values.pathTemplate,
static_headers: {},
},
input_schema: safeParseJson(values.inputSchemaText, "Input schema"),
output_schema: safeParseJson(values.outputSchemaText, "Output schema"),
input_mapping: safeParseJson(values.inputMappingText, "Input mapping"),
output_mapping: safeParseJson(values.outputMappingText, "Output mapping"),
execution_config: safeParseJson(
values.executionConfigText,
"Execution config",
),
tool_description: {
title: values.toolTitle,
description: values.toolDescription,
tags: ["rest"],
examples: [],
},
};
}
@@ -0,0 +1,135 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useMemo } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { createOperation } from "../../entities/operation/api";
import { ApiError } from "../../shared/api/client";
import {
buildOperationPayload,
defaultOperationFormValues,
type OperationFormValues,
} from "./model";
const formResolverSchema = z.object({
name: z.string().min(3),
displayName: z.string().min(3),
baseUrl: z.string().url(),
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
pathTemplate: z.string().min(1),
toolTitle: z.string().min(3),
toolDescription: z.string().min(8),
inputSchemaText: z.string().min(2),
outputSchemaText: z.string().min(2),
inputMappingText: z.string().min(2),
outputMappingText: z.string().min(2),
executionConfigText: z.string().min(2),
});
export function OperationForm() {
const navigate = useNavigate();
const form = useForm<OperationFormValues>({
defaultValues: defaultOperationFormValues,
resolver: zodResolver(formResolverSchema),
});
const creationMutation = useMutation({
mutationFn: async (values: OperationFormValues) =>
createOperation(buildOperationPayload(values)),
onSuccess: (data) => {
navigate(`/operations/${data.operation_id}`);
},
});
type OperationFieldName = keyof OperationFormValues;
const fieldGroups = useMemo<OperationFieldName[][]>(
() => [
["name", "displayName", "baseUrl", "method", "pathTemplate"],
["toolTitle", "toolDescription"],
["inputSchemaText", "outputSchemaText"],
["inputMappingText", "outputMappingText", "executionConfigText"],
],
[],
);
return (
<form
className="stack-layout"
onSubmit={form.handleSubmit((values) => {
creationMutation.mutate(values);
})}
>
{fieldGroups.map((fieldGroup, index) => (
<div className="form-grid" key={index}>
{fieldGroup.map((fieldName) => {
const fieldError = form.formState.errors[fieldName];
const isTextarea = fieldName.endsWith("Text") || fieldName === "toolDescription";
const label = fieldName
.replace(/([A-Z])/g, " $1")
.replace(/^./, (character) => character.toUpperCase());
if (fieldName === "method") {
return (
<label className="field-block" key={fieldName}>
<span>{label}</span>
<select {...form.register("method")}>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</select>
{fieldError ? (
<small className="field-error">{fieldError.message}</small>
) : null}
</label>
);
}
if (isTextarea) {
return (
<label className="field-block field-block-wide" key={fieldName}>
<span>{label}</span>
<textarea
rows={fieldName === "toolDescription" ? 4 : 12}
{...form.register(fieldName)}
/>
{fieldError ? (
<small className="field-error">{fieldError.message}</small>
) : null}
</label>
);
}
return (
<label className="field-block" key={fieldName}>
<span>{label}</span>
<input type="text" {...form.register(fieldName)} />
{fieldError ? (
<small className="field-error">{fieldError.message}</small>
) : null}
</label>
);
})}
</div>
))}
{creationMutation.error ? (
<div className="feedback-card feedback-error">
{creationMutation.error instanceof ApiError
? creationMutation.error.message
: "Failed to create operation"}
</div>
) : null}
<div className="button-row">
<button className="button-primary" type="submit" disabled={creationMutation.isPending}>
{creationMutation.isPending ? "Creating..." : "Create operation"}
</button>
</div>
</form>
);
}