mirror of https://github.com/apache/superset.git
feat(explore): time picker enhancement (#11418)
This commit is contained in:
parent
92fdf54aa3
commit
b592cc7e73
|
@ -24,6 +24,7 @@ chardet==3.0.4 # via aiohttp
|
|||
click==7.1.2 # via apache-superset, flask, flask-appbuilder
|
||||
colorama==0.4.4 # via apache-superset, flask-appbuilder
|
||||
contextlib2==0.6.0.post1 # via apache-superset
|
||||
convertdate==2.3.0 # via holidays
|
||||
cron-descriptor==1.2.24 # via apache-superset
|
||||
croniter==0.3.36 # via apache-superset
|
||||
cryptography==3.2.1 # via apache-superset
|
||||
|
@ -46,6 +47,7 @@ flask==1.1.2 # via apache-superset, flask-appbuilder, flask-babel,
|
|||
geographiclib==1.50 # via geopy
|
||||
geopy==2.0.0 # via apache-superset
|
||||
gunicorn==20.0.4 # via apache-superset
|
||||
holidays==0.10.3 # via apache-superset
|
||||
humanize==3.1.0 # via apache-superset
|
||||
idna==2.10 # via email-validator, yarl
|
||||
importlib-metadata==2.1.1 # via -r requirements/base.in, jsonschema, kombu, markdown
|
||||
|
@ -54,6 +56,7 @@ itsdangerous==1.1.0 # via flask, flask-wtf
|
|||
jinja2==2.11.2 # via flask, flask-babel
|
||||
jsonschema==3.2.0 # via flask-appbuilder
|
||||
kombu==4.6.11 # via celery
|
||||
korean-lunar-calendar==0.2.1 # via holidays
|
||||
mako==1.1.3 # via alembic
|
||||
markdown==3.3.3 # via apache-superset
|
||||
markupsafe==1.1.1 # via jinja2, mako, wtforms
|
||||
|
@ -75,20 +78,21 @@ py==1.9.0 # via retry
|
|||
pyarrow==1.0.1 # via apache-superset
|
||||
pycparser==2.20 # via cffi
|
||||
pyjwt==1.7.1 # via flask-appbuilder, flask-jwt-extended
|
||||
pyparsing==2.4.7 # via packaging
|
||||
pymeeus==0.3.7 # via convertdate
|
||||
pyparsing==2.4.7 # via apache-superset, packaging
|
||||
pyrsistent==0.16.1 # via -r requirements/base.in, jsonschema
|
||||
python-dateutil==2.8.1 # via alembic, apache-superset, croniter, flask-appbuilder, pandas
|
||||
python-dateutil==2.8.1 # via alembic, apache-superset, croniter, flask-appbuilder, holidays, pandas
|
||||
python-dotenv==0.15.0 # via apache-superset
|
||||
python-editor==1.0.4 # via alembic
|
||||
python-geohash==0.8.5 # via apache-superset
|
||||
python3-openid==3.2.0 # via flask-openid
|
||||
pytz==2020.4 # via babel, celery, flask-babel, pandas
|
||||
pytz==2020.4 # via babel, celery, convertdate, flask-babel, pandas
|
||||
pyyaml==5.3.1 # via apache-superset, apispec
|
||||
redis==3.5.3 # via apache-superset
|
||||
retry==0.9.2 # via apache-superset
|
||||
selenium==3.141.0 # via apache-superset
|
||||
simplejson==3.17.2 # via apache-superset
|
||||
six==1.15.0 # via bleach, cryptography, flask-jwt-extended, flask-talisman, isodate, jsonschema, packaging, pathlib2, polyline, prison, pyrsistent, python-dateutil, sqlalchemy-utils, wtforms-json
|
||||
six==1.15.0 # via bleach, cryptography, flask-jwt-extended, flask-talisman, holidays, isodate, jsonschema, pathlib2, polyline, prison, pyrsistent, python-dateutil, sqlalchemy-utils, wtforms-json
|
||||
slackclient==2.5.0 # via apache-superset
|
||||
sqlalchemy-utils==0.36.8 # via apache-superset, flask-appbuilder
|
||||
sqlalchemy==1.3.20 # via alembic, apache-superset, flask-sqlalchemy, marshmallow-sqlalchemy, sqlalchemy-utils
|
||||
|
|
|
@ -30,7 +30,7 @@ combine_as_imports = true
|
|||
include_trailing_comma = true
|
||||
line_length = 88
|
||||
known_first_party = superset
|
||||
known_third_party =alembic,apispec,backoff,bleach,cachelib,celery,click,colorama,contextlib2,cron_descriptor,croniter,cryptography,dateutil,flask,flask_appbuilder,flask_babel,flask_caching,flask_compress,flask_login,flask_migrate,flask_sqlalchemy,flask_talisman,flask_testing,flask_wtf,freezegun,geohash,geopy,humanize,isodate,jinja2,jwt,markdown,markupsafe,marshmallow,msgpack,numpy,pandas,parameterized,parsedatetime,pathlib2,pgsanity,pkg_resources,polyline,prison,pyarrow,pyhive,pytest,pytz,redis,retry,selenium,setuptools,simplejson,slack,sqlalchemy,sqlalchemy_utils,sqlparse,typing_extensions,werkzeug,wtforms,wtforms_json,yaml
|
||||
known_third_party =alembic,apispec,backoff,bleach,cachelib,celery,click,colorama,contextlib2,cron_descriptor,croniter,cryptography,dateutil,flask,flask_appbuilder,flask_babel,flask_caching,flask_compress,flask_login,flask_migrate,flask_sqlalchemy,flask_talisman,flask_testing,flask_wtf,freezegun,geohash,geopy,holidays,humanize,isodate,jinja2,jwt,markdown,markupsafe,marshmallow,msgpack,numpy,pandas,parameterized,parsedatetime,pathlib2,pgsanity,pkg_resources,polyline,prison,pyarrow,pyhive,pyparsing,pytest,pytz,redis,retry,selenium,setuptools,simplejson,slack,sqlalchemy,sqlalchemy_utils,sqlparse,typing_extensions,werkzeug,wtforms,wtforms_json,yaml
|
||||
multi_line_output = 3
|
||||
order_by_type = false
|
||||
|
||||
|
|
2
setup.py
2
setup.py
|
@ -106,6 +106,8 @@ setup(
|
|||
"sqlalchemy-utils>=0.36.6,<0.37",
|
||||
"sqlparse==0.3.0", # PINNED! see https://github.com/andialbrecht/sqlparse/issues/562
|
||||
"wtforms-json",
|
||||
"pyparsing>=2.4.7, <3.0.0",
|
||||
"holidays==0.10.3", # PINNED! https://github.com/dr-prodigy/python-holidays/issues/406
|
||||
],
|
||||
extras_require={
|
||||
"athena": ["pyathena>=1.10.8,<1.11"],
|
||||
|
|
|
@ -138,21 +138,21 @@ describe('Time range filter', () => {
|
|||
cy.visitChartByParams(JSON.stringify(formData));
|
||||
cy.verifySliceSuccess({ waitAlias: '@postJson' });
|
||||
|
||||
cy.get('[data-test=time_range]').within(() => {
|
||||
cy.get('span.label').click();
|
||||
});
|
||||
|
||||
cy.get('#filter-popover').within(() => {
|
||||
cy.get('div.ant-tabs-tabpane-active').within(() => {
|
||||
cy.get('div.PopoverSection :not(.dimmed)').within(() => {
|
||||
cy.get('[data-test=time-range-trigger]')
|
||||
.click()
|
||||
.then(() => {
|
||||
cy.get('.ant-modal-footer')
|
||||
.find('button')
|
||||
.its('length')
|
||||
.should('eq', 3);
|
||||
cy.get('.ant-modal-body').within(() => {
|
||||
cy.get('input[value="100 years ago"]');
|
||||
cy.get('input[value="now"]');
|
||||
});
|
||||
cy.get('[data-test=modal-cancel-button]').click();
|
||||
cy.get('[data-test=time-range-modal]').should('not.be.visible');
|
||||
});
|
||||
});
|
||||
cy.get('#filter-popover button').contains('Ok').click();
|
||||
cy.get('#filter-popover').should('not.be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Groupby control', () => {
|
||||
|
|
|
@ -32,14 +32,17 @@ export {
|
|||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popover,
|
||||
Radio,
|
||||
Row,
|
||||
Select,
|
||||
Skeleton,
|
||||
Switch,
|
||||
|
|
|
@ -0,0 +1,879 @@
|
|||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import rison from 'rison';
|
||||
import moment, { Moment } from 'moment';
|
||||
import {
|
||||
SupersetClient,
|
||||
styled,
|
||||
supersetTheme,
|
||||
t,
|
||||
TimeRangeEndpoints,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
buildTimeRangeString,
|
||||
formatTimeRange,
|
||||
SEPARATOR,
|
||||
} from 'src/explore/dateFilterUtils';
|
||||
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
|
||||
import Button from 'src/components/Button';
|
||||
import ControlHeader from 'src/explore/components/ControlHeader';
|
||||
import Label from 'src/components/Label';
|
||||
import Modal from 'src/common/components/Modal';
|
||||
import {
|
||||
Col,
|
||||
DatePicker,
|
||||
Divider,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Row,
|
||||
} from 'src/common/components';
|
||||
import Icon from 'src/components/Icon';
|
||||
import { Select } from 'src/components/Select';
|
||||
import {
|
||||
TimeRangeFrameType,
|
||||
CommonRangeType,
|
||||
CalendarRangeType,
|
||||
CustomRangeType,
|
||||
CustomRangeDecodeType,
|
||||
CustomRangeKey,
|
||||
PreviousCalendarWeek,
|
||||
PreviousCalendarMonth,
|
||||
PreviousCalendarYear,
|
||||
} from './types';
|
||||
import {
|
||||
COMMON_RANGE_OPTIONS,
|
||||
CALENDAR_RANGE_OPTIONS,
|
||||
RANGE_FRAME_OPTIONS,
|
||||
SINCE_GRAIN_OPTIONS,
|
||||
UNTIL_GRAIN_OPTIONS,
|
||||
SINCE_MODE_OPTIONS,
|
||||
UNTIL_MODE_OPTIONS,
|
||||
} from './constants';
|
||||
|
||||
const MOMENT_FORMAT = 'YYYY-MM-DD[T]HH:mm:ss';
|
||||
const DEFAULT_SINCE = moment()
|
||||
.utc()
|
||||
.startOf('day')
|
||||
.subtract(7, 'days')
|
||||
.format(MOMENT_FORMAT);
|
||||
const DEFAULT_UNTIL = moment().utc().startOf('day').format(MOMENT_FORMAT);
|
||||
|
||||
/**
|
||||
* RegExp to test a string for a full ISO 8601 Date
|
||||
* Does not do any sort of date validation, only checks if the string is according to the ISO 8601 spec.
|
||||
* YYYY-MM-DDThh:mm:ss
|
||||
* YYYY-MM-DDThh:mm:ssTZD
|
||||
* YYYY-MM-DDThh:mm:ss.sTZD
|
||||
* @see: https://www.w3.org/TR/NOTE-datetime
|
||||
*/
|
||||
const iso8601 = String.raw`\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:(?:[+-]\d\d:\d\d)|Z)?`;
|
||||
const datetimeConstant = String.raw`TODAY|NOW`;
|
||||
const grainValue = String.raw`[+-]?[1-9][0-9]*`;
|
||||
const grain = String.raw`YEAR|QUARTER|MONTH|WEEK|DAY|HOUR|MINUTE|SECOND`;
|
||||
const CUSTOM_RANGE_EXPRESSION = RegExp(
|
||||
String.raw`^DATEADD\(DATETIME\("(${iso8601}|${datetimeConstant})"\),\s(${grainValue}),\s(${grain})\)$`,
|
||||
'i',
|
||||
);
|
||||
export const ISO8601_AND_CONSTANT = RegExp(
|
||||
String.raw`^${iso8601}$|^${datetimeConstant}$`,
|
||||
'i',
|
||||
);
|
||||
|
||||
const DATETIME_CONSTANT = ['now', 'today'];
|
||||
const defaultCustomRange: CustomRangeType = {
|
||||
sinceDatetime: DEFAULT_SINCE,
|
||||
sinceMode: 'relative',
|
||||
sinceGrain: 'day',
|
||||
sinceGrainValue: -7,
|
||||
untilDatetime: DEFAULT_UNTIL,
|
||||
untilMode: 'specific',
|
||||
untilGrain: 'day',
|
||||
untilGrainValue: 7,
|
||||
anchorMode: 'now',
|
||||
anchorValue: 'now',
|
||||
};
|
||||
const SPECIFIC_MODE = ['specific', 'today', 'now'];
|
||||
|
||||
const COMMON_RANGE_OPTIONS_SET = new Set(
|
||||
COMMON_RANGE_OPTIONS.map(({ value }) => value),
|
||||
);
|
||||
const CALENDAR_RANGE_OPTIONS_SET = new Set(
|
||||
CALENDAR_RANGE_OPTIONS.map(({ value }) => value),
|
||||
);
|
||||
|
||||
const commonRangeSet: Set<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)) {
|
||||
return 'Common';
|
||||
}
|
||||
if (CALENDAR_RANGE_OPTIONS_SET.has(timeRange)) {
|
||||
return 'Calendar';
|
||||
}
|
||||
if (timeRange === 'No filter') {
|
||||
return 'No Filter';
|
||||
}
|
||||
if (customTimeRangeDecode(timeRange).matchedFlag) {
|
||||
return 'Custom';
|
||||
}
|
||||
return 'Advanced';
|
||||
};
|
||||
|
||||
const dttmToMoment = (dttm: string): Moment => {
|
||||
if (dttm === 'now') {
|
||||
return moment().utc().startOf('second');
|
||||
}
|
||||
if (dttm === 'today') {
|
||||
return moment().utc().startOf('day');
|
||||
}
|
||||
return moment(dttm);
|
||||
};
|
||||
|
||||
const fetchTimeRange = async (
|
||||
timeRange: string,
|
||||
endpoints?: TimeRangeEndpoints,
|
||||
) => {
|
||||
const query = rison.encode(timeRange);
|
||||
const endpoint = `/api/v1/time_range/?q=${query}`;
|
||||
|
||||
try {
|
||||
const response = await SupersetClient.get({ endpoint });
|
||||
const timeRangeString = buildTimeRangeString(
|
||||
response?.json?.result?.since || '',
|
||||
response?.json?.result?.until || '',
|
||||
);
|
||||
return {
|
||||
value: formatTimeRange(timeRangeString, endpoints),
|
||||
};
|
||||
} catch (response) {
|
||||
const clientError = await getClientErrorObject(response);
|
||||
return {
|
||||
error: clientError.message || clientError.error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const StyledModalContainer = styled.div`
|
||||
.ant-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ant-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-picker {
|
||||
padding: 4px 17px 4px;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-divider-horizontal {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #b2b2b2;
|
||||
line-height: 16px;
|
||||
text-transform: uppercase;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.vertical-radio {
|
||||
display: block;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledValidateBtn = styled.span`
|
||||
.validate-btn {
|
||||
float: left;
|
||||
}
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
svg {
|
||||
margin-right: ${({ theme }) => 2 * theme.gridUnit}px;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
}
|
||||
.text {
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
}
|
||||
.error {
|
||||
color: ${({ theme }) => theme.colors.error.base};
|
||||
}
|
||||
`;
|
||||
|
||||
interface DateFilterLabelProps {
|
||||
name: string;
|
||||
onChange: (timeRange: string) => void;
|
||||
value?: string;
|
||||
endpoints?: TimeRangeEndpoints;
|
||||
}
|
||||
|
||||
export default function DateFilterControl(props: DateFilterLabelProps) {
|
||||
const { value = 'Last week', endpoints, onChange } = props;
|
||||
const [actualTimeRange, setActualTimeRange] = useState<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 [validTimeRange, setValidTimeRange] = useState<boolean>(false);
|
||||
const [evalTimeRange, setEvalTimeRange] = useState<string>(value);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTimeRange(value, endpoints).then(({ value, error }) => {
|
||||
if (error) {
|
||||
setEvalTimeRange(error || '');
|
||||
setValidTimeRange(false);
|
||||
} else {
|
||||
setActualTimeRange(value || '');
|
||||
setValidTimeRange(true);
|
||||
}
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const value = getCurrentValue();
|
||||
fetchTimeRange(value, endpoints).then(({ value, error }) => {
|
||||
if (error) {
|
||||
setEvalTimeRange(error || '');
|
||||
setValidTimeRange(false);
|
||||
} else {
|
||||
setEvalTimeRange(value || '');
|
||||
setValidTimeRange(true);
|
||||
}
|
||||
});
|
||||
}, [timeRangeFrame, commonRange, calendarRange, customRange]);
|
||||
|
||||
function getCurrentValue(): string {
|
||||
// get current time_range string
|
||||
let value = 'Last week';
|
||||
if (timeRangeFrame === 'Common') {
|
||||
value = commonRange;
|
||||
}
|
||||
if (timeRangeFrame === 'Calendar') {
|
||||
value = calendarRange;
|
||||
}
|
||||
if (timeRangeFrame === 'Custom') {
|
||||
value = customTimeRangeEncode(customRange);
|
||||
}
|
||||
if (timeRangeFrame === 'Advanced') {
|
||||
value = advancedRange;
|
||||
}
|
||||
if (timeRangeFrame === 'No Filter') {
|
||||
value = 'No filter';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getDefaultOrCommonRange(value: any): CommonRangeType {
|
||||
return commonRangeSet.has(value) ? value : 'Last week';
|
||||
}
|
||||
|
||||
function getDefaultOrCalendarRange(value: any): CalendarRangeType {
|
||||
return CalendarRangeSet.has(value) ? value : PreviousCalendarWeek;
|
||||
}
|
||||
|
||||
function getAdvancedRange(value: string): string {
|
||||
if (value.includes(SEPARATOR)) {
|
||||
return value;
|
||||
}
|
||||
if (value.startsWith('Last')) {
|
||||
return [value, ''].join(SEPARATOR);
|
||||
}
|
||||
if (value.startsWith('Next')) {
|
||||
return ['', value].join(SEPARATOR);
|
||||
}
|
||||
return SEPARATOR;
|
||||
}
|
||||
|
||||
function onAdvancedRangeChange(control: 'since' | 'until', value: string) {
|
||||
setValidTimeRange(false);
|
||||
setEvalTimeRange(t('Need to verify the time range.'));
|
||||
const [since, until] = advancedRange.split(SEPARATOR);
|
||||
if (control === 'since') {
|
||||
setAdvancedRange(`${value}${SEPARATOR}${until}`);
|
||||
} else {
|
||||
setAdvancedRange(`${since}${SEPARATOR}${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function onCustomRangeChange(
|
||||
control: CustomRangeKey,
|
||||
value: string | number,
|
||||
) {
|
||||
setCustomRange({
|
||||
...customRange,
|
||||
[control]: value,
|
||||
});
|
||||
}
|
||||
|
||||
function onCustomRangeChangeAnchorMode(option: any) {
|
||||
const radioValue = option.target.value;
|
||||
if (radioValue === 'now') {
|
||||
setCustomRange({
|
||||
...customRange,
|
||||
anchorValue: 'now',
|
||||
anchorMode: radioValue,
|
||||
});
|
||||
} else {
|
||||
setCustomRange({
|
||||
...customRange,
|
||||
anchorValue: DEFAULT_UNTIL,
|
||||
anchorMode: radioValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function showValidateBtn(): boolean {
|
||||
return timeRangeFrame === 'Advanced';
|
||||
}
|
||||
|
||||
function resetState(value: string) {
|
||||
setTimeRangeFrame(guessTimeRangeFrame(value));
|
||||
setCommonRange(getDefaultOrCommonRange(value));
|
||||
setCalendarRange(getDefaultOrCalendarRange(value));
|
||||
setCustomRange(customTimeRangeDecode(value).customRange);
|
||||
setAdvancedRange(getAdvancedRange(value));
|
||||
setShow(false);
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
const currentValue = getCurrentValue();
|
||||
onChange(currentValue);
|
||||
resetState(currentValue);
|
||||
}
|
||||
|
||||
function onHide() {
|
||||
resetState(value);
|
||||
}
|
||||
|
||||
function onValidate() {
|
||||
const value = getCurrentValue();
|
||||
fetchTimeRange(value, endpoints).then(({ value, error }) => {
|
||||
if (error) {
|
||||
setEvalTimeRange(error || '');
|
||||
setValidTimeRange(false);
|
||||
} else {
|
||||
setEvalTimeRange(value || '');
|
||||
setValidTimeRange(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderCommon() {
|
||||
const commonRangeValue =
|
||||
COMMON_RANGE_OPTIONS.find(({ value }) => value === commonRange)?.value ||
|
||||
'Last week';
|
||||
return (
|
||||
<>
|
||||
<div className="section-title">
|
||||
{t('Configure Time Range: Last...')}
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={commonRangeValue}
|
||||
onChange={(e: any) => setCommonRange(e.target.value)}
|
||||
>
|
||||
{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)}
|
||||
>
|
||||
{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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCustom() {
|
||||
const {
|
||||
sinceDatetime,
|
||||
sinceMode,
|
||||
sinceGrain,
|
||||
sinceGrainValue,
|
||||
untilDatetime,
|
||||
untilMode,
|
||||
untilGrain,
|
||||
untilGrainValue,
|
||||
anchorValue,
|
||||
anchorMode,
|
||||
} = { ...customRange };
|
||||
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ControlHeader {...props} />
|
||||
<Label
|
||||
className="pointer"
|
||||
data-test="time-range-trigger"
|
||||
onClick={() => setShow(true)}
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* 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 { t } from '@superset-ui/core';
|
||||
import {
|
||||
SelectOptionType,
|
||||
PreviousCalendarWeek,
|
||||
PreviousCalendarMonth,
|
||||
PreviousCalendarYear,
|
||||
} from './types';
|
||||
|
||||
export const RANGE_FRAME_OPTIONS: SelectOptionType[] = [
|
||||
{ value: 'Common', label: t('Last') },
|
||||
{ value: 'Calendar', label: t('Previous') },
|
||||
{ value: 'Custom', label: t('Custom') },
|
||||
{ value: 'Advanced', label: t('Advanced') },
|
||||
{ value: 'No Filter', label: t('No Filter') },
|
||||
];
|
||||
|
||||
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') },
|
||||
];
|
||||
|
||||
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') },
|
||||
];
|
||||
|
||||
const GRAIN_OPTIONS = [
|
||||
{ value: 'second', label: (rel: string) => `${t('Seconds')} ${rel}` },
|
||||
{ value: 'minute', label: (rel: string) => `${t('Minutes')} ${rel}` },
|
||||
{ value: 'hour', label: (rel: string) => `${t('Hours')} ${rel}` },
|
||||
{ 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: 'year', label: (rel: string) => `${t('Years')} ${rel}` },
|
||||
];
|
||||
|
||||
export const SINCE_GRAIN_OPTIONS: SelectOptionType[] = GRAIN_OPTIONS.map(
|
||||
item => ({
|
||||
value: item.value,
|
||||
label: item.label(t('Before')),
|
||||
}),
|
||||
);
|
||||
|
||||
export const UNTIL_GRAIN_OPTIONS: SelectOptionType[] = GRAIN_OPTIONS.map(
|
||||
item => ({
|
||||
value: item.value,
|
||||
label: item.label(t('After')),
|
||||
}),
|
||||
);
|
||||
|
||||
export const SINCE_MODE_OPTIONS: SelectOptionType[] = [
|
||||
{ value: 'specific', label: t('Specific Date/Time') },
|
||||
{ value: 'relative', label: t('Relative Date/Time') },
|
||||
{ value: 'now', label: t('Now') },
|
||||
{ value: 'today', label: t('Midnight') },
|
||||
];
|
||||
|
||||
export const UNTIL_MODE_OPTIONS: SelectOptionType[] = SINCE_MODE_OPTIONS.slice();
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* 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 type SelectOptionType = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type TimeRangeFrameType =
|
||||
| 'Common'
|
||||
| 'Calendar'
|
||||
| 'Custom'
|
||||
| 'Advanced'
|
||||
| 'No Filter';
|
||||
|
||||
export type DateTimeGrainType =
|
||||
| 'second'
|
||||
| 'minite'
|
||||
| 'hour'
|
||||
| 'day'
|
||||
| 'week'
|
||||
| 'month'
|
||||
| 'year';
|
||||
|
||||
export type CustomRangeKey =
|
||||
| 'sinceMode'
|
||||
| 'sinceDatetime'
|
||||
| 'sinceGrain'
|
||||
| 'sinceGrainValue'
|
||||
| 'untilMode'
|
||||
| 'untilDatetime'
|
||||
| 'untilGrain'
|
||||
| 'untilGrainValue'
|
||||
| 'anchorMode'
|
||||
| 'anchorValue';
|
||||
|
||||
export type CustomRangeType = {
|
||||
sinceMode: string;
|
||||
sinceDatetime: string;
|
||||
sinceGrain: string;
|
||||
sinceGrainValue: number;
|
||||
untilMode: string;
|
||||
untilDatetime: string;
|
||||
untilGrain: string;
|
||||
untilGrainValue: number;
|
||||
anchorMode: 'now' | 'specific';
|
||||
anchorValue: string;
|
||||
};
|
||||
|
||||
export type CustomRangeDecodeType = {
|
||||
customRange: CustomRangeType;
|
||||
matchedFlag: boolean;
|
||||
};
|
||||
|
||||
export type CommonRangeType =
|
||||
| 'Last day'
|
||||
| 'Last week'
|
||||
| 'Last month'
|
||||
| 'Last quarter'
|
||||
| 'Last year';
|
||||
|
||||
export const PreviousCalendarWeek =
|
||||
'DATETRUNC(DATEADD(DATETIME("TODAY"), -1, WEEK), WEEK) : LASTDAY(DATEADD(DATETIME("TODAY"), -1, WEEK), WEEK)';
|
||||
export const PreviousCalendarMonth =
|
||||
'DATETRUNC(DATEADD(DATETIME("TODAY"), -1, MONTH), MONTH) : LASTDAY(DATEADD(DATETIME("TODAY"), -1, MONTH), MONTH)';
|
||||
export const PreviousCalendarYear =
|
||||
'DATETRUNC(DATEADD(DATETIME("TODAY"), -1, YEAR), YEAR) : LASTDAY(DATEADD(DATETIME("TODAY"), -1, YEAR), YEAR)';
|
||||
export type CalendarRangeType =
|
||||
| typeof PreviousCalendarWeek
|
||||
| typeof PreviousCalendarMonth
|
||||
| typeof PreviousCalendarYear;
|
|
@ -24,7 +24,7 @@ import ColorMapControl from './ColorMapControl';
|
|||
import ColorPickerControl from './ColorPickerControl';
|
||||
import ColorSchemeControl from './ColorSchemeControl';
|
||||
import DatasourceControl from './DatasourceControl';
|
||||
import DateFilterControl from './DateFilterControl';
|
||||
import DateFilterControl from './DateFilterControl/DateFilterControl';
|
||||
import FixedOrMetricControl from './FixedOrMetricControl';
|
||||
import HiddenControl from './HiddenControl';
|
||||
import SelectAsyncControl from './SelectAsyncControl';
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
*/
|
||||
import { TimeRangeEndpoints } from '@superset-ui/core';
|
||||
|
||||
const SEPARATOR = ' : ';
|
||||
export const SEPARATOR = ' : ';
|
||||
|
||||
export const buildTimeRangeString = (since: string, until: string): string =>
|
||||
`${since}${SEPARATOR}${until}`;
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Utility functions used across Superset"""
|
||||
import calendar
|
||||
import decimal
|
||||
import errno
|
||||
import functools
|
||||
|
@ -75,6 +76,18 @@ from flask import current_app, flash, g, Markup, render_template
|
|||
from flask_appbuilder import SQLA
|
||||
from flask_appbuilder.security.sqla.models import Role, User
|
||||
from flask_babel import gettext as __, lazy_gettext as _
|
||||
from holidays import CountryHoliday
|
||||
from pyparsing import (
|
||||
CaselessKeyword,
|
||||
Forward,
|
||||
Group,
|
||||
Optional as ppOptional,
|
||||
ParseException,
|
||||
ParseResults,
|
||||
pyparsing_common,
|
||||
quotedString,
|
||||
Suppress,
|
||||
)
|
||||
from sqlalchemy import event, exc, select, Text
|
||||
from sqlalchemy.dialects.mysql import MEDIUMTEXT
|
||||
from sqlalchemy.engine import Connection, Engine
|
||||
|
@ -450,6 +463,10 @@ def parse_human_datetime(human_readable: str) -> datetime:
|
|||
>>> year_ago_2 = (datetime.now() - relativedelta(years=1)).date()
|
||||
>>> year_ago_1 == year_ago_2
|
||||
True
|
||||
>>> year_after_1 = parse_human_datetime('2 years after').date()
|
||||
>>> year_after_2 = (datetime.now() + relativedelta(years=2)).date()
|
||||
>>> year_after_1 == year_after_2
|
||||
True
|
||||
"""
|
||||
try:
|
||||
dttm = parse(human_readable)
|
||||
|
@ -499,28 +516,26 @@ class DashboardEncoder(json.JSONEncoder):
|
|||
return json.JSONEncoder(sort_keys=True).default(o)
|
||||
|
||||
|
||||
def parse_human_timedelta(human_readable: Optional[str]) -> timedelta:
|
||||
def parse_human_timedelta(
|
||||
human_readable: Optional[str], source_time: Optional[datetime] = None,
|
||||
) -> timedelta:
|
||||
"""
|
||||
Returns ``datetime.datetime`` from natural language time deltas
|
||||
Returns ``datetime.timedelta`` from natural language time deltas
|
||||
|
||||
>>> parse_human_datetime('now') <= datetime.now()
|
||||
>>> parse_human_timedelta('1 day') == timedelta(days=1)
|
||||
True
|
||||
"""
|
||||
cal = parsedatetime.Calendar()
|
||||
dttm = dttm_from_timetuple(datetime.now().timetuple())
|
||||
date_ = cal.parse(human_readable or "", dttm)[0]
|
||||
date_ = datetime(
|
||||
date_.tm_year,
|
||||
date_.tm_mon,
|
||||
date_.tm_mday,
|
||||
date_.tm_hour,
|
||||
date_.tm_min,
|
||||
date_.tm_sec,
|
||||
source_dttm = dttm_from_timetuple(
|
||||
source_time.timetuple() if source_time else datetime.now().timetuple()
|
||||
)
|
||||
return date_ - dttm
|
||||
modified_dttm = dttm_from_timetuple(cal.parse(human_readable or "", source_dttm)[0])
|
||||
return modified_dttm - source_dttm
|
||||
|
||||
|
||||
def parse_past_timedelta(delta_str: str) -> timedelta:
|
||||
def parse_past_timedelta(
|
||||
delta_str: str, source_time: Optional[datetime] = None
|
||||
) -> timedelta:
|
||||
"""
|
||||
Takes a delta like '1 year' and finds the timedelta for that period in
|
||||
the past, then represents that past timedelta in positive terms.
|
||||
|
@ -530,7 +545,7 @@ def parse_past_timedelta(delta_str: str) -> timedelta:
|
|||
or datetime.timedelta(365).
|
||||
"""
|
||||
return -parse_human_timedelta(
|
||||
delta_str if delta_str.startswith("-") else f"-{delta_str}"
|
||||
delta_str if delta_str.startswith("-") else f"-{delta_str}", source_time,
|
||||
)
|
||||
|
||||
|
||||
|
@ -1231,7 +1246,205 @@ def ensure_path_exists(path: str) -> None:
|
|||
raise
|
||||
|
||||
|
||||
def get_since_until( # pylint: disable=too-many-arguments
|
||||
class EvalText: # pylint: disable=too-few-public-methods
|
||||
def __init__(self, tokens: ParseResults) -> None:
|
||||
self.value = tokens[0]
|
||||
|
||||
def eval(self) -> str:
|
||||
# strip quotes
|
||||
return self.value[1:-1]
|
||||
|
||||
|
||||
class EvalDateTimeFunc: # pylint: disable=too-few-public-methods
|
||||
def __init__(self, tokens: ParseResults) -> None:
|
||||
self.value = tokens[1]
|
||||
|
||||
def eval(self) -> datetime:
|
||||
return parse_human_datetime(self.value.eval())
|
||||
|
||||
|
||||
class EvalDateAddFunc: # pylint: disable=too-few-public-methods
|
||||
def __init__(self, tokens: ParseResults) -> None:
|
||||
self.value = tokens[1]
|
||||
|
||||
def eval(self) -> datetime:
|
||||
dttm_expression, delta, unit = self.value
|
||||
dttm = dttm_expression.eval()
|
||||
if unit.lower() == "quarter":
|
||||
delta = delta * 3
|
||||
unit = "month"
|
||||
return dttm + parse_human_timedelta(f"{delta} {unit}s", dttm)
|
||||
|
||||
|
||||
class EvalDateTruncFunc: # pylint: disable=too-few-public-methods
|
||||
def __init__(self, tokens: ParseResults) -> None:
|
||||
self.value = tokens[1]
|
||||
|
||||
def eval(self) -> datetime:
|
||||
dttm_expression, unit = self.value
|
||||
dttm = dttm_expression.eval()
|
||||
if unit == "year":
|
||||
dttm = dttm.replace(
|
||||
month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
elif unit == "month":
|
||||
dttm = dttm.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
elif unit == "week":
|
||||
dttm = dttm - relativedelta(days=dttm.weekday())
|
||||
dttm = dttm.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
elif unit == "day":
|
||||
dttm = dttm.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
elif unit == "hour":
|
||||
dttm = dttm.replace(minute=0, second=0, microsecond=0)
|
||||
elif unit == "minute":
|
||||
dttm = dttm.replace(second=0, microsecond=0)
|
||||
else:
|
||||
dttm = dttm.replace(microsecond=0)
|
||||
return dttm
|
||||
|
||||
|
||||
class EvalLastDayFunc: # pylint: disable=too-few-public-methods
|
||||
def __init__(self, tokens: ParseResults) -> None:
|
||||
self.value = tokens[1]
|
||||
|
||||
def eval(self) -> datetime:
|
||||
dttm_expression, unit = self.value
|
||||
dttm = dttm_expression.eval()
|
||||
if unit == "year":
|
||||
return dttm.replace(
|
||||
month=12, day=31, hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
if unit == "month":
|
||||
return dttm.replace(
|
||||
day=calendar.monthrange(dttm.year, dttm.month)[1],
|
||||
hour=0,
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
# unit == "week":
|
||||
mon = dttm - relativedelta(days=dttm.weekday())
|
||||
mon = mon.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return mon + relativedelta(days=6)
|
||||
|
||||
|
||||
class EvalHolidayFunc: # pylint: disable=too-few-public-methods
|
||||
def __init__(self, tokens: ParseResults) -> None:
|
||||
self.value = tokens[1]
|
||||
|
||||
def eval(self) -> datetime:
|
||||
holiday = self.value[0].eval()
|
||||
dttm, country = [None, None]
|
||||
if len(self.value) >= 2:
|
||||
dttm = self.value[1].eval()
|
||||
if len(self.value) == 3:
|
||||
country = self.value[2]
|
||||
holiday_year = dttm.year if dttm else parse_human_datetime("today").year
|
||||
country = country.eval() if country else "US"
|
||||
|
||||
holiday_lookup = CountryHoliday(country, years=[holiday_year], observed=False)
|
||||
searched_result = holiday_lookup.get_named(holiday)
|
||||
if len(searched_result) == 1:
|
||||
return dttm_from_timetuple(searched_result[0].timetuple())
|
||||
raise ValueError(_("Unable to find such a holiday: [{}]").format(holiday))
|
||||
|
||||
|
||||
@memoized()
|
||||
def datetime_parser() -> ParseResults: # pylint: disable=too-many-locals
|
||||
( # pylint: disable=invalid-name
|
||||
DATETIME,
|
||||
DATEADD,
|
||||
DATETRUNC,
|
||||
LASTDAY,
|
||||
HOLIDAY,
|
||||
YEAR,
|
||||
QUARTER,
|
||||
MONTH,
|
||||
WEEK,
|
||||
DAY,
|
||||
HOUR,
|
||||
MINUTE,
|
||||
SECOND,
|
||||
) = map(
|
||||
CaselessKeyword,
|
||||
"datetime dateadd datetrunc lastday holiday "
|
||||
"year quarter month week day hour minute second".split(),
|
||||
)
|
||||
lparen, rparen, comma = map(Suppress, "(),")
|
||||
int_operand = pyparsing_common.signed_integer().setName("int_operand")
|
||||
text_operand = quotedString.setName("text_operand").setParseAction(EvalText)
|
||||
|
||||
# allow expression to be used recursively
|
||||
datetime_func = Forward().setName("datetime")
|
||||
dateadd_func = Forward().setName("dateadd")
|
||||
datetrunc_func = Forward().setName("datetrunc")
|
||||
lastday_func = Forward().setName("lastday")
|
||||
holiday_func = Forward().setName("holiday")
|
||||
date_expr = (
|
||||
datetime_func | dateadd_func | datetrunc_func | lastday_func | holiday_func
|
||||
)
|
||||
|
||||
datetime_func <<= (DATETIME + lparen + text_operand + rparen).setParseAction(
|
||||
EvalDateTimeFunc
|
||||
)
|
||||
dateadd_func <<= (
|
||||
DATEADD
|
||||
+ lparen
|
||||
+ Group(
|
||||
date_expr
|
||||
+ comma
|
||||
+ int_operand
|
||||
+ comma
|
||||
+ (YEAR | QUARTER | MONTH | WEEK | DAY | HOUR | MINUTE | SECOND)
|
||||
+ ppOptional(comma)
|
||||
)
|
||||
+ rparen
|
||||
).setParseAction(EvalDateAddFunc)
|
||||
datetrunc_func <<= (
|
||||
DATETRUNC
|
||||
+ lparen
|
||||
+ Group(
|
||||
date_expr
|
||||
+ comma
|
||||
+ (YEAR | MONTH | WEEK | DAY | HOUR | MINUTE | SECOND)
|
||||
+ ppOptional(comma)
|
||||
)
|
||||
+ rparen
|
||||
).setParseAction(EvalDateTruncFunc)
|
||||
lastday_func <<= (
|
||||
LASTDAY
|
||||
+ lparen
|
||||
+ Group(date_expr + comma + (YEAR | MONTH | WEEK) + ppOptional(comma))
|
||||
+ rparen
|
||||
).setParseAction(EvalLastDayFunc)
|
||||
holiday_func <<= (
|
||||
HOLIDAY
|
||||
+ lparen
|
||||
+ Group(
|
||||
text_operand
|
||||
+ ppOptional(comma)
|
||||
+ ppOptional(date_expr)
|
||||
+ ppOptional(comma)
|
||||
+ ppOptional(text_operand)
|
||||
+ ppOptional(comma)
|
||||
)
|
||||
+ rparen
|
||||
).setParseAction(EvalHolidayFunc)
|
||||
|
||||
return date_expr
|
||||
|
||||
|
||||
def datetime_eval(datetime_expression: Optional[str] = None) -> Optional[datetime]:
|
||||
if datetime_expression:
|
||||
try:
|
||||
return datetime_parser().parseString(datetime_expression)[0].eval()
|
||||
except ParseException as error:
|
||||
raise ValueError(error)
|
||||
return None
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments, too-many-locals, too-many-branches
|
||||
def get_since_until(
|
||||
time_range: Optional[str] = None,
|
||||
since: Optional[str] = None,
|
||||
until: Optional[str] = None,
|
||||
|
@ -1264,74 +1477,79 @@ def get_since_until( # pylint: disable=too-many-arguments
|
|||
|
||||
"""
|
||||
separator = " : "
|
||||
relative_start = parse_human_datetime( # type: ignore
|
||||
relative_start if relative_start else "today"
|
||||
)
|
||||
relative_end = parse_human_datetime( # type: ignore
|
||||
relative_end if relative_end else "today"
|
||||
)
|
||||
common_time_frames = {
|
||||
"Last day": (
|
||||
relative_start - relativedelta(days=1), # type: ignore
|
||||
relative_end,
|
||||
),
|
||||
"Last week": (
|
||||
relative_start - relativedelta(weeks=1), # type: ignore
|
||||
relative_end,
|
||||
),
|
||||
"Last month": (
|
||||
relative_start - relativedelta(months=1), # type: ignore
|
||||
relative_end,
|
||||
),
|
||||
"Last quarter": (
|
||||
relative_start - relativedelta(months=3), # type: ignore
|
||||
relative_end,
|
||||
),
|
||||
"Last year": (
|
||||
relative_start - relativedelta(years=1), # type: ignore
|
||||
relative_end,
|
||||
),
|
||||
}
|
||||
_relative_start = relative_start if relative_start else "today"
|
||||
_relative_end = relative_end if relative_end else "today"
|
||||
|
||||
if time_range:
|
||||
if separator in time_range:
|
||||
since, until = time_range.split(separator, 1)
|
||||
if since and since not in common_time_frames:
|
||||
since = add_ago_to_since(since)
|
||||
since = parse_human_datetime(since) if since else None # type: ignore
|
||||
until = parse_human_datetime(until) if until else None # type: ignore
|
||||
elif time_range in common_time_frames:
|
||||
since, until = common_time_frames[time_range]
|
||||
elif time_range == "No filter":
|
||||
since = until = None
|
||||
else:
|
||||
rel, num, grain = time_range.split()
|
||||
if rel == "Last":
|
||||
since = relative_start - relativedelta( # type: ignore
|
||||
**{grain: int(num)} # type: ignore
|
||||
)
|
||||
until = relative_end
|
||||
else: # rel == 'Next'
|
||||
since = relative_start
|
||||
until = relative_end + relativedelta( # type: ignore
|
||||
**{grain: int(num)} # type: ignore
|
||||
)
|
||||
if time_range == "No filter":
|
||||
return None, None
|
||||
|
||||
if time_range and time_range.startswith("Last") and separator not in time_range:
|
||||
time_range = time_range + separator + _relative_end
|
||||
|
||||
if time_range and time_range.startswith("Next") and separator not in time_range:
|
||||
time_range = _relative_start + separator + time_range
|
||||
|
||||
if time_range and separator in time_range:
|
||||
time_range_lookup = [
|
||||
(
|
||||
r"^last\s+(day|week|month|quarter|year)$",
|
||||
lambda unit: f"DATEADD(DATETIME('{_relative_start}'), -1, {unit})",
|
||||
),
|
||||
(
|
||||
r"^last\s+([0-9]+)\s+(second|minute|hour|day|week|month|year)s$",
|
||||
lambda delta, unit: f"DATEADD(DATETIME('{_relative_start}'), -{int(delta)}, {unit})", # pylint: disable=line-too-long
|
||||
),
|
||||
(
|
||||
r"^next\s+([0-9]+)\s+(second|minute|hour|day|week|month|year)s$",
|
||||
lambda delta, unit: f"DATEADD(DATETIME('{_relative_end}'), {int(delta)}, {unit})", # pylint: disable=line-too-long
|
||||
),
|
||||
(
|
||||
r"^(DATETIME.*|DATEADD.*|DATETRUNC.*|LASTDAY.*|HOLIDAY.*)$",
|
||||
lambda text: text,
|
||||
),
|
||||
]
|
||||
|
||||
since_and_until_partition = [_.strip() for _ in time_range.split(separator, 1)]
|
||||
since_and_until: List[Optional[str]] = []
|
||||
for part in since_and_until_partition:
|
||||
if not part:
|
||||
# if since or until is "", set as None
|
||||
since_and_until.append(None)
|
||||
continue
|
||||
|
||||
# Is it possible to match to time_range_lookup
|
||||
matched = False
|
||||
for pattern, fn in time_range_lookup:
|
||||
result = re.search(pattern, part, re.IGNORECASE)
|
||||
if result:
|
||||
matched = True
|
||||
# converted matched time_range to "formal time expressions"
|
||||
since_and_until.append(fn(*result.groups())) # type: ignore
|
||||
if not matched:
|
||||
# default matched case
|
||||
since_and_until.append(f"DATETIME('{part}')")
|
||||
|
||||
_since, _until = map(datetime_eval, since_and_until)
|
||||
else:
|
||||
since = since or ""
|
||||
if since:
|
||||
since = add_ago_to_since(since)
|
||||
since = parse_human_datetime(since) if since else None # type: ignore
|
||||
until = parse_human_datetime(until) if until else relative_end # type: ignore
|
||||
_since = parse_human_datetime(since) if since else None
|
||||
_until = (
|
||||
parse_human_datetime(until)
|
||||
if until
|
||||
else parse_human_datetime(_relative_end)
|
||||
)
|
||||
|
||||
if time_shift:
|
||||
time_delta = parse_past_timedelta(time_shift)
|
||||
since = since if since is None else (since - time_delta) # type: ignore
|
||||
until = until if until is None else (until - time_delta) # type: ignore
|
||||
_since = _since if _since is None else (_since - time_delta)
|
||||
_until = _until if _until is None else (_until - time_delta)
|
||||
|
||||
if since and until and since > until:
|
||||
if _since and _until and _since > _until:
|
||||
raise ValueError(_("From date cannot be larger than to date"))
|
||||
|
||||
return since, until # type: ignore
|
||||
return _since, _until
|
||||
|
||||
|
||||
def add_ago_to_since(since: str) -> str:
|
||||
|
|
|
@ -15,9 +15,12 @@
|
|||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# pylint: disable=R
|
||||
from typing import Any
|
||||
|
||||
import simplejson as json
|
||||
from flask import request
|
||||
from flask_appbuilder import expose
|
||||
from flask_appbuilder.api import rison
|
||||
from flask_appbuilder.security.decorators import has_access_api
|
||||
|
||||
from superset import db, event_logger
|
||||
|
@ -26,8 +29,11 @@ from superset.legacy import update_time_range
|
|||
from superset.models.slice import Slice
|
||||
from superset.typing import FlaskResponse
|
||||
from superset.utils import core as utils
|
||||
from superset.utils.core import get_since_until
|
||||
from superset.views.base import api, BaseSupersetView, handle_api_exception
|
||||
|
||||
get_time_range_schema = {"type": "string"}
|
||||
|
||||
|
||||
class Api(BaseSupersetView):
|
||||
@event_logger.log_this
|
||||
|
@ -70,3 +76,23 @@ class Api(BaseSupersetView):
|
|||
update_time_range(form_data)
|
||||
|
||||
return json.dumps(form_data)
|
||||
|
||||
@api
|
||||
@handle_api_exception
|
||||
@has_access_api
|
||||
@rison(get_time_range_schema)
|
||||
@expose("/v1/time_range/", methods=["GET"])
|
||||
def time_range(self, **kwargs: Any) -> FlaskResponse:
|
||||
"""Get actually time range from human readable string or datetime expression"""
|
||||
time_range = kwargs["rison"]
|
||||
try:
|
||||
since, until = get_since_until(time_range)
|
||||
result = {
|
||||
"since": since.isoformat() if since else "",
|
||||
"until": until.isoformat() if until else "",
|
||||
"timeRange": time_range,
|
||||
}
|
||||
return self.json_response({"result": result})
|
||||
except ValueError as error:
|
||||
error_msg = {"message": f"Unexpected time range: {error}"}
|
||||
return self.json_response(error_msg, 400)
|
||||
|
|
|
@ -982,6 +982,18 @@ class TestChartApi(SupersetTestCase, ApiOwnersTestCaseMixin):
|
|||
if res["id"] in users_favorite_ids:
|
||||
assert res["value"]
|
||||
|
||||
def test_get_time_range(self):
|
||||
"""
|
||||
Chart API: Test get actually time range from human readable string
|
||||
"""
|
||||
self.login(username="admin")
|
||||
humanize_time_range = "100 years ago : now"
|
||||
uri = f"api/v1/time_range/?q={prison.dumps(humanize_time_range)}"
|
||||
rv = self.client.get(uri)
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
self.assertEqual(len(data["result"]), 3)
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"load_unicode_dashboard_with_slice", "load_energy_table_with_slice"
|
||||
)
|
||||
|
|
|
@ -63,6 +63,7 @@ from superset.utils.core import (
|
|||
validate_json,
|
||||
zlib_compress,
|
||||
zlib_decompress,
|
||||
datetime_eval,
|
||||
)
|
||||
from superset.utils import schema
|
||||
from superset.views.utils import (
|
||||
|
@ -154,6 +155,18 @@ class TestUtils(SupersetTestCase):
|
|||
self.assertEqual(parse_human_timedelta("1 year"), timedelta(366))
|
||||
self.assertEqual(parse_human_timedelta("-1 year"), timedelta(-365))
|
||||
self.assertEqual(parse_human_timedelta(None), timedelta(0))
|
||||
self.assertEqual(
|
||||
parse_human_timedelta("1 month", datetime(2019, 4, 1)), timedelta(30),
|
||||
)
|
||||
self.assertEqual(
|
||||
parse_human_timedelta("1 month", datetime(2019, 5, 1)), timedelta(31),
|
||||
)
|
||||
self.assertEqual(
|
||||
parse_human_timedelta("1 month", datetime(2019, 2, 1)), timedelta(28),
|
||||
)
|
||||
self.assertEqual(
|
||||
parse_human_timedelta("-1 month", datetime(2019, 2, 1)), timedelta(-31),
|
||||
)
|
||||
|
||||
@patch("superset.utils.core.datetime")
|
||||
def test_parse_past_timedelta(self, mock_datetime):
|
||||
|
@ -708,6 +721,10 @@ class TestUtils(SupersetTestCase):
|
|||
expected = datetime(2015, 11, 7), datetime(2016, 11, 7)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = get_since_until("Last quarter")
|
||||
expected = datetime(2016, 8, 7), datetime(2016, 11, 7)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = get_since_until("Last 5 months")
|
||||
expected = datetime(2016, 6, 7), datetime(2016, 11, 7)
|
||||
self.assertEqual(result, expected)
|
||||
|
@ -747,6 +764,109 @@ class TestUtils(SupersetTestCase):
|
|||
with self.assertRaises(ValueError):
|
||||
get_since_until(time_range="tomorrow : yesterday")
|
||||
|
||||
@patch("superset.utils.core.parse_human_datetime", mock_parse_human_datetime)
|
||||
def test_datetime_eval(self):
|
||||
result = datetime_eval("datetime('now')")
|
||||
expected = datetime(2016, 11, 7, 9, 30, 10)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("datetime('today' )")
|
||||
expected = datetime(2016, 11, 7)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
# Parse compact arguments spelling
|
||||
result = datetime_eval("dateadd(datetime('today'),1,year,)")
|
||||
expected = datetime(2017, 11, 7)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("dateadd(datetime('today'), -2, year)")
|
||||
expected = datetime(2014, 11, 7)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("dateadd(datetime('today'), 2, quarter)")
|
||||
expected = datetime(2017, 5, 7)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("dateadd(datetime('today'), 3, month)")
|
||||
expected = datetime(2017, 2, 7)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("dateadd(datetime('today'), -3, week)")
|
||||
expected = datetime(2016, 10, 17)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("dateadd(datetime('today'), 3, day)")
|
||||
expected = datetime(2016, 11, 10)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("dateadd(datetime('now'), 3, hour)")
|
||||
expected = datetime(2016, 11, 7, 12, 30, 10)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("dateadd(datetime('now'), 40, minute)")
|
||||
expected = datetime(2016, 11, 7, 10, 10, 10)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("dateadd(datetime('now'), -11, second)")
|
||||
expected = datetime(2016, 11, 7, 9, 29, 59)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("datetrunc(datetime('now'), year)")
|
||||
expected = datetime(2016, 1, 1, 0, 0, 0)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("datetrunc(datetime('now'), month)")
|
||||
expected = datetime(2016, 11, 1, 0, 0, 0)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("datetrunc(datetime('now'), day)")
|
||||
expected = datetime(2016, 11, 7, 0, 0, 0)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("datetrunc(datetime('now'), week)")
|
||||
expected = datetime(2016, 11, 7, 0, 0, 0)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("datetrunc(datetime('now'), hour)")
|
||||
expected = datetime(2016, 11, 7, 9, 0, 0)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("datetrunc(datetime('now'), minute)")
|
||||
expected = datetime(2016, 11, 7, 9, 30, 0)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("datetrunc(datetime('now'), second)")
|
||||
expected = datetime(2016, 11, 7, 9, 30, 10)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("lastday(datetime('now'), year)")
|
||||
expected = datetime(2016, 12, 31, 0, 0, 0)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("lastday(datetime('today'), month)")
|
||||
expected = datetime(2016, 11, 30, 0, 0, 0)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("holiday('Christmas')")
|
||||
expected = datetime(2016, 12, 25, 0, 0, 0)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval("holiday('Labor day', datetime('2018-01-01T00:00:00'))")
|
||||
expected = datetime(2018, 9, 3, 0, 0, 0)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval(
|
||||
"holiday('Boxing day', datetime('2018-01-01T00:00:00'), 'UK')"
|
||||
)
|
||||
expected = datetime(2018, 12, 26, 0, 0, 0)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
result = datetime_eval(
|
||||
"lastday(dateadd(datetime('2018-01-01T00:00:00'), 1, month), month)"
|
||||
)
|
||||
expected = datetime(2018, 2, 28, 0, 0, 0)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
@patch("superset.utils.core.to_adhoc", mock_to_adhoc)
|
||||
def test_convert_legacy_filters_into_adhoc_where(self):
|
||||
form_data = {"where": "a = 1"}
|
||||
|
|
Loading…
Reference in New Issue