diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index 80579710332ba..7c499b16c7501 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Enhancement + +- DataViews now supports multi-selection. A new set of filter operators has been introduced: `is`, `isNot`, `isAny`, `isNone`. Single-selection operators are `is` and `isNot`, and multi-selection operators are `isAny` and `isNone`. If no operators are declared for a filter, it will support multi-selection. Additionally, the old filter operators `in` and `notIn` operators have been deprecated and will work as `is` and `isNot` respectively. Please, migrate to the new operators as they'll be removed soon. + ## 0.7.0 (2024-03-06) ## 0.6.0 (2024-02-21) diff --git a/packages/dataviews/README.md b/packages/dataviews/README.md index bbc2271db6573..32135bc65eba2 100644 --- a/packages/dataviews/README.md +++ b/packages/dataviews/README.md @@ -59,6 +59,14 @@ The fields describe the visible items for each record in the dataset. Example: ```js +const STATUSES = [ + { value: 'draft', label: __( 'Draft' ) }, + { value: 'future', label: __( 'Scheduled' ) }, + { value: 'pending', label: __( 'Pending Review' ) }, + { value: 'private', label: __( 'Private' ) }, + { value: 'publish', label: __( 'Published' ) }, + { value: 'trash', label: __( 'Trash' ) }, +]; const fields = [ { id: 'title', @@ -89,9 +97,25 @@ const fields = [ elements: [ { value: 1, label: 'Admin' } { value: 2, label: 'User' } - ] + ], + filterBy: { + operators: [ 'is', 'isNot' ] + }, enableSorting: false - } + }, + { + header: __( 'Status' ), + id: 'status', + getValue: ( { item } ) => + STATUSES.find( ( { value } ) => value === item.status ) + ?.label ?? item.status, + type: 'enumeration', + elements: STATUSES, + filterBy: { + operators: [ 'isAny' ], + }, + enableSorting: false, + }, ] ``` @@ -120,8 +144,8 @@ const view = { type: 'table', search: '', filters: [ - { field: 'author', operator: 'in', value: 2 }, - { field: 'status', operator: 'in', value: 'publish,draft' } + { field: 'author', operator: 'is', value: 2 }, + { field: 'status', operator: 'isAny', value: [ 'publish', 'draft'] } ], page: 1, perPage: 5, @@ -140,7 +164,7 @@ Properties: - `search`: the text search applied to the dataset. - `filters`: the filters applied to the dataset. Each item describes: - `field`: which field this filter is bound to. - - `operator`: which type of filter it is. One of `in`, `notIn`. See "Operator types". + - `operator`: which type of filter it is. See "Operator types". - `value`: the actual value selected by the user. - `perPage`: number of records to show per page. - `page`: the page that is visible. @@ -172,8 +196,8 @@ function MyCustomPageTable() { }, search: '', filters: [ - { field: 'author', operator: 'in', value: 2 }, - { field: 'status', operator: 'in', value: 'publish,draft' } + { field: 'author', operator: 'is', value: 2 }, + { field: 'status', operator: 'isAny', value: [ 'publish', 'draft' ] } ], hiddenFields: [ 'date', 'featured-image' ], layout: {}, @@ -182,10 +206,10 @@ function MyCustomPageTable() { const queryArgs = useMemo( () => { const filters = {}; view.filters.forEach( ( filter ) => { - if ( filter.field === 'status' && filter.operator === 'in' ) { + if ( filter.field === 'status' && filter.operator === 'isAny' ) { filters.status = filter.value; } - if ( filter.field === 'author' && filter.operator === 'in' ) { + if ( filter.field === 'author' && filter.operator === 'is' ) { filters.author = filter.value; } } ); @@ -282,8 +306,16 @@ Callback that signals the user triggered the details for one of more items, and ### Operators -- `in`: operator to be used in filters for fields of type `enumeration`. -- `notIn`: operator to be used in filters for fields of type `enumeration`. +Allowed operators for fields of type `enumeration`: + +- `is`: whether the item is equal to a single value. +- `isNot`: whether the item is not equal to a single value. +- `isAny`: whether the item is present in a list of values. +- `isNone`: whether the item is not present in a list of values. + +`is` and `isNot` are single-selection operators, while `isAny` and `isNone` are multi-selection. By default, a filter with no operators declared will support multi-selection. A filter cannot mix single-selection & multi-selection operators; if a single-selection operator is present in the list of valid operators, the multi-selection ones will be discarded and the filter won't allow selecting more than one item. + +> The legacy operators `in` and `notIn` have been deprecated and will be removed soon. In the meantime, they work as `is` and `isNot` operators, respectively. ## Contributing to this package diff --git a/packages/dataviews/src/constants.js b/packages/dataviews/src/constants.js index b3d17d7fd1145..bae974ff4eb5f 100644 --- a/packages/dataviews/src/constants.js +++ b/packages/dataviews/src/constants.js @@ -20,17 +20,33 @@ import ViewList from './view-list'; export const ENUMERATION_TYPE = 'enumeration'; // Filter operators. -export const OPERATOR_IN = 'in'; -export const OPERATOR_NOT_IN = 'notIn'; +export const OPERATOR_IS = 'is'; +export const OPERATOR_IS_NOT = 'isNot'; +export const OPERATOR_IS_ANY = 'isAny'; +export const OPERATOR_IS_NONE = 'isNone'; +export const ALL_OPERATORS = [ + OPERATOR_IS, + OPERATOR_IS_NOT, + OPERATOR_IS_ANY, + OPERATOR_IS_NONE, +]; export const OPERATORS = { - [ OPERATOR_IN ]: { - key: 'in-filter', + [ OPERATOR_IS ]: { + key: 'is-filter', label: __( 'Is' ), }, - [ OPERATOR_NOT_IN ]: { - key: 'not-in-filter', + [ OPERATOR_IS_NOT ]: { + key: 'is-not-filter', label: __( 'Is not' ), }, + [ OPERATOR_IS_ANY ]: { + key: 'is-any-filter', + label: __( 'Is any' ), + }, + [ OPERATOR_IS_NONE ]: { + key: 'is-none-filter', + label: __( 'Is none' ), + }, }; // Sorting diff --git a/packages/dataviews/src/filter-summary.js b/packages/dataviews/src/filter-summary.js index 6e1e3e9e2620b..f382efb32be80 100644 --- a/packages/dataviews/src/filter-summary.js +++ b/packages/dataviews/src/filter-summary.js @@ -24,43 +24,67 @@ import { ENTER, SPACE } from '@wordpress/keycodes'; * Internal dependencies */ import SearchWidget from './search-widget'; -import { OPERATOR_IN, OPERATOR_NOT_IN, OPERATORS } from './constants'; +import { + OPERATORS, + OPERATOR_IS, + OPERATOR_IS_NOT, + OPERATOR_IS_ANY, + OPERATOR_IS_NONE, +} from './constants'; -const FilterText = ( { activeElement, filterInView, filter } ) => { - if ( activeElement === undefined ) { +const FilterText = ( { activeElements, filterInView, filter } ) => { + if ( activeElements === undefined || activeElements.length === 0 ) { return filter.name; } const filterTextWrappers = { - Span1: , - Span2: , + Name: , + Value: , }; - if ( - activeElement !== undefined && - filterInView?.operator === OPERATOR_IN - ) { + if ( filterInView?.operator === OPERATOR_IS_ANY ) { + return createInterpolateElement( + sprintf( + /* translators: 1: Filter name. 3: Filter value. e.g.: "Author is any: Admin, Editor". */ + __( '%1$s is any: %2$s' ), + filter.name, + activeElements.map( ( element ) => element.label ).join( ', ' ) + ), + filterTextWrappers + ); + } + + if ( filterInView?.operator === OPERATOR_IS_NONE ) { return createInterpolateElement( sprintf( - /* translators: 1: Filter name. 2: Filter value. e.g.: "Author is Admin". */ - __( '%1$s is %2$s' ), + /* translators: 1: Filter name. 3: Filter value. e.g.: "Author is none: Admin, Editor". */ + __( '%1$s is none: %2$s' ), filter.name, - activeElement.label + activeElements.map( ( element ) => element.label ).join( ', ' ) ), filterTextWrappers ); } - if ( - activeElement !== undefined && - filterInView?.operator === OPERATOR_NOT_IN - ) { + if ( filterInView?.operator === OPERATOR_IS ) { return createInterpolateElement( sprintf( - /* translators: 1: Filter name. 2: Filter value. e.g.: "Author is not Admin". */ - __( '%1$s is not %2$s' ), + /* translators: 1: Filter name. 3: Filter value. e.g.: "Author is: Admin". */ + __( '%1$s is: %2$s' ), filter.name, - activeElement.label + activeElements[ 0 ].label + ), + filterTextWrappers + ); + } + + if ( filterInView?.operator === OPERATOR_IS_NOT ) { + return createInterpolateElement( + sprintf( + /* translators: 1: Filter name. 3: Filter value. e.g.: "Author is not: Admin". */ + __( '%1$s is not: %2$s' ), + filter.name, + activeElements[ 0 ].label ), filterTextWrappers ); @@ -140,9 +164,12 @@ export default function FilterSummary( { const toggleRef = useRef(); const { filter, view, onChangeView } = commonProps; const filterInView = view.filters.find( ( f ) => f.field === filter.field ); - const activeElement = filter.elements.find( - ( element ) => element.value === filterInView?.value - ); + const activeElements = filter.elements.filter( ( element ) => { + if ( filter.singleSelection ) { + return element.value === filterInView?.value; + } + return filterInView?.value?.includes( element.value ); + } ); const isPrimary = filter.isPrimary; const hasValues = filterInView?.value !== undefined; const canResetOrRemove = ! isPrimary || hasValues; @@ -188,7 +215,7 @@ export default function FilterSummary( { ref={ toggleRef } > diff --git a/packages/dataviews/src/filters.js b/packages/dataviews/src/filters.js index eb1bce3a42dc4..9f5cb0aedf7d8 100644 --- a/packages/dataviews/src/filters.js +++ b/packages/dataviews/src/filters.js @@ -10,7 +10,12 @@ import FilterSummary from './filter-summary'; import AddFilter from './add-filter'; import ResetFilters from './reset-filters'; import { sanitizeOperators } from './utils'; -import { ENUMERATION_TYPE, OPERATOR_IN, OPERATOR_NOT_IN } from './constants'; +import { + ENUMERATION_TYPE, + ALL_OPERATORS, + OPERATOR_IS, + OPERATOR_IS_NOT, +} from './constants'; import { __experimentalHStack as HStack } from '@wordpress/components'; const Filters = memo( function Filters( { @@ -43,15 +48,16 @@ const Filters = memo( function Filters( { field: field.id, name: field.header, elements: field.elements, + singleSelection: operators.some( ( op ) => + [ OPERATOR_IS, OPERATOR_IS_NOT ].includes( op ) + ), operators, isVisible: isPrimary || view.filters.some( ( f ) => f.field === field.id && - [ OPERATOR_IN, OPERATOR_NOT_IN ].includes( - f.operator - ) + ALL_OPERATORS.includes( f.operator ) ), isPrimary, } ); diff --git a/packages/dataviews/src/search-widget.js b/packages/dataviews/src/search-widget.js index f8b3e84fd8ba3..d6fcddf969b60 100644 --- a/packages/dataviews/src/search-widget.js +++ b/packages/dataviews/src/search-widget.js @@ -15,7 +15,7 @@ import { Icon, privateApis as componentsPrivateApis, } from '@wordpress/components'; -import { search } from '@wordpress/icons'; +import { search, check } from '@wordpress/icons'; import { SVG, Circle } from '@wordpress/primitives'; /** @@ -39,6 +39,36 @@ function normalizeSearchInput( input = '' ) { return removeAccents( input.trim().toLowerCase() ); } +const getCurrentValue = ( filterDefinition, currentFilter ) => { + if ( filterDefinition.singleSelection ) { + return currentFilter?.value; + } + + if ( Array.isArray( currentFilter?.value ) ) { + return currentFilter.value; + } + + if ( ! Array.isArray( currentFilter?.value ) && !! currentFilter?.value ) { + return [ currentFilter.value ]; + } + + return []; +}; + +const getNewValue = ( filterDefinition, currentFilter, value ) => { + if ( filterDefinition.singleSelection ) { + return value; + } + + if ( Array.isArray( currentFilter?.value ) ) { + return currentFilter.value.includes( value ) + ? currentFilter.value.filter( ( v ) => v !== value ) + : [ ...currentFilter.value, value ]; + } + + return [ value ]; +}; + function ListBox( { view, filter, onChangeView } ) { const compositeStore = useCompositeStore( { virtualFocus: true, @@ -48,10 +78,10 @@ function ListBox( { view, filter, onChangeView } ) { // so the first item is not selected, since the focus is on the operators control. defaultActiveId: filter.operators?.length === 1 ? undefined : null, } ); - const selectedFilter = view.filters.find( - ( _filter ) => _filter.field === filter.field + const currentFilter = view.filters.find( + ( f ) => f.field === filter.field ); - const selectedValues = selectedFilter?.value; + const currentValue = getCurrentValue( filter, currentFilter ); return ( } onClick={ () => { - const currentFilter = view.filters.find( - ( _filter ) => - _filter.field === filter.field - ); const newFilters = currentFilter ? [ ...view.filters.map( @@ -101,7 +127,11 @@ function ListBox( { view, filter, onChangeView } ) { currentFilter.operator || filter .operators[ 0 ], - value: element.value, + value: getNewValue( + filter, + currentFilter, + element.value + ), }; } return _filter; @@ -113,7 +143,11 @@ function ListBox( { view, filter, onChangeView } ) { { field: filter.field, operator: filter.operators[ 0 ], - value: element.value, + value: getNewValue( + filter, + currentFilter, + element.value + ), }, ]; onChangeView( { @@ -126,9 +160,14 @@ function ListBox( { view, filter, onChangeView } ) { } > - { selectedValues === element.value && ( - - ) } + { filter.singleSelection && + currentValue === element.value && ( + + ) } + { ! filter.singleSelection && + currentValue.includes( element.value ) && ( + + ) } { element.label } @@ -147,10 +186,10 @@ function ListBox( { view, filter, onChangeView } ) { function ComboboxList( { view, filter, onChangeView } ) { const [ searchValue, setSearchValue ] = useState( '' ); const deferredSearchValue = useDeferredValue( searchValue ); - const selectedFilter = view.filters.find( + const currentFilter = view.filters.find( ( _filter ) => _filter.field === filter.field ); - const selectedValues = selectedFilter?.value; + const currentValue = getCurrentValue( filter, currentFilter ); const matches = useMemo( () => { const normalizedSearch = normalizeSearchInput( deferredSearchValue ); return filter.elements.filter( ( item ) => @@ -160,10 +199,8 @@ function ComboboxList( { view, filter, onChangeView } ) { return ( { - const currentFilter = view.filters.find( - ( _filter ) => _filter.field === filter.field - ); const newFilters = currentFilter ? [ ...view.filters.map( ( _filter ) => { @@ -223,9 +260,14 @@ function ComboboxList( { view, filter, onChangeView } ) { focusOnHover > - { selectedValues === element.value && ( - - ) } + { filter.singleSelection && + currentValue === element.value && ( + + ) } + { ! filter.singleSelection && + currentValue.includes( element.value ) && ( + + ) } { let operators = field.filterBy?.operators; + + // Assign default values. if ( ! operators || ! Array.isArray( operators ) ) { - operators = Object.keys( OPERATORS ); + operators = [ OPERATOR_IS_ANY, OPERATOR_IS_NONE ]; + } + + // Transform legacy in, notIn operators to is, isNot. + // To be removed in the future. + if ( operators.includes( 'in' ) ) { + operators = operators.filter( ( operator ) => operator !== 'is' ); + operators.push( 'is' ); + } + if ( operators.includes( 'notIn' ) ) { + operators = operators.filter( ( operator ) => operator !== 'notIn' ); + operators.push( 'isNot' ); } - return operators.filter( ( operator ) => - Object.keys( OPERATORS ).includes( operator ) + + // Make sure only valid operators are used. + operators = operators.filter( ( operator ) => + ALL_OPERATORS.includes( operator ) ); + + // Do not allow mixing single & multiselection operators. + // Remove multiselection operators if any of the single selection ones is present. + if ( + operators.includes( OPERATOR_IS ) || + operators.includes( OPERATOR_IS_NOT ) + ) { + operators = operators.filter( ( operator ) => + [ OPERATOR_IS, OPERATOR_IS_NOT ].includes( operator ) + ); + } + + return operators; }; export function WithDropDownMenuSeparators( { children } ) { diff --git a/packages/edit-site/src/components/page-pages/index.js b/packages/edit-site/src/components/page-pages/index.js index 73dd87eeab5a5..b8b735c4bfcf6 100644 --- a/packages/edit-site/src/components/page-pages/index.js +++ b/packages/edit-site/src/components/page-pages/index.js @@ -30,8 +30,8 @@ import { LAYOUT_GRID, LAYOUT_TABLE, LAYOUT_LIST, - OPERATOR_IN, - OPERATOR_NOT_IN, + OPERATOR_IS_ANY, + OPERATOR_IS_NONE, } from '../../utils/constants'; import { @@ -222,18 +222,18 @@ export default function PagePages() { view.filters.forEach( ( filter ) => { if ( filter.field === 'status' && - filter.operator === OPERATOR_IN + filter.operator === OPERATOR_IS_ANY ) { filters.status = filter.value; } if ( filter.field === 'author' && - filter.operator === OPERATOR_IN + filter.operator === OPERATOR_IS_ANY ) { filters.author = filter.value; } else if ( filter.field === 'author' && - filter.operator === OPERATOR_NOT_IN + filter.operator === OPERATOR_IS_NONE ) { filters.author_exclude = filter.value; } @@ -331,7 +331,7 @@ export default function PagePages() { elements: STATUSES, enableSorting: false, filterBy: { - operators: [ OPERATOR_IN ], + operators: [ OPERATOR_IS_ANY ], }, }, { diff --git a/packages/edit-site/src/components/page-patterns/index.js b/packages/edit-site/src/components/page-patterns/index.js index 8ca10d2357e55..d4cedaf27dee5 100644 --- a/packages/edit-site/src/components/page-patterns/index.js +++ b/packages/edit-site/src/components/page-patterns/index.js @@ -46,7 +46,7 @@ import { PATTERN_SYNC_TYPES, PATTERN_DEFAULT_CATEGORY, ENUMERATION_TYPE, - OPERATOR_IN, + OPERATOR_IS, } from '../../utils/constants'; import { exportJSONaction, @@ -323,7 +323,7 @@ export default function DataviewsPatterns() { type: ENUMERATION_TYPE, elements: SYNC_FILTERS, filterBy: { - operators: [ OPERATOR_IN ], + operators: [ OPERATOR_IS ], isPrimary: true, }, enableSorting: false, diff --git a/packages/edit-site/src/components/page-templates-template-parts/index.js b/packages/edit-site/src/components/page-templates-template-parts/index.js index 1462c5143a71d..4ada7f98391d6 100644 --- a/packages/edit-site/src/components/page-templates-template-parts/index.js +++ b/packages/edit-site/src/components/page-templates-template-parts/index.js @@ -40,8 +40,8 @@ import { TEMPLATE_POST_TYPE, TEMPLATE_PART_POST_TYPE, ENUMERATION_TYPE, - OPERATOR_IN, - OPERATOR_NOT_IN, + OPERATOR_IS_ANY, + OPERATOR_IS_NONE, LAYOUT_GRID, LAYOUT_TABLE, LAYOUT_LIST, @@ -378,19 +378,19 @@ export default function PageTemplatesTemplateParts( { postType } ) { view.filters.forEach( ( filter ) => { if ( filter.field === 'author' && - filter.operator === OPERATOR_IN && - !! filter.value + filter.operator === OPERATOR_IS_ANY && + filter?.value?.length > 0 ) { filteredData = filteredData.filter( ( item ) => { - return item.author_text === filter.value; + return filter.value.includes( item.author_text ); } ); } else if ( filter.field === 'author' && - filter.operator === OPERATOR_NOT_IN && - !! filter.value + filter.operator === OPERATOR_IS_NONE && + filter?.value?.length > 0 ) { filteredData = filteredData.filter( ( item ) => { - return item.author_text !== filter.value; + return ! filter.value.includes( item.author_text ); } ); } } ); diff --git a/packages/edit-site/src/components/sidebar-dataviews/default-views.js b/packages/edit-site/src/components/sidebar-dataviews/default-views.js index 1320564d0d9af..c02869dbc7fdb 100644 --- a/packages/edit-site/src/components/sidebar-dataviews/default-views.js +++ b/packages/edit-site/src/components/sidebar-dataviews/default-views.js @@ -11,7 +11,7 @@ import { LAYOUT_LIST, LAYOUT_TABLE, LAYOUT_GRID, - OPERATOR_IN, + OPERATOR_IS_ANY, } from '../../utils/constants'; export const DEFAULT_CONFIG_PER_VIEW_TYPE = { @@ -61,7 +61,11 @@ export const DEFAULT_VIEWS = { view: { ...DEFAULT_PAGE_BASE, filters: [ - { field: 'status', operator: OPERATOR_IN, value: 'draft' }, + { + field: 'status', + operator: OPERATOR_IS_ANY, + value: 'draft', + }, ], }, }, @@ -72,7 +76,11 @@ export const DEFAULT_VIEWS = { view: { ...DEFAULT_PAGE_BASE, filters: [ - { field: 'status', operator: OPERATOR_IN, value: 'trash' }, + { + field: 'status', + operator: OPERATOR_IS_ANY, + value: 'trash', + }, ], }, }, diff --git a/packages/edit-site/src/utils/constants.js b/packages/edit-site/src/utils/constants.js index f5ca89b9fb62c..dfae1102df921 100644 --- a/packages/edit-site/src/utils/constants.js +++ b/packages/edit-site/src/utils/constants.js @@ -50,5 +50,7 @@ export const LAYOUT_GRID = 'grid'; export const LAYOUT_TABLE = 'table'; export const LAYOUT_LIST = 'list'; export const ENUMERATION_TYPE = 'enumeration'; -export const OPERATOR_IN = 'in'; -export const OPERATOR_NOT_IN = 'notIn'; +export const OPERATOR_IS = 'is'; +export const OPERATOR_IS_NOT = 'isNot'; +export const OPERATOR_IS_ANY = 'isAny'; +export const OPERATOR_IS_NONE = 'isNone';