diff --git a/packages/ui/src/elements/WhereBuilder/Condition/index.scss b/packages/ui/src/elements/WhereBuilder/Condition/index.scss index f96e0addb2..2ef1349282 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/index.scss +++ b/packages/ui/src/elements/WhereBuilder/Condition/index.scss @@ -18,6 +18,12 @@ } } + &__field { + .field-label { + padding-bottom: 0; + } + } + &__actions { flex-shrink: 0; display: flex; diff --git a/packages/ui/src/elements/WhereBuilder/Condition/index.tsx b/packages/ui/src/elements/WhereBuilder/Condition/index.tsx index 1b67971d21..b52e55f541 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/index.tsx +++ b/packages/ui/src/elements/WhereBuilder/Condition/index.tsx @@ -1,15 +1,39 @@ -import type { Where } from 'payload/types' - import React, { useEffect, useState } from 'react' -import type { Action, FieldCondition } from '../types.js' +import type { FieldCondition } from '../types.js' export type Props = { + addCondition: ({ + andIndex, + fieldName, + orIndex, + relation, + }: { + andIndex: number + fieldName: string + orIndex: number + relation: 'and' | 'or' + }) => void andIndex: number - dispatch: (action: Action) => void + fieldName: string fields: FieldCondition[] + initialValue: string + operator: string orIndex: number - value: Where + removeCondition: ({ andIndex, orIndex }: { andIndex: number; orIndex: number }) => void + updateCondition: ({ + andIndex, + fieldName, + operator, + orIndex, + value, + }: { + andIndex: number + fieldName: string + operator: string + orIndex: number + value: string + }) => void } import { RenderCustomComponent } from '../../../elements/RenderCustomComponent/index.js' @@ -35,42 +59,46 @@ const valueFields: Record = { const baseClass = 'condition' export const Condition: React.FC = (props) => { - const { andIndex, dispatch, fields, orIndex, value } = props - const fieldName = Object.keys(value)[0] + const { + addCondition, + andIndex, + fieldName, + fields, + initialValue, + operator, + orIndex, + removeCondition, + updateCondition, + } = props const [activeField, setActiveField] = useState(() => fields.find((field) => fieldName === field.value), ) - const operatorAndValue = value?.[fieldName] ? Object.entries(value[fieldName])[0] : undefined - const queryValue = operatorAndValue?.[1] - const operatorValue = operatorAndValue?.[0] + const [internalQueryValue, setInternalQueryValue] = useState(initialValue) + const [internalOperatorOption, setInternalOperatorOption] = useState(operator) - const [internalValue, setInternalValue] = useState(queryValue) - const [internalOperatorField, setInternalOperatorField] = useState(operatorValue) - - const debouncedValue = useDebounce(internalValue, 300) + const debouncedValue = useDebounce(internalQueryValue, 300) useEffect(() => { - const newActiveField = fields.find(({ value: name }) => name === fieldName) - - if (newActiveField && newActiveField !== activeField) { - setActiveField(newActiveField) - setInternalOperatorField(null) - setInternalValue('') - } - }, [fieldName, fields, activeField]) - - useEffect(() => { - dispatch({ - type: 'update', + updateCondition({ andIndex, + fieldName, + operator: internalOperatorOption, orIndex, - value: debouncedValue || '', + value: debouncedValue, }) - }, [debouncedValue, dispatch, orIndex, andIndex]) + }, [ + debouncedValue, + andIndex, + fieldName, + internalOperatorOption, + orIndex, + updateCondition, + operator, + ]) const booleanSelect = - ['exists'].includes(operatorValue) || activeField?.props?.type === 'checkbox' + ['exists'].includes(internalOperatorOption) || activeField?.props?.type === 'checkbox' const ValueComponent = booleanSelect ? Select : valueFields[activeField?.component] || valueFields.Text @@ -90,15 +118,17 @@ export const Condition: React.FC = (props) => { { - dispatch({ - type: 'update', + setActiveField(fields.find((f) => f.value === field.value)) + updateCondition({ andIndex, - field: field?.value, + fieldName: field.value, + operator, orIndex, + value: internalQueryValue, }) }} options={fields} - value={fields.find((field) => fieldName === field.value)} + value={fields.find((field) => fieldName === field.value) || fields[0]} />
@@ -106,18 +136,19 @@ export const Condition: React.FC = (props) => { disabled={!fieldName} isClearable={false} onChange={(operator) => { - dispatch({ - type: 'update', + setInternalOperatorOption(operator.value) + updateCondition({ andIndex, + fieldName, operator: operator.value, orIndex, + value: internalQueryValue, }) - setInternalOperatorField(operator.value) }} options={activeField?.operators} value={ activeField?.operators.find( - (operator) => internalOperatorField === operator.value, + (operator) => internalOperatorOption === operator.value, ) || null } /> @@ -128,11 +159,11 @@ export const Condition: React.FC = (props) => { DefaultComponent={ValueComponent} componentProps={{ ...activeField?.props, - disabled: !operatorValue, - onChange: setInternalValue, - operator: operatorValue, + disabled: !internalOperatorOption, + onChange: setInternalQueryValue, + operator: internalOperatorOption, options: valueOptions, - value: internalValue, + value: internalQueryValue ?? '', }} />
@@ -144,8 +175,7 @@ export const Condition: React.FC = (props) => { icon="x" iconStyle="with-border" onClick={() => - dispatch({ - type: 'remove', + removeCondition({ andIndex, orIndex, }) @@ -158,10 +188,9 @@ export const Condition: React.FC = (props) => { icon="plus" iconStyle="with-border" onClick={() => - dispatch({ - type: 'add', - andIndex: andIndex + 1, - field: fields[0].value, + addCondition({ + andIndex, + fieldName, orIndex, relation: 'and', }) diff --git a/packages/ui/src/elements/WhereBuilder/index.tsx b/packages/ui/src/elements/WhereBuilder/index.tsx index ee67a2839b..343c44737c 100644 --- a/packages/ui/src/elements/WhereBuilder/index.tsx +++ b/packages/ui/src/elements/WhereBuilder/index.tsx @@ -1,12 +1,14 @@ -import type { Where } from 'payload/types' - import { getTranslation } from '@payloadcms/translations' -import { flattenTopLevelFields } from 'payload/utilities' -import React, { useReducer, useState } from 'react' +import { FieldLabel } from '@payloadcms/ui/forms/FieldLabel' +import { useComponentMap } from '@payloadcms/ui/providers/ComponentMap' +import React, { useState } from 'react' +import type { + CollectionComponentMap, + FieldMap, +} from '../../providers/ComponentMap/buildComponentMap/types.js' import type { WhereBuilderProps } from './types.js' -import { useConfig } from '../../providers/Config/index.js' import { useListQuery } from '../../providers/ListQuery/index.js' import { useSearchParams } from '../../providers/SearchParams/index.js' import { useTranslation } from '../../providers/Translation/index.js' @@ -14,14 +16,13 @@ import { Button } from '../Button/index.js' import { Condition } from './Condition/index.js' import fieldTypes from './field-types.js' import './index.scss' -import reducer from './reducer.js' import { transformWhereQuery } from './transformWhereQuery.js' import validateWhereQuery from './validateWhereQuery.js' const baseClass = 'where-builder' -const reduceFields = (fields, i18n) => - flattenTopLevelFields(fields).reduce((reduced, field) => { +const reduceFields = (fieldMap: FieldMap, i18n) => + fieldMap.reduce((reduced, field) => { if (typeof fieldTypes[field.type] === 'object') { const operatorKeys = new Set() const operators = fieldTypes[field.type].operators.reduce((acc, operator) => { @@ -39,7 +40,12 @@ const reduceFields = (fields, i18n) => }, []) const formattedField = { - label: getTranslation(field.label || field.name, i18n), + label: ( + + ), value: field.name, ...fieldTypes[field.type], operators, @@ -63,39 +69,131 @@ export { WhereBuilderProps } export const WhereBuilder: React.FC = (props) => { const { collectionPluralLabel, collectionSlug } = props const { i18n, t } = useTranslation() + const { getComponentMap } = useComponentMap() - const config = useConfig() - const collection = config.collections.find((c) => c.slug === collectionSlug) - const [reducedFields] = useState(() => reduceFields(collection.fields, i18n)) + const { fieldMap } = getComponentMap({ collectionSlug }) as CollectionComponentMap + const [reducedFields] = useState(() => reduceFields(fieldMap, i18n)) const { searchParams } = useSearchParams() const { handleWhereChange } = useListQuery() + const [shouldUpdateQuery, setShouldUpdateQuery] = React.useState(false) // 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((state) => handleWhereChange({ or: state })), - searchParams.where as Where, - (whereFromSearch: Where) => { - if (whereFromSearch) { - if (validateWhereQuery(whereFromSearch)) { - return whereFromSearch.or - } + /* + stored conditions look like this: + [ + _or_ & _and_ queries have the same shape: + { + and: [{ + category: { + equals: 'category-a' + } + }] + }, - // 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: ${JSON.stringify(whereFromSearch)}`) + { + and:[{ + category: { + equals: 'category-b' + }, + text: { + not_equals: 'oranges' + }, + }] } - return [] + ] + */ + const [conditions, setConditions] = React.useState(() => { + const whereFromSearch = searchParams.where + if (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: ${JSON.stringify(whereFromSearch)}`) + } + + return [] + }) + + const addCondition = React.useCallback(({ andIndex, fieldName, orIndex, relation }) => { + setConditions((prevConditions) => { + const newConditions = [...prevConditions] + if (relation === 'and') { + newConditions[orIndex].and.splice(andIndex, 0, { [fieldName]: {} }) + } else { + newConditions.push({ + and: [ + { + [fieldName]: {}, + }, + ], + }) + } + + return newConditions + }) + }, []) + + const updateCondition = React.useCallback( + ({ andIndex, fieldName: fieldNameArg, operator: operatorArg, orIndex, value: valueArg }) => { + setConditions((prevConditions) => { + const newConditions = [...prevConditions] + if (typeof newConditions[orIndex].and[andIndex] === 'object') { + const [existingFieldName, existingCondition] = Object.entries( + newConditions[orIndex].and[andIndex], + )?.[0] || [fieldNameArg, operatorArg] + const fieldName = existingFieldName || fieldNameArg + const operator = operatorArg || Object.keys(existingCondition)?.[0] || undefined + const value = valueArg ?? (operator ? newConditions[orIndex].and[andIndex][operator] : '') + + if (fieldName) { + newConditions[orIndex].and[andIndex] = { + [fieldName]: operator ? { [operator]: value } : {}, + } + } + + if (fieldName && operator && ![null, undefined].includes(value)) { + setShouldUpdateQuery(true) + } + } + + return newConditions + }) }, + [], ) + const removeCondition = React.useCallback(({ andIndex, orIndex }) => { + setConditions((prevConditions) => { + const newConditions = [...prevConditions] + newConditions[orIndex].and.splice(andIndex, 1) + + if (newConditions[orIndex].and.length === 0) { + newConditions.splice(orIndex, 1) + } + + return newConditions + }) + setShouldUpdateQuery(true) + }, []) + + React.useEffect(() => { + if (shouldUpdateQuery) { + handleWhereChange({ or: conditions }) + setShouldUpdateQuery(false) + } + }, [conditions, handleWhereChange, shouldUpdateQuery]) + return (
{conditions.length > 0 && ( @@ -109,21 +207,34 @@ export const WhereBuilder: React.FC = (props) => { {orIndex !== 0 &&
{t('general:or')}
}
    {Array.isArray(or?.and) && - or.and.map((_, andIndex) => ( -
  • - {andIndex !== 0 && ( -
    {t('general:and')}
    - )} - -
  • - ))} + or.and.map((_, andIndex) => { + const fieldName = Object.keys(conditions[orIndex].and[andIndex])[0] + const operator = + Object.keys(conditions[orIndex].and[andIndex]?.[fieldName] || {})?.[0] || + undefined + const initialValue = + conditions[orIndex].and[andIndex]?.[fieldName]?.[operator] || '' + + return ( +
  • + {andIndex !== 0 && ( +
    {t('general:and')}
    + )} + +
  • + ) + })}
))} @@ -135,8 +246,12 @@ export const WhereBuilder: React.FC = (props) => { iconPosition="left" iconStyle="with-border" onClick={() => { - if (reducedFields.length > 0) - dispatchConditions({ type: 'add', field: reducedFields[0].value }) + addCondition({ + andIndex: 0, + fieldName: reducedFields[0].value, + orIndex: conditions.length, + relation: 'or', + }) }} > {t('general:or')} @@ -153,8 +268,14 @@ export const WhereBuilder: React.FC = (props) => { iconPosition="left" iconStyle="with-border" onClick={() => { - if (reducedFields.length > 0) - dispatchConditions({ type: 'add', field: reducedFields[0].value }) + if (reducedFields.length > 0) { + addCondition({ + andIndex: 0, + fieldName: reducedFields[0].value, + orIndex: conditions.length, + relation: 'or', + }) + } }} > {t('general:addFilter')} diff --git a/packages/ui/src/elements/WhereBuilder/reducer.ts b/packages/ui/src/elements/WhereBuilder/reducer.ts deleted file mode 100644 index 960875139f..0000000000 --- a/packages/ui/src/elements/WhereBuilder/reducer.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { Where } from 'payload/types' - -import type { Action } from './types.js' - -const reducer = - (callback?: (state: Where[]) => void) => - (state: Where[], action: Action): Where[] => { - const newState = [...state] - - const { andIndex, orIndex } = action - - switch (action.type) { - case 'add': { - const { field, relation } = action - - if (relation === 'and') { - newState[orIndex].and.splice(andIndex, 0, { [field]: {} }) - } else { - newState.push({ - and: [ - { - [field]: {}, - }, - ], - }) - } - break - } - - case 'remove': { - newState[orIndex].and.splice(andIndex, 1) - - if (newState[orIndex].and.length === 0) { - newState.splice(orIndex, 1) - } - break - } - - case 'update': { - const { field, operator, value } = action - - if (typeof newState[orIndex].and[andIndex] === 'object') { - newState[orIndex].and[andIndex] = { - ...newState[orIndex].and[andIndex], - } - - const [existingFieldName, existingCondition] = Object.entries( - newState[orIndex].and[andIndex], - )[0] || [undefined, undefined] - - if (operator) { - newState[orIndex].and[andIndex] = { - [existingFieldName]: { - [operator]: Object.values(existingCondition)[0], - }, - } - } - - if (field) { - newState[orIndex].and[andIndex] = { - [field]: operator ? { [operator]: value } : {}, - } - } - - if (value !== undefined) { - newState[orIndex].and[andIndex] = { - [existingFieldName]: Object.keys(existingCondition)[0] - ? { - [Object.keys(existingCondition)[0]]: value, - } - : {}, - } - } - } - - break - } - - default: { - return newState - } - } - - if (callback) callback(newState) - return newState - } - -export default reducer