(core) Allows range filter for Date, DateTime columns

Summary:
This diff is first of a series of 3 commits to enable range filering
for Date and DateTime columns. Diff only enable setting date's min/max
throw typing dates, Date picker and relative ranges are left for
follow-up commits.

 - Exposes columns value formatter to the range input
 - Fixes column filter func to work with dates

Test Plan:
Adds Date to projects range filter test
Adds Date/DateTime to nbrowser ColumnFilterMenu tests

Reviewers: alexmojaki

Reviewed By: alexmojaki

Subscribers: alexmojaki

Differential Revision: https://phab.getgrist.com/D3455
This commit is contained in:
Cyprien P 2022-06-23 10:01:12 +02:00
parent 9fffb491f9
commit 64ff9ccd0a
6 changed files with 46 additions and 27 deletions

View File

@ -37,7 +37,7 @@ export class SectionFilter extends Disposable {
const columnFilterFunc = Computed.create(this, this._openFilterOverride, (use, openFilter) => { const columnFilterFunc = Computed.create(this, this._openFilterOverride, (use, openFilter) => {
const openFilterFilterFunc = openFilter && use(openFilter.colFilter.filterFunc); const openFilterFilterFunc = openFilter && use(openFilter.colFilter.filterFunc);
function getFilterFunc(fieldOrColumn: ViewFieldRec|ColumnRec, colFilter: ColumnFilterFunc|null) { function getFilterFunc(fieldOrColumn: ViewFieldRec|ColumnRec, colFilter: ColumnFilterFunc|null) {
if (openFilter?.colRef === fieldOrColumn.getRowId()) { if (openFilter?.colRef === fieldOrColumn.origCol().getRowId()) {
return openFilterFilterFunc; return openFilterFilterFunc;
} }
return colFilter; return colFilter;

View File

@ -221,12 +221,8 @@ export interface CustomViewSectionDef {
} }
// Information about filters for a field or hidden column. // Information about filters for a field or hidden column.
// TODO: It looks like that it is not needed for FilterInfo to support ViewFieldRec anymore (db
// _grist_Filters explicitely maintain a reference to _grist_Tables_column, not
// _grist_Views_section_field). And it has caused a bug (due to mismatching a viewField id against a
// column id).
export interface FilterInfo { export interface FilterInfo {
// The field or column associated with this filter info. // The field or column associated with this filter info (field if column is visible, else column).
fieldOrColumn: ViewFieldRec|ColumnRec; fieldOrColumn: ViewFieldRec|ColumnRec;
// Filter that applies to this field/column, if any. // Filter that applies to this field/column, if any.
filter: modelUtil.CustomComputed<string>; filter: modelUtil.CustomComputed<string>;
@ -337,6 +333,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
*/ */
this.filters = this.autoDispose(ko.computed(() => { this.filters = this.autoDispose(ko.computed(() => {
const savedFiltersByColRef = new Map(this._savedFilters().all().map(f => [f.colRef(), f])); const savedFiltersByColRef = new Map(this._savedFilters().all().map(f => [f.colRef(), f]));
const viewFieldsByColRef = new Map(this.viewFields().all().map(f => [f.origCol().getRowId(), f]));
return this.columns().map(column => { return this.columns().map(column => {
const savedFilter = savedFiltersByColRef.get(column.origColRef()); const savedFilter = savedFiltersByColRef.get(column.origColRef());
@ -351,9 +348,9 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
return { return {
filter, filter,
fieldOrColumn: column, fieldOrColumn: viewFieldsByColRef.get(column.origColRef()) ?? column,
isFiltered: ko.pureComputed(() => filter() !== '') isFiltered: ko.pureComputed(() => filter() !== '')
} as FilterInfo; };
}); });
})); }));

View File

@ -30,23 +30,23 @@ import tail = require('lodash/tail');
import debounce = require('lodash/debounce'); import debounce = require('lodash/debounce');
import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel'; import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel';
import {decodeObject} from 'app/plugin/objtypes'; import {decodeObject} from 'app/plugin/objtypes';
import {isList, isNumberType, isRefListType} from 'app/common/gristTypes'; import {isDateLikeType, isList, isNumberType, isRefListType} from 'app/common/gristTypes';
import {choiceToken} from 'app/client/widgets/ChoiceToken'; import {choiceToken} from 'app/client/widgets/ChoiceToken';
import {ChoiceOptions} from 'app/client/widgets/ChoiceTextBox'; import {ChoiceOptions} from 'app/client/widgets/ChoiceTextBox';
interface IFilterMenuOptions { export interface IFilterMenuOptions {
model: ColumnFilterMenuModel; model: ColumnFilterMenuModel;
valueCounts: Map<CellValue, IFilterCount>; valueCounts: Map<CellValue, IFilterCount>;
doSave: (reset: boolean) => void; doSave: (reset: boolean) => void;
onClose: () => void; onClose: () => void;
renderValue: (key: CellValue, value: IFilterCount) => DomElementArg; renderValue: (key: CellValue, value: IFilterCount) => DomElementArg;
valueParser?: (val: string) => any; rangeInputOptions?: IRangeInputOptions
} }
const testId = makeTestId('test-filter-menu-'); const testId = makeTestId('test-filter-menu-');
export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptions): HTMLElement { export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptions): HTMLElement {
const { model, doSave, onClose, renderValue, valueParser } = opts; const { model, doSave, onClose, rangeInputOptions = {}, renderValue } = opts;
const { columnFilter } = model; const { columnFilter } = model;
// 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();
@ -67,6 +67,7 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
const isAboveLimitObs = Computed.create(owner, (use) => use(model.valuesBeyondLimit).length > 0); const isAboveLimitObs = Computed.create(owner, (use) => use(model.valuesBeyondLimit).length > 0);
const isSearchingObs = Computed.create(owner, (use) => Boolean(use(searchValueObs))); const isSearchingObs = Computed.create(owner, (use) => Boolean(use(searchValueObs)));
const showRangeFilter = isNumberType(columnFilter.columnType) || isDateLikeType(columnFilter.columnType);
let searchInput: HTMLInputElement; let searchInput: HTMLInputElement;
let minRangeInput: HTMLInputElement; let minRangeInput: HTMLInputElement;
@ -87,12 +88,12 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
}), }),
// Filter by range // Filter by range
dom.maybe(isNumberType(columnFilter.columnType), () => [ dom.maybe(showRangeFilter, () => [
cssRangeHeader('Filter by Range'), cssRangeHeader('Filter by Range'),
cssRangeContainer( cssRangeContainer(
minRangeInput = rangeInput('Min ', columnFilter.min, {valueParser}, testId('min')), minRangeInput = rangeInput('Min ', columnFilter.min, rangeInputOptions, testId('min')),
cssRangeInputSeparator('→'), cssRangeInputSeparator('→'),
rangeInput('Max ', columnFilter.max, {valueParser}, testId('max')), rangeInput('Max ', columnFilter.max, rangeInputOptions, testId('max')),
), ),
cssMenuDivider(), cssMenuDivider(),
]), ]),
@ -212,11 +213,15 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
return filterMenu; return filterMenu;
} }
function rangeInput(placeholder: string, obs: Observable<number|undefined>, export interface IRangeInputOptions {
opts: {valueParser?: (val: string) => any}, valueParser?: (val: string) => any;
valueFormatter?: (val: any) => string;
}
function rangeInput(placeholder: string, obs: Observable<number|undefined>, opts: IRangeInputOptions,
...args: DomElementArg[]) { ...args: DomElementArg[]) {
const valueParser = opts.valueParser || Number; const valueParser = opts.valueParser || Number;
const formatValue = ((val: any) => val?.toString() || ''); const formatValue = opts.valueFormatter || ((val) => val?.toString() || '');
let editMode = false; let editMode = false;
let el: HTMLInputElement; let el: HTMLInputElement;
// keep input content in sync only when no edit are going on. // keep input content in sync only when no edit are going on.
@ -233,7 +238,7 @@ function rangeInput(placeholder: string, obs: Observable<number|undefined>,
if (val === undefined || !isNaN(val)) { if (val === undefined || !isNaN(val)) {
obs.set(val); obs.set(val);
} }
}, 10); }, 100);
return el = cssRangeInput( return el = cssRangeInput(
{inputmode: 'numeric', placeholder, value: formatValue(obs.get())}, {inputmode: 'numeric', placeholder, value: formatValue(obs.get())},
dom.on('input', onInput), dom.on('input', onInput),
@ -353,7 +358,17 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
const visibleColumnType = fieldOrColumn.visibleColModel.peek()?.type.peek() || columnType; const visibleColumnType = fieldOrColumn.visibleColModel.peek()?.type.peek() || columnType;
const {keyMapFunc, labelMapFunc, valueMapFunc} = getMapFuncs(columnType, tableData, filterInfo.fieldOrColumn); const {keyMapFunc, labelMapFunc, valueMapFunc} = getMapFuncs(columnType, tableData, filterInfo.fieldOrColumn);
const activeFilterBar = sectionFilter.viewSection.activeFilterBar; const activeFilterBar = sectionFilter.viewSection.activeFilterBar;
// range input options
const valueParser = (fieldOrColumn as any).createValueParser?.(); const valueParser = (fieldOrColumn as any).createValueParser?.();
const colFormatter = fieldOrColumn.visibleColFormatter();
// formatting values for Numeric columns entail issues. For instance with '%' when users type
// 0.499 and press enter, the input now shows 50% and there's no way to know what is the actual
// underlying value. Maybe worth, both 0.499 and 0.495 format to 50% but they can have different
// effects depending on data. Hence as of writing better to keep it only for Date.
const valueFormatter = isDateLikeType(visibleColumnType) ?
(val: any) => colFormatter.formatAny(val) :
undefined;
function getFilterFunc(col: ViewFieldRec|ColumnRec, colFilter: ColumnFilterFunc|null) { function getFilterFunc(col: ViewFieldRec|ColumnRec, colFilter: ColumnFilterFunc|null) {
return col.getRowId() === fieldOrColumn.getRowId() ? null : colFilter; return col.getRowId() === fieldOrColumn.getRowId() ? null : colFilter;
@ -371,7 +386,7 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
const valueCountsArr = Array.from(valueCounts); const valueCountsArr = Array.from(valueCounts);
const columnFilter = ColumnFilter.create(openCtl, filterInfo.filter.peek(), columnType, visibleColumnType, const columnFilter = ColumnFilter.create(openCtl, filterInfo.filter.peek(), columnType, visibleColumnType,
valueCountsArr.map((arr) => arr[0])); valueCountsArr.map((arr) => arr[0]));
sectionFilter.setFilterOverride(fieldOrColumn.getRowId(), columnFilter); // Will be removed on menu disposal sectionFilter.setFilterOverride(fieldOrColumn.origCol().getRowId(), columnFilter); // Will be removed on menu disposal
const model = ColumnFilterMenuModel.create(openCtl, columnFilter, valueCountsArr); const model = ColumnFilterMenuModel.create(openCtl, columnFilter, valueCountsArr);
return columnFilterMenu(openCtl, { return columnFilterMenu(openCtl, {
@ -390,7 +405,10 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
} }
}, },
renderValue: getRenderFunc(columnType, fieldOrColumn), renderValue: getRenderFunc(columnType, fieldOrColumn),
rangeInputOptions: {
valueParser, valueParser,
valueFormatter,
}
}); });
} }
@ -695,5 +713,5 @@ const cssRangeInputSeparator = styled('span', `
color: var(--grist-color-slate); color: var(--grist-color-slate);
`); `);
const cssRangeInput = styled('input', ` const cssRangeInput = styled('input', `
width: 80px; width: 120px;
`); `);

View File

@ -25,7 +25,7 @@ function makeFilterField(viewSection: ViewSectionRec, filterInfo: FilterInfo,
primaryButton( primaryButton(
testId('btn'), testId('btn'),
cssIcon('FilterSimple'), cssIcon('FilterSimple'),
cssMenuTextLabel(dom.text(filterInfo.fieldOrColumn.label)), cssMenuTextLabel(dom.text(filterInfo.fieldOrColumn.origCol().label)),
cssBtn.cls('-grayed', filterInfo.filter.isSaved), cssBtn.cls('-grayed', filterInfo.filter.isSaved),
attachColumnFilterMenu(viewSection, filterInfo, { attachColumnFilterMenu(viewSection, filterInfo, {
placement: 'bottom-start', attach: 'body', placement: 'bottom-start', attach: 'body',
@ -48,7 +48,7 @@ export function addFilterMenu(filters: FilterInfo[], viewSection: ViewSectionRec
...filters.map((filterInfo) => ( ...filters.map((filterInfo) => (
menuItemAsync( menuItemAsync(
() => turnOnAndOpenFilter(filterInfo.fieldOrColumn, viewSection, popupControls), () => turnOnAndOpenFilter(filterInfo.fieldOrColumn, viewSection, popupControls),
filterInfo.fieldOrColumn.label.peek(), filterInfo.fieldOrColumn.origCol().label.peek(),
dom.cls('disabled', filterInfo.isFiltered), dom.cls('disabled', filterInfo.isFiltered),
testId('add-filter-item'), testId('add-filter-item'),
) )

View File

@ -1,18 +1,18 @@
import {CellValue} from "app/common/DocActions"; import {CellValue} from "app/common/DocActions";
import {FilterState, isRangeFilter, makeFilterState} from "app/common/FilterState"; import {FilterState, isRangeFilter, makeFilterState} from "app/common/FilterState";
import {decodeObject} from "app/plugin/objtypes"; import {decodeObject} from "app/plugin/objtypes";
import {isList, isListType, isNumberType} from "./gristTypes"; import {isDateLikeType, isList, isListType, isNumberType} from "./gristTypes";
export type ColumnFilterFunc = (value: CellValue) => boolean; export type ColumnFilterFunc = (value: CellValue) => boolean;
// 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.
export function makeFilterFunc(state: FilterState, export function makeFilterFunc(state: FilterState,
columnType?: string): ColumnFilterFunc { columnType: string = ''): ColumnFilterFunc {
if (isRangeFilter(state)) { if (isRangeFilter(state)) {
const {min, max} = state; const {min, max} = state;
if (isNumberType(columnType)) { if (isNumberType(columnType) || isDateLikeType(columnType)) {
return (val) => { return (val) => {
if (typeof val !== 'number') { return false; } if (typeof val !== 'number') { return false; }
return ( return (

View File

@ -329,6 +329,10 @@ export function isNumberType(type: string|undefined) {
return ['Numeric', 'Int'].includes(type || ''); return ['Numeric', 'Int'].includes(type || '');
} }
export function isDateLikeType(type: string) {
return type === 'Date' || type.startsWith('DateTime');
}
export function isFullReferencingType(type: string) { export function isFullReferencingType(type: string) {
return type.startsWith('Ref:') || isRefListType(type); return type.startsWith('Ref:') || isRefListType(type);
} }