This is an automated email from the ASF dual-hosted git repository.

kgabryje pushed a commit to branch what-if
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 1370810a9c09cd9a5c15b220d79311bec98f1921
Author: Kamil Gabryjelski <[email protected]>
AuthorDate: Thu Dec 18 19:40:49 2025 +0100

    Filters UI
---
 .../src/time-comparison/fetchTimeRange.ts          |   4 +-
 .../superset-ui-core/src/time-comparison/index.ts  |   6 +-
 .../dashboard/components/WhatIfDrawer/index.tsx    | 337 ++++++++++++++++++++-
 superset/connectors/sqla/models.py                 |  13 +-
 4 files changed, 338 insertions(+), 22 deletions(-)

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 61c2a2f8ad..7c52ee814a 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
@@ -32,7 +32,7 @@ export const buildTimeRangeString = (since: string, until: 
string): string =>
 const formatDateEndpoint = (dttm: string, isStart?: boolean): string =>
   dttm.replace('T00:00:00', '') || (isStart ? '-∞' : '∞');
 
-export const formatTimeRange = (
+export const formatTimeRangeLabel = (
   timeRange: string,
   columnPlaceholder = 'col',
 ) => {
@@ -86,7 +86,7 @@ export const fetchTimeRange = async (
         response?.json?.result[0]?.until || '',
       );
       return {
-        value: formatTimeRange(timeRangeString, columnPlaceholder),
+        value: formatTimeRangeLabel(timeRangeString, columnPlaceholder),
       };
     }
     const timeRanges = response?.json?.result.map((result: any) =>
diff --git 
a/superset-frontend/packages/superset-ui-core/src/time-comparison/index.ts 
b/superset-frontend/packages/superset-ui-core/src/time-comparison/index.ts
index 94f8ea4527..bf35a66ab2 100644
--- a/superset-frontend/packages/superset-ui-core/src/time-comparison/index.ts
+++ b/superset-frontend/packages/superset-ui-core/src/time-comparison/index.ts
@@ -26,5 +26,9 @@ export {
   getTimeOffset,
   computeCustomDateTime,
 } from './getTimeOffset';
-export { SEPARATOR, fetchTimeRange } from './fetchTimeRange';
+export {
+  SEPARATOR,
+  fetchTimeRange,
+  formatTimeRangeLabel,
+} from './fetchTimeRange';
 export { customTimeRangeDecode } from './customTimeRangeDecode';
diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx 
b/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx
index 60dc4c2dd8..b0c0220948 100644
--- a/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx
+++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx
@@ -18,7 +18,7 @@
  */
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useDispatch, useSelector } from 'react-redux';
-import { t, logging } from '@superset-ui/core';
+import { t, logging, formatTimeRangeLabel } from '@superset-ui/core';
 import { css, styled, Alert, useTheme } from '@apache-superset/core/ui';
 import {
   Button,
@@ -26,6 +26,7 @@ import {
   Checkbox,
   Tooltip,
   Tag,
+  Popover,
 } from '@superset-ui/core/components';
 import Slider from '@superset-ui/core/components/Slider';
 import { Icons } from '@superset-ui/core/components/Icons';
@@ -35,7 +36,17 @@ import {
   saveOriginalChartData,
 } from 'src/components/Chart/chartAction';
 import { getNumericColumnsForDashboard } from 'src/dashboard/util/whatIf';
-import { RootState, Slice, WhatIfColumn } from 'src/dashboard/types';
+import {
+  RootState,
+  Slice,
+  WhatIfColumn,
+  WhatIfFilter,
+  Datasource,
+} from 'src/dashboard/types';
+import AdhocFilter from 
'src/explore/components/controls/FilterControl/AdhocFilter';
+import AdhocFilterEditPopover from 
'src/explore/components/controls/FilterControl/AdhocFilterEditPopover';
+import { Clauses } from 'src/explore/components/controls/FilterControl/types';
+import { OPERATOR_ENUM_TO_OPERATOR_TYPE } from 'src/explore/constants';
 import WhatIfAIInsights from './WhatIfAIInsights';
 import { fetchRelatedColumnSuggestions } from './whatIfApi';
 import { ExtendedWhatIfModification } from './types';
@@ -189,6 +200,48 @@ const AIReasoningItem = styled.div`
   color: ${({ theme }) => theme.colorTextSecondary};
 `;
 
+const ColumnSelectRow = styled.div`
+  display: flex;
+  gap: ${({ theme }) => theme.sizeUnit * 2}px;
+  align-items: flex-start;
+`;
+
+const ColumnSelectWrapper = styled.div`
+  flex: 1;
+  min-width: 0;
+`;
+
+const FilterButton = styled(Button)`
+  flex-shrink: 0;
+  padding: 0 ${({ theme }) => theme.sizeUnit * 2}px;
+`;
+
+const FilterPopoverContent = styled.div`
+  .edit-popover-resize {
+    transform: scaleX(-1);
+    float: right;
+    margin-top: ${({ theme }) => theme.sizeUnit * 4}px;
+    margin-right: ${({ theme }) => theme.sizeUnit * -1}px;
+    color: ${({ theme }) => theme.colorIcon};
+    cursor: nwse-resize;
+  }
+  .filter-sql-editor {
+    border: ${({ theme }) => theme.colorBorder} solid thin;
+  }
+`;
+
+const FiltersSection = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: ${({ theme }) => theme.sizeUnit * 2}px;
+`;
+
+const FilterTagsContainer = styled.div`
+  display: flex;
+  flex-wrap: wrap;
+  gap: ${({ theme }) => theme.sizeUnit}px;
+`;
+
 interface WhatIfPanelProps {
   onClose: () => void;
   topOffset: number;
@@ -210,6 +263,15 @@ const WhatIfPanel = ({ onClose, topOffset }: 
WhatIfPanelProps) => {
   const [applyCounter, setApplyCounter] = useState(0);
   const [showAIReasoning, setShowAIReasoning] = useState(false);
 
+  // Filter state
+  const [filters, setFilters] = useState<WhatIfFilter[]>([]);
+  const [filterPopoverVisible, setFilterPopoverVisible] = useState(false);
+  const [editingFilterIndex, setEditingFilterIndex] = useState<number | null>(
+    null,
+  );
+  const [currentAdhocFilter, setCurrentAdhocFilter] =
+    useState<AdhocFilter | null>(null);
+
   // AbortController for cancelling in-flight /suggest_related requests
   const suggestionsAbortControllerRef = useRef<AbortController | null>(null);
 
@@ -249,14 +311,160 @@ const WhatIfPanel = ({ onClose, topOffset }: 
WhatIfPanelProps) => {
     return map;
   }, [numericColumns]);
 
+  // Find the datasource for the selected column
+  const selectedColumnInfo = useMemo(
+    () => numericColumns.find(col => col.columnName === selectedColumn),
+    [numericColumns, selectedColumn],
+  );
+
+  const selectedDatasource = useMemo((): Datasource | null => {
+    if (!selectedColumnInfo) return null;
+    // Find datasource by ID - keys are in format "id__type"
+    const datasourceEntry = Object.entries(datasources).find(([key]) => {
+      const [idStr] = key.split('__');
+      return parseInt(idStr, 10) === selectedColumnInfo.datasourceId;
+    });
+    return datasourceEntry ? datasourceEntry[1] : null;
+  }, [datasources, selectedColumnInfo]);
+
+  // Get all columns from the selected datasource for filter options
+  const filterColumnOptions = useMemo(() => {
+    if (!selectedDatasource?.columns) return [];
+    return selectedDatasource.columns;
+  }, [selectedDatasource]);
+
+  // Convert AdhocFilter to WhatIfFilter
+  const adhocFilterToWhatIfFilter = useCallback(
+    (adhocFilter: AdhocFilter): WhatIfFilter | null => {
+      if (!adhocFilter.isValid()) return null;
+
+      const { subject, operator, comparator } = adhocFilter;
+      if (!subject || !operator) return null;
+
+      // Map operator to WhatIfFilterOperator
+      let op = operator as WhatIfFilter['op'];
+
+      // Handle operator mapping
+      if (operator === 'TEMPORAL_RANGE') {
+        op = 'TEMPORAL_RANGE';
+      } else if (operator === 'IN' || operator === 'in') {
+        op = 'IN';
+      } else if (operator === 'NOT IN' || operator === 'not in') {
+        op = 'NOT IN';
+      }
+
+      return {
+        col: subject,
+        op,
+        val: comparator,
+      };
+    },
+    [],
+  );
+
+  // Convert WhatIfFilter to AdhocFilter for editing
+  const whatIfFilterToAdhocFilter = useCallback(
+    (filter: WhatIfFilter): AdhocFilter => {
+      // Find the operatorId from the operator
+      let operatorId: string | undefined;
+      for (const [key, value] of Object.entries(
+        OPERATOR_ENUM_TO_OPERATOR_TYPE,
+      )) {
+        if (value.operation === filter.op) {
+          operatorId = key;
+          break;
+        }
+      }
+
+      return new AdhocFilter({
+        expressionType: 'SIMPLE',
+        subject: filter.col,
+        operator: filter.op,
+        operatorId,
+        comparator: filter.val,
+        clause: Clauses.Where,
+      });
+    },
+    [],
+  );
+
   const handleColumnChange = useCallback((value: string | null) => {
     setSelectedColumn(value);
+    // Clear filters when column changes since they're tied to the datasource
+    setFilters([]);
   }, []);
 
   const handleSliderChange = useCallback((value: number) => {
     setSliderValue(value);
   }, []);
 
+  // Filter handlers
+  const handleOpenFilterPopover = useCallback(() => {
+    // Create a new empty AdhocFilter
+    const newFilter = new AdhocFilter({
+      expressionType: 'SIMPLE',
+      clause: Clauses.Where,
+      subject: null,
+      operator: null,
+      comparator: null,
+      isNew: true,
+    });
+    setCurrentAdhocFilter(newFilter);
+    setEditingFilterIndex(null);
+    setFilterPopoverVisible(true);
+  }, []);
+
+  const handleEditFilter = useCallback(
+    (index: number) => {
+      const filter = filters[index];
+      const adhocFilter = whatIfFilterToAdhocFilter(filter);
+      setCurrentAdhocFilter(adhocFilter);
+      setEditingFilterIndex(index);
+      setFilterPopoverVisible(true);
+    },
+    [filters, whatIfFilterToAdhocFilter],
+  );
+
+  const handleFilterChange = useCallback(
+    (adhocFilter: AdhocFilter) => {
+      const whatIfFilter = adhocFilterToWhatIfFilter(adhocFilter);
+      if (!whatIfFilter) return;
+
+      setFilters(prevFilters => {
+        if (editingFilterIndex !== null) {
+          // Update existing filter
+          const newFilters = [...prevFilters];
+          newFilters[editingFilterIndex] = whatIfFilter;
+          return newFilters;
+        }
+        // Add new filter
+        return [...prevFilters, whatIfFilter];
+      });
+      setFilterPopoverVisible(false);
+      setCurrentAdhocFilter(null);
+      setEditingFilterIndex(null);
+    },
+    [adhocFilterToWhatIfFilter, editingFilterIndex],
+  );
+
+  const handleRemoveFilter = useCallback(
+    (e: React.MouseEvent, index: number) => {
+      e.preventDefault();
+      e.stopPropagation();
+      setFilters(prevFilters => prevFilters.filter((_, i) => i !== index));
+    },
+    [],
+  );
+
+  const handleFilterPopoverClose = useCallback(() => {
+    setFilterPopoverVisible(false);
+    setCurrentAdhocFilter(null);
+    setEditingFilterIndex(null);
+  }, []);
+
+  // No-op handler for popover resize
+  const handleFilterPopoverResize = useCallback(() => {}, []);
+
   const dashboardInfo = useSelector((state: RootState) => state.dashboardInfo);
 
   const handleApply = useCallback(async () => {
@@ -272,11 +480,12 @@ const WhatIfPanel = ({ onClose, topOffset }: 
WhatIfPanelProps) => {
 
     const multiplier = 1 + sliderValue / 100;
 
-    // Base user modification
+    // Base user modification with filters
     const userModification: ExtendedWhatIfModification = {
       column: selectedColumn,
       multiplier,
       isAISuggested: false,
+      filters: filters.length > 0 ? filters : undefined,
     };
 
     let allModifications: ExtendedWhatIfModification[] = [userModification];
@@ -306,7 +515,7 @@ const WhatIfPanel = ({ onClose, topOffset }: 
WhatIfPanelProps) => {
           abortController.signal,
         );
 
-        // Add AI suggestions to modifications
+        // Add AI suggestions to modifications (with same filters as user 
modification)
         const aiModifications: ExtendedWhatIfModification[] =
           suggestions.suggestedModifications.map(mod => ({
             column: mod.column,
@@ -314,6 +523,7 @@ const WhatIfPanel = ({ onClose, topOffset }: 
WhatIfPanelProps) => {
             isAISuggested: true,
             reasoning: mod.reasoning,
             confidence: mod.confidence,
+            filters: filters.length > 0 ? filters : undefined,
           }));
 
         allModifications = [...allModifications, ...aiModifications];
@@ -372,11 +582,11 @@ const WhatIfPanel = ({ onClose, topOffset }: 
WhatIfPanelProps) => {
     enableCascadingEffects,
     numericColumns,
     dashboardInfo,
+    filters,
   ]);
 
   const isApplyDisabled =
     !selectedColumn || sliderValue === SLIDER_DEFAULT || isLoadingSuggestions;
-  const isSliderDisabled = !selectedColumn;
 
   // Helper to format percentage change
   const formatPercentage = (multiplier: number): string => {
@@ -385,6 +595,30 @@ const WhatIfPanel = ({ onClose, topOffset }: 
WhatIfPanelProps) => {
     return `${sign}${pct.toFixed(1)}%`;
   };
 
+  // Helper to format filter for display (matching Explore filter label format)
+  const formatFilterLabel = (filter: WhatIfFilter): string => {
+    const { col, op, val } = filter;
+
+    // Special handling for TEMPORAL_RANGE to match Explore format
+    if (op === 'TEMPORAL_RANGE' && typeof val === 'string') {
+      return formatTimeRangeLabel(val, col);
+    }
+
+    let valStr: string;
+    if (Array.isArray(val)) {
+      valStr = val.join(', ');
+    } else if (typeof val === 'boolean') {
+      valStr = val ? 'true' : 'false';
+    } else {
+      valStr = String(val);
+    }
+    // Truncate long values
+    if (valStr.length > 20) {
+      valStr = `${valStr.substring(0, 17)}...`;
+    }
+    return `${col} ${op} ${valStr}`;
+  };
+
   const sliderMarks = {
     [SLIDER_MIN]: `${SLIDER_MIN}%`,
     0: '0%',
@@ -410,15 +644,89 @@ const WhatIfPanel = ({ onClose, topOffset }: 
WhatIfPanelProps) => {
       <PanelContent>
         <FormSection>
           <Label>{t('Select column to adjust')}</Label>
-          <Select
-            value={selectedColumn}
-            onChange={handleColumnChange}
-            options={columnOptions}
-            placeholder={t('Choose a column...')}
-            allowClear
-            showSearch
-            ariaLabel={t('Select column to adjust')}
-          />
+          <ColumnSelectRow>
+            <ColumnSelectWrapper>
+              <Select
+                value={selectedColumn}
+                onChange={handleColumnChange}
+                options={columnOptions}
+                placeholder={t('Choose a column...')}
+                allowClear
+                showSearch
+                ariaLabel={t('Select column to adjust')}
+              />
+            </ColumnSelectWrapper>
+            <Popover
+              open={filterPopoverVisible}
+              onOpenChange={setFilterPopoverVisible}
+              trigger="click"
+              placement="left"
+              destroyOnHidden
+              content={
+                currentAdhocFilter && selectedDatasource ? (
+                  <FilterPopoverContent>
+                    <AdhocFilterEditPopover
+                      adhocFilter={currentAdhocFilter}
+                      options={filterColumnOptions}
+                      datasource={selectedDatasource}
+                      onChange={handleFilterChange}
+                      onClose={handleFilterPopoverClose}
+                      onResize={handleFilterPopoverResize}
+                      requireSave
+                    />
+                  </FilterPopoverContent>
+                ) : null
+              }
+            >
+              <Tooltip
+                title={
+                  selectedColumn
+                    ? t('Add filter to scope the modification')
+                    : t('Select a column first')
+                }
+              >
+                <FilterButton
+                  onClick={handleOpenFilterPopover}
+                  disabled={!selectedColumn || !selectedDatasource}
+                  aria-label={t('Add filter')}
+                  buttonStyle="tertiary"
+                >
+                  <Icons.FilterOutlined iconSize="m" />
+                </FilterButton>
+              </Tooltip>
+            </Popover>
+          </ColumnSelectRow>
+          {filters.length > 0 && (
+            <FiltersSection>
+              <Label
+                css={css`
+                  font-size: ${theme.fontSizeSM}px;
+                  color: ${theme.colorTextSecondary};
+                `}
+              >
+                {t('Filters')}
+              </Label>
+              <FilterTagsContainer>
+                {filters.map((filter, index) => (
+                  <Tag
+                    key={`${filter.col}-${filter.op}-${index}`}
+                    closable
+                    onClose={e => handleRemoveFilter(e, index)}
+                    onClick={() => handleEditFilter(index)}
+                    css={css`
+                      cursor: pointer;
+                      margin: 0;
+                      &:hover {
+                        opacity: 0.8;
+                      }
+                    `}
+                  >
+                    {formatFilterLabel(filter)}
+                  </Tag>
+                ))}
+              </FilterTagsContainer>
+            </FiltersSection>
+          )}
         </FormSection>
 
         <FormSection>
@@ -429,7 +737,6 @@ const WhatIfPanel = ({ onClose, topOffset }: 
WhatIfPanelProps) => {
               max={SLIDER_MAX}
               value={sliderValue}
               onChange={handleSliderChange}
-              disabled={isSliderDisabled}
               marks={sliderMarks}
               tooltip={{
                 formatter: (value?: number) =>
diff --git a/superset/connectors/sqla/models.py 
b/superset/connectors/sqla/models.py
index d8ec1fad38..26d497a836 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -1547,7 +1547,8 @@ class SqlaTable(
         from superset.utils.core import FilterOperator
 
         conditions: list[ColumnElement] = []
-        available_columns = {col.column_name for col in self.columns}
+        # Build a map of column name -> column object for quick lookup
+        columns_by_name = {col.column_name: col for col in self.columns}
 
         for flt in filters:
             col_name = flt.get("col")
@@ -1555,7 +1556,7 @@ class SqlaTable(
             val = flt.get("val")
 
             # Skip if column doesn't exist in datasource
-            if col_name not in available_columns:
+            if col_name not in columns_by_name:
                 continue
 
             sqla_col = sa.column(col_name)
@@ -1583,10 +1584,14 @@ class SqlaTable(
                 if isinstance(val, str):
                     since, until = 
get_since_until_from_time_range(time_range=val)
                     time_conditions = []
+                    col_obj = columns_by_name[col_name]
                     if since:
-                        time_conditions.append(sqla_col >= sa.literal(since))
+                        # Convert datetime to database-specific SQL literal
+                        since_sql = self.dttm_sql_literal(since, col_obj)
+                        time_conditions.append(sqla_col >= 
sa.literal_column(since_sql))
                     if until:
-                        time_conditions.append(sqla_col < sa.literal(until))
+                        until_sql = self.dttm_sql_literal(until, col_obj)
+                        time_conditions.append(sqla_col < 
sa.literal_column(until_sql))
                     if time_conditions:
                         conditions.append(and_(*time_conditions))
 

Reply via email to