mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Adds new range filter for numeric columns
Summary: Shows the range filter next to the filter by values on filter menu. When users set min and/or max, it takes precendence over the filter by values. If users set: - `[] < [max]` behaves as `less than max`. - `[min] < []` behaves as `more than min`. - `[min] < [max]` behaves as `between min and max` - bounds are always inclusives. - when users change min or max the values of the by values filter gets checked/unchecked depending on whether they are included by the range filter. - when users clicks any btn/checkbox of the by values filter both min and max input gets cleared, and the filter convert to a filter by values. Test Plan: Adds both projets and nbrowser tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D3435
This commit is contained in:
@@ -19,16 +19,18 @@ import {icon} from 'app/client/ui2018/icons';
|
||||
import {menuCssClass, menuDivider} from 'app/client/ui2018/menus';
|
||||
import {CellValue} from 'app/common/DocActions';
|
||||
import {isEquivalentFilter} from "app/common/FilterState";
|
||||
import {Computed, dom, DomElementArg, DomElementMethod, IDisposableOwner, input, makeTestId, styled} from 'grainjs';
|
||||
import {Computed, dom, DomElementArg, DomElementMethod, IDisposableOwner, input, makeTestId, Observable,
|
||||
styled} from 'grainjs';
|
||||
import concat = require('lodash/concat');
|
||||
import identity = require('lodash/identity');
|
||||
import noop = require('lodash/noop');
|
||||
import partition = require('lodash/partition');
|
||||
import some = require('lodash/some');
|
||||
import tail = require('lodash/tail');
|
||||
import debounce = require('lodash/debounce');
|
||||
import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel';
|
||||
import {decodeObject} from 'app/plugin/objtypes';
|
||||
import {isList, isRefListType} from 'app/common/gristTypes';
|
||||
import {isList, isNumberType, isRefListType} from 'app/common/gristTypes';
|
||||
import {choiceToken} from 'app/client/widgets/ChoiceToken';
|
||||
import {ChoiceOptions} from 'app/client/widgets/ChoiceTextBox';
|
||||
import {cssInvalidToken} from 'app/client/widgets/ChoiceListCell';
|
||||
@@ -39,12 +41,13 @@ interface IFilterMenuOptions {
|
||||
doSave: (reset: boolean) => void;
|
||||
onClose: () => void;
|
||||
renderValue: (key: CellValue, value: IFilterCount) => DomElementArg;
|
||||
valueParser?: (val: string) => any;
|
||||
}
|
||||
|
||||
const testId = makeTestId('test-filter-menu-');
|
||||
|
||||
export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptions): HTMLElement {
|
||||
const { model, doSave, onClose, renderValue } = opts;
|
||||
const { model, doSave, onClose, renderValue, valueParser } = opts;
|
||||
const { columnFilter } = model;
|
||||
// Save the initial state to allow reverting back to it on Cancel
|
||||
const initialStateJson = columnFilter.makeFilterJson();
|
||||
@@ -52,12 +55,14 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
||||
// 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 => {
|
||||
// Listen for changes to filterFunc, and update checkboxes accordingly. Debounce is needed to
|
||||
// prevent some weirdness when users click on a checkbox while focus was on a range input (causing
|
||||
// sometimes the checkbox to not toggle)
|
||||
const filterListener = columnFilter.filterFunc.addListener(debounce(func => {
|
||||
for (const [value, elem] of checkboxMap) {
|
||||
elem.checked = func(value);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
const {searchValue: searchValueObs, filteredValues, filteredKeys, isSortedByCount} = model;
|
||||
|
||||
@@ -65,10 +70,11 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
||||
const isSearchingObs = Computed.create(owner, (use) => Boolean(use(searchValueObs)));
|
||||
|
||||
let searchInput: HTMLInputElement;
|
||||
let minRangeInput: HTMLInputElement;
|
||||
let reset = false;
|
||||
|
||||
// Gives focus to the searchInput on open
|
||||
setTimeout(() => searchInput.focus(), 0);
|
||||
// Gives focus to the searchInput on open (or to the min input if the range filter is present).
|
||||
setTimeout(() => (minRangeInput || searchInput).select(), 0);
|
||||
|
||||
const filterMenu: HTMLElement = cssMenu(
|
||||
{ tabindex: '-1' }, // Allow menu to be focused
|
||||
@@ -80,6 +86,18 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
||||
Enter: () => onClose(),
|
||||
Escape: () => onClose()
|
||||
}),
|
||||
|
||||
// Filter by range
|
||||
dom.maybe(isNumberType(columnFilter.columnType), () => [
|
||||
cssRangeHeader('Filter by Range'),
|
||||
cssRangeContainer(
|
||||
minRangeInput = rangeInput('Min ', columnFilter.min, {valueParser}, testId('min')),
|
||||
cssRangeInputSeparator('→'),
|
||||
rangeInput('Max ', columnFilter.max, {valueParser}, testId('max')),
|
||||
),
|
||||
cssMenuDivider(),
|
||||
]),
|
||||
|
||||
cssMenuHeader(
|
||||
cssSearchIcon('Search'),
|
||||
searchInput = cssSearch(
|
||||
@@ -149,8 +167,9 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
||||
cssLabel(
|
||||
cssCheckboxSquare(
|
||||
{type: 'checkbox'},
|
||||
dom.on('change', (_ev, elem) =>
|
||||
elem.checked ? columnFilter.add(key) : columnFilter.delete(key)),
|
||||
dom.on('change', (_ev, elem) => {
|
||||
elem.checked ? columnFilter.add(key) : columnFilter.delete(key);
|
||||
}),
|
||||
(elem) => { elem.checked = columnFilter.includes(key); checkboxMap.set(key, elem); },
|
||||
dom.style('position', 'relative'),
|
||||
),
|
||||
@@ -194,6 +213,37 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
||||
return filterMenu;
|
||||
}
|
||||
|
||||
function rangeInput(placeholder: string, obs: Observable<number|undefined>,
|
||||
opts: {valueParser?: (val: string) => any},
|
||||
...args: DomElementArg[]) {
|
||||
const valueParser = opts.valueParser || Number;
|
||||
const formatValue = ((val: any) => val?.toString() || '');
|
||||
let editMode = false;
|
||||
let el: HTMLInputElement;
|
||||
// keep input content in sync only when no edit are going on.
|
||||
const lis = obs.addListener(() => editMode ? null : el.value = formatValue(obs.get()));
|
||||
// handle change
|
||||
const onBlur = () => {
|
||||
onInput.flush();
|
||||
editMode = false;
|
||||
el.value = formatValue(obs.get());
|
||||
};
|
||||
const onInput = debounce(() => {
|
||||
editMode = true;
|
||||
const val = el.value ? valueParser(el.value) : undefined;
|
||||
if (val === undefined || !isNaN(val)) {
|
||||
obs.set(val);
|
||||
}
|
||||
}, 10);
|
||||
return el = cssRangeInput(
|
||||
{inputmode: 'numeric', placeholder, value: formatValue(obs.get())},
|
||||
dom.on('input', onInput),
|
||||
dom.on('blur', onBlur),
|
||||
dom.autoDispose(lis),
|
||||
...args
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Builds a tri-state checkbox that summaries the state of all the `values`. The special value
|
||||
@@ -304,6 +354,7 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
|
||||
const visibleColumnType = fieldOrColumn.visibleColModel.peek()?.type.peek() || columnType;
|
||||
const {keyMapFunc, labelMapFunc, valueMapFunc} = getMapFuncs(columnType, tableData, filterInfo.fieldOrColumn);
|
||||
const activeFilterBar = sectionFilter.viewSection.activeFilterBar;
|
||||
const valueParser = (fieldOrColumn as any).createValueParser?.();
|
||||
|
||||
function getFilterFunc(col: ViewFieldRec|ColumnRec, colFilter: ColumnFilterFunc|null) {
|
||||
return col.getRowId() === fieldOrColumn.getRowId() ? null : colFilter;
|
||||
@@ -311,9 +362,6 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
|
||||
const filterFunc = Computed.create(null, use => sectionFilter.buildFilterFunc(getFilterFunc, use));
|
||||
openCtl.autoDispose(filterFunc);
|
||||
|
||||
const columnFilter = ColumnFilter.create(openCtl, filterInfo.filter.peek(), columnType, visibleColumnType);
|
||||
sectionFilter.setFilterOverride(fieldOrColumn.getRowId(), columnFilter); // Will be removed on menu disposal
|
||||
|
||||
const [allRows, hiddenRows] = partition(Array.from(rowSource.getAllRows()), filterFunc.get());
|
||||
const valueCounts = getEmptyCountMap(fieldOrColumn);
|
||||
addCountsToMap(valueCounts, allRows, {keyMapFunc, labelMapFunc, columnType,
|
||||
@@ -321,7 +369,11 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
|
||||
addCountsToMap(valueCounts, hiddenRows, {keyMapFunc, labelMapFunc, columnType,
|
||||
areHiddenRows: true, valueMapFunc});
|
||||
|
||||
const model = ColumnFilterMenuModel.create(openCtl, columnFilter, Array.from(valueCounts));
|
||||
const valueCountsArr = Array.from(valueCounts);
|
||||
const columnFilter = ColumnFilter.create(openCtl, filterInfo.filter.peek(), columnType, visibleColumnType,
|
||||
valueCountsArr.map((arr) => arr[0]));
|
||||
sectionFilter.setFilterOverride(fieldOrColumn.getRowId(), columnFilter); // Will be removed on menu disposal
|
||||
const model = ColumnFilterMenuModel.create(openCtl, columnFilter, valueCountsArr);
|
||||
|
||||
return columnFilterMenu(openCtl, {
|
||||
model,
|
||||
@@ -339,6 +391,7 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
|
||||
}
|
||||
},
|
||||
renderValue: getRenderFunc(columnType, fieldOrColumn),
|
||||
valueParser,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -616,3 +669,24 @@ const cssToken = styled('div', `
|
||||
margin-left: 8px;
|
||||
margin-right: 12px;
|
||||
`);
|
||||
const cssRangeHeader = styled(cssMenuItem, `
|
||||
padding: unset;
|
||||
border-radius: 0 0 3px 0;
|
||||
text-transform: uppercase;
|
||||
font-size: var(--grist-x-small-font-size);
|
||||
margin: 16px 16px 6px 16px;
|
||||
`);
|
||||
const cssRangeContainer = styled(cssMenuItem, `
|
||||
display: flex;
|
||||
justify-content: left;
|
||||
column-gap: 10px;
|
||||
`);
|
||||
const cssRangeInputSeparator = styled('span', `
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
top: 3px;
|
||||
color: var(--grist-color-slate);
|
||||
`);
|
||||
const cssRangeInput = styled('input', `
|
||||
width: 80px;
|
||||
`);
|
||||
|
||||
Reference in New Issue
Block a user