From b7ab318eadf0a5643dd340db696717a229dfa5ff Mon Sep 17 00:00:00 2001 From: Iveta Date: Fri, 24 Jan 2025 17:18:38 -0500 Subject: [PATCH 1/9] Contract Explorer: Contract storage filters in progress --- .../components/ContractStorage.tsx | 22 +++++-- src/components/DataTable/index.tsx | 60 +++++++++++-------- src/components/DataTable/styles.scss | 12 ++++ src/components/ScValPrettyJson/index.tsx | 16 +++-- src/helpers/processContractStorageData.ts | 55 +++++++++++++++++ src/query/external/useSEContracStorage.ts | 1 + src/types/types.ts | 5 ++ 7 files changed, 138 insertions(+), 33 deletions(-) create mode 100644 src/helpers/processContractStorageData.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 cda49735..d7b20e8e 100644 --- a/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractStorage.tsx +++ b/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractStorage.tsx @@ -12,7 +12,11 @@ import { capitalizeString } from "@/helpers/capitalizeString"; import { useIsXdrInit } from "@/hooks/useIsXdrInit"; -import { ContractStorageResponseItem, NetworkType } from "@/types/types"; +import { + ContractStorageProcessedItem, + ContractStorageResponseItem, + NetworkType, +} from "@/types/types"; export const ContractStorage = ({ isActive, @@ -71,18 +75,28 @@ export const ContractStorage = ({ { id: "ttl", value: "TTL", isSortable: true }, { id: "updated", value: "Updated", isSortable: true }, ]} - formatDataRow={(vh: ContractStorageResponseItem) => [ + formatDataRow={( + vh: ContractStorageProcessedItem, + ) => [ { value: (
- +
), }, { value: (
- +
), }, diff --git a/src/components/DataTable/index.tsx b/src/components/DataTable/index.tsx index 34e1e0f3..8f2e0a62 100644 --- a/src/components/DataTable/index.tsx +++ b/src/components/DataTable/index.tsx @@ -1,11 +1,18 @@ import { useEffect, useState } from "react"; -import { Button, Card, Icon } from "@stellar/design-system"; +import { Button, Card, Icon, Loader } from "@stellar/design-system"; import { Box } from "@/components/layout/Box"; -import { DataTableCell, DataTableHeader, SortDirection } from "@/types/types"; +import { processContractStorageData } from "@/helpers/processContractStorageData"; +import { + AnyObject, + ContractStorageProcessedItem, + DataTableCell, + DataTableHeader, + SortDirection, +} from "@/types/types"; import "./styles.scss"; -export const DataTable = ({ +export const DataTable = ({ tableId, cssGridTemplateColumns, tableHeaders, @@ -23,6 +30,12 @@ export const DataTable = ({ const PAGE_SIZE = 20; const tableDataSize = tableData.length; + // Data + const [processedData, setProcessedData] = useState< + ContractStorageProcessedItem[] + >([]); + const [isUpdating, setIsUpdating] = useState(false); + // Sort by const [sortById, setSortById] = useState(""); const [sortByDir, setSortByDir] = useState("default"); @@ -31,10 +44,25 @@ export const DataTable = ({ const [currentPage, setCurrentPage] = useState(1); const [totalPageCount, setTotalPageCount] = useState(1); + useEffect(() => { + const data = processContractStorageData({ + data: tableData, + sortById, + sortByDir, + }); + + setProcessedData(data); + }, [tableData, sortByDir, sortById]); + useEffect(() => { setTotalPageCount(Math.ceil(tableDataSize / PAGE_SIZE)); }, [tableDataSize]); + // Hide loader when processed data is done + useEffect(() => { + setIsUpdating(false); + }, [processedData]); + const getSortByProps = (th: DataTableHeader) => { if (th.isSortable) { return { @@ -67,27 +95,7 @@ export const DataTable = ({ setSortById(headerId); setSortByDir(sortDir); setCurrentPage(1); - }; - - const tableRowsData = (): DataTableCell[][] => { - let sortedData = [...tableData]; - - // Sort - if (sortById) { - if (["asc", "desc"].includes(sortByDir)) { - // Asc - sortedData = sortedData.sort((a: any, b: any) => - a[sortById] > b[sortById] ? 1 : -1, - ); - - // Desc - if (sortByDir === "desc") { - sortedData = sortedData.reverse(); - } - } - } - - return sortedData.map(formatDataRow); + setIsUpdating(true); }; const paginateData = (data: DataTableCell[][]): DataTableCell[][] => { @@ -105,18 +113,20 @@ export const DataTable = ({ "--DataTable-grid-template-columns": cssGridTemplateColumns, } as React.CSSProperties; - const displayData = paginateData(tableRowsData()); + const displayData = paginateData(processedData.map(formatDataRow)); return ( {/* Table */}
+ {isUpdating ? : null}
diff --git a/src/components/DataTable/styles.scss b/src/components/DataTable/styles.scss index dbdda21d..7a394d42 100644 --- a/src/components/DataTable/styles.scss +++ b/src/components/DataTable/styles.scss @@ -7,6 +7,13 @@ width: 100%; position: relative; overflow: hidden; + + & > .Loader { + position: absolute; + top: 5rem; + left: 50%; + transform: translateX(-50%); + } } &__scroll { @@ -19,6 +26,7 @@ text-align: left; font-weight: var(--sds-fw-medium); color: var(--sds-clr-gray-11); + transition: opacity var(--sds-anim-transition-default); tr[data-style="row"] { display: grid; @@ -71,6 +79,10 @@ thead tr { border-bottom: 1px solid var(--sds-clr-gray-06); } + + &[data-disabled="true"] { + opacity: 0.5; + } } &__sortBy { diff --git a/src/components/ScValPrettyJson/index.tsx b/src/components/ScValPrettyJson/index.tsx index 8c64d27b..2db7a7a2 100644 --- a/src/components/ScValPrettyJson/index.tsx +++ b/src/components/ScValPrettyJson/index.tsx @@ -17,11 +17,13 @@ import "./styles.scss"; /* Create contract storage JSON-like structure */ export const ScValPrettyJson = ({ - xdrString, isReady, + xdrString, + json, }: { - xdrString: string; isReady: boolean; + xdrString?: string; + json?: AnyObject | null; }) => { const { network } = useStore(); @@ -31,7 +33,9 @@ export const ScValPrettyJson = ({ const parseJson = () => { try { - return parse(StellarXdr.decode("ScVal", xdrString)) as AnyObject; + return xdrString + ? (parse(StellarXdr.decode("ScVal", xdrString)) as AnyObject) + : null; } catch (e: any) { return null; } @@ -329,7 +333,11 @@ export const ScValPrettyJson = ({ }; // Entry point - return
{render({ item: parseJson() })}
; + return ( +
+ {render({ item: typeof json !== "undefined" ? json : parseJson() })} +
+ ); }; // ============================================================================= diff --git a/src/helpers/processContractStorageData.ts b/src/helpers/processContractStorageData.ts new file mode 100644 index 00000000..167311fb --- /dev/null +++ b/src/helpers/processContractStorageData.ts @@ -0,0 +1,55 @@ +import { parse } from "lossless-json"; +import * as StellarXdr from "@/helpers/StellarXdr"; +import { + AnyObject, + ContractStorageProcessedItem, + SortDirection, +} from "@/types/types"; + +export const processContractStorageData = ({ + data, + sortById, + sortByDir, +}: { + data: T[]; + sortById: string | undefined; + sortByDir: SortDirection; +}): ContractStorageProcessedItem[] => { + 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)) { + // Asc + sortedData = sortedData.sort((a: any, b: any) => + a[sortById] > b[sortById] ? 1 : -1, + ); + + // Desc + if (sortByDir === "desc") { + sortedData = sortedData.reverse(); + } + } + } + + // TODO: Filter + + return sortedData as ContractStorageProcessedItem[]; +}; + +const decodeScVal = (xdrString: string) => { + try { + return xdrString + ? (parse(StellarXdr.decode("ScVal", xdrString)) as AnyObject) + : null; + } catch (e) { + return null; + } +}; diff --git a/src/query/external/useSEContracStorage.ts b/src/query/external/useSEContracStorage.ts index b5db121e..2bae55e7 100644 --- a/src/query/external/useSEContracStorage.ts +++ b/src/query/external/useSEContracStorage.ts @@ -65,6 +65,7 @@ export const useSEContractStorage = ({ await fetchData(lastRecord?.paging_token); } + // TODO: check max entries for smooth UX return allRecords; } catch (e: any) { throw `Something went wrong. ${e}`; diff --git a/src/types/types.ts b/src/types/types.ts index e4d06796..f14a1548 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -403,6 +403,11 @@ export type ContractStorageResponseItem = { expired?: boolean; }; +export type ContractStorageProcessedItem = T & { + keyJson?: AnyObject; + valueJson?: AnyObject; +}; + // ============================================================================= // Data table // ============================================================================= From a81f509f8b47f499b85ad937de2b8d197f1c7ff7 Mon Sep 17 00:00:00 2001 From: Iveta Date: Fri, 24 Jan 2025 17:34:40 -0500 Subject: [PATCH 2/9] Fix ScValPrettyJson --- src/components/ScValPrettyJson/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ScValPrettyJson/index.tsx b/src/components/ScValPrettyJson/index.tsx index 2db7a7a2..cc641903 100644 --- a/src/components/ScValPrettyJson/index.tsx +++ b/src/components/ScValPrettyJson/index.tsx @@ -335,7 +335,7 @@ export const ScValPrettyJson = ({ // Entry point return (
- {render({ item: typeof json !== "undefined" ? json : parseJson() })} + {render({ item: json ? json : parseJson() })}
); }; From 52fa8e709ed04eba20d5eedf09970a03b0bd7c6b Mon Sep 17 00:00:00 2001 From: Iveta Date: Tue, 28 Jan 2025 15:52:54 -0500 Subject: [PATCH 3/9] 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 ( - + ), }, { value: (
- +
), }, 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 = ({ customFooterEl?: React.ReactNode; }) => { const PAGE_SIZE = 20; - const tableDataSize = tableData.length; // Data const [processedData, setProcessedData] = useState< @@ -40,6 +49,20 @@ export const DataTable = ({ const [sortById, setSortById] = useState(""); const [sortByDir, setSortByDir] = useState("default"); + // Filters + type FilterCols = "key" | "value"; + type DataFilters = { [key: string]: string[] }; + + const INIT_FILTERS = { key: [], value: [] }; + + const [visibleFilters, setVisibleFilters] = useState( + undefined, + ); + const [selectedFilters, setSelectedFilters] = + useState(INIT_FILTERS); + const [appliedFilters, setAppliedFilters] = + useState(INIT_FILTERS); + // Pagination const [currentPage, setCurrentPage] = useState(1); const [totalPageCount, setTotalPageCount] = useState(1); @@ -49,21 +72,19 @@ export const DataTable = ({ 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 = ({ }; } + 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 = ({ 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 = ({ 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 ( + { + setVisibleFilters(undefined); + }} + triggerDataAttribute="filter" + > +
+
Filter by
+
+ {filters.map((f) => { + const id = `filter-${headerId}-${f}`; + let currentFilters = selectedFilters[headerId] || []; + + return ( +
+ + { + if (currentFilters.includes(f)) { + currentFilters = currentFilters.filter( + (c) => c !== f, + ); + } else { + currentFilters = [...currentFilters, f]; + } + + setSelectedFilters({ + ...selectedFilters, + [headerId]: currentFilters, + }); + }} + checked={currentFilters.includes(f)} + /> +
+ ); + })} +
+
+ + +
+
+
+ ); + } + + return null; + }; + + const renderFilterBadges = () => { + return Object.entries(appliedFilters).map((af) => { + const [id, filters] = af; + + return ( + <> + {filters.map((f) => ( +
+ {f} + +
{ + const idFilters = appliedFilters[id].filter((c) => c !== f); + const updatedFilters = { ...appliedFilters, [id]: idFilters }; + + // Update both selected and applied filters + setSelectedFilters(updatedFilters); + setAppliedFilters(updatedFilters); + }} + > + +
+
+ ))} + + ); + }); + }; + const customStyle = { "--DataTable-grid-template-columns": cssGridTemplateColumns, } as React.CSSProperties; @@ -117,6 +291,13 @@ export const DataTable = ({ return ( + + + {/* Applied filter badges */} + {renderFilterBadges()} + + + {/* Table */}
@@ -131,14 +312,27 @@ export const DataTable = ({
{tableHeaders.map((th) => ( - ))} 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(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 ( +
+
{children}
+
+ ); +}; 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 = ({ data, sortById, sortByDir, + filters, }: { data: T[]; sortById: string | undefined; sortByDir: SortDirection; + filters: { [key: string]: string[] }; }): ContractStorageProcessedItem[] => { 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 = ({ } } - // TODO: Filter + // Filter + const keyFilters = filters.key; + const valueFilters = filters.value; - return sortedData as ContractStorageProcessedItem[]; -}; + 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[]; }; 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 & { - 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 = { From 8fe9aef69131f122a91ca3af85e93e6c8e557d9a Mon Sep 17 00:00:00 2001 From: Iveta Date: Wed, 29 Jan 2025 09:17:27 -0500 Subject: [PATCH 4/9] Filter dropdown styled --- src/components/DataTable/index.tsx | 71 ++++++++++++++--------- src/components/DataTable/styles.scss | 44 +++++++++++++- src/query/external/useSEContracStorage.ts | 1 - 3 files changed, 86 insertions(+), 30 deletions(-) diff --git a/src/components/DataTable/index.tsx b/src/components/DataTable/index.tsx index f07dfc8c..674dcb62 100644 --- a/src/components/DataTable/index.tsx +++ b/src/components/DataTable/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { Fragment, useEffect, useState } from "react"; import { Button, Card, @@ -126,8 +126,18 @@ export const DataTable = ({ setIsUpdating(true); }; + const closeFilterDropdown = () => { + setVisibleFilters(undefined); + // Clear selections that weren't applied + setSelectedFilters({ ...appliedFilters }); + }; + const toggleFilterDropdown = (headerId: FilterCols) => { - setVisibleFilters(visibleFilters === headerId ? undefined : headerId); + if (visibleFilters === headerId) { + closeFilterDropdown(); + } else { + setVisibleFilters(headerId); + } }; const paginateData = (data: DataTableCell[][]): DataTableCell[][] => { @@ -176,21 +186,19 @@ export const DataTable = ({ { - setVisibleFilters(undefined); - }} + onClose={closeFilterDropdown} triggerDataAttribute="filter" >
Filter by
-
- {filters.map((f) => { - const id = `filter-${headerId}-${f}`; + <> + {filters.map((f, idx) => { + const id = `filter-${headerId}-${f}-${idx}`; let currentFilters = selectedFilters[headerId] || []; return (
-
); })} -
-
+ + + -
+
); @@ -250,14 +265,14 @@ export const DataTable = ({ }; const renderFilterBadges = () => { - return Object.entries(appliedFilters).map((af) => { + return Object.entries(appliedFilters).map((af, afIdx) => { const [id, filters] = af; return ( - <> - {filters.map((f) => ( + + {filters.map((f, fIdx) => (
{f} @@ -278,7 +293,7 @@ export const DataTable = ({
))} - +
); }); }; @@ -311,8 +326,8 @@ export const DataTable = ({ >
- {tableHeaders.map((th) => ( -
- {th.value} - {th.isSortable ? ( - - - - - ) : null} + +
+ {th.value} + + {/* Sort icon */} + {th.isSortable ? ( + + + + + ) : null} + + {/* Filter icon */} + {th.filter ? ( + + + + ) : null} +
+ + {renderFilterDropdown(th.id, th.filter)}
+ {tableHeaders.map((th, idx) => ( +
{th.value} diff --git a/src/components/DataTable/styles.scss b/src/components/DataTable/styles.scss index f4bdbd8f..f0d368bc 100644 --- a/src/components/DataTable/styles.scss +++ b/src/components/DataTable/styles.scss @@ -140,14 +140,56 @@ top: 85%; left: pxToRem(4px); - // TODO: style &__container { + display: flex; + flex-direction: column; + gap: pxToRem(4px); } &__title { + font-size: pxToRem(14px); + line-height: pxToRem(20px); + font-weight: var(--sds-fw-medium); + color: var(--sds-clr-gray-11); + padding: pxToRem(6px) pxToRem(8px); } &__filter { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: pxToRem(6px) pxToRem(8px); + background-color: transparent; + transition: background-color var(--sds-anim-transition-default); + border-radius: pxToRem(4px); + cursor: pointer; + + .Label__wrapper { + width: 100%; + } + + .Label { + color: var(--sds-clr-gray-12); + width: 100%; + cursor: pointer; + } + + @media (hover: hover) { + &:hover { + background-color: var(--sds-clr-gray-04); + } + } + + &:has(input[type="checkbox"]:checked) { + background-color: var(--sds-clr-gray-04); + } + } + + &__buttons { + .Button { + flex: 1; + } } } diff --git a/src/query/external/useSEContracStorage.ts b/src/query/external/useSEContracStorage.ts index 2bae55e7..b5db121e 100644 --- a/src/query/external/useSEContracStorage.ts +++ b/src/query/external/useSEContracStorage.ts @@ -65,7 +65,6 @@ export const useSEContractStorage = ({ await fetchData(lastRecord?.paging_token); } - // TODO: check max entries for smooth UX return allRecords; } catch (e: any) { throw `Something went wrong. ${e}`; From 3f073e0afdea7cfc2c9cbdb656d7f0adcc8204f5 Mon Sep 17 00:00:00 2001 From: Iveta Date: Wed, 29 Jan 2025 11:00:07 -0500 Subject: [PATCH 5/9] Tweaks --- src/components/DataTable/index.tsx | 8 +++++++- src/components/DataTable/styles.scss | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/DataTable/index.tsx b/src/components/DataTable/index.tsx index 674dcb62..e29983b9 100644 --- a/src/components/DataTable/index.tsx +++ b/src/components/DataTable/index.tsx @@ -198,7 +198,7 @@ export const DataTable = ({ return (
-