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 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…
Reference in New Issue
Block a user