feat(form): x-references поля → SingleSelect с опциями из target словаря

Поля spacecraft.status, satellite_type_code и другие FK с x-references
теперь показывают dropdown с записями из целевого справочника вместо
свободного ввода. Label формируется как '<code> — <name.ru-RU>' если
target имеет localized name.

Fallback на TextInput когда target dict недоступен (scope-hide → 404)
или пуст — admin может ввести значение вручную, валидация серверная.

Кеш 5 минут (referenced data slow-moving) через TanStack Query.

UI tests +2 (75 total).
This commit is contained in:
Zimin A.N.
2026-05-06 19:29:01 +03:00
parent 5d5b03c421
commit c5d41da834
4 changed files with 287 additions and 2 deletions
@@ -3,7 +3,18 @@ import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithI18n } from '@/test/render'
import { SchemaDrivenForm } from './SchemaDrivenForm'
import type { JsonSchema } from '@/api/client'
import type { FlattenedRecord, JsonSchema } from '@/api/client'
// Stub useReferencedRecords so tests don't need a QueryClient. Each test
// can override via mockReturnValue.
const mockUseReferencedRecords = vi.fn(() => ({
data: undefined,
isLoading: false,
isError: false,
}))
vi.mock('@/api/queries', () => ({
useReferencedRecords: (name: string | undefined) => mockUseReferencedRecords(name),
}))
const minimalSchema: JsonSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
@@ -92,6 +103,109 @@ describe('SchemaDrivenForm', () => {
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('x-references поле рендерится как SingleSelect с подгруженными опциями', async () => {
const fkRecords: FlattenedRecord[] = [
{
id: '1',
businessKey: 'OPERATIONAL',
data: { code: 'OPERATIONAL', name: { 'ru-RU': 'Действующий' } },
dataScope: 'PUBLIC',
validFrom: '2020-01-01',
validTo: '9999-12-31',
},
{
id: '2',
businessKey: 'LOST',
data: { code: 'LOST', name: { 'ru-RU': 'Утерян' } },
dataScope: 'PUBLIC',
validFrom: '2020-01-01',
validTo: '9999-12-31',
},
]
mockUseReferencedRecords.mockReturnValue({
data: fkRecords,
isLoading: false,
isError: false,
})
const fkSchema: JsonSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
additionalProperties: false,
required: ['code', 'status'],
'x-id-source': 'code',
properties: {
code: { type: 'string', minLength: 1, 'x-unique': true },
status: {
type: 'string',
'x-references': 'spacecraft_status.code',
description: 'Статус КА',
},
},
} as JsonSchema
const user = userEvent.setup()
renderWithI18n(
<SchemaDrivenForm
schema={fkSchema}
supportedLocales={['ru-RU']}
defaultLocale="ru-RU"
mode="create"
isPending={false}
onSubmit={() => {}}
onCancel={() => {}}
/>,
)
expect(mockUseReferencedRecords).toHaveBeenCalledWith('spacecraft_status')
// Hint показывает FK target.
expect(screen.getByText(/Статус КА/)).toBeInTheDocument()
// Открываем SingleSelect → видим обе локализованные опции.
// SingleSelect рендерит trigger как button с placeholder "—".
const triggers = screen.getAllByRole('button')
const selectTrigger = triggers.find((b) => b.textContent === '—')
expect(selectTrigger).toBeDefined()
await user.click(selectTrigger!)
expect(await screen.findByText('OPERATIONAL — Действующий')).toBeInTheDocument()
expect(screen.getByText('LOST — Утерян')).toBeInTheDocument()
})
it('x-references fallback на TextInput когда target dict недоступен (scope-hide)', () => {
mockUseReferencedRecords.mockReturnValue({
data: [],
isLoading: false,
isError: false,
})
const fkSchema: JsonSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
additionalProperties: false,
required: ['code', 'status'],
'x-id-source': 'code',
properties: {
code: { type: 'string', minLength: 1, 'x-unique': true },
status: {
type: 'string',
'x-references': 'restricted_dict.code',
},
},
} as JsonSchema
renderWithI18n(
<SchemaDrivenForm
schema={fkSchema}
supportedLocales={['ru-RU']}
defaultLocale="ru-RU"
mode="create"
isPending={false}
onSubmit={() => {}}
onCancel={() => {}}
/>,
)
// Hint показывает FK target — admin понимает что валидация серверная.
expect(screen.getByText(/FK на restricted_dict\.code/)).toBeInTheDocument()
})
it('serverError рендерится как Alert', () => {
renderWithI18n(
<SchemaDrivenForm
@@ -20,7 +20,8 @@ import {
type SelectOption,
type TabItem,
} from '@nstart/ui'
import type { CreateRecordRequest, JsonSchema } from '@/api/client'
import type { CreateRecordRequest, FlattenedRecord, JsonSchema } from '@/api/client'
import { useReferencedRecords } from '@/api/queries'
import {
combineDateTime,
extractTime,
@@ -351,6 +352,20 @@ const FieldBody = ({
)
}
if (schema['x-references']) {
return (
<ReferenceSelectField
fieldKey={fieldKey}
schema={schema}
required={required}
control={control}
defaultLocale={defaultLocale}
label={label}
errorMsg={errorMsg}
/>
)
}
if (schema.enum) {
const options: SelectOption[] = schema.enum.map((v) => ({
id: String(v),
@@ -662,6 +677,127 @@ const findTabForField = (
return null
}
type ReferenceSelectFieldProps = {
fieldKey: string
schema: JsonSchema
required: boolean
control: ReturnType<typeof useForm<FormValues>>['control']
defaultLocale: string
label: string
errorMsg?: string
}
/**
* SingleSelect populated from a referenced dictionary's records.
* Schema must have `x-references: "dict.field"`. Renders TextInput fallback
* when the target dict is not accessible (scope-hide → 404/403 → empty list).
*
* Label format: `<businessKey> — <localized name>` if record has a name field,
* otherwise just `<businessKey>`. Defaults to user's locale, falls back to
* any available locale.
*/
const ReferenceSelectField = ({
fieldKey,
schema,
required,
control,
defaultLocale,
label,
errorMsg,
}: ReferenceSelectFieldProps) => {
const ref = schema['x-references'] ?? ''
const [refDict, refField] = ref.split('.', 2)
const { data: records, isLoading, isError } = useReferencedRecords(refDict)
const options: SelectOption[] = 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) => a.label.localeCompare(b.label, 'ru'))
}, [records, refField, defaultLocale])
const hint = schema.description ?? `FK на ${ref}`
// Fallback: target dict not accessible OR empty → free text input so user
// can still enter the FK value manually (server-side ReferenceValidator
// will validate on submit). Better than blocking edit entirely.
const accessible = !isError && (isLoading || (records && records.length > 0))
if (!accessible) {
return (
<Controller
control={control}
name={`data.${fieldKey}` as `data.${string}`}
render={({ field }) => (
<div>
<FieldLabel required={required}>{label}</FieldLabel>
<TextInput {...field} value={(field.value as string | undefined) ?? ''} />
<FieldHint>{hint}</FieldHint>
<FieldError>{errorMsg}</FieldError>
</div>
)}
/>
)
}
return (
<Controller
control={control}
name={`data.${fieldKey}` as `data.${string}`}
render={({ field }) => (
<SingleSelect
label={label}
required={required}
hint={hint}
error={errorMsg}
options={options}
value={(field.value as string | undefined) ?? ''}
onChange={field.onChange}
placeholder={isLoading ? '…' : '—'}
/>
)}
/>
)
}
/**
* Build a SelectOption from a record. Uses `record.data[refField]` as the
* option id (the FK value). Label combines id with a localized name field
* when available — `OPERATIONAL — Действующий` is much friendlier than
* just `OPERATIONAL`.
*/
const buildOption = (
record: FlattenedRecord,
refField: string | undefined,
defaultLocale: string,
): SelectOption | null => {
const id = refField ? record.data?.[refField] : record.businessKey
const idStr = id != null ? String(id) : record.businessKey
if (!idStr) return null
const nameField = record.data?.['name']
const display = pickLocalized(nameField, defaultLocale)
const label = display ? `${idStr}${display}` : idStr
return { id: idStr, label }
}
const pickLocalized = (
value: unknown,
defaultLocale: string,
): string | undefined => {
if (typeof value === 'string') return value
if (!value || typeof value !== 'object') return undefined
const map = value as Record<string, unknown>
const direct = map[defaultLocale]
if (typeof direct === 'string' && direct.length > 0) return direct
for (const key of Object.keys(map)) {
const v = map[key]
if (typeof v === 'string' && v.length > 0) return v
}
return undefined
}
const countErrorsPerTab = (
errors: ReturnType<typeof useForm<FormValues>>['formState']['errors'],
buckets: Record<TabId, string[]>,