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}`;
+};