Compare commits

...

3 Commits

Author SHA1 Message Date
Patrik Kozak
bc729c9838 fix: remove in & not in operators for hasMany: false relationship fields 2025-04-08 11:44:57 -04:00
Jessica Chowdhury
ec34e64261 fix(ui): resets value in where builder when operator changes (#11136)
### What?
The list filters in the collection view allows invalid queries. If you
enter a value and then change operator, the value will remain even if it
doesn't pass the new value field validation, and an error is thrown.

### Why?
The value isn't reset or revalidated on operator change. It is reset on
field change.

### How?
Resets the value field when the operator changes.

Fixes #10648
2025-04-08 14:52:11 +01:00
Jessica Chowdhury
f079eced8a fix: array minRow validation should not show when non-required with no rows (#12037)
### What?
UI only issue: An array row with `required: false` and `minRows: x` was
displaying an error banner - this should only happen if one or more rows
are present.

The validation is not affected, the document still saved as expected,
but the error should not be inaccurately displayed.

### Why?
The logic for displaying the `minRow` validation error was `rows.length
> minRows` and it needs to be `rows.length > 1 && rows.length > minRows`

### How?
Updates the UI logic.

Fixes #12010
2025-04-08 13:47:29 +00:00
6 changed files with 91 additions and 5 deletions

View File

@@ -26,8 +26,9 @@ import { useEffectEvent } from '../../../hooks/useEffectEvent.js'
import { useTranslation } from '../../../providers/Translation/index.js'
import { Button } from '../../Button/index.js'
import { ReactSelect } from '../../ReactSelect/index.js'
import './index.scss'
import { DefaultFilter } from './DefaultFilter/index.js'
import { getOperatorValueTypes } from './validOperators.js'
import './index.scss'
const baseClass = 'condition'
@@ -103,12 +104,25 @@ export const Condition: React.FC<Props> = (props) => {
const handleOperatorChange = useCallback(
async (operator: Option<Operator>) => {
const operatorValueTypes = getOperatorValueTypes(reducedField.field.type)
const validOperatorValue = operatorValueTypes[operator.value] || 'any'
const isValidValue =
validOperatorValue === 'any' ||
typeof value === validOperatorValue ||
(validOperatorValue === 'boolean' && (value === 'true' || value === 'false'))
if (!isValidValue) {
// if the current value is not valid for the new operator
// reset the value before passing it to updateCondition
setInternalValue(undefined)
}
await updateCondition({
andIndex,
field: reducedField,
operator: operator.value,
orIndex,
value,
value: isValidValue ? value : undefined,
})
},
[andIndex, reducedField, orIndex, updateCondition, value],

View File

@@ -0,0 +1,34 @@
export const getOperatorValueTypes = (fieldType) => {
return {
all: 'any',
contains: 'string',
equals: 'any',
/*
* exists:
* The expected value is boolean, but it's passed as a string ('true' or 'false').
* Need to additionally check if the value is strictly 'true' or 'false' as a string,
* rather than using a direct typeof comparison.
* This is handled as:
* validOperatorValue === 'boolean' && (value === 'true' || value === 'false')
*/
exists: 'boolean',
/*
* greater_than, greater_than_equal, less_than, less_than_equal:
* Used for number and date fields:
* - For date fields, the value is an object (e.g., Mon Feb 17 2025 12:00:00 GMT+0000).
* - For number fields, the value is a string representing the number.
*/
greater_than: fieldType === 'date' ? 'object' : 'string',
greater_than_equal: fieldType === 'date' ? 'object' : 'string',
in: 'any',
intersects: 'any',
less_than: fieldType === 'date' ? 'object' : 'string',
less_than_equal: fieldType === 'date' ? 'object' : 'string',
like: 'string',
near: 'any',
not_equals: 'any',
not_in: 'any',
not_like: 'string',
within: 'any',
}
}

View File

@@ -130,7 +130,15 @@ export const reduceFields = ({
if (typeof fieldTypes[field.type] === 'object') {
const operatorKeys = new Set()
const operators = fieldTypes[field.type].operators.reduce((acc, operator) => {
let fieldTypeOperators = [...fieldTypes[field.type].operators]
if (field.type === 'relationship' && field.hasMany !== true) {
fieldTypeOperators = fieldTypeOperators.filter(
(op) => op.value !== 'in' && op.value !== 'not_in',
)
}
const operators = fieldTypeOperators.reduce((acc, operator) => {
if (!operatorKeys.has(operator.value)) {
operatorKeys.add(operator.value)
const operatorKey = `operators:${operator.label}` as ClientTranslationKeys

View File

@@ -201,7 +201,7 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
const fieldHasErrors = submitted && errorPaths.length > 0
const showRequired = (readOnly || disabled) && rows.length === 0
const showMinRows = rows.length < minRows || (required && rows.length === 0)
const showMinRows = (rows.length && rows.length < minRows) || (required && rows.length === 0)
return (
<div

View File

@@ -413,7 +413,6 @@ describe('List View', () => {
await expect(whereBuilder.locator('.condition__value input')).toHaveValue('')
})
// eslint-disable-next-line playwright/expect-expect
test('should remove condition from URL when value is cleared', async () => {
await page.goto(postsUrl.list)

View File

@@ -4,6 +4,7 @@ import { expect, test } from '@playwright/test'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
import { openDocControls } from 'helpers/e2e/openDocControls.js'
import { openListFilters } from 'helpers/e2e/openListFilters.js'
import { openCreateDocDrawer, openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
import path from 'path'
import { wait } from 'payload/shared'
@@ -650,6 +651,36 @@ describe('relationship', () => {
await expect(page.locator(tableRowLocator)).toHaveCount(1)
})
test('should not allow filtering by relationship field hasMany false / in & not in', async () => {
await page.goto(url.list)
await openListFilters(page, {})
const whereBuilder = page.locator('.where-builder')
await whereBuilder.locator('.where-builder__add-first-filter').click()
const conditionField = whereBuilder.locator('.condition__field')
await conditionField.click()
await conditionField
.locator('.rs__option', {
hasText: exactText('Relationship'),
})
?.click()
await expect(whereBuilder.locator('.condition__field')).toContainText('Relationship')
const operatorInput = whereBuilder.locator('.condition__operator')
await operatorInput.click()
const operatorOptions = operatorInput.locator('.rs__option')
const optionTexts = await operatorOptions.allTextContents()
await expect.poll(() => optionTexts).not.toContain('is in')
await expect.poll(() => optionTexts).not.toContain('is not in')
})
})
async function createTextFieldDoc(overrides?: Partial<TextField>): Promise<TextField> {