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-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,130 +426,103 @@ 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)} />
|
||||
)}
|
||||
|
||||
<div
|
||||
id={`form-section-${formId ?? 'default'}-identity`}
|
||||
className={sectionVisible('identity') ? 'block scroll-mt-12' : 'hidden'}
|
||||
>
|
||||
{showAllSections && (
|
||||
<h3 className="text-cap text-mute mb-2 sticky top-10 bg-surface py-1 z-[5]">
|
||||
{t('form.tabs.identity')}
|
||||
</h3>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{!idSource && (
|
||||
<div className="sm:col-span-2">
|
||||
<TextInput
|
||||
label={t('form.businessKey')}
|
||||
required
|
||||
disabled={mode === 'edit'}
|
||||
placeholder="MY_RECORD_KEY"
|
||||
error={formState.errors.businessKey ? t('form.error.required') : undefined}
|
||||
{...register('businessKey', { required: !idSource, minLength: idSource ? 0 : 1 })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{idSource && (
|
||||
<div className="sm:col-span-2 text-cell text-mute px-2">
|
||||
{t('form.idSourceNote', { field: idSource })}
|
||||
</div>
|
||||
)}
|
||||
<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}
|
||||
/>
|
||||
{/* 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
|
||||
key={id}
|
||||
id={`form-section-${formId ?? 'default'}-${id}`}
|
||||
className={sectionVisible(id) ? 'block scroll-mt-12' : 'hidden'}
|
||||
>
|
||||
{showAllSections && (isIdentity || fields.length > 0) && (
|
||||
<h3 className="text-cap text-mute mb-2 sticky top-10 bg-surface py-1 z-[5]">
|
||||
{sectionLabel(id)}
|
||||
</h3>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="validTo"
|
||||
render={({ field }) => (
|
||||
<DateTimeField
|
||||
label={t('form.validTo')}
|
||||
hint={t('form.validToHint')}
|
||||
error={(formState.errors.validTo as { message?: string } | undefined)?.message}
|
||||
value={field.value as string | undefined}
|
||||
defaultTime="23:59"
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{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) => (
|
||||
<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="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{isIdentity && (
|
||||
<>
|
||||
{!idSource && (
|
||||
<div className="sm:col-span-2">
|
||||
<TextInput
|
||||
label={t('form.businessKey')}
|
||||
required
|
||||
disabled={mode === 'edit'}
|
||||
placeholder="MY_RECORD_KEY"
|
||||
error={formState.errors.businessKey ? t('form.error.required') : undefined}
|
||||
{...register('businessKey', { required: !idSource, minLength: idSource ? 0 : 1 })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{idSource && (
|
||||
<div className="sm:col-span-2 text-cell text-mute px-2">
|
||||
{t('form.idSourceNote', { field: idSource })}
|
||||
</div>
|
||||
)}
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="validTo"
|
||||
render={({ field }) => (
|
||||
<DateTimeField
|
||||
label={t('form.validTo')}
|
||||
hint={t('form.validToHint')}
|
||||
error={(formState.errors.validTo as { message?: string } | undefined)?.message}
|
||||
value={field.value as string | undefined}
|
||||
defaultTime="23:59"
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{fields.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>}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user