diff --git a/app/client/lib/autocomplete.ts b/app/client/lib/autocomplete.ts index 12ef6608..8ad4a828 100644 --- a/app/client/lib/autocomplete.ts +++ b/app/client/lib/autocomplete.ts @@ -218,7 +218,7 @@ export const defaultPopperOptions: Partial = { * 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(elem: T, callback: EventCB) { +export function attachMouseOverOnMove(elem: T, callback: EventCB) { let lis: IDisposable|undefined; function setListener(eventType: 'mouseover'|'mousemove', cb: EventCB) { if (lis) { lis.dispose(); } diff --git a/app/client/lib/popupControl.ts b/app/client/lib/popupControl.ts new file mode 100644 index 00000000..ab6b5531 --- /dev/null +++ b/app/client/lib/popupControl.ts @@ -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; +} diff --git a/app/client/lib/simpleList.ts b/app/client/lib/simpleList.ts new file mode 100644 index 00000000..4eadf4bc --- /dev/null +++ b/app/client/lib/simpleList.ts @@ -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 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>>, + 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; +`); diff --git a/app/client/models/ColumnFilter.ts b/app/client/models/ColumnFilter.ts index d30dac48..0f923898 100644 --- a/app/client/models/ColumnFilter.ts +++ b/app/client/models/ColumnFilter.ts @@ -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(this, undefined); - public max = Observable.create(this, undefined); + public min = Observable.create(this, undefined); + public max = Observable.create(this, undefined); public readonly filterFunc = Observable.create(this, () => true); @@ -25,6 +28,8 @@ export class ColumnFilter extends Disposable { // Computed that returns the current filter state. public readonly state: Computed = Computed.create(this, this.filterFunc, () => this._getState()); + public readonly isRange: Computed = Computed.create(this, this.filterFunc, () => this._isRange()); + private _include: boolean; private _values: Set; @@ -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)); } diff --git a/app/client/ui/ColumnFilterCalendarView.ts b/app/client/ui/ColumnFilterCalendarView.ts new file mode 100644 index 00000000..2ec8ec57 --- /dev/null +++ b/app/client/ui/ColumnFilterCalendarView.ts @@ -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, + // 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; +`); diff --git a/app/client/ui/ColumnFilterMenu.ts b/app/client/ui/ColumnFilterMenu.ts index 5bb27d75..6940055e 100644 --- a/app/client/ui/ColumnFilterMenu.ts +++ b/app/client/ui/ColumnFilterMenu.ts @@ -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 = 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(owner, ( + (use) => isDateFilter && use(selectedBoundObs) ? 'calendarView' : 'listView' + )); + const isMinSelected = Computed.create(owner, (use) => use(selectedBoundObs) === 'min') + .onWrite((val) => val ? selectedBoundObs.set('min') : selectedBoundObs.set('max')); + const isMaxSelected = Computed.create(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; + viewTypeObs: Observable; + valueParser?(val: string): any; + valueFormatter(val: any): string; + nextSelected?(): void; +} + +// The range input with the preset links. +function rangeInput(obs: Observable, opts: IRangeInputOptions, + ...args: DomArg[]) { + + 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, opts: IRangeInputOptions, - ...args: DomElementArg[]) { +// Attach the date options dropdown to elem. +function attachRelativeDatesOptions(elem: HTMLElement, obs: Observable, + 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, + opts: IRangeInputOptions, + ...args: DomArg[]) { 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, + 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; `); diff --git a/app/client/ui/ColumnFilterMenuUtils.ts b/app/client/ui/ColumnFilterMenuUtils.ts new file mode 100644 index 00000000..99691a87 --- /dev/null +++ b/app/client/ui/ColumnFilterMenuUtils.ts @@ -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, + 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; + private _items: Observable>> = Observable.create(this, []); + constructor(ctl: IOpenController, + private _obs: Observable, + private _opt: {valueFormatter(val: any): string}) { + super(); + this._dropdownList = SimpleList.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); + } +} diff --git a/app/client/ui/DateRangeOptions.ts b/app/client/ui/DateRangeOptions.ts new file mode 100644 index 00000000..d79f420a --- /dev/null +++ b/app/client/ui/DateRangeOptions.ts @@ -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}], +}]; diff --git a/app/client/ui/RelativeDatesOptions.ts b/app/client/ui/RelativeDatesOptions.ts new file mode 100644 index 00000000..50499dfc --- /dev/null +++ b/app/client/ui/RelativeDatesOptions.ts @@ -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 { + + 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}!` + ); +} diff --git a/app/client/widgets/ChoiceListEditor.ts b/app/client/widgets/ChoiceListEditor.ts index fb46c45f..143d793b 100644 --- a/app/client/widgets/ChoiceListEditor.ts +++ b/app/client/widgets/ChoiceListEditor.ts @@ -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}; diff --git a/app/common/ColumnFilterFunc.ts b/app/common/ColumnFilterFunc.ts index 9088cc1c..2174886d 100644 --- a/app/common/ColumnFilterFunc.ts +++ b/app/common/ColumnFilterFunc.ts @@ -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; +} diff --git a/app/common/FilterState.ts b/app/common/FilterState.ts index 1bec7cd6..9649b26c 100644 --- a/app/common/FilterState.ts +++ b/app/common/FilterState.ts @@ -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; +} diff --git a/app/common/RelativeDates.ts b/app/common/RelativeDates.ts new file mode 100644 index 00000000..3e34aab4 --- /dev/null +++ b/app/common/RelativeDates.ts @@ -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); +} diff --git a/app/common/getCurrentTime.ts b/app/common/getCurrentTime.ts new file mode 100644 index 00000000..b54c814f --- /dev/null +++ b/app/common/getCurrentTime.ts @@ -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(); +} diff --git a/static/locales/en.client.json b/static/locales/en.client.json index 9aa203bd..92896ee1 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -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", diff --git a/test/client/ui/RelativeDatesOptions.ts b/test/client/ui/RelativeDatesOptions.ts new file mode 100644 index 00000000..8de6fcbc --- /dev/null +++ b/test/client/ui/RelativeDatesOptions.ts @@ -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}]); + }); + + + }); +}); diff --git a/test/common/RelativeDates.ts b/test/common/RelativeDates.ts new file mode 100644 index 00000000..6050dc35 --- /dev/null +++ b/test/common/RelativeDates.ts @@ -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; +} diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 16388b7a..95237141 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -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);