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
|
items?: JsonSchema
|
||||||
additionalProperties?: boolean | JsonSchema
|
additionalProperties?: boolean | JsonSchema
|
||||||
['x-localized']?: boolean
|
['x-localized']?: boolean
|
||||||
|
['x-references']?: string
|
||||||
|
['x-id-source']?: string
|
||||||
|
['x-unique']?: boolean
|
||||||
default?: unknown
|
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) =>
|
export const recordHistoryQuery = (dictionaryName: string, businessKey: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['record-history', dictionaryName, businessKey] as const,
|
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 userEvent from '@testing-library/user-event'
|
||||||
import { renderWithI18n } from '@/test/render'
|
import { renderWithI18n } from '@/test/render'
|
||||||
import { SchemaDrivenForm } from './SchemaDrivenForm'
|
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 = {
|
const minimalSchema: JsonSchema = {
|
||||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||||
@@ -92,6 +103,109 @@ describe('SchemaDrivenForm', () => {
|
|||||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
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', () => {
|
it('serverError рендерится как Alert', () => {
|
||||||
renderWithI18n(
|
renderWithI18n(
|
||||||
<SchemaDrivenForm
|
<SchemaDrivenForm
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import {
|
|||||||
type SelectOption,
|
type SelectOption,
|
||||||
type TabItem,
|
type TabItem,
|
||||||
} from '@nstart/ui'
|
} from '@nstart/ui'
|
||||||
import type { CreateRecordRequest, JsonSchema } from '@/api/client'
|
import type { CreateRecordRequest, FlattenedRecord, JsonSchema } from '@/api/client'
|
||||||
|
import { useReferencedRecords } from '@/api/queries'
|
||||||
import {
|
import {
|
||||||
combineDateTime,
|
combineDateTime,
|
||||||
extractTime,
|
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) {
|
if (schema.enum) {
|
||||||
const options: SelectOption[] = schema.enum.map((v) => ({
|
const options: SelectOption[] = schema.enum.map((v) => ({
|
||||||
id: String(v),
|
id: String(v),
|
||||||
@@ -662,6 +677,127 @@ const findTabForField = (
|
|||||||
return null
|
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 = (
|
const countErrorsPerTab = (
|
||||||
errors: ReturnType<typeof useForm<FormValues>>['formState']['errors'],
|
errors: ReturnType<typeof useForm<FormValues>>['formState']['errors'],
|
||||||
buckets: Record<TabId, string[]>,
|
buckets: Record<TabId, string[]>,
|
||||||
|
|||||||
Reference in New Issue
Block a user