(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
pull/214/head
Cyprien P 2 years ago
parent 9fffb491f9
commit 64ff9ccd0a

@ -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;

@ -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<string>;
@ -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;
};
});
}));

@ -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<CellValue, IFilterCount>;
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<number|undefined>,
opts: {valueParser?: (val: string) => any},
export interface IRangeInputOptions {
valueParser?: (val: string) => any;
valueFormatter?: (val: any) => string;
}
function rangeInput(placeholder: string, obs: Observable<number|undefined>, 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<number|undefined>,
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;
`);

@ -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'),
)

@ -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 (

@ -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);
}

Loading…
Cancel
Save