diff --git a/app/client/models/ColumnFilter.ts b/app/client/models/ColumnFilter.ts index d641b0ee..888a5207 100644 --- a/app/client/models/ColumnFilter.ts +++ b/app/client/models/ColumnFilter.ts @@ -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(); } diff --git a/app/client/models/ColumnFilterMenuModel.ts b/app/client/models/ColumnFilterMenuModel.ts new file mode 100644 index 00000000..5bd5fd84 --- /dev/null +++ b/app/client/models/ColumnFilterMenuModel.ts @@ -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(); + } +} diff --git a/app/client/ui/ColumnFilterMenu.ts b/app/client/ui/ColumnFilterMenu.ts index 672b2c40..c4b2f057 100644 --- a/app/client/ui/ColumnFilterMenu.ts +++ b/app/client/ui/ColumnFilterMenu.ts @@ -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; 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 = 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 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(); - }); + 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 ; + + // Callback for when the checkbox is changed. + callback: (checked: boolean) => void; + + // The count. + count: Computed; +} + +// 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, 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, 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;