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:
@@ -1,22 +1,23 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useForm, Controller, type SubmitHandler } from 'react-hook-form'
|
import { useForm, Controller, type SubmitHandler } from 'react-hook-form'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Ajv, { type ErrorObject } from 'ajv'
|
import Ajv, { type ErrorObject } from 'ajv'
|
||||||
import addFormats from 'ajv-formats'
|
import addFormats from 'ajv-formats'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
FieldError,
|
FieldError,
|
||||||
FieldHint,
|
FieldHint,
|
||||||
FieldLabel,
|
FieldLabel,
|
||||||
FormActions,
|
FormActions,
|
||||||
FormGrid,
|
|
||||||
FormSection,
|
|
||||||
SingleSelect,
|
SingleSelect,
|
||||||
|
Tabs,
|
||||||
TextArea,
|
TextArea,
|
||||||
TextInput,
|
TextInput,
|
||||||
type SelectOption,
|
type SelectOption,
|
||||||
|
type TabItem,
|
||||||
} from '@nstart/ui'
|
} from '@nstart/ui'
|
||||||
import type { CreateRecordRequest, JsonSchema } from '@/api/client'
|
import type { CreateRecordRequest, JsonSchema } from '@/api/client'
|
||||||
|
|
||||||
@@ -45,9 +46,26 @@ addFormats(ajv)
|
|||||||
const isLocalized = (s: JsonSchema): boolean => Boolean(s['x-localized'])
|
const isLocalized = (s: JsonSchema): boolean => Boolean(s['x-localized'])
|
||||||
|
|
||||||
const humanize = (key: string): string =>
|
const humanize = (key: string): string =>
|
||||||
key
|
key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase())
|
||||||
.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 = ({
|
export const SchemaDrivenForm = ({
|
||||||
schema,
|
schema,
|
||||||
@@ -61,6 +79,7 @@ export const SchemaDrivenForm = ({
|
|||||||
onCancel,
|
onCancel,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>('identity')
|
||||||
|
|
||||||
const validator = useMemo(() => {
|
const validator = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
@@ -72,6 +91,7 @@ export const SchemaDrivenForm = ({
|
|||||||
|
|
||||||
const properties = schema.properties ?? {}
|
const properties = schema.properties ?? {}
|
||||||
const required = new Set(schema.required ?? [])
|
const required = new Set(schema.required ?? [])
|
||||||
|
const buckets = useMemo(() => bucketProperties(properties, required), [properties, required])
|
||||||
|
|
||||||
const { register, handleSubmit, control, setError, formState } = useForm<FormValues>({
|
const { register, handleSubmit, control, setError, formState } = useForm<FormValues>({
|
||||||
defaultValues: {
|
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) => {
|
const submit: SubmitHandler<FormValues> = (values) => {
|
||||||
if (validator) {
|
if (validator) {
|
||||||
const ok = validator(values.data)
|
const ok = validator(values.data)
|
||||||
if (!ok && validator.errors) {
|
if (!ok && validator.errors && validator.errors.length > 0) {
|
||||||
applyAjvErrors(validator.errors, setError)
|
applyAjvErrors(validator.errors, setError)
|
||||||
|
const firstField = firstFieldFromErrors(validator.errors)
|
||||||
|
if (firstField) {
|
||||||
|
const tabForField = findTabForField(firstField, buckets)
|
||||||
|
if (tabForField) setActiveTab(tabForField)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit({
|
onSubmit({
|
||||||
businessKey: values.businessKey.trim(),
|
businessKey: values.businessKey.trim(),
|
||||||
data: values.data,
|
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 (
|
return (
|
||||||
<form onSubmit={handleSubmit(submit)} className="space-y-4">
|
<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="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -123,26 +164,57 @@ export const SchemaDrivenForm = ({
|
|||||||
label={t('form.validTo')}
|
label={t('form.validTo')}
|
||||||
{...register('validTo')}
|
{...register('validTo')}
|
||||||
/>
|
/>
|
||||||
</div>
|
{buckets.identity.map((key) => (
|
||||||
</FormSection>
|
|
||||||
|
|
||||||
<FormSection title={t('form.dataFields')}>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
||||||
{Object.entries(properties).map(([key, propSchema]) => (
|
|
||||||
<FieldRenderer
|
<FieldRenderer
|
||||||
key={key}
|
key={key}
|
||||||
fieldKey={key}
|
fieldKey={key}
|
||||||
schema={propSchema}
|
schema={properties[key]}
|
||||||
required={required.has(key)}
|
required={required.has(key)}
|
||||||
control={control}
|
control={control}
|
||||||
supportedLocales={supportedLocales}
|
supportedLocales={supportedLocales}
|
||||||
defaultLocale={defaultLocale}
|
defaultLocale={defaultLocale}
|
||||||
fieldError={formState.errors.data?.[key as keyof FormValues['data']]}
|
fieldError={formState.errors.data?.[key as keyof FormValues['data']]}
|
||||||
labelOf={(k, s) => s.title ?? t(`field.${k}`, { defaultValue: humanize(k) })}
|
labelOf={labelOf}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>}
|
{serverError && <Alert variant="error">{serverError}</Alert>}
|
||||||
|
|
||||||
@@ -203,7 +275,8 @@ const FieldBody = ({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel required={required}>
|
<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>
|
</FieldLabel>
|
||||||
<div className="space-y-2 pl-3 border-l-2 border-regolith mt-1">
|
<div className="space-y-2 pl-3 border-l-2 border-regolith mt-1">
|
||||||
{supportedLocales.map((loc) => (
|
{supportedLocales.map((loc) => (
|
||||||
@@ -368,3 +441,39 @@ const applyAjvErrors = (
|
|||||||
setError(fieldPath as 'data', { type: 'ajv', message: err.message ?? 'invalid' })
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ i18n
|
|||||||
'dict.confirmClose.reason': 'Причина (опционально)',
|
'dict.confirmClose.reason': 'Причина (опционально)',
|
||||||
'dict.confirmClose.reasonPlaceholder': 'например, «дубликат» или «выведено из эксплуатации»',
|
'dict.confirmClose.reasonPlaceholder': 'например, «дубликат» или «выведено из эксплуатации»',
|
||||||
'form.identity': 'Идентификация',
|
'form.identity': 'Идентификация',
|
||||||
|
'form.tabs.identity': 'Идентификация',
|
||||||
|
'form.tabs.description': 'Описание',
|
||||||
|
'form.tabs.extra': 'Дополнительно',
|
||||||
'form.businessKey': 'Бизнес-ключ',
|
'form.businessKey': 'Бизнес-ключ',
|
||||||
'form.validFrom': 'Действует с',
|
'form.validFrom': 'Действует с',
|
||||||
'form.validTo': 'Действует до',
|
'form.validTo': 'Действует до',
|
||||||
@@ -84,6 +87,9 @@ i18n
|
|||||||
'dict.confirmClose.reason': 'Reason (optional)',
|
'dict.confirmClose.reason': 'Reason (optional)',
|
||||||
'dict.confirmClose.reasonPlaceholder': 'e.g., "duplicate" or "decommissioned"',
|
'dict.confirmClose.reasonPlaceholder': 'e.g., "duplicate" or "decommissioned"',
|
||||||
'form.identity': 'Identity',
|
'form.identity': 'Identity',
|
||||||
|
'form.tabs.identity': 'Identity',
|
||||||
|
'form.tabs.description': 'Description',
|
||||||
|
'form.tabs.extra': 'Additional',
|
||||||
'form.businessKey': 'Business key',
|
'form.businessKey': 'Business key',
|
||||||
'form.validFrom': 'Valid from',
|
'form.validFrom': 'Valid from',
|
||||||
'form.validTo': 'Valid to',
|
'form.validTo': 'Valid to',
|
||||||
|
|||||||
Reference in New Issue
Block a user