diff --git a/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts b/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts index e2aec6b7b..6bc084054 100644 --- a/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts @@ -99,11 +99,13 @@ describe('VizType control', () => { cy.visitChartByName('Daily Totals'); cy.verifySliceSuccess({ waitAlias: '@tableChartData' }); - cy.get('[data-test="visualization-type"]').contains('Table').click(); + cy.contains('View all charts').click(); - cy.get('button').contains('Evolution').click(); // change categories - cy.get('[role="button"]').contains('Line Chart').click(); - cy.get('button').contains('Select').click(); + cy.get('.ant-modal-content').within(() => { + cy.get('button').contains('Evolution').click(); // change categories + cy.get('[role="button"]').contains('Line Chart').click(); + cy.get('button').contains('Select').click(); + }); cy.get('button[data-test="run-query-button"]').click(); cy.verifySliceSuccess({ diff --git a/superset-frontend/src/assets/images/icons/area-chart-tile.svg b/superset-frontend/src/assets/images/icons/area-chart-tile.svg new file mode 100644 index 000000000..dbd747d5e --- /dev/null +++ b/superset-frontend/src/assets/images/icons/area-chart-tile.svg @@ -0,0 +1,21 @@ + + + + diff --git a/superset-frontend/src/assets/images/icons/bar-chart-tile.svg b/superset-frontend/src/assets/images/icons/bar-chart-tile.svg new file mode 100644 index 000000000..a3aaa6fdd --- /dev/null +++ b/superset-frontend/src/assets/images/icons/bar-chart-tile.svg @@ -0,0 +1,21 @@ + + + + diff --git a/superset-frontend/src/assets/images/icons/big-number-chart-tile.svg b/superset-frontend/src/assets/images/icons/big-number-chart-tile.svg new file mode 100644 index 000000000..cd4ac8f2f --- /dev/null +++ b/superset-frontend/src/assets/images/icons/big-number-chart-tile.svg @@ -0,0 +1,22 @@ + + + + + diff --git a/superset-frontend/src/assets/images/icons/current-rendered-tile.svg b/superset-frontend/src/assets/images/icons/current-rendered-tile.svg new file mode 100644 index 000000000..78f63014e --- /dev/null +++ b/superset-frontend/src/assets/images/icons/current-rendered-tile.svg @@ -0,0 +1,21 @@ + + + + diff --git a/superset-frontend/src/assets/images/icons/line-chart-tile.svg b/superset-frontend/src/assets/images/icons/line-chart-tile.svg new file mode 100644 index 000000000..c6b6b4e40 --- /dev/null +++ b/superset-frontend/src/assets/images/icons/line-chart-tile.svg @@ -0,0 +1,21 @@ + + + + diff --git a/superset-frontend/src/assets/images/icons/pie-chart-tile.svg b/superset-frontend/src/assets/images/icons/pie-chart-tile.svg new file mode 100644 index 000000000..3bd3bf74d --- /dev/null +++ b/superset-frontend/src/assets/images/icons/pie-chart-tile.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/superset-frontend/src/assets/images/icons/table-chart-tile.svg b/superset-frontend/src/assets/images/icons/table-chart-tile.svg new file mode 100644 index 000000000..9a99419d5 --- /dev/null +++ b/superset-frontend/src/assets/images/icons/table-chart-tile.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/superset-frontend/src/components/Icons/index.tsx b/superset-frontend/src/components/Icons/index.tsx index 27efbe4c2..0761890e4 100644 --- a/superset-frontend/src/components/Icons/index.tsx +++ b/superset-frontend/src/components/Icons/index.tsx @@ -27,6 +27,9 @@ const IconFileNames = [ 'alert', 'alert_solid', 'alert_solid_small', + 'area-chart-tile', + 'bar-chart-tile', + 'big-number-chart-tile', 'binoculars', 'bolt', 'bolt_small', @@ -56,6 +59,7 @@ const IconFileNames = [ 'cog', 'collapse', 'color_palette', + 'current-rendered-tile', 'components', 'copy', 'cursor_target', @@ -101,6 +105,7 @@ const IconFileNames = [ 'keyboard', 'layers', 'lightbulb', + 'line-chart-tile', 'link', 'list', 'list_view', @@ -123,6 +128,7 @@ const IconFileNames = [ 'note', 'offline', 'paperclip', + 'pie-chart-tile', 'placeholder', 'plus', 'plus_large', @@ -141,6 +147,7 @@ const IconFileNames = [ 'sort_desc', 'sort', 'table', + 'table-chart-tile', 'tag', 'trash', 'triangle_change', diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx index 6ed73f2b3..bb2be2b18 100644 --- a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx +++ b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx @@ -535,7 +535,6 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { defaultActiveKey={expandedQuerySections} expandIconPosition="right" ghost - key={`query-sections-${props.form_data.datasource}-${props.form_data.viz_type}`} > {showDatasourceAlert && } {querySections.map(renderControlPanelSection)} @@ -547,7 +546,6 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { defaultActiveKey={expandedCustomizeSections} expandIconPosition="right" ghost - key={`customize-sections-${props.form_data.datasource}-${props.form_data.viz_type}`} > {customizeSections.map(renderControlPanelSection)} diff --git a/superset-frontend/src/explore/components/controls/VizTypeControl/FastVizSwitcher.tsx b/superset-frontend/src/explore/components/controls/VizTypeControl/FastVizSwitcher.tsx new file mode 100644 index 000000000..5b9df8021 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/VizTypeControl/FastVizSwitcher.tsx @@ -0,0 +1,253 @@ +/** + * 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 React, { + ReactElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useSelector } from 'react-redux'; +import { css, SupersetTheme, t, useTheme } from '@superset-ui/core'; +import { usePluginContext } from 'src/components/DynamicPlugins'; +import { Tooltip } from 'src/components/Tooltip'; +import Icons from 'src/components/Icons'; +import { ExplorePageState } from 'src/explore/reducers/getInitialState'; + +export interface VizMeta { + icon: ReactElement; + name: string; +} + +export interface FastVizSwitcherProps { + onChange: (vizName: string) => void; + currentSelection: string | null; +} +interface VizTileProps { + vizMeta: VizMeta; + isActive: boolean; + isRendered: boolean; + onTileClick: (vizType: string) => void; +} + +const FEATURED_CHARTS: VizMeta[] = [ + { + name: 'echarts_timeseries_line', + icon: , + }, + { name: 'table', icon: }, + { + name: 'big_number_total', + icon: , + }, + { name: 'pie', icon: }, + { + name: 'echarts_timeseries_bar', + icon: , + }, + { name: 'echarts_area', icon: }, +]; + +const VizTile = ({ + isActive, + isRendered, + vizMeta, + onTileClick, +}: VizTileProps) => { + const { mountedPluginMetadata } = usePluginContext(); + const chartNameRef = useRef(null); + const theme = useTheme(); + const TILE_TRANSITION_TIME = theme.transitionTiming * 2; + const [tooltipVisible, setTooltipVisible] = useState(false); + const [isTransitioning, setIsTransitioning] = useState(false); + const [showTooltip, setShowTooltip] = useState(false); + const chartName = vizMeta.name + ? mountedPluginMetadata[vizMeta.name]?.name || `${vizMeta.name}` + : t('Select Viz Type'); + + const handleTileClick = useCallback(() => { + onTileClick(vizMeta.name); + setIsTransitioning(true); + setTooltipVisible(false); + setTimeout(() => { + setIsTransitioning(false); + }, TILE_TRANSITION_TIME * 1000); + }, [onTileClick, TILE_TRANSITION_TIME, vizMeta.name]); + + // Antd tooltip seems to be bugged - when elements move, the tooltip sometimes + // stays visible even when user doesn't hover over the element. + // Here we manually prevent it from displaying after user triggers transition + useEffect(() => { + setShowTooltip( + Boolean( + !isTransitioning && + (!isActive || + (chartNameRef.current && + chartNameRef.current.scrollWidth > + chartNameRef.current.clientWidth)), + ), + ); + }, [isActive, isTransitioning]); + + const containerProps = useMemo( + () => + !isActive + ? { role: 'button', tabIndex: 0, onClick: handleTileClick } + : {}, + [handleTileClick, isActive], + ); + + let tooltipTitle: string | null = null; + if (showTooltip) { + tooltipTitle = isRendered + ? t('Currently rendered: %s', chartName) + : chartName; + } + return ( + setTooltipVisible(visible)} + visible={tooltipVisible && !isTransitioning} + placement="top" + mouseEnterDelay={0.4} + > +
+ {vizMeta.icon}{' '} + + {chartName} + +
+
+ ); +}; + +export const FastVizSwitcher = React.memo( + ({ currentSelection, onChange }: FastVizSwitcherProps) => { + const currentViz = useSelector( + state => + state.charts && + Object.values(state.charts)[0]?.latestQueryFormData?.viz_type, + ); + const vizTiles = useMemo(() => { + const vizTiles = [...FEATURED_CHARTS]; + if ( + currentSelection && + FEATURED_CHARTS.every( + featuredVizMeta => featuredVizMeta.name !== currentSelection, + ) && + currentSelection !== currentViz + ) { + vizTiles.unshift({ + name: currentSelection, + icon: ( + css` + padding: ${theme.gridUnit}px; + & > * { + line-height: 0; + } + `} + /> + ), + }); + } + if ( + currentViz && + FEATURED_CHARTS.every( + featuredVizMeta => featuredVizMeta.name !== currentViz, + ) + ) { + vizTiles.unshift({ + name: currentViz, + icon: , + }); + } + return vizTiles; + }, [currentSelection, currentViz]); + + return ( +
css` + display: flex; + justify-content: space-between; + column-gap: ${theme.gridUnit}px; + `} + data-test="fast-viz-switcher" + > + {vizTiles.map(vizMeta => ( + + ))} +
+ ); + }, +); diff --git a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeControl.test.jsx b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeControl.test.jsx index c027fd0c3..67a6a1933 100644 --- a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeControl.test.jsx +++ b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeControl.test.jsx @@ -67,6 +67,7 @@ describe('VizTypeControl', () => { , + { useRedux: true }, ); await waitForEffects(); }); diff --git a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeControl.test.tsx b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeControl.test.tsx index a87559162..98a7b9099 100644 --- a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeControl.test.tsx +++ b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeControl.test.tsx @@ -17,21 +17,22 @@ * under the License. */ import { Preset } from '@superset-ui/core'; -import { render, cleanup, screen, waitFor } from 'spec/helpers/testing-library'; -import { Provider } from 'react-redux'; -import { - getMockStore, - mockStore, - stateWithoutNativeFilters, -} from 'spec/fixtures/mockStore'; +import { render, cleanup, screen, within } from 'spec/helpers/testing-library'; +import { stateWithoutNativeFilters } from 'spec/fixtures/mockStore'; import React from 'react'; import userEvent from '@testing-library/user-event'; import { DynamicPluginProvider } from 'src/components/DynamicPlugins'; import { testWithId } from 'src/utils/testUtils'; import { + BigNumberTotalChartPlugin, + EchartsAreaChartPlugin, EchartsMixedTimeseriesChartPlugin, + EchartsPieChartPlugin, + EchartsTimeseriesBarChartPlugin, EchartsTimeseriesChartPlugin, + EchartsTimeseriesLineChartPlugin, } from '@superset-ui/plugin-chart-echarts'; +import TableChartPlugin from '@superset-ui/plugin-chart-table'; import { LineChartPlugin } from '@superset-ui/preset-chart-xy'; import TimeTableChartPlugin from '../../../../visualizations/TimeTable'; import VizTypeControl, { VIZ_TYPE_CONTROL_TEST_ID } from './index'; @@ -44,6 +45,18 @@ class MainPreset extends Preset { name: 'Legacy charts', plugins: [ new LineChartPlugin().configure({ key: 'line' }), + new TableChartPlugin().configure({ key: 'table' }), + new BigNumberTotalChartPlugin().configure({ key: 'big_number_total' }), + new EchartsTimeseriesLineChartPlugin().configure({ + key: 'echarts_timeseries_line', + }), + new EchartsAreaChartPlugin().configure({ + key: 'echarts_area', + }), + new EchartsTimeseriesBarChartPlugin().configure({ + key: 'echarts_timeseries_bar', + }), + new EchartsPieChartPlugin().configure({ key: 'pie' }), new EchartsTimeseriesChartPlugin().configure({ key: 'echarts_timeseries', }), @@ -67,7 +80,7 @@ const getTestId = testWithId(VIZ_TYPE_CONTROL_TEST_ID, true); describe('VizTypeControl', () => { new MainPreset().register(); - const newVizTypeControlProps = { + const defaultProps = { description: '', label: '', name: '', @@ -75,20 +88,17 @@ describe('VizTypeControl', () => { labelType: 'primary', onChange: jest.fn(), isModalOpenInit: true, - } as const; + }; const renderWrapper = ( - props = newVizTypeControlProps, + props: typeof defaultProps = defaultProps, state: object = stateWithoutNativeFilters, ) => { render( - - - - - , + + + , + { useRedux: true, initialState: state }, ); }; @@ -97,6 +107,119 @@ describe('VizTypeControl', () => { jest.clearAllMocks(); }); + it('Fast viz switcher tiles render', () => { + const props = { + ...defaultProps, + value: 'echarts_timeseries_line', + isModalOpenInit: false, + }; + renderWrapper(props); + expect(screen.getByLabelText('line-chart-tile')).toBeVisible(); + expect(screen.getByLabelText('table-chart-tile')).toBeVisible(); + expect(screen.getByLabelText('big-number-chart-tile')).toBeVisible(); + expect(screen.getByLabelText('pie-chart-tile')).toBeVisible(); + expect(screen.getByLabelText('bar-chart-tile')).toBeVisible(); + expect(screen.getByLabelText('area-chart-tile')).toBeVisible(); + expect(screen.queryByLabelText('monitor')).not.toBeInTheDocument(); + expect( + screen.queryByLabelText('current-rendered-tile'), + ).not.toBeInTheDocument(); + + expect( + within(screen.getByTestId('fast-viz-switcher')).getByText( + 'Time-series Line Chart', + ), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('fast-viz-switcher')).getByText('Table'), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('fast-viz-switcher')).getByText('Big Number'), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('fast-viz-switcher')).getByText('Pie Chart'), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('fast-viz-switcher')).getByText( + 'Time-series Bar Chart v2', + ), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('fast-viz-switcher')).getByText( + 'Time-series Area Chart', + ), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('fast-viz-switcher')).queryByText('Line Chart'), + ).not.toBeInTheDocument(); + }); + + it('Render viz tiles when non-featured chart is selected', () => { + const props = { + ...defaultProps, + value: 'line', + isModalOpenInit: false, + }; + renderWrapper(props); + + expect(screen.getByLabelText('monitor')).toBeVisible(); + expect( + within(screen.getByTestId('fast-viz-switcher')).getByText('Line Chart'), + ).toBeVisible(); + }); + + it('Render viz tiles when non-featured is rendered', () => { + const props = { + ...defaultProps, + value: 'line', + isModalOpenInit: false, + }; + const state = { + charts: { + 1: { + latestQueryFormData: { + viz_type: 'line', + }, + }, + }, + }; + renderWrapper(props, state); + expect(screen.getByLabelText('current-rendered-tile')).toBeVisible(); + expect( + within(screen.getByTestId('fast-viz-switcher')).getByText('Line Chart'), + ).toBeVisible(); + }); + + it('Change viz type on click', () => { + const props = { + ...defaultProps, + value: 'echarts_timeseries_line', + isModalOpenInit: false, + }; + renderWrapper(props); + userEvent.click( + within(screen.getByTestId('fast-viz-switcher')).getByText( + 'Time-series Line Chart', + ), + ); + expect(props.onChange).not.toHaveBeenCalled(); + userEvent.click( + within(screen.getByTestId('fast-viz-switcher')).getByText('Table'), + ); + expect(props.onChange).toHaveBeenCalledWith('table'); + }); + + it('Open viz gallery modal on "View all charts" click', async () => { + renderWrapper({ ...defaultProps, isModalOpenInit: false }); + expect( + screen.queryByText('Select a visualization type'), + ).not.toBeInTheDocument(); + userEvent.click(screen.getByText('View all charts')); + expect( + await screen.findByText('Select a visualization type'), + ).toBeVisible(); + }); + it('Search visualization type', async () => { renderWrapper(); @@ -104,20 +227,38 @@ describe('VizTypeControl', () => { userEvent.click(screen.getByRole('button', { name: 'ballot All charts' })); - await waitFor(() => { - expect(visualizations).toHaveTextContent(/Time-series Table/); - }); + expect( + await within(visualizations).findByText('Time-series Line Chart'), + ).toBeVisible(); // search userEvent.type( screen.getByTestId(getTestId('search-input')), 'time series', ); - await waitFor(() => { - expect(visualizations).toHaveTextContent(/Time-series Table/); - expect(visualizations).toHaveTextContent(/Time-series Chart/); - expect(visualizations).toHaveTextContent(/Mixed Time-Series/); - expect(visualizations).not.toHaveTextContent(/Line Chart/); - }); + expect( + await within(visualizations).findByText('Time-series Table'), + ).toBeVisible(); + expect(within(visualizations).getByText('Time-series Chart')).toBeVisible(); + expect(within(visualizations).getByText('Mixed Time-Series')).toBeVisible(); + expect( + within(visualizations).getByText('Time-series Area Chart'), + ).toBeVisible(); + expect( + within(visualizations).getByText('Time-series Line Chart'), + ).toBeVisible(); + expect( + within(visualizations).getByText('Time-series Bar Chart v2'), + ).toBeVisible(); + expect( + within(visualizations).queryByText('Line Chart'), + ).not.toBeInTheDocument(); + expect(within(visualizations).queryByText('Table')).not.toBeInTheDocument(); + expect( + within(visualizations).queryByText('Big Number'), + ).not.toBeInTheDocument(); + expect( + within(visualizations).queryByText('Pie Chart'), + ).not.toBeInTheDocument(); }); }); diff --git a/superset-frontend/src/explore/components/controls/VizTypeControl/index.tsx b/superset-frontend/src/explore/components/controls/VizTypeControl/index.tsx index 7837eb04a..0f8de7692 100644 --- a/superset-frontend/src/explore/components/controls/VizTypeControl/index.tsx +++ b/superset-frontend/src/explore/components/controls/VizTypeControl/index.tsx @@ -17,25 +17,20 @@ * under the License. */ import React, { useCallback, useState } from 'react'; -import PropTypes from 'prop-types'; -import { t, getChartMetadataRegistry, styled } from '@superset-ui/core'; +import { + css, + t, + getChartMetadataRegistry, + styled, + SupersetTheme, +} from '@superset-ui/core'; import { usePluginContext } from 'src/components/DynamicPlugins'; import Modal from 'src/components/Modal'; -import { Tooltip } from 'src/components/Tooltip'; -import Label, { Type } from 'src/components/Label'; -import ControlHeader from 'src/explore/components/ControlHeader'; +import { noOp } from 'src/utils/common'; import VizTypeGallery, { MAX_ADVISABLE_VIZ_GALLERY_WIDTH, } from './VizTypeGallery'; - -const propTypes = { - description: PropTypes.string, - label: PropTypes.string, - name: PropTypes.string.isRequired, - onChange: PropTypes.func, - value: PropTypes.string.isRequired, - labelType: PropTypes.string, -}; +import { FastVizSwitcher } from './FastVizSwitcher'; interface VizTypeControlProps { description?: string; @@ -43,15 +38,9 @@ interface VizTypeControlProps { name: string; onChange: (vizType: string | null) => void; value: string | null; - labelType?: Type; isModalOpenInit?: boolean; } -const defaultProps = { - onChange: () => {}, - labelType: 'default', -}; - const metadataRegistry = getChartMetadataRegistry(); export const VIZ_TYPE_CONTROL_TEST_ID = 'viz-type-control'; @@ -62,7 +51,14 @@ function VizSupportValidation({ vizType }: { vizType: string }) { return null; } return ( -
+
+ css` + margin-top: ${theme.gridUnit}px; + ` + } + > {' '} {t('This visualization type is not supported.')}
@@ -76,9 +72,11 @@ const UnpaddedModal = styled(Modal)` `; /** Manages the viz type and the viz picker modal */ -const VizTypeControl = (props: VizTypeControlProps) => { - const { value: initialValue, onChange, isModalOpenInit, labelType } = props; - const { mountedPluginMetadata } = usePluginContext(); +const VizTypeControl = ({ + value: initialValue, + onChange = noOp, + isModalOpenInit, +}: VizTypeControlProps) => { const [showModal, setShowModal] = useState(!!isModalOpenInit); // a trick to force re-initialization of the gallery each time the modal opens, // ensuring that the modal always opens to the correct category. @@ -101,30 +99,32 @@ const VizTypeControl = (props: VizTypeControlProps) => { setSelectedViz(initialValue); }, [initialValue]); - const labelContent = initialValue - ? mountedPluginMetadata[initialValue]?.name || `${initialValue}` - : t('Select Viz Type'); - return ( -
- - +
css` + min-width: ${theme.gridUnit * 72}px; + max-width: fit-content; + `} > - <> - - {initialValue && } - - - + + {initialValue && } +
+
+ css` + display: flex; + justify-content: flex-end; + margin-top: ${theme.gridUnit * 3}px; + color: ${theme.colors.grayscale.base}; + text-decoration: underline; + ` + } + > + + {t('View all charts')} + +
{ onChange={setSelectedViz} /> -
+ ); }; -VizTypeControl.propTypes = propTypes; -VizTypeControl.defaultProps = defaultProps; - export default VizTypeControl; diff --git a/superset-frontend/src/explore/controlPanels/sections.tsx b/superset-frontend/src/explore/controlPanels/sections.tsx index be21747ed..78815215d 100644 --- a/superset-frontend/src/explore/controlPanels/sections.tsx +++ b/superset-frontend/src/explore/controlPanels/sections.tsx @@ -29,7 +29,7 @@ export const druidTimeSeries: ControlPanelSectionConfig = { }; export const datasourceAndVizType: ControlPanelSectionConfig = { - label: t('Chart type'), + label: t('Visualization type'), expanded: true, controlSetRows: [ ['datasource'],