feat(admin-ui): wizard tabs in record form (no internal scroll)

Поля авто-распределяются по 3 табам:
- Идентификация: businessKey + validFrom/validTo + required scalar поля
- Описание: все x-localized поля (i18n)
- Дополнительно: optional scalar поля

UX:
- Все табы mounted (через hidden) — RHF state сохраняется при переключении.
- Бейдж с числом ошибок на каждом табе (variant=error).
- На submit: ajv валидация → если есть ошибки, форма автопереключает на таб
  первой ошибки + setError по всем затронутым полям.
- Таб «Описание» / «Дополнительно» скрывается если нет соответствующих полей.

Дроп FormSection — табы заменяют секционирование. Layout стал плоский
(Tabs + grid 1/2 cols + FormActions).
This commit is contained in:
Zimin A.N.
2026-05-04 02:50:12 +03:00
parent 43a06d1a7b
commit ff9e6b26b4
2 changed files with 134 additions and 19 deletions
@@ -1,22 +1,23 @@
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { useForm, Controller, type SubmitHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import Ajv, { type ErrorObject } from 'ajv'
import addFormats from 'ajv-formats'
import {
Alert,
Badge,
Button,
Checkbox,
FieldError,
FieldHint,
FieldLabel,
FormActions,
FormGrid,
FormSection,
SingleSelect,
Tabs,
TextArea,
TextInput,
type SelectOption,
type TabItem,
} from '@nstart/ui'
import type { CreateRecordRequest, JsonSchema } from '@/api/client'
@@ -45,9 +46,26 @@ addFormats(ajv)
const isLocalized = (s: JsonSchema): boolean => Boolean(s['x-localized'])
const humanize = (key: string): string =>
key
.replace(/[_-]+/g, ' ')
.replace(/\b\w/g, (ch) => ch.toUpperCase())
key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase())
type TabId = 'identity' | 'description' | 'extra'
const bucketProperties = (
properties: Record<string, JsonSchema>,
required: Set<string>,
): Record<TabId, string[]> => {
const buckets: Record<TabId, string[]> = { identity: [], description: [], extra: [] }
for (const [key, schema] of Object.entries(properties)) {
if (isLocalized(schema)) {
buckets.description.push(key)
} else if (required.has(key)) {
buckets.identity.push(key)
} else {
buckets.extra.push(key)
}
}
return buckets
}
export const SchemaDrivenForm = ({
schema,
@@ -61,6 +79,7 @@ export const SchemaDrivenForm = ({
onCancel,
}: Props) => {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<TabId>('identity')
const validator = useMemo(() => {
try {
@@ -72,6 +91,7 @@ export const SchemaDrivenForm = ({
const properties = schema.properties ?? {}
const required = new Set(schema.required ?? [])
const buckets = useMemo(() => bucketProperties(properties, required), [properties, required])
const { register, handleSubmit, control, setError, formState } = useForm<FormValues>({
defaultValues: {
@@ -82,15 +102,22 @@ export const SchemaDrivenForm = ({
},
})
const labelOf = (k: string, s: JsonSchema): string =>
s.title ?? t(`field.${k}`, { defaultValue: humanize(k) })
const submit: SubmitHandler<FormValues> = (values) => {
if (validator) {
const ok = validator(values.data)
if (!ok && validator.errors) {
if (!ok && validator.errors && validator.errors.length > 0) {
applyAjvErrors(validator.errors, setError)
const firstField = firstFieldFromErrors(validator.errors)
if (firstField) {
const tabForField = findTabForField(firstField, buckets)
if (tabForField) setActiveTab(tabForField)
}
return
}
}
onSubmit({
businessKey: values.businessKey.trim(),
data: values.data,
@@ -99,9 +126,23 @@ export const SchemaDrivenForm = ({
})
}
const tabErrorCount = countErrorsPerTab(formState.errors, buckets)
const visibleTabs: TabItem[] = (['identity', 'description', 'extra'] as TabId[])
.filter((id) => id === 'identity' || buckets[id].length > 0)
.map((id) => ({
id,
label: t(`form.tabs.${id}`),
count:
tabErrorCount[id] > 0 ? (
<Badge variant="error">{tabErrorCount[id]}</Badge>
) : undefined,
}))
return (
<form onSubmit={handleSubmit(submit)} className="space-y-4">
<FormSection title={t('form.identity')}>
<Tabs items={visibleTabs} value={activeTab} onValueChange={(id) => setActiveTab(id as TabId)} />
<div className={activeTab === 'identity' ? 'block' : 'hidden'}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="sm:col-span-2">
<TextInput
@@ -123,26 +164,57 @@ export const SchemaDrivenForm = ({
label={t('form.validTo')}
{...register('validTo')}
/>
</div>
</FormSection>
<FormSection title={t('form.dataFields')}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{Object.entries(properties).map(([key, propSchema]) => (
{buckets.identity.map((key) => (
<FieldRenderer
key={key}
fieldKey={key}
schema={propSchema}
schema={properties[key]}
required={required.has(key)}
control={control}
supportedLocales={supportedLocales}
defaultLocale={defaultLocale}
fieldError={formState.errors.data?.[key as keyof FormValues['data']]}
labelOf={(k, s) => s.title ?? t(`field.${k}`, { defaultValue: humanize(k) })}
labelOf={labelOf}
/>
))}
</div>
</FormSection>
</div>
<div className={activeTab === 'description' ? 'block' : 'hidden'}>
<div className="grid grid-cols-1 gap-3">
{buckets.description.map((key) => (
<FieldRenderer
key={key}
fieldKey={key}
schema={properties[key]}
required={required.has(key)}
control={control}
supportedLocales={supportedLocales}
defaultLocale={defaultLocale}
fieldError={formState.errors.data?.[key as keyof FormValues['data']]}
labelOf={labelOf}
/>
))}
</div>
</div>
<div className={activeTab === 'extra' ? 'block' : 'hidden'}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{buckets.extra.map((key) => (
<FieldRenderer
key={key}
fieldKey={key}
schema={properties[key]}
required={required.has(key)}
control={control}
supportedLocales={supportedLocales}
defaultLocale={defaultLocale}
fieldError={formState.errors.data?.[key as keyof FormValues['data']]}
labelOf={labelOf}
/>
))}
</div>
</div>
{serverError && <Alert variant="error">{serverError}</Alert>}
@@ -203,7 +275,8 @@ const FieldBody = ({
return (
<div>
<FieldLabel required={required}>
{label} <span className="text-2xs uppercase tracking-label text-carbon/60 ml-1">i18n</span>
{label}{' '}
<span className="text-2xs uppercase tracking-label text-carbon/60 ml-1">i18n</span>
</FieldLabel>
<div className="space-y-2 pl-3 border-l-2 border-regolith mt-1">
{supportedLocales.map((loc) => (
@@ -368,3 +441,39 @@ const applyAjvErrors = (
setError(fieldPath as 'data', { type: 'ajv', message: err.message ?? 'invalid' })
}
}
const firstFieldFromErrors = (errors: ErrorObject[]): string | null => {
for (const err of errors) {
const path = err.instancePath.replace(/^\//, '')
if (path) return path.split('/')[0]
if (err.params && typeof err.params === 'object' && 'missingProperty' in err.params) {
return String((err.params as { missingProperty: string }).missingProperty)
}
}
return null
}
const findTabForField = (
fieldKey: string,
buckets: Record<TabId, string[]>,
): TabId | null => {
for (const id of ['identity', 'description', 'extra'] as TabId[]) {
if (buckets[id].includes(fieldKey)) return id
}
return null
}
const countErrorsPerTab = (
errors: ReturnType<typeof useForm<FormValues>>['formState']['errors'],
buckets: Record<TabId, string[]>,
): Record<TabId, number> => {
const counts: Record<TabId, number> = { identity: 0, description: 0, extra: 0 }
if (errors.businessKey) counts.identity += 1
const dataErrors = errors.data as Record<string, unknown> | undefined
if (!dataErrors) return counts
for (const key of Object.keys(dataErrors)) {
const tab = findTabForField(key, buckets)
if (tab) counts[tab] += 1
}
return counts
}
+6
View File
@@ -32,6 +32,9 @@ i18n
'dict.confirmClose.reason': 'Причина (опционально)',
'dict.confirmClose.reasonPlaceholder': 'например, «дубликат» или «выведено из эксплуатации»',
'form.identity': 'Идентификация',
'form.tabs.identity': 'Идентификация',
'form.tabs.description': 'Описание',
'form.tabs.extra': 'Дополнительно',
'form.businessKey': 'Бизнес-ключ',
'form.validFrom': 'Действует с',
'form.validTo': 'Действует до',
@@ -84,6 +87,9 @@ i18n
'dict.confirmClose.reason': 'Reason (optional)',
'dict.confirmClose.reasonPlaceholder': 'e.g., "duplicate" or "decommissioned"',
'form.identity': 'Identity',
'form.tabs.identity': 'Identity',
'form.tabs.description': 'Description',
'form.tabs.extra': 'Additional',
'form.businessKey': 'Business key',
'form.validFrom': 'Valid from',
'form.validTo': 'Valid to',