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:
Andrei Zimin
2026-05-14 15:56:50 +03:00
parent bdae52ff28
commit fe7f9cd01f
4 changed files with 339 additions and 20 deletions
@@ -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)