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:
Zimin A.N.
2026-05-25 13:28:03 +03:00
parent 1ea402e7ec
commit 1cb0d868c8
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'ом —