mirror of https://github.com/apache/superset.git
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:
parent
d8a15e60b9
commit
257385e888
|
@ -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({
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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/);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue