gristlabs_grist-core/app/client/ui/ColumnFilterMenu.ts
Cyprien P e2d3b70509 (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
2021-03-16 11:59:36 +01:00

342 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Creates a UI for column filter menu given a columnFilter model, a mapping of cell values to counts, and an onClose
* callback that's triggered on Apply or on Cancel. Changes to the UI result in changes to the underlying model,
* but on Cancel the model is reset to its initial state prior to menu closing.
*/
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';
import {TableData} from 'app/client/models/TableData';
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} 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 identity = require('lodash/identity');
import {IOpenController} from 'popweasel';
interface IFilterCount {
label: string;
count: number;
}
interface IFilterMenuOptions {
columnFilter: ColumnFilter;
valueCounts: Map<CellValue, IFilterCount>;
doSave: (reset: boolean) => void;
onClose: () => void;
}
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-');
// Map to keep track of displayed checkboxes
const checkboxMap: Map<CellValue, HTMLInputElement> = new Map();
// Listen for changes to filterFunc, and update checkboxes accordingly
const filterListener = columnFilter.filterFunc.addListener(func => {
for (const [value, elem] of checkboxMap) {
elem.checked = func(value);
}
});
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(([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(filterListener),
dom.onDispose(() => doSave(reset)), // Save on disposal, which should always happen as part of closing.
dom.onKeyDown({
Enter: () => onClose(),
Escape: () => onClose()
}),
cssMenuHeader(
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()});
}
},
Escape$: (ev) => {
if (searchValueObs.get()) {
searchValueObs.set('');
searchInput.focus();
ev.stopPropagation();
}
}
})
),
dom.maybe(searchValueObs, () => cssSearchIcon(
'CrossSmall', testId('search-close'),
dom.on('click', () => {
searchValueObs.set('');
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')),
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); }),
cssItemValue(value.label === undefined ? key as string : value.label),
),
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)))
),
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;
}
/**
* Returns content for the newly created columnFilterMenu; for use with setPopupToCreateDom().
*/
export function createFilterMenu(openCtl: IOpenController, sectionFilter: SectionFilter, field: ViewFieldRec,
rowSource: FilteredRowSource, tableData: TableData) {
// Go through all of our shown and hidden rows, and count them up by the values in this column.
const valueGetter = tableData.getRowPropFunc(field.column().colId())!;
const labelGetter = tableData.getRowPropFunc(field.displayColModel().colId())!;
const formatter = field.createVisibleColFormatter();
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(openCtl, {
columnFilter,
valueCounts,
onClose: () => openCtl.close(),
doSave: (reset: boolean = false) => {
const spec = columnFilter.makeFilterJson();
field.activeFilter(spec === allInclusive ? '' : spec);
if (reset) {
sectionFilter.resetTemporaryRows();
}
},
});
}
/**
* For each value in Iterable, adds a key mapped with `keyMapFunc` and a value object with a `label` mapped
* with `labelMapFunc` and a `count` representing the total number of times the key has been encountered.
*/
function addCountsToMap(valueMap: Map<CellValue, IFilterCount>, values: Iterable<CellValue>,
keyMapFunc: (v: any) => any = identity, labelMapFunc: (v: any) => any = identity) {
for (const v of values) {
let key = keyMapFunc(v);
// For complex values, serialize the value to allow them to be properly stored
if (Array.isArray(key)) { key = JSON.stringify(key); }
if (valueMap.get(key)) {
valueMap.get(key)!.count++;
} else {
valueMap.set(key, { label: labelMapFunc(v), count: 1 });
}
}
}
const cssMenu = styled('div', `
display: flex;
flex-direction: column;
min-width: 400px;
max-width: 400px;
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 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: 0;
`);
const cssItemList = styled('div', `
flex-shrink: 1;
overflow: auto;
min-height: 80px;
margin-top: 4px;
padding-bottom: 8px;
`);
const cssMenuItem = styled('div', `
display: flex;
padding: 8px 16px;
`);
const cssItemValue = styled(cssLabelText, `
margin-right: 12px;
color: ${colors.dark};
white-space: nowrap;
`);
const cssItemCount = styled('div', `
flex-grow: 1;
align-self: normal;
text-align: right;
color: ${colors.slate};
`);
const cssMenuFooter = styled('div', `
display: flex;
flex-shrink: 0;
flex-direction: column;
& .${cssMenuItem.className} {
padding: 12px 16px;
}
`);
const cssApplyButton = styled(primaryButton, `
margin-right: 4px;
`);
const cssSearch = styled(input, `
flex-grow: 1;
min-width: 1px;
-webkit-appearance: none;
-moz-appearance: none;
font-size: ${vars.mediumFontSize};
margin: 0px 16px 0px 8px;
padding: 0px;
border: none;
outline: none;
`);
const cssSearchIcon = styled(icon, `
flex-shrink: 0;
margin-left: auto;
margin-right: 4px;
`);
const cssNoResults = styled(cssMenuItem, `
font-style: italic;
color: ${colors.slate};
justify-content: center;
`);