fix(dashboard): normalize spacings and background colors (#35001)

This commit is contained in:
Gabriel Torres Ruiz
2025-09-05 23:13:42 -03:00
committed by GitHub
parent 385471c34d
commit 0fce5ecfa5
49 changed files with 1590 additions and 624 deletions

View File

@@ -0,0 +1,306 @@
/**
* 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 { 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>,
},
];
describe('Basic Tabs', () => {
it('should render tabs with default props', () => {
const { getByText, container } = render(<Tabs items={defaultItems} />);
expect(getByText('Tab 1')).toBeInTheDocument();
expect(getByText('Tab 2')).toBeInTheDocument();
expect(getByText('Tab 3')).toBeInTheDocument();
const activeTabContent = container.querySelector(
'.ant-tabs-tabpane-active',
);
expect(activeTabContent).toBeDefined();
expect(
activeTabContent?.querySelector('[data-testid="tab1-content"]'),
).toBeDefined();
});
it('should render tabs component structure', () => {
const { container } = render(<Tabs items={defaultItems} />);
const tabsElement = container.querySelector('.ant-tabs');
const tabsNav = container.querySelector('.ant-tabs-nav');
const tabsContent = container.querySelector('.ant-tabs-content-holder');
expect(tabsElement).toBeDefined();
expect(tabsNav).toBeDefined();
expect(tabsContent).toBeDefined();
});
it('should apply default tabBarStyle with padding', () => {
const { container } = render(<Tabs items={defaultItems} />);
const tabsNav = container.querySelector('.ant-tabs-nav') as HTMLElement;
// Check that tabBarStyle is applied (default padding is added)
expect(tabsNav?.style?.paddingLeft).toBeDefined();
});
it('should merge custom tabBarStyle with defaults', () => {
const customStyle = { paddingRight: '20px', backgroundColor: 'red' };
const { container } = render(
<Tabs items={defaultItems} tabBarStyle={customStyle} />,
);
const tabsNav = container.querySelector('.ant-tabs-nav') as HTMLElement;
expect(tabsNav?.style?.paddingLeft).toBeDefined();
expect(tabsNav?.style?.paddingRight).toBe('20px');
expect(tabsNav?.style?.backgroundColor).toBe('red');
});
it('should handle allowOverflow prop', () => {
const { container: allowContainer } = render(
<Tabs items={defaultItems} allowOverflow />,
);
const { container: disallowContainer } = render(
<Tabs items={defaultItems} allowOverflow={false} />,
);
expect(allowContainer.querySelector('.ant-tabs')).toBeDefined();
expect(disallowContainer.querySelector('.ant-tabs')).toBeDefined();
});
it('should disable animation by default', () => {
const { container } = render(<Tabs items={defaultItems} />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement?.className).not.toContain('ant-tabs-animated');
});
it('should handle tab change events', () => {
const onChangeMock = jest.fn();
const { getByText } = render(
<Tabs items={defaultItems} onChange={onChangeMock} />,
);
fireEvent.click(getByText('Tab 2'));
expect(onChangeMock).toHaveBeenCalledWith('2');
});
it('should pass through additional props to Antd Tabs', () => {
const onTabClickMock = jest.fn();
const { getByText } = render(
<Tabs
items={defaultItems}
onTabClick={onTabClickMock}
size="large"
centered
/>,
);
fireEvent.click(getByText('Tab 2'));
expect(onTabClickMock).toHaveBeenCalled();
});
});
describe('EditableTabs', () => {
it('should render with editable features', () => {
const { container } = render(<EditableTabs items={defaultItems} />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement?.className).toContain('ant-tabs-card');
expect(tabsElement?.className).toContain('ant-tabs-editable-card');
});
it('should handle onEdit callback for add/remove actions', () => {
const onEditMock = jest.fn();
const itemsWithRemove = defaultItems.map(item => ({
...item,
closable: true,
}));
const { container } = render(
<EditableTabs items={itemsWithRemove} onEdit={onEditMock} />,
);
const removeButton = container.querySelector('.ant-tabs-tab-remove');
expect(removeButton).toBeDefined();
fireEvent.click(removeButton!);
expect(onEditMock).toHaveBeenCalledWith(expect.any(String), 'remove');
});
it('should have default props set correctly', () => {
expect(EditableTabs.defaultProps?.type).toBe('editable-card');
expect(EditableTabs.defaultProps?.animated).toEqual({
inkBar: true,
tabPane: false,
});
});
});
describe('LineEditableTabs', () => {
it('should render as line-style editable tabs', () => {
const { container } = render(<LineEditableTabs items={defaultItems} />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement?.className).toContain('ant-tabs-card');
expect(tabsElement?.className).toContain('ant-tabs-editable-card');
});
it('should render with line-specific styling', () => {
const { container } = render(<LineEditableTabs items={defaultItems} />);
const inkBar = container.querySelector('.ant-tabs-ink-bar');
expect(inkBar).toBeDefined();
});
});
describe('TabPane Legacy Support', () => {
it('should support TabPane component access', () => {
expect(Tabs.TabPane).toBeDefined();
expect(EditableTabs.TabPane).toBeDefined();
expect(LineEditableTabs.TabPane).toBeDefined();
});
it('should render using legacy TabPane syntax', () => {
const { getByText, container } = render(
<Tabs>
<Tabs.TabPane tab="Legacy Tab 1" key="1">
<div data-testid="legacy-content-1">Legacy content 1</div>
</Tabs.TabPane>
<Tabs.TabPane tab="Legacy Tab 2" key="2">
<div data-testid="legacy-content-2">Legacy content 2</div>
</Tabs.TabPane>
</Tabs>,
);
expect(getByText('Legacy Tab 1')).toBeInTheDocument();
expect(getByText('Legacy Tab 2')).toBeInTheDocument();
const activeTabContent = container.querySelector(
'.ant-tabs-tabpane-active [data-testid="legacy-content-1"]',
);
expect(activeTabContent).toBeDefined();
expect(activeTabContent?.textContent).toBe('Legacy content 1');
});
});
describe('Edge Cases', () => {
it('should handle empty items array', () => {
const { container } = render(<Tabs items={[]} />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement).toBeDefined();
});
it('should handle undefined items', () => {
const { container } = render(<Tabs />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement).toBeDefined();
});
it('should handle tabs with no content', () => {
const itemsWithoutContent = [
{ key: '1', label: 'Tab 1' },
{ key: '2', label: 'Tab 2' },
];
const { getByText } = render(<Tabs items={itemsWithoutContent} />);
expect(getByText('Tab 1')).toBeInTheDocument();
expect(getByText('Tab 2')).toBeInTheDocument();
});
it('should handle allowOverflow default value', () => {
const { container } = render(<Tabs items={defaultItems} />);
expect(container.querySelector('.ant-tabs')).toBeDefined();
});
});
describe('Accessibility', () => {
it('should render with proper ARIA roles', () => {
const { container } = render(<Tabs items={defaultItems} />);
const tablist = container.querySelector('[role="tablist"]');
const tabs = container.querySelectorAll('[role="tab"]');
expect(tablist).toBeDefined();
expect(tabs.length).toBe(3);
});
it('should support keyboard navigation', () => {
const { container, getByText } = render(<Tabs items={defaultItems} />);
const firstTab = container.querySelector('[role="tab"]');
const secondTab = getByText('Tab 2');
if (firstTab) {
fireEvent.keyDown(firstTab, { key: 'ArrowRight', code: 'ArrowRight' });
}
fireEvent.click(secondTab);
expect(secondTab).toBeInTheDocument();
});
});
describe('Styling Integration', () => {
it('should accept and apply custom CSS classes', () => {
const { container } = render(
<Tabs items={defaultItems} className="custom-tabs-class" />,
);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement?.className).toContain('custom-tabs-class');
});
it('should accept and apply custom styles', () => {
const customStyle = { minHeight: '200px' };
const { container } = render(
<Tabs items={defaultItems} style={customStyle} />,
);
const tabsElement = container.querySelector('.ant-tabs') as HTMLElement;
expect(tabsElement?.style?.minHeight).toBe('200px');
});
});
});

View File

@@ -29,14 +29,18 @@ export interface TabsProps extends AntdTabsProps {
const StyledTabs = ({
animated = false,
allowOverflow = true,
tabBarStyle,
...props
}: TabsProps) => {
const theme = useTheme();
const defaultTabBarStyle = { paddingLeft: theme.sizeUnit * 4 };
const mergedStyle = { ...defaultTabBarStyle, ...tabBarStyle };
return (
<AntdTabs
animated={animated}
{...props}
tabBarStyle={{ paddingLeft: theme.sizeUnit * 4 }}
tabBarStyle={mergedStyle}
css={theme => css`
overflow: ${allowOverflow ? 'visible' : 'hidden'};

View File

@@ -80,6 +80,11 @@ import {
getDynamicLabelsColors,
} from '../../utils/colorScheme';
export const TOGGLE_NATIVE_FILTERS_BAR = 'TOGGLE_NATIVE_FILTERS_BAR';
export function toggleNativeFiltersBar(isOpen) {
return { type: TOGGLE_NATIVE_FILTERS_BAR, isOpen };
}
export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES';
export function setUnsavedChanges(hasUnsavedChanges) {
return { type: SET_UNSAVED_CHANGES, payload: { hasUnsavedChanges } };

View File

@@ -25,7 +25,14 @@ jest.mock('src/dashboard/containers/SliceAdder', () => () => (
));
test('BuilderComponentPane has correct tabs in correct order', () => {
render(<BuilderComponentPane topOffset={115} />);
render(<BuilderComponentPane topOffset={115} />, {
useRedux: true,
initialState: {
dashboardState: {
nativeFiltersBarOpen: false,
},
},
});
const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(2);
expect(tabs[0]).toHaveTextContent('Charts');

View File

@@ -19,9 +19,11 @@
/* eslint-env browser */
import { rgba } from 'emotion-rgba';
import Tabs from '@superset-ui/core/components/Tabs';
import { t, css, SupersetTheme } from '@superset-ui/core';
import { t, css, SupersetTheme, useTheme } from '@superset-ui/core';
import { useSelector } from 'react-redux';
import SliceAdder from 'src/dashboard/containers/SliceAdder';
import dashboardComponents from 'src/visualizations/presets/dashboardComponents';
import { useMemo } from 'react';
import NewColumn from '../gridComponents/new/NewColumn';
import NewDivider from '../gridComponents/new/NewDivider';
import NewHeader from '../gridComponents/new/NewHeader';
@@ -37,81 +39,97 @@ const TABS_KEYS = {
LAYOUT_ELEMENTS: 'LAYOUT_ELEMENTS',
};
const BuilderComponentPane = ({ topOffset = 0 }) => (
<div
data-test="dashboard-builder-sidepane"
css={css`
position: sticky;
right: 0;
top: ${topOffset}px;
height: calc(100vh - ${topOffset}px);
width: ${BUILDER_PANE_WIDTH}px;
`}
>
const BuilderComponentPane = ({ topOffset = 0 }) => {
const theme = useTheme();
const nativeFiltersBarOpen = useSelector(
(state: any) => state.dashboardState.nativeFiltersBarOpen ?? false,
);
const tabBarStyle = useMemo(
() => ({
paddingLeft: nativeFiltersBarOpen ? 0 : theme.sizeUnit * 4,
}),
[nativeFiltersBarOpen, theme.sizeUnit],
);
return (
<div
css={(theme: SupersetTheme) => css`
position: absolute;
height: 100%;
data-test="dashboard-builder-sidepane"
css={css`
position: sticky;
right: 0;
top: ${topOffset}px;
height: calc(100vh - ${topOffset}px);
width: ${BUILDER_PANE_WIDTH}px;
box-shadow: -4px 0 4px 0 ${rgba(theme.colorBorder, 0.1)};
background-color: ${theme.colorBgBase};
`}
>
<Tabs
data-test="dashboard-builder-component-pane-tabs-navigation"
id="tabs"
<div
css={(theme: SupersetTheme) => css`
line-height: inherit;
margin-top: ${theme.sizeUnit * 2}px;
position: absolute;
height: 100%;
& .ant-tabs-content-holder {
height: 100%;
& .ant-tabs-content {
height: 100%;
}
}
width: ${BUILDER_PANE_WIDTH}px;
box-shadow: -4px 0 4px 0 ${rgba(theme.colorBorder, 0.1)};
background-color: ${theme.colorBgBase};
`}
items={[
{
key: TABS_KEYS.CHARTS,
label: t('Charts'),
children: (
<div
css={css`
height: calc(100vh - ${topOffset * 2}px);
`}
>
<SliceAdder />
</div>
),
},
{
key: TABS_KEYS.LAYOUT_ELEMENTS,
label: t('Layout elements'),
children: (
<>
<NewTabs />
<NewRow />
<NewColumn />
<NewHeader />
<NewMarkdown />
<NewDivider />
{dashboardComponents
.getAll()
.map(({ key: componentKey, metadata }) => (
<NewDynamicComponent
metadata={metadata}
componentKey={componentKey}
/>
))}
</>
),
},
]}
/>
>
<Tabs
data-test="dashboard-builder-component-pane-tabs-navigation"
id="tabs"
tabBarStyle={tabBarStyle}
css={(theme: SupersetTheme) => css`
line-height: inherit;
margin-top: ${theme.sizeUnit * 2}px;
height: 100%;
& .ant-tabs-content-holder {
height: 100%;
& .ant-tabs-content {
height: 100%;
}
}
`}
items={[
{
key: TABS_KEYS.CHARTS,
label: t('Charts'),
children: (
<div
css={css`
height: calc(100vh - ${topOffset * 2}px);
`}
>
<SliceAdder />
</div>
),
},
{
key: TABS_KEYS.LAYOUT_ELEMENTS,
label: t('Layout elements'),
children: (
<>
<NewTabs />
<NewRow />
<NewColumn />
<NewHeader />
<NewMarkdown />
<NewDivider />
{dashboardComponents
.getAll()
.map(({ key: componentKey, metadata }) => (
<NewDynamicComponent
key={componentKey}
metadata={metadata}
componentKey={componentKey}
/>
))}
</>
),
},
]}
/>
</div>
</div>
</div>
);
);
};
export default BuilderComponentPane;

View File

@@ -80,6 +80,7 @@ import DashboardWrapper from './DashboardWrapper';
// @z-index-above-dashboard-charts + 1 = 11
const FiltersPanel = styled.div<{ width: number; hidden: boolean }>`
background-color: ${({ theme }) => theme.colorBgContainer};
grid-column: 1;
grid-row: 1 / span 2;
z-index: 11;
@@ -275,6 +276,7 @@ const StyledDashboardContent = styled.div<{
marginLeft: number;
}>`
${({ theme, editMode, marginLeft }) => css`
background-color: ${theme.colorBgLayout};
display: flex;
flex-direction: row;
flex-wrap: nowrap;
@@ -291,9 +293,7 @@ const StyledDashboardContent = styled.div<{
width: 0;
flex: 1;
position: relative;
margin-top: ${theme.sizeUnit * 4}px;
margin-right: ${theme.sizeUnit * 8}px;
margin-bottom: ${theme.sizeUnit * 4}px;
margin: ${theme.sizeUnit * 4}px;
margin-left: ${marginLeft}px;
${editMode &&
@@ -557,13 +557,9 @@ const DashboardBuilder = () => {
],
);
const dashboardContentMarginLeft =
!dashboardFiltersOpen &&
!editMode &&
nativeFiltersEnabled &&
filterBarOrientation !== FilterBarOrientation.Horizontal
? 0
: theme.sizeUnit * 8;
const dashboardContentMarginLeft = !editMode
? theme.sizeUnit * 4
: theme.sizeUnit * 8;
const renderChild = useCallback(
adjustedWidth => {

View File

@@ -70,13 +70,12 @@ type DashboardContainerProps = {
topLevelTabs?: LayoutItem;
};
export const renderedChartIdsSelector = createSelector(
[(state: RootState) => state.charts],
charts =>
export const renderedChartIdsSelector: (state: RootState) => number[] =
createSelector([(state: RootState) => state.charts], charts =>
Object.values(charts)
.filter(chart => chart.chartStatus === 'rendered')
.map(chart => chart.id),
);
);
const useRenderedChartIds = () => {
const renderedChartIds = useSelector<RootState, number[]>(
@@ -297,6 +296,7 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
allowOverflow
onFocus={handleFocus}
items={tabItems}
tabBarStyle={{ paddingLeft: 0 }}
/>
);
},

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { FC, useEffect, useState } from 'react';
import { FC, PropsWithChildren, useEffect, useState } from 'react';
import { css, styled } from '@superset-ui/core';
import { Constants } from '@superset-ui/core/components';
@@ -113,9 +113,7 @@ const StyledDiv = styled.div`
`}
`;
type Props = {};
const DashboardWrapper: FC<Props> = ({ children }) => {
const DashboardWrapper: FC<PropsWithChildren<{}>> = ({ children }) => {
const editMode = useSelector<RootState, boolean>(
state => state.dashboardState.editMode,
);

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useSelector } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
@@ -26,23 +26,26 @@ import {
useFilters,
useNativeFiltersDataMask,
} from '../nativeFilters/FilterBar/state';
import { toggleNativeFiltersBar } from '../../actions/dashboardState';
// eslint-disable-next-line import/prefer-default-export
export const useNativeFilters = () => {
const dispatch = useDispatch();
const [isInitialized, setIsInitialized] = useState(false);
const showNativeFilters = useSelector<RootState, boolean>(
() => getUrlParam(URL_PARAMS.showFilters) ?? true,
);
const canEdit = useSelector<RootState, boolean>(
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
);
const dashboardFiltersOpen = useSelector<RootState, boolean>(
state => state.dashboardState.nativeFiltersBarOpen ?? false,
);
const filters = useFilters();
const filterValues = useMemo(() => Object.values(filters), [filters]);
const expandFilters = getUrlParam(URL_PARAMS.expandFilters);
const [dashboardFiltersOpen, setDashboardFiltersOpen] = useState(
expandFilters ?? !!filterValues.length,
);
const nativeFiltersEnabled =
showNativeFilters && (canEdit || (!canEdit && filterValues.length !== 0));
@@ -66,9 +69,13 @@ export const useNativeFilters = () => {
!nativeFiltersEnabled ||
missingInitialFilters.length === 0;
const toggleDashboardFiltersOpen = useCallback((visible?: boolean) => {
setDashboardFiltersOpen(prevState => visible ?? !prevState);
}, []);
const toggleDashboardFiltersOpen = useCallback(
(visible?: boolean) => {
const newState = visible ?? !dashboardFiltersOpen;
dispatch(toggleNativeFiltersBar(newState));
},
[dispatch, dashboardFiltersOpen],
);
useEffect(() => {
if (
@@ -77,11 +84,11 @@ export const useNativeFilters = () => {
expandFilters === false ||
(filterValues.length === 0 && nativeFiltersEnabled)
) {
toggleDashboardFiltersOpen(false);
dispatch(toggleNativeFiltersBar(false));
} else {
toggleDashboardFiltersOpen(true);
dispatch(toggleNativeFiltersBar(true));
}
}, [filterValues.length]);
}, [dispatch, filterValues.length, expandFilters, nativeFiltersEnabled]);
useEffect(() => {
if (showDashboard) {

View File

@@ -39,26 +39,26 @@ import { URL_PARAMS } from 'src/constants';
import { enforceSharedLabelsColorsArray } from 'src/utils/colorScheme';
import exportPivotExcel from 'src/utils/downloadAsPivotExcel';
import SliceHeader from '../SliceHeader';
import MissingChart from '../MissingChart';
import SliceHeader from '../../SliceHeader';
import MissingChart from '../../MissingChart';
import {
addDangerToast,
addSuccessToast,
} from '../../../components/MessageToasts/actions';
} from '../../../../components/MessageToasts/actions';
import {
setFocusedFilterField,
toggleExpandSlice,
unsetFocusedFilterField,
} from '../../actions/dashboardState';
import { changeFilter } from '../../actions/dashboardFilters';
import { refreshChart } from '../../../components/Chart/chartAction';
import { logEvent } from '../../../logger/actions';
} from '../../../actions/dashboardState';
import { changeFilter } from '../../../actions/dashboardFilters';
import { refreshChart } from '../../../../components/Chart/chartAction';
import { logEvent } from '../../../../logger/actions';
import {
getActiveFilters,
getAppliedFilterValues,
} from '../../util/activeDashboardFilters';
import getFormDataWithExtraFilters from '../../util/charts/getFormDataWithExtraFilters';
import { PLACEHOLDER_DATASOURCE } from '../../constants';
} from '../../../util/activeDashboardFilters';
import getFormDataWithExtraFilters from '../../../util/charts/getFormDataWithExtraFilters';
import { PLACEHOLDER_DATASOURCE } from '../../../constants';
const propTypes = {
id: PropTypes.number.isRequired,

View File

@@ -20,13 +20,13 @@ import { fireEvent, render } from 'spec/helpers/testing-library';
import { FeatureFlag, VizType } from '@superset-ui/core';
import * as redux from 'redux';
import Chart from 'src/dashboard/components/gridComponents/Chart';
import * as exploreUtils from 'src/explore/exploreUtils';
import { sliceEntitiesForChart as sliceEntities } from 'spec/fixtures/mockSliceEntities';
import mockDatasource from 'spec/fixtures/mockDatasource';
import chartQueries, {
sliceId as queryId,
} from 'spec/fixtures/mockChartQueries';
import Chart from './Chart';
const props = {
id: queryId,

View File

@@ -0,0 +1,21 @@
/**
* 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 Chart from './Chart';
export default Chart;

View File

@@ -35,9 +35,13 @@ import { nativeFiltersInfo } from 'src/dashboard/fixtures/mockNativeFilters';
import newComponentFactory from 'src/dashboard/util/newComponentFactory';
import { initialState } from 'src/SqlLab/fixtures';
import { SET_DIRECT_PATH } from 'src/dashboard/actions/dashboardState';
import { CHART_TYPE, COLUMN_TYPE, ROW_TYPE } from '../../util/componentTypes';
import {
CHART_TYPE,
COLUMN_TYPE,
ROW_TYPE,
} from '../../../util/componentTypes';
import ChartHolder, { CHART_MARGIN } from './ChartHolder';
import { GRID_BASE_UNIT, GRID_GUTTER_SIZE } from '../../util/constants';
import { GRID_BASE_UNIT, GRID_GUTTER_SIZE } from '../../../util/constants';
const DEFAULT_HEADER_HEIGHT = 22;

View File

@@ -0,0 +1,19 @@
/**
* 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.
*/
export { default } from './ChartHolder';

View File

@@ -19,12 +19,12 @@
import { fireEvent, render } from 'spec/helpers/testing-library';
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
import Column from 'src/dashboard/components/gridComponents/Column';
import IconButton from 'src/dashboard/components/IconButton';
import { getMockStore } from 'spec/fixtures/mockStore';
import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout';
import { initialState } from 'src/SqlLab/fixtures';
import Column from './Column';
jest.mock('src/dashboard/components/dnd/DragDroppable', () => ({
Draggable: ({ children }) => (

View File

@@ -0,0 +1,21 @@
/**
* 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 Column from './Column';
export default Column;

View File

@@ -20,10 +20,10 @@ import { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { css, styled } from '@superset-ui/core';
import { Draggable } from '../dnd/DragDroppable';
import HoverMenu from '../menu/HoverMenu';
import DeleteComponentButton from '../DeleteComponentButton';
import { componentShape } from '../../util/propShapes';
import { Draggable } from '../../dnd/DragDroppable';
import HoverMenu from '../../menu/HoverMenu';
import DeleteComponentButton from '../../DeleteComponentButton';
import { componentShape } from '../../../util/propShapes';
const propTypes = {
id: PropTypes.string.isRequired,

View File

@@ -18,13 +18,13 @@
*/
import sinon from 'sinon';
import Divider from 'src/dashboard/components/gridComponents/Divider';
import newComponentFactory from 'src/dashboard/util/newComponentFactory';
import {
DIVIDER_TYPE,
DASHBOARD_GRID_TYPE,
} from 'src/dashboard/util/componentTypes';
import { screen, render, userEvent } from 'spec/helpers/testing-library';
import Divider from './Divider';
describe('Divider', () => {
const props = {

View File

@@ -0,0 +1,21 @@
/**
* 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 Divider from './Divider';
export default Divider;

View File

@@ -0,0 +1,329 @@
/**
* 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 { render, screen, fireEvent } from 'spec/helpers/testing-library';
import { COLUMN_TYPE, ROW_TYPE } from 'src/dashboard/util/componentTypes';
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
import DynamicComponent from './DynamicComponent';
// Mock the dashboard components registry
const mockComponent = () => (
<div data-test="mock-dynamic-component">Test Component</div>
);
jest.mock('src/visualizations/presets/dashboardComponents', () => ({
get: jest.fn(() => ({ Component: mockComponent })),
}));
// Mock other dependencies
jest.mock('src/dashboard/components/dnd/DragDroppable', () => ({
Draggable: jest.fn(({ children, editMode }) => {
const mockElement = { tagName: 'DIV', dataset: {} };
const mockDragSourceRef = { current: mockElement };
return (
<div data-test="mock-draggable">
{children({ dragSourceRef: editMode ? mockDragSourceRef : null })}
</div>
);
}),
}));
jest.mock('src/dashboard/components/menu/WithPopoverMenu', () =>
jest.fn(({ children, menuItems, editMode }) => (
<div data-test="mock-popover-menu">
{editMode &&
menuItems &&
menuItems.map((item: React.ReactNode, index: number) => (
<div key={index} data-test="menu-item">
{item}
</div>
))}
{children}
</div>
)),
);
jest.mock('src/dashboard/components/resizable/ResizableContainer', () =>
jest.fn(({ children }) => (
<div data-test="mock-resizable-container">{children}</div>
)),
);
jest.mock('src/dashboard/components/menu/HoverMenu', () =>
jest.fn(({ children }) => <div data-test="mock-hover-menu">{children}</div>),
);
jest.mock('src/dashboard/components/DeleteComponentButton', () =>
jest.fn(({ onDelete }) => (
<button type="button" data-test="mock-delete-button" onClick={onDelete}>
Delete
</button>
)),
);
jest.mock('src/dashboard/components/menu/BackgroundStyleDropdown', () =>
jest.fn(({ onChange, value }) => (
<select
data-test="mock-background-dropdown"
value={value}
onChange={e => onChange(e.target.value)}
>
<option value="BACKGROUND_TRANSPARENT">Transparent</option>
<option value="BACKGROUND_WHITE">White</option>
</select>
)),
);
const createProps = (overrides = {}) => ({
component: {
id: 'DYNAMIC_COMPONENT_1',
meta: {
componentKey: 'test-component',
width: 6,
height: 4,
background: BACKGROUND_TRANSPARENT,
},
componentKey: 'test-component',
},
parentComponent: {
id: 'ROW_1',
type: ROW_TYPE,
meta: {
width: 12,
},
},
index: 0,
depth: 1,
handleComponentDrop: jest.fn(),
editMode: false,
columnWidth: 100,
availableColumnCount: 12,
onResizeStart: jest.fn(),
onResizeStop: jest.fn(),
onResize: jest.fn(),
deleteComponent: jest.fn(),
updateComponents: jest.fn(),
parentId: 'ROW_1',
id: 'DYNAMIC_COMPONENT_1',
...overrides,
});
const renderWithRedux = (component: React.ReactElement) =>
render(component, {
useRedux: true,
initialState: {
nativeFilters: { filters: {} },
dataMask: {},
},
});
describe('DynamicComponent', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('should render the component with basic structure', () => {
const props = createProps();
renderWithRedux(<DynamicComponent {...props} />);
expect(screen.getByTestId('mock-draggable')).toBeInTheDocument();
expect(screen.getByTestId('mock-popover-menu')).toBeInTheDocument();
expect(screen.getByTestId('mock-resizable-container')).toBeInTheDocument();
expect(
screen.getByTestId('dashboard-component-chart-holder'),
).toBeInTheDocument();
expect(screen.getByTestId('mock-dynamic-component')).toBeInTheDocument();
});
test('should render with proper CSS classes and data attributes', () => {
const props = createProps();
renderWithRedux(<DynamicComponent {...props} />);
const componentElement = screen.getByTestId('dashboard-test-component');
expect(componentElement).toHaveClass('dashboard-component');
expect(componentElement).toHaveClass('dashboard-test-component');
expect(componentElement).toHaveAttribute('id', 'DYNAMIC_COMPONENT_1');
});
test('should render HoverMenu and DeleteComponentButton in edit mode', () => {
const props = createProps({ editMode: true });
renderWithRedux(<DynamicComponent {...props} />);
expect(screen.getByTestId('mock-hover-menu')).toBeInTheDocument();
expect(screen.getByTestId('mock-delete-button')).toBeInTheDocument();
});
test('should not render HoverMenu and DeleteComponentButton when not in edit mode', () => {
const props = createProps({ editMode: false });
renderWithRedux(<DynamicComponent {...props} />);
expect(screen.queryByTestId('mock-hover-menu')).not.toBeInTheDocument();
expect(screen.queryByTestId('mock-delete-button')).not.toBeInTheDocument();
});
test('should call deleteComponent when delete button is clicked', () => {
const props = createProps({ editMode: true });
renderWithRedux(<DynamicComponent {...props} />);
fireEvent.click(screen.getByTestId('mock-delete-button'));
expect(props.deleteComponent).toHaveBeenCalledWith(
'DYNAMIC_COMPONENT_1',
'ROW_1',
);
});
test('should call updateComponents when background is changed', () => {
const props = createProps({ editMode: true });
renderWithRedux(<DynamicComponent {...props} />);
const backgroundDropdown = screen.getByTestId('mock-background-dropdown');
fireEvent.change(backgroundDropdown, {
target: { value: 'BACKGROUND_WHITE' },
});
expect(props.updateComponents).toHaveBeenCalledWith({
DYNAMIC_COMPONENT_1: {
...props.component,
meta: {
...props.component.meta,
background: 'BACKGROUND_WHITE',
},
},
});
});
test('should calculate width multiple from component meta when parent is not COLUMN_TYPE', () => {
const props = createProps({
component: {
...createProps().component,
meta: { ...createProps().component.meta, width: 8 },
},
parentComponent: {
...createProps().parentComponent,
type: ROW_TYPE,
},
});
renderWithRedux(<DynamicComponent {...props} />);
// Component should render successfully with width from component.meta.width
expect(screen.getByTestId('mock-resizable-container')).toBeInTheDocument();
});
test('should calculate width multiple from parent meta when parent is COLUMN_TYPE', () => {
const props = createProps({
parentComponent: {
id: 'COLUMN_1',
type: COLUMN_TYPE,
meta: {
width: 6,
},
},
});
renderWithRedux(<DynamicComponent {...props} />);
// Component should render successfully with width from parentComponent.meta.width
expect(screen.getByTestId('mock-resizable-container')).toBeInTheDocument();
});
test('should use default width when no width is specified', () => {
const props = createProps({
component: {
...createProps().component,
meta: {
...createProps().component.meta,
width: undefined,
},
},
parentComponent: {
...createProps().parentComponent,
type: ROW_TYPE,
meta: {},
},
});
renderWithRedux(<DynamicComponent {...props} />);
// Component should render successfully with default width (GRID_MIN_COLUMN_COUNT)
expect(screen.getByTestId('mock-resizable-container')).toBeInTheDocument();
});
test('should render background style correctly', () => {
const props = createProps({
editMode: true, // Need edit mode for menu items to render
component: {
...createProps().component,
meta: {
...createProps().component.meta,
background: 'BACKGROUND_WHITE',
},
},
});
renderWithRedux(<DynamicComponent {...props} />);
// Background dropdown should have the correct value
const backgroundDropdown = screen.getByTestId('mock-background-dropdown');
expect(backgroundDropdown).toHaveValue('BACKGROUND_WHITE');
});
test('should pass dashboard data from Redux store to dynamic component', () => {
const props = createProps();
const initialState = {
nativeFilters: { filters: { filter1: {} } },
dataMask: { mask1: {} },
};
render(<DynamicComponent {...props} />, {
useRedux: true,
initialState,
});
// Component should render - either the mock component or loading state
const container = screen.getByTestId('dashboard-component-chart-holder');
expect(container).toBeInTheDocument();
// Check that either the component loaded or is loading
expect(
screen.queryByTestId('mock-dynamic-component') ||
screen.queryByText('Loading...'),
).toBeTruthy();
});
test('should handle resize callbacks', () => {
const props = createProps();
renderWithRedux(<DynamicComponent {...props} />);
// Resize callbacks should be passed to ResizableContainer
expect(screen.getByTestId('mock-resizable-container')).toBeInTheDocument();
});
test('should render with proper data-test attribute based on componentKey', () => {
const props = createProps({
component: {
...createProps().component,
meta: {
...createProps().component.meta,
componentKey: 'custom-component',
},
componentKey: 'custom-component',
},
});
renderWithRedux(<DynamicComponent {...props} />);
expect(
screen.getByTestId('dashboard-custom-component'),
).toBeInTheDocument();
});
});

View File

@@ -22,40 +22,40 @@ import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
import cx from 'classnames';
import { shallowEqual, useSelector } from 'react-redux';
import { ResizeCallback, ResizeStartCallback } from 're-resizable';
import { Draggable } from '../dnd/DragDroppable';
import { COLUMN_TYPE, ROW_TYPE } from '../../util/componentTypes';
import WithPopoverMenu from '../menu/WithPopoverMenu';
import ResizableContainer from '../resizable/ResizableContainer';
import { Draggable } from '../../dnd/DragDroppable';
import { COLUMN_TYPE, ROW_TYPE } from '../../../util/componentTypes';
import WithPopoverMenu from '../../menu/WithPopoverMenu';
import ResizableContainer from '../../resizable/ResizableContainer';
import {
BACKGROUND_TRANSPARENT,
GRID_BASE_UNIT,
GRID_MIN_COLUMN_COUNT,
} from '../../util/constants';
import HoverMenu from '../menu/HoverMenu';
import DeleteComponentButton from '../DeleteComponentButton';
import BackgroundStyleDropdown from '../menu/BackgroundStyleDropdown';
import dashboardComponents from '../../../visualizations/presets/dashboardComponents';
import { RootState } from '../../types';
} from '../../../util/constants';
import HoverMenu from '../../menu/HoverMenu';
import DeleteComponentButton from '../../DeleteComponentButton';
import BackgroundStyleDropdown from '../../menu/BackgroundStyleDropdown';
import dashboardComponents from '../../../../visualizations/presets/dashboardComponents';
import { RootState } from '../../../types';
type FilterSummaryType = {
type DynamicComponentProps = {
component: JsonObject;
parentComponent: JsonObject;
index: number;
depth: number;
handleComponentDrop: (...args: any[]) => any;
handleComponentDrop: (dropResult: unknown) => void;
editMode: boolean;
columnWidth: number;
availableColumnCount: number;
onResizeStart: ResizeStartCallback;
onResizeStop: ResizeCallback;
onResize: ResizeCallback;
deleteComponent: Function;
updateComponents: Function;
parentId: number;
id: number;
deleteComponent: (id: string, parentId: string) => void;
updateComponents: (updates: Record<string, JsonObject>) => void;
parentId: string;
id: string;
};
const DynamicComponent: FC<FilterSummaryType> = ({
const DynamicComponent: FC<DynamicComponentProps> = ({
component,
parentComponent,
index,

View File

@@ -0,0 +1,19 @@
/**
* 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.
*/
export { default } from './DynamicComponent';

View File

@@ -22,7 +22,6 @@ import { HTML5Backend } from 'react-dnd-html5-backend';
import sinon from 'sinon';
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
import Header from 'src/dashboard/components/gridComponents/Header';
import newComponentFactory from 'src/dashboard/util/newComponentFactory';
import {
HEADER_TYPE,
@@ -30,6 +29,7 @@ import {
} from 'src/dashboard/util/componentTypes';
import { mockStoreWithTabs } from 'spec/fixtures/mockStore';
import Header from './Header';
describe('Header', () => {
const props = {

View File

@@ -0,0 +1,21 @@
/**
* 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 Header from './Header';
export default Header;

View File

@@ -18,9 +18,9 @@
*/
import { Provider } from 'react-redux';
import { act, render, screen, fireEvent } from 'spec/helpers/testing-library';
import MarkdownConnected from 'src/dashboard/components/gridComponents/Markdown';
import { mockStore } from 'spec/fixtures/mockStore';
import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout';
import MarkdownConnected from './Markdown';
describe('Markdown', () => {
const props = {

View File

@@ -0,0 +1,21 @@
/**
* 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 Markdown from './Markdown';
export default Markdown;

View File

@@ -53,7 +53,7 @@ import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
import { EMPTY_CONTAINER_Z_INDEX } from 'src/dashboard/constants';
import { isCurrentUserBot } from 'src/utils/isBot';
import { useDebouncedEffect } from '../../../explore/exploreUtils';
import { useDebouncedEffect } from '../../../../explore/exploreUtils';
const propTypes = {
id: PropTypes.string.isRequired,

View File

@@ -20,12 +20,12 @@ import { fireEvent, render } from 'spec/helpers/testing-library';
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
import IconButton from 'src/dashboard/components/IconButton';
import Row from 'src/dashboard/components/gridComponents/Row';
import { DASHBOARD_GRID_ID } from 'src/dashboard/util/constants';
import { getMockStore } from 'spec/fixtures/mockStore';
import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout';
import { initialState } from 'src/SqlLab/fixtures';
import Row from './Row';
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),

View File

@@ -0,0 +1,21 @@
/**
* 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 Row from './Row';
export default Row;

View File

@@ -1,141 +0,0 @@
/**
* 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 { render, screen, fireEvent } from 'spec/helpers/testing-library';
import { Provider } from 'react-redux';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import Tab, { RENDER_TAB } from 'src/dashboard/components/gridComponents/Tab';
import { dashboardLayoutWithTabs } from 'spec/fixtures/mockDashboardLayout';
import { getMockStore } from 'spec/fixtures/mockStore';
// TODO: rewrite to RTL
describe('Tabs', () => {
const props = {
id: 'TAB_ID',
parentId: 'TABS_ID',
component: dashboardLayoutWithTabs.present.TAB_ID,
parentComponent: dashboardLayoutWithTabs.present.TABS_ID,
index: 0,
depth: 1,
editMode: false,
renderType: RENDER_TAB,
filters: {},
dashboardId: 123,
setDirectPathToChild: jest.fn(),
onDropOnTab() {},
onDeleteTab() {},
availableColumnCount: 12,
columnWidth: 50,
onResizeStart() {},
onResize() {},
onResizeStop() {},
createComponent() {},
handleComponentDrop() {},
onChangeTab() {},
deleteComponent() {},
updateComponents() {},
dropToChild: false,
maxChildrenHeight: 100,
shouldDropToChild: () => false, // Add this prop
};
function setup(overrideProps = {}) {
return render(
<Provider
store={getMockStore({
dashboardLayout: dashboardLayoutWithTabs,
})}
>
<DndProvider backend={HTML5Backend}>
<Tab {...props} {...overrideProps} />
</DndProvider>
</Provider>,
);
}
describe('renderType=RENDER_TAB', () => {
it('should render a DragDroppable', () => {
setup();
expect(screen.getByTestId('dragdroppable-object')).toBeInTheDocument();
});
it('should render an EditableTitle with meta.text', () => {
setup();
const titleElement = screen.getByTestId('editable-title');
expect(titleElement).toBeInTheDocument();
expect(titleElement).toHaveTextContent(
props.component.meta.defaultText || '',
);
});
it('should call updateComponents when EditableTitle changes', async () => {
const updateComponents = jest.fn();
setup({
editMode: true,
updateComponents,
component: {
...dashboardLayoutWithTabs.present.TAB_ID,
meta: {
text: 'Original Title',
defaultText: 'Original Title', // Add defaultText to match component
},
},
isFocused: true,
});
const titleElement = screen.getByTestId('editable-title');
fireEvent.click(titleElement);
const titleInput = await screen.findByTestId(
'textarea-editable-title-input',
);
fireEvent.change(titleInput, { target: { value: 'New title' } });
fireEvent.blur(titleInput);
expect(updateComponents).toHaveBeenCalledWith({
TAB_ID: {
...dashboardLayoutWithTabs.present.TAB_ID,
meta: {
...dashboardLayoutWithTabs.present.TAB_ID.meta,
text: 'New title',
defaultText: 'Original Title', // Keep the original defaultText
},
},
});
});
});
describe('renderType=RENDER_TAB_CONTENT', () => {
it('should render DashboardComponents', () => {
setup({
renderType: 'RENDER_TAB_CONTENT',
component: {
...dashboardLayoutWithTabs.present.TAB_ID,
children: ['ROW_ID'],
},
});
expect(
screen.getByTestId('dashboard-component-chart-holder'),
).toBeInTheDocument();
});
});
});

View File

@@ -29,7 +29,7 @@ import { EditableTitle } from '@superset-ui/core/components';
import { setEditMode } from 'src/dashboard/actions/dashboardState';
import Tab from './Tab';
import Markdown from './Markdown';
import Markdown from '../Markdown';
jest.mock('src/dashboard/containers/DashboardComponent', () =>
jest.fn(() => <div data-test="DashboardComponent" />),

View File

@@ -0,0 +1,22 @@
/**
* 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 Tab from './Tab';
export default Tab;
export { RENDER_TAB, RENDER_TAB_CONTENT } from './Tab';

View File

@@ -1,203 +0,0 @@
/**
* 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 { fireEvent, render } from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
import Tabs from 'src/dashboard/components/gridComponents/Tabs';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import emptyDashboardLayout from 'src/dashboard/fixtures/emptyDashboardLayout';
import { dashboardLayoutWithTabs } from 'spec/fixtures/mockDashboardLayout';
import { nativeFilters } from 'spec/fixtures/mockNativeFilters';
import { initialState } from 'src/SqlLab/fixtures';
jest.mock('src/dashboard/components/dnd/DragDroppable', () => ({
Draggable: ({ children }) => (
<div data-test="mock-draggable">{children({})}</div>
),
Droppable: ({ children }) => (
<div data-test="mock-droppable">{children({})}</div>
),
}));
jest.mock('src/dashboard/containers/DashboardComponent', () => ({ id }) => (
<div data-test="mock-dashboard-component">{id}</div>
));
jest.mock(
'src/dashboard/components/DeleteComponentButton',
() =>
({ onDelete }) => (
<button
type="button"
data-test="mock-delete-component-button"
onClick={onDelete}
>
Delete
</button>
),
);
fetchMock.post('glob:*/r/shortener/', {});
const props = {
id: 'TABS_ID',
parentId: DASHBOARD_ROOT_ID,
component: dashboardLayoutWithTabs.present.TABS_ID,
parentComponent: dashboardLayoutWithTabs.present[DASHBOARD_ROOT_ID],
index: 0,
depth: 1,
renderTabContent: true,
editMode: false,
availableColumnCount: 12,
columnWidth: 50,
dashboardId: 1,
onResizeStart() {},
onResize() {},
onResizeStop() {},
createComponent() {},
handleComponentDrop() {},
onChangeTab() {},
deleteComponent() {},
updateComponents() {},
logEvent() {},
dashboardLayout: emptyDashboardLayout,
nativeFilters: nativeFilters.filters,
};
function setup(overrideProps, overrideState = {}) {
return render(<Tabs {...props} {...overrideProps} />, {
useDnd: true,
useRouter: true,
useRedux: true,
initialState: {
...initialState,
dashboardLayout: dashboardLayoutWithTabs,
dashboardFilters: {},
...overrideState,
},
});
}
test('should render a Draggable', () => {
// test just Tabs with no children Draggable
const { getByTestId } = setup({
component: { ...props.component, children: [] },
});
expect(getByTestId('mock-draggable')).toBeInTheDocument();
});
test('should render non-editable tabs', () => {
const { getAllByRole, container } = setup();
expect(getAllByRole('tab')[0]).toBeInTheDocument();
expect(container.querySelector('.ant-tabs-nav-add')).not.toBeInTheDocument();
});
test('should render a tab pane for each child', () => {
const { getAllByRole } = setup();
expect(getAllByRole('tab')).toHaveLength(props.component.children.length);
});
test('should render editable tabs in editMode', () => {
const { getAllByRole, container } = setup({ editMode: true });
expect(getAllByRole('tab')[0]).toBeInTheDocument();
expect(container.querySelector('.ant-tabs-nav-add')).toBeInTheDocument();
});
test('should render a DashboardComponent for each child', () => {
// note: this does not test Tab content
const { getAllByTestId } = setup({ renderTabContent: false });
expect(getAllByTestId('mock-dashboard-component')).toHaveLength(
props.component.children.length,
);
});
test('should call createComponent if the (+) tab is clicked', () => {
const createComponent = jest.fn();
const { getAllByRole } = setup({ editMode: true, createComponent });
const addButtons = getAllByRole('button', { name: 'Add tab' });
fireEvent.click(addButtons[0]);
expect(createComponent).toHaveBeenCalledTimes(1);
});
test('should call onChangeTab when a tab is clicked', () => {
const onChangeTab = jest.fn();
const { getByRole } = setup({ editMode: true, onChangeTab });
const newTab = getByRole('tab', { selected: false });
fireEvent.click(newTab);
expect(onChangeTab).toHaveBeenCalledTimes(1);
});
test('should not call onChangeTab when anchor link is clicked', () => {
const onChangeTab = jest.fn();
const { getByRole } = setup({ editMode: true, onChangeTab });
const currentTab = getByRole('tab', { selected: true });
fireEvent.click(currentTab);
expect(onChangeTab).toHaveBeenCalledTimes(0);
});
test('should render a HoverMenu in editMode', () => {
const { container } = setup({ editMode: true });
expect(container.querySelector('.hover-menu')).toBeInTheDocument();
});
test('should render a DeleteComponentButton in editMode', () => {
const { getByTestId } = setup({ editMode: true });
expect(getByTestId('mock-delete-component-button')).toBeInTheDocument();
});
test('should call deleteComponent when deleted', () => {
const deleteComponent = jest.fn();
const { getByTestId } = setup({ editMode: true, deleteComponent });
fireEvent.click(getByTestId('mock-delete-component-button'));
expect(deleteComponent).toHaveBeenCalledTimes(1);
});
test('should direct display direct-link tab', () => {
// display child in directPathToChild list
const directPathToChild =
dashboardLayoutWithTabs.present.ROW_ID2.parents.slice();
const { getByRole } = setup({}, { dashboardState: { directPathToChild } });
expect(getByRole('tab', { selected: true })).toHaveTextContent('TAB_ID2');
});
test('should render Modal when clicked remove tab button', () => {
const deleteComponent = jest.fn();
const { container, getByText, queryByText } = setup({
editMode: true,
deleteComponent,
});
// Initially no modal should be visible
expect(queryByText('Delete dashboard tab?')).not.toBeInTheDocument();
// Click the remove tab button
fireEvent.click(container.querySelector('.ant-tabs-tab-remove'));
// Modal should now be visible
expect(getByText('Delete dashboard tab?')).toBeInTheDocument();
expect(deleteComponent).toHaveBeenCalledTimes(0);
});
test('should set new tab key if dashboardId was changed', () => {
const { getByRole } = setup({
...props,
dashboardId: 2,
component: dashboardLayoutWithTabs.present.TAB_ID,
});
expect(getByRole('tab', { selected: true })).toHaveTextContent('ROW_ID');
});

View File

@@ -18,25 +18,22 @@
*/
import { useCallback, useEffect, useMemo, useState, memo } from 'react';
import PropTypes from 'prop-types';
import { styled, t, usePrevious, css } from '@superset-ui/core';
import { t, usePrevious, useTheme, styled } from '@superset-ui/core';
import { useSelector } from 'react-redux';
import { LineEditableTabs } from '@superset-ui/core/components/Tabs';
import { Icons } from '@superset-ui/core/components/Icons';
import { LOG_ACTIONS_SELECT_DASHBOARD_TAB } from 'src/logger/LogUtils';
import { Modal } from '@superset-ui/core/components';
import { DROP_LEFT, DROP_RIGHT } from 'src/dashboard/util/getDropPosition';
import { Draggable } from '../dnd/DragDroppable';
import DragHandle from '../dnd/DragHandle';
import DashboardComponent from '../../containers/DashboardComponent';
import DeleteComponentButton from '../DeleteComponentButton';
import HoverMenu from '../menu/HoverMenu';
import findTabIndexByComponentId from '../../util/findTabIndexByComponentId';
import getDirectPathToTabIndex from '../../util/getDirectPathToTabIndex';
import getLeafComponentIdFromPath from '../../util/getLeafComponentIdFromPath';
import { componentShape } from '../../util/propShapes';
import { NEW_TAB_ID } from '../../util/constants';
import { RENDER_TAB, RENDER_TAB_CONTENT } from './Tab';
import { TABS_TYPE, TAB_TYPE } from '../../util/componentTypes';
import { Draggable } from '../../dnd/DragDroppable';
import DashboardComponent from '../../../containers/DashboardComponent';
import findTabIndexByComponentId from '../../../util/findTabIndexByComponentId';
import getDirectPathToTabIndex from '../../../util/getDirectPathToTabIndex';
import getLeafComponentIdFromPath from '../../../util/getLeafComponentIdFromPath';
import { componentShape } from '../../../util/propShapes';
import { NEW_TAB_ID } from '../../../util/constants';
import { RENDER_TAB, RENDER_TAB_CONTENT } from '../Tab';
import { TABS_TYPE, TAB_TYPE } from '../../../util/componentTypes';
import TabsRenderer from '../TabsRenderer';
const propTypes = {
id: PropTypes.string.isRequired,
@@ -76,34 +73,6 @@ const defaultProps = {
onResizeStop() {},
};
const StyledTabsContainer = styled.div`
${({ theme }) => css`
width: 100%;
background-color: ${theme.colorBgBase};
.dashboard-component-tabs-content {
min-height: ${theme.sizeUnit * 12}px;
margin-top: ${theme.sizeUnit / 4}px;
position: relative;
}
.ant-tabs {
overflow: visible;
.ant-tabs-nav-wrap {
min-height: ${theme.sizeUnit * 12.5}px;
}
.ant-tabs-content-holder {
overflow: visible;
}
}
div .ant-tabs-tab-btn {
text-transform: none;
}
`}
`;
const DropIndicator = styled.div`
border: 2px solid ${({ theme }) => theme.colorPrimary};
width: 5px;
@@ -124,11 +93,16 @@ const CloseIconWithDropIndicator = props => (
);
const Tabs = props => {
const theme = useTheme();
const nativeFilters = useSelector(state => state.nativeFilters);
const activeTabs = useSelector(state => state.dashboardState.activeTabs);
const directPathToChild = useSelector(
state => state.dashboardState.directPathToChild,
);
const nativeFiltersBarOpen = useSelector(
state => state.dashboardState.nativeFiltersBarOpen ?? false,
);
const { tabIndex: initTabIndex, activeKey: initActiveKey } = useMemo(() => {
let tabIndex = Math.max(
@@ -378,6 +352,13 @@ const Tabs = props => {
const { children: tabIds } = tabsComponent;
const tabBarPaddingLeft =
renderTabContent === false
? nativeFiltersBarOpen
? 0
: theme.sizeUnit * 4
: 0;
const showDropIndicators = useCallback(
currentDropTabIndex =>
currentDropTabIndex === dragOverTabIndex && {
@@ -392,16 +373,21 @@ const Tabs = props => {
[draggingTabId],
);
let tabsToHighlight;
const highlightedFilterId =
nativeFilters?.focusedFilterId || nativeFilters?.hoveredFilterId;
if (highlightedFilterId) {
tabsToHighlight = nativeFilters.filters[highlightedFilterId]?.tabsInScope;
}
// Extract tab highlighting logic into a hook
const useTabHighlighting = useCallback(() => {
const highlightedFilterId =
nativeFilters?.focusedFilterId || nativeFilters?.hoveredFilterId;
return highlightedFilterId
? nativeFilters.filters[highlightedFilterId]?.tabsInScope
: undefined;
}, [nativeFilters]);
const renderChild = useCallback(
({ dragSourceRef: tabsDragSourceRef }) => {
const tabItems = tabIds.map((tabId, tabIndex) => ({
const tabsToHighlight = useTabHighlighting();
// Extract tab items creation logic into a memoized value (not a hook inside hook)
const tabItems = useMemo(
() =>
tabIds.map((tabId, tabIndex) => ({
key: tabId,
label: removeDraggedTab(tabId) ? (
<></>
@@ -456,51 +442,20 @@ const Tabs = props => {
}
/>
),
}));
return (
<StyledTabsContainer
className="dashboard-component dashboard-component-tabs"
data-test="dashboard-component-tabs"
>
{editMode && renderHoverMenu && (
<HoverMenu innerRef={tabsDragSourceRef} position="left">
<DragHandle position="left" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
<LineEditableTabs
id={tabsComponent.id}
activeKey={activeKey}
onChange={key => {
handleClickTab(tabIds.indexOf(key));
}}
onEdit={handleEdit}
data-test="nav-list"
type={editMode ? 'editable-card' : 'card'}
items={tabItems} // Pass the dynamically generated items array
/>
</StyledTabsContainer>
);
},
})),
[
editMode,
renderHoverMenu,
handleDeleteComponent,
tabsComponent.id,
activeKey,
handleEdit,
tabIds,
handleClickTab,
removeDraggedTab,
showDropIndicators,
tabsComponent.id,
depth,
availableColumnCount,
columnWidth,
handleDropOnTab,
handleGetDropPosition,
handleDragggingTab,
handleClickTab,
activeKey,
tabsToHighlight,
renderTabContent,
onResizeStart,
@@ -511,6 +466,36 @@ const Tabs = props => {
],
);
const renderChild = useCallback(
({ dragSourceRef: tabsDragSourceRef }) => (
<TabsRenderer
tabItems={tabItems}
editMode={editMode}
renderHoverMenu={renderHoverMenu}
tabsDragSourceRef={tabsDragSourceRef}
handleDeleteComponent={handleDeleteComponent}
tabsComponent={tabsComponent}
activeKey={activeKey}
tabIds={tabIds}
handleClickTab={handleClickTab}
handleEdit={handleEdit}
tabBarPaddingLeft={tabBarPaddingLeft}
/>
),
[
tabItems,
editMode,
renderHoverMenu,
handleDeleteComponent,
tabsComponent,
activeKey,
tabIds,
handleClickTab,
handleEdit,
tabBarPaddingLeft,
],
);
return (
<>
<Draggable

View File

@@ -59,9 +59,10 @@ jest.mock('src/dashboard/util/getLeafComponentIdFromPath', () => jest.fn());
jest.mock('src/dashboard/components/dnd/DragDroppable', () => ({
Draggable: jest.fn(props => {
const mockElement = { tagName: 'DIV', dataset: {} };
const childProps = props.editMode
? {
dragSourceRef: props.dragSourceRef,
dragSourceRef: { current: mockElement },
dropIndicatorProps: props.dropIndicatorProps,
}
: {};
@@ -135,6 +136,36 @@ test('Should render editMode:true', () => {
expect(DeleteComponentButton).toHaveBeenCalledTimes(1);
});
test('Should render HoverMenu in editMode', () => {
const props = createProps();
const { container } = render(<Tabs {...props} />, {
useRedux: true,
useDnd: true,
});
// HoverMenu is rendered inside TabsRenderer when editMode is true
expect(container.querySelector('.hover-menu')).toBeInTheDocument();
});
test('Should not render HoverMenu when not in editMode', () => {
const props = createProps();
props.editMode = false;
const { container } = render(<Tabs {...props} />, {
useRedux: true,
useDnd: true,
});
expect(container.querySelector('.hover-menu')).not.toBeInTheDocument();
});
test('Should not render HoverMenu when renderHoverMenu is false', () => {
const props = createProps();
props.renderHoverMenu = false;
const { container } = render(<Tabs {...props} />, {
useRedux: true,
useDnd: true,
});
expect(container.querySelector('.hover-menu')).not.toBeInTheDocument();
});
test('Should render editMode:false', () => {
const props = createProps();
props.editMode = false;

View File

@@ -0,0 +1,21 @@
/**
* 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 Tabs from './Tabs';
export default Tabs;

View File

@@ -0,0 +1,201 @@
/**
* 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 { fireEvent, render, screen } from 'spec/helpers/testing-library';
import TabsRenderer, { TabItem, TabsRendererProps } from './TabsRenderer';
const mockTabItems: TabItem[] = [
{
key: 'tab-1',
label: <div>Tab 1</div>,
closeIcon: <div>×</div>,
children: <div>Tab 1 Content</div>,
},
{
key: 'tab-2',
label: <div>Tab 2</div>,
closeIcon: <div>×</div>,
children: <div>Tab 2 Content</div>,
},
];
const mockProps: TabsRendererProps = {
tabItems: mockTabItems,
editMode: false,
renderHoverMenu: true,
tabsDragSourceRef: undefined,
handleDeleteComponent: jest.fn(),
tabsComponent: { id: 'test-tabs-id' },
activeKey: 'tab-1',
tabIds: ['tab-1', 'tab-2'],
handleClickTab: jest.fn(),
handleEdit: jest.fn(),
tabBarPaddingLeft: 16,
};
describe('TabsRenderer', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('renders tabs container with correct test attributes', () => {
render(<TabsRenderer {...mockProps} />);
const tabsContainer = screen.getByTestId('dashboard-component-tabs');
expect(tabsContainer).toBeInTheDocument();
expect(tabsContainer).toHaveClass('dashboard-component-tabs');
});
test('renders LineEditableTabs with correct props', () => {
render(<TabsRenderer {...mockProps} />);
const editableTabs = screen.getByTestId('nav-list');
expect(editableTabs).toBeInTheDocument();
});
test('applies correct tab bar padding', () => {
const { rerender } = render(<TabsRenderer {...mockProps} />);
let editableTabs = screen.getByTestId('nav-list');
expect(editableTabs).toBeInTheDocument();
rerender(<TabsRenderer {...mockProps} tabBarPaddingLeft={0} />);
editableTabs = screen.getByTestId('nav-list');
expect(editableTabs).toBeInTheDocument();
});
test('calls handleClickTab when tab is clicked', () => {
const handleClickTabMock = jest.fn();
const propsWithTab2Active = {
...mockProps,
activeKey: 'tab-2',
handleClickTab: handleClickTabMock,
};
render(<TabsRenderer {...propsWithTab2Active} />);
const tabElement = screen.getByText('Tab 1').closest('[role="tab"]');
expect(tabElement).not.toBeNull();
fireEvent.click(tabElement!);
expect(handleClickTabMock).toHaveBeenCalledWith(0);
expect(handleClickTabMock).toHaveBeenCalledTimes(1);
});
test('shows hover menu in edit mode', () => {
const mockRef = { current: null };
const editModeProps: TabsRendererProps = {
...mockProps,
editMode: true,
renderHoverMenu: true,
tabsDragSourceRef: mockRef,
};
render(<TabsRenderer {...editModeProps} />);
const hoverMenu = document.querySelector('.hover-menu');
expect(hoverMenu).toBeInTheDocument();
});
test('hides hover menu when not in edit mode', () => {
const viewModeProps: TabsRendererProps = {
...mockProps,
editMode: false,
renderHoverMenu: true,
};
render(<TabsRenderer {...viewModeProps} />);
const hoverMenu = document.querySelector('.hover-menu');
expect(hoverMenu).not.toBeInTheDocument();
});
test('hides hover menu when renderHoverMenu is false', () => {
const mockRef = { current: null };
const noHoverMenuProps: TabsRendererProps = {
...mockProps,
editMode: true,
renderHoverMenu: false,
tabsDragSourceRef: mockRef,
};
render(<TabsRenderer {...noHoverMenuProps} />);
const hoverMenu = document.querySelector('.hover-menu');
expect(hoverMenu).not.toBeInTheDocument();
});
test('renders with correct tab type based on edit mode', () => {
const { rerender } = render(
<TabsRenderer {...mockProps} editMode={false} />,
);
let editableTabs = screen.getByTestId('nav-list');
expect(editableTabs).toBeInTheDocument();
rerender(<TabsRenderer {...mockProps} editMode />);
editableTabs = screen.getByTestId('nav-list');
expect(editableTabs).toBeInTheDocument();
});
test('handles default props correctly', () => {
const minimalProps: TabsRendererProps = {
tabItems: mockProps.tabItems,
editMode: false,
handleDeleteComponent: mockProps.handleDeleteComponent,
tabsComponent: mockProps.tabsComponent,
activeKey: mockProps.activeKey,
tabIds: mockProps.tabIds,
handleClickTab: mockProps.handleClickTab,
handleEdit: mockProps.handleEdit,
};
render(<TabsRenderer {...minimalProps} />);
const tabsContainer = screen.getByTestId('dashboard-component-tabs');
expect(tabsContainer).toBeInTheDocument();
});
test('calls onEdit when edit action is triggered', () => {
const handleEditMock = jest.fn();
const editableProps = {
...mockProps,
editMode: true,
handleEdit: handleEditMock,
};
render(<TabsRenderer {...editableProps} />);
expect(screen.getByTestId('nav-list')).toBeInTheDocument();
});
test('renders tab content correctly', () => {
render(<TabsRenderer {...mockProps} />);
expect(screen.getByText('Tab 1 Content')).toBeInTheDocument();
expect(screen.queryByText('Tab 2 Content')).not.toBeInTheDocument(); // Not active
});
});

View File

@@ -0,0 +1,121 @@
/**
* 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 { memo, ReactElement, RefObject } from 'react';
import { styled } from '@superset-ui/core';
import {
LineEditableTabs,
TabsProps as AntdTabsProps,
} from '@superset-ui/core/components/Tabs';
import HoverMenu from '../../menu/HoverMenu';
import DragHandle from '../../dnd/DragHandle';
import DeleteComponentButton from '../../DeleteComponentButton';
const StyledTabsContainer = styled.div`
width: 100%;
background-color: ${({ theme }) => theme.colorBgContainer};
& .dashboard-component-tabs-content {
height: 100%;
}
& > .hover-menu:hover {
opacity: 1;
}
&.dragdroppable-row .dashboard-component-tabs-content {
height: calc(100% - 47px);
}
`;
export interface TabItem {
key: string;
label: ReactElement;
closeIcon: ReactElement;
children: ReactElement;
}
export interface TabsComponent {
id: string;
}
export interface TabsRendererProps {
tabItems: TabItem[];
editMode: boolean;
renderHoverMenu?: boolean;
tabsDragSourceRef?: RefObject<HTMLDivElement>;
handleDeleteComponent: () => void;
tabsComponent: TabsComponent;
activeKey: string;
tabIds: string[];
handleClickTab: (index: number) => void;
handleEdit: AntdTabsProps['onEdit'];
tabBarPaddingLeft?: number;
}
/**
* TabsRenderer component handles the rendering of dashboard tabs
* Extracted from the main Tabs component for better separation of concerns
*/
const TabsRenderer = memo<TabsRendererProps>(
({
tabItems,
editMode,
renderHoverMenu = true,
tabsDragSourceRef,
handleDeleteComponent,
tabsComponent,
activeKey,
tabIds,
handleClickTab,
handleEdit,
tabBarPaddingLeft = 0,
}) => (
<StyledTabsContainer
className="dashboard-component dashboard-component-tabs"
data-test="dashboard-component-tabs"
>
{editMode && renderHoverMenu && tabsDragSourceRef && (
<HoverMenu innerRef={tabsDragSourceRef} position="left">
<DragHandle position="left" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
<LineEditableTabs
id={tabsComponent.id}
activeKey={activeKey}
onChange={key => {
if (typeof key === 'string') {
const tabIndex = tabIds.indexOf(key);
if (tabIndex !== -1) handleClickTab(tabIndex);
}
}}
onEdit={handleEdit}
data-test="nav-list"
type={editMode ? 'editable-card' : 'card'}
items={tabItems}
tabBarStyle={{ paddingLeft: tabBarPaddingLeft }}
/>
</StyledTabsContainer>
),
);
TabsRenderer.displayName = 'TabsRenderer';
export default TabsRenderer;

View File

@@ -0,0 +1,20 @@
/**
* 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.
*/
export { default } from './TabsRenderer';
export type { TabsRendererProps, TabItem, TabsComponent } from './TabsRenderer';

View File

@@ -38,16 +38,6 @@ import Tab from './Tab';
import Tabs from './Tabs';
import DynamicComponent from './DynamicComponent';
export { default as ChartHolder } from './ChartHolder';
export { default as Markdown } from './Markdown';
export { default as Column } from './Column';
export { default as Divider } from './Divider';
export { default as Header } from './Header';
export { default as Row } from './Row';
export { default as Tab } from './Tab';
export { default as Tabs } from './Tabs';
export { default as DynamicComponent } from './DynamicComponent';
export const componentLookup = {
[CHART_TYPE]: ChartHolder,
[MARKDOWN_TYPE]: Markdown,

View File

@@ -49,6 +49,7 @@ import {
SET_DASHBOARD_LABELS_COLORMAP_SYNCED,
SET_DASHBOARD_SHARED_LABELS_COLORS_SYNCABLE,
SET_DASHBOARD_SHARED_LABELS_COLORS_SYNCED,
TOGGLE_NATIVE_FILTERS_BAR,
} from '../actions/dashboardState';
import { HYDRATE_DASHBOARD } from '../actions/hydrate';
@@ -267,6 +268,12 @@ export default function dashboardStateReducer(state = {}, action) {
datasetsStatus: action.status,
};
},
[TOGGLE_NATIVE_FILTERS_BAR]() {
return {
...state,
nativeFiltersBarOpen: action.isOpen,
};
},
};
if (action.type in actionHandlers) {

View File

@@ -27,6 +27,7 @@ import {
SET_UNSAVED_CHANGES,
TOGGLE_EXPAND_SLICE,
TOGGLE_FAVE_STAR,
TOGGLE_NATIVE_FILTERS_BAR,
UNSET_FOCUSED_FILTER_FIELD,
} from 'src/dashboard/actions/dashboardState';
@@ -197,4 +198,20 @@ describe('dashboardState reducer', () => {
column: 'column_2',
});
});
it('should toggle native filters bar', () => {
expect(
dashboardStateReducer(
{ nativeFiltersBarOpen: false },
{ type: TOGGLE_NATIVE_FILTERS_BAR, isOpen: true },
),
).toEqual({ nativeFiltersBarOpen: true });
expect(
dashboardStateReducer(
{ nativeFiltersBarOpen: true },
{ type: TOGGLE_NATIVE_FILTERS_BAR, isOpen: false },
),
).toEqual({ nativeFiltersBarOpen: false });
});
});

View File

@@ -20,10 +20,39 @@ import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import dashboardStateReducer from './dashboardState';
import { setActiveTab, setActiveTabs } from '../actions/dashboardState';
import { DashboardState } from '../types';
// Type the reducer function properly since it's imported from JS
type DashboardStateReducer = (
state: Partial<DashboardState> | undefined,
action: any,
) => Partial<DashboardState>;
const typedDashboardStateReducer =
dashboardStateReducer as DashboardStateReducer;
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
// Helper function to create mock dashboard state with proper types
const createMockDashboardState = (
overrides: Partial<DashboardState> = {},
): DashboardState => ({
editMode: false,
isPublished: false,
directPathToChild: [],
activeTabs: [],
fullSizeChartId: null,
isRefreshing: false,
isFiltersRefreshing: false,
hasUnsavedChanges: false,
dashboardIsSaving: false,
colorScheme: '',
sliceIds: [],
directPathLastUpdated: 0,
nativeFiltersBarOpen: false,
...overrides,
});
describe('DashboardState reducer', () => {
describe('SET_ACTIVE_TAB', () => {
it('switches a single tab', () => {
@@ -34,16 +63,28 @@ describe('DashboardState reducer', () => {
const request = setActiveTab('tab1');
const thunkAction = request(store.dispatch, store.getState);
expect(dashboardStateReducer({ activeTabs: [] }, thunkAction)).toEqual({
activeTabs: ['tab1'],
inactiveTabs: [],
});
expect(
typedDashboardStateReducer(
createMockDashboardState({ activeTabs: [] }),
thunkAction,
),
).toEqual(
expect.objectContaining({
activeTabs: ['tab1'],
inactiveTabs: [],
}),
);
const request2 = setActiveTab('tab2', 'tab1');
const thunkAction2 = request2(store.dispatch, store.getState);
expect(
dashboardStateReducer({ activeTabs: ['tab1'] }, thunkAction2),
).toEqual({ activeTabs: ['tab2'], inactiveTabs: [] });
typedDashboardStateReducer(
createMockDashboardState({ activeTabs: ['tab1'] }),
thunkAction2,
),
).toEqual(
expect.objectContaining({ activeTabs: ['tab2'], inactiveTabs: [] }),
);
});
it('switches a multi-depth tab', () => {
@@ -63,75 +104,90 @@ describe('DashboardState reducer', () => {
});
let request = setActiveTab('TAB-B', 'TAB-A');
let thunkAction = request(store.dispatch, store.getState);
let result = dashboardStateReducer(
{ activeTabs: ['TAB-1', 'TAB-A', 'TAB-__a'] },
let result = typedDashboardStateReducer(
createMockDashboardState({ activeTabs: ['TAB-1', 'TAB-A', 'TAB-__a'] }),
thunkAction,
);
expect(result).toEqual({
activeTabs: expect.arrayContaining(['TAB-1', 'TAB-B']),
inactiveTabs: ['TAB-__a'],
});
expect(result).toEqual(
expect.objectContaining({
activeTabs: expect.arrayContaining(['TAB-1', 'TAB-B']),
inactiveTabs: ['TAB-__a'],
}),
);
request = setActiveTab('TAB-2', 'TAB-1');
thunkAction = request(store.dispatch, () => ({
...(store.getState() ?? {}),
dashboardState: result,
}));
result = dashboardStateReducer(result, thunkAction);
expect(result).toEqual({
activeTabs: ['TAB-2'],
inactiveTabs: expect.arrayContaining(['TAB-B', 'TAB-__a']),
});
result = typedDashboardStateReducer(result, thunkAction);
expect(result).toEqual(
expect.objectContaining({
activeTabs: ['TAB-2'],
inactiveTabs: expect.arrayContaining(['TAB-B', 'TAB-__a']),
}),
);
request = setActiveTab('TAB-1', 'TAB-2');
thunkAction = request(store.dispatch, () => ({
...(store.getState() ?? {}),
dashboardState: result,
}));
result = dashboardStateReducer(result, thunkAction);
expect(result).toEqual({
activeTabs: expect.arrayContaining(['TAB-1', 'TAB-B']),
inactiveTabs: ['TAB-__a'],
});
result = typedDashboardStateReducer(result, thunkAction);
expect(result).toEqual(
expect.objectContaining({
activeTabs: expect.arrayContaining(['TAB-1', 'TAB-B']),
inactiveTabs: ['TAB-__a'],
}),
);
request = setActiveTab('TAB-A', 'TAB-B');
thunkAction = request(store.dispatch, () => ({
...(store.getState() ?? {}),
dashboardState: result,
}));
result = dashboardStateReducer(result, thunkAction);
expect(result).toEqual({
activeTabs: expect.arrayContaining(['TAB-1', 'TAB-A', 'TAB-__a']),
inactiveTabs: [],
});
result = typedDashboardStateReducer(result, thunkAction);
expect(result).toEqual(
expect.objectContaining({
activeTabs: expect.arrayContaining(['TAB-1', 'TAB-A', 'TAB-__a']),
inactiveTabs: [],
}),
);
request = setActiveTab('TAB-2', 'TAB-1');
thunkAction = request(store.dispatch, () => ({
...(store.getState() ?? {}),
dashboardState: result,
}));
result = dashboardStateReducer(result, thunkAction);
expect(result).toEqual({
activeTabs: expect.arrayContaining(['TAB-2']),
inactiveTabs: ['TAB-A', 'TAB-__a'],
});
result = typedDashboardStateReducer(result, thunkAction);
expect(result).toEqual(
expect.objectContaining({
activeTabs: expect.arrayContaining(['TAB-2']),
inactiveTabs: ['TAB-A', 'TAB-__a'],
}),
);
request = setActiveTab('TAB-1', 'TAB-2');
thunkAction = request(store.dispatch, () => ({
...(store.getState() ?? {}),
dashboardState: result,
}));
result = dashboardStateReducer(result, thunkAction);
expect(result).toEqual({
activeTabs: expect.arrayContaining(['TAB-1', 'TAB-A', 'TAB-__a']),
inactiveTabs: [],
});
result = typedDashboardStateReducer(result, thunkAction);
expect(result).toEqual(
expect.objectContaining({
activeTabs: expect.arrayContaining(['TAB-1', 'TAB-A', 'TAB-__a']),
inactiveTabs: [],
}),
);
});
});
it('SET_ACTIVE_TABS', () => {
expect(
dashboardStateReducer({ activeTabs: [] }, setActiveTabs(['tab1'])),
).toEqual({ activeTabs: ['tab1'] });
typedDashboardStateReducer(
createMockDashboardState({ activeTabs: [] }),
setActiveTabs(['tab1']),
),
).toEqual(expect.objectContaining({ activeTabs: ['tab1'] }));
expect(
dashboardStateReducer(
{ activeTabs: ['tab1', 'tab2'] },
typedDashboardStateReducer(
createMockDashboardState({ activeTabs: ['tab1', 'tab2'] }),
setActiveTabs(['tab3', 'tab4']),
),
).toEqual({ activeTabs: ['tab3', 'tab4'] });
).toEqual(expect.objectContaining({ activeTabs: ['tab3', 'tab4'] }));
});
});

View File

@@ -107,6 +107,7 @@ export type DashboardState = {
colorScheme: string;
sliceIds: number[];
directPathLastUpdated: number;
nativeFiltersBarOpen?: boolean;
css?: string;
focusedFilterField?: {
chartId: number;