This is an automated email from the ASF dual-hosted git repository. arivero pushed a commit to branch table-time-comparison-offset in repository https://gitbox.apache.org/repos/asf/superset.git
commit 1c283435c3386aff35b395009e7dcc13bcf2579b Author: Antonio Rivero <[email protected]> AuthorDate: Mon Apr 15 13:12:01 2024 +0200 Table with Time Comparison: - Use existing time_offsets API to build time comparison in teh Table viz - Use the time comparison label and section from BN POC - Add a new visibility prop to the ControlPanelSectionConfig so we can hide/show an entire section based on control values --- .../src/sections/index.ts | 1 + .../src/sections/timeComparison.tsx | 120 +++++++++++++++++++++ .../superset-ui-chart-controls/src/types.ts | 1 + .../src/time-comparison/fetchTimeRange.ts | 57 ++++++++-- .../plugins/plugin-chart-table/src/buildQuery.ts | 46 +++++++- .../plugin-chart-table/src/controlPanel.tsx | 17 +++ .../explore/components/ControlPanelsContainer.tsx | 10 +- .../components/controls/ComparisonRangeLabel.tsx | 96 +++++++++++++++++ .../src/explore/components/controls/index.js | 2 + superset/views/api.py | 7 +- 10 files changed, 345 insertions(+), 12 deletions(-) diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/sections/index.ts b/superset-frontend/packages/superset-ui-chart-controls/src/sections/index.ts index c0113b189f..caa07faa9c 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/sections/index.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/sections/index.ts @@ -23,3 +23,4 @@ export * from './annotationsAndLayers'; export * from './forecastInterval'; export * from './chartTitle'; export * from './echartsTimeSeriesQuery'; +export * from './timeComparison'; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/sections/timeComparison.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/sections/timeComparison.tsx new file mode 100644 index 0000000000..eb8ac80033 --- /dev/null +++ b/superset-frontend/packages/superset-ui-chart-controls/src/sections/timeComparison.tsx @@ -0,0 +1,120 @@ +/** + * 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, + ensureIsArray, + isAdhocColumn, + isPhysicalColumn, +} from '@superset-ui/core'; + +import { ControlPanelSectionConfig } from '../types'; +import { sharedControls } from '../shared-controls'; + +export const timeComparisonControls: ControlPanelSectionConfig = { + label: t('Time Comparison'), + tabOverride: 'data', + description: t( + 'This section contains options ' + + 'that allow for time comparison ' + + 'of query results using some portions of the ' + + 'existing advanced analytics section', + ), + controlSetRows: [ + [ + { + name: 'time_compare', + config: { + type: 'SelectControl', + multi: true, + freeForm: true, + label: t('Time shift'), + choices: [ + ['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')], + ['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')], + ], + description: t( + 'Overlay one or more timeseries 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.', + ), + }, + }, + ], + [ + { + name: 'time_comparison_grain_sqla', + config: { + ...sharedControls.time_grain_sqla, + visibility: ({ controls }) => { + // So it doesn't collide with any existing time_grain_sqla control + const dttmLookup = Object.fromEntries( + ensureIsArray(controls?.groupby?.options).map(option => [ + option.column_name, + option.is_dttm, + ]), + ); + + return !ensureIsArray(controls?.groupby.value) + .map(selection => { + if (isAdhocColumn(selection)) { + return true; + } + if (isPhysicalColumn(selection)) { + return !!dttmLookup[selection]; + } + return false; + }) + .some(Boolean); + }, + }, + }, + ], + [ + { + 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.', + ), + }, + }, + ], + ], +}; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts index 3d149b1299..85fd0063d6 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts @@ -376,6 +376,7 @@ export interface ControlPanelSectionConfig { expanded?: boolean; tabOverride?: TabOverride; controlSetRows: ControlSetRow[]; + visibility?: (props: ControlPanelsContainerProps) => boolean; } export interface StandardizedControls { diff --git a/superset-frontend/packages/superset-ui-core/src/time-comparison/fetchTimeRange.ts b/superset-frontend/packages/superset-ui-core/src/time-comparison/fetchTimeRange.ts index 50509af52b..8de50d501b 100644 --- a/superset-frontend/packages/superset-ui-core/src/time-comparison/fetchTimeRange.ts +++ b/superset-frontend/packages/superset-ui-core/src/time-comparison/fetchTimeRange.ts @@ -18,6 +18,7 @@ */ import rison from 'rison'; import { SupersetClient, getClientErrorObject } from '@superset-ui/core'; +import { isEmpty } from 'lodash'; export const SEPARATOR = ' : '; @@ -39,20 +40,64 @@ export const formatTimeRange = ( )} ≤ ${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 ( timeRange: string, columnPlaceholder = 'col', + shifts?: string[], ) => { - const query = rison.encode_uri(timeRange); - const endpoint = `/api/v1/time_range/?q=${query}`; + let query; + let endpoint; + if (!isEmpty(shifts)) { + const timeRanges = 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 { const response = await SupersetClient.get({ endpoint }); - const timeRangeString = buildTimeRangeString( - response?.json?.result[0]?.since || '', - response?.json?.result[0]?.until || '', + if (isEmpty(shifts)) { + const timeRangeString = buildTimeRangeString( + 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 { - value: formatTimeRange(timeRangeString, columnPlaceholder), + value: timeRanges + .slice(1) + .map((timeRange: string) => + formatTimeRangeComparison( + timeRanges[0], + timeRange, + columnPlaceholder, + ), + ), }; } catch (response) { const clientError = await getClientErrorObject(response); diff --git a/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts b/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts index 69631a5f35..75bd8c6b37 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts @@ -28,6 +28,11 @@ import { } from '@superset-ui/core'; import { PostProcessingRule } from '@superset-ui/core/src/query/types/PostProcessing'; import { BuildQuery } from '@superset-ui/core/src/chart/registries/ChartBuildQueryRegistrySingleton'; +import { + isTimeComparison, + timeCompareOperator, +} from '@superset-ui/chart-controls'; +import { isEmpty } from 'lodash'; import { TableChartFormData } from './types'; import { updateExternalFormData } from './DataTable/utils/externalAPIs'; @@ -69,9 +74,19 @@ const buildQuery: BuildQuery<TableChartFormData> = ( }; } + const addComparisonPercentMetrics = (metrics: string[], suffixes: string[]) => + metrics.reduce<string[]>((acc, metric) => { + const newMetrics = suffixes.map(suffix => `${metric}__${suffix}`); + return acc.concat([metric, ...newMetrics]); + }, []); + return buildQueryContext(formDataCopy, baseQueryObject => { let { metrics, orderby = [], columns = [] } = baseQueryObject; let postProcessing: PostProcessingRule[] = []; + const timeOffsets = isTimeComparison(formData, baseQueryObject) + ? formData.time_compare + : []; + const timeCompareGrainSqla = formData.time_comparison_grain_sqla; if (queryMode === QueryMode.Aggregate) { metrics = metrics || []; @@ -85,8 +100,17 @@ const buildQuery: BuildQuery<TableChartFormData> = ( } // add postprocessing for percent metrics only when in aggregation mode if (percentMetrics && percentMetrics.length > 0) { + const percentMetricsLabelsWithTimeComparison = isTimeComparison( + formData, + baseQueryObject, + ) + ? addComparisonPercentMetrics( + percentMetrics.map(getMetricLabel), + timeOffsets, + ) + : percentMetrics.map(getMetricLabel); const percentMetricLabels = removeDuplicates( - percentMetrics.map(getMetricLabel), + percentMetricsLabelsWithTimeComparison, ); metrics = removeDuplicates( metrics.concat(percentMetrics), @@ -102,13 +126,19 @@ const buildQuery: BuildQuery<TableChartFormData> = ( }, ]; } + // Add the operator for the time comparison if some is selected + if (!isEmpty(timeOffsets)) { + postProcessing.push(timeCompareOperator(formData, baseQueryObject)); + } + let temporalColumAdded = false; columns = columns.map(col => { if ( isPhysicalColumn(col) && time_grain_sqla && formData?.temporal_columns_lookup?.[col] ) { + temporalColumAdded = true; return { timeGrain: time_grain_sqla, columnType: 'BASE_AXIS', @@ -119,6 +149,19 @@ const buildQuery: BuildQuery<TableChartFormData> = ( } return col; }); + + if (!temporalColumAdded && !isEmpty(timeOffsets)) { + columns = [ + { + timeGrain: timeCompareGrainSqla || 'P1Y', // Group by year by default + columnType: 'BASE_AXIS', + sqlExpression: baseQueryObject.filters?.[0]?.col.toString() || '', + label: baseQueryObject.filters?.[0]?.col.toString() || '', + expressionType: 'SQL', + } as AdhocColumn, + ...columns, + ]; + } } const moreProps: Partial<QueryObject> = {}; @@ -136,6 +179,7 @@ const buildQuery: BuildQuery<TableChartFormData> = ( orderby, metrics, post_processing: postProcessing, + time_offsets: timeOffsets, ...moreProps, }; diff --git a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx index 6cce125f36..035e3195e7 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx @@ -44,6 +44,7 @@ import { ColumnMeta, defineSavedMetrics, getStandardizedControls, + sections, } from '@superset-ui/chart-controls'; import { PAGE_SIZE_OPTIONS } from './consts'; @@ -531,6 +532,22 @@ const config: ControlPanelConfig = { ], ], }, + { + ...sections.timeComparisonControls, + controlSetRows: [ + ...sections.timeComparisonControls.controlSetRows, + [ + { + name: 'comparison_range_label', + config: { + type: 'ComparisonRangeLabel', + multi: false, + }, + }, + ], + ], + visibility: isAggMode, + }, ], formDataOverrides: formData => ({ ...formData, diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx index d47c1abaf8..9811018fb4 100644 --- a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx +++ b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx @@ -542,14 +542,16 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { const renderControlPanelSection = ( section: ExpandedControlPanelSectionConfig, ) => { - const { controls } = props; - const { label, description } = section; + const { controls } = props || {}; + const { label, description, visibility } = section; // Section label can be a ReactNode but in some places we want to // have a string ID. Using forced type conversion for now, // should probably add a `id` field to sections in the future. const sectionId = String(label); + const isVisible = visibility ? visibility.call(section, props) : true; + const hasErrors = section.controlSetRows.some(rows => rows.some(item => { const controlName = @@ -606,7 +608,7 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { </span> ); - return ( + return isVisible ? ( <Collapse.Panel css={theme => css` margin-bottom: 0; @@ -668,7 +670,7 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { ); })} </Collapse.Panel> - ); + ) : null; }; const hasControlsTransferred = diff --git a/superset-frontend/src/explore/components/controls/ComparisonRangeLabel.tsx b/superset-frontend/src/explore/components/controls/ComparisonRangeLabel.tsx new file mode 100644 index 0000000000..b056388107 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/ComparisonRangeLabel.tsx @@ -0,0 +1,96 @@ +/** + * 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, + fetchTimeRange, + 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, + ); + + useEffect(() => { + if (isEmpty(currentTimeRangeFilters) || isEmpty(shifts)) { + setLabels([]); + } else if (!isEmpty(shifts)) { + const promises = currentTimeRangeFilters.map(filter => + fetchTimeRange( + filter.comparator, + filter.subject, + multi ? shifts : shifts.slice(0, 1), + ), + ); + 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]); + + 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; +}; diff --git a/superset-frontend/src/explore/components/controls/index.js b/superset-frontend/src/explore/components/controls/index.js index a5d65f7768..2bf2662d0c 100644 --- a/superset-frontend/src/explore/components/controls/index.js +++ b/superset-frontend/src/explore/components/controls/index.js @@ -48,6 +48,7 @@ import DndColumnSelectControl, { import XAxisSortControl from './XAxisSortControl'; import CurrencyControl from './CurrencyControl'; import ColumnConfigControl from './ColumnConfigControl'; +import { ComparisonRangeLabel } from './ComparisonRangeLabel'; const controlMap = { AnnotationLayerControl, @@ -80,6 +81,7 @@ const controlMap = { ConditionalFormattingControl, XAxisSortControl, ContourControl, + ComparisonRangeLabel, ...sharedControlComponents, }; export default controlMap; diff --git a/superset/views/api.py b/superset/views/api.py index 2e3c3b9bdd..d5dd0eca4c 100644 --- a/superset/views/api.py +++ b/superset/views/api.py @@ -46,6 +46,7 @@ get_time_range_schema = { "type": "object", "properties": { "timeRange": {"type": "string"}, + "shift": {"type": "string"}, }, }, } @@ -110,12 +111,16 @@ class Api(BaseSupersetView): rv = [] 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( { "since": since.isoformat() if since else "", "until": until.isoformat() if until else "", "timeRange": time_range["timeRange"], + "shift": time_range.get("shift"), } ) return self.json_response({"result": rv})
