From baf76ac6e0f128f7210bf4b240bbe70a640d1896 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 4 May 2026 03:38:21 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin-ui):=20D-pack=20=E2=80=94=20history,?= =?UTF-8?q?=20breaking-change=20detection,=20x-unique,=20x-id-source,=20da?= =?UTF-8?q?tetime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/form/SchemaDrivenForm.tsx | 102 +++++++++++++----- .../schema/DictionaryEditorDialog.tsx | 83 +++++++++++++- .../src/components/schema/PropertyEditor.tsx | 18 +++- ordinis-admin-ui/src/i18n.ts | 36 +++++++ .../src/routes/dictionaries.$name.tsx | 17 ++- 5 files changed, 221 insertions(+), 35 deletions(-) diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx index 843ba0a..be01a86 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx @@ -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 = (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 = ({
-
- -
+ {!idSource && ( +
+ +
+ )} + {idSource && ( +
+ {t('form.idSourceNote', { field: idSource })} +
+ )} ( - 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 }) => ( - 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 ( +
+ onChange(combineDateTime(d, timeValue, value ?? ''))} + /> +
+ onChange(combineDateTime(dateValue, e.target.value, value ?? ''))} + /> +
+
+ ) +} + type JsonFieldProps = { label: string required: boolean diff --git a/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx b/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx index 8a444d6..d8466a9 100644 --- a/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx +++ b/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx @@ -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('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( - () => parseSchemaJson(initial?.schemaJson), - ) + const [properties, setProperties] = useState(parsed.properties) + const [idSource, setIdSource] = useState(parsed.idSource ?? '') const [nameError, setNameError] = useState(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 setSchemaVersion(e.target.value)} /> + p.name && (p.kind === 'string' || p.kind === 'integer' || p.kind === 'number')) + .map((p) => ({ id: p.name, label: p.name })), + ]} + value={idSource} + onChange={setIdSource} + />
+ {isEdit && breaking && ( + +
+

{t('schema.diff.blockedBody')}

+
    + {changes + .filter((c) => c.severity === 'breaking') + .map((c, i) => ( +
  • + {c.field} — {c.reason} +
  • + ))} +
+
+
+ )} + + {isEdit && !breaking && changes.length > 0 && ( + +
    + {changes.map((c, i) => ( +
  • + {c.field} — {c.reason} +
  • + ))} +
+
+ )} + {serverError && {serverError}} @@ -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')} diff --git a/ordinis-admin-ui/src/components/schema/PropertyEditor.tsx b/ordinis-admin-ui/src/components/schema/PropertyEditor.tsx index 24f97a3..8592986 100644 --- a/ordinis-admin-ui/src/components/schema/PropertyEditor.tsx +++ b/ordinis-admin-ui/src/components/schema/PropertyEditor.tsx @@ -87,11 +87,19 @@ export const PropertyEditor = ({ />
- update({ required: e.target.checked })} - /> +
+ update({ required: e.target.checked })} + /> + update({ unique: e.target.checked })} + /> +