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: Add a utility to share filtering, sorting and pagination logic #59897

Merged
merged 13 commits into from
Mar 21, 2024
4 changes: 4 additions & 0 deletions packages/dataviews/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
- Two new operators have been added: `isAll` and `isNotAll`. These are meant to represent `AND` operations. For example, `Category is all: Book, Review, Science Fiction` would represent all items that have all three categories selected.
- 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.

### Breaking changes

- Removed the `getPaginationResults` and `sortByTextFields` utils and replaced them with a unique `filterAndSortDataView` function.

## 0.7.0 (2024-03-06)

## 0.6.0 (2024-02-21)
Expand Down
14 changes: 2 additions & 12 deletions packages/dataviews/src/dataviews.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import Filters from './filters';
import Search from './search';
import { VIEW_LAYOUTS, LAYOUT_TABLE, LAYOUT_GRID } from './constants';
import BulkActions from './bulk-actions';
import { normalizeFields } from './normalize-fields';

const defaultGetItemId = ( item ) => item.id;
const defaultOnSelectionChange = () => {};
Expand Down Expand Up @@ -76,18 +77,7 @@ export default function DataViews( {
const ViewComponent = VIEW_LAYOUTS.find(
( v ) => v.type === view.type
).component;
const _fields = useMemo( () => {
return fields.map( ( field ) => {
const getValue =
field.getValue || ( ( { item } ) => item[ field.id ] );

return {
...field,
getValue,
render: field.render || getValue,
};
} );
}, [ fields ] );
const _fields = useMemo( () => normalizeFields( fields ), [ fields ] );

const hasPossibleBulkAction = useSomeItemHasAPossibleBulkAction(
actions,
Expand Down
154 changes: 154 additions & 0 deletions packages/dataviews/src/filter-and-sort-data-view.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* External dependencies
*/
import removeAccents from 'remove-accents';

/**
* Internal dependencies
*/
import {
OPERATOR_IS,
OPERATOR_IS_NOT,
OPERATOR_IS_NONE,
OPERATOR_IS_ANY,
OPERATOR_IS_ALL,
OPERATOR_IS_NOT_ALL,
} from './constants';
import { normalizeFields } from './normalize-fields';

function normalizeSearchInput( input = '' ) {
return removeAccents( input.trim().toLowerCase() );
Copy link
Contributor

Choose a reason for hiding this comment

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

Annoying that we still need to use third-party libraries like remove-accent for this kind of stuff.

Intl.Collator#compare is almost what we want, but it doesn't do partial matches.

</rant> :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I just copied what we had before, now let me merge my PR and do your follow-up :P

Copy link
Contributor

Choose a reason for hiding this comment

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

Hah, I know, this was just a rant in passing :P

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh hey, it's @tyxla's package! No disrespect meant! 😅

}

const EMPTY_ARRAY = [];

/**
* Applies the filtering, sorting and pagination to the raw data based on the view configuration.
*
* @param {any[]} data Raw data.
* @param {Object} view View config.
* @param {Object[]} fields Fields config.
*
* @return {Object} { data: any[], paginationInfo: { totalItems: number, totalPages: number } }
*/
export function filterAndSortDataView( data, view, fields ) {
youknowriad marked this conversation as resolved.
Show resolved Hide resolved
if ( ! data ) {
return {
data: EMPTY_ARRAY,
paginationInfo: { totalItems: 0, totalPages: 0 },
};
}
const _fields = normalizeFields( fields );
let filteredData = [ ...data ];
// Handle global search.
if ( view.search ) {
const normalizedSearch = normalizeSearchInput( view.search );
filteredData = filteredData.filter( ( item ) => {
return _fields
.filter( ( field ) => field.enableGlobalSearch )
.map( ( field ) => {
return normalizeSearchInput( field.getValue( { item } ) );
} )
.some( ( field ) => field.includes( normalizedSearch ) );
} );
}

if ( view.filters.length > 0 ) {
view.filters.forEach( ( filter ) => {
const field = _fields.find(
( _field ) => _field.id === filter.field
);
if (
filter.operator === OPERATOR_IS_ANY &&
filter?.value?.length > 0
) {
filteredData = filteredData.filter( ( item ) => {
const fieldValue = field.getValue( { item } );
if ( Array.isArray( fieldValue ) ) {
Copy link
Member

@oandregal oandregal Mar 21, 2024

Choose a reason for hiding this comment

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

We should rely on the field.type instead of doing this. This is for a follow-up, when we have type string or array instead of enumeration.

return filter.value.some( ( filterValue ) =>
fieldValue.includes( filterValue )
);
} else if ( typeof fieldValue === 'string' ) {
return filter.value.includes( fieldValue );
}
return false;
} );
} else if (
filter.operator === OPERATOR_IS_NONE &&
filter?.value?.length > 0
) {
filteredData = filteredData.filter( ( item ) => {
const fieldValue = field.getValue( { item } );
if ( Array.isArray( fieldValue ) ) {
return ! filter.value.some( ( filterValue ) =>
fieldValue.includes( filterValue )
);
} else if ( typeof fieldValue === 'string' ) {
return ! filter.value.includes( fieldValue );
}
return false;
} );
} else if (
filter.operator === OPERATOR_IS_ALL &&
filter?.value?.length > 0
) {
filteredData = filteredData.filter( ( item ) => {
return filter.value.every( ( value ) => {
return field.getValue( { item } ).includes( value );
} );
} );
} else if (
filter.operator === OPERATOR_IS_NOT_ALL &&
filter?.value?.length > 0
) {
filteredData = filteredData.filter( ( item ) => {
return filter.value.every( ( value ) => {
return ! field.getValue( { item } ).includes( value );
} );
} );
} else if ( filter.operator === OPERATOR_IS ) {
filteredData = filteredData.filter( ( item ) => {
return filter.value === field.getValue( { item } );
} );
} else if ( filter.operator === OPERATOR_IS_NOT ) {
filteredData = filteredData.filter( ( item ) => {
return filter.value !== field.getValue( { item } );
} );
}
} );
}
youknowriad marked this conversation as resolved.
Show resolved Hide resolved

// Handle sorting.
if ( view.sort ) {
const fieldId = view.sort.field;
const fieldToSort = _fields.find( ( field ) => {
return field.id === fieldId;
} );
filteredData.sort( ( a, b ) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

What about sortable fields that are not text(ex Date)? Maybe the field could provide an override sort function and we could maintain some defaults. For example in fields API:

sort: 'date', and we could pick up a date sorter. Of course the could provide a function.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, something like that would be good (especially when we introduce the "field types" more formally.

const valueA = fieldToSort.getValue( { item: a } ) ?? '';
const valueB = fieldToSort.getValue( { item: b } ) ?? '';
return view.sort.direction === 'asc'
? valueA.localeCompare( valueB )
: valueB.localeCompare( valueA );
} );
}

// Handle pagination.
const hasPagination = view.page && view.perPage;
const start = hasPagination ? ( view.page - 1 ) * view.perPage : 0;
const totalItems = filteredData?.length || 0;
const totalPages = hasPagination
? Math.ceil( totalItems / view.perPage )
: 1;
filteredData = hasPagination
? filteredData?.slice( start, start + view.perPage )
: filteredData;

return {
data: filteredData,
paginationInfo: {
totalItems,
totalPages,
},
};
}
2 changes: 1 addition & 1 deletion packages/dataviews/src/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { default as DataViews } from './dataviews';
export { sortByTextFields, getPaginationResults } from './utils';
export { VIEW_LAYOUTS } from './constants';
export { filterAndSortDataView } from './filter-and-sort-data-view';
17 changes: 17 additions & 0 deletions packages/dataviews/src/normalize-fields.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Apply default values and normalize the fields config.
*
* @param {Object[]} fields Raw Fields.
* @return {Object[]} Normalized fields.
*/
export function normalizeFields( fields ) {
return fields.map( ( field ) => {
const getValue = field.getValue || ( ( { item } ) => item[ field.id ] );

return {
...field,
getValue,
render: field.render || getValue,
};
} );
}
74 changes: 74 additions & 0 deletions packages/dataviews/src/stories/fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,76 +21,87 @@ export const data = [
description: 'Apollo description',
image: 'https://live.staticflickr.com/5725/21726228300_51333bd62c_b.jpg',
type: 'Not a planet',
categories: [ 'Space', 'NASA' ],
},
{
id: 2,
title: 'Space',
description: 'Space description',
image: 'https://live.staticflickr.com/5678/21911065441_92e2d44708_b.jpg',
type: 'Not a planet',
categories: [ 'Space' ],
},
{
id: 3,
title: 'NASA',
description: 'NASA photo',
image: 'https://live.staticflickr.com/742/21712365770_8f70a2c91e_b.jpg',
type: 'Not a planet',
categories: [ 'NASA' ],
},
{
id: 4,
title: 'Neptune',
description: 'Neptune description',
image: 'https://live.staticflickr.com/5725/21726228300_51333bd62c_b.jpg',
type: 'Ice giant',
categories: [ 'Space', 'Planet', 'Solar system' ],
},
{
id: 5,
title: 'Mercury',
description: 'Mercury description',
image: 'https://live.staticflickr.com/5725/21726228300_51333bd62c_b.jpg',
type: 'Terrestrial',
categories: [ 'Space', 'Planet', 'Solar system' ],
},
{
id: 6,
title: 'Venus',
description: 'Venus description',
image: 'https://live.staticflickr.com/5725/21726228300_51333bd62c_b.jpg',
type: 'Terrestrial',
categories: [ 'Space', 'Planet', 'Solar system' ],
},
{
id: 7,
title: 'Earth',
description: 'Earth description',
image: 'https://live.staticflickr.com/5725/21726228300_51333bd62c_b.jpg',
type: 'Terrestrial',
categories: [ 'Space', 'Planet', 'Solar system' ],
},
{
id: 8,
title: 'Mars',
description: 'Mars description',
image: 'https://live.staticflickr.com/5725/21726228300_51333bd62c_b.jpg',
type: 'Terrestrial',
categories: [ 'Space', 'Planet', 'Solar system' ],
},
{
id: 9,
title: 'Jupiter',
description: 'Jupiter description',
image: 'https://live.staticflickr.com/5725/21726228300_51333bd62c_b.jpg',
type: 'Gas giant',
categories: [ 'Space', 'Planet', 'Solar system' ],
},
{
id: 10,
title: 'Saturn',
description: 'Saturn description',
image: 'https://live.staticflickr.com/5725/21726228300_51333bd62c_b.jpg',
type: 'Gas giant',
categories: [ 'Space', 'Planet', 'Solar system' ],
},
{
id: 11,
title: 'Uranus',
description: 'Uranus description',
image: 'https://live.staticflickr.com/5725/21726228300_51333bd62c_b.jpg',
type: 'Ice giant',
categories: [ 'Space', 'Ice giant', 'Solar system' ],
},
];

Expand Down Expand Up @@ -135,3 +146,66 @@ export const actions = [
callback() {},
},
];

export const fields = [
{
header: 'Image',
id: 'image',
render: ( { item } ) => {
return (
<img src={ item.image } alt="" style={ { width: '100%' } } />
);
},
width: 50,
enableSorting: false,
},
{
header: 'Title',
id: 'title',
maxWidth: 400,
enableHiding: false,
enableGlobalSearch: true,
},
{
header: 'Type',
id: 'type',
maxWidth: 400,
enableHiding: false,
type: 'enumeration',
elements: [
{ value: 'Not a planet', label: 'Not a planet' },
{ value: 'Ice giant', label: 'Ice giant' },
{ value: 'Terrestrial', label: 'Terrestrial' },
{ value: 'Gas giant', label: 'Gas giant' },
],
},
{
header: 'Description',
id: 'description',
maxWidth: 200,
enableSorting: false,
enableGlobalSearch: true,
},
{
header: 'Categories',
id: 'categories',
type: 'enumeration',
elements: [
{ value: 'Space', label: 'Space' },
{ value: 'NASA', label: 'NASA' },
{ value: 'Planet', label: 'Planet' },
{ value: 'Solar system', label: 'Solar system' },
{ value: 'Ice giant', label: 'Ice giant' },
],
filterBy: {
operators: [ 'isAny', 'isNone', 'isAll', 'isNotAll' ],
},
getValue: ( { item } ) => {
return item.categories;
},
render: ( { item } ) => {
return item.categories.join( ',' );
},
enableSorting: false,
},
];