mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) New date filter with a calendar view
Summary: Implements the new date filtering panel. Design results from long discussion between: Alex, Anais, Cyprien and Dmitry. Test environment: https://grist-new-date-range-filter.fly.dev/ Test Plan: Include various new tests. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3720
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import {CellValue} from "app/common/DocActions";
|
||||
import {FilterState, isRangeFilter, makeFilterState} from "app/common/FilterState";
|
||||
import {
|
||||
FilterState, IRangeBoundType, isRangeFilter, isRelativeBound, makeFilterState
|
||||
} from "app/common/FilterState";
|
||||
import {decodeObject} from "app/plugin/objtypes";
|
||||
import moment from "moment-timezone";
|
||||
import {isDateLikeType, isList, isListType, isNumberType} from "./gristTypes";
|
||||
import {toUnixTimestamp} from "app/common/RelativeDates";
|
||||
|
||||
export type ColumnFilterFunc = (value: CellValue) => boolean;
|
||||
|
||||
@@ -11,8 +15,12 @@ export function makeFilterFunc(state: FilterState,
|
||||
columnType: string = ''): ColumnFilterFunc {
|
||||
|
||||
if (isRangeFilter(state)) {
|
||||
const {min, max} = state;
|
||||
let {min, max} = state;
|
||||
if (isNumberType(columnType) || isDateLikeType(columnType)) {
|
||||
|
||||
min = getBoundsValue(state, 'min');
|
||||
max = getBoundsValue(state, 'max');
|
||||
|
||||
return (val) => {
|
||||
if (typeof val !== 'number') { return false; }
|
||||
return (
|
||||
@@ -50,3 +58,19 @@ export function buildColFilter(filterJson: string | undefined,
|
||||
columnType?: string): ColumnFilterFunc | null {
|
||||
return filterJson ? makeFilterFunc(makeFilterState(filterJson), columnType) : null;
|
||||
}
|
||||
|
||||
|
||||
function getBoundsValue(state: {min?: IRangeBoundType, max?: IRangeBoundType}, minMax: 'min'|'max') {
|
||||
const value = state[minMax];
|
||||
if (isRelativeBound(value)) {
|
||||
const val = toUnixTimestamp(value);
|
||||
const m = moment.utc(val * 1000);
|
||||
if (minMax === 'min') {
|
||||
m.startOf('day');
|
||||
} else {
|
||||
m.endOf('day');
|
||||
}
|
||||
return Math.floor(m.valueOf() / 1000);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { CellValue } from "app/common/DocActions";
|
||||
import { IRelativeDateSpec, isEquivalentRelativeDate, isRelativeBound } from "app/common/RelativeDates";
|
||||
|
||||
export type { IRelativeDateSpec } from "app/common/RelativeDates";
|
||||
export { isRelativeBound } from "app/common/RelativeDates";
|
||||
|
||||
// Filter object as stored in the db
|
||||
export interface FilterSpec {
|
||||
included?: CellValue[];
|
||||
excluded?: CellValue[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
min?: number|IRelativeDateSpec;
|
||||
max?: number|IRelativeDateSpec;
|
||||
}
|
||||
|
||||
export type IRangeBoundType = undefined|number|IRelativeDateSpec;
|
||||
|
||||
export type FilterState = ByValueFilterState | RangeFilterState
|
||||
|
||||
@@ -18,8 +23,8 @@ interface ByValueFilterState {
|
||||
}
|
||||
|
||||
interface RangeFilterState {
|
||||
min?: number;
|
||||
max?: number;
|
||||
min?: number|IRelativeDateSpec;
|
||||
max?: number|IRelativeDateSpec;
|
||||
}
|
||||
|
||||
// Creates a FilterState. Accepts spec as a json string or a FilterSpec.
|
||||
@@ -59,3 +64,13 @@ export function isRangeFilter(state: FilterState): state is RangeFilterState {
|
||||
const {min, max} = state as any;
|
||||
return min !== undefined || max !== undefined;
|
||||
}
|
||||
|
||||
export function isEquivalentBound(a: IRangeBoundType, b: IRangeBoundType) {
|
||||
if (isRelativeBound(a) && isRelativeBound(b)) {
|
||||
return isEquivalentRelativeDate(a, b);
|
||||
}
|
||||
if (isRelativeBound(a) || isRelativeBound(b)) {
|
||||
return false;
|
||||
}
|
||||
return a === b;
|
||||
}
|
||||
|
||||
161
app/common/RelativeDates.ts
Normal file
161
app/common/RelativeDates.ts
Normal file
@@ -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);
|
||||
}
|
||||
13
app/common/getCurrentTime.ts
Normal file
13
app/common/getCurrentTime.ts
Normal file
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user