fix(admin-ui): DatePicker + nested object + raw record fetch для Edit
Несколько связанных багов в форме записей:
DatePicker:
- launch_date / decay_date (format=date) и validFrom/validTo рендерились как
raw text input → теперь @nstart/ui DatePicker с RU локалью.
- DatePickerProvider добавлен в main.tsx с DATE_PICKER_LOCALE_RU.
- Far-future infinity dates (year>=9999) скрываются (пустое поле).
- ISO date format на submit: yyyy-MM-dd.
Nested object → JsonField fallback:
- orbit (type=object с raan_deg/eccentricity/...) и antennas (array of object)
показывали [object Object] в text input. Добавлен JsonField — TextArea
с JSON.stringify/parse, локальный state для невалидного промежуточного
ввода + inline parse error.
Edit raw record fetch:
- Read-api flatten'ит i18n в строку выбранной локали → form для edit получал
data.name = 'Terra' вместо {ru-RU, en-US}. На Edit теперь fetch raw record
через writer GET /api/v1/dictionaries/{name}/records/{businessKey}.
- nginx regex location ~ ^/api/v1/dictionaries/ → writer (вместо узкого regex
только для /dictionaries/{name}). GET schema, GET raw record, mutations —
всё на writer. Read-api держит /api/v1/{name}/records/* (другой URL pattern).
- LoadingBlock пока тащим raw запись + Alert на ошибке.
This commit is contained in:
@@ -15,9 +15,10 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# /api/v1/dictionaries/{name} (без /records) — admin endpoint, отдаёт schema_json.
|
# /api/v1/dictionaries/* — все admin endpoints (schema, raw record, mutations) идут
|
||||||
# Только writer его реализует. Regex-location имеет приоритет над префиксом ниже.
|
# на writer. Read-api держит /api/v1/{name}/records/* (другой URL pattern, flatten i18n).
|
||||||
location ~ ^/api/v1/dictionaries/[^/]+$ {
|
# Regex-location имеет приоритет над префиксом ниже.
|
||||||
|
location ~ ^/api/v1/dictionaries/ {
|
||||||
set $backend "ordinis-app.${ORDINIS_NAMESPACE}.svc.${CLUSTER_DOMAIN}";
|
set $backend "ordinis-app.${ORDINIS_NAMESPACE}.svc.${CLUSTER_DOMAIN}";
|
||||||
proxy_pass http://$backend$request_uri;
|
proxy_pass http://$backend$request_uri;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type DictionaryDefinition,
|
type DictionaryDefinition,
|
||||||
type DictionaryDetail,
|
type DictionaryDetail,
|
||||||
type FlattenedRecord,
|
type FlattenedRecord,
|
||||||
|
type RecordResponse,
|
||||||
} from './client'
|
} from './client'
|
||||||
|
|
||||||
export const dictionariesQuery = queryOptions({
|
export const dictionariesQuery = queryOptions({
|
||||||
@@ -35,7 +36,26 @@ export const recordsQuery = (dictionaryName: string, scopeCsv: string) =>
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const recordRawQuery = (dictionaryName: string, businessKey: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['record-raw', dictionaryName, businessKey] as const,
|
||||||
|
queryFn: async (): Promise<RecordResponse> => {
|
||||||
|
const { data } = await apiClient.get<RecordResponse>(
|
||||||
|
`/dictionaries/${dictionaryName}/records/${encodeURIComponent(businessKey)}`,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const useDictionaries = () => useQuery(dictionariesQuery)
|
export const useDictionaries = () => useQuery(dictionariesQuery)
|
||||||
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
|
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
|
||||||
export const useRecords = (dictionaryName: string, scopeCsv: string) =>
|
export const useRecords = (dictionaryName: string, scopeCsv: string) =>
|
||||||
useQuery(recordsQuery(dictionaryName, scopeCsv))
|
useQuery(recordsQuery(dictionaryName, scopeCsv))
|
||||||
|
export const useRecordRaw = (
|
||||||
|
dictionaryName: string,
|
||||||
|
businessKey: string | undefined,
|
||||||
|
) =>
|
||||||
|
useQuery({
|
||||||
|
...recordRawQuery(dictionaryName, businessKey ?? ''),
|
||||||
|
enabled: Boolean(businessKey),
|
||||||
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
DatePicker,
|
||||||
FieldError,
|
FieldError,
|
||||||
FieldHint,
|
FieldHint,
|
||||||
FieldLabel,
|
FieldLabel,
|
||||||
@@ -48,6 +49,22 @@ const isLocalized = (s: JsonSchema): boolean => Boolean(s['x-localized'])
|
|||||||
const humanize = (key: string): string =>
|
const humanize = (key: string): string =>
|
||||||
key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase())
|
key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase())
|
||||||
|
|
||||||
|
const parseFormDate = (s: unknown): Date | null => {
|
||||||
|
if (typeof s !== 'string' || !s) return null
|
||||||
|
const d = new Date(s)
|
||||||
|
if (Number.isNaN(d.getTime())) return null
|
||||||
|
if (d.getFullYear() >= 9999) return null
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatIsoDate = (d: Date): string => {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDateFormat = (s: JsonSchema): boolean =>
|
||||||
|
s.type === 'string' && (s.format === 'date' || s.format === 'date-time')
|
||||||
|
|
||||||
type TabId = 'identity' | 'description' | 'extra'
|
type TabId = 'identity' | 'description' | 'extra'
|
||||||
|
|
||||||
const bucketProperties = (
|
const bucketProperties = (
|
||||||
@@ -154,20 +171,33 @@ export const SchemaDrivenForm = ({
|
|||||||
{...register('businessKey', { required: true, minLength: 1 })}
|
{...register('businessKey', { required: true, minLength: 1 })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<TextInput
|
<Controller
|
||||||
type="datetime-local"
|
control={control}
|
||||||
label={t('form.validFrom')}
|
name="validFrom"
|
||||||
{...register('validFrom')}
|
render={({ field }) => (
|
||||||
|
<DatePicker
|
||||||
|
label={t('form.validFrom')}
|
||||||
|
value={parseFormDate(field.value)}
|
||||||
|
onChange={(d) => field.onChange(d ? formatIsoDate(d) : '')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<Controller
|
||||||
type="datetime-local"
|
control={control}
|
||||||
label={t('form.validTo')}
|
name="validTo"
|
||||||
{...register('validTo')}
|
render={({ field }) => (
|
||||||
|
<DatePicker
|
||||||
|
label={t('form.validTo')}
|
||||||
|
value={parseFormDate(field.value)}
|
||||||
|
onChange={(d) => field.onChange(d ? formatIsoDate(d) : '')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{buckets.identity.map((key) => (
|
{buckets.identity.map((key) => (
|
||||||
<FieldRenderer
|
<FieldRenderer
|
||||||
key={key}
|
key={key}
|
||||||
fieldKey={key}
|
fieldKey={key}
|
||||||
|
path={`data.${key}`}
|
||||||
schema={properties[key]}
|
schema={properties[key]}
|
||||||
required={required.has(key)}
|
required={required.has(key)}
|
||||||
control={control}
|
control={control}
|
||||||
@@ -186,6 +216,7 @@ export const SchemaDrivenForm = ({
|
|||||||
<FieldRenderer
|
<FieldRenderer
|
||||||
key={key}
|
key={key}
|
||||||
fieldKey={key}
|
fieldKey={key}
|
||||||
|
path={`data.${key}`}
|
||||||
schema={properties[key]}
|
schema={properties[key]}
|
||||||
required={required.has(key)}
|
required={required.has(key)}
|
||||||
control={control}
|
control={control}
|
||||||
@@ -204,6 +235,7 @@ export const SchemaDrivenForm = ({
|
|||||||
<FieldRenderer
|
<FieldRenderer
|
||||||
key={key}
|
key={key}
|
||||||
fieldKey={key}
|
fieldKey={key}
|
||||||
|
path={`data.${key}`}
|
||||||
schema={properties[key]}
|
schema={properties[key]}
|
||||||
required={required.has(key)}
|
required={required.has(key)}
|
||||||
control={control}
|
control={control}
|
||||||
@@ -346,6 +378,25 @@ const FieldBody = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isDateFormat(schema)) {
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`data.${fieldKey}` as `data.${string}`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<DatePicker
|
||||||
|
label={label}
|
||||||
|
required={required}
|
||||||
|
hint={schema.description}
|
||||||
|
error={errorMsg}
|
||||||
|
value={parseFormDate(field.value)}
|
||||||
|
onChange={(d) => field.onChange(d ? formatIsoDate(d) : undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (schema.type === 'integer' || schema.type === 'number') {
|
if (schema.type === 'integer' || schema.type === 'number') {
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
@@ -369,6 +420,44 @@ const FieldBody = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (schema.type === 'object') {
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`data.${fieldKey}` as `data.${string}`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<JsonField
|
||||||
|
label={label}
|
||||||
|
required={required}
|
||||||
|
hint={schema.description}
|
||||||
|
error={errorMsg}
|
||||||
|
value={field.value as object | undefined}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.type === 'array' && schema.items?.type === 'object') {
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`data.${fieldKey}` as `data.${string}`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<JsonField
|
||||||
|
label={label}
|
||||||
|
required={required}
|
||||||
|
hint={schema.description ? `${schema.description} (JSON array)` : 'JSON array'}
|
||||||
|
error={errorMsg}
|
||||||
|
value={field.value as unknown[] | undefined}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (schema.type === 'array' && schema.items?.type === 'string') {
|
if (schema.type === 'array' && schema.items?.type === 'string') {
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
@@ -431,6 +520,48 @@ const FieldBody = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type JsonFieldProps = {
|
||||||
|
label: string
|
||||||
|
required: boolean
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
value: unknown
|
||||||
|
onChange: (v: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const JsonField = ({ label, required, hint, error, value, onChange }: JsonFieldProps) => {
|
||||||
|
const [text, setText] = useState<string>(() =>
|
||||||
|
value === undefined || value === null ? '' : JSON.stringify(value, null, 2),
|
||||||
|
)
|
||||||
|
const [parseError, setParseError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextArea
|
||||||
|
label={label}
|
||||||
|
required={required}
|
||||||
|
hint={hint ? `${hint} (JSON)` : 'JSON'}
|
||||||
|
error={parseError ?? error}
|
||||||
|
rows={6}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value
|
||||||
|
setText(next)
|
||||||
|
if (next.trim() === '') {
|
||||||
|
setParseError(null)
|
||||||
|
onChange(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
onChange(JSON.parse(next))
|
||||||
|
setParseError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setParseError(err instanceof Error ? err.message : 'Invalid JSON')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const applyAjvErrors = (
|
const applyAjvErrors = (
|
||||||
errors: ErrorObject[],
|
errors: ErrorObject[],
|
||||||
setError: ReturnType<typeof useForm<FormValues>>['setError'],
|
setError: ReturnType<typeof useForm<FormValues>>['setError'],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||||
import { NStartUiProvider } from '@nstart/ui'
|
import { NStartUiProvider, DatePickerProvider, DATE_PICKER_LOCALE_RU } from '@nstart/ui'
|
||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from './routeTree.gen'
|
||||||
import './i18n'
|
import './i18n'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
@@ -25,7 +25,9 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<NStartUiProvider>
|
<NStartUiProvider>
|
||||||
<RouterProvider router={router} />
|
<DatePickerProvider datePicker={DATE_PICKER_LOCALE_RU}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</DatePickerProvider>
|
||||||
</NStartUiProvider>
|
</NStartUiProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
} from '@nstart/ui'
|
} from '@nstart/ui'
|
||||||
import { PlusIcon, PencilSimpleIcon, XCircleIcon } from '@phosphor-icons/react'
|
import { PlusIcon, PencilSimpleIcon, XCircleIcon } from '@phosphor-icons/react'
|
||||||
import { useDictionaryDetail, useRecords } from '@/api/queries'
|
import { useDictionaryDetail, useRecordRaw, useRecords } from '@/api/queries'
|
||||||
import { useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
|
import { useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
|
||||||
import type { CreateRecordRequest, FlattenedRecord } from '@/api/client'
|
import type { CreateRecordRequest, FlattenedRecord } from '@/api/client'
|
||||||
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
||||||
@@ -48,6 +48,9 @@ function DictionaryDetail() {
|
|||||||
const [edit, setEdit] = useState<EditState>({ kind: 'closed' })
|
const [edit, setEdit] = useState<EditState>({ kind: 'closed' })
|
||||||
const [closeReason, setCloseReason] = useState('')
|
const [closeReason, setCloseReason] = useState('')
|
||||||
|
|
||||||
|
const editingKey = edit.kind === 'edit' ? edit.record.businessKey : undefined
|
||||||
|
const rawRecordQuery = useRecordRaw(name, editingKey)
|
||||||
|
|
||||||
const handleSubmit = (req: CreateRecordRequest) => {
|
const handleSubmit = (req: CreateRecordRequest) => {
|
||||||
if (edit.kind === 'create') {
|
if (edit.kind === 'create') {
|
||||||
createMut.mutate(req, { onSuccess: () => setEdit({ kind: 'closed' }) })
|
createMut.mutate(req, { onSuccess: () => setEdit({ kind: 'closed' }) })
|
||||||
@@ -188,24 +191,43 @@ function DictionaryDetail() {
|
|||||||
panelClassName="max-h-[calc(100vh-2rem)] my-4 flex flex-col"
|
panelClassName="max-h-[calc(100vh-2rem)] my-4 flex flex-col"
|
||||||
bodyClassName="overflow-y-auto flex-1"
|
bodyClassName="overflow-y-auto flex-1"
|
||||||
>
|
>
|
||||||
{detailQuery.data && (edit.kind === 'create' || edit.kind === 'edit') && (
|
{detailQuery.data && edit.kind === 'create' && (
|
||||||
<SchemaDrivenForm
|
<SchemaDrivenForm
|
||||||
schema={detailQuery.data.schemaJson}
|
schema={detailQuery.data.schemaJson}
|
||||||
supportedLocales={detailQuery.data.supportedLocales}
|
supportedLocales={detailQuery.data.supportedLocales}
|
||||||
defaultLocale={detailQuery.data.defaultLocale}
|
defaultLocale={detailQuery.data.defaultLocale}
|
||||||
mode={edit.kind}
|
mode="create"
|
||||||
isPending={Boolean(activeMutation?.isPending)}
|
isPending={Boolean(activeMutation?.isPending)}
|
||||||
serverError={serverError}
|
serverError={serverError}
|
||||||
defaultValues={
|
onSubmit={handleSubmit}
|
||||||
edit.kind === 'edit'
|
onCancel={() => setEdit({ kind: 'closed' })}
|
||||||
? {
|
/>
|
||||||
businessKey: edit.record.businessKey,
|
)}
|
||||||
data: edit.record.data,
|
|
||||||
validFrom: toDatetimeLocal(edit.record.validFrom),
|
{detailQuery.data && edit.kind === 'edit' && rawRecordQuery.isLoading && (
|
||||||
validTo: toDatetimeLocal(edit.record.validTo),
|
<LoadingBlock size="md" label={t('loading')} />
|
||||||
}
|
)}
|
||||||
: undefined
|
|
||||||
}
|
{detailQuery.data && edit.kind === 'edit' && rawRecordQuery.error && (
|
||||||
|
<Alert variant="error" title={t('error.failed')}>
|
||||||
|
{String(rawRecordQuery.error)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detailQuery.data && edit.kind === 'edit' && rawRecordQuery.data && (
|
||||||
|
<SchemaDrivenForm
|
||||||
|
schema={detailQuery.data.schemaJson}
|
||||||
|
supportedLocales={detailQuery.data.supportedLocales}
|
||||||
|
defaultLocale={detailQuery.data.defaultLocale}
|
||||||
|
mode="edit"
|
||||||
|
isPending={Boolean(activeMutation?.isPending)}
|
||||||
|
serverError={serverError}
|
||||||
|
defaultValues={{
|
||||||
|
businessKey: rawRecordQuery.data.businessKey,
|
||||||
|
data: rawRecordQuery.data.data,
|
||||||
|
validFrom: toIsoDate(rawRecordQuery.data.validFrom),
|
||||||
|
validTo: toIsoDate(rawRecordQuery.data.validTo),
|
||||||
|
}}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => setEdit({ kind: 'closed' })}
|
onCancel={() => setEdit({ kind: 'closed' })}
|
||||||
/>
|
/>
|
||||||
@@ -260,12 +282,13 @@ function DictionaryDetail() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toDatetimeLocal = (iso: string | undefined): string => {
|
const toIsoDate = (iso: string | undefined): string => {
|
||||||
if (!iso) return ''
|
if (!iso) return ''
|
||||||
const d = new Date(iso)
|
const d = new Date(iso)
|
||||||
if (isNaN(d.getTime())) return ''
|
if (isNaN(d.getTime())) return ''
|
||||||
|
if (d.getFullYear() >= 9999) return ''
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverErrorMessage = (err: unknown): string | null => {
|
const serverErrorMessage = (err: unknown): string | null => {
|
||||||
|
|||||||
Reference in New Issue
Block a user