You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by jl...@apache.org on 2026/04/16 21:40:57 UTC

(superset) branch fix-blank-embedded-filter-bar created (now 6f396494f21)

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

jli pushed a change to branch fix-blank-embedded-filter-bar
in repository https://gitbox.apache.org/repos/asf/superset.git


      at 6f396494f21 fix(embedded): show filter bar controls on embedded dashboards with tabs

This branch includes the following new commits:

     new 6f396494f21 fix(embedded): show filter bar controls on embedded dashboards with tabs

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(superset) 01/01: fix(embedded): show filter bar controls on embedded dashboards with tabs

Posted by jl...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

jli pushed a commit to branch fix-blank-embedded-filter-bar
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 6f396494f2113a53f8379de530ae270644d2eed3
Author: Joe Li <jo...@preset.io>
AuthorDate: Thu Apr 16 14:40:39 2026 -0700

    fix(embedded): show filter bar controls on embedded dashboards with tabs
    
    When an embedded dashboard loads fresh (no permalink, no stored state),
    activeTabs starts as [] because hydrateDashboard receives null and the
    Tabs component populates it via useEffect after the first render. During
    that initial render, useIsFilterInScope evaluates every filter against
    empty activeTabs and marks them all out of scope, producing a blank
    filter bar.
    
    Skip scope evaluation in useSelectFiltersInScope and
    useSelectCustomizationsInScope when activeTabs is empty and the
    dashboard has tabs. This also correctly handles hide_tabs mode where
    tabs never render and activeTabs stays empty permanently.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
 .../components/nativeFilters/state.test.ts         | 284 ++++++++++++++++++++-
 .../dashboard/components/nativeFilters/state.ts    |  15 +-
 2 files changed, 289 insertions(+), 10 deletions(-)

diff --git a/superset-frontend/src/dashboard/components/nativeFilters/state.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/state.test.ts
index 782e0fff6bf..f08d01a3b8e 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/state.test.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/state.test.ts
@@ -31,11 +31,21 @@ jest.mock('react-redux', () => {
 });
 
 beforeEach(() => {
-  (useSelector as jest.Mock).mockImplementation(selector => {
-    if (selector.name === 'useActiveDashboardTabs') {
-      return ['TAB_1'];
-    }
-    return [];
+  (useSelector as jest.Mock).mockImplementation((selector: Function) => {
+    const mockState = {
+      dashboardState: { activeTabs: ['TAB_1'] },
+      dashboardLayout: {
+        present: {
+          'CHART-123': {
+            type: 'CHART',
+            meta: { chartId: 123 },
+            parents: ['ROOT_ID', 'TAB_1'],
+          },
+          'TAB_1': { type: 'TAB', id: 'TAB_1' },
+        },
+      },
+    };
+    return selector(mockState);
   });
 });
 
@@ -89,6 +99,14 @@ test('useIsFilterInScope should return false for filters with inactive rootPath'
 });
 
 test('useSelectFiltersInScope should return all filters in scope when no tabs exist', () => {
+  (useSelector as jest.Mock).mockImplementation((selector: Function) => {
+    const mockState = {
+      dashboardState: { activeTabs: [] },
+      dashboardLayout: { present: {} },
+    };
+    return selector(mockState);
+  });
+
   const filters: Filter[] = [
     {
       id: 'filter_1',
@@ -507,3 +525,259 @@ test('filter with mix of existing and non-existent charts in chartsInScope', ()
   const { result } = renderHook(() => useIsFilterInScope());
   expect(result.current(filter)).toBe(true);
 });
+
+// --- Embedded mode: activeTabs is empty on initial render ---
+// When an embedded dashboard loads fresh (no permalink, no stored state),
+// activeTabs starts as [] because hydrateDashboard receives null and falls back
+// to []. The Tabs component fires setActiveTab in a useEffect AFTER the first
+// render, so the filter bar renders with activeTabs = [] and all filters
+// evaluate as "out of scope" — producing a blank filter bar.
+//
+// useIsFilterInScope correctly reports false with empty activeTabs — that's
+// accurate scope evaluation. The fix belongs in useSelectFiltersInScope, which
+// should bypass scope evaluation entirely when activeTabs hasn't initialized.
+
+test('useIsFilterInScope: filters with chartsInScope report out-of-scope when activeTabs is empty', () => {
+  (useSelector as jest.Mock).mockImplementation((selector: Function) => {
+    const mockState = {
+      dashboardState: { activeTabs: [] },
+      dashboardLayout: {
+        present: {
+          'CHART-1': {
+            type: 'CHART',
+            meta: { chartId: 1 },
+            parents: ['ROOT_ID', 'TAB-Company'],
+          },
+          'CHART-2': {
+            type: 'CHART',
+            meta: { chartId: 2 },
+            parents: ['ROOT_ID', 'TAB-Company'],
+          },
+          'TAB-Company': { type: 'TAB', id: 'TAB-Company' },
+          'TAB-Desktop': { type: 'TAB', id: 'TAB-Desktop' },
+        },
+      },
+    };
+    return selector(mockState);
+  });
+
+  const filter: Filter = {
+    id: 'filter_embedded',
+    name: 'Embedded Filter',
+    filterType: 'filter_select',
+    type: NativeFilterType.NativeFilter,
+    chartsInScope: [1, 2],
+    scope: { rootPath: ['TAB-Company'], excluded: [] },
+    controlValues: {},
+    defaultDataMask: {},
+    cascadeParentIds: [],
+    targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
+    description: 'Filter that should be visible in embedded mode',
+  };
+
+  const { result } = renderHook(() => useIsFilterInScope());
+  expect(result.current(filter)).toBe(false);
+});
+
+test('useIsFilterInScope: filters with rootPath fallback report out-of-scope when activeTabs is empty', () => {
+  (useSelector as jest.Mock).mockImplementation((selector: Function) => {
+    const mockState = {
+      dashboardState: { activeTabs: [] },
+      dashboardLayout: {
+        present: {
+          'TAB-Company': { type: 'TAB', id: 'TAB-Company' },
+        },
+      },
+    };
+    return selector(mockState);
+  });
+
+  const filter: Filter = {
+    id: 'filter_rootpath_embedded',
+    name: 'RootPath Filter',
+    filterType: 'filter_select',
+    type: NativeFilterType.NativeFilter,
+    scope: { rootPath: ['TAB-Company'], excluded: [] },
+    controlValues: {},
+    defaultDataMask: {},
+    cascadeParentIds: [],
+    targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
+    description: 'Filter using rootPath fallback in embedded mode',
+  };
+
+  const { result } = renderHook(() => useIsFilterInScope());
+  expect(result.current(filter)).toBe(false);
+});
+
+test('useSelectFiltersInScope: all filters should be in scope when dashboard has tabs but activeTabs is empty (embedded initial render)', () => {
+  (useSelector as jest.Mock).mockImplementation((selector: Function) => {
+    const mockState = {
+      dashboardState: { activeTabs: [] },
+      dashboardLayout: {
+        present: {
+          'CHART-1': {
+            type: 'CHART',
+            meta: { chartId: 1 },
+            parents: ['ROOT_ID', 'TAB-Company'],
+          },
+          'CHART-2': {
+            type: 'CHART',
+            meta: { chartId: 2 },
+            parents: ['ROOT_ID', 'TAB-Company'],
+          },
+          'CHART-3': {
+            type: 'CHART',
+            meta: { chartId: 3 },
+            parents: ['ROOT_ID', 'TAB-Desktop'],
+          },
+          'TAB-Company': { type: 'TAB', id: 'TAB-Company' },
+          'TAB-Desktop': { type: 'TAB', id: 'TAB-Desktop' },
+        },
+      },
+    };
+    return selector(mockState);
+  });
+
+  const filters: Filter[] = [
+    {
+      id: 'filter_1',
+      name: 'Survey Rating Filter',
+      filterType: 'filter_select',
+      type: NativeFilterType.NativeFilter,
+      chartsInScope: [1, 2],
+      scope: { rootPath: ['TAB-Company'], excluded: [] },
+      controlValues: {},
+      defaultDataMask: {},
+      cascadeParentIds: [],
+      targets: [{ column: { name: 'survey_rating' }, datasetId: 1 }],
+      description: 'Filter for survey rating',
+    },
+    {
+      id: 'filter_2',
+      name: 'Date Range Filter',
+      filterType: 'filter_time',
+      type: NativeFilterType.NativeFilter,
+      chartsInScope: [1, 2, 3],
+      scope: { rootPath: ['TAB-Company', 'TAB-Desktop'], excluded: [] },
+      controlValues: {},
+      defaultDataMask: {},
+      cascadeParentIds: [],
+      targets: [{ column: { name: 'date' }, datasetId: 1 }],
+      description: 'Date range filter',
+    },
+  ];
+
+  const { result } = renderHook(() => useSelectFiltersInScope(filters));
+  // All filters should be in scope — not hidden
+  expect(result.current[0]).toHaveLength(2);
+  expect(result.current[1]).toHaveLength(0);
+});
+
+test('useSelectFiltersInScope: mixed filters and dividers should all be in scope when activeTabs is empty (embedded initial render)', () => {
+  (useSelector as jest.Mock).mockImplementation((selector: Function) => {
+    const mockState = {
+      dashboardState: { activeTabs: [] },
+      dashboardLayout: {
+        present: {
+          'CHART-1': {
+            type: 'CHART',
+            meta: { chartId: 1 },
+            parents: ['ROOT_ID', 'TAB-Company'],
+          },
+          'TAB-Company': { type: 'TAB', id: 'TAB-Company' },
+        },
+      },
+    };
+    return selector(mockState);
+  });
+
+  const items: (Filter | Divider)[] = [
+    {
+      id: 'divider_embedded',
+      type: NativeFilterType.Divider,
+      title: 'Section',
+      description: 'Divider in embedded mode',
+    },
+    {
+      id: 'filter_embedded_mixed',
+      name: 'Embedded Filter',
+      filterType: 'filter_select',
+      type: NativeFilterType.NativeFilter,
+      chartsInScope: [1],
+      scope: { rootPath: ['TAB-Company'], excluded: [] },
+      controlValues: {},
+      defaultDataMask: {},
+      cascadeParentIds: [],
+      targets: [{ column: { name: 'col' }, datasetId: 1 }],
+      description: 'Filter in embedded mode',
+    },
+  ];
+
+  const { result } = renderHook(() => useSelectFiltersInScope(items));
+  expect(result.current[0]).toHaveLength(2);
+  expect(result.current[1]).toHaveLength(0);
+});
+
+test('useSelectFiltersInScope: correctly scopes chartsInScope filters when activeTabs is populated', () => {
+  (useSelector as jest.Mock).mockImplementation((selector: Function) => {
+    const mockState = {
+      dashboardState: { activeTabs: ['TAB-Company'] },
+      dashboardLayout: {
+        present: {
+          'CHART-1': {
+            type: 'CHART',
+            meta: { chartId: 1 },
+            parents: ['ROOT_ID', 'TAB-Company'],
+          },
+          'CHART-2': {
+            type: 'CHART',
+            meta: { chartId: 2 },
+            parents: ['ROOT_ID', 'TAB-Desktop'],
+          },
+          'TAB-Company': { type: 'TAB', id: 'TAB-Company' },
+          'TAB-Desktop': { type: 'TAB', id: 'TAB-Desktop' },
+        },
+      },
+    };
+    return selector(mockState);
+  });
+
+  const filters: Filter[] = [
+    {
+      id: 'filter_active_tab',
+      name: 'Active Tab Filter',
+      filterType: 'filter_select',
+      type: NativeFilterType.NativeFilter,
+      chartsInScope: [1],
+      scope: { rootPath: ['TAB-Company'], excluded: [] },
+      controlValues: {},
+      defaultDataMask: {},
+      cascadeParentIds: [],
+      targets: [{ column: { name: 'col' }, datasetId: 1 }],
+      description: 'Filter scoped to active tab',
+    },
+    {
+      id: 'filter_inactive_tab',
+      name: 'Inactive Tab Filter',
+      filterType: 'filter_select',
+      type: NativeFilterType.NativeFilter,
+      chartsInScope: [2],
+      scope: { rootPath: ['TAB-Desktop'], excluded: [] },
+      controlValues: {},
+      defaultDataMask: {},
+      cascadeParentIds: [],
+      targets: [{ column: { name: 'col' }, datasetId: 2 }],
+      description: 'Filter scoped to inactive tab',
+    },
+  ];
+
+  const { result } = renderHook(() => useSelectFiltersInScope(filters));
+  expect(result.current[0]).toHaveLength(1);
+  expect(result.current[0][0]).toEqual(
+    expect.objectContaining({ id: 'filter_active_tab' }),
+  );
+  expect(result.current[1]).toHaveLength(1);
+  expect(result.current[1][0]).toEqual(
+    expect.objectContaining({ id: 'filter_inactive_tab' }),
+  );
+});
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/state.ts b/superset-frontend/src/dashboard/components/nativeFilters/state.ts
index 11034ab1da0..d5f4933a3b9 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/state.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/state.ts
@@ -251,14 +251,16 @@ export function useIsFilterInScope() {
 
 export function useSelectFiltersInScope(filters: (Filter | Divider)[]) {
   const dashboardHasTabs = useDashboardHasTabs();
+  const activeTabs = useActiveDashboardTabs();
   const isFilterInScope = useIsFilterInScope();
 
   return useMemo(() => {
     let filtersInScope: (Filter | Divider)[] = [];
     const filtersOutOfScope: (Filter | Divider)[] = [];
 
-    // we check native filters scopes only on dashboards with tabs
-    if (!dashboardHasTabs) {
+    // Skip scope evaluation when tabs exist but activeTabs hasn't been
+    // populated yet (embedded dashboards on initial render).
+    if (!dashboardHasTabs || activeTabs.length === 0) {
       filtersInScope = filters;
     } else {
       filters.forEach(filter => {
@@ -272,7 +274,7 @@ export function useSelectFiltersInScope(filters: (Filter | Divider)[]) {
       });
     }
     return [filtersInScope, filtersOutOfScope];
-  }, [filters, dashboardHasTabs, isFilterInScope]);
+  }, [filters, dashboardHasTabs, activeTabs, isFilterInScope]);
 }
 
 export function useIsCustomizationInScope() {
@@ -318,6 +320,7 @@ export function useSelectCustomizationsInScope(
   customizations: (ChartCustomization | ChartCustomizationDivider)[],
 ) {
   const dashboardHasTabs = useDashboardHasTabs();
+  const activeTabs = useActiveDashboardTabs();
   const isCustomizationInScope = useIsCustomizationInScope();
 
   return useMemo(() => {
@@ -330,7 +333,9 @@ export function useSelectCustomizationsInScope(
       | ChartCustomizationDivider
     )[] = [];
 
-    if (!dashboardHasTabs) {
+    // Skip scope evaluation when tabs exist but activeTabs hasn't been
+    // populated yet (embedded dashboards on initial render).
+    if (!dashboardHasTabs || activeTabs.length === 0) {
       customizationsInScope = customizations;
     } else {
       customizations.forEach(customization => {
@@ -344,5 +349,5 @@ export function useSelectCustomizationsInScope(
       });
     }
     return [customizationsInScope, customizationsOutOfScope];
-  }, [customizations, dashboardHasTabs, isCustomizationInScope]);
+  }, [customizations, dashboardHasTabs, activeTabs, isCustomizationInScope]);
 }