From 05893b02f13dca33e3a0b435ac922199f61c384f Mon Sep 17 00:00:00 2001 From: Iveta Date: Wed, 8 Jan 2025 14:31:56 -0500 Subject: [PATCH] Contract Explorer: Contract version history (#1219) * Contract Info: version history * Styled table * Sort data * Added UI tests --- playwright.config.ts | 4 +- .../components/ContractInfo.tsx | 97 +++++++-- .../components/VersionHistory.tsx | 184 ++++++++++++++++++ .../contract-explorer/page.tsx | 8 +- src/components/ErrorText.tsx | 15 ++ src/components/Tabs/styles.scss | 2 + src/helpers/formatEpochToDate.ts | 20 +- .../external/useSEContracVersionHistory.ts | 45 +++++ src/query/external/useSEContractInfo.ts | 3 + src/styles/globals.scss | 98 ++++++++++ src/types/types.ts | 7 + tests/mock/smartContracts.ts | 20 ++ ....ts => smartContractsContractInfo.test.ts} | 28 +-- tests/smartContractsVersionHistory.test.ts | 105 ++++++++++ 14 files changed, 595 insertions(+), 41 deletions(-) create mode 100644 src/app/(sidebar)/smart-contracts/contract-explorer/components/VersionHistory.tsx create mode 100644 src/components/ErrorText.tsx create mode 100644 src/query/external/useSEContracVersionHistory.ts create mode 100644 tests/mock/smartContracts.ts rename tests/{contractInfo.test.ts => smartContractsContractInfo.test.ts} (84%) create mode 100644 tests/smartContractsVersionHistory.test.ts diff --git a/playwright.config.ts b/playwright.config.ts index 17a564df..8ba1d9c9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -23,8 +23,8 @@ export default defineConfig({ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, + /* Retry */ + retries: process.env.CI ? 2 : 1, /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ diff --git a/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractInfo.tsx b/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractInfo.tsx index fee463f0..ab435ae3 100644 --- a/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractInfo.tsx +++ b/src/app/(sidebar)/smart-contracts/contract-explorer/components/ContractInfo.tsx @@ -1,11 +1,17 @@ -import { Avatar, Card, Icon, Logo } from "@stellar/design-system"; +import { useState } from "react"; +import { Avatar, Badge, Card, Icon, Logo, Text } from "@stellar/design-system"; + import { Box } from "@/components/layout/Box"; import { SdsLink } from "@/components/SdsLink"; +import { TabView } from "@/components/TabView"; import { formatEpochToDate } from "@/helpers/formatEpochToDate"; import { formatNumber } from "@/helpers/formatNumber"; import { stellarExpertAccountLink } from "@/helpers/stellarExpertAccountLink"; + import { ContractInfoApiResponse, NetworkType } from "@/types/types"; +import { VersionHistory } from "./VersionHistory"; + export const ContractInfo = ({ infoData, networkId, @@ -13,6 +19,8 @@ export const ContractInfo = ({ infoData: ContractInfoApiResponse; networkId: NetworkType; }) => { + const [activeTab, setActiveTab] = useState("contract-version-history"); + type ContractExplorerInfoField = { id: string; label: string; @@ -85,9 +93,9 @@ export const ContractInfo = ({ key={field.id} label={field.label} value={ - infoData.validation?.repository ? ( + infoData.validation?.repository && infoData.validation?.commit ? ( @@ -163,15 +171,80 @@ export const ContractInfo = ({ } }; + const ComingSoonText = () => ( + + Coming soon + + ); + return ( - - - <>{INFO_FIELDS.map((f) => renderInfoField(f))} - - + + + + <>{INFO_FIELDS.map((f) => renderInfoField(f))} + + + + + + + + Contract + + + {infoData.validation?.status === "verified" ? ( + }> + Verified + + ) : ( + }> + Unverified + + )} + + + , + }} + tab2={{ + id: "contract-contract-info", + label: "Contract Info", + content: , + }} + tab3={{ + id: "contract-source-code", + label: "Source Code", + content: , + }} + tab4={{ + id: "contract-contract-storage", + label: "Contract Storage", + content: , + }} + tab5={{ + id: "contract-version-history", + label: "Version History", + content: ( + + ), + }} + activeTabId={activeTab} + onTabChange={(tabId) => { + setActiveTab(tabId); + }} + /> + + + ); }; diff --git a/src/app/(sidebar)/smart-contracts/contract-explorer/components/VersionHistory.tsx b/src/app/(sidebar)/smart-contracts/contract-explorer/components/VersionHistory.tsx new file mode 100644 index 00000000..e1707533 --- /dev/null +++ b/src/app/(sidebar)/smart-contracts/contract-explorer/components/VersionHistory.tsx @@ -0,0 +1,184 @@ +import { useState } from "react"; +import { Card, Icon, Loader, Text } from "@stellar/design-system"; + +import { Box } from "@/components/layout/Box"; +import { ErrorText } from "@/components/ErrorText"; +import { useSEContracVersionHistory } from "@/query/external/useSEContracVersionHistory"; +import { formatEpochToDate } from "@/helpers/formatEpochToDate"; + +import { NetworkType } from "@/types/types"; + +export const VersionHistory = ({ + contractId, + networkId, +}: { + contractId: string; + networkId: NetworkType; +}) => { + type SortDirection = "default" | "asc" | "desc"; + + const [sortById, setSortById] = useState(""); + const [sortByDir, setSortByDir] = useState("default"); + + const { + data: versionHistoryData, + error: versionHistoryError, + isLoading: isVersionHistoryLoading, + isFetching: isVersionHistoryFetching, + } = useSEContracVersionHistory({ + networkId, + contractId, + }); + + if (isVersionHistoryLoading || isVersionHistoryFetching) { + return ( + + + + ); + } + + if (versionHistoryError) { + return ( + + ); + } + + if (!versionHistoryData) { + return ( + + No version history + + ); + } + + type TableHeader = { + id: string; + value: string; + isSortable?: boolean; + }; + + const tableId = "contract-version-history"; + const cssGridTemplateColumns = "minmax(210px, 2fr) minmax(210px, 1fr)"; + const tableHeaders: TableHeader[] = [ + { id: "wasm", value: "Contract WASM Hash", isSortable: true }, + { id: "ts", value: "Updated", isSortable: true }, + ]; + + type TableCell = { + value: string; + isBold?: boolean; + }; + + const tableRowsData = (): TableCell[][] => { + let sortedData = [...versionHistoryData]; + + 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((vh) => [ + { value: vh.wasm, isBold: true }, + { value: formatEpochToDate(vh.ts, "short") || "-" }, + ]); + }; + + const customStyle = { + "--LabTable-grid-template-columns": cssGridTemplateColumns, + } as React.CSSProperties; + + const getSortByProps = (th: TableHeader) => { + if (th.isSortable) { + return { + "data-sortby-dir": sortById === th.id ? sortByDir : "default", + onClick: () => handleSort(th.id), + }; + } + + return {}; + }; + + const handleSort = (headerId: string) => { + let sortDir: SortDirection; + + // Sorting by new id + if (sortById && headerId !== sortById) { + sortDir = "asc"; + } else { + // Sorting the same id + if (sortByDir === "default") { + sortDir = "asc"; + } else if (sortByDir === "asc") { + sortDir = "desc"; + } else { + // from descending + sortDir = "default"; + } + } + + setSortById(headerId); + setSortByDir(sortDir); + }; + + return ( + + +
+
+ + + + {tableHeaders.map((th) => ( + + ))} + + + + {tableRowsData().map((row, rowIdx) => { + const rowKey = `${tableId}-row-${rowIdx}`; + + return ( + + {row.map((cell, cellIdx) => ( + + ))} + + ); + })} + +
+ {th.value} + {th.isSortable ? ( + + + + + ) : null} +
+ {cell.value} +
+
+
+
+
+ ); +}; diff --git a/src/app/(sidebar)/smart-contracts/contract-explorer/page.tsx b/src/app/(sidebar)/smart-contracts/contract-explorer/page.tsx index 2ea5a6d2..2b3b36e0 100644 --- a/src/app/(sidebar)/smart-contracts/contract-explorer/page.tsx +++ b/src/app/(sidebar)/smart-contracts/contract-explorer/page.tsx @@ -65,7 +65,13 @@ export default function ContractExplorer() { }; const renderContractInvokeContent = () => { - return Coming soon; + return ( + + + Coming soon + + + ); }; const renderButtons = () => { diff --git a/src/components/ErrorText.tsx b/src/components/ErrorText.tsx new file mode 100644 index 00000000..a7023d05 --- /dev/null +++ b/src/components/ErrorText.tsx @@ -0,0 +1,15 @@ +import { Text } from "@stellar/design-system"; + +export const ErrorText = ({ + errorMessage, + size, +}: { + errorMessage: string; + size: "sm" | "md" | "lg"; +}) => { + return ( + + {errorMessage} + + ); +}; diff --git a/src/components/Tabs/styles.scss b/src/components/Tabs/styles.scss index 86cf646f..0cae0303 100644 --- a/src/components/Tabs/styles.scss +++ b/src/components/Tabs/styles.scss @@ -7,11 +7,13 @@ display: flex; align-items: center; gap: pxToRem(8px); + flex-wrap: wrap; .Tab { font-size: pxToRem(14px); line-height: pxToRem(20px); font-weight: var(--sds-fw-medium); + white-space: nowrap; color: var(--Tabs-default-text); background-color: var(--Tabs-default-background); border-radius: pxToRem(6px); diff --git a/src/helpers/formatEpochToDate.ts b/src/helpers/formatEpochToDate.ts index 1f1db379..51623a37 100644 --- a/src/helpers/formatEpochToDate.ts +++ b/src/helpers/formatEpochToDate.ts @@ -1,11 +1,21 @@ -export const formatEpochToDate = (epoch: number) => { +export const formatEpochToDate = ( + epoch: number, + format: "short" | "long" = "long", +) => { try { const date = new Date(epoch * 1000); + + const dateOptions: Intl.DateTimeFormatOptions = + format === "short" + ? { + month: "2-digit", + day: "2-digit", + year: "numeric", + } + : { weekday: "short", month: "short", day: "numeric", year: "numeric" }; + const dateTimeFormatter = new Intl.DateTimeFormat("en-US", { - weekday: "short", - month: "short", - day: "numeric", - year: "numeric", + ...dateOptions, hour: "numeric", minute: "numeric", second: "numeric", diff --git a/src/query/external/useSEContracVersionHistory.ts b/src/query/external/useSEContracVersionHistory.ts new file mode 100644 index 00000000..602ce762 --- /dev/null +++ b/src/query/external/useSEContracVersionHistory.ts @@ -0,0 +1,45 @@ +import { useQuery } from "@tanstack/react-query"; +import { STELLAR_EXPERT_API } from "@/constants/settings"; +import { ContractVersionHistoryResponseItem, NetworkType } from "@/types/types"; + +/** + * StellarExpert API to get smart contract’s version history + */ +export const useSEContracVersionHistory = ({ + networkId, + contractId, +}: { + networkId: NetworkType; + contractId: string; +}) => { + const query = useQuery({ + queryKey: ["useSEContracVersionHistory", networkId, contractId], + queryFn: async () => { + // Not supported networks + if (["futurenet", "custom"].includes(networkId)) { + return null; + } + + const network = networkId === "mainnet" ? "public" : "testnet"; + + try { + const response = await fetch( + `${STELLAR_EXPERT_API}/${network}/contract/${contractId}/version`, + ); + + const responseJson = await response.json(); + + if (responseJson.error) { + throw responseJson.error; + } + + return responseJson?._embedded?.records || []; + } catch (e: any) { + throw `Something went wrong. ${e}`; + } + }, + enabled: Boolean(networkId && contractId), + }); + + return query; +}; diff --git a/src/query/external/useSEContractInfo.ts b/src/query/external/useSEContractInfo.ts index 9f8bb15d..16bf5324 100644 --- a/src/query/external/useSEContractInfo.ts +++ b/src/query/external/useSEContractInfo.ts @@ -2,6 +2,9 @@ import { useQuery } from "@tanstack/react-query"; import { STELLAR_EXPERT_API } from "@/constants/settings"; import { ContractInfoApiResponse, NetworkType } from "@/types/types"; +/** + * StellarExpert API to get smart contract info + */ export const useSEContractInfo = ({ networkId, contractId, diff --git a/src/styles/globals.scss b/src/styles/globals.scss index e356f91f..80364ca9 100644 --- a/src/styles/globals.scss +++ b/src/styles/globals.scss @@ -81,6 +81,104 @@ } } + .LabTable { + --LabTable-grid-template-columns: 1fr; + + &__container { + width: 100%; + position: relative; + overflow: hidden; + } + + &__scroll { + overflow-x: auto; + } + + &__table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-weight: var(--sds-fw-medium); + color: var(--sds-clr-gray-11); + + tr[data-style="row"] { + display: grid; + grid-template-columns: var(--LabTable-grid-template-columns); + } + + td, + th { + padding: pxToRem(8px) pxToRem(12px); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + th { + font-size: pxToRem(12px); + line-height: pxToRem(18px); + min-width: 100px; + + &[data-sortby-dir] { + cursor: pointer; + display: flex; + align-items: center; + gap: pxToRem(4px); + } + + &[data-sortby-dir="asc"] { + .LabTable__sortBy svg:first-of-type { + stroke: var(--sds-clr-gray-12); + } + } + + &[data-sortby-dir="desc"] { + .LabTable__sortBy svg:last-of-type { + stroke: var(--sds-clr-gray-12); + } + } + } + + td { + font-size: pxToRem(14px); + line-height: pxToRem(20px); + + &[data-style="bold"] { + color: var(--sds-clr-gray-12); + } + } + + tr:not(:last-child), + thead tr { + border-bottom: 1px solid var(--sds-clr-gray-06); + } + } + + &__sortBy { + display: block; + position: relative; + width: pxToRem(12px); + height: pxToRem(12px); + overflow: hidden; + + svg { + display: block; + position: absolute; + width: 100%; + left: 0; + stroke: var(--sds-clr-gray-09); + + &:first-of-type { + top: pxToRem(-3px); + } + + &:last-of-type { + bottom: pxToRem(-3px); + } + } + } + } + // =========================================================================== // Layout // =========================================================================== diff --git a/src/types/types.ts b/src/types/types.ts index d0458d50..db416d46 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -383,3 +383,10 @@ export type ContractInfoApiResponse = { function: string; }[]; }; + +export type ContractVersionHistoryResponseItem = { + operation: string; + paging_token: string; + ts: number; + wasm: string; +}; diff --git a/tests/mock/smartContracts.ts b/tests/mock/smartContracts.ts new file mode 100644 index 00000000..ab52de75 --- /dev/null +++ b/tests/mock/smartContracts.ts @@ -0,0 +1,20 @@ +import { SAVED_ACCOUNT_1 } from "./localStorage"; + +export const MOCK_CONTRACT_ID = + "C000NO6F7FRDHSOFQBT2L2UWYIZ2PU76JKVRYAQTG3KZSQLYAOKIF2WB"; +export const MOCK_CONTRACT_INFO_RESPONSE_SUCCESS = { + contract: MOCK_CONTRACT_ID, + account: MOCK_CONTRACT_ID, + created: 1731402776, + creator: SAVED_ACCOUNT_1, + payments: 300, + trades: 0, + wasm: "df88820e231ad8f3027871e5dd3cf45491d7b7735e785731466bfc2946008608", + storage_entries: 10, + validation: { + status: "verified", + repository: "https://github.com/test-org/test-repo", + commit: "391f37e39a849ddf7543a5d7f1488e055811cb68", + ts: 1731402776, + }, +}; diff --git a/tests/contractInfo.test.ts b/tests/smartContractsContractInfo.test.ts similarity index 84% rename from tests/contractInfo.test.ts rename to tests/smartContractsContractInfo.test.ts index 71f6229b..67d79fea 100644 --- a/tests/contractInfo.test.ts +++ b/tests/smartContractsContractInfo.test.ts @@ -1,8 +1,12 @@ import { test, expect } from "@playwright/test"; import { STELLAR_EXPERT_API } from "@/constants/settings"; import { SAVED_ACCOUNT_1 } from "./mock/localStorage"; +import { + MOCK_CONTRACT_ID, + MOCK_CONTRACT_INFO_RESPONSE_SUCCESS, +} from "./mock/smartContracts"; -test.describe("Contract Info", () => { +test.describe("Smart Contracts: Contract Info", () => { test.beforeEach(async ({ page }) => { await page.goto("http://localhost:3000/smart-contracts/contract-explorer"); }); @@ -23,7 +27,7 @@ test.describe("Contract Info", () => { await route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify(MOCK_RESPONSE_SUCCESS), + body: JSON.stringify(MOCK_CONTRACT_INFO_RESPONSE_SUCCESS), }); }, ); @@ -50,7 +54,7 @@ test.describe("Contract Info", () => { // Show info await expect( - page.getByText("Contract Info", { exact: true }), + page.getByText("Contract Info", { exact: true }).first(), ).toHaveAttribute("data-is-active", "true"); const contractInfoContainer = page.getByTestId("contract-info-container"); @@ -108,24 +112,6 @@ test.describe("Contract Info", () => { // ============================================================================= // Mock data // ============================================================================= -const MOCK_CONTRACT_ID = - "CBP7NO6F7FRDHSOFQBT2L2UWYIZ2PU76JKVRYAQTG3KZSQLYAOKIF2WB"; -const MOCK_RESPONSE_SUCCESS = { - contract: MOCK_CONTRACT_ID, - account: MOCK_CONTRACT_ID, - created: 1731402776, - creator: SAVED_ACCOUNT_1, - payments: 300, - trades: 0, - wasm: "df88820e231ad8f3027871e5dd3cf45491d7b7735e785731466bfc2946008608", - storage_entries: 10, - validation: { - status: "verified", - repository: "https://github.com/test-org/test-repo", - commit: "391f37e39a849ddf7543a5d7f1488e055811cb68", - ts: 1731402776, - }, -}; const MOCK_RESPONSE_ERROR = { error: "Not found. Contract was not found on the ledger. Check if you specified contract address correctly.", diff --git a/tests/smartContractsVersionHistory.test.ts b/tests/smartContractsVersionHistory.test.ts new file mode 100644 index 00000000..1567b72b --- /dev/null +++ b/tests/smartContractsVersionHistory.test.ts @@ -0,0 +1,105 @@ +import { test, expect } from "@playwright/test"; +import { STELLAR_EXPERT_API } from "@/constants/settings"; +import { + MOCK_CONTRACT_ID, + MOCK_CONTRACT_INFO_RESPONSE_SUCCESS, +} from "./mock/smartContracts"; + +test.describe("Smart Contracts: Version History", () => { + test.beforeEach(async ({ page }) => { + // Mock the Contract Info API call + await page.route( + `${STELLAR_EXPERT_API}/testnet/contract/${MOCK_CONTRACT_ID}`, + async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(MOCK_CONTRACT_INFO_RESPONSE_SUCCESS), + }); + }, + ); + + // Mock the Version History API call + await page.route( + `${STELLAR_EXPERT_API}/testnet/contract/${MOCK_CONTRACT_ID}/version`, + async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(MOCK_RESPONSE_SUCCESS), + }); + }, + ); + + await page.goto("http://localhost:3000/smart-contracts/contract-explorer"); + await expect(page.locator("h1")).toHaveText("Contract Explorer"); + + // Load Contract Info + await page.getByLabel("Contract ID").fill(MOCK_CONTRACT_ID); + await page.getByRole("button", { name: "Load contract" }).click(); + }); + + test("Loads", async ({ page }) => { + await expect( + page.getByText("Version History", { exact: true }), + ).toHaveAttribute("data-is-active", "true"); + }); + + test("Table data", async ({ page }) => { + const table = page.getByTestId("version-history-table"); + const colWasm = table.locator("th").nth(0); + const colUpdated = table.locator("th").nth(1); + + // Table headers + await expect(colWasm).toContainText("Contract WASM Hash"); + await expect(colUpdated).toContainText("Updated"); + + // Table data + const firstRow = table.locator("tr").nth(1); + await expect(firstRow.locator("td").nth(0)).toContainText(DATA_1_WASM); + await expect(firstRow.locator("td").nth(1)).toContainText( + "08/09/2024, 18:46:16 UTC", + ); + + const secondRow = table.locator("tr").nth(2); + await expect(secondRow.locator("td").nth(0)).toContainText(DATA_2_WASM); + await expect(secondRow.locator("td").nth(1)).toContainText( + "08/09/2024, 21:07:18 UTC", + ); + + // Sort by Wasm hash + await colWasm.click(); + await expect(firstRow.locator("td").nth(0)).toContainText(DATA_2_WASM); + + // Sort by Updated + await colUpdated.click(); + await expect(firstRow.locator("td").nth(0)).toContainText(DATA_1_WASM); + }); +}); + +// ============================================================================= +// Mock data +// ============================================================================= +const DATA_1_WASM = + "eea70a48a2fbac11ed98c081b11dbdce89e6be8d421a833228069497c1c50d28"; +const DATA_2_WASM = + "531feab70c29fe5373191071fdc5d92057cccee9f5d8113fc090447029868100"; + +const MOCK_RESPONSE_SUCCESS = { + _embedded: { + records: [ + { + wasm: DATA_1_WASM, + operation: "227446955302756353", + ts: 1723229176, + paging_token: "QAAFc6L7rBFmtmP4sR29zonmvo1CGoMyKAaUl8HFDSg=", + }, + { + wasm: DATA_2_WASM, + operation: "227453183007010817", + ts: 1723237638, + paging_token: "QAAFcwwp/lNmtoUG/cXZIFfMzun12BE/wJBEcCmGgQA=", + }, + ], + }, +};