fix: workflow editor Phase 5/6 sign-off — save guard, conflict detection, dispatch pre-check
- Save button disabled for non-admins (canSave prop threaded through WorkflowEditor → WorkflowCanvas → WorkflowCanvasToolbar); tooltip explains why when hovered - Optimistic concurrency: migration 072 adds updated_at to workflow_definitions; PUT /workflows/:id returns 409 when client sends a stale updated_at; frontend shows a specific reload-prompt toast - _legacy_dispatch now routes through dispatch_order_line_render instead of calling render_order_line_task directly, so cancelled/rejected order lines are skipped before queueing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,7 @@ export interface WorkflowDefinition {
|
||||
rollout_summary: WorkflowRolloutSummary
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface WorkflowConfig {
|
||||
@@ -239,7 +240,11 @@ export const getWorkflow = (id: string): Promise<WorkflowDefinition> =>
|
||||
export const createWorkflow = (data: WorkflowCreate): Promise<WorkflowDefinition> =>
|
||||
api.post('/workflows', data).then(r => normalizeWorkflowDefinition(r.data))
|
||||
|
||||
export const updateWorkflow = (id: string, data: Partial<WorkflowCreate>): Promise<WorkflowDefinition> =>
|
||||
export interface WorkflowUpdatePayload extends Partial<WorkflowCreate> {
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export const updateWorkflow = (id: string, data: WorkflowUpdatePayload): Promise<WorkflowDefinition> =>
|
||||
api.put(`/workflows/${id}`, data).then(r => normalizeWorkflowDefinition(r.data))
|
||||
|
||||
export const deleteWorkflow = (id: string): Promise<void> =>
|
||||
|
||||
@@ -56,9 +56,10 @@ type WorkflowCanvasProps = {
|
||||
workflow: WorkflowDefinition
|
||||
onSave: (config: WorkflowConfig) => void
|
||||
isSaving: boolean
|
||||
canSave?: boolean
|
||||
}
|
||||
|
||||
export function WorkflowCanvas({ workflow, onSave, isSaving }: WorkflowCanvasProps) {
|
||||
export function WorkflowCanvas({ workflow, onSave, isSaving, canSave = true }: WorkflowCanvasProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const {
|
||||
reactFlowWrapper,
|
||||
@@ -308,6 +309,7 @@ export function WorkflowCanvas({ workflow, onSave, isSaving }: WorkflowCanvasPro
|
||||
isDispatchPending={dispatchMutation.isPending}
|
||||
isContextOptionsLoading={isOrderLineContextsLoading}
|
||||
isSaving={isSaving}
|
||||
canSave={canSave}
|
||||
rollbackPendingOutputTypeId={rollbackOutputTypeMutation.variables?.outputTypeId ?? null}
|
||||
preflightState={preflightState}
|
||||
authoringActions={authoringActions}
|
||||
|
||||
@@ -125,6 +125,7 @@ interface WorkflowCanvasToolbarProps {
|
||||
isDispatchPending: boolean
|
||||
isContextOptionsLoading: boolean
|
||||
isSaving: boolean
|
||||
canSave?: boolean
|
||||
rollbackPendingOutputTypeId?: string | null
|
||||
preflightState: 'ready' | 'required' | 'stale' | 'blocked'
|
||||
authoringActions: WorkflowAuthoringActions
|
||||
@@ -175,6 +176,7 @@ export function WorkflowCanvasToolbar({
|
||||
isDispatchPending,
|
||||
isContextOptionsLoading,
|
||||
isSaving,
|
||||
canSave = true,
|
||||
rollbackPendingOutputTypeId,
|
||||
preflightState,
|
||||
authoringActions,
|
||||
@@ -345,7 +347,8 @@ export function WorkflowCanvasToolbar({
|
||||
</ToolbarActionButton>
|
||||
<ToolbarActionButton
|
||||
onClick={onSave}
|
||||
disabled={isSaving || hasValidationErrors}
|
||||
disabled={isSaving || hasValidationErrors || !canSave}
|
||||
title={!canSave ? 'Admin access required to save workflows' : undefined}
|
||||
tone="primary"
|
||||
>
|
||||
<Save size={14} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAuthStore, isAdmin } from '../store/auth'
|
||||
import { WorkflowCanvas } from '../components/workflows/WorkflowCanvas'
|
||||
import { NewWorkflowModal } from '../components/workflows/NewWorkflowModal'
|
||||
import { WorkflowEditorEmptyState } from '../components/workflows/WorkflowEditorEmptyState'
|
||||
@@ -39,6 +40,8 @@ export default function WorkflowEditor() {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [showNewModal, setShowNewModal] = useState(false)
|
||||
const user = useAuthStore(s => s.user)
|
||||
const canEdit = isAdmin(user)
|
||||
|
||||
const { data: workflows = [], isLoading } = useQuery({
|
||||
queryKey: ['workflows'],
|
||||
@@ -67,13 +70,19 @@ export default function WorkflowEditor() {
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, config }: { id: string; config: WorkflowConfig }) =>
|
||||
updateWorkflow(id, { config }),
|
||||
mutationFn: ({ id, config, updated_at }: { id: string; config: WorkflowConfig; updated_at?: string }) =>
|
||||
updateWorkflow(id, { config, updated_at }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['workflows'] })
|
||||
toast.success('Workflow saved')
|
||||
},
|
||||
onError: () => toast.error('Failed to save workflow'),
|
||||
onError: (err: any) => {
|
||||
if (err?.response?.status === 409) {
|
||||
toast.error('Workflow was modified by someone else — please reload the page')
|
||||
} else {
|
||||
toast.error('Failed to save workflow')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -199,8 +208,9 @@ export default function WorkflowEditor() {
|
||||
<WorkflowCanvas
|
||||
key={selectedWorkflow.id}
|
||||
workflow={selectedWorkflow}
|
||||
onSave={config => updateMutation.mutate({ id: selectedWorkflow.id, config })}
|
||||
onSave={config => updateMutation.mutate({ id: selectedWorkflow.id, config, updated_at: selectedWorkflow.updated_at })}
|
||||
isSaving={updateMutation.isPending}
|
||||
canSave={canEdit}
|
||||
/>
|
||||
) : (
|
||||
<WorkflowEditorEmptyState
|
||||
|
||||
Reference in New Issue
Block a user