From 35797126d9e0003632c1ecdf4ba87c9e12b9f6a6 Mon Sep 17 00:00:00 2001 From: Jake Boone Date: Thu, 13 Aug 2026 23:49:32 -0700 Subject: [PATCH 1/3] Split createQueryBuilderState.ts --- AGENTS.md | 2 +- CHANGELOG.md | 13 + README.md | 33 + docs/differences-from-react-querybuilder.md | 5 +- ...ateRuleActions.test.ts => actions.test.ts} | 6 +- .../createRuleActions.ts => actions.ts} | 2 +- .../src/components/QueryBuilder.tsx | 6 +- .../src/components/Rule.tsx | 2 +- packages/solid-querybuilder/src/index.tsx | 1 + .../src/internal/RuleSubQuery.tsx | 6 +- .../src/reactive/context-value.ts | 76 ++ ...ate.test.ts => createQueryBuilder.test.ts} | 92 +-- .../src/reactive/createQueryBuilder.ts | 224 +++++ .../src/reactive/createQueryBuilderState.ts | 770 ------------------ .../src/reactive/createRuleGroupState.test.ts | 4 +- .../src/reactive/createRuleState.test.ts | 4 +- .../solid-querybuilder/src/reactive/index.ts | 3 +- .../src/reactive/manager-bridge.ts | 205 +++++ .../src/reactive/manager-options.ts | 191 +++++ .../solid-querybuilder/src/reactive/schema.ts | 253 ++++++ .../test/conformance/actions.solid.test.ts | 12 +- .../test/conformance/actions.test.ts | 2 +- .../test/conformance/replay.ts | 2 +- packages/solid-querybuilder/test/support.ts | 2 +- 24 files changed, 1065 insertions(+), 851 deletions(-) rename packages/solid-querybuilder/src/{reactive/createRuleActions.test.ts => actions.test.ts} (98%) rename packages/solid-querybuilder/src/{reactive/createRuleActions.ts => actions.ts} (98%) create mode 100644 packages/solid-querybuilder/src/reactive/context-value.ts rename packages/solid-querybuilder/src/reactive/{createQueryBuilderState.test.ts => createQueryBuilder.test.ts} (88%) create mode 100644 packages/solid-querybuilder/src/reactive/createQueryBuilder.ts delete mode 100644 packages/solid-querybuilder/src/reactive/createQueryBuilderState.ts create mode 100644 packages/solid-querybuilder/src/reactive/manager-bridge.ts create mode 100644 packages/solid-querybuilder/src/reactive/manager-options.ts create mode 100644 packages/solid-querybuilder/src/reactive/schema.ts 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..830e7d9 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,39 @@ 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 { createQueryBuilder } from 'solid-querybuilder'; + +function CustomBuilder(props) { + const state = createQueryBuilder(props); + + return ( + + ); +} +``` + +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 88% rename from packages/solid-querybuilder/src/reactive/createQueryBuilderState.test.ts rename to packages/solid-querybuilder/src/reactive/createQueryBuilder.test.ts index 016335d..69c8888 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', @@ -442,7 +436,7 @@ describe('createQueryBuilderState', () => { it('exposes schema helpers derived from the manager', () => { const state = setupInRoot(() => - createQueryBuilderState({ + createQueryBuilder({ fields, enableMountQueryChange: false, getValueEditorSeparator: () => ' - ', @@ -495,9 +489,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 +510,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 +523,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 +547,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 +567,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 +590,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 +598,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 +615,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 +626,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..1751337 --- /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, initialProps); + + 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( + 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 = (): Record => { + 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, + }; + }; + + return { maxLevels, disabledPaths, buildManagerOptions, structuralOptions }; +}; diff --git a/packages/solid-querybuilder/src/reactive/schema.ts b/packages/solid-querybuilder/src/reactive/schema.ts new file mode 100644 index 0000000..eed759e --- /dev/null +++ b/packages/solid-querybuilder/src/reactive/schema.ts @@ -0,0 +1,253 @@ +import type { + FullCombinator, + FullField, + FullOperator, + FullOption, + FullOptionList, + FullOptionRecord, + GetOptionIdentifierType, + InputType, + MatchModeOptions, + Option, + Path, + QueryManager, + RuleGroupTypeAny, + RuleType, + ValidationMap, + ValueEditorType, + ValueSourceFullOptions, +} from '@react-querybuilder/core'; +import { + generateAccessibleDescription, + getRuleDefaultValue, + prepareOptionList, + resolveDefaultOperator, + toFlatOptionArray, +} from '@react-querybuilder/core'; +import type { Accessor } from 'solid-js'; +import { createMemo } from 'solid-js'; +import type { QueryBuilderProps } from '../types/props.js'; +import type { Schema } from '../types/schema.js'; +import type { LabelNode } from '../types/translations.js'; +import type { MergedQueryBuilderConfig } from './context.js'; + +const defaultGetValueEditorSeparator = (): LabelNode => ''; +const defaultGetRuleOrGroupClassname = (): string => ''; + +/** Everything {@link createSchema} needs from the rest of the state. */ +export interface CreateSchemaOptions { + getProps: Accessor>; + config: Accessor>>; + manager: QueryManager; + /** Bumped on every manager notification; keys the option lists so a reconfigure refreshes them. */ + configVersion: Accessor; + maxLevels: () => number; + disabledPaths: Accessor; + independentCombinators: () => boolean; + validationMap: () => ValidationMap; +} + +/** + * Builds the {@link Schema} every subcomponent renders from, along with the option lists and + * resolvers it exposes. + * + * The result is 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 createSchema = ({ + getProps, + config, + manager, + configVersion, + maxLevels, + disabledPaths, + independentCombinators, + validationMap, +}: CreateSchemaOptions): Schema> => { + type OName = GetOptionIdentifierType; + type FName = GetOptionIdentifierType; + + // Option lists are 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 refreshes them. + 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> + ); + + 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(key: ForwardedFnProp): ((...args: A) => R) | undefined => { + if (typeof getProps()[key] !== 'function') return undefined; + return (...args: A) => { + const fn = getProps()[key] as unknown; + return typeof fn === 'function' ? (fn as (...args: A) => R)(...args) : (undefined as R); + }; + }; /** * Builds the full option set for the manager. Used both for construction and for every @@ -137,22 +161,20 @@ export const createManagerOptions = 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, + // Forwarded so that changes to these props take effect without a reconfigure. `live` returns + // `undefined` for a non-function prop, so the two that also accept a plain name fall back + // to the raw value. + getDefaultField: (live('getDefaultField') ?? p.getDefaultField) as never, + getDefaultOperator: (live('getDefaultOperator') ?? p.getDefaultOperator) as never, + getDefaultValue: live('getDefaultValue') as never, + getOperators: live('getOperators') as never, + getValueEditorType: live('getValueEditorType') as never, + getValues: live('getValues') as never, + getValueSources: live('getValueSources') as never, + getMatchModes: live('getMatchModes') as never, + getParameters: live('getParameters') as never, + getInputType: live('getInputType') as never, + getSubQueryBuilderProps: live('getSubQueryBuilderProps') as never, }; }; @@ -184,6 +206,14 @@ export const createManagerOptions = [`fn:${k}`, typeof p[k] === 'function' ? true : p[k]]) + ), }; }; From dbe34bf5ae3114b6e3567fd210c6660e0441a805 Mon Sep 17 00:00:00 2001 From: Jake Boone Date: Fri, 14 Aug 2026 08:13:00 -0700 Subject: [PATCH 3/3] Add validator and idGenerator to forwarded function properties --- .../src/reactive/createQueryBuilder.test.ts | 2 ++ packages/solid-querybuilder/src/reactive/manager-options.ts | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/solid-querybuilder/src/reactive/createQueryBuilder.test.ts b/packages/solid-querybuilder/src/reactive/createQueryBuilder.test.ts index 3f3df1f..fd3e4a6 100644 --- a/packages/solid-querybuilder/src/reactive/createQueryBuilder.test.ts +++ b/packages/solid-querybuilder/src/reactive/createQueryBuilder.test.ts @@ -477,6 +477,8 @@ describe('createQueryBuilder', () => { ['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) => { diff --git a/packages/solid-querybuilder/src/reactive/manager-options.ts b/packages/solid-querybuilder/src/reactive/manager-options.ts index a619c03..81fd190 100644 --- a/packages/solid-querybuilder/src/reactive/manager-options.ts +++ b/packages/solid-querybuilder/src/reactive/manager-options.ts @@ -83,6 +83,8 @@ const forwardedFnProps = [ 'getParameters', 'getInputType', 'getSubQueryBuilderProps', + 'validator', + 'idGenerator', ] as const; type ForwardedFnProp = (typeof forwardedFnProps)[number]; @@ -159,11 +161,11 @@ export const createManagerOptions =