diff --git a/apps/trading/pages/toasts-manager.tsx b/apps/trading/pages/toasts-manager.tsx index ffae3b287..e376bc6bd 100644 --- a/apps/trading/pages/toasts-manager.tsx +++ b/apps/trading/pages/toasts-manager.tsx @@ -5,7 +5,7 @@ import { ProgressBar, ToastsContainer, } from '@vegaprotocol/ui-toolkit'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useEthTransactionStore, useEthWithdrawApprovalsStore, @@ -44,6 +44,7 @@ import { useLinks, } from '@vegaprotocol/environment'; import { prepend0x } from '@vegaprotocol/smart-contracts'; +import { useUpdateNetworkParametersToasts } from '@vegaprotocol/governance'; const intentMap = { Default: Intent.Primary, @@ -137,6 +138,7 @@ const EthTransactionDetails = ({ tx }: { tx: EthStoredTxState }) => { }; export const ToastsManager = () => { + const updateNetworkParametersToasts = useUpdateNetworkParametersToasts(); const vegaTransactions = useVegaTransactionStore((state) => state.transactions.filter((transaction) => transaction?.dialogOpen) ); @@ -450,29 +452,20 @@ export const ToastsManager = () => { ...compact(vegaTransactions).map(fromVegaTransaction), ...compact(ethTransactions).map(fromEthTransaction), ...compact(withdrawApprovals).map(fromWithdrawalApproval), + ...updateNetworkParametersToasts, ], ['createdBy'] ); }, [ - fromEthTransaction, - fromVegaTransaction, - fromWithdrawalApproval, - ethTransactions, vegaTransactions, + fromVegaTransaction, + ethTransactions, + fromEthTransaction, withdrawApprovals, + fromWithdrawalApproval, + updateNetworkParametersToasts, ]); - useEffect( - () => - console.log([ - ...vegaTransactions, - ...ethTransactions, - ...withdrawApprovals, - ]), - [ethTransactions, vegaTransactions, withdrawApprovals] - ); - useEffect(() => console.log(toasts), [toasts]); - return ; }; diff --git a/libs/governance/src/lib/proposals-hooks/Proposal.graphql b/libs/governance/src/lib/proposals-hooks/Proposal.graphql index 4034f7863..1a559274e 100644 --- a/libs/governance/src/lib/proposals-hooks/Proposal.graphql +++ b/libs/governance/src/lib/proposals-hooks/Proposal.graphql @@ -16,3 +16,30 @@ subscription ProposalEvent($partyId: ID!) { } } } + +fragment UpdateNetworkParameterFields on Proposal { + id + state + datetime + terms { + enactmentDatetime + change { + ... on UpdateNetworkParameter { + networkParameter { + key + value + } + } + } + } +} + +subscription OnUpdateNetworkParameters { + busEvents(types: [Proposal], batchSize: 0) { + event { + ... on Proposal { + ...UpdateNetworkParameterFields + } + } + } +} diff --git a/libs/governance/src/lib/proposals-hooks/__generated__/Proposal.ts b/libs/governance/src/lib/proposals-hooks/__generated__/Proposal.ts index cf03e19e4..eeb1f6966 100644 --- a/libs/governance/src/lib/proposals-hooks/__generated__/Proposal.ts +++ b/libs/governance/src/lib/proposals-hooks/__generated__/Proposal.ts @@ -12,6 +12,13 @@ export type ProposalEventSubscriptionVariables = Types.Exact<{ export type ProposalEventSubscription = { __typename?: 'Subscription', busEvents?: Array<{ __typename?: 'BusEvent', type: Types.BusEventType, event: { __typename?: 'AccountEvent' } | { __typename?: 'Asset' } | { __typename?: 'AuctionEvent' } | { __typename?: 'Deposit' } | { __typename?: 'LiquidityProvision' } | { __typename?: 'LossSocialization' } | { __typename?: 'MarginLevels' } | { __typename?: 'Market' } | { __typename?: 'MarketData' } | { __typename?: 'MarketEvent' } | { __typename?: 'MarketTick' } | { __typename?: 'NodeSignature' } | { __typename?: 'OracleSpec' } | { __typename?: 'Order' } | { __typename?: 'Party' } | { __typename?: 'PositionResolution' } | { __typename?: 'Proposal', id?: string | null, reference: string, state: Types.ProposalState, rejectionReason?: Types.ProposalRejectionReason | null, errorDetails?: string | null } | { __typename?: 'RiskFactor' } | { __typename?: 'SettleDistressed' } | { __typename?: 'SettlePosition' } | { __typename?: 'TimeUpdate' } | { __typename?: 'Trade' } | { __typename?: 'TransactionResult' } | { __typename?: 'TransferResponses' } | { __typename?: 'Vote' } | { __typename?: 'Withdrawal' } }> | null }; +export type UpdateNetworkParameterFieldsFragment = { __typename?: 'Proposal', id?: string | null, state: Types.ProposalState, datetime: any, terms: { __typename?: 'ProposalTerms', enactmentDatetime?: any | null, change: { __typename?: 'NewAsset' } | { __typename?: 'NewFreeform' } | { __typename?: 'NewMarket' } | { __typename?: 'UpdateAsset' } | { __typename?: 'UpdateMarket' } | { __typename?: 'UpdateNetworkParameter', networkParameter: { __typename?: 'NetworkParameter', key: string, value: string } } } }; + +export type OnUpdateNetworkParametersSubscriptionVariables = Types.Exact<{ [key: string]: never; }>; + + +export type OnUpdateNetworkParametersSubscription = { __typename?: 'Subscription', busEvents?: Array<{ __typename?: 'BusEvent', event: { __typename?: 'AccountEvent' } | { __typename?: 'Asset' } | { __typename?: 'AuctionEvent' } | { __typename?: 'Deposit' } | { __typename?: 'LiquidityProvision' } | { __typename?: 'LossSocialization' } | { __typename?: 'MarginLevels' } | { __typename?: 'Market' } | { __typename?: 'MarketData' } | { __typename?: 'MarketEvent' } | { __typename?: 'MarketTick' } | { __typename?: 'NodeSignature' } | { __typename?: 'OracleSpec' } | { __typename?: 'Order' } | { __typename?: 'Party' } | { __typename?: 'PositionResolution' } | { __typename?: 'Proposal', id?: string | null, state: Types.ProposalState, datetime: any, terms: { __typename?: 'ProposalTerms', enactmentDatetime?: any | null, change: { __typename?: 'NewAsset' } | { __typename?: 'NewFreeform' } | { __typename?: 'NewMarket' } | { __typename?: 'UpdateAsset' } | { __typename?: 'UpdateMarket' } | { __typename?: 'UpdateNetworkParameter', networkParameter: { __typename?: 'NetworkParameter', key: string, value: string } } } } | { __typename?: 'RiskFactor' } | { __typename?: 'SettleDistressed' } | { __typename?: 'SettlePosition' } | { __typename?: 'TimeUpdate' } | { __typename?: 'Trade' } | { __typename?: 'TransactionResult' } | { __typename?: 'TransferResponses' } | { __typename?: 'Vote' } | { __typename?: 'Withdrawal' } }> | null }; + export const ProposalEventFieldsFragmentDoc = gql` fragment ProposalEventFields on Proposal { id @@ -21,6 +28,24 @@ export const ProposalEventFieldsFragmentDoc = gql` errorDetails } `; +export const UpdateNetworkParameterFieldsFragmentDoc = gql` + fragment UpdateNetworkParameterFields on Proposal { + id + state + datetime + terms { + enactmentDatetime + change { + ... on UpdateNetworkParameter { + networkParameter { + key + value + } + } + } + } +} + `; export const ProposalEventDocument = gql` subscription ProposalEvent($partyId: ID!) { busEvents(partyId: $partyId, batchSize: 0, types: [Proposal]) { @@ -55,4 +80,37 @@ export function useProposalEventSubscription(baseOptions: Apollo.SubscriptionHoo return Apollo.useSubscription(ProposalEventDocument, options); } export type ProposalEventSubscriptionHookResult = ReturnType; -export type ProposalEventSubscriptionResult = Apollo.SubscriptionResult; \ No newline at end of file +export type ProposalEventSubscriptionResult = Apollo.SubscriptionResult; +export const OnUpdateNetworkParametersDocument = gql` + subscription OnUpdateNetworkParameters { + busEvents(types: [TimeUpdate], batchSize: 0) { + event { + ... on Proposal { + ...UpdateNetworkParameterFields + } + } + } +} + ${UpdateNetworkParameterFieldsFragmentDoc}`; + +/** + * __useOnUpdateNetworkParametersSubscription__ + * + * To run a query within a React component, call `useOnUpdateNetworkParametersSubscription` and pass it any options that fit your needs. + * When your component renders, `useOnUpdateNetworkParametersSubscription` 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 subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useOnUpdateNetworkParametersSubscription({ + * variables: { + * }, + * }); + */ +export function useOnUpdateNetworkParametersSubscription(baseOptions?: Apollo.SubscriptionHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useSubscription(OnUpdateNetworkParametersDocument, options); + } +export type OnUpdateNetworkParametersSubscriptionHookResult = ReturnType; +export type OnUpdateNetworkParametersSubscriptionResult = Apollo.SubscriptionResult; \ No newline at end of file diff --git a/libs/governance/src/lib/proposals-hooks/index.ts b/libs/governance/src/lib/proposals-hooks/index.ts index cb5d72f3e..96bb36091 100644 --- a/libs/governance/src/lib/proposals-hooks/index.ts +++ b/libs/governance/src/lib/proposals-hooks/index.ts @@ -2,3 +2,4 @@ export * from './__generated__/Proposal'; export * from './use-proposal-event'; export * from './use-proposal-submit'; export * from './use-update-proposal'; +export * from './use-update-network-paramaters-toasts'; diff --git a/libs/governance/src/lib/proposals-hooks/use-update-network-paramaters-toasts.tsx b/libs/governance/src/lib/proposals-hooks/use-update-network-paramaters-toasts.tsx new file mode 100644 index 000000000..220d7dd96 --- /dev/null +++ b/libs/governance/src/lib/proposals-hooks/use-update-network-paramaters-toasts.tsx @@ -0,0 +1,103 @@ +import { DApp, TOKEN_PROPOSAL, useLinks } from '@vegaprotocol/environment'; +import { getDateTimeFormat, t } from '@vegaprotocol/react-helpers'; +import type { UpdateNetworkParameter } from '@vegaprotocol/types'; +import { ProposalStateMapping } from '@vegaprotocol/types'; +import { ProposalState } from '@vegaprotocol/types'; +import type { Toast } from '@vegaprotocol/ui-toolkit'; +import { useToasts } from '@vegaprotocol/ui-toolkit'; +import { ExternalLink, Intent } from '@vegaprotocol/ui-toolkit'; +import compact from 'lodash/compact'; +import { useCallback } from 'react'; +import type { UpdateNetworkParameterFieldsFragment } from './__generated__/Proposal'; +import { useOnUpdateNetworkParametersSubscription } from './__generated__/Proposal'; + +const CLOSE_AFTER = 5000; +type Proposal = UpdateNetworkParameterFieldsFragment; + +const UpdateNetworkParameterToastContent = ({ + proposal, +}: { + proposal: Proposal; +}) => { + const tokenLink = useLinks(DApp.Token); + const change = proposal.terms.change as UpdateNetworkParameter; + const title = t('Network change proposal %s').replace( + '%s', + ProposalStateMapping[proposal.state].toLowerCase() + ); + const enactment = Date.parse(proposal.terms.enactmentDatetime); + return ( +
+

{title}

+

+ ' + {t( + `Update ${change.networkParameter.key} to ${change.networkParameter.value}` + )} + ' +

+ {!isNaN(enactment) && ( +

+ {t('Enactment date:')} {getDateTimeFormat().format(enactment)} +

+ )} +

+ + {t('View proposal details')} + +

+
+ ); +}; + +export const useUpdateNetworkParametersToasts = (): Toast[] => { + const { proposalToasts, setToast, remove } = useToasts((store) => ({ + proposalToasts: store.toasts, + setToast: store.setToast, + remove: store.remove, + })); + + const fromProposal = useCallback( + (proposal: Proposal): Toast => { + const id = `update-network-param-proposal-${proposal.id}`; + return { + id: `update-network-param-proposal-${proposal.id}`, + intent: Intent.Warning, + render: () => ( + + ), + onClose: () => remove(id), + closeAfter: CLOSE_AFTER, + }; + }, + [remove] + ); + + useOnUpdateNetworkParametersSubscription({ + onData: (options) => { + const events = compact(options.data.data?.busEvents); + if (!events || events.length === 0) return; + const validProposals = events + .filter( + (ev) => + ev.event.__typename === 'Proposal' && + ev.event.terms.__typename === 'ProposalTerms' && + ev.event.terms.change.__typename === 'UpdateNetworkParameter' && + [ + ProposalState.STATE_DECLINED, + ProposalState.STATE_ENACTED, + ProposalState.STATE_OPEN, + ProposalState.STATE_PASSED, + ].includes(ev.event.state) + ) + .map((ev) => ev.event as Proposal); + if (validProposals.length < 5) { + validProposals.forEach((p) => setToast(fromProposal(p))); + } + }, + }); + + return proposalToasts; +}; diff --git a/libs/governance/src/lib/proposals-hooks/use-update-network-parameters-toasts.spec.tsx b/libs/governance/src/lib/proposals-hooks/use-update-network-parameters-toasts.spec.tsx new file mode 100644 index 000000000..42883eba0 --- /dev/null +++ b/libs/governance/src/lib/proposals-hooks/use-update-network-parameters-toasts.spec.tsx @@ -0,0 +1,132 @@ +import type { MockedResponse } from '@apollo/client/testing'; +import { MockedProvider } from '@apollo/client/testing'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { ProposalState } from '@vegaprotocol/types'; +import type { ReactNode } from 'react'; +import { useUpdateNetworkParametersToasts } from './use-update-network-paramaters-toasts'; +import type { + UpdateNetworkParameterFieldsFragment, + OnUpdateNetworkParametersSubscription, +} from './__generated__/Proposal'; +import { OnUpdateNetworkParametersDocument } from './__generated__/Proposal'; +import waitForNextTick from 'flush-promises'; +import { useToasts } from '@vegaprotocol/ui-toolkit'; + +const render = (mocks?: MockedResponse[]) => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(() => useUpdateNetworkParametersToasts(), { wrapper }); +}; + +const generateUpdateNetworkParametersProposal = ( + key: string, + value: string, + state: ProposalState = ProposalState.STATE_OPEN +): UpdateNetworkParameterFieldsFragment => ({ + __typename: 'Proposal', + id: Math.random().toString(), + datetime: Math.random().toString(), + state, + terms: { + __typename: 'ProposalTerms', + enactmentDatetime: '2022-12-09T14:40:38Z', + change: { + __typename: 'UpdateNetworkParameter', + networkParameter: { + __typename: 'NetworkParameter', + key, + value, + }, + }, + }, +}); + +const mockedWrongEvent: MockedResponse = + { + request: { + query: OnUpdateNetworkParametersDocument, + }, + result: { + data: { + __typename: 'Subscription', + busEvents: [ + { + __typename: 'BusEvent', + event: { + __typename: 'Asset', + }, + }, + ], + }, + }, + }; + +const mockedEmptyEvent: MockedResponse = + { + request: { + query: OnUpdateNetworkParametersDocument, + }, + result: { + data: { + __typename: 'Subscription', + busEvents: [], + }, + }, + }; + +const mockedEvent: MockedResponse = { + request: { + query: OnUpdateNetworkParametersDocument, + }, + result: { + data: { + __typename: 'Subscription', + busEvents: [ + { + __typename: 'BusEvent', + event: generateUpdateNetworkParametersProposal('abc.def', '123.456'), + }, + ], + }, + }, +}; + +const clear = () => { + const { result: clearer } = renderHook(() => + useToasts((store) => store.removeAll) + ); + act(() => clearer.current()); +}; + +describe('useUpdateNetworkParametersToasts', () => { + beforeEach(clear); + afterAll(clear); + + it('returns toast for update network parameters bus event', async () => { + const { waitForNextUpdate, result } = render([mockedEvent]); + await act(async () => { + waitForNextUpdate(); + await waitForNextTick(); + }); + expect(result.current.length).toBe(1); + }); + + it('does not return toast for empty event', async () => { + const { waitForNextUpdate, result } = render([mockedEmptyEvent]); + await act(async () => { + waitForNextUpdate(); + await waitForNextTick(); + }); + expect(result.current.length).toBe(0); + }); + + it('does not return toast for wrong event', async () => { + const { waitForNextUpdate, result } = render([mockedWrongEvent]); + await act(async () => { + waitForNextUpdate(); + await waitForNextTick(); + }); + expect(result.current.length).toBe(0); + }); +}); diff --git a/libs/ui-toolkit/src/components/toast/use-toasts.ts b/libs/ui-toolkit/src/components/toast/use-toasts.ts index ccb47b6ec..1741d25f2 100644 --- a/libs/ui-toolkit/src/components/toast/use-toasts.ts +++ b/libs/ui-toolkit/src/components/toast/use-toasts.ts @@ -17,7 +17,7 @@ type ToastsStore = { /** * Adds a new toast or updates if id already exists. */ - addOrUpdate: (toast: Toast) => void; + setToast: (toast: Toast) => void; /** * Closes a toast */ @@ -55,7 +55,7 @@ export const useToasts = create((set) => ({ toasts: [], add: (toast) => set(add(toast)), update: (id, toastData) => set(update(id, toastData)), - addOrUpdate: (toast: Toast) => + setToast: (toast: Toast) => set((store) => { if (store.toasts.find((t) => t.id === toast.id)) { return update(toast.id, toast)(store);