mirror of https://github.com/apache/superset.git
refactor(plugins): BigNumber Time Comparison with existing time_offset API (#27718)
Co-authored-by: lilykuang <jialikuang@gmail.com> Co-authored-by: Kamil Gabryjelski <kamil.gabryjelski@gmail.com>
This commit is contained in:
parent
b69958b412
commit
b1f85dce71
|
@ -23,3 +23,4 @@ export * from './annotationsAndLayers';
|
||||||
export * from './forecastInterval';
|
export * from './forecastInterval';
|
||||||
export * from './chartTitle';
|
export * from './chartTitle';
|
||||||
export * from './echartsTimeSeriesQuery';
|
export * from './echartsTimeSeriesQuery';
|
||||||
|
export * from './timeComparison';
|
||||||
|
|
|
@ -0,0 +1,136 @@
|
||||||
|
/**
|
||||||
|
* 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, ComparisonType } from '@superset-ui/core';
|
||||||
|
|
||||||
|
import { ControlPanelSectionConfig } from '../types';
|
||||||
|
|
||||||
|
const fullChoices = [
|
||||||
|
['1 day ago', t('1 day ago')],
|
||||||
|
['1 week ago', t('1 week ago')],
|
||||||
|
['28 days ago', t('28 days ago')],
|
||||||
|
['30 days ago', t('30 days ago')],
|
||||||
|
['1 month ago', t('1 month ago')],
|
||||||
|
['52 weeks ago', t('52 weeks ago')],
|
||||||
|
['1 year ago', t('1 year ago')],
|
||||||
|
['104 weeks ago', t('104 weeks ago')],
|
||||||
|
['2 years ago', t('2 years ago')],
|
||||||
|
['156 weeks ago', t('156 weeks ago')],
|
||||||
|
['3 years ago', t('3 years ago')],
|
||||||
|
['custom', t('Custom date')],
|
||||||
|
['inherit', t('Inherit range from time filter')],
|
||||||
|
];
|
||||||
|
|
||||||
|
const reducedKeys = new Set([
|
||||||
|
'1 day ago',
|
||||||
|
'1 week ago',
|
||||||
|
'1 month ago',
|
||||||
|
'1 year ago',
|
||||||
|
'custom',
|
||||||
|
'inherit',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Filter fullChoices to get only the entries whose keys are in reducedKeys
|
||||||
|
const reducedChoices = fullChoices.filter(choice => reducedKeys.has(choice[0]));
|
||||||
|
|
||||||
|
type TimeComparisonControlsType = {
|
||||||
|
multi?: boolean;
|
||||||
|
showCalculationType?: boolean;
|
||||||
|
showFullChoices?: boolean;
|
||||||
|
};
|
||||||
|
export const timeComparisonControls: ({
|
||||||
|
multi,
|
||||||
|
showCalculationType,
|
||||||
|
showFullChoices,
|
||||||
|
}: TimeComparisonControlsType) => ControlPanelSectionConfig = ({
|
||||||
|
multi = true,
|
||||||
|
showCalculationType = true,
|
||||||
|
showFullChoices = true,
|
||||||
|
}) => ({
|
||||||
|
label: t('Time Comparison'),
|
||||||
|
tabOverride: 'data',
|
||||||
|
description: t('Compare results with other time periods.'),
|
||||||
|
controlSetRows: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'time_compare',
|
||||||
|
config: {
|
||||||
|
type: 'SelectControl',
|
||||||
|
multi,
|
||||||
|
freeForm: true,
|
||||||
|
placeholder: t('Select or type a custom value...'),
|
||||||
|
label: t('Time shift'),
|
||||||
|
choices: showFullChoices ? fullChoices : reducedChoices,
|
||||||
|
description: t(
|
||||||
|
'Overlay results from a relative time period. ' +
|
||||||
|
'Expects relative time deltas ' +
|
||||||
|
'in natural language (example: 24 hours, 7 days, ' +
|
||||||
|
'52 weeks, 365 days). Free text is supported. ' +
|
||||||
|
'Use "Inherit range from time filters" ' +
|
||||||
|
'to shift the comparison time range ' +
|
||||||
|
'by the same length as your time range ' +
|
||||||
|
'and use "Custom" to set a custom comparison range.',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'start_date_offset',
|
||||||
|
config: {
|
||||||
|
type: 'TimeOffsetControl',
|
||||||
|
label: t('shift start date'),
|
||||||
|
visibility: ({ controls }) =>
|
||||||
|
controls?.time_compare.value === 'custom',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'comparison_type',
|
||||||
|
config: {
|
||||||
|
type: 'SelectControl',
|
||||||
|
label: t('Calculation type'),
|
||||||
|
default: 'values',
|
||||||
|
choices: [
|
||||||
|
[ComparisonType.Values, t('Actual values')],
|
||||||
|
[ComparisonType.Difference, t('Difference')],
|
||||||
|
[ComparisonType.Percentage, t('Percentage change')],
|
||||||
|
[ComparisonType.Ratio, t('Ratio')],
|
||||||
|
],
|
||||||
|
description: t(
|
||||||
|
'How to display time shifts: as individual lines; as the ' +
|
||||||
|
'difference between the main time series and each time shift; ' +
|
||||||
|
'as the percentage change; or as the ratio between series and time shifts.',
|
||||||
|
),
|
||||||
|
visibility: () => Boolean(showCalculationType),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'comparison_range_label',
|
||||||
|
config: {
|
||||||
|
type: 'ComparisonRangeLabel',
|
||||||
|
multi,
|
||||||
|
visibility: ({ controls }) => Boolean(controls?.time_compare.value),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
});
|
|
@ -17,7 +17,12 @@
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import rison from 'rison';
|
import rison from 'rison';
|
||||||
import { SupersetClient, getClientErrorObject } from '@superset-ui/core';
|
import { isEmpty } from 'lodash';
|
||||||
|
import {
|
||||||
|
SupersetClient,
|
||||||
|
getClientErrorObject,
|
||||||
|
ensureIsArray,
|
||||||
|
} from '@superset-ui/core';
|
||||||
|
|
||||||
export const SEPARATOR = ' : ';
|
export const SEPARATOR = ' : ';
|
||||||
|
|
||||||
|
@ -39,20 +44,64 @@ export const formatTimeRange = (
|
||||||
)} ≤ ${columnPlaceholder} < ${formatDateEndpoint(splitDateRange[1])}`;
|
)} ≤ ${columnPlaceholder} < ${formatDateEndpoint(splitDateRange[1])}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatTimeRangeComparison = (
|
||||||
|
initialTimeRange: string,
|
||||||
|
shiftedTimeRange: string,
|
||||||
|
columnPlaceholder = 'col',
|
||||||
|
) => {
|
||||||
|
const splitInitialDateRange = initialTimeRange.split(SEPARATOR);
|
||||||
|
const splitShiftedDateRange = shiftedTimeRange.split(SEPARATOR);
|
||||||
|
return `${columnPlaceholder}: ${formatDateEndpoint(
|
||||||
|
splitInitialDateRange[0],
|
||||||
|
true,
|
||||||
|
)} to ${formatDateEndpoint(splitInitialDateRange[1])} vs
|
||||||
|
${formatDateEndpoint(splitShiftedDateRange[0], true)} to ${formatDateEndpoint(
|
||||||
|
splitShiftedDateRange[1],
|
||||||
|
)}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchTimeRange = async (
|
export const fetchTimeRange = async (
|
||||||
timeRange: string,
|
timeRange: string,
|
||||||
columnPlaceholder = 'col',
|
columnPlaceholder = 'col',
|
||||||
|
shifts?: string[],
|
||||||
) => {
|
) => {
|
||||||
const query = rison.encode_uri(timeRange);
|
let query;
|
||||||
const endpoint = `/api/v1/time_range/?q=${query}`;
|
let endpoint;
|
||||||
|
if (!isEmpty(shifts)) {
|
||||||
|
const timeRanges = ensureIsArray(shifts).map(shift => ({
|
||||||
|
timeRange,
|
||||||
|
shift,
|
||||||
|
}));
|
||||||
|
query = rison.encode_uri([{ timeRange }, ...timeRanges]);
|
||||||
|
endpoint = `/api/v1/time_range/?q=${query}`;
|
||||||
|
} else {
|
||||||
|
query = rison.encode_uri(timeRange);
|
||||||
|
endpoint = `/api/v1/time_range/?q=${query}`;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await SupersetClient.get({ endpoint });
|
const response = await SupersetClient.get({ endpoint });
|
||||||
const timeRangeString = buildTimeRangeString(
|
if (isEmpty(shifts)) {
|
||||||
response?.json?.result[0]?.since || '',
|
const timeRangeString = buildTimeRangeString(
|
||||||
response?.json?.result[0]?.until || '',
|
response?.json?.result[0]?.since || '',
|
||||||
|
response?.json?.result[0]?.until || '',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
value: formatTimeRange(timeRangeString, columnPlaceholder),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const timeRanges = response?.json?.result.map((result: any) =>
|
||||||
|
buildTimeRangeString(result.since, result.until),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
value: formatTimeRange(timeRangeString, columnPlaceholder),
|
value: timeRanges
|
||||||
|
.slice(1)
|
||||||
|
.map((timeRange: string) =>
|
||||||
|
formatTimeRangeComparison(
|
||||||
|
timeRanges[0],
|
||||||
|
timeRange,
|
||||||
|
columnPlaceholder,
|
||||||
|
),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
} catch (response) {
|
} catch (response) {
|
||||||
const clientError = await getClientErrorObject(response);
|
const clientError = await getClientErrorObject(response);
|
||||||
|
|
|
@ -0,0 +1,121 @@
|
||||||
|
/**
|
||||||
|
* 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 { ensureIsArray } from '../utils';
|
||||||
|
|
||||||
|
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||||
|
export const parseDttmToDate = (dttm: string): Date => {
|
||||||
|
const now = new Date();
|
||||||
|
now.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (dttm === 'now' || dttm === 'today' || dttm === 'No filter') {
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
if (dttm === 'Last week') {
|
||||||
|
now.setUTCDate(now.getUTCDate() - 7);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
if (dttm === 'Last month') {
|
||||||
|
now.setUTCMonth(now.getUTCMonth() - 1);
|
||||||
|
now.setUTCDate(1);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
if (dttm === 'Last quarter') {
|
||||||
|
now.setUTCMonth(now.getUTCMonth() - 3);
|
||||||
|
now.setUTCDate(1);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
if (dttm === 'Last year') {
|
||||||
|
now.setUTCFullYear(now.getUTCFullYear() - 1);
|
||||||
|
now.setUTCDate(1);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
if (dttm === 'previous calendar week') {
|
||||||
|
now.setUTCDate(now.getUTCDate() - now.getUTCDay());
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
if (dttm === 'previous calendar month') {
|
||||||
|
now.setUTCMonth(now.getUTCMonth() - 1, 1);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
if (dttm === 'previous calendar year') {
|
||||||
|
now.setUTCFullYear(now.getUTCFullYear() - 1, 0, 1);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
if (dttm?.includes('ago')) {
|
||||||
|
const parts = dttm.split(' ');
|
||||||
|
const amount = parseInt(parts[0], 10);
|
||||||
|
const unit = parts[1];
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case 'day':
|
||||||
|
case 'days':
|
||||||
|
now.setUTCDate(now.getUTCDate() - amount);
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
case 'weeks':
|
||||||
|
now.setUTCDate(now.getUTCDate() - amount * 7);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
case 'months':
|
||||||
|
now.setUTCMonth(now.getUTCMonth() - amount);
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
case 'years':
|
||||||
|
now.setUTCFullYear(now.getUTCFullYear() - amount);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
const parsed = new Date(dttm);
|
||||||
|
parsed.setUTCHours(0, 0, 0, 0);
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTimeOffset = (
|
||||||
|
timeRangeFilter: any,
|
||||||
|
shifts: string[],
|
||||||
|
startDate: string,
|
||||||
|
): string[] => {
|
||||||
|
const isCustom = shifts?.includes('custom');
|
||||||
|
const isInherit = shifts?.includes('inherit');
|
||||||
|
const customStartDate = isCustom && parseDttmToDate(startDate).getTime();
|
||||||
|
const filterStartDate = parseDttmToDate(
|
||||||
|
timeRangeFilter.comparator.split(' : ')[0],
|
||||||
|
).getTime();
|
||||||
|
const filterEndDate = parseDttmToDate(
|
||||||
|
timeRangeFilter.comparator.split(' : ')[1],
|
||||||
|
).getTime();
|
||||||
|
|
||||||
|
const customShift =
|
||||||
|
customStartDate &&
|
||||||
|
Math.ceil((filterStartDate - customStartDate) / DAY_IN_MS);
|
||||||
|
const inInheritShift =
|
||||||
|
isInherit && Math.ceil((filterEndDate - filterStartDate) / DAY_IN_MS);
|
||||||
|
|
||||||
|
let newShifts = shifts;
|
||||||
|
if (isCustom) {
|
||||||
|
newShifts = [`${customShift} days ago`];
|
||||||
|
}
|
||||||
|
if (isInherit) {
|
||||||
|
newShifts = [`${inInheritShift} days ago`];
|
||||||
|
}
|
||||||
|
return ensureIsArray(newShifts);
|
||||||
|
};
|
|
@ -21,4 +21,5 @@ export * from './types';
|
||||||
|
|
||||||
export { default as getComparisonInfo } from './getComparisonInfo';
|
export { default as getComparisonInfo } from './getComparisonInfo';
|
||||||
export { default as getComparisonFilters } from './getComparisonFilters';
|
export { default as getComparisonFilters } from './getComparisonFilters';
|
||||||
|
export { parseDttmToDate, getTimeOffset } from './getTimeOffset';
|
||||||
export { SEPARATOR, fetchTimeRange } from './fetchTimeRange';
|
export { SEPARATOR, fetchTimeRange } from './fetchTimeRange';
|
||||||
|
|
|
@ -22,11 +22,12 @@ import { fetchTimeRange } from '@superset-ui/core';
|
||||||
import {
|
import {
|
||||||
buildTimeRangeString,
|
buildTimeRangeString,
|
||||||
formatTimeRange,
|
formatTimeRange,
|
||||||
|
formatTimeRangeComparison,
|
||||||
} from '../../src/time-comparison/fetchTimeRange';
|
} from '../../src/time-comparison/fetchTimeRange';
|
||||||
|
|
||||||
afterEach(fetchMock.restore);
|
afterEach(fetchMock.restore);
|
||||||
|
|
||||||
it('generates proper time range string', () => {
|
test('generates proper time range string', () => {
|
||||||
expect(
|
expect(
|
||||||
buildTimeRangeString('2010-07-30T00:00:00', '2020-07-30T00:00:00'),
|
buildTimeRangeString('2010-07-30T00:00:00', '2020-07-30T00:00:00'),
|
||||||
).toBe('2010-07-30T00:00:00 : 2020-07-30T00:00:00');
|
).toBe('2010-07-30T00:00:00 : 2020-07-30T00:00:00');
|
||||||
|
@ -36,7 +37,7 @@ it('generates proper time range string', () => {
|
||||||
expect(buildTimeRangeString('', '')).toBe(' : ');
|
expect(buildTimeRangeString('', '')).toBe(' : ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates a readable time range', () => {
|
test('generates a readable time range', () => {
|
||||||
expect(formatTimeRange('Last 7 days')).toBe('Last 7 days');
|
expect(formatTimeRange('Last 7 days')).toBe('Last 7 days');
|
||||||
expect(formatTimeRange('No filter')).toBe('No filter');
|
expect(formatTimeRange('No filter')).toBe('No filter');
|
||||||
expect(formatTimeRange('Yesterday : Tomorrow')).toBe(
|
expect(formatTimeRange('Yesterday : Tomorrow')).toBe(
|
||||||
|
@ -53,7 +54,7 @@ it('generates a readable time range', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns a formatted time range from response', async () => {
|
test('returns a formatted time range from response', async () => {
|
||||||
fetchMock.get("glob:*/api/v1/time_range/?q='Last+day'", {
|
fetchMock.get("glob:*/api/v1/time_range/?q='Last+day'", {
|
||||||
result: [
|
result: [
|
||||||
{
|
{
|
||||||
|
@ -70,7 +71,7 @@ it('returns a formatted time range from response', async () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns a formatted time range from empty response', async () => {
|
test('returns a formatted time range from empty response', async () => {
|
||||||
fetchMock.get("glob:*/api/v1/time_range/?q='Last+day'", {
|
fetchMock.get("glob:*/api/v1/time_range/?q='Last+day'", {
|
||||||
result: [],
|
result: [],
|
||||||
});
|
});
|
||||||
|
@ -81,7 +82,7 @@ it('returns a formatted time range from empty response', async () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns a formatted error message from response', async () => {
|
test('returns a formatted error message from response', async () => {
|
||||||
fetchMock.getOnce("glob:*/api/v1/time_range/?q='Last+day'", {
|
fetchMock.getOnce("glob:*/api/v1/time_range/?q='Last+day'", {
|
||||||
throws: new Response(JSON.stringify({ message: 'Network error' })),
|
throws: new Response(JSON.stringify({ message: 'Network error' })),
|
||||||
});
|
});
|
||||||
|
@ -116,3 +117,54 @@ it('returns a formatted error message from response', async () => {
|
||||||
error: 'Network error',
|
error: 'Network error',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('fetchTimeRange with shift', async () => {
|
||||||
|
fetchMock.getOnce(
|
||||||
|
"glob:*/api/v1/time_range/?q=!((timeRange:'Last+day'),(shift%3A'last%20month'%2CtimeRange%3A'Last%20day'))",
|
||||||
|
{
|
||||||
|
result: [
|
||||||
|
{
|
||||||
|
since: '2021-04-13T00:00:00',
|
||||||
|
until: '2021-04-14T00:00:00',
|
||||||
|
timeRange: 'Last day',
|
||||||
|
shift: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
since: '2021-03-13T00:00:00',
|
||||||
|
until: '2021-03-14T00:00:00',
|
||||||
|
timeRange: 'Last day',
|
||||||
|
shift: 'last month',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const timeRange = await fetchTimeRange('Last day', 'temporal_col', [
|
||||||
|
'last month',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(timeRange).toEqual({
|
||||||
|
value: [
|
||||||
|
'temporal_col: 2021-04-13 to 2021-04-14 vs\n 2021-03-13 to 2021-03-14',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatTimeRangeComparison', () => {
|
||||||
|
expect(
|
||||||
|
formatTimeRangeComparison(
|
||||||
|
'2021-04-13T00:00:00 : 2021-04-14T00:00:00',
|
||||||
|
'2021-03-13T00:00:00 : 2021-03-14T00:00:00',
|
||||||
|
),
|
||||||
|
).toEqual('col: 2021-04-13 to 2021-04-14 vs\n 2021-03-13 to 2021-03-14');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
formatTimeRangeComparison(
|
||||||
|
'2021-04-13T00:00:00 : 2021-04-14T00:00:00',
|
||||||
|
'2021-03-13T00:00:00 : 2021-03-14T00:00:00',
|
||||||
|
'col_name',
|
||||||
|
),
|
||||||
|
).toEqual(
|
||||||
|
'col_name: 2021-04-13 to 2021-04-14 vs\n 2021-03-13 to 2021-03-14',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
/**
|
||||||
|
* 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 { getTimeOffset } from '@superset-ui/core';
|
||||||
|
|
||||||
|
test('handles custom shifts', () => {
|
||||||
|
const shifts = ['custom'];
|
||||||
|
const startDate = '2023-01-01';
|
||||||
|
const timeRangeFilter = { comparator: '2023-01-03 : 2023-01-10' };
|
||||||
|
|
||||||
|
const result = getTimeOffset(timeRangeFilter, shifts, startDate);
|
||||||
|
expect(result).toEqual(['2 days ago']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles inherit shifts', () => {
|
||||||
|
const shifts = ['inherit'];
|
||||||
|
const startDate = '';
|
||||||
|
const timeRangeFilter = { comparator: '2023-01-03 : 2023-01-10' };
|
||||||
|
|
||||||
|
const result = getTimeOffset(timeRangeFilter, shifts, startDate);
|
||||||
|
expect(result).toEqual(['7 days ago']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles no custom or inherit shifts', () => {
|
||||||
|
const shifts = ['1 week ago'];
|
||||||
|
const startDate = '';
|
||||||
|
const timeRangeFilter = { comparator: '2023-01-03 : 2023-01-10' };
|
||||||
|
|
||||||
|
const result = getTimeOffset(timeRangeFilter, shifts, startDate);
|
||||||
|
expect(result).toEqual(['1 week ago']);
|
||||||
|
});
|
|
@ -0,0 +1,131 @@
|
||||||
|
/**
|
||||||
|
* 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 { parseDttmToDate } from '@superset-ui/core';
|
||||||
|
|
||||||
|
test('should handle "now"', () => {
|
||||||
|
const now = parseDttmToDate('now');
|
||||||
|
const expected = new Date();
|
||||||
|
expected.setUTCHours(0, 0, 0, 0);
|
||||||
|
expect(expected).toEqual(now);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle "today" and "No filter"', () => {
|
||||||
|
const today = parseDttmToDate('today');
|
||||||
|
const noFilter = parseDttmToDate('No filter');
|
||||||
|
const expected = new Date();
|
||||||
|
expected.setUTCHours(0, 0, 0, 0);
|
||||||
|
expect(today).toEqual(expected);
|
||||||
|
expect(noFilter).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle relative time strings', () => {
|
||||||
|
const lastWeek = parseDttmToDate('Last week');
|
||||||
|
const lastMonth = parseDttmToDate('Last month');
|
||||||
|
const lastQuarter = parseDttmToDate('Last quarter');
|
||||||
|
const lastYear = parseDttmToDate('Last year');
|
||||||
|
let now = new Date();
|
||||||
|
now.setUTCHours(0, 0, 0, 0);
|
||||||
|
now.setUTCDate(now.getUTCDate() - 7);
|
||||||
|
expect(lastWeek).toEqual(now);
|
||||||
|
|
||||||
|
now = new Date();
|
||||||
|
now.setUTCHours(0, 0, 0, 0);
|
||||||
|
now.setUTCMonth(now.getUTCMonth() - 1);
|
||||||
|
now.setUTCDate(1);
|
||||||
|
expect(lastMonth).toEqual(now);
|
||||||
|
|
||||||
|
now = new Date();
|
||||||
|
now.setUTCHours(0, 0, 0, 0);
|
||||||
|
now.setUTCMonth(now.getUTCMonth() - 3);
|
||||||
|
now.setUTCDate(1);
|
||||||
|
expect(lastQuarter).toEqual(now);
|
||||||
|
|
||||||
|
now = new Date();
|
||||||
|
now.setUTCHours(0, 0, 0, 0);
|
||||||
|
now.setUTCFullYear(now.getUTCFullYear() - 1);
|
||||||
|
now.setUTCDate(1);
|
||||||
|
expect(lastYear).toEqual(now);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle previous calendar units', () => {
|
||||||
|
let now = new Date();
|
||||||
|
now.setUTCHours(0, 0, 0, 0);
|
||||||
|
now.setUTCDate(now.getUTCDate() - now.getUTCDay());
|
||||||
|
const previousWeek = parseDttmToDate('previous calendar week');
|
||||||
|
expect(previousWeek).toEqual(now);
|
||||||
|
|
||||||
|
now = new Date();
|
||||||
|
now.setUTCMonth(now.getUTCMonth() - 1, 1);
|
||||||
|
now.setUTCHours(0, 0, 0, 0);
|
||||||
|
const previousMonth = parseDttmToDate('previous calendar month');
|
||||||
|
expect(previousMonth).toEqual(now);
|
||||||
|
|
||||||
|
now = new Date();
|
||||||
|
now.setUTCFullYear(now.getUTCFullYear() - 1, 0, 1);
|
||||||
|
now.setUTCHours(0, 0, 0, 0);
|
||||||
|
const previousYear = parseDttmToDate('previous calendar year');
|
||||||
|
expect(previousYear).toEqual(now);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle dynamic "ago" times', () => {
|
||||||
|
const fiveDaysAgo = parseDttmToDate('5 days ago');
|
||||||
|
const fiveDayAgo = parseDttmToDate('5 day ago');
|
||||||
|
let now = new Date();
|
||||||
|
now.setUTCHours(0, 0, 0, 0);
|
||||||
|
now.setUTCDate(now.getUTCDate() - 5);
|
||||||
|
expect(fiveDaysAgo).toEqual(now);
|
||||||
|
expect(fiveDayAgo).toEqual(now);
|
||||||
|
|
||||||
|
const weeksAgo = parseDttmToDate('7 weeks ago');
|
||||||
|
const weekAgo = parseDttmToDate('7 week ago');
|
||||||
|
now = new Date();
|
||||||
|
now.setUTCHours(0, 0, 0, 0);
|
||||||
|
now.setUTCDate(now.getUTCDate() - 7 * 7);
|
||||||
|
expect(weeksAgo).toEqual(now);
|
||||||
|
expect(weekAgo).toEqual(now);
|
||||||
|
|
||||||
|
const fiveMonthsAgo = parseDttmToDate('5 months ago');
|
||||||
|
const fiveMonthAgo = parseDttmToDate('5 month ago');
|
||||||
|
now = new Date();
|
||||||
|
now.setUTCHours(0, 0, 0, 0);
|
||||||
|
now.setUTCMonth(now.getUTCMonth() - 5);
|
||||||
|
expect(fiveMonthsAgo).toEqual(now);
|
||||||
|
expect(fiveMonthAgo).toEqual(now);
|
||||||
|
|
||||||
|
const fiveYearsAgo = parseDttmToDate('5 years ago');
|
||||||
|
const fiveYearAgo = parseDttmToDate('5 year ago');
|
||||||
|
now = new Date();
|
||||||
|
now.setUTCHours(0, 0, 0, 0);
|
||||||
|
now.setUTCFullYear(now.getUTCFullYear() - 5);
|
||||||
|
expect(fiveYearsAgo).toEqual(now);
|
||||||
|
expect(fiveYearAgo).toEqual(now);
|
||||||
|
|
||||||
|
// default case
|
||||||
|
const fiveHoursAgo = parseDttmToDate('5 hours ago');
|
||||||
|
now = new Date();
|
||||||
|
now.setUTCHours(0, 0, 0, 0);
|
||||||
|
expect(fiveHoursAgo).toEqual(now);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should parse valid moment strings', () => {
|
||||||
|
const specificDate = new Date('2023-01-01');
|
||||||
|
specificDate.setUTCHours(0, 0, 0, 0);
|
||||||
|
const parsedDate = parseDttmToDate('2023-01-01');
|
||||||
|
expect(parsedDate).toEqual(specificDate);
|
||||||
|
});
|
|
@ -16,9 +16,18 @@
|
||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import React, { useMemo } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { css, styled, t, useTheme } from '@superset-ui/core';
|
import {
|
||||||
|
css,
|
||||||
|
ensureIsArray,
|
||||||
|
fetchTimeRange,
|
||||||
|
getTimeOffset,
|
||||||
|
styled,
|
||||||
|
t,
|
||||||
|
useTheme,
|
||||||
|
} from '@superset-ui/core';
|
||||||
import { Tooltip } from '@superset-ui/chart-controls';
|
import { Tooltip } from '@superset-ui/chart-controls';
|
||||||
|
import { isEmpty } from 'lodash';
|
||||||
import {
|
import {
|
||||||
ColorSchemeEnum,
|
ColorSchemeEnum,
|
||||||
PopKPIComparisonSymbolStyleProps,
|
PopKPIComparisonSymbolStyleProps,
|
||||||
|
@ -69,9 +78,38 @@ export default function PopKPI(props: PopKPIProps) {
|
||||||
comparisonColorEnabled,
|
comparisonColorEnabled,
|
||||||
comparisonColorScheme,
|
comparisonColorScheme,
|
||||||
percentDifferenceNumber,
|
percentDifferenceNumber,
|
||||||
comparatorText,
|
currentTimeRangeFilter,
|
||||||
|
startDateOffset,
|
||||||
|
shift,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const [comparisonRange, setComparisonRange] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentTimeRangeFilter || (!shift && !startDateOffset)) {
|
||||||
|
setComparisonRange('');
|
||||||
|
} else if (!isEmpty(shift) || startDateOffset) {
|
||||||
|
const newShift = getTimeOffset(
|
||||||
|
currentTimeRangeFilter,
|
||||||
|
ensureIsArray(shift),
|
||||||
|
startDateOffset || '',
|
||||||
|
);
|
||||||
|
const promise: any = fetchTimeRange(
|
||||||
|
(currentTimeRangeFilter as any).comparator,
|
||||||
|
currentTimeRangeFilter.subject,
|
||||||
|
newShift || [],
|
||||||
|
);
|
||||||
|
Promise.resolve(promise).then((res: any) => {
|
||||||
|
const response: string[] = ensureIsArray(res.value);
|
||||||
|
const firstRange: string = response.flat()[0];
|
||||||
|
const rangeText = firstRange.split('vs\n');
|
||||||
|
setComparisonRange(
|
||||||
|
rangeText.length > 1 ? rangeText[1].trim() : rangeText[0],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [currentTimeRangeFilter, shift, startDateOffset]);
|
||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const flexGap = theme.gridUnit * 5;
|
const flexGap = theme.gridUnit * 5;
|
||||||
const wrapperDivStyles = css`
|
const wrapperDivStyles = css`
|
||||||
|
@ -150,7 +188,7 @@ export default function PopKPI(props: PopKPIProps) {
|
||||||
{
|
{
|
||||||
symbol: '#',
|
symbol: '#',
|
||||||
value: prevNumber,
|
value: prevNumber,
|
||||||
tooltipText: t('Data for %s', comparatorText),
|
tooltipText: t('Data for %s', comparisonRange || 'previous range'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
symbol: '△',
|
symbol: '△',
|
||||||
|
@ -164,7 +202,7 @@ export default function PopKPI(props: PopKPIProps) {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
comparatorText,
|
comparisonRange,
|
||||||
prevNumber,
|
prevNumber,
|
||||||
valueDifference,
|
valueDifference,
|
||||||
percentDifferenceFormattedString,
|
percentDifferenceFormattedString,
|
||||||
|
|
|
@ -18,50 +18,50 @@
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
buildQueryContext,
|
buildQueryContext,
|
||||||
getComparisonInfo,
|
|
||||||
ComparisonTimeRangeType,
|
|
||||||
QueryFormData,
|
QueryFormData,
|
||||||
|
PostProcessingRule,
|
||||||
|
ensureIsArray,
|
||||||
|
SimpleAdhocFilter,
|
||||||
|
getTimeOffset,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
|
import {
|
||||||
|
isTimeComparison,
|
||||||
|
timeCompareOperator,
|
||||||
|
} from '@superset-ui/chart-controls';
|
||||||
|
|
||||||
export default function buildQuery(formData: QueryFormData) {
|
export default function buildQuery(formData: QueryFormData) {
|
||||||
const {
|
const { cols: groupby } = formData;
|
||||||
cols: groupby,
|
|
||||||
time_comparison: timeComparison,
|
|
||||||
extra_form_data: extraFormData,
|
|
||||||
} = formData;
|
|
||||||
|
|
||||||
const queryContextA = buildQueryContext(formData, baseQueryObject => [
|
const queryContextA = buildQueryContext(formData, baseQueryObject => {
|
||||||
{
|
const postProcessing: PostProcessingRule[] = [];
|
||||||
...baseQueryObject,
|
postProcessing.push(timeCompareOperator(formData, baseQueryObject));
|
||||||
groupby,
|
const TimeRangeFilters =
|
||||||
},
|
formData.adhoc_filters?.filter(
|
||||||
]);
|
(filter: SimpleAdhocFilter) => filter.operator === 'TEMPORAL_RANGE',
|
||||||
|
) || [];
|
||||||
|
|
||||||
const comparisonFormData = getComparisonInfo(
|
const timeOffsets = ensureIsArray(
|
||||||
formData,
|
isTimeComparison(formData, baseQueryObject)
|
||||||
timeComparison,
|
? getTimeOffset(
|
||||||
extraFormData,
|
TimeRangeFilters[0],
|
||||||
);
|
formData.time_compare,
|
||||||
|
formData.start_date_offset,
|
||||||
const queryContextB = buildQueryContext(
|
)
|
||||||
comparisonFormData,
|
: [],
|
||||||
baseQueryObject => [
|
);
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
...baseQueryObject,
|
...baseQueryObject,
|
||||||
groupby,
|
groupby,
|
||||||
extras: {
|
post_processing: postProcessing,
|
||||||
...baseQueryObject.extras,
|
time_offsets: isTimeComparison(formData, baseQueryObject)
|
||||||
instant_time_comparison_range:
|
? ensureIsArray(timeOffsets)
|
||||||
timeComparison !== ComparisonTimeRangeType.Custom
|
: [],
|
||||||
? timeComparison
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
];
|
||||||
);
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...queryContextA,
|
...queryContextA,
|
||||||
queries: [...queryContextA.queries, ...queryContextB.queries],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,20 +16,12 @@
|
||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
import { t } from '@superset-ui/core';
|
||||||
import {
|
import {
|
||||||
AdhocFilter,
|
|
||||||
ComparisonTimeRangeType,
|
|
||||||
SimpleAdhocFilter,
|
|
||||||
t,
|
|
||||||
validateTimeComparisonRangeValues,
|
|
||||||
} from '@superset-ui/core';
|
|
||||||
import {
|
|
||||||
ColumnMeta,
|
|
||||||
ControlPanelConfig,
|
ControlPanelConfig,
|
||||||
ControlPanelState,
|
|
||||||
ControlState,
|
|
||||||
getStandardizedControls,
|
getStandardizedControls,
|
||||||
sharedControls,
|
sharedControls,
|
||||||
|
sections,
|
||||||
} from '@superset-ui/chart-controls';
|
} from '@superset-ui/chart-controls';
|
||||||
import { headerFontSize, subheaderFontSize } from '../sharedControls';
|
import { headerFontSize, subheaderFontSize } from '../sharedControls';
|
||||||
import { ColorSchemeEnum } from './types';
|
import { ColorSchemeEnum } from './types';
|
||||||
|
@ -42,70 +34,6 @@ const config: ControlPanelConfig = {
|
||||||
controlSetRows: [
|
controlSetRows: [
|
||||||
['metric'],
|
['metric'],
|
||||||
['adhoc_filters'],
|
['adhoc_filters'],
|
||||||
[
|
|
||||||
{
|
|
||||||
name: 'time_comparison',
|
|
||||||
config: {
|
|
||||||
type: 'SelectControl',
|
|
||||||
label: t('Range for Comparison'),
|
|
||||||
default: 'r',
|
|
||||||
choices: [
|
|
||||||
['r', 'Inherit range from time filters'],
|
|
||||||
['y', 'Year'],
|
|
||||||
['m', 'Month'],
|
|
||||||
['w', 'Week'],
|
|
||||||
['c', 'Custom'],
|
|
||||||
],
|
|
||||||
rerender: ['adhoc_custom'],
|
|
||||||
description: t(
|
|
||||||
'Set the time range that will be used for the comparison metrics. ' +
|
|
||||||
'For example, "Year" will compare to the same dates one year earlier. ' +
|
|
||||||
'Use "Inherit range from time filters" to shift the comparison time range' +
|
|
||||||
'by the same length as your time range and use "Custom" to set a custom comparison range.',
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
name: `adhoc_custom`,
|
|
||||||
config: {
|
|
||||||
...sharedControls.adhoc_filters,
|
|
||||||
label: t('Filters for Comparison'),
|
|
||||||
description:
|
|
||||||
'This only applies when selecting the Range for Comparison Type: Custom',
|
|
||||||
visibility: ({ controls }) =>
|
|
||||||
controls?.time_comparison?.value ===
|
|
||||||
ComparisonTimeRangeType.Custom,
|
|
||||||
mapStateToProps: (
|
|
||||||
state: ControlPanelState,
|
|
||||||
controlState: ControlState,
|
|
||||||
) => {
|
|
||||||
const originalMapStateToPropsRes =
|
|
||||||
sharedControls.adhoc_filters.mapStateToProps?.(
|
|
||||||
state,
|
|
||||||
controlState,
|
|
||||||
) || {};
|
|
||||||
const columns = originalMapStateToPropsRes.columns.filter(
|
|
||||||
(col: ColumnMeta) =>
|
|
||||||
col.is_dttm &&
|
|
||||||
(state.controls.adhoc_filters.value as AdhocFilter[]).some(
|
|
||||||
(val: SimpleAdhocFilter) =>
|
|
||||||
val.subject === col.column_name,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...originalMapStateToPropsRes,
|
|
||||||
columns,
|
|
||||||
externalValidationErrors: validateTimeComparisonRangeValues(
|
|
||||||
state.controls?.time_comparison?.value,
|
|
||||||
controlState.value,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
name: 'row_limit',
|
name: 'row_limit',
|
||||||
|
@ -180,14 +108,16 @@ const config: ControlPanelConfig = {
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
sections.timeComparisonControls({
|
||||||
|
multi: false,
|
||||||
|
showCalculationType: false,
|
||||||
|
showFullChoices: false,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
controlOverrides: {
|
controlOverrides: {
|
||||||
y_axis_format: {
|
y_axis_format: {
|
||||||
label: t('Number format'),
|
label: t('Number format'),
|
||||||
},
|
},
|
||||||
adhoc_filters: {
|
|
||||||
rerender: ['adhoc_custom'],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
formDataOverrides: formData => ({
|
formDataOverrides: formData => ({
|
||||||
...formData,
|
...formData,
|
||||||
|
|
|
@ -22,7 +22,9 @@ import {
|
||||||
getMetricLabel,
|
getMetricLabel,
|
||||||
getValueFormatter,
|
getValueFormatter,
|
||||||
getNumberFormatter,
|
getNumberFormatter,
|
||||||
formatTimeRange,
|
SimpleAdhocFilter,
|
||||||
|
ensureIsArray,
|
||||||
|
getTimeOffset,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { getComparisonFontSize, getHeaderFontSize } from './utils';
|
import { getComparisonFontSize, getHeaderFontSize } from './utils';
|
||||||
|
|
||||||
|
@ -87,17 +89,49 @@ export default function transformProps(chartProps: ChartProps) {
|
||||||
percentDifferenceFormat,
|
percentDifferenceFormat,
|
||||||
} = formData;
|
} = formData;
|
||||||
const { data: dataA = [] } = queriesData[0];
|
const { data: dataA = [] } = queriesData[0];
|
||||||
const {
|
|
||||||
data: dataB = [],
|
|
||||||
from_dttm: comparisonFromDatetime,
|
|
||||||
to_dttm: comparisonToDatetime,
|
|
||||||
} = queriesData[1];
|
|
||||||
const data = dataA;
|
const data = dataA;
|
||||||
const metricName = getMetricLabel(metric);
|
const metricName = getMetricLabel(metric);
|
||||||
|
const timeComparison = ensureIsArray(chartProps.rawFormData?.time_compare)[0];
|
||||||
|
const startDateOffset = chartProps.rawFormData?.start_date_offset;
|
||||||
|
const currentTimeRangeFilter = chartProps.rawFormData?.adhoc_filters?.filter(
|
||||||
|
(adhoc_filter: SimpleAdhocFilter) =>
|
||||||
|
adhoc_filter.operator === 'TEMPORAL_RANGE',
|
||||||
|
)?.[0];
|
||||||
|
const isCustomOrInherit =
|
||||||
|
timeComparison === 'custom' || timeComparison === 'inherit';
|
||||||
|
let dataOffset: string[] = [];
|
||||||
|
if (isCustomOrInherit) {
|
||||||
|
dataOffset = getTimeOffset(
|
||||||
|
currentTimeRangeFilter,
|
||||||
|
ensureIsArray(timeComparison),
|
||||||
|
startDateOffset || '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { value1, value2 } = data.reduce(
|
||||||
|
(acc: { value1: number; value2: number }, curr: { [x: string]: any }) => {
|
||||||
|
Object.keys(curr).forEach(key => {
|
||||||
|
if (
|
||||||
|
key.includes(
|
||||||
|
`${metricName}__${
|
||||||
|
!isCustomOrInherit ? timeComparison : dataOffset[0]
|
||||||
|
}`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
acc.value2 += curr[key];
|
||||||
|
} else if (key.includes(metricName)) {
|
||||||
|
acc.value1 += curr[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ value1: 0, value2: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
let bigNumber: number | string =
|
let bigNumber: number | string =
|
||||||
data.length === 0 ? 0 : parseMetricValue(data[0][metricName]);
|
data.length === 0 ? 0 : parseMetricValue(value1);
|
||||||
let prevNumber: number | string =
|
let prevNumber: number | string =
|
||||||
data.length === 0 ? 0 : parseMetricValue(dataB[0][metricName]);
|
data.length === 0 ? 0 : parseMetricValue(value2);
|
||||||
|
|
||||||
const numberFormatter = getValueFormatter(
|
const numberFormatter = getValueFormatter(
|
||||||
metric,
|
metric,
|
||||||
|
@ -133,10 +167,6 @@ export default function transformProps(chartProps: ChartProps) {
|
||||||
prevNumber = numberFormatter(prevNumber);
|
prevNumber = numberFormatter(prevNumber);
|
||||||
valueDifference = numberFormatter(valueDifference);
|
valueDifference = numberFormatter(valueDifference);
|
||||||
const percentDifference: string = formatPercentChange(percentDifferenceNum);
|
const percentDifference: string = formatPercentChange(percentDifferenceNum);
|
||||||
const comparatorText = formatTimeRange('%Y-%m-%d', [
|
|
||||||
comparisonFromDatetime,
|
|
||||||
comparisonToDatetime,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
width,
|
width,
|
||||||
|
@ -155,6 +185,8 @@ export default function transformProps(chartProps: ChartProps) {
|
||||||
comparisonColorEnabled,
|
comparisonColorEnabled,
|
||||||
comparisonColorScheme,
|
comparisonColorScheme,
|
||||||
percentDifferenceNumber: percentDifferenceNum,
|
percentDifferenceNumber: percentDifferenceNum,
|
||||||
comparatorText,
|
currentTimeRangeFilter,
|
||||||
|
startDateOffset,
|
||||||
|
shift: timeComparison,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
supersetTheme,
|
supersetTheme,
|
||||||
TimeseriesDataRecord,
|
TimeseriesDataRecord,
|
||||||
Metric,
|
Metric,
|
||||||
|
SimpleAdhocFilter,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
|
|
||||||
export interface PopKPIStylesProps {
|
export interface PopKPIStylesProps {
|
||||||
|
@ -60,8 +61,10 @@ export type PopKPIProps = PopKPIStylesProps &
|
||||||
percentDifferenceFormattedString: string;
|
percentDifferenceFormattedString: string;
|
||||||
compType: string;
|
compType: string;
|
||||||
percentDifferenceNumber: number;
|
percentDifferenceNumber: number;
|
||||||
comparatorText: string;
|
|
||||||
comparisonColorScheme?: string;
|
comparisonColorScheme?: string;
|
||||||
|
currentTimeRangeFilter?: SimpleAdhocFilter;
|
||||||
|
startDateOffset?: string;
|
||||||
|
shift: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum ColorSchemeEnum {
|
export enum ColorSchemeEnum {
|
||||||
|
|
|
@ -0,0 +1,107 @@
|
||||||
|
/**
|
||||||
|
* 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, { useEffect, useState } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { isEmpty, isEqual } from 'lodash';
|
||||||
|
import {
|
||||||
|
BinaryAdhocFilter,
|
||||||
|
css,
|
||||||
|
ensureIsArray,
|
||||||
|
fetchTimeRange,
|
||||||
|
getTimeOffset,
|
||||||
|
SimpleAdhocFilter,
|
||||||
|
t,
|
||||||
|
} from '@superset-ui/core';
|
||||||
|
import ControlHeader, {
|
||||||
|
ControlHeaderProps,
|
||||||
|
} from 'src/explore/components/ControlHeader';
|
||||||
|
import { RootState } from 'src/views/store';
|
||||||
|
|
||||||
|
const isTimeRangeEqual = (
|
||||||
|
left: BinaryAdhocFilter[],
|
||||||
|
right: BinaryAdhocFilter[],
|
||||||
|
) => isEqual(left, right);
|
||||||
|
|
||||||
|
type ComparisonRangeLabelProps = ControlHeaderProps & {
|
||||||
|
multi?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComparisonRangeLabel = ({
|
||||||
|
multi = true,
|
||||||
|
}: ComparisonRangeLabelProps) => {
|
||||||
|
const [labels, setLabels] = useState<string[]>([]);
|
||||||
|
const currentTimeRangeFilters = useSelector<RootState, BinaryAdhocFilter[]>(
|
||||||
|
state =>
|
||||||
|
state.explore.form_data.adhoc_filters.filter(
|
||||||
|
(adhoc_filter: SimpleAdhocFilter) =>
|
||||||
|
adhoc_filter.operator === 'TEMPORAL_RANGE',
|
||||||
|
),
|
||||||
|
isTimeRangeEqual,
|
||||||
|
);
|
||||||
|
const shifts = useSelector<RootState, string[]>(
|
||||||
|
state => state.explore.form_data.time_compare,
|
||||||
|
);
|
||||||
|
const startDate = useSelector<RootState, string>(
|
||||||
|
state => state.explore.form_data.start_date_offset,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const shiftsArray = ensureIsArray(shifts);
|
||||||
|
if (
|
||||||
|
isEmpty(currentTimeRangeFilters) ||
|
||||||
|
(isEmpty(shiftsArray) && !startDate)
|
||||||
|
) {
|
||||||
|
setLabels([]);
|
||||||
|
} else if (!isEmpty(shifts) || startDate) {
|
||||||
|
const promises = currentTimeRangeFilters.map(filter => {
|
||||||
|
const newShifts = getTimeOffset(filter, shiftsArray, startDate);
|
||||||
|
|
||||||
|
return fetchTimeRange(
|
||||||
|
filter.comparator,
|
||||||
|
filter.subject,
|
||||||
|
ensureIsArray(newShifts),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Promise.all(promises).then(res => {
|
||||||
|
// access the value property inside the res and set the labels with it in the state
|
||||||
|
setLabels(res.map(r => r.value ?? ''));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [currentTimeRangeFilters, shifts, startDate]);
|
||||||
|
|
||||||
|
return labels.length ? (
|
||||||
|
<>
|
||||||
|
<ControlHeader label={t('Actual range for comparison')} />
|
||||||
|
{labels.flat().map(label => (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
css={theme => css`
|
||||||
|
font-size: ${theme.typography.sizes.m}px;
|
||||||
|
color: ${theme.colors.grayscale.dark1};
|
||||||
|
`}
|
||||||
|
key={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
};
|
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* 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, { ReactNode } from 'react';
|
||||||
|
import { isEqual } from 'lodash';
|
||||||
|
import moment, { Moment } from 'moment';
|
||||||
|
import {
|
||||||
|
parseDttmToDate,
|
||||||
|
BinaryAdhocFilter,
|
||||||
|
SimpleAdhocFilter,
|
||||||
|
css,
|
||||||
|
} from '@superset-ui/core';
|
||||||
|
import { DatePicker } from 'antd';
|
||||||
|
import { RangePickerProps } from 'antd/lib/date-picker';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
import ControlHeader from 'src/explore/components/ControlHeader';
|
||||||
|
import { RootState } from 'src/views/store';
|
||||||
|
|
||||||
|
export interface TimeOffsetControlsProps {
|
||||||
|
label?: ReactNode;
|
||||||
|
startDate?: string;
|
||||||
|
description?: string;
|
||||||
|
hovered?: boolean;
|
||||||
|
value?: Moment;
|
||||||
|
onChange: (datetime: string) => void;
|
||||||
|
}
|
||||||
|
const MOMENT_FORMAT = 'YYYY-MM-DD';
|
||||||
|
|
||||||
|
const isTimeRangeEqual = (
|
||||||
|
left: BinaryAdhocFilter[],
|
||||||
|
right: BinaryAdhocFilter[],
|
||||||
|
) => isEqual(left, right);
|
||||||
|
|
||||||
|
export default function TimeOffsetControls({
|
||||||
|
onChange,
|
||||||
|
...props
|
||||||
|
}: TimeOffsetControlsProps) {
|
||||||
|
const currentTimeRangeFilters = useSelector<RootState, BinaryAdhocFilter[]>(
|
||||||
|
state =>
|
||||||
|
state.explore.form_data.adhoc_filters.filter(
|
||||||
|
(adhoc_filter: SimpleAdhocFilter) =>
|
||||||
|
adhoc_filter.operator === 'TEMPORAL_RANGE',
|
||||||
|
),
|
||||||
|
isTimeRangeEqual,
|
||||||
|
);
|
||||||
|
|
||||||
|
const startDate = currentTimeRangeFilters[0]?.comparator.split(' : ')[0];
|
||||||
|
|
||||||
|
const formatedDate = moment(parseDttmToDate(startDate));
|
||||||
|
const disabledDate: RangePickerProps['disabledDate'] = current =>
|
||||||
|
current && current > formatedDate;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ControlHeader {...props} />
|
||||||
|
<DatePicker
|
||||||
|
css={css`
|
||||||
|
width: 100%;
|
||||||
|
`}
|
||||||
|
onChange={(datetime: Moment) =>
|
||||||
|
onChange(datetime ? datetime.format(MOMENT_FORMAT) : '')
|
||||||
|
}
|
||||||
|
defaultPickerValue={
|
||||||
|
startDate ? moment(formatedDate).subtract(1, 'day') : undefined
|
||||||
|
}
|
||||||
|
disabledDate={disabledDate}
|
||||||
|
defaultValue={formatedDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -34,6 +34,7 @@ import SpatialControl from './SpatialControl';
|
||||||
import TextAreaControl from './TextAreaControl';
|
import TextAreaControl from './TextAreaControl';
|
||||||
import TextControl from './TextControl';
|
import TextControl from './TextControl';
|
||||||
import TimeSeriesColumnControl from './TimeSeriesColumnControl';
|
import TimeSeriesColumnControl from './TimeSeriesColumnControl';
|
||||||
|
import TimeOffsetControl from './TimeOffsetControl';
|
||||||
import ViewportControl from './ViewportControl';
|
import ViewportControl from './ViewportControl';
|
||||||
import VizTypeControl from './VizTypeControl';
|
import VizTypeControl from './VizTypeControl';
|
||||||
import MetricsControl from './MetricControl/MetricsControl';
|
import MetricsControl from './MetricControl/MetricsControl';
|
||||||
|
@ -48,6 +49,7 @@ import DndColumnSelectControl, {
|
||||||
import XAxisSortControl from './XAxisSortControl';
|
import XAxisSortControl from './XAxisSortControl';
|
||||||
import CurrencyControl from './CurrencyControl';
|
import CurrencyControl from './CurrencyControl';
|
||||||
import ColumnConfigControl from './ColumnConfigControl';
|
import ColumnConfigControl from './ColumnConfigControl';
|
||||||
|
import { ComparisonRangeLabel } from './ComparisonRangeLabel';
|
||||||
|
|
||||||
const controlMap = {
|
const controlMap = {
|
||||||
AnnotationLayerControl,
|
AnnotationLayerControl,
|
||||||
|
@ -80,6 +82,8 @@ const controlMap = {
|
||||||
ConditionalFormattingControl,
|
ConditionalFormattingControl,
|
||||||
XAxisSortControl,
|
XAxisSortControl,
|
||||||
ContourControl,
|
ContourControl,
|
||||||
|
ComparisonRangeLabel,
|
||||||
|
TimeOffsetControl,
|
||||||
...sharedControlComponents,
|
...sharedControlComponents,
|
||||||
};
|
};
|
||||||
export default controlMap;
|
export default controlMap;
|
||||||
|
|
|
@ -19,7 +19,7 @@ from __future__ import annotations
|
||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Any, ClassVar, TYPE_CHECKING, TypedDict
|
from typing import Any, cast, ClassVar, TYPE_CHECKING, TypedDict
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
@ -55,6 +55,7 @@ from superset.utils.core import (
|
||||||
DateColumn,
|
DateColumn,
|
||||||
DTTM_ALIAS,
|
DTTM_ALIAS,
|
||||||
error_msg_from_exception,
|
error_msg_from_exception,
|
||||||
|
FilterOperator,
|
||||||
get_base_axis_labels,
|
get_base_axis_labels,
|
||||||
get_column_names_from_columns,
|
get_column_names_from_columns,
|
||||||
get_column_names_from_metrics,
|
get_column_names_from_metrics,
|
||||||
|
@ -390,11 +391,6 @@ class QueryContextProcessor:
|
||||||
|
|
||||||
time_grain = self.get_time_grain(query_object)
|
time_grain = self.get_time_grain(query_object)
|
||||||
|
|
||||||
if not time_grain:
|
|
||||||
raise QueryObjectValidationError(
|
|
||||||
_("Time Grain must be specified when using Time Shift.")
|
|
||||||
)
|
|
||||||
|
|
||||||
metric_names = get_metric_names(query_object.metrics)
|
metric_names = get_metric_names(query_object.metrics)
|
||||||
|
|
||||||
# use columns that are not metrics as join keys
|
# use columns that are not metrics as join keys
|
||||||
|
@ -429,6 +425,28 @@ class QueryContextProcessor:
|
||||||
query_object_clone.inner_to_dttm = outer_to_dttm
|
query_object_clone.inner_to_dttm = outer_to_dttm
|
||||||
query_object_clone.time_offsets = []
|
query_object_clone.time_offsets = []
|
||||||
query_object_clone.post_processing = []
|
query_object_clone.post_processing = []
|
||||||
|
# Get time offset index
|
||||||
|
index = (get_base_axis_labels(query_object.columns) or [DTTM_ALIAS])[0]
|
||||||
|
# The comparison is not using a temporal column so we need to modify
|
||||||
|
# the temporal filter so we run the query with the correct time range
|
||||||
|
if not dataframe_utils.is_datetime_series(df.get(index)):
|
||||||
|
# Lets find the first temporal filter in the filters array and change
|
||||||
|
# its val to be the result of get_since_until with the offset
|
||||||
|
for flt in query_object_clone.filter:
|
||||||
|
if flt.get(
|
||||||
|
"op"
|
||||||
|
) == FilterOperator.TEMPORAL_RANGE.value and isinstance(
|
||||||
|
flt.get("val"), str
|
||||||
|
):
|
||||||
|
time_range = cast(str, flt.get("val"))
|
||||||
|
(
|
||||||
|
new_outer_from_dttm,
|
||||||
|
new_outer_to_dttm,
|
||||||
|
) = get_since_until_from_time_range(
|
||||||
|
time_range=time_range,
|
||||||
|
time_shift=offset,
|
||||||
|
)
|
||||||
|
flt["val"] = f"{new_outer_from_dttm} : {new_outer_to_dttm}"
|
||||||
query_object_clone.filter = [
|
query_object_clone.filter = [
|
||||||
flt
|
flt
|
||||||
for flt in query_object_clone.filter
|
for flt in query_object_clone.filter
|
||||||
|
@ -488,16 +506,6 @@ class QueryContextProcessor:
|
||||||
# 2. rename extra query columns
|
# 2. rename extra query columns
|
||||||
offset_metrics_df = offset_metrics_df.rename(columns=metrics_mapping)
|
offset_metrics_df = offset_metrics_df.rename(columns=metrics_mapping)
|
||||||
|
|
||||||
# 3. set time offset for index
|
|
||||||
index = (get_base_axis_labels(query_object.columns) or [DTTM_ALIAS])[0]
|
|
||||||
if not dataframe_utils.is_datetime_series(offset_metrics_df.get(index)):
|
|
||||||
raise QueryObjectValidationError(
|
|
||||||
_(
|
|
||||||
"A time column must be specified "
|
|
||||||
"when using a Time Comparison."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# cache df and query
|
# cache df and query
|
||||||
value = {
|
value = {
|
||||||
"df": offset_metrics_df,
|
"df": offset_metrics_df,
|
||||||
|
@ -526,7 +534,7 @@ class QueryContextProcessor:
|
||||||
self,
|
self,
|
||||||
df: pd.DataFrame,
|
df: pd.DataFrame,
|
||||||
offset_dfs: dict[str, pd.DataFrame],
|
offset_dfs: dict[str, pd.DataFrame],
|
||||||
time_grain: str,
|
time_grain: str | None,
|
||||||
join_keys: list[str],
|
join_keys: list[str],
|
||||||
) -> pd.DataFrame:
|
) -> pd.DataFrame:
|
||||||
"""
|
"""
|
||||||
|
@ -541,43 +549,58 @@ class QueryContextProcessor:
|
||||||
time_grain
|
time_grain
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if join_column_producer and not time_grain:
|
||||||
|
raise QueryObjectValidationError(
|
||||||
|
_("Time Grain must be specified when using Time Shift.")
|
||||||
|
)
|
||||||
|
|
||||||
# iterate on offset_dfs, left join each with df
|
# iterate on offset_dfs, left join each with df
|
||||||
for offset, offset_df in offset_dfs.items():
|
for offset, offset_df in offset_dfs.items():
|
||||||
# defines a column name for the offset join column
|
actual_join_keys = join_keys
|
||||||
column_name = OFFSET_JOIN_COLUMN_SUFFIX + offset
|
|
||||||
|
|
||||||
# add offset join column to df
|
if time_grain:
|
||||||
self.add_offset_join_column(
|
# defines a column name for the offset join column
|
||||||
df, column_name, time_grain, offset, join_column_producer
|
column_name = OFFSET_JOIN_COLUMN_SUFFIX + offset
|
||||||
)
|
|
||||||
|
|
||||||
# add offset join column to offset_df
|
# add offset join column to df
|
||||||
self.add_offset_join_column(
|
self.add_offset_join_column(
|
||||||
offset_df, column_name, time_grain, None, join_column_producer
|
df, column_name, time_grain, offset, join_column_producer
|
||||||
)
|
)
|
||||||
|
|
||||||
# the temporal column is the first column in the join keys
|
# add offset join column to offset_df
|
||||||
# so we use the join column instead of the temporal column
|
self.add_offset_join_column(
|
||||||
actual_join_keys = [column_name, *join_keys[1:]]
|
offset_df, column_name, time_grain, None, join_column_producer
|
||||||
|
)
|
||||||
|
|
||||||
# left join df with offset_df
|
# the temporal column is the first column in the join keys
|
||||||
df = dataframe_utils.left_join_df(
|
# so we use the join column instead of the temporal column
|
||||||
left_df=df,
|
actual_join_keys = [column_name, *join_keys[1:]]
|
||||||
right_df=offset_df,
|
|
||||||
join_keys=actual_join_keys,
|
|
||||||
rsuffix=R_SUFFIX,
|
|
||||||
)
|
|
||||||
|
|
||||||
# move the temporal column to the first column in df
|
if join_keys:
|
||||||
col = df.pop(join_keys[0])
|
df = dataframe_utils.left_join_df(
|
||||||
df.insert(0, col.name, col)
|
left_df=df,
|
||||||
|
right_df=offset_df,
|
||||||
|
join_keys=actual_join_keys,
|
||||||
|
rsuffix=R_SUFFIX,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
df = dataframe_utils.full_outer_join_df(
|
||||||
|
left_df=df,
|
||||||
|
right_df=offset_df,
|
||||||
|
rsuffix=R_SUFFIX,
|
||||||
|
)
|
||||||
|
|
||||||
# removes columns created only for join purposes
|
if time_grain:
|
||||||
df.drop(
|
# move the temporal column to the first column in df
|
||||||
list(df.filter(regex=f"{OFFSET_JOIN_COLUMN_SUFFIX}|{R_SUFFIX}")),
|
col = df.pop(join_keys[0])
|
||||||
axis=1,
|
df.insert(0, col.name, col)
|
||||||
inplace=True,
|
|
||||||
)
|
# removes columns created only for join purposes
|
||||||
|
df.drop(
|
||||||
|
list(df.filter(regex=f"{OFFSET_JOIN_COLUMN_SUFFIX}|{R_SUFFIX}")),
|
||||||
|
axis=1,
|
||||||
|
inplace=True,
|
||||||
|
)
|
||||||
return df
|
return df
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
@ -40,6 +40,17 @@ def left_join_df(
|
||||||
return df
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def full_outer_join_df(
|
||||||
|
left_df: pd.DataFrame,
|
||||||
|
right_df: pd.DataFrame,
|
||||||
|
lsuffix: str = "",
|
||||||
|
rsuffix: str = "",
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
df = left_df.join(right_df, lsuffix=lsuffix, rsuffix=rsuffix, how="outer")
|
||||||
|
df.reset_index(inplace=True)
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
def df_metrics_to_num(df: pd.DataFrame, query_object: QueryObject) -> None:
|
def df_metrics_to_num(df: pd.DataFrame, query_object: QueryObject) -> None:
|
||||||
"""Converting metrics to numeric when pandas.read_sql cannot"""
|
"""Converting metrics to numeric when pandas.read_sql cannot"""
|
||||||
for col, dtype in df.dtypes.items():
|
for col, dtype in df.dtypes.items():
|
||||||
|
|
|
@ -21,7 +21,7 @@ from typing import Any, cast
|
||||||
|
|
||||||
from superset import app
|
from superset import app
|
||||||
from superset.common.query_object import QueryObject
|
from superset.common.query_object import QueryObject
|
||||||
from superset.utils.core import FilterOperator, get_xaxis_label
|
from superset.utils.core import FilterOperator
|
||||||
from superset.utils.date_parser import get_since_until
|
from superset.utils.date_parser import get_since_until
|
||||||
|
|
||||||
|
|
||||||
|
@ -66,10 +66,8 @@ def get_since_until_from_query_object(
|
||||||
|
|
||||||
time_range = None
|
time_range = None
|
||||||
for flt in query_object.filter:
|
for flt in query_object.filter:
|
||||||
if (
|
if flt.get("op") == FilterOperator.TEMPORAL_RANGE.value and isinstance(
|
||||||
flt.get("op") == FilterOperator.TEMPORAL_RANGE.value
|
flt.get("val"), str
|
||||||
and flt.get("col") == get_xaxis_label(query_object.columns)
|
|
||||||
and isinstance(flt.get("val"), str)
|
|
||||||
):
|
):
|
||||||
time_range = cast(str, flt.get("val"))
|
time_range = cast(str, flt.get("val"))
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,212 @@
|
||||||
|
# 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.
|
||||||
|
"""Update charts with old time comparison controls
|
||||||
|
|
||||||
|
Revision ID: f84fde59123a
|
||||||
|
Revises: 9621c6d56ffb
|
||||||
|
Create Date: 2024-05-10 18:02:38.891060
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from hashlib import md5
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import Column, Integer, or_, String, Text
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
|
||||||
|
from superset import db
|
||||||
|
from superset.migrations.shared.utils import paginated_update
|
||||||
|
from superset.utils.date_parser import get_since_until
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "f84fde59123a"
|
||||||
|
down_revision = "9621c6d56ffb"
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class Slice(Base):
|
||||||
|
__tablename__ = "slices"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
params = Column(Text)
|
||||||
|
viz_type = Column(String(250))
|
||||||
|
|
||||||
|
|
||||||
|
time_map = {
|
||||||
|
"r": "inherit",
|
||||||
|
"y": "1 year ago",
|
||||||
|
"m": "1 month ago",
|
||||||
|
"w": "1 week ago",
|
||||||
|
"c": "custom",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade_comparison_params(slice_params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
params = deepcopy(slice_params)
|
||||||
|
|
||||||
|
if "enable_time_comparison" in params:
|
||||||
|
# Remove enable_time_comparison
|
||||||
|
del params["enable_time_comparison"]
|
||||||
|
|
||||||
|
# Update time_comparison to time_compare
|
||||||
|
if "time_comparison" in params:
|
||||||
|
time_comp = params.pop("time_comparison")
|
||||||
|
params["time_compare"] = time_map.get(
|
||||||
|
time_comp, "inherit"
|
||||||
|
) # Default to 'inherit' if not found
|
||||||
|
|
||||||
|
# Add comparison_type
|
||||||
|
params["comparison_type"] = "values"
|
||||||
|
|
||||||
|
# Adjust adhoc_custom
|
||||||
|
if "adhoc_custom" in params and params["adhoc_custom"]:
|
||||||
|
adhoc = params["adhoc_custom"][0] # As there's always only one element
|
||||||
|
if adhoc["comparator"] != "No filter":
|
||||||
|
# Set start_date_offset in params, not in adhoc
|
||||||
|
start_date_offset, _ = get_since_until(adhoc["comparator"])
|
||||||
|
params["start_date_offset"] = start_date_offset.strftime("%Y-%m-%d")
|
||||||
|
# delete adhoc_custom
|
||||||
|
del params["adhoc_custom"]
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
session = db.Session(bind=bind)
|
||||||
|
|
||||||
|
for slc in paginated_update(
|
||||||
|
session.query(Slice).filter(
|
||||||
|
or_(Slice.viz_type == "pop_kpi", Slice.viz_type == "table")
|
||||||
|
)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
params = json.loads(slc.params)
|
||||||
|
updated_slice_params = upgrade_comparison_params(params)
|
||||||
|
slc.params = json.dumps(updated_slice_params)
|
||||||
|
except Exception as ex:
|
||||||
|
session.rollback()
|
||||||
|
logger.exception(
|
||||||
|
f"An error occurred: Upgrading params for slice {slc.id} failed."
|
||||||
|
f"You need to fix it before upgrading your DB."
|
||||||
|
)
|
||||||
|
raise Exception(f"An error occurred while upgrading slice: {ex}")
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade_comparison_params(slice_params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
params = deepcopy(slice_params)
|
||||||
|
|
||||||
|
reverse_time_map = {
|
||||||
|
v: k for k, v in time_map.items()
|
||||||
|
} # Reverse the map from the upgrade function
|
||||||
|
|
||||||
|
# Add enable_time_comparison
|
||||||
|
params["enable_time_comparison"] = True
|
||||||
|
|
||||||
|
# Revert time_compare to time_comparison
|
||||||
|
if "time_compare" in params:
|
||||||
|
time_comp = params.pop("time_compare")
|
||||||
|
params["time_comparison"] = reverse_time_map.get(
|
||||||
|
time_comp, "r"
|
||||||
|
) # Default to 'r' if not found
|
||||||
|
|
||||||
|
# Remove comparison_type
|
||||||
|
if "comparison_type" in params:
|
||||||
|
del params["comparison_type"]
|
||||||
|
|
||||||
|
# Default adhoc_custom
|
||||||
|
adhoc_custom = [
|
||||||
|
{
|
||||||
|
"clause": "WHERE",
|
||||||
|
"subject": "ds",
|
||||||
|
"operator": "TEMPORAL_RANGE",
|
||||||
|
"comparator": "No filter",
|
||||||
|
"expressionType": "SIMPLE",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Handle start_date_offset and adjust adhoc_custom if necessary
|
||||||
|
if "start_date_offset" in params:
|
||||||
|
start_date_offset = datetime.strptime(
|
||||||
|
params.pop("start_date_offset"), "%Y-%m-%d"
|
||||||
|
)
|
||||||
|
adhoc_filters = params.get("adhoc_filters", [])
|
||||||
|
temporal_range_filter = next(
|
||||||
|
(f for f in adhoc_filters if f["operator"] == "TEMPORAL_RANGE"), None
|
||||||
|
)
|
||||||
|
|
||||||
|
if temporal_range_filter:
|
||||||
|
since, until = get_since_until(temporal_range_filter["comparator"])
|
||||||
|
delta_days = (until - since).days
|
||||||
|
new_until_date = start_date_offset + timedelta(days=delta_days - 1)
|
||||||
|
comparator_str = f"{start_date_offset.strftime('%Y-%m-%d')} : {new_until_date.strftime('%Y-%m-%d')}"
|
||||||
|
|
||||||
|
# Generate filterOptionName
|
||||||
|
random_string = md5(comparator_str.encode("utf-8")).hexdigest()
|
||||||
|
filter_option_name = f"filter_{random_string}"
|
||||||
|
|
||||||
|
adhoc_custom[0] = {
|
||||||
|
"expressionType": "SIMPLE",
|
||||||
|
"subject": "ds",
|
||||||
|
"operator": "TEMPORAL_RANGE",
|
||||||
|
"comparator": comparator_str,
|
||||||
|
"clause": "WHERE",
|
||||||
|
"sqlExpression": None,
|
||||||
|
"isExtra": False,
|
||||||
|
"isNew": False,
|
||||||
|
"datasourceWarning": False,
|
||||||
|
"filterOptionName": filter_option_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
params["adhoc_custom"] = adhoc_custom
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
session = db.Session(bind=bind)
|
||||||
|
|
||||||
|
for slc in paginated_update(
|
||||||
|
session.query(Slice).filter(
|
||||||
|
Slice.viz_type == "pop_kpi" or Slice.viz_type == "table"
|
||||||
|
)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
params = json.loads(slc.params)
|
||||||
|
updated_slice_params = downgrade_comparison_params(params)
|
||||||
|
slc.params = json.dumps(updated_slice_params)
|
||||||
|
except Exception as ex:
|
||||||
|
session.rollback()
|
||||||
|
logger.exception(
|
||||||
|
f"An error occurred: Downgrading params for slice {slc.id} failed."
|
||||||
|
f"You need to fix it before downgrading your DB."
|
||||||
|
)
|
||||||
|
raise Exception(f"An error occurred while downgrading slice: {ex}")
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
session.close()
|
|
@ -263,9 +263,10 @@ def get_since_until( # pylint: disable=too-many-arguments,too-many-locals,too-m
|
||||||
)
|
)
|
||||||
|
|
||||||
if time_shift:
|
if time_shift:
|
||||||
time_delta = parse_past_timedelta(time_shift)
|
time_delta_since = parse_past_timedelta(time_shift, _since)
|
||||||
_since = _since if _since is None else (_since - time_delta)
|
time_delta_until = parse_past_timedelta(time_shift, _until)
|
||||||
_until = _until if _until is None else (_until - time_delta)
|
_since = _since if _since is None else (_since - time_delta_since)
|
||||||
|
_until = _until if _until is None else (_until - time_delta_until)
|
||||||
|
|
||||||
if instant_time_comparison_range:
|
if instant_time_comparison_range:
|
||||||
# This is only set using the new time comparison controls
|
# This is only set using the new time comparison controls
|
||||||
|
|
|
@ -46,6 +46,7 @@ get_time_range_schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"timeRange": {"type": "string"},
|
"timeRange": {"type": "string"},
|
||||||
|
"shift": {"type": "string"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -110,12 +111,16 @@ class Api(BaseSupersetView):
|
||||||
|
|
||||||
rv = []
|
rv = []
|
||||||
for time_range in time_ranges:
|
for time_range in time_ranges:
|
||||||
since, until = get_since_until(time_range["timeRange"])
|
since, until = get_since_until(
|
||||||
|
time_range=time_range["timeRange"],
|
||||||
|
time_shift=time_range.get("shift"),
|
||||||
|
)
|
||||||
rv.append(
|
rv.append(
|
||||||
{
|
{
|
||||||
"since": since.isoformat() if since else "",
|
"since": since.isoformat() if since else "",
|
||||||
"until": until.isoformat() if until else "",
|
"until": until.isoformat() if until else "",
|
||||||
"timeRange": time_range["timeRange"],
|
"timeRange": time_range["timeRange"],
|
||||||
|
"shift": time_range.get("shift"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self.json_response({"result": rv})
|
return self.json_response({"result": rv})
|
||||||
|
|
|
@ -1520,6 +1520,20 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase):
|
||||||
assert "until" in data["result"][0]
|
assert "until" in data["result"][0]
|
||||||
assert "timeRange" in data["result"][0]
|
assert "timeRange" in data["result"][0]
|
||||||
|
|
||||||
|
humanize_time_range = [
|
||||||
|
{"timeRange": "2021-01-01 : 2022-02-01", "shift": "1 year ago"},
|
||||||
|
{"timeRange": "2022-01-01 : 2023-02-01", "shift": "2 year ago"},
|
||||||
|
]
|
||||||
|
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"))
|
||||||
|
assert rv.status_code == 200
|
||||||
|
assert len(data["result"]) == 2
|
||||||
|
assert "since" in data["result"][0]
|
||||||
|
assert "until" in data["result"][0]
|
||||||
|
assert "timeRange" in data["result"][0]
|
||||||
|
assert "shift" in data["result"][0]
|
||||||
|
|
||||||
def test_query_form_data(self):
|
def test_query_form_data(self):
|
||||||
"""
|
"""
|
||||||
Chart API: Test query form data
|
Chart API: Test query form data
|
||||||
|
|
|
@ -0,0 +1,315 @@
|
||||||
|
# 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.
|
||||||
|
from copy import deepcopy
|
||||||
|
from importlib import import_module
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
migrate_time_comparison_to_new_format = import_module(
|
||||||
|
"superset.migrations.versions."
|
||||||
|
"2024-05-10_18-02_f84fde59123a_update_charts_with_old_time_comparison",
|
||||||
|
)
|
||||||
|
downgrade_comparison_params = (
|
||||||
|
migrate_time_comparison_to_new_format.downgrade_comparison_params
|
||||||
|
)
|
||||||
|
upgrade_comparison_params = (
|
||||||
|
migrate_time_comparison_to_new_format.upgrade_comparison_params
|
||||||
|
)
|
||||||
|
|
||||||
|
params_v1_with_custom: dict[str, Any] = {
|
||||||
|
"datasource": "2__table",
|
||||||
|
"viz_type": "pop_kpi",
|
||||||
|
"metric": {
|
||||||
|
"expressionType": "SIMPLE",
|
||||||
|
"column": {
|
||||||
|
"advanced_data_type": None,
|
||||||
|
"certification_details": None,
|
||||||
|
"certified_by": None,
|
||||||
|
"column_name": "num_boys",
|
||||||
|
"description": None,
|
||||||
|
"expression": None,
|
||||||
|
"filterable": True,
|
||||||
|
"groupby": True,
|
||||||
|
"id": 334,
|
||||||
|
"is_certified": False,
|
||||||
|
"is_dttm": False,
|
||||||
|
"python_date_format": None,
|
||||||
|
"type": "BIGINT",
|
||||||
|
"type_generic": 0,
|
||||||
|
"verbose_name": None,
|
||||||
|
"warning_markdown": None,
|
||||||
|
},
|
||||||
|
"aggregate": "SUM",
|
||||||
|
"sqlExpression": None,
|
||||||
|
"datasourceWarning": False,
|
||||||
|
"hasCustomLabel": False,
|
||||||
|
"label": "SUM(num_boys)",
|
||||||
|
"optionName": "metric_o6rj1h6jty_3t6mrruogfv",
|
||||||
|
},
|
||||||
|
"adhoc_filters": [
|
||||||
|
{
|
||||||
|
"expressionType": "SIMPLE",
|
||||||
|
"subject": "ds",
|
||||||
|
"operator": "TEMPORAL_RANGE",
|
||||||
|
"comparator": "1984 : 1986",
|
||||||
|
"clause": "WHERE",
|
||||||
|
"sqlExpression": None,
|
||||||
|
"isExtra": False,
|
||||||
|
"isNew": False,
|
||||||
|
"datasourceWarning": False,
|
||||||
|
"filterOptionName": "filter_p50i4xw50d_8x8e4ypwjs8",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"row_limit": 10000,
|
||||||
|
"y_axis_format": "SMART_NUMBER",
|
||||||
|
"percentDifferenceFormat": "SMART_NUMBER",
|
||||||
|
"header_font_size": 0.2,
|
||||||
|
"subheader_font_size": 0.125,
|
||||||
|
"comparison_color_scheme": "Green",
|
||||||
|
"extra_form_data": {},
|
||||||
|
"dashboards": [],
|
||||||
|
"time_comparison": "c",
|
||||||
|
"enable_time_comparison": True,
|
||||||
|
"adhoc_custom": [
|
||||||
|
{
|
||||||
|
"expressionType": "SIMPLE",
|
||||||
|
"subject": "ds",
|
||||||
|
"operator": "TEMPORAL_RANGE",
|
||||||
|
"comparator": "1981-01-01 : 1983-01-01",
|
||||||
|
"clause": "WHERE",
|
||||||
|
"sqlExpression": None,
|
||||||
|
"isExtra": False,
|
||||||
|
"isNew": False,
|
||||||
|
"datasourceWarning": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
params_v1_other_than_custom: dict[str, Any] = {
|
||||||
|
"datasource": "2__table",
|
||||||
|
"viz_type": "pop_kpi",
|
||||||
|
"metric": {
|
||||||
|
"expressionType": "SIMPLE",
|
||||||
|
"column": {
|
||||||
|
"advanced_data_type": None,
|
||||||
|
"certification_details": None,
|
||||||
|
"certified_by": None,
|
||||||
|
"column_name": "num_boys",
|
||||||
|
"description": None,
|
||||||
|
"expression": None,
|
||||||
|
"filterable": True,
|
||||||
|
"groupby": True,
|
||||||
|
"id": 334,
|
||||||
|
"is_certified": False,
|
||||||
|
"is_dttm": False,
|
||||||
|
"python_date_format": None,
|
||||||
|
"type": "BIGINT",
|
||||||
|
"type_generic": 0,
|
||||||
|
"verbose_name": None,
|
||||||
|
"warning_markdown": None,
|
||||||
|
},
|
||||||
|
"aggregate": "SUM",
|
||||||
|
"sqlExpression": None,
|
||||||
|
"datasourceWarning": False,
|
||||||
|
"hasCustomLabel": False,
|
||||||
|
"label": "SUM(num_boys)",
|
||||||
|
"optionName": "metric_96s7b8iypsr_4wrlgm0i7il",
|
||||||
|
},
|
||||||
|
"adhoc_filters": [
|
||||||
|
{
|
||||||
|
"expressionType": "SIMPLE",
|
||||||
|
"subject": "ds",
|
||||||
|
"operator": "TEMPORAL_RANGE",
|
||||||
|
"comparator": "1984 : 2000",
|
||||||
|
"clause": "WHERE",
|
||||||
|
"sqlExpression": None,
|
||||||
|
"isExtra": False,
|
||||||
|
"isNew": False,
|
||||||
|
"datasourceWarning": False,
|
||||||
|
"filterOptionName": "filter_2sefqq1rwb7_lhqvw7ukc6",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"row_limit": 10000,
|
||||||
|
"y_axis_format": "SMART_NUMBER",
|
||||||
|
"percentDifferenceFormat": "SMART_NUMBER",
|
||||||
|
"header_font_size": 0.2,
|
||||||
|
"subheader_font_size": 0.125,
|
||||||
|
"comparison_color_scheme": "Green",
|
||||||
|
"extra_form_data": {},
|
||||||
|
"dashboards": [],
|
||||||
|
"time_comparison": "r",
|
||||||
|
"enable_time_comparison": True,
|
||||||
|
"adhoc_custom": [
|
||||||
|
{
|
||||||
|
"clause": "WHERE",
|
||||||
|
"subject": "ds",
|
||||||
|
"operator": "TEMPORAL_RANGE",
|
||||||
|
"comparator": "No filter",
|
||||||
|
"expressionType": "SIMPLE",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
params_v2_with_custom: dict[str, Any] = {
|
||||||
|
"datasource": "2__table",
|
||||||
|
"viz_type": "pop_kpi",
|
||||||
|
"metric": {
|
||||||
|
"expressionType": "SIMPLE",
|
||||||
|
"column": {
|
||||||
|
"advanced_data_type": None,
|
||||||
|
"certification_details": None,
|
||||||
|
"certified_by": None,
|
||||||
|
"column_name": "num_boys",
|
||||||
|
"description": None,
|
||||||
|
"expression": None,
|
||||||
|
"filterable": True,
|
||||||
|
"groupby": True,
|
||||||
|
"id": 334,
|
||||||
|
"is_certified": False,
|
||||||
|
"is_dttm": False,
|
||||||
|
"python_date_format": None,
|
||||||
|
"type": "BIGINT",
|
||||||
|
"type_generic": 0,
|
||||||
|
"verbose_name": None,
|
||||||
|
"warning_markdown": None,
|
||||||
|
},
|
||||||
|
"aggregate": "SUM",
|
||||||
|
"sqlExpression": None,
|
||||||
|
"datasourceWarning": False,
|
||||||
|
"hasCustomLabel": False,
|
||||||
|
"label": "SUM(num_boys)",
|
||||||
|
"optionName": "metric_o6rj1h6jty_3t6mrruogfv",
|
||||||
|
},
|
||||||
|
"adhoc_filters": [
|
||||||
|
{
|
||||||
|
"expressionType": "SIMPLE",
|
||||||
|
"subject": "ds",
|
||||||
|
"operator": "TEMPORAL_RANGE",
|
||||||
|
"comparator": "1984 : 1986",
|
||||||
|
"clause": "WHERE",
|
||||||
|
"sqlExpression": None,
|
||||||
|
"isExtra": False,
|
||||||
|
"isNew": False,
|
||||||
|
"datasourceWarning": False,
|
||||||
|
"filterOptionName": "filter_p50i4xw50d_8x8e4ypwjs8",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"row_limit": 10000,
|
||||||
|
"y_axis_format": "SMART_NUMBER",
|
||||||
|
"percentDifferenceFormat": "SMART_NUMBER",
|
||||||
|
"header_font_size": 0.2,
|
||||||
|
"subheader_font_size": 0.125,
|
||||||
|
"comparison_color_scheme": "Green",
|
||||||
|
"extra_form_data": {},
|
||||||
|
"dashboards": [],
|
||||||
|
"time_compare": "custom",
|
||||||
|
"comparison_type": "values",
|
||||||
|
"start_date_offset": "1981-01-01",
|
||||||
|
}
|
||||||
|
params_v2_other_than_custom: dict[str, Any] = {
|
||||||
|
"datasource": "2__table",
|
||||||
|
"viz_type": "pop_kpi",
|
||||||
|
"metric": {
|
||||||
|
"expressionType": "SIMPLE",
|
||||||
|
"column": {
|
||||||
|
"advanced_data_type": None,
|
||||||
|
"certification_details": None,
|
||||||
|
"certified_by": None,
|
||||||
|
"column_name": "num_boys",
|
||||||
|
"description": None,
|
||||||
|
"expression": None,
|
||||||
|
"filterable": True,
|
||||||
|
"groupby": True,
|
||||||
|
"id": 334,
|
||||||
|
"is_certified": False,
|
||||||
|
"is_dttm": False,
|
||||||
|
"python_date_format": None,
|
||||||
|
"type": "BIGINT",
|
||||||
|
"type_generic": 0,
|
||||||
|
"verbose_name": None,
|
||||||
|
"warning_markdown": None,
|
||||||
|
},
|
||||||
|
"aggregate": "SUM",
|
||||||
|
"sqlExpression": None,
|
||||||
|
"datasourceWarning": False,
|
||||||
|
"hasCustomLabel": False,
|
||||||
|
"label": "SUM(num_boys)",
|
||||||
|
"optionName": "metric_96s7b8iypsr_4wrlgm0i7il",
|
||||||
|
},
|
||||||
|
"adhoc_filters": [
|
||||||
|
{
|
||||||
|
"expressionType": "SIMPLE",
|
||||||
|
"subject": "ds",
|
||||||
|
"operator": "TEMPORAL_RANGE",
|
||||||
|
"comparator": "1984 : 2000",
|
||||||
|
"clause": "WHERE",
|
||||||
|
"sqlExpression": None,
|
||||||
|
"isExtra": False,
|
||||||
|
"isNew": False,
|
||||||
|
"datasourceWarning": False,
|
||||||
|
"filterOptionName": "filter_2sefqq1rwb7_lhqvw7ukc6",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"row_limit": 10000,
|
||||||
|
"y_axis_format": "SMART_NUMBER",
|
||||||
|
"percentDifferenceFormat": "SMART_NUMBER",
|
||||||
|
"header_font_size": 0.2,
|
||||||
|
"subheader_font_size": 0.125,
|
||||||
|
"comparison_color_scheme": "Green",
|
||||||
|
"extra_form_data": {},
|
||||||
|
"dashboards": [],
|
||||||
|
"time_compare": "inherit",
|
||||||
|
"comparison_type": "values",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_chart_params_with_custom():
|
||||||
|
"""
|
||||||
|
ensure that the new time comparison params are added
|
||||||
|
"""
|
||||||
|
original_params = deepcopy(params_v1_with_custom)
|
||||||
|
upgraded_params = upgrade_comparison_params(original_params)
|
||||||
|
assert upgraded_params == params_v2_with_custom
|
||||||
|
|
||||||
|
|
||||||
|
def test_downgrade_chart_params_with_custom():
|
||||||
|
"""
|
||||||
|
ensure that the params downgrade operation produces an almost identical dict
|
||||||
|
as the original value
|
||||||
|
"""
|
||||||
|
original_params = deepcopy(params_v2_with_custom)
|
||||||
|
downgraded_params = downgrade_comparison_params(original_params)
|
||||||
|
# Ignore any property called filterOptionName simce that uses a random hash
|
||||||
|
for adhoc_custom in downgraded_params["adhoc_custom"]:
|
||||||
|
adhoc_custom.pop("filterOptionName", None)
|
||||||
|
assert downgraded_params == params_v1_with_custom
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_chart_params_other_than_custom():
|
||||||
|
"""
|
||||||
|
ensure that the new time comparison params are added
|
||||||
|
"""
|
||||||
|
original_params = deepcopy(params_v1_other_than_custom)
|
||||||
|
upgraded_params = upgrade_comparison_params(original_params)
|
||||||
|
assert upgraded_params == params_v2_other_than_custom
|
||||||
|
|
||||||
|
|
||||||
|
def test_downgrade_chart_params_other_than_custom():
|
||||||
|
"""
|
||||||
|
ensure that the params downgrade operation produces an almost identical dict
|
||||||
|
as the original value
|
||||||
|
"""
|
||||||
|
original_params = deepcopy(params_v2_other_than_custom)
|
||||||
|
downgraded_params = downgrade_comparison_params(original_params)
|
||||||
|
assert downgraded_params == params_v1_other_than_custom
|
|
@ -189,6 +189,27 @@ def test_get_since_until() -> None:
|
||||||
expected = datetime(2000, 1, 1), datetime(2018, 1, 1)
|
expected = datetime(2000, 1, 1), datetime(2018, 1, 1)
|
||||||
assert result == expected
|
assert result == expected
|
||||||
|
|
||||||
|
result = get_since_until(
|
||||||
|
time_range="2000-01-01T00:00:00 : 2018-01-01T00:00:00",
|
||||||
|
time_shift="1 year ago",
|
||||||
|
)
|
||||||
|
expected = datetime(1999, 1, 1), datetime(2017, 1, 1)
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
result = get_since_until(
|
||||||
|
time_range="2000-01-01T00:00:00 : 2018-01-01T00:00:00",
|
||||||
|
time_shift="1 month ago",
|
||||||
|
)
|
||||||
|
expected = datetime(1999, 12, 1), datetime(2017, 12, 1)
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
result = get_since_until(
|
||||||
|
time_range="2000-01-01T00:00:00 : 2018-01-01T00:00:00",
|
||||||
|
time_shift="1 week ago",
|
||||||
|
)
|
||||||
|
expected = datetime(1999, 12, 25), datetime(2017, 12, 25)
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
get_since_until(time_range="tomorrow : yesterday")
|
get_since_until(time_range="tomorrow : yesterday")
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue