diff --git a/AGENTS.md b/AGENTS.md
index 526ba01..b7c2730 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -185,7 +185,7 @@ setX(v)` returns the setter's return value and throws "invalid cleanup value". U
- `createProjection(fn, seed, options?)` is a derived, **read-only** store with the same `'id'`
default key. It can be driven from a non-reactive external source (the manager's subscribe
callback) by bumping a version signal from that callback and reading the signal in `fn`; this is
- what `createQueryBuilderState` uses.
+ what `createQueryBuilder` uses.
- `createStore`'s setter takes a **draft callback** (`setStore(draft => { draft.x = … })`). There is
no 1.x `setStore('key', value)` path-argument form; it throws `fn is not a function`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 019c5b6..542b444 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,19 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Changed
+- **Breaking:** `createQueryBuilderState` is renamed **`createQueryBuilder`** and is now documented
+ as the package's headless entry point (see "Headless usage" in the README). It already was one —
+ it returns query, tree, manager, schema, actions and context and renders nothing — so this is a
+ naming change only. `CreateQueryBuilderStateOptions` is renamed `CreateQueryBuilderOptions`; the
+ returned `QueryBuilderState` interface keeps its name. No alias is kept.
+- **Breaking (source path only):** `createRuleActions` moved from `src/reactive/` to `src/actions.ts`.
+ It is the one module in the reactive layer with no reactive primitives — a pure `QueryManager` →
+ `QueryActions` adapter. The public barrel export is unchanged.
+- Internal: `createQueryBuilder.ts` (767 lines) is split along its existing `#region` seams into
+ `manager-options.ts` (the option builders and `valuesEqual`), `manager-bridge.ts` (manager
+ construction, query seeding, the version signals, the store projection, the subscription and the
+ three effects), `schema.ts` (the option lists, the resolvers and the `Schema` getter object) and
+ `context-value.ts`. Pure moves; the assembly file is ~200 lines and the new modules are internal.
- Internal: ~20 `createMemo` calls that wrapped a single property read or a primitive-returning
boolean expression are now plain closures. Solid props are already lazy getters, so those memos
allocated a computation node to cache a property access. Memos that allocate an object, run a
diff --git a/README.md b/README.md
index 2f5875e..fcfe16c 100644
--- a/README.md
+++ b/README.md
@@ -85,6 +85,40 @@ function App() {
}
```
+## Headless usage
+
+`createQueryBuilder` is the primitive `` is built on, and it is public API. It takes
+the same props, returns the query, the manager, the schema, the actions and the context value, and
+renders nothing — so you can drive an entirely custom UI from it.
+
+```tsx
+import { For } from 'solid-js';
+import { createQueryBuilder } from 'solid-querybuilder';
+
+function CustomBuilder(props) {
+ const state = createQueryBuilder(props);
+
+ return (
+
+
+ {(r, i) => (
+
+ {r.field} {r.operator} {String(r.value)}
+
+
+ )}
+
+
+
+ );
+}
+```
+
+Read the query from `state.rootGroup` (the store mirror, reconciled by `id`, so `` sees stable
+identities) rather than `state.query` (a plain identity signal) whenever you are rendering it.
+`state.manager` is the underlying [`QueryManager`](https://react-querybuilder.js.org); everything
+else is derived from it.
+
## Styling
Two prebuilt stylesheets ship in `dist`: `query-builder.css` (full) and
diff --git a/docs/differences-from-react-querybuilder.md b/docs/differences-from-react-querybuilder.md
index 954ed35..757935e 100644
--- a/docs/differences-from-react-querybuilder.md
+++ b/docs/differences-from-react-querybuilder.md
@@ -144,8 +144,9 @@ signature — so no component needs a `widenedProps` re-widening cast.
React Query Builder's hooks (`useQueryBuilder`, `useRule`, `useRuleGroup`, `useValueEditor`, …) are
not ported under those names. The Solid equivalents live in the `reactive/` layer and are exported:
-`createQueryBuilderState`, `createRuleState`, `createRuleGroupState`, `createRuleActions`,
-`createValueEditorReset`, and the `QueryBuilderContext` / `useQueryBuilderConfig` pair. The `create*`
+`createQueryBuilder`, `createRuleState`, `createRuleGroupState`, `createValueEditorReset`, and the
+`QueryBuilderContext` / `useQueryBuilderConfig` pair (`createRuleActions` is exported too, from
+`src/actions.ts` — it uses no reactive primitives). The `create*`
naming disambiguates from core's own `createRule` / `createRuleGroup` / `createQueryActions`, which
this package re-exports.
diff --git a/packages/solid-querybuilder/src/reactive/createRuleActions.test.ts b/packages/solid-querybuilder/src/actions.test.ts
similarity index 98%
rename from packages/solid-querybuilder/src/reactive/createRuleActions.test.ts
rename to packages/solid-querybuilder/src/actions.test.ts
index 0f34aa0..e2f3815 100644
--- a/packages/solid-querybuilder/src/reactive/createRuleActions.test.ts
+++ b/packages/solid-querybuilder/src/actions.test.ts
@@ -2,9 +2,9 @@ import type { RuleGroupType, RuleType } from '@react-querybuilder/core';
import { QueryManager } from '@react-querybuilder/core';
import { createStore } from 'solid-js';
import { describe, expect, it, vi } from 'vitest';
-import { flatQuery, testFields } from '../../test/support.js';
-import type { QueryBuilderProps } from '../types/props.js';
-import { createRuleActions } from './createRuleActions.js';
+import { flatQuery, testFields } from '../test/support.js';
+import { createRuleActions } from './actions.js';
+import type { QueryBuilderProps } from './types/props.js';
const nested: RuleGroupType = {
id: 'root',
diff --git a/packages/solid-querybuilder/src/reactive/createRuleActions.ts b/packages/solid-querybuilder/src/actions.ts
similarity index 98%
rename from packages/solid-querybuilder/src/reactive/createRuleActions.ts
rename to packages/solid-querybuilder/src/actions.ts
index 3a821b3..5ea6387 100644
--- a/packages/solid-querybuilder/src/reactive/createRuleActions.ts
+++ b/packages/solid-querybuilder/src/actions.ts
@@ -10,7 +10,7 @@ import type {
} from '@react-querybuilder/core';
import { isRuleGroup } from '@react-querybuilder/core';
import { snapshot } from 'solid-js';
-import type { QueryBuilderProps } from '../types/props.js';
+import type { QueryBuilderProps } from './types/props.js';
/**
* The `onAdd*`/`onMove*`/`onGroup*`/`onRemove` props return `false` to cancel an operation, a
diff --git a/packages/solid-querybuilder/src/components/QueryBuilder.tsx b/packages/solid-querybuilder/src/components/QueryBuilder.tsx
index e142cb3..6422cd7 100644
--- a/packages/solid-querybuilder/src/components/QueryBuilder.tsx
+++ b/packages/solid-querybuilder/src/components/QueryBuilder.tsx
@@ -9,7 +9,7 @@ import { rootPath } from '@react-querybuilder/core';
import type { JSX } from '@solidjs/web';
import { Dynamic } from '@solidjs/web';
import { QueryBuilderContext } from '../reactive/context.js';
-import { createQueryBuilderState } from '../reactive/createQueryBuilderState.js';
+import { createQueryBuilder } from '../reactive/createQueryBuilder.js';
import type { QueryBuilderProps } from '../types/props.js';
import { defaultControlElements } from './defaultControlElements.js';
@@ -17,7 +17,7 @@ import { defaultControlElements } from './defaultControlElements.js';
* The query builder.
*
* Port of React Query Builder's `QueryBuilder`/`QueryBuilderInternal`. All state lives in a
- * `QueryManager`; see `createQueryBuilderState`. The query can be driven three ways:
+ * `QueryManager`; see `createQueryBuilder`. The query can be driven three ways:
*
* - `query` + `onQueryChange` — controlled.
* - `defaultQuery` — uncontrolled.
@@ -38,7 +38,7 @@ export const QueryBuilder = <
// read through for the lifetime of the component.
const p = props as QueryBuilderProps;
- const state = createQueryBuilderState(p, { defaultControls: defaultControlElements });
+ const state = createQueryBuilder(p, { defaultControls: defaultControlElements });
return (
// Solid 2 removed `.Provider`. `state.context` is a getter object, so descendants read
diff --git a/packages/solid-querybuilder/src/components/Rule.tsx b/packages/solid-querybuilder/src/components/Rule.tsx
index f076c2d..d5bd42d 100644
--- a/packages/solid-querybuilder/src/components/Rule.tsx
+++ b/packages/solid-querybuilder/src/components/Rule.tsx
@@ -11,7 +11,7 @@ import type { RuleProps } from '../types/props.js';
*
* Port of React Query Builder's `Rule`, and, like it, a small wrapper: the controls themselves
* live in `RuleComponents`, and a rule whose field supports match modes renders `RuleSubQuery`
- * instead — which needs its own `createQueryBuilderState`, and therefore its own component.
+ * instead — which needs its own `createQueryBuilder`, and therefore its own component.
*
* Element order and conditional rendering are the contract: read React's `Rule.tsx` as the spec.
*/
diff --git a/packages/solid-querybuilder/src/index.tsx b/packages/solid-querybuilder/src/index.tsx
index f107e83..5d8a206 100644
--- a/packages/solid-querybuilder/src/index.tsx
+++ b/packages/solid-querybuilder/src/index.tsx
@@ -7,6 +7,7 @@
// This is a star export, so it loses every name the port declares explicitly below; that is the
// intended precedence (the port's `Schema`, `RuleProps`, etc. are deliberate deltas).
export * from '@react-querybuilder/core';
+export * from './actions.js';
export * from './components/index.js';
export { Label } from './internal/Label.jsx';
export * from './reactive/index.js';
diff --git a/packages/solid-querybuilder/src/internal/RuleSubQuery.tsx b/packages/solid-querybuilder/src/internal/RuleSubQuery.tsx
index 5cc646c..c66cc74 100644
--- a/packages/solid-querybuilder/src/internal/RuleSubQuery.tsx
+++ b/packages/solid-querybuilder/src/internal/RuleSubQuery.tsx
@@ -3,7 +3,7 @@ import { isRuleGroup, prepareOptionList, rootPath } from '@react-querybuilder/co
import type { JSX } from '@solidjs/web';
import { createMemo, merge, untrack } from 'solid-js';
import { defaultControlElements } from '../components/defaultControlElements.js';
-import { createQueryBuilderState } from '../reactive/createQueryBuilderState.js';
+import { createQueryBuilder } from '../reactive/createQueryBuilder.js';
import { createRuleGroupState } from '../reactive/createRuleGroupState.js';
import type { RuleState } from '../reactive/createRuleState.js';
import type { QueryBuilderProps, RuleGroupProps, RuleProps } from '../types/props.js';
@@ -16,7 +16,7 @@ const defaultSubproperties: FullField[] = [{ name: '', value: '', label: '' }];
* builder for the rule's value.
*
* Port of React Query Builder's `RuleComponentsWithSubQuery`. It exists as its own component for
- * the same reason React's does: the subquery needs its own `createQueryBuilderState`, which runs
+ * the same reason React's does: the subquery needs its own `createQueryBuilder`, which runs
* during component setup and therefore cannot live behind a `` inside `Rule`.
*
* It provides no new context, matching React and both prior ports — a replacement control
@@ -71,7 +71,7 @@ export const RuleSubQuery = (props: { ruleProps: RuleProps; parts: RuleState }):
},
}) as unknown as QueryBuilderProps;
- const subState = createQueryBuilderState(() => subProps as never, {
+ const subState = createQueryBuilder(() => subProps as never, {
defaultControls: defaultControlElements as never,
});
diff --git a/packages/solid-querybuilder/src/reactive/context-value.ts b/packages/solid-querybuilder/src/reactive/context-value.ts
new file mode 100644
index 0000000..4943da1
--- /dev/null
+++ b/packages/solid-querybuilder/src/reactive/context-value.ts
@@ -0,0 +1,76 @@
+import type { FullField, FullOperator, GetOptionIdentifierType } from '@react-querybuilder/core';
+import type { Accessor } from 'solid-js';
+import type { QueryBuilderContextProps } from '../types/props.js';
+import type { MergedQueryBuilderConfig } from './context.js';
+
+/**
+ * Builds the value handed to `` — the merged config, projected back
+ * into the context's own shape so nested builders inherit it.
+ *
+ * A getter object, not a memo returning a fresh object: a Solid context value is read once by
+ * descendants, so every field must be a getter or consumers freeze on the first value.
+ */
+export const createContextValue = (
+ config: Accessor>>
+): QueryBuilderContextProps> => ({
+ get controlElements() {
+ return config().controls;
+ },
+ get controlClassnames() {
+ return config().classNames;
+ },
+ get translations() {
+ return config().translations;
+ },
+ get debugMode() {
+ return config().debugMode;
+ },
+ get enableMountQueryChange() {
+ return config().enableMountQueryChange;
+ },
+ get showCombinatorsBetweenRules() {
+ return config().showCombinatorsBetweenRules;
+ },
+ get showNotToggle() {
+ return config().showNotToggle;
+ },
+ get showShiftActions() {
+ return config().showShiftActions;
+ },
+ get showUndoRedo() {
+ return config().showUndoRedo;
+ },
+ get showCloneButtons() {
+ return config().showCloneButtons;
+ },
+ get showLockButtons() {
+ return config().showLockButtons;
+ },
+ get showMuteButtons() {
+ return config().showMuteButtons;
+ },
+ get resetOnFieldChange() {
+ return config().resetOnFieldChange;
+ },
+ get resetOnOperatorChange() {
+ return config().resetOnOperatorChange;
+ },
+ get autoSelectField() {
+ return config().autoSelectField;
+ },
+ get autoSelectOperator() {
+ return config().autoSelectOperator;
+ },
+ get autoSelectValue() {
+ return config().autoSelectValue;
+ },
+ get addRuleToNewGroups() {
+ return config().addRuleToNewGroups;
+ },
+ get listsAsArrays() {
+ return config().listsAsArrays;
+ },
+ get suppressStandardClassnames() {
+ return config().suppressStandardClassnames;
+ },
+});
diff --git a/packages/solid-querybuilder/src/reactive/createQueryBuilderState.test.ts b/packages/solid-querybuilder/src/reactive/createQueryBuilder.test.ts
similarity index 79%
rename from packages/solid-querybuilder/src/reactive/createQueryBuilderState.test.ts
rename to packages/solid-querybuilder/src/reactive/createQueryBuilder.test.ts
index 016335d..fd3e4a6 100644
--- a/packages/solid-querybuilder/src/reactive/createQueryBuilderState.test.ts
+++ b/packages/solid-querybuilder/src/reactive/createQueryBuilder.test.ts
@@ -3,7 +3,7 @@ import { QueryManager, defaultCombinators } from '@react-querybuilder/core';
import { createSignal, createStore, flush, snapshot } from 'solid-js';
import { describe, expect, it, vi } from 'vitest';
import { setupInRoot } from '../../test/reactive-harness.js';
-import { createQueryBuilderState } from './createQueryBuilderState.js';
+import { createQueryBuilder } from './createQueryBuilder.js';
const fields = [
{ name: 'firstName', label: 'First Name' },
@@ -15,23 +15,23 @@ const simpleQuery: RuleGroupType = {
rules: [{ id: 'r1', field: 'firstName', operator: '=', value: 'Steve' }],
};
-describe('createQueryBuilderState', () => {
+describe('createQueryBuilder', () => {
it('seeds the manager from defaultQuery without making it undoable', () => {
- const state = setupInRoot(() => createQueryBuilderState({ fields, defaultQuery: simpleQuery }));
+ const state = setupInRoot(() => createQueryBuilder({ fields, defaultQuery: simpleQuery }));
expect(state.query.rules).toHaveLength(1);
expect(state.manager.canUndo()).toBe(false);
});
it('falls back to an empty group when neither query nor defaultQuery is given', () => {
- const state = setupInRoot(() => createQueryBuilderState({ fields }));
+ const state = setupInRoot(() => createQueryBuilder({ fields }));
expect(state.query.rules).toHaveLength(0);
expect(state.independentCombinators).toBe(false);
});
it('exposes option lists from the manager', () => {
- const state = setupInRoot(() => createQueryBuilderState({ fields }));
+ const state = setupInRoot(() => createQueryBuilder({ fields }));
expect(state.schema.fields.map(f => (f as { name: string }).name)).toEqual([
'firstName',
@@ -45,7 +45,7 @@ describe('createQueryBuilderState', () => {
it('mirrors the query in a store keyed by id, preserving identity across a commit', () => {
const state = setupInRoot(() =>
- createQueryBuilderState({
+ createQueryBuilder({
fields,
defaultQuery: {
combinator: 'and',
@@ -69,7 +69,7 @@ describe('createQueryBuilderState', () => {
it('fires onQueryChange exactly once per commit, before any reader observes it', () => {
const onQueryChange = vi.fn();
const state = setupInRoot(() =>
- createQueryBuilderState({
+ createQueryBuilder({
fields,
defaultQuery: simpleQuery,
enableMountQueryChange: false,
@@ -92,7 +92,7 @@ describe('createQueryBuilderState', () => {
it('fires onQueryChange once for a whole manager batch', () => {
const onQueryChange = vi.fn();
const state = setupInRoot(() =>
- createQueryBuilderState({
+ createQueryBuilder({
fields,
defaultQuery: simpleQuery,
enableMountQueryChange: false,
@@ -112,16 +112,14 @@ describe('createQueryBuilderState', () => {
it('fires onQueryChange on mount when enabled, and not when disabled', () => {
const onQueryChange = vi.fn();
- setupInRoot(() =>
- createQueryBuilderState({ fields, defaultQuery: simpleQuery, onQueryChange })
- );
+ setupInRoot(() => createQueryBuilder({ fields, defaultQuery: simpleQuery, onQueryChange }));
expect(onQueryChange).not.toHaveBeenCalled();
flush();
expect(onQueryChange).toHaveBeenCalledTimes(1);
const off = vi.fn();
setupInRoot(() =>
- createQueryBuilderState({
+ createQueryBuilder({
fields,
defaultQuery: simpleQuery,
enableMountQueryChange: false,
@@ -135,7 +133,7 @@ describe('createQueryBuilderState', () => {
it('pushes a new query prop into the manager (controlled mode)', () => {
const [query, setQuery] = createSignal(simpleQuery);
const state = setupInRoot(() =>
- createQueryBuilderState(() => ({ fields, query: query(), enableMountQueryChange: false }))
+ createQueryBuilder(() => ({ fields, query: query(), enableMountQueryChange: false }))
);
setQuery({
@@ -152,7 +150,7 @@ describe('createQueryBuilderState', () => {
const onQueryChange = vi.fn();
const [query, setQuery] = createSignal(simpleQuery);
const state = setupInRoot(() =>
- createQueryBuilderState(() => ({
+ createQueryBuilder(() => ({
fields,
query: query(),
enableMountQueryChange: false,
@@ -183,7 +181,7 @@ describe('createQueryBuilderState', () => {
});
const state = setupInRoot(() =>
- createQueryBuilderState(() => ({ fields, query: store.query, enableMountQueryChange: false }))
+ createQueryBuilder(() => ({ fields, query: store.query, enableMountQueryChange: false }))
);
expect(state.manager.getQuery().combinator).toBe('and');
@@ -208,7 +206,7 @@ describe('createQueryBuilderState', () => {
},
});
const state = setupInRoot(() =>
- createQueryBuilderState({ fields, defaultQuery: store.query, enableMountQueryChange: false })
+ createQueryBuilder({ fields, defaultQuery: store.query, enableMountQueryChange: false })
);
expect(state.manager.getQuery().combinator).toBe('or');
});
@@ -217,7 +215,7 @@ describe('createQueryBuilderState', () => {
const manager = new QueryManager(simpleQuery, { fields });
const [flds, setFlds] = createSignal(fields);
const state = setupInRoot(() =>
- createQueryBuilderState(() => ({ manager, fields: flds(), enableMountQueryChange: false }))
+ createQueryBuilder(() => ({ manager, fields: flds(), enableMountQueryChange: false }))
);
expect(state.manager).toBe(manager);
@@ -235,7 +233,7 @@ describe('createQueryBuilderState', () => {
it('reconfigures in place when a structural prop changes, keeping query and history', () => {
const [flds, setFlds] = createSignal(fields);
const state = setupInRoot(() =>
- createQueryBuilderState(() => ({
+ createQueryBuilder(() => ({
fields: flds(),
defaultQuery: simpleQuery,
enableMountQueryChange: false,
@@ -262,7 +260,7 @@ describe('createQueryBuilderState', () => {
it('does not reconfigure when a rebuilt props object is structurally identical', () => {
const [tick, setTick] = createSignal(0);
const state = setupInRoot(() =>
- createQueryBuilderState(() => {
+ createQueryBuilder(() => {
tick();
// A fresh object identity on every read, exactly as a consumer with inline literals —
// or the conformance harness — produces. The deep compare is what stops this from
@@ -288,7 +286,7 @@ describe('createQueryBuilderState', () => {
});
it('does not reconfigure on mount', () => {
- const state = setupInRoot(() => createQueryBuilderState({ fields, defaultQuery: simpleQuery }));
+ const state = setupInRoot(() => createQueryBuilder({ fields, defaultQuery: simpleQuery }));
const versionAtInit = state.manager.getConfigVersion();
flush();
expect(state.manager.getConfigVersion()).toBe(versionAtInit);
@@ -297,7 +295,7 @@ describe('createQueryBuilderState', () => {
it('refreshes option lists after a reconfigure without committing a query change', () => {
const onQueryChange = vi.fn();
const state = setupInRoot(() =>
- createQueryBuilderState({
+ createQueryBuilder({
fields,
defaultQuery: simpleQuery,
enableMountQueryChange: false,
@@ -314,7 +312,7 @@ describe('createQueryBuilderState', () => {
it('derives the wrapper class name from queryDisabled, not the root group', () => {
const disabledRoot = setupInRoot(() =>
- createQueryBuilderState({
+ createQueryBuilder({
fields,
defaultQuery: { ...simpleQuery, disabled: true },
enableMountQueryChange: false,
@@ -325,7 +323,7 @@ describe('createQueryBuilderState', () => {
expect(disabledRoot.wrapperClassName).not.toContain('queryBuilder-disabled');
const disabledAll = setupInRoot(() =>
- createQueryBuilderState({
+ createQueryBuilder({
fields,
defaultQuery: simpleQuery,
disabled: true,
@@ -338,7 +336,7 @@ describe('createQueryBuilderState', () => {
it('reports disabled paths and the inline-combinators attribute', () => {
const state = setupInRoot(() =>
- createQueryBuilderState({
+ createQueryBuilder({
fields,
defaultQuery: simpleQuery,
disabled: [[]],
@@ -352,7 +350,7 @@ describe('createQueryBuilderState', () => {
expect(state.dndEnabledAttr).toBe('disabled');
const between = setupInRoot(() =>
- createQueryBuilderState({
+ createQueryBuilder({
fields,
defaultQuery: simpleQuery,
showCombinatorsBetweenRules: true,
@@ -364,11 +362,7 @@ describe('createQueryBuilderState', () => {
it('detects independent combinators', () => {
const state = setupInRoot(() =>
- createQueryBuilderState({
- fields,
- defaultQuery: { rules: [] },
- enableMountQueryChange: false,
- })
+ createQueryBuilder({ fields, defaultQuery: { rules: [] }, enableMountQueryChange: false })
);
expect(state.independentCombinators).toBe(true);
expect(state.inlineCombinatorsAttr).toBe('enabled');
@@ -376,7 +370,7 @@ describe('createQueryBuilderState', () => {
it('routes a validation map through validationMap', () => {
const state = setupInRoot(() =>
- createQueryBuilderState({
+ createQueryBuilder({
fields,
defaultQuery: simpleQuery,
enableMountQueryChange: false,
@@ -391,7 +385,7 @@ describe('createQueryBuilderState', () => {
it('marks the wrapper invalid for a boolean validator result', () => {
const state = setupInRoot(() =>
- createQueryBuilderState({
+ createQueryBuilder({
fields,
defaultQuery: simpleQuery,
enableMountQueryChange: false,
@@ -405,14 +399,14 @@ describe('createQueryBuilderState', () => {
});
it('uses an empty validation map when there is no validator', () => {
- const state = setupInRoot(() => createQueryBuilderState({ fields, defaultQuery: simpleQuery }));
+ const state = setupInRoot(() => createQueryBuilder({ fields, defaultQuery: simpleQuery }));
expect(state.schema.validationMap).toEqual({});
});
it('forwards function props live, without a reconfigure', () => {
const [suffix, setSuffix] = createSignal('!');
const state = setupInRoot(() =>
- createQueryBuilderState(() => ({
+ createQueryBuilder(() => ({
fields,
enableMountQueryChange: false,
getDefaultValue: () => `default${suffix()}`,
@@ -431,7 +425,7 @@ describe('createQueryBuilderState', () => {
it('routes getDefaultField through the live-closure path', () => {
const state = setupInRoot(() =>
- createQueryBuilderState({
+ createQueryBuilder({
fields,
enableMountQueryChange: false,
getDefaultField: () => 'lastName',
@@ -440,9 +434,95 @@ describe('createQueryBuilderState', () => {
expect(state.manager.createRule().field).toBe('lastName');
});
+ it('picks up a function prop supplied after initialization, and its later removal', () => {
+ const [getDefaultValue, setGetDefaultValue] = createSignal<(() => string) | undefined>();
+ const state = setupInRoot(() =>
+ createQueryBuilder(() => ({
+ fields,
+ enableMountQueryChange: false,
+ getDefaultValue: getDefaultValue() as never,
+ }))
+ );
+
+ // Absent at init: the manager applies its own precedence rules.
+ expect(state.manager.createRule().value).toBe('');
+
+ setGetDefaultValue(() => () => 'added');
+ flush();
+ expect(state.manager.createRule().value).toBe('added');
+
+ // Replaced: the live closure keeps up with no reconfigure.
+ const versionAfterAdd = state.manager.getConfigVersion();
+ setGetDefaultValue(() => () => 'replaced');
+ flush();
+ expect(state.manager.createRule().value).toBe('replaced');
+ expect(state.manager.getConfigVersion()).toBe(versionAfterAdd);
+
+ // Removed: the wrapper is uninstalled rather than left calling `undefined`.
+ setGetDefaultValue(undefined);
+ flush();
+ expect(() => state.manager.createRule()).not.toThrow();
+ expect(state.manager.createRule().value).toBe('');
+ });
+
+ it.each([
+ ['getDefaultField', () => 'lastName'],
+ ['getDefaultOperator', () => '='],
+ ['getDefaultValue', () => 'v'],
+ ['getOperators', () => [{ name: '=', value: '=', label: '=' }]],
+ ['getValueEditorType', () => 'text'],
+ ['getValues', () => []],
+ ['getValueSources', () => ['value']],
+ ['getMatchModes', () => []],
+ ['getParameters', () => []],
+ ['getInputType', () => 'text'],
+ ['getSubQueryBuilderProps', () => ({ fields: [] })],
+ ['validator', () => true],
+ ['idGenerator', () => 'generated-id'],
+ ] as [string, () => unknown][])(
+ 'reconfigures when %s appears or disappears, but not when it is merely replaced',
+ (key, fn) => {
+ const [present, setPresent] = createSignal(false);
+ // ⚠️ `createSignal` treats a bare function as a lazy initializer; wrap it.
+ const [identity, setIdentity] = createSignal<() => unknown>(() => fn);
+ const state = setupInRoot(() =>
+ createQueryBuilder(
+ () =>
+ ({
+ fields,
+ enableMountQueryChange: false,
+ ...(present() ? { [key]: identity() } : {}),
+ }) as never
+ )
+ );
+
+ // Effects created inside a root are queued: the deferred reconfigure effect must take its
+ // first (dependency-registering) run before the test drives anything.
+ flush();
+ const initial = state.manager.getConfigVersion();
+
+ setPresent(true);
+ flush();
+ const afterAdd = state.manager.getConfigVersion();
+ expect(afterAdd).toBeGreaterThan(initial);
+
+ setIdentity(
+ () =>
+ (...args: unknown[]) =>
+ fn(...(args as []))
+ );
+ flush();
+ expect(state.manager.getConfigVersion()).toBe(afterAdd);
+
+ setPresent(false);
+ flush();
+ expect(state.manager.getConfigVersion()).toBeGreaterThan(afterAdd);
+ }
+ );
+
it('exposes schema helpers derived from the manager', () => {
const state = setupInRoot(() =>
- createQueryBuilderState({
+ createQueryBuilder({
fields,
enableMountQueryChange: false,
getValueEditorSeparator: () => ' - ',
@@ -495,9 +575,7 @@ describe('createQueryBuilderState', () => {
});
it('falls back to defaults for absent schema function props', () => {
- const state = setupInRoot(() =>
- createQueryBuilderState({ fields, enableMountQueryChange: false })
- );
+ const state = setupInRoot(() => createQueryBuilder({ fields, enableMountQueryChange: false }));
const { schema } = state;
expect(
schema.getValueEditorSeparator('firstName', '=', { fieldData: schema.fieldMap.firstName! })
@@ -518,12 +596,12 @@ describe('createQueryBuilderState', () => {
it('honors maxLevels only when positive', () => {
const capped = setupInRoot(() =>
- createQueryBuilderState({ fields, maxLevels: 2, enableMountQueryChange: false })
+ createQueryBuilder({ fields, maxLevels: 2, enableMountQueryChange: false })
);
expect(capped.schema.maxLevels).toBe(2);
const ignored = setupInRoot(() =>
- createQueryBuilderState({ fields, maxLevels: 0, enableMountQueryChange: false })
+ createQueryBuilder({ fields, maxLevels: 0, enableMountQueryChange: false })
);
expect(ignored.schema.maxLevels).toBe(Infinity);
});
@@ -531,7 +609,7 @@ describe('createQueryBuilderState', () => {
it('exposes a context value that tracks configuration changes', () => {
const [notToggle, setNotToggle] = createSignal(false);
const state = setupInRoot(() =>
- createQueryBuilderState(() => ({
+ createQueryBuilder(() => ({
fields,
showNotToggle: notToggle(),
enableMountQueryChange: false,
@@ -555,7 +633,7 @@ describe('createQueryBuilderState', () => {
// These reach the manager as options rather than being reimplemented here; the value editor
// reset effect handles only the editor-shape half.
const state = setupInRoot(() =>
- createQueryBuilderState({
+ createQueryBuilder({
fields,
defaultQuery: simpleQuery,
enableMountQueryChange: false,
@@ -575,7 +653,7 @@ describe('createQueryBuilderState', () => {
it('exposes every schema and context member as a live getter', () => {
const state = setupInRoot(() =>
- createQueryBuilderState({ fields, defaultQuery: simpleQuery, enableMountQueryChange: false })
+ createQueryBuilder({ fields, defaultQuery: simpleQuery, enableMountQueryChange: false })
);
// Reading through `Object.keys` exercises every getter: a member accidentally written as a
@@ -598,7 +676,7 @@ describe('createQueryBuilderState', () => {
it('accepts a non-function getDefaultOperator', () => {
const state = setupInRoot(() =>
- createQueryBuilderState({ fields, enableMountQueryChange: false, getDefaultOperator: '>' })
+ createQueryBuilder({ fields, enableMountQueryChange: false, getDefaultOperator: '>' })
);
expect(state.manager.createRule().operator).toBe('>');
});
@@ -606,7 +684,7 @@ describe('createQueryBuilderState', () => {
it('ignores a query prop that is structurally unchanged', () => {
const [query, setQuery] = createSignal(simpleQuery);
const state = setupInRoot(() =>
- createQueryBuilderState(() => ({ fields, query: query(), enableMountQueryChange: false }))
+ createQueryBuilder(() => ({ fields, query: query(), enableMountQueryChange: false }))
);
const before = state.manager.getQuery();
@@ -623,11 +701,7 @@ describe('createQueryBuilderState', () => {
// identical, both arrays, nor same-prototype objects.
const [baseField, setBaseField] = createSignal | undefined>(undefined);
const state = setupInRoot(() =>
- createQueryBuilderState(() => ({
- fields,
- baseField: baseField(),
- enableMountQueryChange: false,
- }))
+ createQueryBuilder(() => ({ fields, baseField: baseField(), enableMountQueryChange: false }))
);
const versionBefore = state.manager.getConfigVersion();
@@ -638,7 +712,7 @@ describe('createQueryBuilderState', () => {
});
it('is snapshot-able for the manager hand-off', () => {
- const state = setupInRoot(() => createQueryBuilderState({ fields, defaultQuery: simpleQuery }));
+ const state = setupInRoot(() => createQueryBuilder({ fields, defaultQuery: simpleQuery }));
expect(snapshot(state.manager)).toBe(state.manager);
});
});
diff --git a/packages/solid-querybuilder/src/reactive/createQueryBuilder.ts b/packages/solid-querybuilder/src/reactive/createQueryBuilder.ts
new file mode 100644
index 0000000..7cb48b5
--- /dev/null
+++ b/packages/solid-querybuilder/src/reactive/createQueryBuilder.ts
@@ -0,0 +1,224 @@
+import type {
+ Classnames,
+ FullCombinator,
+ FullField,
+ FullOperator,
+ GetOptionIdentifierType,
+ QueryActions,
+ QueryManager,
+ RuleGroupTypeAny,
+ ValidationMap,
+} from '@react-querybuilder/core';
+import { deriveQueryBuilderClassNames, isRuleGroupTypeIC } from '@react-querybuilder/core';
+import type { Accessor, Store } from 'solid-js';
+import { createMemo, untrack } from 'solid-js';
+import { createRuleActions } from '../actions.js';
+import type { Controls } from '../types/controls.js';
+import type { QueryBuilderContextProps, QueryBuilderProps } from '../types/props.js';
+import type { Schema } from '../types/schema.js';
+import type { TranslationsFull } from '../types/translations.js';
+import { createContextValue } from './context-value.js';
+import type { MergedQueryBuilderConfig } from './context.js';
+import { emptyValidationMap, mergeQueryBuilderConfig, useQueryBuilderConfig } from './context.js';
+import type { QueryTree } from './manager-bridge.js';
+import { createManagerBridge } from './manager-bridge.js';
+import { createManagerOptions } from './manager-options.js';
+import { createSchema } from './schema.js';
+
+/**
+ * Everything a `QueryBuilder` component needs to render, derived from its props and driven by a
+ * {@link QueryManager}.
+ *
+ * Data members are **getters**, so the whole object can be read once by a Solid context or
+ * passed as a prop without any consumer freezing on the first value.
+ */
+export interface QueryBuilderState {
+ /** The current query, by identity. Reassigned whenever the manager notifies. */
+ readonly query: RuleGroupTypeAny;
+ /**
+ * The store mirror of the query, reconciled by `id`. Internal: it is the read path for
+ * components, has no setter, and is not part of the public API.
+ *
+ * @internal
+ */
+ readonly tree: Store;
+ /** The root group, read from the store mirror so `` sees stable identities. */
+ readonly rootGroup: RuleGroupTypeAny;
+ readonly manager: QueryManager;
+ readonly schema: Schema;
+ readonly actions: QueryActions;
+ readonly translations: TranslationsFull;
+ readonly controls: Controls;
+ readonly classNames: Classnames;
+ readonly wrapperClassName: string;
+ readonly dndEnabledAttr: string;
+ readonly inlineCombinatorsAttr: string;
+ readonly rootGroupDisabled: boolean;
+ readonly queryDisabled: boolean;
+ readonly independentCombinators: boolean;
+ /** The config to hand to ``. */
+ readonly context: QueryBuilderContextProps;
+}
+
+/**
+ * Options for {@link createQueryBuilder} that cannot be expressed as `QueryBuilderProps`.
+ */
+export interface CreateQueryBuilderOptions {
+ /**
+ * Default components for every control, applied last in the `controlElements` merge. Provided
+ * by the component layer so this module stays free of component imports.
+ */
+ defaultControls?: Partial>;
+ /**
+ * Inherited context. Defaults to {@link useQueryBuilderConfig}.
+ */
+ context?: QueryBuilderContextProps;
+}
+
+/**
+ * The headless query builder primitive: everything the `QueryBuilder` component renders from,
+ * with no rendering of its own. Use it directly to drive a custom UI, or let ``
+ * call it for you.
+ *
+ * The query lives in a {@link QueryManager}. Pass an externally created manager as the `manager`
+ * prop to drive the query from outside the component tree.
+ *
+ * Structural manager options (`fields`, `operators`, `combinators`, and the boolean flags) are
+ * applied in place with `QueryManager#reconfigure` whenever the corresponding props change, so
+ * the query, the undo/redo history, and every subscriber survive a config change. Function props
+ * (`getOperators`, `getDefaultValue`, etc.) are forwarded through closures, so those stay live
+ * without any reconfiguration at all. An externally supplied `manager` prop is never
+ * reconfigured.
+ *
+ * @param props - The `QueryBuilder` props. Solid props are already reactive getters, so they can
+ * be passed directly; an accessor is also accepted, for a synthesized props object.
+ */
+export const createQueryBuilder = <
+ F extends FullField = FullField,
+ O extends FullOperator = FullOperator,
+>(
+ props:
+ | QueryBuilderProps
+ | Accessor>,
+ options: CreateQueryBuilderOptions> = {}
+): QueryBuilderState> => {
+ type OName = GetOptionIdentifierType;
+
+ const getProps: Accessor> =
+ typeof props === 'function' ? props : () => props;
+
+ const inheritedContext = options.context ?? useQueryBuilderConfig();
+
+ const config = createMemo(
+ () =>
+ mergeQueryBuilderConfig({
+ props: getProps(),
+ context: inheritedContext,
+ defaultControls: options.defaultControls,
+ }) satisfies MergedQueryBuilderConfig
+ );
+
+ // ⚠️ `untrack` wraps the *property* reads, not just the call that returns the props object.
+ // Solid props are getters, so reading one outside a tracking scope is what raises
+ // `[STRICT_READ_UNTRACKED]` — one warning per setup read, on every mount. These reads are
+ // deliberately one-time (this is initialization), so declaring that is the fix.
+ const initialProps = untrack(getProps);
+
+ const { maxLevels, disabledPaths, buildManagerOptions, structuralOptions } = createManagerOptions<
+ F,
+ O
+ >(getProps, config);
+
+ const { manager, query, tree, configVersion } = createManagerBridge({
+ getProps,
+ config,
+ initialProps,
+ buildManagerOptions,
+ structuralOptions,
+ });
+
+ const actions = createRuleActions(getProps, manager);
+
+ // Plain closures: each returns a primitive over reads that allocate nothing.
+ const independentCombinators = (): boolean => isRuleGroupTypeIC(query());
+ const queryDisabled = (): boolean => getProps().disabled === true;
+ const rootGroupDisabled = (): boolean =>
+ !!query().disabled || disabledPaths().some(p => p.length === 0);
+
+ const validationResult = createMemo(() => {
+ const { validator } = getProps();
+ return typeof validator === 'function' ? validator(query()) : emptyValidationMap;
+ });
+ // `validationResult` stays memoized — it runs a user callback. This only reshapes its result.
+ const validationMap = (): ValidationMap => {
+ const result = validationResult();
+ return typeof result === 'boolean' ? emptyValidationMap : result;
+ };
+
+ // A disabled root *group* does not disable the wrapper, so this reads `queryDisabled` rather
+ // than `rootGroupDisabled`.
+ const wrapperClassName = createMemo(() =>
+ deriveQueryBuilderClassNames({
+ classNames: config().classNames,
+ suppressStandardClassnames: config().suppressStandardClassnames,
+ disabled: queryDisabled(),
+ validationResult: validationResult(),
+ })
+ );
+
+ const inlineCombinatorsAttr = (): string =>
+ independentCombinators() || config().showCombinatorsBetweenRules ? 'enabled' : 'disabled';
+
+ const schema = createSchema({
+ getProps,
+ config,
+ manager,
+ configVersion,
+ maxLevels,
+ disabledPaths,
+ independentCombinators,
+ validationMap,
+ });
+
+ const contextValue = createContextValue(config);
+
+ return {
+ get query() {
+ return query();
+ },
+ tree,
+ get rootGroup() {
+ return tree.root;
+ },
+ manager,
+ schema,
+ actions,
+ get translations() {
+ return config().translations;
+ },
+ get controls() {
+ return config().controls;
+ },
+ get classNames() {
+ return config().classNames;
+ },
+ get wrapperClassName() {
+ return wrapperClassName();
+ },
+ // Drag-and-drop is a non-goal, but the attribute must be present for DOM parity.
+ dndEnabledAttr: 'disabled',
+ get inlineCombinatorsAttr() {
+ return inlineCombinatorsAttr();
+ },
+ get rootGroupDisabled() {
+ return rootGroupDisabled();
+ },
+ get queryDisabled() {
+ return queryDisabled();
+ },
+ get independentCombinators() {
+ return independentCombinators();
+ },
+ context: contextValue,
+ };
+};
diff --git a/packages/solid-querybuilder/src/reactive/createQueryBuilderState.ts b/packages/solid-querybuilder/src/reactive/createQueryBuilderState.ts
deleted file mode 100644
index 6b1a5dc..0000000
--- a/packages/solid-querybuilder/src/reactive/createQueryBuilderState.ts
+++ /dev/null
@@ -1,770 +0,0 @@
-import type {
- Classnames,
- FullCombinator,
- FullField,
- FullOperator,
- FullOption,
- FullOptionList,
- FullOptionRecord,
- GetOptionIdentifierType,
- InputType,
- MatchModeOptions,
- Option,
- Path,
- QueryActions,
- QueryManagerOptions,
- RuleGroupTypeAny,
- RuleType,
- ValidationMap,
- ValueEditorType,
- ValueSourceFullOptions,
-} from '@react-querybuilder/core';
-import {
- QueryManager,
- deriveQueryBuilderClassNames,
- generateAccessibleDescription,
- getRuleDefaultValue,
- isRuleGroupTypeIC,
- prepareOptionList,
- resolveCandidateQuery,
- resolveDefaultOperator,
- toFlatOptionArray,
- unchangedSignature,
-} from '@react-querybuilder/core';
-import type { Accessor, Store } from 'solid-js';
-import {
- createEffect,
- createMemo,
- createProjection,
- createSignal,
- onCleanup,
- snapshot,
- untrack,
-} from 'solid-js';
-import type { Controls } from '../types/controls.js';
-import type { QueryBuilderContextProps, QueryBuilderProps } from '../types/props.js';
-import type { Schema } from '../types/schema.js';
-import type { LabelNode, TranslationsFull } from '../types/translations.js';
-import type { MergedQueryBuilderConfig } from './context.js';
-import { emptyValidationMap, mergeQueryBuilderConfig, useQueryBuilderConfig } from './context.js';
-import { createRuleActions } from './createRuleActions.js';
-
-const emptyDisabledPaths: Path[] = [];
-const defaultGetValueEditorSeparator = (): LabelNode => '';
-const defaultGetRuleOrGroupClassname = (): string => '';
-
-/** The store mirror's shape. The query is nested so the mirror itself is a stable object. */
-interface QueryTree {
- root: RuleGroupTypeAny;
-}
-
-/**
- * Structural equality for manager option values, used to decide whether a prop change is worth a
- * `reconfigure`. Arrays and plain objects are compared by value; everything else — functions
- * included — by identity, which is what makes a config object rebuilt on every render compare
- * equal as long as its data did not change.
- *
- * This is load-bearing, not an optimization: any caller that rebuilds its props object per render
- * (the conformance harness does, and so does every consumer passing object literals) hands the
- * effect a fresh identity for every structural read, so an identity-only gate would make the
- * effect self-perpetuating. A split effect narrows the *tracked set*, but its compute phase still
- * reads a fresh identity every run, so it does not solve this by itself.
- */
-const valuesEqual = (a: unknown, b: unknown): boolean => {
- if (Object.is(a, b)) return true;
- if (Array.isArray(a) || Array.isArray(b)) {
- return (
- Array.isArray(a) &&
- Array.isArray(b) &&
- a.length === b.length &&
- a.every((v, i) => valuesEqual(v, b[i]))
- );
- }
- if (
- typeof a !== 'object' ||
- typeof b !== 'object' ||
- a === null ||
- b === null ||
- Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)
- ) {
- return false;
- }
- const aKeys = Object.keys(a);
- return (
- aKeys.length === Object.keys(b).length &&
- aKeys.every(k =>
- valuesEqual((a as Record)[k], (b as Record)[k])
- )
- );
-};
-
-/**
- * Everything a `QueryBuilder` component needs to render, derived from its props and driven by a
- * {@link QueryManager}.
- *
- * Data members are **getters**, so the whole object can be read once by a Solid context or
- * passed as a prop without any consumer freezing on the first value.
- */
-export interface QueryBuilderState {
- /** The current query, by identity. Reassigned whenever the manager notifies. */
- readonly query: RuleGroupTypeAny;
- /**
- * The store mirror of the query, reconciled by `id`. Internal: it is the read path for
- * components, has no setter, and is not part of the public API.
- *
- * @internal
- */
- readonly tree: Store;
- /** The root group, read from the store mirror so `` sees stable identities. */
- readonly rootGroup: RuleGroupTypeAny;
- readonly manager: QueryManager;
- readonly schema: Schema;
- readonly actions: QueryActions;
- readonly translations: TranslationsFull;
- readonly controls: Controls;
- readonly classNames: Classnames;
- readonly wrapperClassName: string;
- readonly dndEnabledAttr: string;
- readonly inlineCombinatorsAttr: string;
- readonly rootGroupDisabled: boolean;
- readonly queryDisabled: boolean;
- readonly independentCombinators: boolean;
- /** The config to hand to ``. */
- readonly context: QueryBuilderContextProps;
-}
-
-/**
- * Options for {@link createQueryBuilderState} that cannot be expressed as `QueryBuilderProps`.
- */
-export interface CreateQueryBuilderStateOptions {
- /**
- * Default components for every control, applied last in the `controlElements` merge. Provided
- * by the component layer so this module stays free of component imports.
- */
- defaultControls?: Partial>;
- /**
- * Inherited context. Defaults to {@link useQueryBuilderConfig}.
- */
- context?: QueryBuilderContextProps;
-}
-
-/**
- * Builds the reactive state for a query builder.
- *
- * The query lives in a {@link QueryManager}. Pass an externally created manager as the `manager`
- * prop to drive the query from outside the component tree.
- *
- * Structural manager options (`fields`, `operators`, `combinators`, and the boolean flags) are
- * applied in place with `QueryManager#reconfigure` whenever the corresponding props change, so
- * the query, the undo/redo history, and every subscriber survive a config change. Function props
- * (`getOperators`, `getDefaultValue`, etc.) are forwarded through closures, so those stay live
- * without any reconfiguration at all. An externally supplied `manager` prop is never
- * reconfigured.
- *
- * @param props - The `QueryBuilder` props. Solid props are already reactive getters, so they can
- * be passed directly; an accessor is also accepted, for a synthesized props object.
- */
-export const createQueryBuilderState = <
- F extends FullField = FullField,
- O extends FullOperator = FullOperator,
->(
- props:
- | QueryBuilderProps
- | Accessor>,
- options: CreateQueryBuilderStateOptions> = {}
-): QueryBuilderState> => {
- type OName = GetOptionIdentifierType;
- type FName = GetOptionIdentifierType;
-
- const getProps: Accessor> =
- typeof props === 'function' ? props : () => props;
-
- const inheritedContext = options.context ?? useQueryBuilderConfig();
-
- const config = createMemo(
- () =>
- mergeQueryBuilderConfig({
- props: getProps(),
- context: inheritedContext,
- defaultControls: options.defaultControls,
- }) satisfies MergedQueryBuilderConfig
- );
-
- // #region Manager
- // ⚠️ `untrack` wraps the *property* reads, not just the call that returns the props object.
- // Solid props are getters, so reading one outside a tracking scope is what raises
- // `[STRICT_READ_UNTRACKED]` — one warning per setup read, on every mount. These reads are
- // deliberately one-time (this is initialization), so declaring that is the fix.
- const initialProps = untrack(getProps);
- const initialManager = untrack(() => snapshot(initialProps.manager)) as
- | QueryManager
- | undefined;
-
- // A plain closure: it returns a primitive, so there is no identity to stabilize.
- const maxLevels = (): number =>
- (getProps().maxLevels ?? 0) > 0 ? Number(getProps().maxLevels) : Infinity;
- // Memoized, unlike `maxLevels`: it returns an array, and `structuralOptions` compares it by
- // value. Identity stability is the point here, not the cost of the derivation.
- const disabledPaths = createMemo(() =>
- Array.isArray(getProps().disabled) ? (getProps().disabled as Path[]) : emptyDisabledPaths
- );
-
- /**
- * Forwards a function prop to the manager through a closure, so later changes to the prop take
- * effect without rebuilding the manager. Returns `undefined` when the prop is absent at
- * initialization, leaving the manager to apply its own precedence rules instead of treating
- * the option as configured.
- */
- const live = (
- pick: (props: QueryBuilderProps) => unknown
- ): ((...args: A) => R) | undefined =>
- typeof pick(initialProps) === 'function'
- ? (...args: A) => (pick(getProps()) as (...args: A) => R)(...args)
- : undefined;
-
- /**
- * Builds the full option set for the manager. Used both for construction and for every
- * `reconfigure` call, so the two cannot drift — the same discipline the manager's own
- * `#applyOptions` enforces upstream.
- *
- * `snapshot` throughout: the manager deep-freezes what it is given, which throws on a store
- * proxy, and a consumer holding `fields` in a store is an ordinary case.
- */
- const buildManagerOptions = (): QueryManagerOptions => {
- const p = getProps();
- const c = config();
- return {
- fields: snapshot(p.fields),
- operators: snapshot(p.operators),
- combinators: snapshot(p.combinators),
- baseField: snapshot(p.baseField),
- baseOperator: snapshot(p.baseOperator),
- baseCombinator: snapshot(p.baseCombinator),
- autoSelectField: c.autoSelectField,
- autoSelectOperator: c.autoSelectOperator,
- autoSelectValue: c.autoSelectValue,
- // The manager prepares every option list, including the placeholder options, so it needs
- // the merged translations. Everything rendered here reads those lists back off the
- // manager; `prepareOptionList` is deliberately not reimplemented locally.
- translations: snapshot(c.translations),
- addRuleToNewGroups: c.addRuleToNewGroups,
- listsAsArrays: c.listsAsArrays,
- resetOnFieldChange: c.resetOnFieldChange,
- resetOnOperatorChange: c.resetOnOperatorChange,
- maxLevels: maxLevels(),
- disabledPaths: snapshot(disabledPaths()),
- queryDisabled: p.disabled === true,
- history: true,
- validator: p.validator,
- idGenerator: p.idGenerator,
- // Forwarded so that changes to these props take effect without a reconfigure.
- getDefaultField: (typeof initialProps.getDefaultField === 'function'
- ? live(pp => pp.getDefaultField)
- : p.getDefaultField) as never,
- getDefaultOperator: (typeof initialProps.getDefaultOperator === 'function'
- ? live(pp => pp.getDefaultOperator)
- : p.getDefaultOperator) as never,
- getDefaultValue: live(pp => pp.getDefaultValue) as never,
- getOperators: live(pp => pp.getOperators) as never,
- getValueEditorType: live(pp => pp.getValueEditorType) as never,
- getValues: live(pp => pp.getValues) as never,
- getValueSources: live(pp => pp.getValueSources) as never,
- getMatchModes: live(pp => pp.getMatchModes) as never,
- getParameters: live(pp => pp.getParameters) as never,
- getInputType: live(pp => pp.getInputType) as never,
- getSubQueryBuilderProps: live(pp => pp.getSubQueryBuilderProps) as never,
- };
- };
-
- /**
- * The subset of the manager's options that cannot be forwarded through a closure, and so has to
- * be re-applied with `reconfigure` when it changes. Doubles as the reconfigure effect's compute
- * phase, i.e. its dependency declaration. Function props are deliberately excluded — they reach
- * the manager through `live()` closures and stay current on their own, and comparing them would
- * defeat the equality gate for anyone passing inline arrows.
- */
- const structuralOptions = () => {
- const p = getProps();
- const c = config();
- return {
- fields: p.fields,
- operators: p.operators,
- combinators: p.combinators,
- baseField: p.baseField,
- baseOperator: p.baseOperator,
- baseCombinator: p.baseCombinator,
- autoSelectField: c.autoSelectField,
- autoSelectOperator: c.autoSelectOperator,
- autoSelectValue: c.autoSelectValue,
- translations: c.translations,
- addRuleToNewGroups: c.addRuleToNewGroups,
- listsAsArrays: c.listsAsArrays,
- resetOnFieldChange: c.resetOnFieldChange,
- resetOnOperatorChange: c.resetOnOperatorChange,
- maxLevels: maxLevels(),
- disabledPaths: disabledPaths(),
- queryDisabled: p.disabled === true,
- };
- };
-
- const manager =
- initialManager ??
- new QueryManager(
- undefined,
- untrack(buildManagerOptions)
- );
-
- if (!initialManager) {
- const candidate = untrack(() =>
- resolveCandidateQuery(
- {
- // `snapshot` throughout: the manager deep-freezes what it is given, which throws on a
- // store proxy. A parent holding the query in its own `createStore` is the common case.
- query: snapshot(initialProps.query),
- defaultQuery: snapshot(initialProps.defaultQuery),
- fallbackQuery: manager.getQuery(),
- },
- { idGenerator: initialProps.idGenerator }
- )
- );
- if (!Object.is(candidate, manager.getQuery())) {
- manager.setQuery(snapshot(candidate));
- // Seeding the query is not a user action, so it must not be undoable. Without this,
- // `UndoRedoActions` would render an enabled "undo" button on first paint.
- manager.clearHistory();
- }
- }
- // #endregion
-
- // #region Option lists
- // Read off the manager, which prepares them from the same options — including `translations`,
- // which supplies the placeholder options when `autoSelect*` is `false`. Keyed on
- // `configVersion` so that a reconfigure (see below) refreshes them.
- const [configVersion, setConfigVersion] = createSignal(manager.getConfigVersion());
-
- const fields = createMemo(() => {
- configVersion();
- return manager.getFields();
- });
- const combinators = createMemo(() => {
- configVersion();
- return manager.getCombinators();
- });
- const fieldMap = createMemo(
- () =>
- Object.fromEntries(
- toFlatOptionArray(fields() as FullOptionList).map(f => [f.value ?? f.name, f])
- ) as Partial>
- );
- // #endregion
-
- // #region Resolvers
- const getParameters = (
- field?: string,
- operator?: string,
- misc?: { fieldData: F }
- ): FullOptionList =>
- prepareOptionList({
- optionList: getProps().getParameters?.(field as FName, operator as OName, misc) ?? [],
- autoSelectOption: true,
- }).optionList;
-
- const getOperators = (field: string): FullOptionList =>
- manager.getOperators(field) as FullOptionList;
-
- const getValueEditorType = (field: string, operator: string): ValueEditorType =>
- manager.getValueEditorType(field, operator);
-
- const getValues = (field: string, operator: string): FullOptionList