diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 1d38f38..7393078 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -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 } diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index 0203398..74d0dca 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -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 => { + try { + const { data } = await apiClient.get( + `/${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, diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx index 079cc9e..2168755 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.test.tsx @@ -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( + {}} + 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( + {}} + onCancel={() => {}} + />, + ) + // Hint показывает FK target — admin понимает что валидация серверная. + expect(screen.getByText(/FK на restricted_dict\.code/)).toBeInTheDocument() + }) + it('serverError рендерится как Alert', () => { renderWithI18n( + ) + } + 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>['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: `` if record has a name field, + * otherwise just ``. 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 ( + ( +
+ {label} + + {hint} + {errorMsg} +
+ )} + /> + ) + } + + return ( + ( + + )} + /> + ) +} + +/** + * 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 + 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>['formState']['errors'], buckets: Record,