chore: implement new mockup to the new viz gallery (2nd iteration) (#15868)

* chore: implement new mockup to the new viz gallery

* fix: update package-lock

* fix: add license

* fix: reduce duplication and fit within the sidebar

* fix: ut
This commit is contained in:
Yaozong Liu
2021-07-27 02:10:39 +08:00
committed by GitHub
parent 2f95f81be7
commit fb5dce07b7
9 changed files with 315 additions and 109 deletions

View File

@@ -0,0 +1,22 @@
<!--
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.
-->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.66667 5H12V6.33333H8.66667V5ZM8.66667 9.66667H12V11H8.66667V9.66667ZM12.6667 2H3.33333C2.6 2 2 2.6 2 3.33333V12.6667C2 13.4 2.6 14 3.33333 14H12.6667C13.4 14 14 13.4 14 12.6667V3.33333C14 2.6 13.4 2 12.6667 2ZM12.6667 12.6667H3.33333V3.33333H12.6667V12.6667ZM7.33333 4H4V7.33333H7.33333V4ZM6.66667 6.66667H4.66667V4.66667H6.66667V6.66667ZM7.33333 8.66667H4V12H7.33333V8.66667ZM6.66667 11.3333H4.66667V9.33333H6.66667V11.3333Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,22 @@
<!--
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.
-->
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.73967 1.7002L4.04888 7.79629H11.4305L7.73967 1.7002ZM7.73967 4.3012L9.0348 6.4416H6.43783L7.73967 4.3012ZM11.4305 9.15098C9.75954 9.15098 8.41072 10.5124 8.41072 12.199C8.41072 13.8856 9.75954 15.2471 11.4305 15.2471C13.1014 15.2471 14.4502 13.8856 14.4502 12.199C14.4502 10.5124 13.1014 9.15098 11.4305 9.15098ZM11.4305 13.8924C10.5044 13.8924 9.75283 13.1338 9.75283 12.199C9.75283 11.2643 10.5044 10.5057 11.4305 10.5057C12.3565 10.5057 13.1081 11.2643 13.1081 12.199C13.1081 13.1338 12.3565 13.8924 11.4305 13.8924ZM1.7002 14.9084H7.06862V9.48965H1.7002V14.9084ZM3.0423 10.8443H5.72651V13.5537H3.0423V10.8443Z" fill="currentColor" fill-opacity="0.85"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,22 @@
<!--
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.
-->
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.1668 7.0835V5.66683H11.3335V2.8335H9.91683V5.66683H7.0835V2.8335H5.66683V5.66683H2.8335V7.0835H5.66683V9.91683H2.8335V11.3335H5.66683V14.1668H7.0835V11.3335H9.91683V14.1668H11.3335V11.3335H14.1668V9.91683H11.3335V7.0835H14.1668ZM9.91683 9.91683H7.0835V7.0835H9.91683V9.91683Z" fill="currentColor" fill-opacity="0.85"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

View File

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

View File

@@ -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();

View File

@@ -150,6 +150,9 @@ const IconFileNames = [
'warning_solid',
'x-large',
'x-small',
'tags',
'ballot',
'category',
];
const iconOverrides: Record<string, React.FC> = {};

View File

@@ -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/);

View File

@@ -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<ThumbnailGalleryProps> = ({
</IconsPane>
);
const CategorySelector: React.FC<{
category: string;
const Selector: React.FC<{
selector: string;
icon: JSX.Element;
isSelected: boolean;
onClick: (category: string) => void;
}> = ({ category, isSelected, onClick }) => (
<CategoryLabel
key={category}
name={category}
className={isSelected ? 'selected' : ''}
onClick={() => onClick(category)}
>
{category}
</CategoryLabel>
);
onClick: (selector: string) => void;
onClear: (e: React.MouseEvent) => void;
}> = ({ selector, icon, isSelected, onClick, onClear }) => {
const btnRef = useRef<HTMLButtonElement>(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 (
<SelectorLabel
ref={btnRef}
key={selector}
name={selector}
className={isSelected ? 'selected' : ''}
onClick={() => onClick(selector)}
>
{icon}
{selector}
<CloseOutlined className="cancel" onClick={onClear} />
</SelectorLabel>
);
};
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<HTMLInputElement>();
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<string>(
() => selectedVizMetadata?.category || categories[0],
const chartsByTags = useMemo(() => {
const result: Record<string, VizEntry[]> = {};
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<string>(
() => 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: <Icons.Tags />,
selectors: RECOMMENDED_TAGS,
},
ALL: {
title: t('All'),
icon: <Icons.Ballot />,
selectors: [ALL_CHARTS],
},
CATEGORY: {
title: t('Category'),
icon: <Icons.Category />,
selectors: categories,
},
TAGS: {
title: t('Tags'),
icon: <Icons.Tags />,
selectors: tags,
},
}),
[categories, tags],
);
const vizEntriesToDisplay = isActivelySearching
? searchResults
: chartsByCategory[activeCategory] || [];
: activeSelector === ALL_CHARTS
? sortedMetadata
: chartsByCategory[activeSelector] || chartsByTags[activeSelector] || [];
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>
<Collapse
expandIconPosition="right"
ghost
defaultActiveKey={Object.keys(sectionMap)}
>
{Object.keys(sectionMap).map(key => {
const section = sectionMap[key];
return (
<Collapse.Panel
header={<span className="header">{section.title}</span>}
key={key}
>
{section.selectors.map((selector: string) => (
<Selector
selector={selector}
icon={section.icon}
isSelected={
!isActivelySearching && selector === activeSelector
}
onClick={clickSelector}
onClear={clearSelector}
/>
))}
</Collapse.Panel>
);
})}
</Collapse>
</LeftPane>
<AllTagsWrapper>
{ALL_TAGS.map(tag => (
<Label
key={tag}
onClick={() => {
focusSearch();
setSearchInputValue(tag);
}}
>
{tag}
</Label>
))}
</AllTagsWrapper>
<SearchWrapper>
<Input
type="text"
ref={searchInputRef as any /* cast required because emotion */}
value={searchInputValue}
placeholder={t('Search all charts')}
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>
<RightPane>
<ThumbnailGallery