feat(form): x-section schema metadata + dynamic section bucketing
This commit is contained in:
@@ -131,6 +131,17 @@ export type JsonSchema = {
|
|||||||
['x-references']?: string
|
['x-references']?: string
|
||||||
['x-id-source']?: string
|
['x-id-source']?: string
|
||||||
['x-unique']?: boolean
|
['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
|
default?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,23 +84,71 @@ const humanize = (key: string): string =>
|
|||||||
const isDateFormat = (s: JsonSchema): boolean =>
|
const isDateFormat = (s: JsonSchema): boolean =>
|
||||||
s.type === 'string' && (s.format === 'date' || s.format === 'date-time')
|
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 = (
|
const bucketProperties = (
|
||||||
properties: Record<string, JsonSchema>,
|
properties: Record<string, JsonSchema>,
|
||||||
required: Set<string>,
|
required: Set<string>,
|
||||||
): Record<TabId, string[]> => {
|
): { buckets: Record<SectionId, string[]>; order: SectionId[] } => {
|
||||||
const buckets: Record<TabId, string[]> = { identity: [], description: [], extra: [] }
|
const buckets: Record<SectionId, string[]> = {}
|
||||||
|
const customOrder: SectionId[] = []
|
||||||
for (const [key, schema] of Object.entries(properties)) {
|
for (const [key, schema] of Object.entries(properties)) {
|
||||||
if (isLocalized(schema)) {
|
const id = deriveSection(key, schema, required)
|
||||||
buckets.description.push(key)
|
if (!buckets[id]) {
|
||||||
} else if (required.has(key)) {
|
buckets[id] = []
|
||||||
buckets.identity.push(key)
|
if (!PREDEFINED_SECTION_ORDER.includes(id)) customOrder.push(id)
|
||||||
} else {
|
|
||||||
buckets.extra.push(key)
|
|
||||||
}
|
}
|
||||||
|
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 = ({
|
export const SchemaDrivenForm = ({
|
||||||
@@ -121,7 +169,7 @@ export const SchemaDrivenForm = ({
|
|||||||
onCounterChange,
|
onCounterChange,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('identity')
|
const [activeTab, setActiveTab] = useState<SectionId>('identity')
|
||||||
|
|
||||||
const validator = useMemo(() => {
|
const validator = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
@@ -133,7 +181,16 @@ 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 { 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>({
|
const { register, handleSubmit, control, setError, formState } = useForm<FormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -177,21 +234,25 @@ export const SchemaDrivenForm = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [searchFilter, onlyFilled, properties, dataValues, t])
|
}, [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.
|
// visible subset. Used для render + counters.
|
||||||
const filteredBuckets = useMemo(
|
const filteredBuckets = useMemo(() => {
|
||||||
() => ({
|
const out: Record<SectionId, string[]> = {}
|
||||||
identity: buckets.identity.filter(isFieldVisible),
|
for (const id of sectionOrder) {
|
||||||
description: buckets.description.filter(isFieldVisible),
|
out[id] = (buckets[id] ?? []).filter(isFieldVisible)
|
||||||
extra: buckets.extra.filter(isFieldVisible),
|
}
|
||||||
}),
|
return out
|
||||||
[buckets, isFieldVisible],
|
}, [buckets, sectionOrder, isFieldVisible])
|
||||||
)
|
|
||||||
|
|
||||||
// Counter visible/total для drawer toolbar caption "N / M".
|
// Counter visible/total для drawer toolbar caption "N / M".
|
||||||
const totalFieldCount = buckets.identity.length + buckets.description.length + buckets.extra.length
|
const totalFieldCount = sectionOrder.reduce(
|
||||||
const visibleFieldCount =
|
(acc, id) => acc + (buckets[id]?.length ?? 0),
|
||||||
filteredBuckets.identity.length + filteredBuckets.description.length + filteredBuckets.extra.length
|
0,
|
||||||
|
)
|
||||||
|
const visibleFieldCount = sectionOrder.reduce(
|
||||||
|
(acc, id) => acc + (filteredBuckets[id]?.length ?? 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
// Ref-pattern для callback: parent часто передаёт inline arrow
|
// Ref-pattern для callback: parent часто передаёт inline arrow
|
||||||
// (() => setState(...)) — identity меняется каждый render. Если включить
|
// (() => setState(...)) — identity меняется каждый render. Если включить
|
||||||
// callback в effect deps → effect fires → setState → re-render → loop
|
// callback в effect deps → effect fires → setState → re-render → loop
|
||||||
@@ -221,7 +282,7 @@ export const SchemaDrivenForm = ({
|
|||||||
applyAjvErrors(validator.errors, setError)
|
applyAjvErrors(validator.errors, setError)
|
||||||
const firstField = firstFieldFromErrors(validator.errors)
|
const firstField = firstFieldFromErrors(validator.errors)
|
||||||
if (firstField) {
|
if (firstField) {
|
||||||
const tabForField = findTabForField(firstField, buckets)
|
const tabForField = findSectionForField(firstField, buckets, sectionOrder)
|
||||||
if (tabForField) setActiveTab(tabForField)
|
if (tabForField) setActiveTab(tabForField)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -238,18 +299,27 @@ export const SchemaDrivenForm = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabErrorCount = countErrorsPerTab(formState.errors, buckets)
|
const tabErrorCount = countErrorsPerSection(formState.errors, buckets, sectionOrder)
|
||||||
const visibleTabs: TabItem[] = (['identity', 'description', 'extra'] as TabId[])
|
const visibleTabs: TabItem[] = sectionOrder
|
||||||
.filter((id) => id === 'identity' || buckets[id].length > 0)
|
.filter((id) => id === 'identity' || (buckets[id]?.length ?? 0) > 0)
|
||||||
.map((id) => ({
|
.map((id) => ({
|
||||||
id,
|
id,
|
||||||
label: t(`form.tabs.${id}`),
|
label: sectionLabel(id),
|
||||||
count:
|
count:
|
||||||
tabErrorCount[id] > 0 ? (
|
(tabErrorCount[id] ?? 0) > 0 ? (
|
||||||
<Badge variant="error">{tabErrorCount[id]}</Badge>
|
<Badge variant="error">{tabErrorCount[id]}</Badge>
|
||||||
) : undefined,
|
) : 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).
|
// Surface dirty-field count к parent (RecordDrawer footer caption).
|
||||||
// Используем formState.dirtyFields shallow keys + nested data keys.
|
// Используем formState.dirtyFields shallow keys + nested data keys.
|
||||||
const dirtyCount = useMemo(() => {
|
const dirtyCount = useMemo(() => {
|
||||||
@@ -274,12 +344,12 @@ export const SchemaDrivenForm = ({
|
|||||||
// Section visibility — в 'sections' layout все buckets рендерятся подряд,
|
// Section visibility — в 'sections' layout все buckets рендерятся подряд,
|
||||||
// в 'tabs' — только active. Sections layout per handoff Screen 3 prototype.
|
// в 'tabs' — только active. Sections layout per handoff Screen 3 prototype.
|
||||||
const showAllSections = layout === 'sections'
|
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
|
// Scroll-to-section для chip click + rail click. Использует form container
|
||||||
// (scrollable parent = drawer body) вместо window — section header sticky
|
// (scrollable parent = drawer body) вместо window — section header sticky
|
||||||
// считается от form container coordinate space.
|
// считается от form container coordinate space.
|
||||||
const scrollToSection = (id: TabId) => {
|
const scrollToSection = (id: SectionId) => {
|
||||||
document.getElementById(`form-section-${formId ?? 'default'}-${id}`)?.scrollIntoView({
|
document.getElementById(`form-section-${formId ?? 'default'}-${id}`)?.scrollIntoView({
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
block: 'start',
|
block: 'start',
|
||||||
@@ -312,12 +382,12 @@ export const SchemaDrivenForm = ({
|
|||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
type="button"
|
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"
|
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}
|
{tab.label}
|
||||||
<span className="text-mono text-mute">
|
<span className="text-mono text-mute">
|
||||||
{filteredBuckets[tab.id as TabId].length}
|
{filteredBuckets[tab.id]?.length ?? 0}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -333,12 +403,12 @@ export const SchemaDrivenForm = ({
|
|||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
type="button"
|
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"
|
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="truncate">{tab.label}</span>
|
||||||
<span className="text-mono text-cell text-mute shrink-0">
|
<span className="text-mono text-cell text-mute shrink-0">
|
||||||
{filteredBuckets[tab.id as TabId].length}
|
{filteredBuckets[tab.id]?.length ?? 0}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -356,130 +426,103 @@ export const SchemaDrivenForm = ({
|
|||||||
{leftRail}
|
{leftRail}
|
||||||
<div className="space-y-4 min-w-0">
|
<div className="space-y-4 min-w-0">
|
||||||
{showAllSections ? sectionsChipStrip : (
|
{showAllSections ? sectionsChipStrip : (
|
||||||
<Tabs items={visibleTabs} value={activeTab} onValueChange={(id) => setActiveTab(id as TabId)} />
|
<Tabs items={visibleTabs} value={activeTab} onValueChange={(id) => setActiveTab(id)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
{/* Dynamic section rendering — order приходит из bucketProperties.
|
||||||
id={`form-section-${formId ?? 'default'}-identity`}
|
* identity всегда первая (она содержит businessKey/validFrom/validTo
|
||||||
className={sectionVisible('identity') ? 'block scroll-mt-12' : 'hidden'}
|
* + identity-bucket schema fields). Остальные секции — generic
|
||||||
>
|
* grid рендер. Custom x-section ID'ы автоматически включаются
|
||||||
{showAllSections && (
|
* через sectionOrder. */}
|
||||||
<h3 className="text-cap text-mute mb-2 sticky top-10 bg-surface py-1 z-[5]">
|
{sectionOrder.map((id) => {
|
||||||
{t('form.tabs.identity')}
|
const isIdentity = id === 'identity'
|
||||||
</h3>
|
const fields = filteredBuckets[id] ?? []
|
||||||
)}
|
// Скрываем секцию (кроме identity) если в ней 0 visible fields после
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
// filter — иначе rail/chip уже это делает, но render лишних header'ов
|
||||||
{!idSource && (
|
// создаёт визуальный шум.
|
||||||
<div className="sm:col-span-2">
|
if (!isIdentity && fields.length === 0) {
|
||||||
<TextInput
|
// В tabs layout всё равно нужен anchor для switch, чтобы tab клик
|
||||||
label={t('form.businessKey')}
|
// не падал. Рендерим пустой div только когда секция активна, иначе
|
||||||
required
|
// 'hidden'. В sections layout — полностью skip.
|
||||||
disabled={mode === 'edit'}
|
if (showAllSections) return null
|
||||||
placeholder="MY_RECORD_KEY"
|
}
|
||||||
error={formState.errors.businessKey ? t('form.error.required') : undefined}
|
return (
|
||||||
{...register('businessKey', { required: !idSource, minLength: idSource ? 0 : 1 })}
|
<div
|
||||||
/>
|
key={id}
|
||||||
</div>
|
id={`form-section-${formId ?? 'default'}-${id}`}
|
||||||
)}
|
className={sectionVisible(id) ? 'block scroll-mt-12' : 'hidden'}
|
||||||
{idSource && (
|
>
|
||||||
<div className="sm:col-span-2 text-cell text-mute px-2">
|
{showAllSections && (isIdentity || fields.length > 0) && (
|
||||||
{t('form.idSourceNote', { field: idSource })}
|
<h3 className="text-cap text-mute mb-2 sticky top-10 bg-surface py-1 z-[5]">
|
||||||
</div>
|
{sectionLabel(id)}
|
||||||
)}
|
</h3>
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="validFrom"
|
|
||||||
render={({ field }) => (
|
|
||||||
<DateTimeField
|
|
||||||
label={t('form.validFrom')}
|
|
||||||
hint={t('form.validFromHint')}
|
|
||||||
value={field.value as string | undefined}
|
|
||||||
defaultTime="00:00"
|
|
||||||
onChange={field.onChange}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<Controller
|
{isIdentity && (
|
||||||
control={control}
|
<>
|
||||||
name="validTo"
|
{!idSource && (
|
||||||
render={({ field }) => (
|
<div className="sm:col-span-2">
|
||||||
<DateTimeField
|
<TextInput
|
||||||
label={t('form.validTo')}
|
label={t('form.businessKey')}
|
||||||
hint={t('form.validToHint')}
|
required
|
||||||
error={(formState.errors.validTo as { message?: string } | undefined)?.message}
|
disabled={mode === 'edit'}
|
||||||
value={field.value as string | undefined}
|
placeholder="MY_RECORD_KEY"
|
||||||
defaultTime="23:59"
|
error={formState.errors.businessKey ? t('form.error.required') : undefined}
|
||||||
onChange={field.onChange}
|
{...register('businessKey', { required: !idSource, minLength: idSource ? 0 : 1 })}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
/>
|
)}
|
||||||
{filteredBuckets.identity.map((key) => (
|
{idSource && (
|
||||||
<FieldRenderer
|
<div className="sm:col-span-2 text-cell text-mute px-2">
|
||||||
key={key}
|
{t('form.idSourceNote', { field: idSource })}
|
||||||
fieldKey={key}
|
</div>
|
||||||
schema={properties[key]}
|
)}
|
||||||
required={required.has(key)}
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
supportedLocales={supportedLocales}
|
name="validFrom"
|
||||||
defaultLocale={defaultLocale}
|
render={({ field }) => (
|
||||||
fieldError={formState.errors.data?.[key as keyof FormValues['data']]}
|
<DateTimeField
|
||||||
labelOf={labelOf}
|
label={t('form.validFrom')}
|
||||||
/>
|
hint={t('form.validFromHint')}
|
||||||
))}
|
value={field.value as string | undefined}
|
||||||
</div>
|
defaultTime="00:00"
|
||||||
</div>
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
<div
|
)}
|
||||||
id={`form-section-${formId ?? 'default'}-description`}
|
/>
|
||||||
className={sectionVisible('description') ? 'block scroll-mt-12' : 'hidden'}
|
<Controller
|
||||||
>
|
control={control}
|
||||||
{showAllSections && filteredBuckets.description.length > 0 && (
|
name="validTo"
|
||||||
<h3 className="text-cap text-mute mb-2 sticky top-10 bg-surface py-1 z-[5]">
|
render={({ field }) => (
|
||||||
{t('form.tabs.description')}
|
<DateTimeField
|
||||||
</h3>
|
label={t('form.validTo')}
|
||||||
)}
|
hint={t('form.validToHint')}
|
||||||
<div className="grid grid-cols-1 gap-3">
|
error={(formState.errors.validTo as { message?: string } | undefined)?.message}
|
||||||
{filteredBuckets.description.map((key) => (
|
value={field.value as string | undefined}
|
||||||
<FieldRenderer
|
defaultTime="23:59"
|
||||||
key={key}
|
onChange={field.onChange}
|
||||||
fieldKey={key}
|
/>
|
||||||
schema={properties[key]}
|
)}
|
||||||
required={required.has(key)}
|
/>
|
||||||
control={control}
|
</>
|
||||||
supportedLocales={supportedLocales}
|
)}
|
||||||
defaultLocale={defaultLocale}
|
{fields.map((key) => (
|
||||||
fieldError={formState.errors.data?.[key as keyof FormValues['data']]}
|
<FieldRenderer
|
||||||
labelOf={labelOf}
|
key={key}
|
||||||
/>
|
fieldKey={key}
|
||||||
))}
|
schema={properties[key]}
|
||||||
</div>
|
required={required.has(key)}
|
||||||
</div>
|
control={control}
|
||||||
|
supportedLocales={supportedLocales}
|
||||||
<div
|
defaultLocale={defaultLocale}
|
||||||
id={`form-section-${formId ?? 'default'}-extra`}
|
fieldError={formState.errors.data?.[key as keyof FormValues['data']]}
|
||||||
className={sectionVisible('extra') ? 'block scroll-mt-12' : 'hidden'}
|
labelOf={labelOf}
|
||||||
>
|
/>
|
||||||
{showAllSections && filteredBuckets.extra.length > 0 && (
|
))}
|
||||||
<h3 className="text-cap text-mute mb-2 sticky top-10 bg-surface py-1 z-[5]">
|
</div>
|
||||||
{t('form.tabs.extra')}
|
</div>
|
||||||
</h3>
|
)
|
||||||
)}
|
})}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
||||||
{filteredBuckets.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>}
|
||||||
|
|
||||||
@@ -922,12 +965,13 @@ const firstFieldFromErrors = (errors: ErrorObject[]): string | null => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const findTabForField = (
|
const findSectionForField = (
|
||||||
fieldKey: string,
|
fieldKey: string,
|
||||||
buckets: Record<TabId, string[]>,
|
buckets: Record<SectionId, string[]>,
|
||||||
): TabId | null => {
|
order: SectionId[],
|
||||||
for (const id of ['identity', 'description', 'extra'] as TabId[]) {
|
): SectionId | null => {
|
||||||
if (buckets[id].includes(fieldKey)) return id
|
for (const id of order) {
|
||||||
|
if (buckets[id]?.includes(fieldKey)) return id
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -1091,17 +1135,19 @@ const buildOption = (
|
|||||||
return { id: idStr, label: `${idStr} — ${truncated}` }
|
return { id: idStr, label: `${idStr} — ${truncated}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
const countErrorsPerTab = (
|
const countErrorsPerSection = (
|
||||||
errors: ReturnType<typeof useForm<FormValues>>['formState']['errors'],
|
errors: ReturnType<typeof useForm<FormValues>>['formState']['errors'],
|
||||||
buckets: Record<TabId, string[]>,
|
buckets: Record<SectionId, string[]>,
|
||||||
): Record<TabId, number> => {
|
order: SectionId[],
|
||||||
const counts: Record<TabId, number> = { identity: 0, description: 0, extra: 0 }
|
): Record<SectionId, number> => {
|
||||||
if (errors.businessKey) counts.identity += 1
|
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
|
const dataErrors = errors.data as Record<string, unknown> | undefined
|
||||||
if (!dataErrors) return counts
|
if (!dataErrors) return counts
|
||||||
for (const key of Object.keys(dataErrors)) {
|
for (const key of Object.keys(dataErrors)) {
|
||||||
const tab = findTabForField(key, buckets)
|
const section = findSectionForField(key, buckets, order)
|
||||||
if (tab) counts[tab] += 1
|
if (section) counts[section] = (counts[section] ?? 0) + 1
|
||||||
}
|
}
|
||||||
return counts
|
return counts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,6 +314,10 @@ i18n
|
|||||||
'form.tabs.identity': 'Идентификация',
|
'form.tabs.identity': 'Идентификация',
|
||||||
'form.tabs.description': 'Описание',
|
'form.tabs.description': 'Описание',
|
||||||
'form.tabs.extra': 'Дополнительно',
|
'form.tabs.extra': 'Дополнительно',
|
||||||
|
'form.tabs.references': 'Связи',
|
||||||
|
'form.tabs.dates': 'Даты',
|
||||||
|
'form.tabs.geometry': 'Геометрия',
|
||||||
|
'form.tabs.physical': 'Физические параметры',
|
||||||
'form.businessKey': 'Бизнес-ключ',
|
'form.businessKey': 'Бизнес-ключ',
|
||||||
'form.validFrom': 'Действует с',
|
'form.validFrom': 'Действует с',
|
||||||
'form.validFromHint': 'Когда запись становится активной. Пусто = с момента создания.',
|
'form.validFromHint': 'Когда запись становится активной. Пусто = с момента создания.',
|
||||||
@@ -826,6 +830,10 @@ i18n
|
|||||||
'form.tabs.identity': 'Identity',
|
'form.tabs.identity': 'Identity',
|
||||||
'form.tabs.description': 'Description',
|
'form.tabs.description': 'Description',
|
||||||
'form.tabs.extra': 'Additional',
|
'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.businessKey': 'Business key',
|
||||||
'form.validFrom': 'Valid from',
|
'form.validFrom': 'Valid from',
|
||||||
'form.validFromHint': 'When this record becomes active. Empty = from creation moment.',
|
'form.validFromHint': 'When this record becomes active. Empty = from creation moment.',
|
||||||
|
|||||||
Reference in New Issue
Block a user