From 52fa8e709ed04eba20d5eedf09970a03b0bd7c6b Mon Sep 17 00:00:00 2001
From: Iveta <quietbits@gmail.com>
Date: Tue, 28 Jan 2025 15:52:54 -0500
Subject: [PATCH] Functional, needs styling

---
 .../components/ContractStorage.tsx            |  80 ++++++-
 src/components/DataTable/index.tsx            | 226 ++++++++++++++++--
 src/components/DataTable/styles.scss          |  83 +++++--
 src/components/Dropdown/index.tsx             | 103 ++++++++
 src/components/Dropdown/styles.scss           |  20 ++
 src/helpers/decodeScVal.ts                    |  10 +
 src/helpers/processContractStorageData.ts     |  50 ++--
 src/types/types.ts                            |   5 +-
 8 files changed, 513 insertions(+), 64 deletions(-)
 create mode 100644 src/components/Dropdown/index.tsx
 create mode 100644 src/components/Dropdown/styles.scss
 create mode 100644 src/helpers/decodeScVal.ts

diff --git a/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractStorage.tsx b/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractStorage.tsx
index d7b20e8e..b186c912 100644
--- a/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractStorage.tsx
+++ b/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractStorage.tsx
@@ -9,6 +9,7 @@ import { useSEContractStorage } from "@/query/external/useSEContracStorage";
 import { formatEpochToDate } from "@/helpers/formatEpochToDate";
 import { formatNumber } from "@/helpers/formatNumber";
 import { capitalizeString } from "@/helpers/capitalizeString";
+import { decodeScVal } from "@/helpers/decodeScVal";
 
 import { useIsXdrInit } from "@/hooks/useIsXdrInit";
 
@@ -64,13 +65,74 @@ export const ContractStorage = ({
     );
   }
 
+  const parsedKeyValueData = () => {
+    return storageData.map((i) => ({
+      ...i,
+      keyJson: i.key ? decodeScVal(i.key) : undefined,
+      valueJson: i.value ? decodeScVal(i.value) : undefined,
+    }));
+  };
+
+  const parsedData = parsedKeyValueData();
+
+  const getKeyValueFilters = () => {
+    return parsedData.reduce(
+      (
+        res: {
+          key: string[];
+          value: string[];
+        },
+        cur,
+      ) => {
+        // Key
+        if (cur.keyJson && Array.isArray(cur.keyJson)) {
+          const keyFilter = cur.keyJson[0];
+
+          if (!res.key.includes(keyFilter)) {
+            res.key = [...res.key, keyFilter];
+          }
+        }
+
+        // Value
+        if (cur.valueJson && typeof cur.valueJson === "object") {
+          // Excluding keys that start with _ because on the UI structure is
+          // different. For example, for Instance type.
+          const valueFilters = Object.keys(cur.valueJson).filter(
+            (f) => !f.startsWith("_"),
+          );
+
+          valueFilters.forEach((v) => {
+            if (!res.value.includes(v)) {
+              res.value = [...res.value, v];
+            }
+          });
+        }
+
+        return res;
+      },
+      { key: [], value: [] },
+    );
+  };
+
+  const keyValueFilters = getKeyValueFilters();
+
   return (
     <DataTable
       tableId="contract-storage"
-      tableData={storageData}
+      tableData={parsedData}
       tableHeaders={[
-        { id: "key", value: "Key", isSortable: false },
-        { id: "value", value: "Value", isSortable: false },
+        {
+          id: "key",
+          value: "Key",
+          isSortable: false,
+          filter: keyValueFilters.key,
+        },
+        {
+          id: "value",
+          value: "Value",
+          isSortable: false,
+          filter: keyValueFilters.value,
+        },
         { id: "durability", value: "Durability", isSortable: true },
         { id: "ttl", value: "TTL", isSortable: true },
         { id: "updated", value: "Updated", isSortable: true },
@@ -81,22 +143,14 @@ export const ContractStorage = ({
         {
           value: (
             <div className="CodeBox">
-              <ScValPrettyJson
-                xdrString={vh.key}
-                json={vh.keyJson}
-                isReady={isXdrInit}
-              />
+              <ScValPrettyJson xdrString={vh.key} isReady={isXdrInit} />
             </div>
           ),
         },
         {
           value: (
             <div className="CodeBox">
-              <ScValPrettyJson
-                xdrString={vh.value}
-                json={vh.valueJson}
-                isReady={isXdrInit}
-              />
+              <ScValPrettyJson xdrString={vh.value} isReady={isXdrInit} />
             </div>
           ),
         },
diff --git a/src/components/DataTable/index.tsx b/src/components/DataTable/index.tsx
index 8f2e0a62..f07dfc8c 100644
--- a/src/components/DataTable/index.tsx
+++ b/src/components/DataTable/index.tsx
@@ -1,7 +1,17 @@
 import { useEffect, useState } from "react";
-import { Button, Card, Icon, Loader } from "@stellar/design-system";
+import {
+  Button,
+  Card,
+  Checkbox,
+  Icon,
+  Label,
+  Loader,
+} from "@stellar/design-system";
+
 import { Box } from "@/components/layout/Box";
+import { Dropdown } from "@/components/Dropdown";
 import { processContractStorageData } from "@/helpers/processContractStorageData";
+
 import {
   AnyObject,
   ContractStorageProcessedItem,
@@ -28,7 +38,6 @@ export const DataTable = <T extends AnyObject>({
   customFooterEl?: React.ReactNode;
 }) => {
   const PAGE_SIZE = 20;
-  const tableDataSize = tableData.length;
 
   // Data
   const [processedData, setProcessedData] = useState<
@@ -40,6 +49,20 @@ export const DataTable = <T extends AnyObject>({
   const [sortById, setSortById] = useState("");
   const [sortByDir, setSortByDir] = useState<SortDirection>("default");
 
+  // Filters
+  type FilterCols = "key" | "value";
+  type DataFilters = { [key: string]: string[] };
+
+  const INIT_FILTERS = { key: [], value: [] };
+
+  const [visibleFilters, setVisibleFilters] = useState<FilterCols | undefined>(
+    undefined,
+  );
+  const [selectedFilters, setSelectedFilters] =
+    useState<DataFilters>(INIT_FILTERS);
+  const [appliedFilters, setAppliedFilters] =
+    useState<DataFilters>(INIT_FILTERS);
+
   // Pagination
   const [currentPage, setCurrentPage] = useState(1);
   const [totalPageCount, setTotalPageCount] = useState(1);
@@ -49,21 +72,19 @@ export const DataTable = <T extends AnyObject>({
       data: tableData,
       sortById,
       sortByDir,
+      filters: appliedFilters,
     });
 
     setProcessedData(data);
-  }, [tableData, sortByDir, sortById]);
-
-  useEffect(() => {
-    setTotalPageCount(Math.ceil(tableDataSize / PAGE_SIZE));
-  }, [tableDataSize]);
+  }, [tableData, sortByDir, sortById, appliedFilters]);
 
   // Hide loader when processed data is done
   useEffect(() => {
     setIsUpdating(false);
+    setTotalPageCount(Math.ceil(processedData.length / PAGE_SIZE));
   }, [processedData]);
 
-  const getSortByProps = (th: DataTableHeader) => {
+  const getCustomProps = (th: DataTableHeader) => {
     if (th.isSortable) {
       return {
         "data-sortby-dir": sortById === th.id ? sortByDir : "default",
@@ -71,6 +92,13 @@ export const DataTable = <T extends AnyObject>({
       };
     }
 
+    if (th.filter && th.filter.length > 0) {
+      return {
+        "data-filter": "true",
+        onClick: () => toggleFilterDropdown(th.id as FilterCols),
+      };
+    }
+
     return {};
   };
 
@@ -98,6 +126,10 @@ export const DataTable = <T extends AnyObject>({
     setIsUpdating(true);
   };
 
+  const toggleFilterDropdown = (headerId: FilterCols) => {
+    setVisibleFilters(visibleFilters === headerId ? undefined : headerId);
+  };
+
   const paginateData = (data: DataTableCell[][]): DataTableCell[][] => {
     if (!data || data.length === 0) {
       return [];
@@ -109,6 +141,148 @@ export const DataTable = <T extends AnyObject>({
     return data.slice(startIndex, endIndex);
   };
 
+  const isFilterApplyDisabled = (headerId: string) => {
+    const selected = selectedFilters[headerId];
+    const applied = appliedFilters[headerId];
+
+    // Both filters are empty
+    if (selected.length === 0 && applied.length === 0) {
+      return true;
+    }
+
+    // Different array sizes
+    if (selected.length !== applied.length) {
+      return false;
+    }
+
+    // The array sizes are equal, need to check if items are the same
+    return (
+      selected.reduce((res: string[], cur) => {
+        if (!applied.includes(cur)) {
+          return [...res, cur];
+        }
+
+        return res;
+      }, []).length === 0
+    );
+  };
+
+  const renderFilterDropdown = (
+    headerId: string,
+    filters: string[] | undefined,
+  ) => {
+    if (filters && filters.length > 0) {
+      return (
+        <Dropdown
+          addlClassName="DataTable__filterDropdown"
+          isDropdownVisible={visibleFilters === headerId}
+          onClose={() => {
+            setVisibleFilters(undefined);
+          }}
+          triggerDataAttribute="filter"
+        >
+          <div className="DataTable__filterDropdown__container">
+            <div className="DataTable__filterDropdown__title">Filter by</div>
+            <div>
+              {filters.map((f) => {
+                const id = `filter-${headerId}-${f}`;
+                let currentFilters = selectedFilters[headerId] || [];
+
+                return (
+                  <div key={id} className="DataTable__filterDropdown__filter">
+                    <Label size="sm" htmlFor={id}>
+                      {f}
+                    </Label>
+                    <Checkbox
+                      id={id}
+                      fieldSize="sm"
+                      onChange={() => {
+                        if (currentFilters.includes(f)) {
+                          currentFilters = currentFilters.filter(
+                            (c) => c !== f,
+                          );
+                        } else {
+                          currentFilters = [...currentFilters, f];
+                        }
+
+                        setSelectedFilters({
+                          ...selectedFilters,
+                          [headerId]: currentFilters,
+                        });
+                      }}
+                      checked={currentFilters.includes(f)}
+                    />
+                  </div>
+                );
+              })}
+            </div>
+            <div>
+              <Button
+                size="sm"
+                variant="secondary"
+                onClick={() => {
+                  setAppliedFilters(selectedFilters);
+                  setVisibleFilters(undefined);
+                }}
+                disabled={isFilterApplyDisabled(headerId)}
+              >
+                Apply
+              </Button>
+              <Button
+                size="sm"
+                variant="error"
+                onClick={() => {
+                  setSelectedFilters({ ...selectedFilters, [headerId]: [] });
+                  setAppliedFilters({ ...appliedFilters, [headerId]: [] });
+                  setVisibleFilters(undefined);
+                }}
+                disabled={appliedFilters[headerId].length === 0}
+              >
+                Clear filter
+              </Button>
+            </div>
+          </div>
+        </Dropdown>
+      );
+    }
+
+    return null;
+  };
+
+  const renderFilterBadges = () => {
+    return Object.entries(appliedFilters).map((af) => {
+      const [id, filters] = af;
+
+      return (
+        <>
+          {filters.map((f) => (
+            <div
+              key={`badge-${id}-${f}`}
+              className="DataTable__badge Badge Badge--secondary Badge--sm"
+            >
+              {f}
+
+              <div
+                role="button"
+                className="DataTable__badge__button"
+                onClick={() => {
+                  const idFilters = appliedFilters[id].filter((c) => c !== f);
+                  const updatedFilters = { ...appliedFilters, [id]: idFilters };
+
+                  // Update both selected and applied filters
+                  setSelectedFilters(updatedFilters);
+                  setAppliedFilters(updatedFilters);
+                }}
+              >
+                <Icon.XClose />
+              </div>
+            </div>
+          ))}
+        </>
+      );
+    });
+  };
+
   const customStyle = {
     "--DataTable-grid-template-columns": cssGridTemplateColumns,
   } as React.CSSProperties;
@@ -117,6 +291,13 @@ export const DataTable = <T extends AnyObject>({
 
   return (
     <Box gap="md">
+      <Box gap="sm" direction="row" align="center" justify="space-between">
+        <Box gap="sm" direction="row" align="center">
+          {/* Applied filter badges */}
+          {renderFilterBadges()}
+        </Box>
+      </Box>
+
       {/* Table */}
       <Card noPadding={true}>
         <div className="DataTable__container">
@@ -131,14 +312,27 @@ export const DataTable = <T extends AnyObject>({
               <thead>
                 <tr data-style="row" role="row">
                   {tableHeaders.map((th) => (
-                    <th key={th.id} role="cell" {...getSortByProps(th)}>
-                      {th.value}
-                      {th.isSortable ? (
-                        <span className="DataTable__sortBy">
-                          <Icon.ChevronUp />
-                          <Icon.ChevronDown />
-                        </span>
-                      ) : null}
+                    <th key={`col-${th.id}`} role="cell">
+                      <div {...getCustomProps(th)}>
+                        {th.value}
+
+                        {/* Sort icon */}
+                        {th.isSortable ? (
+                          <span className="DataTable__sortBy">
+                            <Icon.ChevronUp />
+                            <Icon.ChevronDown />
+                          </span>
+                        ) : null}
+
+                        {/* Filter icon */}
+                        {th.filter ? (
+                          <span className="DataTable__filter">
+                            <Icon.FilterFunnel01 />
+                          </span>
+                        ) : null}
+                      </div>
+
+                      {renderFilterDropdown(th.id, th.filter)}
                     </th>
                   ))}
                 </tr>
diff --git a/src/components/DataTable/styles.scss b/src/components/DataTable/styles.scss
index 7a394d42..f4bdbd8f 100644
--- a/src/components/DataTable/styles.scss
+++ b/src/components/DataTable/styles.scss
@@ -45,23 +45,33 @@
       font-size: pxToRem(12px);
       line-height: pxToRem(18px);
       min-width: 100px;
+      position: relative;
+      overflow: visible;
+
+      & > div {
+        &[data-sortby-dir],
+        &[data-filter] {
+          cursor: pointer;
+          display: flex;
+          align-items: center;
+          gap: pxToRem(4px);
+          position: relative;
+        }
 
-      &[data-sortby-dir] {
-        cursor: pointer;
-        display: flex;
-        align-items: center;
-        gap: pxToRem(4px);
-      }
+        &[data-filter] {
+          overflow: visible;
+        }
 
-      &[data-sortby-dir="asc"] {
-        .DataTable__sortBy svg:first-of-type {
-          stroke: var(--sds-clr-gray-12);
+        &[data-sortby-dir="asc"] {
+          .DataTable__sortBy svg:first-of-type {
+            stroke: var(--sds-clr-gray-12);
+          }
         }
-      }
 
-      &[data-sortby-dir="desc"] {
-        .DataTable__sortBy svg:last-of-type {
-          stroke: var(--sds-clr-gray-12);
+        &[data-sortby-dir="desc"] {
+          .DataTable__sortBy svg:last-of-type {
+            stroke: var(--sds-clr-gray-12);
+          }
         }
       }
     }
@@ -85,11 +95,15 @@
     }
   }
 
-  &__sortBy {
+  &__sortBy,
+  &__filter {
     display: block;
     position: relative;
     width: pxToRem(12px);
     height: pxToRem(12px);
+  }
+
+  &__sortBy {
     overflow: hidden;
 
     svg {
@@ -119,4 +133,45 @@
     font-weight: var(--sds-fw-semi-bold);
     color: var(--sds-clr-gray-12);
   }
+
+  &__filterDropdown {
+    position: absolute;
+    width: calc(100% - 0.6rem);
+    top: 85%;
+    left: pxToRem(4px);
+
+    // TODO: style
+    &__container {
+    }
+
+    &__title {
+    }
+
+    &__filter {
+    }
+  }
+
+  &__badge {
+    &__button {
+      cursor: pointer;
+      width: pxToRem(12px);
+      height: pxToRem(12px);
+
+      svg {
+        display: block;
+        width: 100%;
+        height: 100%;
+        stroke: var(--sds-clr-lilac-11);
+        transition: stroke var(--sds-anim-transition-default);
+      }
+
+      @media (hover: hover) {
+        &:hover {
+          svg {
+            stroke: var(--sds-clr-lilac-12);
+          }
+        }
+      }
+    }
+  }
 }
diff --git a/src/components/Dropdown/index.tsx b/src/components/Dropdown/index.tsx
new file mode 100644
index 00000000..556954db
--- /dev/null
+++ b/src/components/Dropdown/index.tsx
@@ -0,0 +1,103 @@
+import {
+  useCallback,
+  useEffect,
+  useLayoutEffect,
+  useRef,
+  useState,
+} from "react";
+import { delayedAction } from "@/helpers/delayedAction";
+
+import "./styles.scss";
+
+type DropdownProps = {
+  children: React.ReactNode;
+  isDropdownVisible: boolean;
+  onClose: () => void;
+  // [data-] attribute must be set on the trigger element
+  triggerDataAttribute: string;
+  addlClassName?: string;
+  testId?: string;
+};
+
+export const Dropdown = ({
+  children,
+  isDropdownVisible,
+  onClose,
+  triggerDataAttribute,
+  addlClassName,
+  testId,
+}: DropdownProps) => {
+  const [isActive, setIsActive] = useState(false);
+  const [isVisible, setIsVisible] = useState(false);
+
+  const dropdownRef = useRef<HTMLDivElement | null>(null);
+
+  const toggleDropdown = useCallback((show: boolean) => {
+    const delay = 100;
+
+    if (show) {
+      setIsActive(true);
+      delayedAction({
+        action: () => {
+          setIsVisible(true);
+        },
+        delay,
+      });
+    } else {
+      setIsVisible(false);
+      delayedAction({
+        action: () => {
+          setIsActive(false);
+        },
+        delay,
+      });
+    }
+  }, []);
+
+  const handleClickOutside = useCallback(
+    (event: MouseEvent) => {
+      // Ignore the dropdown
+      if (dropdownRef?.current?.contains(event.target as Node)) {
+        return;
+      }
+
+      // Ingnore the trigger element
+      if ((event.target as any).dataset?.[triggerDataAttribute]) {
+        return;
+      }
+
+      onClose();
+    },
+    [onClose, triggerDataAttribute],
+  );
+
+  // Update internal state when visible state changes from outside
+  useEffect(() => {
+    toggleDropdown(isDropdownVisible);
+  }, [isDropdownVisible, toggleDropdown]);
+
+  // Close dropdown when clicked outside
+  useLayoutEffect(() => {
+    if (isVisible) {
+      document.addEventListener("pointerup", handleClickOutside);
+    } else {
+      document.removeEventListener("pointerup", handleClickOutside);
+    }
+
+    return () => {
+      document.removeEventListener("pointerup", handleClickOutside);
+    };
+  }, [isVisible, handleClickOutside]);
+
+  return (
+    <div
+      className={`Dropdown Floater__content Floater__content--light ${addlClassName || ""}`}
+      data-is-active={isActive}
+      data-is-visible={isVisible}
+      ref={dropdownRef}
+      data-testid={testId}
+    >
+      <div className="Dropdown__body">{children}</div>
+    </div>
+  );
+};
diff --git a/src/components/Dropdown/styles.scss b/src/components/Dropdown/styles.scss
new file mode 100644
index 00000000..f306ae64
--- /dev/null
+++ b/src/components/Dropdown/styles.scss
@@ -0,0 +1,20 @@
+@use "../../styles/utils.scss" as *;
+
+.Dropdown {
+  z-index: 2;
+  transform: none;
+  display: none;
+  opacity: 0;
+
+  &[data-is-active="true"] {
+    display: block;
+  }
+
+  &[data-is-visible="true"] {
+    opacity: 1;
+  }
+
+  &__body {
+    padding: pxToRem(4px);
+  }
+}
diff --git a/src/helpers/decodeScVal.ts b/src/helpers/decodeScVal.ts
new file mode 100644
index 00000000..eb84c054
--- /dev/null
+++ b/src/helpers/decodeScVal.ts
@@ -0,0 +1,10 @@
+import { scValToNative, xdr } from "@stellar/stellar-sdk";
+
+export const decodeScVal = (xdrString: string) => {
+  try {
+    const scv = xdr.ScVal.fromXDR(xdrString, "base64");
+    return scValToNative(scv);
+  } catch (e) {
+    return null;
+  }
+};
diff --git a/src/helpers/processContractStorageData.ts b/src/helpers/processContractStorageData.ts
index 167311fb..20e3fc8a 100644
--- a/src/helpers/processContractStorageData.ts
+++ b/src/helpers/processContractStorageData.ts
@@ -1,5 +1,3 @@
-import { parse } from "lossless-json";
-import * as StellarXdr from "@/helpers/StellarXdr";
 import {
   AnyObject,
   ContractStorageProcessedItem,
@@ -10,20 +8,15 @@ export const processContractStorageData = <T extends AnyObject>({
   data,
   sortById,
   sortByDir,
+  filters,
 }: {
   data: T[];
   sortById: string | undefined;
   sortByDir: SortDirection;
+  filters: { [key: string]: string[] };
 }): ContractStorageProcessedItem<T>[] => {
   let sortedData = [...data];
 
-  // Decode key and value
-  sortedData = sortedData.map((i) => ({
-    ...i,
-    keyJson: i.key ? decodeScVal(i.key) : undefined,
-    valueJson: i.value ? decodeScVal(i.value) : undefined,
-  }));
-
   // Sort
   if (sortById) {
     if (["asc", "desc"].includes(sortByDir)) {
@@ -39,17 +32,36 @@ export const processContractStorageData = <T extends AnyObject>({
     }
   }
 
-  // TODO: Filter
+  // Filter
+  const keyFilters = filters.key;
+  const valueFilters = filters.value;
 
-  return sortedData as ContractStorageProcessedItem<T>[];
-};
+  if (keyFilters.length > 0 || valueFilters.length > 0) {
+    sortedData = sortedData.filter((s) => {
+      let hasKeyFilter = false;
+      let hasValueFilter = false;
+
+      // Key
+      if (s.keyJson && Array.isArray(s.keyJson)) {
+        const sFilter = s.keyJson[0];
+
+        hasKeyFilter = keyFilters.includes(sFilter);
+      }
 
-const decodeScVal = (xdrString: string) => {
-  try {
-    return xdrString
-      ? (parse(StellarXdr.decode("ScVal", xdrString)) as AnyObject)
-      : null;
-  } catch (e) {
-    return null;
+      // Value
+      if (s.valueJson && typeof s.valueJson === "object") {
+        const vFilters = Object.keys(s.valueJson);
+
+        valueFilters.forEach((v) => {
+          if (vFilters.includes(v)) {
+            hasValueFilter = true;
+          }
+        });
+      }
+
+      return hasKeyFilter || hasValueFilter;
+    });
   }
+
+  return sortedData as ContractStorageProcessedItem<T>[];
 };
diff --git a/src/types/types.ts b/src/types/types.ts
index f14a1548..28955334 100644
--- a/src/types/types.ts
+++ b/src/types/types.ts
@@ -404,8 +404,8 @@ export type ContractStorageResponseItem = {
 };
 
 export type ContractStorageProcessedItem<T> = T & {
-  keyJson?: AnyObject;
-  valueJson?: AnyObject;
+  keyJson?: AnyObject | null;
+  valueJson?: AnyObject | null;
 };
 
 // =============================================================================
@@ -417,6 +417,7 @@ export type DataTableHeader = {
   id: string;
   value: string;
   isSortable?: boolean;
+  filter?: string[];
 };
 
 export type DataTableCell = {