feat: implement ui v1
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user