feat(admin-ui): D-pack — history, breaking-change detection, x-unique, x-id-source, datetime
History:
- Drawer на детальной странице записи (timeline всех версий через GET /records/{key}/history)
- Иконка ClockCounterClockwise в actions колонке таблицы записей
- Каждая версия показывает v(version), validFrom/To, updatedBy + expand JSON
Schema diff (breaking detection):
- Util diffSchemas() сравнивает старую/новую схему и классифицирует изменения:
breaking (removed/typeChanged/becameRequired/enumRemoved/maxLengthTightened/
patternChanged/newRequiredField), compatible (added optional/enumAdded/
becameOptional), metadata (description)
- В DictionaryEditorDialog при breaking → Alert с детальным списком + Save заблокирован
- При compatible → Alert info со списком
- suggestVersionBump: major для breaking, minor для compatible field changes,
patch для metadata-only
x-unique:
- Чекбокс «Уникальное» на PropertyEditor → emit x-unique: true в schema
- Backend enforcement позже — отдельный PR с partial unique index
x-id-source:
- SingleSelect в Метаданных «Использовать как ID» → emit x-id-source на schema root
- В SchemaDrivenForm если установлен — businessKey input скрывается, на submit
derived из data[idSource]
- Подсказка с именем поля
DateTime для validFrom/validTo:
- Заменил DatePicker (date-only) на DateTimeField компонент: DatePicker + TextInput
type=time рядом
- Default time: validFrom=00:00, validTo=23:59 (запись действует до конца дня
иначе зануляется в полночь)
- Поддерживает 2-часовые интервалы (запуск КА в 14:30 → сход в 02:15 etc.)
Bug fix:
- parseSchemaJson изменил сигнатуру с PropertyDef[] на {properties, idSource},
старый код в DictionaryEditorDialog падал "{} is not iterable". Поправлено
через destructuring const parsed = parseSchemaJson(...).
i18n: 25+ новых ключей (RU/EN) — schema.unique, schema.idSource, schema.diff.*,
history.*, form.idSourceNote.
This commit is contained in:
@@ -62,12 +62,23 @@ const formatIsoDate = (d: Date): string => {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
const formatIsoDateTime = (d: Date): string => `${formatIsoDate(d)}T00:00:00Z`
|
||||
const formatIsoDateTime = (d: Date, time: string = '00:00'): string =>
|
||||
`${formatIsoDate(d)}T${time}:00Z`
|
||||
|
||||
const ensureIsoDateTime = (s: string | undefined): string | undefined => {
|
||||
if (!s) return undefined
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return `${s}T00:00:00Z`
|
||||
return s
|
||||
const extractTime = (s: string | undefined, fallback: string): string => {
|
||||
if (!s) return fallback
|
||||
const m = /T(\d{2}):(\d{2})/.exec(s)
|
||||
return m ? `${m[1]}:${m[2]}` : fallback
|
||||
}
|
||||
|
||||
const combineDateTime = (
|
||||
d: Date | null,
|
||||
time: string,
|
||||
current: string,
|
||||
): string => {
|
||||
if (!d) return current
|
||||
const safeTime = /^\d{2}:\d{2}$/.test(time) ? time : '00:00'
|
||||
return formatIsoDateTime(d, safeTime)
|
||||
}
|
||||
|
||||
const isDateFormat = (s: JsonSchema): boolean =>
|
||||
@@ -130,6 +141,8 @@ export const SchemaDrivenForm = ({
|
||||
const labelOf = (k: string, s: JsonSchema): string =>
|
||||
s.title ?? t(`field.${k}`, { defaultValue: humanize(k) })
|
||||
|
||||
const idSource = (schema as { 'x-id-source'?: string })['x-id-source']
|
||||
|
||||
const submit: SubmitHandler<FormValues> = (values) => {
|
||||
if (validator) {
|
||||
const ok = validator(values.data)
|
||||
@@ -143,11 +156,14 @@ export const SchemaDrivenForm = ({
|
||||
return
|
||||
}
|
||||
}
|
||||
const derivedKey = idSource && mode === 'create'
|
||||
? String(values.data?.[idSource] ?? '').trim()
|
||||
: values.businessKey.trim()
|
||||
onSubmit({
|
||||
businessKey: values.businessKey.trim(),
|
||||
businessKey: derivedKey,
|
||||
data: values.data,
|
||||
validFrom: ensureIsoDateTime(values.validFrom),
|
||||
validTo: ensureIsoDateTime(values.validTo),
|
||||
validFrom: values.validFrom || undefined,
|
||||
validTo: values.validTo || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,24 +185,32 @@ export const SchemaDrivenForm = ({
|
||||
|
||||
<div className={activeTab === 'identity' ? 'block' : 'hidden'}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="sm:col-span-2">
|
||||
<TextInput
|
||||
label={t('form.businessKey')}
|
||||
required
|
||||
disabled={mode === 'edit'}
|
||||
placeholder="MY_RECORD_KEY"
|
||||
error={formState.errors.businessKey ? t('form.error.required') : undefined}
|
||||
{...register('businessKey', { required: true, minLength: 1 })}
|
||||
/>
|
||||
</div>
|
||||
{!idSource && (
|
||||
<div className="sm:col-span-2">
|
||||
<TextInput
|
||||
label={t('form.businessKey')}
|
||||
required
|
||||
disabled={mode === 'edit'}
|
||||
placeholder="MY_RECORD_KEY"
|
||||
error={formState.errors.businessKey ? t('form.error.required') : undefined}
|
||||
{...register('businessKey', { required: !idSource, minLength: idSource ? 0 : 1 })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{idSource && (
|
||||
<div className="sm:col-span-2 text-2xs text-carbon/60 px-2">
|
||||
{t('form.idSourceNote', { field: idSource })}
|
||||
</div>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="validFrom"
|
||||
render={({ field }) => (
|
||||
<DatePicker
|
||||
<DateTimeField
|
||||
label={t('form.validFrom')}
|
||||
value={parseFormDate(field.value)}
|
||||
onChange={(d) => field.onChange(d ? formatIsoDate(d) : '')}
|
||||
value={field.value as string | undefined}
|
||||
defaultTime="00:00"
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -194,10 +218,11 @@ export const SchemaDrivenForm = ({
|
||||
control={control}
|
||||
name="validTo"
|
||||
render={({ field }) => (
|
||||
<DatePicker
|
||||
<DateTimeField
|
||||
label={t('form.validTo')}
|
||||
value={parseFormDate(field.value)}
|
||||
onChange={(d) => field.onChange(d ? formatIsoDate(d) : '')}
|
||||
value={field.value as string | undefined}
|
||||
defaultTime="23:59"
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -531,6 +556,35 @@ const FieldBody = ({
|
||||
)
|
||||
}
|
||||
|
||||
type DateTimeFieldProps = {
|
||||
label: string
|
||||
value: string | undefined
|
||||
defaultTime: string
|
||||
onChange: (iso: string) => void
|
||||
}
|
||||
|
||||
const DateTimeField = ({ label, value, defaultTime, onChange }: DateTimeFieldProps) => {
|
||||
const dateValue = parseFormDate(value)
|
||||
const timeValue = extractTime(value, defaultTime)
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2 items-end">
|
||||
<DatePicker
|
||||
label={label}
|
||||
value={dateValue}
|
||||
onChange={(d) => onChange(combineDateTime(d, timeValue, value ?? ''))}
|
||||
/>
|
||||
<div className="w-28">
|
||||
<TextInput
|
||||
label="HH:mm"
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={(e) => onChange(combineDateTime(dateValue, e.target.value, value ?? ''))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type JsonFieldProps = {
|
||||
label: string
|
||||
required: boolean
|
||||
|
||||
@@ -17,7 +17,15 @@ import {
|
||||
import { useCreateDictionary, useUpdateDictionary } from '@/api/mutations'
|
||||
import type { CreateDictionaryRequest, DataScope, DictionaryDetail } from '@/api/client'
|
||||
import { SchemaBuilder } from './SchemaBuilder'
|
||||
import { buildSchemaJson, parseSchemaJson, type PropertyDef } from './types'
|
||||
import {
|
||||
buildSchemaJson,
|
||||
diffSchemas,
|
||||
hasBreakingChanges,
|
||||
parseSchemaJson,
|
||||
suggestVersionBump,
|
||||
type PropertyDef,
|
||||
type SchemaChange,
|
||||
} from './types'
|
||||
|
||||
type Mode =
|
||||
| { kind: 'create' }
|
||||
@@ -48,6 +56,8 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
||||
const isEdit = mode.kind === 'edit'
|
||||
const initial = mode.kind === 'edit' ? mode.existing : null
|
||||
|
||||
const parsed = useMemo(() => parseSchemaJson(initial?.schemaJson), [initial])
|
||||
|
||||
const [activeTab, setActiveTab] = useState<EditorTab>('metadata')
|
||||
const [name, setName] = useState(initial?.name ?? '')
|
||||
const [displayName, setDisplayName] = useState(initial?.displayName ?? '')
|
||||
@@ -59,12 +69,24 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
||||
initial?.supportedLocales ?? ['ru-RU'],
|
||||
)
|
||||
const [defaultLocale, setDefaultLocale] = useState(initial?.defaultLocale ?? 'ru-RU')
|
||||
const [properties, setProperties] = useState<PropertyDef[]>(
|
||||
() => parseSchemaJson(initial?.schemaJson),
|
||||
)
|
||||
const [properties, setProperties] = useState<PropertyDef[]>(parsed.properties)
|
||||
const [idSource, setIdSource] = useState<string>(parsed.idSource ?? '')
|
||||
const [nameError, setNameError] = useState<string | null>(null)
|
||||
|
||||
const schemaJson = useMemo(() => buildSchemaJson(properties), [properties])
|
||||
const schemaJson = useMemo(
|
||||
() => buildSchemaJson(properties, idSource || undefined),
|
||||
[properties, idSource],
|
||||
)
|
||||
|
||||
const changes: SchemaChange[] = useMemo(
|
||||
() => (isEdit ? diffSchemas(initial?.schemaJson, schemaJson) : []),
|
||||
[isEdit, initial, schemaJson],
|
||||
)
|
||||
const breaking = hasBreakingChanges(changes)
|
||||
const suggestedVersion = useMemo(
|
||||
() => (isEdit ? suggestVersionBump(initial?.schemaVersion, changes) : schemaVersion),
|
||||
[isEdit, initial, changes, schemaVersion],
|
||||
)
|
||||
|
||||
const activeMutation = isEdit ? updateMut : createMut
|
||||
const serverError = serverErrorMessage(activeMutation.error)
|
||||
@@ -79,6 +101,10 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
||||
return
|
||||
}
|
||||
setNameError(null)
|
||||
if (isEdit && breaking) {
|
||||
setActiveTab('metadata')
|
||||
return
|
||||
}
|
||||
|
||||
const payload: CreateDictionaryRequest = {
|
||||
name,
|
||||
@@ -161,9 +187,26 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
||||
<TextInput
|
||||
label={t('schema.version')}
|
||||
placeholder="1.0.0"
|
||||
hint={
|
||||
isEdit && suggestedVersion !== schemaVersion
|
||||
? t('schema.versionSuggest', { v: suggestedVersion })
|
||||
: undefined
|
||||
}
|
||||
value={schemaVersion}
|
||||
onChange={(e) => setSchemaVersion(e.target.value)}
|
||||
/>
|
||||
<SingleSelect
|
||||
label={t('schema.idSource')}
|
||||
hint={t('schema.idSourceHint')}
|
||||
options={[
|
||||
{ id: '', label: '— ' + t('schema.idSourceManual') + ' —' },
|
||||
...properties
|
||||
.filter((p) => p.name && (p.kind === 'string' || p.kind === 'integer' || p.kind === 'number'))
|
||||
.map((p) => ({ id: p.name, label: p.name })),
|
||||
]}
|
||||
value={idSource}
|
||||
onChange={setIdSource}
|
||||
/>
|
||||
<MultiSelect
|
||||
label={t('schema.supportedLocales')}
|
||||
options={localeOptions}
|
||||
@@ -194,6 +237,35 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{isEdit && breaking && (
|
||||
<Alert variant="error" title={t('schema.diff.blockedTitle')}>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">{t('schema.diff.blockedBody')}</p>
|
||||
<ul className="list-disc pl-5 text-sm space-y-0.5">
|
||||
{changes
|
||||
.filter((c) => c.severity === 'breaking')
|
||||
.map((c, i) => (
|
||||
<li key={i}>
|
||||
<span className="font-mono">{c.field}</span> — {c.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isEdit && !breaking && changes.length > 0 && (
|
||||
<Alert variant="info" title={t('schema.diff.compatibleTitle')}>
|
||||
<ul className="list-disc pl-5 text-sm space-y-0.5">
|
||||
{changes.map((c, i) => (
|
||||
<li key={i}>
|
||||
<span className="font-mono">{c.field}</span> — {c.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{serverError && <Alert variant="error">{serverError}</Alert>}
|
||||
|
||||
<FormActions align="end">
|
||||
@@ -204,6 +276,7 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
||||
type="button"
|
||||
variant="primary"
|
||||
loading={activeMutation.isPending}
|
||||
disabled={isEdit && breaking}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{activeMutation.isPending ? t('form.saving') : t('form.save')}
|
||||
|
||||
@@ -87,11 +87,19 @@ export const PropertyEditor = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Checkbox
|
||||
label={t('schema.required')}
|
||||
checked={prop.required}
|
||||
onChange={(e) => update({ required: e.target.checked })}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Checkbox
|
||||
label={t('schema.required')}
|
||||
checked={prop.required}
|
||||
onChange={(e) => update({ required: e.target.checked })}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('schema.unique')}
|
||||
description={t('schema.uniqueHint')}
|
||||
checked={prop.unique}
|
||||
onChange={(e) => update({ unique: e.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
label={t('schema.description')}
|
||||
|
||||
Reference in New Issue
Block a user