(core) New date filter with a calendar view

Summary:
Implements the new date filtering panel. Design results from long
discussion between: Alex, Anais, Cyprien and Dmitry.

Test environment: https://grist-new-date-range-filter.fly.dev/

Test Plan: Include various new tests.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3720
pull/383/head
Cyprien P 2 years ago
parent 7dc49f3c85
commit 620e86a9f1

@ -218,7 +218,7 @@ export const defaultPopperOptions: Partial<PopperOptions> = {
* Helper function which returns the direct child of ancestor which is an ancestor of elem, or
* null if elem is not a descendant of ancestor.
*/
function findAncestorChild(ancestor: Element, elem: Element|null): Element|null {
export function findAncestorChild(ancestor: Element, elem: Element|null): Element|null {
while (elem && elem.parentElement !== ancestor) {
elem = elem.parentElement;
}
@ -231,7 +231,7 @@ function findAncestorChild(ancestor: Element, elem: Element|null): Element|null
* instance) it's not immediately highlighted, but only when a user moves the mouse.
* Returns an object with a reset() method, which restarts the wait for mousemove.
*/
function attachMouseOverOnMove<T extends EventTarget>(elem: T, callback: EventCB<MouseEvent, T>) {
export function attachMouseOverOnMove<T extends EventTarget>(elem: T, callback: EventCB<MouseEvent, T>) {
let lis: IDisposable|undefined;
function setListener(eventType: 'mouseover'|'mousemove', cb: EventCB<MouseEvent, T>) {
if (lis) { lis.dispose(); }

@ -0,0 +1,34 @@
/**
*
* Returns a popup control allowing to open/close a popup using as content the element returned by
* the given func. Note that the `trigger` option is ignored by this function and that the default
* of the `attach` option is `body` instead of `null`.
*
* It allows you to bind the creation of the popup to a menu item as follow:
* const ctl = popupControl(triggerElem, (ctl) => buildDom(ctl));
* ...
* menuItem(elem => ctl.open(), 'do stuff...')
*/
import { domDispose } from "grainjs";
import { IOpenController, IPopupDomCreator, IPopupOptions, PopupControl } from "popweasel";
export function popupControl(reference: Element, domCreator: IPopupDomCreator, options: IPopupOptions): PopupControl {
function openFunc(openCtl: IOpenController) {
const content = domCreator(openCtl);
function dispose() { domDispose(content); }
return {content, dispose};
}
const ctl = PopupControl.create(null);
ctl.attachElem(reference, openFunc, {
attach: 'body',
boundaries: 'viewport',
...options,
trigger: undefined
});
return ctl;
}

@ -0,0 +1,134 @@
/**
* SimpleList is a simple collection of item. Besides holding the items, it also knows which item is
* selected, and allows selection via keyboard. In particular simpleList does not steal focus from
* the trigger element, which makes it suitable to show a list of items next to an input element
* while user is typing without interfering.
*
* const array = observable([{label: 'foo': value: 0, {label: 'bar', value: 1}]);
* const ctl = popupControl(elem, ctl => SimpleList.create(null, ctl, array, action));
*
* dom('input', dom.on('click', () => ctl.toggle()));
*/
import { Disposable, dom, Observable, styled } from "grainjs";
import { cssMenuItem, getOptionFull, IOpenController, IOption } from "popweasel";
import { attachMouseOverOnMove, findAncestorChild } from "app/client/lib/autocomplete";
import { menuCssClass, menuItem } from "app/client/ui2018/menus";
export type { IOption, IOptionFull } from 'popweasel';
export class SimpleList<T> extends Disposable {
public readonly content: HTMLElement;
private _menuContent: HTMLElement;
private _selected: HTMLElement;
private _selectedIndex: number = -1;
private _mouseOver: {reset(): void};
constructor(private _ctl: IOpenController,
private _items: Observable<Array<IOption<T>>>,
private _action: (value: T) => void) {
super();
this.content = cssMenuWrap(
{class: menuCssClass + ' grist-floating-menu'},
this._menuContent = cssMenuList(
dom.forEach(this._items, (i) => {
const item = getOptionFull(i);
return cssOptionRow(
{class: menuItem.className + ' ' + cssMenuItem.className},
dom.on('click', () => this._doAction(item.value)),
item.label,
dom.cls('disabled', Boolean(item.disabled)),
dom.data('itemValue', item.value),
);
}),
),
dom.on('mouseleave', (_ev) => this.setSelected(-1)),
);
this.autoDispose(dom.onKeyElem(_ctl.getTriggerElem() as any, 'keydown', {
Escape: () => this._ctl.close(),
ArrowDown: () => this.setSelected(this._getNextSelectable(1)),
ArrowUp: () => this.setSelected(this._getNextSelectable(-1)),
Enter: () => this._doAction(this._getSelectedData()),
}));
this.autoDispose(_items.addListener(() => this._update()));
this._mouseOver = attachMouseOverOnMove(
this._menuContent,
(ev) => this.setSelected(this._findTargetItem(ev.target))
);
this._update();
}
// When the selected element changes, update the classes of the formerly and newly-selected
// elements.
public setSelected(index: number) {
const elem = (this._menuContent.children[index] as HTMLElement) || null;
const prev = this._selected;
if (elem !== prev) {
const clsName = cssMenuItem.className + '-sel';
if (prev) { prev.classList.remove(clsName); }
if (elem) {
elem.classList.add(clsName);
elem.scrollIntoView({block: 'nearest'});
}
}
this._selected = elem;
this._selectedIndex = elem ? index : -1;
}
private _update() {
this._mouseOver?.reset();
}
private _findTargetItem(target: EventTarget|null): number {
// Find immediate child of this._menuContent which is an ancestor of ev.target.
const elem = findAncestorChild(this._menuContent, target as Element|null);
if (elem?.classList.contains('disabled')) { return -1; }
return Array.prototype.indexOf.call(this._menuContent.children, elem);
}
private _getSelectedData() {
return this._selected ? dom.getData(this._selected, 'itemValue') : null;
}
private _doAction(value: T | null) {
// If value is null, simply close the menu. This happens when pressing enter with no element
// selected.
if (value) { this._action(value); }
this._ctl.close();
}
private _getNext(index: number, step: 1 | -1): number {
// Pretend there is an extra element at the end to mean "nothing selected".
const xsize = this._items.get().length + 1;
const next = (index + step + xsize) % xsize;
return (next === xsize - 1) ? -1 : next;
}
private _getNextSelectable(step: 1 | -1): number {
let next = this._getNext(this._selectedIndex, step);
while (this._menuContent.children[next]?.classList.contains('disabled')) {
next = this._getNext(next, step);
}
return next;
}
}
const cssMenuWrap = styled('div', `
position: absolute;
display: flex;
flex-direction: column;
outline: none;
`);
const cssMenuList = styled('ul', `
overflow: auto;
list-style: none;
outline: none;
padding: 6px 0;
width: 100%;
`);
const cssOptionRow = styled('li', `
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
`);

@ -1,6 +1,9 @@
import {ColumnFilterFunc, makeFilterFunc} from "app/common/ColumnFilterFunc";
import {CellValue} from 'app/common/DocActions';
import {FilterSpec, FilterState, isRangeFilter, makeFilterState} from "app/common/FilterState";
import {
FilterSpec, FilterState, IRelativeDateSpec, isRangeFilter, isRelativeBound, makeFilterState
} from "app/common/FilterState";
import {toUnixTimestamp} from "app/common/RelativeDates";
import {nativeCompare} from 'app/common/gutil';
import {Computed, Disposable, Observable} from 'grainjs';
@ -14,8 +17,8 @@ import {Computed, Disposable, Observable} from 'grainjs';
*/
export class ColumnFilter extends Disposable {
public min = Observable.create<number|undefined>(this, undefined);
public max = Observable.create<number|undefined>(this, undefined);
public min = Observable.create<number|undefined|IRelativeDateSpec>(this, undefined);
public max = Observable.create<number|undefined|IRelativeDateSpec>(this, undefined);
public readonly filterFunc = Observable.create<ColumnFilterFunc>(this, () => true);
@ -25,6 +28,8 @@ export class ColumnFilter extends Disposable {
// Computed that returns the current filter state.
public readonly state: Computed<FilterState> = Computed.create(this, this.filterFunc, () => this._getState());
public readonly isRange: Computed<boolean> = Computed.create(this, this.filterFunc, () => this._isRange());
private _include: boolean;
private _values: Set<CellValue>;
@ -119,6 +124,12 @@ export class ColumnFilter extends Disposable {
return this.makeFilterJson() !== this._initialFilterJson;
}
public getBoundsValue(minMax: 'min' | 'max'): number | undefined {
const value = this[minMax].get();
return isRelativeBound(value) ? toUnixTimestamp(value) : value;
}
private _updateState(): void {
this.filterFunc.set(makeFilterFunc(this._getState(), this._columnType));
}

@ -0,0 +1,164 @@
import { Disposable, dom, Observable, styled } from "grainjs";
import { ColumnFilter } from "app/client/models/ColumnFilter";
import { testId } from "app/client/ui2018/cssVars";
import { textButton } from "app/client/ui2018/buttons";
import { IColumnFilterViewType } from "app/client/ui/ColumnFilterMenu";
import getCurrentTime from "app/common/getCurrentTime";
import { IRelativeDateSpec, isRelativeBound } from "app/common/FilterState";
import { toUnixTimestamp } from "app/common/RelativeDates";
import { updateRelativeDate } from "app/client/ui/RelativeDatesOptions";
import moment from "moment-timezone";
export class ColumnFilterCalendarView extends Disposable {
private _$el: any;
constructor(private _opts: {
viewTypeObs: Observable<IColumnFilterViewType>,
// Note the invariant: `selectedBoundObs.get() !== null` until this gets disposed.
selectedBoundObs: Observable<'min' | 'max' | null>,
columnFilter: ColumnFilter,
}) {
super();
this._moveToSelected = this._moveToSelected.bind(this);
this.autoDispose(this.columnFilter.min.addListener(() => this._setRange()));
this.autoDispose(this.columnFilter.max.addListener(() => this._setRange()));
this.autoDispose(this._opts.selectedBoundObs.addListener(this._moveToSelected));
}
public get columnFilter() { return this._opts.columnFilter; }
public get selectedBoundObs() { return this._opts.selectedBoundObs; }
public buildDom() {
setTimeout(() => this._moveToSelected(), 0);
return cssContainer(
cssLinkRow(
cssLink(
'← List view',
dom.on('click', () => this._opts.selectedBoundObs.set(null)),
),
cssLink(
'Today',
dom.on('click', () => {
this._$el.datepicker('update', this._getCurrentTime());
this._cleanup();
}),
),
testId('calendar-links'),
),
cssDatepickerContainer(
(el) => {
const $el = this._$el = $(el) as any;
$el.datepicker({
defaultViewDate: this._getCurrentTime(),
todayHighlight: true,
});
$el[0].querySelector('.datepicker');
this._setRange();
$el.on('changeDate', () => this._onChangeDate());
// Schedules cleanups after users navigations (ie: navigating to next/prev month).
$el.on('changeMonth', () => setTimeout(() => this._cleanup(), 0));
$el.on('changeYear', () => setTimeout(() => this._cleanup(), 0));
$el.on('changeDecade', () => setTimeout(() => this._cleanup(), 0));
$el.on('changeCentury', () => setTimeout(() => this._cleanup(), 0));
},
)
);
}
private _setRange() {
this._$el.datepicker('setRange', this._getRange());
this._moveToSelected();
}
// Move calendar to the selected bound's current date.
private _moveToSelected() {
const minMax = this._opts.selectedBoundObs.get();
let dateValue = this._getCurrentTime();
if (minMax !== null) {
const value = this.columnFilter.getBoundsValue(minMax);
if (value !== undefined) {
dateValue = new Date(value * 1000);
}
}
this._$el.datepicker('update', dateValue);
this._cleanup();
}
private _getCurrentTime(): Date {
return getCurrentTime().toDate();
}
private _onChangeDate() {
const d = this._$el.datepicker('getUTCDate').valueOf() / 1000;
const {min, max} = this.columnFilter;
// Check the the min bounds is before max bounds. If not update the other bounds to the same
// value.
// TODO: also perform this check when users pick relative dates from popup
if (this.selectedBoundObs.get() === 'min') {
min.set(this._updateBoundValue(min.get(), d));
if (max.get() !== undefined && toUnixTimestamp(max.get()!) < d) {
max.set(this._updateBoundValue(max.get(), d));
}
} else {
max.set(this._updateBoundValue(max.get(), d));
if (min.get() !== undefined && d < toUnixTimestamp(min.get()!)) {
min.set(this._updateBoundValue(min.get(), d));
}
}
this._cleanup();
}
private _getRange() {
const min = this.columnFilter.getBoundsValue('min');
const max = this.columnFilter.getBoundsValue('max');
const toDate = (val: number) => {
const m = moment.utc(val * 1000);
return new Date(Date.UTC(m.year(), m.month(), m.date()));
};
if (min === undefined && max === undefined) {
return [];
}
if (min === undefined) {
return [{valueOf: () => -Infinity}, toDate(max!)];
}
if (max === undefined) {
return [toDate(min), {valueOf: () => +Infinity}];
}
return [toDate(min), toDate(max)];
}
// Update val with date. Returns the new updated value. Useful to update bounds' value after users
// have picked new value from calendar.
private _updateBoundValue(val: IRelativeDateSpec|number|undefined, date: number) {
return isRelativeBound(val) ? updateRelativeDate(val, date) : date;
}
// Removes the `.active` class from date elements in the datepicker. The active dates background
// takes precedence over other backgrounds which are more important to us, such as range's bounds
// and current day.
private _cleanup() {
const elements = this._$el.get()[0].querySelectorAll('.active');
for (const el of elements) {
el.classList.remove('active');
}
}
}
const cssContainer = styled('div', `
padding: 16px 16px;
`);
const cssLink = textButton;
const cssLinkRow = styled('div', `
display: flex;
justify-content: space-between;
`);
const cssDatepickerContainer = styled('div', `
padding-top: 16px;
`);

@ -19,11 +19,14 @@ import {cssLabel as cssCheckboxLabel, cssCheckboxSquare, cssLabelText, Indetermi
} from 'app/client/ui2018/checkbox';
import {theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menuCssClass, menuDivider} from 'app/client/ui2018/menus';
import {cssOptionRowIcon, menu, menuCssClass, menuDivider, menuItem} from 'app/client/ui2018/menus';
import {CellValue} from 'app/common/DocActions';
import {isEquivalentFilter} from "app/common/FilterState";
import {Computed, dom, DomElementArg, DomElementMethod, IDisposableOwner, input, makeTestId, Observable,
styled} from 'grainjs';
import {IRelativeDateSpec, isEquivalentFilter, isRelativeBound} from "app/common/FilterState";
import {formatRelBounds} from "app/common/RelativeDates";
import {
Computed, dom, DomArg, DomElementArg, DomElementMethod, IDisposableOwner, input, makeTestId,
Observable, styled
} from 'grainjs';
import concat = require('lodash/concat');
import identity = require('lodash/identity');
import noop = require('lodash/noop');
@ -33,9 +36,15 @@ import tail = require('lodash/tail');
import debounce = require('lodash/debounce');
import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel';
import {decodeObject} from 'app/plugin/objtypes';
import {isDateLikeType, isList, isNumberType, isRefListType} from 'app/common/gristTypes';
import {extractTypeFromColType, isDateLikeType, isList, isNumberType, isRefListType} from 'app/common/gristTypes';
import {choiceToken} from 'app/client/widgets/ChoiceToken';
import {ChoiceOptions} from 'app/client/widgets/ChoiceTextBox';
import {ColumnFilterCalendarView} from 'app/client/ui/ColumnFilterCalendarView';
import {cssDeleteButton, cssDeleteIcon, cssToken as cssTokenTokenBase} from 'app/client/widgets/ChoiceListEditor';
import {relativeDatesControl} from 'app/client/ui/ColumnFilterMenuUtils';
import {FocusLayer} from 'app/client/lib/FocusLayer';
import {DateRangeOptions, IDateRangeOption} from 'app/client/ui/DateRangeOptions';
import {createFormatter} from 'app/common/ValueFormatter';
const t = makeT('ColumnFilterMenu');
@ -48,18 +57,23 @@ export interface IFilterMenuOptions {
doSave(reset: boolean): void;
renderValue(key: CellValue, value: IFilterCount): DomElementArg;
onClose(): void;
valueParser?(val: string): any;
valueFormatter?(val: any): string;
}
const testId = makeTestId('test-filter-menu-');
export type IColumnFilterViewType = 'listView'|'calendarView';
/**
* Returns the DOM content for the column filter menu.
*
* For use with setPopupToCreateDom().
*/
export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptions): HTMLElement {
const { model, doCancel, doSave, onClose, rangeInputOptions = {}, renderValue, showAllFiltersButton } = opts;
const { model, doCancel, doSave, onClose, renderValue, valueParser, showAllFiltersButton } = opts;
const { columnFilter, filterInfo } = model;
const valueFormatter = opts.valueFormatter || ((val) => val?.toString() || '');
// Map to keep track of displayed checkboxes
const checkboxMap: Map<CellValue, HTMLInputElement> = new Map();
@ -78,211 +92,415 @@ 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);
const isDateFilter = isDateLikeType(columnFilter.columnType);
const selectedBoundObs = Observable.create<'min'|'max'|null>(owner, null);
const viewTypeObs = Computed.create<IColumnFilterViewType>(owner, (
(use) => isDateFilter && use(selectedBoundObs) ? 'calendarView' : 'listView'
));
const isMinSelected = Computed.create<boolean>(owner, (use) => use(selectedBoundObs) === 'min')
.onWrite((val) => val ? selectedBoundObs.set('min') : selectedBoundObs.set('max'));
const isMaxSelected = Computed.create<boolean>(owner, (use) => use(selectedBoundObs) === 'max')
.onWrite((val) => val ? selectedBoundObs.set('max') : selectedBoundObs.set('min'));
let searchInput: HTMLInputElement;
let minRangeInput: HTMLInputElement;
let cancel = false;
let reset = false;
// Gives focus to the searchInput on open (or to the min input if the range filter is present).
setTimeout(() => (minRangeInput || searchInput).select(), 0);
const filterMenu: HTMLElement = cssMenu(
{ tabindex: '-1' }, // Allow menu to be focused
testId('wrapper'),
// Makes sure focus goes back to menu container and disable grist keyboard shortcut while open.
elem => {
FocusLayer.create(owner, {defaultFocusElem: elem, pauseMousetrap: true});
// Gives focus to the searchInput on open (or to the min input if the range filter is
// present). Note that this must happen after the instanciation of FocusLayer in order to
// correctly override focus set by the latter also using a 0 delay.
setTimeout(() => {
const el = searchInput;
el.focus();
el.select();
}, 0);
},
dom.cls(menuCssClass),
dom.autoDispose(filterListener),
// Save or cancel on disposal, which should always happen as part of closing.
dom.onDispose(() => cancel ? doCancel() : doSave(reset)),
dom.onKeyDown({
Enter: () => onClose(),
Escape: () => onClose()
Escape: () => onClose(),
}),
// Filter by range
dom.maybe(showRangeFilter, () => [
cssRangeHeader(t('FilterByRange')),
cssRangeContainer(
minRangeInput = rangeInput('Min ', columnFilter.min, rangeInputOptions, testId('min')),
cssRangeInputSeparator('→'),
rangeInput('Max ', columnFilter.max, rangeInputOptions, testId('max')),
),
cssMenuDivider(),
]),
cssMenuHeader(
cssSearchIcon('Search'),
searchInput = cssSearch(
searchValueObs, { onInput: true },
testId('search-input'),
{ type: 'search', placeholder: t('SearchValues') },
dom.onKeyDown({
Enter: () => {
if (searchValueObs.get()) {
columnFilter.setState({included: filteredKeys.get()});
}
rangeInput(
columnFilter.min, {
isDateFilter,
placeholder: isDateFilter ? t('DateRangeMin') : t('RangeMin'),
valueParser,
valueFormatter,
isSelected: isMinSelected,
viewTypeObs,
nextSelected: () => selectedBoundObs.set('max'),
},
Escape$: (ev) => {
if (searchValueObs.get()) {
searchValueObs.set('');
searchInput.focus();
ev.stopPropagation();
}
}
})
testId('min'),
dom.onKeyDown({Tab: (e) => e.shiftKey || selectedBoundObs.set('max')}),
),
rangeInput(
columnFilter.max, {
isDateFilter,
placeholder: isDateFilter ? t('DateRangeMax') : t('RangeMax'),
valueParser,
valueFormatter,
isSelected: isMaxSelected,
viewTypeObs,
},
testId('max'),
dom.onKeyDown({Tab: (e) => e.shiftKey ? selectedBoundObs.set('min') : selectedBoundObs.set('max')}),
),
),
dom.maybe(searchValueObs, () => cssSearchIcon(
'CrossSmall', testId('search-close'),
dom.on('click', () => {
searchValueObs.set('');
searchInput.focus();
}),
)),
),
cssMenuDivider(),
cssMenuItem(
dom.domComputed((use) => {
const searchValue = use(searchValueObs);
// This is necessary to avoid a known bug in grainjs where filteredKeys does not get
// recalculated.
use(filteredKeys);
const allSpec = searchValue ? {included: use(filteredKeys)} : {excluded: []};
const noneSpec = searchValue ? {excluded: use(filteredKeys)} : {included: []};
const state = use(columnFilter.state);
// presets links
dom.maybe(isDateFilter, () => {
function action(option: IDateRangeOption) {
const {min, max} = option;
columnFilter.min.set(min);
columnFilter.max.set(max);
// open the calendar view
selectedBoundObs.set('min');
}
return [
cssSelectAll(
dom.text(searchValue ? t('AllShown') : t('All')),
cssSelectAll.cls('-disabled', isEquivalentFilter(state, allSpec)),
dom.on('click', () => columnFilter.setState(allSpec)),
testId('bulk-action'),
cssLinkRow(
testId('presets-links'),
cssLink(
DateRangeOptions[0].label,
dom.on('click', () => action(DateRangeOptions[0]))
),
cssLink(
DateRangeOptions[1].label,
dom.on('click', () => action(DateRangeOptions[1]))
),
cssLink(
'More ', icon('Dropdown'),
menu(() => DateRangeOptions.map(
(option) => menuItem(() => action(option), option.label)
), {attach: '.' + cssMenu.className})
),
),
cssDotSeparator('•'),
cssSelectAll(
searchValue ? t('AllExcept') : t('None'),
cssSelectAll.cls('-disabled', isEquivalentFilter(state, noneSpec)),
dom.on('click', () => columnFilter.setState(noneSpec)),
testId('bulk-action'),
)
];
}),
cssSortIcon(
'Sort',
cssSortIcon.cls('-active', isSortedByCount),
dom.on('click', () => isSortedByCount.set(!isSortedByCount.get())),
)
),
cssItemList(
testId('list'),
dom.maybe(use => use(filteredValues).length === 0, () => cssNoResults(t('NoMatchingValues'))),
dom.domComputed(filteredValues, (values) => values.slice(0, model.limitShown).map(([key, value]) => (
cssMenuItem(
cssLabel(
cssCheckboxSquare(
{type: 'checkbox'},
dom.on('change', (_ev, elem) => {
elem.checked ? columnFilter.add(key) : columnFilter.delete(key);
}),
(elem) => { elem.checked = columnFilter.includes(key); checkboxMap.set(key, elem); },
dom.style('position', 'relative'),
cssMenuDivider(),
]),
dom.domComputed(viewTypeObs, viewType => viewType === 'listView' ? ListView() :
dom.create(ColumnFilterCalendarView, {
viewTypeObs, selectedBoundObs, columnFilter,
})),
Footer(),
// Prevents click on presets links submenus (any one of the 'More' submenus) from bubling up and
// eventually cause the parent menu to close (which used to happen when opening the column
// filter from the section sort&filter menu)
dom.on('click', ev => ev.stopPropagation()),
);
function ListView() {
return [
cssMenuHeader(
cssSearchIcon('Search'),
searchInput = cssSearch(
searchValueObs, { onInput: true },
testId('search-input'),
{ type: 'search', placeholder: t('SearchValues') },
dom.onKeyDown({
Enter: () => {
if (searchValueObs.get()) {
columnFilter.setState({included: filteredKeys.get()});
}
},
Escape$: (ev) => {
if (searchValueObs.get()) {
searchValueObs.set('');
searchInput.focus();
ev.stopPropagation();
}
}
})
),
dom.maybe(searchValueObs, () => cssSearchIcon(
'CrossSmall', testId('search-close'),
dom.on('click', () => {
searchValueObs.set('');
searchInput.focus();
}),
)),
),
cssMenuDivider(),
cssMenuItem(
dom.domComputed((use) => {
const searchValue = use(searchValueObs);
// This is necessary to avoid a known bug in grainjs where filteredKeys does not get
// recalculated.
use(filteredKeys);
const allSpec = searchValue ? {included: use(filteredKeys)} : {excluded: []};
const noneSpec = searchValue ? {excluded: use(filteredKeys)} : {included: []};
const state = use(columnFilter.state);
return [
cssSelectAll(
dom.text(searchValue ? t('AllShown') : t('All')),
dom.prop('disabled', isEquivalentFilter(state, allSpec)),
dom.on('click', () => columnFilter.setState(allSpec)),
testId('bulk-action'),
),
renderValue(key, value),
),
cssItemCount(value.count.toLocaleString(), testId('count')))
))) // Include comma separator
),
cssMenuDivider(),
cssMenuFooter(
dom.domComputed((use) => {
const isAboveLimit = use(isAboveLimitObs);
const searchValue = use(isSearchingObs);
const otherValues = use(model.otherValues);
const anyOtherValues = Boolean(otherValues.length);
const valuesBeyondLimit = use(model.valuesBeyondLimit);
if (isAboveLimit) {
return searchValue ? [
buildSummary(t('OtherMatching'), valuesBeyondLimit, false, model),
buildSummary(t('OtherNonMatching'), otherValues, true, model),
] : [
buildSummary(t('OtherValues'), concat(otherValues, valuesBeyondLimit), false, model),
buildSummary(t('FutureValues'), [], true, model),
];
} else {
return anyOtherValues ? [
buildSummary(t('Others'), otherValues, true, model)
] : [
buildSummary(t('FutureValues'), [], true, model)
cssDotSeparator('•'),
cssSelectAll(
searchValue ? t('AllExcept') : t('None'),
dom.prop('disabled', isEquivalentFilter(state, noneSpec)),
dom.on('click', () => columnFilter.setState(noneSpec)),
testId('bulk-action'),
)
];
}
}),
cssFooterButtons(
dom('div',
cssPrimaryButton('Close', testId('apply-btn'),
dom.on('click', () => {
reset = true;
onClose();
}),
),
basicButton('Cancel', testId('cancel-btn'),
dom.on('click', () => {
cancel = true;
onClose();
}),
),
!showAllFiltersButton ? null : cssAllFiltersButton(
'All filters',
dom.on('click', () => {
onClose();
commands.allCommands.sortFilterMenuOpen.run(filterInfo.viewSection.getRowId());
}),
testId('all-filters-btn'),
}),
cssSortIcon(
'Sort',
cssSortIcon.cls('-active', isSortedByCount),
dom.on('click', () => isSortedByCount.set(!isSortedByCount.get())),
)
),
cssItemList(
testId('list'),
dom.maybe(use => use(filteredValues).length === 0, () => cssNoResults(t('NoMatchingValues'))),
dom.domComputed(filteredValues, (values) => values.slice(0, model.limitShown).map(([key, value]) => (
cssMenuItem(
cssLabel(
cssCheckboxSquare(
{type: 'checkbox'},
dom.on('change', (_ev, elem) => {
elem.checked ? columnFilter.add(key) : columnFilter.delete(key);
}),
(elem) => { elem.checked = columnFilter.includes(key); checkboxMap.set(key, elem); },
dom.style('position', 'relative'),
),
renderValue(key, value),
),
cssItemCount(value.count.toLocaleString(), testId('count')))
))) // Include comma separator
),
];
}
function Footer() {
return [
cssMenuDivider(),
cssMenuFooter(
dom.domComputed((use) => {
const isAboveLimit = use(isAboveLimitObs);
const searchValue = use(isSearchingObs);
const otherValues = use(model.otherValues);
const anyOtherValues = Boolean(otherValues.length);
const valuesBeyondLimit = use(model.valuesBeyondLimit);
const isRangeFilter = use(columnFilter.isRange);
if (isRangeFilter || use(viewTypeObs) === 'calendarView') {
return [];
}
if (isAboveLimit) {
return searchValue ? [
buildSummary(t('OtherMatching'), valuesBeyondLimit, false, model),
buildSummary(t('OtherNonMatching'), otherValues, true, model),
] : [
buildSummary(t('OtherValues'), concat(otherValues, valuesBeyondLimit), false, model),
buildSummary(t('FutureValues'), [], true, model),
];
} else {
return anyOtherValues ? [
buildSummary(t('Others'), otherValues, true, model)
] : [
buildSummary(t('FutureValues'), [], true, model)
];
}
}),
cssFooterButtons(
dom('div',
cssPrimaryButton('Close', testId('apply-btn'),
dom.on('click', () => {
reset = true;
onClose();
}),
),
basicButton('Cancel', testId('cancel-btn'),
dom.on('click', () => {
cancel = true;
onClose();
}),
),
!showAllFiltersButton ? null : cssAllFiltersButton(
'All filters',
dom.on('click', () => {
onClose();
commands.allCommands.sortFilterMenuOpen.run(filterInfo.viewSection.getRowId());
}),
testId('all-filters-btn'),
),
),
),
dom('div',
cssPinButton(
icon('PinTilted'),
cssPinButton.cls('-pinned', model.filterInfo.isPinned),
dom.on('click', () => filterInfo.pinned(!filterInfo.pinned())),
testId('pin-btn'),
dom('div',
cssPinButton(
icon('PinTilted'),
cssPinButton.cls('-pinned', model.filterInfo.isPinned),
dom.on('click', () => filterInfo.pinned(!filterInfo.pinned())),
testId('pin-btn'),
),
),
),
),
),
);
)
)
];
}
return filterMenu;
}
export interface IRangeInputOptions {
valueParser?: (val: string) => any;
valueFormatter?: (val: any) => string;
isDateFilter: boolean;
placeholder: string;
isSelected: Observable<boolean>;
viewTypeObs: Observable<IColumnFilterViewType>;
valueParser?(val: string): any;
valueFormatter(val: any): string;
nextSelected?(): void;
}
// The range input with the preset links.
function rangeInput(obs: Observable<number|undefined|IRelativeDateSpec>, opts: IRangeInputOptions,
...args: DomArg<HTMLDivElement>[]) {
const buildInput = () => [
dom.maybe(use => isRelativeBound(use(obs)), () => relativeToken(obs, opts)),
numericInput(obs, opts),
];
return cssRangeInputContainer(
dom.maybe(opts.isDateFilter, () => [
cssRangeInputIcon('FieldDate'),
buildInput(),
icon('Dropdown')
]),
dom.maybe(!opts.isDateFilter, () => [
buildInput(),
]),
cssRangeInputContainer.cls('-relative', use => isRelativeBound(use(obs))),
dom.cls('selected', (use) => use(opts.viewTypeObs) === 'calendarView' && use(opts.isSelected)),
dom.on('click', () => opts.isSelected.set(true)),
(elem) => opts.isDateFilter ? attachRelativeDatesOptions(elem, obs, opts) : null,
dom.onKeyDown({
Backspace$: () => isRelativeBound(obs.get()) && obs.set(undefined),
}),
...args,
);
}
function rangeInput(placeholder: string, obs: Observable<number|undefined>, opts: IRangeInputOptions,
...args: DomElementArg[]) {
// Attach the date options dropdown to elem.
function attachRelativeDatesOptions(elem: HTMLElement, obs: Observable<number|undefined|IRelativeDateSpec>,
opts: IRangeInputOptions) {
const popupCtl = relativeDatesControl(elem, obs, {
...opts,
placement: 'right-start',
attach: '.' + cssMenu.className
});
// makes sure the options are shown any time the value changes.
const onValueChange = () => {
if (opts.isSelected.get()) {
popupCtl.open();
} else {
popupCtl.close();
}
};
// toggle popup on click
dom.update(elem, [
dom.on('click', () => popupCtl.toggle()),
dom.autoDispose(opts.isSelected.addListener(onValueChange)),
dom.autoDispose(obs.addListener(onValueChange)),
dom.onKeyDown({
Enter$: (e) => {
if (opts.viewTypeObs.get() === 'listView') { return; }
if (opts.isSelected.get()) {
if (popupCtl.isOpen()) {
opts.nextSelected?.();
} else {
popupCtl.open();
}
}
// Prevents Enter to close filter menu
e.stopPropagation();
},
}),
]);
}
function numericInput(obs: Observable<number|undefined|IRelativeDateSpec>,
opts: IRangeInputOptions,
...args: DomArg<HTMLDivElement>[]) {
const valueParser = opts.valueParser || Number;
const formatValue = opts.valueFormatter || ((val) => val?.toString() || '');
const formatValue = opts.valueFormatter;
const placeholder = opts.placeholder;
let editMode = false;
let el: HTMLInputElement;
// keep input content in sync only when no edit are going on.
const lis = obs.addListener(() => editMode ? null : el.value = formatValue(obs.get()));
let inputEl: HTMLInputElement;
// handle change
const onBlur = () => {
onInput.flush();
editMode = false;
el.value = formatValue(obs.get());
inputEl.value = formatValue(obs.get());
// Make sure focus is trapped on input during calendar view, so that uses can still use keyboard
// to navigate relative date options just after picking a date on the calendar.
setTimeout(() => opts.isSelected.get() && inputEl.focus());
};
const onInput = debounce(() => {
if (isRelativeBound(obs.get())) { return; }
editMode = true;
const val = el.value ? valueParser(el.value) : undefined;
if (val === undefined || !isNaN(val)) {
const val = inputEl.value ? valueParser(inputEl.value) : undefined;
if (val === undefined || typeof val === 'number' && !isNaN(val)) {
obs.set(val);
}
}, 100);
return el = cssRangeInput(
// TODO: could be nice to have the cursor positioned at the end of the input
return inputEl = cssRangeInput(
{inputmode: 'numeric', placeholder, value: formatValue(obs.get())},
dom.on('input', onInput),
dom.on('blur', onBlur),
dom.autoDispose(lis),
...args
// keep input content in sync only when no edit are going on.
dom.autoDispose(obs.addListener(() => editMode ? null : inputEl.value = formatValue(obs.get()))),
dom.autoDispose(opts.isSelected.addListener(val => val && inputEl.focus())),
dom.onKeyDown({
Enter$: () => onBlur(),
Tab$: () => onBlur(),
}),
...args,
);
}
function relativeToken(obs: Observable<number|undefined|IRelativeDateSpec>,
opts: IRangeInputOptions) {
return cssTokenContainer(
cssTokenToken(
dom.text((use) => formatRelBounds(use(obs) as IRelativeDateSpec)),
cssDeleteButton(
// Ignore mousedown events, so that tokens aren't draggable by the delete button.
dom.on('mousedown', (ev) => ev.stopPropagation()),
cssDeleteIcon('CrossSmall'),
dom.on('click', () => obs.set(undefined)),
testId('tokenfield-delete'),
),
testId('tokenfield-token'),
),
);
}
@ -413,14 +631,21 @@ export function createFilterMenu(
// range input options
const valueParser = (fieldOrColumn as any).createValueParser?.();
const colFormatter = fieldOrColumn.visibleColFormatter();
let colFormatter = fieldOrColumn.visibleColFormatter();
// Show only the date part of the datetime format in range picker.
if (extractTypeFromColType(colFormatter.type) === 'DateTime') {
const {docSettings} = colFormatter;
const widgetOpts = fieldOrColumn.origCol.peek().widgetOptionsJson();
colFormatter = createFormatter('Date', widgetOpts, docSettings);
}
// 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
// underlying value. Maybe worse, 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;
(val: any) => colFormatter.formatAny(val) : undefined;
function getFilterFunc(col: ViewFieldRec|ColumnRec, colFilter: ColumnFilterFunc|null) {
return col.getRowId() === fieldOrColumn.getRowId() ? null : colFilter;
@ -472,10 +697,8 @@ export function createFilterMenu(
}
},
renderValue: getRenderFunc(columnType, fieldOrColumn),
rangeInputOptions: {
valueParser,
valueFormatter,
},
valueParser,
valueFormatter,
showAllFiltersButton,
});
}
@ -662,7 +885,7 @@ export function attachColumnFilterMenu(
const cssMenu = styled('div', `
display: flex;
flex-direction: column;
min-width: 400px;
min-width: 252px;
max-width: 400px;
max-height: 90vh;
outline: none;
@ -679,14 +902,8 @@ const cssMenuHeader = styled('div', `
margin: 0 16px;
`);
const cssSelectAll = styled('div', `
display: flex;
color: ${theme.controlFg};
cursor: default;
user-select: none;
&-disabled {
color: ${theme.controlSecondaryFg};
}
const cssSelectAll = styled(textButton, `
--icon-color: ${theme.controlFg};
`);
const cssDotSeparator = styled('span', `
color: ${theme.controlFg};
@ -708,6 +925,12 @@ const cssMenuItem = styled('div', `
display: flex;
padding: 8px 16px;
`);
const cssLink = textButton;
const cssLinkRow = styled(cssMenuItem, `
column-gap: 12px;
padding-top: 0;
padding-bottom: 16px;
`);
export const cssItemValue = styled(cssLabelText, `
margin-right: 12px;
white-space: pre;
@ -781,25 +1004,48 @@ const cssToken = styled('div', `
margin-left: 8px;
margin-right: 12px;
`);
const cssRangeHeader = styled(cssMenuItem, `
color: ${theme.text};
padding: unset;
border-radius: 0 0 3px 0;
text-transform: uppercase;
font-size: var(--grist-x-small-font-size);
margin: 16px 16px 6px 16px;
`);
const cssRangeContainer = styled(cssMenuItem, `
display: flex;
justify-content: left;
align-items: center;
column-gap: 10px;
row-gap: 6px;
flex-direction: column;
padding: 16px 16px;
`);
const cssRangeInputSeparator = styled('span', `
font-weight: 600;
color: ${theme.lightText};
const cssRangeInputContainer = styled('div', `
position: relative;
width: 100%;
display: flex;
background-color: ${theme.inputBg};
height: 30px;
width: 100%;
border-radius: 3px;
border: 1px solid ${theme.inputBorder};
outline: none;
padding: 5px;
&.selected {
border: 1px solid ${theme.inputValid};
}
&-relative input {
padding: 0;
max-width: 0;
}
`);
const cssRangeInputIcon = cssOptionRowIcon;
const cssRangeInput = styled(cssInput, `
height: unset;
width: 120px;
border: none;
padding: 0;
width: unset;
flex-grow: 1;
`);
const cssTokenToken = styled(cssTokenTokenBase, `
height: 18px;
line-height: unset;
align-self: center;
cursor: default;
`);
const cssTokenContainer = styled('div', `
width: 100%;
display: flex;
outline: none;
`);

@ -0,0 +1,61 @@
import { Placement } from "@popperjs/core";
import { IRangeBoundType, isEquivalentBound } from "app/common/FilterState";
import { Disposable, dom, Observable } from "grainjs";
import { IOpenController, IPopupOptions, PopupControl } from "popweasel";
import { popupControl } from "app/client/lib/popupControl";
import { IOptionFull, SimpleList } from "app/client/lib/simpleList";
import { relativeDatesOptions } from "app/client/ui/RelativeDatesOptions";
export interface IOptionsDropdownOpt {
placement: Placement;
valueFormatter(val: any): string
}
// Create a popup control that show the relative dates options for obs in a popup attached to
// reference.
export function relativeDatesControl(
reference: HTMLElement,
obs: Observable<IRangeBoundType>,
opt: {valueFormatter(val: any): string} & IPopupOptions): PopupControl {
const popupCtl = popupControl(
reference,
ctl => RelativeDatesMenu.create(null, ctl, obs, opt).content,
opt,
);
dom.autoDisposeElem(reference, popupCtl);
return popupCtl;
}
// Builds the list of relatives dates to show in a popup next to the range inputs for date
// filtering. It does not still focus from the range input and takes care of keyboard navigation
// using arrow Up/Down, Escape to close the menu and enter to trigger select option.
class RelativeDatesMenu extends Disposable {
public content: Element;
private _dropdownList: SimpleList<IRangeBoundType>;
private _items: Observable<Array<IOptionFull<IRangeBoundType>>> = Observable.create(this, []);
constructor(ctl: IOpenController,
private _obs: Observable<IRangeBoundType>,
private _opt: {valueFormatter(val: any): string}) {
super();
this._dropdownList = SimpleList<IRangeBoundType>.create(this, ctl, this._items, this._action.bind(this));
this.content = this._dropdownList.content;
this.autoDispose(this._obs.addListener(() => this._update()));
this._update();
}
private _getOptions() {
const newItems = relativeDatesOptions(this._obs.get(), this._opt.valueFormatter);
return newItems.map(item => ({label: item.label, value: item.spec}));
}
private _update() {
this._items.set(this._getOptions());
const index = this._items.get().findIndex(o => isEquivalentBound(o.value, this._obs.get()));
this._dropdownList.setSelected(index ?? -1);
}
private _action(value: IRangeBoundType) {
this._obs.set(value);
}
}

@ -0,0 +1,41 @@
import { CURRENT_DATE, IRelativeDateSpec } from "app/common/RelativeDates";
export interface IDateRangeOption {
label: string;
min: IRelativeDateSpec;
max: IRelativeDateSpec;
}
export const DateRangeOptions: IDateRangeOption[] = [{
label: 'Today',
min: CURRENT_DATE,
max: CURRENT_DATE,
}, {
label: 'Last 7 days',
min: [{quantity: -7, unit: 'day'}],
max: [{quantity: -1, unit: 'day'}],
}, {
label: 'Next 7 days',
min: [{quantity: 1, unit: 'day'}],
max: [{quantity: 7, unit: 'day'}],
}, {
label: 'Last Week',
min: [{quantity: -1, unit: 'week'}],
max: [{quantity: -1, unit: 'week', endOf: true}],
}, {
label: 'Last 30 days',
min: [{quantity: -30, unit: 'day'}],
max: [{quantity: -1, unit: 'day'}],
}, {
label: 'This week',
min: [{quantity: 0, unit: 'week'}],
max: [{quantity: 0, unit: 'week', endOf: true}],
}, {
label: 'This month',
min: [{quantity: 0, unit: 'month'}],
max: [{quantity: 0, unit: 'month', endOf: true}],
}, {
label: 'This year',
min: [{quantity: 0, unit: 'year'}],
max: [{quantity: 0, unit: 'year', endOf: true}],
}];

@ -0,0 +1,152 @@
import {
CURRENT_DATE, diffUnit, formatRelBounds, IPeriod, IRelativeDateSpec, isEquivalentRelativeDate, toUnixTimestamp
} from "app/common/RelativeDates";
import { IRangeBoundType, isRelativeBound } from "app/common/FilterState";
import getCurrentTime from "app/common/getCurrentTime";
import moment from "moment-timezone";
export const DEPS = {getCurrentTime};
export interface IRelativeDateOption {
label: string;
value: number|IRelativeDateSpec;
}
const DEFAULT_OPTION_LIST: IRelativeDateSpec[] = [
CURRENT_DATE, [{
quantity: -3,
unit: 'day',
}], [{
quantity: -7,
unit: 'day',
}], [{
quantity: -30,
unit: 'day',
}], [{
quantity: 0,
unit: 'year',
}], [{
quantity: 3,
unit: 'day',
}], [{
quantity: 7,
unit: 'day',
}], [{
quantity: 30,
unit: 'day',
}], [{
quantity: 0,
unit: 'year',
endOf: true,
}]];
export function relativeDatesOptions(value: IRangeBoundType, valueFormatter: (val: any) => string
): Array<{label: string, spec: IRangeBoundType}> {
return relativeDateOptionsSpec(value)
.map((spec) => ({spec, label: formatBoundOption(spec, valueFormatter)}));
}
// Returns a list of different relative date spec that all match passed in date value. If value is
// undefined it returns a default list of spec meant to showcase user the different flavors of
// relative date.
function relativeDateOptionsSpec(value: IRangeBoundType): Array<IRangeBoundType> {
if (value === undefined) {
return DEFAULT_OPTION_LIST;
} else if (isRelativeBound(value)) {
value = toUnixTimestamp(value);
}
const date = moment.utc(value * 1000);
const res: IRangeBoundType[] = [value];
let relDate = getMatchingDoubleRelativeDate(value, {unit: 'day'});
if (Math.abs(relDate[0].quantity) <= 90) {
res.push(relDate);
}
relDate = getMatchingDoubleRelativeDate(value, {unit: 'week'});
if (Math.abs(relDate[0].quantity) <= 4) {
res.push(relDate);
}
// any day of the month (with longer limit for 1st day of the month)
relDate = getMatchingDoubleRelativeDate(value, {unit: 'month'});
if (Math.abs(relDate[0].quantity) <= (date.date() === 1 ? 12 : 3)) {
res.push(relDate);
}
// If date is 1st of Jan show 1st day of year options
if (date.date() === 1 && date.month() === 0) {
res.push(getMatchingDoubleRelativeDate(value, {unit: 'year'}));
}
// 31st of Dec
if (date.date() === 31 && date.month() === 11) {
res.push(getMatchingDoubleRelativeDate(value, {unit: 'year', endOf: true}));
}
// Last day of any month
if (date.clone().endOf('month').date() === date.date()) {
relDate = getMatchingDoubleRelativeDate(value, {unit: 'month', endOf: true});
if (Math.abs(relDate[0].quantity) < 12) {
res.push(relDate);
}
}
return res;
}
function now(): moment.Moment {
const m = DEPS.getCurrentTime();
return moment.utc([m.year(), m.month(), m.date()]);
}
// Returns a relative date spec as a sequence of one or two IPeriod that allows to match dateValue
// starting from the current date. The first period has .unit, .startOf and .endOf set according to
// passed in option.
export function getMatchingDoubleRelativeDate(
dateValue: number,
option: {unit: 'day'|'week'|'month'|'year', endOf?: boolean}
): IPeriod[] {
const {unit} = option;
const date = moment.utc(dateValue * 1000);
const dateNow = now();
const quantity = diffUnit(date, dateNow.clone(), unit);
const m = dateNow.clone().add(quantity, unit);
if (option.endOf) { m.endOf(unit); m.startOf('day'); }
else { m.startOf(unit); }
const dayQuantity = diffUnit(date, m, 'day');
const res = [{quantity, ...option}];
// Only add a 2nd period when it is not moot.
if (dayQuantity) { res.push({quantity: dayQuantity, unit: 'day'}); }
return res;
}
export function formatBoundOption(bound: IRangeBoundType, valueFormatter: (val: any) => string): string {
return isRelativeBound(bound) ? formatRelBounds(bound) : valueFormatter(bound);
}
// Update relativeDate to match the new date picked by user.
export function updateRelativeDate(relativeDate: IRelativeDateSpec, date: number): IRelativeDateSpec|number {
const periods = Array.isArray(relativeDate) ? relativeDate : [relativeDate];
if ([1, 2].includes(periods.length)) {
const {unit, endOf} = periods[0];
const relDate = getMatchingDoubleRelativeDate(date, {unit, endOf});
// Returns the relative date only if it is one of the suggested relative dates, otherwise
// returns the absolute date.
const options = relativeDateOptionsSpec(date);
if (options.find(opt => isRelativeBound(opt) && isEquivalentRelativeDate(opt, relDate))) {
return relDate;
}
return date;
}
throw new Error(
`Relative date spec does only support 1 or 2 periods, got ${periods.length}!`
);
}

@ -270,7 +270,7 @@ const cssTokenField = styled(tokenFieldStyles.cssTokenField, `
flex-wrap: wrap;
`);
const cssToken = styled(tokenFieldStyles.cssToken, `
export const cssToken = styled(tokenFieldStyles.cssToken, `
padding: 1px 4px;
margin: 2px;
line-height: 16px;
@ -281,7 +281,7 @@ const cssToken = styled(tokenFieldStyles.cssToken, `
}
`);
const cssDeleteButton = styled(tokenFieldStyles.cssDeleteButton, `
export const cssDeleteButton = styled(tokenFieldStyles.cssDeleteButton, `
position: absolute;
top: -8px;
right: -6px;
@ -303,7 +303,7 @@ const cssDeleteButton = styled(tokenFieldStyles.cssDeleteButton, `
}
`);
const cssDeleteIcon = styled(tokenFieldStyles.cssDeleteIcon, `
export const cssDeleteIcon = styled(tokenFieldStyles.cssDeleteIcon, `
--icon-color: ${colors.light};
&:hover {
--icon-color: ${colors.darkGrey};

@ -1,7 +1,11 @@
import {CellValue} from "app/common/DocActions";
import {FilterState, isRangeFilter, makeFilterState} from "app/common/FilterState";
import {
FilterState, IRangeBoundType, isRangeFilter, isRelativeBound, makeFilterState
} from "app/common/FilterState";
import {decodeObject} from "app/plugin/objtypes";
import moment from "moment-timezone";
import {isDateLikeType, isList, isListType, isNumberType} from "./gristTypes";
import {toUnixTimestamp} from "app/common/RelativeDates";
export type ColumnFilterFunc = (value: CellValue) => boolean;
@ -11,8 +15,12 @@ export function makeFilterFunc(state: FilterState,
columnType: string = ''): ColumnFilterFunc {
if (isRangeFilter(state)) {
const {min, max} = state;
let {min, max} = state;
if (isNumberType(columnType) || isDateLikeType(columnType)) {
min = getBoundsValue(state, 'min');
max = getBoundsValue(state, 'max');
return (val) => {
if (typeof val !== 'number') { return false; }
return (
@ -50,3 +58,19 @@ export function buildColFilter(filterJson: string | undefined,
columnType?: string): ColumnFilterFunc | null {
return filterJson ? makeFilterFunc(makeFilterState(filterJson), columnType) : null;
}
function getBoundsValue(state: {min?: IRangeBoundType, max?: IRangeBoundType}, minMax: 'min'|'max') {
const value = state[minMax];
if (isRelativeBound(value)) {
const val = toUnixTimestamp(value);
const m = moment.utc(val * 1000);
if (minMax === 'min') {
m.startOf('day');
} else {
m.endOf('day');
}
return Math.floor(m.valueOf() / 1000);
}
return value;
}

@ -1,13 +1,18 @@
import { CellValue } from "app/common/DocActions";
import { IRelativeDateSpec, isEquivalentRelativeDate, isRelativeBound } from "app/common/RelativeDates";
export type { IRelativeDateSpec } from "app/common/RelativeDates";
export { isRelativeBound } from "app/common/RelativeDates";
// Filter object as stored in the db
export interface FilterSpec {
included?: CellValue[];
excluded?: CellValue[];
min?: number;
max?: number;
min?: number|IRelativeDateSpec;
max?: number|IRelativeDateSpec;
}
export type IRangeBoundType = undefined|number|IRelativeDateSpec;
export type FilterState = ByValueFilterState | RangeFilterState
@ -18,8 +23,8 @@ interface ByValueFilterState {
}
interface RangeFilterState {
min?: number;
max?: number;
min?: number|IRelativeDateSpec;
max?: number|IRelativeDateSpec;
}
// Creates a FilterState. Accepts spec as a json string or a FilterSpec.
@ -59,3 +64,13 @@ export function isRangeFilter(state: FilterState): state is RangeFilterState {
const {min, max} = state as any;
return min !== undefined || max !== undefined;
}
export function isEquivalentBound(a: IRangeBoundType, b: IRangeBoundType) {
if (isRelativeBound(a) && isRelativeBound(b)) {
return isEquivalentRelativeDate(a, b);
}
if (isRelativeBound(a) || isRelativeBound(b)) {
return false;
}
return a === b;
}

@ -0,0 +1,161 @@
// Relative date spec describes a date that is distant to the current date by a series of jumps in
// time defined as a series of periods. Hence, starting from the current date, each one of the
// periods gets applied successively which eventually yields to the final date. Typical relative
import { isEqual, isNumber, isUndefined, omitBy } from "lodash";
import moment from "moment-timezone";
import getCurrentTime from "app/common/getCurrentTime";
// Relative date uses one or two periods. When relative dates are defined by two periods, they are
// applied successively to the start date to resolve the target date. In practice in grist, as of
// the time of writing, relative date never uses more than 2 periods and the second period's unit is
// always day.
export type IRelativeDateSpec = IPeriod[];
// IPeriod describes a period of time: when used along with a start date, it allows to target a new
// date. It allows to encode simple periods such as `30 days ago` as `{quantity: -30, unit:
// 'day'}`. Or `The last day of last week` as `{quantity: -1, unit: 'week', endOf: true}`. Not that
// .endOf flag is only relevant when the unit is one of 'week', 'month' or 'year'. When `endOf` is
// false or missing then it will target the first day (of the week, month or year).
export interface IPeriod {
quantity: number;
unit: 'day'|'week'|'month'|'year';
endOf?: boolean;
}
export const CURRENT_DATE: IRelativeDateSpec = [{quantity: 0, unit: 'day'}];
export function isRelativeBound(bound?: number|IRelativeDateSpec): bound is IRelativeDateSpec {
return !isUndefined(bound) && !isNumber(bound);
}
// Returns the number of seconds between 1 January 1970 00:00:00 UTC and the given bound, may it be
// a relative date.
export function toUnixTimestamp(bound: IRelativeDateSpec|number): number {
if (isRelativeBound(bound)) {
const localDate = getCurrentTime().startOf('day');
const date = moment.utc(localDate.toObject());
const periods = Array.isArray(bound) ? bound : [bound];
for (const period of periods) {
const {quantity, unit, endOf} = period;
date.add(quantity, unit);
if (endOf) {
date.endOf(unit);
// date must have "hh:mm:ss" set to "00:00:00"
date.startOf('day');
} else {
date.startOf(unit);
}
}
return Math.floor(date.valueOf() / 1000);
} else {
return bound;
}
}
// Format a relative date.
export function formatRelBounds(periods: IPeriod[]): string {
// if 2nd period is moot revert to one single period
periods = periods[1]?.quantity ? periods : [periods[0]];
if (periods.length === 1) {
const {quantity, unit, endOf} = periods[0];
if (unit === 'day') {
if (quantity === 0) { return 'Today'; }
if (quantity === -1) { return 'Yesterday'; }
if (quantity === 1) { return 'Tomorrow'; }
return formatReference(periods[0]);
}
if (endOf) {
return `Last day of ${formatReference(periods[0])}`;
} else {
return `1st day of ${formatReference(periods[0])}`;
}
}
if (periods.length === 2) {
let dayQuantity = periods[1].quantity;
// If the 1st period has the endOf flag, we're already 1 day back.
if (periods[0].endOf) { dayQuantity -= 1; }
let startOrEnd = '';
if (periods[0].unit === 'week') {
if (periods[1].quantity === 0) {
startOrEnd = 'start ';
} else if (periods[1].quantity === 6) {
startOrEnd = 'end ';
}
}
return `${formatDay(dayQuantity, periods[0].unit)} ${startOrEnd}of ${formatReference(periods[0])}`;
}
throw new Error(
`Relative date spec does not support more that 2 periods: ${periods.length}`
);
}
function formatDay(quantity: number, refUnit: IPeriod['unit']): string {
if (refUnit === 'week') {
const n = (quantity + 7) % 7;
return ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][n];
}
const ord = (n: number) => moment.localeData().ordinal(n);
if (quantity < 0) {
if (quantity === -1) {
return 'Last day';
}
return `${ord(-quantity)} to last day`;
} else {
return `${ord(quantity + 1)} day`;
}
}
function formatReference(period: IPeriod): string {
const {quantity, unit} = period;
if (quantity === 0) {
return `this ${unit}`;
}
if (quantity === -1) {
return `last ${unit}`;
}
if (quantity === 1) {
return `next ${unit}`;
}
const n = Math.abs(quantity);
const plurals = n > 1 ? 's' : '';
return `${n} ${unit}${plurals} ${quantity < 1 ? 'ago' : 'from now'}`;
}
export function isEquivalentRelativeDate(a: IPeriod|IPeriod[], b: IPeriod|IPeriod[]) {
a = Array.isArray(a) ? a : [a];
b = Array.isArray(b) ? b : [b];
if (a.length === 2 && a[1].quantity === 0) { a = [a[0]]; }
if (b.length === 2 && b[1].quantity === 0) { b = [b[0]]; }
const compactA = a.map(period => omitBy(period, isUndefined));
const compactB = b.map(period => omitBy(period, isUndefined));
return isEqual(compactA, compactB);
}
// Get the difference in unit of measurement. If unit is week, makes sure that two dates that are in
// two different weeks are always at least 1 number apart. Same for month and year.
export function diffUnit(a: moment.Moment, b: moment.Moment, unit: 'day'|'week'|'month'|'year') {
return a.clone().startOf(unit).diff(b.clone().startOf(unit), unit);
}

@ -0,0 +1,13 @@
import moment from "moment-timezone";
/**
* Returns the current local time. Allows overriding via a "currentTime" URL parameter, for the sake
* of tests.
*/
export default function getCurrentTime(): moment.Moment {
const getDefault = () => moment();
if (typeof window === 'undefined' || !window) { return getDefault(); }
const searchParams = new URLSearchParams(window.location.search);
return searchParams.has('currentTime') ? moment(searchParams.get('currentTime')!) : getDefault();
}

@ -76,7 +76,6 @@
"InsertColumnLeft": "Insert column to the left"
},
"ColumnFilterMenu": {
"FilterByRange": "Filter by Range",
"Search": "Search",
"SearchValues": "Search values",
"All": "All",
@ -88,7 +87,11 @@
"OtherNonMatching": "Other Non-Matching",
"OtherValues": "Other Values",
"FutureValues": "Future Values",
"Others": "Others"
"Others": "Others",
"RangeMin": "Min",
"RangeMax": "Max",
"DateRangeMin": "Start",
"DateRangeMax": "End"
},
"CustomSectionConfig": {
"Add": "Add",

@ -0,0 +1,170 @@
import {DEPS, relativeDatesOptions} from 'app/client/ui/RelativeDatesOptions';
import sinon, { SinonStub } from 'sinon';
import {assert} from 'chai';
import moment from 'moment-timezone';
const valueFormatter = (val: any) => moment(val * 1000).format('YYYY-MM-DD');
const toGristDate = (val: moment.Moment) => Math.floor(val.valueOf() / 1000);
function getOptions(date: string) {
const m = moment(date);
const dateUTC = moment.utc([m.year(), m.month(), m.date()]);
return relativeDatesOptions(toGristDate(dateUTC), valueFormatter);
}
function checkOption(options: Array<{label: string, spec: any}>, label: string, spec: any) {
try {
assert.deepInclude(options, {label, spec});
} catch (e) {
const json = `{\n ${options.map(o => JSON.stringify({label: o.label, spec: o.spec})).join('\n ')}\n}`;
assert.fail(`expected ${json} to include\n ${JSON.stringify({label, spec})}`);
}
}
function optionNotIncluded(options: any[], label: string) {
assert.notInclude(options.map(o => o.label), label);
}
describe('RelativeDatesOptions', function() {
const sandbox = sinon.createSandbox();
let getCurrentTimeSub: SinonStub;
function setCurrentDate(now: string) {
getCurrentTimeSub.returns(moment(now));
}
before(() => {
getCurrentTimeSub = sandbox.stub(DEPS, 'getCurrentTime');
});
after(() => {
sandbox.restore();
});
describe('relativeDateOptions', function() {
it('should limit \'X days ago/from now\' to 90 days ago/from now', function() {
setCurrentDate('2022-09-26');
checkOption(getOptions('2022-09-10'), '16 days ago', [{quantity: -16, unit: 'day'}]);
checkOption(getOptions('2022-06-28'), '90 days ago', [{quantity: -90, unit: 'day'}]);
// check no options of the form 'X days ago'
optionNotIncluded(getOptions('2022-06-27'), '91 days ago');
assert.notOk(getOptions('2022-06-27').find(o => /^[0-9]+ days ago$/.test(o.label)));
checkOption(getOptions('2022-09-26'), 'Today', [{quantity: 0, unit: 'day'}]);
checkOption(getOptions('2022-09-27'), 'Tomorrow', [{quantity: 1, unit: 'day'}]);
checkOption(getOptions('2022-10-02'), '6 days from now', [{quantity: 6, unit: 'day'}]);
});
it('should limit \'WEEKDAY of X weeks ago/from now\' to 4 weeks ago/from now', function() {
setCurrentDate('2022-09-26');
checkOption(getOptions('2022-09-20'), 'Tuesday of last week', [
{quantity: -1, unit: 'week'}, {quantity: 2, unit: 'day'}]);
checkOption(getOptions('2022-09-21'), 'Wednesday of last week', [
{quantity: -1, unit: 'week'}, {quantity: 3, unit: 'day'}]);
checkOption(getOptions('2022-08-31'), 'Wednesday of 4 weeks ago', [
{quantity: -4, unit: 'week'}, {quantity: 3, unit: 'day'}]);
assert.notDeepInclude(getOptions('2022-08-24'), {
label: 'Wednesday of 5 weeks ago',
spec: [{quantity: -5, unit: 'week'}, {quantity: 3, unit: 'day'}]
});
assert.notOk(getOptions('2022-08-24').find(o => /Wednesday/.test(o.label)));
checkOption(getOptions('2022-09-29'), 'Thursday of this week', [
{quantity: 0, unit: 'week'}, {quantity: 4, unit: 'day'}]);
checkOption(getOptions('2022-10-13'), 'Thursday of 2 weeks from now', [
{quantity: 2, unit: 'week'}, {quantity: 4, unit: 'day'}]);
});
it('should limit \'N day of X month ago/from no\' to 3 months ago/from now', function() {
setCurrentDate('2022-09-26');
checkOption(getOptions('2022-09-27'), '27th day of this month', [
{quantity: 0, unit: 'month'}, {quantity: 26, unit: 'day'}]);
checkOption(getOptions('2022-06-16'), '16th day of 3 months ago', [
{quantity: -3, unit: 'month'}, {quantity: 15, unit: 'day'}]);
assert.notOk(getOptions('2022-05-16').find(o => /months? ago/.test(o.label)));
checkOption(getOptions('2022-10-16'), '16th day of next month', [
{quantity: 1, unit: 'month'}, {quantity: 15, unit: 'day'}]);
checkOption(getOptions('2022-11-16'), '16th day of 2 months from now', [
{quantity: 2, unit: 'month'}, {quantity: 15, unit: 'day'}]);
assert.notOk(getOptions('2023-01-16').find(o => /months? from now/.test(o.label)));
});
it('should limit \'1st day of year\' to 1st of Jan', function() {
setCurrentDate('2022-09-26');
checkOption(getOptions('2022-01-01'), '1st day of this year', [
{quantity: 0, unit: 'year'}]);
checkOption(getOptions('2021-01-01'), '1st day of last year', [
{quantity: -1, unit: 'year'}]);
checkOption(getOptions('2024-01-01'), '1st day of 2 years from now', [
{quantity: 2, unit: 'year'}]);
});
it('should limit \'Last day of X year ago/from now\' to 31st of Dec', function() {
setCurrentDate('2022-09-26');
checkOption(getOptions('2022-12-31'), 'Last day of this year', [
{quantity: 0, unit: 'year', endOf: true}]);
checkOption(getOptions('2019-12-31'), 'Last day of 3 years ago', [
{quantity: -3, unit: 'year', endOf: true}]);
checkOption(getOptions('2027-12-31'), 'Last day of 5 years from now', [
{quantity: 5, unit: 'year', endOf: true}]);
});
it('should offer 1st day of any month, limited to 12 months ago/from now', function() {
setCurrentDate('2022-09-29');
checkOption(getOptions('2022-09-01'), '1st day of this month', [
{quantity: 0, unit: 'month'}]);
checkOption(getOptions('2021-09-01'), '1st day of 12 months ago', [
{quantity: -12, unit: 'month'}]);
assert.notOk(getOptions('2021-08-01').find(o => /1st day of [0-9]+ months? ago/.test(o.label)));
checkOption(getOptions('2022-11-01'), '1st day of 2 months from now', [{
quantity: 2, unit: 'month'}]);
});
it('should offer last day of the month, limited to 12 months ago/from now', function() {
setCurrentDate('2022-09-29');
checkOption(getOptions('2022-09-30'), 'Last day of this month', [
{quantity: 0, unit: 'month', endOf: true}]);
checkOption(getOptions('2022-08-31'), 'Last day of last month', [
{quantity: -1, unit: 'month', endOf: true}]);
assert.notOk(getOptions('2021-08-31').find(o => /Last day of [0-9]+ months? ago/.test(o.label)));
checkOption(getOptions('2022-12-31'), 'Last day of 3 months from now', [
{quantity: 3, unit: 'month', endOf: true}]);
});
});
});

@ -0,0 +1,62 @@
import {DEPS, getMatchingDoubleRelativeDate} from 'app/client/ui/RelativeDatesOptions';
import sinon from 'sinon';
import {assert} from 'chai';
import moment from 'moment-timezone';
import {diffUnit} from 'app/common/RelativeDates';
const CURRENT_TIME = moment.tz('2022-09-26T12:13:32.018Z', 'utc');
const now = () => moment(CURRENT_TIME);
describe('RelativeDates', function() {
const sandbox = sinon.createSandbox();
before(() => {
sinon.stub(DEPS, 'getCurrentTime').returns(now());
});
after(() => {
sandbox.restore();
});
describe('getMatchingDoubleRelativeDate', function() {
it('should work correctly', function() {
assert.deepEqual(
getMatchingDoubleRelativeDate(getDateValue('10/1/2022'), {unit: 'month'}),
[{unit: 'month', quantity: 1}]
);
assert.deepEqual(
getMatchingDoubleRelativeDate(getDateValue('9/19/2022'), {unit: 'week'}),
[{unit: 'week', quantity: -1}, {quantity: 1, unit: 'day'}]
);
assert.deepEqual(
getMatchingDoubleRelativeDate(getDateValue('9/21/2022'), {unit: 'week'}),
[{unit: 'week', quantity: -1}, {quantity: 3, unit: 'day'}]
);
assert.deepEqual(
getMatchingDoubleRelativeDate(getDateValue('9/30/2022'), {unit: 'month'}),
[{unit: 'month', quantity: 0}, {quantity: 29, unit: 'day'}]
);
assert.deepEqual(
getMatchingDoubleRelativeDate(getDateValue('10/1/2022'), {unit: 'month'}),
[{unit: 'month', quantity: 1}]
);
});
});
describe('diffUnit', function() {
it('should work correctly', function() {
assert.equal(diffUnit(moment('2022-09-30'), moment('2022-10-01'), 'month'), -1);
assert.equal(diffUnit(moment('2022-10-01'), moment('2022-09-30'), 'month'), 1);
assert.equal(diffUnit(moment('2022-09-30'), moment('2022-10-01'), 'week'), 0);
assert.equal(diffUnit(moment('2022-09-30'), moment('2022-10-02'), 'week'), -1);
});
});
});
function getDateValue(date: string): number {
return moment.tz(date, "MM-DD-YYYY", 'utc').valueOf()/1000;
}

@ -2827,6 +2827,28 @@ export async function beginAclTran(api: UserAPI, docId: string) {
};
}
/**
* Helper to set the value of a column range filter bound. Helper also support picking relative date
* from options for Date columns, simply pass {relative: '2 days ago'} as value.
*/
export async function setRangeFilterBound(minMax: 'min'|'max', value: string|{relative: string}|null) {
await driver.find(`.test-filter-menu-${minMax}`).click();
if (typeof value === 'string' || value === null) {
await selectAll();
await driver.sendKeys(value === null ? Key.DELETE : value);
// send TAB to trigger blur event, that will force call on the debounced callback
await driver.sendKeys(Key.TAB);
} else {
await waitToPass(async () => {
// makes sure the relative options is opened
if (!await driver.find('.grist-floatin-menu').isPresent()) {
await driver.find(`.test-filter-menu-${minMax}`).click();
}
await driver.findContent('.grist-floating-menu li', value.relative).click();
});
}
}
} // end of namespace gristUtils
stackWrapOwnMethods(gristUtils);

Loading…
Cancel
Save