From 24bd232e5f213933bcad1e1d6e6e60587499e69d Mon Sep 17 00:00:00 2001 From: Jake Boone Date: Tue, 5 Aug 2025 18:45:54 -0700 Subject: [PATCH 1/9] Initial addition of `RulesEngine` and related types --- .../src/types/index.noReact.ts | 1 + .../src/types/rulesEngine.ts | 93 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 packages/react-querybuilder/src/types/rulesEngine.ts diff --git a/packages/react-querybuilder/src/types/index.noReact.ts b/packages/react-querybuilder/src/types/index.noReact.ts index a43751ebe..ae78ed25e 100644 --- a/packages/react-querybuilder/src/types/index.noReact.ts +++ b/packages/react-querybuilder/src/types/index.noReact.ts @@ -28,5 +28,6 @@ export * from './options'; export * from './props'; export * from './ruleGroups'; export * from './ruleGroupsIC'; +export * from './rulesEngine'; export * from './type-fest'; export * from './validation'; diff --git a/packages/react-querybuilder/src/types/rulesEngine.ts b/packages/react-querybuilder/src/types/rulesEngine.ts new file mode 100644 index 000000000..11734487b --- /dev/null +++ b/packages/react-querybuilder/src/types/rulesEngine.ts @@ -0,0 +1,93 @@ +import type { RuleGroupType, RuleType } from './ruleGroups'; +import type { RuleGroupTypeAny, RuleGroupTypeIC } from './ruleGroupsIC'; + +export type RulesEngineCondition = RG & { + action?: RulesEngineAction; + conditions?: RulesEngineConditions; +}; + +export type RulesEngineConditions = + | RulesEngineCondition[] // if/if-else clauses only + | [...RulesEngineCondition[], RulesEngineAction]; // if/if-else clauses and a final, unconditional "else" action + +export interface RulesEngine { + conditions: RulesEngineConditions>; +} + +export interface RulesEngineIC { + conditions: RulesEngineConditions>; +} + +export type RulesEngineActionBase = { + actionType: T; +}; + +export interface RulesEngineAction extends RulesEngineActionBase { + [etc: string]: unknown; +} + +// ------------------------------------------- +// Playground: +// ------------------------------------------- + +interface _ExampleRulesEngineAction extends RulesEngineActionBase<'rea' | 'hope'> { + command: string; + args: unknown[] | Record; + options?: { + async?: boolean; + timeout?: number; + retries?: number; + }; + metadata?: { + name?: string; + description?: string; + tags?: string[]; + }; +} + +const _myREA: _ExampleRulesEngineAction = { actionType: 'rea', command: 'cmd', args: [] }; + +const _rngn: RulesEngine = { + conditions: [ + // IF + { + combinator: 'and', + rules: [], + // action: { actionType: 'cmd', payload: { command: '', args: [] }}, + }, + // ELSE IF + { + combinator: 'and', + rules: [{ field: '', operator: '', value: '' }], + action: { actionType: 'cmd', payload: { command: '', args: [] } }, + conditions: [ + { + combinator: 'and', + rules: [], + action: { actionType: 'cmd', payload: { command: '', args: [] } }, + }, + ], + }, + // ELSE + { actionType: 'hope' }, + ], +}; + +// conditions: +// oxlint-disable-next-line no-dupe-else-if +if (process.env.TZ) { + // action: + // doSomething(); + + // conditions: + if (process.env.TZ) { + // action: + // doSomething(); + } +} else if (process.env.TZ) { + // action: + // doSomething(); +} else { + // action: + // doSomething(); +} From 9060aad06fe9b6806909cbf5f358202f78271ea6 Mon Sep 17 00:00:00 2001 From: Jake Boone Date: Thu, 7 Aug 2025 16:23:29 -0700 Subject: [PATCH 2/9] Initial `rulesEngineTools.ts` (`addRE` only) and new path utils Still needs full test coverage --- .../src/types/ruleGroups.ts | 7 +- .../src/types/rulesEngine.ts | 12 +- .../react-querybuilder/src/utils/index.ts | 2 + .../src/utils/isRuleGroup.ts | 22 ++- .../src/utils/isRulesEngine.ts | 32 ++++ packages/react-querybuilder/src/utils/misc.ts | 3 +- .../src/utils/pathUtils.test.ts | 4 +- .../react-querybuilder/src/utils/pathUtils.ts | 86 ++++++++++- .../src/utils/queryTools.ts | 2 +- .../src/utils/rulesEngineTools.test.ts | 140 ++++++++++++++++++ .../src/utils/rulesEngineTools.ts | 86 +++++++++++ 11 files changed, 375 insertions(+), 21 deletions(-) create mode 100644 packages/react-querybuilder/src/utils/isRulesEngine.ts create mode 100644 packages/react-querybuilder/src/utils/rulesEngineTools.test.ts create mode 100644 packages/react-querybuilder/src/utils/rulesEngineTools.ts diff --git a/packages/react-querybuilder/src/types/ruleGroups.ts b/packages/react-querybuilder/src/types/ruleGroups.ts index 74c116a59..c3921a715 100644 --- a/packages/react-querybuilder/src/types/ruleGroups.ts +++ b/packages/react-querybuilder/src/types/ruleGroups.ts @@ -54,10 +54,9 @@ export type RuleGroupArray< * All updateable properties of rules and groups (everything except * `id`, `path`, and `rules`). */ -export type UpdateableProperties = Exclude< - keyof (RuleType & RuleGroupType), - 'id' | 'path' | 'rules' ->; +export type UpdateableProperties = + | Exclude + | (string & {}); /** * The type of the `rules` array in a {@link DefaultRuleGroupType}. diff --git a/packages/react-querybuilder/src/types/rulesEngine.ts b/packages/react-querybuilder/src/types/rulesEngine.ts index 11734487b..765ab266c 100644 --- a/packages/react-querybuilder/src/types/rulesEngine.ts +++ b/packages/react-querybuilder/src/types/rulesEngine.ts @@ -1,4 +1,4 @@ -import type { RuleGroupType, RuleType } from './ruleGroups'; +import type { CommonRuleAndGroupProperties, RuleGroupType, RuleType } from './ruleGroups'; import type { RuleGroupTypeAny, RuleGroupTypeIC } from './ruleGroupsIC'; export type RulesEngineCondition = RG & { @@ -10,14 +10,20 @@ export type RulesEngineConditions = | RulesEngineCondition[] // if/if-else clauses only | [...RulesEngineCondition[], RulesEngineAction]; // if/if-else clauses and a final, unconditional "else" action -export interface RulesEngine { +export interface RulesEngine + extends CommonRuleAndGroupProperties { conditions: RulesEngineConditions>; } -export interface RulesEngineIC { +export interface RulesEngineIC + extends CommonRuleAndGroupProperties { conditions: RulesEngineConditions>; } +export type RulesEngineAny = + | RulesEngine + | RulesEngineIC; + export type RulesEngineActionBase = { actionType: T; }; diff --git a/packages/react-querybuilder/src/utils/index.ts b/packages/react-querybuilder/src/utils/index.ts index ce3d3c77e..43292aa7f 100644 --- a/packages/react-querybuilder/src/utils/index.ts +++ b/packages/react-querybuilder/src/utils/index.ts @@ -12,6 +12,7 @@ export * from './getValidationClassNames'; export * from './getValueSourcesUtil'; export * from './isRuleGroup'; export * from './isRuleOrGroupValid'; +export * from './isRulesEngine'; export * from './mergeClassnames'; export * from './mergeTranslations'; export * from './misc'; @@ -21,6 +22,7 @@ export * from './parseNumber'; export * from './pathUtils'; export * from './prepareQueryObjects'; export * from './queryTools'; +export * from './rulesEngineTools'; export * from './regenerateIDs'; export * from './toOptions'; export * from './transformQuery'; diff --git a/packages/react-querybuilder/src/utils/isRuleGroup.ts b/packages/react-querybuilder/src/utils/isRuleGroup.ts index 1b7275c22..0953a9645 100644 --- a/packages/react-querybuilder/src/utils/isRuleGroup.ts +++ b/packages/react-querybuilder/src/utils/isRuleGroup.ts @@ -1,23 +1,31 @@ -import type { RuleGroupType, RuleGroupTypeAny, RuleGroupTypeIC } from '../types/index.noReact'; +import type { + RuleGroupType, + RuleGroupTypeAny, + RuleGroupTypeIC, + RuleType, +} from '../types/index.noReact'; import { isPojo } from './misc'; +/** + * Determines if an object is a {@link RuleType} (only checks for a `field` property). + */ +export const isRuleType = (s: unknown): s is RuleType => + isPojo(s) && 'field' in s && typeof s.field === 'string'; + /** * Determines if an object is a {@link RuleGroupType} or {@link RuleGroupTypeIC}. */ -// oxlint-disable-next-line typescript/no-explicit-any -export const isRuleGroup = (rg: any): rg is RuleGroupTypeAny => +export const isRuleGroup = (rg: unknown): rg is RuleGroupTypeAny => isPojo(rg) && Array.isArray(rg.rules); /** * Determines if an object is a {@link RuleGroupType}. */ -// oxlint-disable-next-line typescript/no-explicit-any -export const isRuleGroupType = (rg: any): rg is RuleGroupType => +export const isRuleGroupType = (rg: unknown): rg is RuleGroupType => isRuleGroup(rg) && typeof rg.combinator === 'string'; /** * Determines if an object is a {@link RuleGroupTypeIC}. */ -// oxlint-disable-next-line typescript/no-explicit-any -export const isRuleGroupTypeIC = (rg: any): rg is RuleGroupTypeIC => +export const isRuleGroupTypeIC = (rg: unknown): rg is RuleGroupTypeIC => isRuleGroup(rg) && rg.combinator === undefined; diff --git a/packages/react-querybuilder/src/utils/isRulesEngine.ts b/packages/react-querybuilder/src/utils/isRulesEngine.ts new file mode 100644 index 000000000..9c9d18ca6 --- /dev/null +++ b/packages/react-querybuilder/src/utils/isRulesEngine.ts @@ -0,0 +1,32 @@ +import type { + RulesEngine, + RulesEngineAction, + RulesEngineAny, + RulesEngineIC, +} from '../types/index.noReact'; +import { isRuleGroupType, isRuleGroupTypeIC } from './isRuleGroup'; +import { isPojo } from './misc'; + +/** + * Determines if an object is a {@link RulesEngine} or {@link RulesEngineIC}. + */ +export const isRulesEngineAny = (re: unknown): re is RulesEngineAny => + isPojo(re) && 'conditions' in re && Array.isArray(re.conditions); + +/** + * Determines if an object is a {@link RulesEngine}. + */ +export const isRulesEngine = (re: unknown): re is RulesEngine => + isRulesEngineAny(re) && isRuleGroupType(re.conditions[0]); + +/** + * Determines if an object is a {@link RulesEngineIC}. + */ +export const isRulesEngineIC = (re: unknown): re is RulesEngineIC => + isRulesEngineAny(re) && isRuleGroupTypeIC(re.conditions[0]); + +/** + * Determines if an object is a {@link RulesEngine} or {@link RulesEngineIC}. + */ +export const isRulesEngineAction = (obj: unknown): obj is RulesEngineAction => + isPojo(obj) && typeof obj.actionType === 'string'; diff --git a/packages/react-querybuilder/src/utils/misc.ts b/packages/react-querybuilder/src/utils/misc.ts index 773f78900..ea8333d69 100644 --- a/packages/react-querybuilder/src/utils/misc.ts +++ b/packages/react-querybuilder/src/utils/misc.ts @@ -24,6 +24,5 @@ export const isPojo = (obj: any): obj is Record => /** * Simple helper to determine whether a value is null, undefined, or an empty string. */ -// oxlint-disable-next-line typescript/no-explicit-any -export const nullOrUndefinedOrEmpty = (value: any): value is null | undefined | '' => +export const nullOrUndefinedOrEmpty = (value: unknown): value is null | undefined | '' => value === null || value === undefined || value === ''; diff --git a/packages/react-querybuilder/src/utils/pathUtils.test.ts b/packages/react-querybuilder/src/utils/pathUtils.test.ts index 61fd27630..661786aae 100644 --- a/packages/react-querybuilder/src/utils/pathUtils.test.ts +++ b/packages/react-querybuilder/src/utils/pathUtils.test.ts @@ -75,7 +75,7 @@ describe('findPath', () => { }); it('should not find an invalid path', () => { - expect(findPath([7, 7, 7], query)).toBeUndefined(); + expect(findPath([7, 7, 7], query) ?? undefined).toBeUndefined(); }); }); @@ -89,7 +89,7 @@ describe('findPath', () => { }); it('should not find an invalid path', () => { - expect(findPath([7, 7, 7], queryIC)).toBeUndefined(); + expect(findPath([7, 7, 7], queryIC) ?? undefined).toBeUndefined(); }); it('should return null for combinator elements', () => { diff --git a/packages/react-querybuilder/src/utils/pathUtils.ts b/packages/react-querybuilder/src/utils/pathUtils.ts index a57d2e152..16ff1200d 100644 --- a/packages/react-querybuilder/src/utils/pathUtils.ts +++ b/packages/react-querybuilder/src/utils/pathUtils.ts @@ -1,5 +1,12 @@ -import type { Path, RuleGroupTypeAny, RuleType } from '../types/index.noReact'; +import type { + Path, + RuleGroupTypeAny, + RulesEngineAction, + RulesEngineAny, + RuleType, +} from '../types/index.noReact'; import { isRuleGroup } from './isRuleGroup'; +import { isRulesEngine } from './isRulesEngine'; import { isPojo } from './misc'; /** @@ -7,6 +14,15 @@ import { isPojo } from './misc'; */ export type FindPathReturnType = RuleGroupTypeAny | RuleType | null; +/** + * Return type for {@link findConditionPath}. + */ +export type FindConditionPathReturnType = + | RulesEngineAny + | RuleGroupTypeAny + | RulesEngineAction + | null; + /** * Returns the {@link RuleType} or {@link RuleGroupType}/{@link RuleGroupTypeIC} * at the given path within a query. @@ -20,7 +36,25 @@ export const findPath = (path: Path, query: RuleGroupTypeAny): FindPathReturnTyp level++; } - return target; + return level < path.length ? null : target; +}; + +/** + * Returns the {@link RuleGroupType}/{@link RuleGroupTypeIC} + * at the given path within a rules engine. + */ +export const findConditionPath = ( + path: Path, + rulesEngine: RulesEngineAny +): FindConditionPathReturnType => { + let target: FindConditionPathReturnType = rulesEngine; + let level = 0; + while (level < path.length && target && isRulesEngine(target)) { + target = target.conditions[path[level]]; + level++; + } + + return level < path.length ? null : (target ?? null); }; /** @@ -47,6 +81,29 @@ export const findID = (id: string, query: RuleGroupTypeAny): FindPathReturnType return null; }; +/** + * Returns the {@link RuleGroupType}/{@link RuleGroupTypeIC} + * with the given `id` within a rules engine. + */ +export const findConditionID = ( + id: string, + rulesEngine: RulesEngineAny +): RuleGroupTypeAny | RulesEngineAny | RulesEngineAction | null => { + if (rulesEngine.id === id) { + return rulesEngine; + } + + for (const condition of rulesEngine.conditions) { + if (condition.id === id) { + return condition; + } else if (isRulesEngine(condition)) { + return findConditionID(id, condition); + } + } + + return null; +}; + /** * Returns the {@link Path} of the {@link RuleType} or {@link RuleGroupType}/{@link RuleGroupTypeIC} * with the given `id` within a query. @@ -72,6 +129,31 @@ export const getPathOfID = (id: string, query: RuleGroupTypeAny): Path | null => return null; }; +/** + * Returns the {@link Path} of the {@link RuleGroupType}/{@link RuleGroupTypeIC} + * with the given `id` within a rules engine. + */ +export const getConditionPathOfID = (id: string, re: RulesEngineAny): Path | null => { + if (re.id === id) return []; + + const idx = re.conditions.findIndex(c => !(typeof c === 'string') && c.id === id); + + if (idx >= 0) { + return [idx]; + } + + for (const [i, c] of Object.entries(re.conditions)) { + if (isRulesEngine(c)) { + const subPath = getConditionPathOfID(id, c); + if (Array.isArray(subPath)) { + return [Number.parseInt(i), ...subPath]; + } + } + } + + return null; +}; + /** * Truncates the last element of an array and returns the result as a new array. */ diff --git a/packages/react-querybuilder/src/utils/queryTools.ts b/packages/react-querybuilder/src/utils/queryTools.ts index a0d784482..73956ed96 100644 --- a/packages/react-querybuilder/src/utils/queryTools.ts +++ b/packages/react-querybuilder/src/utils/queryTools.ts @@ -86,7 +86,7 @@ export const add = ( (typeof prevCombinator === 'string' ? prevCombinator : getFirstOption(combinators)) ); } - // `as RuleType` in only here to avoid the ambiguity with `RuleGroupTypeAny` + // `as RuleType` is only here to avoid the ambiguity with `RuleGroupTypeAny` parent.rules.push(prepareRuleOrGroup(ruleOrGroup, { idGenerator }) as RuleType); }); diff --git a/packages/react-querybuilder/src/utils/rulesEngineTools.test.ts b/packages/react-querybuilder/src/utils/rulesEngineTools.test.ts new file mode 100644 index 000000000..8b78ce5c5 --- /dev/null +++ b/packages/react-querybuilder/src/utils/rulesEngineTools.test.ts @@ -0,0 +1,140 @@ +import { addRE } from './rulesEngineTools'; + +const id = expect.any(String); + +describe('addRE', () => { + it('adds a rule action to an empty rules engine', () => { + expect(addRE({ conditions: [], id: 'root' }, { actionType: 'a' }, [])).toEqual({ + conditions: [{ actionType: 'a' }], + id: 'root', + }); + }); + + it('does not add a rule action to a rules engine with a trailing rule action', () => { + expect( + addRE({ conditions: [{ actionType: 'a' }], id: 'root' }, { actionType: 'b' }, []) + ).toEqual({ + conditions: [{ actionType: 'a' }], + id: 'root', + }); + }); + + it('adds a rule group to an empty rules engine', () => { + expect(addRE({ conditions: [], id: 'root' }, { combinator: 'and', rules: [] }, [])).toEqual({ + conditions: [{ combinator: 'and', rules: [] }], + id: 'root', + }); + }); + + it('adds a rule group as second-to-last element when action is present', () => { + expect( + addRE( + { conditions: [{ combinator: 'and', rules: [] }, { actionType: 'a' }], id: 'root' }, + { id: 'new', combinator: 'and', rules: [] }, + [] + ) + ).toEqual({ + conditions: [ + { combinator: 'and', rules: [] }, + { id: 'new', combinator: 'and', rules: [] }, + { actionType: 'a' }, + ], + id: 'root', + }); + }); + + it('does not add a rule to the conditions of a rules engine', () => { + expect( + addRE({ conditions: [], id: 'root' }, { field: 'f', operator: '=', value: 'v' }, []) + ).toEqual({ conditions: [], id: 'root' }); + }); + + it('adds a rule group to a nested rules engine', () => { + expect( + addRE( + { conditions: [{ combinator: 'and', rules: [], conditions: [] }], id: 'root' }, + { combinator: 'and', rules: [] }, + [0] + ) + ).toEqual({ + conditions: [ + { combinator: 'and', rules: [], conditions: [{ combinator: 'and', rules: [] }] }, + ], + id: 'root', + }); + }); + + it('makes a rule group a rule engine when appropriate', () => { + expect( + addRE( + { conditions: [{ combinator: 'and', rules: [] }], id: 'root' }, + { id: 'new', combinator: 'and', rules: [] }, + [0] + ) + ).toEqual({ + conditions: [ + { + combinator: 'and', + rules: [], + conditions: [{ id: 'new', combinator: 'and', rules: [] }], + }, + ], + id: 'root', + }); + }); + + it('adds a rule to a nested rules engine', () => { + expect( + addRE( + { + conditions: [ + { + combinator: 'and', + rules: [], + conditions: [ + { combinator: 'and', rules: [] }, + { combinator: 'and', rules: [] }, + { combinator: 'and', rules: [] }, + ], + }, + ], + id: 'root', + }, + { field: 'f', operator: '=', value: 'v' }, + [0, 1], + [] + ) + ).toEqual({ + conditions: [ + { + combinator: 'and', + rules: [], + conditions: [ + { combinator: 'and', rules: [] }, + { combinator: 'and', rules: [{ id, field: 'f', operator: '=', value: 'v' }] }, + { combinator: 'and', rules: [] }, + ], + }, + ], + id: 'root', + }); + }); + + it('ignores invalid rules engines', () => { + // oxlint-disable-next-line no-explicit-any + const r1 = {} as any; + expect(addRE(r1, { actionType: 'a' }, [])).toBe(r1); + + // oxlint-disable-next-line no-explicit-any + const r2 = { conditions: [{ dummy: 'd' }] } as any; + expect(addRE(r2, { actionType: 'a' }, [0])).toBe(r2); + }); + + it('ignores invalid paths', () => { + const r1 = { conditions: [], id: 'root' }; + + // oxlint-disable-next-line no-explicit-any + expect(addRE(r1, { actionType: 'a' }, 26 as any)).toBe(r1); + expect(addRE(r1, { actionType: 'a' }, [1, 2])).toBe(r1); + }); +}); diff --git a/packages/react-querybuilder/src/utils/rulesEngineTools.ts b/packages/react-querybuilder/src/utils/rulesEngineTools.ts new file mode 100644 index 000000000..ade46cc3e --- /dev/null +++ b/packages/react-querybuilder/src/utils/rulesEngineTools.ts @@ -0,0 +1,86 @@ +import { produce } from 'immer'; +import type { + Path, + RuleGroupTypeAny, + RulesEngineAction, + RulesEngineAny, + RuleType, +} from '../types/index.noReact'; +import { isRuleGroup, isRuleType } from './isRuleGroup'; +import { isRulesEngineAction, isRulesEngineAny } from './isRulesEngine'; +import { findConditionPath, getConditionPathOfID, getParentPath } from './pathUtils'; +import type { AddOptions } from './queryTools'; +import { add } from './queryTools'; + +const push = (a: unknown[], ...items: unknown[]) => a.push(...items); +const splice = (a: unknown[], start: number, deleteCount: number, ...items: unknown[]) => + a.splice(start, deleteCount, ...items); + +const coerceToRulesEngine = (re: unknown): re is RulesEngineAny => { + if (!isRulesEngineAny(re)) (re as RulesEngineAny).conditions = []; + return isRulesEngineAny(re); +}; + +/** + * Options for {@link add}. + * + * @group Rules Engine Tools + */ +export interface AddOptionsRE extends AddOptions {} +/** + * Adds a rule or group to a query. + * @returns The new query with the rule or group added. + * + * @group Rules Engine Tools + */ +export const addRE = ( + /** The rules engine to update. */ + rulesEngine: RE, + /** The rules engine, action, rule, or rule group to add. */ + subject: RE | RulesEngineAction | RuleGroupTypeAny | RuleType, + /** Path or ID of the rules engine condition to add to. */ + conditionPathOrID: Path | string, + /** Path or ID of the group to add to (within the rules engine at `conditionPathOrID`), if adding a rule or group. */ + parentGroupPathOrID?: Path | string | null | undefined, + /** Options. */ + addOptions: AddOptionsRE = {} +): RE => + produce(rulesEngine, draft => { + const rePath = Array.isArray(conditionPathOrID) + ? conditionPathOrID + : getConditionPathOfID(conditionPathOrID, draft); + + if (!rePath) return; + + const parentRE = findConditionPath(rePath, draft); + + if (!isRulesEngineAny(parentRE) && !isRuleGroup(parentRE)) return; + + if ( + parentGroupPathOrID && + isRuleGroup(parentRE) && + // Only add rules or groups to a `rules` array. + (isRuleGroup(subject) || isRuleType(subject)) + ) { + const newGroup = add(parentRE, subject, parentGroupPathOrID, addOptions); + const parentREofGroup = findConditionPath(getParentPath(rePath), draft); + // istanbul ignore next + if (!coerceToRulesEngine(parentREofGroup)) return; + splice(parentREofGroup.conditions, rePath.at(-1)!, 1, newGroup); + } else if (isRulesEngineAny(subject) || isRulesEngineAction(subject) || isRuleGroup(subject)) { + // Force the parent rules engine to have a `conditions` array. + // The return will never fire; it's only for type safety and hence ignored for coverage. + // istanbul ignore next + if (!coerceToRulesEngine(parentRE)) return; + + // Check if the last condition is an action, i.e. an "else" block + if (isRulesEngineAction(parentRE.conditions.at(-1))) { + // Can't have two "else" blocks + if (isRulesEngineAction(subject)) return; + + splice(parentRE.conditions, parentRE.conditions.length - 1, 0, subject); + } else { + push(parentRE.conditions, subject); + } + } + }); From d158a4f9cb2393e91c389cf4c801b7ff0bcad7f3 Mon Sep 17 00:00:00 2001 From: Jake Boone Date: Fri, 8 Aug 2025 09:53:35 -0700 Subject: [PATCH 3/9] Add `updateRE` util --- .../react-querybuilder/src/utils/pathUtils.ts | 8 +-- .../src/utils/rulesEngineTools.test.ts | 42 ++++++++++++++- .../src/utils/rulesEngineTools.ts | 54 ++++++++++++++++++- 3 files changed, 97 insertions(+), 7 deletions(-) diff --git a/packages/react-querybuilder/src/utils/pathUtils.ts b/packages/react-querybuilder/src/utils/pathUtils.ts index 16ff1200d..c5ed67743 100644 --- a/packages/react-querybuilder/src/utils/pathUtils.ts +++ b/packages/react-querybuilder/src/utils/pathUtils.ts @@ -6,7 +6,7 @@ import type { RuleType, } from '../types/index.noReact'; import { isRuleGroup } from './isRuleGroup'; -import { isRulesEngine } from './isRulesEngine'; +import { isRulesEngineAny } from './isRulesEngine'; import { isPojo } from './misc'; /** @@ -49,7 +49,7 @@ export const findConditionPath = ( ): FindConditionPathReturnType => { let target: FindConditionPathReturnType = rulesEngine; let level = 0; - while (level < path.length && target && isRulesEngine(target)) { + while (level < path.length && isRulesEngineAny(target)) { target = target.conditions[path[level]]; level++; } @@ -96,7 +96,7 @@ export const findConditionID = ( for (const condition of rulesEngine.conditions) { if (condition.id === id) { return condition; - } else if (isRulesEngine(condition)) { + } else if (isRulesEngineAny(condition)) { return findConditionID(id, condition); } } @@ -143,7 +143,7 @@ export const getConditionPathOfID = (id: string, re: RulesEngineAny): Path | nul } for (const [i, c] of Object.entries(re.conditions)) { - if (isRulesEngine(c)) { + if (isRulesEngineAny(c)) { const subPath = getConditionPathOfID(id, c); if (Array.isArray(subPath)) { return [Number.parseInt(i), ...subPath]; diff --git a/packages/react-querybuilder/src/utils/rulesEngineTools.test.ts b/packages/react-querybuilder/src/utils/rulesEngineTools.test.ts index 8b78ce5c5..55b1ebb61 100644 --- a/packages/react-querybuilder/src/utils/rulesEngineTools.test.ts +++ b/packages/react-querybuilder/src/utils/rulesEngineTools.test.ts @@ -1,4 +1,4 @@ -import { addRE } from './rulesEngineTools'; +import { addRE, updateRE } from './rulesEngineTools'; const id = expect.any(String); @@ -138,3 +138,43 @@ describe('addRE', () => { expect(addRE(r1, { actionType: 'a' }, [1, 2])).toBe(r1); }); }); + +describe('updateRE', () => { + it('updates a rules engine in a rules engine', () => { + expect( + updateRE( + { conditions: [{ rules: [{ field: 'f', operator: '=', value: 'v' }] }], id: 'root' }, + 'someProp', + 'initial value', + [0] + ) + ).toEqual({ + conditions: [{ rules: [{ field: 'f', operator: '=', value: 'v' }] }], + id: 'root', + someProp: 'initial value', + }); + }); + + it('updates a rule in a rules engine', () => { + expect( + updateRE( + { conditions: [{ rules: [{ field: 'f', operator: '=', value: 'v' }] }], id: 'root' }, + 'value', + 'new value', + [0], + [0] + ) + ).toEqual({ + conditions: [{ rules: [{ field: 'f', operator: '=', value: 'new value' }] }], + id: 'root', + }); + }); + + it('ignores invalid paths', () => { + const r1 = { conditions: [], id: 'root' }; + + // oxlint-disable-next-line no-explicit-any + expect(updateRE(r1, 'value', 'new value', 26 as any)).toBe(r1); + expect(updateRE(r1, 'value', 'new value', [1, 2])).toBe(r1); + }); +}); diff --git a/packages/react-querybuilder/src/utils/rulesEngineTools.ts b/packages/react-querybuilder/src/utils/rulesEngineTools.ts index ade46cc3e..d81b1ce9f 100644 --- a/packages/react-querybuilder/src/utils/rulesEngineTools.ts +++ b/packages/react-querybuilder/src/utils/rulesEngineTools.ts @@ -9,8 +9,8 @@ import type { import { isRuleGroup, isRuleType } from './isRuleGroup'; import { isRulesEngineAction, isRulesEngineAny } from './isRulesEngine'; import { findConditionPath, getConditionPathOfID, getParentPath } from './pathUtils'; -import type { AddOptions } from './queryTools'; -import { add } from './queryTools'; +import type { AddOptions, UpdateOptions } from './queryTools'; +import { add, update } from './queryTools'; const push = (a: unknown[], ...items: unknown[]) => a.push(...items); const splice = (a: unknown[], start: number, deleteCount: number, ...items: unknown[]) => @@ -84,3 +84,53 @@ export const addRE = ( } } }); + +/** + * Options for {@link updateRE}. + * + * @group Query Tools + */ +export interface UpdateOptionsRE extends UpdateOptions {} +/** + * Updates a property of a rule or group within a query. + * @returns The new query with the rule or group property updated. + * + * @group Query Tools + */ +export const updateRE = ( + /** The query to update. */ + rulesEngine: RE, + /** The name of the property to update. */ + prop: string, + /** The new value of the property. */ + // oxlint-disable-next-line typescript/no-explicit-any + value: any, + /** Path or ID of the rules engine condition to update. */ + conditionPathOrID: Path | string, + /** Path or ID of the group to update (within the rules engine at `conditionPathOrID`), if updating a rule or group. */ + parentGroupPathOrID?: Path | string | null | undefined, + /** Options. */ + updateOptions: UpdateOptionsRE = {} +): RE => + produce(rulesEngine, draft => { + const rePath = Array.isArray(conditionPathOrID) + ? conditionPathOrID + : getConditionPathOfID(conditionPathOrID, draft); + + if (!rePath) return; + + const parentRE = findConditionPath(rePath, draft); + + if (!isRulesEngineAny(parentRE) && !isRuleGroup(parentRE)) return; + + if (parentGroupPathOrID && isRuleGroup(parentRE)) { + const newGroup = update(parentRE, prop, value, parentGroupPathOrID, updateOptions); + const parentREofGroup = findConditionPath(getParentPath(rePath), draft); + // istanbul ignore next + if (!coerceToRulesEngine(parentREofGroup)) return; + splice(parentREofGroup.conditions, rePath.at(-1)!, 1, newGroup); + } else { + // @ts-expect-error `prop` can be any string + parentRE[prop] = value; + } + }); From 475b4ad289de7a851b46ca4dcd46747352abb009 Mon Sep 17 00:00:00 2001 From: Jake Boone Date: Fri, 8 Aug 2025 11:51:18 -0700 Subject: [PATCH 4/9] Lint should only warn for skipped tests --- .oxlintrc.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.oxlintrc.json b/.oxlintrc.json index e1d1574f7..c0e8c7dc8 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -26,6 +26,7 @@ "error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } ], + "jest/no-disabled-tests": "warn", "jest/require-to-throw-message": "off", "jest/valid-title": "off", "react/display-name": "off", From 479d8474bf11d153f069f050af0d93903928f7eb Mon Sep 17 00:00:00 2001 From: Jake Boone Date: Fri, 8 Aug 2025 11:52:52 -0700 Subject: [PATCH 5/9] Add remaining `rulesEngineTools` (`moveRE`, `groupRE` incomplete) --- .../src/utils/rulesEngineTools.test.ts | 290 +++++++++++++++- .../src/utils/rulesEngineTools.ts | 315 +++++++++++++++++- 2 files changed, 598 insertions(+), 7 deletions(-) diff --git a/packages/react-querybuilder/src/utils/rulesEngineTools.test.ts b/packages/react-querybuilder/src/utils/rulesEngineTools.test.ts index 55b1ebb61..2091be80a 100644 --- a/packages/react-querybuilder/src/utils/rulesEngineTools.test.ts +++ b/packages/react-querybuilder/src/utils/rulesEngineTools.test.ts @@ -1,4 +1,5 @@ -import { addRE, updateRE } from './rulesEngineTools'; +import type { RulesEngine } from '../types'; +import { addRE, groupRE, insertRE, moveRE, removeRE, updateRE } from './rulesEngineTools'; const id = expect.any(String); @@ -149,9 +150,10 @@ describe('updateRE', () => { [0] ) ).toEqual({ - conditions: [{ rules: [{ field: 'f', operator: '=', value: 'v' }] }], + conditions: [ + { rules: [{ field: 'f', operator: '=', value: 'v' }], someProp: 'initial value' }, + ], id: 'root', - someProp: 'initial value', }); }); @@ -178,3 +180,285 @@ describe('updateRE', () => { expect(updateRE(r1, 'value', 'new value', [1, 2])).toBe(r1); }); }); + +describe('removeRE', () => { + it('removes a condition from a rules engine', () => { + expect( + removeRE( + { conditions: [{ combinator: 'and', rules: [] }, { actionType: 'a' }], id: 'root' }, + [0] + ) + ).toEqual({ conditions: [{ actionType: 'a' }], id: 'root' }); + }); + + it('removes a rule from a nested condition', () => { + expect( + removeRE( + { + conditions: [{ combinator: 'and', rules: [{ field: 'f', operator: '=', value: 'v' }] }], + id: 'root', + }, + [0], + [0] + ) + ).toEqual({ + conditions: [{ combinator: 'and', rules: [] }], + id: 'root', + }); + }); + + it('ignores root removal', () => { + const r1 = { conditions: [], id: 'root' }; + expect(removeRE(r1, [])).toBe(r1); + }); + + it('ignores invalid paths', () => { + const r1 = { conditions: [], id: 'root' }; + expect(removeRE(r1, [1, 2])).toBe(r1); + expect(removeRE(r1, 'invalid-id')).toBe(r1); + }); +}); + +describe('moveRE', () => { + it('moves a condition up', () => { + expect( + moveRE( + { + conditions: [ + { combinator: 'and', rules: [] }, + { combinator: 'or', rules: [] }, + { actionType: 'a' }, + ], + id: 'root', + }, + [1], + 'up' + ) + ).toEqual({ + conditions: [ + { combinator: 'or', rules: [] }, + { combinator: 'and', rules: [] }, + { actionType: 'a' }, + ], + id: 'root', + }); + }); + + it.skip('moves a condition down', () => { + expect( + moveRE( + { + conditions: [ + { combinator: 'and', rules: [] }, + { combinator: 'or', rules: [] }, + { actionType: 'a' }, + ], + id: 'root', + }, + [0], + 'down' + ) + ).toEqual({ + conditions: [ + { combinator: 'or', rules: [] }, + { combinator: 'and', rules: [] }, + { actionType: 'a' }, + ], + id: 'root', + }); + }); + + it.skip('moves a condition to specific path', () => { + expect( + moveRE( + { + conditions: [ + { combinator: 'and', rules: [] }, + { combinator: 'or', rules: [] }, + { combinator: 'xor', rules: [] }, + ], + id: 'root', + }, + [0], + [2] + ) + ).toEqual({ + conditions: [ + { combinator: 'or', rules: [] }, + { combinator: 'xor', rules: [] }, + { combinator: 'and', rules: [] }, + ], + id: 'root', + }); + }); + + it('ignores moving to same location', () => { + const r1: RulesEngine = { + conditions: [{ combinator: 'and', rules: [] }, { actionType: 'a' }], + id: 'root', + }; + expect(moveRE(r1, [0], [0])).toBe(r1); + }); + + it('ignores invalid moves', () => { + const r1 = { + conditions: [{ combinator: 'and', rules: [] }], + id: 'root', + }; + expect(moveRE(r1, [0], 'up')).toBe(r1); // Can't move up from first position + expect(moveRE(r1, [0], 'down')).toBe(r1); // Can't move down from last position + + const r2: RulesEngine = { + conditions: [{ combinator: 'and', rules: [] }, { actionType: 'b' }], + id: 'root', + }; + expect(moveRE(r2, [1], 'up')).toBe(r2); // Can't move action up + expect(moveRE(r2, [0], 'down')).toBe(r2); // Can't move rules engine below action + expect(moveRE(r2, [1], [0])).toBe(r2); // Can't move action to non-last position + }); + + it('ignores root moves', () => { + const r1 = { conditions: [], id: 'root' }; + expect(moveRE(r1, [], [1])).toBe(r1); + }); +}); + +describe('insertRE', () => { + it('inserts a condition at specified path', () => { + expect( + insertRE( + { conditions: [{ combinator: 'and', rules: [] }], id: 'root' }, + { combinator: 'or', rules: [] }, + [1] + ) + ).toEqual({ + conditions: [ + { combinator: 'and', rules: [] }, + { combinator: 'or', rules: [] }, + ], + id: 'root', + }); + }); + + it('inserts an action before existing action', () => { + expect( + insertRE( + { conditions: [{ combinator: 'and', rules: [] }, { actionType: 'a' }], id: 'root' }, + { combinator: 'or', rules: [] }, + [2] + ) + ).toEqual({ + conditions: [ + { combinator: 'and', rules: [] }, + { combinator: 'or', rules: [] }, + { actionType: 'a' }, + ], + id: 'root', + }); + }); + + it('does not insert second action', () => { + const r1: RulesEngine = { + conditions: [{ combinator: 'and', rules: [] }, { actionType: 'a' }], + id: 'root', + }; + expect(insertRE(r1, { actionType: 'b' }, [1])).toBe(r1); + }); + + it('inserts rule into nested condition', () => { + expect( + insertRE( + { conditions: [{ combinator: 'and', rules: [] }], id: 'root' }, + { field: 'f', operator: '=', value: 'v' }, + [0], + [0] + ) + ).toEqual({ + conditions: [{ combinator: 'and', rules: [{ id, field: 'f', operator: '=', value: 'v' }] }], + id: 'root', + }); + }); + + it('ignores invalid insertions', () => { + const r1 = { conditions: [], id: 'root' }; + expect(insertRE(r1, { field: 'f', operator: '=', value: 'v' }, [0])).toBe(r1); + }); +}); + +describe('groupRE', () => { + it('groups rules within the same condition', () => { + expect( + groupRE( + { + conditions: [ + { + combinator: 'and', + rules: [ + { field: 'f1', operator: '=', value: 'v1' }, + { field: 'f2', operator: '=', value: 'v2' }, + ], + }, + ], + id: 'root', + }, + [0], + [0], + [0], + [1] + ) + ).toEqual({ + conditions: [ + { + combinator: 'and', + rules: [ + { + id, + combinator: 'and', + rules: [ + { id, field: 'f2', operator: '=', value: 'v2' }, + { id, field: 'f1', operator: '=', value: 'v1' }, + ], + }, + ], + }, + ], + id: 'root', + }); + }); + + // TODO: Cross-condition grouping should work. This test as written should fail. + it('ignores grouping across different conditions', () => { + const r1 = { + conditions: [ + { combinator: 'and', rules: [{ field: 'f1', operator: '=', value: 'v1' }] }, + { combinator: 'and', rules: [{ field: 'f2', operator: '=', value: 'v2' }] }, + ], + id: 'root', + }; + expect(groupRE(r1, [0], [0], [1], [0])).toBe(r1); + }); + + it('ignores invalid paths', () => { + const r1 = { conditions: [], id: 'root' }; + expect(groupRE(r1, 'invalid1', [0], 'invalid2', [0])).toBe(r1); + }); + + it('ignores non-rule-group conditions', () => { + const r1: RulesEngine = { conditions: [{ actionType: 'a' }], id: 'root' }; + expect(groupRE(r1, [0], [0], [0], [1])).toBe(r1); + }); +}); + +describe('error handling', () => { + it('handles malformed rules engines gracefully', () => { + // oxlint-disable-next-line no-explicit-any + const malformed = { conditions: [{ invalid: 'data' }] } as any; + + expect(removeRE(malformed, [0])).toEqual({ conditions: [] }); + expect(moveRE(malformed, [0], 'up')).toBe(malformed); + expect(insertRE(malformed, { actionType: 'a' }, [0])).toEqual({ + conditions: [{ actionType: 'a' }, { invalid: 'data' }], + }); + expect(groupRE(malformed, [0], [0], [0], [1])).toBe(malformed); + }); +}); diff --git a/packages/react-querybuilder/src/utils/rulesEngineTools.ts b/packages/react-querybuilder/src/utils/rulesEngineTools.ts index d81b1ce9f..e702b973b 100644 --- a/packages/react-querybuilder/src/utils/rulesEngineTools.ts +++ b/packages/react-querybuilder/src/utils/rulesEngineTools.ts @@ -8,15 +8,22 @@ import type { } from '../types/index.noReact'; import { isRuleGroup, isRuleType } from './isRuleGroup'; import { isRulesEngineAction, isRulesEngineAny } from './isRulesEngine'; -import { findConditionPath, getConditionPathOfID, getParentPath } from './pathUtils'; -import type { AddOptions, UpdateOptions } from './queryTools'; -import { add, update } from './queryTools'; +import { findConditionPath, getConditionPathOfID, getParentPath, pathsAreEqual } from './pathUtils'; +import type { + AddOptions, + UpdateOptions, + MoveOptions, + InsertOptions, + GroupOptions, +} from './queryTools'; +import { add, update, remove, move, insert, group } from './queryTools'; const push = (a: unknown[], ...items: unknown[]) => a.push(...items); const splice = (a: unknown[], start: number, deleteCount: number, ...items: unknown[]) => a.splice(start, deleteCount, ...items); const coerceToRulesEngine = (re: unknown): re is RulesEngineAny => { + if (!re || typeof re !== 'object') return false; if (!isRulesEngineAny(re)) (re as RulesEngineAny).conditions = []; return isRulesEngineAny(re); }; @@ -88,7 +95,7 @@ export const addRE = ( /** * Options for {@link updateRE}. * - * @group Query Tools + * @group Rules Engine Tools */ export interface UpdateOptionsRE extends UpdateOptions {} /** @@ -134,3 +141,303 @@ export const updateRE = ( parentRE[prop] = value; } }); + +/** + * Removes a rule engine condition from a rules engine. + * @returns The new rules engine with the condition removed. + * + * @group Rules Engine Tools + */ +export const removeRE = ( + /** The rules engine to update. */ + rulesEngine: RE, + /** Path or ID of the rules engine condition to remove. */ + conditionPathOrID: Path | string, + /** Path or ID of the rule or group to remove (within the rules engine at `conditionPathOrID`), if removing a rule or group. */ + parentGroupPathOrID?: Path | string | null | undefined +): RE => + produce(rulesEngine, draft => { + // Delegate to underlying queryTools remove for rule/group removal within a condition + const rePath = Array.isArray(conditionPathOrID) + ? conditionPathOrID + : getConditionPathOfID(conditionPathOrID, draft); + + // Ignore invalid paths/ids or root removal + if (!rePath || rePath.length === 0) return; + + const parentRE = findConditionPath(rePath, draft); + + if (parentGroupPathOrID && isRuleGroup(parentRE)) { + const newGroup = remove(parentRE, parentGroupPathOrID); + const parentREofGroup = findConditionPath(getParentPath(rePath), draft); + if (!isRulesEngineAny(parentREofGroup)) return; + splice(parentREofGroup.conditions, rePath.at(-1)!, 1, newGroup); + } else { + const parentREofRemovalTarget = findConditionPath(getParentPath(rePath), draft); + if (isRulesEngineAny(parentREofRemovalTarget)) { + parentREofRemovalTarget.conditions.splice(rePath.at(-1)!, 1); + } + } + }); + +/** + * Options for {@link moveRE}. + * + * @group Rules Engine Tools + */ +export interface MoveOptionsRE extends MoveOptions {} +/** + * Moves a rule engine condition from one path to another. In the options parameter, pass + * `{ clone: true }` to copy instead of move. + * @returns The new rules engine with the condition moved or cloned. + * + * @group Rules Engine Tools + */ +export const moveRE = ( + /** The rules engine to update. */ + rulesEngine: RE, + /** Path or ID of the condition to move, or the condition itself if moving within a nested group. */ + oldConditionPathOrID: Path | string, + /** Path to move the condition to, or shift direction, or the condition path if moving within a nested group. */ + newConditionPathOrShiftDirection: Path | 'up' | 'down', + /** Path or ID of the rule or group within the old condition, if moving a rule or group. */ + oldParentGroupPathOrID?: Path | string | null | undefined, + /** Path or ID of the rule or group within the new condition, if moving a rule or group. */ + newParentGroupPathOrID?: Path | 'up' | 'down' | null | undefined, + /** Options. */ + moveOptions: MoveOptionsRE = {} +): RE => { + if (oldParentGroupPathOrID && newParentGroupPathOrID) { + // Delegate to underlying queryTools move for rule/group movement within conditions + return produce(rulesEngine, draft => { + const oldRePath = Array.isArray(oldConditionPathOrID) + ? oldConditionPathOrID + : getConditionPathOfID(oldConditionPathOrID, draft); + const newRePath = Array.isArray(newConditionPathOrShiftDirection) + ? newConditionPathOrShiftDirection + : typeof newConditionPathOrShiftDirection === 'string' && + newConditionPathOrShiftDirection !== 'up' && + newConditionPathOrShiftDirection !== 'down' + ? getConditionPathOfID(newConditionPathOrShiftDirection, draft) + : oldRePath; + + if (!oldRePath || !newRePath) return; + + const oldParentRE = findConditionPath(oldRePath, draft); + const newParentRE = findConditionPath(newRePath, draft); + + if (!isRuleGroup(oldParentRE) || !isRuleGroup(newParentRE)) return; + + const updatedOldGroup = move( + oldParentRE, + oldParentGroupPathOrID, + newParentGroupPathOrID, + moveOptions + ); + + if (pathsAreEqual(oldRePath, newRePath)) { + // Moving within the same condition + const parentREofGroup = findConditionPath(getParentPath(oldRePath), draft); + // istanbul ignore next + if (!coerceToRulesEngine(parentREofGroup)) return; + splice(parentREofGroup.conditions, oldRePath.at(-1)!, 1, updatedOldGroup); + } else { + // Moving between different conditions - not implemented for now + return; + } + }); + } + + // Handle condition-level movement + return produce(rulesEngine, draft => { + const oldConditionPath = Array.isArray(oldConditionPathOrID) + ? oldConditionPathOrID + : getConditionPathOfID(oldConditionPathOrID, draft); + + if (!oldConditionPath || oldConditionPath.length === 0) return; + + let newConditionPath: Path; + if (Array.isArray(newConditionPathOrShiftDirection)) { + newConditionPath = newConditionPathOrShiftDirection; + } else if ( + typeof newConditionPathOrShiftDirection === 'string' && + newConditionPathOrShiftDirection !== 'up' && + newConditionPathOrShiftDirection !== 'down' + ) { + const foundPath = getConditionPathOfID(newConditionPathOrShiftDirection, draft); + if (!foundPath) return; + newConditionPath = foundPath; + } else { + // Handle 'up'/'down' shift direction for conditions + const direction = newConditionPathOrShiftDirection as 'up' | 'down'; + const currentIndex = oldConditionPath.at(-1)!; + const parent = findConditionPath(getParentPath(oldConditionPath), draft); + // istanbul ignore next + if (!coerceToRulesEngine(parent)) return; + + if (direction === 'up' && currentIndex > 0) { + newConditionPath = [...getParentPath(oldConditionPath), currentIndex - 1]; + } else if (direction === 'down' && currentIndex < parent.conditions.length - 1) { + newConditionPath = [...getParentPath(oldConditionPath), currentIndex + 1]; + } else { + return; // Can't move in requested direction + } + } + + // Don't move to the same location + if (pathsAreEqual(oldConditionPath, newConditionPath)) return; + + const conditionToMove = findConditionPath(oldConditionPath, draft); + if (!conditionToMove) return; + + const oldIndex = oldConditionPath.at(-1)!; + const newIndex = newConditionPath.at(-1)!; + + // Remove from old location + const oldParent = findConditionPath(getParentPath(oldConditionPath), draft); + // istanbul ignore next + if (!coerceToRulesEngine(oldParent)) return; + oldParent.conditions.splice(oldIndex, 1); + + // Insert at new location, adjusting for removal if necessary + const newParent = findConditionPath(getParentPath(newConditionPath), draft); + // istanbul ignore next + if (!coerceToRulesEngine(newParent)) return; + + // Adjust insertion index if we're moving within the same parent + let insertIndex = newIndex; + if (pathsAreEqual(getParentPath(oldConditionPath), getParentPath(newConditionPath))) { + // If the old index was before the new index, the array shifted left by 1 + if (oldIndex < newIndex) { + insertIndex = newIndex - 1; + } + } + + // oxlint-disable-next-line no-explicit-any + newParent.conditions.splice(insertIndex, 0, conditionToMove as any); + }); +}; + +/** + * Options for {@link insertRE}. + * + * @group Rules Engine Tools + */ +export interface InsertOptionsRE extends InsertOptions {} +/** + * Inserts a rule engine condition into a rules engine. + * @returns The new rules engine with the condition inserted. + * + * @group Rules Engine Tools + */ +export const insertRE = ( + /** The rules engine to update. */ + rulesEngine: RE, + /** The rules engine, action, rule, or rule group to insert. */ + subject: RE | RulesEngineAction | RuleGroupTypeAny | RuleType, + /** Path at which to insert the condition. */ + conditionPath: Path, + /** Path at which to insert the rule or group (within the condition at `conditionPath`), if inserting a rule or group. */ + parentGroupPath?: Path | null | undefined, + /** Options. */ + insertOptions: InsertOptionsRE = {} +): RE => { + if (parentGroupPath && (isRuleGroup(subject) || isRuleType(subject))) { + // Delegate to underlying queryTools insert for rule/group insertion within a condition + return produce(rulesEngine, draft => { + const rePath = conditionPath; + const parentRE = findConditionPath(rePath, draft); + + if (!isRuleGroup(parentRE)) return; + + const newGroup = insert(parentRE, subject, parentGroupPath, insertOptions); + const parentREofGroup = findConditionPath(getParentPath(rePath), draft); + // istanbul ignore next + if (!coerceToRulesEngine(parentREofGroup)) return; + splice(parentREofGroup.conditions, rePath.at(-1)!, 1, newGroup); + }); + } + + return produce(rulesEngine, draft => { + const parent = findConditionPath(getParentPath(conditionPath), draft); + if (!parent) return; + if (!coerceToRulesEngine(parent)) return; + + const newIndex = conditionPath.at(-1)!; + + if (isRulesEngineAny(subject) || isRulesEngineAction(subject) || isRuleGroup(subject)) { + // Check if trying to insert an action when there's already a trailing action + if (isRulesEngineAction(subject) && isRulesEngineAction(parent.conditions.at(-1))) { + return; // Can't have two "else" blocks + } + + // If inserting a rules engine, and there's a trailing action, insert before the action + if (isRulesEngineAction(parent.conditions.at(-1)) && !isRulesEngineAction(subject)) { + splice(parent.conditions, Math.min(newIndex, parent.conditions.length - 1), 0, subject); + } else { + splice(parent.conditions, newIndex, 0, subject); + } + } + }); +}; + +/** + * Options for {@link groupRE}. + * + * @group Rules Engine Tools + */ +export interface GroupOptionsRE extends GroupOptions {} +/** + * Creates a new group at a target condition with its `rules` array containing the current + * objects at the target path and the source path. In the options parameter, pass + * `{ clone: true }` to copy the source rule/group instead of move. + * + * @returns The new rules engine with the rules or groups grouped. + * + * @group Rules Engine Tools + */ +export const groupRE = ( + /** The rules engine to update. */ + rulesEngine: RE, + /** Path of the condition containing the rule/group to move or clone. */ + sourceConditionPathOrID: Path | string, + /** Path of the rule/group within the source condition. */ + sourceParentGroupPathOrID: Path | string, + /** Path of the condition containing the target rule/group. */ + targetConditionPathOrID: Path | string, + /** Path of the target rule/group within the target condition. */ + targetParentGroupPathOrID: Path | string, + /** Options. */ + groupOptions: GroupOptionsRE = {} +): RE => { + return produce(rulesEngine, draft => { + const sourceConditionPath = Array.isArray(sourceConditionPathOrID) + ? sourceConditionPathOrID + : getConditionPathOfID(sourceConditionPathOrID, draft); + const targetConditionPath = Array.isArray(targetConditionPathOrID) + ? targetConditionPathOrID + : getConditionPathOfID(targetConditionPathOrID, draft); + + if (!sourceConditionPath || !targetConditionPath) return; + + // TODO: allow cross-condition grouping + // Must be in the same condition for grouping + if (!pathsAreEqual(sourceConditionPath, targetConditionPath)) return; + + const conditionRE = findConditionPath(sourceConditionPath, draft); + + if (!isRuleGroup(conditionRE)) return; + + const newGroup = group( + conditionRE, + sourceParentGroupPathOrID, + targetParentGroupPathOrID, + groupOptions + ); + + const parentREofCondition = findConditionPath(getParentPath(sourceConditionPath), draft); + // istanbul ignore next + if (!coerceToRulesEngine(parentREofCondition)) return; + splice(parentREofCondition.conditions, sourceConditionPath.at(-1)!, 1, newGroup); + }); +}; From c5d6eec861fec9583e3fd28ffa5d1a5b1bd16fd9 Mon Sep 17 00:00:00 2001 From: Jake Boone Date: Thu, 14 Aug 2025 19:37:11 -0700 Subject: [PATCH 6/9] Rules engine builder progress and a dev page --- .../src/components/RulesEngineBuilder.tsx | 225 ++++++++++++++++++ .../src/components/barrel.ts | 1 + .../src/types/rulesEngine.ts | 19 -- .../src/utils/rulesEngineTools.test.ts | 35 ++- .../src/utils/rulesEngineTools.ts | 18 +- utils/devapp/AppRE.tsx | 16 ++ utils/devapp/DevLayout.tsx | 1 + utils/devapp/pages/rulesengine.html | 12 + utils/devapp/pages/rulesengine.tsx | 9 + utils/devapp/server.ts | 2 + utils/devapp/styles.css | 16 ++ 11 files changed, 331 insertions(+), 23 deletions(-) create mode 100644 packages/react-querybuilder/src/components/RulesEngineBuilder.tsx create mode 100644 utils/devapp/AppRE.tsx create mode 100644 utils/devapp/pages/rulesengine.html create mode 100644 utils/devapp/pages/rulesengine.tsx diff --git a/packages/react-querybuilder/src/components/RulesEngineBuilder.tsx b/packages/react-querybuilder/src/components/RulesEngineBuilder.tsx new file mode 100644 index 000000000..bde494920 --- /dev/null +++ b/packages/react-querybuilder/src/components/RulesEngineBuilder.tsx @@ -0,0 +1,225 @@ +import { produce } from 'immer'; +import * as React from 'react'; +import { standardClassnames as sc } from '../defaults'; +import type { + BaseOption, + Field, + FullOptionList, + Path, + RuleGroupType, + RuleGroupTypeAny, + RulesEngine, + RulesEngineAction, + RulesEngineCondition, +} from '../types'; +import { + isRuleGroup, + isRulesEngineAction, + pathsAreEqual, + toFlatOptionArray, + toFullOptionList, +} from '../utils'; +import clsx from '../utils/clsx'; +import { QueryBuilder } from './QueryBuilder.debug'; + +const fields: Field[] = [{ name: 'age', label: 'Age' }]; + +const actionTypes: FullOptionList = toFullOptionList([ + { value: 'send_email', label: 'Send Email' }, + { value: 'log_event', label: 'Log Event' }, +]); + +export const dummyRE: RulesEngine = { + conditions: [ + { + id: '1', + combinator: 'and', + rules: [{ id: '1-0', field: 'age', operator: '>=', value: 18 }], + action: { + actionType: 'send_email', + params: { to: 'user@example.com', subject: 'Welcome!', body: 'Thanks for signing up!' }, + }, + conditions: [ + { + id: '2-1', + combinator: 'and', + rules: [{ id: '2-1-0', field: 'age', operator: '=', value: 18 }], + action: { + actionType: 'send_email', + params: { + to: 'user@example.com', + subject: 'Happy Birthday!', + body: 'Thanks for signing up!', + }, + }, + }, + ], + }, + { + id: '2', + combinator: 'and', + rules: [{ id: '2-0', field: 'age', operator: '<', value: 18 }], + action: { + actionType: 'send_email', + params: { + to: 'user@example.com', + subject: 'Sorry!', + body: 'You must be 18 or older to sign up.', + }, + }, + }, + { id: '3', actionType: 'log_event' }, + ], +}; + +interface RulesEngineProps { + conditionPath?: Path; + rulesEngine?: RulesEngine; + actionTypes?: FullOptionList; + autoSelectActionType?: boolean; +} + +export const RulesEngineBuilder = ( + props: RulesEngineProps = {} +): React.JSX.Element => { + const { rulesEngine = dummyRE, autoSelectActionType = false, conditionPath } = props; + const [re, setRE] = React.useState(rulesEngine); + + return ( +
+ {re.conditions.map((c, i) => { + const updater = (c: RulesEngineCondition | RulesEngineAction) => + setRE( + produce(re, draft => { + // oxlint-disable-next-line no-explicit-any + draft.conditions.splice(i, 1, c as any); + }) + ); + return isRulesEngineAction(c) ? ( + + ) : ( + + key={c.id as string} + // oxlint-disable-next-line jsx-no-new-array-as-prop + conditionPath={[...(conditionPath ?? []), i]} + actionTypes={actionTypes} + condition={c as RulesEngineCondition} + isOnlyCondition={i === 0 && !isRuleGroup(rulesEngine.conditions[i + 1])} + onConditionChange={updater} + /> + ); + })} +
+ ); +}; + +interface RulesEngineConditionProps { + conditionPath: Path; + condition: RulesEngineCondition; + actionTypes?: FullOptionList; + isOnlyCondition: boolean; + onConditionChange: (condition: RulesEngineCondition) => void; +} + +/** + * Analogous to an "if" or "else-if" block. + */ +export const RulesEngineConditionBuilder = ( + props: RulesEngineConditionProps +): React.JSX.Element => { + // const onQueryChange = React.useCallback( + // (query: unknown) => { + // console.log({ conditionPath: props.conditionPath, query }); + // }, + // [props.conditionPath] + // ); + const actionUpdater = (action: RulesEngineAction) => + props.onConditionChange({ ...props.condition, action }); + + return ( +
+
+
{props.conditionPath.at(-1) === 0 ? 'If' : 'Else If'}
+ {!pathsAreEqual([0], props.conditionPath) && } +
+ + {(props.condition.action || props.condition.conditions) && ( + + {props.condition.action && ( + + )} + {props.condition.conditions && props.condition.conditions.length > 0 && ( + + )} + + )} +
+ ); +}; + +interface RulesEngineActionProps { + conditionPath: Path; + actionTypes?: FullOptionList; + action: RulesEngineAction; + standalone?: boolean; + onActionChange: (action: RulesEngineAction) => void; + conditionsMet?: RuleGroupTypeAny; + conditionsFailed?: RuleGroupTypeAny; +} + +/** + * Analogous to the body of an "if" or "else-if" block. + */ +export const RulesEngineActionBuilder = (props: RulesEngineActionProps): React.JSX.Element => { + const className = clsx(sc.ruleGroup, 'rulesEngine-action', { + 'rulesEngine-action-standalone': props.standalone, + }); + return ( +
+
+
{props.standalone ? 'Else' : 'Then'}
+ +
+
+ {props.actionTypes && ( + + )} +
+
+        {JSON.stringify(props.action)}
+      
+
+ ); +}; diff --git a/packages/react-querybuilder/src/components/barrel.ts b/packages/react-querybuilder/src/components/barrel.ts index e02d581e1..124012e89 100644 --- a/packages/react-querybuilder/src/components/barrel.ts +++ b/packages/react-querybuilder/src/components/barrel.ts @@ -8,6 +8,7 @@ export * from './QueryBuilder.useQueryBuilderSchema'; export * from './QueryBuilder.useQueryBuilderSetup'; export * from './Rule'; export * from './RuleGroup'; +export * from './RulesEngineBuilder'; export * from './ShiftActions'; export * from './ValueEditor'; export * from './ValueSelector'; diff --git a/packages/react-querybuilder/src/types/rulesEngine.ts b/packages/react-querybuilder/src/types/rulesEngine.ts index 765ab266c..eabd906cc 100644 --- a/packages/react-querybuilder/src/types/rulesEngine.ts +++ b/packages/react-querybuilder/src/types/rulesEngine.ts @@ -78,22 +78,3 @@ const _rngn: RulesEngine = { { actionType: 'hope' }, ], }; - -// conditions: -// oxlint-disable-next-line no-dupe-else-if -if (process.env.TZ) { - // action: - // doSomething(); - - // conditions: - if (process.env.TZ) { - // action: - // doSomething(); - } -} else if (process.env.TZ) { - // action: - // doSomething(); -} else { - // action: - // doSomething(); -} diff --git a/packages/react-querybuilder/src/utils/rulesEngineTools.test.ts b/packages/react-querybuilder/src/utils/rulesEngineTools.test.ts index 2091be80a..3c5e3999b 100644 --- a/packages/react-querybuilder/src/utils/rulesEngineTools.test.ts +++ b/packages/react-querybuilder/src/utils/rulesEngineTools.test.ts @@ -345,7 +345,7 @@ describe('insertRE', () => { insertRE( { conditions: [{ combinator: 'and', rules: [] }, { actionType: 'a' }], id: 'root' }, { combinator: 'or', rules: [] }, - [2] + [1] ) ).toEqual({ conditions: [ @@ -357,6 +357,39 @@ describe('insertRE', () => { }); }); + it('replaces an existing condition', () => { + expect( + insertRE( + { conditions: [{ combinator: 'and', rules: [] }, { actionType: 'a' }], id: 'root' }, + { combinator: 'or', rules: [] }, + [0], + undefined, + { replace: true } + ) + ).toEqual({ + conditions: [{ combinator: 'or', rules: [] }, { actionType: 'a' }], + id: 'root', + }); + }); + + it('replaces an existing action', () => { + expect( + insertRE( + { conditions: [{ combinator: 'and', rules: [] }, { actionType: 'a' }], id: 'root' }, + { combinator: 'or', rules: [] }, + [1], + undefined, + { replace: true } + ) + ).toEqual({ + conditions: [ + { combinator: 'and', rules: [] }, + { combinator: 'or', rules: [] }, + ], + id: 'root', + }); + }); + it('does not insert second action', () => { const r1: RulesEngine = { conditions: [{ combinator: 'and', rules: [] }, { actionType: 'a' }], diff --git a/packages/react-querybuilder/src/utils/rulesEngineTools.ts b/packages/react-querybuilder/src/utils/rulesEngineTools.ts index e702b973b..e09e91666 100644 --- a/packages/react-querybuilder/src/utils/rulesEngineTools.ts +++ b/packages/react-querybuilder/src/utils/rulesEngineTools.ts @@ -371,11 +371,23 @@ export const insertRE = ( return; // Can't have two "else" blocks } - // If inserting a rules engine, and there's a trailing action, insert before the action - if (isRulesEngineAction(parent.conditions.at(-1)) && !isRulesEngineAction(subject)) { + if ( + !insertOptions.replace && + isRulesEngineAction(parent.conditions.at(-1)) && + !isRulesEngineAction(subject) + ) { + // Inserting a rules engine and there's a trailing action that isn't being replaced. Insert before the action. splice(parent.conditions, Math.min(newIndex, parent.conditions.length - 1), 0, subject); + } else if ( + insertOptions.replace && + isRulesEngineAction(subject) && + newIndex >= parent.conditions.length - 1 + ) { + // Replacing an action at the last index (doesn't matter what we replace it with) + splice(parent.conditions, parent.conditions.length - 1, 1, subject); } else { - splice(parent.conditions, newIndex, 0, subject); + // Normal insertion/replacement + splice(parent.conditions, newIndex, insertOptions.replace ? 1 : 0, subject); } } }); diff --git a/utils/devapp/AppRE.tsx b/utils/devapp/AppRE.tsx new file mode 100644 index 000000000..731e23ec6 --- /dev/null +++ b/utils/devapp/AppRE.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { RulesEngineBuilder } from 'react-querybuilder'; +import { DevLayout } from './DevLayout'; +import { useDevApp } from './useDevApp'; + +export const AppRE = (): React.JSX.Element => { + const devApp = useDevApp(); + + return ( + + + + ); +}; diff --git a/utils/devapp/DevLayout.tsx b/utils/devapp/DevLayout.tsx index 89de6b2b3..e0bb711bf 100644 --- a/utils/devapp/DevLayout.tsx +++ b/utils/devapp/DevLayout.tsx @@ -29,6 +29,7 @@ const parserMap: Record = { const links = [ 'react-querybuilder', + 'rulesengine', 'antd', 'bootstrap', 'bulma', diff --git a/utils/devapp/pages/rulesengine.html b/utils/devapp/pages/rulesengine.html new file mode 100644 index 000000000..03c32bc7c --- /dev/null +++ b/utils/devapp/pages/rulesengine.html @@ -0,0 +1,12 @@ + + + + + + Rules Engine Builder Dev Page + + +
+ + + diff --git a/utils/devapp/pages/rulesengine.tsx b/utils/devapp/pages/rulesengine.tsx new file mode 100644 index 000000000..c5dd1ee26 --- /dev/null +++ b/utils/devapp/pages/rulesengine.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; +import { createRoot } from 'react-dom/client'; +import { AppRE } from '../AppRE'; + +createRoot(document.querySelector('#app')!).render( + + + +); diff --git a/utils/devapp/server.ts b/utils/devapp/server.ts index 8cd4ec875..2ab62c77d 100644 --- a/utils/devapp/server.ts +++ b/utils/devapp/server.ts @@ -8,6 +8,7 @@ import fluentIndexHTML from './pages/fluent.html'; import mantineIndexHTML from './pages/mantine.html'; import materialIndexHTML from './pages/material.html'; import nativeIndexHTML from './pages/native.html'; +import rebIndexHTML from './pages/rulesengine.html'; import rqbIndexHTML from './pages/rqb.html'; import tremorIndexHTML from './pages/tremor.html'; @@ -22,6 +23,7 @@ const indexHTMLs = { '/mantine': mantineIndexHTML, '/material': materialIndexHTML, '/native': nativeIndexHTML, // Flow transpilation not working + '/rulesengine': rebIndexHTML, '/': rqbIndexHTML, '/rqb': rqbIndexHTML, '/react-querybuilder': rqbIndexHTML, diff --git a/utils/devapp/styles.css b/utils/devapp/styles.css index b94ddc229..7705979a3 100644 --- a/utils/devapp/styles.css +++ b/utils/devapp/styles.css @@ -69,3 +69,19 @@ nav { } } } + +.rulesEngine { + display: flex; + flex-direction: column; + gap: var(--rqb-spacing); +} + +.ruleGroup.rulesEngine-action { + background-color: #0963; +} + +/* +.ruleGroup.rulesEngine-action.rulesEngine-action-standalone { + background-color: #3393; +} +*/ From befe51a226b6acde721fddd38df158747c1effdc Mon Sep 17 00:00:00 2001 From: Jake Boone Date: Tue, 19 Aug 2025 05:58:17 -0700 Subject: [PATCH 7/9] More rules engine builder progress --- .../src/components/RulesEngineBuilder.tsx | 99 +++++++---- .../src/utils/regenerateIDs.test.ts | 160 ++++++++++++------ .../src/utils/regenerateIDs.ts | 63 ++++--- utils/devapp/AppRE.tsx | 9 +- utils/devapp/useDevApp.ts | 22 ++- 5 files changed, 222 insertions(+), 131 deletions(-) diff --git a/packages/react-querybuilder/src/components/RulesEngineBuilder.tsx b/packages/react-querybuilder/src/components/RulesEngineBuilder.tsx index bde494920..b56b50ba2 100644 --- a/packages/react-querybuilder/src/components/RulesEngineBuilder.tsx +++ b/packages/react-querybuilder/src/components/RulesEngineBuilder.tsx @@ -1,6 +1,7 @@ import { produce } from 'immer'; import * as React from 'react'; import { standardClassnames as sc } from '../defaults'; +import { useOptionListProp } from '../hooks'; import type { BaseOption, Field, @@ -16,6 +17,7 @@ import { isRuleGroup, isRulesEngineAction, pathsAreEqual, + regenerateIDs, toFlatOptionArray, toFullOptionList, } from '../utils'; @@ -24,26 +26,24 @@ import { QueryBuilder } from './QueryBuilder.debug'; const fields: Field[] = [{ name: 'age', label: 'Age' }]; -const actionTypes: FullOptionList = toFullOptionList([ +const dummyActionTypes: FullOptionList = toFullOptionList([ { value: 'send_email', label: 'Send Email' }, { value: 'log_event', label: 'Log Event' }, ]); -export const dummyRE: RulesEngine = { +export const dummyRE: RulesEngine = regenerateIDs({ conditions: [ { - id: '1', combinator: 'and', - rules: [{ id: '1-0', field: 'age', operator: '>=', value: 18 }], + rules: [{ field: 'age', operator: '>=', value: 18 }], action: { actionType: 'send_email', params: { to: 'user@example.com', subject: 'Welcome!', body: 'Thanks for signing up!' }, }, conditions: [ { - id: '2-1', combinator: 'and', - rules: [{ id: '2-1-0', field: 'age', operator: '=', value: 18 }], + rules: [{ field: 'age', operator: '=', value: 18 }], action: { actionType: 'send_email', params: { @@ -56,9 +56,8 @@ export const dummyRE: RulesEngine = { ], }, { - id: '2', combinator: 'and', - rules: [{ id: '2-0', field: 'age', operator: '<', value: 18 }], + rules: [{ field: 'age', operator: '<', value: 18 }], action: { actionType: 'send_email', params: { @@ -70,10 +69,13 @@ export const dummyRE: RulesEngine = { }, { id: '3', actionType: 'log_event' }, ], -}; +}); + +const rootPath: Path = []; interface RulesEngineProps { conditionPath?: Path; + onRulesEngineChange?: (re: RulesEngine) => void; rulesEngine?: RulesEngine; actionTypes?: FullOptionList; autoSelectActionType?: boolean; @@ -82,38 +84,56 @@ interface RulesEngineProps { export const RulesEngineBuilder = ( props: RulesEngineProps = {} ): React.JSX.Element => { - const { rulesEngine = dummyRE, autoSelectActionType = false, conditionPath } = props; + const { + rulesEngine = dummyRE, + autoSelectActionType = false, + conditionPath = rootPath, + onRulesEngineChange, + } = props; const [re, setRE] = React.useState(rulesEngine); + const onChange = React.useCallback( + (re: RulesEngine) => { + setRE(re); + onRulesEngineChange?.(re); + }, + [onRulesEngineChange] + ); + const { optionList: actionTypes } = useOptionListProp({ optionList: dummyActionTypes }); + const updater = React.useCallback( + (c: RulesEngineCondition | RulesEngineAction, index: number) => + onChange( + produce(re, draft => { + // oxlint-disable-next-line no-explicit-any + draft.conditions.splice(index, 1, c as any); + }) + ), + [onChange, re] + ); return (
{re.conditions.map((c, i) => { - const updater = (c: RulesEngineCondition | RulesEngineAction) => - setRE( - produce(re, draft => { - // oxlint-disable-next-line no-explicit-any - draft.conditions.splice(i, 1, c as any); - }) - ); return isRulesEngineAction(c) ? ( ) : ( - + } isOnlyCondition={i === 0 && !isRuleGroup(rulesEngine.conditions[i + 1])} onConditionChange={updater} + autoSelectActionType={autoSelectActionType} /> ); })} @@ -126,7 +146,8 @@ interface RulesEngineConditionProps { condition: RulesEngineCondition; actionTypes?: FullOptionList; isOnlyCondition: boolean; - onConditionChange: (condition: RulesEngineCondition) => void; + onConditionChange: (condition: RulesEngineCondition, index: number) => void; + autoSelectActionType?: boolean; } /** @@ -135,14 +156,17 @@ interface RulesEngineConditionProps { export const RulesEngineConditionBuilder = ( props: RulesEngineConditionProps ): React.JSX.Element => { - // const onQueryChange = React.useCallback( - // (query: unknown) => { - // console.log({ conditionPath: props.conditionPath, query }); - // }, - // [props.conditionPath] - // ); - const actionUpdater = (action: RulesEngineAction) => - props.onConditionChange({ ...props.condition, action }); + const { condition, onConditionChange } = props; + const actionUpdater = React.useCallback( + (action: RulesEngineAction) => + onConditionChange({ ...condition, action }, props.conditionPath.at(-1)!), + [condition, onConditionChange, props.conditionPath] + ); + const conditionUpdater = React.useCallback( + (re: RulesEngineCondition) => + onConditionChange(re as RulesEngineCondition, props.conditionPath.at(-1)!), + [onConditionChange, props.conditionPath] + ); return (
@@ -151,6 +175,7 @@ export const RulesEngineConditionBuilder = ( {!pathsAreEqual([0], props.conditionPath) && }
( actionTypes={props.actionTypes} action={props.condition.action as RulesEngineAction} onActionChange={actionUpdater} + autoSelectActionType={props.autoSelectActionType} /> )} - {props.condition.conditions && props.condition.conditions.length > 0 && ( + {Array.isArray(props.condition.conditions) && props.condition.conditions.length > 0 && ( )} @@ -185,9 +214,10 @@ interface RulesEngineActionProps { actionTypes?: FullOptionList; action: RulesEngineAction; standalone?: boolean; - onActionChange: (action: RulesEngineAction) => void; + onActionChange: (action: RulesEngineAction, index: number) => void; conditionsMet?: RuleGroupTypeAny; conditionsFailed?: RuleGroupTypeAny; + autoSelectActionType?: boolean; } /** @@ -208,7 +238,12 @@ export const RulesEngineActionBuilder = (props: RulesEngineActionProps): React.J