airbnb/caravel

View on GitHub
superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx

Summary

Maintainability
F
6 days
Test Coverage
/**
 * 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,
  ReactElement,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import Fuse from 'fuse.js';
import cx from 'classnames';
import {
  t,
  styled,
  css,
  ChartMetadata,
  SupersetTheme,
  useTheme,
  chartLabelWeight,
  chartLabelExplanations,
} from '@superset-ui/core';
import { AntdCollapse } from 'src/components';
import { Tooltip } from 'src/components/Tooltip';
import { Input } from 'src/components/Input';
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 scrollIntoView from 'scroll-into-view-if-needed';

interface VizTypeGalleryProps {
  onChange: (vizType: string | null) => void;
  onDoubleClick: () => void;
  selectedViz: string | null;
  className?: string;
  denyList: string[];
}

type VizEntry = {
  key: string;
  value: ChartMetadata;
};

enum Sections {
  AllCharts = 'ALL_CHARTS',
  Featured = 'FEATURED',
  Category = 'CATEGORY',
  Tags = 'TAGS',
}

const DEFAULT_ORDER = [
  'line',
  'big_number',
  'big_number_total',
  'table',
  'pivot_table_v2',
  'echarts_timeseries_line',
  'echarts_area',
  'echarts_timeseries_bar',
  'echarts_timeseries_scatter',
  'pie',
  'mixed_timeseries',
  'dist_bar',
  'area',
  'bar',
  'deck_polygon',
  'time_table',
  'histogram',
  'deck_scatter',
  'deck_hex',
  'time_pivot',
  'deck_arc',
  'heatmap',
  'heatmap_v2',
  'deck_grid',
  'deck_screengrid',
  'treemap_v2',
  'box_plot',
  'sankey',
  'word_cloud',
  'mapbox',
  'kepler',
  'cal_heatmap',
  'rose',
  'bubble',
  'bubble_v2',
  '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');

const ALL_CHARTS = t('All charts');

const FEATURED = t('Featured');

const RECOMMENDED_TAGS = [t('Popular'), t('ECharts'), t('Advanced-Analytics')];

export const VIZ_TYPE_CONTROL_TEST_ID = 'viz-type-control';

const VizPickerLayout = styled.div<{ isSelectedVizMetadata: boolean }>`
  ${({ isSelectedVizMetadata }) => `
    display: grid;
    grid-template-rows: ${
      isSelectedVizMetadata
        ? `auto minmax(100px, 1fr) minmax(200px, 35%)`
        : 'auto minmax(100px, 1fr)'
    };
    // em is used here because the sidebar should be sized to fit the longest standard tag
    grid-template-columns: minmax(14em, auto) 5fr;
    grid-template-areas:
      'sidebar search'
      '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};
  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`
  grid-area: main;
  overflow-y: auto;
`;

const SearchWrapper = styled.div`
  ${({ theme }) => `
    grid-area: search;
    margin-top: ${theme.gridUnit * 3}px;
    margin-bottom: ${theme.gridUnit}px;
    margin-left: ${theme.gridUnit * 3}px;
    margin-right: ${theme.gridUnit * 3}px;
    .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 SelectorLabel = styled.button`
  ${({ theme }) => `
    all: unset; // remove default button styles
    display: flex;
    flex-direction: row;
    align-items: center;
    cursor: pointer;
    margin: ${theme.gridUnit}px 0;
    padding: 0 ${theme.gridUnit}px;
    border-radius: ${theme.borderRadius}px;
    line-height: 2em;
    text-overflow: ellipsis;
    white-space: nowrap;
    position: relative;

    &:focus {
      outline: initial;
    }

    &.selected {
      background-color: ${theme.colors.primary.base};
      color: ${theme.colors.primary.light5};

      svg {
        color: ${theme.colors.primary.light5};
      }

      &:hover {
        .cancel {
          visibility: visible;
        }
      }
    }

    & > span[role="img"] {
      margin-right: ${theme.gridUnit * 2}px;
    }

    .cancel {
      visibility: hidden;
    }
  `}
`;

const IconsPane = styled.div`
  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 auto 1fr;
  grid-template-areas:
    'viz-name examples-header'
    'viz-tags examples'
    'description examples';
`;

// 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 TagsWrapper = styled.div`
  grid-area: viz-tags;
  width: ${({ theme }) => theme.gridUnit * 120}px;
  padding-right: ${({ theme }) => theme.gridUnit * 14}px;
  padding-bottom: ${({ theme }) => theme.gridUnit * 2}px;
`;

const Description = styled.p`
  grid-area: description;
  overflow: auto;
  padding-right: ${({ theme }) => theme.gridUnit * 14}px;
  margin: 0;
`;

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;
  position: relative;

  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;
  }
`;

const HighlightLabel = styled.div`
  ${({ theme }) => `
    border: 1px solid ${theme.colors.primary.dark1};
    box-sizing: border-box;
    border-radius: ${theme.gridUnit}px;
    background: ${theme.colors.grayscale.light5};
    line-height: ${theme.gridUnit * 2.5}px;
    color: ${theme.colors.primary.dark1};
    font-size: ${theme.typography.sizes.s}px;
    font-weight: ${theme.typography.weights.bold};
    text-align: center;
    padding: ${theme.gridUnit * 0.5}px ${theme.gridUnit}px;
    text-transform: uppercase;
    cursor: pointer;

    div {
      transform: scale(0.83,0.83);
    }
  `}
`;

const ThumbnailLabelWrapper = styled.div`
  position: absolute;
  right: ${({ theme }) => theme.gridUnit}px;
  top: ${({ theme }) => theme.gridUnit * 19}px;
`;

const TitleLabelWrapper = styled.div`
  display: inline-block !important;
  margin-left: ${({ theme }) => theme.gridUnit * 2}px;
`;

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;
  onDoubleClick: () => void;
}

const Thumbnail: React.FC<ThumbnailProps> = ({
  entry,
  selectedViz,
  setSelectedViz,
  onDoubleClick,
}) => {
  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)}
      onDoubleClick={onDoubleClick}
      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>
      {type.label && (
        <ThumbnailLabelWrapper>
          <HighlightLabel>
            <div>{t(type.label)}</div>
          </HighlightLabel>
        </ThumbnailLabelWrapper>
      )}
    </div>
  );
};

interface ThumbnailGalleryProps {
  vizEntries: VizEntry[];
  selectedViz: string | null;
  setSelectedViz: (viz: string) => void;
  onDoubleClick: () => 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 Selector: React.FC<{
  selector: string;
  sectionId: string;
  icon: ReactElement;
  isSelected: boolean;
  onClick: (selector: string, sectionId: string) => void;
  className?: string;
}> = ({ selector, sectionId, icon, isSelected, onClick, className }) => {
  const btnRef = useRef<HTMLButtonElement>(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={cx(className, isSelected && 'selected')}
      onClick={() => onClick(selector, sectionId)}
    >
      {icon}
      {selector}
    </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, onDoubleClick, className } = 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 }) => !props.denyList.includes(key))
      .filter(
        ({ value }) =>
          nativeFilterGate(value.behaviors || []) && !value.deprecated,
      );
    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 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.key.localeCompare(b.key)),
    [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.Tags 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[t('Popular')]
    ) {
      return chartsByTags[t('Popular')];
    }
    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];

            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.Search iconSize="m" />
            </InputIconAlignment>
          }
          suffix={
            <InputIconAlignment>
              {searchInputValue && (
                <Icons.XLarge 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}>{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>
  );
}