(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 3 years ago
parent 1a5bacc807
commit 351a717e6d

@ -85,13 +85,17 @@ export class ColumnFilter extends Disposable {
return this._values.has(val) === this._include;
}
public add(val: CellValue) {
this._include ? this._values.add(val) : this._values.delete(val);
public add(...values: CellValue[]) {
for (const val of values) {
this._include ? this._values.add(val) : this._values.delete(val);
}
this._updateState();
}
public delete(val: CellValue) {
this._include ? this._values.delete(val) : this._values.add(val);
public delete(...values: CellValue[]) {
for (const val of values) {
this._include ? this._values.delete(val) : this._values.add(val);
}
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 {menuCssClass, menuDivider} from 'app/client/ui2018/menus';
import {CellValue} from 'app/common/DocActions';
import {localeCompare} from 'app/common/gutil';
import {Computed, dom, IDisposableOwner, input, makeTestId, Observable, styled} from 'grainjs';
import escapeRegExp = require('lodash/escapeRegExp');
import {Computed, Disposable, dom, IDisposableOwner, input, makeTestId, styled} from 'grainjs';
import identity = require('lodash/identity');
import {IOpenController} from 'popweasel';
import {ColumnFilterMenuModel, IFilterCount} from '../models/ColumnFilterMenuModel';
interface IFilterCount {
label: string;
count: number;
}
interface IFilterMenuOptions {
columnFilter: ColumnFilter;
model: ColumnFilterMenuModel;
valueCounts: Map<CellValue, IFilterCount>;
doSave: (reset: boolean) => void;
onClose: () => void;
}
export function columnFilterMenu(owner: IDisposableOwner,
{ columnFilter, valueCounts, doSave, onClose }: IFilterMenuOptions): HTMLElement {
const testId = makeTestId('test-filter-menu-');
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
const initialStateJson = columnFilter.makeFilterJson();
const testId = makeTestId('test-filter-menu-');
// Map to keep track of displayed checkboxes
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 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();
});
const {searchValue: searchValueObs, filteredValues, filteredKeys} = model;
// computes the array of keys that matches the search text
const filteredKeys = Computed.create(owner, filteredValues, (_use, values) => values.map(([key]) => key));
const isAboveLimitObs = Computed.create(owner, (use) => use(model.valuesBeyondLimit).length > 0);
const isSearchingObs = Computed.create(owner, (use) => Boolean(use(searchValueObs)));
let searchInput: HTMLInputElement;
let reset = false;
@ -155,34 +126,39 @@ export function columnFilterMenu(owner: IDisposableOwner,
cssItemList(
testId('list'),
dom.maybe(use => use(filteredValues).length === 0, () => cssNoResults('No matching values')),
dom.forEach(filteredValues, ([key, value]) => cssMenuItem(
cssLabel(
cssCheckboxSquare({type: 'checkbox'},
dom.on('change', (_ev, elem) =>
elem.checked ? columnFilter.add(key) : columnFilter.delete(key)),
(elem) => { elem.checked = columnFilter.includes(key); checkboxMap.set(key, elem); }),
dom.domComputed(filteredValues, (values) => values.slice(0, model.limitShown).map(([key, value]) => (
cssMenuItem(
cssLabel(
cssCheckboxSquare({type: 'checkbox'},
dom.on('change', (_ev, 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),
),
cssItemCount(value.count.toLocaleString(), testId('count')))) // Include comma separator
),
cssItemCount(value.count.toLocaleString(), testId('count')))
))) // Include comma separator
),
cssMenuDivider(),
cssMenuFooter(
cssMenuItem(
testId('summary'),
cssLabel(
cssCheckboxSquare(
{type: 'checkbox'},
dom.on('change', (_ev, elem) => columnFilter.setState(
elem.checked ?
{excluded: filteredKeys.get().filter((key) => !columnFilter.includes(key))} :
{included: filteredKeys.get().filter((key) => columnFilter.includes(key))}
)),
dom.prop('checked', (use) => !use(columnFilter.isInclusionFilter))
),
cssItemValue(dom.text((use) => use(searchValueObs) ? 'Others' : 'Future Values')),
),
dom.maybe(searchValueObs, () => cssItemCount(dom.text(othersCount)))
),
dom.domComputed((use) => {
const isAboveLimit = use(isAboveLimitObs);
const searchValue = use(isSearchingObs);
if (isAboveLimit) {
return searchValue ? [
buildSummary('Other Matching', BeyondLimit, model),
buildSummary('Other Non-Matching', OtherValues, model),
] : [
buildSummary('Other Values', BeyondLimit, model),
buildSummary('Future Values', OtherValues, model)
];
} else {
return searchValue ? [
buildSummary('Others', OtherValues, model)
] : [
buildSummary('Future Values', OtherValues, model)
];
}
}),
cssMenuItem(
cssApplyButton('Apply', testId('apply-btn'),
dom.on('click', () => { reset = true; onClose(); })),
@ -194,6 +170,90 @@ export function columnFilterMenu(owner: IDisposableOwner,
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().
*/
@ -213,10 +273,11 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
addCountsToMap(valueCounts, rowSource.getHiddenRows() as Iterable<number>, valueGetter, valueMapFunc);
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
return columnFilterMenu(openCtl, {
columnFilter,
model,
valueCounts,
onClose: () => openCtl.close(),
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', `
display: flex;
flex-direction: column;
@ -311,9 +376,7 @@ const cssMenuFooter = styled('div', `
display: flex;
flex-shrink: 0;
flex-direction: column;
& .${cssMenuItem.className} {
padding: 12px 16px;
}
padding-top: 4px;
`);
const cssApplyButton = styled(primaryButton, `
margin-right: 4px;

Loading…
Cancel
Save