From 1a72418cb862322ed4ff01ea0b24cf21a99b31c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Mon, 11 May 2026 21:52:24 +0000 Subject: [PATCH] feat(form): x-section schema metadata + dynamic section bucketing --- ordinis-admin-ui/src/api/client.ts | 11 + .../src/components/form/SchemaDrivenForm.tsx | 384 ++++++++++-------- ordinis-admin-ui/src/i18n.ts | 8 + 3 files changed, 234 insertions(+), 169 deletions(-) diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 153b809..5a3ecca 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -131,6 +131,17 @@ export type JsonSchema = { ['x-references']?: string ['x-id-source']?: string ['x-unique']?: boolean + /** + * Section bucket for form rendering. Если задан — поле попадает в эту + * секцию (можно использовать predefined ID: identity / references / + * description / dates / geometry / physical / extra; или любой custom + * string — он отобразится с humanized fallback меткой). + * + * Без `x-section` SchemaDrivenForm.deriveSection auto-derive'ит секцию + * по эвристикам (FK→references, localized→description, format=date→dates, + * geometry-like keys→geometry, required→identity, else→extra). + */ + ['x-section']?: string default?: unknown } diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx index 919d54e..8aafb72 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx @@ -84,23 +84,71 @@ const humanize = (key: string): string => const isDateFormat = (s: JsonSchema): boolean => s.type === 'string' && (s.format === 'date' || s.format === 'date-time') -type TabId = 'identity' | 'description' | 'extra' +// Section bucketing — schema fields группируются в логические секции для +// рендера формы. Schema property может декларировать `x-section: ` — +// тогда используется напрямую. Иначе срабатывает auto-derive по эвристикам: +// x-references → 'references' (FK на другой dict) +// x-localized → 'description' (мультиязычные имена/описания) +// date|date-time → 'dates' +// key looks like geo → 'geometry' (lon/lat/altitude/coord/geom*/wkt) +// required → 'identity' +// else → 'extra' +// +// Predefined order ниже определяет в каком порядке секции появляются в форме. +// Custom section ID'ы (не из этого списка) добавляются после в schema-declared +// порядке первого появления. +type SectionId = string + +const PREDEFINED_SECTION_ORDER: readonly SectionId[] = [ + 'identity', + 'references', + 'description', + 'dates', + 'geometry', + 'physical', + 'extra', +] + +const GEO_KEY_RE = /^(geo|geom|geometry|wkt|lon|lng|lat|latitude|longitude|altitude|coord)/i + +const deriveSection = ( + key: string, + schema: JsonSchema, + required: Set, +): SectionId => { + // Explicit wins — авторам schema'ы могут точно знать в какую секцию ставить. + const explicit = schema['x-section'] + if (explicit) return explicit + if (schema['x-references']) return 'references' + if (isLocalized(schema)) return 'description' + if (isDateFormat(schema)) return 'dates' + if (GEO_KEY_RE.test(key)) return 'geometry' + if (required.has(key)) return 'identity' + return 'extra' +} const bucketProperties = ( properties: Record, required: Set, -): Record => { - const buckets: Record = { identity: [], description: [], extra: [] } +): { buckets: Record; order: SectionId[] } => { + const buckets: Record = {} + const customOrder: SectionId[] = [] 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) + const id = deriveSection(key, schema, required) + if (!buckets[id]) { + buckets[id] = [] + if (!PREDEFINED_SECTION_ORDER.includes(id)) customOrder.push(id) } + buckets[id].push(key) } - return buckets + // identity всегда первая — даже если в schema нет required полей, секция + // нужна для businessKey + validFrom/validTo. + if (!buckets['identity']) buckets['identity'] = [] + const order: SectionId[] = [ + ...PREDEFINED_SECTION_ORDER.filter((id) => buckets[id]?.length || id === 'identity'), + ...customOrder, + ] + return { buckets, order } } export const SchemaDrivenForm = ({ @@ -121,7 +169,7 @@ export const SchemaDrivenForm = ({ onCounterChange, }: Props) => { const { t } = useTranslation() - const [activeTab, setActiveTab] = useState('identity') + const [activeTab, setActiveTab] = useState('identity') const validator = useMemo(() => { try { @@ -133,7 +181,16 @@ export const SchemaDrivenForm = ({ const properties = schema.properties ?? {} const required = new Set(schema.required ?? []) - const buckets = useMemo(() => bucketProperties(properties, required), [properties, required]) + const { buckets, order: sectionOrder } = useMemo( + () => bucketProperties(properties, required), + [properties, required], + ) + + // Resolve i18n label для секции. Predefined IDs имеют ключи form.tabs.{id}, + // custom — fallback на humanize(id). humanize конвертит snake/kebab в + // Title Case ("orbit_params" → "Orbit Params"). + const sectionLabel = (id: SectionId): string => + t(`form.tabs.${id}`, { defaultValue: humanize(id) }) const { register, handleSubmit, control, setError, formState } = useForm({ defaultValues: { @@ -177,21 +234,25 @@ export const SchemaDrivenForm = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchFilter, onlyFilled, properties, dataValues, t]) - // Apply filter ON TOP of bucketing — каждый bucket после filter содержит + // Apply filter ON TOP of bucketing — каждая секция после filter содержит // visible subset. Used для render + counters. - const filteredBuckets = useMemo( - () => ({ - identity: buckets.identity.filter(isFieldVisible), - description: buckets.description.filter(isFieldVisible), - extra: buckets.extra.filter(isFieldVisible), - }), - [buckets, isFieldVisible], - ) + const filteredBuckets = useMemo(() => { + const out: Record = {} + for (const id of sectionOrder) { + out[id] = (buckets[id] ?? []).filter(isFieldVisible) + } + return out + }, [buckets, sectionOrder, isFieldVisible]) // Counter visible/total для drawer toolbar caption "N / M". - const totalFieldCount = buckets.identity.length + buckets.description.length + buckets.extra.length - const visibleFieldCount = - filteredBuckets.identity.length + filteredBuckets.description.length + filteredBuckets.extra.length + const totalFieldCount = sectionOrder.reduce( + (acc, id) => acc + (buckets[id]?.length ?? 0), + 0, + ) + const visibleFieldCount = sectionOrder.reduce( + (acc, id) => acc + (filteredBuckets[id]?.length ?? 0), + 0, + ) // Ref-pattern для callback: parent часто передаёт inline arrow // (() => setState(...)) — identity меняется каждый render. Если включить // callback в effect deps → effect fires → setState → re-render → loop @@ -221,7 +282,7 @@ export const SchemaDrivenForm = ({ applyAjvErrors(validator.errors, setError) const firstField = firstFieldFromErrors(validator.errors) if (firstField) { - const tabForField = findTabForField(firstField, buckets) + const tabForField = findSectionForField(firstField, buckets, sectionOrder) if (tabForField) setActiveTab(tabForField) } return @@ -238,18 +299,27 @@ 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) + const tabErrorCount = countErrorsPerSection(formState.errors, buckets, sectionOrder) + const visibleTabs: TabItem[] = sectionOrder + .filter((id) => id === 'identity' || (buckets[id]?.length ?? 0) > 0) .map((id) => ({ id, - label: t(`form.tabs.${id}`), + label: sectionLabel(id), count: - tabErrorCount[id] > 0 ? ( + (tabErrorCount[id] ?? 0) > 0 ? ( {tabErrorCount[id]} ) : undefined, })) + // Guard: если schema'а сменилась и activeTab указывает на больше-не-существующую + // секцию (например, переход между dict'ами) — сбросим на первую видимую. + useEffect(() => { + if (!visibleTabs.some((t) => t.id === activeTab)) { + setActiveTab(visibleTabs[0]?.id ?? 'identity') + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sectionOrder]) + // Surface dirty-field count к parent (RecordDrawer footer caption). // Используем formState.dirtyFields shallow keys + nested data keys. const dirtyCount = useMemo(() => { @@ -274,12 +344,12 @@ export const SchemaDrivenForm = ({ // Section visibility — в 'sections' layout все buckets рендерятся подряд, // в 'tabs' — только active. Sections layout per handoff Screen 3 prototype. const showAllSections = layout === 'sections' - const sectionVisible = (id: TabId) => showAllSections || activeTab === id + const sectionVisible = (id: SectionId) => showAllSections || activeTab === id // Scroll-to-section для chip click + rail click. Использует form container // (scrollable parent = drawer body) вместо window — section header sticky // считается от form container coordinate space. - const scrollToSection = (id: TabId) => { + const scrollToSection = (id: SectionId) => { document.getElementById(`form-section-${formId ?? 'default'}-${id}`)?.scrollIntoView({ behavior: 'smooth', block: 'start', @@ -312,12 +382,12 @@ export const SchemaDrivenForm = ({ ))} @@ -333,12 +403,12 @@ export const SchemaDrivenForm = ({ ))} @@ -356,130 +426,103 @@ export const SchemaDrivenForm = ({ {leftRail}
{showAllSections ? sectionsChipStrip : ( - setActiveTab(id as TabId)} /> + setActiveTab(id)} /> )} -
- {showAllSections && ( -

- {t('form.tabs.identity')} -

- )} -
- {!idSource && ( -
- -
- )} - {idSource && ( -
- {t('form.idSourceNote', { field: idSource })} -
- )} - ( - + {/* Dynamic section rendering — order приходит из bucketProperties. + * identity всегда первая (она содержит businessKey/validFrom/validTo + * + identity-bucket schema fields). Остальные секции — generic + * grid рендер. Custom x-section ID'ы автоматически включаются + * через sectionOrder. */} + {sectionOrder.map((id) => { + const isIdentity = id === 'identity' + const fields = filteredBuckets[id] ?? [] + // Скрываем секцию (кроме identity) если в ней 0 visible fields после + // filter — иначе rail/chip уже это делает, но render лишних header'ов + // создаёт визуальный шум. + if (!isIdentity && fields.length === 0) { + // В tabs layout всё равно нужен anchor для switch, чтобы tab клик + // не падал. Рендерим пустой div только когда секция активна, иначе + // 'hidden'. В sections layout — полностью skip. + if (showAllSections) return null + } + return ( +
+ {showAllSections && (isIdentity || fields.length > 0) && ( +

+ {sectionLabel(id)} +

)} - /> - ( - - )} - /> - {filteredBuckets.identity.map((key) => ( - - ))} -
-
- -
- {showAllSections && filteredBuckets.description.length > 0 && ( -

- {t('form.tabs.description')} -

- )} -
- {filteredBuckets.description.map((key) => ( - - ))} -
-
- -
- {showAllSections && filteredBuckets.extra.length > 0 && ( -

- {t('form.tabs.extra')} -

- )} -
- {filteredBuckets.extra.map((key) => ( - - ))} -
-
+
+ {isIdentity && ( + <> + {!idSource && ( +
+ +
+ )} + {idSource && ( +
+ {t('form.idSourceNote', { field: idSource })} +
+ )} + ( + + )} + /> + ( + + )} + /> + + )} + {fields.map((key) => ( + + ))} +
+
+ ) + })} {serverError && {serverError}} @@ -922,12 +965,13 @@ const firstFieldFromErrors = (errors: ErrorObject[]): string | null => { return null } -const findTabForField = ( +const findSectionForField = ( fieldKey: string, - buckets: Record, -): TabId | null => { - for (const id of ['identity', 'description', 'extra'] as TabId[]) { - if (buckets[id].includes(fieldKey)) return id + buckets: Record, + order: SectionId[], +): SectionId | null => { + for (const id of order) { + if (buckets[id]?.includes(fieldKey)) return id } return null } @@ -1091,17 +1135,19 @@ const buildOption = ( return { id: idStr, label: `${idStr} — ${truncated}` } } -const countErrorsPerTab = ( +const countErrorsPerSection = ( errors: ReturnType>['formState']['errors'], - buckets: Record, -): Record => { - const counts: Record = { identity: 0, description: 0, extra: 0 } - if (errors.businessKey) counts.identity += 1 + buckets: Record, + order: SectionId[], +): Record => { + const counts: Record = {} + for (const id of order) counts[id] = 0 + if (errors.businessKey) counts['identity'] = (counts['identity'] ?? 0) + 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 + const section = findSectionForField(key, buckets, order) + if (section) counts[section] = (counts[section] ?? 0) + 1 } return counts } diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 0fa6458..1aa922a 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -314,6 +314,10 @@ i18n 'form.tabs.identity': 'Идентификация', 'form.tabs.description': 'Описание', 'form.tabs.extra': 'Дополнительно', + 'form.tabs.references': 'Связи', + 'form.tabs.dates': 'Даты', + 'form.tabs.geometry': 'Геометрия', + 'form.tabs.physical': 'Физические параметры', 'form.businessKey': 'Бизнес-ключ', 'form.validFrom': 'Действует с', 'form.validFromHint': 'Когда запись становится активной. Пусто = с момента создания.', @@ -826,6 +830,10 @@ i18n 'form.tabs.identity': 'Identity', 'form.tabs.description': 'Description', 'form.tabs.extra': 'Additional', + 'form.tabs.references': 'References', + 'form.tabs.dates': 'Dates', + 'form.tabs.geometry': 'Geometry', + 'form.tabs.physical': 'Physical parameters', 'form.businessKey': 'Business key', 'form.validFrom': 'Valid from', 'form.validFromHint': 'When this record becomes active. Empty = from creation moment.',