(core) Adds new range filter for numeric columns

Summary:
Shows the range filter next to the filter by values on filter menu. When users
set min and/or max, it takes precendence over the filter by values.

If users set:
 - `[] < [max]` behaves as `less than max`.
 - `[min] < []` behaves as `more than min`.
 - `[min] < [max]` behaves as `between min and max`
 - bounds are always inclusives.
 - when users change min or max the values of the by values filter
   gets checked/unchecked depending on whether they are included by
   the range filter.
 - when users clicks any btn/checkbox of the by values filter both min
   and max input gets cleared, and the filter convert to a filter by
   values.

Test Plan: Adds both projets and nbrowser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3435
This commit is contained in:
Cyprien P 2022-05-24 16:59:12 +02:00
parent dcaa2b4f29
commit 815c9e1462
5 changed files with 195 additions and 30 deletions

View File

@ -1,6 +1,6 @@
import {ColumnFilterFunc, makeFilterFunc} from "app/common/ColumnFilterFunc"; import {ColumnFilterFunc, makeFilterFunc} from "app/common/ColumnFilterFunc";
import {CellValue} from 'app/common/DocActions'; import {CellValue} from 'app/common/DocActions';
import {FilterSpec, FilterState, makeFilterState} from "app/common/FilterState"; import {FilterSpec, FilterState, isRangeFilter, makeFilterState} from "app/common/FilterState";
import {nativeCompare} from 'app/common/gutil'; import {nativeCompare} from 'app/common/gutil';
import {Computed, Disposable, Observable} from 'grainjs'; import {Computed, Disposable, Observable} from 'grainjs';
@ -13,6 +13,10 @@ import {Computed, Disposable, Observable} from 'grainjs';
* been customized. * been customized.
*/ */
export class ColumnFilter extends Disposable { export class ColumnFilter extends Disposable {
public min = Observable.create<number|undefined>(this, undefined);
public max = Observable.create<number|undefined>(this, undefined);
public readonly filterFunc = Observable.create<ColumnFilterFunc>(this, () => true); public readonly filterFunc = Observable.create<ColumnFilterFunc>(this, () => true);
// Computed that returns true if filter is an inclusion filter, false otherwise. // Computed that returns true if filter is an inclusion filter, false otherwise.
@ -25,9 +29,11 @@ export class ColumnFilter extends Disposable {
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 = '') { public visibleColumnType: string = '', private _allValues: CellValue[] = []) {
super(); super();
this.setState(_initialFilterJson); this.setState(_initialFilterJson);
this.autoDispose(this.min.addListener(() => this._updateState()));
this.autoDispose(this.max.addListener(() => this._updateState()));
} }
public get columnType() { public get columnType() {
@ -36,13 +42,25 @@ export class ColumnFilter extends Disposable {
public setState(filterJson: string|FilterSpec) { public setState(filterJson: string|FilterSpec) {
const state = makeFilterState(filterJson); const state = makeFilterState(filterJson);
if (isRangeFilter(state)) {
this.min.set(state.min);
this.max.set(state.max);
// Setting _include to false allows to make sure that the filter reverts to all values
// included when users delete one bound (min or max) while the other bound is already
// undefined (filter reverts to switching by value when both min and max are undefined).
this._include = false;
this._values = new Set();
} else {
this.min.set(undefined);
this.max.set(undefined);
this._include = state.include; this._include = state.include;
this._values = state.values; this._values = state.values;
}
this._updateState(); this._updateState();
} }
public includes(val: CellValue): boolean { public includes(val: CellValue): boolean {
return this._values.has(val) === this._include; return this.filterFunc.get()(val);
} }
public add(val: CellValue) { public add(val: CellValue) {
@ -50,6 +68,7 @@ export class ColumnFilter extends Disposable {
} }
public addMany(values: CellValue[]) { public addMany(values: CellValue[]) {
this._toValues();
for (const val of values) { for (const val of values) {
this._include ? this._values.add(val) : this._values.delete(val); this._include ? this._values.add(val) : this._values.delete(val);
} }
@ -61,6 +80,7 @@ export class ColumnFilter extends Disposable {
} }
public deleteMany(values: CellValue[]) { public deleteMany(values: CellValue[]) {
this._toValues();
for (const val of values) { for (const val of values) {
this._include ? this._values.delete(val) : this._values.add(val); this._include ? this._values.delete(val) : this._values.add(val);
} }
@ -81,8 +101,14 @@ export class ColumnFilter extends Disposable {
// For saving the filter value back. // For saving the filter value back.
public makeFilterJson(): string { public makeFilterJson(): string {
let filter: any;
if (this.min.get() !== undefined || this.max.get() !== undefined) {
filter = {min: this.min.get(), max: this.max.get()};
} else {
const values = Array.from(this._values).sort(nativeCompare); const values = Array.from(this._values).sort(nativeCompare);
return JSON.stringify(this._include ? {included: values} : {excluded: values}); filter = {[this._include ? 'included' : 'excluded']: values};
}
return JSON.stringify(filter);
} }
public hasChanged(): boolean { public hasChanged(): boolean {
@ -94,7 +120,21 @@ export class ColumnFilter extends Disposable {
} }
private _getState(): FilterState { private _getState(): FilterState {
return {include: this._include, values: this._values}; return {include: this._include, values: this._values, min: this.min.get(), max: this.max.get()};
}
private _isRange() {
return isRangeFilter(this._getState());
}
private _toValues() {
if (this._isRange()) {
const func = this.filterFunc.get();
const state = this._include ?
{ included: this._allValues.filter((val) => func(val)) } :
{ excluded: this._allValues.filter((val) => !func(val)) };
this.setState(state);
}
} }
} }

View File

@ -19,16 +19,18 @@ import {icon} from 'app/client/ui2018/icons';
import {menuCssClass, menuDivider} from 'app/client/ui2018/menus'; import {menuCssClass, menuDivider} from 'app/client/ui2018/menus';
import {CellValue} from 'app/common/DocActions'; import {CellValue} from 'app/common/DocActions';
import {isEquivalentFilter} from "app/common/FilterState"; import {isEquivalentFilter} from "app/common/FilterState";
import {Computed, dom, DomElementArg, DomElementMethod, IDisposableOwner, input, makeTestId, styled} from 'grainjs'; import {Computed, dom, DomElementArg, DomElementMethod, IDisposableOwner, input, makeTestId, Observable,
styled} from 'grainjs';
import concat = require('lodash/concat'); import concat = require('lodash/concat');
import identity = require('lodash/identity'); import identity = require('lodash/identity');
import noop = require('lodash/noop'); import noop = require('lodash/noop');
import partition = require('lodash/partition'); import partition = require('lodash/partition');
import some = require('lodash/some'); import some = require('lodash/some');
import tail = require('lodash/tail'); import tail = require('lodash/tail');
import debounce = require('lodash/debounce');
import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel'; import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel';
import {decodeObject} from 'app/plugin/objtypes'; import {decodeObject} from 'app/plugin/objtypes';
import {isList, isRefListType} from 'app/common/gristTypes'; import {isList, isNumberType, isRefListType} from 'app/common/gristTypes';
import {choiceToken} from 'app/client/widgets/ChoiceToken'; import {choiceToken} from 'app/client/widgets/ChoiceToken';
import {ChoiceOptions} from 'app/client/widgets/ChoiceTextBox'; import {ChoiceOptions} from 'app/client/widgets/ChoiceTextBox';
import {cssInvalidToken} from 'app/client/widgets/ChoiceListCell'; import {cssInvalidToken} from 'app/client/widgets/ChoiceListCell';
@ -39,12 +41,13 @@ interface IFilterMenuOptions {
doSave: (reset: boolean) => void; doSave: (reset: boolean) => void;
onClose: () => void; onClose: () => void;
renderValue: (key: CellValue, value: IFilterCount) => DomElementArg; renderValue: (key: CellValue, value: IFilterCount) => DomElementArg;
valueParser?: (val: string) => any;
} }
const testId = makeTestId('test-filter-menu-'); const testId = makeTestId('test-filter-menu-');
export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptions): HTMLElement { export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptions): HTMLElement {
const { model, doSave, onClose, renderValue } = opts; const { model, doSave, onClose, renderValue, valueParser } = opts;
const { columnFilter } = model; const { columnFilter } = model;
// Save the initial state to allow reverting back to it on Cancel // Save the initial state to allow reverting back to it on Cancel
const initialStateJson = columnFilter.makeFilterJson(); const initialStateJson = columnFilter.makeFilterJson();
@ -52,12 +55,14 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
// Map to keep track of displayed checkboxes // Map to keep track of displayed checkboxes
const checkboxMap: Map<CellValue, HTMLInputElement> = new Map(); const checkboxMap: Map<CellValue, HTMLInputElement> = new Map();
// Listen for changes to filterFunc, and update checkboxes accordingly // Listen for changes to filterFunc, and update checkboxes accordingly. Debounce is needed to
const filterListener = columnFilter.filterFunc.addListener(func => { // prevent some weirdness when users click on a checkbox while focus was on a range input (causing
// sometimes the checkbox to not toggle)
const filterListener = columnFilter.filterFunc.addListener(debounce(func => {
for (const [value, elem] of checkboxMap) { for (const [value, elem] of checkboxMap) {
elem.checked = func(value); elem.checked = func(value);
} }
}); }));
const {searchValue: searchValueObs, filteredValues, filteredKeys, isSortedByCount} = model; const {searchValue: searchValueObs, filteredValues, filteredKeys, isSortedByCount} = model;
@ -65,10 +70,11 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
const isSearchingObs = Computed.create(owner, (use) => Boolean(use(searchValueObs))); const isSearchingObs = Computed.create(owner, (use) => Boolean(use(searchValueObs)));
let searchInput: HTMLInputElement; let searchInput: HTMLInputElement;
let minRangeInput: HTMLInputElement;
let reset = false; let reset = false;
// Gives focus to the searchInput on open // Gives focus to the searchInput on open (or to the min input if the range filter is present).
setTimeout(() => searchInput.focus(), 0); setTimeout(() => (minRangeInput || searchInput).select(), 0);
const filterMenu: HTMLElement = cssMenu( const filterMenu: HTMLElement = cssMenu(
{ tabindex: '-1' }, // Allow menu to be focused { tabindex: '-1' }, // Allow menu to be focused
@ -80,6 +86,18 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
Enter: () => onClose(), Enter: () => onClose(),
Escape: () => onClose() Escape: () => onClose()
}), }),
// Filter by range
dom.maybe(isNumberType(columnFilter.columnType), () => [
cssRangeHeader('Filter by Range'),
cssRangeContainer(
minRangeInput = rangeInput('Min ', columnFilter.min, {valueParser}, testId('min')),
cssRangeInputSeparator('→'),
rangeInput('Max ', columnFilter.max, {valueParser}, testId('max')),
),
cssMenuDivider(),
]),
cssMenuHeader( cssMenuHeader(
cssSearchIcon('Search'), cssSearchIcon('Search'),
searchInput = cssSearch( searchInput = cssSearch(
@ -149,8 +167,9 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
cssLabel( cssLabel(
cssCheckboxSquare( cssCheckboxSquare(
{type: 'checkbox'}, {type: 'checkbox'},
dom.on('change', (_ev, elem) => dom.on('change', (_ev, elem) => {
elem.checked ? columnFilter.add(key) : columnFilter.delete(key)), elem.checked ? columnFilter.add(key) : columnFilter.delete(key);
}),
(elem) => { elem.checked = columnFilter.includes(key); checkboxMap.set(key, elem); }, (elem) => { elem.checked = columnFilter.includes(key); checkboxMap.set(key, elem); },
dom.style('position', 'relative'), dom.style('position', 'relative'),
), ),
@ -194,6 +213,37 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
return filterMenu; return filterMenu;
} }
function rangeInput(placeholder: string, obs: Observable<number|undefined>,
opts: {valueParser?: (val: string) => any},
...args: DomElementArg[]) {
const valueParser = opts.valueParser || Number;
const formatValue = ((val: any) => val?.toString() || '');
let editMode = false;
let el: HTMLInputElement;
// keep input content in sync only when no edit are going on.
const lis = obs.addListener(() => editMode ? null : el.value = formatValue(obs.get()));
// handle change
const onBlur = () => {
onInput.flush();
editMode = false;
el.value = formatValue(obs.get());
};
const onInput = debounce(() => {
editMode = true;
const val = el.value ? valueParser(el.value) : undefined;
if (val === undefined || !isNaN(val)) {
obs.set(val);
}
}, 10);
return el = cssRangeInput(
{inputmode: 'numeric', placeholder, value: formatValue(obs.get())},
dom.on('input', onInput),
dom.on('blur', onBlur),
dom.autoDispose(lis),
...args
);
}
/** /**
* Builds a tri-state checkbox that summaries the state of all the `values`. The special value * Builds a tri-state checkbox that summaries the state of all the `values`. The special value
@ -304,6 +354,7 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
const visibleColumnType = fieldOrColumn.visibleColModel.peek()?.type.peek() || columnType; const visibleColumnType = fieldOrColumn.visibleColModel.peek()?.type.peek() || columnType;
const {keyMapFunc, labelMapFunc, valueMapFunc} = getMapFuncs(columnType, tableData, filterInfo.fieldOrColumn); const {keyMapFunc, labelMapFunc, valueMapFunc} = getMapFuncs(columnType, tableData, filterInfo.fieldOrColumn);
const activeFilterBar = sectionFilter.viewSection.activeFilterBar; const activeFilterBar = sectionFilter.viewSection.activeFilterBar;
const valueParser = (fieldOrColumn as any).createValueParser?.();
function getFilterFunc(col: ViewFieldRec|ColumnRec, colFilter: ColumnFilterFunc|null) { function getFilterFunc(col: ViewFieldRec|ColumnRec, colFilter: ColumnFilterFunc|null) {
return col.getRowId() === fieldOrColumn.getRowId() ? null : colFilter; return col.getRowId() === fieldOrColumn.getRowId() ? null : colFilter;
@ -311,9 +362,6 @@ 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, visibleColumnType);
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,
@ -321,7 +369,11 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
addCountsToMap(valueCounts, hiddenRows, {keyMapFunc, labelMapFunc, columnType, addCountsToMap(valueCounts, hiddenRows, {keyMapFunc, labelMapFunc, columnType,
areHiddenRows: true, valueMapFunc}); areHiddenRows: true, valueMapFunc});
const model = ColumnFilterMenuModel.create(openCtl, columnFilter, Array.from(valueCounts)); const valueCountsArr = Array.from(valueCounts);
const columnFilter = ColumnFilter.create(openCtl, filterInfo.filter.peek(), columnType, visibleColumnType,
valueCountsArr.map((arr) => arr[0]));
sectionFilter.setFilterOverride(fieldOrColumn.getRowId(), columnFilter); // Will be removed on menu disposal
const model = ColumnFilterMenuModel.create(openCtl, columnFilter, valueCountsArr);
return columnFilterMenu(openCtl, { return columnFilterMenu(openCtl, {
model, model,
@ -339,6 +391,7 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
} }
}, },
renderValue: getRenderFunc(columnType, fieldOrColumn), renderValue: getRenderFunc(columnType, fieldOrColumn),
valueParser,
}); });
} }
@ -616,3 +669,24 @@ const cssToken = styled('div', `
margin-left: 8px; margin-left: 8px;
margin-right: 12px; margin-right: 12px;
`); `);
const cssRangeHeader = styled(cssMenuItem, `
padding: unset;
border-radius: 0 0 3px 0;
text-transform: uppercase;
font-size: var(--grist-x-small-font-size);
margin: 16px 16px 6px 16px;
`);
const cssRangeContainer = styled(cssMenuItem, `
display: flex;
justify-content: left;
column-gap: 10px;
`);
const cssRangeInputSeparator = styled('span', `
font-weight: 600;
position: relative;
top: 3px;
color: var(--grist-color-slate);
`);
const cssRangeInput = styled('input', `
width: 80px;
`);

View File

@ -1,14 +1,34 @@
import {CellValue} from "app/common/DocActions"; import {CellValue} from "app/common/DocActions";
import {FilterState, makeFilterState} from "app/common/FilterState"; import {FilterState, isRangeFilter, makeFilterState} from "app/common/FilterState";
import {decodeObject} from "app/plugin/objtypes"; import {decodeObject} from "app/plugin/objtypes";
import {isList, isListType} from "./gristTypes"; import {isList, isListType, isNumberType} from "./gristTypes";
export type ColumnFilterFunc = (value: CellValue) => boolean; export type ColumnFilterFunc = (value: CellValue) => boolean;
// Returns a filter function for a particular column: the function takes a cell value and returns // Returns a filter function for a particular column: the function takes a cell value and returns
// whether it's accepted according to the given FilterState. // whether it's accepted according to the given FilterState.
export function makeFilterFunc({ include, values }: FilterState, export function makeFilterFunc(state: FilterState,
columnType?: string): ColumnFilterFunc { columnType?: string): ColumnFilterFunc {
if (isRangeFilter(state)) {
const {min, max} = state;
if (isNumberType(columnType)) {
return (val) => {
if (typeof val !== 'number') { return false; }
return (
(max === undefined ? true : val <= max) &&
(min === undefined ? true : min <= val)
);
};
} else {
// Although it is not possible to set a range filter for non numeric columns, this still can
// happen as a result of a column type conversion. In this case, let's include all values.
return () => true;
}
}
const {include, values} = state;
// NOTE: This logic results in complex values and their stringified JSON representations as equivalent. // NOTE: This logic results in complex values and their stringified JSON representations as equivalent.
// For example, a TypeError in the formula column and the string '["E","TypeError"]' would be seen as the same. // For example, a TypeError in the formula column and the string '["E","TypeError"]' would be seen as the same.
// TODO: This narrow corner case seems acceptable for now, but may be worth revisiting. // TODO: This narrow corner case seems acceptable for now, but may be worth revisiting.
@ -17,7 +37,6 @@ export function makeFilterFunc({ include, values }: FilterState,
const list = decodeObject(val) as unknown[]; const list = decodeObject(val) as unknown[];
return list.some(item => values.has(item as any) === include); return list.some(item => values.has(item as any) === include);
} }
return (values.has(Array.isArray(val) ? JSON.stringify(val) : val) === include); return (values.has(Array.isArray(val) ? JSON.stringify(val) : val) === include);
}; };
} }

View File

@ -4,19 +4,32 @@ import { CellValue } from "app/common/DocActions";
export interface FilterSpec { export interface FilterSpec {
included?: CellValue[]; included?: CellValue[];
excluded?: CellValue[]; excluded?: CellValue[];
min?: number;
max?: number;
} }
export type FilterState = ByValueFilterState | RangeFilterState
// A more efficient representation of filter state for a column than FilterSpec. // A more efficient representation of filter state for a column than FilterSpec.
export interface FilterState { interface ByValueFilterState {
include: boolean; include: boolean;
values: Set<CellValue>; values: Set<CellValue>;
} }
interface RangeFilterState {
min?: number;
max?: number;
}
// Creates a FilterState. Accepts spec as a json string or a FilterSpec. // Creates a FilterState. Accepts spec as a json string or a FilterSpec.
export function makeFilterState(spec: string | FilterSpec): FilterState { export function makeFilterState(spec: string | FilterSpec): FilterState {
if (typeof (spec) === 'string') { if (typeof (spec) === 'string') {
return makeFilterState((spec && JSON.parse(spec)) || {}); return makeFilterState((spec && JSON.parse(spec)) || {});
} }
if (spec.min !== undefined || spec.max !== undefined) {
return {min: spec.min, max: spec.max};
}
return { return {
include: Boolean(spec.included), include: Boolean(spec.included),
values: new Set(spec.included || spec.excluded || []), values: new Set(spec.included || spec.excluded || []),
@ -26,8 +39,23 @@ export function makeFilterState(spec: string | FilterSpec): FilterState {
// Returns true if state and spec are equivalent, false otherwise. // Returns true if state and spec are equivalent, false otherwise.
export function isEquivalentFilter(state: FilterState, spec: FilterSpec): boolean { export function isEquivalentFilter(state: FilterState, spec: FilterSpec): boolean {
const other = makeFilterState(spec); const other = makeFilterState(spec);
if (!isRangeFilter(state) && !isRangeFilter(other)) {
if (state.include !== other.include) { return false; } if (state.include !== other.include) { return false; }
if (state.values.size !== other.values.size) { return false; } if (state.values.size !== other.values.size) { return false; }
if (other.values) {
for (const val of other.values) { if (!state.values.has(val)) { return false; } } for (const val of other.values) { if (!state.values.has(val)) { return false; } }
}
} else {
if (isRangeFilter(state) && isRangeFilter(other)) {
if (state.min !== other.min || state.max !== other.max) { return false; }
} else {
return false;
}
}
return true; return true;
} }
export function isRangeFilter(state: FilterState): state is RangeFilterState {
const {min, max} = state as any;
return min !== undefined || max !== undefined;
}

View File

@ -337,6 +337,10 @@ export function isListType(type: string) {
return type === "ChoiceList" || isRefListType(type); return type === "ChoiceList" || isRefListType(type);
} }
export function isNumberType(type: string|undefined) {
return ['Numeric', 'Int'].includes(type || '');
}
export function isFullReferencingType(type: string) { export function isFullReferencingType(type: string) {
return type.startsWith('Ref:') || isRefListType(type); return type.startsWith('Ref:') || isRefListType(type);
} }