From ff9e6b26b423fc18b7eac9829e3d3dc95d3cee2f Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 4 May 2026 02:50:12 +0300 Subject: [PATCH] feat(admin-ui): wizard tabs in record form (no internal scroll) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поля авто-распределяются по 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). --- .../src/components/form/SchemaDrivenForm.tsx | 147 +++++++++++++++--- ordinis-admin-ui/src/i18n.ts | 6 + 2 files changed, 134 insertions(+), 19 deletions(-) diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx index c302f82..d2d354f 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx @@ -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, + required: Set, +): Record => { + const buckets: Record = { 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('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({ 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 = (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 ? ( + {tabErrorCount[id]} + ) : undefined, + })) + return (
- + setActiveTab(id as TabId)} /> + +
-
- - - -
- {Object.entries(properties).map(([key, propSchema]) => ( + {buckets.identity.map((key) => ( s.title ?? t(`field.${k}`, { defaultValue: humanize(k) })} + labelOf={labelOf} /> ))}
-
+
+ +
+
+ {buckets.description.map((key) => ( + + ))} +
+
+ +
+
+ {buckets.extra.map((key) => ( + + ))} +
+
{serverError && {serverError}} @@ -203,7 +275,8 @@ const FieldBody = ({ return (
- {label} i18n + {label}{' '} + i18n
{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 | null => { + for (const id of ['identity', 'description', 'extra'] as TabId[]) { + if (buckets[id].includes(fieldKey)) return id + } + return null +} + +const countErrorsPerTab = ( + errors: ReturnType>['formState']['errors'], + buckets: Record, +): Record => { + const counts: Record = { identity: 0, description: 0, extra: 0 } + if (errors.businessKey) counts.identity += 1 + const dataErrors = errors.data as Record | undefined + if (!dataErrors) return counts + for (const key of Object.keys(dataErrors)) { + const tab = findTabForField(key, buckets) + if (tab) counts[tab] += 1 + } + return counts +} diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index af4fd94..1c7e5bf 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -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',