fix(ui): DictionaryEditorDialog disables Save when draft workflow needed
This commit is contained in:
@@ -138,6 +138,24 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
|||||||
[isEdit, initial, changes, schemaVersion],
|
[isEdit, initial, changes, schemaVersion],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Approval-aware Save: when editing an approval-required dict, any schema
|
||||||
|
// change OR toggling approvalRequired off OR changing approvalMinRole has
|
||||||
|
// to route through the schema-draft workflow. The backend will reject with
|
||||||
|
// 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 activeMutation = isEdit ? updateMut : createMut
|
const activeMutation = isEdit ? updateMut : createMut
|
||||||
const serverError = serverErrorMessage(activeMutation.error)
|
const serverError = serverErrorMessage(activeMutation.error)
|
||||||
|
|
||||||
@@ -412,6 +430,17 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
|||||||
|
|
||||||
{serverError && <Alert variant="error">{serverError}</Alert>}
|
{serverError && <Alert variant="error">{serverError}</Alert>}
|
||||||
|
|
||||||
|
{requiresDraftFlow && (
|
||||||
|
<Alert variant="warning">
|
||||||
|
{t('schema.approvalRequired.editorBlock', {
|
||||||
|
defaultValue:
|
||||||
|
'Этот словарь требует ревью изменений. Закройте редактор и нажмите ' +
|
||||||
|
'«Создать черновик схемы» на странице справочника — там вы сможете ' +
|
||||||
|
'отправить эти изменения на согласование.',
|
||||||
|
})}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormActions align="end">
|
<FormActions align="end">
|
||||||
<Button type="button" variant="ghost" onClick={onClose} disabled={activeMutation.isPending}>
|
<Button type="button" variant="ghost" onClick={onClose} disabled={activeMutation.isPending}>
|
||||||
{t('form.cancel')}
|
{t('form.cancel')}
|
||||||
@@ -420,7 +449,7 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
|||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
loading={activeMutation.isPending}
|
loading={activeMutation.isPending}
|
||||||
disabled={isEdit && breaking}
|
disabled={(isEdit && breaking) || requiresDraftFlow}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
{activeMutation.isPending ? t('form.saving') : t('form.save')}
|
{activeMutation.isPending ? t('form.saving') : t('form.save')}
|
||||||
|
|||||||
+16
-5
@@ -141,13 +141,24 @@ public class DictionaryDefinitionService {
|
|||||||
boolean schemaChanged = !java.util.Objects.equals(prevSchema, req.schemaJson());
|
boolean schemaChanged = !java.util.Objects.equals(prevSchema, req.schemaJson());
|
||||||
boolean tryingToDisableApproval =
|
boolean tryingToDisableApproval =
|
||||||
req.approvalRequired() != null && !req.approvalRequired();
|
req.approvalRequired() != null && !req.approvalRequired();
|
||||||
if (existing.isApprovalRequired() && (schemaChanged || tryingToDisableApproval)) {
|
// Approval-min-role flip is a third backdoor: a maker with PUT permission
|
||||||
|
// could clear approvalMinRole (send "" → stored as null) and then approve
|
||||||
|
// their own restricted-dict drafts using a generic RECORD_REVIEWER role.
|
||||||
|
// Treat any change to approvalMinRole the same as a schema rewrite —
|
||||||
|
// route through the draft workflow (or eventually a dedicated admin
|
||||||
|
// endpoint that requires a higher role than `approvalMinRole` itself).
|
||||||
|
boolean tryingToChangeMinRole =
|
||||||
|
req.approvalMinRole() != null
|
||||||
|
&& !java.util.Objects.equals(
|
||||||
|
req.approvalMinRole().isBlank() ? null : req.approvalMinRole(),
|
||||||
|
existing.getApprovalMinRole());
|
||||||
|
if (existing.isApprovalRequired()
|
||||||
|
&& (schemaChanged || tryingToDisableApproval || tryingToChangeMinRole)) {
|
||||||
throw OrdinisException.unprocessableEntity(
|
throw OrdinisException.unprocessableEntity(
|
||||||
"approval_required",
|
"approval_required",
|
||||||
"Schema changes require the draft workflow on this dictionary. "
|
"Schema changes, approvalRequired toggle-off, and approvalMinRole "
|
||||||
+ "Use POST /api/v1/admin/dictionaries/" + name
|
+ "edits all require the draft workflow on this dictionary. "
|
||||||
+ "/schema-drafts. Toggling approvalRequired off is also "
|
+ "Use POST /api/v1/admin/dictionaries/" + name + "/schema-drafts.");
|
||||||
+ "blocked here — needs an explicit admin endpoint.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
existing.setDisplayName(req.displayName());
|
existing.setDisplayName(req.displayName());
|
||||||
|
|||||||
+49
-1
@@ -64,6 +64,10 @@ public class CascadeCloseService {
|
|||||||
private final LineageIndexService lineage;
|
private final LineageIndexService lineage;
|
||||||
private final RecordCloseSink closeSink;
|
private final RecordCloseSink closeSink;
|
||||||
private final ScopeContext scopeContext;
|
private final ScopeContext scopeContext;
|
||||||
|
/** Used to enforce the approvalRequired gate on the target + every cascaded
|
||||||
|
* dependent dict. Optional: null in legacy test ctors that don't exercise
|
||||||
|
* the gate. Production wiring always provides it. */
|
||||||
|
private final DictionaryDefinitionService defService;
|
||||||
/** Phase 4 SLO. Optional — null в test ctor'е. */
|
/** Phase 4 SLO. Optional — null в test ctor'е. */
|
||||||
private final Optional<MeterRegistry> meterRegistry;
|
private final Optional<MeterRegistry> meterRegistry;
|
||||||
|
|
||||||
@@ -79,6 +83,7 @@ public class CascadeCloseService {
|
|||||||
this(lineage,
|
this(lineage,
|
||||||
new DefaultRecordCloseSink(recordRepo, defService, auditLogger, outbox),
|
new DefaultRecordCloseSink(recordRepo, defService, auditLogger, outbox),
|
||||||
scopeContext,
|
scopeContext,
|
||||||
|
defService,
|
||||||
meterRegistry);
|
meterRegistry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,17 +92,19 @@ public class CascadeCloseService {
|
|||||||
LineageIndexService lineage,
|
LineageIndexService lineage,
|
||||||
RecordCloseSink closeSink,
|
RecordCloseSink closeSink,
|
||||||
ScopeContext scopeContext) {
|
ScopeContext scopeContext) {
|
||||||
this(lineage, closeSink, scopeContext, Optional.empty());
|
this(lineage, closeSink, scopeContext, null, Optional.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
CascadeCloseService(
|
CascadeCloseService(
|
||||||
LineageIndexService lineage,
|
LineageIndexService lineage,
|
||||||
RecordCloseSink closeSink,
|
RecordCloseSink closeSink,
|
||||||
ScopeContext scopeContext,
|
ScopeContext scopeContext,
|
||||||
|
DictionaryDefinitionService defService,
|
||||||
Optional<MeterRegistry> meterRegistry) {
|
Optional<MeterRegistry> meterRegistry) {
|
||||||
this.lineage = lineage;
|
this.lineage = lineage;
|
||||||
this.closeSink = closeSink;
|
this.closeSink = closeSink;
|
||||||
this.scopeContext = scopeContext;
|
this.scopeContext = scopeContext;
|
||||||
|
this.defService = defService;
|
||||||
this.meterRegistry = meterRegistry;
|
this.meterRegistry = meterRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +157,22 @@ public class CascadeCloseService {
|
|||||||
OffsetDateTime when = closeAt == null ? OffsetDateTime.now() : closeAt;
|
OffsetDateTime when = closeAt == null ? OffsetDateTime.now() : closeAt;
|
||||||
CascadePlan plan = evaluatePlan(targetDict, targetKey, when);
|
CascadePlan plan = evaluatePlan(targetDict, targetKey, when);
|
||||||
|
|
||||||
|
// Approval gate: cascade-close goes through closeSink which writes
|
||||||
|
// straight to the repo + outbox + audit, completely skipping
|
||||||
|
// DictionaryRecordService.close → requireApprovalGate. So we have to
|
||||||
|
// enforce here, against BOTH the target dict and every cascaded
|
||||||
|
// dependent. If any of them is approval-required, refuse the whole
|
||||||
|
// cascade — it cannot atomically transition to drafts mid-cascade,
|
||||||
|
// and partial cascade would leave dangling references.
|
||||||
|
// (Future work: auto-create one parent draft + N dependent drafts in
|
||||||
|
// a single submit-for-review bundle. Out of scope for the gap fix.)
|
||||||
|
if (defService != null) {
|
||||||
|
assertNotApprovalRequired(targetDict);
|
||||||
|
for (var dep : plan.cascade()) {
|
||||||
|
assertNotApprovalRequired(dep.sourceDict());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!plan.blockers().isEmpty()) {
|
if (!plan.blockers().isEmpty()) {
|
||||||
stopTimerAndCount(sample, targetDict, "blocked");
|
stopTimerAndCount(sample, targetDict, "blocked");
|
||||||
throw OrdinisException.conflict(
|
throw OrdinisException.conflict(
|
||||||
@@ -202,6 +225,31 @@ public class CascadeCloseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block cascade-close from bypassing the maker-checker draft workflow on
|
||||||
|
* approval-required dictionaries. Throws 422 {@code approval_required}
|
||||||
|
* pointing the caller at the drafts API.
|
||||||
|
*/
|
||||||
|
private void assertNotApprovalRequired(String dict) {
|
||||||
|
DictionaryDefinition d;
|
||||||
|
try {
|
||||||
|
d = defService.findByName(dict);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// If we can't resolve the dict we definitely shouldn't proceed.
|
||||||
|
throw OrdinisException.badRequest(
|
||||||
|
"dictionary_not_found",
|
||||||
|
"Cascade close: cannot resolve dictionary " + dict);
|
||||||
|
}
|
||||||
|
if (d.isApprovalRequired()) {
|
||||||
|
throw OrdinisException.unprocessableEntity(
|
||||||
|
"approval_required",
|
||||||
|
"Cascade close cannot proceed: dictionary " + dict
|
||||||
|
+ " requires the draft workflow. Open a record draft via "
|
||||||
|
+ "POST /api/v1/dictionaries/" + dict + "/records/{businessKey}/drafts "
|
||||||
|
+ "with operation=CLOSE for each affected record.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// === Metrics helpers ===
|
// === Metrics helpers ===
|
||||||
|
|
||||||
private Timer.Sample startTimer() {
|
private Timer.Sample startTimer() {
|
||||||
|
|||||||
Reference in New Issue
Block a user