This is an automated email from the ASF dual-hosted git repository.

junlin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git


The following commit(s) were added to refs/heads/master by this push:
     new b349ede  feat(explore): Time picker enhancement follow up (#12208)
b349ede is described below

commit b349edef295a5273e8394a01f6cfda25d6ead0c8
Author: Yongjie Zhao <[email protected]>
AuthorDate: Tue Jan 5 10:29:17 2021 +0800

    feat(explore): Time picker enhancement follow up (#12208)
    
    * refactor layout
    
    * WIP
    
    * fix typing
    
    * styling
    
    * fix lint
    
    * frontend IT
    
    * rename variable
    
    * added quarter
    
    * typos
    
    * refine code structure
---
 .../cypress/integration/explore/control.test.ts    |  96 ++-
 .../DateFilterControl/DateFilterControl.tsx        | 837 ++++-----------------
 .../controls/DateFilterControl/constants.ts        |  50 +-
 .../DateFilterControl/frame/AdvancedFrame.tsx      |  66 ++
 .../DateFilterControl/frame/CalendarFrame.tsx      |  54 ++
 .../DateFilterControl/frame/CommonFrame.tsx        |  48 ++
 .../DateFilterControl/frame/CustomFrame.tsx        | 263 +++++++
 .../controls/DateFilterControl/frame/index.ts      |  22 +
 .../components/controls/DateFilterControl/types.ts |  18 +-
 .../components/controls/DateFilterControl/utils.ts | 209 +++++
 10 files changed, 948 insertions(+), 715 deletions(-)

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 20566ca..fb1b68c 100644
--- a/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts
+++ b/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts
@@ -128,7 +128,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],
@@ -142,18 +142,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 6fb9b3c..bc0af3c 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<CommonRangeType> = new Set([
-  'Last day',
-  'Last week',
-  'Last month',
-  'Last quarter',
-  'Last year',
-]);
-const CalendarRangeSet: Set<CalendarRangeType> = 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<string>(value);
 
-  // State used for Modal
   const [show, setShow] = useState<boolean>(false);
-  const [timeRangeFrame, setTimeRangeFrame] = useState<TimeRangeFrameType>(
-    guessTimeRangeFrame(value),
-  );
-  const [commonRange, setCommonRange] = useState<CommonRangeType>(
-    getDefaultOrCommonRange(value),
-  );
-  const [calendarRange, setCalendarRange] = useState<CalendarRangeType>(
-    getDefaultOrCalendarRange(value),
-  );
-  const [customRange, setCustomRange] = useState<CustomRangeType>(
-    customTimeRangeDecode(value).customRange,
-  );
-  const [advancedRange, setAdvancedRange] = useState<string>(
-    getAdvancedRange(value),
-  );
+  const [frame, setFrame] = useState<FrameType>(guessFrame(value));
+  const [timeRangeValue, setTimeRangeValue] = useState(value);
   const [validTimeRange, setValidTimeRange] = useState<boolean>(false);
-  const [evalTimeRange, setEvalTimeRange] = useState<string>(value);
+  const [evalResponse, setEvalResponse] = useState<string>(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 (
-      <>
-        <div className="section-title">
-          {t('Configure Time Range: Last...')}
-        </div>
-        <Radio.Group
-          value={commonRangeValue}
-          onChange={(e: any) => setCommonRange(e.target.value)}
+    setFrame(option.value as FrameType);
+  }
+
+  const overlayConetent = (
+    <ContentStyleWrapper>
+      <div className="control-label">{t('RANGE TYPE')}</div>
+      <Select
+        options={FRAME_OPTIONS}
+        value={FRAME_OPTIONS.filter(({ value }) => value === frame)}
+        onChange={onFrame}
+        className="frame-dropdown"
+      />
+      {frame !== 'No Filter' && <Divider />}
+      {frame === 'Common' && (
+        <CommonFrame value={timeRangeValue} onChange={setTimeRangeValue} />
+      )}
+      {frame === 'Calendar' && (
+        <CalendarFrame value={timeRangeValue} onChange={setTimeRangeValue} />
+      )}
+      {frame === 'Advanced' && (
+        <AdvancedFrame value={timeRangeValue} onChange={setTimeRangeValue} />
+      )}
+      {frame === 'Custom' && (
+        <CustomFrame value={timeRangeValue} onChange={setTimeRangeValue} />
+      )}
+      {frame === 'No Filter' && <div data-test="no-filter" />}
+      <Divider />
+      <div>
+        <div className="section-title">{t('Actual Time Range')}</div>
+        {validTimeRange && <div>{evalResponse}</div>}
+        {!validTimeRange && (
+          <IconWrapper className="warning">
+            <Icon
+              name="error-solid-small"
+              color={supersetTheme.colors.error.base}
+            />
+            <span className="text error">{evalResponse}</span>
+          </IconWrapper>
+        )}
+      </div>
+      <Divider />
+      <div className="footer">
+        <Button
+          buttonStyle="secondary"
+          cta
+          key="cancel"
+          onClick={onHide}
+          data-test="cancel-button"
         >
-          {COMMON_RANGE_OPTIONS.map(({ value, label }) => (
-            <Radio key={value} value={value} className="vertical-radio">
-              {label}
-            </Radio>
-          ))}
-        </Radio.Group>
-      </>
-    );
-  }
-
-  function renderCalendar() {
-    const currentValue =
-      CALENDAR_RANGE_OPTIONS.find(({ value }) => value === calendarRange)
-        ?.value || PreviousCalendarWeek;
-    return (
-      <>
-        <div className="section-title">
-          {t('Configure Time Range: Previous...')}
-        </div>
-        <Radio.Group
-          value={currentValue}
-          onChange={(e: any) => setCalendarRange(e.target.value)}
+          {t('CANCEL')}
+        </Button>
+        <Button
+          buttonStyle="primary"
+          cta
+          disabled={!validTimeRange}
+          key="apply"
+          onClick={onSave}
         >
-          {CALENDAR_RANGE_OPTIONS.map(({ value, label }) => (
-            <Radio key={value} value={value} className="vertical-radio">
-              {label}
-            </Radio>
-          ))}
-        </Radio.Group>
-      </>
-    );
-  }
-
-  function renderAdvanced() {
-    const [since, until] = advancedRange.split(SEPARATOR);
-    return (
-      <>
-        <div className="section-title">
-          {t('Configure Advanced Time Range')}
-        </div>
-        <div className="control-label">{t('START')}</div>
-        <Input
-          key="since"
-          value={since}
-          onChange={e => onAdvancedRangeChange('since', e.target.value)}
-        />
-        <div className="control-label">{t('END')}</div>
-        <Input
-          key="until"
-          value={until}
-          onChange={e => onAdvancedRangeChange('until', e.target.value)}
-        />
-      </>
-    );
-  }
+          {t('APPLY')}
+        </Button>
+      </div>
+    </ContentStyleWrapper>
+  );
 
-  function renderCustom() {
-    const {
-      sinceDatetime,
-      sinceMode,
-      sinceGrain,
-      sinceGrainValue,
-      untilDatetime,
-      untilMode,
-      untilGrain,
-      untilGrainValue,
-      anchorValue,
-      anchorMode,
-    } = { ...customRange };
+  const title = (
+    <IconWrapper>
+      <Icon name="edit-alt" />
+      <span className="text">{t('Edit Time Range')}</span>
+    </IconWrapper>
+  );
 
-    return (
-      <>
-        <div className="section-title">{t('Configure Custom Time Range')}</div>
-        <Row gutter={8}>
-          <Col span={12}>
-            <div className="control-label">{t('START')}</div>
-            <Select
-              options={SINCE_MODE_OPTIONS}
-              value={SINCE_MODE_OPTIONS.filter(
-                option => option.value === sinceMode,
-              )}
-              onChange={(option: any) =>
-                onCustomRangeChange('sinceMode', option.value)
-              }
-            />
-            {sinceMode === 'specific' && (
-              <Row>
-                <DatePicker
-                  showTime
-                  value={dttmToMoment(sinceDatetime)}
-                  onChange={(datetime: Moment) =>
-                    onCustomRangeChange(
-                      'sinceDatetime',
-                      datetime.format(MOMENT_FORMAT),
-                    )
-                  }
-                  allowClear={false}
-                />
-              </Row>
-            )}
-            {sinceMode === 'relative' && (
-              <Row gutter={8}>
-                <Col span={10}>
-                  {/* Make sure sinceGrainValue looks like a positive integer 
*/}
-                  <InputNumber
-                    placeholder={t('Relative quantity')}
-                    value={Math.abs(sinceGrainValue)}
-                    min={1}
-                    defaultValue={1}
-                    onStep={value =>
-                      onCustomRangeChange('sinceGrainValue', value || 1)
-                    }
-                  />
-                </Col>
-                <Col span={14}>
-                  <Select
-                    options={SINCE_GRAIN_OPTIONS}
-                    value={SINCE_GRAIN_OPTIONS.filter(
-                      option => option.value === sinceGrain,
-                    )}
-                    onChange={(option: any) =>
-                      onCustomRangeChange('sinceGrain', option.value)
-                    }
-                  />
-                </Col>
-              </Row>
-            )}
-          </Col>
-          <Col span={12}>
-            <div className="control-label">{t('END')}</div>
-            <Select
-              options={UNTIL_MODE_OPTIONS}
-              value={UNTIL_MODE_OPTIONS.filter(
-                option => option.value === untilMode,
-              )}
-              onChange={(option: any) =>
-                onCustomRangeChange('untilMode', option.value)
-              }
-            />
-            {untilMode === 'specific' && (
-              <Row>
-                <DatePicker
-                  showTime
-                  value={dttmToMoment(untilDatetime)}
-                  onChange={(datetime: Moment) =>
-                    onCustomRangeChange(
-                      'untilDatetime',
-                      datetime.format(MOMENT_FORMAT),
-                    )
-                  }
-                  allowClear={false}
-                />
-              </Row>
-            )}
-            {untilMode === 'relative' && (
-              <Row gutter={8}>
-                <Col span={10}>
-                  <InputNumber
-                    placeholder={t('Relative quantity')}
-                    value={untilGrainValue}
-                    min={1}
-                    defaultValue={1}
-                    onStep={value =>
-                      onCustomRangeChange('untilGrainValue', value || 1)
-                    }
-                  />
-                </Col>
-                <Col span={14}>
-                  <Select
-                    options={UNTIL_GRAIN_OPTIONS}
-                    value={UNTIL_GRAIN_OPTIONS.filter(
-                      option => option.value === untilGrain,
-                    )}
-                    onChange={(option: any) =>
-                      onCustomRangeChange('untilGrain', option.value)
-                    }
-                  />
-                </Col>
-              </Row>
-            )}
-          </Col>
-        </Row>
-        {sinceMode === 'relative' && untilMode === 'relative' && (
-          <>
-            <div className="control-label">{t('ANCHOR RELATIVE TO')}</div>
-            <Row align="middle">
-              <Col>
-                <Radio.Group
-                  onChange={onCustomRangeChangeAnchorMode}
-                  defaultValue="now"
-                  value={anchorMode}
-                >
-                  <Radio key="now" value="now">
-                    {t('NOW')}
-                  </Radio>
-                  <Radio key="specific" value="specific">
-                    {t('Date/Time')}
-                  </Radio>
-                </Radio.Group>
-              </Col>
-              {anchorMode !== 'now' && (
-                <Col>
-                  <DatePicker
-                    showTime
-                    value={dttmToMoment(anchorValue)}
-                    onChange={(datetime: Moment) =>
-                      onCustomRangeChange(
-                        'anchorValue',
-                        datetime.format(MOMENT_FORMAT),
-                      )
-                    }
-                    allowClear={false}
-                  />
-                </Col>
-              )}
-            </Row>
-          </>
-        )}
-      </>
-    );
-  }
+  const overlayStyle = {
+    width: '600px',
+  };
 
   return (
     <>
       <ControlHeader {...props} />
-      <Label
-        className="pointer"
-        data-test="time-range-trigger"
-        onClick={() => setShow(true)}
+      <StyledPopover
+        placement="right"
+        trigger="click"
+        content={overlayConetent}
+        title={title}
+        defaultVisible={show}
+        visible={show}
+        onVisibleChange={togglePopover}
+        overlayStyle={overlayStyle}
       >
-        {actualTimeRange}
-      </Label>
-      <Modal
-        name="time-range" // data-test=time-range-modal
-        title={
-          <IconWrapper>
-            <Icon name="edit-alt" />
-            <span className="text">{t('Edit Time Range')}</span>
-          </IconWrapper>
-        }
-        show={show}
-        onHide={onHide}
-        footer={[
-          <Button
-            buttonStyle="secondary"
-            cta
-            key="cancel"
-            onClick={onHide}
-            data-test="modal-cancel-button"
-          >
-            {t('CANCEL')}
-          </Button>,
-          <Button
-            buttonStyle="primary"
-            cta
-            disabled={!validTimeRange}
-            key="apply"
-            onClick={onSave}
-          >
-            {t('APPLY')}
-          </Button>,
-          showValidateBtn() && (
-            <StyledValidateBtn key="validate">
-              <Button
-                buttonStyle="tertiary"
-                cta
-                className="validate-btn"
-                onClick={onValidate}
-              >
-                {t('Validate')}
-              </Button>
-            </StyledValidateBtn>
-          ),
-        ]}
-      >
-        <StyledModalContainer>
-          <div className="control-label">{t('RANGE TYPE')}</div>
-          <Select
-            options={RANGE_FRAME_OPTIONS}
-            value={RANGE_FRAME_OPTIONS.filter(
-              ({ value }) => value === timeRangeFrame,
-            )}
-            onChange={(_: any) => setTimeRangeFrame(_.value)}
-          />
-          {timeRangeFrame !== 'No Filter' && <Divider />}
-          {timeRangeFrame === 'Common' && renderCommon()}
-          {timeRangeFrame === 'Calendar' && renderCalendar()}
-          {timeRangeFrame === 'Advanced' && renderAdvanced()}
-          {timeRangeFrame === 'Custom' && renderCustom()}
-          <Divider />
-          <div>
-            <div className="section-title">{t('Actual Time Range')}</div>
-            {validTimeRange && <div>{evalTimeRange}</div>}
-            {!validTimeRange && (
-              <IconWrapper className="warning">
-                <Icon
-                  name="error-solid-small"
-                  color={supersetTheme.colors.error.base}
-                />
-                <span className="text error">{evalTimeRange}</span>
-              </IconWrapper>
-            )}
-          </div>
-        </StyledModalContainer>
-      </Modal>
+        <Label
+          className="pointer"
+          data-test="time-range-trigger"
+          onClick={() => setShow(true)}
+        >
+          {actualTimeRange}
+        </Label>
+      </StyledPopover>
     </>
   );
 }
diff --git 
a/superset-frontend/src/explore/components/controls/DateFilterControl/constants.ts
 
b/superset-frontend/src/explore/components/controls/DateFilterControl/constants.ts
index acf6450..d1ec0a8 100644
--- 
a/superset-frontend/src/explore/components/controls/DateFilterControl/constants.ts
+++ 
b/superset-frontend/src/explore/components/controls/DateFilterControl/constants.ts
@@ -16,15 +16,18 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import moment from 'moment';
 import { t } from '@superset-ui/core';
 import {
   SelectOptionType,
   PreviousCalendarWeek,
   PreviousCalendarMonth,
   PreviousCalendarYear,
+  CommonRangeType,
+  CalendarRangeType,
 } from './types';
 
-export const RANGE_FRAME_OPTIONS: SelectOptionType[] = [
+export const FRAME_OPTIONS: SelectOptionType[] = [
   { value: 'Common', label: t('Last') },
   { value: 'Calendar', label: t('Previous') },
   { value: 'Custom', label: t('Custom') },
@@ -33,18 +36,24 @@ export const RANGE_FRAME_OPTIONS: SelectOptionType[] = [
 ];
 
 export const COMMON_RANGE_OPTIONS: SelectOptionType[] = [
-  { value: 'Last day', label: t('Last day') },
-  { value: 'Last week', label: t('Last week') },
-  { value: 'Last month', label: t('Last month') },
-  { value: 'Last quarter', label: t('Last quarter') },
-  { value: 'Last year', label: t('Last year') },
+  { value: 'Last day', label: t('last day') },
+  { value: 'Last week', label: t('last week') },
+  { value: 'Last month', label: t('last month') },
+  { value: 'Last quarter', label: t('last quarter') },
+  { value: 'Last year', label: t('last year') },
 ];
+export const COMMON_RANGE_VALUES_SET = new Set(
+  COMMON_RANGE_OPTIONS.map(({ value }) => value),
+);
 
 export const CALENDAR_RANGE_OPTIONS: SelectOptionType[] = [
-  { value: PreviousCalendarWeek, label: t('Previous Calendar week') },
-  { value: PreviousCalendarMonth, label: t('Previous Calendar month') },
-  { value: PreviousCalendarYear, label: t('Previous Calendar year') },
+  { value: PreviousCalendarWeek, label: t('previous calendar week') },
+  { value: PreviousCalendarMonth, label: t('previous calendar month') },
+  { value: PreviousCalendarYear, label: t('previous calendar year') },
 ];
+export const CALENDAR_RANGE_VALUES_SET = new Set(
+  CALENDAR_RANGE_OPTIONS.map(({ value }) => value),
+);
 
 const GRAIN_OPTIONS = [
   { value: 'second', label: (rel: string) => `${t('Seconds')} ${rel}` },
@@ -53,6 +62,7 @@ const GRAIN_OPTIONS = [
   { value: 'day', label: (rel: string) => `${t('Days')} ${rel}` },
   { value: 'week', label: (rel: string) => `${t('Weeks')} ${rel}` },
   { value: 'month', label: (rel: string) => `${t('Months')} ${rel}` },
+  { value: 'quarter', label: (rel: string) => `${t('Quarters')} ${rel}` },
   { value: 'year', label: (rel: string) => `${t('Years')} ${rel}` },
 ];
 
@@ -78,3 +88,25 @@ export const SINCE_MODE_OPTIONS: SelectOptionType[] = [
 ];
 
 export const UNTIL_MODE_OPTIONS: SelectOptionType[] = 
SINCE_MODE_OPTIONS.slice();
+
+export const COMMON_RANGE_SET: Set<CommonRangeType> = new Set([
+  'Last day',
+  'Last week',
+  'Last month',
+  'Last quarter',
+  'Last year',
+]);
+
+export const CALENDAR_RANGE_SET: Set<CalendarRangeType> = new Set([
+  PreviousCalendarWeek,
+  PreviousCalendarMonth,
+  PreviousCalendarYear,
+]);
+
+export const MOMENT_FORMAT = 'YYYY-MM-DD[T]HH:mm:ss';
+export const SEVEN_DAYS_AGO = moment()
+  .utc()
+  .startOf('day')
+  .subtract(7, 'days')
+  .format(MOMENT_FORMAT);
+export const MIDNIGHT = moment().utc().startOf('day').format(MOMENT_FORMAT);
diff --git 
a/superset-frontend/src/explore/components/controls/DateFilterControl/frame/AdvancedFrame.tsx
 
b/superset-frontend/src/explore/components/controls/DateFilterControl/frame/AdvancedFrame.tsx
new file mode 100644
index 0000000..93ce72f
--- /dev/null
+++ 
b/superset-frontend/src/explore/components/controls/DateFilterControl/frame/AdvancedFrame.tsx
@@ -0,0 +1,66 @@
+/**
+ * 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 { SEPARATOR } from 'src/explore/dateFilterUtils';
+import { Input } from 'src/common/components';
+import { FrameComponentProps } from '../types';
+
+export function AdvancedFrame(props: FrameComponentProps) {
+  const [since, until] = getAdvancedRange(props.value || '').split(SEPARATOR);
+
+  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 onChange(control: 'since' | 'until', value: string) {
+    if (control === 'since') {
+      props.onChange(`${value}${SEPARATOR}${until}`);
+    } else {
+      props.onChange(`${since}${SEPARATOR}${value}`);
+    }
+  }
+
+  return (
+    <>
+      <div className="section-title">{t('Configure Advanced Time Range')}</div>
+      <div className="control-label">{t('START')}</div>
+      <Input
+        key="since"
+        value={since}
+        onChange={e => onChange('since', e.target.value)}
+      />
+      <div className="control-label">{t('END')}</div>
+      <Input
+        key="until"
+        value={until}
+        onChange={e => 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 0000000..0be926d
--- /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 (
+    <>
+      <div className="section-title">
+        {t('Configure Time Range: Previous...')}
+      </div>
+      <Radio.Group
+        value={calendarRange}
+        onChange={(e: any) => props.onChange(e.target.value)}
+      >
+        {CALENDAR_RANGE_OPTIONS.map(({ value, label }) => (
+          <Radio key={value} value={value} className="vertical-radio">
+            {label}
+          </Radio>
+        ))}
+      </Radio.Group>
+    </>
+  );
+}
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 0000000..d51b943
--- /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 (
+    <>
+      <div className="section-title">{t('Configure Time Range: Last...')}</div>
+      <Radio.Group
+        value={commonRange}
+        onChange={(e: any) => props.onChange(e.target.value)}
+      >
+        {COMMON_RANGE_OPTIONS.map(({ value, label }) => (
+          <Radio key={value} value={value} className="vertical-radio">
+            {label}
+          </Radio>
+        ))}
+      </Radio.Group>
+    </>
+  );
+}
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 0000000..2ad8e63
--- /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 (
+    <div data-test="custom-frame">
+      <div className="section-title">{t('Configure Custom Time Range')}</div>
+      <Row gutter={24}>
+        <Col span={12}>
+          <div className="control-label">{t('START')}</div>
+          <Select
+            options={SINCE_MODE_OPTIONS}
+            value={SINCE_MODE_OPTIONS.filter(
+              option => option.value === sinceMode,
+            )}
+            onChange={(option: SelectOptionType) =>
+              onChange('sinceMode', option.value)
+            }
+          />
+          {sinceMode === 'specific' && (
+            <Row>
+              <DatePicker
+                showTime
+                value={dttmToMoment(sinceDatetime)}
+                onChange={(datetime: Moment) =>
+                  onChange('sinceDatetime', datetime.format(MOMENT_FORMAT))
+                }
+                allowClear={false}
+              />
+            </Row>
+          )}
+          {sinceMode === 'relative' && (
+            <Row gutter={8}>
+              <Col span={11}>
+                {/* Make sure sinceGrainValue looks like a positive integer */}
+                <InputNumber
+                  placeholder={t('Relative quantity')}
+                  value={Math.abs(sinceGrainValue)}
+                  min={1}
+                  defaultValue={1}
+                  onChange={value =>
+                    onGrainValue('sinceGrainValue', value || 1)
+                  }
+                  onStep={value => onGrainValue('sinceGrainValue', value || 1)}
+                />
+              </Col>
+              <Col span={13}>
+                <Select
+                  options={SINCE_GRAIN_OPTIONS}
+                  value={SINCE_GRAIN_OPTIONS.filter(
+                    option => option.value === sinceGrain,
+                  )}
+                  onChange={(option: SelectOptionType) =>
+                    onChange('sinceGrain', option.value)
+                  }
+                />
+              </Col>
+            </Row>
+          )}
+        </Col>
+        <Col span={12}>
+          <div className="control-label">{t('END')}</div>
+          <Select
+            options={UNTIL_MODE_OPTIONS}
+            value={UNTIL_MODE_OPTIONS.filter(
+              option => option.value === untilMode,
+            )}
+            onChange={(option: SelectOptionType) =>
+              onChange('untilMode', option.value)
+            }
+          />
+          {untilMode === 'specific' && (
+            <Row>
+              <DatePicker
+                showTime
+                value={dttmToMoment(untilDatetime)}
+                onChange={(datetime: Moment) =>
+                  onChange('untilDatetime', datetime.format(MOMENT_FORMAT))
+                }
+                allowClear={false}
+              />
+            </Row>
+          )}
+          {untilMode === 'relative' && (
+            <Row gutter={8}>
+              <Col span={11}>
+                <InputNumber
+                  placeholder={t('Relative quantity')}
+                  value={untilGrainValue}
+                  min={1}
+                  defaultValue={1}
+                  onChange={value =>
+                    onGrainValue('untilGrainValue', value || 1)
+                  }
+                  onStep={value => onGrainValue('untilGrainValue', value || 1)}
+                />
+              </Col>
+              <Col span={13}>
+                <Select
+                  options={UNTIL_GRAIN_OPTIONS}
+                  value={UNTIL_GRAIN_OPTIONS.filter(
+                    option => option.value === untilGrain,
+                  )}
+                  onChange={(option: SelectOptionType) =>
+                    onChange('untilGrain', option.value)
+                  }
+                />
+              </Col>
+            </Row>
+          )}
+        </Col>
+      </Row>
+      {sinceMode === 'relative' && untilMode === 'relative' && (
+        <div className="control-anchor-to">
+          <div className="control-label">{t('ANCHOR TO')}</div>
+          <Row align="middle">
+            <Col>
+              <Radio.Group
+                onChange={onAnchorMode}
+                defaultValue="now"
+                value={anchorMode}
+              >
+                <Radio key="now" value="now">
+                  {t('NOW')}
+                </Radio>
+                <Radio key="specific" value="specific">
+                  {t('Date/Time')}
+                </Radio>
+              </Radio.Group>
+            </Col>
+            {anchorMode !== 'now' && (
+              <Col>
+                <DatePicker
+                  showTime
+                  value={dttmToMoment(anchorValue)}
+                  onChange={(datetime: Moment) =>
+                    onChange('anchorValue', datetime.format(MOMENT_FORMAT))
+                  }
+                  allowClear={false}
+                  className="control-anchor-to-datetime"
+                />
+              </Col>
+            )}
+          </Row>
+        </div>
+      )}
+    </div>
+  );
+}
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 0000000..0bec821
--- /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 18e6194..7e43615 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 0000000..e077958
--- /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}`;
+};

Reply via email to