feat(schema-workflow): PATCH /drafts/{id} — edit без recreate (A)

This commit is contained in:
Александр Зимин
2026-05-12 11:33:01 +00:00
parent ab6ec6ebec
commit b3a9104d54
11 changed files with 323 additions and 51 deletions
@@ -9,15 +9,21 @@ import {
toast,
} from '@/ui'
import { ArrowsClockwiseIcon, CheckSquareIcon } from '@phosphor-icons/react'
import type { DictionaryDetail } from '@/api/client'
import { useCreateSchemaDraft } from '@/api/mutations'
import type { DictionaryDetail, SchemaDraft } from '@/api/client'
import { useCreateSchemaDraft, useUpdateSchemaDraft } from '@/api/mutations'
type Props = {
open: boolean
onClose: () => void
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
}
@@ -39,26 +45,36 @@ type Props = {
* вернёт detail в message.</li>
* </ul>
*/
export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Props) {
export function CreateSchemaDraftModal({ open, onClose, detail, editDraft, onCreated }: Props) {
const { t } = useTranslation()
const isEdit = Boolean(editDraft)
// В edit mode seed'им из draft'овой proposedSchema, иначе из live HEAD.
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 [reason, setReason] = useState('')
const [reason, setReason] = useState(initialReason)
const [parseError, setParseError] = useState<string | null>(null)
// Reset state при re-open чтобы не показывать stale данные с прошлого dict'а.
useEffect(() => {
if (open) {
setJson(initialJson)
setReason('')
setReason(initialReason)
setParseError(null)
}
}, [open, initialJson])
}, [open, initialJson, initialReason])
const createMut = useCreateSchemaDraft(detail.name)
const updateMut = useUpdateSchemaDraft(detail.name)
const pending = createMut.isPending || updateMut.isPending
const handleFormat = () => {
try {
@@ -100,6 +116,39 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
setParseError((e as Error).message)
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(
{
fromVersion: detail.schemaVersion,
@@ -114,18 +163,12 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
onCreated?.(draft.draftId)
onClose()
},
onError: (err) => {
let msg = t('createDraft.toast.failed', {
defaultValue: 'Не удалось создать черновик',
})
if (axios.isAxiosError(err)) {
const body = err.response?.data as
| { code?: string; message?: string }
| undefined
msg = body?.message ?? body?.code ?? msg
}
toast.error(msg)
},
onError: (err) =>
handleErrorToast(
err,
'createDraft.toast.failed',
'Не удалось создать черновик',
),
},
)
}
@@ -135,15 +178,28 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
isOpen={open}
onClose={onClose}
size="xl"
title={t('createDraft.title', { defaultValue: 'Создать черновик схемы' })}
description={`${detail.name} · v${detail.schemaVersion}`}
title={
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">
<Alert variant="info">
{t('createDraft.intro', {
defaultValue:
'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.',
})}
{isEdit
? t('createDraft.editIntro', {
defaultValue:
'После сохранения статус черновика вернётся в draft — отправьте его на ревью заново.',
})
: t('createDraft.intro', {
defaultValue:
'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.',
})}
</Alert>
{/* Reason */}
@@ -158,7 +214,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
defaultValue: 'Зачем эти изменения нужны (видно ревьюеру)',
})}
rows={2}
disabled={createMut.isPending}
disabled={pending}
/>
</div>
@@ -174,7 +230,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
variant="ghost"
size="sm"
onClick={handleFormat}
disabled={createMut.isPending}
disabled={pending}
leftIcon={<ArrowsClockwiseIcon weight="regular" size={14} />}
>
{t('createDraft.format', { defaultValue: 'Format' })}
@@ -184,7 +240,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
variant="ghost"
size="sm"
onClick={handleValidate}
disabled={createMut.isPending}
disabled={pending}
leftIcon={<CheckSquareIcon weight="regular" size={14} />}
>
{t('createDraft.validate', { defaultValue: 'Validate' })}
@@ -201,7 +257,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
if (parseError) setParseError(null)
}}
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"
/>
{parseError && (
@@ -217,7 +273,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
type="button"
variant="ghost"
onClick={onClose}
disabled={createMut.isPending}
disabled={pending}
>
{t('common.cancel', { defaultValue: 'Отмена' })}
</Button>
@@ -225,11 +281,15 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
type="button"
variant="primary"
onClick={handleSubmit}
disabled={createMut.isPending || Boolean(parseError)}
disabled={pending || Boolean(parseError)}
>
{createMut.isPending
? t('createDraft.creating', { defaultValue: 'Создание…' })
: t('createDraft.submit', { defaultValue: 'Создать черновик' })}
{pending
? isEdit
? t('createDraft.updating', { defaultValue: 'Сохранение…' })
: t('createDraft.creating', { defaultValue: 'Создание…' })
: isEdit
? t('createDraft.submitEdit', { defaultValue: 'Сохранить' })
: t('createDraft.submit', { defaultValue: 'Создать черновик' })}
</Button>
</div>
</div>
@@ -5,6 +5,7 @@ import { Alert, Badge, Button, Drawer, LoadingBlock, TextArea, toast } from '@/u
import {
CheckIcon,
PaperPlaneTiltIcon,
PencilSimpleIcon,
XCircleIcon,
ArrowsCounterClockwiseIcon,
RocketIcon,
@@ -12,10 +13,12 @@ import {
} from '@phosphor-icons/react'
import {
isActiveSchemaDraft,
type DictionaryDetail,
type SchemaDraft,
type SchemaDraftStatus,
type SchemaDraftVerdict,
} from '@/api/client'
import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
import { useDictActiveSchemaDraft } from '@/api/queries'
import {
useDecideSchemaDraft,
@@ -31,6 +34,9 @@ type Props = {
dictionaryName: string
/** Current live schemaVersion — passed для publish expectedHeadVersion check. */
currentHeadVersion: string | undefined
/** Optional full dict detail — нужен для edit modal seeding. Если не
* передан, edit button скрывается. */
detail?: DictionaryDetail
}
/**
@@ -58,9 +64,11 @@ export function SchemaDraftDrawer({
onClose,
dictionaryName,
currentHeadVersion,
detail,
}: Props) {
const { t } = useTranslation()
const [comment, setComment] = useState('')
const [editOpen, setEditOpen] = useState(false)
const { data: draft, isLoading, error } = useDictActiveSchemaDraft(
open ? dictionaryName : undefined,
)
@@ -241,9 +249,18 @@ export function SchemaDraftDrawer({
onDecide={onDecide}
onPublish={onPublish}
onWithdraw={onWithdraw}
onEdit={detail ? () => setEditOpen(true) : undefined}
disabled={anyPending}
hasCurrentVersion={Boolean(currentHeadVersion)}
/>
{detail && (
<CreateSchemaDraftModal
open={editOpen}
onClose={() => setEditOpen(false)}
detail={detail}
editDraft={draft}
/>
)}
</>
)}
</div>
@@ -380,6 +397,7 @@ function DraftActions({
onDecide,
onPublish,
onWithdraw,
onEdit,
disabled,
hasCurrentVersion,
}: {
@@ -388,6 +406,7 @@ function DraftActions({
onDecide: (v: SchemaDraftVerdict) => void
onPublish: () => void
onWithdraw: () => void
onEdit?: () => void
disabled: boolean
hasCurrentVersion: boolean
}) {
@@ -411,21 +430,36 @@ function DraftActions({
return (
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-line">
{(status === 'draft' || status === 'changes_requested') && (
<Button
variant="primary"
size="sm"
onClick={onSubmitReview}
disabled={disabled}
leftIcon={<PaperPlaneTiltIcon weight="regular" size={14} />}
>
{status === 'draft'
? t('workflow.schemaDraft.actions.submit', {
defaultValue: 'Отправить на ревью',
})
: t('workflow.schemaDraft.actions.resubmit', {
defaultValue: 'Доработать и отправить',
<>
{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"
size="sm"
onClick={onSubmitReview}
disabled={disabled}
leftIcon={<PaperPlaneTiltIcon weight="regular" size={14} />}
>
{status === 'draft'
? t('workflow.schemaDraft.actions.submit', {
defaultValue: 'Отправить на ревью',
})
: t('workflow.schemaDraft.actions.resubmit', {
defaultValue: 'Доработать и отправить',
})}
</Button>
</>
)}
{status === 'review_pending' && canReview && (
<>