feat(schema-workflow): PATCH /drafts/{id} — edit без recreate (A)
This commit is contained in:
@@ -496,6 +496,7 @@ export type ChangelogKind =
|
|||||||
| 'definition_create'
|
| 'definition_create'
|
||||||
| 'definition_update'
|
| 'definition_update'
|
||||||
| 'draft_create'
|
| 'draft_create'
|
||||||
|
| 'draft_update'
|
||||||
| 'draft_review'
|
| 'draft_review'
|
||||||
| 'draft_approve'
|
| 'draft_approve'
|
||||||
| 'draft_reject'
|
| 'draft_reject'
|
||||||
@@ -602,6 +603,18 @@ export type CreateSchemaDraftRequest = {
|
|||||||
proposedSchema: unknown
|
proposedSchema: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partial update existing draft без recreate. Status-gate enforced
|
||||||
|
* backend'ом: только DRAFT и CHANGES_REQUESTED. CHANGES_REQUESTED →
|
||||||
|
* DRAFT auto-transition после update (maker должен ре-submit).
|
||||||
|
*/
|
||||||
|
export type UpdateSchemaDraftRequest = {
|
||||||
|
/** Replacement JSON Schema (full document). undefined = не меняем. */
|
||||||
|
proposedSchema?: unknown
|
||||||
|
/** Новая maker rationale. undefined = не меняем. */
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type SubmitForReviewRequest = {
|
export type SubmitForReviewRequest = {
|
||||||
/** Optional explicit reviewer IDs. NULL/[] = pool. */
|
/** Optional explicit reviewer IDs. NULL/[] = pool. */
|
||||||
reviewers?: string[] | null
|
reviewers?: string[] | null
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type SchemaDraft,
|
type SchemaDraft,
|
||||||
type SubmitDraftRequest,
|
type SubmitDraftRequest,
|
||||||
type SubmitForReviewRequest,
|
type SubmitForReviewRequest,
|
||||||
|
type UpdateSchemaDraftRequest,
|
||||||
type WebhookDelivery,
|
type WebhookDelivery,
|
||||||
type WebhookSubscription,
|
type WebhookSubscription,
|
||||||
type WebhookTestPingResult,
|
type WebhookTestPingResult,
|
||||||
@@ -514,6 +515,35 @@ export const useCreateSchemaDraft = (dictionaryName: string) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update content (proposedSchema / reason) в существующем draft'е без
|
||||||
|
* recreate. Status-gate: только DRAFT и CHANGES_REQUESTED. Backend
|
||||||
|
* автоматически переводит CHANGES_REQUESTED → DRAFT после update
|
||||||
|
* (maker должен ре-submit на ревью осознанно).
|
||||||
|
*/
|
||||||
|
export const useUpdateSchemaDraft = (dictionaryName: string) => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (params: {
|
||||||
|
draftId: string
|
||||||
|
body: UpdateSchemaDraftRequest
|
||||||
|
}): Promise<SchemaDraft> => {
|
||||||
|
const { data } = await apiClient.patch<SchemaDraft>(
|
||||||
|
`/dictionaries/${dictionaryName}/drafts/${params.draftId}`,
|
||||||
|
params.body,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['schema-review-queue'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['changelog', dictionaryName] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** Maker submits draft → review_pending. 202 Accepted. */
|
/** Maker submits draft → review_pending. 202 Accepted. */
|
||||||
export const useSubmitSchemaDraftForReview = (dictionaryName: string) => {
|
export const useSubmitSchemaDraftForReview = (dictionaryName: string) => {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export function kindIcon(kind: ChangelogKind): Icon {
|
|||||||
return PencilSimpleIcon
|
return PencilSimpleIcon
|
||||||
case 'draft_create':
|
case 'draft_create':
|
||||||
return ChatCircleTextIcon
|
return ChatCircleTextIcon
|
||||||
|
case 'draft_update':
|
||||||
|
return PencilSimpleIcon
|
||||||
case 'draft_review':
|
case 'draft_review':
|
||||||
return EyeIcon
|
return EyeIcon
|
||||||
case 'draft_approve':
|
case 'draft_approve':
|
||||||
@@ -61,6 +63,7 @@ export function kindBadgeVariant(kind: ChangelogKind): BadgeProps['variant'] {
|
|||||||
case 'draft_changes_requested':
|
case 'draft_changes_requested':
|
||||||
return 'warning'
|
return 'warning'
|
||||||
case 'draft_create':
|
case 'draft_create':
|
||||||
|
case 'draft_update':
|
||||||
case 'draft_review':
|
case 'draft_review':
|
||||||
case 'draft_withdraw':
|
case 'draft_withdraw':
|
||||||
return 'neutral'
|
return 'neutral'
|
||||||
@@ -72,6 +75,7 @@ export function kindLabel(kind: ChangelogKind, t: TFn): string {
|
|||||||
definition_create: 'Создан словарь',
|
definition_create: 'Создан словарь',
|
||||||
definition_update: 'Изменена схема',
|
definition_update: 'Изменена схема',
|
||||||
draft_create: 'Черновик создан',
|
draft_create: 'Черновик создан',
|
||||||
|
draft_update: 'Черновик обновлён',
|
||||||
draft_review: 'Отправлен на ревью',
|
draft_review: 'Отправлен на ревью',
|
||||||
draft_approve: 'Одобрен',
|
draft_approve: 'Одобрен',
|
||||||
draft_reject: 'Отклонён',
|
draft_reject: 'Отклонён',
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ function SchemaDraftBanner({
|
|||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
dictionaryName={detail.name}
|
dictionaryName={detail.name}
|
||||||
currentHeadVersion={detail.schemaVersion}
|
currentHeadVersion={detail.schemaVersion}
|
||||||
|
detail={detail}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,15 +9,21 @@ import {
|
|||||||
toast,
|
toast,
|
||||||
} from '@/ui'
|
} from '@/ui'
|
||||||
import { ArrowsClockwiseIcon, CheckSquareIcon } from '@phosphor-icons/react'
|
import { ArrowsClockwiseIcon, CheckSquareIcon } from '@phosphor-icons/react'
|
||||||
import type { DictionaryDetail } from '@/api/client'
|
import type { DictionaryDetail, SchemaDraft } from '@/api/client'
|
||||||
import { useCreateSchemaDraft } from '@/api/mutations'
|
import { useCreateSchemaDraft, useUpdateSchemaDraft } from '@/api/mutations'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
detail: DictionaryDetail
|
detail: DictionaryDetail
|
||||||
/** Optional callback после успешного create — parent может закрыть modal +
|
/**
|
||||||
* открыть SchemaDraftDrawer чтоб maker сразу submit'нул. */
|
* Edit mode: если передан существующий draft, modal работает как
|
||||||
|
* редактор (PATCH /drafts/{id}) вместо create. Initial JSON и reason
|
||||||
|
* seeded из draft'а. Используется когда maker правит draft после
|
||||||
|
* CHANGES_REQUESTED — backend auto-transition CHANGES_REQUESTED → DRAFT.
|
||||||
|
*/
|
||||||
|
editDraft?: SchemaDraft
|
||||||
|
/** Callback после create — parent может закрыть modal + открыть drawer. */
|
||||||
onCreated?: (draftId: string) => void
|
onCreated?: (draftId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,26 +45,36 @@ type Props = {
|
|||||||
* вернёт detail в message.</li>
|
* вернёт detail в message.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Props) {
|
export function CreateSchemaDraftModal({ open, onClose, detail, editDraft, onCreated }: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const isEdit = Boolean(editDraft)
|
||||||
|
// В edit mode seed'им из draft'овой proposedSchema, иначе из live HEAD.
|
||||||
const initialJson = useMemo(
|
const initialJson = useMemo(
|
||||||
() => JSON.stringify(detail.schemaJson, null, 2),
|
() =>
|
||||||
[detail.schemaJson],
|
JSON.stringify(
|
||||||
|
editDraft ? editDraft.proposedSchema : detail.schemaJson,
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
[editDraft, detail.schemaJson],
|
||||||
)
|
)
|
||||||
|
const initialReason = editDraft?.reason ?? ''
|
||||||
const [json, setJson] = useState(initialJson)
|
const [json, setJson] = useState(initialJson)
|
||||||
const [reason, setReason] = useState('')
|
const [reason, setReason] = useState(initialReason)
|
||||||
const [parseError, setParseError] = useState<string | null>(null)
|
const [parseError, setParseError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Reset state при re-open чтобы не показывать stale данные с прошлого dict'а.
|
// Reset state при re-open чтобы не показывать stale данные с прошлого dict'а.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setJson(initialJson)
|
setJson(initialJson)
|
||||||
setReason('')
|
setReason(initialReason)
|
||||||
setParseError(null)
|
setParseError(null)
|
||||||
}
|
}
|
||||||
}, [open, initialJson])
|
}, [open, initialJson, initialReason])
|
||||||
|
|
||||||
const createMut = useCreateSchemaDraft(detail.name)
|
const createMut = useCreateSchemaDraft(detail.name)
|
||||||
|
const updateMut = useUpdateSchemaDraft(detail.name)
|
||||||
|
const pending = createMut.isPending || updateMut.isPending
|
||||||
|
|
||||||
const handleFormat = () => {
|
const handleFormat = () => {
|
||||||
try {
|
try {
|
||||||
@@ -100,6 +116,39 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
|
|||||||
setParseError((e as Error).message)
|
setParseError((e as Error).message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const handleErrorToast = (err: unknown, fallbackKey: string, fallbackText: string) => {
|
||||||
|
let msg = t(fallbackKey, { defaultValue: fallbackText })
|
||||||
|
if (axios.isAxiosError(err)) {
|
||||||
|
const body = err.response?.data as { code?: string; message?: string } | undefined
|
||||||
|
msg = body?.message ?? body?.code ?? msg
|
||||||
|
}
|
||||||
|
toast.error(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit && editDraft) {
|
||||||
|
updateMut.mutate(
|
||||||
|
{
|
||||||
|
draftId: editDraft.draftId,
|
||||||
|
body: { proposedSchema: parsed, reason: reason || undefined },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(
|
||||||
|
t('createDraft.toast.updated', { defaultValue: 'Черновик обновлён' }),
|
||||||
|
)
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
handleErrorToast(
|
||||||
|
err,
|
||||||
|
'createDraft.toast.updateFailed',
|
||||||
|
'Не удалось обновить черновик',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
createMut.mutate(
|
createMut.mutate(
|
||||||
{
|
{
|
||||||
fromVersion: detail.schemaVersion,
|
fromVersion: detail.schemaVersion,
|
||||||
@@ -114,18 +163,12 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
|
|||||||
onCreated?.(draft.draftId)
|
onCreated?.(draft.draftId)
|
||||||
onClose()
|
onClose()
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) =>
|
||||||
let msg = t('createDraft.toast.failed', {
|
handleErrorToast(
|
||||||
defaultValue: 'Не удалось создать черновик',
|
err,
|
||||||
})
|
'createDraft.toast.failed',
|
||||||
if (axios.isAxiosError(err)) {
|
'Не удалось создать черновик',
|
||||||
const body = err.response?.data as
|
),
|
||||||
| { code?: string; message?: string }
|
|
||||||
| undefined
|
|
||||||
msg = body?.message ?? body?.code ?? msg
|
|
||||||
}
|
|
||||||
toast.error(msg)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -135,12 +178,25 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
|
|||||||
isOpen={open}
|
isOpen={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size="xl"
|
size="xl"
|
||||||
title={t('createDraft.title', { defaultValue: 'Создать черновик схемы' })}
|
title={
|
||||||
description={`${detail.name} · v${detail.schemaVersion}`}
|
isEdit
|
||||||
|
? t('createDraft.editTitle', { defaultValue: 'Редактировать черновик' })
|
||||||
|
: t('createDraft.title', { defaultValue: 'Создать черновик схемы' })
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
isEdit && editDraft
|
||||||
|
? `${detail.name} · черновик от v${editDraft.branchedFromVersion}`
|
||||||
|
: `${detail.name} · v${detail.schemaVersion}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Alert variant="info">
|
<Alert variant="info">
|
||||||
{t('createDraft.intro', {
|
{isEdit
|
||||||
|
? t('createDraft.editIntro', {
|
||||||
|
defaultValue:
|
||||||
|
'После сохранения статус черновика вернётся в draft — отправьте его на ревью заново.',
|
||||||
|
})
|
||||||
|
: t('createDraft.intro', {
|
||||||
defaultValue:
|
defaultValue:
|
||||||
'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.',
|
'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.',
|
||||||
})}
|
})}
|
||||||
@@ -158,7 +214,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
|
|||||||
defaultValue: 'Зачем эти изменения нужны (видно ревьюеру)',
|
defaultValue: 'Зачем эти изменения нужны (видно ревьюеру)',
|
||||||
})}
|
})}
|
||||||
rows={2}
|
rows={2}
|
||||||
disabled={createMut.isPending}
|
disabled={pending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -174,7 +230,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleFormat}
|
onClick={handleFormat}
|
||||||
disabled={createMut.isPending}
|
disabled={pending}
|
||||||
leftIcon={<ArrowsClockwiseIcon weight="regular" size={14} />}
|
leftIcon={<ArrowsClockwiseIcon weight="regular" size={14} />}
|
||||||
>
|
>
|
||||||
{t('createDraft.format', { defaultValue: 'Format' })}
|
{t('createDraft.format', { defaultValue: 'Format' })}
|
||||||
@@ -184,7 +240,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleValidate}
|
onClick={handleValidate}
|
||||||
disabled={createMut.isPending}
|
disabled={pending}
|
||||||
leftIcon={<CheckSquareIcon weight="regular" size={14} />}
|
leftIcon={<CheckSquareIcon weight="regular" size={14} />}
|
||||||
>
|
>
|
||||||
{t('createDraft.validate', { defaultValue: 'Validate' })}
|
{t('createDraft.validate', { defaultValue: 'Validate' })}
|
||||||
@@ -201,7 +257,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
|
|||||||
if (parseError) setParseError(null)
|
if (parseError) setParseError(null)
|
||||||
}}
|
}}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
disabled={createMut.isPending}
|
disabled={pending}
|
||||||
className="w-full min-h-[400px] max-h-[60vh] text-mono text-cell rounded-md border border-line bg-[#0c0a23] text-[#f0eaff] p-3 focus:outline-none focus:ring-1 focus:ring-accent"
|
className="w-full min-h-[400px] max-h-[60vh] text-mono text-cell rounded-md border border-line bg-[#0c0a23] text-[#f0eaff] p-3 focus:outline-none focus:ring-1 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
{parseError && (
|
{parseError && (
|
||||||
@@ -217,7 +273,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={createMut.isPending}
|
disabled={pending}
|
||||||
>
|
>
|
||||||
{t('common.cancel', { defaultValue: 'Отмена' })}
|
{t('common.cancel', { defaultValue: 'Отмена' })}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -225,10 +281,14 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
|
|||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={createMut.isPending || Boolean(parseError)}
|
disabled={pending || Boolean(parseError)}
|
||||||
>
|
>
|
||||||
{createMut.isPending
|
{pending
|
||||||
? t('createDraft.creating', { defaultValue: 'Создание…' })
|
? isEdit
|
||||||
|
? t('createDraft.updating', { defaultValue: 'Сохранение…' })
|
||||||
|
: t('createDraft.creating', { defaultValue: 'Создание…' })
|
||||||
|
: isEdit
|
||||||
|
? t('createDraft.submitEdit', { defaultValue: 'Сохранить' })
|
||||||
: t('createDraft.submit', { defaultValue: 'Создать черновик' })}
|
: t('createDraft.submit', { defaultValue: 'Создать черновик' })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Alert, Badge, Button, Drawer, LoadingBlock, TextArea, toast } from '@/u
|
|||||||
import {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
PaperPlaneTiltIcon,
|
PaperPlaneTiltIcon,
|
||||||
|
PencilSimpleIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
ArrowsCounterClockwiseIcon,
|
ArrowsCounterClockwiseIcon,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
@@ -12,10 +13,12 @@ import {
|
|||||||
} from '@phosphor-icons/react'
|
} from '@phosphor-icons/react'
|
||||||
import {
|
import {
|
||||||
isActiveSchemaDraft,
|
isActiveSchemaDraft,
|
||||||
|
type DictionaryDetail,
|
||||||
type SchemaDraft,
|
type SchemaDraft,
|
||||||
type SchemaDraftStatus,
|
type SchemaDraftStatus,
|
||||||
type SchemaDraftVerdict,
|
type SchemaDraftVerdict,
|
||||||
} from '@/api/client'
|
} from '@/api/client'
|
||||||
|
import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
|
||||||
import { useDictActiveSchemaDraft } from '@/api/queries'
|
import { useDictActiveSchemaDraft } from '@/api/queries'
|
||||||
import {
|
import {
|
||||||
useDecideSchemaDraft,
|
useDecideSchemaDraft,
|
||||||
@@ -31,6 +34,9 @@ type Props = {
|
|||||||
dictionaryName: string
|
dictionaryName: string
|
||||||
/** Current live schemaVersion — passed для publish expectedHeadVersion check. */
|
/** Current live schemaVersion — passed для publish expectedHeadVersion check. */
|
||||||
currentHeadVersion: string | undefined
|
currentHeadVersion: string | undefined
|
||||||
|
/** Optional full dict detail — нужен для edit modal seeding. Если не
|
||||||
|
* передан, edit button скрывается. */
|
||||||
|
detail?: DictionaryDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,9 +64,11 @@ export function SchemaDraftDrawer({
|
|||||||
onClose,
|
onClose,
|
||||||
dictionaryName,
|
dictionaryName,
|
||||||
currentHeadVersion,
|
currentHeadVersion,
|
||||||
|
detail,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [comment, setComment] = useState('')
|
const [comment, setComment] = useState('')
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
const { data: draft, isLoading, error } = useDictActiveSchemaDraft(
|
const { data: draft, isLoading, error } = useDictActiveSchemaDraft(
|
||||||
open ? dictionaryName : undefined,
|
open ? dictionaryName : undefined,
|
||||||
)
|
)
|
||||||
@@ -241,9 +249,18 @@ export function SchemaDraftDrawer({
|
|||||||
onDecide={onDecide}
|
onDecide={onDecide}
|
||||||
onPublish={onPublish}
|
onPublish={onPublish}
|
||||||
onWithdraw={onWithdraw}
|
onWithdraw={onWithdraw}
|
||||||
|
onEdit={detail ? () => setEditOpen(true) : undefined}
|
||||||
disabled={anyPending}
|
disabled={anyPending}
|
||||||
hasCurrentVersion={Boolean(currentHeadVersion)}
|
hasCurrentVersion={Boolean(currentHeadVersion)}
|
||||||
/>
|
/>
|
||||||
|
{detail && (
|
||||||
|
<CreateSchemaDraftModal
|
||||||
|
open={editOpen}
|
||||||
|
onClose={() => setEditOpen(false)}
|
||||||
|
detail={detail}
|
||||||
|
editDraft={draft}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -380,6 +397,7 @@ function DraftActions({
|
|||||||
onDecide,
|
onDecide,
|
||||||
onPublish,
|
onPublish,
|
||||||
onWithdraw,
|
onWithdraw,
|
||||||
|
onEdit,
|
||||||
disabled,
|
disabled,
|
||||||
hasCurrentVersion,
|
hasCurrentVersion,
|
||||||
}: {
|
}: {
|
||||||
@@ -388,6 +406,7 @@ function DraftActions({
|
|||||||
onDecide: (v: SchemaDraftVerdict) => void
|
onDecide: (v: SchemaDraftVerdict) => void
|
||||||
onPublish: () => void
|
onPublish: () => void
|
||||||
onWithdraw: () => void
|
onWithdraw: () => void
|
||||||
|
onEdit?: () => void
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
hasCurrentVersion: boolean
|
hasCurrentVersion: boolean
|
||||||
}) {
|
}) {
|
||||||
@@ -411,6 +430,20 @@ function DraftActions({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-line">
|
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-line">
|
||||||
{(status === 'draft' || status === 'changes_requested') && (
|
{(status === 'draft' || status === 'changes_requested') && (
|
||||||
|
<>
|
||||||
|
{onEdit && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onEdit}
|
||||||
|
disabled={disabled}
|
||||||
|
leftIcon={<PencilSimpleIcon weight="regular" size={14} />}
|
||||||
|
>
|
||||||
|
{t('workflow.schemaDraft.actions.edit', {
|
||||||
|
defaultValue: 'Редактировать',
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -426,6 +459,7 @@ function DraftActions({
|
|||||||
defaultValue: 'Доработать и отправить',
|
defaultValue: 'Доработать и отправить',
|
||||||
})}
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{status === 'review_pending' && canReview && (
|
{status === 'review_pending' && canReview && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -417,6 +417,7 @@ i18n
|
|||||||
'changelog.kind.definition_create': 'Создан словарь',
|
'changelog.kind.definition_create': 'Создан словарь',
|
||||||
'changelog.kind.definition_update': 'Изменена схема',
|
'changelog.kind.definition_update': 'Изменена схема',
|
||||||
'changelog.kind.draft_create': 'Черновик создан',
|
'changelog.kind.draft_create': 'Черновик создан',
|
||||||
|
'changelog.kind.draft_update': 'Черновик обновлён',
|
||||||
'changelog.kind.draft_review': 'Отправлен на ревью',
|
'changelog.kind.draft_review': 'Отправлен на ревью',
|
||||||
'changelog.kind.draft_approve': 'Одобрен',
|
'changelog.kind.draft_approve': 'Одобрен',
|
||||||
'changelog.kind.draft_reject': 'Отклонён',
|
'changelog.kind.draft_reject': 'Отклонён',
|
||||||
@@ -491,6 +492,13 @@ i18n
|
|||||||
'createDraft.creating': 'Создание…',
|
'createDraft.creating': 'Создание…',
|
||||||
'createDraft.toast.created': 'Черновик создан',
|
'createDraft.toast.created': 'Черновик создан',
|
||||||
'createDraft.toast.failed': 'Не удалось создать черновик',
|
'createDraft.toast.failed': 'Не удалось создать черновик',
|
||||||
|
'createDraft.editTitle': 'Редактировать черновик',
|
||||||
|
'createDraft.editIntro': 'После сохранения статус черновика вернётся в draft — отправьте его на ревью заново.',
|
||||||
|
'createDraft.submitEdit': 'Сохранить',
|
||||||
|
'createDraft.updating': 'Сохранение…',
|
||||||
|
'createDraft.toast.updated': 'Черновик обновлён',
|
||||||
|
'createDraft.toast.updateFailed': 'Не удалось обновить черновик',
|
||||||
|
'workflow.schemaDraft.actions.edit': 'Редактировать',
|
||||||
// === Schema review queue panel ===
|
// === Schema review queue panel ===
|
||||||
'reviews.schema.title': 'Изменения схем',
|
'reviews.schema.title': 'Изменения схем',
|
||||||
'reviews.schema.label': 'Schema workflow',
|
'reviews.schema.label': 'Schema workflow',
|
||||||
@@ -1036,6 +1044,7 @@ i18n
|
|||||||
'changelog.kind.definition_create': 'Dictionary created',
|
'changelog.kind.definition_create': 'Dictionary created',
|
||||||
'changelog.kind.definition_update': 'Schema updated',
|
'changelog.kind.definition_update': 'Schema updated',
|
||||||
'changelog.kind.draft_create': 'Draft created',
|
'changelog.kind.draft_create': 'Draft created',
|
||||||
|
'changelog.kind.draft_update': 'Draft updated',
|
||||||
'changelog.kind.draft_review': 'Sent for review',
|
'changelog.kind.draft_review': 'Sent for review',
|
||||||
'changelog.kind.draft_approve': 'Approved',
|
'changelog.kind.draft_approve': 'Approved',
|
||||||
'changelog.kind.draft_reject': 'Rejected',
|
'changelog.kind.draft_reject': 'Rejected',
|
||||||
@@ -1110,6 +1119,13 @@ i18n
|
|||||||
'createDraft.creating': 'Creating…',
|
'createDraft.creating': 'Creating…',
|
||||||
'createDraft.toast.created': 'Draft created',
|
'createDraft.toast.created': 'Draft created',
|
||||||
'createDraft.toast.failed': 'Failed to create draft',
|
'createDraft.toast.failed': 'Failed to create draft',
|
||||||
|
'createDraft.editTitle': 'Edit draft',
|
||||||
|
'createDraft.editIntro': 'After save, the draft status reverts to draft — re-submit for review explicitly.',
|
||||||
|
'createDraft.submitEdit': 'Save',
|
||||||
|
'createDraft.updating': 'Saving…',
|
||||||
|
'createDraft.toast.updated': 'Draft updated',
|
||||||
|
'createDraft.toast.updateFailed': 'Failed to update draft',
|
||||||
|
'workflow.schemaDraft.actions.edit': 'Edit',
|
||||||
// === Schema review queue panel ===
|
// === Schema review queue panel ===
|
||||||
'reviews.schema.title': 'Schema changes',
|
'reviews.schema.title': 'Schema changes',
|
||||||
'reviews.schema.label': 'Schema workflow',
|
'reviews.schema.label': 'Schema workflow',
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partial update черновика без recreate. Полезно maker'у после
|
||||||
|
* {@code CHANGES_REQUESTED} — вместо withdraw + create нового draft'а
|
||||||
|
* (с потерей id, history, reviewer assignment'ов) — обновляем content
|
||||||
|
* существующего.
|
||||||
|
*
|
||||||
|
* <p>Endpoint: {@code PATCH /api/v1/dictionaries/{name}/drafts/{draftId}}.
|
||||||
|
*
|
||||||
|
* <p>Status-gate (enforced backend'ом):
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code DRAFT} — OK.</li>
|
||||||
|
* <li>{@code CHANGES_REQUESTED} — OK; status переводится обратно в
|
||||||
|
* {@code DRAFT} автоматически (maker должен ре-submit).</li>
|
||||||
|
* <li>{@code REVIEW_PENDING} / {@code APPROVED} — 409 invalid_transition.
|
||||||
|
* Pending review не должен ловить moving target; approved content
|
||||||
|
* freezing до publish.</li>
|
||||||
|
* <li>Terminal — 409.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Все поля optional. Если null — соответствующее поле draft'а не
|
||||||
|
* меняется. Это позволяет, например, обновить только reason без замены
|
||||||
|
* proposedSchema.
|
||||||
|
*/
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public record UpdateSchemaDraftRequest(
|
||||||
|
/** Replacement JSON Schema (full document). null = не меняем. */
|
||||||
|
JsonNode proposedSchema,
|
||||||
|
/** Новая maker rationale. null = не меняем. */
|
||||||
|
String reason) {}
|
||||||
+2
@@ -55,6 +55,7 @@ public class ChangelogService {
|
|||||||
"DEFINITION_CREATED",
|
"DEFINITION_CREATED",
|
||||||
"DEFINITION_UPDATED",
|
"DEFINITION_UPDATED",
|
||||||
"SCHEMA_DRAFT_CREATED",
|
"SCHEMA_DRAFT_CREATED",
|
||||||
|
"SCHEMA_DRAFT_UPDATED",
|
||||||
"SCHEMA_DRAFT_REVIEW_REQUESTED",
|
"SCHEMA_DRAFT_REVIEW_REQUESTED",
|
||||||
"SCHEMA_DRAFT_DECIDED",
|
"SCHEMA_DRAFT_DECIDED",
|
||||||
"SCHEMA_DRAFT_PUBLISHED",
|
"SCHEMA_DRAFT_PUBLISHED",
|
||||||
@@ -201,6 +202,7 @@ public class ChangelogService {
|
|||||||
case "DEFINITION_CREATED" -> "definition_create";
|
case "DEFINITION_CREATED" -> "definition_create";
|
||||||
case "DEFINITION_UPDATED" -> "definition_update";
|
case "DEFINITION_UPDATED" -> "definition_update";
|
||||||
case "SCHEMA_DRAFT_CREATED" -> "draft_create";
|
case "SCHEMA_DRAFT_CREATED" -> "draft_create";
|
||||||
|
case "SCHEMA_DRAFT_UPDATED" -> "draft_update";
|
||||||
case "SCHEMA_DRAFT_REVIEW_REQUESTED" -> "draft_review";
|
case "SCHEMA_DRAFT_REVIEW_REQUESTED" -> "draft_review";
|
||||||
case "SCHEMA_DRAFT_DECIDED" -> switch (action == null ? "" : action) {
|
case "SCHEMA_DRAFT_DECIDED" -> switch (action == null ? "" : action) {
|
||||||
case "DRAFT_APPROVE" -> "draft_approve";
|
case "DRAFT_APPROVE" -> "draft_approve";
|
||||||
|
|||||||
+60
@@ -11,6 +11,7 @@ import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.CreateSchemaDr
|
|||||||
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.DecisionRequest;
|
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.DecisionRequest;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.PublishDraftRequest;
|
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.PublishDraftRequest;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.SubmitForReviewRequest;
|
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.SubmitForReviewRequest;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.UpdateSchemaDraftRequest;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
@@ -348,6 +349,65 @@ public class SchemaDraftService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maker правит proposedSchema или reason в существующем draft'е без
|
||||||
|
* recreate. Полезно после CHANGES_REQUESTED: вместо withdraw + create
|
||||||
|
* (с потерей draftId, history события, reviewer assignment) — просто
|
||||||
|
* обновляем content.
|
||||||
|
*
|
||||||
|
* <p>Status-gate: только {@code DRAFT} и {@code CHANGES_REQUESTED}.
|
||||||
|
* Из {@code REVIEW_PENDING} update запрещён — pending review должен
|
||||||
|
* сначала разрешиться decision'ом (reviewer не должен ловить moving
|
||||||
|
* target). Из {@code APPROVED} тоже запрещён — после approve content
|
||||||
|
* заморожен до publish (иначе одобрили одну схему, опубликовали другую).
|
||||||
|
*
|
||||||
|
* <p>Side effect: если status был {@code CHANGES_REQUESTED}, переводим
|
||||||
|
* обратно в {@code DRAFT} чтобы maker должен был осознанно submit'нуть
|
||||||
|
* на ревью заново. Это match'ит логичную ментальную модель «правки →
|
||||||
|
* новый draft state → re-submit».
|
||||||
|
*
|
||||||
|
* <p>Audit + outbox event {@code SchemaDraftUpdated} даёт changelog
|
||||||
|
* timeline видимость "maker правил черновик после review feedback".
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public DictionarySchemaDraft updateContent(UUID draftId, UpdateSchemaDraftRequest req) {
|
||||||
|
DictionarySchemaDraft draft = findById(draftId);
|
||||||
|
assertMakerActor(draft, "update_content");
|
||||||
|
|
||||||
|
SchemaDraftStatus current = draft.getStatus();
|
||||||
|
if (current != SchemaDraftStatus.DRAFT && current != SchemaDraftStatus.CHANGES_REQUESTED) {
|
||||||
|
throw OrdinisException.conflict(
|
||||||
|
"invalid_transition",
|
||||||
|
"Draft " + draftId + " status=" + current.dbValue()
|
||||||
|
+ " — update возможен только из draft или changes_requested.");
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode beforeSchema = draft.getProposedSchema();
|
||||||
|
if (req.proposedSchema() != null) {
|
||||||
|
draft.setProposedSchema(req.proposedSchema());
|
||||||
|
}
|
||||||
|
if (req.reason() != null) {
|
||||||
|
draft.setReason(req.reason());
|
||||||
|
}
|
||||||
|
// CHANGES_REQUESTED → DRAFT: maker должен заново submit'нуть после правок.
|
||||||
|
if (current == SchemaDraftStatus.CHANGES_REQUESTED) {
|
||||||
|
draft.setStatus(SchemaDraftStatus.DRAFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
DictionarySchemaDraft saved = repository.save(draft);
|
||||||
|
DictionaryDefinition def = definitionService.findById(saved.getDictionaryId());
|
||||||
|
emitEvent("SchemaDraftUpdated", saved, def, Map.of(
|
||||||
|
"previousStatus", current.dbValue()));
|
||||||
|
if (auditLogger != null) {
|
||||||
|
auditLogger.schemaDraftEvent(
|
||||||
|
"SCHEMA_DRAFT_UPDATED", "DRAFT_UPDATE", def,
|
||||||
|
beforeSchema, saved.getProposedSchema());
|
||||||
|
}
|
||||||
|
log.info("schema_draft_updated id={} dict={} maker={} prevStatus={}",
|
||||||
|
saved.getId(), def.getName(), saved.getMakerId(), current.dbValue());
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
/** Maker отзывает свой draft (только non-terminal статусы). */
|
/** Maker отзывает свой draft (только non-terminal статусы). */
|
||||||
@Transactional
|
@Transactional
|
||||||
public DictionarySchemaDraft withdraw(UUID draftId) {
|
public DictionarySchemaDraft withdraw(UUID draftId) {
|
||||||
|
|||||||
+18
@@ -7,6 +7,7 @@ import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.DecisionReques
|
|||||||
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.PublishDraftRequest;
|
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.PublishDraftRequest;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.SchemaDraftResponse;
|
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.SchemaDraftResponse;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.SubmitForReviewRequest;
|
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.SubmitForReviewRequest;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.UpdateSchemaDraftRequest;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.service.schemaworkflow.SchemaDraftService;
|
import cloud.nstart.terravault.ordinis.restapi.service.schemaworkflow.SchemaDraftService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@@ -130,6 +131,23 @@ public class SchemaDraftController {
|
|||||||
"publishedAt", draft.getPublishedAt());
|
"publishedAt", draft.getPublishedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partial update content draft'а без recreate. Status-gate:
|
||||||
|
* только DRAFT и CHANGES_REQUESTED. CHANGES_REQUESTED → DRAFT
|
||||||
|
* автоматически после update (maker должен ре-submit).
|
||||||
|
*
|
||||||
|
* <p>409 {@code invalid_transition} — review_pending / approved /
|
||||||
|
* terminal. 403 {@code maker_only} — actor != maker.
|
||||||
|
*/
|
||||||
|
@org.springframework.web.bind.annotation.PatchMapping("/api/v1/dictionaries/{name}/drafts/{draftId}")
|
||||||
|
public SchemaDraftResponse update(
|
||||||
|
@PathVariable String name,
|
||||||
|
@PathVariable UUID draftId,
|
||||||
|
@Valid @RequestBody UpdateSchemaDraftRequest req) {
|
||||||
|
DictionarySchemaDraft draft = draftService.updateContent(draftId, req);
|
||||||
|
return SchemaDraftResponse.from(draft, name);
|
||||||
|
}
|
||||||
|
|
||||||
/** Withdraw — maker отменяет non-terminal draft. */
|
/** Withdraw — maker отменяет non-terminal draft. */
|
||||||
@DeleteMapping("/api/v1/dictionaries/{name}/drafts/{draftId}")
|
@DeleteMapping("/api/v1/dictionaries/{name}/drafts/{draftId}")
|
||||||
public SchemaDraftResponse withdraw(
|
public SchemaDraftResponse withdraw(
|
||||||
|
|||||||
Reference in New Issue
Block a user