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 0024558629ffb..2e08dcfb82a0c 100644 --- a/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts @@ -127,7 +127,7 @@ describe('Time range filter', () => { cy.route('POST', '/superset/explore_json/**').as('postJson'); }); - it('Defaults to the correct tab for time_range params', () => { + it('Advanced time_range params', () => { const formData = { ...FORM_DATA_DEFAULTS, metrics: [NUM_METRIC], @@ -141,18 +141,98 @@ describe('Time range filter', () => { 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('.footer').find('button').its('length').should('eq', 2); + cy.get('.ant-popover-content').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('[data-test=cancel-button]').click(); + cy.get('.ant-popover').should('not.be.visible'); }); }); + + it('Common time_range params', () => { + const formData = { + ...FORM_DATA_DEFAULTS, + metrics: [NUM_METRIC], + viz_type: 'line', + time_range: 'Last year', + }; + + cy.visitChartByParams(JSON.stringify(formData)); + cy.verifySliceSuccess({ waitAlias: '@postJson' }); + + cy.get('[data-test=time-range-trigger]') + .click() + .then(() => { + cy.get('.ant-radio-group').children().its('length').should('eq', 5); + cy.get('.ant-radio-checked + span').contains('last year'); + cy.get('[data-test=cancel-button]').click(); + }); + }); + + it('Previous time_range params', () => { + const formData = { + ...FORM_DATA_DEFAULTS, + metrics: [NUM_METRIC], + viz_type: 'line', + time_range: + 'DATETRUNC(DATEADD(DATETIME("TODAY"), -1, MONTH), MONTH) : LASTDAY(DATEADD(DATETIME("TODAY"), -1, MONTH), MONTH)', + }; + + cy.visitChartByParams(JSON.stringify(formData)); + cy.verifySliceSuccess({ waitAlias: '@postJson' }); + + cy.get('[data-test=time-range-trigger]') + .click() + .then(() => { + cy.get('.ant-radio-group').children().its('length').should('eq', 3); + cy.get('.ant-radio-checked + span').contains('previous calendar month'); + cy.get('[data-test=cancel-button]').click(); + }); + }); + + it('Custom time_range params', () => { + const formData = { + ...FORM_DATA_DEFAULTS, + metrics: [NUM_METRIC], + viz_type: 'line', + time_range: 'DATEADD(DATETIME("today"), -7, day) : today', + }; + + cy.visitChartByParams(JSON.stringify(formData)); + cy.verifySliceSuccess({ waitAlias: '@postJson' }); + + cy.get('[data-test=time-range-trigger]') + .click() + .then(() => { + cy.get('[data-test=custom-frame]').then(() => { + cy.get('.ant-input-number-input-wrap > input') + .invoke('attr', 'value') + .should('eq', '7'); + }); + cy.get('[data-test=cancel-button]').click(); + }); + }); + + it('No filter time_range params', () => { + const formData = { + ...FORM_DATA_DEFAULTS, + metrics: [NUM_METRIC], + viz_type: 'line', + time_range: 'No filter', + }; + + cy.visitChartByParams(JSON.stringify(formData)); + cy.verifySliceSuccess({ waitAlias: '@postJson' }); + + cy.get('[data-test=time-range-trigger]') + .click() + .then(() => { + cy.get('[data-test=no-filter]'); + }); + cy.get('[data-test=cancel-button]').click(); + }); }); describe('Groupby control', () => { diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterControl.tsx b/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterControl.tsx index 6fb9b3c894add..bc0af3c108d7c 100644 --- a/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterControl.tsx +++ b/superset-frontend/src/explore/components/controls/DateFilterControl/DateFilterControl.tsx @@ -18,7 +18,6 @@ */ import React, { useState, useEffect } from 'react'; import rison from 'rison'; -import moment, { Moment } from 'moment'; import { SupersetClient, styled, @@ -29,252 +28,34 @@ import { 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 Popover from 'src/common/components/Popover'; +import { Divider } from 'src/common/components'; import Icon from 'src/components/Icon'; import { Select } from 'src/components/Select'; +import { SelectOptionType, FrameType } from './types'; 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, + COMMON_RANGE_VALUES_SET, + CALENDAR_RANGE_VALUES_SET, + FRAME_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)) { +import { customTimeRangeDecode } from './utils'; +import { + CommonFrame, + CalendarFrame, + CustomFrame, + AdvancedFrame, +} from './frame'; + +const guessFrame = (timeRange: string): FrameType => { + if (COMMON_RANGE_VALUES_SET.has(timeRange)) { return 'Common'; } - if (CALENDAR_RANGE_OPTIONS_SET.has(timeRange)) { + if (CALENDAR_RANGE_VALUES_SET.has(timeRange)) { return 'Calendar'; } if (timeRange === 'No filter') { @@ -286,16 +67,6 @@ const guessTimeRangeFrame = (timeRange: string): TimeRangeFrameType => { 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, @@ -320,7 +91,9 @@ const fetchTimeRange = async ( } }; -const StyledModalContainer = styled.div` +const StyledPopover = styled(Popover)``; + +const ContentStyleWrapper = styled.div` .ant-row { margin-top: 8px; } @@ -329,6 +102,10 @@ const StyledModalContainer = styled.div` width: 100%; } + .frame-dropdown { + width: 272px; + } + .ant-picker { padding: 4px 17px 4px; border-radius: 4px; @@ -361,11 +138,17 @@ const StyledModalContainer = styled.div` line-height: 24px; margin-bottom: 8px; } -`; -const StyledValidateBtn = styled.span` - .validate-btn { - float: left; + .control-anchor-to { + margin-top: 16px; + } + + .control-anchor-to-datetime { + width: 217px; + } + + .footer { + text-align: right; } `; @@ -373,11 +156,9 @@ 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}; @@ -395,30 +176,16 @@ 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 [frame, setFrame] = useState(guessFrame(value)); + const [timeRangeValue, setTimeRangeValue] = useState(value); const [validTimeRange, setValidTimeRange] = useState(false); - const [evalTimeRange, setEvalTimeRange] = useState(value); + const [evalResponse, setEvalResponse] = useState(value); useEffect(() => { fetchTimeRange(value, endpoints).then(({ value, error }) => { if (error) { - setEvalTimeRange(error || ''); + setEvalResponse(error || ''); setValidTimeRange(false); } else { setActualTimeRange(value || ''); @@ -428,452 +195,136 @@ export default function DateFilterControl(props: DateFilterLabelProps) { }, [value]); useEffect(() => { - const value = getCurrentValue(); - fetchTimeRange(value, endpoints).then(({ value, error }) => { + fetchTimeRange(timeRangeValue, endpoints).then(({ value, error }) => { if (error) { - setEvalTimeRange(error || ''); + setEvalResponse(error || ''); setValidTimeRange(false); } else { - setEvalTimeRange(value || ''); + setEvalResponse(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; - } + }, [timeRangeValue]); - function getDefaultOrCommonRange(value: any): CommonRangeType { - return commonRangeSet.has(value) ? value : 'Last week'; - } - - function getDefaultOrCalendarRange(value: any): CalendarRangeType { - return CalendarRangeSet.has(value) ? value : PreviousCalendarWeek; + function onSave() { + onChange(timeRangeValue); + setShow(false); } - 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 onHide() { + setFrame(guessFrame(value)); + setTimeRangeValue(value); + setShow(false); } - 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}`); + const togglePopover = () => { + if (show) { + onHide(); } else { - setAdvancedRange(`${since}${SEPARATOR}${value}`); + setShow(true); } - } - - 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 onFrame(option: SelectOptionType) { + if (option.value === 'No Filter') { + setTimeRangeValue('No filter'); } - } - - 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)} + setFrame(option.value as FrameType); + } + + const overlayConetent = ( + +
{t('RANGE TYPE')}
+ onAdvancedRangeChange('since', e.target.value)} - /> -
{t('END')}
- onAdvancedRangeChange('until', e.target.value)} - /> - - ); - } + {t('APPLY')} + + +
+ ); - function renderCustom() { - const { - sinceDatetime, - sinceMode, - sinceGrain, - sinceGrainValue, - untilDatetime, - untilMode, - untilGrain, - untilGrainValue, - anchorValue, - anchorMode, - } = { ...customRange }; + const title = ( + + + {t('Edit Time Range')} + + ); - 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} - /> - - )} - - - )} - - ); - } + const overlayStyle = { + width: '600px', + }; return ( <> - - - - {t('Edit Time Range')} - - } - show={show} - onHide={onHide} - footer={[ - , - , - showValidateBtn() && ( - - - - ), - ]} - > - -
{t('RANGE TYPE')}
- onChange('since', e.target.value)} + /> +
{t('END')}
+ onChange('until', e.target.value)} + /> + + ); +} diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/frame/CalendarFrame.tsx b/superset-frontend/src/explore/components/controls/DateFilterControl/frame/CalendarFrame.tsx new file mode 100644 index 0000000000000..0be926de47da8 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/DateFilterControl/frame/CalendarFrame.tsx @@ -0,0 +1,54 @@ +/** + * 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 from 'react'; +import { t } from '@superset-ui/core'; +import { Radio } from 'src/common/components'; +import { CALENDAR_RANGE_OPTIONS, CALENDAR_RANGE_SET } from '../constants'; +import { + CalendarRangeType, + PreviousCalendarWeek, + FrameComponentProps, +} from '../types'; + +export function CalendarFrame(props: FrameComponentProps) { + let calendarRange = PreviousCalendarWeek; + if (CALENDAR_RANGE_SET.has(props.value as CalendarRangeType)) { + calendarRange = props.value; + } else { + props.onChange(calendarRange); + } + + return ( + <> +
+ {t('Configure Time Range: Previous...')} +
+ props.onChange(e.target.value)} + > + {CALENDAR_RANGE_OPTIONS.map(({ value, label }) => ( + + {label} + + ))} + + + ); +} diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/frame/CommonFrame.tsx b/superset-frontend/src/explore/components/controls/DateFilterControl/frame/CommonFrame.tsx new file mode 100644 index 0000000000000..d51b94344a82f --- /dev/null +++ b/superset-frontend/src/explore/components/controls/DateFilterControl/frame/CommonFrame.tsx @@ -0,0 +1,48 @@ +/** + * 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 from 'react'; +import { t } from '@superset-ui/core'; +import { Radio } from 'src/common/components'; +import { COMMON_RANGE_OPTIONS, COMMON_RANGE_SET } from '../constants'; +import { CommonRangeType, FrameComponentProps } from '../types'; + +export function CommonFrame(props: FrameComponentProps) { + let commonRange = 'Last week'; + if (COMMON_RANGE_SET.has(props.value as CommonRangeType)) { + commonRange = props.value; + } else { + props.onChange(commonRange); + } + + return ( + <> +
{t('Configure Time Range: Last...')}
+ props.onChange(e.target.value)} + > + {COMMON_RANGE_OPTIONS.map(({ value, label }) => ( + + {label} + + ))} + + + ); +} diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/frame/CustomFrame.tsx b/superset-frontend/src/explore/components/controls/DateFilterControl/frame/CustomFrame.tsx new file mode 100644 index 0000000000000..2ad8e638f4852 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/DateFilterControl/frame/CustomFrame.tsx @@ -0,0 +1,263 @@ +/** + * 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 from 'react'; +import { t } from '@superset-ui/core'; +import moment, { Moment } from 'moment'; +import { isInteger } from 'lodash'; +import { + Col, + DatePicker, + InputNumber, + Radio, + Row, +} from 'src/common/components'; +import { Select } from 'src/components/Select'; +import { + SINCE_GRAIN_OPTIONS, + SINCE_MODE_OPTIONS, + UNTIL_GRAIN_OPTIONS, + UNTIL_MODE_OPTIONS, + MOMENT_FORMAT, + MIDNIGHT, +} from '../constants'; +import { customTimeRangeDecode, customTimeRangeEncode } from '../utils'; +import { + CustomRangeKey, + SelectOptionType, + FrameComponentProps, +} from '../types'; + +const dttmToMoment = (dttm: string): Moment => { + if (dttm === 'now') { + return moment().utc().startOf('second'); + } + if (dttm === 'today') { + return moment().utc().startOf('day'); + } + return moment(dttm); +}; + +export function CustomFrame(props: FrameComponentProps) { + const { customRange, matchedFlag } = customTimeRangeDecode(props.value); + if (!matchedFlag) { + props.onChange(customTimeRangeEncode(customRange)); + } + const { + sinceDatetime, + sinceMode, + sinceGrain, + sinceGrainValue, + untilDatetime, + untilMode, + untilGrain, + untilGrainValue, + anchorValue, + anchorMode, + } = { ...customRange }; + + function onChange(control: CustomRangeKey, value: string) { + props.onChange( + customTimeRangeEncode({ + ...customRange, + [control]: value, + }), + ); + } + + function onGrainValue( + control: 'sinceGrainValue' | 'untilGrainValue', + value: string | number, + ) { + // only positive values in grainValue controls + if (isInteger(value) && value > 0) { + props.onChange( + customTimeRangeEncode({ + ...customRange, + [control]: value, + }), + ); + } + } + + function onAnchorMode(option: any) { + const radioValue = option.target.value; + if (radioValue === 'now') { + props.onChange( + customTimeRangeEncode({ + ...customRange, + anchorValue: 'now', + anchorMode: radioValue, + }), + ); + } else { + props.onChange( + customTimeRangeEncode({ + ...customRange, + anchorValue: MIDNIGHT, + anchorMode: radioValue, + }), + ); + } + } + + return ( +
+
{t('Configure Custom Time Range')}
+ + +
{t('START')}
+ option.value === sinceGrain, + )} + onChange={(option: SelectOptionType) => + onChange('sinceGrain', option.value) + } + /> + +
+ )} + + +
{t('END')}
+ option.value === untilGrain, + )} + onChange={(option: SelectOptionType) => + onChange('untilGrain', option.value) + } + /> + + + )} + + + {sinceMode === 'relative' && untilMode === 'relative' && ( +
+
{t('ANCHOR TO')}
+ + + + + {t('NOW')} + + + {t('Date/Time')} + + + + {anchorMode !== 'now' && ( + + + onChange('anchorValue', datetime.format(MOMENT_FORMAT)) + } + allowClear={false} + className="control-anchor-to-datetime" + /> + + )} + +
+ )} +
+ ); +} diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/frame/index.ts b/superset-frontend/src/explore/components/controls/DateFilterControl/frame/index.ts new file mode 100644 index 0000000000000..0bec821065627 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/DateFilterControl/frame/index.ts @@ -0,0 +1,22 @@ +/** + * 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. + */ +export { CommonFrame } from './CommonFrame'; +export { CalendarFrame } from './CalendarFrame'; +export { CustomFrame } from './CustomFrame'; +export { AdvancedFrame } from './AdvancedFrame'; diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/types.ts b/superset-frontend/src/explore/components/controls/DateFilterControl/types.ts index 18e6194329794..7e43615729a13 100644 --- a/superset-frontend/src/explore/components/controls/DateFilterControl/types.ts +++ b/superset-frontend/src/explore/components/controls/DateFilterControl/types.ts @@ -21,7 +21,7 @@ export type SelectOptionType = { label: string; }; -export type TimeRangeFrameType = +export type FrameType = | 'Common' | 'Calendar' | 'Custom' @@ -35,6 +35,7 @@ export type DateTimeGrainType = | 'day' | 'week' | 'month' + | 'quarter' | 'year'; export type CustomRangeKey = @@ -49,14 +50,16 @@ export type CustomRangeKey = | 'anchorMode' | 'anchorValue'; +export type DateTimeModeType = 'specific' | 'relative' | 'now' | 'today'; + export type CustomRangeType = { - sinceMode: string; + sinceMode: DateTimeModeType; sinceDatetime: string; - sinceGrain: string; + sinceGrain: DateTimeGrainType; sinceGrainValue: number; - untilMode: string; + untilMode: 'specific' | 'relative' | 'now' | 'today'; untilDatetime: string; - untilGrain: string; + untilGrain: DateTimeGrainType; untilGrainValue: number; anchorMode: 'now' | 'specific'; anchorValue: string; @@ -84,3 +87,8 @@ export type CalendarRangeType = | typeof PreviousCalendarWeek | typeof PreviousCalendarMonth | typeof PreviousCalendarYear; + +export type FrameComponentProps = { + onChange: (timeRange: string) => void; + value: string; +}; diff --git a/superset-frontend/src/explore/components/controls/DateFilterControl/utils.ts b/superset-frontend/src/explore/components/controls/DateFilterControl/utils.ts new file mode 100644 index 0000000000000..e0779588f7aee --- /dev/null +++ b/superset-frontend/src/explore/components/controls/DateFilterControl/utils.ts @@ -0,0 +1,209 @@ +/** + * 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 { SEPARATOR } from 'src/explore/dateFilterUtils'; +import { + CustomRangeDecodeType, + CustomRangeType, + DateTimeGrainType, + DateTimeModeType, +} from './types'; +import { SEVEN_DAYS_AGO, MIDNIGHT } from './constants'; + +/** + * 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: SEVEN_DAYS_AGO, + sinceMode: 'relative', + sinceGrain: 'day', + sinceGrainValue: -7, + untilDatetime: MIDNIGHT, + untilMode: 'specific', + untilGrain: 'day', + untilGrainValue: 7, + anchorMode: 'now', + anchorValue: 'now', +}; +const SPECIFIC_MODE = ['specific', 'today', 'now']; + +export 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') as DateTimeModeType; + const untilMode = (DATETIME_CONSTANT.includes(until) + ? until + : 'specific') as DateTimeModeType; + 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') as DateTimeModeType; + return { + customRange: { + ...defaultCustomRange, + sinceGrain: grain as DateTimeGrainType, + 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') as DateTimeModeType; + return { + customRange: { + ...defaultCustomRange, + untilGrain: grain as DateTimeGrainType, + 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: sinceGrain as DateTimeGrainType, + sinceGrainValue: parseInt(sinceGrainValue, 10), + untilGrain: untilGrain as DateTimeGrainType, + untilGrainValue: parseInt(untilGrainValue, 10), + anchorValue: sinceDttm, + sinceMode: 'relative', + untilMode: 'relative', + anchorMode: sinceDttm === 'now' ? 'now' : 'specific', + }, + matchedFlag: true, + }; + } + } + } + + return { + customRange: defaultCustomRange, + matchedFlag: false, + }; +}; + +export 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}`; +};