in superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx [438:840]
export default function VizTypeGallery(props: VizTypeGalleryProps) {
const { selectedViz, onChange, onDoubleClick, className, denyList } = props;
const { mountedPluginMetadata } = usePluginContext();
const searchInputRef = useRef<HTMLInputElement>();
const [searchInputValue, setSearchInputValue] = useState('');
const [isSearchFocused, setIsSearchFocused] = useState(true);
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(({ key }) => !denyList.includes(key))
.filter(
({ value }) =>
nativeFilterGate(value.behaviors || []) && !value.deprecated,
)
.sort((a, b) => a.value.name.localeCompare(b.value.name));
return result;
}, [mountedPluginMetadata, denyList]);
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 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),
[chartsByTags],
);
const sortedMetadata = useMemo(
() =>
chartMetadata.sort((a, b) => a.value.name.localeCompare(b.value.name)),
[chartMetadata],
);
const [activeSelector, setActiveSelector] = useState<string>(
() => selectedVizMetadata?.category || FEATURED,
);
const [activeSection, setActiveSection] = useState<string>(() =>
selectedVizMetadata?.category ? Sections.Category : Sections.Featured,
);
// get a fuse instance for fuzzy search
const fuse = useMemo(
() =>
new Fuse(chartMetadata, {
ignoreLocation: true,
threshold: 0.3,
keys: [
{
name: 'value.name',
weight: 4,
},
{
name: 'value.tags',
weight: 2,
},
'value.description',
],
}),
[chartMetadata],
);
const searchResults = useMemo(() => {
if (searchInputValue.trim() === '') {
return [];
}
return fuse
.search(searchInputValue)
.map(result => result.item)
.sort((a, b) => {
const aLabel = a.value?.label;
const bLabel = b.value?.label;
const aOrder =
aLabel && chartLabelWeight[aLabel]
? chartLabelWeight[aLabel].weight
: 0;
const bOrder =
bLabel && chartLabelWeight[bLabel]
? chartLabelWeight[bLabel].weight
: 0;
return bOrder - aOrder;
});
}, [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 clickSelector = useCallback(
(selector: string, sectionId: string) => {
if (isSearchFocused) {
stopSearching();
}
setActiveSelector(selector);
setActiveSection(sectionId);
// clear the selected viz if it is not present in the new category or tags
const isSelectedVizCompatible =
selectedVizMetadata &&
doesVizMatchSelector(selectedVizMetadata, selector);
if (selector !== activeSelector && !isSelectedVizCompatible) {
onChange(null);
}
},
[
stopSearching,
isSearchFocused,
activeSelector,
selectedVizMetadata,
onChange,
],
);
const sectionMap = useMemo(
() => ({
[Sections.Category]: {
title: t('Category'),
icon: <Icons.Category iconSize="m" />,
selectors: categories,
},
[Sections.Tags]: {
title: t('Tags'),
icon: <Icons.NumberOutlined iconSize="m" />,
selectors: tags,
},
}),
[categories, tags],
);
const getVizEntriesToDisplay = () => {
if (isActivelySearching) {
return searchResults;
}
if (activeSelector === ALL_CHARTS && activeSection === Sections.AllCharts) {
return sortedMetadata;
}
if (
activeSelector === FEATURED &&
activeSection === Sections.Featured &&
chartsByTags[FEATURED]
) {
return chartsByTags[FEATURED];
}
if (
activeSection === Sections.Category &&
chartsByCategory[activeSelector]
) {
return chartsByCategory[activeSelector];
}
if (activeSection === Sections.Tags && chartsByTags[activeSelector]) {
return chartsByTags[activeSelector];
}
return [];
};
return (
<VizPickerLayout
className={className}
isSelectedVizMetadata={Boolean(selectedVizMetadata)}
>
<LeftPane>
<Selector
css={({ gridUnit }) =>
// adjust style for not being inside a collapse
css`
margin: ${gridUnit * 2}px;
margin-bottom: 0;
`
}
sectionId={Sections.AllCharts}
selector={ALL_CHARTS}
icon={<Icons.Ballot iconSize="m" />}
isSelected={
!isActivelySearching &&
ALL_CHARTS === activeSelector &&
Sections.AllCharts === activeSection
}
onClick={clickSelector}
/>
<Selector
css={({ gridUnit }) =>
// adjust style for not being inside a collapse
css`
margin: ${gridUnit * 2}px;
margin-bottom: 0;
`
}
sectionId={Sections.Featured}
selector={FEATURED}
icon={<Icons.FireOutlined iconSize="m" />}
isSelected={
!isActivelySearching &&
FEATURED === activeSelector &&
Sections.Featured === activeSection
}
onClick={clickSelector}
/>
<AntdCollapse
expandIconPosition="right"
ghost
defaultActiveKey={Sections.Category}
>
{Object.keys(sectionMap).map(sectionId => {
const section = sectionMap[sectionId as keyof typeof sectionMap];
return (
<AntdCollapse.Panel
header={<span className="header">{section.title}</span>}
key={sectionId}
>
{section.selectors.map((selector: string) => (
<Selector
key={selector}
selector={selector}
sectionId={sectionId}
icon={section.icon}
isSelected={
!isActivelySearching &&
selector === activeSelector &&
sectionId === activeSection
}
onClick={clickSelector}
/>
))}
</AntdCollapse.Panel>
);
})}
</AntdCollapse>
</LeftPane>
<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.SearchOutlined iconSize="m" />
</InputIconAlignment>
}
suffix={
<InputIconAlignment>
{searchInputValue && (
<Icons.CloseOutlined iconSize="m" onClick={stopSearching} />
)}
</InputIconAlignment>
}
/>
</SearchWrapper>
<RightPane>
<ThumbnailGallery
vizEntries={getVizEntriesToDisplay()}
selectedViz={selectedViz}
setSelectedViz={onChange}
onDoubleClick={onDoubleClick}
/>
</RightPane>
{selectedVizMetadata ? (
<div
css={(theme: SupersetTheme) => [
DetailsPane(theme),
DetailsPopulated(theme),
]}
>
<>
<SectionTitle
css={css`
grid-area: viz-name;
position: relative;
`}
>
{selectedVizMetadata?.name}
{selectedVizMetadata?.label && (
<Tooltip
id="viz-badge-tooltip"
placement="top"
title={
selectedVizMetadata.labelExplanation ??
chartLabelExplanations[selectedVizMetadata.label]
}
>
<TitleLabelWrapper>
<HighlightLabel>
<div>{t(selectedVizMetadata.label)}</div>
</HighlightLabel>
</TitleLabelWrapper>
</Tooltip>
)}
</SectionTitle>
<TagsWrapper>
{selectedVizMetadata?.tags.map(tag => (
<Label
key={tag}
css={({ gridUnit }) => css`
margin-bottom: ${gridUnit * 2}px;
`}
>
{tag}
</Label>
))}
</TagsWrapper>
<Description>
{selectedVizMetadata?.description ||
t('No description available.')}
</Description>
<SectionTitle
css={css`
grid-area: examples-header;
`}
>
{t('Examples')}
</SectionTitle>
<Examples>
{(selectedVizMetadata?.exampleGallery?.length
? selectedVizMetadata.exampleGallery
: [
{
url: selectedVizMetadata?.thumbnail,
caption: selectedVizMetadata?.name,
},
]
).map(example => (
<img
key={example.url}
src={example.url}
alt={example.caption}
title={example.caption}
/>
))}
</Examples>
</>
</div>
) : null}
</VizPickerLayout>
);
}