fix: WhereBuilder component does not accept all valid Where queries (#3087)
* chore: add jsDocs for ListControls * chore: add jsDocs for ListView * chore: add jsDocs for WhereBuilder * chore: add comment * chore: remove unnecessary console log * chore: improve operator type * fix: transform where queries which aren't necessarily incorrect, and improve their validation * chore: add type to import * fix: do not merge existing old query params with new ones if the existing old ones got transformed and are not valid, as that would cause duplicates * chore: sort imports and remove extra validation * fix: transformWhereQuery logic * chore: add back extra validation * chore: add e2e tests
This commit is contained in:
@@ -38,6 +38,11 @@ const getUseAsTitle = (collection: SanitizedCollectionConfig) => {
|
||||
return topLevelFields.find((field) => fieldAffectsData(field) && field.name === useAsTitle);
|
||||
};
|
||||
|
||||
/**
|
||||
* The ListControls component is used to render the controls (search, filter, where)
|
||||
* for a collection's list view. You can find those directly above the table which lists
|
||||
* the collection's documents.
|
||||
*/
|
||||
const ListControls: React.FC<Props> = (props) => {
|
||||
const {
|
||||
collection,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useSearchParams } from '../../utilities/SearchParams';
|
||||
import validateWhereQuery from './validateWhereQuery';
|
||||
import { Where } from '../../../../types';
|
||||
import { getTranslation } from '../../../../utilities/getTranslation';
|
||||
import { transformWhereQuery } from './transformWhereQuery';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -43,6 +44,10 @@ const reduceFields = (fields, i18n) => flattenTopLevelFields(fields).reduce((red
|
||||
return reduced;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* The WhereBuilder component is used to render the filter controls for a collection's list view.
|
||||
* It is part of the {@link ListControls} component which is used to render the controls (search, filter, where).
|
||||
*/
|
||||
const WhereBuilder: React.FC<Props> = (props) => {
|
||||
const {
|
||||
collection,
|
||||
@@ -59,16 +64,30 @@ const WhereBuilder: React.FC<Props> = (props) => {
|
||||
const params = useSearchParams();
|
||||
const { t, i18n } = useTranslation('general');
|
||||
|
||||
// This handles initializing the where conditions from the search query (URL). That way, if you pass in
|
||||
// query params to the URL, the where conditions will be initialized from those and displayed in the UI.
|
||||
// Example: /admin/collections/posts?where[or][0][and][0][text][equals]=example%20post
|
||||
const [conditions, dispatchConditions] = useReducer(reducer, params.where, (whereFromSearch) => {
|
||||
if (modifySearchQuery && validateWhereQuery(whereFromSearch)) {
|
||||
return whereFromSearch.or;
|
||||
}
|
||||
if (modifySearchQuery && whereFromSearch) {
|
||||
if (validateWhereQuery(whereFromSearch)) {
|
||||
return whereFromSearch.or;
|
||||
}
|
||||
|
||||
// Transform the where query to be in the right format. This will transform something simple like [text][equals]=example%20post to the right format
|
||||
const transformedWhere = transformWhereQuery(whereFromSearch);
|
||||
|
||||
if (validateWhereQuery(transformedWhere)) {
|
||||
return transformedWhere.or;
|
||||
}
|
||||
|
||||
console.warn('Invalid where query in URL. Ignoring.');
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const [reducedFields] = useState(() => reduceFields(collection.fields, i18n));
|
||||
|
||||
// This handles updating the search query (URL) when the where conditions change
|
||||
useThrottledEffect(() => {
|
||||
const currentParams = queryString.parse(history.location.search, { ignoreQueryPrefix: true, depth: 10 }) as { where: Where };
|
||||
|
||||
@@ -83,8 +102,11 @@ const WhereBuilder: React.FC<Props> = (props) => {
|
||||
];
|
||||
}, []) : [];
|
||||
|
||||
const hasNewWhereConditions = conditions.length > 0;
|
||||
|
||||
|
||||
const newWhereQuery = {
|
||||
...typeof currentParams?.where === 'object' ? currentParams.where : {},
|
||||
...typeof currentParams?.where === 'object' && (validateWhereQuery(currentParams?.where) || !hasNewWhereConditions) ? currentParams.where : {},
|
||||
or: [
|
||||
...conditions,
|
||||
...paramsToKeep,
|
||||
@@ -94,7 +116,6 @@ const WhereBuilder: React.FC<Props> = (props) => {
|
||||
if (handleChange) handleChange(newWhereQuery as Where);
|
||||
|
||||
const hasExistingConditions = typeof currentParams?.where === 'object' && 'or' in currentParams.where;
|
||||
const hasNewWhereConditions = conditions.length > 0;
|
||||
|
||||
if (modifySearchQuery && ((hasExistingConditions && !hasNewWhereConditions) || hasNewWhereConditions)) {
|
||||
history.replace({
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Where } from '../../../../types';
|
||||
|
||||
/**
|
||||
* Something like [or][0][and][0][text][equals]=example%20post will work and pass through the validateWhereQuery check.
|
||||
* However, something like [text][equals]=example%20post will not work and will fail the validateWhereQuery check,
|
||||
* even though it is a valid Where query. This needs to be transformed here.
|
||||
*/
|
||||
export const transformWhereQuery = (whereQuery): Where => {
|
||||
if (!whereQuery) {
|
||||
return {};
|
||||
}
|
||||
// Check if 'whereQuery' has 'or' field but no 'and'. This is the case for "correct" queries
|
||||
if (whereQuery.or && !whereQuery.and) {
|
||||
return {
|
||||
or: whereQuery.or.map((query) => {
|
||||
// ...but if the or query does not have an and, we need to add it
|
||||
if(!query.and) {
|
||||
return {
|
||||
and: [query]
|
||||
}
|
||||
}
|
||||
return query;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Check if 'whereQuery' has 'and' field but no 'or'.
|
||||
if (whereQuery.and && !whereQuery.or) {
|
||||
return {
|
||||
or: [
|
||||
{
|
||||
and: whereQuery.and,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Check if 'whereQuery' has neither 'or' nor 'and'.
|
||||
if (!whereQuery.or && !whereQuery.and) {
|
||||
return {
|
||||
or: [
|
||||
{
|
||||
and: [whereQuery], // top-level siblings are considered 'and'
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// If 'whereQuery' has 'or' and 'and', just return it as it is.
|
||||
return whereQuery;
|
||||
};
|
||||
@@ -1,8 +1,36 @@
|
||||
import { Where } from '../../../../types';
|
||||
import { type Operator, type Where, validOperators } from '../../../../types';
|
||||
|
||||
const validateWhereQuery = (whereQuery): whereQuery is Where => {
|
||||
if (whereQuery?.or?.length > 0 && whereQuery?.or?.[0]?.and && whereQuery?.or?.[0]?.and?.length > 0) {
|
||||
return true;
|
||||
// At this point we know that the whereQuery has 'or' and 'and' fields,
|
||||
// now let's check the structure and content of these fields.
|
||||
|
||||
const isValid = whereQuery.or.every((orQuery) => {
|
||||
if (orQuery.and && Array.isArray(orQuery.and)) {
|
||||
return orQuery.and.every((andQuery) => {
|
||||
if (typeof andQuery !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const andKeys = Object.keys(andQuery);
|
||||
// If there are no keys, it's not a valid WhereField.
|
||||
if (andKeys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const key of andKeys) {
|
||||
const operator = Object.keys(andQuery[key])[0];
|
||||
// Check if the key is a valid Operator.
|
||||
if (!operator || !validOperators.includes(operator as Operator)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -16,6 +16,12 @@ import { useSearchParams } from '../../../utilities/SearchParams';
|
||||
import { TableColumnsProvider } from '../../../elements/TableColumns';
|
||||
import type { Field } from '../../../../../fields/config/types';
|
||||
|
||||
/**
|
||||
* The ListView component is table which lists the collection's documents.
|
||||
* The default list view can be found at the {@link DefaultList} component.
|
||||
* Users can also create pass their own custom list view component instead
|
||||
* of using the default one.
|
||||
*/
|
||||
const ListView: React.FC<ListIndexProps> = (props) => {
|
||||
const {
|
||||
collection,
|
||||
|
||||
@@ -4,20 +4,23 @@ import { FileData } from '../uploads/types';
|
||||
|
||||
export { PayloadRequest } from '../express/types';
|
||||
|
||||
export type Operator =
|
||||
| 'equals'
|
||||
| 'contains'
|
||||
| 'not_equals'
|
||||
| 'in'
|
||||
| 'all'
|
||||
| 'not_in'
|
||||
| 'exists'
|
||||
| 'greater_than'
|
||||
| 'greater_than_equal'
|
||||
| 'less_than'
|
||||
| 'less_than_equal'
|
||||
| 'like'
|
||||
| 'near';
|
||||
export const validOperators = [
|
||||
'equals',
|
||||
'contains',
|
||||
'not_equals',
|
||||
'in',
|
||||
'all',
|
||||
'not_in',
|
||||
'exists',
|
||||
'greater_than',
|
||||
'greater_than_equal',
|
||||
'less_than',
|
||||
'less_than_equal',
|
||||
'like',
|
||||
'near',
|
||||
] as const;
|
||||
|
||||
export type Operator = typeof validOperators[number];
|
||||
|
||||
export type WhereField = {
|
||||
[key in Operator]?: unknown;
|
||||
|
||||
Reference in New Issue
Block a user