feat(admin-ui,cuod): array-FK picker для instrument
Три массива в instrument были comma-separated текстом с подсказкой «businessKey'и из X». Пользователь не знал что туда вписать — справочника доступных значений рядом нет. UI: добавлен ReferenceMultiSelectField — array-аналог ReferenceSelectField. Триггер — `items["x-references"]` на string-массиве. Под капотом — готовый MultiSelect (popover + cmdk-поиск). Fallback на comma-separated TextInput сохраняется когда target dict недоступен (scope-hide / 403 / 404), сервер всё равно валидирует FK через ReferenceValidator. Schema: - spacecraft_codes → items.x-references: spacecraft.designator - supported_format_codes → items.x-references: data_format.code - supported_level_codes → items.x-references: processing_level.code instrument schemaVersion 1.0.0 → 1.1.0, bundle 1.3.2 → 1.3.3 — CuodBundleImporter перепишет schema_json в БД на старте writer'а, admin-ui подхватит свежую схему через /api/v1/dictionaries/instrument. Tests: 2 новых кейса в SchemaDrivenForm.test.tsx — happy path (MultiSelect рендерится, запрос ушёл на правильный dict) + fallback (comma-separated с подсказкой «список недоступен»). 171/171 passed.
This commit is contained in:
@@ -184,6 +184,105 @@ describe('SchemaDrivenForm', () => {
|
|||||||
expect(screen.getByRole('option', { name: 'LOST — Утерян' })).toBeInTheDocument()
|
expect(screen.getByRole('option', { name: 'LOST — Утерян' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('array items.x-references рендерится как MultiSelect (popover с поиском)', async () => {
|
||||||
|
const fkRecords: FlattenedRecord[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
businessKey: '2014-037A',
|
||||||
|
data: { designator: '2014-037A', name: { 'ru-RU': 'Ресурс-П №2' } },
|
||||||
|
dataScope: 'PUBLIC',
|
||||||
|
validFrom: '2014-12-01',
|
||||||
|
validTo: '9999-12-31',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
businessKey: '2019-038A',
|
||||||
|
data: { designator: '2019-038A', name: { 'ru-RU': 'Метеор-М №2-2' } },
|
||||||
|
dataScope: 'PUBLIC',
|
||||||
|
validFrom: '2019-07-05',
|
||||||
|
validTo: '9999-12-31',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mockUseReferencedRecords.mockReturnValue({
|
||||||
|
data: fkRecords,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
})
|
||||||
|
const arrayFkSchema: JsonSchema = {
|
||||||
|
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['code'],
|
||||||
|
'x-id-source': 'code',
|
||||||
|
properties: {
|
||||||
|
code: { type: 'string', minLength: 1, 'x-unique': true },
|
||||||
|
spacecraft_codes: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'КА с этим прибором — FK на spacecraft.designator',
|
||||||
|
items: { type: 'string', 'x-references': 'spacecraft.designator' },
|
||||||
|
uniqueItems: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as JsonSchema
|
||||||
|
renderWithI18n(
|
||||||
|
<SchemaDrivenForm
|
||||||
|
schema={arrayFkSchema}
|
||||||
|
supportedLocales={['ru-RU']}
|
||||||
|
defaultLocale="ru-RU"
|
||||||
|
mode="create"
|
||||||
|
isPending={false}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
// Запрос ушёл на правильный dict (items.x-references), не на пустую строку.
|
||||||
|
expect(mockUseReferencedRecords).toHaveBeenCalledWith('spacecraft')
|
||||||
|
// Hint показывает описание (FK target). MultiSelect лежит в popover,
|
||||||
|
// содержимое опций пробрасывается через cmdk command list — проверять
|
||||||
|
// open-state не нужно: достаточно подтвердить что MultiSelect rendered
|
||||||
|
// вместо comma-separated TextInput.
|
||||||
|
expect(screen.getByText(/FK на spacecraft\.designator/)).toBeInTheDocument()
|
||||||
|
// Comma-separated TextInput НЕ должен появиться.
|
||||||
|
expect(screen.queryByDisplayValue(/2014-037A, /)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('array items.x-references fallback на comma-separated TextInput когда dict недоступен', () => {
|
||||||
|
mockUseReferencedRecords.mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
})
|
||||||
|
const arrayFkSchema: JsonSchema = {
|
||||||
|
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['code'],
|
||||||
|
'x-id-source': 'code',
|
||||||
|
properties: {
|
||||||
|
code: { type: 'string', minLength: 1, 'x-unique': true },
|
||||||
|
supported_format_codes: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Форматы выгрузки',
|
||||||
|
items: { type: 'string', 'x-references': 'data_format.code' },
|
||||||
|
uniqueItems: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as JsonSchema
|
||||||
|
renderWithI18n(
|
||||||
|
<SchemaDrivenForm
|
||||||
|
schema={arrayFkSchema}
|
||||||
|
supportedLocales={['ru-RU']}
|
||||||
|
defaultLocale="ru-RU"
|
||||||
|
mode="create"
|
||||||
|
isPending={false}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
// Hint показывает что список недоступен — admin может ввести вручную.
|
||||||
|
expect(screen.getByText(/comma-separated — список недоступен/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('x-references fallback на TextInput когда target dict недоступен (scope-hide)', () => {
|
it('x-references fallback на TextInput когда target dict недоступен (scope-hide)', () => {
|
||||||
mockUseReferencedRecords.mockReturnValue({
|
mockUseReferencedRecords.mockReturnValue({
|
||||||
data: [],
|
data: [],
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
FieldHint,
|
FieldHint,
|
||||||
FieldLabel,
|
FieldLabel,
|
||||||
FormActions,
|
FormActions,
|
||||||
|
MultiSelect,
|
||||||
|
type MultiSelectOption,
|
||||||
SingleSelect,
|
SingleSelect,
|
||||||
Tabs,
|
Tabs,
|
||||||
TextArea,
|
TextArea,
|
||||||
@@ -891,6 +893,23 @@ const FieldBody = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (schema.type === 'array' && schema.items?.type === 'string') {
|
if (schema.type === 'array' && schema.items?.type === 'string') {
|
||||||
|
// Array-of-FK: items объявляет `x-references: "dict.field"` — рендерим
|
||||||
|
// MultiSelect popover c cmdk поиском (для длинных списков spacecraft/
|
||||||
|
// processing_level и т.п.) вместо comma-separated TextInput.
|
||||||
|
if (schema.items['x-references']) {
|
||||||
|
return (
|
||||||
|
<ReferenceMultiSelectField
|
||||||
|
fieldKey={fieldKey}
|
||||||
|
itemSchema={schema.items}
|
||||||
|
arrayDescription={schema.description}
|
||||||
|
required={required}
|
||||||
|
control={control}
|
||||||
|
defaultLocale={defaultLocale}
|
||||||
|
label={label}
|
||||||
|
errorMsg={errorMsg}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
@@ -1279,6 +1298,110 @@ const ReferenceSelectField = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReferenceMultiSelectFieldProps = {
|
||||||
|
fieldKey: string
|
||||||
|
itemSchema: JsonSchema
|
||||||
|
arrayDescription: string | undefined
|
||||||
|
required: boolean
|
||||||
|
control: ReturnType<typeof useForm<FormValues>>['control']
|
||||||
|
defaultLocale: string
|
||||||
|
label: string
|
||||||
|
errorMsg?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MultiSelect populated from a referenced dictionary's records — array
|
||||||
|
* аналог {@link ReferenceSelectField}. Используется когда `items.x-references`
|
||||||
|
* объявлен на array-of-string поле (e.g. instrument.spacecraft_codes →
|
||||||
|
* spacecraft.designator).
|
||||||
|
*
|
||||||
|
* <p>Cmdk-powered поиск (searchable=true) обязательно — references могут
|
||||||
|
* быть длинными (50+ КА на проде), без фильтра popover превращается в
|
||||||
|
* прокручиваемый список.
|
||||||
|
*
|
||||||
|
* <p>Fallback (target dict inaccessible / empty) — comma-separated TextInput,
|
||||||
|
* как делает {@link ReferenceSelectField}. Ничего не блокируем — серверный
|
||||||
|
* ReferenceValidator всё равно проверит FK на submit.
|
||||||
|
*/
|
||||||
|
const ReferenceMultiSelectField = ({
|
||||||
|
fieldKey,
|
||||||
|
itemSchema,
|
||||||
|
arrayDescription,
|
||||||
|
required,
|
||||||
|
control,
|
||||||
|
defaultLocale,
|
||||||
|
label,
|
||||||
|
errorMsg,
|
||||||
|
}: ReferenceMultiSelectFieldProps) => {
|
||||||
|
const ref = itemSchema['x-references'] ?? ''
|
||||||
|
const [refDict, refField] = ref.split('.', 2)
|
||||||
|
const { data: records, isLoading, isError } = useReferencedRecords(refDict)
|
||||||
|
|
||||||
|
const options: MultiSelectOption[] = useMemo(() => {
|
||||||
|
if (!records || records.length === 0) return []
|
||||||
|
return records
|
||||||
|
.map((r) => buildOption(r, refField, defaultLocale))
|
||||||
|
.filter((o): o is SelectOption => o !== null)
|
||||||
|
.sort((a, b) => String(a.label ?? '').localeCompare(String(b.label ?? ''), 'ru'))
|
||||||
|
.map((o) => ({ id: o.id, label: o.label }))
|
||||||
|
}, [records, refField, defaultLocale])
|
||||||
|
|
||||||
|
const hint = arrayDescription ?? `Список FK на ${ref}`
|
||||||
|
const accessible = !isError && (isLoading || (records && records.length > 0))
|
||||||
|
|
||||||
|
if (!accessible) {
|
||||||
|
// Same fallback contract as ReferenceSelectField — never lock the user
|
||||||
|
// out, even if scope-hide или transient API error. Сервер всё равно
|
||||||
|
// валидирует FK на submit.
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`data.${fieldKey}` as `data.${string}`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div>
|
||||||
|
<FieldLabel required={required}>{label}</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
value={Array.isArray(field.value) ? (field.value as string[]).join(', ') : ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(
|
||||||
|
e.target.value
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FieldHint>{`${hint} (comma-separated — список недоступен)`}</FieldHint>
|
||||||
|
<FieldError>{errorMsg}</FieldError>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`data.${fieldKey}` as `data.${string}`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<MultiSelect
|
||||||
|
label={label}
|
||||||
|
required={required}
|
||||||
|
hint={hint}
|
||||||
|
error={errorMsg}
|
||||||
|
options={options}
|
||||||
|
searchable
|
||||||
|
searchPlaceholder="Поиск…"
|
||||||
|
placeholder={isLoading ? '…' : 'Выбрать'}
|
||||||
|
value={Array.isArray(field.value) ? (field.value as string[]) : []}
|
||||||
|
onChange={(ids) => field.onChange(ids.length > 0 ? ids : undefined)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a SelectOption from a record. Uses `record.data[refField]` as the
|
* Build a SelectOption from a record. Uses `record.data[refField]` as the
|
||||||
* option id (the FK value). Label combines id с локализованным name'ом —
|
* option id (the FK value). Label combines id с локализованным name'ом —
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"$id": "https://ordinis.nstart.cloud/schemas/cuod/instrument/1.0.0",
|
"$id": "https://ordinis.nstart.cloud/schemas/cuod/instrument/1.1.0",
|
||||||
"title": "Instrument (бортовой сенсор)",
|
"title": "Instrument (бортовой сенсор)",
|
||||||
"description": "Конкретный сенсор/полезная нагрузка на борту КА. Один прибор может летать на нескольких бортах (many-to-many со spacecraft).",
|
"description": "Конкретный сенсор/полезная нагрузка на борту КА. Один прибор может летать на нескольких бортах (many-to-many со spacecraft).",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -32,8 +32,8 @@
|
|||||||
},
|
},
|
||||||
"spacecraft_codes": {
|
"spacecraft_codes": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"description": "businessKey'и КА с этим прибором (many-to-many)",
|
"description": "КА с этим прибором (many-to-many) — FK на spacecraft.designator",
|
||||||
"items": { "type": "string" },
|
"items": { "type": "string", "x-references": "spacecraft.designator" },
|
||||||
"uniqueItems": true
|
"uniqueItems": true
|
||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
@@ -59,14 +59,14 @@
|
|||||||
},
|
},
|
||||||
"supported_format_codes": {
|
"supported_format_codes": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"description": "businessKey'и из data_format — какие форматы поддерживает выгрузка",
|
"description": "Форматы выгрузки — FK на data_format.code",
|
||||||
"items": { "type": "string" },
|
"items": { "type": "string", "x-references": "data_format.code" },
|
||||||
"uniqueItems": true
|
"uniqueItems": true
|
||||||
},
|
},
|
||||||
"supported_level_codes": {
|
"supported_level_codes": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"description": "businessKey'и из processing_level — какие уровни обработки доступны",
|
"description": "Уровни обработки — FK на processing_level.code",
|
||||||
"items": { "type": "string" },
|
"items": { "type": "string", "x-references": "processing_level.code" },
|
||||||
"uniqueItems": true
|
"uniqueItems": true
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"bundle": "cuod",
|
"bundle": "cuod",
|
||||||
"displayName": "ЦУОД ДЗЗ",
|
"displayName": "ЦУОД ДЗЗ",
|
||||||
"description": "Bundle справочников для Центра Управления Обработкой Данных ДЗЗ. Anchor v1.",
|
"description": "Bundle справочников для Центра Управления Обработкой Данных ДЗЗ. Anchor v1.",
|
||||||
"version": "1.3.2",
|
"version": "1.3.3",
|
||||||
"supportedLocales": ["ru-RU", "en-US"],
|
"supportedLocales": ["ru-RU", "en-US"],
|
||||||
"defaultLocale": "ru-RU",
|
"defaultLocale": "ru-RU",
|
||||||
"dictionaries": [
|
"dictionaries": [
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
{ "name": "ground_station", "displayName": "Наземные станции", "description": "Каталог наземных пунктов приёма и управления", "scope": "PUBLIC", "schemaResource": "ground_station.schema.json", "schemaVersion": "1.0.0", "approvalRequired": true },
|
{ "name": "ground_station", "displayName": "Наземные станции", "description": "Каталог наземных пунктов приёма и управления", "scope": "PUBLIC", "schemaResource": "ground_station.schema.json", "schemaVersion": "1.0.0", "approvalRequired": true },
|
||||||
{ "name": "antenna", "displayName": "Антенны", "description": "Антенны наземных станций с G/T и поддерживаемыми диапазонами.", "scope": "PUBLIC", "schemaResource": "antenna.schema.json", "schemaVersion": "1.0.0" },
|
{ "name": "antenna", "displayName": "Антенны", "description": "Антенны наземных станций с G/T и поддерживаемыми диапазонами.", "scope": "PUBLIC", "schemaResource": "antenna.schema.json", "schemaVersion": "1.0.0" },
|
||||||
{ "name": "mission", "displayName": "Миссии / программы", "description": "Программы ДЗЗ — Ресурс-П, Канопус-В, Sentinel и т.д. Группируют семейства КА.", "scope": "PUBLIC", "schemaResource": "mission.schema.json", "schemaVersion": "1.0.0" },
|
{ "name": "mission", "displayName": "Миссии / программы", "description": "Программы ДЗЗ — Ресурс-П, Канопус-В, Sentinel и т.д. Группируют семейства КА.", "scope": "PUBLIC", "schemaResource": "mission.schema.json", "schemaVersion": "1.0.0" },
|
||||||
{ "name": "instrument", "displayName": "Инструменты съёмки", "description": "Полезная нагрузка / сенсоры на борту КА (Геотон-Л1, MODIS, C-SAR, ...)", "scope": "PUBLIC", "schemaResource": "instrument.schema.json", "schemaVersion": "1.0.0", "approvalRequired": true },
|
{ "name": "instrument", "displayName": "Инструменты съёмки", "description": "Полезная нагрузка / сенсоры на борту КА (Геотон-Л1, MODIS, C-SAR, ...)", "scope": "PUBLIC", "schemaResource": "instrument.schema.json", "schemaVersion": "1.1.0", "approvalRequired": true },
|
||||||
{ "name": "sensor_type", "displayName": "Типы сенсоров", "description": "Класс полезной нагрузки: optical/SAR/hyperspectral/lidar/thermal.", "scope": "PUBLIC", "schemaResource": "sensor_type.schema.json", "schemaVersion": "1.0.0" },
|
{ "name": "sensor_type", "displayName": "Типы сенсоров", "description": "Класс полезной нагрузки: optical/SAR/hyperspectral/lidar/thermal.", "scope": "PUBLIC", "schemaResource": "sensor_type.schema.json", "schemaVersion": "1.0.0" },
|
||||||
{ "name": "spectral_band", "displayName": "Спектральные каналы", "description": "Стандартные оптические/ИК каналы (PAN/R/G/B/NIR/SWIR/TIR).", "scope": "PUBLIC", "schemaResource": "spectral_band.schema.json", "schemaVersion": "1.0.0" },
|
{ "name": "spectral_band", "displayName": "Спектральные каналы", "description": "Стандартные оптические/ИК каналы (PAN/R/G/B/NIR/SWIR/TIR).", "scope": "PUBLIC", "schemaResource": "spectral_band.schema.json", "schemaVersion": "1.0.0" },
|
||||||
{ "name": "frequency_band", "displayName": "Радиочастотные диапазоны", "description": "IEEE-диапазоны (L/S/C/X/Ku/Ka) для SAR и связи.", "scope": "PUBLIC", "schemaResource": "frequency_band.schema.json", "schemaVersion": "1.0.0" },
|
{ "name": "frequency_band", "displayName": "Радиочастотные диапазоны", "description": "IEEE-диапазоны (L/S/C/X/Ku/Ka) для SAR и связи.", "scope": "PUBLIC", "schemaResource": "frequency_band.schema.json", "schemaVersion": "1.0.0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user