Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DataViews: implement multiple selection for filters #59610

Merged
merged 24 commits into from Mar 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/dataviews/CHANGELOG.md
Expand Up @@ -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)
Expand Down
54 changes: 43 additions & 11 deletions packages/dataviews/README.md
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
},
]
```

Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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: {},
Expand All @@ -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;
}
} );
Expand Down Expand Up @@ -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

Expand Down
28 changes: 22 additions & 6 deletions packages/dataviews/src/constants.js
Expand Up @@ -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
Expand Down
73 changes: 50 additions & 23 deletions packages/dataviews/src/filter-summary.js
Expand Up @@ -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: <span className="dataviews-filter-summary__filter-text-name" />,
Span2: <span className="dataviews-filter-summary__filter-text-value" />,
Name: <span className="dataviews-filter-summary__filter-text-name" />,
Value: <span className="dataviews-filter-summary__filter-text-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". */
__( '<Name>%1$s is any: </Name><Value>%2$s</Value>' ),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a UX perspective, I wonder if we shouldn't switch the string based on the number of active filters:

  • Is any of: Admin, Editor
  • Is: Editor

… or if that would make things more confusing because "Is:" overlaps with OPERATOR_IS. 🤔

(I also wonder whether "Is any of" would sound more natural.)

filter.name,
activeElements.map( ( element ) => element.label ).join( ', ' )
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect that some (few) locales will want to have a say in the punctuation used to enumerate filters:

activeElements.map( ... ).join(
  /* translators: string used to join together active filters, e.g. "Author is any: Admin, Editor" */
  __( ', ')
)

@swissspidy: do you know if this is done elsewhere in WP?

),
filterTextWrappers
);
}

if ( filterInView?.operator === OPERATOR_IS_NONE ) {
return createInterpolateElement(
sprintf(
/* translators: 1: Filter name. 2: Filter value. e.g.: "Author is Admin". */
__( '<Span1>%1$s </Span1><Span2>is %2$s</Span2>' ),
/* translators: 1: Filter name. 3: Filter value. e.g.: "Author is none: Admin, Editor". */
__( '<Name>%1$s is none: </Name><Value>%2$s</Value>' ),
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". */
__( '<Span1>%1$s </Span1><Span2>is not %2$s</Span2>' ),
/* translators: 1: Filter name. 3: Filter value. e.g.: "Author is: Admin". */
__( '<Name>%1$s is: </Name><Value>%2$s</Value>' ),
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". */
__( '<Name>%1$s is not: </Name><Value>%2$s</Value>' ),
filter.name,
activeElements[ 0 ].label
),
filterTextWrappers
);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -188,7 +215,7 @@ export default function FilterSummary( {
ref={ toggleRef }
>
<FilterText
activeElement={ activeElement }
activeElements={ activeElements }
filterInView={ filterInView }
filter={ filter }
/>
Expand Down
14 changes: 10 additions & 4 deletions packages/dataviews/src/filters.js
Expand Up @@ -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( {
Expand Down Expand Up @@ -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,
} );
Expand Down