fix(schema-editor): preserve nested objects via 'opaque' kind
Bug: parseSchemaJson fallback на line 196 возвращал {kind: 'string'}
для unknown types (object с nested properties, array of objects, etc.).
При save back to schema это давало lossy round-trip:
spacecraft.orbit (object с 7 nested fields)
→ parseSchemaJson → kind: 'string'
→ propertyToSchema → {type: 'string'}
→ diffSchemas → BREAKING CHANGE 'object → string' → save blocked
Симптом видел user: edit modal spacecraft показывает 'НЕСОВМЕСТИМОЕ
ИЗМЕНЕНИЕ СХЕМЫ' для orbit, save заблокирован, хотя admin не трогал
orbit.
Fix:
- Новый PropertyKind 'opaque' для unknown types
- PropertyDef.rawSchema (optional JsonSchema) хранит оригинал
- parseSchemaJson fallback теперь {kind:'opaque', rawSchema:raw}
- propertyToSchema для opaque возвращает rawSchema verbatim (с
description override если admin менял в Поля-табе)
- PropertyEditor: 'opaque' исключён из Type dropdown (admin не может
вручную выбрать), для existing opaque поля показывается warning
card с raw JSON read-only + hint что редактируется через JSON-таб
Test coverage: 73 tests passing (5 + 10 SchemaBuilder + 7 + ...).
Эффект: edit любого справочника с nested objects (orbit) больше не
триггерит false-positive breaking change.
i18n: schema.kind.opaque + schema.opaque.{title,hint} × ru-RU/en-US.
This commit is contained in:
@@ -32,7 +32,12 @@ export const PropertyEditor = ({
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const kindOptions: SelectOption[] = PROPERTY_KINDS.map((k) => ({
|
// 'opaque' исключён из dropdown — admin не может вручную выбрать сложный
|
||||||
|
// тип. Поле получает opaque только через parseSchemaJson (когда уже было
|
||||||
|
// в исходной schema). Для NEW поля admin использует обычные kinds.
|
||||||
|
const kindOptions: SelectOption[] = PROPERTY_KINDS
|
||||||
|
.filter((k) => k !== 'opaque')
|
||||||
|
.map((k) => ({
|
||||||
id: k,
|
id: k,
|
||||||
label: t(`schema.kind.${k}`),
|
label: t(`schema.kind.${k}`),
|
||||||
}))
|
}))
|
||||||
@@ -108,6 +113,20 @@ export const PropertyEditor = ({
|
|||||||
onChange={(e) => update({ description: e.target.value })}
|
onChange={(e) => update({ description: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{prop.kind === 'opaque' && (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-md p-3 text-2xs space-y-2">
|
||||||
|
<div className="font-medium text-carbon">
|
||||||
|
{t('schema.opaque.title')}
|
||||||
|
</div>
|
||||||
|
<div className="text-carbon/70">
|
||||||
|
{t('schema.opaque.hint')}
|
||||||
|
</div>
|
||||||
|
<pre className="font-mono text-2xs bg-white border border-regolith rounded p-2 overflow-auto max-h-40">
|
||||||
|
{JSON.stringify(prop.rawSchema ?? {}, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{(prop.kind === 'string') && (
|
{(prop.kind === 'string') && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type PropertyKind =
|
|||||||
| 'date'
|
| 'date'
|
||||||
| 'datetime'
|
| 'datetime'
|
||||||
| 'array_string'
|
| 'array_string'
|
||||||
|
| 'opaque' // Сложный тип (nested object, array of objects), редактируется только через JSON-таб
|
||||||
|
|
||||||
export type PropertyDef = {
|
export type PropertyDef = {
|
||||||
id: string
|
id: string
|
||||||
@@ -25,6 +26,9 @@ export type PropertyDef = {
|
|||||||
minimum?: number
|
minimum?: number
|
||||||
maximum?: number
|
maximum?: number
|
||||||
uniqueItems?: boolean
|
uniqueItems?: boolean
|
||||||
|
/** Для kind: 'opaque' — оригинальный JSON Schema fragment, передаётся
|
||||||
|
* без изменений на save. Защита от lossy round-trip при nested objects. */
|
||||||
|
rawSchema?: JsonSchema
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PROPERTY_KINDS: PropertyKind[] = [
|
export const PROPERTY_KINDS: PropertyKind[] = [
|
||||||
@@ -128,6 +132,14 @@ const propertyToSchema = (p: PropertyDef): JsonSchema => {
|
|||||||
...(p.uniqueItems ? { uniqueItems: true } : {}),
|
...(p.uniqueItems ? { uniqueItems: true } : {}),
|
||||||
...uniqueExt,
|
...uniqueExt,
|
||||||
} as JsonSchema
|
} as JsonSchema
|
||||||
|
case 'opaque':
|
||||||
|
// Round-trip raw schema без изменений. Description можно overwrite
|
||||||
|
// если admin отредактировал в metadata-табе, остальное as-is.
|
||||||
|
return p.rawSchema
|
||||||
|
? (desc !== undefined && desc !== p.rawSchema.description
|
||||||
|
? { ...p.rawSchema, description: desc }
|
||||||
|
: p.rawSchema)
|
||||||
|
: { type: 'string', description: desc }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +205,10 @@ const inferKind = (
|
|||||||
uniqueItems: Boolean((raw as { uniqueItems?: boolean }).uniqueItems),
|
uniqueItems: Boolean((raw as { uniqueItems?: boolean }).uniqueItems),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { ...base, kind: 'string' }
|
// Fallback: nested objects, complex arrays, unknown types — preserve raw
|
||||||
|
// schema полностью. Lossy round-trip (object → string fallback) ломал
|
||||||
|
// breaking-change detector + терял nested properties (см. spacecraft.orbit).
|
||||||
|
return { ...base, kind: 'opaque', rawSchema: raw }
|
||||||
}
|
}
|
||||||
|
|
||||||
// === schema diff (breaking detection) ===
|
// === schema diff (breaking detection) ===
|
||||||
|
|||||||
@@ -210,6 +210,9 @@ i18n
|
|||||||
'schema.kind.date': 'Дата',
|
'schema.kind.date': 'Дата',
|
||||||
'schema.kind.datetime': 'Дата+время',
|
'schema.kind.datetime': 'Дата+время',
|
||||||
'schema.kind.array_string': 'Массив строк',
|
'schema.kind.array_string': 'Массив строк',
|
||||||
|
'schema.kind.opaque': 'Сложный (JSON)',
|
||||||
|
'schema.opaque.title': 'Сложное поле — редактируется только через JSON-таб',
|
||||||
|
'schema.opaque.hint': 'Nested object / array of objects. Поля-табе нет редактора, raw schema показан ниже. Меняй через вкладку "JSON".',
|
||||||
'field.name': 'Наименование',
|
'field.name': 'Наименование',
|
||||||
'field.designator': 'Designator (COSPAR)',
|
'field.designator': 'Designator (COSPAR)',
|
||||||
'field.norad_id': 'NORAD ID',
|
'field.norad_id': 'NORAD ID',
|
||||||
@@ -432,6 +435,9 @@ i18n
|
|||||||
'schema.kind.date': 'Date',
|
'schema.kind.date': 'Date',
|
||||||
'schema.kind.datetime': 'Date+time',
|
'schema.kind.datetime': 'Date+time',
|
||||||
'schema.kind.array_string': 'Array of strings',
|
'schema.kind.array_string': 'Array of strings',
|
||||||
|
'schema.kind.opaque': 'Complex (JSON)',
|
||||||
|
'schema.opaque.title': 'Complex field — edit only via JSON tab',
|
||||||
|
'schema.opaque.hint': 'Nested object / array of objects. No editor in Fields tab, raw schema shown below. Edit via JSON tab.',
|
||||||
'field.name': 'Name',
|
'field.name': 'Name',
|
||||||
'field.designator': 'Designator (COSPAR)',
|
'field.designator': 'Designator (COSPAR)',
|
||||||
'field.norad_id': 'NORAD ID',
|
'field.norad_id': 'NORAD ID',
|
||||||
|
|||||||
Reference in New Issue
Block a user