Skip to content

Commit

Permalink
#153 Add application summary (#160)
Browse files Browse the repository at this point in the history
  • Loading branch information
nevendyulgerov authored May 2, 2024
1 parent 289b538 commit 651589b
Show file tree
Hide file tree
Showing 11 changed files with 1,046 additions and 3 deletions.
8 changes: 6 additions & 2 deletions apps/web/src/app/applications/[address]/inputs/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Group, Stack, Title } from "@mantine/core";
import { Group, Stack, Text, Title } from "@mantine/core";
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { FC } from "react";
Expand Down Expand Up @@ -61,7 +61,11 @@ const ApplicationInputsPage: FC<ApplicationInputsPageProps> = async ({
},
]}
>
<Address value={params.address as AddressType} icon />
<Address
value={params.address as AddressType}
href={`/applications/${params.address}`}
/>
<Text>Inputs</Text>
</Breadcrumbs>

<Group>
Expand Down
75 changes: 75 additions & 0 deletions apps/web/src/app/applications/[address]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Group, Stack, Title } from "@mantine/core";
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { FC } from "react";
import { TbStack2 } from "react-icons/tb";
import { Address as AddressType } from "viem";
import Address from "../../../components/address";
import Breadcrumbs from "../../../components/breadcrumbs";
import ApplicationSummary from "../../../components/applications/applicationSummary";
import {
ApplicationByIdDocument,
ApplicationByIdQuery,
ApplicationByIdQueryVariables,
} from "../../../graphql/explorer/operations";
import { getUrqlServerClient } from "../../../lib/urql";

export async function generateMetadata({
params,
}: ApplicationPageProps): Promise<Metadata> {
return {
title: `Application ${params.address}`,
};
}

async function getApplication(address: string) {
const client = getUrqlServerClient();
const result = await client.query<
ApplicationByIdQuery,
ApplicationByIdQueryVariables
>(ApplicationByIdDocument, {
id: address.toLowerCase(),
});

return result.data?.applicationById;
}

export type ApplicationPageProps = {
params: { address: string };
};

const ApplicationPage: FC<ApplicationPageProps> = async ({ params }) => {
const application = await getApplication(params.address);

if (!application) {
notFound();
}

return (
<Stack>
<Breadcrumbs
breadcrumbs={[
{
href: "/",
label: "Home",
},
{
href: "/applications",
label: "Applications",
},
]}
>
<Address value={params.address as AddressType} icon />
</Breadcrumbs>

<Group>
<TbStack2 size={40} />
<Title order={2}>Summary</Title>
</Group>

<ApplicationSummary applicationId={params.address} />
</Stack>
);
};

export default ApplicationPage;
143 changes: 143 additions & 0 deletions apps/web/src/components/applications/applicationSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"use client";

import {
Button,
Card,
Flex,
Grid,
Group,
Skeleton,
Stack,
Text,
useMantineTheme,
} from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import React, { FC } from "react";
import { SummaryCard } from "@cartesi/rollups-explorer-ui";
import { TbInbox } from "react-icons/tb";
import { useInputsConnectionQuery } from "../../graphql/explorer/hooks/queries";
import { InputOrderByInput } from "../../graphql/explorer/types";
import { useConnectionConfig } from "../../providers/connectionConfig/hooks";
import { Address } from "viem";
import Link from "next/link";
import LatestInputsTable from "./latestInputsTable";
import ConnectionSummary from "../connection/connectionSummary";

const SummarySkeletonCard = () => (
<Card shadow="xs" w="100%">
<Skeleton animate={false} height={20} circle mb={18} />
<Skeleton animate={false} height={8} radius="xl" />
<Skeleton animate={false} height={8} mt={6} radius="xl" />
<Skeleton animate={false} height={8} mt={6} width="70%" radius="xl" />
</Card>
);

export type ApplicationSummaryProps = {
applicationId: string;
};

const ApplicationSummary: FC<ApplicationSummaryProps> = ({ applicationId }) => {
const [{ data, fetching }] = useInputsConnectionQuery({
variables: {
orderBy: InputOrderByInput.TimestampDesc,
limit: 6,
where: {
application: {
id_eq: applicationId.toLowerCase(),
},
},
},
});
const inputs = data?.inputsConnection.edges.map((edge) => edge.node) ?? [];
const inputsTotalCount = data?.inputsConnection.totalCount ?? 0;

const { getConnection, hasConnection, showConnectionModal } =
useConnectionConfig();
const connection = getConnection(applicationId as Address);
const isAppConnected = hasConnection(applicationId as Address);
const theme = useMantineTheme();
const isSmallDevice = useMediaQuery(`(max-width:${theme.breakpoints.sm})`);

return (
<Stack>
<Grid gutter="sm">
<Grid.Col span={{ base: 12, sm: 6, md: 3 }} mb="sm">
<SummaryCard
title="Inputs"
value={data?.inputsConnection?.totalCount ?? 0}
icon={TbInbox}
/>
</Grid.Col>

{isAppConnected ? (
<ConnectionSummary url={connection?.url as string} />
) : (
<Grid.Col
span={{ base: 12, sm: 6, md: 9 }}
mb="sm"
data-testid="skeleton"
>
<Flex m={0} p={0} gap="sm" w="100%">
<SummarySkeletonCard />
<SummarySkeletonCard />
<SummarySkeletonCard />
</Flex>
</Grid.Col>
)}

{!isAppConnected && (
<Flex justify="center" w="100%" mb="1.5rem">
<Button
variant="light"
radius="md"
tt="uppercase"
mx={6}
fullWidth={isSmallDevice}
onClick={() =>
showConnectionModal(applicationId as Address)
}
>
Add connection
</Button>
</Flex>
)}

<Card w="100%" h="100%" mt="md" mx={6}>
<Group gap={5} align="center">
<TbInbox size={20} />
<Text c="dimmed" lh={1}>
Latest inputs
</Text>
</Group>

<Group gap={5}>
<LatestInputsTable
inputs={inputs}
fetching={fetching}
totalCount={inputsTotalCount}
/>
</Group>

{inputsTotalCount > 0 && (
<Group gap={5} mt="auto">
<Button
component={Link}
href={`/applications/${applicationId}/inputs`}
variant="light"
fullWidth={isSmallDevice}
mt="md"
mx="auto"
radius="md"
tt="uppercase"
>
View inputs
</Button>
</Group>
)}
</Card>
</Grid>
</Stack>
);
};

export default ApplicationSummary;
154 changes: 154 additions & 0 deletions apps/web/src/components/applications/latestInputsTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"use client";
import { Badge, Box, Button, Group, Loader, Table, Text } from "@mantine/core";
import type { Address as AddressType } from "abitype/dist/types/abi";
import prettyMilliseconds from "pretty-ms";
import { FC, useCallback, useState } from "react";
import Address from "../address";
import { getAddress } from "viem";
import { erc20PortalAddress, etherPortalAddress } from "@cartesi/rollups-wagmi";
import { MethodResolver } from "../inputs/inputRow";
import { InputItemFragment } from "../../graphql/explorer/operations";
import { TbArrowRight } from "react-icons/tb";

export interface Entry {
appId: AddressType;
timestamp: number;
href: string;
}

export interface LatestInputsTableProps {
inputs: InputItemFragment[];
fetching: boolean;
totalCount: number;
}

const etherDepositResolver: MethodResolver = (input) =>
getAddress(input.msgSender) === etherPortalAddress && "depositEther";

const erc20PortalResolver: MethodResolver = (input) =>
getAddress(input.msgSender) === erc20PortalAddress && "depositERC20Tokens";

const resolvers: MethodResolver[] = [etherDepositResolver, erc20PortalResolver];
const methodResolver: MethodResolver = (input) => {
for (const resolver of resolvers) {
const method = resolver(input);
if (method) return method;
}
return undefined;
};

const LatestInputsTable: FC<LatestInputsTableProps> = ({
inputs,
fetching,
totalCount,
}) => {
const [timeType, setTimeType] = useState("age");

const onChangeTimeColumnType = useCallback(() => {
setTimeType((timeType) => (timeType === "age" ? "timestamp" : "age"));
}, []);

return (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>From</Table.Th>
<Table.Th>Method</Table.Th>
<Table.Th>
<Button
variant="transparent"
px={0}
onClick={onChangeTimeColumnType}
>
{timeType === "age" ? "Age" : "Timestamp (UTC)"}
</Button>
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{fetching ? (
<Table.Tr>
<Table.Td align="center" colSpan={2}>
<Loader data-testid="inputs-table-spinner" />
</Table.Td>
</Table.Tr>
) : (
totalCount === 0 && (
<Table.Tr>
<Table.Td colSpan={3} align="center" fw={700}>
No inputs
</Table.Td>
</Table.Tr>
)
)}
{inputs.map((input) => (
<Table.Tr key={`${input.application}-${input.timestamp}`}>
<Table.Td>
<Box
display="flex"
w="max-content"
style={{
alignItems: "center",
justifyContent: "center",
}}
>
{input.erc20Deposit ? (
<Group>
<Address
value={
input.erc20Deposit
.from as AddressType
}
icon
shorten
/>
<TbArrowRight />
<Address
value={
input.msgSender as AddressType
}
icon
shorten
/>
</Group>
) : (
<Address
value={input.msgSender as AddressType}
icon
shorten
/>
)}
</Box>
</Table.Td>
<Table.Td>
<Badge
variant="default"
style={{ textTransform: "none" }}
>
{methodResolver(input) ?? "?"}
</Badge>
</Table.Td>
<Table.Td>
<Text>
{timeType === "age"
? `${prettyMilliseconds(
Date.now() - input.timestamp * 1000,
{
unitCount: 2,
secondsDecimalDigits: 0,
verbose: true,
},
)} ago`
: new Date(
input.timestamp * 1000,
).toISOString()}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
};

export default LatestInputsTable;
Loading

0 comments on commit 651589b

Please sign in to comment.