Merge branch 'feat/array-fk-multi-picker' into 'main'

feat(admin-ui,cuod): array-FK picker для instrument

See merge request 2-6/2-6-4/terravault/ordinis!257
This commit is contained in:
Александр Зимин
2026-05-25 10:39:00 +00:00
4 changed files with 231 additions and 9 deletions
@@ -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(
<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)', () => {
mockUseReferencedRecords.mockReturnValue({
data: [],
@@ -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 (
<ReferenceMultiSelectField
fieldKey={fieldKey}
itemSchema={schema.items}
arrayDescription={schema.description}
required={required}
control={control}
defaultLocale={defaultLocale}
label={label}
errorMsg={errorMsg}
/>
)
}
return (
<Controller
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
* option id (the FK value). Label combines id с локализованным name'ом —
@@ -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": {
@@ -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" },