Procházet zdrojové kódy

fix(insights): fix some bugs relating to the search dialog

This PR fixes a few related bugs:

- Safari doesn't support `Iterator.filter`, so we need to iterators that need to
be filtered into arrays instead.
- The search dialog needs to be closed before triggering navigation or the close
animation is janky
- The react-aria ids for publishers in the search dialog need to be
disambiguated between clusters because a few publishers use the same keys in
pythtest and pythnet
Connor Prussin před 9 měsíci
rodič
revize
ec4cd2bb28

+ 9 - 9
apps/insights/src/components/PriceFeed/price-feed-select.tsx

@@ -36,15 +36,15 @@ export const PriceFeedSelect = ({ children }: Props) => {
   const filteredFeeds = useMemo(
     () =>
       search === ""
-        ? feeds.entries()
-        : feeds
-            .entries()
-            .filter(
-              ([, { displaySymbol, assetClass, key }]) =>
-                filter.contains(displaySymbol, search) ||
-                filter.contains(assetClass, search) ||
-                filter.contains(key[Cluster.Pythnet], search),
-            ),
+        ? // This is inefficient but Safari doesn't support `Iterator.filter`, see
+          // https://bugs.webkit.org/show_bug.cgi?id=248650
+          [...feeds.entries()]
+        : [...feeds.entries()].filter(
+            ([, { displaySymbol, assetClass, key }]) =>
+              filter.contains(displaySymbol, search) ||
+              filter.contains(assetClass, search) ||
+              filter.contains(key[Cluster.Pythnet], search),
+          ),
     [feeds, search, filter],
   );
   const sortedFeeds = useMemo(

+ 1 - 1
apps/insights/src/components/Root/index.tsx

@@ -59,7 +59,7 @@ const getPublishersForSearchDialog = async (cluster: Cluster) => {
     const knownPublisher = lookupPublisher(publisher.key);
 
     return {
-      id: publisher.key,
+      publisherKey: publisher.key,
       averageScore: publisher.averageScore,
       cluster,
       ...(knownPublisher && {

+ 130 - 91
apps/insights/src/components/Root/search-dialog.tsx

@@ -14,6 +14,7 @@ import {
   ListBox,
   ListBoxItem,
 } from "@pythnetwork/component-library/unstyled/ListBox";
+import { useRouter } from "next/navigation";
 import {
   type ReactNode,
   useState,
@@ -23,7 +24,7 @@ import {
   use,
   useMemo,
 } from "react";
-import { useCollator, useFilter } from "react-aria";
+import { RouterProvider, useCollator, useFilter } from "react-aria";
 
 import styles from "./search-dialog.module.scss";
 import { usePriceFeeds } from "../../hooks/use-price-feeds";
@@ -46,7 +47,7 @@ const SearchDialogOpenContext = createContext<
 type Props = {
   children: ReactNode;
   publishers: ({
-    id: string;
+    publisherKey: string;
     averageScore: number;
     cluster: Cluster;
   } & (
@@ -63,54 +64,85 @@ export const SearchDialogProvider = ({ children, publishers }: Props) => {
   const filter = useFilter({ sensitivity: "base", usage: "search" });
   const feeds = usePriceFeeds();
 
-  const close = useCallback(() => {
-    searchDialogState.close();
-    setTimeout(() => {
-      setSearch("");
-      setType("");
-    }, CLOSE_DURATION_IN_MS);
-  }, [searchDialogState, setSearch, setType]);
+  const close = useCallback(
+    () =>
+      new Promise<void>((resolve) => {
+        searchDialogState.close();
+        setTimeout(() => {
+          setSearch("");
+          setType("");
+          resolve();
+        }, CLOSE_DURATION_IN_MS);
+      }),
+    [searchDialogState, setSearch, setType],
+  );
 
   const handleOpenChange = useCallback(
     (isOpen: boolean) => {
       if (!isOpen) {
-        close();
+        close().catch(() => {
+          /* no-op since this actually can't fail */
+        });
       }
     },
     [close],
   );
 
+  const router = useRouter();
+  const handleOpenItem = useCallback(
+    (href: string) => {
+      close()
+        .then(() => {
+          router.push(href);
+        })
+        .catch(() => {
+          /* no-op since this actually can't fail */
+        });
+    },
+    [close, router],
+  );
+
   const results = useMemo(
     () =>
       [
         ...(type === ResultType.Publisher
           ? []
-          : feeds
-              .entries()
+          : // This is inefficient but Safari doesn't support `Iterator.filter`,
+            // see https://bugs.webkit.org/show_bug.cgi?id=248650
+            [...feeds.entries()]
               .filter(([, { displaySymbol }]) =>
                 filter.contains(displaySymbol, search),
               )
-              .map(([symbol, feed]) => ({
+              .map(([symbol, { assetClass, displaySymbol }]) => ({
                 type: ResultType.PriceFeed as const,
                 id: symbol,
-                ...feed,
+                assetClass,
+                displaySymbol,
               }))),
         ...(type === ResultType.PriceFeed
           ? []
           : publishers
               .filter(
                 (publisher) =>
-                  filter.contains(publisher.id, search) ||
+                  filter.contains(publisher.publisherKey, search) ||
                   (publisher.name && filter.contains(publisher.name, search)),
               )
               .map((publisher) => ({
                 type: ResultType.Publisher as const,
+                id: [
+                  ClusterToName[publisher.cluster],
+                  publisher.publisherKey,
+                ].join(":"),
                 ...publisher,
               }))),
       ].sort((a, b) =>
         collator.compare(
-          a.type === ResultType.PriceFeed ? a.displaySymbol : (a.name ?? a.id),
-          b.type === ResultType.PriceFeed ? b.displaySymbol : (b.name ?? b.id),
+          a.type === ResultType.PriceFeed
+            ? a.displaySymbol
+            : (a.name ?? a.publisherKey),
+          b.type === ResultType.PriceFeed
+            ? b.displaySymbol
+            : (b.name ?? b.publisherKey),
         ),
       ),
     [feeds, publishers, collator, filter, search, type],
@@ -182,80 +214,87 @@ export const SearchDialogProvider = ({ children, publishers }: Props) => {
           </Button>
         </div>
         <div className={styles.body}>
-          <Virtualizer layout={new ListLayout()}>
-            <ListBox
-              aria-label="Search"
-              items={results}
-              className={styles.listbox ?? ""}
-              // eslint-disable-next-line jsx-a11y/no-autofocus
-              autoFocus={false}
-              // @ts-expect-error looks like react-aria isn't exposing this
-              // property in the typescript types correctly...
-              shouldFocusOnHover
-              onAction={close}
-              emptyState={
-                <NoResults
-                  query={search}
-                  onClearSearch={() => {
-                    setSearch("");
-                  }}
-                />
-              }
-            >
-              {(result) => (
-                <ListBoxItem
-                  textValue={
-                    result.type === ResultType.PriceFeed
-                      ? result.displaySymbol
-                      : (result.name ?? result.id)
-                  }
-                  className={styles.item ?? ""}
-                  href={`${result.type === ResultType.PriceFeed ? "/price-feeds" : `/publishers/${ClusterToName[result.cluster]}`}/${encodeURIComponent(result.id)}`}
-                  data-is-first={result.id === results[0]?.id ? "" : undefined}
-                >
-                  <div className={styles.itemType}>
-                    <Badge
-                      variant={
-                        result.type === ResultType.PriceFeed
-                          ? "warning"
-                          : "info"
-                      }
-                      style="filled"
-                      size="xs"
-                    >
-                      {result.type === ResultType.PriceFeed
-                        ? "PRICE FEED"
-                        : "PUBLISHER"}
-                    </Badge>
-                  </div>
-                  {result.type === ResultType.PriceFeed ? (
-                    <>
-                      <PriceFeedTag
-                        compact
-                        symbol={result.id}
-                        className={styles.itemTag}
-                      />
-                      <AssetClassTag symbol={result.id} />
-                    </>
-                  ) : (
-                    <>
-                      <PublisherTag
-                        className={styles.itemTag}
-                        compact
-                        cluster={result.cluster}
-                        publisherKey={result.id}
-                        {...(result.name && {
-                          name: result.name,
-                          icon: result.icon,
-                        })}
-                      />
-                      <Score score={result.averageScore} />
-                    </>
-                  )}
-                </ListBoxItem>
-              )}
-            </ListBox>
-          </Virtualizer>
+          <RouterProvider navigate={handleOpenItem}>
+            <Virtualizer layout={new ListLayout()}>
+              <ListBox
+                aria-label="Search"
+                items={results}
+                className={styles.listbox ?? ""}
+                // eslint-disable-next-line jsx-a11y/no-autofocus
+                autoFocus={false}
+                // @ts-expect-error looks like react-aria isn't exposing this
+                // property in the typescript types correctly...
+                shouldFocusOnHover
+                emptyState={
+                  <NoResults
+                    query={search}
+                    onClearSearch={() => {
+                      setSearch("");
+                    }}
+                  />
+                }
+              >
+                {(result) => (
+                  <ListBoxItem
+                    textValue={
+                      result.type === ResultType.PriceFeed
+                        ? result.displaySymbol
+                        : (result.name ?? result.publisherKey)
+                    }
+                    className={styles.item ?? ""}
+                    href={
+                      result.type === ResultType.PriceFeed
+                        ? `/price-feeds/${encodeURIComponent(result.id)}`
+                        : `/publishers/${ClusterToName[result.cluster]}/${encodeURIComponent(result.publisherKey)}`
+                    }
+                    data-is-first={
+                      result.id === results[0]?.id ? "" : undefined
+                    }
+                  >
+                    <div className={styles.itemType}>
+                      <Badge
+                        variant={
+                          result.type === ResultType.PriceFeed
+                            ? "warning"
+                            : "info"
+                        }
+                        style="filled"
+                        size="xs"
+                      >
+                        {result.type === ResultType.PriceFeed
+                          ? "PRICE FEED"
+                          : "PUBLISHER"}
+                      </Badge>
+                    </div>
+                    {result.type === ResultType.PriceFeed ? (
+                      <>
+                        <PriceFeedTag
+                          compact
+                          symbol={result.id}
+                          className={styles.itemTag}
+                        />
+                        <AssetClassTag symbol={result.id} />
+                      </>
+                    ) : (
+                      <>
+                        <PublisherTag
+                          className={styles.itemTag}
+                          compact
+                          cluster={result.cluster}
+                          publisherKey={result.publisherKey}
+                          {...(result.name && {
+                            name: result.name,
+                            icon: result.icon,
+                          })}
+                        />
+                        <Score score={result.averageScore} />
+                      </>
+                    )}
+                  </ListBoxItem>
+                )}
+              </ListBox>
+            </Virtualizer>
+          </RouterProvider>
         </div>
       </ModalDialog>
     </>