mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
9fffb491f9
commit
64ff9ccd0a
@ -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;
|
||||||
|
@ -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;
|
};
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -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;
|
||||||
`);
|
`);
|
||||||
|
@ -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'),
|
||||||
)
|
)
|
||||||
|
@ -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 (
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user