Browse Source

chore(ui): Improve `CompactSelect` story (#75164)

- Clarify usage
- Add cacheability story
George Gritsouk 7 months ago
parent
commit
ca1bcbea3e

+ 129 - 8
static/app/components/compactSelect/index.stories.tsx

@@ -1,10 +1,34 @@
-import {Fragment, useState} from 'react';
+import {Fragment, useCallback, useEffect, useState} from 'react';
+import debounce from 'lodash/debounce';
 
 import {CompactSelect} from 'sentry/components/compactSelect';
+import countryNameToCode from 'sentry/data/countryCodesMap';
 import {t} from 'sentry/locale';
 import storyBook from 'sentry/stories/storyBook';
+import {useCompactSelectOptionsCache} from 'sentry/views/insights/common/utils/useCompactSelectOptionsCache';
 
 export default storyBook(CompactSelect, story => {
+  story('Basics', () => {
+    return (
+      <Fragment>
+        <p>
+          <code>CompactSelect</code> is a general-purpose dropdown select component. It's
+          a capable alternative to <code>select</code> elements, and supports features
+          like sections, search, multi-select, loading states, and more.
+        </p>
+
+        <p>
+          <code>SelectControl</code> is a similar component, but is meant to be used
+          inside of forms. <code>CompactSelect</code> is meant for use outside of forms.
+          We use <code>CompactSelect</code> for features like project selectors,
+          environment selectors, and other filter dropdowns. We use{' '}
+          <code>SelectControl</code> inside forms in the Settings UI, and some other
+          similar places.
+        </p>
+      </Fragment>
+    );
+  });
+
   story('Simple', () => {
     const [value, setValue] = useState<string>('');
     const options = [
@@ -22,16 +46,11 @@ export default storyBook(CompactSelect, story => {
     return (
       <Fragment>
         <p>
-          <code>CompactSelect</code> is a general-purpose dropdown selector component. In
-          the most basic case, a <code>value</code>, <code>onChange</code> handler and an
-          array of <code>options</code> are all that's needed. The component does not
+          In the most basic case, a <code>value</code>, <code>onChange</code> handler and
+          an array of <code>options</code> are all that's needed. The component does not
           maintain its own selection state.
         </p>
 
-        <p>
-          <b>NOTE:</b> Prefer using this component over <code>SelectControl</code>!
-        </p>
-
         <CompactSelect value={value} onChange={handleValueChange} options={options} />
       </Fragment>
     );
@@ -82,6 +101,75 @@ export default storyBook(CompactSelect, story => {
       </Fragment>
     );
   });
+
+  story('Caching', () => {
+    const [country, setCountry] = useState<string>('');
+    const [search, setSearch] = useState<string>('');
+    const {data, isLoading} = useCountrySearch(search);
+
+    const options = data.map(dataCountry => ({
+      value: dataCountry,
+      label: dataCountry,
+    }));
+
+    const {options: cachedOptions} = useCompactSelectOptionsCache(options);
+
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    const debouncedSetSearch = useCallback(
+      debounce(newSearch => {
+        setSearch(newSearch);
+      }, 500),
+      []
+    );
+
+    return (
+      <Fragment>
+        <p>
+          In some cases, it's useful to add caching to <code>CompactSelect</code>. If your
+          select is loading data asynchronously as the user types, a naive implementation
+          will interrupt the user flow. Consider the country selector below. Try typing
+          "c" then a second later "a", then "n". You'll notice that the loading state
+          interrupts the flow, because it clears the options list. This happens if the
+          data hook clears previous results while data is loading (very common).
+        </p>
+        <div>
+          <CompactSelect
+            loading={isLoading}
+            value={country}
+            options={options}
+            menuTitle="Countries"
+            searchable
+            onSearch={newSearch => {
+              debouncedSetSearch(newSearch);
+            }}
+            onChange={newValue => {
+              setCountry(newValue.value);
+            }}
+          />
+        </div>
+        <p>
+          One solution is to wrap the data in <code>useCompactSelectOptionsCache</code>.
+          This will store all previously known results, which prevents the list clearing
+          issue when typing forward and backspacing.
+        </p>
+        <div>
+          <CompactSelect
+            loading={isLoading}
+            value={country}
+            options={cachedOptions}
+            menuTitle="Countries"
+            searchable
+            onSearch={newSearch => {
+              debouncedSetSearch(newSearch);
+            }}
+            onChange={newValue => {
+              setCountry(newValue.value.toString());
+            }}
+          />
+        </div>
+      </Fragment>
+    );
+  });
 });
 
 const arrayToOptions = (array: string[]) =>
@@ -89,3 +177,36 @@ const arrayToOptions = (array: string[]) =>
     value: item,
     label: item,
   }));
+
+const COUNTRY_NAMES = Object.keys(countryNameToCode).sort();
+
+const findCountries = (prefix: string) => {
+  const promise = new Promise<string[]>(resolve => {
+    setTimeout(() => {
+      resolve(
+        COUNTRY_NAMES.filter(name =>
+          name.toLocaleLowerCase().startsWith(prefix.toLocaleLowerCase())
+        )
+      );
+    }, 500);
+  });
+
+  return promise;
+};
+
+const useCountrySearch = (prefix: string) => {
+  const [data, setData] = useState<string[]>([]);
+  const [isLoading, setIsLoading] = useState<boolean>(false);
+
+  useEffect(() => {
+    setData([]);
+
+    setIsLoading(true);
+    findCountries(prefix).then(newData => {
+      setIsLoading(false);
+      setData(newData.slice(0, 5));
+    });
+  }, [prefix]);
+
+  return {data, isLoading};
+};

+ 1 - 1
static/app/views/insights/common/utils/useCompactSelectOptionsCache.tsx

@@ -22,7 +22,7 @@ type OptionCache = Map<SelectKey, Option>;
  */
 export function useCompactSelectOptionsCache(options: Option[]): {
   clear: () => void;
-  options: readonly Option[];
+  options: Option[];
 } {
   const cache = useRef<OptionCache>(new Map());