diff --git a/README.md b/README.md index 9e657a93a..45ce92595 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The front-end monorepo provides a toolkit for building apps that interact with V This repository is managed using [Nx](https://nx.dev). -# πŸ”Ž Applications in this repo +## πŸ”Ž Applications in this repo ### [Block explorer](./apps/explorer) @@ -30,7 +30,7 @@ Hosting for static content being shared across apps, for example fonts. The utility dApp for validators wishing to add or remove themselves as a signer of the multisig contract. -# 🧱 Libraries in this repo +## 🧱 Libraries in this repo ### [UI toolkit](./libs/ui-toolkit) @@ -53,7 +53,7 @@ A utility library for connecting to the Ethereum network and interacting with Ve Generic react helpers that can be used across multiple applications, along with other utilities. -# πŸ’» Develop +## πŸ’» Develop ### Set up @@ -103,7 +103,7 @@ In CI linting, formatting and also run. These checks can be seen in the [CI work Visit the [Nx Documentation](https://nx.dev/getting-started/intro) to learn more. -# πŸ‹ Hosting a console +## πŸ‹ Hosting a console To host a console there are two possible build scenarios for running the frontends: nx performed **outside** or **inside** docker build. For specific build instructions follow [build instructions](#build-instructions). @@ -226,6 +226,6 @@ Note: The script is only needed if capsule was built for first time or fresh. To vega wallet service run -n DV --load-tokens --tokens-passphrase-file passphrase --no-version-check --automatic-consent --home ~/.vegacapsule/testnet/wallet ``` -# πŸ“‘ License +## πŸ“‘ License [MIT](./LICENSE) diff --git a/apps/explorer/src/app/components/links/hash.tsx b/apps/explorer/src/app/components/links/hash.tsx index 3fd073aa7..f2f96c900 100644 --- a/apps/explorer/src/app/components/links/hash.tsx +++ b/apps/explorer/src/app/components/links/hash.tsx @@ -1,4 +1,4 @@ -export type HashProps = { +export type HashProps = React.HTMLProps & { text: string; truncate?: boolean; }; diff --git a/apps/explorer/src/app/components/links/index.ts b/apps/explorer/src/app/components/links/index.ts index ec0211d43..e835eba87 100644 --- a/apps/explorer/src/app/components/links/index.ts +++ b/apps/explorer/src/app/components/links/index.ts @@ -2,4 +2,5 @@ export { default as BlockLink } from './block-link/block-link'; export { default as PartyLink } from './party-link/party-link'; export { default as NodeLink } from './node-link/node-link'; export { default as MarketLink } from './market-link/market-link'; +export { default as NetworkParameterLink } from './network-parameter-link/network-parameter-link'; export * from './asset-link/asset-link'; diff --git a/apps/explorer/src/app/components/links/network-parameter-link/network-parameter-link.tsx b/apps/explorer/src/app/components/links/network-parameter-link/network-parameter-link.tsx new file mode 100644 index 000000000..2b8bbc54c --- /dev/null +++ b/apps/explorer/src/app/components/links/network-parameter-link/network-parameter-link.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Routes } from '../../../routes/route-names'; +import { Link } from 'react-router-dom'; + +import type { ComponentProps } from 'react'; +import Hash from '../hash'; + +export type NetworkParameterLinkProps = Partial> & { + parameter: string; +}; + +/** + * Links a given network parameter to the relevant page and anchor on the page + */ +const NetworkParameterLink = ({ + parameter, + ...props +}: NetworkParameterLinkProps) => { + return ( + + + + ); +}; + +export default NetworkParameterLink; 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 c7bd14820..ee6b35c3f 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 @@ -26,7 +26,7 @@ const ProposalLink = ({ id, text }: ProposalLinkProps) => { >; const base = ENV.dataSources.governanceUrl; - const label = proposal?.rationale.title || id; + const label = proposal?.rationale?.title || id; return ( diff --git a/apps/explorer/src/app/components/txs/details/proposal/Proposal.graphql b/apps/explorer/src/app/components/txs/details/proposal/Proposal.graphql index db4b2ea66..3c51a6d9f 100644 --- a/apps/explorer/src/app/components/txs/details/proposal/Proposal.graphql +++ b/apps/explorer/src/app/components/txs/details/proposal/Proposal.graphql @@ -5,5 +5,10 @@ query ExplorerProposalStatus($id: ID!) { state rejectionReason } + ... on BatchProposal { + id + state + rejectionReason + } } } 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 index 0034cd2e2..f2f4c2488 100644 --- a/apps/explorer/src/app/components/txs/details/proposal/__generated__/Proposal.ts +++ b/apps/explorer/src/app/components/txs/details/proposal/__generated__/Proposal.ts @@ -8,7 +8,7 @@ export type ExplorerProposalStatusQueryVariables = Types.Exact<{ }>; -export type ExplorerProposalStatusQuery = { __typename?: 'Query', proposal?: { __typename?: 'BatchProposal' } | { __typename?: 'Proposal', id?: string | null, state: Types.ProposalState, rejectionReason?: Types.ProposalRejectionReason | null } | null }; +export type ExplorerProposalStatusQuery = { __typename?: 'Query', proposal?: { __typename?: 'BatchProposal', id?: string | null, state: Types.ProposalState, rejectionReason?: Types.ProposalRejectionReason | null } | { __typename?: 'Proposal', id?: string | null, state: Types.ProposalState, rejectionReason?: Types.ProposalRejectionReason | null } | null }; export const ExplorerProposalStatusDocument = gql` @@ -19,6 +19,11 @@ export const ExplorerProposalStatusDocument = gql` state rejectionReason } + ... on BatchProposal { + id + state + rejectionReason + } } } `; diff --git a/apps/explorer/src/app/components/txs/details/proposal/batch-item.spec.tsx b/apps/explorer/src/app/components/txs/details/proposal/batch-item.spec.tsx new file mode 100644 index 000000000..39557e557 --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/batch-item.spec.tsx @@ -0,0 +1,257 @@ +import { render, screen } from '@testing-library/react'; +import { BatchItem } from './batch-item'; +import { MemoryRouter } from 'react-router-dom'; +import { MockedProvider } from '@apollo/client/testing'; +import type { components } from '../../../../../types/explorer'; +type Item = components['schemas']['vegaBatchProposalTermsChange']; + +describe('BatchItem', () => { + it('Renders "Unknown proposal type" by default', () => { + const item = {}; + render(); + expect(screen.getByText('Unknown proposal type')).toBeInTheDocument(); + }); + + it('Renders "Unknown proposal type" for unknown items', () => { + const item = { + newLochNessMonster: { + location: 'Loch Ness', + }, + } as unknown as Item; + render(); + expect(screen.getByText('Unknown proposal type')).toBeInTheDocument(); + }); + + it('Renders "New spot market"', () => { + const item = { + newSpotMarket: {}, + }; + render(); + expect(screen.getByText('New spot market')).toBeInTheDocument(); + }); + + it('Renders "Cancel transfer"', () => { + const item = { + cancelTransfer: { + changes: { + transferId: 'transfer123', + }, + }, + }; + render(); + expect(screen.getByText('Cancel transfer')).toBeInTheDocument(); + expect(screen.getByText('transf')).toBeInTheDocument(); + }); + + it('Renders "Cancel transfer" without an id', () => { + const item = { + cancelTransfer: { + changes: {}, + }, + }; + render(); + expect(screen.getByText('Cancel transfer')).toBeInTheDocument(); + }); + + it('Renders "New freeform"', () => { + const item = { + newFreeform: {}, + }; + render(); + expect(screen.getByText('New freeform proposal')).toBeInTheDocument(); + }); + + it('Renders "New market"', () => { + const item = { + newMarket: {}, + }; + render(); + expect(screen.getByText('New market')).toBeInTheDocument(); + }); + + it('Renders "New transfer"', () => { + const item = { + newTransfer: {}, + }; + render(); + expect(screen.getByText('New transfer')).toBeInTheDocument(); + }); + + it('Renders "Update asset" with assetId', () => { + const item = { + updateAsset: { + assetId: 'asset123', + }, + }; + render( + + + + + + ); + expect(screen.getByText('Update asset')).toBeInTheDocument(); + expect(screen.getByText('asset123')).toBeInTheDocument(); + }); + + it('Renders "Update asset" even if assetId is not set', () => { + const item = { + updateAsset: { + assetId: undefined, + }, + }; + render(); + expect(screen.getByText('Update asset')).toBeInTheDocument(); + }); + + it('Renders "Update market state" with marketId', () => { + const item = { + updateMarketState: { + changes: { + marketId: 'market123', + }, + }, + }; + render( + + + + + + ); + expect(screen.getByText('Update market state')).toBeInTheDocument(); + expect(screen.getByText('market123')).toBeInTheDocument(); + }); + + it('Renders "Update market state" even if marketId is not set', () => { + const item = { + updateMarketState: { + changes: { + marketId: undefined, + }, + }, + }; + render( + + + + + + ); + expect(screen.getByText('Update market state')).toBeInTheDocument(); + }); + + it('Renders "Update network parameter" with parameter', () => { + const item = { + updateNetworkParameter: { + changes: { + key: 'parameter123', + }, + }, + }; + render( + + + + + + ); + expect(screen.getByText('Update network parameter')).toBeInTheDocument(); + expect(screen.getByText('parameter123')).toBeInTheDocument(); + }); + + it('Renders "Update network parameter" even if parameter is not set', () => { + const item = { + updateNetworkParameter: { + changes: { + key: undefined, + }, + }, + }; + render(); + expect(screen.getByText('Update network parameter')).toBeInTheDocument(); + }); + + it('Renders "Update referral program"', () => { + const item = { + updateReferralProgram: {}, + }; + render(); + expect(screen.getByText('Update referral program')).toBeInTheDocument(); + }); + + it('Renders "Update spot market" with marketId', () => { + const item = { + updateSpotMarket: { + marketId: 'market123', + }, + }; + render( + + + + + + ); + expect(screen.getByText('Update spot market')).toBeInTheDocument(); + expect(screen.getByText('market123')).toBeInTheDocument(); + }); + + it('Renders "Update spot market" even if marketId is not set', () => { + const item = { + updateSpotMarket: { + marketId: undefined, + }, + }; + render( + + + + + + ); + expect(screen.getByText('Update spot market')).toBeInTheDocument(); + }); + it('Renders "Update market" with marketId', () => { + const item = { + updateMarket: { + marketId: 'market123', + }, + }; + render( + + + + + + ); + expect(screen.getByText('Update market')).toBeInTheDocument(); + expect(screen.getByText('market123')).toBeInTheDocument(); + }); + + it('Renders "Update market" even if marketId is not set', () => { + const item = { + updateMarket: { + marketId: undefined, + }, + }; + render( + + + + + + ); + expect(screen.getByText('Update market')).toBeInTheDocument(); + }); + + it('Renders "Update volume discount program"', () => { + const item = { + updateVolumeDiscountProgram: {}, + }; + render(); + expect( + screen.getByText('Update volume discount program') + ).toBeInTheDocument(); + }); +}); diff --git a/apps/explorer/src/app/components/txs/details/proposal/batch-item.tsx b/apps/explorer/src/app/components/txs/details/proposal/batch-item.tsx new file mode 100644 index 000000000..8fc80e92c --- /dev/null +++ b/apps/explorer/src/app/components/txs/details/proposal/batch-item.tsx @@ -0,0 +1,87 @@ +import { t } from '@vegaprotocol/i18n'; +import { AssetLink, MarketLink, NetworkParameterLink } from '../../../links'; +import type { components } from '../../../../../types/explorer'; +import Hash from '../../../links/hash'; + +type Item = components['schemas']['vegaBatchProposalTermsChange']; + +export interface BatchItemProps { + item: Item; +} + +/** + * Produces a one line summary for an item in a batch proposal. Could + * easily be adapted to summarise individual proposals, but there is no + * place for that yet. + * + * Details (like IDs) should be shown and linked if available, but handled + * if not available. This is adequate as the ProposalSummary component contains + * a JSON viewer for the full proposal. + */ +export const BatchItem = ({ item }: BatchItemProps) => { + if (item.cancelTransfer) { + const transferId = item?.cancelTransfer?.changes?.transferId || false; + return ( + + {t('Cancel transfer')}  + {transferId && ( + + )} + + ); + } else if (item.newFreeform) { + return {t('New freeform proposal')}; + } else if (item.newMarket) { + return {t('New market')}; + } else if (item.newSpotMarket) { + return {t('New spot market')}; + } else if (item.newTransfer) { + return {t('New transfer')}; + } else if (item.updateAsset) { + const assetId = item?.updateAsset?.assetId || false; + return ( + + {t('Update asset')} + {assetId && } + + ); + } else if (item.updateMarket) { + const marketId = item?.updateMarket?.marketId || false; + return ( + + {t('Update market')}{' '} + {marketId && } + + ); + } else if (item.updateMarketState) { + const marketId = item?.updateMarketState?.changes?.marketId || false; + return ( + + {t('Update market state')} + {marketId && } + + ); + } else if (item.updateNetworkParameter) { + const param = item?.updateNetworkParameter?.changes?.key || false; + return ( + + {t('Update network parameter')} + {param && } + + ); + } else if (item.updateReferralProgram) { + return {t('Update referral program')}; + } else if (item.updateSpotMarket) { + const marketId = item?.updateSpotMarket?.marketId || ''; + return ( + + {t('Update spot market')} + + + ); + } else if (item.updateVolumeDiscountProgram) { + return {t('Update volume discount program')}; + } + + return {t('Unknown proposal type')}; +}; diff --git a/apps/explorer/src/app/components/txs/details/proposal/summary.tsx b/apps/explorer/src/app/components/txs/details/proposal/summary.tsx index 48c71cc6c..0972e3d07 100644 --- a/apps/explorer/src/app/components/txs/details/proposal/summary.tsx +++ b/apps/explorer/src/app/components/txs/details/proposal/summary.tsx @@ -1,6 +1,4 @@ -import type { ProposalTerms } from '../tx-proposal'; import { useState } from 'react'; -import type { components } from '../../../../../types/explorer'; import { JsonViewerDialog } from '../../../dialogs/json-viewer-dialog'; import ProposalLink from '../../../links/proposal-link/proposal-link'; import truncate from 'lodash/truncate'; @@ -9,7 +7,12 @@ import ReactMarkdown from 'react-markdown'; import { ProposalDate } from './proposal-date'; import { t } from '@vegaprotocol/i18n'; +import type { ProposalTerms } from '../tx-proposal'; +import type { components } from '../../../../../types/explorer'; +import { BatchItem } from './batch-item'; + type Rationale = components['schemas']['vegaProposalRationale']; +type Batch = components['schemas']['v1BatchProposalSubmissionTerms']['changes']; type ProposalTermsDialog = { open: boolean; @@ -21,6 +24,7 @@ interface ProposalSummaryProps { id: string; rationale?: Rationale; terms?: ProposalTerms; + batch?: Batch; } /** @@ -31,6 +35,7 @@ export const ProposalSummary = ({ id, rationale, terms, + batch, }: ProposalSummaryProps) => { const [dialog, setDialog] = useState({ open: false, @@ -72,6 +77,18 @@ export const ProposalSummary = ({ )} + {batch && ( +
+

{t('Changes')}

+
    + {batch.map((change, index) => ( +
  1. + +
  2. + ))} +
+
+ )}