fix(dashboard): prevent tab content cutoff and excessive whitespace in empty tabs (#35834)

This commit is contained in:
Richard Fogaca Nienkotter
2025-11-13 18:33:43 -03:00
committed by GitHub
parent 74a590cb76
commit 78f9debdd4
8 changed files with 280 additions and 60 deletions

View File

@@ -20,25 +20,25 @@
import { fireEvent, render } from '@superset-ui/core/spec';
import Tabs, { EditableTabs, LineEditableTabs } from './Tabs';
describe('Tabs', () => {
const defaultItems = [
{
key: '1',
label: 'Tab 1',
children: <div data-testid="tab1-content">Tab 1 content</div>,
},
{
key: '2',
label: 'Tab 2',
children: <div data-testid="tab2-content">Tab 2 content</div>,
},
{
key: '3',
label: 'Tab 3',
children: <div data-testid="tab3-content">Tab 3 content</div>,
},
];
const defaultItems = [
{
key: '1',
label: 'Tab 1',
children: <div data-testid="tab1-content">Tab 1 content</div>,
},
{
key: '2',
label: 'Tab 2',
children: <div data-testid="tab2-content">Tab 2 content</div>,
},
{
key: '3',
label: 'Tab 3',
children: <div data-testid="tab3-content">Tab 3 content</div>,
},
];
describe('Tabs', () => {
describe('Basic Tabs', () => {
it('should render tabs with default props', () => {
const { getByText, container } = render(<Tabs items={defaultItems} />);
@@ -284,6 +284,7 @@ describe('Tabs', () => {
describe('Styling Integration', () => {
it('should accept and apply custom CSS classes', () => {
const { container } = render(
// eslint-disable-next-line react/forbid-component-props
<Tabs items={defaultItems} className="custom-tabs-class" />,
);
@@ -295,6 +296,7 @@ describe('Tabs', () => {
it('should accept and apply custom styles', () => {
const customStyle = { minHeight: '200px' };
const { container } = render(
// eslint-disable-next-line react/forbid-component-props
<Tabs items={defaultItems} style={customStyle} />,
);
@@ -304,3 +306,72 @@ describe('Tabs', () => {
});
});
});
test('fullHeight prop renders component hierarchy correctly', () => {
const { container } = render(<Tabs items={defaultItems} fullHeight />);
const tabsElement = container.querySelector('.ant-tabs');
const contentHolder = container.querySelector('.ant-tabs-content-holder');
const content = container.querySelector('.ant-tabs-content');
const tabPane = container.querySelector('.ant-tabs-tabpane');
expect(tabsElement).toBeInTheDocument();
expect(contentHolder).toBeInTheDocument();
expect(content).toBeInTheDocument();
expect(tabPane).toBeInTheDocument();
expect(tabsElement?.contains(contentHolder as Node)).toBe(true);
expect(contentHolder?.contains(content as Node)).toBe(true);
expect(content?.contains(tabPane as Node)).toBe(true);
});
test('fullHeight prop maintains structure when content updates', () => {
const { container, rerender } = render(
<Tabs items={defaultItems} fullHeight />,
);
const initialTabsElement = container.querySelector('.ant-tabs');
const newItems = [
...defaultItems,
{
key: '4',
label: 'Tab 4',
children: <div data-testid="tab4-content">New tab content</div>,
},
];
rerender(<Tabs items={newItems} fullHeight />);
const updatedTabsElement = container.querySelector('.ant-tabs');
const updatedContentHolder = container.querySelector(
'.ant-tabs-content-holder',
);
expect(updatedTabsElement).toBeInTheDocument();
expect(updatedContentHolder).toBeInTheDocument();
expect(initialTabsElement).toBe(updatedTabsElement);
});
test('fullHeight prop works with allowOverflow to handle tall content', () => {
const { container } = render(
<Tabs items={defaultItems} fullHeight allowOverflow />,
);
const tabsElement = container.querySelector('.ant-tabs') as HTMLElement;
const contentHolder = container.querySelector(
'.ant-tabs-content-holder',
) as HTMLElement;
expect(tabsElement).toBeInTheDocument();
expect(contentHolder).toBeInTheDocument();
// Verify overflow handling is not restricted
const holderStyles = window.getComputedStyle(contentHolder);
expect(holderStyles.overflow).not.toBe('hidden');
});
test('fullHeight prop handles empty items array', () => {
const { container } = render(<Tabs items={[]} fullHeight />);
expect(container.querySelector('.ant-tabs')).toBeInTheDocument();
});

View File

@@ -25,12 +25,14 @@ import type { SerializedStyles } from '@emotion/react';
export interface TabsProps extends AntdTabsProps {
allowOverflow?: boolean;
fullHeight?: boolean;
contentStyle?: SerializedStyles;
}
const StyledTabs = ({
animated = false,
allowOverflow = true,
fullHeight = false,
tabBarStyle,
contentStyle,
...props
@@ -46,9 +48,17 @@ const StyledTabs = ({
tabBarStyle={mergedStyle}
css={theme => css`
overflow: ${allowOverflow ? 'visible' : 'hidden'};
${fullHeight && 'height: 100%;'}
.ant-tabs-content-holder {
overflow: ${allowOverflow ? 'visible' : 'auto'};
${fullHeight && 'height: 100%;'}
}
.ant-tabs-content {
${fullHeight && 'height: 100%;'}
}
.ant-tabs-tabpane {
${fullHeight && 'height: 100%;'}
${contentStyle}
}
.ant-tabs-tab {

View File

@@ -58,30 +58,52 @@ jest.mock('src/dashboard/actions/dashboardState', () => ({
jest.mock('src/components/ResizableSidebar/useStoredSidebarWidth');
// mock following dependent components to fix the prop warnings
jest.mock('@superset-ui/core/components/Select/Select', () => () => (
<div data-test="mock-select" />
));
jest.mock('@superset-ui/core/components/Select/AsyncSelect', () => () => (
<div data-test="mock-async-select" />
));
jest.mock('@superset-ui/core/components/PageHeaderWithActions', () => ({
PageHeaderWithActions: () => (
jest.mock('@superset-ui/core/components/Select/Select', () => {
const MockSelect = () => <div data-test="mock-select" />;
MockSelect.displayName = 'MockSelect';
return MockSelect;
});
jest.mock('@superset-ui/core/components/Select/AsyncSelect', () => {
const MockAsyncSelect = () => <div data-test="mock-async-select" />;
MockAsyncSelect.displayName = 'MockAsyncSelect';
return MockAsyncSelect;
});
jest.mock('@superset-ui/core/components/PageHeaderWithActions', () => {
const MockPageHeaderWithActions = () => (
<div data-test="mock-page-header-with-actions" />
),
}));
);
MockPageHeaderWithActions.displayName = 'MockPageHeaderWithActions';
return {
PageHeaderWithActions: MockPageHeaderWithActions,
};
});
jest.mock(
'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal',
() => () => <div data-test="mock-filters-config-modal" />,
() => {
const MockFiltersConfigModal = () => (
<div data-test="mock-filters-config-modal" />
);
MockFiltersConfigModal.displayName = 'MockFiltersConfigModal';
return MockFiltersConfigModal;
},
);
jest.mock('src/dashboard/components/BuilderComponentPane', () => () => (
<div data-test="mock-builder-component-pane" />
));
jest.mock('src/dashboard/components/nativeFilters/FilterBar', () => () => (
<div data-test="mock-filter-bar" />
));
jest.mock('src/dashboard/containers/DashboardGrid', () => () => (
<div data-test="mock-dashboard-grid" />
));
jest.mock('src/dashboard/components/BuilderComponentPane', () => {
const MockBuilderComponentPane = () => (
<div data-test="mock-builder-component-pane" />
);
MockBuilderComponentPane.displayName = 'MockBuilderComponentPane';
return MockBuilderComponentPane;
});
jest.mock('src/dashboard/components/nativeFilters/FilterBar', () => {
const MockFilterBar = () => <div data-test="mock-filter-bar" />;
MockFilterBar.displayName = 'MockFilterBar';
return MockFilterBar;
});
jest.mock('src/dashboard/containers/DashboardGrid', () => {
const MockDashboardGrid = () => <div data-test="mock-dashboard-grid" />;
MockDashboardGrid.displayName = 'MockDashboardGrid';
return MockDashboardGrid;
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('DashboardBuilder', () => {
@@ -178,8 +200,8 @@ describe('DashboardBuilder', () => {
dashboardLayout: undoableDashboardLayoutWithTabs,
});
const parentSize = await findByTestId('grid-container');
const first_tab = screen.getByText('tab1');
expect(first_tab).toBeInTheDocument();
const firstTab = screen.getByText('tab1');
expect(firstTab).toBeInTheDocument();
const tabPanels = within(parentSize).getAllByRole('tabpanel', {
// to include invisible tab panels
hidden: false,
@@ -198,9 +220,9 @@ describe('DashboardBuilder', () => {
},
});
const parentSize = await findByTestId('grid-container');
const second_tab = screen.getByText('tab2');
expect(second_tab).toBeInTheDocument();
fireEvent.click(second_tab);
const secondTab = screen.getByText('tab2');
expect(secondTab).toBeInTheDocument();
fireEvent.click(secondTab);
const tabPanels = within(parentSize).getAllByRole('tabpanel', {
// to include invisible tab panels
hidden: true,
@@ -435,3 +457,67 @@ describe('DashboardBuilder', () => {
expect(queryByTestId('dashboard-filters-panel')).not.toBeInTheDocument();
});
});
test('should render ParentSize wrapper with height 100% for tabs', async () => {
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
100,
jest.fn(),
]);
(fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
(setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
const { findByTestId } = render(<DashboardBuilder />, {
useRedux: true,
store: storeWithState({
...mockState,
dashboardLayout: undoableDashboardLayoutWithTabs,
}),
useDnd: true,
useTheme: true,
});
const gridContainer = await findByTestId('grid-container');
const parentSizeWrapper = gridContainer.querySelector('div');
const tabPanels = within(gridContainer).getAllByRole('tabpanel', {
hidden: true,
});
expect(gridContainer).toBeInTheDocument();
expect(parentSizeWrapper).toBeInTheDocument();
expect(tabPanels.length).toBeGreaterThan(0);
});
test('should maintain layout when switching between tabs', async () => {
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
100,
jest.fn(),
]);
(fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
(setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
(setDirectPathToChild as jest.Mock).mockImplementation(arg0 => ({
type: 'type',
arg0,
}));
const { findByTestId } = render(<DashboardBuilder />, {
useRedux: true,
store: storeWithState({
...mockState,
dashboardLayout: undoableDashboardLayoutWithTabs,
}),
useDnd: true,
useTheme: true,
});
const gridContainer = await findByTestId('grid-container');
fireEvent.click(screen.getByText('tab1'));
fireEvent.click(screen.getByText('tab2'));
const tabPanels = within(gridContainer).getAllByRole('tabpanel', {
hidden: true,
});
expect(gridContainer).toBeInTheDocument();
expect(tabPanels.length).toBeGreaterThan(0);
});

View File

@@ -298,7 +298,7 @@ const StyledDashboardContent = styled.div<{
/* this is the ParentSize wrapper */
& > div:first-child {
height: inherit !important;
height: 100% !important;
}
}

View File

@@ -314,6 +314,7 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
renderTabBar={renderTabBar}
animated={false}
allowOverflow
fullHeight
onFocus={handleFocus}
items={tabItems}
tabBarStyle={{ paddingLeft: 0 }}

View File

@@ -54,6 +54,9 @@ const DashboardEmptyStateContainer = styled.div`
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
`;
const GridContent = styled.div`

View File

@@ -24,22 +24,22 @@ import newComponentFactory from 'src/dashboard/util/newComponentFactory';
import { DASHBOARD_GRID_TYPE } from 'src/dashboard/util/componentTypes';
import { GRID_COLUMN_COUNT } from 'src/dashboard/util/constants';
jest.mock(
'src/dashboard/containers/DashboardComponent',
() =>
({ onResizeStart, onResizeStop }) => (
<button
type="button"
data-test="mock-dashboard-component"
onClick={() => onResizeStart()}
onBlur={() =>
onResizeStop(null, null, null, { width: 1, height: 3 }, 'id')
}
>
Mock
</button>
),
);
jest.mock('src/dashboard/containers/DashboardComponent', () => {
const MockDashboardComponent = ({ onResizeStart, onResizeStop }) => (
<button
type="button"
data-test="mock-dashboard-component"
onClick={() => onResizeStart()}
onBlur={() =>
onResizeStop(null, null, null, { width: 1, height: 3 }, 'id')
}
>
Mock
</button>
);
MockDashboardComponent.displayName = 'MockDashboardComponent';
return MockDashboardComponent;
});
const props = {
depth: 1,
@@ -106,3 +106,51 @@ test('should call resizeComponent when a child DashboardComponent calls resizeSt
height: 3,
});
});
test('should apply flexbox centering and absolute positioning to empty state', () => {
const { container } = setup({
gridComponent: { ...props.gridComponent, children: [] },
editMode: true,
canEdit: true,
setEditMode: jest.fn(),
dashboardId: 1,
});
const dashboardGrid = container.querySelector('.dashboard-grid');
const emptyState = dashboardGrid?.previousElementSibling;
expect(emptyState).toBeInTheDocument();
const styles = window.getComputedStyle(emptyState);
expect(styles.display).toBe('flex');
expect(styles.alignItems).toBe('center');
expect(styles.justifyContent).toBe('center');
expect(styles.position).toBe('absolute');
});
test('should render empty state in both edit and view modes', () => {
const { container: editContainer } = setup({
gridComponent: { ...props.gridComponent, children: [] },
editMode: true,
canEdit: true,
setEditMode: jest.fn(),
dashboardId: 1,
});
const { container: viewContainer } = setup({
gridComponent: { ...props.gridComponent, children: [] },
editMode: false,
canEdit: true,
setEditMode: jest.fn(),
dashboardId: 1,
});
const editDashboardGrid = editContainer.querySelector('.dashboard-grid');
const editEmptyState = editDashboardGrid?.previousElementSibling;
const viewDashboardGrid = viewContainer.querySelector('.dashboard-grid');
const viewEmptyState = viewDashboardGrid?.previousElementSibling;
expect(editEmptyState).toBeInTheDocument();
expect(viewEmptyState).toBeInTheDocument();
});

View File

@@ -111,6 +111,7 @@ const TabsRenderer = memo<TabsRendererProps>(
type={editMode ? 'editable-card' : 'card'}
items={tabItems}
tabBarStyle={{ paddingLeft: tabBarPaddingLeft }}
fullHeight
/>
</StyledTabsContainer>
),