Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

N/A
### Added

- The `useQueryBuilder` hook has been reinstated. It does nothing more than call `useQueryBuilderSetup` and `useQueryBuilderSchema`, which no longer need to be called from separate components.
- `useQueryBuilderQuery` hook to retrieve the full, current query object during the render pass of a custom component. It requires no parameters and should be used in place of the previously recommended hook `useQueryBuilderSelector`, which requires a selector function generated with `getQuerySelectorById(props.schema.qbId)`. While `useQueryBuilderSelector` is not deprecated, it is no longer recommended except in very special circumstances.

### Fixed

- `useQueryBuilderSelector` no longer returns `undefined` during the first render pass (and neither does the new hook `useQueryBuilderQuery`).

## [v7.4.4] - 2024-06-10

Expand Down
64 changes: 64 additions & 0 deletions packages/react-querybuilder/src/components/QueryBuilder.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { defaultControlElements } from './defaults';
import { ValueSelector } from './ValueSelector';
import { ActionElement } from './ActionElement';
import { waitABeat } from './testUtils';
import { getQuerySelectorById, useQueryBuilderQuery, useQueryBuilderSelector } from '../redux';

const user = userEvent.setup();

Expand Down Expand Up @@ -2720,6 +2721,69 @@ describe('null controlElements', () => {
});
});

describe('selector hooks', () => {
const queryTracker = jest.fn();
const UseQueryBuilderSelector = (props: RuleGroupProps) => {
const q = useQueryBuilderSelector(getQuerySelectorById(props.schema.qbId));
queryTracker(q ?? false);
return null;
};
const UseQueryBuilderQueryPARAM = (props: RuleGroupProps) => {
const q = useQueryBuilderQuery(props);
queryTracker(q ?? false);
return null;
};
const UseQueryBuilderQueryNOPARAM = () => {
const q = useQueryBuilderQuery();
queryTracker(q ?? false);
return null;
};
const generateQuery = (value: string): RuleGroupType => ({
combinator: 'and',
rules: [{ field: 'f1', operator: '=', value }],
});

beforeEach(() => {
queryTracker.mockClear();
});

describe.each([
{ RG: UseQueryBuilderSelector, testName: 'useQueryBuilderSelector' },
{ RG: UseQueryBuilderQueryPARAM, testName: 'useQueryBuilderQuery with parameter' },
{ RG: UseQueryBuilderQueryNOPARAM, testName: 'useQueryBuilderQuery without parameter' },
])('$testName', ({ RG }) => {
it('returns a query on first render without query prop', () => {
const query: RuleGroupType = { combinator: 'and', rules: [] };
render(<QueryBuilder controlElements={{ ruleGroup: RG }} />);
expect(queryTracker).toHaveBeenNthCalledWith(1, expect.objectContaining(query));
});

it('returns a query on first render with defaultQuery prop', () => {
const query = generateQuery('defaultQuery prop');
render(<QueryBuilder defaultQuery={query} controlElements={{ ruleGroup: RG }} />);
expect(queryTracker).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
combinator: 'and',
rules: [expect.objectContaining(query.rules[0])],
})
);
});

it('returns a query on first render with query prop', () => {
const query = generateQuery('query prop');
render(<QueryBuilder query={query} controlElements={{ ruleGroup: RG }} />);
expect(queryTracker).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
combinator: 'and',
rules: [expect.objectContaining(query.rules[0])],
})
);
});
});
});

describe('debug mode', () => {
it('logs updates', async () => {
const onLog = jest.fn();
Expand Down
25 changes: 10 additions & 15 deletions packages/react-querybuilder/src/components/QueryBuilder.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';
import { Provider } from 'react-redux';
import { useQueryBuilderSchema, useQueryBuilderSetup } from '../hooks';
import { useQueryBuilder } from '../hooks';
import { QueryBuilderStateContext, queryBuilderStore } from '../redux';
import type {
FullCombinator,
Expand Down Expand Up @@ -33,13 +33,12 @@ const QueryBuilderInternal = <
F extends FullField,
O extends FullOperator,
C extends FullCombinator,
>(allProps: {
>({
props,
}: {
props: QueryBuilderProps<RG, F, O, C>;
setup: ReturnType<typeof useQueryBuilderSetup<RG, F, O, C>>;
}) => {
const { setup, props } = allProps;

const qb = useQueryBuilderSchema<RG, F, O, C>(props, setup);
const qb = useQueryBuilder<RG, F, O, C>(props);

const RuleGroupControlElement = qb.schema.controls.ruleGroup;

Expand Down Expand Up @@ -87,12 +86,8 @@ export const QueryBuilder = <
C extends FullCombinator,
>(
props: QueryBuilderProps<RG, F, O, C>
) => {
const setup = useQueryBuilderSetup(props);

return (
<QueryBuilderStateProvider>
<QueryBuilderInternal props={props} setup={setup} />
</QueryBuilderStateProvider>
);
};
) => (
<QueryBuilderStateProvider>
<QueryBuilderInternal props={props} />
</QueryBuilderStateProvider>
);
13 changes: 10 additions & 3 deletions packages/react-querybuilder/src/components/QueryBuilderContext.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { createContext } from 'react';
import type { QueryBuilderContextProps } from '../types';
import type { QueryBuilderContextProps, RuleGroupTypeAny } from '../types';

interface QueryBuilderContextInternals {
initialQuery?: RuleGroupTypeAny;
qbId?: string;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type QueryBuilderContextType = QueryBuilderContextProps<any, any> & QueryBuilderContextInternals;

/**
* Context provider for {@link QueryBuilder}. Any descendant query builders
* will inherit the props from a context provider.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const QueryBuilderContext = createContext<QueryBuilderContextProps<any, any>>({});
export const QueryBuilderContext = createContext<QueryBuilderContextType>({});
1 change: 1 addition & 0 deletions packages/react-querybuilder/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from './useDeprecatedProps';
export * from './useMergedContext';
export * from './usePreferProp';
export * from './usePrevious';
export * from './useQueryBuilder';
export * from './useQueryBuilderSchema';
export * from './useQueryBuilderSetup';
export * from './useReactDndWarning';
Expand Down
8 changes: 7 additions & 1 deletion packages/react-querybuilder/src/hooks/useMergedContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,18 @@ import type {
ValueSourceSelectorProps,
ControlElementsProp,
DragHandleProps,
RuleGroupTypeAny,
} from '../types';
import { mergeClassnames, mergeTranslations } from '../utils';
import { usePreferProp } from './usePreferProp';

export type UseMergedContextProps<
F extends FullField = FullField,
O extends string = string,
> = QueryBuilderContextProps<F, O>;
> = QueryBuilderContextProps<F, O> & {
initialQuery?: RuleGroupTypeAny;
qbId?: string;
};

const nullComp = () => null;
const nullFwdComp: ForwardRefExoticComponent<DragHandleProps & RefAttributes<HTMLElement>> =
Expand Down Expand Up @@ -297,6 +301,8 @@ export const useMergedContext = <F extends FullField = FullField, O extends stri
enableDragAndDrop,
enableMountQueryChange,
translations,
initialQuery: props.initialQuery,
qbId: props.qbId,
...otherContext,
};
};
18 changes: 18 additions & 0 deletions packages/react-querybuilder/src/hooks/useQueryBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type {
FullCombinator,
FullField,
FullOperator,
QueryBuilderProps,
RuleGroupTypeAny,
} from '../types';
import { useQueryBuilderSchema } from './useQueryBuilderSchema';
import { useQueryBuilderSetup } from './useQueryBuilderSetup';

export const useQueryBuilder = <
RG extends RuleGroupTypeAny,
F extends FullField,
O extends FullOperator,
C extends FullCombinator,
>(
props: QueryBuilderProps<RG, F, O, C>
) => useQueryBuilderSchema<RG, F, O, C>(props, useQueryBuilderSetup(props));
20 changes: 13 additions & 7 deletions packages/react-querybuilder/src/hooks/useQueryBuilderSchema.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { clsx } from 'clsx';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { LogType, standardClassnames } from '../defaults';
import { getQuerySelectorById, useQueryBuilderSelector } from '../redux';
import {
_RQB_INTERNAL_dispatchThunk,
useRQB_INTERNAL_QueryBuilderDispatch,
useRQB_INTERNAL_QueryBuilderStore,
} from '../redux/_internal';
import { getQuerySelectorById, useQueryBuilderSelector } from '../redux';
import type {
FullCombinator,
FullField,
Expand Down Expand Up @@ -39,9 +39,9 @@ import {
remove,
update,
} from '../utils';
import { useControlledOrUncontrolled } from './useControlledOrUncontrolled';
import { useDeprecatedProps } from './useDeprecatedProps';
import type { useQueryBuilderSetup } from './useQueryBuilderSetup';
import { useControlledOrUncontrolled } from './useControlledOrUncontrolled';

const defaultValidationResult: ReturnType<QueryValidator> = {};
const defaultValidationMap: ValidationMap = {};
Expand Down Expand Up @@ -104,7 +104,7 @@ export function useQueryBuilderSchema<

const {
qbId,
rqbContext,
rqbContext: incomingRqbContext,
fields,
fieldMap,
combinators,
Expand All @@ -126,7 +126,7 @@ export function useQueryBuilderSchema<
enableDragAndDrop,
enableMountQueryChange,
translations,
} = rqbContext;
} = incomingRqbContext;

// #region Boolean coercion
const showCombinatorsBetweenRules = !!showCombinatorsBetweenRulesProp;
Expand All @@ -151,7 +151,7 @@ export function useQueryBuilderSchema<
const queryBuilderStore = useRQB_INTERNAL_QueryBuilderStore();
const queryBuilderDispatch = useRQB_INTERNAL_QueryBuilderDispatch();

const querySelector = useMemo(() => getQuerySelectorById(setup.qbId), [setup.qbId]);
const querySelector = useMemo(() => getQuerySelectorById(qbId), [qbId]);
const storeQuery = useQueryBuilderSelector(querySelector);
const getQuery = useCallback(
() => querySelector(queryBuilderStore.getState()),
Expand All @@ -168,10 +168,16 @@ export function useQueryBuilderSchema<
!candidateQuery.id ? prepareRuleGroup(candidateQuery, { idGenerator }) : candidateQuery
) as RuleGroupTypeAny<R>;

const [initialQuery] = useState(rootGroup);
const rqbContext = useMemo(
() => ({ ...incomingRqbContext, initialQuery }),
[incomingRqbContext, initialQuery]
);

// If a new `query` prop is passed in that doesn't match the query in the store,
// update the store to match the prop _without_ calling `onQueryChange`.
useEffect(() => {
if (!!queryProp && queryProp !== storeQuery) {
if (!!queryProp && !Object.is(queryProp, storeQuery)) {
queryBuilderDispatch(
_RQB_INTERNAL_dispatchThunk({
payload: { qbId, query: queryProp },
Expand Down
4 changes: 4 additions & 0 deletions packages/react-querybuilder/src/hooks/useQueryBuilderSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,17 @@ export const useQueryBuilderSetup = <

const operators = (operatorsProp ?? defaultOperators) as FlexibleOptionList<O>;

const [initialQueryProp] = useState(props.query ?? props.defaultQuery);

const rqbContext = useMergedContext({
controlClassnames: controlClassnamesProp,
controlElements: controlElementsProp,
debugMode: debugModeProp,
enableDragAndDrop: enableDragAndDropProp,
enableMountQueryChange: enableMountQueryChangeProp,
translations: translationsProp,
initialQuery: initialQueryProp,
qbId: qbId,
});

const { translations } = rqbContext;
Expand Down
43 changes: 38 additions & 5 deletions packages/react-querybuilder/src/redux/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { configureStore } from '@reduxjs/toolkit';
import * as React from 'react';
import type { ReactReduxContextValue, TypedUseSelectorHook } from 'react-redux';
import { createSelectorHook } from 'react-redux';
import { QueryBuilderContext } from '../components';
import type { QueriesSliceState } from './queriesSlice';
import { queriesSlice } from './queriesSlice';
import type { WarningsSliceState } from './warningsSlice';
Expand Down Expand Up @@ -41,17 +42,49 @@ export const QueryBuilderStateContext = React.createContext<ReactReduxContextVal
> | null>(null);

// #region Hooks
const useRQB_INTERNAL_QueryBuilderSelector: TypedUseSelectorHook<RqbState> =
createSelectorHook(QueryBuilderStateContext);

/**
* A `useSelector` hook for the RQB Redux store.
* A Redux `useSelector` hook for RQB's internal store. See also {@link getQuerySelectorById}.
*
* **TIP:** Prefer {@link useQueryBuilderQuery} if you only need to access the query object
* for the nearest ancestor {@link QueryBuilder} component.
*/
export const useQueryBuilderSelector: TypedUseSelectorHook<RqbState> =
createSelectorHook(QueryBuilderStateContext);
export const useQueryBuilderSelector: TypedUseSelectorHook<RqbState> = (selector, other) => {
const rqbContext = React.useContext(QueryBuilderContext);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = useRQB_INTERNAL_QueryBuilderSelector(selector, other as any);
return result ?? rqbContext?.initialQuery;
};

/**
* Retrieves the full, latest query object for the nearest ancestor {@link QueryBuilder}
* component.
*
* The optional parameter should only be used when retrieving a query object from a different
* {@link QueryBuilder} than the nearest ancestor. It can be a full props object as passed
* to a custom component or any object matching the interface `{ schema: { qbId: string } }`.
*
* Must follow React's [Rules of Hooks](https://react.dev/warnings/invalid-hook-call-warning).
*/
export const useQueryBuilderQuery = (props?: { schema: { qbId: string } }) => {
const rqbContext = React.useContext(QueryBuilderContext);
return (
useRQB_INTERNAL_QueryBuilderSelector(
getQuerySelectorById(props?.schema.qbId ?? rqbContext.qbId ?? /* istanbul ignore next */ '')
) ?? rqbContext?.initialQuery
);
};
// #endregion

// #region Selectors
/**
* Given a `qbId` (provided as part of the `schema` prop), returns
* a selector for use with `useQueryBuilderSelector`.
* Given a `qbId` (passed to every component as part of the `schema` prop), returns
* a Redux selector for use with {@link useQueryBuilderSelector}.
*
* Note that {@link useQueryBuilderQuery} is a more concise way of accessing the
* query for the nearest ancestor {@link QueryBuilder} component.
*/
export const getQuerySelectorById = (qbId: string) => (state: RqbState) =>
queriesSlice.selectors.getQuerySelectorById({ queries: state.queries }, qbId);
Expand Down
2 changes: 1 addition & 1 deletion website/docs/components/querybuilder.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { DemoLink } from '@site/src/components/DemoLink';

The primary export of `react-querybuilder` is the `<QueryBuilder />` React component.

`QueryBuilder` calls the [`useQueryBuilderSetup`](../utils/hooks#usequerybuildersetup) Hook to merge props and context values with defaults, generate update methods, etc. It then renders a context provider over an internal component that calls the [`useQueryBuilderSchema`](../utils/hooks#usequerybuilderschema) Hook to consume the context, prepare the query, and finalize the schema.
`QueryBuilder` calls the [`useQueryBuilder`](../utils/hooks#usequerybuilder) Hook to merge props and context values with defaults, generate update methods, consume the context, prepare the query, and finalize the schema.

## Subcomponents

Expand Down
7 changes: 3 additions & 4 deletions website/docs/tips/path.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ would return this object:

In most scenarios you won't need to interact with the `path` attribute, but it can come in handy in certain situations. One such situation is if you need to access other parts of the query from within a custom component.

Say you have a custom value editor that needs to know the `value` of each of its sibling rules. You can get the full query object using the `useQueryBuilderSelector` hook (which connects to React Query Builder's custom Redux implementation). You can then retrieve the sibling rules with a combination of `getParentPath` and `findPath`.
Say you have a custom value editor that needs to know the `value` of each of its sibling rules. You can get the full query object using the [`useQueryBuilderQuery`](../utils/hooks#usequerybuilderquery) hook (which connects to React Query Builder's custom Redux implementation). You can then retrieve the sibling rules with a combination of `getParentPath` and `findPath`.

:::info

Expand All @@ -79,13 +79,12 @@ import {
ValueEditorProps,
findPath,
getParentPath,
getQuerySelectorById,
useQueryBuilderSelector,
useQueryBuilderQuery,
} from 'react-querybuilder';

export const CustomValueEditor = (props: ValueEditorProps) => {
// Get the full query object
const query = useQueryBuilderSelector(getQuerySelectorById(props.schema.qbId));
const query = useQueryBuilderQuery();
// Get the path of this rule's parent group
const parentPath = getParentPath(props.path);
// Find the parent group object in the query
Expand Down
Loading