feat(schema-workflow): Phase 1c — CreateModal + /reviews extension + RBAC stub
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user