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})

Reply via email to