react-querybuilder
Version:
React Query Builder component for constructing queries and filters, with utilities for executing them in various database and evaluation contexts
253 lines (252 loc) • 11.8 kB
JavaScript
import { clsx, prepareOptionList, standardClassnames } from "@react-querybuilder/core";
import * as React from "react";
import { useEffect, useMemo } from "react";
import { combineSlices, configureStore, createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { createDispatchHook, createSelectorHook, createStoreHook } from "react-redux";
//#region src/messages.ts
const messages = {
errorInvalidIndependentCombinatorsProp: "QueryBuilder was rendered with a truthy independentCombinators prop. This prop is deprecated and unnecessary. Furthermore, the initial query/defaultQuery prop was of type RuleGroupType instead of type RuleGroupIC. More info: https://react-querybuilder.js.org/docs/components/querybuilder#independent-combinators",
errorUnnecessaryIndependentCombinatorsProp: "QueryBuilder was rendered with the deprecated and unnecessary independentCombinators prop. To use independent combinators, make sure the query/defaultQuery prop is of type RuleGroupIC when the component mounts. More info: https://react-querybuilder.js.org/docs/components/querybuilder#independent-combinators",
errorDeprecatedRuleGroupProps: "A custom RuleGroup component has rendered a standard RuleGroup component with deprecated props. The combinator, not, and rules props should not be used. Instead, the full group object should be passed as the ruleGroup prop.",
errorDeprecatedRuleProps: "A custom RuleGroup component has rendered a standard Rule component with deprecated props. The field, operator, value, and valueSource props should not be used. Instead, the full rule object should be passed as the rule prop.",
errorBothQueryDefaultQuery: "QueryBuilder was rendered with both query and defaultQuery props. QueryBuilder must be either controlled or uncontrolled (specify either the query prop, or the defaultQuery prop, but not both). Decide between using a controlled or uncontrolled query builder and remove one of these props. More info: https://reactjs.org/link/controlled-components",
errorUncontrolledToControlled: "QueryBuilder is changing from an uncontrolled component to be controlled. This is likely caused by the query changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled query builder for the lifetime of the component. More info: https://reactjs.org/link/controlled-components",
errorControlledToUncontrolled: "QueryBuilder is changing from a controlled component to be uncontrolled. This is likely caused by the query changing from defined to undefined, which should not happen. Decide between using a controlled or uncontrolled query builder for the lifetime of the component. More info: https://reactjs.org/link/controlled-components",
errorEnabledDndWithoutReactDnD: "QueryBuilder was rendered with the enableDragAndDrop prop set to true, but either react-dnd was not detected or one of react-dnd-html5-backend or react-dnd-touch-backend was not detected. To enable drag-and-drop functionality, install react-dnd and one of the backend packages and wrap QueryBuilder in QueryBuilderDnD from @react-querybuilder/dnd.",
errorDeprecatedDebugImport: `Importing from react-querybuilder/debug is deprecated. To enable Redux DevTools for React Query Builder's internal store, set globalThis.__RQB_DEVTOOLS__ = true.`
};
const queriesSlice = createSlice({
name: "queries",
initialState: {},
reducers: { setQueryState: (state, { payload: { qbId, query } }) => {
state[qbId] = query;
} },
selectors: { getQuerySelectorById: (state, qbId) => state[qbId] }
});
//#endregion
//#region src/redux/QueryBuilderStateContext.ts
const QueryBuilderStateContext = React.createContext(null);
const warningsSlice = createSlice({
name: "warnings",
initialState: {
[messages.errorInvalidIndependentCombinatorsProp]: false,
[messages.errorUnnecessaryIndependentCombinatorsProp]: false,
[messages.errorDeprecatedRuleGroupProps]: false,
[messages.errorDeprecatedRuleProps]: false,
[messages.errorBothQueryDefaultQuery]: false,
[messages.errorUncontrolledToControlled]: false,
[messages.errorControlledToUncontrolled]: false,
[messages.errorEnabledDndWithoutReactDnD]: false,
[messages.errorDeprecatedDebugImport]: false
},
reducers: { rqbWarn: (state, { payload }) => {
if (!state[payload]) {
console.error(payload);
state[payload] = true;
}
} }
});
//#endregion
//#region src/redux/rootReducer.ts
const rootReducer = combineSlices(queriesSlice, warningsSlice).withLazyLoadedSlices();
//#endregion
//#region src/redux/_internal/hooks.ts
const genUseQueryBuilderDispatch = (ctx) => createDispatchHook(ctx);
const genUseQueryBuilderStore = (ctx) => createStoreHook(ctx);
const genUseQueryBuilderSelector = (ctx) => createSelectorHook(ctx);
const getInternalHooks = (ctx) => ({
useRQB_INTERNAL_QueryBuilderDispatch: genUseQueryBuilderDispatch(ctx),
useRQB_INTERNAL_QueryBuilderStore: genUseQueryBuilderStore(ctx),
useRQB_INTERNAL_QueryBuilderSelector: genUseQueryBuilderSelector(ctx)
});
//#endregion
//#region src/redux/_internal/index.ts
const internalHooks = getInternalHooks(QueryBuilderStateContext);
internalHooks.useRQB_INTERNAL_QueryBuilderDispatch;
internalHooks.useRQB_INTERNAL_QueryBuilderStore;
internalHooks.useRQB_INTERNAL_QueryBuilderSelector;
const { rqbWarn: _SYNC_rqbWarn } = warningsSlice.actions;
const storeCommon = {
reducer: rootReducer,
preloadedState: {
queries: queriesSlice.getInitialState(),
warnings: warningsSlice.getInitialState()
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: {
ignoredActions: [queriesSlice.actions.setQueryState.type],
ignoredPaths: [/^queries\b.*\.rules\.\d+\.value$/]
} })
};
//#endregion
//#region src/redux/configureRqbStore.ts
const configureRqbStore = (devTools) => {
const queryBuilderStore = configureStore({
...storeCommon,
devTools: devTools ? ( /* v8 ignore next -- @preserve */ { name: "React Query Builder" }) : false
});
queryBuilderStore.addSlice = (slice) => {
rootReducer.inject(slice);
queryBuilderStore.dispatch({
type: crypto.randomUUID().slice(0, 8),
meta: `Initializing state for slice "${slice.name}"`
});
};
return queryBuilderStore;
};
//#endregion
//#region src/redux/getRqbStore.ts
let _store = null;
/**
* Gets the singleton React Query Builder store instance.
* DevTools are enabled if either:
* - globalThis.__RQB_DEVTOOLS__ is truthy
* - window.__RQB_DEVTOOLS__ is truthy
*/
function getRqbStore(devTools) {
if (!_store) _store = configureRqbStore(devTools || globalThis?.__RQB_DEVTOOLS__);
return _store;
}
/**
* Injects a slice into the React Query Builder store. Useful for extensions
* that need to integrate their own state management.
*/
const injectSlice = (slice) => getRqbStore().addSlice(slice);
//#endregion
//#region src/hooks/useAsyncOptionList/getOptionListsAsync.ts
const DEFAULT_CACHE_TTL = 18e5;
const getOptionListsAsync = createAsyncThunk("asyncOptionLists/asyncOptionListsThunk", async (params, { getState, rejectWithValue }) => {
const { cacheKey, cacheTTL, value, loadOptionList } = params;
const cached = getState().asyncOptionLists.cache[cacheKey];
if (cached && Date.now() - cached.timestamp < (cacheTTL ?? 18e5)) return {
cacheKey,
data: cached.data,
fromCache: true
};
try {
return {
cacheKey,
data: prepareOptionList({ optionList: await loadOptionList(value, params) }).optionList,
fromCache: false
};
} catch (error) {
return rejectWithValue(error.message);
}
}, { condition: ({ cacheKey }, { getState }) => {
return !getState().asyncOptionLists.loading[cacheKey];
} });
const asyncOptionListsSlice = createSlice({
name: "asyncOptionLists",
initialState: {
cache: {},
loading: {},
errors: {}
},
reducers: {
/* v8 ignore start -- -- @preserve */
invalidateCache: (state, { payload }) => {
delete state.cache[payload];
delete state.errors[payload];
},
clearAllCache: (state) => {
state.cache = {};
state.errors = {};
state.loading = {};
}
},
selectors: {
/* v8 ignore start -- -- @preserve */
selectCache: (state) => state.cache,
selectLoading: (state) => state.loading,
selectErrors: (state) => state.errors,
selectCacheByKey: (state, cacheKey) => state.cache[cacheKey] || null,
selectIsLoadingByKey: (state, cacheKey) => state.loading[cacheKey] || false,
selectErrorByKey: (state, cacheKey) => {
const error = state.errors[cacheKey];
return error && error !== "" ? error : null;
}
},
extraReducers: (builder) => {
builder.addAsyncThunk(getOptionListsAsync, {
pending: (state, action) => {
state.loading[action.meta.arg.cacheKey] = true;
state.errors[action.meta.arg.cacheKey] = "";
},
fulfilled: (state, action) => {
const { cacheKey, data } = action.payload;
state.cache[cacheKey] = {
data,
timestamp: Date.now(),
validUntil: Date.now() + (action.meta.arg.cacheTTL ?? 18e5)
};
state.loading[cacheKey] = false;
},
rejected: (state, action) => {
state.loading[action.meta.arg.cacheKey] = false;
state.errors[action.meta.arg.cacheKey] = action.payload;
}
});
}
});
//#endregion
//#region src/hooks/useAsyncOptionList/useAsyncCacheKey.ts
/**
* Generates a cache key given the same props and params as {@link useAsyncOptionList}.
*
* @group Hooks
*/
const useAsyncCacheKey = (props, { getCacheKey } = {}) => {
const ruleOrGroup = props.rule ?? props.ruleGroup;
return useMemo(() => typeof getCacheKey === "string" ? ruleOrGroup?.[getCacheKey] ?? "" : typeof getCacheKey === "function" ? getCacheKey(props) : Array.isArray(getCacheKey) && getCacheKey.length > 0 && ruleOrGroup ? getCacheKey.map((ck) => `${ruleOrGroup[ck]}`).join("|") : "", [getCacheKey, ...Object.keys(props).toSorted().map((k) => props[k])]);
};
//#endregion
//#region src/hooks/useAsyncOptionList/index.ts
const { useRQB_INTERNAL_QueryBuilderDispatch, useRQB_INTERNAL_QueryBuilderSelector } = getInternalHooks(QueryBuilderStateContext);
injectSlice(asyncOptionListsSlice);
function useAsyncOptionList(props, params = {}) {
const queryBuilderDispatch = useRQB_INTERNAL_QueryBuilderDispatch();
const { cacheTTL, loadOptionList } = params;
const { options: optionsProp, values: valuesProp, value } = props;
const ruleOrGroup = props.rule ?? props.ruleGroup;
const cacheKey = useAsyncCacheKey(props, params);
const cached = useRQB_INTERNAL_QueryBuilderSelector((s) => asyncOptionListsSlice.selectors.selectCacheByKey(s, cacheKey));
const cacheIsValid = !!cached && Date.now() <= cached.validUntil;
const options = cached?.data ?? optionsProp ?? valuesProp;
const isLoading = params.isLoading || useRQB_INTERNAL_QueryBuilderSelector((s) => asyncOptionListsSlice.selectors.selectIsLoadingByKey(s, cacheKey));
const errors = useRQB_INTERNAL_QueryBuilderSelector((s) => asyncOptionListsSlice.selectors.selectErrorByKey(s, cacheKey));
const className = useMemo(() => clsx(props.className, isLoading && [props.schema.suppressStandardClassnames || standardClassnames.loading, props.schema.classNames.loading]), [
props.schema.suppressStandardClassnames,
isLoading,
props.className,
props.schema.classNames.loading
]);
useEffect(() => {
if (!isLoading && (!cacheIsValid || !cached) && !errors && typeof loadOptionList === "function") queryBuilderDispatch(getOptionListsAsync({
cacheKey,
cacheTTL,
value,
ruleOrGroup,
loadOptionList
}));
}, [
cacheKey,
cacheIsValid,
cacheTTL,
isLoading,
loadOptionList,
cached,
queryBuilderDispatch,
ruleOrGroup,
value,
errors
]);
return {
...props,
...optionsProp ? { options } : { values: options },
className,
isLoading,
errors
};
}
//#endregion
export { DEFAULT_CACHE_TTL, asyncOptionListsSlice, getOptionListsAsync, useAsyncCacheKey, useAsyncOptionList };
//# sourceMappingURL=async.mjs.map