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