Skip to content

Commit 59fc302

Browse files
committed
Address PR comments
1 parent 3579712 commit 59fc302

4 files changed

Lines changed: 146 additions & 31 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ the same props, returns the query, the manager, the schema, the actions and the
9292
renders nothing — so you can drive an entirely custom UI from it.
9393

9494
```tsx
95+
import { For } from 'solid-js';
9596
import { createQueryBuilder } from 'solid-querybuilder';
9697

9798
function CustomBuilder(props) {

packages/solid-querybuilder/src/reactive/createQueryBuilder.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,90 @@ describe('createQueryBuilder', () => {
434434
expect(state.manager.createRule().field).toBe('lastName');
435435
});
436436

437+
it('picks up a function prop supplied after initialization, and its later removal', () => {
438+
const [getDefaultValue, setGetDefaultValue] = createSignal<(() => string) | undefined>();
439+
const state = setupInRoot(() =>
440+
createQueryBuilder(() => ({
441+
fields,
442+
enableMountQueryChange: false,
443+
getDefaultValue: getDefaultValue() as never,
444+
}))
445+
);
446+
447+
// Absent at init: the manager applies its own precedence rules.
448+
expect(state.manager.createRule().value).toBe('');
449+
450+
setGetDefaultValue(() => () => 'added');
451+
flush();
452+
expect(state.manager.createRule().value).toBe('added');
453+
454+
// Replaced: the live closure keeps up with no reconfigure.
455+
const versionAfterAdd = state.manager.getConfigVersion();
456+
setGetDefaultValue(() => () => 'replaced');
457+
flush();
458+
expect(state.manager.createRule().value).toBe('replaced');
459+
expect(state.manager.getConfigVersion()).toBe(versionAfterAdd);
460+
461+
// Removed: the wrapper is uninstalled rather than left calling `undefined`.
462+
setGetDefaultValue(undefined);
463+
flush();
464+
expect(() => state.manager.createRule()).not.toThrow();
465+
expect(state.manager.createRule().value).toBe('');
466+
});
467+
468+
it.each([
469+
['getDefaultField', () => 'lastName'],
470+
['getDefaultOperator', () => '='],
471+
['getDefaultValue', () => 'v'],
472+
['getOperators', () => [{ name: '=', value: '=', label: '=' }]],
473+
['getValueEditorType', () => 'text'],
474+
['getValues', () => []],
475+
['getValueSources', () => ['value']],
476+
['getMatchModes', () => []],
477+
['getParameters', () => []],
478+
['getInputType', () => 'text'],
479+
['getSubQueryBuilderProps', () => ({ fields: [] })],
480+
] as [string, () => unknown][])(
481+
'reconfigures when %s appears or disappears, but not when it is merely replaced',
482+
(key, fn) => {
483+
const [present, setPresent] = createSignal(false);
484+
// ⚠️ `createSignal` treats a bare function as a lazy initializer; wrap it.
485+
const [identity, setIdentity] = createSignal<() => unknown>(() => fn);
486+
const state = setupInRoot(() =>
487+
createQueryBuilder(
488+
() =>
489+
({
490+
fields,
491+
enableMountQueryChange: false,
492+
...(present() ? { [key]: identity() } : {}),
493+
}) as never
494+
)
495+
);
496+
497+
// Effects created inside a root are queued: the deferred reconfigure effect must take its
498+
// first (dependency-registering) run before the test drives anything.
499+
flush();
500+
const initial = state.manager.getConfigVersion();
501+
502+
setPresent(true);
503+
flush();
504+
const afterAdd = state.manager.getConfigVersion();
505+
expect(afterAdd).toBeGreaterThan(initial);
506+
507+
setIdentity(
508+
() =>
509+
(...args: unknown[]) =>
510+
fn(...(args as []))
511+
);
512+
flush();
513+
expect(state.manager.getConfigVersion()).toBe(afterAdd);
514+
515+
setPresent(false);
516+
flush();
517+
expect(state.manager.getConfigVersion()).toBeGreaterThan(afterAdd);
518+
}
519+
);
520+
437521
it('exposes schema helpers derived from the manager', () => {
438522
const state = setupInRoot(() =>
439523
createQueryBuilder({

packages/solid-querybuilder/src/reactive/createQueryBuilder.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ export const createQueryBuilder = <
127127
const { maxLevels, disabledPaths, buildManagerOptions, structuralOptions } = createManagerOptions<
128128
F,
129129
O
130-
>(getProps, config, initialProps);
130+
>(getProps, config);
131131

132132
const { manager, query, tree, configVersion } = createManagerBridge<F, O>({
133133
getProps,

packages/solid-querybuilder/src/reactive/manager-options.ts

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -66,19 +66,36 @@ export interface ManagerOptionsParts<F extends FullField, O extends FullOperator
6666
readonly structuralOptions: () => Record<string, unknown>;
6767
}
6868

69+
/**
70+
* Every function prop forwarded to the manager through a `live()` closure. Also drives the
71+
* presence flags in {@link ManagerOptionsParts.structuralOptions}, so adding or removing any of
72+
* them reconfigures the manager rather than leaving a stale (or missing) wrapper installed.
73+
*/
74+
const forwardedFnProps = [
75+
'getDefaultField',
76+
'getDefaultOperator',
77+
'getDefaultValue',
78+
'getOperators',
79+
'getValueEditorType',
80+
'getValues',
81+
'getValueSources',
82+
'getMatchModes',
83+
'getParameters',
84+
'getInputType',
85+
'getSubQueryBuilderProps',
86+
] as const;
87+
88+
type ForwardedFnProp = (typeof forwardedFnProps)[number];
89+
6990
/**
7091
* Derives everything the {@link QueryManager} is configured with from props and the merged config.
7192
*
7293
* @param getProps - Reads the current props.
7394
* @param config - The merged `QueryBuilder` config.
74-
* @param initialProps - The props as read once, untracked, at initialization. Used only to decide
75-
* which function props were supplied, which fixes the manager's precedence rules for the lifetime
76-
* of the manager.
7795
*/
7896
export const createManagerOptions = <F extends FullField, O extends FullOperator>(
7997
getProps: Accessor<QueryBuilderProps<RuleGroupTypeAny, F, O, FullCombinator>>,
80-
config: Accessor<MergedQueryBuilderConfig<F, GetOptionIdentifierType<O>>>,
81-
initialProps: QueryBuilderProps<RuleGroupTypeAny, F, O, FullCombinator>
98+
config: Accessor<MergedQueryBuilderConfig<F, GetOptionIdentifierType<O>>>
8299
): ManagerOptionsParts<F, O> => {
83100
// A plain closure: it returns a primitive, so there is no identity to stabilize.
84101
const maxLevels = (): number =>
@@ -91,16 +108,23 @@ export const createManagerOptions = <F extends FullField, O extends FullOperator
91108

92109
/**
93110
* Forwards a function prop to the manager through a closure, so later changes to the prop take
94-
* effect without rebuilding the manager. Returns `undefined` when the prop is absent at
95-
* initialization, leaving the manager to apply its own precedence rules instead of treating
96-
* the option as configured.
111+
* effect without rebuilding the manager. Returns `undefined` when the prop is absent *now*,
112+
* leaving the manager to apply its own precedence rules instead of treating the option as
113+
* configured.
114+
*
115+
* Presence is read from current props, not `initialProps`: a callback that is later removed
116+
* would otherwise leave a wrapper calling `undefined`, and one later supplied would never
117+
* reach the manager. `forwardedFnProps` puts every presence flag in the structural signature,
118+
* so add/remove transitions reconfigure and this is re-evaluated. The wrapper still re-checks
119+
* at call time, since a swap-to-absent is only visible after that reconfigure lands.
97120
*/
98-
const live = <A extends unknown[], R>(
99-
pick: (props: QueryBuilderProps<RuleGroupTypeAny, F, O, FullCombinator>) => unknown
100-
): ((...args: A) => R) | undefined =>
101-
typeof pick(initialProps) === 'function'
102-
? (...args: A) => (pick(getProps()) as (...args: A) => R)(...args)
103-
: undefined;
121+
const live = <A extends unknown[], R>(key: ForwardedFnProp): ((...args: A) => R) | undefined => {
122+
if (typeof getProps()[key] !== 'function') return undefined;
123+
return (...args: A) => {
124+
const fn = getProps()[key] as unknown;
125+
return typeof fn === 'function' ? (fn as (...args: A) => R)(...args) : (undefined as R);
126+
};
127+
};
104128

105129
/**
106130
* Builds the full option set for the manager. Used both for construction and for every
@@ -137,22 +161,20 @@ export const createManagerOptions = <F extends FullField, O extends FullOperator
137161
history: true,
138162
validator: p.validator,
139163
idGenerator: p.idGenerator,
140-
// Forwarded so that changes to these props take effect without a reconfigure.
141-
getDefaultField: (typeof initialProps.getDefaultField === 'function'
142-
? live(pp => pp.getDefaultField)
143-
: p.getDefaultField) as never,
144-
getDefaultOperator: (typeof initialProps.getDefaultOperator === 'function'
145-
? live(pp => pp.getDefaultOperator)
146-
: p.getDefaultOperator) as never,
147-
getDefaultValue: live(pp => pp.getDefaultValue) as never,
148-
getOperators: live(pp => pp.getOperators) as never,
149-
getValueEditorType: live(pp => pp.getValueEditorType) as never,
150-
getValues: live(pp => pp.getValues) as never,
151-
getValueSources: live(pp => pp.getValueSources) as never,
152-
getMatchModes: live(pp => pp.getMatchModes) as never,
153-
getParameters: live(pp => pp.getParameters) as never,
154-
getInputType: live(pp => pp.getInputType) as never,
155-
getSubQueryBuilderProps: live(pp => pp.getSubQueryBuilderProps) as never,
164+
// Forwarded so that changes to these props take effect without a reconfigure. `live` returns
165+
// `undefined` for a non-function prop, so the two that also accept a plain name fall back
166+
// to the raw value.
167+
getDefaultField: (live('getDefaultField') ?? p.getDefaultField) as never,
168+
getDefaultOperator: (live('getDefaultOperator') ?? p.getDefaultOperator) as never,
169+
getDefaultValue: live('getDefaultValue') as never,
170+
getOperators: live('getOperators') as never,
171+
getValueEditorType: live('getValueEditorType') as never,
172+
getValues: live('getValues') as never,
173+
getValueSources: live('getValueSources') as never,
174+
getMatchModes: live('getMatchModes') as never,
175+
getParameters: live('getParameters') as never,
176+
getInputType: live('getInputType') as never,
177+
getSubQueryBuilderProps: live('getSubQueryBuilderProps') as never,
156178
};
157179
};
158180

@@ -184,6 +206,14 @@ export const createManagerOptions = <F extends FullField, O extends FullOperator
184206
maxLevels: maxLevels(),
185207
disabledPaths: disabledPaths(),
186208
queryDisabled: p.disabled === true,
209+
// Presence, not identity: a forwarded callback that appears or disappears changes what the
210+
// manager must be configured with (wrapper vs. `undefined`, i.e. its own precedence rules),
211+
// while a mere identity swap stays invisible to it and is picked up by the live closure. A
212+
// non-function value (`getDefaultField`/`getDefaultOperator` also take a plain name) is
213+
// forwarded as-is, so it is compared by value here instead.
214+
...Object.fromEntries(
215+
forwardedFnProps.map(k => [`fn:${k}`, typeof p[k] === 'function' ? true : p[k]])
216+
),
187217
};
188218
};
189219

0 commit comments

Comments
 (0)