This is an automated email from the ASF dual-hosted git repository.

suddjian pushed a commit to branch viz-metadata-search
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 1798bcd257efa8292b924d248fd06bd80f263707
Author: David Aaron Suddjian <[email protected]>
AuthorDate: Fri Jun 25 17:03:18 2021 -0700

    get search working
---
 .../components/controls/VizTypeControl/index.tsx   | 323 ++++++++++++++-------
 1 file changed, 213 insertions(+), 110 deletions(-)

diff --git 
a/superset-frontend/src/explore/components/controls/VizTypeControl/index.tsx 
b/superset-frontend/src/explore/components/controls/VizTypeControl/index.tsx
index 241507c..a49e1df 100644
--- a/superset-frontend/src/explore/components/controls/VizTypeControl/index.tsx
+++ b/superset-frontend/src/explore/components/controls/VizTypeControl/index.tsx
@@ -19,12 +19,11 @@
 import React, {
   ChangeEventHandler,
   useCallback,
-  useEffect,
-  useRef,
+  useMemo,
   useState,
 } from 'react';
 import PropTypes from 'prop-types';
-import { Input, Row, Col } from 'src/common/components';
+import Fuse from 'fuse.js';
 import {
   t,
   getChartMetadataRegistry,
@@ -34,11 +33,13 @@ import {
   SupersetTheme,
   useTheme,
 } from '@superset-ui/core';
-import { useDynamicPluginContext } from 'src/components/DynamicPlugins';
+import { Input } from 'src/common/components';
+import { usePluginContext } from 'src/components/DynamicPlugins';
 import Modal from 'src/components/Modal';
 import Tabs from 'src/components/Tabs';
 import { Tooltip } from 'src/components/Tooltip';
 import Label, { Type } from 'src/components/Label';
+import Icons from 'src/components/Icons';
 import ControlHeader from 'src/explore/components/ControlHeader';
 import { nativeFilterGate } from 
'src/dashboard/components/nativeFilters/utils';
 import './VizTypeControl.less';
@@ -126,7 +127,7 @@ const typesWithDefaultOrder = new Set(DEFAULT_ORDER);
 export const VIZ_TYPE_CONTROL_TEST_ID = 'viz-type-control';
 
 function VizSupportValidation({ vizType }: { vizType: string }) {
-  const state = useDynamicPluginContext();
+  const state = usePluginContext();
   if (state.loading || metadataRegistry.has(vizType)) {
     return null;
   }
@@ -157,9 +158,11 @@ const SectionTitle = styled.h3`
   line-height: ${({ theme }) => theme.gridUnit * 6}px;
 `;
 
-const IconPane = styled(Row)`
+const IconPane = styled.div`
   overflow: auto;
-  padding: ${({ theme }) => theme.gridUnit * 4}px;
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
 `;
 
 const CategoriesTabs = styled(Tabs)`
@@ -204,6 +207,10 @@ const CategoriesTabs = styled(Tabs)`
         }
       }
     }
+
+    &.ant-tabs-left > .ant-tabs-content-holder > .ant-tabs-content > 
.ant-tabs-tabpane {
+      padding: ${theme.gridUnit * 2}px;
+    }
   `}
 `;
 
@@ -224,8 +231,14 @@ const SearchPane = styled.div`
   padding: ${({ theme }) => theme.gridUnit * 4}px;
 `;
 
+const SearchResultsPane = styled.div`
+  padding: ${({ theme }) => theme.gridUnit * 4}px;
+`;
+
 const thumbnailContainerCss = (theme: SupersetTheme) => css`
   cursor: pointer;
+  width: ${theme.gridUnit * 24}px;
+  margin: ${theme.gridUnit * 2}px;
 
   img {
     border: 1px solid ${theme.colors.grayscale.light2};
@@ -242,109 +255,174 @@ const thumbnailContainerCss = (theme: SupersetTheme) => 
css`
   }
 `;
 
+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;
+  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;
+  setSelectedViz: (viz: string) => void;
+}
+
+/** A list of viz thumbnails, used within the viz picker modal */
+const ThumbnailGallery: React.FC<ThumbnailGalleryProps> = ({
+  vizEntries,
+  ...others
+}) => (
+  <IconPane data-test={`${VIZ_TYPE_CONTROL_TEST_ID}__viz-row`}>
+    {vizEntries.map(entry => (
+      <Thumbnail {...others} entry={entry} />
+    ))}
+  </IconPane>
+);
+
+/** Manages the viz type and the viz picker modal */
 const VizTypeControl = (props: VizTypeControlProps) => {
-  const { value: initialValue, onChange, isModalOpenInit } = props;
+  const { value: initialValue, onChange, isModalOpenInit, labelType } = props;
   const theme = useTheme();
+  const { mountedPluginMetadata } = usePluginContext();
   const [showModal, setShowModal] = useState(!!isModalOpenInit);
-  const [filter, setFilter] = useState('');
-  const searchRef = useRef<any>(null);
+  const [activeCategory, setActiveCategory] = useState<string | undefined>(
+    undefined,
+  );
+  const [categoryRecall, setCategoryRecall] = useState<string | undefined>(
+    undefined,
+  );
+  const [searchInputValue, setSearchInputValue] = useState('');
+  const [isSearching, setIsSearching] = useState(false);
   const [selectedViz, setSelectedViz] = useState(initialValue);
 
-  useEffect(() => {
-    if (showModal) {
-      setTimeout(() => searchRef.current?.focus(), 200);
-    }
-  }, [showModal]);
-
   const onSubmit = useCallback(() => {
     onChange(selectedViz);
     setShowModal(false);
   }, [selectedViz, onChange]);
 
-  const toggleModal = () => {
+  const toggleModal = useCallback(() => {
     setShowModal(prevState => !prevState);
-  };
 
-  const changeSearch: ChangeEventHandler<HTMLInputElement> = event => {
-    setFilter(event.target.value);
-  };
+    // make sure the modal opens up to the last submitted viz
+    setSelectedViz(initialValue);
+    setActiveCategory(
+      mountedPluginMetadata[initialValue]?.category || undefined,
+    );
+  }, [initialValue, mountedPluginMetadata]);
 
-  const renderItem = (entry: VizEntry) => {
-    const { key, value: type } = entry;
-    const isSelected = key === selectedViz;
+  const changeSearch: ChangeEventHandler<HTMLInputElement> = useCallback(
+    event => {
+      setSearchInputValue(event.target.value);
+    },
+    [],
+  );
 
-    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>
-    );
-  };
-
-  const { labelType } = props;
-  const filterString = filter.toLowerCase();
-  const filterStringParts = filterString.split(' ');
-
-  const categories = DEFAULT_ORDER.filter(
-    type =>
-      metadataRegistry.has(type) &&
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      nativeFilterGate(metadataRegistry.get(type)!.behaviors || []),
-  )
-    .map(type => ({
-      key: type,
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      value: metadataRegistry.get(type),
-    }))
-    .concat(
-      metadataRegistry
-        .entries()
-        .filter(entry => {
-          const behaviors = entry.value?.behaviors || [];
-          return nativeFilterGate(behaviors);
-        })
-        .filter(({ key }) => !typesWithDefaultOrder.has(key)),
-    )
-    .filter(
-      entry =>
-        !!entry.value &&
-        filterStringParts.every(
-          part => entry.value?.name.toLowerCase().indexOf(part) !== -1,
-        ),
-    )
-    .reduce((acc, entry) => {
-      const category = entry.value?.category || 'Other';
-      if (!acc[category]) {
-        acc[category] = [];
+  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';
+      if (!result[category]) {
+        result[category] = [];
       }
-      // typecast is safe because we filtered out falsy value already
-      acc[category].push(entry as VizEntry);
-      return acc;
-    }, {} as Record<string, VizEntry[]>);
+      result[category].push(entry);
+    });
+    return result;
+  }, [chartMetadata]);
+
+  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 startSearching = useCallback(() => {
+    if (activeCategory !== undefined) {
+      // this will allow us to go back to the tab the user
+      // was looking at when they exit their search.
+      // "undefined" check is needed because undefined is used when searching,
+      // and we don't want to go back to that when we stop searching.
+      setCategoryRecall(activeCategory);
+    }
+    setActiveCategory(undefined);
+    setIsSearching(true);
+  }, [activeCategory]);
+
+  const stopSearching = useCallback(() => {
+    setActiveCategory(categoryRecall);
+    setIsSearching(false);
+  }, [categoryRecall]);
+
+  const onTabClick = useCallback((key: string) => {
+    setActiveCategory(key);
+    setIsSearching(false);
+  }, []);
 
   const labelContent =
-    metadataRegistry.get(initialValue)?.name || `${initialValue}`;
+    mountedPluginMetadata[initialValue]?.name || `${initialValue}`;
 
-  const selectedVizMetadata = metadataRegistry.get(selectedViz);
+  const selectedVizMetadata = mountedPluginMetadata[selectedViz];
 
   return (
     <div>
@@ -365,6 +443,7 @@ const VizTypeControl = (props: VizTypeControlProps) => {
           <VizSupportValidation vizType={initialValue} />
         </>
       </Tooltip>
+
       <UnpaddedModal
         show={showModal}
         onHide={toggleModal}
@@ -376,30 +455,54 @@ const VizTypeControl = (props: VizTypeControlProps) => {
         <VizPickerLayout>
           <SearchPane>
             <Input
-              ref={searchRef}
               type="text"
-              value={filter}
+              value={searchInputValue}
               placeholder={t('Search')}
               onChange={changeSearch}
+              onFocus={startSearching}
               data-test={`${VIZ_TYPE_CONTROL_TEST_ID}__search-input`}
+              suffix={
+                <div
+                  css={css`
+                    display: flex;
+                    justify-content: center;
+                    align-items: center;
+                    color: ${theme.colors.grayscale.base};
+                  `}
+                >
+                  <Icons.XLarge iconSize="m" onClick={stopSearching} />
+                </div>
+              }
             />
           </SearchPane>
-          <CategoriesTabs tabPosition="left">
-            {Object.entries(categories).map(([category, vizTypes]) => (
-              <Tabs.TabPane tab={category} key={category}>
-                <IconPane
-                  data-test={`${VIZ_TYPE_CONTROL_TEST_ID}__viz-row`}
-                  gutter={16}
-                >
-                  {vizTypes.map(entry => (
-                    <Col xs={12} sm={8} md={6} lg={4} key={entry.key}>
-                      {renderItem(entry)}
-                    </Col>
-                  ))}
-                </IconPane>
-              </Tabs.TabPane>
-            ))}
-          </CategoriesTabs>
+
+          {isSearching ? (
+            <SearchResultsPane>
+              {/* <h3 css={searchResultsHeaderCss}>Search Results</h3> */}
+              <ThumbnailGallery
+                vizEntries={searchResults}
+                selectedViz={selectedViz}
+                setSelectedViz={setSelectedViz}
+              />
+            </SearchResultsPane>
+          ) : (
+            <CategoriesTabs
+              tabPosition="left"
+              activeKey={activeCategory}
+              onTabClick={onTabClick}
+            >
+              {Object.entries(chartsByCategory).map(([category, vizTypes]) => (
+                <Tabs.TabPane tab={category} key={category}>
+                  <ThumbnailGallery
+                    vizEntries={vizTypes}
+                    selectedViz={selectedViz}
+                    setSelectedViz={setSelectedViz}
+                  />
+                </Tabs.TabPane>
+              ))}
+            </CategoriesTabs>
+          )}
+
           <DetailsPane>
             <SectionTitle>{selectedVizMetadata?.name}</SectionTitle>
             <Description>

Reply via email to