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:
Zimin A.N.
2026-05-04 03:38:21 +03:00
parent 0289cf5ff2
commit baf76ac6e0
5 changed files with 221 additions and 35 deletions
@@ -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')}
+36
View File
@@ -32,6 +32,7 @@ i18n
'dict.confirmClose.reason': 'Причина (опционально)',
'dict.confirmClose.reasonPlaceholder': 'например, «дубликат» или «выведено из эксплуатации»',
'form.identity': 'Идентификация',
'form.idSourceNote': 'Бизнес-ключ создастся автоматически из поля «{{field}}»',
'form.tabs.identity': 'Идентификация',
'form.tabs.description': 'Описание',
'form.tabs.extra': 'Дополнительно',
@@ -69,6 +70,23 @@ i18n
'schema.enumValues': 'Значения enum (через запятую)',
'schema.enumValuesHint': 'Например: ACTIVE, INACTIVE, RETIRED',
'schema.arrayEnumOptional': 'Допустимые значения (опц., через запятую)',
'schema.unique': 'Уникальное',
'schema.uniqueHint': 'Дубликаты по этому полю запрещены',
'schema.idSource': 'Использовать как ID (бизнес-ключ)',
'schema.idSourceHint': 'Бизнес-ключ записи будет автоматически = значению этого поля',
'schema.idSourceManual': 'Ввод вручную',
'schema.versionSuggest': 'Рекомендуемая версия: {{v}}',
'schema.diff.blockedTitle': 'Несовместимое изменение схемы',
'schema.diff.blockedBody': 'Эти изменения сломают существующие записи. Сохранение заблокировано. Создайте новый справочник или откатите изменения.',
'schema.diff.compatibleTitle': 'Изменения схемы (совместимые)',
'history.title': 'История версий',
'history.empty': 'История пуста',
'history.current': 'текущая',
'history.closed': 'закрыта',
'history.validFrom': 'действует с',
'history.validTo': 'действует до',
'history.updatedBy': 'кем',
'history.viewData': 'данные записи (JSON)',
'schema.empty': 'Полей нет — добавь первое',
'schema.addProperty': 'Добавить поле',
'schema.delete': 'Удалить',
@@ -128,6 +146,7 @@ i18n
'dict.confirmClose.reason': 'Reason (optional)',
'dict.confirmClose.reasonPlaceholder': 'e.g., "duplicate" or "decommissioned"',
'form.identity': 'Identity',
'form.idSourceNote': 'Business key auto-derived from "{{field}}" field',
'form.tabs.identity': 'Identity',
'form.tabs.description': 'Description',
'form.tabs.extra': 'Additional',
@@ -165,6 +184,23 @@ i18n
'schema.enumValues': 'Enum values (comma-separated)',
'schema.enumValuesHint': 'E.g.: ACTIVE, INACTIVE, RETIRED',
'schema.arrayEnumOptional': 'Allowed values (optional, comma-separated)',
'schema.unique': 'Unique',
'schema.uniqueHint': 'Duplicates by this field are not allowed',
'schema.idSource': 'Use as ID (business key)',
'schema.idSourceHint': 'Record business key auto-derived from this field value',
'schema.idSourceManual': 'Manual input',
'schema.versionSuggest': 'Suggested version: {{v}}',
'schema.diff.blockedTitle': 'Breaking schema change',
'schema.diff.blockedBody': 'These changes break existing records. Save blocked. Create a new dictionary or revert.',
'schema.diff.compatibleTitle': 'Schema changes (compatible)',
'history.title': 'Version history',
'history.empty': 'No history',
'history.current': 'current',
'history.closed': 'closed',
'history.validFrom': 'valid from',
'history.validTo': 'valid to',
'history.updatedBy': 'by',
'history.viewData': 'record data (JSON)',
'schema.empty': 'No fields yet — add the first one',
'schema.addProperty': 'Add field',
'schema.delete': 'Delete',
@@ -20,12 +20,13 @@ import {
TableRow,
TextInput,
} from '@nstart/ui'
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon } from '@phosphor-icons/react'
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon } from '@phosphor-icons/react'
import { useDictionaryDetail, useRecordRaw, useRecords } from '@/api/queries'
import { useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
import type { CreateRecordRequest, FlattenedRecord } from '@/api/client'
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
export const Route = createFileRoute('/dictionaries/$name')({
component: DictionaryDetail,
@@ -49,6 +50,7 @@ function DictionaryDetail() {
const [edit, setEdit] = useState<EditState>({ kind: 'closed' })
const [closeReason, setCloseReason] = useState('')
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
const editingKey = edit.kind === 'edit' ? edit.record.businessKey : undefined
const rawRecordQuery = useRecordRaw(name, editingKey)
@@ -168,6 +170,12 @@ function DictionaryDetail() {
</TableCell>
<TableCell align="right">
<div className="flex items-center justify-end gap-1">
<IconButton
label={t('history.title')}
variant="default"
icon={<ClockCounterClockwiseIcon weight="regular" />}
onClick={() => setHistoryKey(r.businessKey)}
/>
<IconButton
label={t('dict.action.edit')}
variant="default"
@@ -299,6 +307,13 @@ function DictionaryDetail() {
onSuccess={() => setSchemaEditOpen(false)}
/>
)}
<RecordHistoryDrawer
open={Boolean(historyKey)}
onClose={() => setHistoryKey(undefined)}
dictionaryName={name}
businessKey={historyKey}
/>
</div>
)
}