diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx index fa9c41b..c27bbd2 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx @@ -184,6 +184,105 @@ describe('SchemaDrivenForm', () => { 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( + {}} + 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( + {}} + onCancel={() => {}} + />, + ) + // Hint показывает что список недоступен — admin может ввести вручную. + expect(screen.getByText(/comma-separated — список недоступен/)).toBeInTheDocument() + }) + it('x-references fallback на TextInput когда target dict недоступен (scope-hide)', () => { mockUseReferencedRecords.mockReturnValue({ data: [], diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx index 881b6f3..4fcc1d2 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx @@ -20,6 +20,8 @@ import { FieldHint, FieldLabel, FormActions, + MultiSelect, + type MultiSelectOption, SingleSelect, Tabs, TextArea, @@ -891,6 +893,23 @@ const FieldBody = ({ } 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 ( + + ) + } return ( >['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). + * + *

Cmdk-powered поиск (searchable=true) обязательно — references могут + * быть длинными (50+ КА на проде), без фильтра popover превращается в + * прокручиваемый список. + * + *

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 ( + ( +

+ {label} + + field.onChange( + e.target.value + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ) + } + /> + {`${hint} (comma-separated — список недоступен)`} + {errorMsg} +
+ )} + /> + ) + } + + return ( + ( + field.onChange(ids.length > 0 ? ids : undefined)} + disabled={isLoading} + /> + )} + /> + ) +} + /** * Build a SelectOption from a record. Uses `record.data[refField]` as the * option id (the FK value). Label combines id с локализованным name'ом — diff --git a/ordinis-cuod-bundle/src/main/resources/bundles/cuod/instrument.schema.json b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/instrument.schema.json index 85a02d4..f0f5857 100644 --- a/ordinis-cuod-bundle/src/main/resources/bundles/cuod/instrument.schema.json +++ b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/instrument.schema.json @@ -1,6 +1,6 @@ { "$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 (бортовой сенсор)", "description": "Конкретный сенсор/полезная нагрузка на борту КА. Один прибор может летать на нескольких бортах (many-to-many со spacecraft).", "type": "object", @@ -32,8 +32,8 @@ }, "spacecraft_codes": { "type": "array", - "description": "businessKey'и КА с этим прибором (many-to-many)", - "items": { "type": "string" }, + "description": "КА с этим прибором (many-to-many) — FK на spacecraft.designator", + "items": { "type": "string", "x-references": "spacecraft.designator" }, "uniqueItems": true }, "type": { @@ -59,14 +59,14 @@ }, "supported_format_codes": { "type": "array", - "description": "businessKey'и из data_format — какие форматы поддерживает выгрузка", - "items": { "type": "string" }, + "description": "Форматы выгрузки — FK на data_format.code", + "items": { "type": "string", "x-references": "data_format.code" }, "uniqueItems": true }, "supported_level_codes": { "type": "array", - "description": "businessKey'и из processing_level — какие уровни обработки доступны", - "items": { "type": "string" }, + "description": "Уровни обработки — FK на processing_level.code", + "items": { "type": "string", "x-references": "processing_level.code" }, "uniqueItems": true }, "status": { diff --git a/ordinis-cuod-bundle/src/main/resources/bundles/cuod/manifest.json b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/manifest.json index f6711bc..e418529 100644 --- a/ordinis-cuod-bundle/src/main/resources/bundles/cuod/manifest.json +++ b/ordinis-cuod-bundle/src/main/resources/bundles/cuod/manifest.json @@ -2,7 +2,7 @@ "bundle": "cuod", "displayName": "ЦУОД ДЗЗ", "description": "Bundle справочников для Центра Управления Обработкой Данных ДЗЗ. Anchor v1.", - "version": "1.3.2", + "version": "1.3.3", "supportedLocales": ["ru-RU", "en-US"], "defaultLocale": "ru-RU", "dictionaries": [ @@ -12,7 +12,7 @@ { "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": "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": "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" },