diff --git a/.oxlintrc.json b/.oxlintrc.json index 2033c1448..d1d12987c 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", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9166ac85a..5218d7cdd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,21 +25,22 @@ Your IDE should check for TypeScript and lint problems as you code, but to check Each package has its own `start:*`, `build:*`, and `typecheck:*` scripts. -| Package | Start script | Build script | Typecheck script | -| ------------------------------- | --------------------- | --------------------- | ------------------------- | -| All packages | `bun start` | `bun run build` | `bun typecheck` | -| `react-querybuilder` | `bun start:rqb` | `bun build:rqb` | `bun typecheck:rqb` | -| `@react-querybuilder/antd` | `bun start:antd` | `bun build:antd` | `bun typecheck:antd` | -| `@react-querybuilder/bootstrap` | `bun start:bootstrap` | `bun build:bootstrap` | `bun typecheck:bootstrap` | -| `@react-querybuilder/bulma` | `bun start:bulma` | `bun build:bulma` | `bun typecheck:bulma` | -| `@react-querybuilder/chakra` | `bun start:chakra` | `bun build:chakra` | `bun typecheck:chakra` | -| `@react-querybuilder/datetime` | `bun start:datetime` | `bun build:datetime` | `bun typecheck:datetime` | -| `@react-querybuilder/dnd` | `bun start:dnd` | `bun build:dnd` | `bun typecheck:dnd` | -| `@react-querybuilder/fluent` | `bun start:fluent` | `bun build:fluent` | `bun typecheck:fluent` | -| `@react-querybuilder/mantine` | `bun start:mantine` | `bun build:mantine` | `bun typecheck:mantine` | -| `@react-querybuilder/material` | `bun start:material` | `bun build:material` | `bun typecheck:material` | -| `@react-querybuilder/native` | `bun start:native` | `bun build:native` | `bun typecheck:native` | -| `@react-querybuilder/tremor` | `bun start:tremor` | `bun build:tremor` | `bun typecheck:tremor` | +| Package | Start script | Build script | Typecheck script | +| ---------------------------------- | --------------------- | --------------------- | ------------------------- | +| All packages | `bun start` | `bun run build` | `bun typecheck` | +| `react-querybuilder` | `bun start:rqb` | `bun build:rqb` | `bun typecheck:rqb` | +| `@react-querybuilder/antd` | `bun start:antd` | `bun build:antd` | `bun typecheck:antd` | +| `@react-querybuilder/bootstrap` | `bun start:bootstrap` | `bun build:bootstrap` | `bun typecheck:bootstrap` | +| `@react-querybuilder/bulma` | `bun start:bulma` | `bun build:bulma` | `bun typecheck:bulma` | +| `@react-querybuilder/chakra` | `bun start:chakra` | `bun build:chakra` | `bun typecheck:chakra` | +| `@react-querybuilder/datetime` | `bun start:datetime` | `bun build:datetime` | `bun typecheck:datetime` | +| `@react-querybuilder/dnd` | `bun start:dnd` | `bun build:dnd` | `bun typecheck:dnd` | +| `@react-querybuilder/fluent` | `bun start:fluent` | `bun build:fluent` | `bun typecheck:fluent` | +| `@react-querybuilder/mantine` | `bun start:mantine` | `bun build:mantine` | `bun typecheck:mantine` | +| `@react-querybuilder/material` | `bun start:material` | `bun build:material` | `bun typecheck:material` | +| `@react-querybuilder/native` | `bun start:native` | `bun build:native` | `bun typecheck:native` | +| `@react-querybuilder/rules-engine` | `bun start:re` | `bun build:re` | `bun typecheck:re` | +| `@react-querybuilder/tremor` | `bun start:tremor` | `bun build:tremor` | `bun typecheck:tremor` | @@ -107,7 +108,7 @@ Generated folders: [new-discussion]: https://github.com/react-querybuilder/react-querybuilder/discussions/new [discord]: https://react-querybuilder.js.org/discord [egghead]: https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github -[bun]: https://bun.sh/ +[bun]: https://bun.com/ [prettier]: https://prettier.io/ [codesandbox]: https://codesandbox.io [stackblitz]: https://stackblitz.com diff --git a/bun.lock b/bun.lock index 3633552b3..5c3ece873 100644 --- a/bun.lock +++ b/bun.lock @@ -420,6 +420,28 @@ "sequelize", ], }, + "packages/rules-engine": { + "name": "@react-querybuilder/rules-engine", + "version": "8.8.3", + "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.1.10", + "@vitejs/plugin-react": "^5.0.0", + "immer": "^10.1.1", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-querybuilder": "8.8.3", + "rollup-plugin-visualizer": "^6.0.3", + "typescript": "^5.9.2", + "vite": "^7.1.2", + }, + "peerDependencies": { + "immer": ">=10", + "react": ">=18", + "react-querybuilder": "8.8.3", + }, + }, "packages/tremor": { "name": "@react-querybuilder/tremor", "version": "8.8.3", @@ -1760,6 +1782,8 @@ "@react-querybuilder/native": ["@react-querybuilder/native@workspace:packages/native"], + "@react-querybuilder/rules-engine": ["@react-querybuilder/rules-engine@workspace:packages/rules-engine"], + "@react-querybuilder/tremor": ["@react-querybuilder/tremor@workspace:packages/tremor"], "@react-stately/flags": ["@react-stately/flags@3.1.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-XPR5gi5LfrPdhxZzdIlJDz/B5cBf63l4q6/AzNqVWFKgd0QqY5LvWJftXkklaIUpKSJkIKQb8dphuZXDtkWNqg=="], diff --git a/package.json b/package.json index 207733b39..d04ab1614 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,10 @@ "start:material": "bun run --filter @react-querybuilder/material start", "start:native": "bun run --filter @react-querybuilder/native start", "start:rqb": "bun run --filter react-querybuilder start", + "start:re": "bun run --filter @react-querybuilder/rules-engine start", "start:tremor": "bun run --filter @react-querybuilder/tremor start", - "build": "concurrently --timings --max-processes 14 --prefix-colors \"#82a7dd,#0958d9,#712cf9,#00d1b2,#81e6d9,cyan,#4078c0,#c9dbf5,#7fba00,#339af0,#007fff,#61dafb,#60a5fa\" --names rqb,antd,bootstrap,bulma,chakra,datetime,dnd,drizzle,fluent,mantine,material,native,tremor bun:build:rqb bun:build:antd bun:build:bootstrap bun:build:bulma bun:build:chakra bun:build:datetime bun:build:dnd bun:build:drizzle bun:build:fluent bun:build:mantine bun:build:material bun:build:native bun:build:tremor", - "build:sequential": "bun --bun run build:rqb && bun --bun run build:antd && bun --bun run build:bootstrap && bun --bun run build:bulma && bun --bun run build:chakra && bun --bun run build:datetime && bun --bun run build:dnd && bun --bun run build:drizzle && bun --bun run build:fluent && bun --bun run build:mantine && bun --bun run build:material && bun --bun run build:native && bun --bun run build:tremor", + "build": "concurrently --timings --max-processes 14 --prefix-colors \"#82a7dd,#0958d9,#712cf9,#00d1b2,#81e6d9,cyan,#4078c0,#c9dbf5,#7fba00,#339af0,#007fff,#61dafb,#006633,#60a5fa\" --names rqb,antd,bootstrap,bulma,chakra,datetime,dnd,drizzle,fluent,mantine,material,native,re,tremor bun:build:rqb bun:build:antd bun:build:bootstrap bun:build:bulma bun:build:chakra bun:build:datetime bun:build:dnd bun:build:drizzle bun:build:fluent bun:build:mantine bun:build:material bun:build:native bun:build:re bun:build:tremor", + "build:sequential": "bun --bun run build:rqb && bun --bun run build:antd && bun --bun run build:bootstrap && bun --bun run build:bulma && bun --bun run build:chakra && bun --bun run build:datetime && bun --bun run build:dnd && bun --bun run build:drizzle && bun --bun run build:fluent && bun --bun run build:mantine && bun --bun run build:material && bun --bun run build:native && bun --bun run build:re && bun --bun run build:tremor", "build:antd": "bun --bun run --filter @react-querybuilder/antd build", "build:bootstrap": "bun --bun run --filter @react-querybuilder/bootstrap build", "build:bulma": "bun --bun run --filter @react-querybuilder/bulma build", @@ -36,15 +37,16 @@ "build:rqb": "bun run --filter react-querybuilder build", "build:rqb:main": "bun --bun run --filter react-querybuilder build:main", "build:rqb:css": "bun run --filter react-querybuilder build:css", + "build:re": "bun --bun run --filter @react-querybuilder/rules-engine build", "build:tremor": "bun --bun run --filter @react-querybuilder/tremor build", "codesandbox-ci": "bash .codesandbox/ci.sh", "checkall": "bun install --frozen-lockfile && bun check-type-fest && bun run build && concurrently --group --names pretty,lint,typecheck,test --prefix-colors \"#56B3B4,#4b32c3,#3178c6,#99425B\" bun:pretty-check bun:lint bun:typecheck bun:test && bun run are-the-types-wrong && bun run website:build", "lint": "oxlint --format=github --report-unused-disable-directives", "test": "bun test:bun && bun test:drizzlecoverage && bunx jest", - "test:bun": "bun generate-prisma-client && bun test dbquery packages/react-querybuilder/src/utils/parse* formatQuery transformQuery datetimeRuleProcessor drizzle", + "test:bun": "bun generate-prisma-client && bun test dbquery datetimeRuleProcessor drizzle packages/react-querybuilder/src/utils/*.ts", "test:drizzlecoverage": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" RQB_DRIZZLE_COVERAGE=true bunx jest packages/drizzle --coverage-reporter=none", "test:watch": "jest --watch", - "typecheck": "concurrently --timings --max-processes 14 --prefix-colors \"#0958d9,#712cf9,#00d1b2,#81e6d9,cyan,#4078c0,#c9dbf5,#7fba00,#339af0,#007fff,#61dafb,#82a7dd,#60a5fa,darkgray,gray,blue\" --names antd,bootstrap,bulma,chakra,datetime,dnd,drizzle,fluent,mantine,material,native,rqb,tremor,utils,examples,website bun:typecheck:antd bun:typecheck:bootstrap bun:typecheck:bulma bun:typecheck:chakra bun:typecheck:datetime bun:typecheck:dnd bun:typecheck:drizzle bun:typecheck:fluent bun:typecheck:mantine bun:typecheck:material bun:typecheck:native bun:typecheck:rqb bun:typecheck:tremor bun:typecheck:utils bun:typecheck:examples bun:typecheck:website", + "typecheck": "concurrently --timings --max-processes 14 --prefix-colors \"#0958d9,#712cf9,#00d1b2,#81e6d9,cyan,#4078c0,#c9dbf5,#7fba00,#339af0,#007fff,#61dafb,#82a7dd,#006633,#60a5fa,darkgray,gray,blue\" --names antd,bootstrap,bulma,chakra,datetime,dnd,drizzle,fluent,mantine,material,native,rqb,re,tremor,utils,examples,website bun:typecheck:antd bun:typecheck:bootstrap bun:typecheck:bulma bun:typecheck:chakra bun:typecheck:datetime bun:typecheck:dnd bun:typecheck:drizzle bun:typecheck:fluent bun:typecheck:mantine bun:typecheck:material bun:typecheck:native bun:typecheck:rqb bun:typecheck:re bun:typecheck:tremor bun:typecheck:utils bun:typecheck:examples bun:typecheck:website", "typecheck:antd": "tsc -p packages/antd", "typecheck:bootstrap": "tsc -p packages/bootstrap", "typecheck:bulma": "tsc -p packages/bulma", @@ -57,11 +59,12 @@ "typecheck:material": "tsc -p packages/material", "typecheck:native": "tsc -p packages/native", "typecheck:rqb": "tsc -p packages/react-querybuilder", + "typecheck:re": "tsc -p packages/rules-engine", "typecheck:tremor": "tsc -p packages/tremor", "typecheck:examples": "tsc -p examples", "typecheck:website": "tsc -p website", "typecheck:utils": "tsc -p ./tsconfig.json", - "tsnext": "concurrently --timings --max-processes 14 --prefix-colors \"#0958d9,#712cf9,#00d1b2,#81e6d9,cyan,#4078c0,#c9dbf5,#7fba00,#339af0,#007fff,#61dafb,#82a7dd,#60a5fa,darkgray,gray,blue\" --names antd,bootstrap,bulma,chakra,datetime,dnd,drizzle,fluent,mantine,material,native,rqb,tremor,utils,examples,website bun:tsnext:antd bun:tsnext:bootstrap bun:tsnext:bulma bun:tsnext:chakra bun:tsnext:datetime bun:tsnext:dnd bun:tsnext:drizzle bun:tsnext:fluent bun:tsnext:mantine bun:tsnext:material bun:tsnext:native bun:tsnext:rqb bun:tsnext:tremor bun:tsnext:utils bun:tsnext:examples bun:tsnext:website", + "tsnext": "concurrently --timings --max-processes 14 --prefix-colors \"#0958d9,#712cf9,#00d1b2,#81e6d9,cyan,#4078c0,#c9dbf5,#7fba00,#339af0,#007fff,#61dafb,#82a7dd,#006633,#60a5fa,darkgray,gray,blue\" --names antd,bootstrap,bulma,chakra,datetime,dnd,drizzle,fluent,mantine,material,native,rqb,tremor,utils,examples,website bun:tsnext:antd bun:tsnext:bootstrap bun:tsnext:bulma bun:tsnext:chakra bun:tsnext:datetime bun:tsnext:dnd bun:tsnext:drizzle bun:tsnext:fluent bun:tsnext:mantine bun:tsnext:material bun:tsnext:native bun:tsnext:rqb bun:tsnext:re bun:tsnext:tremor bun:tsnext:utils bun:tsnext:examples bun:tsnext:website", "tsnext:antd": "tsgo -p packages/antd", "tsnext:bootstrap": "tsgo -p packages/bootstrap", "tsnext:bulma": "tsgo -p packages/bulma", @@ -74,11 +77,12 @@ "tsnext:material": "tsgo -p packages/material", "tsnext:native": "tsgo -p packages/native", "tsnext:rqb": "tsgo -p packages/react-querybuilder", + "tsnext:re": "tsgo -p packages/rules-engine", "tsnext:tremor": "tsgo -p packages/tremor", "tsnext:examples": "tsgo -p examples", "tsnext:website": "tsgo -p website", "tsnext:utils": "tsgo -p ./tsconfig.json", - "are-the-types-wrong": "bun run attw:rqb && bun run attw:antd && bun run attw:bootstrap && bun run attw:bulma && bun run attw:chakra && bun run attw:datetime && bun run attw:dnd && bun run attw:drizzle && bun run attw:fluent && bun run attw:mantine && bun run attw:material && bun run attw:native && bun run attw:tremor", + "are-the-types-wrong": "bun run attw:rqb && bun run attw:antd && bun run attw:bootstrap && bun run attw:bulma && bun run attw:chakra && bun run attw:datetime && bun run attw:dnd && bun run attw:drizzle && bun run attw:fluent && bun run attw:mantine && bun run attw:material && bun run attw:native && bun run attw:re && bun run attw:tremor", "attw:antd": "attw --format table-flipped --pack ./packages/antd", "attw:bootstrap": "attw --format table-flipped --pack ./packages/bootstrap", "attw:bulma": "attw --format table-flipped --pack ./packages/bulma", @@ -91,6 +95,7 @@ "attw:material": "attw --format table-flipped --pack ./packages/material", "attw:native": "attw --format table-flipped --pack ./packages/native", "attw:rqb": "attw --format table-flipped --profile node16 --pack ./packages/react-querybuilder", + "attw:re": "attw --format table-flipped --pack ./packages/rules-engine", "attw:tremor": "attw --format table-flipped --pack ./packages/tremor", "pretty-print": "prettier --config prettier.config.mjs --write '*.*' './packages/**' './utils/**' './website/**'", "pretty-check": "prettier --config prettier.config.mjs --check '*.*' './packages/**' './utils/**' './website/**'", diff --git a/packages/react-querybuilder/src/hooks/useSelectElementChangeHandler.ts b/packages/react-querybuilder/src/hooks/useSelectElementChangeHandler.ts index c5c9ea446..2d826345e 100644 --- a/packages/react-querybuilder/src/hooks/useSelectElementChangeHandler.ts +++ b/packages/react-querybuilder/src/hooks/useSelectElementChangeHandler.ts @@ -19,7 +19,6 @@ export const useSelectElementChangeHandler = ( () => multiple ? (e: ChangeEvent) => - // TODO: spread instead? onChange(Array.from(e.target.selectedOptions).map(o => o.value)) : (e: ChangeEvent) => onChange(e.target.value), [multiple, onChange] 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/utils/clsx.ts b/packages/react-querybuilder/src/utils/clsx.ts index 14225e7f3..1c90a9c80 100644 --- a/packages/react-querybuilder/src/utils/clsx.ts +++ b/packages/react-querybuilder/src/utils/clsx.ts @@ -44,6 +44,11 @@ function toVal(mix: any) { return str; } +/** + * Vendored/adapted version of the `clsx` package. + * + * **NOTE:** Prefer the official package from npm outside the context of React Query Builder. + */ // istanbul ignore next export function clsx(...args: ClassValue[]): string { let i = 0; diff --git a/packages/react-querybuilder/src/utils/generateIDTestUtils.ts b/packages/react-querybuilder/src/utils/generateIDTestUtils.ts index 84c7401b5..112d331a9 100644 --- a/packages/react-querybuilder/src/utils/generateIDTestUtils.ts +++ b/packages/react-querybuilder/src/utils/generateIDTestUtils.ts @@ -1,5 +1,6 @@ export const uuidV4regex: RegExp = /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i; + const arr = new Array(10_000).fill(0); export const testGenerateID = (generateID: () => string): void => { diff --git a/packages/react-querybuilder/src/utils/index.ts b/packages/react-querybuilder/src/utils/index.ts index ce3d3c77e..c7d5d36a4 100644 --- a/packages/react-querybuilder/src/utils/index.ts +++ b/packages/react-querybuilder/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './arrayUtils'; +export * from './clsx'; export * from './convertQuery'; export * from './defaultValidator'; export * from './filterFieldsByComparator'; @@ -25,10 +26,6 @@ export * from './regenerateIDs'; export * from './toOptions'; export * from './transformQuery'; -// Don't export clsx. It should be imported from the official clsx -// package if used outside the context of this package. -// export * from './clsx'; - // To reduce bundle size, these are only available as // separate exports as of v7. // export * from './parseCEL'; 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/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..273b3e293 100644 --- a/packages/react-querybuilder/src/utils/pathUtils.ts +++ b/packages/react-querybuilder/src/utils/pathUtils.ts @@ -20,7 +20,7 @@ export const findPath = (path: Path, query: RuleGroupTypeAny): FindPathReturnTyp level++; } - return target; + return level < path.length ? null : target; }; /** 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/regenerateIDs.test.ts b/packages/react-querybuilder/src/utils/regenerateIDs.test.ts index 6d29960ba..351736f1e 100644 --- a/packages/react-querybuilder/src/utils/regenerateIDs.test.ts +++ b/packages/react-querybuilder/src/utils/regenerateIDs.test.ts @@ -9,14 +9,7 @@ const ruleGroup: RuleGroupType = { { id: 'innerGroup', combinator: 'and', - rules: [ - { - id: 'innerRule', - field: 'TEST', - operator: '=', - value: '', - }, - ], + rules: [{ id: 'innerRule', field: 'TEST', operator: '=', value: '' }], }, ], }; @@ -27,19 +20,9 @@ const ruleGroupIC: RuleGroupTypeIC = { { id: 'innerGroup', rules: [ - { - id: 'innerRule', - field: 'TEST', - operator: '=', - value: '', - }, + { id: 'innerRule', field: 'TEST', operator: '=', value: '' }, 'and', - { - id: 'innerRule', - field: 'TEST', - operator: '=', - value: '', - }, + { id: 'innerRule', field: 'TEST', operator: '=', value: '' }, ], }, ], @@ -74,14 +57,12 @@ it('should generate different IDs for independent combinators', () => { }); it('should generate different IDs for any POJO', () => { - // @ts-expect-error testing invalid input const newObject = regenerateIDs({ test: 'this' }); expect(newObject.id).toMatch(uuidV4regex); expect(newObject).toHaveProperty('test', 'this'); }); it('should return the first param if not POJO', () => { - // @ts-expect-error testing invalid input const newObject = regenerateIDs('test'); expect(newObject).toBe('test'); }); diff --git a/packages/react-querybuilder/src/utils/regenerateIDs.ts b/packages/react-querybuilder/src/utils/regenerateIDs.ts index 266e2ca9a..13ce41653 100644 --- a/packages/react-querybuilder/src/utils/regenerateIDs.ts +++ b/packages/react-querybuilder/src/utils/regenerateIDs.ts @@ -1,12 +1,6 @@ -import type { - RuleGroupArray, - RuleGroupICArray, - RuleGroupType, - RuleGroupTypeIC, - RuleType, -} from '../types/index.noReact'; +import type { RuleGroupTypeAny, RuleType, SetRequired } from '../types/index.noReact'; import { generateID } from './generateID'; -import { isRuleGroup, isRuleGroupType } from './isRuleGroup'; +import { isRuleGroup } from './isRuleGroup'; import { isPojo } from './misc'; /** @@ -19,41 +13,40 @@ export interface RegenerateIdOptions { /** * Generates a new `id` property for a rule. */ -export const regenerateID = ( - rule: RuleType, +export const regenerateID = ( + rule: R, { idGenerator = generateID }: RegenerateIdOptions = {} -): RuleType => structuredClone({ ...rule, id: idGenerator() }); +): SetRequired => structuredClone({ ...rule, id: idGenerator() } as SetRequired); /** - * Recursively generates new `id` properties for a group and all its rules and subgroups. + * Recursively generates new `id` properties for a rule group and all its rules and subgroups. */ -export const regenerateIDs = ( - ruleOrGroup: RuleGroupType | RuleGroupTypeIC, +export const regenerateIDs = ( + subject: RG, { idGenerator = generateID }: RegenerateIdOptions = {} -): RuleGroupType | RuleGroupTypeIC => { - if (!isPojo(ruleOrGroup)) return ruleOrGroup; +): RG & { id: string } => { + if (!isPojo(subject)) return subject as RG & { id: string }; - if (!isRuleGroup(ruleOrGroup)) { + if (!isRuleGroup(subject)) { return structuredClone({ - ...(ruleOrGroup as RuleType), + ...subject, id: idGenerator(), - }) as unknown as RuleGroupType; - // ^ this is a lie, but it shouldn't matter + }) as RG & { id: string }; } - if (isRuleGroupType(ruleOrGroup)) { - const rules = ruleOrGroup.rules.map(r => - isRuleGroup(r) ? regenerateIDs(r, { idGenerator }) : regenerateID(r, { idGenerator }) - ) as RuleGroupArray; - return { ...ruleOrGroup, id: idGenerator(), rules }; + const newGroup = { ...subject, id: idGenerator() } as RuleGroupTypeAny; + + // istanbul ignore else + if (Array.isArray(newGroup.rules)) { + // oxlint-disable-next-line no-explicit-any + (newGroup.rules as any) = subject.rules.map((r: unknown) => + typeof r === 'string' + ? r + : isRuleGroup(r) + ? regenerateIDs(r, { idGenerator }) + : regenerateID(r as RuleType, { idGenerator }) + ); } - const rules = ruleOrGroup.rules.map(r => - typeof r === 'string' - ? r - : isRuleGroup(r) - ? regenerateIDs(r, { idGenerator }) - : regenerateID(r, { idGenerator }) - ) as RuleGroupICArray; - return { ...ruleOrGroup, id: idGenerator(), rules }; + return newGroup as unknown as RG & { id: string }; }; diff --git a/packages/rules-engine/README.md b/packages/rules-engine/README.md new file mode 100644 index 000000000..387e73009 --- /dev/null +++ b/packages/rules-engine/README.md @@ -0,0 +1,24 @@ +# @react-querybuilder/rules-engine + +Rules engine extension for `react-querybuilder`. + +## Installation + +```bash +npm install react-querybuilder @react-querybuilder/rules-engine +# OR yarn add / pnpm add / bun add +``` + +## Usage + +```tsx +import { RulesEngineBuilder } from '@react-querybuilder/rules-engine'; + +function App() { + return ; +} +``` + +## Documentation + +Full documentation is available at [react-querybuilder.js.org](https://react-querybuilder.js.org/). diff --git a/packages/rules-engine/babel.config.js b/packages/rules-engine/babel.config.js new file mode 100644 index 000000000..6c0a51573 --- /dev/null +++ b/packages/rules-engine/babel.config.js @@ -0,0 +1,4 @@ +module.exports = { + presets: ['@babel/env', ['@babel/react', { runtime: 'automatic' }], '@babel/typescript'], + env: { development: { compact: true }, test: { compact: true } }, +}; diff --git a/packages/rules-engine/dev/main.tsx b/packages/rules-engine/dev/main.tsx new file mode 100644 index 000000000..8017301fe --- /dev/null +++ b/packages/rules-engine/dev/main.tsx @@ -0,0 +1,9 @@ +import { AppRE } from '@rqb-devapp'; +import * as React from 'react'; +import { createRoot } from 'react-dom/client'; + +createRoot(document.querySelector('#app')!).render( + + + +); diff --git a/packages/rules-engine/index.html b/packages/rules-engine/index.html new file mode 100644 index 000000000..3903cf1b3 --- /dev/null +++ b/packages/rules-engine/index.html @@ -0,0 +1,12 @@ + + + + + + React Query Builder Rules Engine + + +
+ + + diff --git a/packages/rules-engine/jest.config.mjs b/packages/rules-engine/jest.config.mjs new file mode 100644 index 000000000..01c96dc7e --- /dev/null +++ b/packages/rules-engine/jest.config.mjs @@ -0,0 +1,7 @@ +import common from '../../jest.common.mjs'; + +/** @type {import('@jest/types').Config.InitialOptions} */ +export default { + ...common, + displayName: 'rules-engine', +}; diff --git a/packages/rules-engine/package.json b/packages/rules-engine/package.json new file mode 100644 index 000000000..32048a444 --- /dev/null +++ b/packages/rules-engine/package.json @@ -0,0 +1,73 @@ +{ + "name": "@react-querybuilder/rules-engine", + "description": "Rules engine extension for react-querybuilder", + "version": "8.8.3", + "publishConfig": { + "access": "public" + }, + "main": "./dist/cjs/index.js", + "module": "./dist/react-querybuilder_rules-engine.legacy-esm.js", + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "types": "./dist/types-esm/index.d.mts", + "default": "./dist/react-querybuilder_rules-engine.mjs" + }, + "require": { + "types": "./dist/types/index.d.ts", + "default": "./dist/cjs/index.js" + } + } + }, + "types": "./dist/types/index.d.ts", + "files": [ + "dist" + ], + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/react-querybuilder/react-querybuilder.git", + "directory": "packages/rules-engine" + }, + "license": "MIT", + "homepage": "https://react-querybuilder.js.org/", + "keywords": [ + "react", + "querybuilder", + "rules-engine", + "rules", + "engine", + "query", + "builder", + "operators", + "component", + "clause", + "expression", + "sql" + ], + "scripts": { + "start": "vite", + "build": "bun --bun tsdown", + "typecheck": "tsc --noEmit", + "typecheck:watch": "tsc --noEmit --watch" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.0", + "@types/react": "^19.1.10", + "@vitejs/plugin-react": "^5.0.0", + "immer": "^10.1.1", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-querybuilder": "8.8.3", + "rollup-plugin-visualizer": "^6.0.3", + "typescript": "^5.9.2", + "vite": "^7.1.2" + }, + "peerDependencies": { + "react": ">=18", + "react-querybuilder": "8.8.3", + "immer": ">=10" + } +} diff --git a/packages/rules-engine/src/components/RulesEngineBuilder.tsx b/packages/rules-engine/src/components/RulesEngineBuilder.tsx new file mode 100644 index 000000000..43a2d1b5c --- /dev/null +++ b/packages/rules-engine/src/components/RulesEngineBuilder.tsx @@ -0,0 +1,237 @@ +import { produce } from 'immer'; +import * as React from 'react'; +import type { + BaseOption, + Field, + FullOptionList, + Path, + RuleGroupType, + RuleGroupTypeAny, +} from 'react-querybuilder'; +import { + clsx, + isRuleGroup, + pathsAreEqual, + QueryBuilder, + regenerateIDs, + standardClassnames as sc, + toFlatOptionArray, + toFullOptionList, + useOptionListProp, +} from 'react-querybuilder'; +import type { + RulesEngine, + RulesEngineAction, + RulesEngineActionProps, + RulesEngineCondition, + RulesEngineConditionProps, + RulesEngineProps, +} from '../types'; +import { isRulesEngineAction } from '../utils'; + +const fields: Field[] = [{ name: 'age', label: 'Age' }]; + +const dummyActionTypes: FullOptionList = toFullOptionList([ + { value: 'send_email', label: 'Send Email' }, + { value: 'log_event', label: 'Log Event' }, +]); + +export const dummyRE: RulesEngine = regenerateIDs({ + conditions: [ + { + combinator: 'and', + rules: [{ field: 'age', operator: '>=', value: 18 }], + action: { + actionType: 'send_email', + params: { to: 'user@example.com', subject: 'Welcome!', body: 'Thanks for signing up!' }, + }, + conditions: [ + { + combinator: 'and', + rules: [{ field: 'age', operator: '=', value: 18 }], + action: { + actionType: 'send_email', + params: { + to: 'user@example.com', + subject: 'Happy Birthday!', + body: 'Thanks for signing up!', + }, + }, + }, + ], + }, + { + combinator: 'and', + rules: [{ 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' }, + ], +}); + +const rootPath: Path = []; + +export const RulesEngineBuilder = ( + props: RulesEngineProps = {} +): React.JSX.Element => { + 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) => { + return isRulesEngineAction(c) ? ( + + ) : ( + } + isOnlyCondition={i === 0 && !isRuleGroup(rulesEngine.conditions[i + 1])} + onConditionChange={updater} + autoSelectActionType={autoSelectActionType} + /> + ); + })} +
+ ); +}; + +/** + * Analogous to an "if" or "else-if" block. + */ +export const RulesEngineConditionBuilder = ( + props: RulesEngineConditionProps +): React.JSX.Element => { + 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 ( +
+
+
{props.conditionPath.at(-1) === 0 ? 'If' : 'Else If'}
+ {!pathsAreEqual([0], props.conditionPath) && } +
+ + {(props.condition.action || props.condition.conditions) && ( + + {props.condition.action && ( + + )} + {Array.isArray(props.condition.conditions) && props.condition.conditions.length > 0 && ( + + )} + + )} +
+ ); +}; + +/** + * 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/rules-engine/src/components/index.ts b/packages/rules-engine/src/components/index.ts new file mode 100644 index 000000000..c9e14a33b --- /dev/null +++ b/packages/rules-engine/src/components/index.ts @@ -0,0 +1 @@ +export * from './RulesEngineBuilder'; diff --git a/packages/rules-engine/src/index.ts b/packages/rules-engine/src/index.ts new file mode 100644 index 000000000..8bf076c0a --- /dev/null +++ b/packages/rules-engine/src/index.ts @@ -0,0 +1,4 @@ +// Export everything from the barrel file +export * from './types'; +export * from './utils'; +export * from './components'; diff --git a/packages/rules-engine/src/types/index.ts b/packages/rules-engine/src/types/index.ts new file mode 100644 index 000000000..b739501b6 --- /dev/null +++ b/packages/rules-engine/src/types/index.ts @@ -0,0 +1,2 @@ +export * from './props'; +export * from './rulesEngine'; diff --git a/packages/rules-engine/src/types/props.ts b/packages/rules-engine/src/types/props.ts new file mode 100644 index 000000000..2566f48ad --- /dev/null +++ b/packages/rules-engine/src/types/props.ts @@ -0,0 +1,30 @@ +import type { BaseOption, FullOptionList, Path, RuleGroupTypeAny } from 'react-querybuilder'; +import type { RulesEngine, RulesEngineAction, RulesEngineCondition } from './rulesEngine'; + +export interface RulesEngineProps { + conditionPath?: Path; + onRulesEngineChange?: (re: RulesEngine) => void; + rulesEngine?: RulesEngine; + actionTypes?: FullOptionList; + autoSelectActionType?: boolean; +} + +export interface RulesEngineConditionProps { + conditionPath: Path; + condition: RulesEngineCondition; + actionTypes?: FullOptionList; + isOnlyCondition: boolean; + onConditionChange: (condition: RulesEngineCondition, index: number) => void; + autoSelectActionType?: boolean; +} + +export interface RulesEngineActionProps { + conditionPath: Path; + actionTypes?: FullOptionList; + action: RulesEngineAction; + standalone?: boolean; + onActionChange: (action: RulesEngineAction, index: number) => void; + conditionsMet?: RuleGroupTypeAny; + conditionsFailed?: RuleGroupTypeAny; + autoSelectActionType?: boolean; +} diff --git a/packages/rules-engine/src/types/rulesEngine.ts b/packages/rules-engine/src/types/rulesEngine.ts new file mode 100644 index 000000000..29c34c889 --- /dev/null +++ b/packages/rules-engine/src/types/rulesEngine.ts @@ -0,0 +1,85 @@ +import type { + CommonRuleAndGroupProperties, + RuleGroupType, + RuleType, + RuleGroupTypeAny, + RuleGroupTypeIC, +} from 'react-querybuilder'; + +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 + extends CommonRuleAndGroupProperties { + conditions: RulesEngineConditions>; +} + +export interface RulesEngineIC + extends CommonRuleAndGroupProperties { + conditions: RulesEngineConditions>; +} + +export type RulesEngineAny = + | RulesEngine + | RulesEngineIC; + +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' }, + ], +}; diff --git a/packages/rules-engine/src/utils/generateIDTestUtils.ts b/packages/rules-engine/src/utils/generateIDTestUtils.ts new file mode 100644 index 000000000..0348e15ae --- /dev/null +++ b/packages/rules-engine/src/utils/generateIDTestUtils.ts @@ -0,0 +1,2 @@ +export const uuidV4regex: RegExp = + /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i; diff --git a/packages/rules-engine/src/utils/index.ts b/packages/rules-engine/src/utils/index.ts new file mode 100644 index 000000000..777f903f8 --- /dev/null +++ b/packages/rules-engine/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './isRulesEngine'; +export * from './pathUtils'; +export * from './rulesEngineTools'; diff --git a/packages/rules-engine/src/utils/isRulesEngine.ts b/packages/rules-engine/src/utils/isRulesEngine.ts new file mode 100644 index 000000000..1f73c3af3 --- /dev/null +++ b/packages/rules-engine/src/utils/isRulesEngine.ts @@ -0,0 +1,26 @@ +import { isPojo, isRuleGroupType, isRuleGroupTypeIC } from 'react-querybuilder'; +import type { RulesEngine, RulesEngineAction, RulesEngineAny, RulesEngineIC } from '../types'; + +/** + * 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/rules-engine/src/utils/pathUtils.ts b/packages/rules-engine/src/utils/pathUtils.ts new file mode 100644 index 000000000..f42a96fd9 --- /dev/null +++ b/packages/rules-engine/src/utils/pathUtils.ts @@ -0,0 +1,92 @@ +import type { Path, RuleGroupTypeAny } from 'react-querybuilder'; +import { isPojo } from 'react-querybuilder'; +import type { RulesEngineAction, RulesEngineAny } from '../types'; +import { isRulesEngineAny } from './isRulesEngine'; + +/** + * Return type for {@link findConditionPath}. + */ +export type FindConditionPathReturnType = + | RulesEngineAny + | RuleGroupTypeAny + | RulesEngineAction + | null; + +/** + * 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 && isRulesEngineAny(target)) { + target = target.conditions[path[level]]; + level++; + } + + return level < path.length ? null : (target ?? 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 (isRulesEngineAny(condition)) { + return findConditionID(id, condition); + } + } + + 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 => isPojo(c) && c.id === id); + + if (idx >= 0) { + return [idx]; + } + + for (const [i, c] of Object.entries(re.conditions)) { + if (isRulesEngineAny(c)) { + const subPath = getConditionPathOfID(id, c); + if (Array.isArray(subPath)) { + return [Number.parseInt(i), ...subPath]; + } + } + } + + return null; +}; + +/** + * Returns the parent path of a given path. + */ +export const getParentPath = (path: Path): Path => path.slice(0, -1); + +/** + * Determines if two paths are equal. + */ +export const pathsAreEqual = (path1: Path, path2: Path): boolean => { + if (path1.length !== path2.length) return false; + return path1.every((segment, i) => segment === path2[i]); +}; diff --git a/packages/rules-engine/src/utils/regenerateIDs.test.ts b/packages/rules-engine/src/utils/regenerateIDs.test.ts new file mode 100644 index 000000000..a0e466f45 --- /dev/null +++ b/packages/rules-engine/src/utils/regenerateIDs.test.ts @@ -0,0 +1,135 @@ +import type { + RulesEngine, + RulesEngineCondition, + RulesEngineIC, +} from '@react-querybuilder/rules-engine'; +import type { RuleGroupType, RuleGroupTypeIC, RuleType } from 'react-querybuilder'; +import { regenerateID, regenerateIDs } from './regenerateIDs'; +import { uuidV4regex } from './generateIDTestUtils'; + +const ruleGroup: RuleGroupType = { + id: 'root', + combinator: 'and', + rules: [ + { + id: 'innerGroup', + combinator: 'and', + rules: [{ id: 'innerRule', field: 'TEST', operator: '=', value: '' }], + }, + ], +}; + +const ruleGroupIC: RuleGroupTypeIC = { + id: 'root', + rules: [ + { + id: 'innerGroup', + rules: [ + { id: 'innerRule', field: 'TEST', operator: '=', value: '' }, + 'and', + { id: 'innerRule', field: 'TEST', operator: '=', value: '' }, + ], + }, + ], +}; + +describe('rule groups', () => { + it('should generate different IDs for rules', () => { + const newRule = regenerateID((ruleGroup.rules[0] as RuleGroupType).rules[0] as RuleType); + expect(newRule.id).toMatch(uuidV4regex); + }); + + it('should generate different IDs for standard queries', () => { + const newRuleGroup = regenerateIDs(ruleGroup); + expect(newRuleGroup.id).not.toBe(ruleGroup.id); + expect((newRuleGroup.rules[0] as RuleGroupType).id).not.toBe(ruleGroup.rules[0].id); + expect((newRuleGroup.rules[0] as RuleGroupType).rules[0].id).not.toBe( + (ruleGroup.rules[0] as RuleGroupType).rules[0].id + ); + }); + + it('should generate different IDs for independent combinators', () => { + const newRuleGroupIC = regenerateIDs(ruleGroupIC); + expect(newRuleGroupIC.id).not.toBe(ruleGroupIC.id); + expect((newRuleGroupIC.rules[0] as RuleGroupTypeIC).id).not.toBe( + (ruleGroupIC.rules[0] as RuleGroupTypeIC).id + ); + expect(((newRuleGroupIC.rules[0] as RuleGroupTypeIC).rules[0] as RuleGroupTypeIC).id).not.toBe( + ((ruleGroupIC.rules[0] as RuleGroupTypeIC).rules[0] as RuleGroupTypeIC).id + ); + expect(((newRuleGroupIC.rules[0] as RuleGroupTypeIC).rules[2] as RuleGroupTypeIC).id).not.toBe( + ((ruleGroupIC.rules[0] as RuleGroupTypeIC).rules[2] as RuleGroupTypeIC).id + ); + }); + + it('should generate different IDs for any POJO', () => { + const newObject = regenerateIDs({ test: 'this' }); + expect(newObject.id).toMatch(uuidV4regex); + expect(newObject).toHaveProperty('test', 'this'); + }); + + it('should return the first param if not POJO', () => { + const newObject = regenerateIDs('test'); + expect(newObject).toBe('test'); + }); +}); + +describe('rules engines', () => { + const re: RulesEngine = { + id: 'root', + conditions: [ + { id: 'firstGroup', combinator: 'and', rules: [ruleGroup], conditions: [ruleGroup] }, + ], + }; + + const reIC: RulesEngineIC = { + id: 'root', + conditions: [ + { id: 'firstGroup', rules: [ruleGroupIC, 'or', ruleGroupIC], conditions: [ruleGroupIC] }, + ], + }; + + it('should generate different IDs for standard rules engines', () => { + const newRulesEngine = regenerateIDs(re); + + expect(newRulesEngine.id).not.toBe(re.id); + expect( + (newRulesEngine.conditions[0] as RulesEngineCondition).rules[0].id + ).not.toBe(re.conditions[0].id); + expect(newRulesEngine.conditions[0].id).not.toBe(re.conditions[0].id); + expect((newRulesEngine.conditions[0] as RulesEngine).conditions[0].id).not.toBe( + (re.conditions[0] as RulesEngine).conditions[0].id + ); + }); + + it('should generate different IDs for rules engines with independent combinators', () => { + const newRulesEngineIC = regenerateIDs(reIC); + // console.log(newRulesEngineIC); + expect(newRulesEngineIC.id).not.toBe(reIC.id); + expect((newRulesEngineIC.conditions[0] as RulesEngineCondition).id).not.toBe( + (reIC.conditions[0] as RulesEngineCondition).id + ); + expect( + ( + (newRulesEngineIC.conditions[0] as RulesEngineCondition) + .conditions![0] as RulesEngineCondition + ).id + ).not.toBe( + ( + (reIC.conditions[0] as RulesEngineCondition) + .conditions![0] as RulesEngineCondition + ).id + ); + expect( + ( + (newRulesEngineIC.conditions[0] as RulesEngineCondition) + .rules[2] as RulesEngineCondition + ).id + ).not.toBe( + ( + (reIC.conditions[0] as RulesEngineCondition) + .rules[2] as RulesEngineCondition + ).id + ); + }); +}); diff --git a/packages/rules-engine/src/utils/regenerateIDs.ts b/packages/rules-engine/src/utils/regenerateIDs.ts new file mode 100644 index 000000000..29d152838 --- /dev/null +++ b/packages/rules-engine/src/utils/regenerateIDs.ts @@ -0,0 +1,55 @@ +import type { RuleGroupTypeAny, RuleType, SetRequired } from 'react-querybuilder'; +import { generateID, isPojo, isRuleGroup } from 'react-querybuilder'; +import type { RulesEngine } from '../types'; +import { isRulesEngineAny } from './isRulesEngine'; + +/** + * Options object for {@link regenerateID}/{@link regenerateIDs}. + */ +export interface RegenerateIdOptions { + idGenerator?: () => string; +} + +/** + * Generates a new `id` property for a rule. + */ +export const regenerateID = ( + rule: R, + { idGenerator = generateID }: RegenerateIdOptions = {} +): SetRequired => structuredClone({ ...rule, id: idGenerator() } as SetRequired); + +/** + * Recursively generates new `id` properties for a rule group or rules engine and all + * its rules/conditions and subgroups/subconditions. + */ +export const regenerateIDs = ( + subject: RG, + { idGenerator = generateID }: RegenerateIdOptions = {} +): RG & { id: string } => { + if (!isPojo(subject)) return subject as RG & { id: string }; + + if (!isRuleGroup(subject) && !isRulesEngineAny(subject)) { + return structuredClone({ + ...subject, + id: idGenerator(), + }) as RG & { id: string }; + } + + const newGroup = { ...subject, id: idGenerator() } as RulesEngine & RuleGroupTypeAny; + + if (Array.isArray(newGroup.rules)) { + newGroup.rules = subject.rules.map((r: unknown) => + typeof r === 'string' + ? r + : isRuleGroup(r) + ? regenerateIDs(r, { idGenerator }) + : regenerateID(r as RuleType, { idGenerator }) + ); + } + + if (Array.isArray(newGroup.conditions)) { + newGroup.conditions = subject.conditions.map((r: unknown) => regenerateIDs(r, { idGenerator })); + } + + return newGroup as unknown as RG & { id: string }; +}; diff --git a/packages/rules-engine/src/utils/rulesEngineTools.test.ts b/packages/rules-engine/src/utils/rulesEngineTools.test.ts new file mode 100644 index 000000000..3c5e3999b --- /dev/null +++ b/packages/rules-engine/src/utils/rulesEngineTools.test.ts @@ -0,0 +1,497 @@ +import type { RulesEngine } from '../types'; +import { addRE, groupRE, insertRE, moveRE, removeRE, updateRE } 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); + }); +}); + +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' }], someProp: 'initial value' }, + ], + id: 'root', + }); + }); + + 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); + }); +}); + +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: [] }, + [1] + ) + ).toEqual({ + conditions: [ + { combinator: 'and', rules: [] }, + { combinator: 'or', rules: [] }, + { actionType: 'a' }, + ], + id: 'root', + }); + }); + + 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' }], + 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/rules-engine/src/utils/rulesEngineTools.ts b/packages/rules-engine/src/utils/rulesEngineTools.ts new file mode 100644 index 000000000..545d5019c --- /dev/null +++ b/packages/rules-engine/src/utils/rulesEngineTools.ts @@ -0,0 +1,460 @@ +import { produce } from 'immer'; +import type { + AddOptions, + GroupOptions, + InsertOptions, + MoveOptions, + Path, + RuleGroupTypeAny, + RuleType, + UpdateOptions, +} from 'react-querybuilder'; +import { + add, + group, + insert, + isRuleGroup, + isRuleType, + move, + remove, + update, +} from 'react-querybuilder'; +import type { RulesEngineAction, RulesEngineAny } from '../types'; +import { isRulesEngineAction, isRulesEngineAny } from './isRulesEngine'; +import { findConditionPath, getConditionPathOfID, getParentPath, pathsAreEqual } from './pathUtils'; + +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); +}; + +/** + * 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); + } + } + }); + +/** + * Options for {@link updateRE}. + * + * @group Rules Engine 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; + } + }); + +/** + * 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 ( + !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 { + // Normal insertion/replacement + splice(parent.conditions, newIndex, insertOptions.replace ? 1 : 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); + }); +}; diff --git a/packages/rules-engine/tsconfig.json b/packages/rules-engine/tsconfig.json new file mode 100644 index 000000000..5f1eb49f5 --- /dev/null +++ b/packages/rules-engine/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./src/", "./dev/"] +} diff --git a/packages/rules-engine/tsdown.config.ts b/packages/rules-engine/tsdown.config.ts new file mode 100644 index 000000000..685de3d7e --- /dev/null +++ b/packages/rules-engine/tsdown.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from 'tsdown'; +import { tsdownCommonConfig } from '../../utils/tsdown.common'; + +export default defineConfig(tsdownCommonConfig(import.meta.dir)); diff --git a/packages/rules-engine/typedoc.json b/packages/rules-engine/typedoc.json new file mode 100644 index 000000000..ebfd82b58 --- /dev/null +++ b/packages/rules-engine/typedoc.json @@ -0,0 +1,4 @@ +{ + "entryPoints": ["src/index.ts"], + "extends": ["../../typedoc.json"] +} diff --git a/packages/rules-engine/vite.config.mts b/packages/rules-engine/vite.config.mts new file mode 100644 index 000000000..f2c1d9da0 --- /dev/null +++ b/packages/rules-engine/vite.config.mts @@ -0,0 +1,4 @@ +import { defineConfig } from 'vite'; +import { getCommonViteConfig } from '../../utils/vite.common'; + +export default defineConfig(getCommonViteConfig({ port: 3113 })); diff --git a/utils/devapp/AppRE.tsx b/utils/devapp/AppRE.tsx new file mode 100644 index 000000000..57b84139e --- /dev/null +++ b/utils/devapp/AppRE.tsx @@ -0,0 +1,21 @@ +import { dummyRE, RulesEngineBuilder } from '@react-querybuilder/rules-engine'; +import * as React from 'react'; +import { DevLayout } from './DevLayout'; +import { useDevApp } from './useDevApp'; + +export const AppRE = (): React.JSX.Element => { + const devApp = useDevApp(); + const [re, setRE] = React.useState(dummyRE); + + return ( + + +
+        {JSON.stringify(re, null, 2)}
+      
+
+ ); +}; 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/index.ts b/utils/devapp/index.ts index 24b028867..034d2c525 100644 --- a/utils/devapp/index.ts +++ b/utils/devapp/index.ts @@ -1,4 +1,5 @@ export * from './App'; +export * from './AppRE'; export * from './DevLayout'; export * from './constants'; export * from './musicalInstruments'; 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; +} +*/ diff --git a/utils/devapp/useDevApp.ts b/utils/devapp/useDevApp.ts index 923b563fe..b2b41f28d 100644 --- a/utils/devapp/useDevApp.ts +++ b/utils/devapp/useDevApp.ts @@ -68,15 +68,19 @@ export const useDevApp = (): { }; }, [optVals]); - const formatQueryResults = formatMap.map(([format]) => { - const formatQueryOptions: FormatQueryOptions = { - format, - fields: optVals.validateQuery ? fields : undefined, - parseNumbers: optVals.parseNumbers, - }; - const q = optVals.independentCombinators ? queryIC : query; - return [format, getFormatQueryString(q, formatQueryOptions)] as const; - }); + const formatQueryResults = useMemo( + () => + formatMap.map(([format]) => { + const formatQueryOptions: FormatQueryOptions = { + format, + fields: optVals.validateQuery ? fields : undefined, + parseNumbers: optVals.parseNumbers, + }; + const q = optVals.independentCombinators ? queryIC : query; + return [format, getFormatQueryString(q, formatQueryOptions)] as const; + }), + [optVals.validateQuery, optVals.parseNumbers, optVals.independentCombinators, queryIC, query] + ); const actions = useMemo( () =>