feat(schema-workflow): Phase 1c — CreateModal + /reviews extension + RBAC stub

This commit is contained in:
Александр Зимин
2026-05-12 11:20:29 +00:00
parent 00fe254ccc
commit ac3395f4de
6 changed files with 455 additions and 4 deletions
@@ -0,0 +1,238 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import axios from 'axios'
import {
Alert,
Button,
Modal,
TextArea,
toast,
} from '@/ui'
import { ArrowsClockwiseIcon, CheckSquareIcon } from '@phosphor-icons/react'
import type { DictionaryDetail } from '@/api/client'
import { useCreateSchemaDraft } from '@/api/mutations'
type Props = {
open: boolean
onClose: () => void
detail: DictionaryDetail
/** Optional callback после успешного create — parent может закрыть modal +
* открыть SchemaDraftDrawer чтоб maker сразу submit'нул. */
onCreated?: (draftId: string) => void
}
/**
* Создание schema draft'а с editor'ом proposed schema. Минимальная версия:
* JSON textarea с {@code Format} и {@code Validate} buttons. Phase 1d
* может upgrade'ить до Monaco / visual schema editor.
*
* <p>Initial value seeded из текущего {@code detail.schemaJson}. User editing
* правит proposedSchema, добавляет {@code reason}, submit'ит.
*
* <p>Backend errors surface'ятся как toast:
* <ul>
* <li>409 {@code version_mismatch} — кто-то опубликовал свой draft пока
* этот пользователь готовился. Refresh page + retry.</li>
* <li>409 {@code active_draft_exists} — уже есть non-terminal draft.
* Пользователь должен open existing.</li>
* <li>400 {@code invalid_schema} — proposedSchema не валидна. Backend
* вернёт detail в message.</li>
* </ul>
*/
export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Props) {
const { t } = useTranslation()
const initialJson = useMemo(
() => JSON.stringify(detail.schemaJson, null, 2),
[detail.schemaJson],
)
const [json, setJson] = useState(initialJson)
const [reason, setReason] = useState('')
const [parseError, setParseError] = useState<string | null>(null)
// Reset state при re-open чтобы не показывать stale данные с прошлого dict'а.
useEffect(() => {
if (open) {
setJson(initialJson)
setReason('')
setParseError(null)
}
}, [open, initialJson])
const createMut = useCreateSchemaDraft(detail.name)
const handleFormat = () => {
try {
const parsed = JSON.parse(json)
setJson(JSON.stringify(parsed, null, 2))
setParseError(null)
} catch (e) {
setParseError((e as Error).message)
}
}
const handleValidate = () => {
try {
const parsed = JSON.parse(json)
if (typeof parsed !== 'object' || parsed === null) {
setParseError(
t('createDraft.notObject', {
defaultValue: 'Schema должна быть JSON object',
}),
)
return false
}
setParseError(null)
toast.success(
t('createDraft.validationPassed', { defaultValue: 'JSON валиден' }),
)
return true
} catch (e) {
setParseError((e as Error).message)
return false
}
}
const handleSubmit = () => {
let parsed: unknown
try {
parsed = JSON.parse(json)
} catch (e) {
setParseError((e as Error).message)
return
}
createMut.mutate(
{
fromVersion: detail.schemaVersion,
reason: reason || undefined,
proposedSchema: parsed,
},
{
onSuccess: (draft) => {
toast.success(
t('createDraft.toast.created', { defaultValue: 'Черновик создан' }),
)
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)
},
},
)
}
return (
<Modal
isOpen={open}
onClose={onClose}
size="xl"
title={t('createDraft.title', { defaultValue: 'Создать черновик схемы' })}
description={`${detail.name} · v${detail.schemaVersion}`}
>
<div className="space-y-4">
<Alert variant="info">
{t('createDraft.intro', {
defaultValue:
'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.',
})}
</Alert>
{/* Reason */}
<div>
<label className="block text-cap text-mute font-display uppercase tracking-wider mb-1">
{t('createDraft.reason', { defaultValue: 'Причина изменений' })}
</label>
<TextArea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t('createDraft.reasonPlaceholder', {
defaultValue: 'Зачем эти изменения нужны (видно ревьюеру)',
})}
rows={2}
disabled={createMut.isPending}
/>
</div>
{/* JSON Schema editor */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-cap text-mute font-display uppercase tracking-wider">
{t('createDraft.schema', { defaultValue: 'Схема (JSON)' })}
</label>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleFormat}
disabled={createMut.isPending}
leftIcon={<ArrowsClockwiseIcon weight="regular" size={14} />}
>
{t('createDraft.format', { defaultValue: 'Format' })}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleValidate}
disabled={createMut.isPending}
leftIcon={<CheckSquareIcon weight="regular" size={14} />}
>
{t('createDraft.validate', { defaultValue: 'Validate' })}
</Button>
</div>
</div>
{/* Custom monospace textarea — для full Monaco editor'а Phase 1d.
Высота min-h-[400px] чтобы средняя schema ~30-50 полей помещалась
без скролла. */}
<textarea
value={json}
onChange={(e) => {
setJson(e.target.value)
if (parseError) setParseError(null)
}}
spellCheck={false}
disabled={createMut.isPending}
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 && (
<Alert variant="error" className="mt-2">
<span className="font-mono text-cell">{parseError}</span>
</Alert>
)}
</div>
{/* Footer actions */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-line">
<Button
type="button"
variant="ghost"
onClick={onClose}
disabled={createMut.isPending}
>
{t('common.cancel', { defaultValue: 'Отмена' })}
</Button>
<Button
type="button"
variant="primary"
onClick={handleSubmit}
disabled={createMut.isPending || Boolean(parseError)}
>
{createMut.isPending
? t('createDraft.creating', { defaultValue: 'Создание…' })
: t('createDraft.submit', { defaultValue: 'Создать черновик' })}
</Button>
</div>
</div>
</Modal>
)
}
@@ -23,6 +23,7 @@ import {
useSubmitSchemaDraftForReview,
useWithdrawSchemaDraft,
} from '@/api/mutations'
import { useCanPublishSchema, useCanReviewSchema } from '@/auth/usePermissions'
type Props = {
open: boolean
@@ -391,6 +392,11 @@ function DraftActions({
hasCurrentVersion: boolean
}) {
const { t } = useTranslation()
// Phase 1c RBAC stub: для review_pending reviewer-only действия скрываем
// если нет approve role. Publish — отдельная role. Backend всё равно
// защищает. Phase 1d intergrate'нет Keycloak realm_access.roles.
const canReview = useCanReviewSchema()
const canPublish = useCanPublishSchema()
if (!isActiveSchemaDraft(status)) {
return (
@@ -421,7 +427,7 @@ function DraftActions({
})}
</Button>
)}
{status === 'review_pending' && (
{status === 'review_pending' && canReview && (
<>
<Button
variant="primary"
@@ -454,7 +460,7 @@ function DraftActions({
</Button>
</>
)}
{status === 'approved' && (
{status === 'approved' && canPublish && (
<Button
variant="primary"
size="sm"