Skip to content

Commit ea7c849

Browse files
authored
feat(chaining): event creation drawer (#6297)
1 parent 17a3d86 commit ea7c849

28 files changed

Lines changed: 1489 additions & 47 deletions

File tree

openaev-api/src/main/java/io/openaev/api/chaining/ConditionMapper.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ private static ConditionOutput toConditionOutput(Condition c) {
8686
.keySubtype(c.getKeySubtype())
8787
.type(c.getType() != null ? c.getType().name() : null)
8888
.value(c.getValue())
89+
.caseSensitive(c.isCaseSensitive())
8990
.conditionParentId(parentId)
9091
.mappingType(c.getMappingType())
9192
.build();
@@ -120,6 +121,7 @@ public static Condition toCondition(ConditionCreateInput input, Condition condit
120121
.keySubtype(input.getKeySubtype())
121122
.type(input.getType())
122123
.value(input.getValue())
124+
.caseSensitive(input.isCaseSensitive())
123125
.conditionParent(conditionParent)
124126
.mappingType(resolveMappingType(input))
125127
.build();

openaev-api/src/main/java/io/openaev/api/chaining/dto/ConditionCreateInput.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ public class ConditionCreateInput {
4444
@JsonProperty("condition_value")
4545
private String value;
4646

47+
/** Whether the comparison is case-sensitive (default: true) */
48+
@Schema(description = "Whether the comparison is case-sensitive")
49+
@JsonProperty("condition_case_sensitive")
50+
@Builder.Default
51+
private boolean caseSensitive = true;
52+
4753
/** Condition key: Property to be mapped */
4854
@Schema(description = "Property to be mapped")
4955
@JsonProperty("condition_key")

openaev-api/src/main/java/io/openaev/api/chaining/dto/ConditionOutput.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ public class ConditionOutput {
3232
@JsonProperty("condition_value")
3333
private String value;
3434

35+
@JsonProperty("condition_case_sensitive")
36+
private boolean caseSensitive;
37+
3538
@JsonProperty("condition_parent_id")
3639
private String conditionParentId;
3740

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package io.openaev.migration;
2+
3+
import java.sql.Statement;
4+
import org.flywaydb.core.api.migration.BaseJavaMigration;
5+
import org.flywaydb.core.api.migration.Context;
6+
import org.springframework.stereotype.Component;
7+
8+
@Component
9+
public class V5_34__Add_condition_case_sensitive extends BaseJavaMigration {
10+
11+
@Override
12+
public void migrate(Context context) throws Exception {
13+
try (Statement statement = context.getConnection().createStatement()) {
14+
statement.execute(
15+
"ALTER TABLE conditions ADD COLUMN IF NOT EXISTS condition_case_sensitive BOOLEAN NOT NULL DEFAULT TRUE");
16+
}
17+
}
18+
}

openaev-api/src/main/java/io/openaev/utils/ConditionUtils.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,22 +82,30 @@ public boolean evaluateLeafCondition(String actualValue, Condition filter) {
8282
return true;
8383
}
8484
String target = filter.getValue();
85+
boolean caseSensitive = filter.isCaseSensitive();
8586

8687
switch (type) {
8788
case IS_NULL:
8889
return actualValue == null;
8990
case IS_NOT_NULL:
9091
return actualValue != null;
9192
case EQ:
92-
return actualValue != null && actualValue.equalsIgnoreCase(target);
93+
return actualValue != null
94+
&& (caseSensitive ? actualValue.equals(target) : actualValue.equalsIgnoreCase(target));
9395
case NEQ:
94-
return actualValue != null && !actualValue.equalsIgnoreCase(target);
96+
return actualValue != null
97+
&& (caseSensitive
98+
? !actualValue.equals(target)
99+
: !actualValue.equalsIgnoreCase(target));
95100
case IN, NIN:
96101
if (actualValue == null || target == null) {
97102
return false;
98103
}
99104
List<String> targetList = Arrays.asList(target.split("\\s*,\\s*"));
100-
boolean contains = targetList.stream().anyMatch(actualValue::equalsIgnoreCase);
105+
boolean contains =
106+
caseSensitive
107+
? targetList.stream().anyMatch(actualValue::equals)
108+
: targetList.stream().anyMatch(actualValue::equalsIgnoreCase);
101109
return (type == ConditionType.IN) == contains;
102110
case GT, GTE, LT, LTE:
103111
return handleNumericComparison(actualValue, target, type);

openaev-api/src/test/java/io/openaev/service/chaining/ConditionServiceTest.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,9 +1089,14 @@ void shouldLeaveMappingTypeNull_whenNonMapperCondition() {
10891089
class IsFilterConditionValid {
10901090

10911091
private Condition leaf(ConditionType type, String value) {
1092+
return leaf(type, value, true);
1093+
}
1094+
1095+
private Condition leaf(ConditionType type, String value, boolean caseSensitive) {
10921096
Condition c = new Condition();
10931097
c.setType(type);
10941098
c.setValue(value);
1099+
c.setCaseSensitive(caseSensitive);
10951100
return c;
10961101
}
10971102

@@ -1215,7 +1220,8 @@ void isNotNull_shouldReturnFalse_whenValueIsNull() {
12151220

12161221
@Test
12171222
void eq_shouldReturnTrue_whenValuesMatch_caseInsensitive() {
1218-
assertTrue(conditionUtils.isFilterConditionValid("Admin", leaf(ConditionType.EQ, "admin")));
1223+
assertTrue(
1224+
conditionUtils.isFilterConditionValid("Admin", leaf(ConditionType.EQ, "admin", false)));
12191225
}
12201226

12211227
@Test
@@ -1265,7 +1271,8 @@ void in_shouldReturnFalse_whenValueIsNotInTargetList() {
12651271
@Test
12661272
void in_shouldBeCaseInsensitive() {
12671273
assertTrue(
1268-
conditionUtils.isFilterConditionValid("ADMIN", leaf(ConditionType.IN, "admin, root")));
1274+
conditionUtils.isFilterConditionValid(
1275+
"ADMIN", leaf(ConditionType.IN, "admin, root", false)));
12691276
}
12701277

12711278
@Test

openaev-front/src/admin/components/chaining/logic/Logic.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type {
1010
import AddComponentButton, { type LogicContext } from './AddComponentButton';
1111
import ChainingFlowConfiguration, { type DrawerView } from './chaining_flow/ChainingFlowConfiguration';
1212
import LogicFlow from './chaining_flow/LogicFlow';
13-
import type { ActionMeta } from './types';
13+
import type { ActionMeta, EventMeta } from './types';
1414

1515
interface LogicProps {
1616
workflowId: string | undefined;
@@ -22,6 +22,8 @@ const Logic = ({ workflowId, context }: LogicProps) => {
2222
const [validAssets, setValidAssets] = useState<ScopeAssetOutput[]>([]);
2323
// Track whether existing steps/events exist
2424
const [hasExistingData, setHasExistingData] = useState<boolean | null>(null);
25+
// Count of existing events (used to generate default names)
26+
const [eventCount, setEventCount] = useState(0);
2527
// Key to force LogicFlow re-mount after adding a step
2628
const [refreshKey, setRefreshKey] = useState(0);
2729
// Drawer navigation state (shared with ChainingFlowConfiguration)
@@ -32,6 +34,12 @@ const Logic = ({ workflowId, context }: LogicProps) => {
3234
meta: ActionMeta;
3335
} | null>(null);
3436

37+
// Event currently being edited
38+
const [editingEvent, setEditingEvent] = useState<{
39+
eventId: string;
40+
meta: EventMeta;
41+
} | null>(null);
42+
3543
useEffect(() => {
3644
if (workflowId) {
3745
fetchValidAssets(workflowId).then((assets: ScopeAssetOutput[]) => {
@@ -50,6 +58,7 @@ const Logic = ({ workflowId, context }: LogicProps) => {
5058
const steps: StepOutput[] = stepsRes.data ?? [];
5159
const events: EventOutput[] = conditionsRes.data ?? [];
5260
setHasExistingData(steps.length > 0 || events.length > 0);
61+
setEventCount(events.length);
5362
});
5463
}, [workflowId]);
5564

@@ -58,6 +67,12 @@ const Logic = ({ workflowId, context }: LogicProps) => {
5867
setRefreshKey(k => k + 1);
5968
}, []);
6069

70+
const handleEventCreated = useCallback(() => {
71+
setHasExistingData(true);
72+
setEventCount(c => c + 1);
73+
setRefreshKey(k => k + 1);
74+
}, []);
75+
6176
const handleOpenDrawer = useCallback(() => {
6277
setDrawerView('choose');
6378
}, []);
@@ -70,6 +85,14 @@ const Logic = ({ workflowId, context }: LogicProps) => {
7085
setDrawerView('actionDetail');
7186
}, []);
7287

88+
const handleEditEvent = useCallback((eventId: string, meta: EventMeta) => {
89+
setEditingEvent({
90+
eventId,
91+
meta,
92+
});
93+
setDrawerView('event');
94+
}, []);
95+
7396
// Loading state
7497
if (hasExistingData === null) {
7598
return null;
@@ -89,6 +112,7 @@ const Logic = ({ workflowId, context }: LogicProps) => {
89112
workflowId={workflowId}
90113
onAddComponent={handleOpenDrawer}
91114
onEditStep={handleEditStep}
115+
onEditEvent={handleEditEvent}
92116
/>
93117
)
94118
: (
@@ -101,7 +125,11 @@ const Logic = ({ workflowId, context }: LogicProps) => {
101125
onDrawerViewChange={setDrawerView}
102126
editingStep={editingStep}
103127
onEditingStepChange={setEditingStep}
128+
editingEvent={editingEvent}
129+
onEditingEventChange={setEditingEvent}
104130
onStepCreated={handleStepCreated}
131+
onEventCreated={handleEventCreated}
132+
eventCount={eventCount}
105133
/>
106134
</div>
107135
);

openaev-front/src/admin/components/chaining/logic/chaining_flow/ChainingFlowConfiguration.tsx

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { useCallback, useMemo, useState } from 'react';
22

3-
import { createStep, updateStep } from '../../../../../actions/chaining/chaining-actions';
3+
import {
4+
createCondition,
5+
createStep,
6+
updateCondition,
7+
updateStep,
8+
} from '../../../../../actions/chaining/chaining-actions';
49
import { useFormatter } from '../../../../../components/i18n';
510
import type {
611
ConditionCreateInput,
@@ -12,9 +17,14 @@ import { MESSAGING$ } from '../../../../../utils/Environment';
1217
import AddActionList from '../drawer/AddActionList';
1318
import AddComponentDrawer from '../drawer/AddComponentDrawer';
1419
import ConfigureActionDetail from '../drawer/ConfigureActionDetail';
15-
import type { ActionDetailData, ActionMeta } from '../types';
20+
import ConfigureEventDetail from '../events/ConfigureEventDetail';
21+
import {
22+
conditionGroupsToApi,
23+
type EventFormData,
24+
} from '../events/event-types';
25+
import type { ActionDetailData, ActionMeta, EventMeta } from '../types';
1626

17-
export type DrawerView = 'closed' | 'choose' | 'action' | 'actionDetail';
27+
export type DrawerView = 'closed' | 'choose' | 'action' | 'actionDetail' | 'event';
1828

1929
interface ChainingFlowConfigurationProps {
2030
workflowId: string | undefined;
@@ -29,7 +39,17 @@ interface ChainingFlowConfigurationProps {
2939
stepId: string;
3040
meta: ActionMeta;
3141
} | null) => void;
42+
editingEvent: {
43+
eventId: string;
44+
meta: EventMeta;
45+
} | null;
46+
onEditingEventChange: (event: {
47+
eventId: string;
48+
meta: EventMeta;
49+
} | null) => void;
3250
onStepCreated: () => void;
51+
onEventCreated: () => void;
52+
eventCount: number;
3353
}
3454

3555
const ChainingFlowConfiguration = ({
@@ -39,7 +59,11 @@ const ChainingFlowConfiguration = ({
3959
onDrawerViewChange,
4060
editingStep,
4161
onEditingStepChange,
62+
editingEvent,
63+
onEditingEventChange,
4264
onStepCreated,
65+
onEventCreated,
66+
eventCount,
4367
}: ChainingFlowConfigurationProps) => {
4468
const { t } = useFormatter();
4569

@@ -100,14 +124,14 @@ const ChainingFlowConfiguration = ({
100124
onDrawerViewChange('closed');
101125
setSelectedAction(null);
102126
onEditingStepChange(null);
103-
}, [onDrawerViewChange, onEditingStepChange]);
127+
onEditingEventChange(null);
128+
}, [onDrawerViewChange, onEditingStepChange, onEditingEventChange]);
104129

105130
const handleSelectComponent = (type: 'action' | 'event') => {
106131
if (type === 'action') {
107132
onDrawerViewChange('action');
108133
} else {
109-
// TODO: handle event creation drawers
110-
onDrawerViewChange('closed');
134+
onDrawerViewChange('event');
111135
}
112136
};
113137

@@ -214,7 +238,34 @@ const ChainingFlowConfiguration = ({
214238
});
215239
};
216240

217-
// -- Events (future drawers will go here) --
241+
// -- Events --
242+
const handleSaveEvent = async (data: EventFormData) => {
243+
if (!workflowId) return;
244+
245+
const apiConditions = conditionGroupsToApi(data.conditionGroups, data.groupOperators);
246+
const event = {
247+
event_name: data.name,
248+
event_description: data.description || undefined,
249+
event_workflow_id: workflowId,
250+
event_conditions: apiConditions,
251+
};
252+
253+
try {
254+
if (editingEvent) {
255+
await updateCondition(editingEvent.eventId, event);
256+
MESSAGING$.notifySuccess(t('Event updated successfully.'));
257+
} else {
258+
await createCondition(event);
259+
MESSAGING$.notifySuccess(t('Event added successfully.'));
260+
onEventCreated();
261+
}
262+
handleCloseAll();
263+
onStepCreated();
264+
} catch {
265+
if (editingEvent) MESSAGING$.notifyError(t('Failed to update event.'));
266+
else MESSAGING$.notifyError(t('Failed to create event.'));
267+
}
268+
};
218269

219270
// Resolve the action to show in ConfigureActionDetail
220271
const activeAction = editingAction ?? selectedAction;
@@ -243,6 +294,15 @@ const ChainingFlowConfiguration = ({
243294
onBackToRoot={handleCloseAll}
244295
onSave={handleSaveActionDetail}
245296
/>
297+
<ConfigureEventDetail
298+
open={drawerView === 'event'}
299+
onClose={handleCloseAll}
300+
onBack={editingEvent ? handleCloseAll : handleBackToChoose}
301+
onSave={handleSaveEvent}
302+
initialData={editingEvent?.meta.formData}
303+
isEditing={!!editingEvent}
304+
defaultEventName={!editingEvent ? `Event ${eventCount + 1}` : undefined}
305+
/>
246306
</>
247307
);
248308
};

openaev-front/src/admin/components/chaining/logic/chaining_flow/LogicFlow.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ interface LogicFlowProps {
4545
reloadTrigger?: number;
4646
onAddComponent: () => void;
4747
onEditStep?: (stepId: string, meta: ActionMeta) => void;
48+
onEditEvent?: (eventId: string, meta: EventMeta) => void;
4849
}
4950

5051
const proOptions = {
@@ -57,7 +58,7 @@ const proOptions = {
5758
* grouped into MITRE tactic columns. Supports connecting events to actions, editing,
5859
* deleting nodes, and adding new components.
5960
*/
60-
const LogicFlow = ({ workflowId, reloadTrigger, onAddComponent, onEditStep }: LogicFlowProps) => {
61+
const LogicFlow = ({ workflowId, reloadTrigger, onAddComponent, onEditStep, onEditEvent }: LogicFlowProps) => {
6162
const { t } = useFormatter();
6263
const theme = useTheme();
6364

@@ -82,7 +83,7 @@ const LogicFlow = ({ workflowId, reloadTrigger, onAddComponent, onEditStep }: Lo
8283

8384
// Extra metadata per node (keyed by node id)
8485
const [actionMetas, setActionMetas] = useState<Record<string, ActionMeta>>({});
85-
const [_eventMetas, setEventMetas] = useState<Record<string, EventMeta>>({});
86+
const [eventMetas, setEventMetas] = useState<Record<string, EventMeta>>({});
8687

8788
// Graph loading state — true until first data load completes
8889
const [loading, setLoading] = useState(true);
@@ -303,12 +304,19 @@ const LogicFlow = ({ workflowId, reloadTrigger, onAddComponent, onEditStep }: Lo
303304
* @param nodeId the step ID to edit
304305
* @param _type the node type (unused)
305306
*/
306-
const editNode = useCallback((nodeId: string, _type: string) => {
307-
const meta = actionMetas[nodeId];
308-
if (meta && onEditStep) {
309-
onEditStep(nodeId, meta);
307+
const editNode = useCallback((nodeId: string, type: string) => {
308+
if (type === 'action') {
309+
const meta = actionMetas[nodeId];
310+
if (meta && onEditStep) {
311+
onEditStep(nodeId, meta);
312+
}
313+
} else if (type === 'event') {
314+
const meta = eventMetas[nodeId];
315+
if (meta && onEditEvent) {
316+
onEditEvent(nodeId, meta);
317+
}
310318
}
311-
}, [actionMetas, onEditStep]);
319+
}, [actionMetas, eventMetas, onEditStep, onEditEvent]);
312320

313321
/**
314322
* Enrich all nodes with edit/delete callbacks so custom node components can trigger actions.

0 commit comments

Comments
 (0)