diff --git a/superset-frontend/images/icons/ballot.svg b/superset-frontend/images/icons/ballot.svg new file mode 100644 index 00000000000..00d1a235ce1 --- /dev/null +++ b/superset-frontend/images/icons/ballot.svg @@ -0,0 +1,22 @@ + + + + + diff --git a/superset-frontend/images/icons/category.svg b/superset-frontend/images/icons/category.svg new file mode 100644 index 00000000000..471cf254d02 --- /dev/null +++ b/superset-frontend/images/icons/category.svg @@ -0,0 +1,22 @@ + + + + + diff --git a/superset-frontend/images/icons/tags.svg b/superset-frontend/images/icons/tags.svg new file mode 100644 index 00000000000..36a061ecf33 --- /dev/null +++ b/superset-frontend/images/icons/tags.svg @@ -0,0 +1,22 @@ + + + + + diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index c4554977fad..4314db65e2d 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -67596,7 +67596,8 @@ "compute-scroll-into-view": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.16.tgz", - "integrity": "sha512-a85LHKY81oQnikatZYA90pufpZ6sQx++BoCxOEMsjpZx+ZnaKGQnCyCehTRr/1p9GBIAHTjcU9k71kSYWloLiQ==" + "integrity": "sha512-a85LHKY81oQnikatZYA90pufpZ6sQx++BoCxOEMsjpZx+ZnaKGQnCyCehTRr/1p9GBIAHTjcU9k71kSYWloLiQ==", + "dev": true }, "concat-map": { "version": "0.0.1", @@ -87305,11 +87306,18 @@ } }, "scroll-into-view-if-needed": { - "version": "2.2.26", - "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.26.tgz", - "integrity": "sha512-SQ6AOKfABaSchokAmmaxVnL9IArxEnLEX9j4wAZw+x4iUTb40q7irtHG3z4GtAWz5veVZcCnubXDBRyLVQaohw==", + "version": "2.2.28", + "resolved": "https://registry.npm.taobao.org/scroll-into-view-if-needed/download/scroll-into-view-if-needed-2.2.28.tgz", + "integrity": "sha1-WhWy9YpSZCyIyOylhGROAXA9ZFo=", "requires": { - "compute-scroll-into-view": "^1.0.16" + "compute-scroll-into-view": "^1.0.17" + }, + "dependencies": { + "compute-scroll-into-view": { + "version": "1.0.17", + "resolved": "https://registry.npm.taobao.org/compute-scroll-into-view/download/compute-scroll-into-view-1.0.17.tgz?cache=0&sync_timestamp=1614042424875&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fcompute-scroll-into-view%2Fdownload%2Fcompute-scroll-into-view-1.0.17.tgz", + "integrity": "sha1-aojxis2dQunPS6pr7H4FImB6t6s=" + } } }, "seedrandom": { diff --git a/superset-frontend/package.json b/superset-frontend/package.json index de78593199c..18d791fd25f 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -179,6 +179,7 @@ "redux-undo": "^1.0.0-beta9-9-7", "regenerator-runtime": "^0.13.5", "rison": "^0.1.1", + "scroll-into-view-if-needed": "^2.2.28", "shortid": "^2.2.6", "urijs": "^1.19.6", "use-immer": "^0.4.2", diff --git a/superset-frontend/spec/javascripts/explore/components/VizTypeControl_spec.jsx b/superset-frontend/spec/javascripts/explore/components/VizTypeControl_spec.jsx index 1258b808528..02cbdac19c5 100644 --- a/superset-frontend/spec/javascripts/explore/components/VizTypeControl_spec.jsx +++ b/superset-frontend/spec/javascripts/explore/components/VizTypeControl_spec.jsx @@ -84,7 +84,7 @@ describe('VizTypeControl', () => { const thumbnails = screen.getByTestId('viztype-selector-container'); expect(thumbnails).toBeInTheDocument(); - const searchInput = screen.getByPlaceholderText('Search'); + const searchInput = screen.getByPlaceholderText('Search all charts'); userEvent.type(searchInput, 'foo'); await waitForEffects(); diff --git a/superset-frontend/src/components/Icons/index.tsx b/superset-frontend/src/components/Icons/index.tsx index 6eed2e8f7f0..f2b19445f7a 100644 --- a/superset-frontend/src/components/Icons/index.tsx +++ b/superset-frontend/src/components/Icons/index.tsx @@ -150,6 +150,9 @@ const IconFileNames = [ 'warning_solid', 'x-large', 'x-small', + 'tags', + 'ballot', + 'category', ]; const iconOverrides: Record = {}; diff --git a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeControl.test.tsx b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeControl.test.tsx index a8534ccb497..cf2a200a262 100644 --- a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeControl.test.tsx +++ b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeControl.test.tsx @@ -105,7 +105,9 @@ describe('VizTypeControl', () => { const visualizations = screen.getByTestId(getTestId('viz-row')); - userEvent.click(screen.getByRole('button', { name: 'Table' })); + userEvent.click( + screen.getByRole('button', { name: 'category Table close' }), + ); expect(visualizations).toHaveTextContent(/Time-series Table/); diff --git a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx index 746bda85e04..805f8cb8479 100644 --- a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx +++ b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx @@ -19,6 +19,7 @@ import React, { ChangeEventHandler, useCallback, + useEffect, useMemo, useRef, useState, @@ -32,11 +33,13 @@ import { SupersetTheme, useTheme, } from '@superset-ui/core'; -import { Input } from 'src/common/components'; +import { Collapse, Input } from 'src/common/components'; import Label from 'src/components/Label'; import { usePluginContext } from 'src/components/DynamicPlugins'; import Icons from 'src/components/Icons'; import { nativeFilterGate } from 'src/dashboard/components/nativeFilters/utils'; +import { CloseOutlined } from '@ant-design/icons'; +import scrollIntoView from 'scroll-into-view-if-needed'; interface VizTypeGalleryProps { onChange: (vizType: string | null) => void; @@ -96,8 +99,6 @@ const DEFAULT_ORDER = [ 'country_map', ]; -const ALL_TAGS = [t('Highly-used'), t('Text'), t('Trend'), t('Formattable')]; - const typesWithDefaultOrder = new Set(DEFAULT_ORDER); const THUMBNAIL_GRID_UNITS = 24; @@ -106,16 +107,22 @@ export const MAX_ADVISABLE_VIZ_GALLERY_WIDTH = 1090; const OTHER_CATEGORY = t('Other'); -const DEFAULT_SEARCH_INPUT_VALUE = t('Highly-used'); +const ALL_CHARTS = t('All charts'); + +const RECOMMENDED_TAGS = [ + t('Highly-used'), + t('ECharts'), + t('Advanced-Analytics'), +]; export const VIZ_TYPE_CONTROL_TEST_ID = 'viz-type-control'; const VizPickerLayout = styled.div` display: grid; grid-template-rows: auto minmax(100px, 1fr) minmax(200px, 35%); - grid-template-columns: 1fr 5fr; + grid-template-columns: auto 5fr; grid-template-areas: - 'sidebar tags' + 'sidebar search' 'sidebar main' 'details details'; height: 70vh; @@ -135,8 +142,21 @@ const LeftPane = styled.div` display: flex; flex-direction: column; border-right: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; - padding: ${({ theme }) => theme.gridUnit * 2}px; - overflow: hidden; + overflow: auto; + + .ant-collapse .ant-collapse-item { + .ant-collapse-header { + font-size: ${({ theme }) => theme.typography.sizes.s}px; + color: ${({ theme }) => theme.colors.grayscale.base}; + padding-left: ${({ theme }) => theme.gridUnit * 2}px; + padding-bottom: ${({ theme }) => theme.gridUnit}px; + } + .ant-collapse-content .ant-collapse-content-box { + display: flex; + flex-direction: column; + padding: 0 ${({ theme }) => theme.gridUnit * 2}px; + } + } `; const RightPane = styled.div` @@ -144,25 +164,10 @@ const RightPane = styled.div` overflow-y: scroll; `; -const AllTagsWrapper = styled.div` - ${({ theme }) => ` - grid-area: tags; - margin: ${theme.gridUnit * 4}px ${theme.gridUnit * 2}px 0; - input { - font-size: ${theme.typography.sizes.s}; - } - `} -`; - -const CategoriesWrapper = styled.div` - display: flex; - flex-direction: column; - overflow: auto; -`; - const SearchWrapper = styled.div` ${({ theme }) => ` - margin-bottom: ${theme.gridUnit * 2}px; + grid-area: search; + margin: ${theme.gridUnit * 2}px ${theme.gridUnit * 3}px; input { font-size: ${theme.typography.sizes.s}; } @@ -180,21 +185,48 @@ const InputIconAlignment = styled.div` color: ${({ theme }) => theme.colors.grayscale.base}; `; -const CategoryLabel = styled.button` +const SelectorLabel = styled.button` ${({ theme }) => ` all: unset; // remove default button styles cursor: pointer; - padding: ${theme.gridUnit}px; + margin: ${theme.gridUnit}px 0; + padding: 0 ${theme.gridUnit * 6}px 0 ${theme.gridUnit}px; border-radius: ${theme.borderRadius}px; line-height: 2em; font-size: ${theme.typography.sizes.s}; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + position: relative; &:focus { outline: initial; } &.selected { - background-color: ${theme.colors.secondary.light4}; + background-color: ${theme.colors.primary.dark1}; + color: ${theme.colors.primary.light5}; + + svg { + color: ${theme.colors.primary.light5}; + } + + &:hover { + .cancel { + visibility: visible; + } + } + } + + svg { + position: relative; + top: ${theme.gridUnit * 2}px + } + + .cancel { + visibility: hidden; + position: absolute; + right: ${theme.gridUnit * 2}px; } `} `; @@ -362,32 +394,54 @@ const ThumbnailGallery: React.FC = ({ ); -const CategorySelector: React.FC<{ - category: string; +const Selector: React.FC<{ + selector: string; + icon: JSX.Element; isSelected: boolean; - onClick: (category: string) => void; -}> = ({ category, isSelected, onClick }) => ( - onClick(category)} - > - {category} - -); + onClick: (selector: string) => void; + onClear: (e: React.MouseEvent) => void; +}> = ({ selector, icon, isSelected, onClick, onClear }) => { + const btnRef = useRef(null); -const doesVizMatchCategory = (viz: ChartMetadata, category: string) => - category === viz.category || - (category === OTHER_CATEGORY && viz.category == null); + // see Element.scrollIntoViewIfNeeded() + // see: https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoViewIfNeeded + useEffect(() => { + if (isSelected) { + // We need to wait for the modal to open and then scroll, so we put it in the microtask queue + queueMicrotask(() => + scrollIntoView(btnRef.current as HTMLButtonElement, { + behavior: 'smooth', + scrollMode: 'if-needed', + }), + ); + } + }, []); + + return ( + onClick(selector)} + > + {icon} + {selector} + + + ); +}; + +const doesVizMatchSelector = (viz: ChartMetadata, selector: string) => + selector === viz.category || + (selector === OTHER_CATEGORY && viz.category == null) || + (viz.tags || []).indexOf(selector) > -1; export default function VizTypeGallery(props: VizTypeGalleryProps) { const { selectedViz, onChange, className } = props; const { mountedPluginMetadata } = usePluginContext(); const searchInputRef = useRef(); - const [searchInputValue, setSearchInputValue] = useState( - DEFAULT_SEARCH_INPUT_VALUE, - ); + const [searchInputValue, setSearchInputValue] = useState(''); const [isSearchFocused, setIsSearchFocused] = useState(true); const isActivelySearching = isSearchFocused && !!searchInputValue; @@ -430,8 +484,38 @@ export default function VizTypeGallery(props: VizTypeGalleryProps) { [chartsByCategory], ); - const [activeCategory, setActiveCategory] = useState( - () => selectedVizMetadata?.category || categories[0], + const chartsByTags = useMemo(() => { + const result: Record = {}; + chartMetadata.forEach(entry => { + const tags = entry.value.tags || []; + tags.forEach(tag => { + if (!result[tag]) { + result[tag] = []; + } + result[tag].push(entry); + }); + }); + return result; + }, [chartMetadata]); + + const tags = useMemo( + () => + Object.keys(chartsByTags) + .sort((a, b) => + // sort alphabetically + a.localeCompare(b), + ) + .filter(tag => RECOMMENDED_TAGS.indexOf(tag) === -1), + [chartsByCategory], + ); + + const sortedMetadata = useMemo( + () => chartMetadata.sort((a, b) => a.key.localeCompare(b.key)), + [chartMetadata], + ); + + const [activeSelector, setActiveSelector] = useState( + () => selectedVizMetadata?.category || RECOMMENDED_TAGS[0], ); // get a fuse instance for fuzzy search @@ -472,83 +556,125 @@ export default function VizTypeGallery(props: VizTypeGalleryProps) { searchInputRef.current!.blur(); }, []); - const selectCategory = useCallback( + const clickSelector = useCallback( (key: string) => { if (isSearchFocused) { stopSearching(); } - setActiveCategory(key); - // clear the selected viz if it is not present in the new category + setActiveSelector(key); + // clear the selected viz if it is not present in the new category or tags const isSelectedVizCompatible = - selectedVizMetadata && doesVizMatchCategory(selectedVizMetadata, key); - if (key !== activeCategory && !isSelectedVizCompatible) { + selectedVizMetadata && doesVizMatchSelector(selectedVizMetadata, key); + if (key !== activeSelector && !isSelectedVizCompatible) { onChange(null); } }, [ stopSearching, isSearchFocused, - activeCategory, + activeSelector, selectedVizMetadata, onChange, ], ); + const clearSelector = useCallback(e => { + e.stopPropagation(); + if (isSearchFocused) { + stopSearching(); + } + // clear current selector and set all charts + setActiveSelector(ALL_CHARTS); + }, []); + + const sectionMap = useMemo( + () => ({ + RECOMMENDED_TAGS: { + title: t('Recommended tags'), + icon: , + selectors: RECOMMENDED_TAGS, + }, + ALL: { + title: t('All'), + icon: , + selectors: [ALL_CHARTS], + }, + CATEGORY: { + title: t('Category'), + icon: , + selectors: categories, + }, + TAGS: { + title: t('Tags'), + icon: , + selectors: tags, + }, + }), + [categories, tags], + ); + const vizEntriesToDisplay = isActivelySearching ? searchResults - : chartsByCategory[activeCategory] || []; + : activeSelector === ALL_CHARTS + ? sortedMetadata + : chartsByCategory[activeSelector] || chartsByTags[activeSelector] || []; return ( - - - - - } - suffix={ - - {searchInputValue && ( - - )} - - } - /> - - - {categories.map(category => ( - - ))} - + + {Object.keys(sectionMap).map(key => { + const section = sectionMap[key]; + + return ( + {section.title}} + key={key} + > + {section.selectors.map((selector: string) => ( + + ))} + + ); + })} + - - {ALL_TAGS.map(tag => ( - - ))} - + + + + + } + suffix={ + + {searchInputValue && ( + + )} + + } + /> +