mirror of
https://github.com/apache/superset.git
synced 2026-04-30 21:44:40 +00:00
Compare commits
2 Commits
feat/toolt
...
fix-blank-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dec070210 | ||
|
|
6f396494f2 |
@@ -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' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user