Compare commits

...

2 Commits

Author SHA1 Message Date
sadpandajoe
c705ab7dde docs(dashboard): make hydrate test comments self-contained 2026-07-15 05:29:56 +00:00
sadpandajoe
4679c07be0 fix(dashboard): seed default active tab path at hydration
On a fresh tabbed dashboard load — no permalink, no stored dashboard
state, no direct-link path — dashboardState.activeTabs resolved to []
until the Tabs grid component dispatched setActiveTab on a post-mount
effect. Any first-render consumer of activeTabs (native filter scoping,
share/permalink payloads, screenshot capture) read that stale empty
array, most visibly breaking filter-bar scoping on embedded dashboards
with tabs (#39417).

Extract the ROOT -> first TABS -> first TAB default-path walk (recursing
into nested TABS) into a shared, cycle-safe helper (getDefaultActiveTabs),
and seed it at hydration time so Redux is correct from render 0. The
existing precedence is preserved: a permalink activeTabs param wins, then
a non-empty stored redux value, then the layout default — gated off
whenever a direct-link path is present so it can't fight a deep link.

useActiveDashboardTabs (components/nativeFilters/state.ts), which #39417
patched to reconstruct the default path from the layout on every read, is
reverted to a plain Redux read now that hydration seeds the value
directly, making Redux the single source of truth. Its defensive
null-guards for a partial/undefined store are retained.

Follow-up to PR #39417 and PR #41832.
2026-07-14 23:42:28 +00:00
7 changed files with 618 additions and 86 deletions

View File

@@ -0,0 +1,276 @@
/**
* 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 { HYDRATE_DASHBOARD, hydrateDashboard } from './hydrate';
import {
DASHBOARD_ROOT_TYPE,
TABS_TYPE,
TAB_TYPE,
} from '../util/componentTypes';
import { DASHBOARD_ROOT_ID } from '../util/constants';
/**
* Regression guard for the follow-up to PR #39417 / PR #41832: the default
* active-tab path must be seeded into `dashboardState.activeTabs` at hydration
* time, so every first-render consumer (filter bar, share menus, permalink
* utilities, screenshot download) reads the dashboard's real default tab path
* instead of an empty array.
*
* Before this change, hydrate seeded `activeTabs: activeTabs ||
* dashboardState?.activeTabs || []` (hydrate.ts). With no permalink, no stored
* state and no directPathToChild, that resolved to `[]`, and the live `Tabs`
* component only populated the value from a post-mount effect. These tests
* assert the seeded default path and fail without the hydration-time seeding.
*/
const layoutItem = (
id: string,
type: string,
children: string[],
parents: string[],
) => ({ id, type, children, parents, meta: {} });
const buildDashboard = (
positionData: Record<string, unknown>,
title = 'Test dashboard',
) => ({
id: 1,
dashboard_title: title,
css: '',
published: true,
changed_on: '2024-01-01T00:00:00.000Z',
owners: [],
metadata: {},
position_data: positionData,
});
const hydrate = (
positionData: Record<string, unknown>,
overrides: {
activeTabs?: string[] | null;
dashboardState?: Record<string, unknown>;
} = {},
) => {
const dispatch = jest.fn((action: unknown) => action);
const getState = () =>
({
user: { roles: {}, userId: 1 },
common: { conf: {} },
dashboardState: overrides.dashboardState ?? {},
}) as any;
const action = (
hydrateDashboard({
history: { replace: jest.fn() },
dashboard: buildDashboard(positionData),
charts: [],
dataMask: {},
activeTabs: overrides.activeTabs ?? null,
chartStates: null,
} as any) as any
)(dispatch, getState);
return action;
};
test('seeds the default (first) tab path for a flat ROOT → TABS → TAB layout', () => {
const positionData = {
[DASHBOARD_ROOT_ID]: layoutItem(
DASHBOARD_ROOT_ID,
DASHBOARD_ROOT_TYPE,
['TABS-1'],
[],
),
'TABS-1': layoutItem(
'TABS-1',
TABS_TYPE,
['TAB-1', 'TAB-2'],
[DASHBOARD_ROOT_ID],
),
'TAB-1': layoutItem('TAB-1', TAB_TYPE, [], [DASHBOARD_ROOT_ID, 'TABS-1']),
'TAB-2': layoutItem('TAB-2', TAB_TYPE, [], [DASHBOARD_ROOT_ID, 'TABS-1']),
};
const action = hydrate(positionData);
expect(action.type).toBe(HYDRATE_DASHBOARD);
expect(action.data.dashboardState.activeTabs).toEqual(['TAB-1']);
});
test('seeds the recursive default path for nested TABS containers', () => {
const positionData = {
[DASHBOARD_ROOT_ID]: layoutItem(
DASHBOARD_ROOT_ID,
DASHBOARD_ROOT_TYPE,
['TABS-1'],
[],
),
'TABS-1': layoutItem(
'TABS-1',
TABS_TYPE,
['TAB-1', 'TAB-2'],
[DASHBOARD_ROOT_ID],
),
'TAB-1': layoutItem(
'TAB-1',
TAB_TYPE,
['TABS-2'],
[DASHBOARD_ROOT_ID, 'TABS-1'],
),
'TAB-2': layoutItem('TAB-2', TAB_TYPE, [], [DASHBOARD_ROOT_ID, 'TABS-1']),
'TABS-2': layoutItem(
'TABS-2',
TABS_TYPE,
['TAB-1-1', 'TAB-1-2'],
[DASHBOARD_ROOT_ID, 'TABS-1', 'TAB-1'],
),
'TAB-1-1': layoutItem(
'TAB-1-1',
TAB_TYPE,
[],
[DASHBOARD_ROOT_ID, 'TABS-1', 'TAB-1', 'TABS-2'],
),
'TAB-1-2': layoutItem(
'TAB-1-2',
TAB_TYPE,
[],
[DASHBOARD_ROOT_ID, 'TABS-1', 'TAB-1', 'TABS-2'],
),
};
const action = hydrate(positionData);
expect(action.type).toBe(HYDRATE_DASHBOARD);
expect(action.data.dashboardState.activeTabs).toEqual(['TAB-1', 'TAB-1-1']);
});
test('seeds the default tab path for an embedded top-level-TABS layout (hideTab scenario)', () => {
// hideTab is a `uiConfig` property (read at DashboardBuilder.tsx, outside
// hydrated state), not a layout/position_data property, so it cannot and
// need not appear in this fixture. The seed derives purely from layout
// shape and is correct whether or not the top-level tab bar renders — this
// is an ordinary top-level-TABS layout mirroring state.test.ts's
// `embeddedLayout`, re-anchoring the #39417 embedded-filter-bar regression
// guard at the layer (hydration) that now owns the seeded value.
const positionData = {
[DASHBOARD_ROOT_ID]: layoutItem(
DASHBOARD_ROOT_ID,
DASHBOARD_ROOT_TYPE,
['TABS-1'],
[],
),
'TABS-1': layoutItem(
'TABS-1',
TABS_TYPE,
['TAB-Company', 'TAB-Desktop'],
[DASHBOARD_ROOT_ID],
),
'TAB-Company': layoutItem(
'TAB-Company',
TAB_TYPE,
[],
[DASHBOARD_ROOT_ID, 'TABS-1'],
),
'TAB-Desktop': layoutItem(
'TAB-Desktop',
TAB_TYPE,
[],
[DASHBOARD_ROOT_ID, 'TABS-1'],
),
};
const action = hydrate(positionData);
expect(action.type).toBe(HYDRATE_DASHBOARD);
expect(action.data.dashboardState.activeTabs).toEqual(['TAB-Company']);
});
// Precedence: the layout default only applies
// to a genuinely fresh load. A permalink `activeTabs`, a non-empty stored
// redux value, or a non-empty `directPathToChild` (deep link) must each
// suppress it.
const flatTabsPositionData = {
[DASHBOARD_ROOT_ID]: layoutItem(
DASHBOARD_ROOT_ID,
DASHBOARD_ROOT_TYPE,
['TABS-1'],
[],
),
'TABS-1': layoutItem(
'TABS-1',
TABS_TYPE,
['TAB-1', 'TAB-2'],
[DASHBOARD_ROOT_ID],
),
'TAB-1': layoutItem('TAB-1', TAB_TYPE, [], [DASHBOARD_ROOT_ID, 'TABS-1']),
'TAB-2': layoutItem('TAB-2', TAB_TYPE, [], [DASHBOARD_ROOT_ID, 'TABS-1']),
};
test('a permalink activeTabs param suppresses the layout default', () => {
const action = hydrate(flatTabsPositionData, { activeTabs: ['TAB-2'] });
expect(action.data.dashboardState.activeTabs).toEqual(['TAB-2']);
});
test('a non-empty stored redux activeTabs value suppresses the layout default', () => {
const action = hydrate(flatTabsPositionData, {
dashboardState: { activeTabs: ['TAB-2'] },
});
expect(action.data.dashboardState.activeTabs).toEqual(['TAB-2']);
});
test('a non-empty directPathToChild (deep link) suppresses the layout default', () => {
const action = hydrate(flatTabsPositionData, {
dashboardState: { directPathToChild: ['TAB-2'] },
});
expect(action.data.dashboardState.activeTabs).toEqual([]);
});
test('an empty stored redux activeTabs value still falls through to the layout default', () => {
// Pins the `.length` guard: a stored `activeTabs: []` is truthy but must
// not be treated as "already populated" — otherwise it would win over the
// layout default and regress to the pre-fix `[]`.
const action = hydrate(flatTabsPositionData, {
dashboardState: { activeTabs: [] },
});
expect(action.data.dashboardState.activeTabs).toEqual(['TAB-1']);
});
test('a non-empty stored redux value wins over a non-empty directPathToChild', () => {
// Pins the `||` operand order: stored value is checked before the
// directPathToChild-gated default branch.
const action = hydrate(flatTabsPositionData, {
dashboardState: { activeTabs: ['TAB-2'], directPathToChild: ['TAB-1'] },
});
expect(action.data.dashboardState.activeTabs).toEqual(['TAB-2']);
});
test('a permalink activeTabs: [] (empty but present) wins and seeds []', () => {
// INTENTIONAL legacy-link behavior: unlike the stored-redux operand, the
// permalink param is not `?.length`-normalized, so a permalink that
// deliberately encoded "no active tabs" still wins over the layout
// default. This degrades to the pre-fix one-paint-late correction (the
// live Tabs component resolves the default after mount) rather than
// seeding the layout default — no regression, since that was already
// today's behavior for such links.
const action = hydrate(flatTabsPositionData, { activeTabs: [] });
expect(action.data.dashboardState.activeTabs).toEqual([]);
});

View File

@@ -49,6 +49,7 @@ import {
ROW_TYPE,
} from 'src/dashboard/util/componentTypes';
import findFirstParentContainerId from 'src/dashboard/util/findFirstParentContainer';
import getDefaultActiveTabs from 'src/dashboard/util/getDefaultActiveTabs';
import getEmptyLayout from 'src/dashboard/util/getEmptyLayout';
import getLocationHash from 'src/dashboard/util/getLocationHash';
import newComponentFactory, {
@@ -327,6 +328,19 @@ export const hydrateDashboard =
metadata.cross_filters_enabled as boolean | undefined,
);
// precedence: permalink param > stored redux value > layout default.
// The layout default only applies to a genuinely fresh load: no permalink
// activeTabs, no stored activeTabs, and no deep-link (directPathToChild),
// which the live Tabs component resolves on its own.
const seededActiveTabs =
activeTabs ||
(dashboardState?.activeTabs?.length
? dashboardState.activeTabs
: undefined) ||
(directPathToChild.length
? []
: getDefaultActiveTabs(dashboardLayout.present as DashboardLayout));
return dispatch({
type: HYDRATE_DASHBOARD,
data: {
@@ -390,7 +404,7 @@ export const hydrateDashboard =
lastModifiedTime: dashboard.changed_on,
isRefreshing: false,
isFiltersRefreshing: false,
activeTabs: activeTabs || dashboardState?.activeTabs || [],
activeTabs: seededActiveTabs,
datasetsStatus:
dashboardState?.datasetsStatus || ResourceStatus.Loading,
chartStates: chartStates || dashboardState?.chartStates || {},

View File

@@ -620,15 +620,15 @@ test('useChartCustomizationConfiguration ignores undefined items in metadata', (
);
});
// --- Embedded / hideTab: activeTabs is empty ---
// --- Embedded / hideTab: seeded default tab path ---
// 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).
// mounts, so setActiveTab never fires. Previously this left activeTabs at its
// empty hydration default and useActiveDashboardTabs reconstructed the
// default tab path from the layout on every read. Now dashboard hydration
// seeds the default (first) tab path directly into Redux (see
// actions/hydrate.ts and util/getDefaultActiveTabs.ts), so
// useActiveDashboardTabs is a plain Redux read and these mocks inject the
// path hydration would have supplied.
// Helper: build a layout with ROOT_ID → TABS container → TAB children
function embeddedLayout(extras: Record<string, Record<string, unknown>> = {}) {
@@ -672,10 +672,10 @@ function embeddedLayout(extras: Record<string, Record<string, unknown>> = {}) {
};
}
test('useIsFilterInScope: filter scoped to default tab is in-scope when activeTabs is empty', () => {
test('useIsFilterInScope: filter scoped to default tab is in-scope for the seeded default tab', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardState: { activeTabs: ['TAB-Company'] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
@@ -699,10 +699,10 @@ test('useIsFilterInScope: filter scoped to default tab is in-scope when activeTa
expect(result.current(filter)).toBe(true);
});
test('useIsFilterInScope: filter scoped only to non-default tab is out-of-scope when activeTabs is empty', () => {
test('useIsFilterInScope: filter scoped only to non-default tab is out-of-scope for the seeded default tab', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardState: { activeTabs: ['TAB-Company'] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
@@ -726,10 +726,10 @@ test('useIsFilterInScope: filter scoped only to non-default tab is out-of-scope
expect(result.current(filter)).toBe(false);
});
test('useIsFilterInScope: filter with rootPath to default tab is in-scope when activeTabs is empty', () => {
test('useIsFilterInScope: filter with rootPath to default tab is in-scope for the seeded default tab', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardState: { activeTabs: ['TAB-Company'] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
@@ -752,10 +752,10 @@ test('useIsFilterInScope: filter with rootPath to default tab is in-scope when a
expect(result.current(filter)).toBe(true);
});
test('useSelectFiltersInScope: only default-tab filters are in scope when activeTabs is empty (embedded hideTab)', () => {
test('useSelectFiltersInScope: only default-tab filters are in scope for the seeded default tab (embedded hideTab)', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardState: { activeTabs: ['TAB-Company'] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
@@ -801,10 +801,10 @@ test('useSelectFiltersInScope: only default-tab filters are in scope when active
);
});
test('useSelectFiltersInScope: dividers are always in scope even when activeTabs is empty', () => {
test('useSelectFiltersInScope: dividers are always in scope for the seeded default tab', () => {
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: { activeTabs: [] },
dashboardState: { activeTabs: ['TAB-Company'] },
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
@@ -958,6 +958,40 @@ test('useIsFilterInScope: missing dashboardLayout falls back without crashing',
expect(() => result.current(filter)).not.toThrow();
});
test('useIsFilterInScope: dashboardState with no activeTabs key falls back to [] without throwing', () => {
// DashboardStateShape.activeTabs is optional; on a partial store (e.g.
// pre-hydration render, a story, or a test that doesn't seed activeTabs at
// all) the plain Redux read can return undefined. useActiveDashboardTabs
// must normalize that to [] so downstream `.includes()` calls don't throw.
(useSelector as jest.Mock).mockImplementation((selector: Function) => {
const mockState = {
dashboardState: {},
dashboardLayout: { present: embeddedLayout() },
};
return selector(mockState);
});
const filter: Filter = {
id: 'filter_no_active_tabs_key',
name: 'No activeTabs key',
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 a tab, with no activeTabs key in state',
};
const { result } = renderHook(() => useIsFilterInScope());
expect(() => result.current(filter)).not.toThrow();
// With no activeTabs, a tab-scoped filter is out-of-scope, not "in scope
// by default" — falling back to [] must not be mistaken for "all tabs".
expect(result.current(filter)).toBe(false);
});
// Shared fixture for the two nested-tabs tests below. Layout is identical;
// only the redux activeTabs differs (empty for the default-path test,
// inner-only for the hideTab ancestor-merge test).
@@ -1015,12 +1049,14 @@ const mockNestedTabsState = (activeTabs: string[]) => ({
dashboardLayout: { present: nestedTabsLayout() },
});
test('useIsFilterInScope: deeply nested tabs — default path includes inner-tab default', () => {
test('useIsFilterInScope: deeply nested tabs — scopes filters against an injected nested active-tab path', () => {
// ROOT → TABS-1 → [TAB-Outer1, TAB-Outer2]
// └─ TAB-Outer1 → TABS-2 → [TAB-Inner1, TAB-Inner2]
// Default path should be ['TAB-Outer1', 'TAB-Inner1'].
// activeTabs ['TAB-Outer1', 'TAB-Inner1'] mirrors the path hydration seeds
// into Redux (util/getDefaultActiveTabs.ts), injected here directly since
// this suite mocks useSelector rather than exercising hydration.
(useSelector as jest.Mock).mockImplementation((selector: Function) =>
selector(mockNestedTabsState([])),
selector(mockNestedTabsState(['TAB-Outer1', 'TAB-Inner1'])),
);
const innerDefaultFilter: Filter = {
@@ -1052,13 +1088,18 @@ test('useIsFilterInScope: deeply nested tabs — default path includes inner-tab
expect(result.current(innerNonDefaultFilter)).toBe(false);
});
test('useIsFilterInScope: nested Tabs mounted under hideTab:true — outer ancestor merged so outer-tab scoping is preserved', () => {
// hideTab:true skips the top-level Tabs but a nested Tabs can still mount
// and dispatch setActiveTab. activeTabs holds only the inner id; without
// ancestor merging, filters whose charts have tabParents=[outer, inner]
// would be marked out-of-scope because the outer id is missing.
test('useIsFilterInScope: nested tabs — full Redux-supplied path keeps outer-tab scoping intact', () => {
// Formerly proved useActiveDashboardTabs reconstructed a missing outer
// ancestor from a partial Redux value (['TAB-Inner1'] only). That
// reconstruction responsibility no longer lives in the hook: hydrate
// seeding (util/getDefaultActiveTabs.ts) and the SET_ACTIVE_TAB reducer's
// ancestor-preservation property (see the mid-session regression guard in
// reducers/dashboardState.test.ts) now guarantee Redux always carries the
// full active-tab path, outer ancestor included. This test pins that a
// plain Redux read of that full path still scopes outer-tab filters
// correctly.
(useSelector as jest.Mock).mockImplementation((selector: Function) =>
selector(mockNestedTabsState(['TAB-Inner1'])),
selector(mockNestedTabsState(['TAB-Outer1', 'TAB-Inner1'])),
);
const innerActiveFilter: Filter = {
@@ -1090,7 +1131,7 @@ test('useIsFilterInScope: nested Tabs mounted under hideTab:true — outer ances
};
const { result } = renderHook(() => useIsFilterInScope());
// Outer ancestor TAB-Outer1 is merged into the active path → in scope.
// Outer ancestor TAB-Outer1 is present in the seeded path → in scope.
expect(result.current(innerActiveFilter)).toBe(true);
// TAB-Outer2 is not in the active path → out of scope, scoping preserved.
expect(result.current(otherOuterFilter)).toBe(false);

View File

@@ -30,8 +30,7 @@ import {
} from '@superset-ui/core';
import { FilterElement } from './FilterBar/FilterControls/types';
import { ActiveTabs, DashboardLayout, RootState } from '../../types';
import { CHART_TYPE, TAB_TYPE, TABS_TYPE } from '../../util/componentTypes';
import { DASHBOARD_ROOT_ID } from '../../util/constants';
import { CHART_TYPE, TAB_TYPE } from '../../util/componentTypes';
import { isChartCustomizationId } from './FiltersConfigModal/utils';
import {
migrateChartCustomizationArray,
@@ -184,62 +183,9 @@ export function useDashboardHasTabs() {
}
function useActiveDashboardTabs(): ActiveTabs {
const reduxTabs = useSelector<RootState, ActiveTabs>(
state => state.dashboardState?.activeTabs,
return useSelector<RootState, ActiveTabs>(
state => state.dashboardState?.activeTabs ?? EMPTY_ACTIVE_TABS,
);
const dashboardLayout = useDashboardLayout();
return useMemo(() => {
const reduxList = reduxTabs ?? [];
const reduxFallback = reduxList.length ? reduxList : EMPTY_ACTIVE_TABS;
if (!dashboardLayout) return reduxFallback;
// Tabbed dashboards always nest the top-level TABS container as the first
// child of ROOT. If that invariant doesn't hold (no-tabs layout), no
// fallback applies and we use reduxTabs as-is.
const root = dashboardLayout[DASHBOARD_ROOT_ID];
if (!root?.children?.length) return reduxFallback;
const topContainer = dashboardLayout[root.children[0]];
if (topContainer?.type !== TABS_TYPE || !topContainer.children?.length) {
return reduxFallback;
}
// Walk every TABS container along the active path. For each container,
// pick the child Redux marked active; otherwise pick the first child (the
// default the live Tabs component would render). This handles:
// - empty reduxTabs (hideTab:true, no permalink) → full default path
// - reduxTabs missing an outer ancestor (hideTab:true skipped the
// top-level Tabs, but a nested Tabs dispatched setActiveTab) → fill
// in the missing ancestor so outer-tab scoping is preserved
// - fully populated reduxTabs (normal hydration) → same result
const reduxSet = new Set(reduxList);
const result: ActiveTabs = [];
const queue: string[] = [
topContainer.children.find(c => reduxSet.has(c)) ??
topContainer.children[0],
];
while (queue.length > 0) {
const tabId = queue.shift()!;
result.push(tabId);
const tab = dashboardLayout[tabId];
if (!tab?.children) continue;
for (const childId of tab.children) {
const child = dashboardLayout[childId];
if (child?.type !== TABS_TYPE || !child.children?.length) continue;
queue.push(
child.children.find(c => reduxSet.has(c)) ?? child.children[0],
);
}
}
// Preserve any reduxTabs entries that fell outside the traversed path so
// we never silently drop a redux-marked active tab id.
const resultSet = new Set(result);
for (const id of reduxList) {
if (!resultSet.has(id)) result.push(id);
}
return result;
}, [reduxTabs, dashboardLayout]);
}
function useSelectChartTabParents() {

View File

@@ -19,6 +19,7 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import dashboardStateReducer from './dashboardState';
import { HYDRATE_DASHBOARD } from '../actions/hydrate';
import {
ADD_SLICE,
ON_CHANGE,
@@ -206,7 +207,95 @@ describe('DashboardState reducer', () => {
}),
);
});
// Pins PLAN.md §4.1: a nested-tab switch must not drop a hydrate-seeded
// outer ancestor from activeTabs. setActiveTab's inactiveTabs/prevTabId
// removal only targets the tab being left and its descendants, so an
// outer ancestor that is neither must survive. This property is what the
// repurposed `nativeFilters/state.test.ts` "full Redux-supplied path"
// test assumes holds once hydration seeds the default tab path.
test('preserves a hydrate-seeded outer ancestor across a nested-tab switch', () => {
const store = mockStore({
dashboardState: { activeTabs: ['TAB-Outer1', 'TAB-Inner1'] },
dashboardLayout: {
present: {
'TAB-Outer1': { parents: [] },
'TAB-Outer2': { parents: [] },
'TAB-Inner1': { parents: ['TAB-Outer1', 'TABS-2'] },
'TAB-Inner2': { parents: ['TAB-Outer1', 'TABS-2'] },
},
},
});
const request = setActiveTab('TAB-Inner2', 'TAB-Inner1');
const thunkAction = request(
store.dispatch,
store.getState as () => RootState,
);
const result = typedDashboardStateReducer(
createMockDashboardState({
activeTabs: ['TAB-Outer1', 'TAB-Inner1'],
}),
thunkAction,
);
expect(result.activeTabs).toContain('TAB-Outer1');
expect(result.activeTabs).toEqual(
expect.arrayContaining(['TAB-Outer1', 'TAB-Inner2']),
);
});
// Pins the exact seam that replaced the deleted useActiveDashboardTabs
// layout-walk reconstruction: the Tabs component's initial-mount
// dispatch (no prevTabId) must not drop a hydrate-seeded outer ancestor
// either. The prior test only covers a later prev→next switch.
test('preserves a hydrate-seeded outer ancestor on the initial mount dispatch (no prevTabId)', () => {
const store = mockStore({
dashboardState: { activeTabs: ['TAB-Outer1', 'TAB-Inner1'] },
dashboardLayout: {
present: {
'TAB-Outer1': { parents: [] },
'TAB-Outer2': { parents: [] },
'TAB-Inner1': { parents: ['TAB-Outer1', 'TABS-2'] },
'TAB-Inner2': { parents: ['TAB-Outer1', 'TABS-2'] },
},
},
});
const request = setActiveTab('TAB-Inner1', undefined);
const thunkAction = request(
store.dispatch,
store.getState as () => RootState,
);
const result = typedDashboardStateReducer(
createMockDashboardState({
activeTabs: ['TAB-Outer1', 'TAB-Inner1'],
}),
thunkAction,
);
expect(result.activeTabs).toContain('TAB-Outer1');
expect(result.activeTabs).toEqual(
expect.arrayContaining(['TAB-Outer1', 'TAB-Inner1']),
);
});
});
// Pins a side effect of seeding activeTabs at hydration (see
// actions/hydrate.ts / util/getDefaultActiveTabs.ts): a non-empty seeded
// activeTabs now reaches HYDRATE_DASHBOARD with entries, which the
// existing "Initialize tab activation times for initially active tabs"
// branch below was already written to handle — previously unreachable in
// practice on a fresh load because activeTabs was always seeded `[]`.
test('HYDRATE_DASHBOARD populates tabActivationTimes for a seeded activeTabs value', () => {
const result = typedDashboardStateReducer(undefined, {
type: HYDRATE_DASHBOARD,
data: { dashboardState: { activeTabs: ['TAB-1'] } },
});
expect(result.activeTabs).toEqual(['TAB-1']);
expect(result.tabActivationTimes?.['TAB-1']).toEqual(expect.any(Number));
});
test('SET_ACTIVE_TABS', () => {
expect(
typedDashboardStateReducer(

View File

@@ -0,0 +1,112 @@
/**
* 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 getDefaultActiveTabs from './getDefaultActiveTabs';
import { DASHBOARD_ROOT_ID } from './constants';
import type { DashboardLayout } from '../types';
const layoutItem = (
id: string,
type: string,
children: string[] = [],
): DashboardLayout[string] =>
({ id, type, children, meta: {} }) as DashboardLayout[string];
test('returns the first tab path for a flat ROOT → TABS → TAB layout', () => {
const layout = {
[DASHBOARD_ROOT_ID]: layoutItem(DASHBOARD_ROOT_ID, 'ROOT', ['TABS-1']),
'TABS-1': layoutItem('TABS-1', 'TABS', ['TAB-1', 'TAB-2']),
'TAB-1': layoutItem('TAB-1', 'TAB'),
'TAB-2': layoutItem('TAB-2', 'TAB'),
} as unknown as DashboardLayout;
expect(getDefaultActiveTabs(layout)).toEqual(['TAB-1']);
});
test('recurses into nested TABS containers', () => {
const layout = {
[DASHBOARD_ROOT_ID]: layoutItem(DASHBOARD_ROOT_ID, 'ROOT', ['TABS-1']),
'TABS-1': layoutItem('TABS-1', 'TABS', ['TAB-1', 'TAB-2']),
'TAB-1': layoutItem('TAB-1', 'TAB', ['TABS-2']),
'TAB-2': layoutItem('TAB-2', 'TAB'),
'TABS-2': layoutItem('TABS-2', 'TABS', ['TAB-1-1', 'TAB-1-2']),
'TAB-1-1': layoutItem('TAB-1-1', 'TAB'),
'TAB-1-2': layoutItem('TAB-1-2', 'TAB'),
} as unknown as DashboardLayout;
expect(getDefaultActiveTabs(layout)).toEqual(['TAB-1', 'TAB-1-1']);
});
test('returns an empty path for a dashboard with no tabs', () => {
const layout = {
[DASHBOARD_ROOT_ID]: layoutItem(DASHBOARD_ROOT_ID, 'ROOT', ['GRID_ID']),
GRID_ID: layoutItem('GRID_ID', 'GRID', ['CHART-1']),
'CHART-1': layoutItem('CHART-1', 'CHART'),
} as unknown as DashboardLayout;
expect(getDefaultActiveTabs(layout)).toEqual([]);
});
test('returns an empty path when TABS is nested under GRID rather than the first ROOT child', () => {
const layout = {
[DASHBOARD_ROOT_ID]: layoutItem(DASHBOARD_ROOT_ID, 'ROOT', ['GRID_ID']),
GRID_ID: layoutItem('GRID_ID', 'GRID', ['TABS-1']),
'TABS-1': layoutItem('TABS-1', 'TABS', ['TAB-1']),
'TAB-1': layoutItem('TAB-1', 'TAB'),
} as unknown as DashboardLayout;
expect(getDefaultActiveTabs(layout)).toEqual([]);
});
test('returns an empty path for a layout with no ROOT entry', () => {
const layout = {} as unknown as DashboardLayout;
expect(getDefaultActiveTabs(layout)).toEqual([]);
});
test('returns an empty path when the top-level TABS container has no children', () => {
const layout = {
[DASHBOARD_ROOT_ID]: layoutItem(DASHBOARD_ROOT_ID, 'ROOT', ['TABS-1']),
'TABS-1': layoutItem('TABS-1', 'TABS', []),
} as unknown as DashboardLayout;
expect(getDefaultActiveTabs(layout)).toEqual([]);
});
test('returns the outer-only path when a nested TABS container has no children', () => {
const layout = {
[DASHBOARD_ROOT_ID]: layoutItem(DASHBOARD_ROOT_ID, 'ROOT', ['TABS-1']),
'TABS-1': layoutItem('TABS-1', 'TABS', ['TAB-1']),
'TAB-1': layoutItem('TAB-1', 'TAB', ['TABS-2']),
'TABS-2': layoutItem('TABS-2', 'TABS', []),
} as unknown as DashboardLayout;
expect(getDefaultActiveTabs(layout)).toEqual(['TAB-1']);
});
test('terminates on a cyclic layout, emitting each id at most once', () => {
// TAB-1 → TABS-2 → TAB-1 (cycle back to the same tab)
const layout = {
[DASHBOARD_ROOT_ID]: layoutItem(DASHBOARD_ROOT_ID, 'ROOT', ['TABS-1']),
'TABS-1': layoutItem('TABS-1', 'TABS', ['TAB-1']),
'TAB-1': layoutItem('TAB-1', 'TAB', ['TABS-2']),
'TABS-2': layoutItem('TABS-2', 'TABS', ['TAB-1']),
} as unknown as DashboardLayout;
expect(getDefaultActiveTabs(layout)).toEqual(['TAB-1']);
});

View File

@@ -0,0 +1,54 @@
/**
* 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 { DashboardLayout } from '../types';
import { TABS_TYPE } from './componentTypes';
import { DASHBOARD_ROOT_ID } from './constants';
// Default active-tab path a freshly-rendered dashboard shows: ROOT → first
// TABS → first TAB, recursing into nested TABS. Empty for non-tabbed layouts
// and for layouts whose top-level TABS is not the first ROOT child (e.g. TABS
// nested under GRID), matching the live Tabs component's default selection.
export default function getDefaultActiveTabs(
layout: DashboardLayout,
): string[] {
const root = layout?.[DASHBOARD_ROOT_ID];
const top = root?.children?.length ? layout[root.children[0]] : undefined;
if (top?.type !== TABS_TYPE || !top.children?.length) return [];
const result: string[] = [];
const visited = new Set<string>();
const queue: string[] = [top.children[0]];
while (queue.length) {
const tabId = queue.shift() as string;
if (visited.has(tabId)) continue;
visited.add(tabId);
result.push(tabId);
layout[tabId]?.children?.forEach(childId => {
const child = layout[childId];
if (
child?.type === TABS_TYPE &&
child.children?.length &&
!visited.has(child.children[0])
) {
queue.push(child.children[0]);
}
});
}
return result;
}