diff --git a/packages/db-mongodb/src/queries/sanitizeQueryValue.ts b/packages/db-mongodb/src/queries/sanitizeQueryValue.ts index 5912254ffe..6f732aca9e 100644 --- a/packages/db-mongodb/src/queries/sanitizeQueryValue.ts +++ b/packages/db-mongodb/src/queries/sanitizeQueryValue.ts @@ -11,6 +11,50 @@ type SanitizeQueryValueArgs = { val: any } +const handleHasManyValues = (formattedValue) => { + return formattedValue.reduce((formattedValues, inVal) => { + const newValues = [inVal] + if (mongoose.Types.ObjectId.isValid(inVal)) { + newValues.push(new mongoose.Types.ObjectId(inVal)) + } + const parsedNumber = parseFloat(inVal) + if (!Number.isNaN(parsedNumber)) { + newValues.push(parsedNumber) + } + + return [...formattedValues, ...newValues] + }, []) +} + +const handleNonHasManyValues = (formattedValue, operator, path) => { + const formattedQueries = formattedValue + .map((inVal) => { + if (inVal && typeof inVal === 'object' && 'relationTo' in inVal && 'value' in inVal) { + if (operator === 'in') { + return { + [`${path}.relationTo`]: { $eq: inVal.relationTo }, + [`${path}.value`]: { $eq: inVal.value }, + } + } else if (operator === 'not_in') { + return { + $and: [ + { [`${path}.value`]: inVal.value }, + { [`${path}.relationTo`]: inVal.relationTo }, + ], + } + } + } + return null + }) + .filter(Boolean) + + if (formattedQueries.length > 0) { + return { + rawQuery: operator === 'in' ? { $or: formattedQueries } : { $nor: formattedQueries }, + } + } +} + export const sanitizeQueryValue = ({ field, hasCustomID, @@ -92,17 +136,15 @@ export const sanitizeQueryValue = ({ } } - if (operator === 'in' && Array.isArray(formattedValue)) { - formattedValue = formattedValue.reduce((formattedValues, inVal) => { - const newValues = [inVal] - if (mongoose.Types.ObjectId.isValid(inVal)) - newValues.push(new mongoose.Types.ObjectId(inVal)) - - const parsedNumber = parseFloat(inVal) - if (!Number.isNaN(parsedNumber)) newValues.push(parsedNumber) - - return [...formattedValues, ...newValues] - }, []) + if (['in', 'not_in'].includes(operator) && Array.isArray(formattedValue)) { + if ('hasMany' in field && field.hasMany) { + formattedValue = handleHasManyValues(formattedValue) + } else { + const result = handleNonHasManyValues(formattedValue, operator, path) + if (result) { + return result + } + } } } diff --git a/packages/payload/src/admin/components/elements/WhereBuilder/Condition/Relationship/index.tsx b/packages/payload/src/admin/components/elements/WhereBuilder/Condition/Relationship/index.tsx index ec85e44d4e..e49e65354e 100644 --- a/packages/payload/src/admin/components/elements/WhereBuilder/Condition/Relationship/index.tsx +++ b/packages/payload/src/admin/components/elements/WhereBuilder/Condition/Relationship/index.tsx @@ -26,6 +26,7 @@ const RelationshipField: React.FC = (props) => { filterOptions, hasMany, onChange, + operator, relationTo, value, } = props @@ -47,6 +48,8 @@ const RelationshipField: React.FC = (props) => { const { i18n, t } = useTranslation('general') const { user } = useAuth() + const isMulti = ['in', 'not_in'].includes(operator) + const addOptions = useCallback( (data, relation) => { const collection = collections.find((coll) => coll.slug === relation) @@ -175,7 +178,7 @@ const RelationshipField: React.FC = (props) => { const findOptionsByValue = useCallback((): Option | Option[] => { if (value) { - if (hasMany) { + if (hasMany || isMulti) { if (Array.isArray(value)) { return value.map((val) => { if (hasMultipleRelations) { @@ -228,7 +231,7 @@ const RelationshipField: React.FC = (props) => { } return undefined - }, [hasMany, hasMultipleRelations, value, options]) + }, [hasMany, hasMultipleRelations, isMulti, value, options]) const handleInputChange = useCallback( (newSearch) => { @@ -253,6 +256,7 @@ const RelationshipField: React.FC = (props) => { const data = await response.json() addOptions({ docs: [data] }, relation) } else { + // eslint-disable-next-line no-console console.error(t('error:loadingDocument', { id })) } } @@ -266,15 +270,15 @@ const RelationshipField: React.FC = (props) => { useEffect(() => { dispatchOptions({ + type: 'CLEAR', i18n, required: true, - type: 'CLEAR', }) setHasLoadedFirstOptions(true) setLastLoadedPage(1) setLastFullyLoadedRelation(-1) - getResults({ search: debouncedSearch }) + void getResults({ search: debouncedSearch }) }, [getResults, debouncedSearch, relationTo, i18n]) // /////////////////////////// @@ -283,15 +287,15 @@ const RelationshipField: React.FC = (props) => { useEffect(() => { if (value && hasLoadedFirstOptions) { - if (hasMany) { + if (hasMany || isMulti) { const matchedOptions = findOptionsByValue() ;((matchedOptions as Option[]) || []).forEach((option, i) => { if (!option) { if (hasMultipleRelations) { - addOptionByID(value[i].value, value[i].relationTo) + void addOptionByID(value[i].value, value[i].relationTo) } else { - addOptionByID(value[i], relationTo) + void addOptionByID(value[i], relationTo) } } }) @@ -301,9 +305,9 @@ const RelationshipField: React.FC = (props) => { if (!matchedOption) { if (hasMultipleRelations) { const valueWithRelation = value as ValueWithRelation - addOptionByID(valueWithRelation.value, valueWithRelation.relationTo) + void addOptionByID(valueWithRelation.value, valueWithRelation.relationTo) } else { - addOptionByID(value, relationTo) + void addOptionByID(value, relationTo) } } } @@ -313,6 +317,7 @@ const RelationshipField: React.FC = (props) => { findOptionsByValue, hasMany, hasMultipleRelations, + isMulti, relationTo, value, hasLoadedFirstOptions, @@ -329,10 +334,10 @@ const RelationshipField: React.FC = (props) => { {!errorLoading && ( { - if (hasMany) { + if (hasMany || isMulti) { onChange( selected ? selected.map((option) => { @@ -358,7 +363,7 @@ const RelationshipField: React.FC = (props) => { }} onInputChange={handleInputChange} onMenuScrollToBottom={() => { - getResults({ lastFullyLoadedRelation, lastLoadedPage: lastLoadedPage + 1 }) + void getResults({ lastFullyLoadedRelation, lastLoadedPage: lastLoadedPage + 1 }) }} options={options} placeholder={t('selectValue')} diff --git a/packages/payload/src/admin/components/elements/WhereBuilder/Condition/Relationship/types.ts b/packages/payload/src/admin/components/elements/WhereBuilder/Condition/Relationship/types.ts index 9a9c47aff9..55e7d09a2e 100644 --- a/packages/payload/src/admin/components/elements/WhereBuilder/Condition/Relationship/types.ts +++ b/packages/payload/src/admin/components/elements/WhereBuilder/Condition/Relationship/types.ts @@ -2,11 +2,13 @@ import type i18n from 'i18next' import type { SanitizedCollectionConfig } from '../../../../../../collections/config/types' import type { PaginatedDocs } from '../../../../../../database/types' +import type { Operator } from '../../../../../../exports/types' import type { RelationshipField } from '../../../../../../fields/config/types' export type Props = { disabled?: boolean onChange: (val: unknown) => void + operator: Operator value: unknown } & RelationshipField diff --git a/test/fields/e2e.spec.ts b/test/fields/e2e.spec.ts index 323a0733a4..748368894f 100644 --- a/test/fields/e2e.spec.ts +++ b/test/fields/e2e.spec.ts @@ -1973,6 +1973,69 @@ describe('fields', () => { await expect(page.locator(tableRowLocator)).toHaveCount(1) }) + + // TODO: properly handle polymorphic relationship handling with the postgres adapter + test('should allow filtering by relationship field / is_in', async () => { + const textDoc = await createTextFieldDoc() + await createRelationshipFieldDoc({ value: textDoc.id, relationTo: 'text-fields' }) + + await page.goto(url.list) + + await page.locator('.list-controls__toggle-columns').click() + await page.locator('.list-controls__toggle-where').click() + await page.waitForSelector('.list-controls__where.rah-static--height-auto') + await page.locator('.where-builder__add-first-filter').click() + + const conditionField = page.locator('.condition__field') + await conditionField.click() + + const dropdownFieldOptions = conditionField.locator('.rs__option') + await dropdownFieldOptions.locator('text=Relationship').nth(0).click() + + const operatorField = page.locator('.condition__operator') + await operatorField.click() + + const dropdownOperatorOptions = operatorField.locator('.rs__option') + await dropdownOperatorOptions.locator('text=is in').click() + + const valueField = page.locator('.condition__value') + await valueField.click() + const dropdownValueOptions = valueField.locator('.rs__option') + await dropdownValueOptions.locator('text=some text').click() + + await expect(page.locator(tableRowLocator)).toHaveCount(1) + }) + + test('should allow filtering by relationship field / not_in', async () => { + const textDoc = await createTextFieldDoc() + await createRelationshipFieldDoc({ value: textDoc.id, relationTo: 'text-fields' }) + + await page.goto(url.list) + + await page.locator('.list-controls__toggle-columns').click() + await page.locator('.list-controls__toggle-where').click() + await page.waitForSelector('.list-controls__where.rah-static--height-auto') + await page.locator('.where-builder__add-first-filter').click() + + const conditionField = page.locator('.condition__field') + await conditionField.click() + + const dropdownFieldOptions = conditionField.locator('.rs__option') + await dropdownFieldOptions.locator('text=Relationship').nth(0).click() + + const operatorField = page.locator('.condition__operator') + await operatorField.click() + + const dropdownOperatorOptions = operatorField.locator('.rs__option') + await dropdownOperatorOptions.locator('text=is not in').click() + + const valueField = page.locator('.condition__value') + await valueField.click() + const dropdownValueOptions = valueField.locator('.rs__option') + await dropdownValueOptions.locator('text=some text').click() + + await expect(page.locator(tableRowLocator)).toHaveCount(0) + }) }) describe('upload', () => {