diff --git a/apps/trading/components/market-banner/market-successor-proposal-banner.tsx b/apps/trading/components/market-banner/market-successor-proposal-banner.tsx index 9cf78f540..f2d140770 100644 --- a/apps/trading/components/market-banner/market-successor-proposal-banner.tsx +++ b/apps/trading/components/market-banner/market-successor-proposal-banner.tsx @@ -1,6 +1,9 @@ import { Fragment, useState } from 'react'; -import type { NewMarketSuccessorFieldsFragment } from '@vegaprotocol/proposals'; -import { useMarketViewProposals } from '@vegaprotocol/proposals'; +import { + marketViewProposalsDataProvider, + type NewMarketSuccessorFieldsFragment, +} from '@vegaprotocol/proposals'; + import { ExternalLink, Intent, @@ -9,17 +12,20 @@ import { import { t } from '@vegaprotocol/i18n'; import { DApp, TOKEN_PROPOSAL, useLinks } from '@vegaprotocol/environment'; import * as Types from '@vegaprotocol/types'; +import { useDataProvider } from '@vegaprotocol/data-provider'; export const MarketSuccessorProposalBanner = ({ marketId, }: { marketId?: string; }) => { - const proposals = useMarketViewProposals({ + const { data: proposals } = useDataProvider({ + dataProvider: marketViewProposalsDataProvider, skip: !marketId, - inState: Types.ProposalState.STATE_OPEN, - proposalType: Types.ProposalType.TYPE_NEW_MARKET, - typename: 'NewMarket', + variables: { + inState: Types.ProposalState.STATE_OPEN, + proposalType: Types.ProposalType.TYPE_NEW_MARKET, + }, }); const successors = diff --git a/apps/trading/components/market-banner/market-termination-banner.spec.tsx b/apps/trading/components/market-banner/market-termination-banner.spec.tsx index e7c806284..44bdb3966 100644 --- a/apps/trading/components/market-banner/market-termination-banner.spec.tsx +++ b/apps/trading/components/market-banner/market-termination-banner.spec.tsx @@ -20,10 +20,13 @@ const marketMock = { }, } as Market; -const proposalMock: MockedResponse = { +const passedProposalMock: MockedResponse = { request: { query: MarketViewProposalsDocument, - variables: { inState: Types.ProposalState.STATE_PASSED }, + variables: { + inState: Types.ProposalState.STATE_PASSED, + proposalType: Types.ProposalType.TYPE_UPDATE_MARKET_STATE, + }, }, result: { data: { @@ -31,7 +34,7 @@ const proposalMock: MockedResponse = { edges: [ { node: { - id: 'first-id', + id: '1', state: Types.ProposalState.STATE_PASSED, terms: { closingDatetime: '2023-09-27T11:48:18Z', @@ -56,7 +59,7 @@ const proposalMock: MockedResponse = { }, { node: { - id: 'second-id', + id: '2', state: Types.ProposalState.STATE_PASSED, terms: { closingDatetime: '2023-09-27T11:48:18Z', @@ -84,7 +87,99 @@ const proposalMock: MockedResponse = { }, }, }; -const mocks: MockedResponse[] = [proposalMock]; +const openProposalMock: MockedResponse = { + request: { + query: MarketViewProposalsDocument, + variables: { + inState: Types.ProposalState.STATE_OPEN, + proposalType: Types.ProposalType.TYPE_UPDATE_MARKET_STATE, + }, + }, + result: { + data: { + proposalsConnection: { + edges: [ + { + node: { + id: '3', + state: Types.ProposalState.STATE_OPEN, + terms: { + closingDatetime: '2023-09-27T11:48:18Z', + enactmentDatetime: '2023-10-01T11:48:18', + change: { + __typename: 'UpdateMarketState', + updateType: + Types.MarketUpdateType.MARKET_STATE_UPDATE_TYPE_TERMINATE, + price: '', + market: { + id: 'market-3', + tradableInstrument: { + instrument: { + name: 'Market three name', + code: 'Market three', + }, + }, + }, + }, + }, + }, + }, + { + node: { + id: '4', + state: Types.ProposalState.STATE_OPEN, + terms: { + closingDatetime: '2023-09-27T11:48:18Z', + enactmentDatetime: '2023-10-11T11:48:18', + change: { + __typename: 'UpdateMarketState', + updateType: + Types.MarketUpdateType.MARKET_STATE_UPDATE_TYPE_TERMINATE, + price: '', + market: { + id: 'market-3', + tradableInstrument: { + instrument: { + name: 'Market three name', + code: 'Market three', + }, + }, + }, + }, + }, + }, + }, + { + node: { + id: '5', + state: Types.ProposalState.STATE_OPEN, + terms: { + closingDatetime: '2023-09-27T11:48:18Z', + enactmentDatetime: '2023-10-01T11:48:18', + change: { + __typename: 'UpdateMarketState', + updateType: + Types.MarketUpdateType.MARKET_STATE_UPDATE_TYPE_TERMINATE, + price: '', + market: { + id: 'market-4', + tradableInstrument: { + instrument: { + name: 'Market four name', + code: 'Market four', + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + }, +}; +const mocks: MockedResponse[] = [passedProposalMock, openProposalMock]; describe('MarketTerminationBanner', () => { beforeAll(() => { @@ -108,4 +203,28 @@ describe('MarketTerminationBanner', () => { screen.getByTestId('termination-warning-banner-market-1') ).toBeInTheDocument(); }); + + it('should render link to proposals', async () => { + const { container } = render( + + + + ); + await waitFor(() => { + expect(container).not.toBeEmptyDOMElement(); + }); + expect(screen.getByText('View proposals')).toBeInTheDocument(); + }); + + it('should render link to proposal', async () => { + const { container } = render( + + + + ); + await waitFor(() => { + expect(container).not.toBeEmptyDOMElement(); + }); + expect(screen.getByText('View proposal')).toBeInTheDocument(); + }); }); diff --git a/apps/trading/components/market-banner/market-termination-banner.tsx b/apps/trading/components/market-banner/market-termination-banner.tsx index 82ea89cd3..7f08bd2de 100644 --- a/apps/trading/components/market-banner/market-termination-banner.tsx +++ b/apps/trading/components/market-banner/market-termination-banner.tsx @@ -1,12 +1,67 @@ +import type { ReactNode } from 'react'; import { useState } from 'react'; import { format, formatDuration, intervalToDuration } from 'date-fns'; -import { Intent, NotificationBanner } from '@vegaprotocol/ui-toolkit'; -import { useMarketViewProposals } from '@vegaprotocol/proposals'; +import { + ExternalLink, + Intent, + NotificationBanner, +} from '@vegaprotocol/ui-toolkit'; +import type { MarketViewProposalFieldsFragment } from '@vegaprotocol/proposals'; +import { marketViewProposalsDataProvider } from '@vegaprotocol/proposals'; import { t } from '@vegaprotocol/i18n'; import * as Types from '@vegaprotocol/types'; import type { Market } from '@vegaprotocol/markets'; import { getQuoteName } from '@vegaprotocol/markets'; import { addDecimalsFormatNumber } from '@vegaprotocol/utils'; +import sortBy from 'lodash/sortBy'; +import { + DApp, + TOKEN_PROPOSAL, + TOKEN_PROPOSALS, + useLinks, +} from '@vegaprotocol/environment'; +import { useDataProvider } from '@vegaprotocol/data-provider'; + +const filterProposals = ( + data: MarketViewProposalFieldsFragment[] | null, + marketId: string, + now: number +) => + sortBy( + (data || []).filter( + (item) => + item.terms.change.__typename === 'UpdateMarketState' && + item.terms.change.market.id === marketId && + item.terms.change.updateType === + Types.MarketUpdateType.MARKET_STATE_UPDATE_TYPE_TERMINATE && + item.terms.enactmentDatetime && + new Date(item.terms.enactmentDatetime).getTime() > now + ), + (item) => item.terms.enactmentDatetime + ); + +const getMessageVariables = (proposal: MarketViewProposalFieldsFragment) => { + const enactmentDatetime = new Date(proposal.terms.enactmentDatetime); + const date = format(enactmentDatetime, 'dd MMMM'); + const duration = formatDuration( + intervalToDuration({ + start: new Date(), + end: enactmentDatetime, + }), + { + format: ['days', 'hours'], + } + ); + const price = + proposal.terms.change.__typename === 'UpdateMarketState' + ? proposal.terms.change.price + : ''; + return { + date, + duration, + price, + }; +}; export const MarketTerminationBanner = ({ market, @@ -15,57 +70,55 @@ export const MarketTerminationBanner = ({ }) => { const [visible, setVisible] = useState(true); const skip = !market || !visible; - const proposalsData = useMarketViewProposals({ + const { data: passedProposalsData } = useDataProvider({ + dataProvider: marketViewProposalsDataProvider, skip, - inState: Types.ProposalState.STATE_PASSED, - typename: 'UpdateMarketState', + variables: { + inState: Types.ProposalState.STATE_PASSED, + proposalType: Types.ProposalType.TYPE_UPDATE_MARKET_STATE, + }, }); + const { data: openProposalsData } = useDataProvider({ + dataProvider: marketViewProposalsDataProvider, + skip, + variables: { + inState: Types.ProposalState.STATE_OPEN, + proposalType: Types.ProposalType.TYPE_UPDATE_MARKET_STATE, + }, + }); + + const governanceLink = useLinks(DApp.Governance); + if (!market) return null; - const marketFound = (proposalsData || []).find( - (item) => - item.terms.change.__typename === 'UpdateMarketState' && - item.terms.change.market.id === market.id && - item.terms.change.updateType === - Types.MarketUpdateType.MARKET_STATE_UPDATE_TYPE_TERMINATE && - item.state === Types.ProposalState.STATE_PASSED // subscription doesn't have state parameter - ); + const now = Date.now(); + const passedProposals = filterProposals(passedProposalsData, market.id, now); + const openProposals = filterProposals(openProposalsData, market.id, now); - const enactmentDatetime = new Date(marketFound?.terms.enactmentDatetime); - const name = - marketFound?.terms.change.__typename === 'UpdateMarketState' - ? marketFound.terms.change.market.tradableInstrument.instrument.code - : ''; + const name = market.tradableInstrument.instrument.code; + if (!passedProposals.length && !openProposals.length) { + return null; + } - if (name && enactmentDatetime.getTime() > Date.now()) { - const dayMonthDate = format(enactmentDatetime, 'dd MMMM'); - const duration = intervalToDuration({ - start: new Date(), - end: enactmentDatetime, - }); - const formattedDuration = formatDuration(duration, { - format: ['days', 'hours'], - }); - const price = - marketFound?.terms.change.__typename === 'UpdateMarketState' - ? marketFound.terms.change.price - : ''; - const assetSymbol = getQuoteName(market); - return ( - { - setVisible(false); - }} - data-testid={`termination-warning-banner-${market.id}`} - > + const assetSymbol = getQuoteName(market); + const proposalLink = + !passedProposals.length && openProposals[0]?.id + ? governanceLink(TOKEN_PROPOSAL.replace(':id', openProposals[0]?.id)) + : undefined; + const proposalsLink = + openProposals.length > 1 ? governanceLink(TOKEN_PROPOSALS) : undefined; + let content: ReactNode; + if (passedProposals.length) { + const { date, duration, price } = getMessageVariables(passedProposals[0]); + content = ( + <>
- {t('Trading on Market %s will stop on %s', [name, dayMonthDate])} + {t('Trading on Market %s will stop on %s', [name, date])}
{t( 'You will no longer be able to hold a position on this market when it closes in %s.', - [formattedDuration] + [duration] )}{' '} {price && assetSymbol && @@ -74,8 +127,57 @@ export const MarketTerminationBanner = ({ assetSymbol, ])}
-
+ + ); + } else if (openProposals.length > 1) { + content = ( + <> +
+ {t( + 'Trading on Market %s may stop. There are open proposals to close this market', + [name] + )} +
+
+ + {t('View proposals')} + +
+ + ); + } else { + const { date, price } = getMessageVariables(openProposals[0]); + content = ( + <> +
+ {t( + 'Trading on Market %s may stop on %s. There is open proposal to close this market.', + [name, date] + )} +
+
+ {price && + assetSymbol && + t('Proposed final price is %s %s.', [ + addDecimalsFormatNumber(price, market.decimalPlaces), + assetSymbol, + ])} +
+
+ {t('View proposal')} +
+ ); } - return null; + return ( + { + setVisible(false); + }} + data-testid={`termination-warning-banner-${market.id}`} + > + {content} + + ); }; diff --git a/libs/proposals/src/lib/proposals-data-provider/index.ts b/libs/proposals/src/lib/proposals-data-provider/index.ts index 0c6a2a3b8..8b38a7f84 100644 --- a/libs/proposals/src/lib/proposals-data-provider/index.ts +++ b/libs/proposals/src/lib/proposals-data-provider/index.ts @@ -1,2 +1,5 @@ -export * from './proposals-data-provider'; +export { + marketViewProposalsDataProvider, + proposalsDataProvider, +} from './proposals-data-provider'; export * from './__generated__/Proposals'; diff --git a/libs/proposals/src/lib/proposals-data-provider/proposals-data-provider.spec.tsx b/libs/proposals/src/lib/proposals-data-provider/proposals-data-provider.spec.tsx new file mode 100644 index 000000000..c06281bc5 --- /dev/null +++ b/libs/proposals/src/lib/proposals-data-provider/proposals-data-provider.spec.tsx @@ -0,0 +1,67 @@ +import { update } from './proposals-data-provider'; +import type { + MarketViewProposalFieldsFragment, + MarketViewProposalsQueryVariables, +} from './__generated__/Proposals'; +import * as Types from '@vegaprotocol/types'; + +describe('proposals data provider', () => { + const proposal = { + id: '1', + state: Types.ProposalState.STATE_OPEN, + terms: { + change: { + __typename: 'UpdateMarketState', + }, + }, + } as MarketViewProposalFieldsFragment; + const data = [proposal]; + const delta = { ...proposal }; + const reload = jest.fn(); + + const variables: MarketViewProposalsQueryVariables = { + inState: Types.ProposalState.STATE_OPEN, + proposalType: Types.ProposalType.TYPE_UPDATE_MARKET_STATE, + }; + + it('update existing data', () => { + expect(update(data, delta, reload, variables)?.[0]).toBe(delta); + }); + + it('removes existing data if delta do not match inState', () => { + expect( + update( + data, + { ...delta, state: Types.ProposalState.STATE_PASSED }, + reload, + variables + )?.length + ).toBe(0); + }); + + it('do not add delta if it do not match inState', () => { + expect( + update( + data, + { ...delta, id: '2', state: Types.ProposalState.STATE_PASSED }, + reload, + variables + )?.length + ).toBe(1); + }); + + it('do not add delta if it do not match proposalType', () => { + expect( + update( + data, + { + ...delta, + id: '2', + terms: { ...delta.terms, change: { __typename: 'UpdateMarket' } }, + }, + reload, + variables + )?.length + ).toBe(1); + }); +}); diff --git a/libs/proposals/src/lib/proposals-data-provider/proposals-data-provider.tsx b/libs/proposals/src/lib/proposals-data-provider/proposals-data-provider.tsx index 91bb0b846..31c13075e 100644 --- a/libs/proposals/src/lib/proposals-data-provider/proposals-data-provider.tsx +++ b/libs/proposals/src/lib/proposals-data-provider/proposals-data-provider.tsx @@ -1,5 +1,7 @@ +import type { Update } from '@vegaprotocol/data-provider'; import { makeDataProvider } from '@vegaprotocol/data-provider'; import produce from 'immer'; +import * as Types from '@vegaprotocol/types'; import type { ProposalsListQuery, ProposalsListQueryVariables, @@ -8,6 +10,7 @@ import type { MarketViewProposalFieldsFragment, MarketViewProposalsQuery, MarketViewProposalsQueryVariables, + MarketViewLiveProposalsSubscriptionVariables, } from './__generated__/Proposals'; import { MarketViewLiveProposalsDocument, @@ -42,21 +45,61 @@ export const proposalsDataProvider = makeDataProvider< errors.every((e) => e.message.match(/failed to get asset for ID/)), }); -const update = ( +const ProposalTypeMap: Record< + Types.ProposalType, + Types.ProposalChange['__typename'] +> = { + [Types.ProposalType.TYPE_CANCEL_TRANSFER]: 'CancelTransfer', + [Types.ProposalType.TYPE_NETWORK_PARAMETERS]: 'UpdateNetworkParameter', + [Types.ProposalType.TYPE_NEW_ASSET]: 'NewAsset', + [Types.ProposalType.TYPE_NEW_FREE_FORM]: 'NewFreeform', + [Types.ProposalType.TYPE_NEW_MARKET]: 'NewMarket', + [Types.ProposalType.TYPE_NEW_SPOT_MARKET]: 'NewSpotMarket', + [Types.ProposalType.TYPE_NEW_TRANSFER]: 'NewTransfer', + [Types.ProposalType.TYPE_UPDATE_ASSET]: 'UpdateAsset', + [Types.ProposalType.TYPE_UPDATE_MARKET]: 'UpdateMarket', + [Types.ProposalType.TYPE_UPDATE_MARKET_STATE]: 'UpdateMarketState', + [Types.ProposalType.TYPE_UPDATE_REFERRAL_PROGRAM]: 'UpdateReferralProgram', + [Types.ProposalType.TYPE_UPDATE_SPOT_MARKET]: 'UpdateSpotMarket', + [Types.ProposalType.TYPE_UPDATE_VOLUME_DISCOUNT_PROGRAM]: + 'UpdateVolumeDiscountProgram', +}; + +const matchFilter = ( + data: MarketViewProposalFieldsFragment, + variables: MarketViewProposalsQueryVariables +) => { + return ( + (!variables.inState || data.state === variables.inState) && + (!variables.proposalType || + data.terms.change.__typename === ProposalTypeMap[variables.proposalType]) + ); +}; + +export const update: Update< + MarketViewProposalFieldsFragment[] | null, + MarketViewProposalFieldsFragment, + MarketViewProposalsQueryVariables +> = ( data: MarketViewProposalFieldsFragment[] | null, - delta: MarketViewProposalFieldsFragment + delta: MarketViewProposalFieldsFragment, + reload, + variables ) => { const updateData = produce(data || [], (draft) => { const { id } = delta; const index = draft.findIndex((item) => item.id === id); + const match = matchFilter(delta, variables); if (index === -1) { - draft.unshift(delta); + if (match) { + draft.unshift(delta); + } } else { - const currNode = draft[index]; - draft[index] = { - ...currNode, - ...delta, - }; + if (match) { + draft[index] = delta; + } else { + draft.splice(index, 1); + } } }); return updateData; @@ -66,12 +109,15 @@ const getMarketProposalsData = ( responseData: MarketViewProposalsQuery | null ) => removePaginationWrapper(responseData?.proposalsConnection?.edges) || []; +const subscriptionVariables: MarketViewLiveProposalsSubscriptionVariables = {}; + export const marketViewProposalsDataProvider = makeDataProvider< MarketViewProposalsQuery, MarketViewProposalFieldsFragment[], MarketViewLiveProposalsSubscription, MarketViewProposalFieldsFragment, - MarketViewProposalsQueryVariables + MarketViewProposalsQueryVariables, + MarketViewLiveProposalsSubscriptionVariables >({ query: MarketViewProposalsDocument, subscriptionQuery: MarketViewLiveProposalsDocument, @@ -79,4 +125,5 @@ export const marketViewProposalsDataProvider = makeDataProvider< getDelta: (subscriptionData: MarketViewLiveProposalsSubscription) => subscriptionData.proposals, getData: getMarketProposalsData, + getSubscriptionVariables: () => subscriptionVariables, }); diff --git a/libs/proposals/src/lib/proposals-hooks/index.ts b/libs/proposals/src/lib/proposals-hooks/index.ts index 93afd080a..b890fae58 100644 --- a/libs/proposals/src/lib/proposals-hooks/index.ts +++ b/libs/proposals/src/lib/proposals-hooks/index.ts @@ -7,4 +7,3 @@ export * from './use-proposal-notification-toasts'; export * from './use-successor-market-proposal-details'; export * from './use-new-transfer-proposal-details'; export * from './use-cancel-transfer-proposal-details'; -export * from './use-market-view-proposals'; diff --git a/libs/proposals/src/lib/proposals-hooks/use-market-view-proposals.ts b/libs/proposals/src/lib/proposals-hooks/use-market-view-proposals.ts deleted file mode 100644 index b988add9d..000000000 --- a/libs/proposals/src/lib/proposals-hooks/use-market-view-proposals.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useDataProvider } from '@vegaprotocol/data-provider'; -import { marketViewProposalsDataProvider } from '../proposals-data-provider'; -import type * as Types from '@vegaprotocol/types'; - -export const useMarketViewProposals = ({ - skip = false, - typename, - proposalType, - inState, -}: { - typename: Types.ProposalChange['__typename']; - skip?: boolean; - inState: Types.ProposalState; - proposalType?: Types.ProposalType; -}) => { - const variables = { - inState, - ...(proposalType ? { proposalType } : null), - }; - - const { data } = useDataProvider({ - dataProvider: marketViewProposalsDataProvider, - skip, - variables, - }); - return (data || []).filter( - (item) => item.terms.change.__typename === typename - ); -}; diff --git a/libs/proposals/src/lib/proposals-hooks/use-update-proposal.ts b/libs/proposals/src/lib/proposals-hooks/use-update-proposal.ts index e2ed7ff42..a59f8438d 100644 --- a/libs/proposals/src/lib/proposals-hooks/use-update-proposal.ts +++ b/libs/proposals/src/lib/proposals-hooks/use-update-proposal.ts @@ -1,9 +1,9 @@ -import { proposalsDataProvider } from '..'; import * as Schema from '@vegaprotocol/types'; import { useDataProvider } from '@vegaprotocol/data-provider'; import { useMemo } from 'react'; import first from 'lodash/first'; -import type { ProposalListFieldsFragment } from '..'; +import { proposalsDataProvider } from '../proposals-data-provider'; +import type { ProposalListFieldsFragment } from '../proposals-data-provider'; type UseUpdateProposalProps = { id?: string;