Skip to content

Commit

Permalink
Merge pull request #1202 from oasisprotocol/csillag/proposal-detail-p…
Browse files Browse the repository at this point in the history
…ages

Add basic proposal details page
  • Loading branch information
csillag authored Jan 30, 2024
2 parents e60ff05 + 980f721 commit d0dd9da
Show file tree
Hide file tree
Showing 12 changed files with 198 additions and 8 deletions.
1 change: 1 addition & 0 deletions .changelog/1202.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add Network proposal details page
8 changes: 8 additions & 0 deletions src/app/components/ErrorDisplay/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ const errorMap: Record<AppErrors, (t: TFunction, error: ErrorPayload) => Formatt
message: t('errors.validateURL'),
}),
[AppErrors.InvalidTxHash]: t => ({ title: t('errors.invalidTxHash'), message: t('errors.validateURL') }),
[AppErrors.InvalidProposalId]: t => ({
title: t('errors.invalidProposalId'),
message: t('errors.validateURL'),
}),
[AppErrors.InvalidPageNumber]: t => ({
title: t('errors.invalidPageNumber'),
message: (
Expand Down Expand Up @@ -45,6 +49,10 @@ const errorMap: Record<AppErrors, (t: TFunction, error: ErrorPayload) => Formatt
message: t('errors.validateURL'),
}),
[AppErrors.NotFoundTxHash]: t => ({ title: t('errors.notFoundTx'), message: t('errors.validateURL') }),
[AppErrors.NotFoundProposalId]: t => ({
title: t('errors.notFoundProposal'),
message: t('errors.validateURL'),
}),
[AppErrors.InvalidUrl]: t => ({ title: t('errors.invalidUrl'), message: t('errors.validateURL') }),
[AppErrors.UnsupportedLayer]: t => ({
title: t('errors.error'),
Expand Down
10 changes: 6 additions & 4 deletions src/app/components/NetworkProposalsList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { Table, TableCellAlign, TableColProps } from '../../components/Table'
import { Proposal } from '../../../oasis-nexus/api'
import { TablePaginationProps } from '../Table/TablePagination'
import { RoundedBalance } from '../RoundedBalance'
import { ProposalStatusIcon } from '../../components/ProposalStatusIcon'
import { ProposalStatusIcon } from '../../components/Proposals/ProposalStatusIcon'
import { ProposalLink } from '../Proposals/ProposalLink'

type NetworkProposalsListProps = {
proposals?: Proposal[]
Expand Down Expand Up @@ -33,13 +34,14 @@ export const NetworkProposalsList: FC<NetworkProposalsListProps> = ({
data: [
{
align: TableCellAlign.Center,
content: <>{proposal.id}</>,
content: <ProposalLink network={proposal.network} proposalId={proposal.id} />,
key: 'id',
},
{
align: TableCellAlign.Left,
// TODO: link to Proposals page once it is ready
content: <>{proposal.handler}</>,
content: (
<ProposalLink network={proposal.network} proposalId={proposal.id} label={proposal.handler} />
),
key: 'handler',
},
{
Expand Down
18 changes: 18 additions & 0 deletions src/app/components/Proposals/ProposalLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { FC } from 'react'
import { Link as RouterLink } from 'react-router-dom'
import Link from '@mui/material/Link'
import { RouteUtils } from '../../utils/route-utils'
import { Network } from '../../../types/network'

export const ProposalLink: FC<{
network: Network
proposalId: string | number
label?: string
}> = ({ network, proposalId, label = proposalId }) => {
const to = RouteUtils.getProposalRoute(network, proposalId)
return (
<Link component={RouterLink} to={to}>
{label}
</Link>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,9 @@ type ProposalStatus = {
const StyledBox = styled(Box, {
shouldForwardProp: prop => prop !== 'success',
})<ProposalStatus>(({ success }) => ({
justifyContent: 'end',
color: success ? COLORS.eucalyptus : COLORS.errorIndicatorBackground,
textTransform: 'capitalize',
display: 'flex',
flexDirection: 'row',
display: 'inline-flex',
alignItems: 'center',
gap: 3,
flex: 1,
Expand Down
96 changes: 96 additions & 0 deletions src/app/pages/ProposalDetailsPage/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useRequiredScopeParam } from '../../hooks/useScopeParam'
import { Layer, Proposal, useGetConsensusProposalsProposalId } from '../../../oasis-nexus/api'
import { AppErrors } from '../../../types/errors'
import { useParams } from 'react-router-dom'
import { PageLayout } from '../../components/PageLayout'
import { SubPageCard } from '../../components/SubPageCard'
import { StyledDescriptionList } from 'app/components/StyledDescriptionList'
import { RoundedBalance } from '../../components/RoundedBalance'
import { DashboardLink } from '../ParatimeDashboardPage/DashboardLink'
import { useScreenSize } from '../../hooks/useScreensize'
import { ProposalStatusIcon } from '../../components/Proposals/ProposalStatusIcon'
import { TextSkeleton } from '../../components/Skeleton'
import { AccountLink } from '../../components/Account/AccountLink'

export const ProposalDetailsPage: FC = () => {
const { t } = useTranslation()
const scope = useRequiredScopeParam()
if (scope.layer !== Layer.consensus) {
throw AppErrors.UnsupportedLayer
}
const proposalId = parseInt(useParams().proposalId!, 10)

const { isLoading, data } = useGetConsensusProposalsProposalId(scope.network, proposalId)
if (!data?.data && !isLoading) {
throw AppErrors.NotFoundProposalId
}
const proposal = data?.data!
return (
<PageLayout>
<SubPageCard featured title={t('common.proposal')}>
<ProposalDetailView isLoading={isLoading} proposal={proposal} />
</SubPageCard>
</PageLayout>
)
}

export const ProposalDetailView: FC<{
proposal: Proposal
isLoading?: boolean
showLayer?: boolean
standalone?: boolean
}> = ({ proposal, isLoading, showLayer = false, standalone = false }) => {
const { t } = useTranslation()
const { isMobile } = useScreenSize()
if (isLoading) return <TextSkeleton numberOfRows={7} />

return (
<StyledDescriptionList titleWidth={isMobile ? '100px' : '200px'} standalone={standalone}>
{showLayer && (
<>
<dt>{t('common.network')}</dt>
<dd>
<DashboardLink scope={proposal} />
</dd>
</>
)}

<dt>{t('networkProposal.id')}</dt>
<dd>{proposal.id}</dd>

<dt>{t('common.title')}</dt>
<dd>{proposal.handler}</dd>

{/*Not enough data*/}
{/*<dt>{t('common.type')}</dt>*/}
{/*<dd>???</dd>*/}

<dt>{t('common.submitter')}</dt>
<dd>
<AccountLink scope={proposal} address={proposal.submitter} />
</dd>

{/*Not enough data*/}
{/*<dt>{t('common.totalVotes')}</dt>*/}
{/*<dd>{proposal.invalid_votes}</dd>*/}

<dt>{t('common.status')}</dt>
<dd>
<ProposalStatusIcon status={proposal.state} />
</dd>

<dt>{t('networkProposal.deposit')}</dt>
<dd>
<RoundedBalance value={proposal.deposit} />
</dd>

<dt>{t('networkProposal.create')}</dt>
<dd>{proposal.created_at}</dd>

<dt>{t('networkProposal.close')}</dt>
<dd>{proposal.closes_at}</dd>
</StyledDescriptionList>
)
}
2 changes: 2 additions & 0 deletions src/app/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export const isValidEthAddress = (hexAddress: string): boolean => {
return /^0x[0-9a-fA-F]{40}$/.test(hexAddress)
}

export const isValidProposalId = (proposalId: string): boolean => /^[0-9]+$/.test(proposalId)

export async function getEvmBech32Address(evmAddress: string) {
const ethAddrU8 = oasis.misc.fromHex(evmAddress.replace('0x', ''))
const addr = await oasis.address.fromData(
Expand Down
19 changes: 18 additions & 1 deletion src/app/utils/route-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { LoaderFunctionArgs } from 'react-router-dom'
import { isValidTxHash } from './helpers'
import { isValidProposalId, isValidTxHash } from './helpers'
import { isValidBlockHeight, isValidOasisAddress, isValidEthAddress } from './helpers'
import { AppError, AppErrors } from '../../types/errors'
import { EvmTokenType, Layer } from '../../oasis-nexus/api'
Expand Down Expand Up @@ -115,6 +115,10 @@ export abstract class RouteUtils {
return Object.values(Layer).filter(layer => RouteUtils.ENABLED_LAYERS_FOR_NETWORK[network][layer])
}

static getProposalRoute = (network: Network, proposalId: string | number) => {
return `/${encodeURIComponent(network)}/consensus/proposal/${encodeURIComponent(proposalId)}`
}

static getEnabledScopes(): SearchScope[] {
return RouteUtils.getEnabledNetworks().flatMap(network =>
RouteUtils.getEnabledLayersForNetwork(network).map(layer => ({ network, layer })),
Expand Down Expand Up @@ -160,6 +164,15 @@ const validateTxHashParam = (hash: string) => {
return true
}

const validateProposalIdParam = (proposalId: string) => {
const isValid = isValidProposalId(proposalId)
if (!isValid) {
throw new AppError(AppErrors.InvalidProposalId)
}

return isValid
}

export const addressParamLoader =
(queryParam: string = 'address') =>
({ params }: LoaderFunctionArgs): string => {
Expand Down Expand Up @@ -195,3 +208,7 @@ export const assertEnabledScope = ({
}
return { network, layer } as SearchScope
}

export const proposalIdParamLoader = async ({ params }: LoaderFunctionArgs) => {
return validateProposalIdParam(params.proposalId!)
}
7 changes: 7 additions & 0 deletions src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"lessThanAmount": "&lt; {{value, number}} <TickerLink />",
"missing": "n/a",
"name": "Name",
"network": "Network",
"nft": "NFT",
"nfts": "NFTs",
"not_defined": "Not defined",
Expand All @@ -78,21 +79,25 @@
"paratime": "Paratime",
"parentheses": "({{subject}})",
"percentage": "Percentage",
"proposal": "Proposal",
"rank": "Rank",
"select": "Select",
"size": "Size",
"sapphire": "Sapphire",
"show": "Show",
"smartContract": "Smart Contract",
"smartContract_short": "Contract",
"submitter": "Submitter",
"success": "Success",
"status": "Status",
"ticker": "Ticker",
"timestamp": "Timestamp",
"title": "Title",
"to": "To",
"token": "Token",
"tokenInstance": "TokenID [<InstanceLink />] <TickerLink/>",
"tokens": "Tokens",
"totalVotes": "Total votes",
"transactions": "Transactions",
"transactionAbbreviation": "Txs",
"transactionFee": "Transaction Fee",
Expand Down Expand Up @@ -179,9 +184,11 @@
"invalidAddress": "Invalid address",
"invalidBlockHeight": "Invalid block height",
"invalidPageNumber": "Invalid page number",
"invalidProposalId": "Invalid proposal ID",
"invalidTxHash": "Invalid transaction hash",
"notFoundBlockHeight": "Block not found",
"notFoundTx": "Transaction not found",
"notFoundProposal": "Proposal not found",
"pageDoesNotExist": "The page you are looking for does not exist.",
"validateURL": "Please validate provided URL",
"validateURLOrGoToFirstPage": "Please check the URL or <FirstPageLink />.",
Expand Down
32 changes: 32 additions & 0 deletions src/oasis-nexus/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ declare module './generated/api' {
export interface Validator {
ticker: NativeTicker
}

export interface Proposal {
network: Network
layer: typeof Layer.consensus
}
}

export const isAccountEmpty = (account: RuntimeAccount) => {
Expand Down Expand Up @@ -811,6 +816,7 @@ export const useGetConsensusProposals: typeof generated.useGetConsensusProposals
proposals: data.proposals.map(proposal => {
return {
...proposal,
network,
deposit: fromBaseUnits(proposal.deposit, consensusDecimals),
}
}),
Expand All @@ -822,6 +828,32 @@ export const useGetConsensusProposals: typeof generated.useGetConsensusProposals
})
}

export const useGetConsensusProposalsProposalId: typeof generated.useGetConsensusProposalsProposalId = (
network,
proposalId,
options?,
) => {
return generated.useGetConsensusProposalsProposalId(network, proposalId, {
...options,
request: {
...options?.request,
transformResponse: [
...arrayify(axios.defaults.transformResponse),
(data: generated.Proposal, headers, status) => {
if (status !== 200) return data
return {
...data,
network,
layer: Layer.consensus,
deposit: fromBaseUnits(data.deposit, consensusDecimals),
}
},
...arrayify(options?.request?.transformResponse),
],
},
})
}

export const useGetConsensusValidators: typeof generated.useGetConsensusValidators = (
network,
params?,
Expand Down
7 changes: 7 additions & 0 deletions src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
blockHeightParamLoader,
transactionParamLoader,
assertEnabledScope,
proposalIdParamLoader,
} from './app/utils/route-utils'
import { searchParamLoader } from './app/components/Search/search-utils'
import { RoutingErrorPage } from './app/pages/RoutingErrorPage'
Expand All @@ -33,6 +34,7 @@ import { NFTTokenTransfersCard } from './app/pages/NFTInstanceDashboardPage/NFTT
import { ConsensusDashboardPage } from 'app/pages/ConsensusDashboardPage'
import { Layer } from './oasis-nexus/api'
import { SearchScope } from './types/searchScope'
import { ProposalDetailsPage } from './app/pages/ProposalDetailsPage'

const NetworkSpecificPart = () => (
<ThemeByNetwork network={useRequiredScopeParam().network}>
Expand Down Expand Up @@ -72,6 +74,11 @@ export const routes: RouteObject[] = [
path: '',
element: <ConsensusDashboardPage />,
},
{
path: `proposal/:proposalId`,
element: <ProposalDetailsPage />,
loader: proposalIdParamLoader,
},
],
},
{
Expand Down
2 changes: 2 additions & 0 deletions src/types/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ export enum AppErrors {
InvalidAddress = 'invalid_address',
InvalidBlockHeight = 'invalid_block_height',
InvalidTxHash = 'invalid_tx_hash',
InvalidProposalId = 'invalid_proposal_id',
InvalidPageNumber = 'invalid_page_number',
PageDoesNotExist = 'page_does_not_exist',
NotFoundBlockHeight = 'not_found_block_height',
NotFoundTxHash = 'not_found_tx_hash',
NotFoundProposalId = 'not_found_proposal_id',
InvalidUrl = 'invalid_url',
Storage = 'storage',
}
Expand Down

0 comments on commit d0dd9da

Please sign in to comment.