diff --git a/app/client/models/ColumnFilter.ts b/app/client/models/ColumnFilter.ts index 0b6594cc..d641b0ee 100644 --- a/app/client/models/ColumnFilter.ts +++ b/app/client/models/ColumnFilter.ts @@ -1,6 +1,6 @@ import {CellValue} from 'app/common/DocActions'; import {nativeCompare} from 'app/common/gutil'; -import {Disposable, Observable} from 'grainjs'; +import {Computed, Disposable, Observable} from 'grainjs'; export type ColumnFilterFunc = (value: CellValue) => boolean; @@ -15,15 +15,26 @@ interface FilterState { values: Set; } -// Converts a JSON string for a filter to a FilterState -function makeFilterState(filterJson: string): FilterState { - const spec: FilterSpec = (filterJson && JSON.parse(filterJson)) || {}; +// Creates a FilterState. Accepts spec as a json string or a FilterSpec. +function makeFilterState(spec: string | FilterSpec): FilterState { + if (typeof(spec) === 'string') { + return makeFilterState((spec && JSON.parse(spec)) || {}); + } return { include: Boolean(spec.included), values: new Set(spec.included || spec.excluded || []), }; } +// Returns true if state and spec are equivalent, false otherwise. +export function isEquivalentFilter(state: FilterState, spec: FilterSpec): boolean { + const other = makeFilterState(spec); + if (state.include !== other.include) { return false; } + if (state.values.size !== other.values.size) { return false; } + for (const val of other.values) { if (!state.values.has(val)) { return false; }} + return true; +} + // 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. function makeFilterFunc({include, values}: FilterState): ColumnFilterFunc { @@ -47,18 +58,23 @@ export function getFilterFunc(filterJson: string): ColumnFilterFunc|null { * been customized. */ export class ColumnFilter extends Disposable { - public readonly filterFunc: Observable; + public readonly filterFunc = Observable.create(this, () => true); + + // Computed that returns true if filter is an inclusion filter, false otherwise. + public readonly isInclusionFilter: Computed = Computed.create(this, this.filterFunc, () => this._include); + + // Computed that returns the current filter state. + public readonly state: Computed = Computed.create(this, this.filterFunc, () => this._getState()); private _include: boolean; private _values: Set; constructor(private _initialFilterJson: string) { super(); - this.filterFunc = Observable.create(this, () => true); this.setState(_initialFilterJson); } - public setState(filterJson: string) { + public setState(filterJson: string|FilterSpec) { const state = makeFilterState(filterJson); this._include = state.include; this._values = state.values; @@ -102,7 +118,11 @@ export class ColumnFilter extends Disposable { } private _updateState(): void { - this.filterFunc.set(makeFilterFunc({include: this._include, values: this._values})); + this.filterFunc.set(makeFilterFunc(this._getState())); + } + + private _getState(): FilterState { + return {include: this._include, values: this._values}; } } diff --git a/app/client/ui/ColumnFilterMenu.ts b/app/client/ui/ColumnFilterMenu.ts index 03aef101..13f66cd5 100644 --- a/app/client/ui/ColumnFilterMenu.ts +++ b/app/client/ui/ColumnFilterMenu.ts @@ -4,7 +4,7 @@ * but on Cancel the model is reset to its initial state prior to menu closing. */ -import {allInclusive, ColumnFilter} from 'app/client/models/ColumnFilter'; +import {allInclusive, ColumnFilter, isEquivalentFilter} from 'app/client/models/ColumnFilter'; import {ViewFieldRec} from 'app/client/models/DocModel'; import {FilteredRowSource} from 'app/client/models/rowset'; import {SectionFilter} from 'app/client/models/SectionFilter'; @@ -13,10 +13,10 @@ import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {cssCheckboxSquare, cssLabel, cssLabelText} from 'app/client/ui2018/checkbox'; import {colors, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; -import {menuCssClass, menuDivider, menuIcon} from 'app/client/ui2018/menus'; +import {menuCssClass, menuDivider} from 'app/client/ui2018/menus'; import {CellValue} from 'app/common/DocActions'; import {localeCompare} from 'app/common/gutil'; -import {Computed, dom, input, makeTestId, Observable, styled} from 'grainjs'; +import {Computed, dom, IDisposableOwner, input, makeTestId, Observable, styled} from 'grainjs'; import escapeRegExp = require('lodash/escapeRegExp'); import identity = require('lodash/identity'); import {IOpenController} from 'popweasel'; @@ -34,18 +34,13 @@ interface IFilterMenuOptions { onClose: () => void; } -export function columnFilterMenu({ columnFilter, valueCounts, doSave, onClose }: IFilterMenuOptions): HTMLElement { +export function columnFilterMenu(owner: IDisposableOwner, + { columnFilter, valueCounts, doSave, onClose }: IFilterMenuOptions): HTMLElement { // Save the initial state to allow reverting back to it on Cancel const initialStateJson = columnFilter.makeFilterJson(); const testId = makeTestId('test-filter-menu-'); - // Computed boolean reflecting whether current filter state is all-inclusive. - const includesAll: Computed = Computed.create(null, columnFilter.filterFunc, () => { - const spec = columnFilter.makeFilterJson(); - return spec === allInclusive; - }); - // Map to keep track of displayed checkboxes const checkboxMap: Map = new Map(); @@ -58,79 +53,102 @@ export function columnFilterMenu({ columnFilter, valueCounts, doSave, onClose }: const valueCountArr: Array<[CellValue, IFilterCount]> = Array.from(valueCounts); - const openSearch = Observable.create(null, false); - const searchValueObs = Observable.create(null, ''); - const filteredValues = Computed.create(null, openSearch, searchValueObs, (_use, isOpen, searchValue) => { + 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 valueCountArr.filter(([key]) => !isOpen || searchRegex.test(key as string)) - .sort((a, b) => localeCompare(a[1].label, b[1].label)); + return new Set(valueCountArr.filter(([key]) => searchRegex.test(key as string)).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 reset = false; + // Gives focus to the searchInput on open + setTimeout(() => searchInput.focus(), 0); + const filterMenu: HTMLElement = cssMenu( { tabindex: '-1' }, // Allow menu to be focused testId('wrapper'), dom.cls(menuCssClass), - dom.autoDispose(includesAll), dom.autoDispose(filterListener), - dom.autoDispose(openSearch), - dom.autoDispose(searchValueObs), - dom.autoDispose(filteredValues), - (elem) => { setTimeout(() => elem.focus(), 0); }, // Grab focus on open dom.onDispose(() => doSave(reset)), // Save on disposal, which should always happen as part of closing. dom.onKeyDown({ Enter: () => onClose(), Escape: () => onClose() }), cssMenuHeader( - cssSelectAll(testId('select-all'), - dom.hide(openSearch), - dom.on('click', () => includesAll.get() ? columnFilter.clear() : columnFilter.selectAll()), - dom.domComputed(includesAll, yesNo => [ - menuIcon(yesNo ? 'CrossSmall' : 'Tick'), - yesNo ? 'Select none' : 'Select all' - ]) - ), - dom.maybe(openSearch, () => { return [ - cssLabel( - cssCheckboxSquare({type: 'checkbox', checked: includesAll.get()}, testId('search-select'), - dom.on('change', (_ev, elem) => { - if (!searchValueObs.get()) { // If no search has been entered, treat select/deselect as Select All - elem.checked ? columnFilter.selectAll() : columnFilter.clear(); - } else { // Otherwise, add/remove specific matched values - filteredValues.get() - .forEach(([key]) => elem.checked ? columnFilter.add(key) : columnFilter.delete(key)); - } - }) - ) - ), - searchInput = cssSearch(searchValueObs, { onInput: true }, - testId('search-input'), - { type: 'search', placeholder: 'Search values' }, - dom.show(openSearch), - dom.onKeyDown({ - Enter: () => undefined, - Escape: () => { - setTimeout(() => filterMenu.focus(), 0); // Give focus back to menu - openSearch.set(false); + cssSearchIcon('Search'), + searchInput = cssSearch( + searchValueObs, { onInput: true }, + testId('search-input'), + { type: 'search', placeholder: 'Search values' }, + dom.onKeyDown({ + Enter: () => { + if (searchValueObs.get()) { + columnFilter.setState({included: filteredKeys.get()}); } - }) - ) - ]; }), - dom.domComputed(openSearch, isOpen => isOpen ? - cssSearchIcon('CrossBig', testId('search-close'), dom.on('click', () => { - openSearch.set(false); + }, + Escape$: (ev) => { + if (searchValueObs.get()) { + searchValueObs.set(''); + searchInput.focus(); + ev.stopPropagation(); + } + } + }) + ), + dom.maybe(searchValueObs, () => cssSearchIcon( + 'CrossSmall', testId('search-close'), + dom.on('click', () => { searchValueObs.set(''); - })) : - cssSearchIcon('Search', testId('search-open'), dom.on('click', () => { - openSearch.set(true); - setTimeout(() => searchInput.focus(), 0); - })) - ) + searchInput.focus(); + }), + )), ), cssMenuDivider(), + cssMenuItem( + dom.domComputed((use) => { + const searchValue = use(searchValueObs); + const allSpec = searchValue ? {included: use(filteredKeys)} : {excluded: []}; + const noneSpec = searchValue ? {excluded: use(filteredKeys)} : {included: []}; + const state = use(columnFilter.state); + return [ + cssSelectAll( + dom.text(searchValue ? 'All Shown' : 'All'), + cssSelectAll.cls('-disabled', isEquivalentFilter(state, allSpec)), + dom.on('click', () => columnFilter.setState(allSpec)), + testId('select-all'), + ), + cssDotSeparator('•'), + cssSelectAll( + searchValue ? 'All Except' : 'None', + cssSelectAll.cls('-disabled', isEquivalentFilter(state, noneSpec)), + dom.on('click', () => columnFilter.setState(noneSpec)), + testId('select-all'), + ) + ]; + }) + ), cssItemList( testId('list'), dom.maybe(use => use(filteredValues).length === 0, () => cssNoResults('No matching values')), @@ -142,15 +160,33 @@ export function columnFilterMenu({ columnFilter, valueCounts, doSave, onClose }: (elem) => { elem.checked = columnFilter.includes(key); checkboxMap.set(key, elem); }), cssItemValue(value.label === undefined ? key as string : value.label), ), - cssItemCount(value.count.toLocaleString())) // Include comma separator - ) + cssItemCount(value.count.toLocaleString(), testId('count')))) // Include comma separator ), cssMenuDivider(), cssMenuFooter( - cssApplyButton('Apply', testId('apply-btn'), - dom.on('click', () => { reset = true; onClose(); })), - basicButton('Cancel', testId('cancel-btn'), - dom.on('click', () => { columnFilter.setState(initialStateJson); onClose(); } ))) + 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))) + ), + cssMenuItem( + cssApplyButton('Apply', testId('apply-btn'), + dom.on('click', () => { reset = true; onClose(); })), + basicButton('Cancel', testId('cancel-btn'), + dom.on('click', () => { columnFilter.setState(initialStateJson); onClose(); } )) + ) + ) ); return filterMenu; } @@ -167,13 +203,16 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio const valueMapFunc = (rowId: number) => formatter.formatAny(labelGetter(rowId)); const valueCounts: Map = new Map(); + // TODO: as of now, this is not working for non text-or-numeric columns, ie: for Date column it is + // not possible to search for anything. Likely caused by the key being something completely + // different than the label. addCountsToMap(valueCounts, rowSource.getAllRows() as Iterable, valueGetter, valueMapFunc); addCountsToMap(valueCounts, rowSource.getHiddenRows() as Iterable, valueGetter, valueMapFunc); const columnFilter = ColumnFilter.create(openCtl, field.activeFilter.peek()); sectionFilter.setFilterOverride(field.getRowId(), columnFilter); // Will be removed on menu disposal - return columnFilterMenu({ + return columnFilterMenu(openCtl, { columnFilter, valueCounts, onClose: () => openCtl.close(), @@ -214,34 +253,45 @@ const cssMenu = styled('div', ` max-height: 90vh; outline: none; background-color: white; + padding-top: 0; + padding-bottom: 12px; `); const cssMenuHeader = styled('div', ` + height: 40px; flex-shrink: 0; display: flex; align-items: center; - margin: 0 8px; + margin: 0 16px; `); const cssSelectAll = styled('div', ` display: flex; color: ${colors.lightGreen}; cursor: default; user-select: none; + &-disabled { + color: ${colors.slate}; + } +`); +const cssDotSeparator = styled('span', ` + color: ${colors.lightGreen}; + margin: 0 4px; `); const cssMenuDivider = styled(menuDivider, ` flex-shrink: 0; - margin: 8px 0; + margin: 0; `); const cssItemList = styled('div', ` flex-shrink: 1; overflow: auto; - padding-right: 8px; /* Space for scrollbar */ min-height: 80px; + margin-top: 4px; + padding-bottom: 8px; `); const cssMenuItem = styled('div', ` display: flex; - padding: 4px 8px; + padding: 8px 16px; `); const cssItemValue = styled(cssLabelText, ` margin-right: 12px; @@ -256,8 +306,11 @@ const cssItemCount = styled('div', ` `); const cssMenuFooter = styled('div', ` display: flex; - margin: 0 8px; flex-shrink: 0; + flex-direction: column; + & .${cssMenuItem.className} { + padding: 12px 16px; + } `); const cssApplyButton = styled(primaryButton, ` margin-right: 4px; @@ -268,7 +321,7 @@ const cssSearch = styled(input, ` -webkit-appearance: none; -moz-appearance: none; - font-size: ${vars.controlFontSize}; + font-size: ${vars.mediumFontSize}; margin: 0px 16px 0px 8px; padding: 0px;