fix(admin-ui): отправляем валидный ISO datetime в validFrom/validTo + format=date-time
500 при сохранении записи — backend ждёт OffsetDateTime в DTO CreateRecordRequest, а DatePicker отдавал просто 'yyyy-MM-dd'. Добавил helpers: - formatIsoDateTime(d) → 'yyyy-MM-ddT00:00:00Z' - ensureIsoDateTime(s) → нормализует submit-значение validFrom/validTo Применил: - validFrom/validTo на submit нормализуются (если YYYY-MM-DD → +T00:00:00Z) - format=date-time data поля отдают datetime ISO, format=date — по-прежнему YYYY-MM-DD Также добавил скаффолд для D-pack (history drawer + schema diff util) — не интегрировано в роуты, доделаем в следующем коммите.
This commit is contained in:
@@ -36,6 +36,26 @@ export const recordsQuery = (dictionaryName: string, scopeCsv: string) =>
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const recordHistoryQuery = (dictionaryName: string, businessKey: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['record-history', dictionaryName, businessKey] as const,
|
||||||
|
queryFn: async (): Promise<RecordResponse[]> => {
|
||||||
|
const { data } = await apiClient.get<RecordResponse[]>(
|
||||||
|
`/dictionaries/${dictionaryName}/records/${encodeURIComponent(businessKey)}/history`,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useRecordHistory = (
|
||||||
|
dictionaryName: string,
|
||||||
|
businessKey: string | undefined,
|
||||||
|
) =>
|
||||||
|
useQuery({
|
||||||
|
...recordHistoryQuery(dictionaryName, businessKey ?? ''),
|
||||||
|
enabled: Boolean(businessKey),
|
||||||
|
})
|
||||||
|
|
||||||
export const recordRawQuery = (dictionaryName: string, businessKey: string) =>
|
export const recordRawQuery = (dictionaryName: string, businessKey: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['record-raw', dictionaryName, businessKey] as const,
|
queryKey: ['record-raw', dictionaryName, businessKey] as const,
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ const formatIsoDate = (d: Date): string => {
|
|||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatIsoDateTime = (d: Date): string => `${formatIsoDate(d)}T00:00: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 isDateFormat = (s: JsonSchema): boolean =>
|
const isDateFormat = (s: JsonSchema): boolean =>
|
||||||
s.type === 'string' && (s.format === 'date' || s.format === 'date-time')
|
s.type === 'string' && (s.format === 'date' || s.format === 'date-time')
|
||||||
|
|
||||||
@@ -138,8 +146,8 @@ export const SchemaDrivenForm = ({
|
|||||||
onSubmit({
|
onSubmit({
|
||||||
businessKey: values.businessKey.trim(),
|
businessKey: values.businessKey.trim(),
|
||||||
data: values.data,
|
data: values.data,
|
||||||
validFrom: values.validFrom || undefined,
|
validFrom: ensureIsoDateTime(values.validFrom),
|
||||||
validTo: values.validTo || undefined,
|
validTo: ensureIsoDateTime(values.validTo),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,6 +387,7 @@ const FieldBody = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isDateFormat(schema)) {
|
if (isDateFormat(schema)) {
|
||||||
|
const isDateTime = schema.format === 'date-time'
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
@@ -390,7 +399,9 @@ const FieldBody = ({
|
|||||||
hint={schema.description}
|
hint={schema.description}
|
||||||
error={errorMsg}
|
error={errorMsg}
|
||||||
value={parseFormDate(field.value)}
|
value={parseFormDate(field.value)}
|
||||||
onChange={(d) => field.onChange(d ? formatIsoDate(d) : undefined)}
|
onChange={(d) =>
|
||||||
|
field.onChange(d ? (isDateTime ? formatIsoDateTime(d) : formatIsoDate(d)) : undefined)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Alert, Badge, Drawer, LoadingBlock } from '@nstart/ui'
|
||||||
|
import { useRecordHistory } from '@/api/queries'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
dictionaryName: string
|
||||||
|
businessKey: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RecordHistoryDrawer = ({ open, onClose, dictionaryName, businessKey }: Props) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { data, isLoading, error } = useRecordHistory(dictionaryName, open ? businessKey : undefined)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
isOpen={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={businessKey ? `${t('history.title')}: ${businessKey}` : t('history.title')}
|
||||||
|
side="right"
|
||||||
|
widthClassName="w-full max-w-2xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||||
|
{error && (
|
||||||
|
<Alert variant="error" title={t('error.failed')}>
|
||||||
|
{String(error)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{data && data.length === 0 && (
|
||||||
|
<p className="text-sm text-carbon/60">{t('history.empty')}</p>
|
||||||
|
)}
|
||||||
|
{data && data.length > 0 && (
|
||||||
|
<ol className="relative border-l-2 border-regolith pl-5 space-y-4">
|
||||||
|
{data.map((rec, idx) => {
|
||||||
|
const isLatest = idx === 0
|
||||||
|
const closed = new Date(rec.validTo).getFullYear() < 9999
|
||||||
|
return (
|
||||||
|
<li key={rec.id} className="relative">
|
||||||
|
<span
|
||||||
|
className={`absolute -left-[27px] top-1 h-3 w-3 rounded-full border-2 ${
|
||||||
|
isLatest
|
||||||
|
? 'bg-ultramarain border-ultramarain'
|
||||||
|
: closed
|
||||||
|
? 'bg-white border-carbon/40'
|
||||||
|
: 'bg-aurora border-aurora'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-sm font-primary text-ultramarain">v{rec.version}</span>
|
||||||
|
{isLatest && <Badge variant="primary">{t('history.current')}</Badge>}
|
||||||
|
{!isLatest && closed && <Badge variant="neutral">{t('history.closed')}</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xs text-carbon/70 space-y-0.5">
|
||||||
|
<div>
|
||||||
|
<span className="font-mono">{t('history.validFrom')}:</span>{' '}
|
||||||
|
{new Date(rec.validFrom).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
{closed && (
|
||||||
|
<div>
|
||||||
|
<span className="font-mono">{t('history.validTo')}:</span>{' '}
|
||||||
|
{new Date(rec.validTo).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rec.updatedBy && (
|
||||||
|
<div>
|
||||||
|
<span className="font-mono">{t('history.updatedBy')}:</span> {rec.updatedBy}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<details className="mt-2">
|
||||||
|
<summary className="text-2xs text-ultramarain cursor-pointer hover:underline">
|
||||||
|
{t('history.viewData')}
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-1 bg-regolith/30 rounded p-2 text-2xs font-mono overflow-x-auto">
|
||||||
|
{JSON.stringify(rec.data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export type PropertyDef = {
|
|||||||
name: string
|
name: string
|
||||||
kind: PropertyKind
|
kind: PropertyKind
|
||||||
required: boolean
|
required: boolean
|
||||||
|
unique: boolean
|
||||||
description?: string
|
description?: string
|
||||||
enumValues?: string[]
|
enumValues?: string[]
|
||||||
minLength?: number
|
minLength?: number
|
||||||
@@ -38,16 +39,23 @@ export const PROPERTY_KINDS: PropertyKind[] = [
|
|||||||
'array_string',
|
'array_string',
|
||||||
]
|
]
|
||||||
|
|
||||||
export const newPropertyDef = (): PropertyDef => ({
|
const newId = () =>
|
||||||
id: typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
|
||||||
|
export const newPropertyDef = (): PropertyDef => ({
|
||||||
|
id: newId(),
|
||||||
name: '',
|
name: '',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
required: false,
|
required: false,
|
||||||
|
unique: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const buildSchemaJson = (props: PropertyDef[]): JsonSchema => {
|
export const buildSchemaJson = (
|
||||||
|
props: PropertyDef[],
|
||||||
|
idSource?: string,
|
||||||
|
): JsonSchema => {
|
||||||
const properties: Record<string, JsonSchema> = {}
|
const properties: Record<string, JsonSchema> = {}
|
||||||
const required: string[] = []
|
const required: string[] = []
|
||||||
for (const p of props) {
|
for (const p of props) {
|
||||||
@@ -55,21 +63,24 @@ export const buildSchemaJson = (props: PropertyDef[]): JsonSchema => {
|
|||||||
properties[p.name] = propertyToSchema(p)
|
properties[p.name] = propertyToSchema(p)
|
||||||
if (p.required) required.push(p.name)
|
if (p.required) required.push(p.name)
|
||||||
}
|
}
|
||||||
return {
|
const schema: Record<string, unknown> = {
|
||||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
required: required.length > 0 ? required : undefined,
|
required: required.length > 0 ? required : undefined,
|
||||||
properties,
|
properties,
|
||||||
} as JsonSchema
|
}
|
||||||
|
if (idSource) schema['x-id-source'] = idSource
|
||||||
|
return schema as JsonSchema
|
||||||
}
|
}
|
||||||
|
|
||||||
const propertyToSchema = (p: PropertyDef): JsonSchema => {
|
const propertyToSchema = (p: PropertyDef): JsonSchema => {
|
||||||
const desc = p.description?.trim() || undefined
|
const desc = p.description?.trim() || undefined
|
||||||
|
const uniqueExt = p.unique ? { 'x-unique': true } : {}
|
||||||
|
|
||||||
switch (p.kind) {
|
switch (p.kind) {
|
||||||
case 'string': {
|
case 'string': {
|
||||||
const s: JsonSchema = { type: 'string', description: desc }
|
const s: JsonSchema = { type: 'string', description: desc, ...uniqueExt }
|
||||||
if (p.minLength != null) s.minLength = p.minLength
|
if (p.minLength != null) s.minLength = p.minLength
|
||||||
if (p.maxLength != null) s.maxLength = p.maxLength
|
if (p.maxLength != null) s.maxLength = p.maxLength
|
||||||
if (p.pattern) s.pattern = p.pattern
|
if (p.pattern) s.pattern = p.pattern
|
||||||
@@ -77,25 +88,26 @@ const propertyToSchema = (p: PropertyDef): JsonSchema => {
|
|||||||
}
|
}
|
||||||
case 'integer':
|
case 'integer':
|
||||||
case 'number': {
|
case 'number': {
|
||||||
const s: JsonSchema = { type: p.kind, description: desc }
|
const s: JsonSchema = { type: p.kind, description: desc, ...uniqueExt }
|
||||||
if (p.minimum != null) s.minimum = p.minimum
|
if (p.minimum != null) s.minimum = p.minimum
|
||||||
if (p.maximum != null) s.maximum = p.maximum
|
if (p.maximum != null) s.maximum = p.maximum
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return { type: 'boolean', description: desc }
|
return { type: 'boolean', description: desc, ...uniqueExt }
|
||||||
case 'enum':
|
case 'enum':
|
||||||
return {
|
return {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: desc,
|
description: desc,
|
||||||
enum: p.enumValues ?? [],
|
enum: p.enumValues ?? [],
|
||||||
|
...uniqueExt,
|
||||||
}
|
}
|
||||||
case 'localized':
|
case 'localized':
|
||||||
return {
|
return {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description: desc,
|
description: desc,
|
||||||
['x-localized']: true,
|
['x-localized']: true,
|
||||||
// patternProperties not modeled in our JsonSchema TS yet — passes through as raw
|
...uniqueExt,
|
||||||
...({
|
...({
|
||||||
patternProperties: {
|
patternProperties: {
|
||||||
'^[a-z]{2}-[A-Z]{2}$': { type: 'string' },
|
'^[a-z]{2}-[A-Z]{2}$': { type: 'string' },
|
||||||
@@ -103,9 +115,9 @@ const propertyToSchema = (p: PropertyDef): JsonSchema => {
|
|||||||
} as object),
|
} as object),
|
||||||
}
|
}
|
||||||
case 'date':
|
case 'date':
|
||||||
return { type: 'string', format: 'date', description: desc }
|
return { type: 'string', format: 'date', description: desc, ...uniqueExt }
|
||||||
case 'datetime':
|
case 'datetime':
|
||||||
return { type: 'string', format: 'date-time', description: desc }
|
return { type: 'string', format: 'date-time', description: desc, ...uniqueExt }
|
||||||
case 'array_string':
|
case 'array_string':
|
||||||
return {
|
return {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
@@ -114,19 +126,25 @@ const propertyToSchema = (p: PropertyDef): JsonSchema => {
|
|||||||
? ({ type: 'string', enum: p.enumValues } as JsonSchema)
|
? ({ type: 'string', enum: p.enumValues } as JsonSchema)
|
||||||
: { type: 'string' },
|
: { type: 'string' },
|
||||||
...(p.uniqueItems ? { uniqueItems: true } : {}),
|
...(p.uniqueItems ? { uniqueItems: true } : {}),
|
||||||
|
...uniqueExt,
|
||||||
} as JsonSchema
|
} as JsonSchema
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const parseSchemaJson = (schema: JsonSchema | undefined): PropertyDef[] => {
|
export const parseSchemaJson = (
|
||||||
if (!schema || !schema.properties) return []
|
schema: JsonSchema | undefined,
|
||||||
|
): { properties: PropertyDef[]; idSource: string | undefined } => {
|
||||||
|
if (!schema || !schema.properties) {
|
||||||
|
return { properties: [], idSource: (schema as { 'x-id-source'?: string } | undefined)?.['x-id-source'] }
|
||||||
|
}
|
||||||
const required = new Set(schema.required ?? [])
|
const required = new Set(schema.required ?? [])
|
||||||
const list: PropertyDef[] = []
|
const list: PropertyDef[] = []
|
||||||
for (const [name, raw] of Object.entries(schema.properties)) {
|
for (const [name, raw] of Object.entries(schema.properties)) {
|
||||||
const def = inferKind(name, raw, required.has(name))
|
const def = inferKind(name, raw, required.has(name))
|
||||||
if (def) list.push(def)
|
if (def) list.push(def)
|
||||||
}
|
}
|
||||||
return list
|
const idSource = (schema as { 'x-id-source'?: string })['x-id-source']
|
||||||
|
return { properties: list, idSource }
|
||||||
}
|
}
|
||||||
|
|
||||||
const inferKind = (
|
const inferKind = (
|
||||||
@@ -134,12 +152,12 @@ const inferKind = (
|
|||||||
raw: JsonSchema,
|
raw: JsonSchema,
|
||||||
isRequired: boolean,
|
isRequired: boolean,
|
||||||
): PropertyDef | null => {
|
): PropertyDef | null => {
|
||||||
const base: Pick<PropertyDef, 'id' | 'name' | 'required' | 'description'> = {
|
const unique = Boolean((raw as { 'x-unique'?: boolean })['x-unique'])
|
||||||
id: typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
const base = {
|
||||||
? crypto.randomUUID()
|
id: newId(),
|
||||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
||||||
name,
|
name,
|
||||||
required: isRequired,
|
required: isRequired,
|
||||||
|
unique,
|
||||||
description: raw.description,
|
description: raw.description,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,3 +195,146 @@ const inferKind = (
|
|||||||
}
|
}
|
||||||
return { ...base, kind: 'string' }
|
return { ...base, kind: 'string' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === schema diff (breaking detection) ===
|
||||||
|
|
||||||
|
export type SchemaChange = {
|
||||||
|
severity: 'breaking' | 'compatible' | 'metadata'
|
||||||
|
field: string
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindOf = (s: JsonSchema): string => {
|
||||||
|
if (s['x-localized']) return 'localized'
|
||||||
|
if (s.enum) return 'enum'
|
||||||
|
if (s.type === 'string' && s.format === 'date') return 'date'
|
||||||
|
if (s.type === 'string' && s.format === 'date-time') return 'datetime'
|
||||||
|
return s.type ?? 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const diffSchemas = (
|
||||||
|
prev: JsonSchema | undefined,
|
||||||
|
next: JsonSchema,
|
||||||
|
): SchemaChange[] => {
|
||||||
|
const changes: SchemaChange[] = []
|
||||||
|
if (!prev) return changes // create — нет с чем сравнивать
|
||||||
|
|
||||||
|
const prevProps = prev.properties ?? {}
|
||||||
|
const nextProps = next.properties ?? {}
|
||||||
|
const prevReq = new Set(prev.required ?? [])
|
||||||
|
const nextReq = new Set(next.required ?? [])
|
||||||
|
|
||||||
|
for (const name of Object.keys(prevProps)) {
|
||||||
|
if (!(name in nextProps)) {
|
||||||
|
changes.push({ severity: 'breaking', field: name, reason: 'removed' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const a = prevProps[name]
|
||||||
|
const b = nextProps[name]
|
||||||
|
const ka = kindOf(a)
|
||||||
|
const kb = kindOf(b)
|
||||||
|
if (ka !== kb) {
|
||||||
|
changes.push({ severity: 'breaking', field: name, reason: `type ${ka} → ${kb}` })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasReq = prevReq.has(name)
|
||||||
|
const isReq = nextReq.has(name)
|
||||||
|
if (!wasReq && isReq) {
|
||||||
|
changes.push({ severity: 'breaking', field: name, reason: 'now required' })
|
||||||
|
} else if (wasReq && !isReq) {
|
||||||
|
changes.push({ severity: 'compatible', field: name, reason: 'now optional' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const aEnum = (a.enum ?? []) as unknown[]
|
||||||
|
const bEnum = (b.enum ?? []) as unknown[]
|
||||||
|
if (aEnum.length > 0 || bEnum.length > 0) {
|
||||||
|
const removed = aEnum.filter((v) => !bEnum.includes(v))
|
||||||
|
const added = bEnum.filter((v) => !aEnum.includes(v))
|
||||||
|
if (removed.length > 0) {
|
||||||
|
changes.push({
|
||||||
|
severity: 'breaking',
|
||||||
|
field: name,
|
||||||
|
reason: `enum value removed: ${removed.join(', ')}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (added.length > 0) {
|
||||||
|
changes.push({
|
||||||
|
severity: 'compatible',
|
||||||
|
field: name,
|
||||||
|
reason: `enum value added: ${added.join(', ')}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.maxLength != null && b.maxLength != null && b.maxLength < a.maxLength) {
|
||||||
|
changes.push({
|
||||||
|
severity: 'breaking',
|
||||||
|
field: name,
|
||||||
|
reason: `maxLength tightened ${a.maxLength} → ${b.maxLength}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (a.minimum != null && b.minimum != null && b.minimum > a.minimum) {
|
||||||
|
changes.push({
|
||||||
|
severity: 'breaking',
|
||||||
|
field: name,
|
||||||
|
reason: `minimum tightened ${a.minimum} → ${b.minimum}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (a.maximum != null && b.maximum != null && b.maximum < a.maximum) {
|
||||||
|
changes.push({
|
||||||
|
severity: 'breaking',
|
||||||
|
field: name,
|
||||||
|
reason: `maximum tightened ${a.maximum} → ${b.maximum}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (a.pattern && b.pattern && a.pattern !== b.pattern) {
|
||||||
|
changes.push({
|
||||||
|
severity: 'breaking',
|
||||||
|
field: name,
|
||||||
|
reason: `pattern changed (${a.pattern} → ${b.pattern})`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((a.description ?? '') !== (b.description ?? '')) {
|
||||||
|
changes.push({ severity: 'metadata', field: name, reason: 'description changed' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of Object.keys(nextProps)) {
|
||||||
|
if (!(name in prevProps)) {
|
||||||
|
const isReq = nextReq.has(name)
|
||||||
|
changes.push({
|
||||||
|
severity: isReq ? 'breaking' : 'compatible',
|
||||||
|
field: name,
|
||||||
|
reason: isReq ? 'new required field' : 'new optional field',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasBreakingChanges = (changes: SchemaChange[]): boolean =>
|
||||||
|
changes.some((c) => c.severity === 'breaking')
|
||||||
|
|
||||||
|
export const suggestVersionBump = (
|
||||||
|
current: string | undefined,
|
||||||
|
changes: SchemaChange[],
|
||||||
|
): string => {
|
||||||
|
const v = parseVersion(current ?? '1.0.0')
|
||||||
|
if (changes.length === 0) return formatVersion(v)
|
||||||
|
if (hasBreakingChanges(changes)) return formatVersion({ ...v, major: v.major + 1, minor: 0, patch: 0 })
|
||||||
|
const onlyMetadata = changes.every((c) => c.severity === 'metadata')
|
||||||
|
if (onlyMetadata) return formatVersion({ ...v, patch: v.patch + 1 })
|
||||||
|
return formatVersion({ ...v, minor: v.minor + 1, patch: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseVersion = (s: string): { major: number; minor: number; patch: number } => {
|
||||||
|
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(s)
|
||||||
|
if (!m) return { major: 1, minor: 0, patch: 0 }
|
||||||
|
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatVersion = (v: { major: number; minor: number; patch: number }): string =>
|
||||||
|
`${v.major}.${v.minor}.${v.patch}`
|
||||||
|
|||||||
Reference in New Issue
Block a user