(core) Adds `limitShown` option to ColumnFilterMenu, defaults to 500

Summary:
  -   Allows ColumnFilter to add/delete keys by batch
  -   Add options limitShown to ColumnFilterMenu
  -   Add summary checkboxes Other Matching/Other Non-Matching/Other Values
  -   Adds missing type to chai declaration

Test Plan:
 -  Adds project test to new file projects/ColumnFilterMenu2
 -  Adds nbrowser test to new file nbrowser/ColumnFilterMenu

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2763
pull/3/head
Cyprien P 4 years ago
parent 1a5bacc807
commit 351a717e6d

@ -85,13 +85,17 @@ export class ColumnFilter extends Disposable {
return this._values.has(val) === this._include; return this._values.has(val) === this._include;
} }
public add(val: CellValue) { public add(...values: CellValue[]) {
this._include ? this._values.add(val) : this._values.delete(val); for (const val of values) {
this._include ? this._values.add(val) : this._values.delete(val);
}
this._updateState(); this._updateState();
} }
public delete(val: CellValue) { public delete(...values: CellValue[]) {
this._include ? this._values.delete(val) : this._values.add(val); for (const val of values) {
this._include ? this._values.delete(val) : this._values.add(val);
}
this._updateState(); this._updateState();
} }

@ -0,0 +1,49 @@
import { Computed, Disposable, Observable } from "grainjs";
import escapeRegExp = require("lodash/escapeRegExp");
import { CellValue } from "app/plugin/GristData";
import { localeCompare } from "app/common/gutil";
import { ColumnFilter } from "./ColumnFilter";
const MAXIMUM_SHOWN_FILTER_ITEMS = 500;
export interface IFilterCount {
label: string;
count: number;
}
export class ColumnFilterMenuModel extends Disposable {
public readonly searchValue = Observable.create(this, '');
// computes a set of all keys that matches the search text.
public readonly filterSet = Computed.create(this, this.searchValue, (_use, searchValue) => {
const searchRegex = new RegExp(escapeRegExp(searchValue), 'i');
return new Set(this._valueCount.filter(([_, {label}]) => searchRegex.test(label)).map(([key]) => key));
});
// computes the sorted array of all values (ie: pair of key and IFilterCount) that matches the search text.
public readonly filteredValues = Computed.create(this, this.filterSet, (_use, filter) => {
return this._valueCount.filter(([key]) => filter.has(key))
.sort((a, b) => localeCompare(a[1].label, b[1].label));
});
// computes the array of all values that does NOT matches the search text
public readonly otherValues = Computed.create(this, this.filterSet, (_use, filter) => {
return this._valueCount.filter(([key]) => !filter.has(key));
});
// computes the array of keys that matches the search text
public readonly filteredKeys = Computed.create(this, this.filteredValues, (_use, values) => (
values.map(([key]) => key)
));
public readonly valuesBeyondLimit = Computed.create(this, this.filteredValues, (_use, values) => (
values.slice(this.limitShown)
));
constructor(public columnFilter: ColumnFilter, private _valueCount: Array<[CellValue, IFilterCount]>,
public limitShown: number = MAXIMUM_SHOWN_FILTER_ITEMS) {
super();
}
}

@ -15,32 +15,27 @@ import {colors, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; 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 {localeCompare} from 'app/common/gutil'; import {Computed, Disposable, dom, IDisposableOwner, input, makeTestId, styled} from 'grainjs';
import {Computed, dom, IDisposableOwner, input, makeTestId, Observable, styled} from 'grainjs';
import escapeRegExp = require('lodash/escapeRegExp');
import identity = require('lodash/identity'); import identity = require('lodash/identity');
import {IOpenController} from 'popweasel'; import {IOpenController} from 'popweasel';
import {ColumnFilterMenuModel, IFilterCount} from '../models/ColumnFilterMenuModel';
interface IFilterCount {
label: string;
count: number;
}
interface IFilterMenuOptions { interface IFilterMenuOptions {
columnFilter: ColumnFilter; model: ColumnFilterMenuModel;
valueCounts: Map<CellValue, IFilterCount>; valueCounts: Map<CellValue, IFilterCount>;
doSave: (reset: boolean) => void; doSave: (reset: boolean) => void;
onClose: () => void; onClose: () => void;
} }
export function columnFilterMenu(owner: IDisposableOwner, const testId = makeTestId('test-filter-menu-');
{ columnFilter, valueCounts, doSave, onClose }: IFilterMenuOptions): HTMLElement {
export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptions): HTMLElement {
const { model, doSave, onClose } = opts;
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();
const testId = makeTestId('test-filter-menu-');
// 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();
@ -51,34 +46,10 @@ export function columnFilterMenu(owner: IDisposableOwner,
} }
}); });
const valueCountArr: Array<[CellValue, IFilterCount]> = Array.from(valueCounts); const {searchValue: searchValueObs, filteredValues, filteredKeys} = model;
const searchValueObs = Observable.create(owner, '');
// computes a set of all keys that matches the search text.
const filterSet = Computed.create(owner, searchValueObs, (_use, searchValue) => {
const searchRegex = new RegExp(escapeRegExp(searchValue), 'i');
return new Set(valueCountArr.filter(([_, {label}]) => searchRegex.test(label)).map(([key]) => key));
});
// computes the sorted array of all values (ie: pair of key and IFilterCount) that matches the search text.
const filteredValues = Computed.create(owner, filterSet, (_use, filter) => {
return valueCountArr.filter(([key]) => filter.has(key))
.sort((a, b) => localeCompare(a[1].label, b[1].label));
});
// computes the array of all values that does NOT matches the search text
const otherValues = Computed.create(owner, filterSet, (_use, filter) => {
return valueCountArr.filter(([key]) => !filter.has(key));
});
// computes the total count across all values that dont match the search text
const othersCount = Computed.create(owner, otherValues, (_use, others) => {
return others.reduce((acc, val) => acc + val[1].count, 0).toLocaleString();
});
// computes the array of keys that matches the search text const isAboveLimitObs = Computed.create(owner, (use) => use(model.valuesBeyondLimit).length > 0);
const filteredKeys = Computed.create(owner, filteredValues, (_use, values) => values.map(([key]) => key)); const isSearchingObs = Computed.create(owner, (use) => Boolean(use(searchValueObs)));
let searchInput: HTMLInputElement; let searchInput: HTMLInputElement;
let reset = false; let reset = false;
@ -155,34 +126,39 @@ export function columnFilterMenu(owner: IDisposableOwner,
cssItemList( cssItemList(
testId('list'), testId('list'),
dom.maybe(use => use(filteredValues).length === 0, () => cssNoResults('No matching values')), dom.maybe(use => use(filteredValues).length === 0, () => cssNoResults('No matching values')),
dom.forEach(filteredValues, ([key, value]) => cssMenuItem( dom.domComputed(filteredValues, (values) => values.slice(0, model.limitShown).map(([key, value]) => (
cssLabel( cssMenuItem(
cssCheckboxSquare({type: 'checkbox'}, cssLabel(
dom.on('change', (_ev, elem) => cssCheckboxSquare({type: 'checkbox'},
elem.checked ? columnFilter.add(key) : columnFilter.delete(key)), dom.on('change', (_ev, elem) =>
(elem) => { elem.checked = columnFilter.includes(key); checkboxMap.set(key, elem); }), elem.checked ? columnFilter.add(key) : columnFilter.delete(key)),
(elem) => { elem.checked = columnFilter.includes(key); checkboxMap.set(key, elem); }),
cssItemValue(value.label === undefined ? key as string : value.label), cssItemValue(value.label === undefined ? key as string : value.label),
), ),
cssItemCount(value.count.toLocaleString(), testId('count')))) // Include comma separator cssItemCount(value.count.toLocaleString(), testId('count')))
))) // Include comma separator
), ),
cssMenuDivider(), cssMenuDivider(),
cssMenuFooter( cssMenuFooter(
cssMenuItem( dom.domComputed((use) => {
testId('summary'), const isAboveLimit = use(isAboveLimitObs);
cssLabel( const searchValue = use(isSearchingObs);
cssCheckboxSquare( if (isAboveLimit) {
{type: 'checkbox'}, return searchValue ? [
dom.on('change', (_ev, elem) => columnFilter.setState( buildSummary('Other Matching', BeyondLimit, model),
elem.checked ? buildSummary('Other Non-Matching', OtherValues, model),
{excluded: filteredKeys.get().filter((key) => !columnFilter.includes(key))} : ] : [
{included: filteredKeys.get().filter((key) => columnFilter.includes(key))} buildSummary('Other Values', BeyondLimit, model),
)), buildSummary('Future Values', OtherValues, model)
dom.prop('checked', (use) => !use(columnFilter.isInclusionFilter)) ];
), } else {
cssItemValue(dom.text((use) => use(searchValueObs) ? 'Others' : 'Future Values')), return searchValue ? [
), buildSummary('Others', OtherValues, model)
dom.maybe(searchValueObs, () => cssItemCount(dom.text(othersCount))) ] : [
), buildSummary('Future Values', OtherValues, model)
];
}
}),
cssMenuItem( cssMenuItem(
cssApplyButton('Apply', testId('apply-btn'), cssApplyButton('Apply', testId('apply-btn'),
dom.on('click', () => { reset = true; onClose(); })), dom.on('click', () => { reset = true; onClose(); })),
@ -194,6 +170,90 @@ export function columnFilterMenu(owner: IDisposableOwner,
return filterMenu; return filterMenu;
} }
// Describes the model for one summary checkbox.
interface SummaryModel extends Disposable {
// Whether checkbox is checked
isChecked: Computed<boolean> ;
// Callback for when the checkbox is changed.
callback: (checked: boolean) => void;
// The count.
count: Computed<string>;
}
// Ctor that construct a SummaryModel.
type SummaryModelCreator = new(columnFilter: ColumnFilterMenuModel) => SummaryModel;
// Summaries all the values that are in `columnFilter.valuesBeyondLimit`, ie: it includes a count
// for all the values and clicking the checkbox successively add/delete these values from the
// `columnFilter`.
class BeyondLimit extends Disposable implements SummaryModel {
public columnFilter = this.model.columnFilter;
public isChecked = Computed.create(this, (use) => (
!use(this.model.valuesBeyondLimit).find(([key, _val]) => !this.columnFilter.includes(key))
));
public count = Computed.create(this, (use) => getCount(use(this.model.valuesBeyondLimit)).toLocaleString());
constructor(public model: ColumnFilterMenuModel) { super(); }
public callback(checked: boolean) {
const keys = this.model.valuesBeyondLimit.get().map(([key, _val]) => key);
if (checked) {
this.columnFilter.add(...keys);
} else {
this.columnFilter.delete(...keys);
}
}
}
// Summaries the values that are not in columnFilter.filteredValues, it includes both the values in
// `columnFilter.otherValues` (ie: the values that are filtered out if user is using the search) and
// the future values. The checkbox successively turns columnFilter into an inclusion/exclusion
// filter. The count is hidden if `columnFilter.otherValues` is empty (ie: no search is peformed =>
// only checkbox only toggles future values)
class OtherValues extends Disposable implements SummaryModel {
public columnFilter = this.model.columnFilter;
public isChecked = Computed.create(this, (use) => !use(this.columnFilter.isInclusionFilter));
public count = Computed.create(this, (use) => {
const c = getCount(use(this.model.otherValues));
return c ? c.toLocaleString() : '';
});
constructor(public model: ColumnFilterMenuModel) { super(); }
public callback(checked: boolean) {
const columnFilter = this.columnFilter;
const filteredKeys = this.model.filteredKeys;
const state = checked ?
{excluded: filteredKeys.get().filter((key) => !columnFilter.includes(key))} :
{included: filteredKeys.get().filter((key) => columnFilter.includes(key))};
return columnFilter.setState(state);
}
}
function buildSummary(label: string, SummaryModelCtor: SummaryModelCreator, model: ColumnFilterMenuModel) {
const summaryModel = new SummaryModelCtor(model);
return cssMenuItem(
dom.autoDispose(summaryModel),
testId('summary'),
cssLabel(
cssCheckboxSquare(
{type: 'checkbox'},
dom.on('change', (_ev, elem) => summaryModel.callback(elem.checked)),
dom.prop('checked', summaryModel.isChecked)
),
cssItemValue(label),
),
summaryModel.count !== undefined ? cssItemCount(dom.text(summaryModel.count), testId('count')) : null,
);
}
/** /**
* Returns content for the newly created columnFilterMenu; for use with setPopupToCreateDom(). * Returns content for the newly created columnFilterMenu; for use with setPopupToCreateDom().
*/ */
@ -213,10 +273,11 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
addCountsToMap(valueCounts, rowSource.getHiddenRows() as Iterable<number>, valueGetter, valueMapFunc); addCountsToMap(valueCounts, rowSource.getHiddenRows() as Iterable<number>, valueGetter, valueMapFunc);
const columnFilter = ColumnFilter.create(openCtl, field.activeFilter.peek()); const columnFilter = ColumnFilter.create(openCtl, field.activeFilter.peek());
const model = ColumnFilterMenuModel.create(openCtl, columnFilter, Array.from(valueCounts));
sectionFilter.setFilterOverride(field.getRowId(), columnFilter); // Will be removed on menu disposal sectionFilter.setFilterOverride(field.getRowId(), columnFilter); // Will be removed on menu disposal
return columnFilterMenu(openCtl, { return columnFilterMenu(openCtl, {
columnFilter, model,
valueCounts, valueCounts,
onClose: () => openCtl.close(), onClose: () => openCtl.close(),
doSave: (reset: boolean = false) => { doSave: (reset: boolean = false) => {
@ -248,6 +309,10 @@ function addCountsToMap(valueMap: Map<CellValue, IFilterCount>, values: Iterable
} }
} }
function getCount(values: Array<[CellValue, IFilterCount]>) {
return values.reduce((acc, val) => acc + val[1].count, 0);
}
const cssMenu = styled('div', ` const cssMenu = styled('div', `
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -311,9 +376,7 @@ const cssMenuFooter = styled('div', `
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
flex-direction: column; flex-direction: column;
& .${cssMenuItem.className} { padding-top: 4px;
padding: 12px 16px;
}
`); `);
const cssApplyButton = styled(primaryButton, ` const cssApplyButton = styled(primaryButton, `
margin-right: 4px; margin-right: 4px;

Loading…
Cancel
Save