From 042919eca9218a2b8cebb37363c4086e1aa085af Mon Sep 17 00:00:00 2001 From: Art Date: Thu, 22 Feb 2024 13:37:48 +0100 Subject: [PATCH] fix(governance): update market proposal diff (#5842) --- apps/governance/.env | 6 +- .../proposal-json/proposal-json.tsx | 10 +- .../proposal-market-changes.spec.tsx | 6 +- .../proposal-market-changes.tsx | 134 +++++---- .../proposal/proposal-change-details.tsx | 25 +- .../components/proposal/proposal-utils.ts | 261 ++++++++++++++++++ .../components/proposal/proposal.spec.tsx | 2 +- .../components/proposal/proposal.tsx | 6 +- .../proposals/proposal/proposal-container.tsx | 11 +- 9 files changed, 365 insertions(+), 96 deletions(-) create mode 100644 apps/governance/src/routes/proposals/components/proposal/proposal-utils.ts diff --git a/apps/governance/.env b/apps/governance/.env index 07f6f1b48..09bfaeca4 100644 --- a/apps/governance/.env +++ b/apps/governance/.env @@ -33,7 +33,7 @@ LC_ALL="en_US.UTF-8" # Cosmic elevator flags NX_SUCCESSOR_MARKETS=true NX_METAMASK_SNAPS=true -NX_PRODUCT_PERPETUALS=false -NX_UPDATE_MARKET_STATE=false +NX_PRODUCT_PERPETUALS=true +NX_UPDATE_MARKET_STATE=true NX_REFERRALS=true -NX_GOVERNANCE_TRANSFERS=false +NX_GOVERNANCE_TRANSFERS=true diff --git a/apps/governance/src/routes/proposals/components/proposal-json/proposal-json.tsx b/apps/governance/src/routes/proposals/components/proposal-json/proposal-json.tsx index c45581b84..529d858b4 100644 --- a/apps/governance/src/routes/proposals/components/proposal-json/proposal-json.tsx +++ b/apps/governance/src/routes/proposals/components/proposal-json/proposal-json.tsx @@ -3,16 +3,8 @@ import { useTranslation } from 'react-i18next'; import { SyntaxHighlighter } from '@vegaprotocol/ui-toolkit'; import { SubHeading } from '../../../../components/heading'; import { CollapsibleToggle } from '../../../../components/collapsible-toggle'; -import { - type BatchProposalFieldsFragment, - type ProposalFieldsFragment, -} from '../../__generated__/Proposals'; -export const ProposalJson = ({ - proposal, -}: { - proposal: ProposalFieldsFragment | BatchProposalFieldsFragment; -}) => { +export const ProposalJson = ({ proposal }: { proposal?: unknown }) => { const { t } = useTranslation(); const [showDetails, setShowDetails] = useState(false); diff --git a/apps/governance/src/routes/proposals/components/proposal-market-changes/proposal-market-changes.spec.tsx b/apps/governance/src/routes/proposals/components/proposal-market-changes/proposal-market-changes.spec.tsx index da19cc37e..10ac38937 100644 --- a/apps/governance/src/routes/proposals/components/proposal-market-changes/proposal-market-changes.spec.tsx +++ b/apps/governance/src/routes/proposals/components/proposal-market-changes/proposal-market-changes.spec.tsx @@ -57,21 +57,21 @@ describe('applyImmutableKeysFromEarlierVersion', () => { describe('ProposalMarketChanges', () => { it('renders correctly', () => { const { getByTestId } = render( - + ); expect(getByTestId('proposal-market-changes')).toBeInTheDocument(); }); it('JsonDiff is not visible when showChanges is false', () => { const { queryByTestId } = render( - + ); expect(queryByTestId('json-diff')).not.toBeInTheDocument(); }); it('JsonDiff is visible when showChanges is true', async () => { const { getByTestId } = render( - + ); fireEvent.click(getByTestId('proposal-market-changes-toggle')); expect(getByTestId('json-diff')).toBeInTheDocument(); diff --git a/apps/governance/src/routes/proposals/components/proposal-market-changes/proposal-market-changes.tsx b/apps/governance/src/routes/proposals/components/proposal-market-changes/proposal-market-changes.tsx index 9ed7dd178..1b0df685d 100644 --- a/apps/governance/src/routes/proposals/components/proposal-market-changes/proposal-market-changes.tsx +++ b/apps/governance/src/routes/proposals/components/proposal-market-changes/proposal-market-changes.tsx @@ -1,14 +1,23 @@ import cloneDeep from 'lodash/cloneDeep'; import set from 'lodash/set'; import get from 'lodash/get'; -import { JsonDiff } from '../../../../components/json-diff'; +import compact from 'lodash/compact'; +import orderBy from 'lodash/orderBy'; +import { JsonDiff, type JsonValue } from '../../../../components/json-diff'; import { useTranslation } from 'react-i18next'; import { useState } from 'react'; import { CollapsibleToggle } from '../../../../components/collapsible-toggle'; import { SubHeading } from '../../../../components/heading'; -import type { JsonValue } from '../../../../components/json-diff'; -import { useFetch } from '@vegaprotocol/react-helpers'; -import { ENV } from '../../../../config'; +import { + useFetchProposal, + useFetchProposals, + flatten, + isBatchProposalNode, + isSingleProposalNode, + type ProposalNode, + type SingleProposalData, + type SubProposalData, +} from '../proposal/proposal-utils'; const immutableKeys = [ 'decimalPlaces', @@ -18,8 +27,8 @@ const immutableKeys = [ ]; export const applyImmutableKeysFromEarlierVersion = ( - earlierVersion: JsonValue, - updatedVersion: JsonValue + earlierVersion: unknown, + updatedVersion: unknown ) => { if ( typeof earlierVersion !== 'object' || @@ -35,7 +44,8 @@ export const applyImmutableKeysFromEarlierVersion = ( // Overwrite the immutable keys in the updatedVersionCopy with the earlier values immutableKeys.forEach((key) => { - set(updatedVersionCopy, key, get(earlierVersion, key)); + const earlier = get(earlierVersion, key); + if (earlier) set(updatedVersionCopy, key, earlier); }); return updatedVersionCopy; @@ -43,49 +53,84 @@ export const applyImmutableKeysFromEarlierVersion = ( interface ProposalMarketChangesProps { marketId: string; - updatedProposal: JsonValue; + /** This are the changes from proposal */ + updateProposalNode: ProposalNode | null; + indicator?: number; } export const ProposalMarketChanges = ({ marketId, - updatedProposal, + updateProposalNode, + indicator, }: ProposalMarketChangesProps) => { const { t } = useTranslation(); const [showChanges, setShowChanges] = useState(false); - const { - state: { data }, - } = useFetch(`${ENV.rest}governance?proposalId=${marketId}`, undefined, true); + const { data: originalProposalData } = useFetchProposal({ + proposalId: marketId, + }); - const { - state: { data: enactedProposalData }, - } = useFetch( - `${ENV.rest}governances?proposalState=STATE_ENACTED&proposalType=TYPE_UPDATE_MARKET`, - undefined, - true + const { data: enactedProposalsData } = useFetchProposals({ + proposalState: 'STATE_ENACTED', + proposalType: 'TYPE_UPDATE_MARKET', + }); + + let updateProposal: SingleProposalData | SubProposalData | undefined; + if (isBatchProposalNode(updateProposalNode)) { + updateProposal = updateProposalNode.proposals.find( + (p, i) => + p.terms.updateMarket?.marketId === marketId && + (indicator != null ? i === indicator - 1 : true) + ); + } + if (isSingleProposalNode(updateProposalNode)) { + updateProposal = updateProposalNode.proposal; + } + + // this should get the proposal before the current one + const enactedUpdateMarketProposals = orderBy( + compact( + flatten(enactedProposalsData).filter((enacted) => { + const related = enacted.terms.updateMarket?.marketId === marketId; + const notCurrent = + enacted.id !== updateProposal?.id || + ('batchId' in enacted && enacted.batchId !== updateProposal.id); + const beforeCurrent = + Number(enacted.terms.enactmentTimestamp) < + Number(updateProposal?.terms.enactmentTimestamp); + return related && notCurrent && beforeCurrent; + }) + ), + [(proposal) => Number(proposal.terms.enactmentTimestamp)], + 'desc' ); - // @ts-ignore no types here :-/ - const enacted = enactedProposalData?.connection?.edges - .filter( - // @ts-ignore no type here - ({ node }) => node?.proposal?.terms?.updateMarket?.marketId === marketId - ) - // @ts-ignore no type here - .sort((a, b) => { - return ( - new Date(a?.node?.terms?.enactmentTimestamp).getTime() - - new Date(b?.node?.terms?.enactmentTimestamp).getTime() - ); - }); + const latestEnactedProposal = + enactedUpdateMarketProposals.length > 0 + ? enactedUpdateMarketProposals[0] + : undefined; - const latestEnactedProposal = enacted?.length - ? enacted[enacted.length - 1] - : undefined; + let originalProposal; + if (isBatchProposalNode(originalProposalData)) { + originalProposal = originalProposalData.proposals.find( + (proposal) => proposal.id === marketId && proposal.terms.newMarket != null + ); + } + if (isSingleProposalNode(originalProposalData)) { + originalProposal = originalProposalData.proposal; + } - const originalProposal = - // @ts-ignore no types with useFetch TODO: check this is good - data?.data?.proposal?.terms?.newMarket?.changes; + // LEFT SIDE: update market proposal enacted just before this one + // or original new market proposal + const left = + latestEnactedProposal?.terms.updateMarket?.changes || + originalProposal?.terms.newMarket?.changes; + + // RIGHT SIDE: this update market proposal + const right = applyImmutableKeysFromEarlierVersion( + left, + updateProposal?.terms.updateMarket?.changes + ); return (
@@ -99,20 +144,7 @@ export const ProposalMarketChanges = ({ {showChanges && (
- +
)}
diff --git a/apps/governance/src/routes/proposals/components/proposal/proposal-change-details.tsx b/apps/governance/src/routes/proposals/components/proposal/proposal-change-details.tsx index b52f351ef..b2b5335b1 100644 --- a/apps/governance/src/routes/proposals/components/proposal/proposal-change-details.tsx +++ b/apps/governance/src/routes/proposals/components/proposal/proposal-change-details.tsx @@ -13,6 +13,7 @@ import { ProposalUpdateBenefitTiers } from '../proposal-update-benefit-tiers'; import { ProposalUpdateMarketState } from '../proposal-update-market-state'; import { ProposalVolumeDiscountProgramDetails } from '../proposal-volume-discount-program-details'; import { getIndicatorStyle } from './colours'; +import { type ProposalNode } from './proposal-utils'; export const ProposalChangeDetails = ({ proposal, @@ -22,8 +23,7 @@ export const ProposalChangeDetails = ({ }: { proposal: Proposal | BatchProposal; terms: ProposalTermsFieldsFragment; - // eslint-disable-next-line - restData: any; + restData: ProposalNode | null; indicator?: number; }) => { let details = null; @@ -63,30 +63,13 @@ export const ProposalChangeDetails = ({ } case 'UpdateMarket': { if (proposal.id) { - const marketId = terms.change.marketId; - const proposalData = restData?.data?.proposal; - let updatedProposal = null; - // single proposal - if ('terms' in proposalData) { - updatedProposal = proposalData?.terms?.updateMarket?.changes; - } - // batch proposal - need to fish for the actual changes - if ( - 'batchTerms' in proposalData && - Array.isArray(proposalData.batchTerms?.changes) - ) { - updatedProposal = proposalData?.batchTerms?.changes.find( - (ch: { updateMarket?: { marketId: string } }) => - ch?.updateMarket?.marketId === marketId - )?.updateMarket?.changes; - } - details = (
); diff --git a/apps/governance/src/routes/proposals/components/proposal/proposal-utils.ts b/apps/governance/src/routes/proposals/components/proposal/proposal-utils.ts new file mode 100644 index 000000000..782e384e0 --- /dev/null +++ b/apps/governance/src/routes/proposals/components/proposal/proposal-utils.ts @@ -0,0 +1,261 @@ +import compact from 'lodash/compact'; +import { ENV } from '../../../../config'; +import { useEffect, useState } from 'react'; + +type Maybe = T | null | undefined; + +type ProposalState = + | 'STATE_UNSPECIFIED' + | 'STATE_FAILED' + | 'STATE_OPEN' + | 'STATE_PASSED' + | 'STATE_REJECTED' + | 'STATE_DECLINED' + | 'STATE_ENACTED' + | 'STATE_WAITING_FOR_NODE_VOTE'; + +type ProposalType = + | 'TYPE_UNSPECIFIED' + | 'TYPE_ALL' + | 'TYPE_NEW_MARKET' + | 'TYPE_UPDATE_MARKET' + | 'TYPE_NETWORK_PARAMETERS' + | 'TYPE_NEW_ASSET' + | 'TYPE_NEW_FREE_FORM' + | 'TYPE_UPDATE_ASSET' + | 'TYPE_NEW_SPOT_MARKET' + | 'TYPE_UPDATE_SPOT_MARKET' + | 'TYPE_NEW_TRANSFER' + | 'TYPE_CANCEL_TRANSFER' + | 'TYPE_UPDATE_MARKET_STATE' + | 'TYPE_UPDATE_REFERRAL_PROGRAM' + | 'TYPE_UPDATE_VOLUME_DISCOUNT_PROGRAM'; + +type ProposalNodeType = 'TYPE_SINGLE_OR_UNSPECIFIED' | 'TYPE_BATCH'; + +type ProposalData = { + id: string; + rationale: { + description: string; + title: string; + }; + state: ProposalState; + timestamp: string; +}; + +type Terms = { + cancelTransfer?: { changes: unknown }; + enactmentTimestamp: string; + newAsset?: { changes: unknown }; + newFreeform: object; + newMarket?: { changes: unknown }; + newSpotMarket?: { changes: unknown }; + newTransfer?: { changes: unknown }; + updateAsset?: { assetId: string; changes: unknown }; + updateMarket?: { marketId: string; changes: unknown }; + updateMarketState?: { + changes: { + marketId: string; + price: string; + updateType: + | 'MARKET_STATE_UPDATE_TYPE_UNSPECIFIED' + | 'MARKET_STATE_UPDATE_TYPE_TERMINATE' + | 'MARKET_STATE_UPDATE_TYPE_SUSPEND' + | 'MARKET_STATE_UPDATE_TYPE_RESUME'; + }; + }; + updateNetworkParameter?: { changes: unknown }; + updateReferralProgram?: { changes: unknown }; + updateSpotMarket?: { marketId: string; changes: unknown }; + updateVolumeDiscountProgram?: { changes: unknown }; +}; + +export type SingleProposalData = ProposalData & { + terms: Terms & { + closingTimestamp: string; + validationTimestamp: string; + }; +}; + +type BatchProposalData = ProposalData & { + batchTerms: { + changes: Terms[]; + }; +}; + +export type SubProposalData = SingleProposalData & { + batchId: string; +}; + +export type ProposalNode = { + proposal: ProposalData; + proposalType: ProposalNodeType; + proposals: SubProposalData[]; +}; + +type SingleProposalNode = ProposalNode & { + proposal: SingleProposalData; + proposalType: 'TYPE_SINGLE_OR_UNSPECIFIED'; + proposals: []; +}; + +type BatchProposalNode = ProposalNode & { + proposal: BatchProposalData; + proposalType: 'TYPE_BATCH'; +}; + +export const isProposalNode = (node: unknown): node is ProposalNode => + Boolean( + typeof node === 'object' && + node && + 'proposal' in node && + typeof node.proposal === 'object' && + node?.proposal && + 'id' in node.proposal && + node?.proposal?.id + ); + +export const isSingleProposalNode = ( + node: Maybe +): node is SingleProposalNode => + Boolean( + node && + node?.proposalType === 'TYPE_SINGLE_OR_UNSPECIFIED' && + node?.proposal + ); + +export const isBatchProposalNode = ( + node: Maybe +): node is BatchProposalNode => + Boolean( + node && + node?.proposalType === 'TYPE_BATCH' && + node?.proposal && + 'batchTerms' in node.proposal && + node?.proposals?.length > 0 + ); + +// this includes also batch proposals with `updateMarket`s 👍 +const PROPOSALS_ENDPOINT = `${ENV.rest}governances?proposalState=:proposalState&proposalType=:proposalType`; + +// this can be queried also by sub proposal id as `proposalId` and it will +// return full batch proposal data with all of its sub proposals including +// the requested one inside `proposals` array. +const PROPOSAL_ENDPOINT = `${ENV.rest}governance?proposalId=:proposalId`; + +export const getProposals = async ({ + proposalState, + proposalType, +}: { + proposalState: ProposalState; + proposalType: ProposalType; +}) => { + try { + const response = await fetch( + PROPOSALS_ENDPOINT.replace(':proposalState', proposalState).replace( + ':proposalType', + proposalType + ) + ); + if (response.ok) { + const data = await response.json(); + if ( + data && + 'connection' in data && + data.connection && + 'edges' in data.connection && + data.connection.edges?.length > 0 + ) { + const nodes = compact( + data.connection.edges.map((e: { node?: object }) => e?.node) + ).filter(isProposalNode); + + return nodes; + } + } + } catch { + // NOOP - ignore errors + } + + return []; +}; + +export const getProposal = async ({ proposalId }: { proposalId: string }) => { + try { + const response = await fetch( + PROPOSAL_ENDPOINT.replace(':proposalId', proposalId) + ); + if (response.ok) { + const data = await response.json(); + if (data && 'data' in data && isProposalNode(data.data)) { + return data.data as ProposalNode; + } + } + } catch (err) { + // NOOP - ignore errors + } + return null; +}; + +export const useFetchProposal = ({ proposalId }: { proposalId?: string }) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const cb = async () => { + if (!proposalId) return; + + setLoading(true); + const data = await getProposal({ proposalId }); + setLoading(false); + if (data) { + setData(data); + } + }; + cb(); + }, [proposalId]); + + return { data, loading }; +}; + +export const useFetchProposals = ({ + proposalState, + proposalType, +}: { + proposalState: ProposalState; + proposalType: ProposalType; +}) => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const cb = async () => { + setLoading(true); + const data = await getProposals({ proposalState, proposalType }); + setLoading(false); + if (data) { + setData(data); + } + }; + cb(); + }, [proposalState, proposalType]); + + return { data, loading }; +}; + +export const flatten = ( + nodes: ProposalNode[] +): (SingleProposalData | SubProposalData)[] => { + const flattenNodes = []; + for (const node of nodes) { + if (isSingleProposalNode(node)) { + flattenNodes.push(node.proposal); + } + if (isBatchProposalNode(node)) { + for (const sub of node.proposals) { + flattenNodes.push(sub); + } + } + } + return flattenNodes; +}; diff --git a/apps/governance/src/routes/proposals/components/proposal/proposal.spec.tsx b/apps/governance/src/routes/proposals/components/proposal/proposal.spec.tsx index e090f296d..94606df8a 100644 --- a/apps/governance/src/routes/proposals/components/proposal/proposal.spec.tsx +++ b/apps/governance/src/routes/proposals/components/proposal/proposal.spec.tsx @@ -61,7 +61,7 @@ const renderComponent = (proposal: IProposal) => { - + diff --git a/apps/governance/src/routes/proposals/components/proposal/proposal.tsx b/apps/governance/src/routes/proposals/components/proposal/proposal.tsx index 63305bcda..209fb5d67 100644 --- a/apps/governance/src/routes/proposals/components/proposal/proposal.tsx +++ b/apps/governance/src/routes/proposals/components/proposal/proposal.tsx @@ -8,15 +8,17 @@ import { ProposalJson } from '../proposal-json'; import { UserVote } from '../vote-details'; import Routes from '../../../routes'; import { ProposalState } from '@vegaprotocol/types'; +import { type ProposalNode } from './proposal-utils'; import { useVoteSubmit } from '@vegaprotocol/proposals'; import { useUserVote } from '../vote-details/use-user-vote'; import { type Proposal as IProposal, type BatchProposal } from '../../types'; import { ProposalChangeDetails } from './proposal-change-details'; +import { type JsonValue } from 'type-fest'; export interface ProposalProps { proposal: IProposal | BatchProposal; // eslint-disable-next-line @typescript-eslint/no-explicit-any - restData: any; + restData: ProposalNode | null; } export const Proposal = ({ proposal, restData }: ProposalProps) => { @@ -95,7 +97,7 @@ export const Proposal = ({ proposal, restData }: ProposalProps) => {
- +
); diff --git a/apps/governance/src/routes/proposals/proposal/proposal-container.tsx b/apps/governance/src/routes/proposals/proposal/proposal-container.tsx index e1647a5ed..5c02311e3 100644 --- a/apps/governance/src/routes/proposals/proposal/proposal-container.tsx +++ b/apps/governance/src/routes/proposals/proposal/proposal-container.tsx @@ -1,17 +1,16 @@ import { useParams } from 'react-router-dom'; import { AsyncRenderer } from '@vegaprotocol/ui-toolkit'; -import { useFetch } from '@vegaprotocol/react-helpers'; -import { ENV } from '../../../config'; import { Proposal } from '../components/proposal'; import { ProposalNotFound } from '../components/proposal-not-found'; import { useProposalQuery } from '../__generated__/Proposals'; +import { useFetchProposal } from '../components/proposal/proposal-utils'; export const ProposalContainer = () => { const params = useParams<{ proposalId: string }>(); - const { - state: { data: restData, loading: restLoading, error: restError }, - } = useFetch(`${ENV.rest}governance?proposalId=${params.proposalId}`); + const { data: restData, loading: restLoading } = useFetchProposal({ + proposalId: params.proposalId, + }); const { data, loading, error } = useProposalQuery({ fetchPolicy: 'network-only', @@ -26,7 +25,7 @@ export const ProposalContainer = () => { return (