mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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
This commit is contained in:
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
49
app/client/models/ColumnFilterMenuModel.ts
Normal file
49
app/client/models/ColumnFilterMenuModel.ts
Normal file
@ -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, '');
|
const isAboveLimitObs = Computed.create(owner, (use) => use(model.valuesBeyondLimit).length > 0);
|
||||||
|
const isSearchingObs = Computed.create(owner, (use) => Boolean(use(searchValueObs)));
|
||||||
// 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 don’t 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 filteredKeys = Computed.create(owner, filteredValues, (_use, values) => values.map(([key]) => key));
|
|
||||||
|
|
||||||
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…
Reference in New Issue
Block a user