(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
This commit is contained in:
Cyprien P 2021-03-08 17:31:06 +01:00
parent 6e844a2e76
commit e2d3b70509
2 changed files with 156 additions and 83 deletions

View File

@ -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<CellValue>;
}
// 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<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 _values: Set<CellValue>;
constructor(private _initialFilterJson: string) {
super();
this.filterFunc = Observable.create<ColumnFilterFunc>(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};
}
}

View File

@ -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<boolean> = Computed.create(null, columnFilter.filterFunc, () => {
const spec = columnFilter.makeFilterJson();
return spec === allInclusive;
});
// Map to keep track of displayed checkboxes
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 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 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 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<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.getHiddenRows() as Iterable<number>, 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;