(core) Filtering improvement - part 1

Summary:
 - Makes search input alway visible
 - Gives search input focus on open
 - Adds `Future Values` Checkbox
 - Show `All Shown` `All Excpet` when values are filtered
 - Show `Others` instead of `Future Values` when values are filtered
 - Escape close search input
 - Enter does the same as `All Shown` when filtering values

Test Plan:
 - Updated existing projects and nbrowser test
 - Adds new projects test

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2753
pull/3/head
Cyprien P 4 years ago
parent 6e844a2e76
commit e2d3b70509

@ -1,6 +1,6 @@
import {CellValue} from 'app/common/DocActions'; import {CellValue} from 'app/common/DocActions';
import {nativeCompare} from 'app/common/gutil'; import {nativeCompare} from 'app/common/gutil';
import {Disposable, Observable} from 'grainjs'; import {Computed, Disposable, Observable} from 'grainjs';
export type ColumnFilterFunc = (value: CellValue) => boolean; export type ColumnFilterFunc = (value: CellValue) => boolean;
@ -15,15 +15,26 @@ interface FilterState {
values: Set<CellValue>; values: Set<CellValue>;
} }
// Converts a JSON string for a filter to a FilterState // Creates a FilterState. Accepts spec as a json string or a FilterSpec.
function makeFilterState(filterJson: string): FilterState { function makeFilterState(spec: string | FilterSpec): FilterState {
const spec: FilterSpec = (filterJson && JSON.parse(filterJson)) || {}; if (typeof(spec) === 'string') {
return makeFilterState((spec && JSON.parse(spec)) || {});
}
return { return {
include: Boolean(spec.included), include: Boolean(spec.included),
values: new Set(spec.included || spec.excluded || []), 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 // 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. // whether it's accepted according to the given FilterState.
function makeFilterFunc({include, values}: FilterState): ColumnFilterFunc { function makeFilterFunc({include, values}: FilterState): ColumnFilterFunc {
@ -47,18 +58,23 @@ export function getFilterFunc(filterJson: string): ColumnFilterFunc|null {
* been customized. * been customized.
*/ */
export class ColumnFilter extends Disposable { export class ColumnFilter extends Disposable {
public readonly filterFunc: Observable<ColumnFilterFunc>; public readonly filterFunc = Observable.create<ColumnFilterFunc>(this, () => true);
// Computed that returns true if filter is an inclusion filter, false otherwise.
public readonly isInclusionFilter: Computed<boolean> = Computed.create(this, this.filterFunc, () => this._include);
// Computed that returns the current filter state.
public readonly state: Computed<FilterState> = Computed.create(this, this.filterFunc, () => this._getState());
private _include: boolean; private _include: boolean;
private _values: Set<CellValue>; private _values: Set<CellValue>;
constructor(private _initialFilterJson: string) { constructor(private _initialFilterJson: string) {
super(); super();
this.filterFunc = Observable.create<ColumnFilterFunc>(this, () => true);
this.setState(_initialFilterJson); this.setState(_initialFilterJson);
} }
public setState(filterJson: string) { public setState(filterJson: string|FilterSpec) {
const state = makeFilterState(filterJson); const state = makeFilterState(filterJson);
this._include = state.include; this._include = state.include;
this._values = state.values; this._values = state.values;
@ -102,7 +118,11 @@ export class ColumnFilter extends Disposable {
} }
private _updateState(): void { 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};
} }
} }

@ -4,7 +4,7 @@
* but on Cancel the model is reset to its initial state prior to menu closing. * 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 {ViewFieldRec} from 'app/client/models/DocModel';
import {FilteredRowSource} from 'app/client/models/rowset'; import {FilteredRowSource} from 'app/client/models/rowset';
import {SectionFilter} from 'app/client/models/SectionFilter'; 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 {cssCheckboxSquare, cssLabel, cssLabelText} from 'app/client/ui2018/checkbox';
import {colors, vars} from 'app/client/ui2018/cssVars'; 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, menuIcon} 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 {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 escapeRegExp = require('lodash/escapeRegExp');
import identity = require('lodash/identity'); import identity = require('lodash/identity');
import {IOpenController} from 'popweasel'; import {IOpenController} from 'popweasel';
@ -34,18 +34,13 @@ interface IFilterMenuOptions {
onClose: () => void; 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 // 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-'); const testId = makeTestId('test-filter-menu-');
// Computed boolean reflecting whether current filter state is all-inclusive.
const includesAll: Computed<boolean> = Computed.create(null, columnFilter.filterFunc, () => {
const spec = columnFilter.makeFilterJson();
return spec === allInclusive;
});
// 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();
@ -58,79 +53,102 @@ export function columnFilterMenu({ columnFilter, valueCounts, doSave, onClose }:
const valueCountArr: Array<[CellValue, IFilterCount]> = Array.from(valueCounts); const valueCountArr: Array<[CellValue, IFilterCount]> = Array.from(valueCounts);
const openSearch = Observable.create(null, false); const searchValueObs = Observable.create(owner, '');
const searchValueObs = Observable.create(null, '');
const filteredValues = Computed.create(null, openSearch, searchValueObs, (_use, isOpen, searchValue) => { // 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'); const searchRegex = new RegExp(escapeRegExp(searchValue), 'i');
return valueCountArr.filter(([key]) => !isOpen || searchRegex.test(key as string)) return new Set(valueCountArr.filter(([key]) => searchRegex.test(key as string)).map(([key]) => key));
.sort((a, b) => localeCompare(a[1].label, b[1].label)); });
// 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 filteredKeys = Computed.create(owner, filteredValues, (_use, values) => values.map(([key]) => key));
let searchInput: HTMLInputElement; let searchInput: HTMLInputElement;
let reset = false; let reset = false;
// Gives focus to the searchInput on open
setTimeout(() => searchInput.focus(), 0);
const filterMenu: HTMLElement = cssMenu( const filterMenu: HTMLElement = cssMenu(
{ tabindex: '-1' }, // Allow menu to be focused { tabindex: '-1' }, // Allow menu to be focused
testId('wrapper'), testId('wrapper'),
dom.cls(menuCssClass), dom.cls(menuCssClass),
dom.autoDispose(includesAll),
dom.autoDispose(filterListener), 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.onDispose(() => doSave(reset)), // Save on disposal, which should always happen as part of closing.
dom.onKeyDown({ dom.onKeyDown({
Enter: () => onClose(), Enter: () => onClose(),
Escape: () => onClose() Escape: () => onClose()
}), }),
cssMenuHeader( cssMenuHeader(
cssSelectAll(testId('select-all'), cssSearchIcon('Search'),
dom.hide(openSearch), searchInput = cssSearch(
dom.on('click', () => includesAll.get() ? columnFilter.clear() : columnFilter.selectAll()), searchValueObs, { onInput: true },
dom.domComputed(includesAll, yesNo => [ testId('search-input'),
menuIcon(yesNo ? 'CrossSmall' : 'Tick'), { type: 'search', placeholder: 'Search values' },
yesNo ? 'Select none' : 'Select all' dom.onKeyDown({
]) Enter: () => {
), if (searchValueObs.get()) {
dom.maybe(openSearch, () => { return [ columnFilter.setState({included: filteredKeys.get()});
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);
} }
}) },
) Escape$: (ev) => {
]; }), if (searchValueObs.get()) {
dom.domComputed(openSearch, isOpen => isOpen ? searchValueObs.set('');
cssSearchIcon('CrossBig', testId('search-close'), dom.on('click', () => { searchInput.focus();
openSearch.set(false); ev.stopPropagation();
}
}
})
),
dom.maybe(searchValueObs, () => cssSearchIcon(
'CrossSmall', testId('search-close'),
dom.on('click', () => {
searchValueObs.set(''); searchValueObs.set('');
})) : searchInput.focus();
cssSearchIcon('Search', testId('search-open'), dom.on('click', () => { }),
openSearch.set(true); )),
setTimeout(() => searchInput.focus(), 0);
}))
)
), ),
cssMenuDivider(), 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( 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')),
@ -142,15 +160,33 @@ export function columnFilterMenu({ columnFilter, valueCounts, doSave, onClose }:
(elem) => { elem.checked = columnFilter.includes(key); checkboxMap.set(key, elem); }), (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())) // Include comma separator cssItemCount(value.count.toLocaleString(), testId('count')))) // Include comma separator
)
), ),
cssMenuDivider(), cssMenuDivider(),
cssMenuFooter( cssMenuFooter(
cssApplyButton('Apply', testId('apply-btn'), cssMenuItem(
dom.on('click', () => { reset = true; onClose(); })), testId('summary'),
basicButton('Cancel', testId('cancel-btn'), cssLabel(
dom.on('click', () => { columnFilter.setState(initialStateJson); onClose(); } ))) 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; return filterMenu;
} }
@ -167,13 +203,16 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
const valueMapFunc = (rowId: number) => formatter.formatAny(labelGetter(rowId)); const valueMapFunc = (rowId: number) => formatter.formatAny(labelGetter(rowId));
const valueCounts: Map<CellValue, {label: string, count: number}> = new Map(); const valueCounts: Map<CellValue, {label: string, count: number}> = 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<number>, valueGetter, valueMapFunc); addCountsToMap(valueCounts, rowSource.getAllRows() as Iterable<number>, valueGetter, valueMapFunc);
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());
sectionFilter.setFilterOverride(field.getRowId(), columnFilter); // Will be removed on menu disposal sectionFilter.setFilterOverride(field.getRowId(), columnFilter); // Will be removed on menu disposal
return columnFilterMenu({ return columnFilterMenu(openCtl, {
columnFilter, columnFilter,
valueCounts, valueCounts,
onClose: () => openCtl.close(), onClose: () => openCtl.close(),
@ -214,34 +253,45 @@ const cssMenu = styled('div', `
max-height: 90vh; max-height: 90vh;
outline: none; outline: none;
background-color: white; background-color: white;
padding-top: 0;
padding-bottom: 12px;
`); `);
const cssMenuHeader = styled('div', ` const cssMenuHeader = styled('div', `
height: 40px;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
margin: 0 8px; margin: 0 16px;
`); `);
const cssSelectAll = styled('div', ` const cssSelectAll = styled('div', `
display: flex; display: flex;
color: ${colors.lightGreen}; color: ${colors.lightGreen};
cursor: default; cursor: default;
user-select: none; user-select: none;
&-disabled {
color: ${colors.slate};
}
`);
const cssDotSeparator = styled('span', `
color: ${colors.lightGreen};
margin: 0 4px;
`); `);
const cssMenuDivider = styled(menuDivider, ` const cssMenuDivider = styled(menuDivider, `
flex-shrink: 0; flex-shrink: 0;
margin: 8px 0; margin: 0;
`); `);
const cssItemList = styled('div', ` const cssItemList = styled('div', `
flex-shrink: 1; flex-shrink: 1;
overflow: auto; overflow: auto;
padding-right: 8px; /* Space for scrollbar */
min-height: 80px; min-height: 80px;
margin-top: 4px;
padding-bottom: 8px;
`); `);
const cssMenuItem = styled('div', ` const cssMenuItem = styled('div', `
display: flex; display: flex;
padding: 4px 8px; padding: 8px 16px;
`); `);
const cssItemValue = styled(cssLabelText, ` const cssItemValue = styled(cssLabelText, `
margin-right: 12px; margin-right: 12px;
@ -256,8 +306,11 @@ const cssItemCount = styled('div', `
`); `);
const cssMenuFooter = styled('div', ` const cssMenuFooter = styled('div', `
display: flex; display: flex;
margin: 0 8px;
flex-shrink: 0; flex-shrink: 0;
flex-direction: column;
& .${cssMenuItem.className} {
padding: 12px 16px;
}
`); `);
const cssApplyButton = styled(primaryButton, ` const cssApplyButton = styled(primaryButton, `
margin-right: 4px; margin-right: 4px;
@ -268,7 +321,7 @@ const cssSearch = styled(input, `
-webkit-appearance: none; -webkit-appearance: none;
-moz-appearance: none; -moz-appearance: none;
font-size: ${vars.controlFontSize}; font-size: ${vars.mediumFontSize};
margin: 0px 16px 0px 8px; margin: 0px 16px 0px 8px;
padding: 0px; padding: 0px;

Loading…
Cancel
Save