From feffd53e466ea61493c0a62a2e02c33eeca024f8 Mon Sep 17 00:00:00 2001 From: Suren Date: Fri, 23 Feb 2024 19:28:34 +0530 Subject: [PATCH] #9915: Ability to select quick date ranges for date filter fields in Filter Layer tool (#9951) --- .../components/data/query/DateField.jsx | 237 ++++++++++++++---- .../components/data/query/GroupField.jsx | 6 +- .../data/query/__tests__/DateField-test.jsx | 80 +++--- .../misc/datetimepicker/DateTimePicker.js | 24 +- .../datetimepicker/QuickTimeSelectors.jsx | 106 ++++++++ .../datetimepicker/RangedDateTimePicker.js | 29 ++- .../__tests__/DateTimePicker-test.js | 11 + .../__tests__/QuickTimeSelectors-test.js | 89 +++++++ .../__tests__/RangedDateTimePicker-test.js | 11 + web/client/plugins/QueryPanel.jsx | 18 +- .../themes/default/less/featuregrid.less | 31 ++- web/client/translations/data.de-DE.json | 13 +- web/client/translations/data.en-US.json | 13 +- web/client/translations/data.es-ES.json | 13 +- web/client/translations/data.fr-FR.json | 13 +- web/client/translations/data.it-IT.json | 13 +- web/client/utils/FeatureGridUtils.js | 6 + web/client/utils/TimeUtils.js | 57 +++++ web/client/utils/__tests__/TimeUtils-test.js | 66 ++++- 19 files changed, 716 insertions(+), 120 deletions(-) create mode 100644 web/client/components/misc/datetimepicker/QuickTimeSelectors.jsx create mode 100644 web/client/components/misc/datetimepicker/__tests__/QuickTimeSelectors-test.js diff --git a/web/client/components/data/query/DateField.jsx b/web/client/components/data/query/DateField.jsx index 0dd47574d0..4777a23026 100644 --- a/web/client/components/data/query/DateField.jsx +++ b/web/client/components/data/query/DateField.jsx @@ -8,13 +8,115 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { intlShape } from 'react-intl'; +import { getContext } from 'recompose'; +import isNil from 'lodash/isNil'; +import isEmpty from 'lodash/isEmpty'; import moment from 'moment'; import momentLocalizer from 'react-widgets/lib/localizers/moment'; momentLocalizer(moment); + +import { getDateTimeFormat } from '../../../utils/TimeUtils'; +import { getMessageById } from '../../../utils/LocaleUtils'; + import utcDateWrapper from '../../misc/enhancers/utcDateWrapper'; import Message from '../../I18N/Message'; -import { getDateTimeFormat } from '../../../utils/TimeUtils'; -import { DateTimePicker } from 'react-widgets'; +import DateTimePicker from '../../misc/datetimepicker'; +import RangedDateTimePicker from '../../misc/datetimepicker/RangedDateTimePicker'; +import { DATE_TYPE } from '../../../utils/FeatureGridUtils'; + +const DEFAULT_QUICK_TIME_SELECTORS = [ + { + "type": DATE_TYPE.DATE, + "value": "{today}+P0D", + "labelId": "queryform.attributefilter.datefield.quickSelectors.today" + }, + { + "type": DATE_TYPE.DATE, + "value": "{today}-P1D", + "labelId": "queryform.attributefilter.datefield.quickSelectors.yesterday" + }, + { + "type": DATE_TYPE.DATE, + "value": "{today}+P1D", + "labelId": "queryform.attributefilter.datefield.quickSelectors.tomorrow" + }, + { + "type": DATE_TYPE.DATE_TIME, + "value": "{now}+P0D", + "labelId": "queryform.attributefilter.datefield.quickSelectors.now" + }, + { + "type": DATE_TYPE.DATE_TIME, + "value": "{now}-P1D", + "labelId": "queryform.attributefilter.datefield.quickSelectors.yesterday" + }, + { + "type": DATE_TYPE.DATE_TIME, + "value": "{now}+P1D", + "labelId": "queryform.attributefilter.datefield.quickSelectors.tomorrow" + }, + { + "type": DATE_TYPE.DATE, + "value": "{today}/{today}", + "labelId": "queryform.attributefilter.datefield.quickSelectors.today" + }, + { + "type": DATE_TYPE.DATE, + "value": "{thisWeekStart}/{thisWeekEnd}", + "labelId": "queryform.attributefilter.datefield.quickSelectors.thisWeek" + }, + { + "type": DATE_TYPE.DATE, + "value": "{thisMonthStart}/{thisMonthEnd}", + "labelId": "queryform.attributefilter.datefield.quickSelectors.thisMonth" + }, + { + "type": DATE_TYPE.DATE, + "value": "{today}/{today}+P7D", + "labelId": "queryform.attributefilter.datefield.quickSelectors.nDaysFrom" + }, + { + "type": DATE_TYPE.DATE, + "value": "{today}/{today}+P30D", + "labelId": "queryform.attributefilter.datefield.quickSelectors.nDaysFrom" + }, + { + "type": DATE_TYPE.DATE, + "value": "{today}/{today}+P90D", + "labelId": "queryform.attributefilter.datefield.quickSelectors.nDaysFrom" + }, + { + "type": DATE_TYPE.DATE_TIME, + "value": "{now}/{now}", + "labelId": "queryform.attributefilter.datefield.quickSelectors.now" + }, + { + "type": DATE_TYPE.DATE_TIME, + "value": "{thisWeekStart}/{thisWeekEnd}", + "labelId": "queryform.attributefilter.datefield.quickSelectors.thisWeek" + }, + { + "type": DATE_TYPE.DATE_TIME, + "value": "{thisMonthStart}/{thisMonthEnd}", + "labelId": "queryform.attributefilter.datefield.quickSelectors.thisMonth" + }, + { + "type": DATE_TYPE.DATE_TIME, + "value": "{now}/{now}+P7D", + "labelId": "queryform.attributefilter.datefield.quickSelectors.nDaysFrom" + }, + { + "type": DATE_TYPE.DATE_TIME, + "value": "{now}/{now}+P30D", + "labelId": "queryform.attributefilter.datefield.quickSelectors.nDaysFrom" + }, + { + "type": DATE_TYPE.DATE_TIME, + "value": "{now}/{now}+P90D", + "labelId": "queryform.attributefilter.datefield.quickSelectors.nDaysFrom" + } +]; /** * Date time picker enhanced with UTC and timezone offset @@ -27,6 +129,12 @@ const UTCDateTimePicker = utcDateWrapper({ setDateProp: "onChange" })(DateTimePicker); +const UTCDateTimePickerWithRange = utcDateWrapper({ + dateProp: "value", + dateTypeProp: "type", + setDateProp: "onChange" +})(RangedDateTimePicker); + /** * This enhanced Component is used for supporting "date", "time" or "date-time" attribute types */ @@ -42,10 +150,15 @@ class DateField extends React.Component { onUpdateField: PropTypes.func, onUpdateExceptionField: PropTypes.func, showLabels: PropTypes.bool, - timeEnabled: PropTypes.bool + timeEnabled: PropTypes.bool, + quickDateTimeSelectors: PropTypes.array, + placeholderMsgId: PropTypes.string, + tooltipMsgId: PropTypes.string, + className: PropTypes.string }; static contextTypes = { + messages: PropTypes.object, locale: PropTypes.string }; @@ -60,66 +173,90 @@ class DateField extends React.Component { fieldException: null, onUpdateField: () => {}, onUpdateExceptionField: () => {}, - showLabels: false + showLabels: false, + className: "query-date", + placeholderMsgId: "queryform.attributefilter.datefield.placeholder", + tooltipMsgId: "queryform.attributefilter.datefield.tooltip", + quickDateTimeSelectors: DEFAULT_QUICK_TIME_SELECTORS }; render() { - // these values are already parsed by the enhancer - const startdate = this.props.fieldValue && this.props.fieldValue.startDate || null; - const enddate = this.props.fieldValue && this.props.fieldValue.endDate || null; + const format = getDateTimeFormat(this.context.locale, this.props.attType); + const placeholder = getMessageById(this.context.messages, this.props.placeholderMsgId) || "Insert date"; + const toolTip = this.props.intl && this.props.intl.formatMessage({id: `${this.props.tooltipMsgId}`}, {format}) || `Insert date in ${format} format`; - // needed to initialize the time parts to 00:00:00 - - let dateRow = this.props.operator === "><" ? + return this.props.operator === "><" ? (
-
- {this.props.showLabels && } - this.updateValueState({startDate: date, endDate: enddate})}/> -
-
- {this.props.showLabels && } - this.updateValueState({startDate: startdate, endDate: date})}/> -
+
) : (
{this.props.showLabels && } { - this.updateValueState({startDate: date, endDate: null}); - }}/> + popupPosition={'bottom'} + time={this.props.attType === DATE_TYPE.DATE_TIME || this.props.attType === DATE_TYPE.TIME} + calendar={this.props.attType === DATE_TYPE.DATE_TIME || this.props.attType === DATE_TYPE.DATE} + onChange={this.handleChange} + quickDateTimeSelectors={this.getQuickTimeSelectors()} + />
); - return ( - dateRow - ); } - updateValueState = (value) => { - if (value.startDate && value.endDate && value.startDate > value.endDate) { - this.props.onUpdateExceptionField(this.props.fieldRowId, "queryform.attributefilter.datefield.wrong_date_range"); + handleChange = (value) => { + this.props.onUpdateExceptionField(this.props.fieldRowId, null); + this.props.onUpdateField(this.props.fieldRowId, this.props.fieldName, {startDate: value}, this.props.attType); + } + + handleChangeRangeFilter = (value, _, order = 'start') => { + let reqVal = {}; + if (order === 'end') { + reqVal = { + startDate: this.props.fieldValue?.startDate, + endDate: value + }; } else { - this.props.onUpdateExceptionField(this.props.fieldRowId, null); + reqVal = { + startDate: value, + endDate: this.props.fieldValue?.endDate + }; } - this.props.onUpdateField(this.props.fieldRowId, this.props.fieldName, value, this.props.attType); + this.props.onUpdateExceptionField(this.props.fieldRowId, null); + this.props.onUpdateField(this.props.fieldRowId, this.props.fieldName, reqVal, this.props.attType); + } + + getQuickTimeSelectors = (isRange) => { + return this.props.quickDateTimeSelectors + ?.filter(({type, value} = {}) => { + if (!isEmpty(value) && type === this.props.attType) { + const endDate = value.split('/')?.[1]; + return isRange ? !isNil(endDate) : isNil(endDate); + } + return false; + }); }; } -export default DateField; +export default getContext({ + intl: intlShape +})(DateField); diff --git a/web/client/components/data/query/GroupField.jsx b/web/client/components/data/query/GroupField.jsx index 11c1ef9f8f..26384fc1bc 100644 --- a/web/client/components/data/query/GroupField.jsx +++ b/web/client/components/data/query/GroupField.jsx @@ -173,12 +173,14 @@ class GroupField extends React.Component { + operator={filterField.operator} + quickDateTimeSelectors={this.props.quickDateTimeSelectors}/> + operator={filterField.operator} + quickDateTimeSelectors={this.props.quickDateTimeSelectors}/> diff --git a/web/client/components/data/query/__tests__/DateField-test.jsx b/web/client/components/data/query/__tests__/DateField-test.jsx index 158338c4d8..40c93e0195 100644 --- a/web/client/components/data/query/__tests__/DateField-test.jsx +++ b/web/client/components/data/query/__tests__/DateField-test.jsx @@ -31,7 +31,7 @@ describe('DateField', () => { let fieldRowId = 200; let fieldValue = {startDate: new Date(86400000), endDate: null}; - const datefield = ReactDOM.render( + ReactDOM.render( { fieldValue={fieldValue}/>, document.getElementById("container") ); - expect(datefield).toBeTruthy(); - const dateFieldDOMNode = ReactDOM.findDOMNode(datefield); - expect(dateFieldDOMNode).toBeTruthy(); - let childNodes = dateFieldDOMNode.getElementsByTagName('DIV'); - expect(childNodes.length).toBe(1); - let dateRow = childNodes[0]; - expect(dateRow).toBeTruthy(); - expect(dateRow.childNodes.length).toBe(2); + const container = document.getElementById('container'); + expect(container).toBeTruthy(); + const datePicker = container.querySelector('.rw-datetimepicker'); + expect(datePicker).toBeTruthy(); + const button = container.querySelector('.rw-btn-calendar'); + expect(button).toBeTruthy(); + ReactTestUtils.Simulate.click(button); + const quickTimeSelector = document.querySelectorAll('.quick-time-selector'); + expect(quickTimeSelector).toBeTruthy(); + const selectorBtns = document.querySelectorAll('.selector-btn'); + expect(selectorBtns).toBeTruthy(); + expect(selectorBtns.length).toBe(3); }); it('creates the DateField component with date range', () => { @@ -55,7 +59,7 @@ describe('DateField', () => { let fieldRowId = 200; let fieldValue = {startDate: new Date(86400000), endDate: new Date(96400000)}; - const datefield = ReactDOM.render( + ReactDOM.render( { fieldValue={fieldValue}/>, document.getElementById("container") ); - expect(datefield).toBeTruthy(); - const dateFieldDOMNode = ReactDOM.findDOMNode(datefield); - expect(dateFieldDOMNode).toBeTruthy(); - let childNodes = dateFieldDOMNode.getElementsByTagName('DIV'); - expect(childNodes.length).toBe(4); - expect(dateFieldDOMNode.childNodes.length).toBe(2); + const container = document.getElementById('container'); + const el = container.querySelector('.rw-datetimepicker.range-time-input.rw-widget'); + const clockIcon = container.querySelector('.rw-i.rw-i-calendar'); + expect(el).toBeTruthy(); + expect(clockIcon).toBeTruthy(); }); it('creates the DateField with date-time type', () => { @@ -77,7 +80,7 @@ describe('DateField', () => { let fieldRowId = 200; let fieldValue = {startDate: new Date(86400000), endDate: null}; - const datefield = ReactDOM.render( + ReactDOM.render( { fieldValue={fieldValue}/>, document.getElementById("container") ); - expect(datefield).toBeTruthy(); - const dateFieldDOMNode = ReactDOM.findDOMNode(datefield); - expect(dateFieldDOMNode).toBeTruthy(); - let childNodes = dateFieldDOMNode.getElementsByTagName('DIV'); - expect(childNodes.length).toBe(1); - let dateRow = childNodes[0]; - expect(dateRow).toBeTruthy(); - expect(dateRow.childNodes.length).toBe(2); - const buttons = ReactTestUtils.scryRenderedDOMComponentsWithTag(datefield, "button"); - expect(buttons.length).toBe(2); - expect(buttons[0].title).toBe("Select Date"); - expect(buttons[1].title).toBe("Select Time"); + const container = document.getElementById('container'); + expect(container).toBeTruthy(); + const el = container.querySelector('.rw-datetimepicker.range-time-input.rw-widget'); + const dateTimeIcon = container.querySelector('.glyphicon-date-time'); + expect(el).toBeTruthy(); + expect(dateTimeIcon).toBeTruthy(); + const button = container.querySelector('.rw-btn-calendar'); + expect(button).toBeTruthy(); + ReactTestUtils.Simulate.click(button); + const quickTimeSelector = document.querySelectorAll('.quick-time-selector'); + expect(quickTimeSelector).toBeTruthy(); + const selectorBtns = document.querySelectorAll('.selector-btn'); + expect(selectorBtns).toBeTruthy(); + expect(selectorBtns.length).toBe(3); }); it('creates the DateField with time type', () => { @@ -108,7 +113,7 @@ describe('DateField', () => { let fieldRowId = 200; let fieldValue = {startDate: new Date(86400000), endDate: null}; - const datefield = ReactDOM.render( + ReactDOM.render( { fieldValue={fieldValue}/>, document.getElementById("container") ); - expect(datefield).toBeTruthy(); - const dateFieldDOMNode = ReactDOM.findDOMNode(datefield); - expect(dateFieldDOMNode).toBeTruthy(); - let childNodes = dateFieldDOMNode.getElementsByTagName('DIV'); - expect(childNodes.length).toBe(1); - let dateRow = childNodes[0]; - expect(dateRow).toBeTruthy(); - expect(dateRow.childNodes.length).toBe(2); - const buttons = ReactTestUtils.scryRenderedDOMComponentsWithTag(datefield, "button"); - expect(buttons.length).toBe(1); - expect(buttons[0].title).toBe("Select Time"); + const container = document.getElementById('container'); + expect(container).toBeTruthy(); + const button = container.querySelector('.rw-btn-time'); + expect(button).toBeTruthy(); }); }); diff --git a/web/client/components/misc/datetimepicker/DateTimePicker.js b/web/client/components/misc/datetimepicker/DateTimePicker.js index 0e28e6d76a..3ed50e0f43 100644 --- a/web/client/components/misc/datetimepicker/DateTimePicker.js +++ b/web/client/components/misc/datetimepicker/DateTimePicker.js @@ -18,6 +18,7 @@ import OverlayTrigger from '../OverlayTrigger'; import Hours from './Hours'; import Popover from '../../styleeditor/Popover'; import {getMessageById} from '../../../utils/LocaleUtils'; +import QuickTimeSelectors from './QuickTimeSelectors'; localizer(moment); @@ -56,6 +57,7 @@ const formats = { class DateTimePicker extends Component { static propTypes = { + className: PropTypes.string, format: PropTypes.string, type: PropTypes.string, placeholder: PropTypes.string, @@ -71,6 +73,7 @@ class DateTimePicker extends Component { options: PropTypes.object, isWithinAttrTbl: PropTypes.bool, disabled: PropTypes.bool, + quickDateTimeSelectors: PropTypes.array, onPopoverOpen: PropTypes.func } static contextTypes = { @@ -114,6 +117,19 @@ class DateTimePicker extends Component { const { date: dateFormat, time: timeFormat, base: defaultFormat } = formats; return format ? format : !time && calendar ? dateFormat : time && !calendar ? timeFormat : defaultFormat; } + + renderQuickTimeSelectors = () => { + return ( + this.handleTimeSelect({date}, type)} + /> + ); + } + renderCustomDateTimePopup = () => { const { inputValue, operator, open } = this.state; const { tabIndex, type } = this.props; @@ -148,6 +164,7 @@ class DateTimePicker extends Component { this.handleTimeSelect(time, type)} /> + {this.renderQuickTimeSelectors()} @@ -168,7 +185,7 @@ class DateTimePicker extends Component { render() { const { open, inputValue, operator, focused, openDateTime } = this.state; - const { calendar, time, toolTip, placeholder, tabIndex, type, popupPosition } = this.props; + const { calendar, time, toolTip, placeholder, tabIndex, type, popupPosition, className } = this.props; const props = Object.keys(this.props).reduce((acc, key) => { if (['placeholder', 'calendar', 'time', 'onChange', 'value'].includes(key)) { // remove these props because they might have undesired effects to the subsequent components @@ -195,7 +212,7 @@ class DateTimePicker extends Component { onOpen={this.props.onPopoverOpen} triggerScrollableElement={document.querySelector('.feature-grid-container .react-grid-Container .react-grid-Canvas')} content={ -
+
{this.renderCustomDateTimePopup()}
} @@ -247,7 +264,7 @@ class DateTimePicker extends Component { onOpen={this.props.onPopoverOpen} triggerScrollableElement={document.querySelector('.feature-grid-container .react-grid-Container .react-grid-Canvas')} // table element to trigger its scroll content={ -
+
+ {this.renderQuickTimeSelectors()}
} > diff --git a/web/client/components/misc/datetimepicker/QuickTimeSelectors.jsx b/web/client/components/misc/datetimepicker/QuickTimeSelectors.jsx new file mode 100644 index 0000000000..979ed76b7c --- /dev/null +++ b/web/client/components/misc/datetimepicker/QuickTimeSelectors.jsx @@ -0,0 +1,106 @@ +/* + * Copyright 2024, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from "react"; +import PropTypes from 'prop-types'; +import isEmpty from "lodash/isEmpty"; + +import Button from "../Button"; +import { + getDateFromTemplate, + parseDateTimeTemplate +} from "../../../utils/TimeUtils"; +import Message from "../../I18N/Message"; +import moment from "moment"; +import { DATE_TYPE } from "../../../utils/FeatureGridUtils"; +import { getMessageById } from '../../../utils/LocaleUtils'; + +/** + * QuickTimeSelector component + */ +const QuickTimeSelectors = ({ + quickDateTimeSelectors = [], + type, + onChangeDate = () => {}, + onChangeTime = () => {}, + onMouseDown = () => {} +}, { messages }) => { + if (isEmpty(quickDateTimeSelectors)) { + return null; + } + + const onClickSelector = (val) => { + const values = val?.split("/"); + if (!isEmpty(values)) { + let startDate; + values.forEach((value, index) => { + const rangeType = index === 0 ? "start" : "end"; + let date = getDateFromTemplate(value, rangeType); + if (index === 0) { + startDate = date; + } else { + // reset date to current date when end date is invalid + date = date > startDate ? date : new Date(); + } + const _type = values.length === 2 ? rangeType : type; + setTimeout(() => onChangeDate(date, _type)); + if (type === DATE_TYPE.DATE_TIME) { + setTimeout(() => onChangeTime(date, _type)); + } + }); + } + }; + + const getLocalizedMsgParam = (value) => { + const [start, endDate] = value?.split("/") ?? []; + const { placeholderKey, durationExp } = + parseDateTimeTemplate(endDate ? endDate : start); + return { + n: String(moment + .duration(durationExp) + .asDays()), + todayNow: getMessageById( + messages, + `queryform.attributefilter.datefield.quickSelectors.${placeholderKey}` + )?.toLowerCase() + }; + }; + + return ( +
+ {quickDateTimeSelectors.map((selector) => { + const labelId = selector.labelId; + return ( + + ); + })} +
+ ); +}; + +QuickTimeSelectors.contextTypes = { + messages: PropTypes.object +}; + +export default QuickTimeSelectors; diff --git a/web/client/components/misc/datetimepicker/RangedDateTimePicker.js b/web/client/components/misc/datetimepicker/RangedDateTimePicker.js index a8749365fb..c2bae1da7c 100644 --- a/web/client/components/misc/datetimepicker/RangedDateTimePicker.js +++ b/web/client/components/misc/datetimepicker/RangedDateTimePicker.js @@ -19,6 +19,7 @@ import Hours from './Hours'; import Popover from '../../styleeditor/Popover'; import { getMessageById } from '../../../utils/LocaleUtils'; import Message from '../../I18N/Message'; +import QuickTimeSelectors from './QuickTimeSelectors'; localizer(moment); @@ -57,6 +58,7 @@ const formats = { class DateTimePickerWithRange extends Component { static propTypes = { + className: PropTypes.string, format: PropTypes.string, type: PropTypes.string, placeholder: PropTypes.string, @@ -70,7 +72,8 @@ class DateTimePickerWithRange extends Component { toolTip: PropTypes.string, tabIndex: PropTypes.string, options: PropTypes.object, - disabled: PropTypes.disabled + disabled: PropTypes.bool, + quickDateTimeSelectors: PropTypes.array } static defaultProps = { @@ -122,6 +125,18 @@ class DateTimePickerWithRange extends Component { return format ? format : !time && calendar ? dateFormat : time && !calendar ? timeFormat : defaultFormat; } + renderQuickTimeSelectors = () => { + return ( + this.handleTimeSelect({date}, type)} + /> + ); + } + renderInput = (inputValue, operator, toolTip, placeholder, tabIndex, calendarVisible, timeVisible, style = {}, className) => { let inputV = this.props.isWithinAttrTbl ? `${inputValue}` : `${operator}${inputValue}`; const inputEl = ; @@ -255,11 +270,11 @@ class DateTimePickerWithRange extends Component { } renderCalendar = () =>{ const { inputValue, operator, focused } = this.state; - const { toolTip, placeholder, tabIndex, popupPosition, type } = this.props; + const { toolTip, placeholder, tabIndex, popupPosition, type, className } = this.props; let shownVal = (inputValue.endDate || inputValue.startDate) ? Object.values(inputValue).join(" : ") : ''; return ( -
{this.calendarRef = elem;}} onBlur={()=> this.handleWidgetBlur(type)} onFocus={this.handleWidgetFocus} className={`rw-datetimepicker range-time-input rw-widget rw-has-neither ${focused ? 'rw-state-focus' : ''}`}> +
{this.calendarRef = elem;}} onBlur={()=> this.handleWidgetBlur(type)} onFocus={this.handleWidgetFocus} className={`rw-datetimepicker range-time-input rw-widget ${focused ? 'rw-state-focus' : ''}`}> {this.renderInput(shownVal, operator, toolTip, placeholder, tabIndex, true, false)} +
{ this.renderCalendarRange() } + {this.renderQuickTimeSelectors()}
} > @@ -355,7 +371,7 @@ class DateTimePickerWithRange extends Component { } renderCalendarTimeDate = () =>{ const { inputValue, operator, focused } = this.state; - const { toolTip, placeholder, tabIndex, popupPosition, type } = this.props; + const { toolTip, placeholder, tabIndex, popupPosition, type, className } = this.props; let shownVal = (inputValue.endDate || inputValue.startDate) ? Object.values(inputValue).join(" : ") : ''; return ( @@ -367,8 +383,9 @@ class DateTimePickerWithRange extends Component { placement={popupPosition} triggerScrollableElement={document.querySelector('.feature-grid-container .react-grid-Container .react-grid-Canvas')} content={ -
+
{this.renderDateTimeRange()} + {this.renderQuickTimeSelectors()}
} > diff --git a/web/client/components/misc/datetimepicker/__tests__/DateTimePicker-test.js b/web/client/components/misc/datetimepicker/__tests__/DateTimePicker-test.js index 9ffb5abc25..da82f0ef2d 100644 --- a/web/client/components/misc/datetimepicker/__tests__/DateTimePicker-test.js +++ b/web/client/components/misc/datetimepicker/__tests__/DateTimePicker-test.js @@ -25,6 +25,17 @@ describe('DateTimePicker component', () => { expect(el).toExist(); }); + it('DateTimePicker rendering with quick time selectors', () => { + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + const el = container.querySelector('.rw-datetimepicker'); + const button = container.querySelector('.rw-btn-calendar'); + TestUtils.Simulate.click(button); + const quickTimeSelector = document.querySelector('.quick-time-selector'); + expect(el).toBeTruthy(); + expect(quickTimeSelector).toBeTruthy(); + }); it('DateTimePicker with value prop of date type', function() { const today = new Date(); diff --git a/web/client/components/misc/datetimepicker/__tests__/QuickTimeSelectors-test.js b/web/client/components/misc/datetimepicker/__tests__/QuickTimeSelectors-test.js new file mode 100644 index 0000000000..8677b03837 --- /dev/null +++ b/web/client/components/misc/datetimepicker/__tests__/QuickTimeSelectors-test.js @@ -0,0 +1,89 @@ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import expect from 'expect'; +import TestUtils from 'react-dom/test-utils'; +import QuickTimeSelectors from '../QuickTimeSelectors'; +import { DATE_TYPE } from '../../../../utils/FeatureGridUtils'; + +const quickTimeSelectors = [ + { + "type": DATE_TYPE.DATE, + "value": "{today}+P0D", + "labelId": "queryform.attributefilter.datefield.quickSelectors.today" + }, + { + "type": DATE_TYPE.DATE, + "value": "{today}-P1D", + "labelId": "queryform.attributefilter.datefield.quickSelectors.yesterday" + } +]; + +describe('QuickTimeSelectors component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + it('QuickTimeSelectors rendering with defaults', () => { + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + const quickTimeSelector = container.querySelector('.quick-time-selector'); + expect(quickTimeSelector).toBeFalsy(); + }); + it('QuickTimeSelectors rendering with quickDateTimeSelectors', () => { + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + const quickTimeSelector = container.querySelector('.quick-time-selector'); + const buttons = container.querySelectorAll('.quick-time-selector .selector-btn'); + expect(quickTimeSelector).toBeTruthy(); + expect(buttons.length).toBe(2); + }); + it('QuickTimeSelectors onChange date', (done) => { + const action = { + onChangeDate: (date) => { + expect(date).toBeTruthy(); + done(); + } + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + const quickTimeSelector = container.querySelector('.quick-time-selector'); + const buttons = container.querySelectorAll('.quick-time-selector .selector-btn'); + expect(quickTimeSelector).toBeTruthy(); + expect(buttons.length).toBe(2); + TestUtils.Simulate.click(buttons[0]); + + }); + it('QuickTimeSelectors onChange date-time', (done) => { + const action = { + onChangeDate: (date) => { + expect(date).toBeTruthy(); + done(); + }, + onChangeTime: (time) => { + expect(time.date).toBeTruthy(); + done(); + } + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + const quickTimeSelector = container.querySelector('.quick-time-selector'); + const buttons = container.querySelectorAll('.quick-time-selector .selector-btn'); + expect(quickTimeSelector).toBeTruthy(); + expect(buttons.length).toBe(2); + TestUtils.Simulate.click(buttons[0]); + }); +}); diff --git a/web/client/components/misc/datetimepicker/__tests__/RangedDateTimePicker-test.js b/web/client/components/misc/datetimepicker/__tests__/RangedDateTimePicker-test.js index c40dc06f17..8ce778dfc2 100644 --- a/web/client/components/misc/datetimepicker/__tests__/RangedDateTimePicker-test.js +++ b/web/client/components/misc/datetimepicker/__tests__/RangedDateTimePicker-test.js @@ -33,6 +33,17 @@ describe('DateTimePickerWithRange component', () => { expect(el).toExist(); expect(clockIcon).toExist(); }); + it('DateTimePickerWithRange with quick time selector', () => { + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + const el = container.querySelector('.rw-datetimepicker.range-time-input.rw-widget'); + const button = container.querySelector('.rw-btn-calendar'); + TestUtils.Simulate.click(button); + const quickTimeSelector = document.querySelector('.quick-time-selector'); + expect(el).toBeTruthy(); + expect(quickTimeSelector).toBeTruthy(); + }); it('DateTimePickerWithRange with type date-time rendering with defaults', () => { ReactDOM.render(, document.getElementById("container")); const container = document.getElementById('container'); diff --git a/web/client/plugins/QueryPanel.jsx b/web/client/plugins/QueryPanel.jsx index 0eb6c7a52e..69e5ad3c30 100644 --- a/web/client/plugins/QueryPanel.jsx +++ b/web/client/plugins/QueryPanel.jsx @@ -403,11 +403,6 @@ class QueryPanel extends React.Component { * Targets available for injection: "start", "attributes", "afterAttributes", "spatial", "afterSpatial", "layers", "end", "map" * @prop {object[]} cfg.spatialOperations: The list of geometric operations use to create the spatial filter.
- * @prop {boolean} cfg.toolsOptions.hideCrossLayer force cross layer filter panel to hide (when is not used or not usable) - * @prop {boolean} cfg.toolsOptions.hideAttributeFilter force attribute filter panel to hide (when is not used or not usable). In general any `hide${CapitailizedItemId}` works to hide a particular panel of the query panel. - * @prop {boolean} cfg.toolsOptions.hideSpatialFilter force spatial filter panel to hide (when is not used or not usable) - * @prop {boolean} cfg.toolsOptions.useEmbeddedMap if spatial filter panel is present, this option allows to use the embedded map instead of the map plugin - * * @example * // This example configure a layer with polygons geometry as spatial filter method * "spatialOperations": [ @@ -443,6 +438,19 @@ class QueryPanel extends React.Component { * }, * "customItemClassName": "customItemClassName" * } + * @prop {boolean} cfg.toolsOptions.hideCrossLayer force cross layer filter panel to hide (when is not used or not usable) + * @prop {boolean} cfg.toolsOptions.hideAttributeFilter force attribute filter panel to hide (when is not used or not usable). In general any `hide${CapitailizedItemId}` works to hide a particular panel of the query panel. + * @prop {boolean} cfg.toolsOptions.hideSpatialFilter force spatial filter panel to hide (when is not used or not usable) + * @prop {boolean} cfg.toolsOptions.useEmbeddedMap if spatial filter panel is present, this option allows to use the embedded map instead of the map plugin + * @prop {boolean} cfg.toolsOptions.quickDateTimeSelectors selectors allow quick selection of configured date/date-time in both single and range Date/DateTime picker. + * The quick time selectors basically uses the template string `{predefinedPlaceholds}[+/-][durationExpression]` to define selectors. Range is denoted as `{startDate}/{endDate}` using the same template string format + * - predefinedPlaceholds: `today`, `now`, `thisWeekStart`, `thisWeekEnd`, `thisMonthStart`, `thisMonthEnd`, `thisYearStart`, `thisYearEnd` + * - durationExpression: duration expression uses ISO 8601 (https://en.wikipedia.org/wiki/ISO_8601#Durations) format `P[n]Y[n]M[n]DT[n]H[n]M[n]S` or `P[n]W` + * + * *Note*: `now` - uses current date time (for range, start time - 'current time' and end time - '23:59') where `today` uses 00:00 time of the current day (for range, start time - '00:00' and end time - '23:59') + * @example + * single - `{now}`, `{today}`, `{today}+P1D` - Tomorrow, `{now}-P10M` - 10 months from now + * range - `{now}/{now}+P1D` - start date is current date time and end date is tomorrow * * @example * // customize the QueryPanels UI via plugin(s) diff --git a/web/client/themes/default/less/featuregrid.less b/web/client/themes/default/less/featuregrid.less index e483441a9c..cf3f7e7ef4 100644 --- a/web/client/themes/default/less/featuregrid.less +++ b/web/client/themes/default/less/featuregrid.less @@ -33,8 +33,14 @@ .background-color-var(@theme-vars[main-variant-bg]); } } + .quick-time-selector { + .border-top-color-var(@theme-vars[main-border-color]) + } } } + .glyphicon-date-time { + .color-var(@theme-vars[primary]) + } } // ************** @@ -138,12 +144,7 @@ left:auto; } } - span.glyphicon-date-time{ - color: var(--ms-primary, #078aa3); - } - } - - + } } /* minimize calendar row height in feature-grid that renders within popover */ .ms-popover-overlay{ @@ -167,8 +168,16 @@ position: relative; width: 300px; height: 290px; + &.query-date { + height: 100%; + max-height: 500px; + } &.range { - height: 328px; + height: 328px; + &.query-date { + height: 100%; + max-height: 500px; + } } &.date-time, &.time { height: 100%; @@ -197,6 +206,14 @@ } } } + .quick-time-selector { + display: flex; + gap: 4px; + padding: 8px 4px; + margin-top: 4px; + flex-wrap: wrap; + border-top: 1px solid; + } overflow: auto; .rw-widget { border: none !important; diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index 26110d32ff..4f450dbfb7 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -1169,7 +1169,18 @@ "wrong_range": "Die untere Grenze muss kleiner als die obere Grenze sein" }, "datefield": { - "wrong_date_range": "Das Datum des Beginns muss früher als das des Endes sein" + "wrong_date_range": "Das Datum des Beginns muss früher als das des Endes sein", + "placeholder": "Datum einfügen", + "tooltip": "Geben Sie das Datum im Format {format} ein", + "quickSelectors": { + "today": "Heute", + "now": "Jetzt", + "tomorrow": "Morgen", + "yesterday": "Gestern", + "thisWeek": "Diese Woche", + "thisMonth": "Diesen Monat", + "nDaysFrom": "{n} Tage ab {todayNow}" + } }, "autocomplete": { "emptyList": "Keine Ergebnisse", diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index 738be2af7f..f0ac0953e1 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -1130,7 +1130,18 @@ "wrong_range": "Lower boundary must be lower than the upper boundary" }, "datefield": { - "wrong_date_range": "Start date must be earlier than end date" + "wrong_date_range": "Start date must be earlier than end date", + "placeholder": "Insert date", + "tooltip": "Insert date in {format} format", + "quickSelectors": { + "today": "Today", + "now": "Now", + "tomorrow": "Tomorrow", + "yesterday": "Yesterday", + "thisWeek": "This week", + "thisMonth": "This month", + "nDaysFrom": "{n} days from {todayNow}" + } }, "autocomplete": { "emptyList": "No results", diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index ba21bd5867..dfb69b4ec1 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -1130,7 +1130,18 @@ "wrong_range": "El límite inferior ha de ser más pequeño que el superior" }, "datefield": { - "wrong_date_range": "La fecha de comienzo ha de ser anterior a la de fin" + "wrong_date_range": "La fecha de comienzo ha de ser anterior a la de fin", + "placeholder": "Insertar la fecha", + "tooltip": "Insertar fecha en formato {format}", + "quickSelectors": { + "today": "Hoy", + "now": "Ahora", + "tomorrow": "Mañana", + "yesterday": "Ayer", + "thisWeek": "Esta semana", + "thisMonth": "Este mes", + "nDaysFrom": "{n} días desde {todayNow}" + } }, "autocomplete": { "emptyList": "Ningún resultado", diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index 6174b80d91..4838621068 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -1130,7 +1130,18 @@ "wrong_range": "La limite inférieure doit être plus petite que la limite supérieure" }, "datefield": { - "wrong_date_range": "La date de début doit être antérieure à la date de fin" + "wrong_date_range": "La date de début doit être antérieure à la date de fin", + "placeholder": "Insérer la date", + "tooltip": "Insérer la date au format {format}", + "quickSelectors": { + "today": "Aujourd'hui", + "now": "Maintenant", + "tomorrow": "Demain", + "yesterday": "Hier", + "thisWeek": "Cette semaine", + "thisMonth": "Ce mois-ci", + "nDaysFrom": "{n} jours à partir de {todayNow}" + } }, "autocomplete": { "emptyList": "Aucun résultat", diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index 1fd2b7cc54..8307162564 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -1130,7 +1130,18 @@ "wrong_range": "Il limite inferiore deve essere minore del limite superiore" }, "datefield": { - "wrong_date_range": "La data di inizio deve essere inferiore a quella di fine" + "wrong_date_range": "La data di inizio deve essere inferiore a quella di fine", + "placeholder": "Inserisci la data", + "tooltip": "Inserisci la data nel formato {format}", + "quickSelectors": { + "today": "Oggi", + "now": "Ora", + "tomorrow": "Domani", + "yesterday": "Ieri", + "thisWeek": "Questa settimana", + "thisMonth": "Questo mese", + "nDaysFrom": "{n} giorni da {todayNow}" + } }, "autocomplete": { "emptyList": "Nessun risultato", diff --git a/web/client/utils/FeatureGridUtils.js b/web/client/utils/FeatureGridUtils.js index 2443710282..b5406acd93 100644 --- a/web/client/utils/FeatureGridUtils.js +++ b/web/client/utils/FeatureGridUtils.js @@ -365,6 +365,12 @@ export const getAttributesNames = (attributes) => { return attributes?.map(attribute => isPlainObject(attribute) ? attribute.name : attribute); }; +export const DATE_TYPE = { + DATE_TIME: "date-time", + TIME: "time", + DATE: "date" +}; + export const dateFormats = { 'date-time': 'YYYY-MM-DDTHH:mm:ss[Z]', 'time': 'HH:mm:ss[Z]', diff --git a/web/client/utils/TimeUtils.js b/web/client/utils/TimeUtils.js index e172e4c180..3a8ebb1a16 100644 --- a/web/client/utils/TimeUtils.js +++ b/web/client/utils/TimeUtils.js @@ -391,3 +391,60 @@ export const getLocalTimePart = (date) => { seconds = seconds < 10 ? "0" + seconds : seconds; return `${hours}:${minutes}:${seconds}`; }; + +/** + * Parse the date time template string to get parts + * Ex: `{now}+P1D` will result in `now`, `+` and `P1D` + * @param {string} value template string + * @returns parsed parts of the string + */ +export const parseDateTimeTemplate = (value) => { + const REGEX_DATE_TIME_TEMPLATE = /\{([^}]+)\}([+-])?(.*)/g; + const [, placeholderKey, sign, durationExp] = REGEX_DATE_TIME_TEMPLATE.exec(value) ?? []; + return { placeholderKey, sign, durationExp }; +}; + +/** + * Get parsed date from date time template string + * Ex: `{now}+P1D`, `{now}-P1Y9M8DT2H25M30S` + * @param {string} value template string + * @param {string} rangeType one of 'start' & 'end' + * @returns {Date} parsed date + */ +export const getDateFromTemplate = (value, rangeType = "start") => { + let date; + const { placeholderKey, sign, durationExp } = parseDateTimeTemplate(value); + const isStartDate = rangeType === "start"; + + switch (placeholderKey) { + case "today": + date = moment()[isStartDate ? "startOf" : "endOf"]('day'); + break; + case "thisWeekStart": + date = moment().startOf('isoWeek'); + break; + case "thisWeekEnd": + date = moment().endOf('isoWeek'); + break; + case "thisMonthStart": + date = moment().startOf('month'); + break; + case "thisMonthEnd": + date = moment().endOf('month'); + break; + case "thisYearStart": + date = moment().startOf('year'); + break; + case "thisYearEnd": + date = moment().endOf('year'); + break; + default: + date = isStartDate ? moment() : moment().endOf('day'); + break; + } + if (sign && durationExp) { + date = date[sign === "+" ? 'add' : 'subtract'](moment.duration(durationExp).asSeconds(), "seconds"); + } + date = date.toDate(); + return date; +}; diff --git a/web/client/utils/__tests__/TimeUtils-test.js b/web/client/utils/__tests__/TimeUtils-test.js index 157e7a84da..8c1d09d27d 100644 --- a/web/client/utils/__tests__/TimeUtils-test.js +++ b/web/client/utils/__tests__/TimeUtils-test.js @@ -19,10 +19,13 @@ import { getLowestAndHighestDates, getStartEndDomainValues, roundRangeResolution, - getLocalTimePart + getLocalTimePart, + parseDateTimeTemplate, + getDateFromTemplate } from '../TimeUtils'; import { describeDomains } from '../../api/MultiDim'; +import moment from 'moment'; const DATES_INTERVAL_ARRAY = ['2021-11-02T23:00:00.000Z/2021-12-29T23:00:00.000Z', '2021-11-08T23:00:00.000Z/2021-12-21T23:00:00.000Z']; const DATES_ARRAY = ['2021-10-01T22:00:00.000Z', '2021-10-21T22:00:00.000Z', '2021-10-29T22:00:00.000Z', '2021-11-21T23:00:00.000Z', '2021-11-21T23:00:00.000Z', '2021-11-29T23:00:00.000Z', '2021-11-29T23:00:00.000Z', '2021-12-21T23:00:00.000Z', '2021-12-29T23:00:00.000Z', '2021-12-29T23:00:00.000Z']; @@ -171,4 +174,65 @@ describe('TimeUtils', () => { expect(getLocalTimePart(new Date("2018-01-09T01:00:00"))).toBe("01:00:00"); expect(getLocalTimePart(new Date("2018-01-09T12:00:00"))).toBe("12:00:00"); }); + it('test parseDateTimeTemplate', () => { + let parsedDateTime = parseDateTimeTemplate("{now}+P1Y1M5D"); + expect(parsedDateTime.placeholderKey).toBe('now'); + expect(parsedDateTime.sign).toBe('+'); + expect(parsedDateTime.durationExp).toBe('P1Y1M5D'); + parsedDateTime = parseDateTimeTemplate("{thisWeekStart}"); + expect(parsedDateTime.placeholderKey).toBe('thisWeekStart'); + expect(parsedDateTime.sign).toBeFalsy(); + expect(parsedDateTime.durationExp).toBeFalsy(); + }); + it('test getDateFromTemplate', () => { + const getDate = (_moment, sign, duration) => { + let date = _moment; + if (sign && duration) { + date = _moment[sign](moment.duration(duration).asSeconds(), "seconds"); + } + return date; + }; + const isSame = (dates) => { + const [date1, date2] = dates.map(date => date.format('YYYY-MM-DD HH:mm:ss')); + return expect(date1).toEqual(date2); + }; + + let duration = "P1Y1M5D"; + const value = `{now}+${duration}`; + let dateFromTemplate = getDateFromTemplate(value); + expect(dateFromTemplate).toBeTruthy(); + isSame([moment(dateFromTemplate), getDate(moment(), 'add', duration)]); + dateFromTemplate = getDateFromTemplate("{thisWeekStart}"); + expect(dateFromTemplate).toBeTruthy(); + isSame([moment(dateFromTemplate), getDate(moment().startOf('isoWeek'))]); + + dateFromTemplate = getDateFromTemplate("{thisWeekEnd}"); + expect(dateFromTemplate).toBeTruthy(); + isSame([moment(dateFromTemplate), getDate(moment().endOf('isoWeek'))]); + + dateFromTemplate = getDateFromTemplate("{thisMonthStart}"); + expect(dateFromTemplate).toBeTruthy(); + isSame([moment(dateFromTemplate), getDate(moment().startOf('month'))]); + + dateFromTemplate = getDateFromTemplate("{thisMonthEnd}"); + expect(dateFromTemplate).toBeTruthy(); + isSame([moment(dateFromTemplate), getDate(moment().endOf('month'))]); + + dateFromTemplate = getDateFromTemplate("{thisYearStart}"); + expect(dateFromTemplate).toBeTruthy(); + isSame([moment(dateFromTemplate), getDate(moment().startOf('year'))]); + + dateFromTemplate = getDateFromTemplate("{thisYearEnd}"); + expect(dateFromTemplate).toBeTruthy(); + isSame([moment(dateFromTemplate), getDate(moment().endOf('year'))]); + + duration = "{today}"; + dateFromTemplate = getDateFromTemplate(duration); + expect(dateFromTemplate).toBeTruthy(); + isSame([moment(dateFromTemplate), getDate(moment().startOf('day'))]); + + dateFromTemplate = getDateFromTemplate("{today}", "end"); + expect(dateFromTemplate).toBeTruthy(); + isSame([moment(dateFromTemplate), getDate(moment().endOf('day'))]); + }); });