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 e6d8b5676e..fc81c92dde 100644 --- a/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts @@ -99,20 +99,14 @@ describe('VizType control', () => { cy.visitChartByName('Daily Totals'); cy.verifySliceSuccess({ waitAlias: '@tableChartData' }); - let numScripts = 0; - cy.get('script').then(nodes => { - numScripts = nodes.length; - }); - cy.get('[data-test="visualization-type"]').contains('Table').click(); + cy.get('button').contains('Evolution').click(); // change categories cy.get('[role="button"]').contains('Line Chart').click(); + cy.get('button').contains('Select').click(); // should load mathjs for line chart cy.get('script[src*="mathjs"]').should('have.length', 1); - cy.get('script').then(nodes => { - expect(nodes.length).to.greaterThan(numScripts); - }); cy.get('button[data-test="run-query-button"]').click(); cy.verifySliceSuccess({ diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 2be4bb621f..c6a91f3896 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -61,6 +61,7 @@ "emotion-rgba": "0.0.9", "fontsource-fira-code": "^3.0.5", "fontsource-inter": "^3.0.5", + "fuse.js": "^6.4.6", "geolib": "^2.0.24", "global-box": "^1.2.0", "html-webpack-plugin": "^4.5.1", @@ -28242,9 +28243,12 @@ "dev": true }, "node_modules/fuse.js": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-3.3.0.tgz", - "integrity": "sha512-ESBRkGLWMuVkapqYCcNO1uqMg5qbCKkgb+VS6wsy17Rix0/cMS9kSOZoYkjH8Ko//pgJ/EEGu0GTjk2mjX2LGQ==" + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.4.6.tgz", + "integrity": "sha512-/gYxR/0VpXmWSfZOIPS3rWwU8SHgsRTwWuXhyb2O6s7aRuVtHtxCkR33bNYu3wyLyNx/Wpv0vU7FZy8Vj53VNw==", + "engines": { + "node": ">=10" + } }, "node_modules/gauge": { "version": "2.7.4", @@ -44653,6 +44657,14 @@ "prop-types": "^15.5.8" } }, + "node_modules/react-search-input/node_modules/fuse.js": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-3.6.1.tgz", + "integrity": "sha512-hT9yh/tiinkmirKrlv4KWOjztdoZo1mx9Qh4KvWqC7isoXwdUY3PNWUxceF4/qO9R6riA2C29jdTOeQOIROjgw==", + "engines": { + "node": ">=6" + } + }, "node_modules/react-select": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/react-select/-/react-select-3.1.0.tgz", @@ -76410,9 +76422,9 @@ "dev": true }, "fuse.js": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-3.3.0.tgz", - "integrity": "sha512-ESBRkGLWMuVkapqYCcNO1uqMg5qbCKkgb+VS6wsy17Rix0/cMS9kSOZoYkjH8Ko//pgJ/EEGu0GTjk2mjX2LGQ==" + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.4.6.tgz", + "integrity": "sha512-/gYxR/0VpXmWSfZOIPS3rWwU8SHgsRTwWuXhyb2O6s7aRuVtHtxCkR33bNYu3wyLyNx/Wpv0vU7FZy8Vj53VNw==" }, "gauge": { "version": "2.7.4", @@ -89326,6 +89338,13 @@ "requires": { "fuse.js": "^3.0.0", "prop-types": "^15.5.8" + }, + "dependencies": { + "fuse.js": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-3.6.1.tgz", + "integrity": "sha512-hT9yh/tiinkmirKrlv4KWOjztdoZo1mx9Qh4KvWqC7isoXwdUY3PNWUxceF4/qO9R6riA2C29jdTOeQOIROjgw==" + } } }, "react-select": { diff --git a/superset-frontend/package.json b/superset-frontend/package.json index f1a138ecab..509c67d04d 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -113,6 +113,7 @@ "emotion-rgba": "0.0.9", "fontsource-fira-code": "^3.0.5", "fontsource-inter": "^3.0.5", + "fuse.js": "^6.4.6", "geolib": "^2.0.24", "global-box": "^1.2.0", "html-webpack-plugin": "^4.5.1", diff --git a/superset-frontend/spec/javascripts/explore/components/VizTypeControl_spec.jsx b/superset-frontend/spec/javascripts/explore/components/VizTypeControl_spec.jsx index 0a47e6b2bf..2b58965741 100644 --- a/superset-frontend/spec/javascripts/explore/components/VizTypeControl_spec.jsx +++ b/superset-frontend/spec/javascripts/explore/components/VizTypeControl_spec.jsx @@ -18,22 +18,31 @@ */ import React from 'react'; import sinon from 'sinon'; -import { shallow } from 'enzyme'; +import userEvent from '@testing-library/user-event'; import { getChartMetadataRegistry, ChartMetadata } from '@superset-ui/core'; +import { render, screen } from 'spec/helpers/testing-library'; import VizTypeControl from 'src/explore/components/controls/VizTypeControl'; -import Modal from 'src/components/Modal'; -import { Input } from 'src/common/components'; +import { DynamicPluginProvider } from 'src/components/DynamicPlugins'; +import { act } from 'react-dom/test-utils'; const defaultProps = { name: 'viz_type', label: 'Visualization Type', value: 'vis1', onChange: sinon.spy(), + isModalOpenInit: true, }; -describe('VizTypeControl', () => { - let wrapper; +/** + * AntD and/or the Icon component seems to be doing some kind of async changes, + * so even though the test passes, there is a warning an update to Icon was not + * wrapped in act(). This sufficiently act-ifies whatever side effects are going + * on and prevents those warnings. + */ +const waitForEffects = () => + act(() => new Promise(resolve => setTimeout(resolve, 0))); +describe('VizTypeControl', () => { const registry = getChartMetadataRegistry(); registry .registerValue( @@ -51,26 +60,33 @@ describe('VizTypeControl', () => { }), ); - beforeEach(() => { - wrapper = shallow(); + beforeEach(async () => { + render( + + + , + ); + await waitForEffects(); }); - it('renders a Modal', () => { - expect(wrapper.find(Modal)).toExist(); - }); - - it('calls onChange when toggled', () => { - const select = wrapper.find('.viztype-selector-container').first(); - select.simulate('click'); + it('calls onChange when submitted', () => { + const thumbnail = screen.getAllByTestId('viztype-selector-container')[0]; + const submit = screen.getByText('Select'); + userEvent.click(thumbnail); + expect(defaultProps.onChange.called).toBe(false); + userEvent.click(submit); expect(defaultProps.onChange.called).toBe(true); }); - it('filters images based on text input', () => { - expect(wrapper.find('img')).toHaveLength(2); - wrapper.find(Input).simulate('change', { - target: { - value: 'vis2', - }, - }); - expect(wrapper.find('img')).toExist(); + + it('filters images based on text input', async () => { + const thumbnails = screen.getAllByTestId('viztype-selector-container'); + expect(thumbnails).toHaveLength(2); + + const searchInput = screen.getByPlaceholderText('Search'); + userEvent.type(searchInput, '2'); + await waitForEffects(); + + const thumbnail = screen.getByTestId('viztype-selector-container'); + expect(thumbnail).toBeInTheDocument(); }); }); diff --git a/superset-frontend/src/addSlice/AddSliceContainer.test.tsx b/superset-frontend/src/addSlice/AddSliceContainer.test.tsx index c3b8bac8a4..e415116d8c 100644 --- a/superset-frontend/src/addSlice/AddSliceContainer.test.tsx +++ b/superset-frontend/src/addSlice/AddSliceContainer.test.tsx @@ -24,8 +24,9 @@ import AddSliceContainer, { AddSliceContainerProps, AddSliceContainerState, } from 'src/addSlice/AddSliceContainer'; -import VizTypeControl from 'src/explore/components/controls/VizTypeControl'; +import VizTypeGallery from 'src/explore/components/controls/VizTypeControl/VizTypeGallery'; import { styledMount as mount } from 'spec/helpers/theming'; +import { act } from 'spec/helpers/testing-library'; const defaultProps = { datasources: [ @@ -41,21 +42,19 @@ describe('AddSliceContainer', () => { AddSliceContainer >; - beforeEach(() => { + beforeEach(async () => { wrapper = mount() as ReactWrapper< AddSliceContainerProps, AddSliceContainerState, AddSliceContainer >; - }); - - it('uses table as default visType', () => { - expect(wrapper.state().visType).toBe('table'); + // suppress a warning caused by some unusual async behavior in Icon + await act(() => new Promise(resolve => setTimeout(resolve, 0))); }); it('renders a select and a VizTypeControl', () => { expect(wrapper.find(Select)).toExist(); - expect(wrapper.find(VizTypeControl)).toExist(); + expect(wrapper.find(VizTypeGallery)).toExist(); }); it('renders a button', () => { @@ -68,12 +67,13 @@ describe('AddSliceContainer', () => { ).toHaveLength(1); }); - it('renders an enabled button if datasource is selected', () => { + it('renders an enabled button if datasource and viz type is selected', () => { const datasourceValue = defaultProps.datasources[0].value; wrapper.setState({ datasourceValue, datasourceId: datasourceValue.split('__')[0], datasourceType: datasourceValue.split('__')[1], + visType: 'table', }); expect( wrapper.find(Button).find({ disabled: true }).hostNodes(), @@ -86,6 +86,7 @@ describe('AddSliceContainer', () => { datasourceValue, datasourceId: datasourceValue.split('__')[0], datasourceType: datasourceValue.split('__')[1], + visType: 'table', }); const formattedUrl = '/superset/explore/?form_data=%7B%22viz_type%22%3A%22table%22%2C%22datasource%22%3A%221__table%22%7D'; diff --git a/superset-frontend/src/addSlice/AddSliceContainer.tsx b/superset-frontend/src/addSlice/AddSliceContainer.tsx index 22230cd0a2..5a88e63838 100644 --- a/superset-frontend/src/addSlice/AddSliceContainer.tsx +++ b/superset-frontend/src/addSlice/AddSliceContainer.tsx @@ -19,9 +19,11 @@ import React from 'react'; import Button from 'src/components/Button'; import Select from 'src/components/Select'; -import { styled, t } from '@superset-ui/core'; +import { css, styled, t } from '@superset-ui/core'; -import VizTypeControl from '../explore/components/controls/VizTypeControl'; +import VizTypeGallery, { + MAX_ADVISABLE_VIZ_GALLERY_WIDTH, +} from 'src/explore/components/controls/VizTypeControl/VizTypeGallery'; interface Datasource { label: string; @@ -36,19 +38,37 @@ export type AddSliceContainerState = { datasourceId?: string; datasourceType?: string; datasourceValue?: string; - visType: string; + visType: string | null; }; +const ESTIMATED_NAV_HEIGHT = '56px'; const styleSelectContainer = { width: 600, marginBottom: '10px' }; const StyledContainer = styled.div` + flex: 1 1 auto; + display: flex; + flex-direction: column; + justify-content: space-between; + width: 100%; + max-width: ${MAX_ADVISABLE_VIZ_GALLERY_WIDTH}px; + max-height: calc(100vh - ${ESTIMATED_NAV_HEIGHT}); border-radius: ${({ theme }) => theme.gridUnit}px; background-color: ${({ theme }) => theme.colors.grayscale.light5}; - padding: ${({ theme }) => theme.gridUnit * 6}px; + padding-bottom: ${({ theme }) => theme.gridUnit * 3}px; h3 { padding-bottom: ${({ theme }) => theme.gridUnit * 3}px; } `; +const cssStatic = css` + flex: 0 0 auto; +`; + +const StyledVizTypeGallery = styled(VizTypeGallery)` + border: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; + margin: ${({ theme }) => theme.gridUnit * 3}px 0px; + flex: 1 1 auto; +`; + export default class AddSliceContainer extends React.PureComponent< AddSliceContainerProps, AddSliceContainerState @@ -56,7 +76,7 @@ export default class AddSliceContainer extends React.PureComponent< constructor(props: AddSliceContainerProps) { super(props); this.state = { - visType: 'table', + visType: null, }; this.changeDatasource = this.changeDatasource.bind(this); @@ -85,7 +105,7 @@ export default class AddSliceContainer extends React.PureComponent< }); } - changeVisType(visType: string) { + changeVisType(visType: string | null) { this.setState({ visType }); } @@ -96,8 +116,8 @@ export default class AddSliceContainer extends React.PureComponent< render() { return ( -

{t('Create a new chart')}

-
+

{t('Create a new chart')}

+

{t('Choose a dataset')}

+ + + } + suffix={ + + {searchInputValue && ( + + )} + + } + /> + + + {categories.map(category => ( + + ))} + + + + + + {selectedVizMetadata ? ( +
[ + DetailsPane(theme), + DetailsPopulated(theme), + ]} + > + <> + + {selectedVizMetadata?.name} + + + {selectedVizMetadata?.description || + t('No description available.')} + + + {!!selectedVizMetadata?.exampleGallery?.length && t('Examples')} + + + {(selectedVizMetadata?.exampleGallery || []).map(example => ( + {example.caption} + ))} + + +
+ ) : ( +
[ + DetailsPane(theme), + DetailsEmpty(theme), + ]} + > + {t('Select a visualization type')} +
+ )} + + ); +} diff --git a/superset-frontend/src/explore/components/controls/VizTypeControl/index.jsx b/superset-frontend/src/explore/components/controls/VizTypeControl/index.jsx deleted file mode 100644 index b7efbd8bc7..0000000000 --- a/superset-frontend/src/explore/components/controls/VizTypeControl/index.jsx +++ /dev/null @@ -1,244 +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 React, { useEffect, useRef, useState } from 'react'; -import PropTypes from 'prop-types'; -import { Input, Row, Col } from 'src/common/components'; -import { t, getChartMetadataRegistry } from '@superset-ui/core'; -import { useDynamicPluginContext } from 'src/components/DynamicPlugins'; -import Modal from 'src/components/Modal'; -import { Tooltip } from 'src/components/Tooltip'; -import Label from 'src/components/Label'; -import ControlHeader from 'src/explore/components/ControlHeader'; -import { nativeFilterGate } from 'src/dashboard/components/nativeFilters/utils'; -import './VizTypeControl.less'; - -const propTypes = { - description: PropTypes.string, - label: PropTypes.string, - name: PropTypes.string.isRequired, - onChange: PropTypes.func, - value: PropTypes.string.isRequired, - labelType: PropTypes.string, -}; - -const defaultProps = { - onChange: () => {}, - labelType: 'default', -}; - -const registry = getChartMetadataRegistry(); - -const DEFAULT_ORDER = [ - 'line', - 'big_number', - 'table', - 'filter_box', - 'dist_bar', - 'area', - 'bar', - 'deck_polygon', - 'pie', - 'time_table', - 'pivot_table', - 'histogram', - 'big_number_total', - 'deck_scatter', - 'deck_hex', - 'time_pivot', - 'deck_arc', - 'heatmap', - 'deck_grid', - 'dual_line', - 'deck_screengrid', - 'line_multi', - 'treemap', - 'box_plot', - 'sunburst', - 'sankey', - 'word_cloud', - 'mapbox', - 'kepler', - 'cal_heatmap', - 'rose', - 'bubble', - 'deck_geojson', - 'horizon', - 'deck_multi', - 'compare', - 'partition', - 'event_flow', - 'deck_path', - 'graph_chart', - 'world_map', - 'paired_ttest', - 'para', - 'country_map', -]; - -const typesWithDefaultOrder = new Set(DEFAULT_ORDER); - -export const VIZ_TYPE_CONTROL_TEST_ID = 'viz-type-control'; - -function VizSupportValidation({ vizType }) { - const state = useDynamicPluginContext(); - if (state.loading || registry.has(vizType)) { - return null; - } - return ( -
- {' '} - {t('This visualization type is not supported.')} -
- ); -} - -const VizTypeControl = props => { - const [showModal, setShowModal] = useState(false); - const [filter, setFilter] = useState(''); - const searchRef = useRef(null); - - useEffect(() => { - if (showModal) { - setTimeout(() => searchRef?.current?.focus(), 200); - } - }, [showModal]); - - const onChange = vizType => { - props.onChange(vizType); - setShowModal(false); - }; - - const toggleModal = () => { - setShowModal(prevState => !prevState); - }; - - const changeSearch = event => { - setFilter(event.target.value); - }; - - const renderItem = entry => { - const { value } = props; - const { key, value: type } = entry; - const isSelected = key === value; - - return ( -
onChange(key)} - > - {type.name} -
- {type.name} -
-
- ); - }; - - const { value, labelType } = props; - const filterString = filter.toLowerCase(); - const filterStringParts = filterString.split(' '); - - const a = DEFAULT_ORDER.filter(type => registry.has(type)); - const filteredTypes = a - .filter(type => { - const behaviors = registry.get(type)?.behaviors || []; - return nativeFilterGate(behaviors); - }) - .map(type => ({ - key: type, - value: registry.get(type), - })) - .concat( - registry - .entries() - .filter(entry => { - const behaviors = entry.value?.behaviors || []; - return nativeFilterGate(behaviors); - }) - .filter(({ key }) => !typesWithDefaultOrder.has(key)), - ) - .filter(entry => - filterStringParts.every( - part => entry.value.name.toLowerCase().indexOf(part) !== -1, - ), - ); - - return ( -
- - - <> - - - - - -
- -
- - {filteredTypes.map(entry => ( - - {renderItem(entry)} - - ))} - -
-
- ); -}; - -VizTypeControl.propTypes = propTypes; -VizTypeControl.defaultProps = defaultProps; - -export default VizTypeControl; diff --git a/superset-frontend/src/explore/components/controls/VizTypeControl/index.tsx b/superset-frontend/src/explore/components/controls/VizTypeControl/index.tsx new file mode 100644 index 0000000000..7837eb04ae --- /dev/null +++ b/superset-frontend/src/explore/components/controls/VizTypeControl/index.tsx @@ -0,0 +1,152 @@ +/** + * 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, { useCallback, useState } from 'react'; +import PropTypes from 'prop-types'; +import { t, getChartMetadataRegistry, styled } 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 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, +}; + +interface VizTypeControlProps { + description?: string; + label?: string; + 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'; + +function VizSupportValidation({ vizType }: { vizType: string }) { + const state = usePluginContext(); + if (state.loading || metadataRegistry.has(vizType)) { + return null; + } + return ( +
+ {' '} + {t('This visualization type is not supported.')} +
+ ); +} + +const UnpaddedModal = styled(Modal)` + .ant-modal-body { + padding: 0; + } +`; + +/** Manages the viz type and the viz picker modal */ +const VizTypeControl = (props: VizTypeControlProps) => { + const { value: initialValue, onChange, isModalOpenInit, labelType } = props; + const { mountedPluginMetadata } = usePluginContext(); + 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. + const [modalKey, setModalKey] = useState(0); + const [selectedViz, setSelectedViz] = useState(initialValue); + + const openModal = useCallback(() => { + setShowModal(true); + }, []); + + const onSubmit = useCallback(() => { + onChange(selectedViz); + setShowModal(false); + }, [selectedViz, onChange]); + + const onCancel = useCallback(() => { + setShowModal(false); + setModalKey(key => key + 1); + // make sure the modal re-opens to the last submitted viz + setSelectedViz(initialValue); + }, [initialValue]); + + const labelContent = initialValue + ? mountedPluginMetadata[initialValue]?.name || `${initialValue}` + : t('Select Viz Type'); + + return ( +
+ + + <> + + {initialValue && } + + + + + {/* When the key increments, it forces react to re-init the gallery component */} + + +
+ ); +}; + +VizTypeControl.propTypes = propTypes; +VizTypeControl.defaultProps = defaultProps; + +export default VizTypeControl; diff --git a/superset-frontend/src/visualizations/TimeTable/TimeTableChartPlugin.js b/superset-frontend/src/visualizations/TimeTable/TimeTableChartPlugin.js index c8e49a8881..6e070ae997 100644 --- a/superset-frontend/src/visualizations/TimeTable/TimeTableChartPlugin.js +++ b/superset-frontend/src/visualizations/TimeTable/TimeTableChartPlugin.js @@ -21,8 +21,21 @@ import transformProps from './transformProps'; import thumbnail from './images/thumbnail.png'; const metadata = new ChartMetadata({ + category: t('Table'), name: t('Time-series Table'), - description: '', + description: t( + 'Compare multiple time series charts (as sparklines) and related metrics quickly.', + ), + tags: [ + t('Advanced-Analytics'), + t('Multi-Variables'), + t('Comparison'), + t('Legacy'), + t('Percentages'), + t('Tabular'), + t('Text'), + t('Trend'), + ], thumbnail, useLegacyApi: true, }); diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js index 3de6365aac..767aaa751e 100644 --- a/superset-frontend/webpack.config.js +++ b/superset-frontend/webpack.config.js @@ -279,7 +279,7 @@ const config = { // viz thumbnails are used in `addSlice` and `explore` page thumbnail: { name: 'thumbnail', - test: /thumbnail(Large)?\.png/i, + test: /thumbnail(Large)?\.(png|jpg)/i, priority: 20, enforce: true, },