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:
Alessio Gravili
2023-08-15 19:22:57 +02:00
committed by GitHub
parent c154eb7e2b
commit fdfdfc83f3
7 changed files with 154 additions and 21 deletions

View File

@@ -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,

View File

@@ -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({

View File

@@ -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;
};

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;