Compare commits

..
2 Commits
10 changed files with 142 additions and 16 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.17.0
1.17.1
@@ -15,10 +15,10 @@ export function HttpToolForm({ editor }: { editor: ServerEditor }) {
<div className="portal-settings-heading"><h3>{t("servers.tool")}</h3><p>{t("servers.http_lead")}</p></div>
<div className="portal-settings-card server-form">
<div className="server-two-fields">
<div><FormField label={t("servers.display_name")} value={draft.name} error={errors.name?.join(" ")} disabled={busy} onChange={(name) => change({ ...draft, name })} /><small>{t("servers.display_name_hint")}</small></div>
<div><FormField label={t("servers.tool_name")} value={server.toolName ?? ""} error={errors.toolName?.join(" ")} mono disabled={busy} onChange={(toolName) => update({ toolName })} />{!errors.toolName?.length && <small>{t("servers.tool_name_hint")}</small>}</div>
<div><FormField label={t("servers.display_name")} value={draft.name} error={errors.name?.join(" ")} disabled={busy} onBlur={() => editor.blur("name")} onChange={(name) => change({ ...draft, name })} /><small>{t("servers.display_name_hint")}</small></div>
<div><FormField label={t("servers.tool_name")} value={server.toolName ?? ""} error={errors.toolName?.join(" ")} mono disabled={busy} onBlur={() => editor.blur("toolName")} onChange={(toolName) => update({ toolName })} />{!errors.toolName?.length && <small>{t("servers.tool_name_hint")}</small>}</div>
</div>
<div><TextAreaField label={t("servers.ai_description")} value={server.description} disabled={busy} onChange={(description) => update({ description })} />
<div onBlur={() => editor.blur("description")}><TextAreaField label={t("servers.ai_description")} value={server.description} disabled={busy} onChange={(description) => update({ description })} />
<small>{t("servers.ai_description_hint")}</small><ServerErrors messages={errors.description} /></div>
<ServerEnabled editor={editor} />
</div>
@@ -29,7 +29,7 @@ export function HttpToolForm({ editor }: { editor: ServerEditor }) {
<div className="server-request-fields">
<Segmented className="server-methods" value={server.method ?? "GET"} items={[["GET", "GET"], ["POST", "POST"]]}
disabledKeys={busy ? ["GET", "POST"] : []} setValue={(method) => update({ method })} />
<TemplateAddressField label={t("servers.request_url")} value={server.url} disabled={busy} invalid={Boolean(errors.url?.length)} onChange={(url) => update({ url })} />
<TemplateAddressField label={t("servers.request_url")} value={server.url} disabled={busy} invalid={Boolean(errors.url?.length)} onBlur={() => editor.blur("url")} onChange={(url) => update({ url })} />
</div>
{!errors.url?.length && <small>{t("servers.url_hint")}</small>}
<ServerErrors messages={errors.url} />
@@ -14,10 +14,11 @@ export function ServerConnection({ editor }: { editor: ServerEditor }) {
<ServerEnabled editor={editor} />
<div className="portal-settings-card server-form">
<FormField label={t("servers.name")} value={draft.name} disabled={busy} error={errors.name?.join(" ")}
onBlur={() => editor.blur("name")}
onChange={(name) => change({ ...draft, name })} />
<div><TextAreaField label={t("servers.description")} value={server.description} disabled={busy} onChange={(description) => update({ description })} />
<small>{t("servers.description_hint")}</small></div>
<div><FormField label={t("servers.address")} value={server.url} mono disabled={busy} onChange={(url) => update({ url })} />
<div><FormField label={t("servers.address")} value={server.url} mono disabled={busy} onBlur={() => editor.blur("url")} onChange={(url) => update({ url })} />
<small>{t("servers.address_hint")}</small><ServerErrors messages={errors.url} /></div>
<ServerHeaders headers={server.headers} onChange={(headers) => update({ headers })} disabled={busy} mcp />
<ServerErrors messages={errors.headers} />
@@ -24,7 +24,7 @@ export function ServerSaveActions({ editor, mcp = false }: { editor: ServerEdito
return <>
{editor.error && <div className="integration-form-error" role="alert">{editor.error}</div>}
<div className="server-actions">
<Button variant="primary" disabled={editor.busy || editor.checkingAddress || count > 0} onClick={() => void editor.save()}>{t(editor.saving ? "common.saving" : "common.save")}</Button>
<Button variant="primary" disabled={editor.busy || editor.checkingAddress || (editor.validationSubmitted && count > 0)} onClick={() => void editor.save()}>{t(editor.saving ? "common.saving" : "common.save")}</Button>
{mcp && <Button variant="secondary" icon="refresh" disabled={editor.busy || !editor.integration || editor.dirty}
title={!editor.integration || editor.dirty ? t("servers.save_first") : undefined}
onClick={() => void editor.action("test/")}>{t("servers.test")}</Button>}
@@ -1,14 +1,14 @@
import { useId, useState } from "react";
// X6/X8: подстановки подсвечены; при вводе работает обычное текстовое поле.
export function TemplateAddressField({ label, value, onChange, disabled, invalid }: {
label: string; value: string; onChange: (value: string) => void; disabled: boolean; invalid: boolean;
export function TemplateAddressField({ label, value, onChange, onBlur, disabled, invalid }: {
label: string; value: string; onChange: (value: string) => void; onBlur?: () => void; disabled: boolean; invalid: boolean;
}) {
const id = useId();
const [focused, setFocused] = useState(false);
return <div className={`server-template-field${invalid ? " is-invalid" : ""}`}>
<input id={id} aria-label={label} aria-invalid={invalid} type="text" value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}
onFocus={() => setFocused(true)} onBlur={(event) => { event.target.scrollLeft = 0; setFocused(false); }} />
onFocus={() => setFocused(true)} onBlur={(event) => { event.target.scrollLeft = 0; setFocused(false); onBlur?.(); }} />
{!focused && <div aria-hidden="true">{value.split(/(\{[^{}]*\})/).map((part, i) => /^\{/.test(part)
? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>)}</div>}
</div>;
@@ -5,6 +5,7 @@ import type { Integration } from "../model";
import { serverDraft } from "./model";
import { draftErrors, mergeErrors } from "./validation";
import { useAddressValidation } from "./useAddressValidation";
import { useServerValidation } from "./useServerValidation";
import type { FieldErrors, ServerDraft, ServerKind } from "./types";
export function useServerEditor(kind: ServerKind, initial: Integration | null, onCreated: (id: number) => void) {
@@ -16,14 +17,19 @@ export function useServerEditor(kind: ServerKind, initial: Integration | null, o
const [saving, setSaving] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [saved, setSaved] = useState(false);
const address = useAddressValidation(draft.externalServer.url, kind === "http");
const errors = mergeErrors(serverErrors, draftErrors(draft), address.errors);
const validation = useServerValidation();
const address = useAddressValidation(draft.externalServer.url, kind === "http" && validation.showField("url"));
const allErrors = mergeErrors(draftErrors(draft), address.errors);
const errors = mergeErrors(validation.visibleErrors(allErrors), serverErrors);
const dirty = JSON.stringify(draft) !== JSON.stringify(serverDraft(kind, integration));
function change(next: ServerDraft) { setDraft(next); setServerErrors({}); setError(""); setSaved(false); }
function change(next: ServerDraft) {
validation.change(draft, next);
setDraft(next); setServerErrors({}); setError(""); setSaved(false);
}
function accept(next: Integration, resetDraft = false) {
setIntegration(next);
if (resetDraft) setDraft(serverDraft(kind, next));
if (resetDraft) { setDraft(serverDraft(kind, next)); validation.reset(); }
}
function fail(caught: unknown) {
if (caught instanceof ApiError && caught.status === 400) {
@@ -33,7 +39,9 @@ export function useServerEditor(kind: ServerKind, initial: Integration | null, o
} else setError(t("common.request_failed"));
}
async function save() {
if (Object.keys(errors).length || busy || address.checking) return;
if (busy || address.checking) return;
validation.submit();
if (Object.keys(allErrors).length || Object.keys(serverErrors).length) return;
setBusy(true); setSaving(true); setError("");
const { tools: _tools, toolsState: _state, toolsRefreshedAt: _refreshed, ...settings } = draft.externalServer;
try {
@@ -59,6 +67,7 @@ export function useServerEditor(kind: ServerKind, initial: Integration | null, o
return true;
} catch (caught) { fail(caught); return false; } finally { setBusy(false); setRefreshing(false); }
}
return { integration, draft, change, errors, error, busy, saving, refreshing, dirty, saved, save, action, checkingAddress: address.checking };
return { integration, draft, change, errors, error, busy, saving, refreshing, dirty, saved, save, action,
blur: validation.blur, validationSubmitted: validation.submitted, checkingAddress: address.checking };
}
export type ServerEditor = ReturnType<typeof useServerEditor>;
@@ -0,0 +1,16 @@
import { useState } from "react";
import type { FieldErrors, ServerDraft } from "./types";
import { initialValidationState, validationAfterBlur, validationAfterChange, visibleValidationErrors } from "./validationState";
export function useServerValidation() {
const [state, setState] = useState(initialValidationState);
return {
submitted: state.submitted,
showField: (field: string) => state.submitted || state.blurred.includes(field),
visibleErrors: (errors: FieldErrors) => visibleValidationErrors(errors, state),
change: (previous: ServerDraft, next: ServerDraft) => setState((current) => validationAfterChange(current, previous, next)),
blur: (field: string) => setState((current) => validationAfterBlur(current, field)),
submit: () => setState((current) => ({ ...current, submitted: true })),
reset: () => setState(initialValidationState),
};
}
@@ -0,0 +1,57 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { t } from "../../../i18n";
import { HttpToolForm } from "./HttpToolForm";
import { ServerConnection } from "./ServerConnection";
import { serverDraft } from "./model";
import { useServerEditor } from "./useServerEditor";
import { draftErrors } from "./validation";
import { initialValidationState, validationAfterBlur, validationAfterChange, visibleValidationErrors } from "./validationState";
import type { ServerKind } from "./types";
describe("показ ошибок настроек MCP и HTTP", () => {
it.each(["mcp", "http"] as const)("открывает пустую форму %s без ошибок и позволяет проверить сохранение", (kind: ServerKind) => {
function Form() {
const editor = useServerEditor(kind, null, () => undefined);
return kind === "mcp" ? <ServerConnection editor={editor} /> : <HttpToolForm editor={editor} />;
}
const markup = renderToStaticMarkup(<Form />);
expect(markup).not.toContain('role="alert"');
expect(markup).not.toContain("is-invalid");
expect(markup).not.toContain('aria-invalid="true"');
expect(markup).toMatch(new RegExp(`<button[^>]*(?<!disabled="")>${t("common.save")}</button>`));
const saveButton = markup.match(new RegExp(`<button[^>]*>${t("common.save")}</button>`))?.[0];
expect(saveButton).not.toContain("disabled");
});
it("не показывает ошибки при выходе из нетронутого поля или во время первого ввода", () => {
const draft = serverDraft("http");
expect(visibleValidationErrors(draftErrors(draft), validationAfterBlur(initialValidationState, "name"))).toEqual({});
const changed = { ...draft, name: " " };
const state = validationAfterChange(initialValidationState, draft, changed);
expect(visibleValidationErrors(draftErrors(changed), state)).toEqual({});
expect(Object.keys(visibleValidationErrors(draftErrors(changed), validationAfterBlur(state, "name")))).toEqual(["name"]);
});
it("показывает ошибку изменённого HTTP-поля после выхода и убирает её после исправления", () => {
const draft = serverDraft("http");
const invalid = { ...draft, externalServer: { ...draft.externalServer, toolName: "get order" } };
const state = validationAfterBlur(validationAfterChange(initialValidationState, draft, invalid), "toolName");
expect(Object.keys(visibleValidationErrors(draftErrors(invalid), state))).toEqual(["toolName"]);
const corrected = { ...invalid, externalServer: { ...invalid.externalServer, toolName: "get_order" } };
expect(visibleValidationErrors(draftErrors(corrected), state)).toEqual({});
});
it.each(["mcp", "http"] as const)("после попытки сохранения %s показывает все ошибки, сохраняя обязательность полей", (kind) => {
const errors = draftErrors(serverDraft(kind));
expect(Object.keys(errors)).toEqual(kind === "mcp" ? ["name", "url"] : ["name", "url", "toolName", "description"]);
expect(visibleValidationErrors(errors, { ...initialValidationState, submitted: true })).toEqual(errors);
});
it("не раскрывает ошибку адреса при изменении другого поля", () => {
const draft = serverDraft("http");
const next = { ...draft, name: "Название" };
const state = validationAfterBlur(validationAfterChange(initialValidationState, draft, next), "name");
expect(visibleValidationErrors({ url: ["Недопустимый адрес"] }, state)).toEqual({});
});
});
@@ -0,0 +1,22 @@
import type { FieldErrors, ServerDraft } from "./types";
export type ValidationState = { changed: string[]; blurred: string[]; submitted: boolean };
export const initialValidationState: ValidationState = { changed: [], blurred: [], submitted: false };
export function validationAfterChange(state: ValidationState, previous: ServerDraft, next: ServerDraft): ValidationState {
const fields = new Set(state.changed);
if (previous.name !== next.name) fields.add("name");
for (const key of Object.keys(next.externalServer) as Array<keyof ServerDraft["externalServer"]>) {
if (JSON.stringify(previous.externalServer[key]) !== JSON.stringify(next.externalServer[key])) fields.add(key);
}
return { ...state, changed: [...fields] };
}
export function validationAfterBlur(state: ValidationState, field: string): ValidationState {
if (!state.changed.includes(field) || state.blurred.includes(field)) return state;
return { ...state, blurred: [...state.blurred, field] };
}
export function visibleValidationErrors(errors: FieldErrors, state: ValidationState): FieldErrors {
return Object.fromEntries(Object.entries(errors).filter(([field]) => state.submitted || state.blurred.includes(field)));
}
+21
View File
@@ -0,0 +1,21 @@
# Chatballs 1.17.1
## Исправления
- Настройки MCP-сервера и HTTP-запроса открываются без красной подсветки
и сообщений об ошибках для ещё не изменённых полей.
- Ошибка изменённого поля появляется после выхода из него и исчезает
после исправления значения. Нетронутые поля остаются нейтральными.
- При попытке сохранения проверяется вся форма. Некорректные данные
не сохраняются.
## Проверки
Пройдены 21 тест затронутых сценариев и сборка внутреннего интерфейса.
Браузерная проверка не завершена: штатный локальный backend не отвечает,
а инструмент Windows Computer Use не смог достоверно определить URL.
## Обновление
Изменения относительно 1.17.0 затрагивают только внутренний интерфейс.
Новых миграций базы данных нет.