mirror of
https://github.com/apache/superset.git
synced 2026-04-18 23:55:00 +00:00
fix(dashboard): normalize spacings and background colors (#35001)
This commit is contained in:
committed by
GitHub
parent
385471c34d
commit
0fce5ecfa5
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'};
|
||||
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
@@ -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 }) => (
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
@@ -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 = {
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
@@ -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';
|
||||
@@ -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 = {
|
||||
@@ -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;
|
||||
@@ -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 = {
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
@@ -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'),
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" />),
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'] }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@ export type DashboardState = {
|
||||
colorScheme: string;
|
||||
sliceIds: number[];
|
||||
directPathLastUpdated: number;
|
||||
nativeFiltersBarOpen?: boolean;
|
||||
css?: string;
|
||||
focusedFilterField?: {
|
||||
chartId: number;
|
||||
|
||||
Reference in New Issue
Block a user