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 = {