(core) Fix values ordering in column filter menu

Summary:
Column filter menu use to mess up the ordering of the items for
numeric and dates values, and also for ref/reflist columns when the
visible column is a numeric a date column.

Solution was to:
 - use the actual value of the visible column for comparison.
 - use native comparison.
 - tweak the native comparison to make blanks appears before valid value. Indeed, it came up several time that it's convenient to have invalid values show up first in the filter panel, it makes for a convenient way to detect them.

Test Plan: Adds new nbrowser test

Reviewers: alexmojaki

Reviewed By: alexmojaki

Differential Revision: https://phab.getgrist.com/D3441
This commit is contained in:
Cyprien P 2022-05-24 09:10:15 +02:00
parent fb575a8b7e
commit 6793377579
3 changed files with 55 additions and 25 deletions

View File

@ -24,7 +24,8 @@ export class ColumnFilter extends Disposable {
private _include: boolean; private _include: boolean;
private _values: Set<CellValue>; private _values: Set<CellValue>;
constructor(private _initialFilterJson: string, private _columnType?: string) { constructor(private _initialFilterJson: string, private _columnType: string = '',
public visibleColumnType: string = '') {
super(); super();
this.setState(_initialFilterJson); this.setState(_initialFilterJson);
} }

View File

@ -1,18 +1,28 @@
import { ColumnFilter } from "app/client/models/ColumnFilter"; import { ColumnFilter } from "app/client/models/ColumnFilter";
import { localeCompare, nativeCompare } from "app/common/gutil";
import { CellValue } from "app/plugin/GristData"; import { CellValue } from "app/plugin/GristData";
import { Computed, Disposable, Observable } from "grainjs"; import { Computed, Disposable, Observable } from "grainjs";
import escapeRegExp = require("lodash/escapeRegExp"); import escapeRegExp = require("lodash/escapeRegExp");
import isNull = require("lodash/isNull");
const MAXIMUM_SHOWN_FILTER_ITEMS = 500; const MAXIMUM_SHOWN_FILTER_ITEMS = 500;
export interface IFilterCount { export interface IFilterCount {
// label is the formatted value
label: string; label: string;
// number of occurences in the table
count: number; count: number;
// displayValue is the underlying value (from the display column, if any), useful to perform
// comparison
displayValue: any;
} }
type ICompare<T> = (a: T, b: T) => number type ICompare<T> = (a: T, b: T) => number
const localeCompare = new Intl.Collator('en-US', {numeric: true}).compare;
export class ColumnFilterMenuModel extends Disposable { export class ColumnFilterMenuModel extends Disposable {
public readonly searchValue = Observable.create(this, ''); public readonly searchValue = Observable.create(this, '');
@ -22,7 +32,7 @@ export class ColumnFilterMenuModel extends Disposable {
// computes a set of all keys that matches the search text. // computes a set of all keys that matches the search text.
public readonly filterSet = Computed.create(this, this.searchValue, (_use, searchValue) => { public readonly filterSet = Computed.create(this, this.searchValue, (_use, searchValue) => {
const searchRegex = new RegExp(escapeRegExp(searchValue), 'i'); const searchRegex = new RegExp(escapeRegExp(searchValue), 'i');
const showAllOptions = ['Bool', 'Choice', 'ChoiceList'].includes(this.columnFilter.columnType!); const showAllOptions = ['Bool', 'Choice', 'ChoiceList'].includes(this.columnFilter.columnType);
return new Set( return new Set(
this._valueCount this._valueCount
.filter(([_, {label, count}]) => (showAllOptions ? true : count) && searchRegex.test(label)) .filter(([_, {label, count}]) => (showAllOptions ? true : count) && searchRegex.test(label))
@ -34,12 +44,21 @@ export class ColumnFilterMenuModel extends Disposable {
public readonly filteredValues = Computed.create( public readonly filteredValues = Computed.create(
this, this.filterSet, this.isSortedByCount, this, this.filterSet, this.isSortedByCount,
(_use, filter, isSortedByCount) => { (_use, filter, isSortedByCount) => {
const comparator: ICompare<[CellValue, IFilterCount]> = isSortedByCount ? const prop: keyof IFilterCount = isSortedByCount ? 'count' : 'displayValue';
(a, b) => nativeCompare(b[1].count, a[1].count) : let isShownFirst: (val: any) => boolean = isNull;
(a, b) => localeCompare(a[1].label, b[1].label); if (['Date', 'DateTime', 'Numeric', 'Int'].includes(this.columnFilter.visibleColumnType)) {
isShownFirst = (val) => isNull(val) || isNaN(val);
}
const comparator: ICompare<any> = (a, b) => {
if (isShownFirst(a)) { return -1; }
if (isShownFirst(b)) { return 1; }
return localeCompare(a, b);
};
return this._valueCount return this._valueCount
.filter(([key]) => filter.has(key)) .filter(([key]) => filter.has(key))
.sort(comparator); .sort((a, b) => comparator(a[1][prop], b[1][prop]));
} }
); );

View File

@ -290,7 +290,7 @@ function getEmptyCountMap(fieldOrColumn: ViewFieldRec|ColumnRec): Map<CellValue,
const options = fieldOrColumn.origCol().widgetOptionsJson; const options = fieldOrColumn.origCol().widgetOptionsJson;
values = options.prop('choices')(); values = options.prop('choices')();
} }
return new Map(values.map((v) => [v, {label: String(v), count: 0}])); return new Map(values.map((v) => [v, {label: String(v), count: 0, displayValue: v}]));
} }
/** /**
@ -301,7 +301,8 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
// Go through all of our shown and hidden rows, and count them up by the values in this column. // Go through all of our shown and hidden rows, and count them up by the values in this column.
const fieldOrColumn = filterInfo.fieldOrColumn; const fieldOrColumn = filterInfo.fieldOrColumn;
const columnType = fieldOrColumn.origCol.peek().type.peek(); const columnType = fieldOrColumn.origCol.peek().type.peek();
const {keyMapFunc, labelMapFunc} = getMapFuncs(columnType, tableData, filterInfo.fieldOrColumn); const visibleColumnType = fieldOrColumn.visibleColModel.peek()?.type.peek() || columnType;
const {keyMapFunc, labelMapFunc, valueMapFunc} = getMapFuncs(columnType, tableData, filterInfo.fieldOrColumn);
const activeFilterBar = sectionFilter.viewSection.activeFilterBar; const activeFilterBar = sectionFilter.viewSection.activeFilterBar;
function getFilterFunc(col: ViewFieldRec|ColumnRec, colFilter: ColumnFilterFunc|null) { function getFilterFunc(col: ViewFieldRec|ColumnRec, colFilter: ColumnFilterFunc|null) {
@ -310,14 +311,15 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
const filterFunc = Computed.create(null, use => sectionFilter.buildFilterFunc(getFilterFunc, use)); const filterFunc = Computed.create(null, use => sectionFilter.buildFilterFunc(getFilterFunc, use));
openCtl.autoDispose(filterFunc); openCtl.autoDispose(filterFunc);
const columnFilter = ColumnFilter.create(openCtl, filterInfo.filter.peek(), columnType); const columnFilter = ColumnFilter.create(openCtl, filterInfo.filter.peek(), columnType, visibleColumnType);
sectionFilter.setFilterOverride(fieldOrColumn.getRowId(), columnFilter); // Will be removed on menu disposal sectionFilter.setFilterOverride(fieldOrColumn.getRowId(), columnFilter); // Will be removed on menu disposal
const [allRows, hiddenRows] = partition(Array.from(rowSource.getAllRows()), filterFunc.get()); const [allRows, hiddenRows] = partition(Array.from(rowSource.getAllRows()), filterFunc.get());
const valueCounts = getEmptyCountMap(fieldOrColumn); const valueCounts = getEmptyCountMap(fieldOrColumn);
addCountsToMap(valueCounts, allRows, {keyMapFunc, labelMapFunc, columnType}); addCountsToMap(valueCounts, allRows, {keyMapFunc, labelMapFunc, columnType,
valueMapFunc});
addCountsToMap(valueCounts, hiddenRows, {keyMapFunc, labelMapFunc, columnType, addCountsToMap(valueCounts, hiddenRows, {keyMapFunc, labelMapFunc, columnType,
areHiddenRows: true}); areHiddenRows: true, valueMapFunc});
const model = ColumnFilterMenuModel.create(openCtl, columnFilter, Array.from(valueCounts)); const model = ColumnFilterMenuModel.create(openCtl, columnFilter, Array.from(valueCounts));
@ -341,8 +343,9 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
} }
/** /**
* Returns two callback functions, `keyMapFunc` and `labelMapFunc`, * Returns three callback functions, `keyMapFunc`, `labelMapFunc`
* which map row ids to cell values and labels respectively. * and `valueMapFunc`, which map row ids to cell values, labels
* and visible col value respectively.
* *
* The functions vary based on the `columnType`. For example, * The functions vary based on the `columnType`. For example,
* Reference Lists have a unique `labelMapFunc` that returns a list * Reference Lists have a unique `labelMapFunc` that returns a list
@ -357,6 +360,8 @@ function getMapFuncs(columnType: string, tableData: TableData, fieldOrColumn: Vi
const formatter = fieldOrColumn.visibleColFormatter(); const formatter = fieldOrColumn.visibleColFormatter();
let labelMapFunc: (rowId: number) => string | string[]; let labelMapFunc: (rowId: number) => string | string[];
const valueMapFunc: (rowId: number) => any = (rowId: number) => decodeObject(labelGetter(rowId)!);
if (isRefListType(columnType)) { if (isRefListType(columnType)) {
labelMapFunc = (rowId: number) => { labelMapFunc = (rowId: number) => {
const maybeLabels = labelGetter(rowId); const maybeLabels = labelGetter(rowId);
@ -367,8 +372,7 @@ function getMapFuncs(columnType: string, tableData: TableData, fieldOrColumn: Vi
} else { } else {
labelMapFunc = (rowId: number) => formatter.formatAny(labelGetter(rowId)); labelMapFunc = (rowId: number) => formatter.formatAny(labelGetter(rowId));
} }
return {keyMapFunc, labelMapFunc, valueMapFunc};
return {keyMapFunc, labelMapFunc};
} }
/** /**
@ -412,14 +416,19 @@ function getRenderFunc(columnType: string, fieldOrColumn: ViewFieldRec|ColumnRec
interface ICountOptions { interface ICountOptions {
columnType: string; columnType: string;
// returns the indexing key for the filter
keyMapFunc?: (v: any) => any; keyMapFunc?: (v: any) => any;
// returns the string representation of the value (can involves some formatting).
labelMapFunc?: (v: any) => any; labelMapFunc?: (v: any) => any;
// returns the underlying value (useful for comparison)
valueMapFunc: (v: any) => any;
areHiddenRows?: boolean; areHiddenRows?: boolean;
} }
/** /**
* For each row id in Iterable, adds a key mapped with `keyMapFunc` and a value object with a `label` mapped * For each row id in Iterable, adds a key mapped with `keyMapFunc` and a value object with a
* with `labelMapFunc` and a `count` representing the total number of times the key has been encountered. * `label` mapped with `labelMapFunc` and a `count` representing the total number of times the key
* has been encountered and a `displayValues` mapped with `valueMapFunc`.
* *
* The optional column type controls how complex cell values are decomposed into keys (e.g. Choice Lists have * The optional column type controls how complex cell values are decomposed into keys (e.g. Choice Lists have
* the possible choices as keys). * the possible choices as keys).
@ -427,7 +436,7 @@ interface ICountOptions {
*/ */
function addCountsToMap(valueMap: Map<CellValue, IFilterCount>, rowIds: RowId[], function addCountsToMap(valueMap: Map<CellValue, IFilterCount>, rowIds: RowId[],
{ keyMapFunc = identity, labelMapFunc = identity, columnType, { keyMapFunc = identity, labelMapFunc = identity, columnType,
areHiddenRows = false }: ICountOptions) { areHiddenRows = false, valueMapFunc }: ICountOptions) {
for (const rowId of rowIds) { for (const rowId of rowIds) {
let key = keyMapFunc(rowId); let key = keyMapFunc(rowId);
@ -436,7 +445,7 @@ function addCountsToMap(valueMap: Map<CellValue, IFilterCount>, rowIds: RowId[],
if (isList(key) && (columnType === 'ChoiceList')) { if (isList(key) && (columnType === 'ChoiceList')) {
const list = decodeObject(key) as unknown[]; const list = decodeObject(key) as unknown[];
for (const item of list) { for (const item of list) {
addSingleCountToMap(valueMap, item, () => item, areHiddenRows); addSingleCountToMap(valueMap, item, () => item, () => item, areHiddenRows);
} }
continue; continue;
} }
@ -445,14 +454,15 @@ function addCountsToMap(valueMap: Map<CellValue, IFilterCount>, rowIds: RowId[],
if (isList(key) && isRefListType(columnType)) { if (isList(key) && isRefListType(columnType)) {
const refIds = decodeObject(key) as unknown[]; const refIds = decodeObject(key) as unknown[];
const refLabels = labelMapFunc(rowId); const refLabels = labelMapFunc(rowId);
const displayValues = valueMapFunc(rowId);
refIds.forEach((id, i) => { refIds.forEach((id, i) => {
addSingleCountToMap(valueMap, id, () => refLabels[i], areHiddenRows); addSingleCountToMap(valueMap, id, () => refLabels[i], () => displayValues[i], areHiddenRows);
}); });
continue; continue;
} }
// For complex values, serialize the value to allow them to be properly stored // For complex values, serialize the value to allow them to be properly stored
if (Array.isArray(key)) { key = JSON.stringify(key); } if (Array.isArray(key)) { key = JSON.stringify(key); }
addSingleCountToMap(valueMap, key, () => labelMapFunc(rowId), areHiddenRows); addSingleCountToMap(valueMap, key, () => labelMapFunc(rowId), () => valueMapFunc(rowId), areHiddenRows);
} }
} }
@ -460,10 +470,10 @@ function addCountsToMap(valueMap: Map<CellValue, IFilterCount>, rowIds: RowId[],
* Adds the `value` to `valueMap` using `labelGetter` to get the label and increments `count` unless * Adds the `value` to `valueMap` using `labelGetter` to get the label and increments `count` unless
* isHiddenRow is true. * isHiddenRow is true.
*/ */
function addSingleCountToMap(valueMap: Map<CellValue, IFilterCount>, value: any, labelGetter: () => any, function addSingleCountToMap(valueMap: Map<CellValue, IFilterCount>, value: any, label: () => any,
isHiddenRow: boolean) { displayValue: () => any, isHiddenRow: boolean) {
if (!valueMap.has(value)) { if (!valueMap.has(value)) {
valueMap.set(value, { label: labelGetter(), count: 0 }); valueMap.set(value, { label: label(), count: 0, displayValue: displayValue() });
} }
if (!isHiddenRow) { if (!isHiddenRow) {
valueMap.get(value)!.count++; valueMap.get(value)!.count++;