From 6a071271854be55ae9ffaaa7fdf83b1a53a7cebe Mon Sep 17 00:00:00 2001 From: Sam Keen Date: Thu, 27 Jul 2023 11:26:32 +0100 Subject: [PATCH] feat(governance): diff highlighter for update market proposals (#4260) Co-authored-by: Matthew Russell --- .../src/components/json-diff/index.ts | 1 + .../components/json-diff/json-diff.spec.tsx | 30 +++++ .../src/components/json-diff/json-diff.tsx | 46 ++++++++ .../governance/src/i18n/translations/dev.json | 4 +- .../proposal-market-changes/index.tsx | 1 + .../proposal-market-changes.spec.tsx | 91 +++++++++++++++ .../proposal-market-changes.tsx | 86 ++++++++++++++ .../components/proposal/proposal.tsx | 25 +++++ .../proposals/proposal/proposal-container.tsx | 106 +++++++++++++++++- apps/governance/src/styles.css | 32 +++++- libs/react-helpers/src/hooks/use-fetch.ts | 7 +- package.json | 1 + yarn.lock | 15 ++- 13 files changed, 435 insertions(+), 10 deletions(-) create mode 100644 apps/governance/src/components/json-diff/index.ts create mode 100644 apps/governance/src/components/json-diff/json-diff.spec.tsx create mode 100644 apps/governance/src/components/json-diff/json-diff.tsx create mode 100644 apps/governance/src/routes/proposals/components/proposal-market-changes/index.tsx create mode 100644 apps/governance/src/routes/proposals/components/proposal-market-changes/proposal-market-changes.spec.tsx create mode 100644 apps/governance/src/routes/proposals/components/proposal-market-changes/proposal-market-changes.tsx diff --git a/apps/governance/src/components/json-diff/index.ts b/apps/governance/src/components/json-diff/index.ts new file mode 100644 index 000000000..e9a224fb8 --- /dev/null +++ b/apps/governance/src/components/json-diff/index.ts @@ -0,0 +1 @@ +export * from './json-diff'; diff --git a/apps/governance/src/components/json-diff/json-diff.spec.tsx b/apps/governance/src/components/json-diff/json-diff.spec.tsx new file mode 100644 index 000000000..4c1b2fee4 --- /dev/null +++ b/apps/governance/src/components/json-diff/json-diff.spec.tsx @@ -0,0 +1,30 @@ +import { render, screen, cleanup } from '@testing-library/react'; +import { JsonDiff } from './json-diff'; + +describe('JsonDiff', () => { + afterEach(cleanup); + + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('json-diff')).toBeInTheDocument(); + }); + + it('shows the correct message when both objects are identical', () => { + render(); + expect(screen.getByText('Data is identical')).toBeInTheDocument(); + }); + + it('does not show the "identical" message when both objects are not identical', () => { + render( + + ); + expect(screen.queryByText('Data is identical')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/governance/src/components/json-diff/json-diff.tsx b/apps/governance/src/components/json-diff/json-diff.tsx new file mode 100644 index 000000000..460590d1e --- /dev/null +++ b/apps/governance/src/components/json-diff/json-diff.tsx @@ -0,0 +1,46 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { formatters, create } from 'jsondiffpatch'; +import 'jsondiffpatch/dist/formatters-styles/html.css'; +import 'jsondiffpatch/dist/formatters-styles/annotated.css'; + +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +interface JsonDiffProps { + left: JsonValue; + right: JsonValue; + objectHash?: (obj: unknown) => string | undefined; +} + +export const JsonDiff = ({ right, left, objectHash }: JsonDiffProps) => { + const { t } = useTranslation(); + const [html, setHtml] = useState(); + + useEffect(() => { + const delta = create({ + objectHash, + }).diff(left, right); + + if (delta) { + const deltaHtml = formatters.html.format(delta, left); + + formatters.html.hideUnchanged(); + + setHtml(deltaHtml); + } else { + setHtml(undefined); + } + }, [right, left, objectHash]); + + return html ? ( +
+ ) : ( +

{t('dataIsIdentical')}

+ ); +}; diff --git a/apps/governance/src/i18n/translations/dev.json b/apps/governance/src/i18n/translations/dev.json index 6616f0c6e..26568ff83 100644 --- a/apps/governance/src/i18n/translations/dev.json +++ b/apps/governance/src/i18n/translations/dev.json @@ -853,5 +853,7 @@ "consensusNodes": "consensus nodes", "activeNodes": "active nodes", "Estimated time to upgrade": "Estimated time to upgrade", - "Upgraded at": "Upgraded at" + "Upgraded at": "Upgraded at", + "dataIsIdentical": "Data is identical", + "updatesToMarket": "Updates to market" } diff --git a/apps/governance/src/routes/proposals/components/proposal-market-changes/index.tsx b/apps/governance/src/routes/proposals/components/proposal-market-changes/index.tsx new file mode 100644 index 000000000..68b597eb3 --- /dev/null +++ b/apps/governance/src/routes/proposals/components/proposal-market-changes/index.tsx @@ -0,0 +1 @@ +export * from './proposal-market-changes'; 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 new file mode 100644 index 000000000..191a5f8bd --- /dev/null +++ b/apps/governance/src/routes/proposals/components/proposal-market-changes/proposal-market-changes.spec.tsx @@ -0,0 +1,91 @@ +import { render, fireEvent } from '@testing-library/react'; +import { + ProposalMarketChanges, + applyImmutableKeysFromEarlierVersion, +} from './proposal-market-changes'; +import type { JsonValue } from '../../../../components/json-diff'; + +describe('applyImmutableKeysFromEarlierVersion', () => { + it('returns an empty object if any argument is not an object or null', () => { + const earlierVersion: JsonValue = null; + const updatedVersion: JsonValue = null; + expect( + applyImmutableKeysFromEarlierVersion(earlierVersion, updatedVersion) + ).toEqual({}); + }); + + it('overrides updatedVersion with values from earlierVersion for immutable keys', () => { + const earlierVersion: JsonValue = { + decimalPlaces: 2, + positionDecimalPlaces: 3, + instrument: { + name: 'Instrument1', + future: { + settlementAsset: 'Asset1', + }, + }, + }; + + const updatedVersion: JsonValue = { + decimalPlaces: 3, // should be overridden by 2 + positionDecimalPlaces: 4, // should be overridden by 3 + instrument: { + name: 'Instrument2', // should be overridden by 'Instrument1' + future: { + settlementAsset: 'Asset2', // should be overridden by 'Asset1' + }, + }, + }; + + const expected: JsonValue = { + decimalPlaces: 2, + positionDecimalPlaces: 3, + instrument: { + name: 'Instrument1', + future: { + settlementAsset: 'Asset1', + }, + }, + }; + + expect( + applyImmutableKeysFromEarlierVersion(earlierVersion, updatedVersion) + ).toEqual(expected); + }); +}); + +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 new file mode 100644 index 000000000..187bc9e17 --- /dev/null +++ b/apps/governance/src/routes/proposals/components/proposal-market-changes/proposal-market-changes.tsx @@ -0,0 +1,86 @@ +import cloneDeep from 'lodash/cloneDeep'; +import set from 'lodash/set'; +import get from 'lodash/get'; +import { JsonDiff } 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'; + +const immutableKeys = [ + 'decimalPlaces', + 'positionDecimalPlaces', + 'instrument.name', + 'instrument.future.settlementAsset', +]; + +export const applyImmutableKeysFromEarlierVersion = ( + earlierVersion: JsonValue, + updatedVersion: JsonValue +) => { + if ( + typeof earlierVersion !== 'object' || + earlierVersion === null || + typeof updatedVersion !== 'object' || + updatedVersion === null + ) { + // If either version is not an object or is null, return null or throw an error + return {}; + } + + const updatedVersionCopy = cloneDeep(updatedVersion); + + // Overwrite the immutable keys in the updatedVersionCopy with the earlier values + immutableKeys.forEach((key) => { + set(updatedVersionCopy, key, get(earlierVersion, key)); + }); + + return updatedVersionCopy; +}; + +interface ProposalMarketChangesProps { + originalProposal: JsonValue; + latestEnactedProposal: JsonValue | undefined; + updatedProposal: JsonValue; +} + +export const ProposalMarketChanges = ({ + originalProposal, + latestEnactedProposal, + updatedProposal, +}: ProposalMarketChangesProps) => { + const { t } = useTranslation(); + const [showChanges, setShowChanges] = useState(false); + + return ( +
+ + + + + {showChanges && ( +
+ +
+ )} +
+ ); +}; diff --git a/apps/governance/src/routes/proposals/components/proposal/proposal.tsx b/apps/governance/src/routes/proposals/components/proposal/proposal.tsx index c87c9a89d..6b86800fb 100644 --- a/apps/governance/src/routes/proposals/components/proposal/proposal.tsx +++ b/apps/governance/src/routes/proposals/components/proposal/proposal.tsx @@ -17,6 +17,7 @@ import type { MarketInfoWithData } from '@vegaprotocol/markets'; import type { AssetQuery } from '@vegaprotocol/assets'; import { removePaginationWrapper } from '@vegaprotocol/utils'; import { ProposalState } from '@vegaprotocol/types'; +import { ProposalMarketChanges } from '../proposal-market-changes'; import type { NetworkParamsResult } from '@vegaprotocol/network-parameters'; export enum ProposalType { @@ -34,6 +35,10 @@ export interface ProposalProps { assetData?: AssetQuery | null; // eslint-disable-next-line @typescript-eslint/no-explicit-any restData: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + originalMarketProposalRestData?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mostRecentlyEnactedAssociatedMarketProposal?: any; } export const Proposal = ({ @@ -42,6 +47,8 @@ export const Proposal = ({ restData, newMarketData, assetData, + originalMarketProposalRestData, + mostRecentlyEnactedAssociatedMarketProposal, }: ProposalProps) => { const { t } = useTranslation(); @@ -154,6 +161,24 @@ export const Proposal = ({
)} + {proposal.terms.change.__typename === 'UpdateMarket' && ( +
+ +
+ )} + {(proposal.terms.change.__typename === 'NewAsset' || proposal.terms.change.__typename === 'UpdateAsset') && asset && ( diff --git a/apps/governance/src/routes/proposals/proposal/proposal-container.tsx b/apps/governance/src/routes/proposals/proposal/proposal-container.tsx index bfbd0ee30..f9986624d 100644 --- a/apps/governance/src/routes/proposals/proposal/proposal-container.tsx +++ b/apps/governance/src/routes/proposals/proposal/proposal-container.tsx @@ -1,5 +1,5 @@ import { AsyncRenderer } from '@vegaprotocol/ui-toolkit'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { Proposal } from '../components/proposal'; @@ -16,7 +16,12 @@ import { } from '@vegaprotocol/network-parameters'; export const ProposalContainer = () => { + const [ + mostRecentlyEnactedAssociatedMarketProposal, + setMostRecentlyEnactedAssociatedMarketProposal, + ] = useState(undefined); const params = useParams<{ proposalId: string }>(); + const { params: networkParams, loading: networkParamsLoading, @@ -37,9 +42,11 @@ export const ProposalContainer = () => { NetworkParams.governance_proposal_updateNetParam_requiredMajority, NetworkParams.governance_proposal_freeform_requiredMajority, ]); + const { - state: { data: restData }, + state: { data: restData, loading: restLoading, error: restError }, } = useFetch(`${ENV.rest}governance?proposalId=${params.proposalId}`); + const { data, loading, error, refetch } = useProposalQuery({ fetchPolicy: 'network-only', errorPolicy: 'ignore', @@ -47,6 +54,35 @@ export const ProposalContainer = () => { skip: !params.proposalId, }); + const { + state: { + data: originalMarketProposalRestData, + loading: originalMarketProposalRestLoading, + error: originalMarketProposalRestError, + }, + } = useFetch( + `${ENV.rest}governance?proposalId=${ + data?.proposal?.terms.change.__typename === 'UpdateMarket' && + data?.proposal.terms.change.marketId + }`, + undefined, + true, + data?.proposal?.terms.change.__typename !== 'UpdateMarket' + ); + + const { + state: { + data: previouslyEnactedMarketProposalsRestData, + loading: previouslyEnactedMarketProposalsRestLoading, + error: previouslyEnactedMarketProposalsRestError, + }, + } = useFetch( + `${ENV.rest}governances?proposalState=STATE_ENACTED&proposalType=TYPE_UPDATE_MARKET`, + undefined, + true, + data?.proposal?.terms.change.__typename !== 'UpdateMarket' + ); + const { data: newMarketData, loading: newMarketLoading, @@ -79,6 +115,39 @@ export const ProposalContainer = () => { ), }); + useEffect(() => { + if ( + previouslyEnactedMarketProposalsRestData && + data?.proposal?.terms.change.__typename === 'UpdateMarket' + ) { + const change = data?.proposal?.terms?.change as { marketId: string }; + + const filteredProposals = + // @ts-ignore rest data is not typed + previouslyEnactedMarketProposalsRestData.connection.edges.filter( + // @ts-ignore rest data is not typed + ({ node }) => + node?.proposal?.terms?.updateMarket?.marketId === change.marketId + ); + + const sortedProposals = filteredProposals.sort( + // @ts-ignore rest data is not typed + (a, b) => + new Date(a?.node?.terms?.enactmentTimestamp).getTime() - + new Date(b?.node?.terms?.enactmentTimestamp).getTime() + ); + + setMostRecentlyEnactedAssociatedMarketProposal( + sortedProposals[sortedProposals.length - 1] + ); + } + }, [ + previouslyEnactedMarketProposalsRestData, + params.proposalId, + data?.proposal?.terms.change.__typename, + data?.proposal?.terms.change, + ]); + useEffect(() => { const interval = setInterval(refetch, 2000); return () => clearInterval(interval); @@ -87,14 +156,39 @@ export const ProposalContainer = () => { return ( {data?.proposal ? ( @@ -104,6 +198,10 @@ export const ProposalContainer = () => { restData={restData} newMarketData={newMarketData} assetData={assetData} + originalMarketProposalRestData={originalMarketProposalRestData} + mostRecentlyEnactedAssociatedMarketProposal={ + mostRecentlyEnactedAssociatedMarketProposal + } /> ) : ( diff --git a/apps/governance/src/styles.css b/apps/governance/src/styles.css index 60b6b57fc..f6859f9f8 100644 --- a/apps/governance/src/styles.css +++ b/apps/governance/src/styles.css @@ -62,7 +62,7 @@ cursor: pointer; } -/* Styles required to (effectively) un-override the +/* Styles required to (effectively) un-override the * reset styles so that the Proposal description fields * render as you'd expect them to. * @@ -97,3 +97,33 @@ .react-markdown-container ul li { list-style: circle; } + +.jsondiffpatch-delta, +.jsondiffpatch-delta pre { + font-family: 'Roboto Mono', monospace !important; +} + +.jsondiffpatch-delta pre { + padding: 0 0.25em !important; +} + +.jsondiffpatch-added .jsondiffpatch-property-name, +.jsondiffpatch-added .jsondiffpatch-value pre, +.jsondiffpatch-modified .jsondiffpatch-right-value pre, +.jsondiffpatch-textdiff-added { + background: theme(colors.vega.green[650]) !important; + color: theme(colors.white) !important; +} + +.jsondiffpatch-deleted .jsondiffpatch-property-name, +.jsondiffpatch-deleted pre, +.jsondiffpatch-modified .jsondiffpatch-left-value pre, +.jsondiffpatch-textdiff-deleted { + background: theme(colors.vega.pink[650]) !important; + color: theme(colors.white) !important; +} + +.jsondiffpatch-moved .jsondiffpatch-moved-destination { + background: theme(colors.vega.yellow[350]) !important; + color: theme(colors.vega.dark[200]) !important; +} diff --git a/libs/react-helpers/src/hooks/use-fetch.ts b/libs/react-helpers/src/hooks/use-fetch.ts index 8c5d92fd4..c410b7548 100644 --- a/libs/react-helpers/src/hooks/use-fetch.ts +++ b/libs/react-helpers/src/hooks/use-fetch.ts @@ -21,7 +21,8 @@ type Action = export const useFetch = ( url: string, options?: RequestInit, - initialFetch = true + initialFetch = true, + skip?: boolean ): { state: State; refetch: ( @@ -105,10 +106,10 @@ export const useFetch = ( useEffect(() => { cancelRequest.current = false; - if (initialFetch) { + if (initialFetch && !skip) { fetchCallback(); } - }, [fetchCallback, initialFetch, url]); + }, [fetchCallback, initialFetch, skip, url]); useEffect(() => { // Use the cleanup function for avoiding a possibly... diff --git a/package.json b/package.json index 72fd2c9d2..4daa23baa 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "immer": "^9.0.12", "iso8601-duration": "^2.1.1", "js-sha3": "^0.8.0", + "jsondiffpatch": "^0.4.1", "lodash": "^4.17.21", "next": "13.3.0", "pennant": "1.10.0", diff --git a/yarn.lock b/yarn.lock index cc03f3050..1f5cb9b59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11274,7 +11274,7 @@ chalk@^1.0.0, chalk@^1.1.3: strip-ansi "^3.0.0" supports-color "^2.0.0" -chalk@^2.0.0, chalk@^2.4.1: +chalk@^2.0.0, chalk@^2.3.0, chalk@^2.4.1: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -12967,6 +12967,11 @@ didyoumean@^1.2.2: resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== +diff-match-patch@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37" + integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== + diff-sequences@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327" @@ -17431,6 +17436,14 @@ jsonc-parser@3.2.0: resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.2.0.tgz#31ff3f4c2b9793f89c67212627c51c6394f88e76" integrity sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w== +jsondiffpatch@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/jsondiffpatch/-/jsondiffpatch-0.4.1.tgz#9fb085036767f03534ebd46dcd841df6070c5773" + integrity sha512-t0etAxTUk1w5MYdNOkZBZ8rvYYN5iL+2dHCCx/DpkFm/bW28M6y5nUS83D4XdZiHy35Fpaw6LBb+F88fHZnVCw== + dependencies: + chalk "^2.3.0" + diff-match-patch "^1.0.0" + jsonfile@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb"