"use client"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; import { Button } from "@workspace/ui/components/button"; import { Checkbox } from "@workspace/ui/components/checkbox"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@workspace/ui/components/form"; import { Label } from "@workspace/ui/components/label"; import { ScrollArea } from "@workspace/ui/components/scroll-area"; import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, } from "@workspace/ui/components/sheet"; import { Combobox } from "@workspace/ui/composed/combobox"; import { EnhancedInput } from "@workspace/ui/composed/enhanced-input"; import TagInput from "@workspace/ui/composed/tag-input"; import { getGroupConfig, getNodeGroupList, } from "@workspace/ui/services/admin/group"; import type { TFunction } from "i18next"; import { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { z } from "zod"; import { useNode } from "@/stores/node"; import { useServer } from "@/stores/server"; export type ProtocolName = | "shadowsocks" | "vmess" | "vless" | "trojan" | "hysteria" | "tuic" | "anytls" | "naive" | "http" | "socks" | "mieru"; const buildSchema = (t: TFunction) => z.object({ name: z .string() .trim() .min(1, t("errors.nameRequired", "Please enter a name")), server_id: z .number({ message: t("errors.serverRequired", "Please select a server") }) .int() .positive(t("errors.serverRequired", "Please select a server")) .optional(), protocol: z .string() .min(1, t("errors.protocolRequired", "Please select a protocol")), address: z .string() .trim() .min(1, t("errors.serverAddrRequired", "Please enter an entry address")), port: z .number({ message: t("errors.portRange", "Port must be between 1 and 65535"), }) .int() .min(1, t("errors.portRange", "Port must be between 1 and 65535")) .max(65_535, t("errors.portRange", "Port must be between 1 and 65535")), tags: z.array(z.string()), node_group_ids: z.optional(z.array(z.string()).default([])), }); export type NodeFormValues = z.infer>; export default function NodeForm(props: { trigger: string; title: string; loading?: boolean; initialValues?: Partial; onSubmit: (values: NodeFormValues) => Promise | boolean; }) { const { trigger, title, loading, initialValues, onSubmit } = props; const { t } = useTranslation("nodes"); const Scheme = useMemo(() => buildSchema(t), [t]); const [open, setOpen] = useState(false); const [autoFilledFields, setAutoFilledFields] = useState>( new Set() ); const addAutoFilledField = (fieldName: string) => { setAutoFilledFields((prev) => new Set(prev).add(fieldName)); }; const removeAutoFilledField = (fieldName: string) => { setAutoFilledFields((prev) => { const newSet = new Set(prev); newSet.delete(fieldName); return newSet; }); }; const form = useForm({ resolver: zodResolver(Scheme), defaultValues: { name: "", server_id: undefined, protocol: "", address: "", port: 0, tags: [], node_group_ids: [], ...initialValues, }, mode: "onSubmit", // Only validate on form submission }); const serverId = form.watch("server_id"); const { servers, getAvailableProtocols } = useServer(); const { tags } = useNode(); const existingTags: string[] = tags || []; const availableProtocols = getAvailableProtocols(serverId); // Fetch node groups const { data: nodeGroupsData } = useQuery({ queryKey: ["nodeGroups"], queryFn: async () => { const { data } = await getNodeGroupList({ page: 1, size: 1000 }); return data.data?.list || []; }, }); // Fetch group config to check if group feature is enabled const { data: groupConfigData } = useQuery({ queryKey: ["groupConfig"], queryFn: async () => { const { data } = await getGroupConfig(); return data.data; }, }); const isGroupEnabled = groupConfigData?.enabled; useEffect(() => { if (initialValues) { const resetValues: NodeFormValues = { name: "", server_id: undefined, protocol: "", address: "", port: 0, tags: [], node_group_ids: [], }; // Copy only the values we need from initialValues if (initialValues.name) resetValues.name = initialValues.name; if (initialValues.server_id) resetValues.server_id = initialValues.server_id; if (initialValues.protocol) resetValues.protocol = initialValues.protocol; if (initialValues.address) resetValues.address = initialValues.address; if (initialValues.port) resetValues.port = initialValues.port; if (initialValues.tags) resetValues.tags = initialValues.tags; // Convert node_group_ids from number[] to string[], ensure it's always an array if ( initialValues.node_group_ids && Array.isArray(initialValues.node_group_ids) ) { resetValues.node_group_ids = initialValues.node_group_ids.map( (id: string | number) => String(id) ); } else { resetValues.node_group_ids = []; } form.reset(resetValues); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialValues]); function handleServerChange(nextId?: number | null) { const id = nextId ?? undefined; form.setValue("server_id", id); if (!id) { setAutoFilledFields(new Set()); return; } const selectedServer = servers.find((s) => s.id === id); if (!selectedServer) return; const currentValues = form.getValues(); const fieldsToFill: string[] = []; if (!currentValues.name || autoFilledFields.has("name")) { form.setValue("name", selectedServer.name as string, { shouldDirty: false, }); fieldsToFill.push("name"); } if (!currentValues.address || autoFilledFields.has("address")) { form.setValue("address", selectedServer.address as string, { shouldDirty: false, }); fieldsToFill.push("address"); } const protocols = getAvailableProtocols(id); const firstProtocol = protocols[0]; if ( firstProtocol && (!currentValues.protocol || autoFilledFields.has("protocol")) ) { form.setValue("protocol", firstProtocol.protocol, { shouldDirty: false }); fieldsToFill.push("protocol"); if ( !currentValues.port || currentValues.port === 0 || autoFilledFields.has("port") ) { const port = firstProtocol.port || 0; form.setValue("port", port, { shouldDirty: false }); fieldsToFill.push("port"); } } setAutoFilledFields(new Set(fieldsToFill)); } const handleManualFieldChange = ( fieldName: keyof NodeFormValues, value: any ) => { form.setValue(fieldName, value); removeAutoFilledField(fieldName); }; function handleProtocolChange(nextProto?: ProtocolName | null) { const protocol = (nextProto || "") as ProtocolName | ""; form.setValue("protocol", protocol); if (!(protocol && serverId)) { removeAutoFilledField("protocol"); return; } const currentValues = form.getValues(); const isPortAutoFilled = autoFilledFields.has("port"); removeAutoFilledField("protocol"); if (!currentValues.port || currentValues.port === 0 || isPortAutoFilled) { const protocolData = availableProtocols.find( (p) => p.protocol === protocol ); if (protocolData) { const port = protocolData.port || 0; form.setValue("port", port, { shouldDirty: false }); addAutoFilledField("port"); } } } async function handleSubmit(values: NodeFormValues) { const result = await onSubmit(values); if (result) { setOpen(false); setAutoFilledFields(new Set()); } } return ( {title}
( {t("server", "Server")} onChange={(v) => handleServerChange(v)} options={servers.map((s) => ({ value: s.id, label: `${s.name} (${(s.address as any) || ""})`, }))} placeholder={t("select_server", "Select server…")} value={field.value} /> )} /> ( {t("protocol", "Protocol")} onChange={(v) => handleProtocolChange((v as ProtocolName) || null) } options={availableProtocols.map((p) => ({ value: p.protocol, label: `${p.protocol}${p.port ? ` (${p.port})` : ""}`, }))} placeholder={t("select_protocol", "Select protocol…")} value={field.value} /> )} /> ( {t("name", "Name")} handleManualFieldChange("name", v as string) } /> )} /> ( {t("address", "Address")} handleManualFieldChange("address", v as string) } /> )} /> ( {t("port", "Port")} handleManualFieldChange("port", Number(v)) } placeholder="1-65535" type="number" /> )} /> {/* Tags field - always shown */} ( {t("tags", "Tags")} form.setValue(field.name, v)} options={existingTags} placeholder={t( "tags_placeholder", "Use Enter or comma (,) to add multiple tags" )} value={field.value || []} /> {isGroupEnabled ? t( "tags_groupMode_description", "Optional tags for display and filtering (node group will be used as tag if empty)." ) : t( "tags_description", "Permission grouping tag (incl. plan binding and delivery policies)." )} )} /> {/* Show Node Group field only when group feature is enabled */} {isGroupEnabled && ( ( {t("nodeGroup", "Node Group")}
{nodeGroupsData?.map((g) => (
{ // Ensure field.value is always an array const currentValue = Array.isArray( field.value ) ? field.value : []; if (checked) { const newValue = [ ...currentValue, String(g.id), ]; form.setValue(field.name, newValue, { shouldValidate: true, shouldDirty: true, }); } else { const newValue = currentValue.filter( (v: string) => v !== String(g.id) ); form.setValue(field.name, newValue, { shouldValidate: true, shouldDirty: true, }); } }} />
))}
{t( "nodeGroup_description", "Assign this node to multiple groups for user access control." )}
)} /> )}
); }