From 4542e25ec99d85913383c74fc97534382bf830a3 Mon Sep 17 00:00:00 2001 From: Edd Date: Fri, 3 Mar 2023 14:27:24 +0000 Subject: [PATCH] feat(explorer): proposal transaction view (#3060) --- apps/explorer/src/__mocks__/react-markdown.js | 5 + .../links/proposal-link/proposal-link.tsx | 5 +- .../txs/details/proposal/Proposal.graphql | 7 + .../details/proposal/SignatureBundle.graphql | 19 + .../proposal/__generated__/Proposal.ts | 50 ++ .../proposal/__generated__/SignatureBundle.ts | 98 ++++ .../txs/details/proposal/proposal-date.tsx | 70 +++ .../details/proposal/proposal-status-icon.tsx | 117 +++++ .../details/proposal/signature-bundle-new.tsx | 42 ++ .../proposal/signature-bundle-update.tsx | 39 ++ .../txs/details/proposal/signature-bundle.tsx | 31 ++ .../signature-bundle/bundle-error.spec.tsx | 67 +++ .../signature-bundle/bundle-error.tsx | 34 ++ .../signature-bundle/bundle-exists.spec.tsx | 64 +++ .../signature-bundle/bundle-exists.tsx | 46 ++ .../signature-bundle/bundle-icon.spec.tsx | 33 ++ .../proposal/signature-bundle/bundle-icon.tsx | 17 + .../proposal/signature-bundle/details.tsx | 41 ++ .../txs/details/proposal/summary.tsx | 90 ++++ .../txs/details/shared/tx-details-shared.tsx | 2 +- .../txs/details/tx-details-wrapper.tsx | 3 + .../components/txs/details/tx-proposal.tsx | 115 +++++ .../src/app/routes/txs/id/tx-details.spec.tsx | 2 +- apps/explorer/src/styles.css | 9 + package.json | 1 + yarn.lock | 467 +++++++++++++++++- 26 files changed, 1468 insertions(+), 6 deletions(-) create mode 100644 apps/explorer/src/__mocks__/react-markdown.js create mode 100644 apps/explorer/src/app/components/txs/details/proposal/Proposal.graphql create mode 100644 apps/explorer/src/app/components/txs/details/proposal/SignatureBundle.graphql create mode 100644 apps/explorer/src/app/components/txs/details/proposal/__generated__/Proposal.ts create mode 100644 apps/explorer/src/app/components/txs/details/proposal/__generated__/SignatureBundle.ts create mode 100644 apps/explorer/src/app/components/txs/details/proposal/proposal-date.tsx create mode 100644 apps/explorer/src/app/components/txs/details/proposal/proposal-status-icon.tsx create mode 100644 apps/explorer/src/app/components/txs/details/proposal/signature-bundle-new.tsx create mode 100644 apps/explorer/src/app/components/txs/details/proposal/signature-bundle-update.tsx create mode 100644 apps/explorer/src/app/components/txs/details/proposal/signature-bundle.tsx create mode 100644 apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-error.spec.tsx create mode 100644 apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-error.tsx create mode 100644 apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-exists.spec.tsx create mode 100644 apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-exists.tsx create mode 100644 apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-icon.spec.tsx create mode 100644 apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-icon.tsx create mode 100644 apps/explorer/src/app/components/txs/details/proposal/signature-bundle/details.tsx create mode 100644 apps/explorer/src/app/components/txs/details/proposal/summary.tsx create mode 100644 apps/explorer/src/app/components/txs/details/tx-proposal.tsx diff --git a/apps/explorer/src/__mocks__/react-markdown.js b/apps/explorer/src/__mocks__/react-markdown.js new file mode 100644 index 000000000..016bb24d0 --- /dev/null +++ b/apps/explorer/src/__mocks__/react-markdown.js @@ -0,0 +1,5 @@ +function ReactMarkdown({ children }) { + return
{children}
; +} + +export default ReactMarkdown; diff --git a/apps/explorer/src/app/components/links/proposal-link/proposal-link.tsx b/apps/explorer/src/app/components/links/proposal-link/proposal-link.tsx index e6cb382e6..c9ae74675 100644 --- a/apps/explorer/src/app/components/links/proposal-link/proposal-link.tsx +++ b/apps/explorer/src/app/components/links/proposal-link/proposal-link.tsx @@ -4,13 +4,14 @@ import { ENV } from '../../../config/env'; import Hash from '../hash'; export type ProposalLinkProps = { id: string; + text?: string; }; /** * Given a proposal ID, generates an external link over to * the Governance page for more information */ -const ProposalLink = ({ id }: ProposalLinkProps) => { +const ProposalLink = ({ id, text }: ProposalLinkProps) => { const { data } = useExplorerProposalQuery({ variables: { id }, }); @@ -20,7 +21,7 @@ const ProposalLink = ({ id }: ProposalLinkProps) => { return ( - + {text ? text : } ); }; diff --git a/apps/explorer/src/app/components/txs/details/proposal/Proposal.graphql b/apps/explorer/src/app/components/txs/details/proposal/Proposal.graphql new file mode 100644 index 000000000..aa67158dd --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/Proposal.graphql @@ -0,0 +1,7 @@ +query ExplorerProposalStatus($id: ID!) { + proposal(id: $id) { + id + state + rejectionReason + } +} diff --git a/apps/explorer/src/app/components/txs/details/proposal/SignatureBundle.graphql b/apps/explorer/src/app/components/txs/details/proposal/SignatureBundle.graphql new file mode 100644 index 000000000..05c70da06 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/SignatureBundle.graphql @@ -0,0 +1,19 @@ +query ExplorerNewAssetSignatureBundle($id: ID!) { + erc20ListAssetBundle(assetId: $id) { + signatures + nonce + } + asset(id: $id) { + status + } +} + +query ExplorerUpdateAssetSignatureBundle($id: ID!) { + erc20SetAssetLimitsBundle(proposalId: $id) { + signatures + nonce + } + asset(id: $id) { + status + } +} diff --git a/apps/explorer/src/app/components/txs/details/proposal/__generated__/Proposal.ts b/apps/explorer/src/app/components/txs/details/proposal/__generated__/Proposal.ts new file mode 100644 index 000000000..8a1e9222a --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/__generated__/Proposal.ts @@ -0,0 +1,50 @@ +import * as Types from '@vegaprotocol/types'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type ExplorerProposalStatusQueryVariables = Types.Exact<{ + id: Types.Scalars['ID']; +}>; + + +export type ExplorerProposalStatusQuery = { __typename?: 'Query', proposal?: { __typename?: 'Proposal', id?: string | null, state: Types.ProposalState, rejectionReason?: Types.ProposalRejectionReason | null } | null }; + + +export const ExplorerProposalStatusDocument = gql` + query ExplorerProposalStatus($id: ID!) { + proposal(id: $id) { + id + state + rejectionReason + } +} + `; + +/** + * __useExplorerProposalStatusQuery__ + * + * To run a query within a React component, call `useExplorerProposalStatusQuery` and pass it any options that fit your needs. + * When your component renders, `useExplorerProposalStatusQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useExplorerProposalStatusQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useExplorerProposalStatusQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ExplorerProposalStatusDocument, options); + } +export function useExplorerProposalStatusLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ExplorerProposalStatusDocument, options); + } +export type ExplorerProposalStatusQueryHookResult = ReturnType; +export type ExplorerProposalStatusLazyQueryHookResult = ReturnType; +export type ExplorerProposalStatusQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/apps/explorer/src/app/components/txs/details/proposal/__generated__/SignatureBundle.ts b/apps/explorer/src/app/components/txs/details/proposal/__generated__/SignatureBundle.ts new file mode 100644 index 000000000..8d919575a --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/__generated__/SignatureBundle.ts @@ -0,0 +1,98 @@ +import * as Types from '@vegaprotocol/types'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type ExplorerNewAssetSignatureBundleQueryVariables = Types.Exact<{ + id: Types.Scalars['ID']; +}>; + + +export type ExplorerNewAssetSignatureBundleQuery = { __typename?: 'Query', erc20ListAssetBundle?: { __typename?: 'Erc20ListAssetBundle', signatures: string, nonce: string } | null, asset?: { __typename?: 'Asset', status: Types.AssetStatus } | null }; + +export type ExplorerUpdateAssetSignatureBundleQueryVariables = Types.Exact<{ + id: Types.Scalars['ID']; +}>; + + +export type ExplorerUpdateAssetSignatureBundleQuery = { __typename?: 'Query', erc20SetAssetLimitsBundle: { __typename?: 'ERC20SetAssetLimitsBundle', signatures: string, nonce: string }, asset?: { __typename?: 'Asset', status: Types.AssetStatus } | null }; + + +export const ExplorerNewAssetSignatureBundleDocument = gql` + query ExplorerNewAssetSignatureBundle($id: ID!) { + erc20ListAssetBundle(assetId: $id) { + signatures + nonce + } + asset(id: $id) { + status + } +} + `; + +/** + * __useExplorerNewAssetSignatureBundleQuery__ + * + * To run a query within a React component, call `useExplorerNewAssetSignatureBundleQuery` and pass it any options that fit your needs. + * When your component renders, `useExplorerNewAssetSignatureBundleQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useExplorerNewAssetSignatureBundleQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useExplorerNewAssetSignatureBundleQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ExplorerNewAssetSignatureBundleDocument, options); + } +export function useExplorerNewAssetSignatureBundleLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ExplorerNewAssetSignatureBundleDocument, options); + } +export type ExplorerNewAssetSignatureBundleQueryHookResult = ReturnType; +export type ExplorerNewAssetSignatureBundleLazyQueryHookResult = ReturnType; +export type ExplorerNewAssetSignatureBundleQueryResult = Apollo.QueryResult; +export const ExplorerUpdateAssetSignatureBundleDocument = gql` + query ExplorerUpdateAssetSignatureBundle($id: ID!) { + erc20SetAssetLimitsBundle(proposalId: $id) { + signatures + nonce + } + asset(id: $id) { + status + } +} + `; + +/** + * __useExplorerUpdateAssetSignatureBundleQuery__ + * + * To run a query within a React component, call `useExplorerUpdateAssetSignatureBundleQuery` and pass it any options that fit your needs. + * When your component renders, `useExplorerUpdateAssetSignatureBundleQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useExplorerUpdateAssetSignatureBundleQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useExplorerUpdateAssetSignatureBundleQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ExplorerUpdateAssetSignatureBundleDocument, options); + } +export function useExplorerUpdateAssetSignatureBundleLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ExplorerUpdateAssetSignatureBundleDocument, options); + } +export type ExplorerUpdateAssetSignatureBundleQueryHookResult = ReturnType; +export type ExplorerUpdateAssetSignatureBundleLazyQueryHookResult = ReturnType; +export type ExplorerUpdateAssetSignatureBundleQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/apps/explorer/src/app/components/txs/details/proposal/proposal-date.tsx b/apps/explorer/src/app/components/txs/details/proposal/proposal-date.tsx new file mode 100644 index 000000000..a94aa7b49 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/proposal-date.tsx @@ -0,0 +1,70 @@ +import { t } from '@vegaprotocol/i18n'; +import { Lozenge } from '@vegaprotocol/ui-toolkit'; +import type { components } from '../../../../../types/explorer'; +import type { ExplorerProposalStatusQuery } from './__generated__/Proposal'; +import { useExplorerProposalStatusQuery } from './__generated__/Proposal'; + +type Terms = components['schemas']['vegaProposalTerms']; + +export function format(date: string | undefined, def: string) { + if (!date) { + return def; + } + + return new Date().toLocaleDateString() || def; +} + +export function getDate( + data: ExplorerProposalStatusQuery | undefined, + terms: Terms +): string { + const DEFAULT = t('Unknown'); + if (!data?.proposal?.state) { + return DEFAULT; + } + + switch (data.proposal.state) { + case 'STATE_DECLINED': + return `${t('Rejected on')}: ${format(terms.closingTimestamp, DEFAULT)}`; + case 'STATE_ENACTED': + return `${t('Vote passed on')}: ${format( + terms.enactmentTimestamp, + DEFAULT + )}`; + case 'STATE_FAILED': + return `${t('Failed on')}: ${format(terms.validationTimestamp, DEFAULT)}`; + case 'STATE_OPEN': + return `${t('Open until')}: ${format(terms.closingTimestamp, DEFAULT)}`; + case 'STATE_PASSED': + return `${t('Passed on')}: ${format(terms.closingTimestamp, DEFAULT)}`; + case 'STATE_REJECTED': + return `${t('Rejected on submission')}`; + case 'STATE_WAITING_FOR_NODE_VOTE': + return `${t('Opening')}...`; + default: + return DEFAULT; + } +} + +interface ProposalDateProps { + id: string; + terms: Terms; +} +/** + * Shows the most relevant date for the proposal summary view. Depending on the + * state returned by GraphQL, we show either the validation, closing or enactment + * timestamp + */ +export const ProposalDate = ({ terms, id }: ProposalDateProps) => { + const { data } = useExplorerProposalStatusQuery({ + variables: { + id, + }, + }); + + return ( + + {getDate(data, terms)} + + ); +}; diff --git a/apps/explorer/src/app/components/txs/details/proposal/proposal-status-icon.tsx b/apps/explorer/src/app/components/txs/details/proposal/proposal-status-icon.tsx new file mode 100644 index 000000000..7e4f696c5 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/proposal-status-icon.tsx @@ -0,0 +1,117 @@ +import { Icon, Tooltip } from '@vegaprotocol/ui-toolkit'; +import type { IconProps } from '@vegaprotocol/ui-toolkit'; +import { useExplorerProposalStatusQuery } from './__generated__/Proposal'; +import type { ExplorerProposalStatusQuery } from './__generated__/Proposal'; +import type * as Apollo from '@apollo/client'; +import type * as Types from '@vegaprotocol/types'; +import { t } from '@vegaprotocol/i18n'; + +type ProposalQueryResult = Apollo.QueryResult< + ExplorerProposalStatusQuery, + Types.Exact<{ + id: string; + }> +>; + +interface ProposalStatusIconProps { + id: string; +} + +type IconAndLabel = { + icon: IconProps['name']; + label: string; +}; + +/** + * Select an icon for a given query result. Tolerates queries that don't return + * any data + * + * @param data a data result from useExplorerProposalStatusQuery + * @returns Icon name + */ +export function getIconAndLabelForStatus( + res: ProposalQueryResult +): IconAndLabel { + const DEFAULT: IconAndLabel = { + icon: 'error', + label: t('Proposal state unknown'), + }; + + if (res.loading) { + return { + icon: 'more', + label: t('Loading data'), + }; + } + + if (!res?.data?.proposal || res.error) { + return { + icon: 'error', + label: res.error?.message || DEFAULT.label, + }; + } + + switch (res.data.proposal.state) { + case 'STATE_DECLINED': + return { + icon: 'stop', + label: t('Proposal did not have enough participation to be valid'), + }; + case 'STATE_ENACTED': + return { + icon: 'tick-circle', + label: t('Vote passed and the proposal has been enacted'), + }; + case 'STATE_FAILED': + return { + icon: 'thumbs-down', + label: t('Proposal became invalid and was not processed'), + }; + case 'STATE_OPEN': + return { + // A checklist to indicate in progress + icon: 'form', + label: t('Voting is in progress'), + }; + case 'STATE_PASSED': + return { + icon: 'thumbs-up', + label: t( + 'Voting is complete and this proposal was approved. It is not yet enacted.' + ), + }; + case 'STATE_REJECTED': + return { + icon: 'disable', + label: t('The proposal was invalid'), + }; + case 'STATE_WAITING_FOR_NODE_VOTE': + return { + // A sparkly thing indicating it's new + icon: 'clean', + label: t('Proposal is being checked by validators'), + }; + default: + return DEFAULT; + } +} + +/** + */ +export const ProposalStatusIcon = ({ id }: ProposalStatusIconProps) => { + const { icon, label } = getIconAndLabelForStatus( + useExplorerProposalStatusQuery({ + variables: { + id, + }, + }) + ); + + return ( +
+ {label}

}> + +
+
+ ); +}; diff --git a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle-new.tsx b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle-new.tsx new file mode 100644 index 000000000..483d312f6 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle-new.tsx @@ -0,0 +1,42 @@ +import { Loader } from '@vegaprotocol/ui-toolkit'; +import { BundleError } from './signature-bundle/bundle-error'; +import { BundleExists } from './signature-bundle/bundle-exists'; +import { useExplorerNewAssetSignatureBundleQuery } from './__generated__/SignatureBundle'; + +export interface ProposalSignatureBundleByTypeProps { + id: string; +} + +/** + * If a proposal needs a signature bundle, AND that signature bundle exists + * AND that proposal is a New Asset Proposal, render an overview of the + * signature bundle. Or an error. + * + * There is an almost identical component, ProposalSignatureBundleUpdateAsset + */ +export const ProposalSignatureBundleNewAsset = ({ + id, +}: ProposalSignatureBundleByTypeProps) => { + const { data, error, loading } = useExplorerNewAssetSignatureBundleQuery({ + variables: { + id, + }, + }); + + if (loading) { + return ; + } + + if (data?.erc20ListAssetBundle?.signatures) { + return ( + + ); + } else { + return ; + } +}; diff --git a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle-update.tsx b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle-update.tsx new file mode 100644 index 000000000..290a37d11 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle-update.tsx @@ -0,0 +1,39 @@ +import { Loader } from '@vegaprotocol/ui-toolkit'; +import type { ProposalSignatureBundleByTypeProps } from './signature-bundle-new'; +import { BundleError } from './signature-bundle/bundle-error'; +import { BundleExists } from './signature-bundle/bundle-exists'; +import { useExplorerUpdateAssetSignatureBundleQuery } from './__generated__/SignatureBundle'; + +/** + * If a proposal needs a signature bundle, AND that signature bundle exists + * AND that proposal is a Asset Limits Proposal, render an overview of the + * signature bundle. Or an error. + * + * There is an almost identical component, ProposalSignatureBundleNewAsset + */ +export const ProposalSignatureBundleUpdateAsset = ({ + id, +}: ProposalSignatureBundleByTypeProps) => { + const { data, error, loading } = useExplorerUpdateAssetSignatureBundleQuery({ + variables: { + id, + }, + }); + + if (loading) { + return ; + } + + if (data?.erc20SetAssetLimitsBundle?.signatures) { + return ( + + ); + } else { + return ; + } +}; diff --git a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle.tsx b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle.tsx new file mode 100644 index 000000000..6f8e7d3d6 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle.tsx @@ -0,0 +1,31 @@ +import { ProposalSignatureBundleNewAsset } from './signature-bundle-new'; +import { ProposalSignatureBundleUpdateAsset } from './signature-bundle-update'; + +export function format(date: string | undefined, def: string) { + if (!date) { + return def; + } + + return new Date().toLocaleDateString() || def; +} + +interface ProposalSignatureBundleProps { + id: string; + type: 'NewAsset' | 'UpdateAsset'; +} + +/** + * Some proposals, if enacted, generate a signature bundle. + * The queries have to be split due to the way the API returns + * errors, hence this slightly redundant feeling switcher. + */ +export const ProposalSignatureBundle = ({ + id, + type, +}: ProposalSignatureBundleProps) => { + return type === 'NewAsset' ? ( + + ) : ( + + ); +}; diff --git a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-error.spec.tsx b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-error.spec.tsx new file mode 100644 index 000000000..b2140c6d5 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-error.spec.tsx @@ -0,0 +1,67 @@ +import type { ApolloError } from '@apollo/client'; +import { MockedProvider } from '@apollo/client/testing'; +import { render } from '@testing-library/react'; +import { AssetStatus } from '@vegaprotocol/types'; +import { MemoryRouter } from 'react-router-dom'; +import { BundleError } from './bundle-error'; + +describe('Bundle Error', () => { + const NON_ENABLED_STATUS: AssetStatus[] = [ + AssetStatus.STATUS_PENDING_LISTING, + AssetStatus.STATUS_PROPOSED, + AssetStatus.STATUS_REJECTED, + ]; + + const ENABLED_STATUS: AssetStatus[] = [AssetStatus.STATUS_ENABLED]; + + it.each(NON_ENABLED_STATUS)( + 'shows the apollo error if not enabled and a message is provided', + (status) => { + const screen = render( + + + + + + ); + + expect(screen.getByText('test-error-message')).toBeInTheDocument(); + } + ); + + it.each(NON_ENABLED_STATUS)( + 'shows some fallback error message if the bundle has not been used and there is no error', + (status) => { + const screen = render( + + + + + + ); + + expect(screen.getByText('No bundle for proposal ID')).toBeInTheDocument(); + } + ); + + it.each(ENABLED_STATUS)( + 'hides ProposalLink if the status is enabled', + (status) => { + const screen = render( + + + + + + ); + + expect(screen.getByText('Asset already enabled')).toBeInTheDocument(); + } + ); +}); diff --git a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-error.tsx b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-error.tsx new file mode 100644 index 000000000..8165258b1 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-error.tsx @@ -0,0 +1,34 @@ +import type { ApolloError } from '@apollo/client'; +import type { AssetStatus } from '@vegaprotocol/types'; + +import { t } from '@vegaprotocol/i18n'; +import Hash from '../../../../links/hash'; +import { IconForBundleStatus } from './bundle-icon'; + +export interface BundleErrorProps { + status?: AssetStatus; + error?: ApolloError; +} + +/** + * Renders if a proposal signature bundle cannot be found. + * It is also possible that a data node has dropped the bundle + * from its retention so there is a backup case where we check + * the status - if it's already enabled, pretend this isn't an error + */ +export const BundleError = ({ status, error }: BundleErrorProps) => { + return ( +
+ +

{t('No signature bundle found')}

+ +

+ {status === 'STATUS_ENABLED' ? ( + t('Asset already enabled') + ) : ( + + )} +

+
+ ); +}; diff --git a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-exists.spec.tsx b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-exists.spec.tsx new file mode 100644 index 000000000..ce4c7bd99 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-exists.spec.tsx @@ -0,0 +1,64 @@ +import { MockedProvider } from '@apollo/client/testing'; +import { render } from '@testing-library/react'; +import { AssetStatus } from '@vegaprotocol/types'; +import { MemoryRouter } from 'react-router-dom'; +import { BundleExists } from './bundle-exists'; + +describe('Bundle Exists', () => { + const NON_ENABLED_STATUS: AssetStatus[] = [ + AssetStatus.STATUS_PENDING_LISTING, + AssetStatus.STATUS_PROPOSED, + AssetStatus.STATUS_REJECTED, + ]; + + const ENABLED_STATUS: AssetStatus[] = [AssetStatus.STATUS_ENABLED]; + + const MOCK_SIGNATURES = + '0x1760ce4efec01d5bb9fe57c0660a39a27e0b5a1bd39b4d3fc0452bf142a4ef733baaf4dbedd74cd676c759f96ac7f412ec05e136bbff8a81d9f0c2428df8e099004c4d166ece0527d86e202386e8758580790d0a46f6429baaa268b4bf5c11c9e3402e175a9022ae8295e543853c01383ef17cd58458ddfc9c706fca0ecffa0d3d0160eb5234e63a78b9649428ca7659eb693fdde86edfc6c2707ef2e8fbf4c76a9f0eebe183da571a58837e2fa995d7b10955ecf04c138dc0ce964f17c3de1f5c6d0060ec132cc515cf974ead1da38e2ff8d4b870335865e8d4d8c982182bddea18bb513e2d37fd32de7fedc0c4f694e6bcdcf20f8547f19e7d9d25ce32c6ca9ea51e01ad3b92475778d9da7251d3943071f59107c9cd7ad9dd1923c06e88d3352869d919a591bf30732bad2c3fcf30a9f664dbb7a9c65a64875a48cbeb5741bd0a853901'; + const MOCK_NONCE = + '18250011763873610289536200551900545467959221115607409799241178172533618346952'; + const MOCK_PROPOSAL_ID = + '285923fed8c66ffb416b163e8ec72d3a87b9b8e2570e7ee7fe97d7092a918bc8'; + + const PROPOSAL_LINK_TEXT = 'Visit our Governance site to submit this'; + + it.each(NON_ENABLED_STATUS)( + 'shows a handy ProposalLink if the status is anything except enabled', + (status) => { + const screen = render( + + + + + + ); + + expect(screen.getByText(PROPOSAL_LINK_TEXT)).toBeInTheDocument(); + } + ); + + it.each(ENABLED_STATUS)( + 'hides ProposalLink if the status is enabled', + (status) => { + const screen = render( + + + + + + ); + + expect(screen.queryAllByText(PROPOSAL_LINK_TEXT)).toEqual([]); + } + ); +}); diff --git a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-exists.tsx b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-exists.tsx new file mode 100644 index 000000000..da0792e23 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-exists.tsx @@ -0,0 +1,46 @@ +import { t } from '@vegaprotocol/i18n'; +import type { AssetStatus } from '@vegaprotocol/types'; +import ProposalLink from '../../../../links/proposal-link/proposal-link'; +import { IconForBundleStatus } from './bundle-icon'; +import { ProposalSignatureBundleDetails } from './details'; + +export interface BundleExistsProps { + signatures: string; + nonce: string; + status?: AssetStatus; + proposalId: string; +} + +/** + * If a proposal needs a signature bundle, AND that signature bundle exists, + * this component renders that signature bundle. + * + */ +export const BundleExists = ({ + signatures, + nonce, + status, + proposalId, +}: BundleExistsProps) => { + return ( +
+ +

+ {status === 'STATUS_ENABLED' + ? t('Asset added to bridge') + : t('Signature bundle generated')} +

+ + + + {status !== 'STATUS_ENABLED' ? ( +

+ +

+ ) : null} +
+ ); +}; diff --git a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-icon.spec.tsx b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-icon.spec.tsx new file mode 100644 index 000000000..f43ec322d --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-icon.spec.tsx @@ -0,0 +1,33 @@ +import { render } from '@testing-library/react'; +import { AssetStatus } from '@vegaprotocol/types'; +import { IconForBundleStatus } from './bundle-icon'; + +describe('Bundle status icon', () => { + const NON_ENABLED_STATUS: AssetStatus[] = [ + AssetStatus.STATUS_PENDING_LISTING, + AssetStatus.STATUS_PROPOSED, + AssetStatus.STATUS_REJECTED, + ]; + + const ENABLED_STATUS: AssetStatus[] = [AssetStatus.STATUS_ENABLED]; + + it.each(NON_ENABLED_STATUS)( + 'show a sparkle icon if the bundle is unused', + (status) => { + const screen = render(); + const i = screen.getByRole('img'); + expect(i).toHaveAttribute('aria-label'); + expect(i.getAttribute('aria-label')).toMatch(/clean/); + } + ); + + it.each(ENABLED_STATUS)( + 'shows a tick if the bundle is already used', + (status) => { + const screen = render(); + const i = screen.getByRole('img'); + expect(i).toHaveAttribute('aria-label'); + expect(i.getAttribute('aria-label')).toMatch(/tick-circle/); + } + ); +}); diff --git a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-icon.tsx b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-icon.tsx new file mode 100644 index 000000000..683b0a85a --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/bundle-icon.tsx @@ -0,0 +1,17 @@ +import type { AssetStatus } from '@vegaprotocol/types'; +import type { IconName } from '@vegaprotocol/ui-toolkit'; +import { Icon } from '@vegaprotocol/ui-toolkit'; + +export interface IconForBundleStatusProps { + status?: AssetStatus; +} + +/** + * Naively select an icon for an asset. If it is enabled, we show a tick - anything + * else is assumed to be 'in progress'. There should only be a signature bundle or the + * asset should not exist + */ +export const IconForBundleStatus = ({ status }: IconForBundleStatusProps) => { + const i: IconName = status === 'STATUS_ENABLED' ? 'tick-circle' : 'clean'; + return ; +}; diff --git a/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/details.tsx b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/details.tsx new file mode 100644 index 000000000..d594eb849 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/signature-bundle/details.tsx @@ -0,0 +1,41 @@ +import { t } from '@vegaprotocol/i18n'; + +export interface ProposalSignatureBundleDetailsProps { + signatures: string; + nonce: string; +} + +export const ProposalSignatureBundleDetails = ({ + signatures, + nonce, +}: ProposalSignatureBundleDetailsProps) => { + return ( +
+ {t('Signature bundle details')} + +
+

{t('Signatures')}

+

+