feat(form): x-section schema metadata + dynamic section bucketing

This commit is contained in:
Александр Зимин
2026-05-11 21:52:24 +00:00
parent 6d34632220
commit 1a72418cb8
3 changed files with 234 additions and 169 deletions
+11
View File
@@ -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
}
@@ -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: <id>` —
// тогда используется напрямую. Иначе срабатывает 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<string>,
): 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<string, JsonSchema>,
required: Set<string>,
): Record<TabId, string[]> => {
const buckets: Record<TabId, string[]> = { identity: [], description: [], extra: [] }
): { buckets: Record<SectionId, string[]>; order: SectionId[] } => {
const buckets: Record<SectionId, string[]> = {}
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<TabId>('identity')
const [activeTab, setActiveTab] = useState<SectionId>('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<FormValues>({
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<SectionId, string[]> = {}
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 ? (
<Badge variant="error">{tabErrorCount[id]}</Badge>
) : 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 = ({
<button
key={tab.id}
type="button"
onClick={() => scrollToSection(tab.id as TabId)}
onClick={() => scrollToSection(tab.id)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm bg-surface-2 border border-line hover:bg-accent-bg hover:border-accent text-cap text-ink-2 hover:text-accent transition-colors"
>
{tab.label}
<span className="text-mono text-mute">
{filteredBuckets[tab.id as TabId].length}
{filteredBuckets[tab.id]?.length ?? 0}
</span>
</button>
))}
@@ -333,12 +403,12 @@ export const SchemaDrivenForm = ({
<button
key={tab.id}
type="button"
onClick={() => scrollToSection(tab.id as TabId)}
onClick={() => scrollToSection(tab.id)}
className="flex items-center justify-between gap-2 px-2.5 py-1.5 rounded-sm hover:bg-surface-2 text-body text-ink-2 hover:text-ink text-left transition-colors"
>
<span className="truncate">{tab.label}</span>
<span className="text-mono text-cell text-mute shrink-0">
{filteredBuckets[tab.id as TabId].length}
{filteredBuckets[tab.id]?.length ?? 0}
</span>
</button>
))}
@@ -356,19 +426,40 @@ export const SchemaDrivenForm = ({
{leftRail}
<div className="space-y-4 min-w-0">
{showAllSections ? sectionsChipStrip : (
<Tabs items={visibleTabs} value={activeTab} onValueChange={(id) => setActiveTab(id as TabId)} />
<Tabs items={visibleTabs} value={activeTab} onValueChange={(id) => setActiveTab(id)} />
)}
{/* 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 (
<div
id={`form-section-${formId ?? 'default'}-identity`}
className={sectionVisible('identity') ? 'block scroll-mt-12' : 'hidden'}
key={id}
id={`form-section-${formId ?? 'default'}-${id}`}
className={sectionVisible(id) ? 'block scroll-mt-12' : 'hidden'}
>
{showAllSections && (
{showAllSections && (isIdentity || fields.length > 0) && (
<h3 className="text-cap text-mute mb-2 sticky top-10 bg-surface py-1 z-[5]">
{t('form.tabs.identity')}
{sectionLabel(id)}
</h3>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{isIdentity && (
<>
{!idSource && (
<div className="sm:col-span-2">
<TextInput
@@ -413,59 +504,9 @@ export const SchemaDrivenForm = ({
/>
)}
/>
{filteredBuckets.identity.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
id={`form-section-${formId ?? 'default'}-description`}
className={sectionVisible('description') ? 'block scroll-mt-12' : 'hidden'}
>
{showAllSections && filteredBuckets.description.length > 0 && (
<h3 className="text-cap text-mute mb-2 sticky top-10 bg-surface py-1 z-[5]">
{t('form.tabs.description')}
</h3>
</>
)}
<div className="grid grid-cols-1 gap-3">
{filteredBuckets.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
id={`form-section-${formId ?? 'default'}-extra`}
className={sectionVisible('extra') ? 'block scroll-mt-12' : 'hidden'}
>
{showAllSections && filteredBuckets.extra.length > 0 && (
<h3 className="text-cap text-mute mb-2 sticky top-10 bg-surface py-1 z-[5]">
{t('form.tabs.extra')}
</h3>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{filteredBuckets.extra.map((key) => (
{fields.map((key) => (
<FieldRenderer
key={key}
fieldKey={key}
@@ -480,6 +521,8 @@ export const SchemaDrivenForm = ({
))}
</div>
</div>
)
})}
{serverError && <Alert variant="error">{serverError}</Alert>}
@@ -922,12 +965,13 @@ const firstFieldFromErrors = (errors: ErrorObject[]): string | null => {
return null
}
const findTabForField = (
const findSectionForField = (
fieldKey: string,
buckets: Record<TabId, string[]>,
): TabId | null => {
for (const id of ['identity', 'description', 'extra'] as TabId[]) {
if (buckets[id].includes(fieldKey)) return id
buckets: Record<SectionId, string[]>,
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<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
buckets: Record<SectionId, string[]>,
order: SectionId[],
): Record<SectionId, number> => {
const counts: Record<SectionId, number> = {}
for (const id of order) counts[id] = 0
if (errors.businessKey) counts['identity'] = (counts['identity'] ?? 0) + 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
const section = findSectionForField(key, buckets, order)
if (section) counts[section] = (counts[section] ?? 0) + 1
}
return counts
}
+8
View File
@@ -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.',