diff --git a/superset-frontend/packages/superset-ui-core/src/components/Tabs/Tabs.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/Tabs/Tabs.test.tsx new file mode 100644 index 000000000000..e01556f33696 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/Tabs/Tabs.test.tsx @@ -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:
Tab 1 content
, + }, + { + key: '2', + label: 'Tab 2', + children:
Tab 2 content
, + }, + { + key: '3', + label: 'Tab 3', + children:
Tab 3 content
, + }, + ]; + + describe('Basic Tabs', () => { + it('should render tabs with default props', () => { + const { getByText, container } = render(); + + 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(); + 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(); + 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( + , + ); + 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( + , + ); + const { container: disallowContainer } = render( + , + ); + + expect(allowContainer.querySelector('.ant-tabs')).toBeDefined(); + expect(disallowContainer.querySelector('.ant-tabs')).toBeDefined(); + }); + + it('should disable animation by default', () => { + const { container } = render(); + 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( + , + ); + + 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( + , + ); + + fireEvent.click(getByText('Tab 2')); + + expect(onTabClickMock).toHaveBeenCalled(); + }); + }); + + describe('EditableTabs', () => { + it('should render with editable features', () => { + const { container } = render(); + + 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( + , + ); + + 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(); + + 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(); + + 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( + + +
Legacy content 1
+
+ +
Legacy content 2
+
+
, + ); + + 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(); + const tabsElement = container.querySelector('.ant-tabs'); + + expect(tabsElement).toBeDefined(); + }); + + it('should handle undefined items', () => { + const { container } = render(); + 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(); + + expect(getByText('Tab 1')).toBeInTheDocument(); + expect(getByText('Tab 2')).toBeInTheDocument(); + }); + + it('should handle allowOverflow default value', () => { + const { container } = render(); + expect(container.querySelector('.ant-tabs')).toBeDefined(); + }); + }); + + describe('Accessibility', () => { + it('should render with proper ARIA roles', () => { + const { container } = render(); + + 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(); + + 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( + , + ); + + 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( + , + ); + + const tabsElement = container.querySelector('.ant-tabs') as HTMLElement; + + expect(tabsElement?.style?.minHeight).toBe('200px'); + }); + }); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/components/Tabs/Tabs.tsx b/superset-frontend/packages/superset-ui-core/src/components/Tabs/Tabs.tsx index 5ea5c0e50919..d834cd05f22d 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Tabs/Tabs.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Tabs/Tabs.tsx @@ -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 ( css` overflow: ${allowOverflow ? 'visible' : 'hidden'}; diff --git a/superset-frontend/src/dashboard/actions/dashboardState.js b/superset-frontend/src/dashboard/actions/dashboardState.js index b3898e32e7e7..d3305f2e6dad 100644 --- a/superset-frontend/src/dashboard/actions/dashboardState.js +++ b/superset-frontend/src/dashboard/actions/dashboardState.js @@ -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 } }; diff --git a/superset-frontend/src/dashboard/components/BuilderComponentPane/BuilderComponentPane.test.tsx b/superset-frontend/src/dashboard/components/BuilderComponentPane/BuilderComponentPane.test.tsx index 24a6a78d3ff5..fbfebe9a60ad 100644 --- a/superset-frontend/src/dashboard/components/BuilderComponentPane/BuilderComponentPane.test.tsx +++ b/superset-frontend/src/dashboard/components/BuilderComponentPane/BuilderComponentPane.test.tsx @@ -25,7 +25,14 @@ jest.mock('src/dashboard/containers/SliceAdder', () => () => ( )); test('BuilderComponentPane has correct tabs in correct order', () => { - render(); + render(, { + useRedux: true, + initialState: { + dashboardState: { + nativeFiltersBarOpen: false, + }, + }, + }); const tabs = screen.getAllByRole('tab'); expect(tabs).toHaveLength(2); expect(tabs[0]).toHaveTextContent('Charts'); diff --git a/superset-frontend/src/dashboard/components/BuilderComponentPane/index.tsx b/superset-frontend/src/dashboard/components/BuilderComponentPane/index.tsx index e8ff251d81b3..068fe3a9b889 100644 --- a/superset-frontend/src/dashboard/components/BuilderComponentPane/index.tsx +++ b/superset-frontend/src/dashboard/components/BuilderComponentPane/index.tsx @@ -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 }) => ( -
+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 (
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}; `} > - css` - line-height: inherit; - margin-top: ${theme.sizeUnit * 2}px; + position: absolute; height: 100%; - - & .ant-tabs-content-holder { + width: ${BUILDER_PANE_WIDTH}px; + box-shadow: -4px 0 4px 0 ${rgba(theme.colorBorder, 0.1)}; + background-color: ${theme.colorBgBase}; + `} + > + css` + line-height: inherit; + margin-top: ${theme.sizeUnit * 2}px; height: 100%; - & .ant-tabs-content { + + & .ant-tabs-content-holder { height: 100%; + & .ant-tabs-content { + height: 100%; + } } - } - `} - items={[ - { - key: TABS_KEYS.CHARTS, - label: t('Charts'), - children: ( -
- -
- ), - }, - { - key: TABS_KEYS.LAYOUT_ELEMENTS, - label: t('Layout elements'), - children: ( - <> - - - - - - - {dashboardComponents - .getAll() - .map(({ key: componentKey, metadata }) => ( - - ))} - - ), - }, - ]} - /> + `} + items={[ + { + key: TABS_KEYS.CHARTS, + label: t('Charts'), + children: ( +
+ +
+ ), + }, + { + key: TABS_KEYS.LAYOUT_ELEMENTS, + label: t('Layout elements'), + children: ( + <> + + + + + + + {dashboardComponents + .getAll() + .map(({ key: componentKey, metadata }) => ( + + ))} + + ), + }, + ]} + /> +
- -); + ); +}; export default BuilderComponentPane; diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx index 19215399dd1c..880d5c7fbe80 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx @@ -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 => { diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx index 784d6020cd14..253c17dc4224 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardContainer.tsx @@ -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( @@ -297,6 +296,7 @@ const DashboardContainer: FC = ({ topLevelTabs }) => { allowOverflow onFocus={handleFocus} items={tabItems} + tabBarStyle={{ paddingLeft: 0 }} /> ); }, diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardWrapper.tsx b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardWrapper.tsx index 4d5190e79dfc..f73fe36e9aeb 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardWrapper.tsx +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/DashboardWrapper.tsx @@ -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 = ({ children }) => { +const DashboardWrapper: FC> = ({ children }) => { const editMode = useSelector( state => state.dashboardState.editMode, ); diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts b/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts index a338e21a9427..c7b7a56fdf42 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts @@ -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( () => getUrlParam(URL_PARAMS.showFilters) ?? true, ); const canEdit = useSelector( ({ dashboardInfo }) => dashboardInfo.dash_edit_perm, ); + const dashboardFiltersOpen = useSelector( + 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) { diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx similarity index 96% rename from superset-frontend/src/dashboard/components/gridComponents/Chart.jsx rename to superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx index ec12d98d7bea..d81a6ed0f253 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.jsx @@ -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, diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart.test.jsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.jsx similarity index 99% rename from superset-frontend/src/dashboard/components/gridComponents/Chart.test.jsx rename to superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.jsx index c5f743a52c51..a00b9e88c086 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart.test.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.test.jsx @@ -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, diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/index.js b/superset-frontend/src/dashboard/components/gridComponents/Chart/index.js new file mode 100644 index 000000000000..7cda5527b4aa --- /dev/null +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/index.js @@ -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; diff --git a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder/ChartHolder.test.tsx similarity index 98% rename from superset-frontend/src/dashboard/components/gridComponents/ChartHolder.test.tsx rename to superset-frontend/src/dashboard/components/gridComponents/ChartHolder/ChartHolder.test.tsx index 072a087c8f93..aac33a802cb9 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder/ChartHolder.test.tsx @@ -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; diff --git a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder/ChartHolder.tsx similarity index 100% rename from superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx rename to superset-frontend/src/dashboard/components/gridComponents/ChartHolder/ChartHolder.tsx diff --git a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder/index.ts b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder/index.ts new file mode 100644 index 000000000000..f5a0b92ff193 --- /dev/null +++ b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder/index.ts @@ -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'; diff --git a/superset-frontend/src/dashboard/components/gridComponents/Column.jsx b/superset-frontend/src/dashboard/components/gridComponents/Column/Column.jsx similarity index 100% rename from superset-frontend/src/dashboard/components/gridComponents/Column.jsx rename to superset-frontend/src/dashboard/components/gridComponents/Column/Column.jsx diff --git a/superset-frontend/src/dashboard/components/gridComponents/Column.test.jsx b/superset-frontend/src/dashboard/components/gridComponents/Column/Column.test.jsx similarity index 99% rename from superset-frontend/src/dashboard/components/gridComponents/Column.test.jsx rename to superset-frontend/src/dashboard/components/gridComponents/Column/Column.test.jsx index b70f865dd303..e67be2c20d5d 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Column.test.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Column/Column.test.jsx @@ -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 }) => ( diff --git a/superset-frontend/src/dashboard/components/gridComponents/Column/index.js b/superset-frontend/src/dashboard/components/gridComponents/Column/index.js new file mode 100644 index 000000000000..9b22df6f6ebe --- /dev/null +++ b/superset-frontend/src/dashboard/components/gridComponents/Column/index.js @@ -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; diff --git a/superset-frontend/src/dashboard/components/gridComponents/Divider.jsx b/superset-frontend/src/dashboard/components/gridComponents/Divider/Divider.jsx similarity index 93% rename from superset-frontend/src/dashboard/components/gridComponents/Divider.jsx rename to superset-frontend/src/dashboard/components/gridComponents/Divider/Divider.jsx index 466b77d0b78e..3247cf794d69 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Divider.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Divider/Divider.jsx @@ -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, diff --git a/superset-frontend/src/dashboard/components/gridComponents/Divider.test.jsx b/superset-frontend/src/dashboard/components/gridComponents/Divider/Divider.test.jsx similarity index 97% rename from superset-frontend/src/dashboard/components/gridComponents/Divider.test.jsx rename to superset-frontend/src/dashboard/components/gridComponents/Divider/Divider.test.jsx index 85a30d115d17..1cf54aab0d7b 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Divider.test.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Divider/Divider.test.jsx @@ -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 = { diff --git a/superset-frontend/src/dashboard/components/gridComponents/Divider/index.js b/superset-frontend/src/dashboard/components/gridComponents/Divider/index.js new file mode 100644 index 000000000000..8a7a0c9781ac --- /dev/null +++ b/superset-frontend/src/dashboard/components/gridComponents/Divider/index.js @@ -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; diff --git a/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent/DynamicComponent.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent/DynamicComponent.test.tsx new file mode 100644 index 000000000000..d3d2ade7ca91 --- /dev/null +++ b/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent/DynamicComponent.test.tsx @@ -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 = () => ( +
Test Component
+); +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 ( +
+ {children({ dragSourceRef: editMode ? mockDragSourceRef : null })} +
+ ); + }), +})); + +jest.mock('src/dashboard/components/menu/WithPopoverMenu', () => + jest.fn(({ children, menuItems, editMode }) => ( +
+ {editMode && + menuItems && + menuItems.map((item: React.ReactNode, index: number) => ( +
+ {item} +
+ ))} + {children} +
+ )), +); + +jest.mock('src/dashboard/components/resizable/ResizableContainer', () => + jest.fn(({ children }) => ( +
{children}
+ )), +); + +jest.mock('src/dashboard/components/menu/HoverMenu', () => + jest.fn(({ children }) =>
{children}
), +); + +jest.mock('src/dashboard/components/DeleteComponentButton', () => + jest.fn(({ onDelete }) => ( + + )), +); + +jest.mock('src/dashboard/components/menu/BackgroundStyleDropdown', () => + jest.fn(({ onChange, value }) => ( + + )), +); + +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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(, { + 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(); + + // 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(); + + expect( + screen.getByTestId('dashboard-custom-component'), + ).toBeInTheDocument(); + }); +}); diff --git a/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent.tsx b/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent/DynamicComponent.tsx similarity index 84% rename from superset-frontend/src/dashboard/components/gridComponents/DynamicComponent.tsx rename to superset-frontend/src/dashboard/components/gridComponents/DynamicComponent/DynamicComponent.tsx index 66db0fd898d0..eb06833fba0d 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent/DynamicComponent.tsx @@ -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) => void; + parentId: string; + id: string; }; -const DynamicComponent: FC = ({ +const DynamicComponent: FC = ({ component, parentComponent, index, diff --git a/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent/index.ts b/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent/index.ts new file mode 100644 index 000000000000..482eedb7f059 --- /dev/null +++ b/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent/index.ts @@ -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'; diff --git a/superset-frontend/src/dashboard/components/gridComponents/Header.jsx b/superset-frontend/src/dashboard/components/gridComponents/Header/Header.jsx similarity index 100% rename from superset-frontend/src/dashboard/components/gridComponents/Header.jsx rename to superset-frontend/src/dashboard/components/gridComponents/Header/Header.jsx diff --git a/superset-frontend/src/dashboard/components/gridComponents/Header.test.jsx b/superset-frontend/src/dashboard/components/gridComponents/Header/Header.test.jsx similarity index 98% rename from superset-frontend/src/dashboard/components/gridComponents/Header.test.jsx rename to superset-frontend/src/dashboard/components/gridComponents/Header/Header.test.jsx index 48c969bb9b53..1f204406a27c 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Header.test.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Header/Header.test.jsx @@ -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 = { diff --git a/superset-frontend/src/dashboard/components/gridComponents/Header/index.js b/superset-frontend/src/dashboard/components/gridComponents/Header/index.js new file mode 100644 index 000000000000..87090f257488 --- /dev/null +++ b/superset-frontend/src/dashboard/components/gridComponents/Header/index.js @@ -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; diff --git a/superset-frontend/src/dashboard/components/gridComponents/Markdown.jsx b/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.jsx similarity index 100% rename from superset-frontend/src/dashboard/components/gridComponents/Markdown.jsx rename to superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.jsx diff --git a/superset-frontend/src/dashboard/components/gridComponents/Markdown.test.jsx b/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.test.jsx similarity index 99% rename from superset-frontend/src/dashboard/components/gridComponents/Markdown.test.jsx rename to superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.test.jsx index 0d968b1b7a7f..c10d10b7762c 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Markdown.test.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.test.jsx @@ -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 = { diff --git a/superset-frontend/src/dashboard/components/gridComponents/Markdown/index.js b/superset-frontend/src/dashboard/components/gridComponents/Markdown/index.js new file mode 100644 index 000000000000..af0149d0681e --- /dev/null +++ b/superset-frontend/src/dashboard/components/gridComponents/Markdown/index.js @@ -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; diff --git a/superset-frontend/src/dashboard/components/gridComponents/Row.jsx b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.jsx similarity index 99% rename from superset-frontend/src/dashboard/components/gridComponents/Row.jsx rename to superset-frontend/src/dashboard/components/gridComponents/Row/Row.jsx index 61cd5ff69e51..917cdfc9654a 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Row.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.jsx @@ -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, diff --git a/superset-frontend/src/dashboard/components/gridComponents/Row.test.jsx b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.test.jsx similarity index 99% rename from superset-frontend/src/dashboard/components/gridComponents/Row.test.jsx rename to superset-frontend/src/dashboard/components/gridComponents/Row/Row.test.jsx index e15d116f06df..2f8499a6fe6e 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Row.test.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.test.jsx @@ -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'), diff --git a/superset-frontend/src/dashboard/components/gridComponents/Row/index.js b/superset-frontend/src/dashboard/components/gridComponents/Row/index.js new file mode 100644 index 000000000000..2b78be10dc73 --- /dev/null +++ b/superset-frontend/src/dashboard/components/gridComponents/Row/index.js @@ -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; diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab.test.jsx b/superset-frontend/src/dashboard/components/gridComponents/Tab.test.jsx deleted file mode 100644 index 74c5f4813aee..000000000000 --- a/superset-frontend/src/dashboard/components/gridComponents/Tab.test.jsx +++ /dev/null @@ -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( - - - - - , - ); - } - - 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(); - }); - }); -}); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab.jsx b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.jsx similarity index 100% rename from superset-frontend/src/dashboard/components/gridComponents/Tab.jsx rename to superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.jsx diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx similarity index 99% rename from superset-frontend/src/dashboard/components/gridComponents/Tab.test.tsx rename to superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx index 05ceb7e0cc42..3ff5f2879783 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tab.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx @@ -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(() =>
), diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab/index.js b/superset-frontend/src/dashboard/components/gridComponents/Tab/index.js new file mode 100644 index 000000000000..f1c4c41d48d6 --- /dev/null +++ b/superset-frontend/src/dashboard/components/gridComponents/Tab/index.js @@ -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'; diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tabs.test.jsx b/superset-frontend/src/dashboard/components/gridComponents/Tabs.test.jsx deleted file mode 100644 index d3c606fc2b8b..000000000000 --- a/superset-frontend/src/dashboard/components/gridComponents/Tabs.test.jsx +++ /dev/null @@ -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 }) => ( -
{children({})}
- ), - Droppable: ({ children }) => ( -
{children({})}
- ), -})); -jest.mock('src/dashboard/containers/DashboardComponent', () => ({ id }) => ( -
{id}
-)); - -jest.mock( - 'src/dashboard/components/DeleteComponentButton', - () => - ({ onDelete }) => ( - - ), -); - -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(, { - 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'); -}); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx b/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.jsx similarity index 84% rename from superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx rename to superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.jsx index 68abbfcee68b..c59b38609c7d 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.jsx @@ -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; - } - - const renderChild = useCallback( - ({ dragSourceRef: tabsDragSourceRef }) => { - const tabItems = tabIds.map((tabId, tabIndex) => ({ + // 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 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 ( - - {editMode && renderHoverMenu && ( - - - - - )} - - { - handleClickTab(tabIds.indexOf(key)); - }} - onEdit={handleEdit} - data-test="nav-list" - type={editMode ? 'editable-card' : 'card'} - items={tabItems} // Pass the dynamically generated items array - /> - - ); - }, + })), [ - 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 }) => ( + + ), + [ + tabItems, + editMode, + renderHoverMenu, + handleDeleteComponent, + tabsComponent, + activeKey, + tabIds, + handleClickTab, + handleEdit, + tabBarPaddingLeft, + ], + ); + return ( <> 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(, { + 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(, { + 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(, { + useRedux: true, + useDnd: true, + }); + expect(container.querySelector('.hover-menu')).not.toBeInTheDocument(); +}); + test('Should render editMode:false', () => { const props = createProps(); props.editMode = false; diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tabs/index.js b/superset-frontend/src/dashboard/components/gridComponents/Tabs/index.js new file mode 100644 index 000000000000..30383821d32e --- /dev/null +++ b/superset-frontend/src/dashboard/components/gridComponents/Tabs/index.js @@ -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; diff --git a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.test.tsx new file mode 100644 index 000000000000..bbba9f01eb0d --- /dev/null +++ b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.test.tsx @@ -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:
Tab 1
, + closeIcon:
×
, + children:
Tab 1 Content
, + }, + { + key: 'tab-2', + label:
Tab 2
, + closeIcon:
×
, + children:
Tab 2 Content
, + }, +]; + +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(); + + const tabsContainer = screen.getByTestId('dashboard-component-tabs'); + + expect(tabsContainer).toBeInTheDocument(); + expect(tabsContainer).toHaveClass('dashboard-component-tabs'); + }); + + test('renders LineEditableTabs with correct props', () => { + render(); + + const editableTabs = screen.getByTestId('nav-list'); + expect(editableTabs).toBeInTheDocument(); + }); + + test('applies correct tab bar padding', () => { + const { rerender } = render(); + + let editableTabs = screen.getByTestId('nav-list'); + expect(editableTabs).toBeInTheDocument(); + + rerender(); + 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(); + + 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(); + + 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(); + + 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(); + + const hoverMenu = document.querySelector('.hover-menu'); + + expect(hoverMenu).not.toBeInTheDocument(); + }); + + test('renders with correct tab type based on edit mode', () => { + const { rerender } = render( + , + ); + + let editableTabs = screen.getByTestId('nav-list'); + expect(editableTabs).toBeInTheDocument(); + + rerender(); + + 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(); + + 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(); + + expect(screen.getByTestId('nav-list')).toBeInTheDocument(); + }); + + test('renders tab content correctly', () => { + render(); + + expect(screen.getByText('Tab 1 Content')).toBeInTheDocument(); + expect(screen.queryByText('Tab 2 Content')).not.toBeInTheDocument(); // Not active + }); +}); diff --git a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx new file mode 100644 index 000000000000..52e34638f755 --- /dev/null +++ b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx @@ -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; + 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( + ({ + tabItems, + editMode, + renderHoverMenu = true, + tabsDragSourceRef, + handleDeleteComponent, + tabsComponent, + activeKey, + tabIds, + handleClickTab, + handleEdit, + tabBarPaddingLeft = 0, + }) => ( + + {editMode && renderHoverMenu && tabsDragSourceRef && ( + + + + + )} + + { + 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 }} + /> + + ), +); + +TabsRenderer.displayName = 'TabsRenderer'; + +export default TabsRenderer; diff --git a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/index.ts b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/index.ts new file mode 100644 index 000000000000..320e8ebb022e --- /dev/null +++ b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/index.ts @@ -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'; diff --git a/superset-frontend/src/dashboard/components/gridComponents/index.js b/superset-frontend/src/dashboard/components/gridComponents/index.js index 38f355886492..8d3078c9ebe7 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/index.js +++ b/superset-frontend/src/dashboard/components/gridComponents/index.js @@ -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, diff --git a/superset-frontend/src/dashboard/reducers/dashboardState.js b/superset-frontend/src/dashboard/reducers/dashboardState.js index 085d4779bb9c..d9444300f9b7 100644 --- a/superset-frontend/src/dashboard/reducers/dashboardState.js +++ b/superset-frontend/src/dashboard/reducers/dashboardState.js @@ -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) { diff --git a/superset-frontend/src/dashboard/reducers/dashboardState.test.js b/superset-frontend/src/dashboard/reducers/dashboardState.test.js index 39798ecf139e..803e159657ff 100644 --- a/superset-frontend/src/dashboard/reducers/dashboardState.test.js +++ b/superset-frontend/src/dashboard/reducers/dashboardState.test.js @@ -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 }); + }); }); diff --git a/superset-frontend/src/dashboard/reducers/dashboardState.test.ts b/superset-frontend/src/dashboard/reducers/dashboardState.test.ts index 5e77b4102223..1594c1b2a6da 100644 --- a/superset-frontend/src/dashboard/reducers/dashboardState.test.ts +++ b/superset-frontend/src/dashboard/reducers/dashboardState.test.ts @@ -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 | undefined, + action: any, +) => Partial; +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 => ({ + 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'] })); }); }); diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts index b7d0e3b5ceea..b6f16768c211 100644 --- a/superset-frontend/src/dashboard/types.ts +++ b/superset-frontend/src/dashboard/types.ts @@ -107,6 +107,7 @@ export type DashboardState = { colorScheme: string; sliceIds: number[]; directPathLastUpdated: number; + nativeFiltersBarOpen?: boolean; css?: string; focusedFilterField?: { chartId: number;