feat(explore): Upgraded viz select gallery (#15303)

* add modal layout with description, rework styles

* thirty percent

* test correctly

* avoid any changes in modal height

* typescriptify

* feat(viz): add categories to the viz picker (#15304)

* feat(viz): add categories to the viz picker

* fix test types

* add a catch-all category

* tweak layout

* upgrade superset-ui to get new metadata

* do i look like i know what a jpeg is

* fix tests

* lint

* remove script count test requirement

* fix e2e test

* feat(explore): Viz picker search improvements (#15399)

* upgrade superset-ui, install fuse.js

* add metadata to plugin context

* get search working

* layout improvements

* fix tests

* Update superset-frontend/src/explore/components/controls/VizTypeControl/index.tsx

Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>

* use typography size instead of grid unit

* comments

Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>

* feat(explore): Examples image gallery in the viz type control (#15489)

* feat(explore): add section for example images in viz type control

* fix jpg webpack config

* formatting

* feat(Explore): Viz gallery component tweaks (#15520)

* separate viz gallery from the modal

* use gallery directly in add slice view

* more formatting on the add slice container

* restyle the thumbnail list

* explicit thumbnail width and height

* remove crappy hack

* remove useless line

* comment

* sort categories

* comments

* tweak search behavior

* fix tests

* open gallery to the currently selected viz type

* null safety

* show all plugins when searching empty string

* get the new metadatas

* adjust categories scrolling behavior

* add time series table metadata

* upgrade superset-ui

* attempt fixing tests

* upgrade descriptions

* fix unit test

* attempt fixing e2e again

* max width for viz gallery

* update package lock

* undo unnecessary webpack changes

* don't show search results until something is entered

* force modal to open to selected viz type

* tweaks to search behavior

* gallery layout tweaks

* enshrine pivot table v2 in a place of honor

* feat(viz): Clear viz gallery when navigating between categories (#15577)

* start viz gallery with null selection, clear when switching categories

* fix AddSliceContainer tests

* show a message when there is no viz type selected

* composition > inheritance

* clarify searching code

* comment

Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>
This commit is contained in:
David Aaron Suddjian 2021-07-12 10:59:10 -07:00 committed by GitHub
parent d8a15e60b9
commit 257385e888
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 943 additions and 404 deletions

View File

@ -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({

View File

@ -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": {

View File

@ -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",

View File

@ -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(<VizTypeControl {...defaultProps} />);
beforeEach(async () => {
render(
<DynamicPluginProvider>
<VizTypeControl {...defaultProps} />
</DynamicPluginProvider>,
);
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();
});
});

View File

@ -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(<AddSliceContainer {...defaultProps} />) 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';

View File

@ -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 (
<StyledContainer className="container">
<h3>{t('Create a new chart')}</h3>
<div>
<h3 css={cssStatic}>{t('Create a new chart')}</h3>
<div css={cssStatic}>
<p>{t('Choose a dataset')}</p>
<div style={styleSelectContainer}>
<Select
@ -130,27 +150,23 @@ export default class AddSliceContainer extends React.PureComponent<
</a>
</span>
</div>
<br />
<div>
<p>{t('Choose a visualization type')}</p>
<VizTypeControl
name="select-vis-type"
onChange={this.changeVisType}
value={this.state.visType}
labelType="primary"
/>
</div>
<br />
<hr />
<StyledVizTypeGallery
onChange={this.changeVisType}
selectedViz={this.state.visType}
/>
<Button
css={[
cssStatic,
css`
align-self: flex-end;
`,
]}
buttonStyle="primary"
disabled={this.isBtnDisabled()}
onClick={this.gotoSlice}
>
{t('Create new chart')}
</Button>
<br />
<br />
</StyledContainer>
);
}

View File

@ -17,30 +17,45 @@
* under the License.
*/
import React, { useContext, useEffect, useReducer } from 'react';
import { defineSharedModules, logging, makeApi } from '@superset-ui/core';
import {
ChartMetadata,
defineSharedModules,
getChartMetadataRegistry,
logging,
makeApi,
} from '@superset-ui/core';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
import { omitBy } from 'lodash';
const metadataRegistry = getChartMetadataRegistry();
export type PluginContextType = {
loading: boolean;
plugins: {
/** These are actually only the dynamic plugins */
dynamicPlugins: {
[key: string]: {
key: string;
loading: boolean;
mounting: boolean;
error: null | Error;
};
};
keys: string[];
/** Mounted means the plugin's js bundle has been imported */
mountedPluginMetadata: Record<string, ChartMetadata>;
fetchAll: () => void;
};
const dummyPluginContext: PluginContextType = {
loading: true,
plugins: {},
dynamicPlugins: {},
keys: [],
mountedPluginMetadata: {},
fetchAll: () => {},
};
/**
* It is highly recommended to use the useDynamicPluginContext hook instead.
* @see useDynamicPluginContext
* It is highly recommended to use the usePluginContext hook instead.
* @see usePluginContext
*/
export const PluginContext = React.createContext(dummyPluginContext);
@ -52,7 +67,7 @@ export const PluginContext = React.createContext(dummyPluginContext);
* Those are compiled into the Superset bundle at build time.
* Dynamic plugins are added by the end user and can be any webhosted javascript.
*/
export const useDynamicPluginContext = () => useContext(PluginContext);
export const usePluginContext = () => useContext(PluginContext);
// the plugin returned from the API
type Plugin = {
@ -75,38 +90,60 @@ type BeginAction = {
keys: string[];
};
type ChangedKeysAction = {
type: 'changed keys';
};
type PluginAction = BeginAction | CompleteAction | ChangedKeysAction;
function getRegistryData() {
return {
keys: metadataRegistry.keys(),
mountedPluginMetadata: omitBy(
metadataRegistry.getMap(),
value => value === undefined,
) as Record<string, ChartMetadata>, // cast required to get rid of undefined values
};
}
function pluginContextReducer(
state: PluginContextType,
action: BeginAction | CompleteAction,
action: PluginAction,
): PluginContextType {
switch (action.type) {
case 'begin': {
const plugins = { ...state.plugins };
const plugins = { ...state.dynamicPlugins };
action.keys.forEach(key => {
plugins[key] = { key, error: null, loading: true };
plugins[key] = { key, error: null, mounting: true };
});
return {
...state,
loading: action.keys.length > 0,
plugins,
dynamicPlugins: plugins,
};
}
case 'complete': {
return {
...state,
loading: Object.values(state.plugins).some(
plugin => plugin.loading && plugin.key !== action.key,
loading: Object.values(state.dynamicPlugins).some(
plugin => plugin.mounting && plugin.key !== action.key,
),
plugins: {
...state.plugins,
dynamicPlugins: {
...state.dynamicPlugins,
[action.key]: {
key: action.key,
loading: false,
mounting: false,
error: action.error,
},
},
};
}
case 'changed keys': {
return {
...state,
...getRegistryData(),
};
}
default:
return state;
}
@ -126,14 +163,18 @@ const sharedModules = {
};
export const DynamicPluginProvider: React.FC = ({ children }) => {
const [pluginState, dispatch] = useReducer(pluginContextReducer, {
// use the dummy plugin context, and override the methods
...dummyPluginContext,
// eslint-disable-next-line @typescript-eslint/no-use-before-define
fetchAll,
loading: isFeatureEnabled(FeatureFlag.DYNAMIC_PLUGINS),
// TODO: Write fetchByKeys
});
const [pluginState, dispatch] = useReducer(
pluginContextReducer,
dummyPluginContext,
state => ({
...state,
...getRegistryData(),
// eslint-disable-next-line @typescript-eslint/no-use-before-define
fetchAll,
loading: isFeatureEnabled(FeatureFlag.DYNAMIC_PLUGINS),
// TODO: Write fetchByKeys
}),
);
// For now, we fetch all the plugins at the same time.
// In the future it would be nice to fetch on an as-needed basis.
@ -171,6 +212,13 @@ export const DynamicPluginProvider: React.FC = ({ children }) => {
if (isFeatureEnabled(FeatureFlag.DYNAMIC_PLUGINS)) {
fetchAll();
}
const registryListener = () => {
dispatch({ type: 'changed keys' });
};
metadataRegistry.addListener(registryListener);
return () => {
metadataRegistry.removeListener(registryListener);
};
}, []);
return (

View File

@ -25,7 +25,7 @@ import { styled, t, css, useTheme } from '@superset-ui/core';
import { debounce } from 'lodash';
import { Resizable } from 're-resizable';
import { useDynamicPluginContext } from 'src/components/DynamicPlugins';
import { usePluginContext } from 'src/components/DynamicPlugins';
import { Global } from '@emotion/react';
import { Tooltip } from 'src/components/Tooltip';
import { usePrevious } from 'src/common/hooks/usePrevious';
@ -159,9 +159,9 @@ function useWindowSize({ delayMs = 250 } = {}) {
}
function ExploreViewContainer(props) {
const dynamicPluginContext = useDynamicPluginContext();
const dynamicPlugin = dynamicPluginContext.plugins[props.vizType];
const isDynamicPluginLoading = dynamicPlugin && dynamicPlugin.loading;
const dynamicPluginContext = usePluginContext();
const dynamicPlugin = dynamicPluginContext.dynamicPlugins[props.vizType];
const isDynamicPluginLoading = dynamicPlugin && dynamicPlugin.mounting;
const wasDynamicPluginLoading = usePrevious(isDynamicPluginLoading);
const previousControls = usePrevious(props.controls);

View File

@ -1,54 +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 '../../../../../stylesheets/less/variables.less';
.viztype-label {
margin-top: 10px;
text-align: center;
font-size: @font-size-m;
}
.viztype-selector-container {
cursor: pointer;
margin-top: 10px;
margin-bottom: 10px;
&:hover img {
border: 1px solid @gray-heading;
}
&.selected {
cursor: not-allowed;
opacity: 1;
img {
border: 1px solid @almost-black;
}
}
img {
border: 1px solid @gray-light;
border-radius: @border-radius-large;
transition: border-color @timing-normal;
}
}
.viztype-control-search-box {
margin-bottom: 10px;
}

View File

@ -17,7 +17,7 @@
* under the License.
*/
import { Preset } from '@superset-ui/core';
import { render, cleanup, screen } from 'spec/helpers/testing-library';
import { render, cleanup, screen, act } from 'spec/helpers/testing-library';
import { Provider } from 'react-redux';
import {
getMockStore,
@ -26,6 +26,7 @@ import {
} 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 {
EchartsMixedTimeseriesChartPlugin,
@ -57,6 +58,15 @@ class MainPreset extends Preset {
const getTestId = testWithId<string>(VIZ_TYPE_CONTROL_TEST_ID, true);
/**
* 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', () => {
new MainPreset().register();
const newVizTypeControlProps = {
@ -64,21 +74,26 @@ describe('VizTypeControl', () => {
label: '',
name: '',
value: '',
labelType: '',
labelType: 'primary',
onChange: jest.fn(),
};
isModalOpenInit: true,
} as const;
const renderWrapper = (
const renderWrapper = async (
props = newVizTypeControlProps,
state: object = stateWithoutNativeFilters,
) =>
) => {
render(
<Provider
store={state ? getMockStore(stateWithoutNativeFilters) : mockStore}
>
<VizTypeControl {...props} />
<DynamicPluginProvider>
<VizTypeControl {...props} />
</DynamicPluginProvider>
</Provider>,
);
await waitForEffects();
};
afterEach(() => {
cleanup();
@ -86,14 +101,13 @@ describe('VizTypeControl', () => {
});
it('Search visualization type', async () => {
renderWrapper();
await renderWrapper();
const visualizations = screen.getByTestId(getTestId('viz-row'));
userEvent.click(screen.getByRole('button', { name: 'Table' }));
expect(visualizations).toHaveTextContent(/Time-series Table/);
expect(visualizations).toHaveTextContent(/Time-series Chart/);
expect(visualizations).toHaveTextContent(/Mixed timeseries chart/);
expect(visualizations).toHaveTextContent(/Line Chart/);
const searchInputText = 'time series';
@ -102,6 +116,7 @@ describe('VizTypeControl', () => {
screen.getByTestId(getTestId('search-input')),
searchInputText,
);
await waitForEffects();
expect(visualizations).toHaveTextContent(/Time-series Table/);
expect(visualizations).toHaveTextContent(/Time-series Chart/);

View File

@ -0,0 +1,562 @@
/**
* 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, {
ChangeEventHandler,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import Fuse from 'fuse.js';
import {
t,
styled,
css,
ChartMetadata,
SupersetTheme,
useTheme,
} from '@superset-ui/core';
import { Input } from 'src/common/components';
import { usePluginContext } from 'src/components/DynamicPlugins';
import Icons from 'src/components/Icons';
import { nativeFilterGate } from 'src/dashboard/components/nativeFilters/utils';
interface VizTypeGalleryProps {
onChange: (vizType: string | null) => void;
selectedViz: string | null;
className?: string;
}
type VizEntry = {
key: string;
value: ChartMetadata;
};
const DEFAULT_ORDER = [
'line',
'big_number',
'table',
'filter_box',
'dist_bar',
'area',
'bar',
'deck_polygon',
'pie',
'time_table',
'pivot_table_v2',
'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);
const THUMBNAIL_GRID_UNITS = 24;
export const MAX_ADVISABLE_VIZ_GALLERY_WIDTH = 1090;
const OTHER_CATEGORY = t('Other');
export const VIZ_TYPE_CONTROL_TEST_ID = 'viz-type-control';
const VizPickerLayout = styled.div`
display: grid;
grid-template-rows: minmax(100px, 1fr) minmax(200px, 35%);
grid-template-columns: 1fr 5fr;
grid-template-areas:
'sidebar main'
'details details';
height: 70vh;
overflow: auto;
`;
const SectionTitle = styled.h3`
margin-top: 0;
margin-bottom: ${({ theme }) => theme.gridUnit * 2}px;
font-size: ${({ theme }) => theme.typography.sizes.l}px;
font-weight: ${({ theme }) => theme.typography.weights.bold};
line-height: ${({ theme }) => theme.gridUnit * 6}px;
`;
const LeftPane = styled.div`
grid-area: sidebar;
display: flex;
flex-direction: column;
border-right: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
padding: ${({ theme }) => theme.gridUnit * 2}px;
overflow: hidden;
`;
const CategoriesWrapper = styled.div`
display: flex;
flex-direction: column;
overflow: auto;
`;
const SearchWrapper = styled.div`
${({ theme }) => `
margin-bottom: ${theme.gridUnit * 2}px;
input {
font-size: ${theme.typography.sizes.s};
}
.ant-input-affix-wrapper {
padding-left: ${theme.gridUnit * 2}px;
}
`}
`;
/** Styles to line up prefix/suffix icons in the search input */
const InputIconAlignment = styled.div`
display: flex;
justify-content: center;
align-items: center;
color: ${({ theme }) => theme.colors.grayscale.base};
`;
const CategoryLabel = styled.button`
${({ theme }) => `
all: unset; // remove default button styles
cursor: pointer;
padding: ${theme.gridUnit}px;
border-radius: ${theme.borderRadius}px;
line-height: 2em;
font-size: ${theme.typography.sizes.s};
&:focus {
outline: initial;
}
&.selected {
background-color: ${theme.colors.secondary.light4};
}
`}
`;
const IconsPane = styled.div`
grid-area: main;
overflow: auto;
display: grid;
grid-template-columns: repeat(
auto-fill,
${({ theme }) => theme.gridUnit * THUMBNAIL_GRID_UNITS}px
);
grid-auto-rows: max-content;
justify-content: space-evenly;
grid-gap: ${({ theme }) => theme.gridUnit * 2}px;
justify-items: center;
// for some reason this padding doesn't seem to apply at the bottom of the container. Why is a mystery.
padding: ${({ theme }) => theme.gridUnit * 2}px;
`;
const DetailsPane = (theme: SupersetTheme) => css`
grid-area: details;
border-top: 1px solid ${theme.colors.grayscale.light2};
`;
const DetailsPopulated = (theme: SupersetTheme) => css`
padding: ${theme.gridUnit * 4}px;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
'viz-name examples-header'
'description examples';
`;
const DetailsEmpty = (theme: SupersetTheme) => css`
display: flex;
justify-content: center;
align-items: center;
text-align: center;
font-style: italic;
color: ${theme.colors.grayscale.light1};
`;
// overflow hidden on the details pane and overflow auto on the description
// (plus grid layout) enables the description to scroll while the header stays in place.
const Description = styled.p`
grid-area: description;
overflow: auto;
padding-right: ${({ theme }) => theme.gridUnit * 14}px;
`;
const Examples = styled.div`
grid-area: examples;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
overflow: auto;
gap: ${({ theme }) => theme.gridUnit * 4}px;
img {
height: 100%;
border-radius: ${({ theme }) => theme.gridUnit}px;
border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
}
`;
const thumbnailContainerCss = (theme: SupersetTheme) => css`
cursor: pointer;
width: ${theme.gridUnit * THUMBNAIL_GRID_UNITS}px;
img {
min-width: ${theme.gridUnit * THUMBNAIL_GRID_UNITS}px;
min-height: ${theme.gridUnit * THUMBNAIL_GRID_UNITS}px;
border: 1px solid ${theme.colors.grayscale.light2};
border-radius: ${theme.gridUnit}px;
transition: border-color ${theme.transitionTiming};
}
&.selected img {
border: 2px solid ${theme.colors.primary.light2};
}
&:hover:not(.selected) img {
border: 1px solid ${theme.colors.grayscale.light1};
}
.viztype-label {
margin-top: ${theme.gridUnit * 2}px;
text-align: center;
}
`;
function vizSortFactor(entry: VizEntry) {
if (typesWithDefaultOrder.has(entry.key)) {
return DEFAULT_ORDER.indexOf(entry.key);
}
return DEFAULT_ORDER.length;
}
interface ThumbnailProps {
entry: VizEntry;
selectedViz: string | null;
setSelectedViz: (viz: string) => void;
}
const Thumbnail: React.FC<ThumbnailProps> = ({
entry,
selectedViz,
setSelectedViz,
}) => {
const theme = useTheme();
const { key, value: type } = entry;
const isSelected = selectedViz === entry.key;
return (
<div
role="button"
// using css instead of a styled component to preserve
// the data-test attribute
css={thumbnailContainerCss(theme)}
tabIndex={0}
className={isSelected ? 'selected' : ''}
onClick={() => setSelectedViz(key)}
data-test="viztype-selector-container"
>
<img
alt={type.name}
width="100%"
className={`viztype-selector ${isSelected ? 'selected' : ''}`}
src={type.thumbnail}
/>
<div
className="viztype-label"
data-test={`${VIZ_TYPE_CONTROL_TEST_ID}__viztype-label`}
>
{type.name}
</div>
</div>
);
};
interface ThumbnailGalleryProps {
vizEntries: VizEntry[];
selectedViz: string | null;
setSelectedViz: (viz: string) => void;
}
/** A list of viz thumbnails, used within the viz picker modal */
const ThumbnailGallery: React.FC<ThumbnailGalleryProps> = ({
vizEntries,
...props
}) => (
<IconsPane data-test={`${VIZ_TYPE_CONTROL_TEST_ID}__viz-row`}>
{vizEntries.map(entry => (
<Thumbnail key={entry.key} {...props} entry={entry} />
))}
</IconsPane>
);
const CategorySelector: React.FC<{
category: string;
isSelected: boolean;
onClick: (category: string) => void;
}> = ({ category, isSelected, onClick }) => (
<CategoryLabel
key={category}
name={category}
className={isSelected ? 'selected' : ''}
onClick={() => onClick(category)}
>
{category}
</CategoryLabel>
);
const doesVizMatchCategory = (viz: ChartMetadata, category: string) =>
category === viz.category ||
(category === OTHER_CATEGORY && viz.category == null);
export default function VizTypeGallery(props: VizTypeGalleryProps) {
const { selectedViz, onChange, className } = props;
const { mountedPluginMetadata } = usePluginContext();
const searchInputRef = useRef<HTMLInputElement>();
const [searchInputValue, setSearchInputValue] = useState('');
const [isSearchFocused, setIsSearchFocused] = useState(false);
const isActivelySearching = isSearchFocused && !!searchInputValue;
const selectedVizMetadata: ChartMetadata | null = selectedViz
? mountedPluginMetadata[selectedViz]
: null;
const chartMetadata: VizEntry[] = useMemo(() => {
const result = Object.entries(mountedPluginMetadata)
.map(([key, value]) => ({ key, value }))
.filter(({ value }) => nativeFilterGate(value.behaviors || []));
result.sort((a, b) => vizSortFactor(a) - vizSortFactor(b));
return result;
}, [mountedPluginMetadata]);
const chartsByCategory = useMemo(() => {
const result: Record<string, VizEntry[]> = {};
chartMetadata.forEach(entry => {
const category = entry.value.category || OTHER_CATEGORY;
if (!result[category]) {
result[category] = [];
}
result[category].push(entry);
});
return result;
}, [chartMetadata]);
const categories = useMemo(
() =>
Object.keys(chartsByCategory).sort((a, b) => {
// make sure Other goes at the end
if (a === OTHER_CATEGORY) return 1;
if (b === OTHER_CATEGORY) return -1;
// sort alphabetically
return a.localeCompare(b);
}),
[chartsByCategory],
);
const [activeCategory, setActiveCategory] = useState<string>(
() => selectedVizMetadata?.category || categories[0],
);
// get a fuse instance for fuzzy search
const fuse = useMemo(
() =>
new Fuse(chartMetadata, {
ignoreLocation: true,
threshold: 0.3,
keys: ['value.name', 'value.tags', 'value.description'],
}),
[chartMetadata],
);
const searchResults = useMemo(() => {
if (searchInputValue.trim() === '') {
return [];
}
return fuse.search(searchInputValue).map(result => result.item);
}, [searchInputValue, fuse]);
const focusSearch = useCallback(() => {
// "start searching" is actually a two-stage process.
// When you first click on the search bar, the input is focused and nothing else happens.
// Once you begin typing, the selected category is cleared and the displayed viz entries change.
setIsSearchFocused(true);
}, []);
const changeSearch: ChangeEventHandler<HTMLInputElement> = useCallback(
event => setSearchInputValue(event.target.value),
[],
);
const stopSearching = useCallback(() => {
// stopping a search takes you back to the category you were looking at before.
// Unlike focusSearch, this is a simple one-step process.
setIsSearchFocused(false);
setSearchInputValue('');
searchInputRef.current!.blur();
}, []);
const selectCategory = useCallback(
(key: string) => {
if (isSearchFocused) {
stopSearching();
}
setActiveCategory(key);
// clear the selected viz if it is not present in the new category
const isSelectedVizCompatible =
selectedVizMetadata && doesVizMatchCategory(selectedVizMetadata, key);
if (key !== activeCategory && !isSelectedVizCompatible) {
onChange(null);
}
},
[
stopSearching,
isSearchFocused,
activeCategory,
selectedVizMetadata,
onChange,
],
);
const vizEntriesToDisplay = isActivelySearching
? searchResults
: chartsByCategory[activeCategory] || [];
return (
<VizPickerLayout className={className}>
<LeftPane>
<SearchWrapper>
<Input
type="text"
ref={searchInputRef as any /* cast required because emotion */}
value={searchInputValue}
placeholder={t('Search')}
onChange={changeSearch}
onFocus={focusSearch}
data-test={`${VIZ_TYPE_CONTROL_TEST_ID}__search-input`}
prefix={
<InputIconAlignment>
<Icons.Search iconSize="m" />
</InputIconAlignment>
}
suffix={
<InputIconAlignment>
{searchInputValue && (
<Icons.XLarge iconSize="m" onClick={stopSearching} />
)}
</InputIconAlignment>
}
/>
</SearchWrapper>
<CategoriesWrapper>
{categories.map(category => (
<CategorySelector
key={category}
category={category}
isSelected={!isActivelySearching && category === activeCategory}
onClick={selectCategory}
/>
))}
</CategoriesWrapper>
</LeftPane>
<ThumbnailGallery
vizEntries={vizEntriesToDisplay}
selectedViz={selectedViz}
setSelectedViz={onChange}
/>
{selectedVizMetadata ? (
<div
css={(theme: SupersetTheme) => [
DetailsPane(theme),
DetailsPopulated(theme),
]}
>
<>
<SectionTitle
css={css`
grid-area: viz-name;
`}
>
{selectedVizMetadata?.name}
</SectionTitle>
<Description>
{selectedVizMetadata?.description ||
t('No description available.')}
</Description>
<SectionTitle
css={css`
grid-area: examples-header;
`}
>
{!!selectedVizMetadata?.exampleGallery?.length && t('Examples')}
</SectionTitle>
<Examples>
{(selectedVizMetadata?.exampleGallery || []).map(example => (
<img
src={example.url}
alt={example.caption}
title={example.caption}
/>
))}
</Examples>
</>
</div>
) : (
<div
css={(theme: SupersetTheme) => [
DetailsPane(theme),
DetailsEmpty(theme),
]}
>
{t('Select a visualization type')}
</div>
)}
</VizPickerLayout>
);
}

View File

@ -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 (
<div className="text-danger">
<i className="fa fa-exclamation-circle text-danger" />{' '}
<small>{t('This visualization type is not supported.')}</small>
</div>
);
}
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 (
<div
role="button"
tabIndex={0}
className={`viztype-selector-container ${isSelected ? 'selected' : ''}`}
onClick={() => onChange(key)}
>
<img
alt={type.name}
width="100%"
className={`viztype-selector ${isSelected ? 'selected' : ''}`}
src={type.thumbnail}
/>
<div
className="viztype-label"
data-test={`${VIZ_TYPE_CONTROL_TEST_ID}__viztype-label`}
>
{type.name}
</div>
</div>
);
};
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 (
<div>
<ControlHeader {...props} />
<Tooltip
id="error-tooltip"
placement="right"
title={t('Click to change visualization type')}
>
<>
<Label
onClick={toggleModal}
type={labelType}
data-test="visualization-type"
>
{registry.has(value) ? registry.get(value).name : `${value}`}
</Label>
<VizSupportValidation vizType={value} />
</>
</Tooltip>
<Modal
show={showModal}
onHide={toggleModal}
title={t('Select a visualization type')}
responsive
hideFooter
forceRender
>
<div className="viztype-control-search-box">
<Input
ref={searchRef}
type="text"
value={filter}
placeholder={t('Search')}
onChange={changeSearch}
data-test={`${VIZ_TYPE_CONTROL_TEST_ID}__search-input`}
/>
</div>
<Row data-test={`${VIZ_TYPE_CONTROL_TEST_ID}__viz-row`} gutter={16}>
{filteredTypes.map(entry => (
<Col xs={12} sm={8} md={6} lg={4} key={`grid-col-${entry.key}`}>
{renderItem(entry)}
</Col>
))}
</Row>
</Modal>
</div>
);
};
VizTypeControl.propTypes = propTypes;
VizTypeControl.defaultProps = defaultProps;
export default VizTypeControl;

View File

@ -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 (
<div className="text-danger">
<i className="fa fa-exclamation-circle text-danger" />{' '}
<small>{t('This visualization type is not supported.')}</small>
</div>
);
}
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<string | null>(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 (
<div>
<ControlHeader {...props} />
<Tooltip
id="error-tooltip"
placement="right"
title={t('Click to change visualization type')}
>
<>
<Label
onClick={openModal}
type={labelType}
data-test="visualization-type"
>
{labelContent}
</Label>
{initialValue && <VizSupportValidation vizType={initialValue} />}
</>
</Tooltip>
<UnpaddedModal
show={showModal}
onHide={onCancel}
title={t('Select a visualization type')}
primaryButtonName={t('Select')}
disablePrimaryButton={!selectedViz}
onHandledPrimaryAction={onSubmit}
maxWidth={`${MAX_ADVISABLE_VIZ_GALLERY_WIDTH}px`}
responsive
>
{/* When the key increments, it forces react to re-init the gallery component */}
<VizTypeGallery
key={modalKey}
selectedViz={selectedViz}
onChange={setSelectedViz}
/>
</UnpaddedModal>
</div>
);
};
VizTypeControl.propTypes = propTypes;
VizTypeControl.defaultProps = defaultProps;
export default VizTypeControl;

View File

@ -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,
});

View File

@ -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,
},