From 7a6a944622f2f6415aa7f317863ff6e45e84f168 Mon Sep 17 00:00:00 2001 From: viettranx Date: Fri, 3 Apr 2026 22:35:06 +0700 Subject: [PATCH] =?UTF-8?q?refactor(ui):=20Phase=203=20=E2=80=94=20Zod=20s?= =?UTF-8?q?chemas=20+=20React=20Hook=20Form=20for=206=20form=20dialogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add zod@4.3.6, react-hook-form@7.72.1, @hookform/resolvers@5.2.2. Create src/schemas/ with 6 validation schemas: - agent.schema.ts, provider.schema.ts, cron.schema.ts - channel.schema.ts, credential.schema.ts, heartbeat.schema.ts Refactor 6 form dialogs from manual useState chains to RHF + Zod: - agent-create-dialog: 12 → 2 useState - provider-form-dialog: 8 → 0 useState - cron-form-dialog: 7 → 0 useState - channel-instance-form-dialog: 11 → 6 useState - cli-credential-form-dialog: 18 → 9 useState - heartbeat-config-dialog: 19 → 4 useState Total: 75 useState removed, replaced with type-safe form state. Async side-effects kept as hooks. No behavior changes. --- ui/web/package.json | 3 + ui/web/pnpm-lock.yaml | 34 +++ .../src/pages/agents/agent-create-dialog.tsx | 205 ++++++++++------ .../agent-detail/heartbeat-config-dialog.tsx | 182 +++++++++----- .../channels/channel-instance-form-dialog.tsx | 139 +++++++---- .../cli-credential-form-dialog.tsx | 163 +++++++----- ui/web/src/pages/cron/cron-form-dialog.tsx | 133 +++++----- .../pages/providers/provider-form-dialog.tsx | 231 +++++++++--------- ui/web/src/schemas/agent.schema.ts | 18 ++ ui/web/src/schemas/channel.schema.ts | 15 ++ ui/web/src/schemas/credential.schema.ts | 15 ++ ui/web/src/schemas/cron.schema.ts | 16 ++ ui/web/src/schemas/heartbeat.schema.ts | 20 ++ ui/web/src/schemas/provider.schema.ts | 22 ++ 14 files changed, 767 insertions(+), 429 deletions(-) create mode 100644 ui/web/src/schemas/agent.schema.ts create mode 100644 ui/web/src/schemas/channel.schema.ts create mode 100644 ui/web/src/schemas/credential.schema.ts create mode 100644 ui/web/src/schemas/cron.schema.ts create mode 100644 ui/web/src/schemas/heartbeat.schema.ts create mode 100644 ui/web/src/schemas/provider.schema.ts diff --git a/ui/web/package.json b/ui/web/package.json index a7dea4f4..0a652842 100644 --- a/ui/web/package.json +++ b/ui/web/package.json @@ -13,6 +13,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^5.2.2", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.95.2", "@tanstack/react-table": "^8.21.3", @@ -30,6 +31,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-force-graph-2d": "^1.29.1", + "react-hook-form": "^7.72.1", "react-i18next": "^17.0.1", "react-markdown": "^10.1.0", "react-router": "^7.13.2", @@ -37,6 +39,7 @@ "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.5.0", + "zod": "^4.3.6", "zustand": "^5.0.12" }, "devDependencies": { diff --git a/ui/web/pnpm-lock.yaml b/ui/web/pnpm-lock.yaml index c9673d3a..47753e51 100644 --- a/ui/web/pnpm-lock.yaml +++ b/ui/web/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.4) + '@hookform/resolvers': + specifier: ^5.2.2 + version: 5.2.2(react-hook-form@7.72.1(react@19.2.4)) '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.2.2) @@ -68,6 +71,9 @@ importers: react-force-graph-2d: specifier: ^1.29.1 version: 1.29.1(react@19.2.4) + react-hook-form: + specifier: ^7.72.1 + version: 7.72.1(react@19.2.4) react-i18next: specifier: ^17.0.1 version: 17.0.1(i18next@26.0.1(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2) @@ -89,6 +95,9 @@ importers: tailwind-merge: specifier: ^3.5.0 version: 3.5.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 zustand: specifier: ^5.0.12 version: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) @@ -176,6 +185,11 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@hookform/resolvers@5.2.2': + resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} + peerDependencies: + react-hook-form: ^7.55.0 + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1880,6 +1894,12 @@ packages: peerDependencies: react: '*' + react-hook-form@7.72.1: + resolution: {integrity: sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-i18next@17.0.1: resolution: {integrity: sha512-iG65FGnFHcYyHNuT01ukffYWCOBFTWSdVD8EZd/dCVWgtjFPObcSsvYYNwcsokO/rDcTb5d6D8Acv8MrOdm6Hw==} peerDependencies: @@ -2197,6 +2217,9 @@ packages: utf-8-validate: optional: true + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} engines: {node: '>=12.7.0'} @@ -2295,6 +2318,11 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@hookform/resolvers@5.2.2(react-hook-form@7.72.1(react@19.2.4))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.72.1(react@19.2.4) + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4202,6 +4230,10 @@ snapshots: react: 19.2.4 react-kapsule: 2.5.7(react@19.2.4) + react-hook-form@7.72.1(react@19.2.4): + dependencies: + react: 19.2.4 + react-i18next@17.0.1(i18next@26.0.1(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2): dependencies: '@babel/runtime': 7.29.2 @@ -4549,6 +4581,8 @@ snapshots: ws@8.20.0: {} + zod@4.3.6: {} + zustand@4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4): dependencies: use-sync-external-store: 1.6.0(react@19.2.4) diff --git a/ui/web/src/pages/agents/agent-create-dialog.tsx b/ui/web/src/pages/agents/agent-create-dialog.tsx index 4911edc2..de74d7d4 100644 --- a/ui/web/src/pages/agents/agent-create-dialog.tsx +++ b/ui/web/src/pages/agents/agent-create-dialog.tsx @@ -1,4 +1,6 @@ -import { useState, useMemo, useEffect } from "react"; +import { useMemo, useEffect } from "react"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslation } from "react-i18next"; import { ChevronRight } from "lucide-react"; import { @@ -22,11 +24,13 @@ import { } from "@/components/ui/select"; import { Combobox } from "@/components/ui/combobox"; import type { AgentData } from "@/types/agent"; -import { slugify, isValidSlug } from "@/lib/slug"; +import { slugify } from "@/lib/slug"; import { useProviders } from "@/pages/providers/hooks/use-providers"; import { useProviderModels } from "@/pages/providers/hooks/use-provider-models"; import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify"; import { useAgentPresets } from "./agent-presets"; +import { agentCreateSchema, type AgentCreateFormData } from "@/schemas/agent.schema"; +import { useState } from "react"; interface AgentCreateDialogProps { open: boolean; @@ -38,21 +42,32 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD const { t } = useTranslation("agents"); const agentPresets = useAgentPresets(); const { providers } = useProviders(); - const [emoji, setEmoji] = useState(""); - const [agentKey, setAgentKey] = useState(""); - const [keyTouched, setKeyTouched] = useState(false); - const [displayName, setDisplayName] = useState(""); - const [provider, setProvider] = useState(""); - const [model, setModel] = useState(""); - const [agentType, setAgentType] = useState<"open" | "predefined">("predefined"); - const [description, setDescription] = useState(""); - const [selfEvolve, setSelfEvolve] = useState(false); const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); + const [submitError, setSubmitError] = useState(""); + + const form = useForm({ + resolver: zodResolver(agentCreateSchema), + mode: "onChange", + defaultValues: { + emoji: "", + displayName: "", + agentKey: "", + provider: "", + model: "", + agentType: "predefined", + description: "", + selfEvolve: false, + }, + }); + + const { register, control, handleSubmit, watch, setValue, reset, formState: { errors } } = form; + + const provider = watch("provider"); + const model = watch("model"); + const agentType = watch("agentType"); const enabledProviders = providers.filter((p) => p.enabled); - // Look up provider ID from selected provider name for model fetching const selectedProvider = useMemo( () => enabledProviders.find((p) => p.name === provider), [enabledProviders, provider], @@ -66,6 +81,15 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD resetVerify(); }, [provider, model, resetVerify]); + // Reset form when dialog closes + useEffect(() => { + if (!open) { + reset(); + setSubmitError(""); + resetVerify(); + } + }, [open, reset, resetVerify]); + const handleVerify = async () => { if (!selectedProviderId || !model.trim()) return; await verify(selectedProviderId, model.trim()); @@ -74,49 +98,46 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD const handleVerifyAndCreate = async () => { if (!selectedProviderId || !model.trim()) return; const res = await verify(selectedProviderId, model.trim()); - if (res?.valid) await handleCreate(); + if (res?.valid) await handleSubmitForm(form.getValues()); }; - const handleCreate = async () => { - if (!agentKey.trim()) return; + const handleSubmitForm = async (data: AgentCreateFormData) => { setLoading(true); - setError(""); + setSubmitError(""); try { const otherConfig: Record = {}; - if (emoji.trim()) otherConfig.emoji = emoji.trim(); - if (description.trim()) otherConfig.description = description.trim(); - if (selfEvolve) otherConfig.self_evolve = true; + if (data.emoji?.trim()) otherConfig.emoji = data.emoji.trim(); + if (data.description?.trim()) otherConfig.description = data.description.trim(); + if (data.selfEvolve) otherConfig.self_evolve = true; await onCreate({ - agent_key: agentKey.trim(), - display_name: displayName.trim() || undefined, - provider: provider.trim(), - model: model.trim(), - agent_type: agentType, + agent_key: data.agentKey, + display_name: data.displayName || undefined, + provider: data.provider, + model: data.model, + agent_type: data.agentType, other_config: Object.keys(otherConfig).length > 0 ? otherConfig : undefined, }); onOpenChange(false); - setEmoji(""); - setAgentKey(""); - setKeyTouched(false); - setDisplayName(""); - setProvider(""); - setModel(""); - setAgentType("predefined"); - setDescription(""); - setSelfEvolve(false); - setError(""); } catch (err) { - setError(err instanceof Error ? err.message : t("create.failedToCreate")); + setSubmitError(err instanceof Error ? err.message : t("create.failedToCreate")); } finally { setLoading(false); } }; const handleProviderChange = (value: string) => { - setProvider(value); - setModel(""); + setValue("provider", value, { shouldValidate: true }); + setValue("model", "", { shouldValidate: false }); }; + const displayName = watch("displayName"); + const agentKey = watch("agentKey"); + + // Derived submit button state + const canCreate = !!agentKey && !!displayName && !!provider && !!model && + !errors.agentKey && !errors.displayName && + (agentType !== "predefined" || !!watch("description")?.trim()); + return ( @@ -130,8 +151,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
setEmoji(e.target.value)} + {...register("emoji")} placeholder="🤖" className="w-14 shrink-0 text-center text-lg" maxLength={2} @@ -139,11 +159,12 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD /> setDisplayName(e.target.value)} - onBlur={() => { - if (!keyTouched && displayName.trim()) { - setAgentKey(slugify(displayName.trim())); + {...register("displayName")} + onBlur={(e) => { + register("displayName").onBlur(e); + const name = e.target.value.trim(); + if (name && !form.getFieldState("agentKey").isDirty) { + setValue("agentKey", slugify(name), { shouldValidate: true }); } }} placeholder={t("create.displayNamePlaceholder")} @@ -154,37 +175,44 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD { - setKeyTouched(true); - setAgentKey(e.target.value); + {...register("agentKey")} + onBlur={(e) => { + setValue("agentKey", slugify(e.target.value), { shouldValidate: true }); }} - onBlur={() => setAgentKey(slugify(agentKey))} placeholder={t("create.agentKeyPlaceholder")} /> -

{t("create.agentKeyHint")}

+ {errors.agentKey ? ( +

{errors.agentKey.message}

+ ) : ( +

{t("create.agentKeyHint")}

+ )}
{enabledProviders.length > 0 ? ( - + ( + + )} + /> ) : ( setProvider(e.target.value)} + {...register("provider")} placeholder="openrouter" /> )} @@ -193,11 +221,17 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
- ({ value: m.id, label: m.name }))} - placeholder={modelsLoading ? t("create.loadingModels") : t("create.enterOrSelectModel")} + ( + setValue("model", v, { shouldValidate: true })} + options={models.map((m) => ({ value: m.id, label: m.name }))} + placeholder={modelsLoading ? t("create.loadingModels") : t("create.enterOrSelectModel")} + /> + )} />