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/D3720pull/383/head
parent
7dc49f3c85
commit
620e86a9f1
@ -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;
|
||||||
|
`);
|
@ -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;
|
||||||
|
`);
|
@ -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}!`
|
||||||
|
);
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
Loading…
Reference in new issue