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:
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user