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:
@@ -57,6 +57,9 @@ export type JsonSchema = {
|
||||
items?: JsonSchema
|
||||
additionalProperties?: boolean | JsonSchema
|
||||
['x-localized']?: boolean
|
||||
['x-references']?: string
|
||||
['x-id-source']?: string
|
||||
['x-unique']?: boolean
|
||||
default?: unknown
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,38 @@ export const recordsQuery = (dictionaryName: string, scopeCsv: string) =>
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Fetch records from a target dictionary for use as FK reference options.
|
||||
* Used by SchemaDrivenForm when a property has `x-references: dict.field`.
|
||||
*
|
||||
* Returns 404 → empty array (scope-hide: target dict not accessible).
|
||||
* 5min staleTime — FK targets are typically slow-moving reference data.
|
||||
*/
|
||||
export const referencedRecordsQuery = (dictionaryName: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['fk-options', dictionaryName] as const,
|
||||
queryFn: async (): Promise<FlattenedRecord[]> => {
|
||||
try {
|
||||
const { data } = await apiClient.get<FlattenedRecord[]>(
|
||||
`/${dictionaryName}/records`,
|
||||
{ params: { as_scope: 'PUBLIC,INTERNAL,RESTRICTED' } },
|
||||
)
|
||||
return data
|
||||
} catch (e: unknown) {
|
||||
const status = (e as { response?: { status?: number } })?.response?.status
|
||||
if (status === 404 || status === 403) return []
|
||||
throw e
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
|
||||
export const useReferencedRecords = (dictionaryName: string | undefined) =>
|
||||
useQuery({
|
||||
...referencedRecordsQuery(dictionaryName ?? ''),
|
||||
enabled: Boolean(dictionaryName),
|
||||
})
|
||||
|
||||
export const recordHistoryQuery = (dictionaryName: string, businessKey: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['record-history', dictionaryName, businessKey] as const,
|
||||
|
||||
@@ -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[]>,
|
||||
|
||||
Reference in New Issue
Block a user