Compare commits

...

2 Commits

Author SHA1 Message Date
Joe Li
9dec070210 fix(embedded): scope filters to default tab when activeTabs is empty
When hideTab:true is set in the embedded SDK, the Tabs component never
mounts and activeTabs stays permanently empty. The previous fix showed
all filters regardless of tab scope. This revision derives the default
first tab from the dashboard layout so filter scoping works correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 09:59:17 -07:00
Joe Li
6f396494f2 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) <noreply@anthropic.com>
2026-04-16 14:40:39 -07:00
2 changed files with 332 additions and 8 deletions

View File

@@ -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,269 @@ test('filter with mix of existing and non-existent charts in chartsInScope', ()
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(true);
});
// --- Embedded / hideTab: activeTabs is empty ---
// When an embedded dashboard uses hideTab:true, the Tabs component never
// mounts, so setActiveTab never fires and activeTabs stays []. The same
// empty state occurs transiently on first render of any tabbed dashboard.
//
// useActiveDashboardTabs derives the default first tab from the layout when
// Redux activeTabs is empty, so scope evaluation uses the correct default
// tab instead of either "no tabs active" (blank filter bar) or "all tabs"
// (showing out-of-scope filters).
// Helper: build a layout with ROOT_ID → TABS container → TAB children
function embeddedLayout(extras: Record<string, Record<string, unknown>> = {}) {
return {
ROOT_ID: {
type: 'ROOT',
id: 'ROOT_ID',
children: ['TABS-1'],
},
'TABS-1': {
type: 'TABS',
id: 'TABS-1',
children: ['TAB-Company', 'TAB-Desktop'],
},
'TAB-Company': {
type: 'TAB',
id: 'TAB-Company',
children: ['CHART-1', 'CHART-2'],
},
'TAB-Desktop': {
type: 'TAB',
id: 'TAB-Desktop',
children: ['CHART-3'],
},
'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'],
},
...extras,
};
}
test('useIsFilterInScope: filter scoped to default tab is in-scope when activeTabs is empty', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
});
const filter: Filter = {
id: 'filter_default_tab',
name: 'Default Tab 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 scoped to default (first) tab',
};
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(true);
});
test('useIsFilterInScope: filter scoped only to non-default tab is out-of-scope when activeTabs is empty', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
});
const filter: Filter = {
id: 'filter_other_tab',
name: 'Other Tab Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [3],
scope: { rootPath: ['TAB-Desktop'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'column_name' }, datasetId: 1 }],
description: 'Filter scoped only to non-default tab',
};
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(false);
});
test('useIsFilterInScope: filter with rootPath to default tab is in-scope when activeTabs is empty', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
});
const filter: Filter = {
id: 'filter_rootpath_default',
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 to default tab',
};
const { result } = renderHook(() => useIsFilterInScope());
expect(result.current(filter)).toBe(true);
});
test('useSelectFiltersInScope: only default-tab filters are in scope when activeTabs is empty (embedded hideTab)', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
});
const filters: Filter[] = [
{
id: 'filter_company',
name: 'Company Tab 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 scoped to default tab',
},
{
id: 'filter_desktop_only',
name: 'Desktop Only Filter',
filterType: 'filter_select',
type: NativeFilterType.NativeFilter,
chartsInScope: [3],
scope: { rootPath: ['TAB-Desktop'], excluded: [] },
controlValues: {},
defaultDataMask: {},
cascadeParentIds: [],
targets: [{ column: { name: 'pool_name' }, datasetId: 2 }],
description: 'Filter scoped only to non-default tab',
},
];
const { result } = renderHook(() => useSelectFiltersInScope(filters));
expect(result.current[0]).toHaveLength(1);
expect(result.current[0][0]).toEqual(
expect.objectContaining({ id: 'filter_company' }),
);
expect(result.current[1]).toHaveLength(1);
expect(result.current[1][0]).toEqual(
expect.objectContaining({ id: 'filter_desktop_only' }),
);
});
test('useSelectFiltersInScope: dividers are always in scope even when activeTabs is empty', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
});
const items: (Filter | Divider)[] = [
{
id: 'divider_embedded',
type: NativeFilterType.Divider,
title: 'Section',
description: 'Divider in embedded mode',
},
{
id: 'filter_default',
name: 'Default 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 in default tab',
},
];
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: embeddedLayout() },
};
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: [3],
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' }),
);
});

View File

@@ -30,7 +30,8 @@ import {
} from '@superset-ui/core';
import { FilterElement } from './FilterBar/FilterControls/types';
import { ActiveTabs, DashboardLayout, RootState } from '../../types';
import { CHART_TYPE, TAB_TYPE } from '../../util/componentTypes';
import { CHART_TYPE, TAB_TYPE, TABS_TYPE } from '../../util/componentTypes';
import { DASHBOARD_GRID_ID, DASHBOARD_ROOT_ID } from '../../util/constants';
import { isChartCustomizationId } from './FiltersConfigModal/utils';
import {
migrateChartCustomizationArray,
@@ -178,10 +179,48 @@ export function useDashboardHasTabs() {
);
}
function useActiveDashboardTabs() {
return useSelector<RootState, ActiveTabs>(
function useActiveDashboardTabs(): ActiveTabs {
const reduxTabs = useSelector<RootState, ActiveTabs>(
state => state.dashboardState?.activeTabs,
);
const dashboardLayout = useDashboardLayout();
return useMemo(() => {
if (reduxTabs?.length > 0) return reduxTabs;
// When activeTabs is empty (e.g. embedded dashboards with hideTab:true
// where the Tabs component never mounts, or transient first-render race),
// derive the default active tabs from the layout: pick the first tab at
// each nesting level along the default path.
const root = dashboardLayout[DASHBOARD_ROOT_ID];
if (!root?.children?.length) return reduxTabs;
const firstChildId = root.children[0];
if (firstChildId === DASHBOARD_GRID_ID) return reduxTabs;
const tabsContainer = dashboardLayout[firstChildId];
if (tabsContainer?.type !== TABS_TYPE || !tabsContainer.children?.length) {
return reduxTabs;
}
const defaults: string[] = [];
const queue = [tabsContainer.children[0]];
while (queue.length > 0) {
const tabId = queue.shift()!;
defaults.push(tabId);
const tab = dashboardLayout[tabId];
if (tab?.children) {
for (const childId of tab.children) {
const child = dashboardLayout[childId];
if (child?.type === TABS_TYPE && child.children?.length) {
queue.push(child.children[0]);
}
}
}
}
return defaults.length > 0 ? defaults : reduxTabs;
}, [reduxTabs, dashboardLayout]);
}
function useSelectChartTabParents() {
@@ -330,6 +369,7 @@ export function useSelectCustomizationsInScope(
| ChartCustomizationDivider
)[] = [];
// we check customization scopes only on dashboards with tabs
if (!dashboardHasTabs) {
customizationsInScope = customizations;
} else {