refactor(approval-ux): eng-review fixes — extract helpers, add tests, register i18n
This commit is contained in:
@@ -52,6 +52,42 @@ const SCOPE_OPTIONS = (t: (k: string) => string): SelectOption[] => [
|
||||
|
||||
type EditorTab = 'metadata' | 'schema' | 'preview' | 'events'
|
||||
|
||||
/**
|
||||
* Decide whether the editor's Save button should be blocked because the
|
||||
* change has to go through the schema-draft workflow instead of inline PUT.
|
||||
* Mirrors the three backend gate clauses in DictionaryDefinitionService
|
||||
* exactly so the UX matches the server contract.
|
||||
*
|
||||
* Returns false (don't block) for create-mode, for non-approval-required
|
||||
* dicts, and for pure metadata edits (display name / description / locales
|
||||
* etc.) that leave schema + approval config alone.
|
||||
*
|
||||
* Exported for unit testability — the three-clause logic is the load-bearing
|
||||
* piece, easier to verify in isolation than mounting the full editor with
|
||||
* its query and mutation deps.
|
||||
*/
|
||||
export function computeRequiresDraftFlow({
|
||||
isEdit,
|
||||
initialApprovalRequired,
|
||||
initialMinRole,
|
||||
currentApprovalRequired,
|
||||
currentMinRole,
|
||||
schemaChangeCount,
|
||||
}: {
|
||||
isEdit: boolean
|
||||
initialApprovalRequired: boolean
|
||||
initialMinRole: string | null
|
||||
currentApprovalRequired: boolean
|
||||
currentMinRole: string | null
|
||||
schemaChangeCount: number
|
||||
}): boolean {
|
||||
if (!isEdit || !initialApprovalRequired) return false
|
||||
const schemaChanged = schemaChangeCount > 0
|
||||
const approvalToggledOff = !currentApprovalRequired
|
||||
const minRoleChanged = initialMinRole !== currentMinRole
|
||||
return schemaChanged || approvalToggledOff || minRoleChanged
|
||||
}
|
||||
|
||||
export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const createMut = useCreateDictionary()
|
||||
@@ -150,17 +186,17 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
||||
// 422 approval_required, but it's much better UX to disable Save up front
|
||||
// and tell the user to open a draft instead. Mirror the three backend
|
||||
// gate clauses exactly so the UX matches the server contract.
|
||||
const wasApprovalRequired =
|
||||
Boolean((initial as { approvalRequired?: boolean } | null)?.approvalRequired)
|
||||
const initialMinRole =
|
||||
((initial as { approvalMinRole?: string } | null)?.approvalMinRole ?? '') || null
|
||||
const currentMinRole = approvalMinRole.trim() === '' ? null : approvalMinRole.trim()
|
||||
const schemaChangedFromInitial = isEdit && changes.length > 0
|
||||
const approvalToggledOff = isEdit && wasApprovalRequired && !approvalRequired
|
||||
const minRoleChanged = isEdit && wasApprovalRequired && initialMinRole !== currentMinRole
|
||||
const requiresDraftFlow =
|
||||
isEdit && wasApprovalRequired
|
||||
&& (schemaChangedFromInitial || approvalToggledOff || minRoleChanged)
|
||||
const requiresDraftFlow = computeRequiresDraftFlow({
|
||||
isEdit,
|
||||
initialApprovalRequired: Boolean(
|
||||
(initial as { approvalRequired?: boolean } | null)?.approvalRequired,
|
||||
),
|
||||
initialMinRole:
|
||||
((initial as { approvalMinRole?: string } | null)?.approvalMinRole ?? '') || null,
|
||||
currentApprovalRequired: approvalRequired,
|
||||
currentMinRole: approvalMinRole.trim() === '' ? null : approvalMinRole.trim(),
|
||||
schemaChangeCount: changes.length,
|
||||
})
|
||||
|
||||
const activeMutation = isEdit ? updateMut : createMut
|
||||
const serverError = serverErrorMessage(activeMutation.error)
|
||||
@@ -490,9 +526,13 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
||||
detail={initial}
|
||||
initialProposedSchema={schemaJson}
|
||||
onCreated={() => {
|
||||
// Cascade close: dismiss the draft modal AND the editor. Once
|
||||
// the draft is submitted, the in-memory edit in the editor is
|
||||
// stale by design — the draft is now the source of truth until
|
||||
// a reviewer decides. Leaving the editor open would let the
|
||||
// maker silently mutate further state that can't go anywhere
|
||||
// (Save still disabled until the draft resolves).
|
||||
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()
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeRequiresDraftFlow } from './DictionaryEditorDialog'
|
||||
|
||||
const base = {
|
||||
isEdit: true,
|
||||
initialApprovalRequired: true,
|
||||
initialMinRole: 'ordinis:reviewer' as string | null,
|
||||
currentApprovalRequired: true,
|
||||
currentMinRole: 'ordinis:reviewer' as string | null,
|
||||
schemaChangeCount: 0,
|
||||
}
|
||||
|
||||
describe('computeRequiresDraftFlow', () => {
|
||||
it('returns false in create mode (no initial dict)', () => {
|
||||
expect(computeRequiresDraftFlow({ ...base, isEdit: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for non-approval-required dicts even with schema changes', () => {
|
||||
expect(
|
||||
computeRequiresDraftFlow({
|
||||
...base,
|
||||
initialApprovalRequired: false,
|
||||
schemaChangeCount: 5,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for pure metadata edits on approval-required dict', () => {
|
||||
// Editing display name / description / locales doesn't touch schema or
|
||||
// approval config; backend allows inline. UX should NOT block Save here.
|
||||
expect(computeRequiresDraftFlow(base)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when schema changed on approval-required dict', () => {
|
||||
expect(
|
||||
computeRequiresDraftFlow({ ...base, schemaChangeCount: 1 }),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when approvalRequired toggled off (backdoor block)', () => {
|
||||
expect(
|
||||
computeRequiresDraftFlow({ ...base, currentApprovalRequired: false }),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when approvalMinRole changed', () => {
|
||||
expect(
|
||||
computeRequiresDraftFlow({ ...base, currentMinRole: 'ordinis:admin' }),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when approvalMinRole cleared', () => {
|
||||
// Edge case from MR !182 audit: clearing the min-role would let any
|
||||
// generic reviewer approve restricted-dict drafts. Must route through
|
||||
// draft workflow.
|
||||
expect(
|
||||
computeRequiresDraftFlow({ ...base, currentMinRole: null }),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('handles all three clauses together (worst-case schema rewrite + governance flip)', () => {
|
||||
expect(
|
||||
computeRequiresDraftFlow({
|
||||
...base,
|
||||
schemaChangeCount: 3,
|
||||
currentApprovalRequired: false,
|
||||
currentMinRole: null,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -55,6 +55,34 @@ type Props = {
|
||||
* вернёт detail в message.</li>
|
||||
* </ul>
|
||||
*/
|
||||
/**
|
||||
* Resolve the schema JSON to seed the editor with. Three-tier priority:
|
||||
*
|
||||
* 1. edit-mode draft — `editDraft.proposedSchema`
|
||||
* Maker is revising their own draft after CHANGES_REQUESTED; the
|
||||
* draft itself is the source of truth.
|
||||
*
|
||||
* 2. caller pre-fill — `initialProposedSchema` prop
|
||||
* DictionaryEditorDialog's inline CTA passes in-progress edits so
|
||||
* the maker doesn't retype them.
|
||||
*
|
||||
* 3. live HEAD — `detailSchemaJson`
|
||||
* First-time draft on this dict with no pre-fill; start from the
|
||||
* current published schema.
|
||||
*
|
||||
* Exported so the priority can be unit-tested without rendering the
|
||||
* whole modal (mutations + queries make the render test setup heavy).
|
||||
*/
|
||||
export function pickSeedSchema(
|
||||
editDraft: SchemaDraft | undefined,
|
||||
initialProposedSchema: unknown,
|
||||
detailSchemaJson: unknown,
|
||||
): unknown {
|
||||
if (editDraft) return editDraft.proposedSchema
|
||||
if (initialProposedSchema !== undefined) return initialProposedSchema
|
||||
return detailSchemaJson
|
||||
}
|
||||
|
||||
export function CreateSchemaDraftModal({
|
||||
open,
|
||||
onClose,
|
||||
@@ -65,17 +93,10 @@ export function CreateSchemaDraftModal({
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const isEdit = Boolean(editDraft)
|
||||
// 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
|
||||
: initialProposedSchema !== undefined
|
||||
? initialProposedSchema
|
||||
: detail.schemaJson,
|
||||
pickSeedSchema(editDraft, initialProposedSchema, detail.schemaJson),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SchemaDraft } from '@/api/client'
|
||||
import { pickSeedSchema } from './CreateSchemaDraftModal'
|
||||
|
||||
const draft = (proposed: unknown): SchemaDraft =>
|
||||
({ proposedSchema: proposed } as unknown as SchemaDraft)
|
||||
|
||||
describe('pickSeedSchema', () => {
|
||||
it('edit-mode draft wins over pre-fill and live HEAD', () => {
|
||||
const seed = pickSeedSchema(draft({ type: 'edit' }), { type: 'pre-fill' }, { type: 'head' })
|
||||
expect(seed).toEqual({ type: 'edit' })
|
||||
})
|
||||
|
||||
it('caller pre-fill wins over live HEAD when no edit-mode draft', () => {
|
||||
const seed = pickSeedSchema(undefined, { type: 'pre-fill' }, { type: 'head' })
|
||||
expect(seed).toEqual({ type: 'pre-fill' })
|
||||
})
|
||||
|
||||
it('falls back to live HEAD when neither edit draft nor pre-fill provided', () => {
|
||||
const seed = pickSeedSchema(undefined, undefined, { type: 'head' })
|
||||
expect(seed).toEqual({ type: 'head' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user