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"