refactor(forms): перевести service sheets на FormSheet и RHF

SubdomainEditSheet на FormSheet+RHF+Zod; FormField hint через FieldDescription; service-edit на Field/Item примитивы.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-17 13:53:54 +07:00
co-authored by Cursor
parent 52edc3ba73
commit a4fd955c1b
3 changed files with 182 additions and 159 deletions
+7 -3
View File
@@ -3,6 +3,7 @@ import type { Control, FieldPath, FieldValues } from 'react-hook-form'
import { Controller } from 'react-hook-form'
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from '@cfdm/ui/components/field'
@@ -13,6 +14,7 @@ interface FormFieldProps<T extends FieldValues> {
control: Control<T>
label: string
htmlFor?: string
hint?: string
className?: string
children: (props: {
id: string
@@ -29,6 +31,7 @@ export function FormField<T extends FieldValues>({
control,
label,
htmlFor,
hint,
className,
children,
}: FormFieldProps<T>) {
@@ -52,6 +55,9 @@ export function FormField<T extends FieldValues>({
onBlur: field.onBlur,
ref: field.ref,
})}
{hint && !fieldState.error ? (
<FieldDescription>{hint}</FieldDescription>
) : null}
<FieldError errors={[fieldState.error]} />
</Field>
)}
@@ -80,9 +86,7 @@ export function FormFieldSimple({
<Field data-invalid={!!error} className={cn(className)}>
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
{children}
{hint && !error && (
<p className="text-xs text-muted-foreground">{hint}</p>
)}
{hint && !error ? <FieldDescription>{hint}</FieldDescription> : null}
<FieldError errors={[error]} />
</Field>
)
+59 -64
View File
@@ -19,19 +19,6 @@ import type {
UpdateServiceConfigInput,
} from '@/lib/schemas'
import { bindingToFqdn } from '@/lib/parse-fqdn'
import { AppButton } from '@/components/app-button'
import { AppInput } from '@/components/app-input'
import {
AppField,
AppFieldGroup,
AppFieldLabel,
} from '@/components/app-field'
import {
AppItem,
AppItemContent,
AppItemGroup,
} from '@/components/app-item'
import { AppSeparator } from '@/components/app-separator'
import {
Sheet,
SheetContent,
@@ -40,6 +27,14 @@ import {
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import { Button } from '@cfdm/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import {
Item,
ItemContent,
ItemGroup,
} from '@cfdm/ui/components/item'
import { LoadingButton } from '@/components/loading-button'
import { TabsContent } from '@cfdm/ui/components/tabs'
import {
@@ -405,27 +400,27 @@ export function ServiceEditSheet({
listClassName="mb-0 w-full"
>
<TabsContent value="general" className="flex flex-col gap-4">
<AppFieldGroup className="flex flex-col gap-4">
<AppField>
<AppFieldLabel htmlFor="edit-service-name">Название</AppFieldLabel>
<AppInput
<FieldGroup className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
<Input
id="edit-service-name"
value={name}
placeholder={isCreate ? 'VPN Panel' : undefined}
onChange={(e) => setName(e.target.value)}
/>
</AppField>
<AppField>
<AppFieldLabel htmlFor="edit-service-slug">Slug</AppFieldLabel>
<AppInput
</Field>
<Field>
<FieldLabel htmlFor="edit-service-slug">Slug</FieldLabel>
<Input
id="edit-service-slug"
value={slug}
placeholder={isCreate ? 'vpn-panel' : undefined}
onChange={(e) => setSlug(e.target.value)}
/>
</AppField>
<AppField>
<AppFieldLabel htmlFor="edit-service-group">Группа сервисов</AppFieldLabel>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
<Select
items={groupItems}
value={serviceGroupId}
@@ -442,9 +437,9 @@ export function ServiceEditSheet({
))}
</SelectContent>
</Select>
</AppField>
<AppField>
<AppFieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</AppFieldLabel>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
<TaggedInput
id="edit-service-ips"
value={ips}
@@ -452,8 +447,8 @@ export function ServiceEditSheet({
placeholder="192.168.1.1"
validate={isValidIpv4}
/>
</AppField>
</AppFieldGroup>
</Field>
</FieldGroup>
{groupHasDomain && (
<>
@@ -464,9 +459,9 @@ export function ServiceEditSheet({
сервиса для общего домена группы.
</p>
<div className="grid grid-cols-2 gap-4">
<AppField>
<AppFieldLabel htmlFor="service-lb-weight">Вес</AppFieldLabel>
<AppInput
<Field>
<FieldLabel htmlFor="service-lb-weight">Вес</FieldLabel>
<Input
id="service-lb-weight"
type="number"
inputMode="numeric"
@@ -477,10 +472,10 @@ export function ServiceEditSheet({
setLbWeight(Math.max(1, Number(e.target.value) || 1))
}
/>
</AppField>
<AppField>
<AppFieldLabel htmlFor="service-lb-priority">Приоритет</AppFieldLabel>
<AppInput
</Field>
<Field>
<FieldLabel htmlFor="service-lb-priority">Приоритет</FieldLabel>
<Input
id="service-lb-priority"
type="number"
inputMode="numeric"
@@ -491,7 +486,7 @@ export function ServiceEditSheet({
setLbPriority(Math.max(1, Number(e.target.value) || 1))
}
/>
</AppField>
</Field>
</div>
</div>
</>
@@ -503,10 +498,10 @@ export function ServiceEditSheet({
<p className="text-sm text-muted-foreground">
FQDN IP или CNAME для DNS-записей Cloudflare
</p>
<AppButton type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<PlusIcon data-icon="inline-start" />
Добавить
</AppButton>
</Button>
</div>
{bindings.length === 0 ? (
@@ -515,14 +510,14 @@ export function ServiceEditSheet({
title="Нет привязок"
description="Необязательно. Пример: newdom.ivx.su — зона ivx.su определится автоматически."
action={
<AppButton type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<PlusIcon data-icon="inline-start" />
Добавить привязку
</AppButton>
</Button>
}
/>
) : (
<AppItemGroup className="gap-2">
<ItemGroup className="gap-2">
{bindings.map((binding, index) => {
const showLbBlock =
(binding.record_type === 'A' && binding.target_ips.length > 0) ||
@@ -532,11 +527,11 @@ export function ServiceEditSheet({
binding.target_ips.length > 1 &&
binding.lb_mode !== 'round_robin'
return (
<AppItem key={`binding-${index}`} variant="outline" className="items-stretch">
<AppItemContent className="w-full flex flex-col gap-3">
<Item key={`binding-${index}`} variant="outline" className="items-stretch">
<ItemContent className="w-full flex flex-col gap-3">
<div className="flex items-end gap-2">
<AppField className="min-w-0 flex-1">
<AppFieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</AppFieldLabel>
<Field className="min-w-0 flex-1">
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
<TaggedInput
id={`binding-fqdn-${index}`}
value={binding.fqdn ? [binding.fqdn] : []}
@@ -546,8 +541,8 @@ export function ServiceEditSheet({
}
maxItems={1}
/>
</AppField>
<AppButton
</Field>
<Button
type="button"
variant="ghost"
size="icon-sm"
@@ -556,10 +551,10 @@ export function ServiceEditSheet({
onClick={() => handleRemoveBinding(index)}
>
<Trash2Icon />
</AppButton>
</Button>
</div>
<AppField>
<AppFieldLabel htmlFor={`binding-type-${index}`}>Тип записи</AppFieldLabel>
<Field>
<FieldLabel htmlFor={`binding-type-${index}`}>Тип записи</FieldLabel>
<Select
items={[
{ label: 'A (IP)', value: 'A' },
@@ -578,22 +573,22 @@ export function ServiceEditSheet({
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
</AppField>
</Field>
{binding.record_type === 'CNAME' ? (
<AppField>
<AppFieldLabel htmlFor={`binding-cname-${index}`}>
<Field>
<FieldLabel htmlFor={`binding-cname-${index}`}>
CNAME-цель
</AppFieldLabel>
<AppInput
</FieldLabel>
<Input
id={`binding-cname-${index}`}
value={binding.target_cname}
placeholder="mmsk.rkns.top"
onChange={(event) => handleCnameChange(index, event.target.value)}
/>
</AppField>
</Field>
) : (
<AppField>
<AppFieldLabel htmlFor={`binding-ip-${index}`}>IP</AppFieldLabel>
<Field>
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
<ServiceBindingIpInput
id={`binding-ip-${index}`}
value={binding.target_ips}
@@ -606,7 +601,7 @@ export function ServiceEditSheet({
handleBindingMetaChange(index, ip, meta)
}
/>
</AppField>
</Field>
)}
{showLbBlock ? (
@@ -629,17 +624,17 @@ export function ServiceEditSheet({
idPrefix={`binding-${index}-health`}
/>
) : null}
</AppItemContent>
</AppItem>
</ItemContent>
</Item>
)
})}
</AppItemGroup>
</ItemGroup>
)}
</TabsContent>
</CountedLineTabs>
</div>
<AppSeparator />
<Separator />
<SheetFooter className="flex flex-row flex-wrap gap-2 border-t-0 pt-4">
{!isCreate ? (
+116 -92
View File
@@ -1,12 +1,16 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import type { CertMonitoring } from '@cfdm/shared'
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
import { certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatServiceGroupLabel } from '@/lib/service-utils'
import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { AppInput } from '@/components/app-input'
import { AppFieldDescription, AppFieldGroup } from '@/components/app-field'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import {
Select,
SelectContent,
@@ -14,20 +18,14 @@ import {
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
export interface SubdomainEditValues {
name: string
serviceId: string
certMonitoring: CertMonitoring
}
const subdomainEditSchema = z.object({
name: z.string().min(1, 'Укажите имя'),
serviceId: z.string(),
certMonitoring: z.enum(['auto', 'required', 'skipped']),
})
export type SubdomainEditValues = z.infer<typeof subdomainEditSchema>
interface SubdomainEditSheetProps {
mode: 'create' | 'edit'
@@ -54,9 +52,14 @@ export function SubdomainEditSheet({
onOpenChange,
onSubmit,
}: SubdomainEditSheetProps) {
const [name, setName] = useState('')
const [serviceId, setServiceId] = useState('none')
const [certMonitoring, setCertMonitoring] = useState<CertMonitoring>('auto')
const form = useForm<SubdomainEditValues>({
resolver: zodResolver(subdomainEditSchema),
defaultValues: {
name: '',
serviceId: 'none',
certMonitoring: 'auto',
},
})
const serviceItems = useMemo(
() => [
@@ -84,55 +87,83 @@ export function SubdomainEditSheet({
useEffect(() => {
if (!open) return
if (mode === 'edit' && subdomain) {
setName(subdomain.name)
setServiceId(currentServiceId || 'none')
setCertMonitoring(subdomain.cert_monitoring)
form.reset({
name: subdomain.name,
serviceId: currentServiceId || 'none',
certMonitoring: subdomain.cert_monitoring,
})
return
}
setName('')
setServiceId('none')
setCertMonitoring('auto')
}, [open, mode, subdomain, currentServiceId])
form.reset({
name: '',
serviceId: 'none',
certMonitoring: 'auto',
})
}, [open, mode, subdomain, currentServiceId, form])
function handleSubmit(event: React.FormEvent) {
event.preventDefault()
const trimmed = name.trim()
if (!trimmed) return
onSubmit({ name: trimmed, serviceId, certMonitoring })
const certMonitoring = form.watch('certMonitoring')
const certHint =
certMonitoringOptions.find((o) => o.value === certMonitoring)?.description
function handleSubmit(values: SubdomainEditValues) {
onSubmit({
name: values.name.trim(),
serviceId: values.serviceId,
certMonitoring: values.certMonitoring as CertMonitoring,
})
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>
{mode === 'create' ? 'Создать поддомен' : 'Редактировать поддомен'}
</SheetTitle>
<SheetDescription>
{mode === 'create'
? `Имя записи в зоне ${zoneName} (например, www или api)`
: `Изменение поддомена в зоне ${zoneName}`}
</SheetDescription>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-4">
<AppFieldGroup>
<FormFieldSimple label="Имя" htmlFor="subdomain_name">
<AppInput
id="subdomain_name"
placeholder="www"
value={name}
onChange={(event) => setName(event.target.value)}
className="font-mono"
aria-invalid={!name.trim() && name.length > 0}
/>
</FormFieldSimple>
{mode === 'edit' && (
<>
<FormFieldSimple label="Сервис" htmlFor="subdomain_service">
<FormSheet<SubdomainEditValues>
open={open}
onOpenChange={onOpenChange}
title={
mode === 'create' ? 'Создать поддомен' : 'Редактировать поддомен'
}
description={
mode === 'create'
? `Имя записи в зоне ${zoneName} (например, www или api)`
: `Изменение поддомена в зоне ${zoneName}`
}
form={form}
onSubmit={handleSubmit}
footer={
<LoadingButton
type="submit"
className="w-full"
disabled={!form.watch('name')?.trim()}
isLoading={isSaving}
loadingLabel="Сохранение…"
>
{mode === 'create' ? 'Создать' : 'Сохранить'}
</LoadingButton>
}
>
<FieldGroup>
<FormFieldSimple
label="Имя"
htmlFor="subdomain_name"
error={form.formState.errors.name}
>
<Input
id="subdomain_name"
placeholder="www"
className="font-mono"
aria-invalid={!!form.formState.errors.name}
{...form.register('name')}
/>
</FormFieldSimple>
{mode === 'edit' ? (
<>
<FormFieldSimple label="Сервис" htmlFor="subdomain_service">
<Controller
control={form.control}
name="serviceId"
render={({ field }) => (
<Select
items={serviceItems}
value={serviceId}
onValueChange={(value) => setServiceId(value ?? 'none')}
value={field.value}
onValueChange={(value) => field.onChange(value ?? 'none')}
>
<SelectTrigger id="subdomain_service" className="w-full">
<SelectValue placeholder="Без сервиса" />
@@ -145,19 +176,29 @@ export function SubdomainEditSheet({
))}
</SelectContent>
</Select>
</FormFieldSimple>
<FormFieldSimple
label="Мониторинг SSL"
htmlFor="subdomain_cert_monitoring"
>
)}
/>
</FormFieldSimple>
<FormFieldSimple
label="Мониторинг SSL"
htmlFor="subdomain_cert_monitoring"
hint={certHint}
>
<Controller
control={form.control}
name="certMonitoring"
render={({ field }) => (
<Select
items={certMonitoringItems}
value={certMonitoring}
value={field.value}
onValueChange={(value) =>
setCertMonitoring((value ?? 'auto') as CertMonitoring)
field.onChange((value ?? 'auto') as CertMonitoring)
}
>
<SelectTrigger id="subdomain_cert_monitoring" className="w-full">
<SelectTrigger
id="subdomain_cert_monitoring"
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -168,29 +209,12 @@ export function SubdomainEditSheet({
))}
</SelectContent>
</Select>
<AppFieldDescription>
{
certMonitoringOptions.find((o) => o.value === certMonitoring)
?.description
}
</AppFieldDescription>
</FormFieldSimple>
</>
)}
</AppFieldGroup>
<SheetFooter>
<LoadingButton
type="submit"
className="w-full"
disabled={!name.trim()}
isLoading={isSaving}
loadingLabel="Сохранение…"
>
{mode === 'create' ? 'Создать' : 'Сохранить'}
</LoadingButton>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)}
/>
</FormFieldSimple>
</>
) : null}
</FieldGroup>
</FormSheet>
)
}