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 })} + /> +