From 64ff9ccd0ad1cb3ff66bab41c8d562adf24ce8c5 Mon Sep 17 00:00:00 2001 From: Cyprien P Date: Thu, 23 Jun 2022 10:01:12 +0200 Subject: [PATCH] (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 --- app/client/models/SectionFilter.ts | 2 +- app/client/models/entities/ViewSectionRec.ts | 11 ++--- app/client/ui/ColumnFilterMenu.ts | 46 ++++++++++++++------ app/client/ui/FilterBar.ts | 4 +- app/common/ColumnFilterFunc.ts | 6 +-- app/common/gristTypes.ts | 4 ++ 6 files changed, 46 insertions(+), 27 deletions(-) diff --git a/app/client/models/SectionFilter.ts b/app/client/models/SectionFilter.ts index 7306f522..a0a713e4 100644 --- a/app/client/models/SectionFilter.ts +++ b/app/client/models/SectionFilter.ts @@ -37,7 +37,7 @@ export class SectionFilter extends Disposable { const columnFilterFunc = Computed.create(this, this._openFilterOverride, (use, openFilter) => { const openFilterFilterFunc = openFilter && use(openFilter.colFilter.filterFunc); function getFilterFunc(fieldOrColumn: ViewFieldRec|ColumnRec, colFilter: ColumnFilterFunc|null) { - if (openFilter?.colRef === fieldOrColumn.getRowId()) { + if (openFilter?.colRef === fieldOrColumn.origCol().getRowId()) { return openFilterFilterFunc; } return colFilter; diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index 6f952a52..3f01d936 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -221,12 +221,8 @@ export interface CustomViewSectionDef { } // 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 { - // 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; // Filter that applies to this field/column, if any. filter: modelUtil.CustomComputed; @@ -337,6 +333,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): */ this.filters = this.autoDispose(ko.computed(() => { 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 => { const savedFilter = savedFiltersByColRef.get(column.origColRef()); @@ -351,9 +348,9 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): return { filter, - fieldOrColumn: column, + fieldOrColumn: viewFieldsByColRef.get(column.origColRef()) ?? column, isFiltered: ko.pureComputed(() => filter() !== '') - } as FilterInfo; + }; }); })); diff --git a/app/client/ui/ColumnFilterMenu.ts b/app/client/ui/ColumnFilterMenu.ts index d4e4e1e0..a87fa4d2 100644 --- a/app/client/ui/ColumnFilterMenu.ts +++ b/app/client/ui/ColumnFilterMenu.ts @@ -30,23 +30,23 @@ import tail = require('lodash/tail'); import debounce = require('lodash/debounce'); import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel'; 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 {ChoiceOptions} from 'app/client/widgets/ChoiceTextBox'; -interface IFilterMenuOptions { +export interface IFilterMenuOptions { model: ColumnFilterMenuModel; valueCounts: Map; doSave: (reset: boolean) => void; onClose: () => void; renderValue: (key: CellValue, value: IFilterCount) => DomElementArg; - valueParser?: (val: string) => any; + rangeInputOptions?: IRangeInputOptions } const testId = makeTestId('test-filter-menu-'); 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; // Save the initial state to allow reverting back to it on Cancel 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 isSearchingObs = Computed.create(owner, (use) => Boolean(use(searchValueObs))); + const showRangeFilter = isNumberType(columnFilter.columnType) || isDateLikeType(columnFilter.columnType); let searchInput: HTMLInputElement; let minRangeInput: HTMLInputElement; @@ -87,12 +88,12 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio }), // Filter by range - dom.maybe(isNumberType(columnFilter.columnType), () => [ + dom.maybe(showRangeFilter, () => [ cssRangeHeader('Filter by Range'), cssRangeContainer( - minRangeInput = rangeInput('Min ', columnFilter.min, {valueParser}, testId('min')), + minRangeInput = rangeInput('Min ', columnFilter.min, rangeInputOptions, testId('min')), cssRangeInputSeparator('→'), - rangeInput('Max ', columnFilter.max, {valueParser}, testId('max')), + rangeInput('Max ', columnFilter.max, rangeInputOptions, testId('max')), ), cssMenuDivider(), ]), @@ -212,11 +213,15 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio return filterMenu; } -function rangeInput(placeholder: string, obs: Observable, - opts: {valueParser?: (val: string) => any}, +export interface IRangeInputOptions { + valueParser?: (val: string) => any; + valueFormatter?: (val: any) => string; +} + +function rangeInput(placeholder: string, obs: Observable, opts: IRangeInputOptions, ...args: DomElementArg[]) { const valueParser = opts.valueParser || Number; - const formatValue = ((val: any) => val?.toString() || ''); + const formatValue = opts.valueFormatter || ((val) => val?.toString() || ''); let editMode = false; let el: HTMLInputElement; // keep input content in sync only when no edit are going on. @@ -233,7 +238,7 @@ function rangeInput(placeholder: string, obs: Observable, if (val === undefined || !isNaN(val)) { obs.set(val); } - }, 10); + }, 100); return el = cssRangeInput( {inputmode: 'numeric', placeholder, value: formatValue(obs.get())}, dom.on('input', onInput), @@ -353,7 +358,17 @@ 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; + + // range input options 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) { return col.getRowId() === fieldOrColumn.getRowId() ? null : colFilter; @@ -371,7 +386,7 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio 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 + sectionFilter.setFilterOverride(fieldOrColumn.origCol().getRowId(), columnFilter); // Will be removed on menu disposal const model = ColumnFilterMenuModel.create(openCtl, columnFilter, valueCountsArr); return columnFilterMenu(openCtl, { @@ -390,7 +405,10 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio } }, renderValue: getRenderFunc(columnType, fieldOrColumn), - valueParser, + rangeInputOptions: { + valueParser, + valueFormatter, + } }); } @@ -695,5 +713,5 @@ const cssRangeInputSeparator = styled('span', ` color: var(--grist-color-slate); `); const cssRangeInput = styled('input', ` - width: 80px; + width: 120px; `); diff --git a/app/client/ui/FilterBar.ts b/app/client/ui/FilterBar.ts index 6b229e7d..1711d0b8 100644 --- a/app/client/ui/FilterBar.ts +++ b/app/client/ui/FilterBar.ts @@ -25,7 +25,7 @@ function makeFilterField(viewSection: ViewSectionRec, filterInfo: FilterInfo, primaryButton( testId('btn'), cssIcon('FilterSimple'), - cssMenuTextLabel(dom.text(filterInfo.fieldOrColumn.label)), + cssMenuTextLabel(dom.text(filterInfo.fieldOrColumn.origCol().label)), cssBtn.cls('-grayed', filterInfo.filter.isSaved), attachColumnFilterMenu(viewSection, filterInfo, { placement: 'bottom-start', attach: 'body', @@ -48,7 +48,7 @@ export function addFilterMenu(filters: FilterInfo[], viewSection: ViewSectionRec ...filters.map((filterInfo) => ( menuItemAsync( () => turnOnAndOpenFilter(filterInfo.fieldOrColumn, viewSection, popupControls), - filterInfo.fieldOrColumn.label.peek(), + filterInfo.fieldOrColumn.origCol().label.peek(), dom.cls('disabled', filterInfo.isFiltered), testId('add-filter-item'), ) diff --git a/app/common/ColumnFilterFunc.ts b/app/common/ColumnFilterFunc.ts index cbb9cc9e..9088cc1c 100644 --- a/app/common/ColumnFilterFunc.ts +++ b/app/common/ColumnFilterFunc.ts @@ -1,18 +1,18 @@ import {CellValue} from "app/common/DocActions"; import {FilterState, isRangeFilter, makeFilterState} from "app/common/FilterState"; 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; // 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. export function makeFilterFunc(state: FilterState, - columnType?: string): ColumnFilterFunc { + columnType: string = ''): ColumnFilterFunc { if (isRangeFilter(state)) { const {min, max} = state; - if (isNumberType(columnType)) { + if (isNumberType(columnType) || isDateLikeType(columnType)) { return (val) => { if (typeof val !== 'number') { return false; } return ( diff --git a/app/common/gristTypes.ts b/app/common/gristTypes.ts index b576aecd..5d5bf937 100644 --- a/app/common/gristTypes.ts +++ b/app/common/gristTypes.ts @@ -329,6 +329,10 @@ export function isNumberType(type: string|undefined) { return ['Numeric', 'Int'].includes(type || ''); } +export function isDateLikeType(type: string) { + return type === 'Date' || type.startsWith('DateTime'); +} + export function isFullReferencingType(type: string) { return type.startsWith('Ref:') || isRefListType(type); }