From 2a5d92a5012f6abd0fac8a54904ce1895b62cb6a Mon Sep 17 00:00:00 2001
From: 1AhmedYasser <26207361+1AhmedYasser@users.noreply.github.com>
Date: Mon, 27 Jul 2026 02:25:29 +0300
Subject: [PATCH 1/5] chore(1123): Enhanced Conditional Node Saving
---
.../ConditionBuilderContent.tsx | 4 +-
.../FlowElementsPopup/ConditionContent.tsx | 4 +-
.../RuleBuilder/ConnectorToggle.tsx | 41 +++++++++++++++
.../FlowElementsPopup/RuleBuilder/index.tsx | 44 ++++++++--------
.../FlowElementsPopup/RuleBuilder/types.tsx | 35 ++++++++++---
.../RuleBuilder/useRuleBuilder.tsx | 42 ++++++++++++----
.../components/FlowElementsPopup/index.tsx | 28 +++++------
GUI/src/services/service-builder.ts | 50 ++++++++++---------
GUI/src/store/new-services.store.ts | 12 ++---
9 files changed, 170 insertions(+), 90 deletions(-)
create mode 100644 GUI/src/components/FlowElementsPopup/RuleBuilder/ConnectorToggle.tsx
diff --git a/GUI/src/components/FlowElementsPopup/ConditionBuilderContent.tsx b/GUI/src/components/FlowElementsPopup/ConditionBuilderContent.tsx
index 59a2fffe8..7229640ef 100644
--- a/GUI/src/components/FlowElementsPopup/ConditionBuilderContent.tsx
+++ b/GUI/src/components/FlowElementsPopup/ConditionBuilderContent.tsx
@@ -15,7 +15,7 @@ const ConditionBuilderContent: React.FC = () => {
const isYesNoQuestion = useServiceStore((state) => state.isYesNoQuestion);
const rules = useServiceStore((state) => state.rules);
const node = useServiceStore((state) => state.selectedNode);
- const seedGroup = node?.data?.rules || (Array.isArray(rules) && rules.length > 0 ? rules : undefined);
+ const seedGroup = node?.data?.rules || (rules && rules.children.length > 0 ? rules : undefined);
return (
- {elements?.map((element) =>
- isInstanceOfRule(element) ? (
-
- ) : (
-
- ),
- )}
+ {elements?.map((element: GroupOrRule, index: number) => (
+
+ {index > 0 && (
+ changeConnector(element.id, connector)}
+ onToggleNot={() => toggleConnectorNot(element.id)}
+ />
+ )}
+ {isInstanceOfRule(element) ? (
+
+ ) : (
+
+ )}
+
+ ))}
);
};
diff --git a/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx b/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx
index 1c517e7fc..5797755b6 100644
--- a/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx
+++ b/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx
@@ -8,7 +8,14 @@ export interface RuleGroupBuilderProps {
seedGroup?: Group | GroupOrRule[];
}
-export interface Rule {
+export type GroupType = 'and' | 'or';
+
+interface WithConnector {
+ connector?: GroupType;
+ connectorNot?: boolean;
+}
+
+export interface Rule extends WithConnector {
id: string;
field: string;
operator: string;
@@ -19,20 +26,20 @@ export interface Rule {
isValueManual?: boolean;
}
-export type GroupType = 'and' | 'or';
-
-export interface Group {
+export interface Group extends WithConnector {
id: string;
children: GroupOrRule[];
- type: GroupType;
not: boolean;
+ // Deprecated: previously the single AND/OR used to combine every child of this group.
+ // Kept only so groups saved before per-child connectors were introduced keep serializing the same way.
+ type?: GroupType;
}
export type GroupOrRule = Group | Rule;
export const isInstanceOfRule = (x: GroupOrRule): boolean => 'operator' in x;
-export const getInitialRule = () => {
+export const getInitialRule = (connector: GroupType = 'and') => {
return {
id: uuidv4(),
field: '',
@@ -42,14 +49,26 @@ export const getInitialRule = () => {
valueDragElement: undefined,
isFieldManual: false,
isValueManual: false,
+ connector,
+ connectorNot: false,
};
};
-export const getInitialGroup = () => {
+export const getInitialGroup = (connector: GroupType = 'and') => {
return {
id: uuidv4(),
children: [getInitialRule()],
- type: 'and',
not: false,
+ connector,
+ connectorNot: false,
} as Group;
};
+
+// Fills in a connector for children saved before per-child connectors existed,
+// falling back to the group's old uniform `type` so existing flows keep evaluating the same way.
+export const withMigratedConnectors = (children: GroupOrRule[], legacyType?: GroupType): GroupOrRule[] =>
+ children.map((child, index) => ({
+ ...child,
+ connector: child.connector ?? (index === 0 ? undefined : (legacyType ?? 'and')),
+ connectorNot: child.connectorNot ?? false,
+ }));
diff --git a/GUI/src/components/FlowElementsPopup/RuleBuilder/useRuleBuilder.tsx b/GUI/src/components/FlowElementsPopup/RuleBuilder/useRuleBuilder.tsx
index 6e9c0276e..eccaf291f 100644
--- a/GUI/src/components/FlowElementsPopup/RuleBuilder/useRuleBuilder.tsx
+++ b/GUI/src/components/FlowElementsPopup/RuleBuilder/useRuleBuilder.tsx
@@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from 'react';
-import { getInitialGroup, getInitialRule, Group, GroupOrRule, GroupType, Rule } from './types';
+import { getInitialGroup, getInitialRule, Group, GroupOrRule, GroupType, Rule, withMigratedConnectors } from './types';
+
+type SeedGroup = Group | GroupOrRule[] | undefined;
interface UseRuleBuilderProps {
group?: Group;
@@ -10,15 +12,21 @@ interface UseRuleBuilderProps {
}
export const useRuleBuilder = (config: UseRuleBuilderProps) => {
- const getSeedGroupChildren = (seedGroup: Group | GroupOrRule[] | undefined): GroupOrRule[] => {
+ const getSeedGroupChildren = (seedGroup: SeedGroup): GroupOrRule[] => {
if (!seedGroup) return [];
if (Array.isArray(seedGroup)) return seedGroup;
return seedGroup.children;
};
- const elementsInitialValue = config.root ? getSeedGroupChildren(config.seedGroup) : config.group!.children;
+ const getSeedGroupLegacyType = (seedGroup: SeedGroup): GroupType | undefined =>
+ seedGroup && !Array.isArray(seedGroup) ? seedGroup.type : undefined;
+
+ const elementsInitialValue = withMigratedConnectors(
+ config.root ? getSeedGroupChildren(config.seedGroup) : config.group!.children,
+ config.root ? getSeedGroupLegacyType(config.seedGroup) : config.group!.type,
+ );
- const isSeedGroupValid = (seedGroup: Group | GroupOrRule[] | undefined): boolean => {
+ const isSeedGroupValid = (seedGroup: SeedGroup): boolean => {
if (!seedGroup) return false;
if (Array.isArray(seedGroup)) return seedGroup.length > 0;
return seedGroup.children?.length > 0;
@@ -41,6 +49,19 @@ export const useRuleBuilder = (config: UseRuleBuilderProps) => {
const onChangeRef = useRef(config.onChange);
onChangeRef.current = config.onChange;
+ // The parent renders the connector (AND/OR/NOT) toggle for a subgroup, mutating this
+ // group's connector fields from outside. Re-sync local state so that doesn't get
+ // clobbered by the next time this group's own onChange effect below fires.
+ useEffect(() => {
+ if (config.root || !config.group) return;
+ setGroupInfo((prev) => ({
+ ...prev,
+ connector: config.group!.connector,
+ connectorNot: config.group!.connectorNot,
+ }));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [config.group?.connector, config.group?.connectorNot]);
+
useEffect(() => {
onChangeRef.current({
...groupInfo,
@@ -67,12 +88,13 @@ export const useRuleBuilder = (config: UseRuleBuilderProps) => {
});
};
- const changeType = (type: GroupType) => () => {
- setGroupInfo({ ...groupInfo, type });
+ const changeConnector = (id: string, connector: GroupType) => {
+ setElements(elements.map((x) => (x.id === id ? { ...x, connector } : x)));
};
- const changeToAnd = changeType('and');
- const changeToOr = changeType('or');
+ const toggleConnectorNot = (id: string) => {
+ setElements(elements.map((x) => (x.id === id ? { ...x, connectorNot: !x.connectorNot } : x)));
+ };
const changeRule = (rule: Rule) => setElementById(rule.id, rule);
@@ -90,8 +112,8 @@ export const useRuleBuilder = (config: UseRuleBuilderProps) => {
addGroup,
remove,
toggleNot,
- changeToAnd,
- changeToOr,
+ changeConnector,
+ toggleConnectorNot,
changeRule,
onSubGroupChange,
};
diff --git a/GUI/src/components/FlowElementsPopup/index.tsx b/GUI/src/components/FlowElementsPopup/index.tsx
index 9908c831b..724ea6c76 100644
--- a/GUI/src/components/FlowElementsPopup/index.tsx
+++ b/GUI/src/components/FlowElementsPopup/index.tsx
@@ -138,9 +138,9 @@ const FlowElementsPopup: React.FC = () => {
case StepType.Input:
case StepType.Condition:
if (node.data?.rules && Array.isArray(node.data.rules.children)) {
- useServiceStore.getState().changeRulesNode(node.data.rules.children);
+ useServiceStore.getState().changeRulesNode(node.data.rules);
} else {
- useServiceStore.getState().changeRulesNode([]);
+ useServiceStore.getState().changeRulesNode(undefined);
}
break;
@@ -309,21 +309,19 @@ const FlowElementsPopup: React.FC = () => {
};
const prepareRulesForSaving = (updatedNode: Node) => {
- const rulesArray = Array.isArray(rules) ? rules : [];
- for (const item of rulesArray) {
- const processRuleField = (obj: GroupOrRule) => {
- if ('field' in obj) obj.field = removeTrailingUnderscores(obj.field);
- else {
- for (const child of obj.children) {
- processRuleField(child);
- }
+ const rootGroup = rules ?? getInitialGroup();
+ const processRuleField = (obj: GroupOrRule) => {
+ if ('field' in obj) obj.field = removeTrailingUnderscores(obj.field);
+ else {
+ for (const child of obj.children) {
+ processRuleField(child);
}
- };
- processRuleField(item);
+ }
+ };
+ for (const child of rootGroup.children) {
+ processRuleField(child);
}
- updatedNode.data.rules = node.data.rules
- ? { ...node.data.rules, children: rulesArray }
- : { ...getInitialGroup(), children: rulesArray };
+ updatedNode.data.rules = rootGroup;
};
const saveMultiChoicePopup = (originalNode: Node, updatedNode: Node) => {
diff --git a/GUI/src/services/service-builder.ts b/GUI/src/services/service-builder.ts
index 36dae7aba..35041e3f0 100644
--- a/GUI/src/services/service-builder.ts
+++ b/GUI/src/services/service-builder.ts
@@ -1,6 +1,6 @@
import { Edge, Node } from '@xyflow/react';
import { AxiosError } from 'axios';
-import { Group, Rule } from 'components/FlowElementsPopup/RuleBuilder/types';
+import { Group, GroupOrRule, GroupType, Rule } from 'components/FlowElementsPopup/RuleBuilder/types';
import { format } from 'date-fns';
import i18next, { t } from 'i18next';
import { NodeHtmlMarkdown, PostProcessResult, TranslatorConfigObject } from 'node-html-markdown';
@@ -252,37 +252,39 @@ const buildConditionString = (group: any, assignedVariableNames: Set): s
return isNumericString(rawField) ? rawField : `"${rawField}"`;
};
+ const buildRuleTerm = (rule: Rule): string => {
+ const rawField = rule.field.replaceAll('${', '').replaceAll('}', '');
+ const absoluteValue = removeWrapperQuotes(rule.value.replaceAll('${', '').replaceAll('}', ''));
+ const value = formatField(absoluteValue);
+ const field = formatField(rawField);
+ return `${field} ${rule.operator} ${value}`;
+ };
+
if ('children' in group) {
const subgroup = group as Group;
if (subgroup.children.length === 0) {
return '';
}
- const conditions = subgroup.children.map((child) => {
- if ('children' in child) {
- return `(${buildConditionString(child, assignedVariableNames)})`;
- } else {
- const rule = child;
- const rawField = rule.field.replaceAll('${', '').replaceAll('}', '');
- const absoluteValue = removeWrapperQuotes(rule.value.replaceAll('${', '').replaceAll('}', ''));
- const value = formatField(absoluteValue);
- const field = formatField(rawField);
- return `${field} ${rule.operator} ${value}`;
- }
- });
+ // Groups saved before per-child connectors existed have no connector on their children,
+ // so fall back to the group's old uniform AND/OR to keep evaluating the same way.
+ const legacyType: GroupType = subgroup.type ?? 'and';
- if (subgroup.not) {
- return `!(${subgroup.type === 'and' ? conditions.join(' && ') : conditions.join(' || ')})`;
- } else {
- return subgroup.type === 'and' ? conditions.join(' && ') : conditions.join(' || ');
- }
+ const combined = subgroup.children.reduce((accumulated: string, child: GroupOrRule, index: number) => {
+ const term =
+ 'children' in child ? `(${buildConditionString(child, assignedVariableNames)})` : buildRuleTerm(child as Rule);
+ const signedTerm = child.connectorNot ? `!(${term})` : term;
+
+ if (index === 0) return signedTerm;
+
+ const connector = child.connector ?? legacyType;
+ const operator = connector === 'and' ? '&&' : '||';
+ return `${accumulated} ${operator} ${signedTerm}`;
+ }, '');
+
+ return subgroup.not ? `!(${combined})` : combined;
} else {
- const rule = group as Rule;
- const rawField = rule.field.replaceAll('${', '').replaceAll('}', '');
- const absoluteValue = removeWrapperQuotes(rule.value.replaceAll('${', '').replaceAll('}', ''));
- const value = formatField(absoluteValue);
- const field = formatField(rawField);
- return `${field} ${rule.operator} ${value}`;
+ return buildRuleTerm(group as Rule);
}
};
diff --git a/GUI/src/store/new-services.store.ts b/GUI/src/store/new-services.store.ts
index d59cac1a1..9add4d93e 100644
--- a/GUI/src/store/new-services.store.ts
+++ b/GUI/src/store/new-services.store.ts
@@ -10,7 +10,7 @@ import {
ReactFlowInstance,
} from '@xyflow/react';
import { AxiosResponse } from 'axios';
-import { GroupOrRule } from 'components/FlowElementsPopup/RuleBuilder/types';
+import { Group } from 'components/FlowElementsPopup/RuleBuilder/types';
import i18next from 'i18next';
import {
getAllEndpoints,
@@ -90,13 +90,13 @@ export interface ServiceStoreState {
isNewService: boolean;
serviceState?: ServiceState;
assignElements: Assign[];
- rules: GroupOrRule[];
+ rules: Group | undefined;
isYesNoQuestion: boolean;
stepPreferences: string[];
endpointsResponseVariables: EndpointResponseVariable[];
setIsYesNoQuestion: (value: boolean) => void;
changeAssignNode: (assign: Assign[]) => void;
- changeRulesNode: (rules: GroupOrRule[]) => void;
+ changeRulesNode: (rules: Group | undefined) => void;
markAsNewService: () => void;
unmarkAsNewService: () => void;
setServiceId: (id: string) => void;
@@ -257,7 +257,7 @@ const useServiceStore = create((set, get) => ({
isTestButtonVisible: false,
isTestButtonEnabled: true,
assignElements: [],
- rules: [],
+ rules: undefined,
isYesNoQuestion: false,
stepPreferences: [],
endpointsResponseVariables: [],
@@ -435,7 +435,7 @@ const useServiceStore = create((set, get) => ({
nodes: initialNodes,
isTestButtonEnabled: true,
assignElements: [],
- rules: [],
+ rules: undefined,
isYesNoQuestion: false,
clickedNode: null,
isTestButtonVisible: false,
@@ -447,7 +447,7 @@ const useServiceStore = create((set, get) => ({
useTestServiceStore.getState().reset();
},
resetAssign: () => set({ assignElements: [] }),
- resetRules: () => set({ rules: [], isYesNoQuestion: false }),
+ resetRules: () => set({ rules: undefined, isYesNoQuestion: false }),
loadService: async (id, resetState, search) => {
if (resetState === true) {
get().resetState();
From 1e2f3f73652c5949275a0b03416626f1a5e88dbd Mon Sep 17 00:00:00 2001
From: 1AhmedYasser <26207361+1AhmedYasser@users.noreply.github.com>
Date: Mon, 27 Jul 2026 02:41:44 +0300
Subject: [PATCH 2/5] fix(1123): Remvoed comment
---
GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx | 2 --
1 file changed, 2 deletions(-)
diff --git a/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx b/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx
index 5797755b6..b8224dd50 100644
--- a/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx
+++ b/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx
@@ -64,8 +64,6 @@ export const getInitialGroup = (connector: GroupType = 'and') => {
} as Group;
};
-// Fills in a connector for children saved before per-child connectors existed,
-// falling back to the group's old uniform `type` so existing flows keep evaluating the same way.
export const withMigratedConnectors = (children: GroupOrRule[], legacyType?: GroupType): GroupOrRule[] =>
children.map((child, index) => ({
...child,
From 181a8d27b76e2460310c30916e6c69a3a1b5c076 Mon Sep 17 00:00:00 2001
From: 1AhmedYasser <26207361+1AhmedYasser@users.noreply.github.com>
Date: Mon, 27 Jul 2026 02:42:51 +0300
Subject: [PATCH 3/5] chore(1123): Removed comments
---
.../FlowElementsPopup/RuleBuilder/useRuleBuilder.tsx | 3 ---
GUI/src/services/service-builder.ts | 2 --
2 files changed, 5 deletions(-)
diff --git a/GUI/src/components/FlowElementsPopup/RuleBuilder/useRuleBuilder.tsx b/GUI/src/components/FlowElementsPopup/RuleBuilder/useRuleBuilder.tsx
index eccaf291f..50460ea8f 100644
--- a/GUI/src/components/FlowElementsPopup/RuleBuilder/useRuleBuilder.tsx
+++ b/GUI/src/components/FlowElementsPopup/RuleBuilder/useRuleBuilder.tsx
@@ -49,9 +49,6 @@ export const useRuleBuilder = (config: UseRuleBuilderProps) => {
const onChangeRef = useRef(config.onChange);
onChangeRef.current = config.onChange;
- // The parent renders the connector (AND/OR/NOT) toggle for a subgroup, mutating this
- // group's connector fields from outside. Re-sync local state so that doesn't get
- // clobbered by the next time this group's own onChange effect below fires.
useEffect(() => {
if (config.root || !config.group) return;
setGroupInfo((prev) => ({
diff --git a/GUI/src/services/service-builder.ts b/GUI/src/services/service-builder.ts
index 35041e3f0..074b48562 100644
--- a/GUI/src/services/service-builder.ts
+++ b/GUI/src/services/service-builder.ts
@@ -266,8 +266,6 @@ const buildConditionString = (group: any, assignedVariableNames: Set): s
return '';
}
- // Groups saved before per-child connectors existed have no connector on their children,
- // so fall back to the group's old uniform AND/OR to keep evaluating the same way.
const legacyType: GroupType = subgroup.type ?? 'and';
const combined = subgroup.children.reduce((accumulated: string, child: GroupOrRule, index: number) => {
From 4d1279f38b3488c8795cb9b0bd02e5a8ceb86a92 Mon Sep 17 00:00:00 2001
From: 1AhmedYasser <26207361+1AhmedYasser@users.noreply.github.com>
Date: Mon, 27 Jul 2026 02:43:37 +0300
Subject: [PATCH 4/5] chore(1123): Removed comment
---
GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx | 2 --
1 file changed, 2 deletions(-)
diff --git a/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx b/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx
index b8224dd50..a4ebcdc25 100644
--- a/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx
+++ b/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx
@@ -30,8 +30,6 @@ export interface Group extends WithConnector {
id: string;
children: GroupOrRule[];
not: boolean;
- // Deprecated: previously the single AND/OR used to combine every child of this group.
- // Kept only so groups saved before per-child connectors were introduced keep serializing the same way.
type?: GroupType;
}
From d21a9ef7154d4234fab993700c38a1ed482991e8 Mon Sep 17 00:00:00 2001
From: 1AhmedYasser <26207361+1AhmedYasser@users.noreply.github.com>
Date: Mon, 27 Jul 2026 13:16:37 +0300
Subject: [PATCH 5/5] chore(1123): Addressed PR Comments
---
.../FlowElementsPopup/RuleBuilder/ConnectorToggle.tsx | 8 ++++----
.../components/FlowElementsPopup/RuleBuilder/types.tsx | 4 ++--
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/GUI/src/components/FlowElementsPopup/RuleBuilder/ConnectorToggle.tsx b/GUI/src/components/FlowElementsPopup/RuleBuilder/ConnectorToggle.tsx
index f8773bf30..222fb2381 100644
--- a/GUI/src/components/FlowElementsPopup/RuleBuilder/ConnectorToggle.tsx
+++ b/GUI/src/components/FlowElementsPopup/RuleBuilder/ConnectorToggle.tsx
@@ -5,10 +5,10 @@ import { useTranslation } from 'react-i18next';
import { GroupType } from './types';
interface ConnectorToggleProps {
- connector: GroupType;
- connectorNot: boolean;
- onChangeConnector: (connector: GroupType) => void;
- onToggleNot: () => void;
+ readonly connector: GroupType;
+ readonly connectorNot: boolean;
+ readonly onChangeConnector: (connector: GroupType) => void;
+ readonly onToggleNot: () => void;
}
const ConnectorToggle: React.FC = ({
diff --git a/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx b/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx
index a4ebcdc25..90f41bba2 100644
--- a/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx
+++ b/GUI/src/components/FlowElementsPopup/RuleBuilder/types.tsx
@@ -11,8 +11,8 @@ export interface RuleGroupBuilderProps {
export type GroupType = 'and' | 'or';
interface WithConnector {
- connector?: GroupType;
- connectorNot?: boolean;
+ readonly connector?: GroupType;
+ readonly connectorNot?: boolean;
}
export interface Rule extends WithConnector {