feat(approval-ux): inline draft CTA + reviewer diff summary + tightened drawer
Acts on the two "build now" items from the design review of the approval
workflow UX (other five went to TODOS.md). Plus the TODOS.md file itself,
authored during the same review.
1. **Inline "Создать черновик с моими изменениями" CTA**
(DictionaryEditorDialog + CreateSchemaDraftModal)
Was: when a maker edited an approval-required dict, Save disabled and a
warning told them to "close the editor and click 'Создать черновик схемы'
on the dict page." Three friction steps and a hunt for a button.
Now: the warning Alert carries an action button — "Создать черновик с
моими изменениями" — that opens CreateSchemaDraftModal in-place with the
maker's in-progress schemaJson pre-loaded. They add a reason and submit.
No retyping, no hunt.
Implementation:
- CreateSchemaDraftModal accepts optional `initialProposedSchema` prop.
Seed order: edit-mode draft → caller pre-fill → live HEAD.
- DictionaryEditorDialog mounts the modal inline and triggers it from the
Alert action. Closes the editor on successful create.
2. **Diff summary chips in ReviewDrawer**
Was: two raw JSON dumps side by side. Reviewer had to eyeball-diff long
records to find the actual edit.
Now: a row of summary chips above the panes — "+N added, −M removed,
~K changed" with the field names inline. Computed shallow client-side
(shallowDiffSummary helper) — ordinis records are flat-ish at the top
level, no need for a backend diff endpoint. Skipped for CLOSE op (no
proposed) and when both sides are null.
3. **Tightened drawer header + trimmed empty-state copy**
Was: single flex-wrap line with four spans (badge + bk + maker + ts) —
visually busy. Plus a verbose empty-state description: "Когда maker
отправит schema draft на ревью, он появится здесь."
Now: two-row layout (identity row on top, provenance row below) so the
eye lands on Badge + BK first. Empty-state copy shortened to "Очередь
пуста" / "Схемы на ревью появятся здесь."
Other five gaps (Мои tab, full state-coverage pass, DESIGN.md, a11y audit,
maker notifications) recorded in TODOS.md.
This commit is contained in:
@@ -20,6 +20,7 @@ import { dictionaryDetailQuery, useDictionaries } from '@/api/queries'
|
||||
import type { CreateDictionaryRequest, DataScope, DictionaryDetail } from '@/api/client'
|
||||
import { SchemaBuilder } from './SchemaBuilder'
|
||||
import { EventsPreviewTab } from './EventsPreviewTab'
|
||||
import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
|
||||
import {
|
||||
buildSchemaJson,
|
||||
diffSchemas,
|
||||
@@ -84,6 +85,11 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
||||
const [properties, setProperties] = useState<PropertyDef[]>(parsed.properties)
|
||||
const [idSource, setIdSource] = useState<string>(parsed.idSource ?? '')
|
||||
const [nameError, setNameError] = useState<string | null>(null)
|
||||
/** Open state for the "Создать черновик с моими изменениями" CTA. The CTA
|
||||
* is the inline handoff from the disabled-Save path (requiresDraftFlow)
|
||||
* into the schema-draft modal with the maker's in-progress schemaJson
|
||||
* pre-loaded — no retyping. */
|
||||
const [draftHandoffOpen, setDraftHandoffOpen] = useState(false)
|
||||
|
||||
// Template (только в create-mode): admin выбирает существующий dict,
|
||||
// его schema + locale + scope копируются. Имя остаётся пустым (admin
|
||||
@@ -431,12 +437,29 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
||||
{serverError && <Alert variant="error">{serverError}</Alert>}
|
||||
|
||||
{requiresDraftFlow && (
|
||||
<Alert variant="warning">
|
||||
<Alert
|
||||
variant="warning"
|
||||
title={t('schema.approvalRequired.editorBlockTitle', {
|
||||
defaultValue: 'Изменения требуют ревью',
|
||||
})}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setDraftHandoffOpen(true)}
|
||||
>
|
||||
{t('schema.approvalRequired.openDraftCta', {
|
||||
defaultValue: 'Создать черновик с моими изменениями',
|
||||
})}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{t('schema.approvalRequired.editorBlock', {
|
||||
defaultValue:
|
||||
'Этот словарь требует ревью изменений. Закройте редактор и нажмите ' +
|
||||
'«Создать черновик схемы» на странице справочника — там вы сможете ' +
|
||||
'отправить эти изменения на согласование.',
|
||||
'Этот словарь требует maker-checker review. Откройте черновик — ' +
|
||||
'ваши текущие изменения будут предзаполнены, вам нужно только ' +
|
||||
'указать причину и отправить на согласование.',
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
@@ -456,6 +479,24 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
||||
</Button>
|
||||
</FormActions>
|
||||
</div>
|
||||
{/* Inline handoff: CreateSchemaDraftModal mounted with the in-progress
|
||||
schemaJson pre-loaded. Maker hits "Создать черновик с моими изменениями"
|
||||
→ modal opens with the same edits they had → they add a reason and
|
||||
submit. No retyping, no hunt for a button on the dict page. */}
|
||||
{isEdit && initial && (
|
||||
<CreateSchemaDraftModal
|
||||
open={draftHandoffOpen}
|
||||
onClose={() => setDraftHandoffOpen(false)}
|
||||
detail={initial}
|
||||
initialProposedSchema={schemaJson}
|
||||
onCreated={() => {
|
||||
setDraftHandoffOpen(false)
|
||||
// Close the editor too — the maker's done editing live, the draft
|
||||
// is now the source of truth until a reviewer decides.
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,15 @@ type Props = {
|
||||
* CHANGES_REQUESTED — backend auto-transition CHANGES_REQUESTED → DRAFT.
|
||||
*/
|
||||
editDraft?: SchemaDraft
|
||||
/**
|
||||
* Optional pre-filled proposed schema. When passed (and not in edit mode),
|
||||
* the JSON editor seeds with this instead of `detail.schemaJson`. Used by
|
||||
* the "Создать черновик с моими изменениями" CTA in DictionaryEditorDialog:
|
||||
* the maker tweaks the schema, hits the disabled Save, then jumps straight
|
||||
* into the draft modal with their in-progress edits already loaded. No
|
||||
* retyping.
|
||||
*/
|
||||
initialProposedSchema?: unknown
|
||||
/** Callback после create — parent может закрыть modal + открыть drawer. */
|
||||
onCreated?: (draftId: string) => void
|
||||
}
|
||||
@@ -46,18 +55,31 @@ type Props = {
|
||||
* вернёт detail в message.</li>
|
||||
* </ul>
|
||||
*/
|
||||
export function CreateSchemaDraftModal({ open, onClose, detail, editDraft, onCreated }: Props) {
|
||||
export function CreateSchemaDraftModal({
|
||||
open,
|
||||
onClose,
|
||||
detail,
|
||||
editDraft,
|
||||
initialProposedSchema,
|
||||
onCreated,
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const isEdit = Boolean(editDraft)
|
||||
// В edit mode seed'им из draft'овой proposedSchema, иначе из live HEAD.
|
||||
// Seed order: edit-mode draft → caller-provided initialProposedSchema →
|
||||
// live HEAD. Caller pre-fill wins over live HEAD so the maker's in-progress
|
||||
// edits from DictionaryEditorDialog flow through.
|
||||
const initialJson = useMemo(
|
||||
() =>
|
||||
JSON.stringify(
|
||||
editDraft ? editDraft.proposedSchema : detail.schemaJson,
|
||||
editDraft
|
||||
? editDraft.proposedSchema
|
||||
: initialProposedSchema !== undefined
|
||||
? initialProposedSchema
|
||||
: detail.schemaJson,
|
||||
null,
|
||||
2,
|
||||
),
|
||||
[editDraft, detail.schemaJson],
|
||||
[editDraft, initialProposedSchema, detail.schemaJson],
|
||||
)
|
||||
const initialReason = editDraft?.reason ?? ''
|
||||
const [json, setJson] = useState(initialJson)
|
||||
|
||||
@@ -40,6 +40,46 @@ const operationVariant = (op: DraftOperation): 'info' | 'success' | 'warning' =>
|
||||
return 'info'
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow diff summary between two record JSON payloads. Reviewer sees three
|
||||
* counts above the side-by-side dump — "+N added, −M removed, ~K changed" —
|
||||
* so they don't have to eyeball-diff long records to find the actual edit.
|
||||
*
|
||||
* Shallow on purpose: ordinis records are flat-ish dicts at the top level
|
||||
* (business fields, no deep object trees). Nested object changes count as
|
||||
* one "changed" entry on the outer key. Good enough for reviewer at-a-glance.
|
||||
*
|
||||
* Returns null when before/after both null (CLOSE op, or first CREATE) —
|
||||
* caller can skip the summary row entirely.
|
||||
*/
|
||||
type DraftDiffSummary = {
|
||||
added: string[]
|
||||
removed: string[]
|
||||
changed: string[]
|
||||
}
|
||||
function shallowDiffSummary(
|
||||
before: Record<string, unknown> | null | undefined,
|
||||
after: Record<string, unknown> | null | undefined,
|
||||
): DraftDiffSummary | null {
|
||||
if (!before && !after) return null
|
||||
const b = before ?? {}
|
||||
const a = after ?? {}
|
||||
const added: string[] = []
|
||||
const removed: string[] = []
|
||||
const changed: string[] = []
|
||||
for (const k of Object.keys(a)) {
|
||||
if (!(k in b)) {
|
||||
added.push(k)
|
||||
} else if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) {
|
||||
changed.push(k)
|
||||
}
|
||||
}
|
||||
for (const k of Object.keys(b)) {
|
||||
if (!(k in a)) removed.push(k)
|
||||
}
|
||||
return { added, removed, changed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval Workflow v2 — reviewer queue (D3=A pool model).
|
||||
*
|
||||
@@ -192,10 +232,10 @@ function ReviewsPage() {
|
||||
{schemasCount === 0 && !schemaQueue.isLoading && (
|
||||
<EmptyState
|
||||
title={t('reviews.schema.empty', {
|
||||
defaultValue: 'Нет схем на ревью',
|
||||
defaultValue: 'Очередь пуста',
|
||||
})}
|
||||
description={t('reviews.schema.emptyDescription', {
|
||||
defaultValue: 'Когда maker отправит schema draft на ревью, он появится здесь.',
|
||||
defaultValue: 'Схемы на ревью появятся здесь.',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
@@ -523,16 +563,23 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
|
||||
|
||||
{draft && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2 text-cell">
|
||||
<Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge>
|
||||
<span className="font-mono">{draft.businessKey}</span>
|
||||
<span className="text-mute inline-flex items-center gap-1">
|
||||
{t('reviews.drawer.maker')}: <UserCell uuid={draft.makerId} />
|
||||
</span>
|
||||
<span className="text-mute">
|
||||
{t('reviews.drawer.submitted')}:{' '}
|
||||
{new Date(draft.submittedAt).toLocaleString()}
|
||||
</span>
|
||||
{/* Tightened header: top row is identity (op + bk), bottom row is
|
||||
provenance (who/when). Was a single flex-wrap line with four
|
||||
spans — visually busy, hard to scan. */}
|
||||
<div className="space-y-1 text-cell">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge>
|
||||
<span className="font-mono">{draft.businessKey}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-mute">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t('reviews.drawer.maker')}: <UserCell uuid={draft.makerId} />
|
||||
</span>
|
||||
<span>
|
||||
{t('reviews.drawer.submitted')}:{' '}
|
||||
{new Date(draft.submittedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draft.makerComment && (
|
||||
@@ -544,6 +591,56 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diff summary chips above the side-by-side dump — reviewer sees
|
||||
"+1 added, ~2 changed" before eyeballing the JSON. Skipped for
|
||||
CLOSE op (no proposed) and when both sides are null. */}
|
||||
{(() => {
|
||||
const summary =
|
||||
draft.operation === 'CLOSE'
|
||||
? null
|
||||
: shallowDiffSummary(
|
||||
liveQ.data?.data as Record<string, unknown> | null | undefined,
|
||||
draft.data as Record<string, unknown> | null | undefined,
|
||||
)
|
||||
if (!summary) return null
|
||||
const empty =
|
||||
summary.added.length === 0 &&
|
||||
summary.removed.length === 0 &&
|
||||
summary.changed.length === 0
|
||||
if (empty) return null
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 text-mono text-cap tabular-nums">
|
||||
{summary.added.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-aurora">
|
||||
<span className="font-bold">+</span>
|
||||
<span>{summary.added.length}</span>
|
||||
<span className="text-mute lowercase">
|
||||
({summary.added.join(', ')})
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{summary.removed.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-danger">
|
||||
<span className="font-bold">−</span>
|
||||
<span>{summary.removed.length}</span>
|
||||
<span className="text-mute lowercase">
|
||||
({summary.removed.join(', ')})
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{summary.changed.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-warn">
|
||||
<span className="font-bold">~</span>
|
||||
<span>{summary.changed.length}</span>
|
||||
<span className="text-mute lowercase">
|
||||
({summary.changed.join(', ')})
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-cap text-mute mb-1">
|
||||
|
||||
Reference in New Issue
Block a user