Skip to content

Commit

Permalink
Contract Explorer: Contract version history (#1219)
Browse files Browse the repository at this point in the history
* Contract Info: version history

* Styled table

* Sort data

* Added UI tests
  • Loading branch information
quietbits authored Jan 8, 2025
1 parent deae27d commit 05893b0
Show file tree
Hide file tree
Showing 14 changed files with 595 additions and 41 deletions.
4 changes: 2 additions & 2 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
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,
}: {
infoData: ContractInfoApiResponse;
networkId: NetworkType;
}) => {
const [activeTab, setActiveTab] = useState("contract-version-history");

type ContractExplorerInfoField = {
id: string;
label: string;
Expand Down Expand Up @@ -85,9 +93,9 @@ export const ContractInfo = ({
key={field.id}
label={field.label}
value={
infoData.validation?.repository ? (
infoData.validation?.repository && infoData.validation?.commit ? (
<SdsLink
href={infoData.validation.repository}
href={`${infoData.validation.repository}/tree/${infoData.validation.commit}`}
addlClassName="Link--external"
>
<Logo.Github />
Expand Down Expand Up @@ -163,15 +171,80 @@ export const ContractInfo = ({
}
};

const ComingSoonText = () => (
<Text as="div" size="sm">
Coming soon
</Text>
);

return (
<Card>
<Box
gap="xs"
addlClassName="ContractInfo"
data-testid="contract-info-container"
>
<>{INFO_FIELDS.map((f) => renderInfoField(f))}</>
</Box>
</Card>
<Box gap="lg">
<Card>
<Box
gap="xs"
addlClassName="ContractInfo"
data-testid="contract-info-container"
>
<>{INFO_FIELDS.map((f) => renderInfoField(f))}</>
</Box>
</Card>

<Card>
<Box gap="lg" data-testid="contract-info-contract-container">
<Box gap="sm" direction="row" align="center">
<Text as="h2" size="md" weight="semi-bold">
Contract
</Text>

{infoData.validation?.status === "verified" ? (
<Badge variant="success" icon={<Icon.CheckCircle />}>
Verified
</Badge>
) : (
<Badge variant="error" icon={<Icon.XCircle />}>
Unverified
</Badge>
)}
</Box>

<TabView
tab1={{
id: "contract-bindings",
label: "Bindings",
content: <ComingSoonText />,
}}
tab2={{
id: "contract-contract-info",
label: "Contract Info",
content: <ComingSoonText />,
}}
tab3={{
id: "contract-source-code",
label: "Source Code",
content: <ComingSoonText />,
}}
tab4={{
id: "contract-contract-storage",
label: "Contract Storage",
content: <ComingSoonText />,
}}
tab5={{
id: "contract-version-history",
label: "Version History",
content: (
<VersionHistory
contractId={infoData.contract}
networkId={networkId}
/>
),
}}
activeTabId={activeTab}
onTabChange={(tabId) => {
setActiveTab(tabId);
}}
/>
</Box>
</Card>
</Box>
);
};
Original file line number Diff line number Diff line change
@@ -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<SortDirection>("default");

const {
data: versionHistoryData,
error: versionHistoryError,
isLoading: isVersionHistoryLoading,
isFetching: isVersionHistoryFetching,
} = useSEContracVersionHistory({
networkId,
contractId,
});

if (isVersionHistoryLoading || isVersionHistoryFetching) {
return (
<Box gap="sm" direction="row" justify="center">
<Loader />
</Box>
);
}

if (versionHistoryError) {
return (
<ErrorText errorMessage={versionHistoryError.toString()} size="sm" />
);
}

if (!versionHistoryData) {
return (
<Text as="div" size="sm">
No version history
</Text>
);
}

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 (
<Box gap="md">
<Card noPadding={true}>
<div className="LabTable__container">
<div className="LabTable__scroll">
<table
className="LabTable__table"
style={customStyle}
data-testid="version-history-table"
>
<thead>
<tr data-style="row" role="row">
{tableHeaders.map((th) => (
<th key={th.id} role="cell" {...getSortByProps(th)}>
{th.value}
{th.isSortable ? (
<span className="LabTable__sortBy">
<Icon.ChevronUp />
<Icon.ChevronDown />
</span>
) : null}
</th>
))}
</tr>
</thead>
<tbody>
{tableRowsData().map((row, rowIdx) => {
const rowKey = `${tableId}-row-${rowIdx}`;

return (
<tr data-style="row" role="row" key={rowKey}>
{row.map((cell, cellIdx) => (
<td
key={`${rowKey}-cell-${cellIdx}`}
title={cell.value}
role="cell"
{...(cell.isBold ? { "data-style": "bold" } : {})}
>
{cell.value}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</Card>
</Box>
);
};
8 changes: 7 additions & 1 deletion src/app/(sidebar)/smart-contracts/contract-explorer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,13 @@ export default function ContractExplorer() {
};

const renderContractInvokeContent = () => {
return <Card>Coming soon</Card>;
return (
<Card>
<Text as="div" size="sm">
Coming soon
</Text>
</Card>
);
};

const renderButtons = () => {
Expand Down
15 changes: 15 additions & 0 deletions src/components/ErrorText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Text } from "@stellar/design-system";

export const ErrorText = ({
errorMessage,
size,
}: {
errorMessage: string;
size: "sm" | "md" | "lg";
}) => {
return (
<Text as="div" size={size} addlClassName="FieldNote--error">
{errorMessage}
</Text>
);
};
2 changes: 2 additions & 0 deletions src/components/Tabs/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 15 additions & 5 deletions src/helpers/formatEpochToDate.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading

0 comments on commit 05893b0

Please sign in to comment.