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:
@@ -8,6 +8,7 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
FieldError,
|
||||
FieldHint,
|
||||
FieldLabel,
|
||||
@@ -48,6 +49,22 @@ const isLocalized = (s: JsonSchema): boolean => Boolean(s['x-localized'])
|
||||
const humanize = (key: string): string =>
|
||||
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'
|
||||
|
||||
const bucketProperties = (
|
||||
@@ -154,20 +171,33 @@ export const SchemaDrivenForm = ({
|
||||
{...register('businessKey', { required: true, minLength: 1 })}
|
||||
/>
|
||||
</div>
|
||||
<TextInput
|
||||
type="datetime-local"
|
||||
label={t('form.validFrom')}
|
||||
{...register('validFrom')}
|
||||
<Controller
|
||||
control={control}
|
||||
name="validFrom"
|
||||
render={({ field }) => (
|
||||
<DatePicker
|
||||
label={t('form.validFrom')}
|
||||
value={parseFormDate(field.value)}
|
||||
onChange={(d) => field.onChange(d ? formatIsoDate(d) : '')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<TextInput
|
||||
type="datetime-local"
|
||||
label={t('form.validTo')}
|
||||
{...register('validTo')}
|
||||
<Controller
|
||||
control={control}
|
||||
name="validTo"
|
||||
render={({ field }) => (
|
||||
<DatePicker
|
||||
label={t('form.validTo')}
|
||||
value={parseFormDate(field.value)}
|
||||
onChange={(d) => field.onChange(d ? formatIsoDate(d) : '')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{buckets.identity.map((key) => (
|
||||
<FieldRenderer
|
||||
key={key}
|
||||
fieldKey={key}
|
||||
path={`data.${key}`}
|
||||
schema={properties[key]}
|
||||
required={required.has(key)}
|
||||
control={control}
|
||||
@@ -186,6 +216,7 @@ export const SchemaDrivenForm = ({
|
||||
<FieldRenderer
|
||||
key={key}
|
||||
fieldKey={key}
|
||||
path={`data.${key}`}
|
||||
schema={properties[key]}
|
||||
required={required.has(key)}
|
||||
control={control}
|
||||
@@ -204,6 +235,7 @@ export const SchemaDrivenForm = ({
|
||||
<FieldRenderer
|
||||
key={key}
|
||||
fieldKey={key}
|
||||
path={`data.${key}`}
|
||||
schema={properties[key]}
|
||||
required={required.has(key)}
|
||||
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') {
|
||||
return (
|
||||
<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') {
|
||||
return (
|
||||
<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 = (
|
||||
errors: ErrorObject[],
|
||||
setError: ReturnType<typeof useForm<FormValues>>['setError'],
|
||||
|
||||
Reference in New Issue
Block a user