feat(schema-workflow): PATCH /drafts/{id} — edit без recreate (A)
This commit is contained in:
@@ -32,6 +32,8 @@ export function kindIcon(kind: ChangelogKind): Icon {
|
||||
return PencilSimpleIcon
|
||||
case 'draft_create':
|
||||
return ChatCircleTextIcon
|
||||
case 'draft_update':
|
||||
return PencilSimpleIcon
|
||||
case 'draft_review':
|
||||
return EyeIcon
|
||||
case 'draft_approve':
|
||||
@@ -61,6 +63,7 @@ export function kindBadgeVariant(kind: ChangelogKind): BadgeProps['variant'] {
|
||||
case 'draft_changes_requested':
|
||||
return 'warning'
|
||||
case 'draft_create':
|
||||
case 'draft_update':
|
||||
case 'draft_review':
|
||||
case 'draft_withdraw':
|
||||
return 'neutral'
|
||||
@@ -72,6 +75,7 @@ export function kindLabel(kind: ChangelogKind, t: TFn): string {
|
||||
definition_create: 'Создан словарь',
|
||||
definition_update: 'Изменена схема',
|
||||
draft_create: 'Черновик создан',
|
||||
draft_update: 'Черновик обновлён',
|
||||
draft_review: 'Отправлен на ревью',
|
||||
draft_approve: 'Одобрен',
|
||||
draft_reject: 'Отклонён',
|
||||
|
||||
@@ -179,6 +179,7 @@ function SchemaDraftBanner({
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
dictionaryName={detail.name}
|
||||
currentHeadVersion={detail.schemaVersion}
|
||||
detail={detail}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user