gristlabs_grist-core/app/common/SortFunc.ts
Cyprien P d2f98b4036 (core) Fix the empty values last option in multicol sorting
Summary:
The "Empty values last" options was breaking sort when multiple
columns are involved in the sort spec. Problem comes from wrong
handling of equals.

Diff fixes that issue and update test.

Test Plan: Updates nbrowser test

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3774
2023-01-31 12:14:22 +01:00

162 lines
5.7 KiB
TypeScript

/**
* SortFunc class interprets the sortSpec (as saved in viewSection.sortColRefs), exposing a
* compare(rowId1, rowId2) function that can be used to actually sort rows in a view.
*
* TODO: When an operation (such as a paste) would cause rows to jump in the sort order, this
* class should support freezing of row positions until the user chooses to re-sort. This is not
* currently implemented.
*/
import {ColumnGetter, ColumnGetters} from 'app/common/ColumnGetters';
import {localeCompare, nativeCompare} from 'app/common/gutil';
import {Sort} from 'app/common/SortSpec';
// Function that will amend column getter to return entry index instead
// of entry value. Result will be a string padded with zeros, so the ordering
// between types is preserved.
export function choiceGetter(getter: ColumnGetter, choices: string[]): ColumnGetter {
return rowId => {
const value = getter(rowId);
const index = choices.indexOf(value);
return index >= 0 ? String(index).padStart(5, "0") : value;
};
}
type Comparator = (val1: any, val2: any) => number;
/**
* Natural comparator based on built in method.
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare
*/
const collator = new Intl.Collator(undefined, {numeric: true});
function naturalCompare(val1: any, val2: any) {
if (typeof val1 === 'string' && typeof val2 === 'string') {
return collator.compare(val1, val2);
}
return typedCompare(val1, val2);
}
/**
* Empty comparator will treat empty values as last.
*/
export const emptyCompare = (next: Comparator) => (val1: any, val2: any) => {
const isEmptyValue1 = !val1 && typeof val1 !== 'number';
const isEmptyValue2 = !val2 && typeof val2 !== 'number';
// If both values are empty values, rely on next to compare.
if (isEmptyValue1 && !isEmptyValue2) {
return 1;
}
if (isEmptyValue2 && !isEmptyValue1) {
return -1;
}
return next(val1, val2);
};
/**
* Compare two cell values, paying attention to types and values. Note that native JS comparison
* can't be used for sorting because it isn't transitive across types (e.g. both 1 < "2" and "2" <
* "a" are true, but 1 < "a" is false.). In addition, we handle complex values represented in
* Grist as arrays.
*
* Note that we need to handle different types of values regardless of the column type,
* because e.g. a numerical column may contain text (alttext) or null values.
*/
export function typedCompare(val1: any, val2: any): number {
let result: number, type1: string, array1: boolean;
// tslint:disable-next-line:no-conditional-assignment
if ((result = nativeCompare(type1 = typeof val1, typeof val2)) !== 0) {
return result;
}
// We need to worry about Array comparisons because formulas returning Any may return null or
// object values represented as arrays (e.g. ['D', ...] for dates). Comparing those without
// distinguishing types would break the sort. Also, arrays need a special comparator.
if (type1 === 'object') {
// tslint:disable-next-line:no-conditional-assignment
if ((result = nativeCompare(array1 = val1 instanceof Array, val2 instanceof Array)) !== 0) {
return result;
}
if (array1) {
return _arrayCompare(val1, val2);
}
}
if (type1 === 'string') {
return localeCompare(val1, val2);
}
return nativeCompare(val1, val2);
}
function _arrayCompare(val1: any[], val2: any[]): number {
for (let i = 0; i < val1.length; i++) {
if (i >= val2.length) {
return 1;
}
const value = typedCompare(val1[i], val2[i]);
if (value) {
return value;
}
}
return val1.length === val2.length ? 0 : -1;
}
/**
* getters is an implementation of app.common.ColumnGetters
*/
export class SortFunc {
// updateSpec() or updateGetters() can populate these fields, used by the compare() method.
private _colGetters: ColumnGetter[] = []; // Array of column getters (mapping rowId to column value)
private _directions: number[] = []; // Array of 1 (ascending) or -1 (descending) flags.
private _comparators: Comparator[] = [];
constructor(private _getters: ColumnGetters) {}
public updateSpec(sortSpec: Sort.SortSpec): void {
// Prepare an array of column getters for each column in sortSpec.
this._colGetters = sortSpec.map(colSpec => {
return this._getters.getColGetter(colSpec);
}).filter(getter => getter) as ColumnGetter[];
// Collect "ascending" flags as an array of 1 or -1, one for each column.
this._directions = sortSpec.map(colSpec => Sort.direction(colSpec));
// Collect comparator functions
this._comparators = sortSpec.map(colSpec => {
const details = Sort.specToDetails(colSpec);
let comparator = typedCompare;
if (details.naturalSort) {
comparator = naturalCompare;
}
// Empty decorator should be added last, as first we want to compare
// empty values
if (details.emptyLast) {
comparator = emptyCompare(comparator);
}
return comparator;
});
const manualSortGetter = this._getters.getManualSortGetter();
if (manualSortGetter) {
this._colGetters.push(manualSortGetter);
this._directions.push(1);
this._comparators.push(typedCompare);
}
}
/**
* Returns 1 or -1 depending on whether rowId1 should be shown before rowId2.
*/
public compare(rowId1: number, rowId2: number): number {
for (let i = 0, len = this._colGetters.length; i < len; i++) {
const getter = this._colGetters[i];
const val1 = getter(rowId1);
const val2 = getter(rowId2);
const comparator = this._comparators[i];
const result = comparator(val1, val2);
if (result !== 0 /* not equal */) {
return result * this._directions[i];
}
}
return nativeCompare(rowId1, rowId2);
}
}