This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 02dbfd2ab5b fix(dashboard): don't register an active tab for childless
TABS components (#44345)
02dbfd2ab5b is described below
commit 02dbfd2ab5b8f13bd2173e7877cafd02033ec2d0
Author: Enzo Martellucci <[email protected]>
AuthorDate: Wed Sep 23 14:08:07 2026 +0200
fix(dashboard): don't register an active tab for childless TABS components
(#44345)
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../components/gridComponents/Tabs/Tabs.test.tsx | 95 ++++++++++++++++++++++
.../components/gridComponents/Tabs/Tabs.tsx | 22 ++++-
.../gridComponents/TabsRenderer/TabsRenderer.tsx | 2 +-
.../src/dashboard/reducers/dashboardState.test.ts | 23 ++++++
4 files changed, 140 insertions(+), 2 deletions(-)
diff --git
a/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.test.tsx
b/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.test.tsx
index eb8484ab1bc..0d31b9b3514 100644
---
a/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.test.tsx
+++
b/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.test.tsx
@@ -265,6 +265,101 @@ test('Switching tabs', async () => {
expect(props.onChangeTab).toHaveBeenCalled();
});
+test.each([false, true])(
+ 'A childless TABS component does not register an active tab (editMode=%s)',
+ editMode => {
+ // An unresolved id reaches activeTabs as `undefined`, which
+ // `JSON.stringify` coerces to `null` in the permalink request body.
+ const props = createProps();
+ props.editMode = editMode;
+ props.component.children = [];
+
+ render(<Tabs {...props} />, {
+ useRedux: true,
+ useDnd: true,
+ });
+
+ expect(props.setActiveTab).not.toHaveBeenCalled();
+ },
+);
+
+test.each([false, true])(
+ 'The first child added to an empty TABS component is activated
(editMode=%s)',
+ editMode => {
+ const props = createProps();
+ const [tabId] = props.component.children;
+ props.editMode = editMode;
+ props.component.children = [];
+ const { rerender } = render(<Tabs {...props} />, {
+ useRedux: true,
+ useDnd: true,
+ });
+
+ expect(props.setActiveTab).not.toHaveBeenCalled();
+ rerender(
+ <Tabs {...props} component={{ ...props.component, children: [tabId] }}
/>,
+ );
+
+ expect(props.setActiveTab.mock.calls).toEqual([[tabId]]);
+ expect(screen.getByRole('tab')).toHaveAttribute('aria-selected', 'true');
+ },
+);
+
+test('A tab added after deleting the last tab is selected and registered',
async () => {
+ const props = createProps();
+ const [deletedTabId, newTabId] = props.component.children;
+ props.component.children = [deletedTabId];
+ const { rerender } = render(<Tabs {...props} />, {
+ useRedux: true,
+ useDnd: true,
+ });
+
+ expect(props.setActiveTab.mock.calls).toEqual([[deletedTabId]]);
+ expect(screen.getByRole('tab')).toHaveAttribute('aria-selected', 'true');
+
+ await userEvent.click(screen.getByRole('button', { name: 'remove' }));
+ await userEvent.click(screen.getByRole('button', { name: 'Delete' }));
+ expect(props.deleteComponent).toHaveBeenCalledWith(
+ deletedTabId,
+ props.component.id,
+ );
+
+ rerender(
+ <Tabs {...props} component={{ ...props.component, children: [] }} />,
+ );
+ expect(screen.queryByRole('tab')).not.toBeInTheDocument();
+ props.setActiveTab.mockClear();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Add tab' }));
+ expect(props.createComponent).toHaveBeenCalled();
+ expect(props.setActiveTab).not.toHaveBeenCalled();
+
+ rerender(
+ <Tabs
+ {...props}
+ component={{ ...props.component, children: [newTabId] }}
+ />,
+ );
+
+ expect(props.setActiveTab.mock.calls).toEqual([[newTabId, deletedTabId]]);
+ expect(screen.getByRole('tab')).toHaveAttribute('aria-selected', 'true');
+});
+
+test.each([false, true])(
+ 'A populated TABS component registers its active tab (editMode=%s)',
+ editMode => {
+ const props = createProps();
+ props.editMode = editMode;
+
+ render(<Tabs {...props} />, {
+ useRedux: true,
+ useDnd: true,
+ });
+
+ expect(props.setActiveTab.mock.calls).toEqual([['TAB-AsMaxdYL_t']]);
+ },
+);
+
test('activeTabs hydrated from a permalink selects the matching tab content',
() => {
// Regression guard for #36132: when a dashboard is opened via a
// permalink/anchor (including embedded dashboards), the permalink state is
diff --git
a/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.tsx
b/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.tsx
index d1f351be0db..10cf3272c9a 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.tsx
@@ -153,7 +153,7 @@ const Tabs = (props: TabsProps): ReactElement => {
};
}, [activeTabs, props.component, directPathToChild]);
- const [activeKey, setActiveKey] = useState<string>(initActiveKey);
+ const [activeKey, setActiveKey] = useState<string |
undefined>(initActiveKey);
const [selectedTabIndex, setSelectedTabIndex] =
useState<number>(initTabIndex);
const [dropPosition, setDropPosition] = useState<string | null>(null);
@@ -166,6 +166,26 @@ const Tabs = (props: TabsProps): ReactElement => {
const prevTabIds = usePrevious(props.component.children);
useEffect(() => {
+ // Resolve missing or deleted active keys when children become available
+ // so a tab added to an empty container is selected and registered.
+ const tabId = props.component.children[selectedTabIndex];
+ if (
+ tabId &&
+ (!activeKey || !props.component.children.includes(activeKey))
+ ) {
+ setActiveKey(tabId);
+ }
+ }, [activeKey, props.component.children, selectedTabIndex]);
+
+ useEffect(() => {
+ // A TABS component with no children resolves no tab id, so there is
+ // nothing to activate. Dispatching the unresolved id would register an
+ // `undefined` entry in dashboardState.activeTabs, which JSON.stringify
+ // coerces to `null` when the dashboard state is posted to the permalink
+ // endpoint.
+ if (!activeKey) {
+ return;
+ }
if (prevActiveKey) {
props.setActiveTab(activeKey, prevActiveKey);
} else {
diff --git
a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx
b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx
index 1d2797fb192..2f3eaef3767 100644
---
a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx
+++
b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx
@@ -184,7 +184,7 @@ export interface TabsRendererProps {
tabsDragSourceRef?: RefObject<HTMLDivElement>;
handleDeleteComponent: () => void;
tabsComponent: TabsComponent;
- activeKey: string;
+ activeKey: string | undefined;
tabIds: string[];
handleClickTab: (index: number) => void;
handleEdit: AntdTabsProps['onEdit'];
diff --git a/superset-frontend/src/dashboard/reducers/dashboardState.test.ts
b/superset-frontend/src/dashboard/reducers/dashboardState.test.ts
index 7ceb1d66824..46226c78f6e 100644
--- a/superset-frontend/src/dashboard/reducers/dashboardState.test.ts
+++ b/superset-frontend/src/dashboard/reducers/dashboardState.test.ts
@@ -274,6 +274,29 @@ describe('DashboardState reducer', () => {
expect.arrayContaining(['TAB-Outer1', 'TAB-Inner1']),
);
});
+
+ // The component tests assert on a `setActiveTab` mock, so they stop short
+ // of the state that actually gets serialized into the permalink body.
+ test('stores a resolved tab id so activeTabs serializes without null', ()
=> {
+ const store = mockStore({
+ dashboardState: { activeTabs: [] },
+ dashboardLayout: { present: { 'TAB-1': { parents: [] } } },
+ });
+ const thunkAction = setActiveTab('TAB-1')(
+ store.dispatch,
+ store.getState as () => RootState,
+ );
+
+ const result = typedDashboardStateReducer(
+ createMockDashboardState({ activeTabs: [] }),
+ thunkAction,
+ );
+
+ expect(result.activeTabs).toEqual(['TAB-1']);
+ expect(JSON.stringify({ activeTabs: result.activeTabs })).toBe(
+ '{"activeTabs":["TAB-1"]}',
+ );
+ });
});
// Pins a side effect of seeding activeTabs at hydration (see
// actions/hydrate.ts / util/getDefaultActiveTabs.ts): a non-empty seeded