Skip to content

Commit

Permalink
Implement the v2 assets/transactions pages using PenumbraUI (#1607)
Browse files Browse the repository at this point in the history
* Build initial assets/transaction pages

* Set actionType

* Create a Display component

* Tweak the Display story

* Add Display to the minifront v2 root

* Make 0-column option possible

* Tweak layout size for dashboard

* Make the card title dynamic

* Add a space between the tabs and content

* Make valueView optional

* Remove duplicate style

* Set box sizing

* Fix width

* Tweak text

* Allow fixed-width tables

* Build out the assets page

* Tweak design of trade link

* Tweak Button docs

* Add background gradient to Card

* Build out transactions page

* Add a title; fix cell width

* Add rich titles for assets and transactions

* Make some updates to the theme

* Overhaul Dialog to better match the Figma mockups

* Define tabs options once

* Extract a hexOpacity helper

* Add doc block

* Fix spacing issue

* Fix docs
  • Loading branch information
jessepinho authored Aug 8, 2024
1 parent b7d5f21 commit ec19b33
Show file tree
Hide file tree
Showing 21 changed files with 589 additions and 91 deletions.
2 changes: 2 additions & 0 deletions apps/minifront/src/components/root-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import { StakingLayout } from './staking/layout';
import { IbcLayout } from './ibc/layout';
import { abortLoader } from '../abort-loader';
import type { Router } from '@remix-run/router';
import { routes as v2Routes } from './v2/root-router';

export const rootRouter: Router = createHashRouter([
...v2Routes,
{
path: '/',
element: <Layout />,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Button } from '@repo/ui/Button';
import { Dialog } from '@repo/ui/Dialog';
import { Text } from '@repo/ui/Text';
import { Info } from 'lucide-react';

export const AssetsCardTitle = () => (
<div className='flex items-center gap-2'>
Asset Balances
<Dialog>
<Dialog.Trigger asChild>
<Button icon={Info} iconOnly='adornment'>
Info
</Button>
</Dialog.Trigger>
<Dialog.Content title='Asset Balances'>
<Text>
Your balances are shielded, and are known only to you. They are not visible on chain. Each
Penumbra wallet controls many numbered accounts, each with its own balance. Account
information is never revealed on-chain.
</Text>
</Dialog.Content>
</Dialog>
</div>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ValueView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js';
import { asValueView } from '@penumbra-zone/getters/equivalent-value';
import { getDisplayDenomFromView, getEquivalentValues } from '@penumbra-zone/getters/value-view';
import { ValueViewComponent } from '@repo/ui/ValueViewComponent';

export const EquivalentValues = ({ valueView }: { valueView?: ValueView }) => {
const equivalentValuesAsValueViews = (getEquivalentValues.optional()(valueView) ?? []).map(
asValueView,
);

return (
<div className='flex flex-wrap gap-2'>
{equivalentValuesAsValueViews.map(equivalentValueAsValueView => (
<ValueViewComponent
key={getDisplayDenomFromView(equivalentValueAsValueView)}
valueView={equivalentValueAsValueView}
priority='secondary'
context='table'
/>
))}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Density } from '@repo/ui/Density';
import { Table } from '@repo/ui/Table';
import { BalancesByAccount, groupByAccount, useBalancesResponses } from '../../../../state/shared';
import { shouldDisplay } from '../../../../fetchers/balances/should-display';
import { sortByPriorityScore } from '../../../../fetchers/balances/by-priority-score';
import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb';
import { getMetadataFromBalancesResponseOptional } from '@penumbra-zone/getters/balances-response';
import { PagePath } from '../../../metadata/paths';
import { getAddressIndex } from '@penumbra-zone/getters/address-view';
import { AbridgedZQueryState } from '@penumbra-zone/zquery/src/types';
import { ValueViewComponent } from '@repo/ui/ValueViewComponent';
import { EquivalentValues } from './equivalent-values';
import { TableTitle } from './table-title';
import { Link } from 'react-router-dom';
import { Button } from '@repo/ui/Button';
import { ArrowRightLeft } from 'lucide-react';

const getTradeLink = (balance: BalancesResponse): string => {
const metadata = getMetadataFromBalancesResponseOptional(balance);
const accountIndex = getAddressIndex(balance.accountAddress).account;
const accountQuery = accountIndex ? `&account=${accountIndex}` : '';
return metadata ? `${PagePath.SWAP}?from=${metadata.symbol}${accountQuery}` : PagePath.SWAP;
};

const filteredBalancesByAccountSelector = (
zQueryState: AbridgedZQueryState<BalancesResponse[]>,
): BalancesByAccount[] =>
zQueryState.data?.filter(shouldDisplay).sort(sortByPriorityScore).reduce(groupByAccount, []) ??
[];

const BUTTON_CELL_WIDTH_PX = '56px';

export const AssetsPage = () => {
const balancesByAccount = useBalancesResponses({
select: filteredBalancesByAccountSelector,
shouldReselect: (before, after) => before?.data !== after.data,
});

return (
<div className='flex flex-col gap-1'>
{balancesByAccount?.map(account => (
<Table key={account.account} layout='fixed' title={<TableTitle account={account} />}>
<Table.Thead>
<Table.Tr>
<Table.Th width={`calc(50% - (${BUTTON_CELL_WIDTH_PX} / 2))`}>Asset</Table.Th>
<Table.Th width={`calc(50% - (${BUTTON_CELL_WIDTH_PX} / 2))`}>Estimate</Table.Th>
<Table.Th width={BUTTON_CELL_WIDTH_PX} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{account.balances.map((balance, index) => (
<Table.Tr key={index}>
<Table.Td vAlign='top'>
<ValueViewComponent valueView={balance.balanceView} context='table' />
</Table.Td>
<Table.Td vAlign='top'>
<EquivalentValues valueView={balance.balanceView} />
</Table.Td>

<Table.Td>
<Link to={getTradeLink(balance)}>
<Density compact>
<Button icon={ArrowRightLeft} iconOnly>
Trade
</Button>
</Density>
</Link>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
))}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { AddressViewComponent } from '@repo/ui/AddressViewComponent';
import { BalancesByAccount } from '../../../../state/shared';
import { AddressView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb';
import { useMemo } from 'react';

export const TableTitle = ({ account }: { account: BalancesByAccount }) => {
const addressView = useMemo(
() =>
new AddressView({
addressView: {
case: 'decoded',
value: {
address: account.address,
index: { account: account.account },
},
},
}),
[account.address, account.account],
);

return <AddressViewComponent addressView={addressView} />;
};
49 changes: 49 additions & 0 deletions apps/minifront/src/components/v2/dashboard-layout/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Card } from '@repo/ui/Card';
import { Outlet, useNavigate } from 'react-router-dom';
import { Grid } from '@repo/ui/Grid';
import { Tabs } from '@repo/ui/Tabs';
import { usePagePath } from '../../../fetchers/page-path';
import { PagePath } from '../../metadata/paths';
import { AssetsCardTitle } from './assets-card-title';
import { TransactionsCardTitle } from './transactions-card-title';

/** @todo: Remove this function and its uses after we switch to v2 layout */
const v2PathPrefix = (path: string) => `/v2${path}`;

const CARD_TITLE_BY_PATH = {
[v2PathPrefix(PagePath.DASHBOARD)]: <AssetsCardTitle />,
[v2PathPrefix(PagePath.TRANSACTIONS)]: <TransactionsCardTitle />,
};

const TABS_OPTIONS = [
{ label: 'Assets', value: v2PathPrefix(PagePath.DASHBOARD) },
{ label: 'Transactions', value: v2PathPrefix(PagePath.TRANSACTIONS) },
];

export const DashboardLayout = () => {
const pagePath = usePagePath();
const navigate = useNavigate();

return (
<Grid container>
<Grid mobile={0} tablet={2} desktop={3} xl={4} />

<Grid tablet={8} desktop={6} xl={4}>
<Card title={CARD_TITLE_BY_PATH[v2PathPrefix(pagePath)]}>
<div className='flex flex-col gap-4'>
<Tabs
value={v2PathPrefix(pagePath)}
onChange={value => navigate(value)}
options={TABS_OPTIONS}
actionType='accent'
/>

<Outlet />
</div>
</Card>
</Grid>

<Grid mobile={0} tablet={2} desktop={3} xl={4} />
</Grid>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Button } from '@repo/ui/Button';
import { Dialog } from '@repo/ui/Dialog';
import { Text } from '@repo/ui/Text';
import { Info } from 'lucide-react';

export const TransactionsCardTitle = () => (
<div className='flex items-center gap-2'>
Transactions List
<Dialog>
<Dialog.Trigger asChild>
<Button icon={Info} iconOnly='adornment'>
Info
</Button>
</Dialog.Trigger>
<Dialog.Content title='Transactions List'>
<Text>
Your wallet scans shielded chain data locally and indexes all relevant transactions it
detects, both incoming and outgoing.
</Text>
</Dialog.Content>
</Dialog>
</div>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Table } from '@repo/ui/Table';
import { useSummaries } from '../../../../state/transactions';
import { Text } from '@repo/ui/Text';
import { Link } from 'react-router-dom';
import { SquareArrowOutUpRight } from 'lucide-react';
import { Button } from '@repo/ui/Button';

export const TransactionsPage = () => {
const summaries = useSummaries();

return (
<Table layout='fixed'>
<Table.Thead>
<Table.Tr>
<Table.Th width='150px'>Block Height</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th width='125px'>Hash</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{summaries.data?.map(summary => (
<Table.Tr key={summary.hash}>
<Table.Td>
<Text>{summary.height}</Text>
</Table.Td>
<Table.Td>
<Text>{summary.description}</Text>
</Table.Td>
<Table.Td>
<div className='flex gap-1'>
<Link
to={`/tx/${summary.hash}`}
className='shrink overflow-hidden'
title={summary.hash}
>
<Text truncate as='div'>
{summary.hash}
</Text>
</Link>
<Link to={`/tx/${summary.hash}`}>
<Button icon={SquareArrowOutUpRight} iconOnly='adornment'>
View transaction
</Button>
</Link>
</div>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
};
14 changes: 14 additions & 0 deletions apps/minifront/src/components/v2/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Display } from '@repo/ui/Display';
import { HeadTag } from '../metadata/head-tag';
import { Outlet } from 'react-router-dom';
import { Toaster } from '@repo/ui/components/ui/toaster';
import { SyncingDialog } from '../syncing-dialog';

export const Layout = () => (
<Display>
<HeadTag />
<Outlet />
<Toaster />
<SyncingDialog />
</Display>
);
52 changes: 52 additions & 0 deletions apps/minifront/src/components/v2/root-router.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { redirect, RouteObject } from 'react-router-dom';
import { Layout } from './layout';
import { abortLoader } from '../../abort-loader';
import { PagePath } from '../metadata/paths';
import { DashboardLayout } from './dashboard-layout';
import { AssetsPage } from './dashboard-layout/assets-page';
import { TransactionsPage } from './dashboard-layout/transactions-page';

/** @todo: Delete this helper once we switch over to the v2 layout. */
const temporarilyPrefixPathsWithV2 = (routes: RouteObject[]): RouteObject[] =>
routes.map(route => {
if (route.index) {
return route;
}

return {
...route,
path: `/v2${route.path === '/' ? '' : route.path}`,
...(route.children ? { children: temporarilyPrefixPathsWithV2(route.children) } : {}),
};
});

/**
* @todo: Once we switch over to the v2 layout, we need to:
* 1) pass these routes to `createHashRouter()` and export the returned router,
* like in `../root-router.tsx`.
* 2) remove the call to `temporarilyPrefixPathsWithV2()`.
*/
export const routes: RouteObject[] = temporarilyPrefixPathsWithV2([
{
path: '/',
element: <Layout />,
loader: abortLoader,
children: [
{ index: true, loader: () => redirect(`/v2${PagePath.DASHBOARD}`) },
{
path: PagePath.DASHBOARD,
element: <DashboardLayout />,
children: [
{
index: true,
element: <AssetsPage />,
},
{
path: PagePath.TRANSACTIONS,
element: <TransactionsPage />,
},
],
},
],
},
]);
2 changes: 2 additions & 0 deletions apps/minifront/src/fetchers/page-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export const usePagePath = <T extends PagePath>() => {
};

export const matchPagePath = (str: string): PagePath => {
/** @todo: Remove next line after we switch to v2 layout */
str = str.replace('/v2', '');
const pathValues = Object.values(PagePath);

if (pathValues.includes(str as PagePath)) {
Expand Down
10 changes: 9 additions & 1 deletion packages/ui/src/Button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,15 @@ interface RegularProps {

export type ButtonProps = BaseButtonProps & (IconOnlyProps | RegularProps);

/** A component for all your button needs! */
/**
* A component for all your button needs!
*
* See individual props for how to use `<Button />` in various forms.
*
* Note that, to use `<Button />` as a link, you can simply wrap it in an anchor
* (`<a />`) tag (or `<Link />`, if you're using e.g., React Router) and leave
* `onClick` undefined.
*/
export const Button = ({
children,
disabled = false,
Expand Down
Loading

0 comments on commit ec19b33

Please sign in to comment.