epam / UUI

React-based components and accelerators library built by EPAM Systems.
https://uui.epam.com/
MIT License
162 stars 62 forks source link

DataSources - more flexible sorting customization #2192

Open jakobz opened 2 months ago

jakobz commented 2 months ago

Description

There are several cases, when we want to customize how ArrayDataSource/useTree sorts items:

  1. We want to change comparator for some/all fields. For example, for getOrderBetween-based we need a simple, not default Intl-based comparator, to have lexicographic order for strings which contains digits.

  2. For certain fields, we might want to specify a custom getter. E.g. i => i.person.name, or i.firstName + ' ' + i.lastName. This is currently possible with sortBy, e.g. (i, sorting) => sorting == 'personName' ? i => i.person.name : i[sorting].

  3. Sometimes, we want to add additional 'default' sorting. E.g. when sorting our Demo Table by person's title or status, it will be nice to add secondary sorting on person's name. This sorting should be always ASC, and not flip to DESC when we sort by title descending. This is currently possible, via patching dataSourceState.sorting before passing it to the dataSource: dataSource.getView({ ...dataSourceState, sorting: [...dataSourceState.sorting, { field: 'name', direction: 'ASC' }] ... However, it's a bit hacky, and impossible in case of PickerInput, as getView is called inside it. Default sorting might depend on fields we sort on.

  4. It's a common approach to sort null values to the bottom of the list, in both ASC and DESC. Currently, we don't know any feasible workarounds or hacks to do this.

So, currently, #1 and #4 are not possible, #3 is tricky and limited.

Options

1. Allow replacing the whole comparator.

getSortingComparator(sortings: SortingOption[]) => (a: TItem, b: TItem) => number

As implementing multi-level comparators from scratch is too much, especially to tweak few aspects, we can export our default implementation as getSortingComparer, which would allow to apply customizations by tweaking it's parameters. This implementation can extend and replace the existing getOrderComparer.

We need to extend our current implementation, with the following for each sorting option:

interface ExtendedSortingOption extends SortingOption {
   // Allow field to also be a getter function:
   field: string | (item: TItem) => any;
   // Allow to change comparator 
   comparator?: (a: any, b: any) => number;
}

These options should not be added to original SortingOptions object, as it should remain serializable. We extend only the getSortingComparator API.

Examples:

// Case 1 - change comparator for `order` field

getSortingComparator: (sortings) => getSortingComparator(sortings.map(s => ({
   ...s,
   comparator: s.field === 'order' ? basicComparer : intlComparer
}))

// Case 2 - custom getter

// in this case, it's better to use existing sortBy API
sortBy: (item, sorting) => sorting.field == 'name' ? item.person.name : item[sorting.name]

/// However, passing getter along with other options, might be more convenient
// in cases when we tweak several sorting aspects at once.:

const getSorting = (sorting) => {
    switch (field) {
        case 'name': return { ...sorting, field: i => i.person.name }; // replace getter
        case 'order': return { ...sorting, comparator: basicComparator }; // replace comparator
        case 'state': return [{ field: i => !i.state, direction: 'ASC' }, sorting ]; // nulls last
        default: return sorting;
    }
}

getSortingComparator: (sortings) => getSortingComparator(sortings.flatMap(s => getSorting(s)) 

// Case 3 - default secondary sorting:

getSortingComparator: (sortings) => getSortingComparator([...sortings, { field: 'name', order: 'ASC' }])

// Case 4 - nulls last:

getSortingComparator: (sortings) => getSortingComparator(
   sortings.flatMap(s => [
      { field: i => !i[s.field], direction: 'ASC' }, // sort by null/non-null first, always ASC 
       s,
   ])

2. Allow tweaking sortings, not the whole comparator.

getSorting(sorting: SortingOption[]) => ExtendedSortingOption[]

Not sure on naming. Other options: customizeSorting, getSortingOptions, getSortBy, getSortingOrder

This is similar to #1. But

Usage will be similar to #1, except you don't need wrap everything with getSortingComparator:

// Case 1 - change comparator for `order` field
getSorting: (sortings) => sortings.map(s => ({ ...s, comparator: s.field === 'order' ? basicComparer : intlComparer })
// Case 2 - custom getter
getSorting: (sortings) => sortings.map(s => ({ ...s, field: s.field == 'name' ? (s => s.person.name) : s.field  })
// Case 3 - default secondary sorting:
getSorting: (sortings) => [...sortings, { field: 'name', order: 'ASC' }]
// Case 4 - nulls last:
getSorting: (sortings) => sortings.flatMap(s => [
      { field: i => !i[s.field], direction: 'ASC' }, // sort by null/non-null first, always ASC 
       s,
   ])
// All cases together:

mapSorting = (sorting) => {
    switch (field) {
        case 'name': return { ...sorting, field: i => i.person.name }; // replace getter
        case 'order': return { ...sorting, comparator: basicComparator }; // replace comparator
        case 'state': return [{ field: i => !i.state, direction: 'ASC' }, sorting ]; // nulls last
        default: return sorting;
    }
}
getSorting: (sortings) => [
    ...sortings.flatMap(mapSorting),
  { field: 'name' },  //  always sort by name as last sorting
]

3. Allow tweaking sortings one-by-one

This is similar to #1 and #2, but we further limit the capabilities, in favor of usability. Instead of passing the SortingOptions array, we pass each SortingOption. We allow returning an array to allow sorting by multiple fields:

getSorting: (sorting: SortingOption) => ExtendedSortingOption[]

There is usually a single SortingOption present. Multiple SortingOptions are possible in cases when we sort by several columns. There can be requirements, which wouldn't be possible with this API, for example there can be sorting by two columns (e.g. Title and Status), and we still want to apply the last sort on person's name. This can be handled with some extensions - like passing all sortings as 2nd parameter, or a dedicated defaultSorting property.

// Case 1 - change comparator for `order` field
getSorting: (sorting) => sorting.field === 'order' ? basicComparer : intlComparer)

// Case 2 - custom getter (one can still use sortBy):
getSorting: (sorting) => { field: (sorting.field == 'name') ? (item => item.person.name) : (item => item[sorting.name]) }

// Case 3 - default secondary sorting (broken for 2-level sortings):

getSorting: (sorting) => [sorting, { field: 'name', order: 'ASC' }]

// Case 4 - nulls last

getSorting: (sorting) => [{ field: i => !i[s.field], direction: 'ASC' }, sorting]

// Cases 1, 2, and 4 together:

getSorting = (sorting) => {
    switch (field) {
        case 'name': return { ...sorting, field: i => i.person.name }; // replace getter
        case 'order': return { ...sorting, comparator: basicComparator }; // replace comparator
        case 'state': return [{ field: i => !i.state, direction: 'ASC' }, sorting ]; // nulls last
        default: return sorting;
    }
}

4. Add declarative sorting options

The idea extends on #3. If we have a field-by-field mapping, we can embed dispatch into the callback:

interface SortingSettings = {
   sortBy?: (item: TItem) => any;
   comparator?: (a: any, b: any) => number;
   direction?: 'asc' | 'desc' | null; // null/undefined means 'don't change'
}

type SortingsSettings = Record<string, SortingSettings | SortingSettings[]>;

const defaultSorting = { field: 'name' };

const sortingsSettings: SortingsSettings  = {
   'name': { sortBy: i => i.person.name },
   'order': { comparator: basicComparator },
   'state': (sorting: { field: string, direction: 'ASC' | 'DESC' }) => [{ sortBy: i => !i.state, direction: 'ASC' }, { sortBy: i => i.state }],

   // As in Option 3, we need an additional API to handle default sorting.
   [Sorting.always]: (sortings: Sorting[] ) => { field: 'name', direction: 'asc' }, 
   // Also, we need a way to define some default sorting, in case when none exists
   [Sorting.default]: { field: 'name', direction: 'asc' }, 
}

dataSource.useView(state, setState, {
  sortingSettings,
});

This API looks more simpler than 1-3, while handling all cases. However, it's also quite limiting. For example, it's hard to change comparer for all fields, or by certain conventions (e.g. if sorting name ends with 'date' - use Date-comparer).

5. A separate callback to customize comparators.

getSortingComparator: (field) => (a: any, b: any) => number
// Case 1 - change comparator for `order` field
getSortingComparator: (field) => field == 'order' ? basicComparator : intlComparator

// Case 2 - custom getter: use existing sortBy
sortBy: (item, sorting) => sorting.field == 'name' ? item.person.name : item[sorting.name]

// Case 3 - default secondary sorting - done via hack by patching sortings before passing them to useView.
// Not applicable in PickerInput.
const view = dataSource.useView({
   ...dataSourceState,
   sorting: [...dataSourceState.sorting, { field: 'name', direction: 'asc' }],
 ... 

// Case 4 - nulls last 
// Not possible with this API, as sorting direction is handled internally.
// We can add sorting direction to the callback like
getSortingComparator: (field, direction: 'asc' | 'desc') => (a: any, b: any) => number
// In this case, it's not clear who should handle direction. Also, it makes callback implementation trickier.

6. Embed sorting options to ColumnConfig

We can extend columns with sorting options, e.g. :

const personColumns = [
   {
     key: 'name',
     sortingOptions: { field: i => i.person.name },
   },
   {
     key: 'order',
     sortingOptions: { comparator: basicComparator },
   }, 
   {
     key: 'state',
     sortingOptions: [{ field: i => !i.state, direction: 'ASC' }, { field: 'state' }],
   }
]

This can be combined with #1-#3. As datasource doesn't currently require passing columns, we'd need to ask to convert columns somehow to our of APIs. E.g.

{
   ...,
   getSortingComparator: getComparatorFromColumns(personColumns)
}