From b592cc7e73a81f1ea97ad257d4472b7f48caf447 Mon Sep 17 00:00:00 2001 From: Yongjie Zhao Date: Fri, 18 Dec 2020 10:27:21 +0800 Subject: [PATCH] feat(explore): time picker enhancement (#11418) --- requirements/base.txt | 12 +- setup.cfg | 2 +- setup.py | 2 + .../integration/explore/control.test.ts | 20 +- .../src/common/components/index.tsx | 3 + .../DateFilterControl/DateFilterControl.tsx | 879 ++++++++++++++++++ .../controls/DateFilterControl/constants.ts | 80 ++ .../controls/DateFilterControl/types.ts | 86 ++ .../src/explore/components/controls/index.js | 2 +- .../src/explore/dateFilterUtils.ts | 2 +- superset/utils/core.py | 366 ++++++-- superset/views/api.py | 26 + tests/charts/api_tests.py | 12 + tests/utils_tests.py | 120 +++ 14 files changed, 1521 insertions(+), 91 deletions(-) create mode 100644 superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterControl.tsx create mode 100644 superset-frontend/src/explore/components/controls/DateFilterControl/constants.ts create mode 100644 superset-frontend/src/explore/components/controls/DateFilterControl/types.ts diff --git a/requirements/base.txt b/requirements/base.txt index 73fc8fac3e..64ae5d1f25 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -24,6 +24,7 @@ chardet==3.0.4 # via aiohttp click==7.1.2 # via apache-superset, flask, flask-appbuilder colorama==0.4.4 # via apache-superset, flask-appbuilder contextlib2==0.6.0.post1 # via apache-superset +convertdate==2.3.0 # via holidays cron-descriptor==1.2.24 # via apache-superset croniter==0.3.36 # via apache-superset cryptography==3.2.1 # via apache-superset @@ -46,6 +47,7 @@ flask==1.1.2 # via apache-superset, flask-appbuilder, flask-babel, geographiclib==1.50 # via geopy geopy==2.0.0 # via apache-superset gunicorn==20.0.4 # via apache-superset +holidays==0.10.3 # via apache-superset humanize==3.1.0 # via apache-superset idna==2.10 # via email-validator, yarl importlib-metadata==2.1.1 # via -r requirements/base.in, jsonschema, kombu, markdown @@ -54,6 +56,7 @@ itsdangerous==1.1.0 # via flask, flask-wtf jinja2==2.11.2 # via flask, flask-babel jsonschema==3.2.0 # via flask-appbuilder kombu==4.6.11 # via celery +korean-lunar-calendar==0.2.1 # via holidays mako==1.1.3 # via alembic markdown==3.3.3 # via apache-superset markupsafe==1.1.1 # via jinja2, mako, wtforms @@ -75,20 +78,21 @@ py==1.9.0 # via retry pyarrow==1.0.1 # via apache-superset pycparser==2.20 # via cffi pyjwt==1.7.1 # via flask-appbuilder, flask-jwt-extended -pyparsing==2.4.7 # via packaging +pymeeus==0.3.7 # via convertdate +pyparsing==2.4.7 # via apache-superset, packaging pyrsistent==0.16.1 # via -r requirements/base.in, jsonschema -python-dateutil==2.8.1 # via alembic, apache-superset, croniter, flask-appbuilder, pandas +python-dateutil==2.8.1 # via alembic, apache-superset, croniter, flask-appbuilder, holidays, pandas python-dotenv==0.15.0 # via apache-superset python-editor==1.0.4 # via alembic python-geohash==0.8.5 # via apache-superset python3-openid==3.2.0 # via flask-openid -pytz==2020.4 # via babel, celery, flask-babel, pandas +pytz==2020.4 # via babel, celery, convertdate, flask-babel, pandas pyyaml==5.3.1 # via apache-superset, apispec redis==3.5.3 # via apache-superset retry==0.9.2 # via apache-superset selenium==3.141.0 # via apache-superset simplejson==3.17.2 # via apache-superset -six==1.15.0 # via bleach, cryptography, flask-jwt-extended, flask-talisman, isodate, jsonschema, packaging, pathlib2, polyline, prison, pyrsistent, python-dateutil, sqlalchemy-utils, wtforms-json +six==1.15.0 # via bleach, cryptography, flask-jwt-extended, flask-talisman, holidays, isodate, jsonschema, pathlib2, polyline, prison, pyrsistent, python-dateutil, sqlalchemy-utils, wtforms-json slackclient==2.5.0 # via apache-superset sqlalchemy-utils==0.36.8 # via apache-superset, flask-appbuilder sqlalchemy==1.3.20 # via alembic, apache-superset, flask-sqlalchemy, marshmallow-sqlalchemy, sqlalchemy-utils diff --git a/setup.cfg b/setup.cfg index 2f057d4b3a..19e32b6f8b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,7 +30,7 @@ combine_as_imports = true include_trailing_comma = true line_length = 88 known_first_party = superset -known_third_party =alembic,apispec,backoff,bleach,cachelib,celery,click,colorama,contextlib2,cron_descriptor,croniter,cryptography,dateutil,flask,flask_appbuilder,flask_babel,flask_caching,flask_compress,flask_login,flask_migrate,flask_sqlalchemy,flask_talisman,flask_testing,flask_wtf,freezegun,geohash,geopy,humanize,isodate,jinja2,jwt,markdown,markupsafe,marshmallow,msgpack,numpy,pandas,parameterized,parsedatetime,pathlib2,pgsanity,pkg_resources,polyline,prison,pyarrow,pyhive,pytest,pytz,redis,retry,selenium,setuptools,simplejson,slack,sqlalchemy,sqlalchemy_utils,sqlparse,typing_extensions,werkzeug,wtforms,wtforms_json,yaml +known_third_party =alembic,apispec,backoff,bleach,cachelib,celery,click,colorama,contextlib2,cron_descriptor,croniter,cryptography,dateutil,flask,flask_appbuilder,flask_babel,flask_caching,flask_compress,flask_login,flask_migrate,flask_sqlalchemy,flask_talisman,flask_testing,flask_wtf,freezegun,geohash,geopy,holidays,humanize,isodate,jinja2,jwt,markdown,markupsafe,marshmallow,msgpack,numpy,pandas,parameterized,parsedatetime,pathlib2,pgsanity,pkg_resources,polyline,prison,pyarrow,pyhive,pyparsing,pytest,pytz,redis,retry,selenium,setuptools,simplejson,slack,sqlalchemy,sqlalchemy_utils,sqlparse,typing_extensions,werkzeug,wtforms,wtforms_json,yaml multi_line_output = 3 order_by_type = false diff --git a/setup.py b/setup.py index 21368047f4..6f99be5007 100644 --- a/setup.py +++ b/setup.py @@ -106,6 +106,8 @@ setup( "sqlalchemy-utils>=0.36.6,<0.37", "sqlparse==0.3.0", # PINNED! see https://github.com/andialbrecht/sqlparse/issues/562 "wtforms-json", + "pyparsing>=2.4.7, <3.0.0", + "holidays==0.10.3", # PINNED! https://github.com/dr-prodigy/python-holidays/issues/406 ], extras_require={ "athena": ["pyathena>=1.10.8,<1.11"], diff --git a/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts b/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts index f4759301a5..0024558629 100644 --- a/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts @@ -138,20 +138,20 @@ describe('Time range filter', () => { cy.visitChartByParams(JSON.stringify(formData)); cy.verifySliceSuccess({ waitAlias: '@postJson' }); - cy.get('[data-test=time_range]').within(() => { - cy.get('span.label').click(); - }); - - cy.get('#filter-popover').within(() => { - cy.get('div.ant-tabs-tabpane-active').within(() => { - cy.get('div.PopoverSection :not(.dimmed)').within(() => { + cy.get('[data-test=time-range-trigger]') + .click() + .then(() => { + cy.get('.ant-modal-footer') + .find('button') + .its('length') + .should('eq', 3); + cy.get('.ant-modal-body').within(() => { cy.get('input[value="100 years ago"]'); cy.get('input[value="now"]'); }); + cy.get('[data-test=modal-cancel-button]').click(); + cy.get('[data-test=time-range-modal]').should('not.be.visible'); }); - }); - cy.get('#filter-popover button').contains('Ok').click(); - cy.get('#filter-popover').should('not.be.visible'); }); }); diff --git a/superset-frontend/src/common/components/index.tsx b/superset-frontend/src/common/components/index.tsx index 4b1fa15531..9d246f6b32 100644 --- a/superset-frontend/src/common/components/index.tsx +++ b/superset-frontend/src/common/components/index.tsx @@ -32,14 +32,17 @@ export { Avatar, Button, Card, + Col, DatePicker, Divider, Dropdown, Empty, Input, + InputNumber, Modal, Popover, Radio, + Row, Select, Skeleton, Switch, diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterControl.tsx b/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterControl.tsx new file mode 100644 index 0000000000..6fb9b3c894 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterControl.tsx @@ -0,0 +1,879 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useState, useEffect } from 'react'; +import rison from 'rison'; +import moment, { Moment } from 'moment'; +import { + SupersetClient, + styled, + supersetTheme, + t, + TimeRangeEndpoints, +} from '@superset-ui/core'; +import { + buildTimeRangeString, + formatTimeRange, + SEPARATOR, +} from 'src/explore/dateFilterUtils'; +import { getClientErrorObject } from 'src/utils/getClientErrorObject'; +import Button from 'src/components/Button'; +import ControlHeader from 'src/explore/components/ControlHeader'; +import Label from 'src/components/Label'; +import Modal from 'src/common/components/Modal'; +import { + Col, + DatePicker, + Divider, + Input, + InputNumber, + Radio, + Row, +} from 'src/common/components'; +import Icon from 'src/components/Icon'; +import { Select } from 'src/components/Select'; +import { + TimeRangeFrameType, + CommonRangeType, + CalendarRangeType, + CustomRangeType, + CustomRangeDecodeType, + CustomRangeKey, + PreviousCalendarWeek, + PreviousCalendarMonth, + PreviousCalendarYear, +} from './types'; +import { + COMMON_RANGE_OPTIONS, + CALENDAR_RANGE_OPTIONS, + RANGE_FRAME_OPTIONS, + SINCE_GRAIN_OPTIONS, + UNTIL_GRAIN_OPTIONS, + SINCE_MODE_OPTIONS, + UNTIL_MODE_OPTIONS, +} from './constants'; + +const MOMENT_FORMAT = 'YYYY-MM-DD[T]HH:mm:ss'; +const DEFAULT_SINCE = moment() + .utc() + .startOf('day') + .subtract(7, 'days') + .format(MOMENT_FORMAT); +const DEFAULT_UNTIL = moment().utc().startOf('day').format(MOMENT_FORMAT); + +/** + * RegExp to test a string for a full ISO 8601 Date + * Does not do any sort of date validation, only checks if the string is according to the ISO 8601 spec. + * YYYY-MM-DDThh:mm:ss + * YYYY-MM-DDThh:mm:ssTZD + * YYYY-MM-DDThh:mm:ss.sTZD + * @see: https://www.w3.org/TR/NOTE-datetime + */ +const iso8601 = String.raw`\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:(?:[+-]\d\d:\d\d)|Z)?`; +const datetimeConstant = String.raw`TODAY|NOW`; +const grainValue = String.raw`[+-]?[1-9][0-9]*`; +const grain = String.raw`YEAR|QUARTER|MONTH|WEEK|DAY|HOUR|MINUTE|SECOND`; +const CUSTOM_RANGE_EXPRESSION = RegExp( + String.raw`^DATEADD\(DATETIME\("(${iso8601}|${datetimeConstant})"\),\s(${grainValue}),\s(${grain})\)$`, + 'i', +); +export const ISO8601_AND_CONSTANT = RegExp( + String.raw`^${iso8601}$|^${datetimeConstant}$`, + 'i', +); + +const DATETIME_CONSTANT = ['now', 'today']; +const defaultCustomRange: CustomRangeType = { + sinceDatetime: DEFAULT_SINCE, + sinceMode: 'relative', + sinceGrain: 'day', + sinceGrainValue: -7, + untilDatetime: DEFAULT_UNTIL, + untilMode: 'specific', + untilGrain: 'day', + untilGrainValue: 7, + anchorMode: 'now', + anchorValue: 'now', +}; +const SPECIFIC_MODE = ['specific', 'today', 'now']; + +const COMMON_RANGE_OPTIONS_SET = new Set( + COMMON_RANGE_OPTIONS.map(({ value }) => value), +); +const CALENDAR_RANGE_OPTIONS_SET = new Set( + CALENDAR_RANGE_OPTIONS.map(({ value }) => value), +); + +const commonRangeSet: Set = new Set([ + 'Last day', + 'Last week', + 'Last month', + 'Last quarter', + 'Last year', +]); +const CalendarRangeSet: Set = new Set([ + PreviousCalendarWeek, + PreviousCalendarMonth, + PreviousCalendarYear, +]); + +const customTimeRangeDecode = (timeRange: string): CustomRangeDecodeType => { + const splitDateRange = timeRange.split(SEPARATOR); + + if (splitDateRange.length === 2) { + const [since, until] = splitDateRange; + + // specific : specific + if (ISO8601_AND_CONSTANT.test(since) && ISO8601_AND_CONSTANT.test(until)) { + const sinceMode = DATETIME_CONSTANT.includes(since) ? since : 'specific'; + const untilMode = DATETIME_CONSTANT.includes(until) ? until : 'specific'; + return { + customRange: { + ...defaultCustomRange, + sinceDatetime: since, + untilDatetime: until, + sinceMode, + untilMode, + }, + matchedFlag: true, + }; + } + + // relative : specific + const sinceCapturedGroup = since.match(CUSTOM_RANGE_EXPRESSION); + if ( + sinceCapturedGroup && + ISO8601_AND_CONSTANT.test(until) && + since.includes(until) + ) { + const [dttm, grainValue, grain] = sinceCapturedGroup.slice(1); + const untilMode = DATETIME_CONSTANT.includes(until) ? until : 'specific'; + return { + customRange: { + ...defaultCustomRange, + sinceGrain: grain, + sinceGrainValue: parseInt(grainValue, 10), + untilDatetime: dttm, + sinceMode: 'relative', + untilMode, + }, + matchedFlag: true, + }; + } + + // specific : relative + const untilCapturedGroup = until.match(CUSTOM_RANGE_EXPRESSION); + if ( + ISO8601_AND_CONSTANT.test(since) && + untilCapturedGroup && + until.includes(since) + ) { + const [dttm, grainValue, grain] = [...untilCapturedGroup.slice(1)]; + const sinceMode = DATETIME_CONSTANT.includes(since) ? since : 'specific'; + return { + customRange: { + ...defaultCustomRange, + untilGrain: grain, + untilGrainValue: parseInt(grainValue, 10), + sinceDatetime: dttm, + untilMode: 'relative', + sinceMode, + }, + matchedFlag: true, + }; + } + + // relative : relative + if (sinceCapturedGroup && untilCapturedGroup) { + const [sinceDttm, sinceGrainValue, sinceGrain] = [ + ...sinceCapturedGroup.slice(1), + ]; + const [untileDttm, untilGrainValue, untilGrain] = [ + ...untilCapturedGroup.slice(1), + ]; + if (sinceDttm === untileDttm) { + return { + customRange: { + ...defaultCustomRange, + sinceGrain, + sinceGrainValue: parseInt(sinceGrainValue, 10), + untilGrain, + untilGrainValue: parseInt(untilGrainValue, 10), + anchorValue: sinceDttm, + sinceMode: 'relative', + untilMode: 'relative', + anchorMode: sinceDttm === 'now' ? 'now' : 'specific', + }, + matchedFlag: true, + }; + } + } + } + + return { + customRange: defaultCustomRange, + matchedFlag: false, + }; +}; + +const customTimeRangeEncode = (customRange: CustomRangeType): string => { + const { + sinceDatetime, + sinceMode, + sinceGrain, + sinceGrainValue, + untilDatetime, + untilMode, + untilGrain, + untilGrainValue, + anchorValue, + } = { ...customRange }; + // specific : specific + if (SPECIFIC_MODE.includes(sinceMode) && SPECIFIC_MODE.includes(untilMode)) { + const since = sinceMode === 'specific' ? sinceDatetime : sinceMode; + const until = untilMode === 'specific' ? untilDatetime : untilMode; + return `${since} : ${until}`; + } + + // specific : relative + if (SPECIFIC_MODE.includes(sinceMode) && untilMode === 'relative') { + const since = sinceMode === 'specific' ? sinceDatetime : sinceMode; + const until = `DATEADD(DATETIME("${since}"), ${untilGrainValue}, ${untilGrain})`; + return `${since} : ${until}`; + } + + // relative : specific + if (sinceMode === 'relative' && SPECIFIC_MODE.includes(untilMode)) { + const until = untilMode === 'specific' ? untilDatetime : untilMode; + const since = `DATEADD(DATETIME("${until}"), ${-Math.abs(sinceGrainValue)}, ${sinceGrain})`; // eslint-disable-line + return `${since} : ${until}`; + } + + // relative : relative + const since = `DATEADD(DATETIME("${anchorValue}"), ${-Math.abs(sinceGrainValue)}, ${sinceGrain})`; // eslint-disable-line + const until = `DATEADD(DATETIME("${anchorValue}"), ${untilGrainValue}, ${untilGrain})`; + return `${since} : ${until}`; +}; + +const guessTimeRangeFrame = (timeRange: string): TimeRangeFrameType => { + if (COMMON_RANGE_OPTIONS_SET.has(timeRange)) { + return 'Common'; + } + if (CALENDAR_RANGE_OPTIONS_SET.has(timeRange)) { + return 'Calendar'; + } + if (timeRange === 'No filter') { + return 'No Filter'; + } + if (customTimeRangeDecode(timeRange).matchedFlag) { + return 'Custom'; + } + return 'Advanced'; +}; + +const dttmToMoment = (dttm: string): Moment => { + if (dttm === 'now') { + return moment().utc().startOf('second'); + } + if (dttm === 'today') { + return moment().utc().startOf('day'); + } + return moment(dttm); +}; + +const fetchTimeRange = async ( + timeRange: string, + endpoints?: TimeRangeEndpoints, +) => { + const query = rison.encode(timeRange); + const endpoint = `/api/v1/time_range/?q=${query}`; + + try { + const response = await SupersetClient.get({ endpoint }); + const timeRangeString = buildTimeRangeString( + response?.json?.result?.since || '', + response?.json?.result?.until || '', + ); + return { + value: formatTimeRange(timeRangeString, endpoints), + }; + } catch (response) { + const clientError = await getClientErrorObject(response); + return { + error: clientError.message || clientError.error, + }; + } +}; + +const StyledModalContainer = styled.div` + .ant-row { + margin-top: 8px; + } + + .ant-input-number { + width: 100%; + } + + .ant-picker { + padding: 4px 17px 4px; + border-radius: 4px; + width: 100%; + } + + .ant-divider-horizontal { + margin: 16px 0; + } + + .control-label { + font-size: 11px; + font-weight: 500; + color: #b2b2b2; + line-height: 16px; + text-transform: uppercase; + margin: 8px 0; + } + + .vertical-radio { + display: block; + height: 40px; + line-height: 40px; + } + + .section-title { + font-style: normal; + font-weight: 500; + font-size: 15px; + line-height: 24px; + margin-bottom: 8px; + } +`; + +const StyledValidateBtn = styled.span` + .validate-btn { + float: left; + } +`; + +const IconWrapper = styled.span` + svg { + margin-right: ${({ theme }) => 2 * theme.gridUnit}px; + vertical-align: middle; + display: inline-block; + } + .text { + vertical-align: middle; + display: inline-block; + } + .error { + color: ${({ theme }) => theme.colors.error.base}; + } +`; + +interface DateFilterLabelProps { + name: string; + onChange: (timeRange: string) => void; + value?: string; + endpoints?: TimeRangeEndpoints; +} + +export default function DateFilterControl(props: DateFilterLabelProps) { + const { value = 'Last week', endpoints, onChange } = props; + const [actualTimeRange, setActualTimeRange] = useState(value); + + // State used for Modal + const [show, setShow] = useState(false); + const [timeRangeFrame, setTimeRangeFrame] = useState( + guessTimeRangeFrame(value), + ); + const [commonRange, setCommonRange] = useState( + getDefaultOrCommonRange(value), + ); + const [calendarRange, setCalendarRange] = useState( + getDefaultOrCalendarRange(value), + ); + const [customRange, setCustomRange] = useState( + customTimeRangeDecode(value).customRange, + ); + const [advancedRange, setAdvancedRange] = useState( + getAdvancedRange(value), + ); + const [validTimeRange, setValidTimeRange] = useState(false); + const [evalTimeRange, setEvalTimeRange] = useState(value); + + useEffect(() => { + fetchTimeRange(value, endpoints).then(({ value, error }) => { + if (error) { + setEvalTimeRange(error || ''); + setValidTimeRange(false); + } else { + setActualTimeRange(value || ''); + setValidTimeRange(true); + } + }); + }, [value]); + + useEffect(() => { + const value = getCurrentValue(); + fetchTimeRange(value, endpoints).then(({ value, error }) => { + if (error) { + setEvalTimeRange(error || ''); + setValidTimeRange(false); + } else { + setEvalTimeRange(value || ''); + setValidTimeRange(true); + } + }); + }, [timeRangeFrame, commonRange, calendarRange, customRange]); + + function getCurrentValue(): string { + // get current time_range string + let value = 'Last week'; + if (timeRangeFrame === 'Common') { + value = commonRange; + } + if (timeRangeFrame === 'Calendar') { + value = calendarRange; + } + if (timeRangeFrame === 'Custom') { + value = customTimeRangeEncode(customRange); + } + if (timeRangeFrame === 'Advanced') { + value = advancedRange; + } + if (timeRangeFrame === 'No Filter') { + value = 'No filter'; + } + return value; + } + + function getDefaultOrCommonRange(value: any): CommonRangeType { + return commonRangeSet.has(value) ? value : 'Last week'; + } + + function getDefaultOrCalendarRange(value: any): CalendarRangeType { + return CalendarRangeSet.has(value) ? value : PreviousCalendarWeek; + } + + function getAdvancedRange(value: string): string { + if (value.includes(SEPARATOR)) { + return value; + } + if (value.startsWith('Last')) { + return [value, ''].join(SEPARATOR); + } + if (value.startsWith('Next')) { + return ['', value].join(SEPARATOR); + } + return SEPARATOR; + } + + function onAdvancedRangeChange(control: 'since' | 'until', value: string) { + setValidTimeRange(false); + setEvalTimeRange(t('Need to verify the time range.')); + const [since, until] = advancedRange.split(SEPARATOR); + if (control === 'since') { + setAdvancedRange(`${value}${SEPARATOR}${until}`); + } else { + setAdvancedRange(`${since}${SEPARATOR}${value}`); + } + } + + function onCustomRangeChange( + control: CustomRangeKey, + value: string | number, + ) { + setCustomRange({ + ...customRange, + [control]: value, + }); + } + + function onCustomRangeChangeAnchorMode(option: any) { + const radioValue = option.target.value; + if (radioValue === 'now') { + setCustomRange({ + ...customRange, + anchorValue: 'now', + anchorMode: radioValue, + }); + } else { + setCustomRange({ + ...customRange, + anchorValue: DEFAULT_UNTIL, + anchorMode: radioValue, + }); + } + } + + function showValidateBtn(): boolean { + return timeRangeFrame === 'Advanced'; + } + + function resetState(value: string) { + setTimeRangeFrame(guessTimeRangeFrame(value)); + setCommonRange(getDefaultOrCommonRange(value)); + setCalendarRange(getDefaultOrCalendarRange(value)); + setCustomRange(customTimeRangeDecode(value).customRange); + setAdvancedRange(getAdvancedRange(value)); + setShow(false); + } + + function onSave() { + const currentValue = getCurrentValue(); + onChange(currentValue); + resetState(currentValue); + } + + function onHide() { + resetState(value); + } + + function onValidate() { + const value = getCurrentValue(); + fetchTimeRange(value, endpoints).then(({ value, error }) => { + if (error) { + setEvalTimeRange(error || ''); + setValidTimeRange(false); + } else { + setEvalTimeRange(value || ''); + setValidTimeRange(true); + } + }); + } + + function renderCommon() { + const commonRangeValue = + COMMON_RANGE_OPTIONS.find(({ value }) => value === commonRange)?.value || + 'Last week'; + return ( + <> +
+ {t('Configure Time Range: Last...')} +
+ setCommonRange(e.target.value)} + > + {COMMON_RANGE_OPTIONS.map(({ value, label }) => ( + + {label} + + ))} + + + ); + } + + function renderCalendar() { + const currentValue = + CALENDAR_RANGE_OPTIONS.find(({ value }) => value === calendarRange) + ?.value || PreviousCalendarWeek; + return ( + <> +
+ {t('Configure Time Range: Previous...')} +
+ setCalendarRange(e.target.value)} + > + {CALENDAR_RANGE_OPTIONS.map(({ value, label }) => ( + + {label} + + ))} + + + ); + } + + function renderAdvanced() { + const [since, until] = advancedRange.split(SEPARATOR); + return ( + <> +
+ {t('Configure Advanced Time Range')} +
+
{t('START')}
+ onAdvancedRangeChange('since', e.target.value)} + /> +
{t('END')}
+ onAdvancedRangeChange('until', e.target.value)} + /> + + ); + } + + function renderCustom() { + const { + sinceDatetime, + sinceMode, + sinceGrain, + sinceGrainValue, + untilDatetime, + untilMode, + untilGrain, + untilGrainValue, + anchorValue, + anchorMode, + } = { ...customRange }; + + return ( + <> +
{t('Configure Custom Time Range')}
+ + +
{t('START')}
+ option.value === sinceGrain, + )} + onChange={(option: any) => + onCustomRangeChange('sinceGrain', option.value) + } + /> + +
+ )} + + +
{t('END')}
+ option.value === untilGrain, + )} + onChange={(option: any) => + onCustomRangeChange('untilGrain', option.value) + } + /> + + + )} + + + {sinceMode === 'relative' && untilMode === 'relative' && ( + <> +
{t('ANCHOR RELATIVE TO')}
+ + + + + {t('NOW')} + + + {t('Date/Time')} + + + + {anchorMode !== 'now' && ( + + + onCustomRangeChange( + 'anchorValue', + datetime.format(MOMENT_FORMAT), + ) + } + allowClear={false} + /> + + )} + + + )} + + ); + } + + return ( + <> + + + + + {t('Edit Time Range')} + + } + show={show} + onHide={onHide} + footer={[ + , + , + showValidateBtn() && ( + + + + ), + ]} + > + +
{t('RANGE TYPE')}
+