Skip to content

Commit fa5c04e

Browse files
fix(workflow): Handle multiple from and improve layout (#15068)
Co-authored-by: Octave <octave.albert@filigran.io>
1 parent 37cc56f commit fa5c04e

10 files changed

Lines changed: 88 additions & 54 deletions

File tree

opencti-platform/opencti-front/src/private/components/settings/sub_types/workflow/hooks/useWorkflowInitialElements.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ describe('useWorkflowInitialElements', () => {
7070
],
7171
transitions: [
7272
{
73-
from: 'status-open',
73+
from: ['status-open'],
7474
to: 'status-closed',
7575
event: 'close_event',
7676
conditions: {},
@@ -221,7 +221,7 @@ describe('useWorkflowInitialElements', () => {
221221
...mockWorkflowDefinition!,
222222
transitions: [
223223
{
224-
from: 'status-open',
224+
from: ['status-open'],
225225
to: 'status-closed',
226226
event: 'share_event',
227227
conditions: {},
@@ -260,7 +260,7 @@ describe('useWorkflowInitialElements', () => {
260260
...mockWorkflowDefinition!,
261261
transitions: [
262262
{
263-
from: 'status-open',
263+
from: ['status-open'],
264264
to: 'status-closed',
265265
event: 'unshare_event',
266266
conditions: {},

opencti-platform/opencti-front/src/private/components/settings/sub_types/workflow/hooks/useWorkflowInitialElements.ts

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -94,29 +94,34 @@ export const useWorkflowInitialElements = (
9494

9595
// 2. Map transitions to transition nodes
9696
const transitionNodes: Node[] = workflowDefinition.transitions
97-
.map(({ from, to, event, conditions = {}, comment, asyncActions = [], syncActions = [] }) => ({
98-
id: `${WorkflowNodeType.transition}-${from}-${to}`,
99-
type: WorkflowNodeType.transition,
100-
data: {
101-
event,
102-
conditions,
103-
comment: (comment ?? CommentMode.disabled) as CommentModeType,
104-
asyncActions: parseActions((asyncActions ?? []) as ReadonlyArray<ReadOnlyAction>),
105-
syncActions: parseActions((syncActions ?? []) as ReadonlyArray<ReadOnlyAction>),
106-
},
107-
position: { x: 0, y: 0 },
108-
}));
97+
.map(({ from, to, event, conditions = {}, comment, asyncActions = [], syncActions = [] }) => {
98+
const fromIds = (Array.isArray(from) ? from : [from]).join(',');
99+
return {
100+
id: `${WorkflowNodeType.transition}-${fromIds}-${to}`,
101+
type: WorkflowNodeType.transition,
102+
data: {
103+
event,
104+
conditions,
105+
comment: (comment ?? CommentMode.disabled) as CommentModeType,
106+
asyncActions: parseActions((asyncActions ?? []) as ReadonlyArray<ReadOnlyAction>),
107+
syncActions: parseActions((syncActions ?? []) as ReadonlyArray<ReadOnlyAction>),
108+
},
109+
position: { x: 0, y: 0 },
110+
};
111+
});
109112

110113
// 3. Map transitions to edges
111114
const transitionEdges: Edge[] = workflowDefinition.transitions.flatMap((transition) => {
112-
const transitionId = `${WorkflowNodeType.transition}-${transition.from}-${transition.to}`;
115+
const fromArray = Array.isArray(transition.from) ? transition.from : [transition.from];
116+
const fromIds = fromArray.join(',');
117+
const transitionId = `${WorkflowNodeType.transition}-${fromIds}-${transition.to}`;
113118
return [
114-
{
115-
id: `e-${transition.from}->${transitionId}`,
119+
...fromArray.map((fromState) => ({
120+
id: `e-${fromState}->${transitionId}`,
116121
type: WorkflowNodeType.transition,
117-
source: transition.from,
122+
source: fromState,
118123
target: transitionId,
119-
},
124+
})),
120125
{
121126
id: `e-${transitionId}->${transition.to}`,
122127
type: WorkflowNodeType.transition,

opencti-platform/opencti-front/src/private/components/settings/sub_types/workflow/utils.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,28 @@ describe('Workflow utils', () => {
108108
expect(result.states[0].onExit).toEqual([{ type: 'validateDraft' }]);
109109
});
110110

111+
it('should fan out multiple-source transitions into one entry per source state', () => {
112+
const nodes: Node[] = [
113+
{ id: 'state-1', type: WorkflowNodeType.status, data: { statusTemplate: { id: 'state-1' }, onEnter: [], onExit: [] }, position: { x: 0, y: 0 } },
114+
{ id: 'state-2', type: WorkflowNodeType.status, data: { statusTemplate: { id: 'state-2' }, onEnter: [], onExit: [] }, position: { x: 0, y: 0 } },
115+
{ id: 'state-reject', type: WorkflowNodeType.status, data: { statusTemplate: { id: 'state-reject' }, onEnter: [], onExit: [] }, position: { x: 0, y: 0 } },
116+
{ id: 'trans-reject', type: WorkflowNodeType.transition, data: { event: 'reject', conditions: {}, actions: [], asyncActions: [], syncActions: [] }, position: { x: 0, y: 0 } },
117+
];
118+
const edges: Edge[] = [
119+
// Both state-1 and state-2 feed into the same "reject" transition node
120+
{ id: 'e1', source: 'state-1', target: 'trans-reject', type: 'smoothstep' },
121+
{ id: 'e2', source: 'state-2', target: 'trans-reject', type: 'smoothstep' },
122+
{ id: 'e3', source: 'trans-reject', target: 'state-reject', type: 'smoothstep' },
123+
];
124+
const result = transformToWorkflowDefinition(nodes, edges, mockWorkflowDefinition);
125+
// Must produce two separate transitions rather than one with from: ['state-1', 'state-2']
126+
expect(result.transitions).toHaveLength(2);
127+
expect(result.transitions).toEqual(expect.arrayContaining([
128+
expect.objectContaining({ from: 'state-1', to: 'state-reject', event: 'reject' }),
129+
expect.objectContaining({ from: 'state-2', to: 'state-reject', event: 'reject' }),
130+
]));
131+
});
132+
111133
it('should correctly identify element types', () => {
112134
const statusNode: Node = { id: '1', type: WorkflowNodeType.status, data: {}, position: { x: 0, y: 0 } };
113135
const placeholderNode: Node = { id: '2', type: WorkflowNodeType.placeholder, data: {}, position: { x: 0, y: 0 } };

opencti-platform/opencti-front/src/private/components/settings/sub_types/workflow/utils.ts

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -131,31 +131,29 @@ const transformToWorkflowDefinition = (
131131
// Find ALL outgoing edges (This Transition -> To Status)
132132
const outgoingEdges = edges.filter((e) => e.source === node.id);
133133

134-
// Create a transition entry for every possible path through this node
135-
// This handles: Multiple Sources -> 1 Transition -> Multiple Targets
136-
if (outgoingEdges.length > 0) {
137-
return incomingEdges.flatMap((inEdge) =>
138-
outgoingEdges.map((outEdge) => ({
139-
from: nodes.find((n) => n.id === inEdge.source)?.data.statusTemplate.id,
140-
to: nodes.find((n) => n.id === outEdge.target)?.data.statusTemplate.id || null,
141-
event,
142-
conditions,
143-
comment,
144-
asyncActions: formatActions(asyncActions),
145-
syncActions: formatActions(syncActions),
146-
})),
147-
);
148-
}
149-
// Multiple Sources -> 1 Transition -> (no target)
150-
return incomingEdges.map((inEdge) => ({
151-
from: nodes.find((n) => n.id === inEdge.source)?.data.statusTemplate.id,
152-
to: null as string | null,
134+
// Collect all source state IDs
135+
const fromStates = incomingEdges
136+
.map((inEdge) => nodes.find((n) => n.id === inEdge.source)?.data.statusTemplate.id)
137+
.filter(Boolean) as string[];
138+
139+
const actionPayload = {
153140
event,
154141
conditions,
155-
comment,
156142
asyncActions: formatActions(asyncActions),
157143
syncActions: formatActions(syncActions),
158-
}));
144+
comment,
145+
};
146+
147+
// Fan out: one SerializedTransition per (from, to) pair.
148+
// SerializedTransition.from is always a single string — never an array —
149+
// so the backend's getTransitions(currentState) strict-equality check works correctly.
150+
const toStates = outgoingEdges.length > 0
151+
? outgoingEdges.map((outEdge) => nodes.find((n) => n.id === outEdge.target)?.data.statusTemplate.id || null)
152+
: [null as string | null];
153+
154+
return fromStates.flatMap((from) =>
155+
toStates.map((to) => ({ ...actionPayload, from, to })),
156+
);
159157
}
160158
return [];
161159
});

opencti-platform/opencti-front/src/schema/relay.schema.graphql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10784,7 +10784,7 @@ type WorkflowSerializedState {
1078410784
}
1078510785

1078610786
type WorkflowSerializedTransition {
10787-
from: String!
10787+
from: [String!]!
1078810788
to: String!
1078910789
event: String!
1079010790
asyncActions: [WorkflowActionConfig!]

opencti-platform/opencti-graphql/src/generated/graphql.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38021,7 +38021,7 @@ export type WorkflowSerializedTransition = {
3802138021
comment?: Maybe<Scalars['String']['output']>;
3802238022
conditions?: Maybe<Scalars['JSON']['output']>;
3802338023
event: Scalars['String']['output'];
38024-
from: Scalars['String']['output'];
38024+
from: Array<Scalars['String']['output']>;
3802538025
syncActions?: Maybe<Array<WorkflowActionConfig>>;
3802638026
to: Scalars['String']['output'];
3802738027
};
@@ -52849,7 +52849,7 @@ export type WorkflowSerializedTransitionResolvers<ContextType = any, ParentType
5284952849
comment?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
5285052850
conditions?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType>;
5285152851
event?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
52852-
from?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
52852+
from?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
5285352853
syncActions?: Resolver<Maybe<Array<ResolversTypes['WorkflowActionConfig']>>, ParentType, ContextType>;
5285452854
to?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
5285552855
}>;

opencti-platform/opencti-graphql/src/modules/workflow/api/workflow-resolvers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ const workflowResolvers = {
7676
pendingError: (instance: any) => instance.pendingError ?? null,
7777
pendingTransition: (instance: any) => instance.pendingTransition ?? null,
7878
},
79+
WorkflowSerializedTransition: {
80+
from: (transition: any) => (Array.isArray(transition.from) ? transition.from : [transition.from]),
81+
},
7982
WorkflowTransition: {
8083
toStatus: (transition: any) => ({ id: transition.toState, template_id: transition.toState }),
8184
comment: (transition: any) => transition.comment ?? null,

opencti-platform/opencti-graphql/src/modules/workflow/api/workflow.graphql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type WorkflowSerializedState {
1616
}
1717

1818
type WorkflowSerializedTransition {
19-
from: String!
19+
from: [String!]!
2020
to: String!
2121
event: String!
2222
asyncActions: [WorkflowActionConfig!]

opencti-platform/opencti-graphql/src/modules/workflow/workflow-validation.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,8 @@ export const validateWorkflowDefinitionData = async (
165165
stateIdsToCheck.add(initialState);
166166
}
167167

168-
const events = new Set<string>();
168+
// Track (fromState, event) pairs to prevent duplicate transitions from the same source.
169+
const fromEventPairs = new Set<string>();
169170
let hasValidateDraft = false;
170171
const statesWithIncomingTransition = new Set<string>();
171172

@@ -176,13 +177,18 @@ export const validateWorkflowDefinitionData = async (
176177
message: `Transition ${transition.event} should be linked to at least one status`,
177178
});
178179
}
179-
if (events.has(transition.event)) {
180-
errors.push({
181-
type: 'DUPLICATE_TRANSITION_EVENT',
182-
message: `Transition '${transition.event}' referenced in multiple transitions`,
183-
});
180+
181+
const fromStatesForCheck = Array.isArray(transition.from) ? transition.from : [transition.from];
182+
for (const fromState of fromStatesForCheck) {
183+
const key = `${fromState}::${transition.event}`;
184+
if (fromEventPairs.has(key)) {
185+
errors.push({
186+
type: 'DUPLICATE_TRANSITION_EVENT',
187+
message: `Transition '${transition.event}' referenced in multiple transitions`,
188+
});
189+
}
190+
fromEventPairs.add(key);
184191
}
185-
events.add(transition.event);
186192

187193
const fromStates = Array.isArray(transition.from) ? transition.from : [transition.from];
188194
for (const fromState of fromStates) {

opencti-platform/opencti-graphql/tests/01-unit/modules/workflow-validation-test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ describe('Workflow Validation', () => {
120120
initialState: 'existing-state',
121121
transitions: [
122122
{ from: 'a', to: 'b', event: 'test' },
123-
{ from: 'b', to: 'c', event: 'test' },
123+
{ from: 'a', to: 'c', event: 'test' }, // Change 'b' to 'a' to duplicate from the same source
124124
],
125125
};
126126
const errors = await validateWorkflowDefinitionData(mockContext, mockUser, JSON.stringify(invalid), 'Report');
@@ -649,7 +649,7 @@ describe('Workflow Validation', () => {
649649
],
650650
};
651651
await expect(validateWorkflowDefinitionData(mockContext, mockUser, JSON.stringify(invalid), 'DraftWorkspace'))
652-
.rejects.toThrow("not allowed in syncActions");
652+
.rejects.toThrow('not allowed in syncActions');
653653
});
654654

655655
it('should detect validateDraft in syncActions for DraftWorkspace requirement', async () => {

0 commit comments

Comments
 (0)