From ee0f5753e37d1ebd7db46732bde0a99355448898 Mon Sep 17 00:00:00 2001 From: jamaljsr Date: Wed, 13 May 2020 15:34:21 -0400 Subject: [PATCH 1/4] swaps: automatically poll for swap updates whenever there is one or more pending --- app/src/__stories__/LoopHistory.stories.tsx | 7 +-- .../components/loop/LoopHistory.spec.tsx | 9 +++- app/src/components/loop/LoopHistory.tsx | 15 +++--- app/src/components/loop/LoopTiles.tsx | 2 +- app/src/store/models/swap.ts | 7 +++ app/src/store/stores/buildSwapStore.ts | 1 + app/src/store/stores/swapStore.ts | 48 +++++++++++++++++++ app/src/util/tests/sampleData.ts | 2 +- 8 files changed, 73 insertions(+), 18 deletions(-) diff --git a/app/src/__stories__/LoopHistory.stories.tsx b/app/src/__stories__/LoopHistory.stories.tsx index 878c831c8..dfcaf904c 100644 --- a/app/src/__stories__/LoopHistory.stories.tsx +++ b/app/src/__stories__/LoopHistory.stories.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { useStore } from 'store'; import Tile from 'components/common/Tile'; import LoopHistory from 'components/loop/LoopHistory'; @@ -10,15 +9,13 @@ export default { }; export const Default = () => { - const { swapStore } = useStore(); - return ; + return ; }; export const InsideTile = () => { - const { swapStore } = useStore(); return ( - + ); }; diff --git a/app/src/__tests__/components/loop/LoopHistory.spec.tsx b/app/src/__tests__/components/loop/LoopHistory.spec.tsx index 2ceae2edc..8d21331cb 100644 --- a/app/src/__tests__/components/loop/LoopHistory.spec.tsx +++ b/app/src/__tests__/components/loop/LoopHistory.spec.tsx @@ -10,11 +10,16 @@ describe('LoopHistory component', () => { beforeEach(async () => { store = createStore(); await store.init(); + + // remove all but one swap to prevent `getByText` from + // complaining about multiple elements in tests + const swap = store.swapStore.recentSwaps[0]; + store.swapStore.swaps.clear(); + store.swapStore.swaps.set(swap.id, swap); }); const render = () => { - const swaps = store.swapStore.sortedSwaps.slice(0, 1); - return renderWithProviders(); + return renderWithProviders(, store); }; it('should display a successful swap', async () => { diff --git a/app/src/components/loop/LoopHistory.tsx b/app/src/components/loop/LoopHistory.tsx index d0091edec..d58ea2f46 100644 --- a/app/src/components/loop/LoopHistory.tsx +++ b/app/src/components/loop/LoopHistory.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { observer } from 'mobx-react-lite'; +import { useStore } from 'store'; import { Swap } from 'store/models'; import { Column, Row } from 'components/common/grid'; import StatusDot from 'components/common/StatusDot'; @@ -16,7 +17,7 @@ const Styled = { `, }; -const SwapDot: React.FC<{ swap: Swap }> = ({ swap }) => { +const SwapDot: React.FC<{ swap: Swap }> = observer(({ swap }) => { switch (swap.stateLabel) { case 'Success': return ; @@ -25,19 +26,15 @@ const SwapDot: React.FC<{ swap: Swap }> = ({ swap }) => { default: return ; } -}; - -interface Props { - swaps: Swap[]; -} +}); -const LoopHistory: React.FC = ({ swaps }) => { - const recentSwaps = swaps.slice(0, 2); +const LoopHistory: React.FC = () => { + const store = useStore(); const { RightColumn, SmallText } = Styled; return ( <> - {recentSwaps.map(swap => ( + {store.swapStore.recentSwaps.map(swap => ( diff --git a/app/src/components/loop/LoopTiles.tsx b/app/src/components/loop/LoopTiles.tsx index ed1304230..8859b4416 100644 --- a/app/src/components/loop/LoopTiles.tsx +++ b/app/src/components/loop/LoopTiles.tsx @@ -23,7 +23,7 @@ const LoopTiles: React.FC = () => { null}> - + diff --git a/app/src/store/models/swap.ts b/app/src/store/models/swap.ts index 3ab3b64f4..88cf42bca 100644 --- a/app/src/store/models/swap.ts +++ b/app/src/store/models/swap.ts @@ -13,6 +13,13 @@ export default class Swap { this.update(loopSwap); } + /** + * True when the state of this swap is not Success or Failed + */ + @computed get isPending() { + return this.state !== LOOP.SwapState.SUCCESS && this.state !== LOOP.SwapState.FAILED; + } + /** * The numeric swap type as a user friendly string */ diff --git a/app/src/store/stores/buildSwapStore.ts b/app/src/store/stores/buildSwapStore.ts index 327e40e6d..e7df6a68a 100644 --- a/app/src/store/stores/buildSwapStore.ts +++ b/app/src/store/stores/buildSwapStore.ts @@ -276,6 +276,7 @@ class BuildSwapStore { this._store.log.info('completed loop', toJS(res)); // hide the swap UI after it is complete this.cancel(); + this._store.swapStore.fetchSwaps(); } catch (error) { this.swapError = error; this._store.log.error(`failed to perform ${direction}`, error); diff --git a/app/src/store/stores/swapStore.ts b/app/src/store/stores/swapStore.ts index 57518bd7e..13d53994b 100644 --- a/app/src/store/stores/swapStore.ts +++ b/app/src/store/stores/swapStore.ts @@ -3,6 +3,7 @@ import { computed, observable, ObservableMap, + reaction, runInAction, toJS, values, @@ -16,8 +17,22 @@ export default class SwapStore { /** the collection of swaps */ @observable swaps: ObservableMap = observable.map(); + pollingInterval?: NodeJS.Timeout; + constructor(store: Store) { this._store = store; + + // automatically start & stop polling for swaps if there are any pending + reaction( + () => this.pendingSwaps.length, + (length: number) => { + if (length > 0) { + this.startPolling(); + } else { + this.stopPolling(); + } + }, + ); } /** @@ -29,6 +44,20 @@ export default class SwapStore { .sort((a, b) => b.initiationTime - a.initiationTime); } + /** + * an array of the two most recent swaps + */ + @computed get recentSwaps() { + return this.sortedSwaps.slice(0, 2); + } + + /** + * an array of swaps that are currently pending + */ + @computed get pendingSwaps() { + return this.sortedSwaps.filter(s => s.isPending); + } + /** * queries the Loop api to fetch the list of swaps and stores them * in the state @@ -58,4 +87,23 @@ export default class SwapStore { this._store.log.info('updated swapStore.swaps', toJS(this.swaps)); }); } + + @action.bound + startPolling() { + if (this.pollingInterval) this.stopPolling(); + this._store.log.info('start polling for swap updates'); + this.pollingInterval = setInterval(this.fetchSwaps, 1000); + } + + @action.bound + stopPolling() { + this._store.log.info('stop polling for swap updates'); + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = undefined; + this._store.log.info('polling stopped'); + } else { + this._store.log.info('polling was already stopped'); + } + } } diff --git a/app/src/util/tests/sampleData.ts b/app/src/util/tests/sampleData.ts index 55d1a2f00..5ac0f1f89 100644 --- a/app/src/util/tests/sampleData.ts +++ b/app/src/util/tests/sampleData.ts @@ -115,7 +115,7 @@ export const loopListSwaps: LOOP.ListSwapsResponse.AsObject = { id: `f4eb118383c2b09d8c7289ce21c25900cfb4545d46c47ed23a31ad2aa57ce83${i}`, idBytes: '9OsRg4PCsJ2MconOIcJZAM+0VF1GxH7SOjGtKqV86DU=', type: (i % 3) as LOOP.SwapStatus.AsObject['type'], - state: (i % 7) as LOOP.SwapStatus.AsObject['state'], + state: i % 2 ? LOOP.SwapState.SUCCESS : LOOP.SwapState.FAILED, initiationTime: 1586390353623905000 + i * 100000000000000, lastUpdateTime: 1586398369729857000, htlcAddress: 'bcrt1qzu4077erkr78k52yuf2rwkk6ayr6m3wtazdfz2qqmd7taa5vvy9s5d75gd', From 4610cc9d23fc53298207d2078a32fd3402689257 Mon Sep 17 00:00:00 2001 From: jamaljsr Date: Wed, 13 May 2020 22:41:16 -0400 Subject: [PATCH 2/4] swaps: create components to display the processing swaps --- app/package.json | 1 + .../__stories__/ProcessingSwaps.stories.tsx | 120 ++++++++++++++++++ app/src/components/loop/LoopHistory.tsx | 14 +- app/src/components/loop/LoopPage.tsx | 5 +- app/src/components/loop/SwapDot.tsx | 21 +++ .../components/loop/processing/FailedSwap.tsx | 56 ++++++++ .../loop/processing/ProcessingSwapRow.tsx | 34 +++++ .../loop/processing/ProcessingSwaps.tsx | 49 +++++++ .../components/loop/processing/SwapInfo.tsx | 45 +++++++ .../loop/processing/SwapProgress.tsx | 63 +++++++++ app/src/i18n/locales/en-US.json | 1 + app/src/store/models/swap.ts | 26 +++- app/src/store/stores/swapStore.ts | 25 +++- app/yarn.lock | 5 + 14 files changed, 442 insertions(+), 23 deletions(-) create mode 100644 app/src/__stories__/ProcessingSwaps.stories.tsx create mode 100644 app/src/components/loop/SwapDot.tsx create mode 100644 app/src/components/loop/processing/FailedSwap.tsx create mode 100644 app/src/components/loop/processing/ProcessingSwapRow.tsx create mode 100644 app/src/components/loop/processing/ProcessingSwaps.tsx create mode 100644 app/src/components/loop/processing/SwapInfo.tsx create mode 100644 app/src/components/loop/processing/SwapProgress.tsx diff --git a/app/package.json b/app/package.json index f1d8cfdce..f1aef493a 100644 --- a/app/package.json +++ b/app/package.json @@ -29,6 +29,7 @@ "lottie-web": "5.6.8", "mobx": "5.15.4", "mobx-react-lite": "2.0.6", + "mobx-utils": "5.5.7", "react": "^16.13.1", "react-dom": "^16.13.1", "react-i18next": "11.4.0", diff --git a/app/src/__stories__/ProcessingSwaps.stories.tsx b/app/src/__stories__/ProcessingSwaps.stories.tsx new file mode 100644 index 000000000..a13b60d84 --- /dev/null +++ b/app/src/__stories__/ProcessingSwaps.stories.tsx @@ -0,0 +1,120 @@ +import React, { useEffect } from 'react'; +import { observable } from 'mobx'; +import * as LOOP from 'types/generated/loop_pb'; +import { loopListSwaps } from 'util/tests/sampleData'; +import { useStore } from 'store'; +import { Swap } from 'store/models'; +import ProcessingSwaps from 'components/loop/processing/ProcessingSwaps'; + +const { LOOP_IN, LOOP_OUT } = LOOP.SwapType; +const { + INITIATED, + PREIMAGE_REVEALED, + HTLC_PUBLISHED, + SUCCESS, + INVOICE_SETTLED, + FAILED, +} = LOOP.SwapState; + +export default { + title: 'Components/Processing Swaps', + component: ProcessingSwaps, + parameters: { contained: true }, + decorators: [ + (StoryFn: any) => ( +
+ +
+ ), + ], +}; + +// the multiple variations of swap types and states +const swapProps = [ + [LOOP_IN, INITIATED], + [LOOP_IN, HTLC_PUBLISHED], + [LOOP_IN, INVOICE_SETTLED], + [LOOP_IN, SUCCESS], + [LOOP_IN, FAILED], + [LOOP_OUT, INITIATED], + [LOOP_OUT, PREIMAGE_REVEALED], + [LOOP_OUT, SUCCESS], + [LOOP_OUT, FAILED], +]; +// const mockSwap = loopListSwaps.swapsList[0]; +const mockSwap = (type: number, state: number, id?: string) => { + const swap = new Swap(loopListSwaps.swapsList[0]); + swap.id = `${id || ''}${swap.id}`; + swap.type = type; + swap.state = state; + swap.initiationTime = Date.now() * 1000 * 1000; + return swap; +}; +// create a list of swaps to use for stories +const createSwaps = () => { + return [...Array(9)] + .map((_, i) => mockSwap(swapProps[i][0], swapProps[i][1], `${i}`)) + .reduce((map, swap) => { + map.set(swap.id, swap); + return map; + }, observable.map()); +}; + +let timer: NodeJS.Timeout; +const delay = (timeout: number) => + new Promise(resolve => (timer = setTimeout(resolve, timeout))); + +export const AllSwapStates = () => { + const store = useStore(); + store.swapStore.stopAutoPolling(); + store.swapStore.swaps = createSwaps(); + return ; +}; + +export const LoopInProgress = () => { + const store = useStore(); + store.swapStore.stopAutoPolling(); + const swap = mockSwap(LOOP_IN, INITIATED); + store.swapStore.swaps = observable.map({ [swap.id]: swap }); + + useEffect(() => { + const startTransitions = async () => { + await delay(2000); + swap.state = HTLC_PUBLISHED; + await delay(2000); + swap.state = INVOICE_SETTLED; + await delay(2000); + swap.state = SUCCESS; + await delay(2000); + swap.initiationTime = 0; + }; + + startTransitions(); + return () => clearTimeout(timer); + }, []); + + return ; +}; + +export const LoopOutProgress = () => { + const store = useStore(); + store.swapStore.stopAutoPolling(); + const swap = mockSwap(LOOP_OUT, INITIATED); + store.swapStore.swaps = observable.map({ [swap.id]: swap }); + + useEffect(() => { + const startTransitions = async () => { + await delay(2000); + swap.state = PREIMAGE_REVEALED; + await delay(2000); + swap.state = SUCCESS; + await delay(2000); + swap.initiationTime = 0; + }; + + startTransitions(); + return () => clearTimeout(timer); + }, []); + + return ; +}; diff --git a/app/src/components/loop/LoopHistory.tsx b/app/src/components/loop/LoopHistory.tsx index d58ea2f46..09d207662 100644 --- a/app/src/components/loop/LoopHistory.tsx +++ b/app/src/components/loop/LoopHistory.tsx @@ -1,11 +1,10 @@ import React from 'react'; import { observer } from 'mobx-react-lite'; import { useStore } from 'store'; -import { Swap } from 'store/models'; import { Column, Row } from 'components/common/grid'; -import StatusDot from 'components/common/StatusDot'; import { SmallText } from 'components/common/text'; import { styled } from 'components/theme'; +import SwapDot from './SwapDot'; const Styled = { RightColumn: styled(Column)` @@ -17,17 +16,6 @@ const Styled = { `, }; -const SwapDot: React.FC<{ swap: Swap }> = observer(({ swap }) => { - switch (swap.stateLabel) { - case 'Success': - return ; - case 'Failed': - return ; - default: - return ; - } -}); - const LoopHistory: React.FC = () => { const store = useStore(); diff --git a/app/src/components/loop/LoopPage.tsx b/app/src/components/loop/LoopPage.tsx index a0fea1f37..63a2209a6 100644 --- a/app/src/components/loop/LoopPage.tsx +++ b/app/src/components/loop/LoopPage.tsx @@ -7,6 +7,7 @@ import { styled } from 'components/theme'; import ChannelList from './ChannelList'; import LoopActions from './LoopActions'; import LoopTiles from './LoopTiles'; +import ProcessingSwaps from './processing/ProcessingSwaps'; import SwapWizard from './swap/SwapWizard'; const Styled = { @@ -23,7 +24,9 @@ const LoopPage: React.FC = () => { const { PageWrap } = Styled; return ( - {build.showWizard ? ( + {store.swapStore.processingSwaps.length ? ( + + ) : build.showWizard ? ( ) : ( <> diff --git a/app/src/components/loop/SwapDot.tsx b/app/src/components/loop/SwapDot.tsx new file mode 100644 index 000000000..ffe23d622 --- /dev/null +++ b/app/src/components/loop/SwapDot.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Swap } from 'store/models'; +import StatusDot from 'components/common/StatusDot'; + +interface Props { + swap: Swap; +} + +const SwapDot: React.FC = ({ swap }) => { + switch (swap.stateLabel) { + case 'Success': + return ; + case 'Failed': + return ; + default: + return ; + } +}; + +export default observer(SwapDot); diff --git a/app/src/components/loop/processing/FailedSwap.tsx b/app/src/components/loop/processing/FailedSwap.tsx new file mode 100644 index 000000000..8663272a0 --- /dev/null +++ b/app/src/components/loop/processing/FailedSwap.tsx @@ -0,0 +1,56 @@ +import React, { useCallback } from 'react'; +import { observer } from 'mobx-react-lite'; +import { useStore } from 'store'; +import { Swap } from 'store/models'; +import { Close } from 'components/common/icons'; +import { styled } from 'components/theme'; + +const Styled = { + Wrapper: styled.div` + height: 100%; + display: flex; + align-items: center; + `, + Circle: styled.span` + display: inline-block; + width: 34px; + height: 34px; + text-align: center; + line-height: 30px; + background-color: ${props => props.theme.colors.darkGray}; + border-radius: 34px; + margin-right: 10px; + cursor: pointer; + + &:hover { + opacity: 0.8; + } + `, + ErrorMessage: styled.span` + color: ${props => props.theme.colors.pink}; + `, +}; + +interface Props { + swap: Swap; +} + +const FailedSwap: React.FC = ({ swap }) => { + const { swapStore } = useStore(); + const handleCloseClick = useCallback(() => swapStore.dismissSwap(swap.id), [ + swapStore, + swap, + ]); + + const { Wrapper, Circle, ErrorMessage } = Styled; + return ( + + + + + {swap.stateLabel} + + ); +}; + +export default observer(FailedSwap); diff --git a/app/src/components/loop/processing/ProcessingSwapRow.tsx b/app/src/components/loop/processing/ProcessingSwapRow.tsx new file mode 100644 index 000000000..770c20c0a --- /dev/null +++ b/app/src/components/loop/processing/ProcessingSwapRow.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Swap } from 'store/models'; +import { Column, Row } from 'components/common/grid'; +import { styled } from 'components/theme'; +import FailedSwap from './FailedSwap'; +import SwapInfo from './SwapInfo'; +import SwapProgress from './SwapProgress'; + +const Styled = { + Row: styled(Row)` + margin-bottom: 10px; + `, +}; + +interface Props { + swap: Swap; +} + +const ProcessingSwapRow: React.FC = ({ swap }) => { + const { Row } = Styled; + return ( + + + + + + {swap.isFailed ? : } + + + ); +}; + +export default observer(ProcessingSwapRow); diff --git a/app/src/components/loop/processing/ProcessingSwaps.tsx b/app/src/components/loop/processing/ProcessingSwaps.tsx new file mode 100644 index 000000000..f8a5dfae6 --- /dev/null +++ b/app/src/components/loop/processing/ProcessingSwaps.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { usePrefixedTranslation } from 'hooks'; +import { useStore } from 'store'; +import { Title } from 'components/common/text'; +import { styled } from 'components/theme'; +import ProcessingSwapRow from './ProcessingSwapRow'; + +const Styled = { + Wrapper: styled.section` + display: flex; + flex-direction: column; + min-height: 360px; + padding: 40px; + background-color: ${props => props.theme.colors.darkBlue}; + border-radius: 35px; + box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.5); + `, + Header: styled.div` + display: flex; + justify-content: space-between; + margin-bottom: 20px; + `, + Content: styled.div` + display: flex; + flex-direction: column; + `, +}; + +const ProcessingSwaps: React.FC = () => { + const { l } = usePrefixedTranslation('cmps.loop.processing.ProcessingSwaps'); + const { swapStore } = useStore(); + + const { Wrapper, Header, Content } = Styled; + return ( + +
+ {l('title')} +
+ + {swapStore.processingSwaps.map(swap => ( + + ))} + +
+ ); +}; + +export default observer(ProcessingSwaps); diff --git a/app/src/components/loop/processing/SwapInfo.tsx b/app/src/components/loop/processing/SwapInfo.tsx new file mode 100644 index 000000000..28b2bb1fc --- /dev/null +++ b/app/src/components/loop/processing/SwapInfo.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Swap } from 'store/models'; +import { Title } from 'components/common/text'; +import { styled } from 'components/theme'; +import SwapDot from '../SwapDot'; + +const Styled = { + Wrapper: styled.div` + display: flex; + `, + Dot: styled.div` + display: flex; + justify-content: center; + align-items: center; + margin-right: 20px; + `, + Details: styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + `, +}; + +interface Props { + swap: Swap; +} + +const SwapInfo: React.FC = ({ swap }) => { + const { Wrapper, Dot, Details } = Styled; + return ( + + + + +
+ {swap.idEllipsed} +
{swap.amount.toLocaleString()} SAT
+
+
+ ); +}; + +export default observer(SwapInfo); diff --git a/app/src/components/loop/processing/SwapProgress.tsx b/app/src/components/loop/processing/SwapProgress.tsx new file mode 100644 index 000000000..5519cd025 --- /dev/null +++ b/app/src/components/loop/processing/SwapProgress.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { SwapState, SwapType } from 'types/generated/loop_pb'; +import { Swap } from 'store/models'; +import { styled } from 'components/theme'; + +const { LOOP_IN, LOOP_OUT } = SwapType; +const { + INITIATED, + PREIMAGE_REVEALED, + HTLC_PUBLISHED, + SUCCESS, + INVOICE_SETTLED, +} = SwapState; + +// the order of steps for each of the swap types. used to calculate +// the percentage of progress made based on the current swap state +const progressSteps: Record = { + [LOOP_IN]: [INITIATED, HTLC_PUBLISHED, INVOICE_SETTLED, SUCCESS], + [LOOP_OUT]: [INITIATED, PREIMAGE_REVEALED, SUCCESS], +}; + +const Styled = { + Wrapper: styled.div` + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + `, + Track: styled.div` + height: 3px; + background-color: #464d62; + border: 1px solid #5a6276; + border-radius: 2px; + `, + Fill: styled.div<{ state: number; pct: number }>` + height: 1px; + width: ${props => props.pct}%; + background-color: ${props => + props.state === SUCCESS ? props.theme.colors.green : props.theme.colors.orange}; + transition: all 1s; + `, +}; + +interface Props { + swap: Swap; +} + +const SwapProgress: React.FC = ({ swap }) => { + const steps = progressSteps[swap.type]; + const pct = Math.floor(((steps.indexOf(swap.state) + 1) / steps.length) * 100); + + const { Wrapper, Track, Fill } = Styled; + return ( + + + + + + ); +}; + +export default observer(SwapProgress); diff --git a/app/src/i18n/locales/en-US.json b/app/src/i18n/locales/en-US.json index 84bb1007e..161c7d849 100644 --- a/app/src/i18n/locales/en-US.json +++ b/app/src/i18n/locales/en-US.json @@ -9,6 +9,7 @@ "cmps.loop.LoopTiles.history": "Loop History", "cmps.loop.LoopTiles.inbound": "Total Inbound Liquidity", "cmps.loop.LoopTiles.outbound": "Total Outbound Liquidity", + "cmps.loop.processing.ProcessingSwaps.title": "Processing Loops", "cmps.loop.swap.StepButtons.cancel": "Cancel", "cmps.loop.swap.StepButtons.next": "Next", "cmps.loop.swap.StepButtons.confirm": "Confirm", diff --git a/app/src/store/models/swap.ts b/app/src/store/models/swap.ts index 88cf42bca..a0ce960b0 100644 --- a/app/src/store/models/swap.ts +++ b/app/src/store/models/swap.ts @@ -1,5 +1,7 @@ import { action, computed, observable } from 'mobx'; +import { now } from 'mobx-utils'; import * as LOOP from 'types/generated/loop_pb'; +import { ellipseInside } from 'util/strings'; export default class Swap { // native values from the Loop api @@ -13,11 +15,28 @@ export default class Swap { this.update(loopSwap); } + /** the first and last 6 chars of the swap id */ + @computed get idEllipsed() { + return ellipseInside(this.id); + } + + /** True if the swap's state is Failed */ + @computed get isFailed() { + return this.state === LOOP.SwapState.FAILED; + } + /** - * True when the state of this swap is not Success or Failed + * True when the state of this swap is not Success/Failed or the swap + * was completed less than 5 minutes ago */ - @computed get isPending() { - return this.state !== LOOP.SwapState.SUCCESS && this.state !== LOOP.SwapState.FAILED; + @computed get isProcessing() { + const fiveMinutes = 5 * 60 * 1000; + const recent = now() - this.createdOn.getTime() < fiveMinutes; + + const pending = + this.state !== LOOP.SwapState.SUCCESS && this.state !== LOOP.SwapState.FAILED; + + return recent || pending; } /** @@ -55,6 +74,7 @@ export default class Swap { return 'Unknown'; } + /** The date this swap was created as a JS Date object */ @computed get createdOn() { return new Date(this.initiationTime / 1000 / 1000); } diff --git a/app/src/store/stores/swapStore.ts b/app/src/store/stores/swapStore.ts index 13d53994b..de990cf41 100644 --- a/app/src/store/stores/swapStore.ts +++ b/app/src/store/stores/swapStore.ts @@ -1,6 +1,7 @@ import { action, computed, + IReactionDisposer, observable, ObservableMap, reaction, @@ -13,18 +14,23 @@ import { Swap } from '../models'; export default class SwapStore { private _store: Store; + /** a reference to the polling timer, needed to stop polling */ + pollingInterval?: NodeJS.Timeout; + /** the mobx disposer func to cancel automatic polling */ + stopAutoPolling: IReactionDisposer; /** the collection of swaps */ @observable swaps: ObservableMap = observable.map(); - pollingInterval?: NodeJS.Timeout; + /** the ids of failed swaps that have been dismissed */ + @observable dismissedSwapIds: string[] = []; constructor(store: Store) { this._store = store; // automatically start & stop polling for swaps if there are any pending - reaction( - () => this.pendingSwaps.length, + this.stopAutoPolling = reaction( + () => this.processingSwaps.length, (length: number) => { if (length > 0) { this.startPolling(); @@ -52,10 +58,17 @@ export default class SwapStore { } /** - * an array of swaps that are currently pending + * an array of swaps that are currently processing */ - @computed get pendingSwaps() { - return this.sortedSwaps.filter(s => s.isPending); + @computed get processingSwaps() { + return this.sortedSwaps.filter( + s => s.isProcessing || this.dismissedSwapIds.includes(s.id), + ); + } + + @action.bound + dismissSwap(swapId: string) { + this.dismissedSwapIds.push(swapId); } /** diff --git a/app/yarn.lock b/app/yarn.lock index 9ed2e321e..d3e2bf110 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -9715,6 +9715,11 @@ mobx-react-lite@2.0.6: resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-2.0.6.tgz#e1307a2b271c6a6016c8ad815a25014b7f95997d" integrity sha512-h/5GqxNIoSqnjt7SHxVtU7i1Kg0Xoxj853amzmzLgLRZKK9WwPc9tMuawW79ftmFSQhML0Zwt8kEuG1DIjQNBA== +mobx-utils@5.5.7: + version "5.5.7" + resolved "https://registry.yarnpkg.com/mobx-utils/-/mobx-utils-5.5.7.tgz#0ef58f2d5e05ca0e59ba2322f84f9c763de6ce14" + integrity sha512-jEtTe45gCXYtv3WTAyPiQUhQQRRDnx68WxgNn886i1B11ormsAey+gIJJkfh/cqSssBEWXcXwYTvODpGPN8Tgw== + mobx@5.15.4: version "5.15.4" resolved "https://registry.yarnpkg.com/mobx/-/mobx-5.15.4.tgz#9da1a84e97ba624622f4e55a0bf3300fb931c2ab" From 7b6e062f3a401517e98ff442f04b954fd9a4de05 Mon Sep 17 00:00:00 2001 From: jamaljsr Date: Thu, 14 May 2020 12:04:52 -0400 Subject: [PATCH 3/4] swaps: add components to display the swaps currently being processed --- app/src/__stories__/Tile.stories.tsx | 2 +- .../__tests__/components/common/Tile.spec.tsx | 4 +-- .../components/loop/LoopHistory.spec.tsx | 2 +- app/src/assets/icons/maximize.svg | 1 + app/src/assets/icons/minimize.svg | 1 + app/src/components/common/Tile.tsx | 23 +++++++----- app/src/components/common/icons.tsx | 2 ++ app/src/components/loop/LoopHistory.tsx | 2 +- app/src/components/loop/LoopPage.tsx | 7 ++-- app/src/components/loop/LoopTiles.tsx | 8 ++--- .../loop/processing/ProcessingSwapRow.tsx | 9 +++-- .../loop/processing/ProcessingSwaps.tsx | 16 +++++++-- app/src/components/theme.tsx | 2 ++ app/src/store/models/swap.ts | 21 +++++++---- app/src/store/store.ts | 19 +++++----- app/src/store/stores/buildSwapStore.ts | 1 + app/src/store/stores/index.ts | 1 + app/src/store/stores/swapStore.ts | 35 ++++++++++++------- app/src/store/stores/uiStore.ts | 17 +++++++++ 19 files changed, 119 insertions(+), 54 deletions(-) create mode 100644 app/src/assets/icons/maximize.svg create mode 100644 app/src/assets/icons/minimize.svg create mode 100644 app/src/store/stores/uiStore.ts diff --git a/app/src/__stories__/Tile.stories.tsx b/app/src/__stories__/Tile.stories.tsx index a3e48fbaa..90972515d 100644 --- a/app/src/__stories__/Tile.stories.tsx +++ b/app/src/__stories__/Tile.stories.tsx @@ -17,7 +17,7 @@ export const WithChildren = () => ( ); export const WithArrowIcon = () => ( - action('ArrowIcon')}> + action('ArrowIcon')}> Sample Text ); diff --git a/app/src/__tests__/components/common/Tile.spec.tsx b/app/src/__tests__/components/common/Tile.spec.tsx index 6b36dd7e4..6fcf876b4 100644 --- a/app/src/__tests__/components/common/Tile.spec.tsx +++ b/app/src/__tests__/components/common/Tile.spec.tsx @@ -8,7 +8,7 @@ describe('Tile component', () => { const render = (text?: string, children?: ReactNode) => { const cmp = ( - + {children} ); @@ -32,7 +32,7 @@ describe('Tile component', () => { it('should handle the arrow click event', () => { const { getByText } = render(); - fireEvent.click(getByText('arrow-right.svg')); + fireEvent.click(getByText('maximize.svg')); expect(handleArrowClick).toBeCalled(); }); }); diff --git a/app/src/__tests__/components/loop/LoopHistory.spec.tsx b/app/src/__tests__/components/loop/LoopHistory.spec.tsx index 8d21331cb..b5c7644dc 100644 --- a/app/src/__tests__/components/loop/LoopHistory.spec.tsx +++ b/app/src/__tests__/components/loop/LoopHistory.spec.tsx @@ -13,7 +13,7 @@ describe('LoopHistory component', () => { // remove all but one swap to prevent `getByText` from // complaining about multiple elements in tests - const swap = store.swapStore.recentSwaps[0]; + const swap = store.swapStore.sortedSwaps[0]; store.swapStore.swaps.clear(); store.swapStore.swaps.set(swap.id, swap); }); diff --git a/app/src/assets/icons/maximize.svg b/app/src/assets/icons/maximize.svg new file mode 100644 index 000000000..e41fc0b73 --- /dev/null +++ b/app/src/assets/icons/maximize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/assets/icons/minimize.svg b/app/src/assets/icons/minimize.svg new file mode 100644 index 000000000..a720fa6c3 --- /dev/null +++ b/app/src/assets/icons/minimize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/components/common/Tile.tsx b/app/src/components/common/Tile.tsx index f3ffcd3af..306e9f0e1 100644 --- a/app/src/components/common/Tile.tsx +++ b/app/src/components/common/Tile.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { styled } from 'components/theme'; -import { ArrowRight } from './icons'; +import { Maximize } from './icons'; import { Title } from './text'; const Styled = { @@ -14,10 +14,17 @@ const Styled = { display: flex; justify-content: space-between; `, - ArrowIcon: styled(ArrowRight)` - width: 16px; + MaximizeIcon: styled(Maximize)` + width: 20px; + height: 20px; + padding: 4px; margin-top: -5px; cursor: pointer; + + &:hover { + border-radius: 24px; + background-color: ${props => props.theme.colors.purple}; + } `, Text: styled.div` font-size: ${props => props.theme.sizes.xl}; @@ -38,20 +45,20 @@ interface Props { */ text?: string; /** - * optional click handler for the arrow which will not be + * optional click handler for the icon which will not be * visible if this prop is not defined */ - onArrowClick?: () => void; + onMaximizeClick?: () => void; } -const Tile: React.FC = ({ title, text, onArrowClick, children }) => { - const { TileWrap, Header, ArrowIcon, Text } = Styled; +const Tile: React.FC = ({ title, text, onMaximizeClick, children }) => { + const { TileWrap, Header, MaximizeIcon, Text } = Styled; return (
{title} - {onArrowClick && } + {onMaximizeClick && }
{text ? {text} : children}
diff --git a/app/src/components/common/icons.tsx b/app/src/components/common/icons.tsx index fc9c7a195..78489b00c 100644 --- a/app/src/components/common/icons.tsx +++ b/app/src/components/common/icons.tsx @@ -7,4 +7,6 @@ export { ReactComponent as Chevrons } from 'assets/icons/chevrons.svg'; export { ReactComponent as Close } from 'assets/icons/close.svg'; export { ReactComponent as Dot } from 'assets/icons/dot.svg'; export { ReactComponent as Menu } from 'assets/icons/menu.svg'; +export { ReactComponent as Minimize } from 'assets/icons/minimize.svg'; +export { ReactComponent as Maximize } from 'assets/icons/maximize.svg'; export { ReactComponent as Refresh } from 'assets/icons/refresh-cw.svg'; diff --git a/app/src/components/loop/LoopHistory.tsx b/app/src/components/loop/LoopHistory.tsx index 09d207662..b1cb79d0e 100644 --- a/app/src/components/loop/LoopHistory.tsx +++ b/app/src/components/loop/LoopHistory.tsx @@ -22,7 +22,7 @@ const LoopHistory: React.FC = () => { const { RightColumn, SmallText } = Styled; return ( <> - {store.swapStore.recentSwaps.map(swap => ( + {store.swapStore.lastTwoSwaps.map(swap => ( diff --git a/app/src/components/loop/LoopPage.tsx b/app/src/components/loop/LoopPage.tsx index 63a2209a6..1dcfb526c 100644 --- a/app/src/components/loop/LoopPage.tsx +++ b/app/src/components/loop/LoopPage.tsx @@ -18,15 +18,14 @@ const Styled = { const LoopPage: React.FC = () => { const { l } = usePrefixedTranslation('cmps.loop.LoopPage'); - const store = useStore(); - const build = store.buildSwapStore; + const { uiStore, buildSwapStore } = useStore(); const { PageWrap } = Styled; return ( - {store.swapStore.processingSwaps.length ? ( + {uiStore.processingSwapsVisible ? ( - ) : build.showWizard ? ( + ) : buildSwapStore.showWizard ? ( ) : ( <> diff --git a/app/src/components/loop/LoopTiles.tsx b/app/src/components/loop/LoopTiles.tsx index 8859b4416..243401bb9 100644 --- a/app/src/components/loop/LoopTiles.tsx +++ b/app/src/components/loop/LoopTiles.tsx @@ -15,27 +15,27 @@ const Styled = { const LoopTiles: React.FC = () => { const { l } = usePrefixedTranslation('cmps.loop.LoopTiles'); - const store = useStore(); + const { channelStore, uiStore } = useStore(); const { TileSection } = Styled; return ( - null}> + diff --git a/app/src/components/loop/processing/ProcessingSwapRow.tsx b/app/src/components/loop/processing/ProcessingSwapRow.tsx index 770c20c0a..626667bee 100644 --- a/app/src/components/loop/processing/ProcessingSwapRow.tsx +++ b/app/src/components/loop/processing/ProcessingSwapRow.tsx @@ -11,6 +11,9 @@ const Styled = { Row: styled(Row)` margin-bottom: 10px; `, + InfoCol: styled(Column)` + min-width: 200px; + `, }; interface Props { @@ -18,12 +21,12 @@ interface Props { } const ProcessingSwapRow: React.FC = ({ swap }) => { - const { Row } = Styled; + const { Row, InfoCol } = Styled; return ( - + - + {swap.isFailed ? : } diff --git a/app/src/components/loop/processing/ProcessingSwaps.tsx b/app/src/components/loop/processing/ProcessingSwaps.tsx index f8a5dfae6..e63d3352e 100644 --- a/app/src/components/loop/processing/ProcessingSwaps.tsx +++ b/app/src/components/loop/processing/ProcessingSwaps.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { observer } from 'mobx-react-lite'; import { usePrefixedTranslation } from 'hooks'; import { useStore } from 'store'; +import { Minimize } from 'components/common/icons'; import { Title } from 'components/common/text'; import { styled } from 'components/theme'; import ProcessingSwapRow from './ProcessingSwapRow'; @@ -21,6 +22,16 @@ const Styled = { justify-content: space-between; margin-bottom: 20px; `, + MinimizeIcon: styled(Minimize)` + display: inline-block; + padding: 4px; + cursor: pointer; + + &:hover { + border-radius: 24px; + background-color: ${props => props.theme.colors.purple}; + } + `, Content: styled.div` display: flex; flex-direction: column; @@ -29,13 +40,14 @@ const Styled = { const ProcessingSwaps: React.FC = () => { const { l } = usePrefixedTranslation('cmps.loop.processing.ProcessingSwaps'); - const { swapStore } = useStore(); + const { swapStore, uiStore } = useStore(); - const { Wrapper, Header, Content } = Styled; + const { Wrapper, Header, MinimizeIcon, Content } = Styled; return (
{l('title')} +
{swapStore.processingSwaps.map(swap => ( diff --git a/app/src/components/theme.tsx b/app/src/components/theme.tsx index 4ae7d404f..390c53dcb 100644 --- a/app/src/components/theme.tsx +++ b/app/src/components/theme.tsx @@ -28,6 +28,7 @@ export interface Theme { green: string; orange: string; tileBack: string; + purple: string; }; } @@ -56,6 +57,7 @@ const theme: Theme = { green: '#46E80E', orange: '#f66b1c', tileBack: 'rgba(245,245,245,0.04)', + purple: '#57038d', }, }; diff --git a/app/src/store/models/swap.ts b/app/src/store/models/swap.ts index a0ce960b0..9cef42cfa 100644 --- a/app/src/store/models/swap.ts +++ b/app/src/store/models/swap.ts @@ -9,6 +9,7 @@ export default class Swap { @observable type = 0; @observable amount = 0; @observable initiationTime = 0; + @observable lastUpdateTime = 0; @observable state = 0; constructor(loopSwap: LOOP.SwapStatus.AsObject) { @@ -25,18 +26,18 @@ export default class Swap { return this.state === LOOP.SwapState.FAILED; } - /** - * True when the state of this swap is not Success/Failed or the swap - * was completed less than 5 minutes ago - */ - @computed get isProcessing() { + /** True if the swap */ + @computed get isRecent() { const fiveMinutes = 5 * 60 * 1000; - const recent = now() - this.createdOn.getTime() < fiveMinutes; + return now() - this.updatedOn.getTime() < fiveMinutes; + } + /** True when the state of this swap is not Success or Failed */ + @computed get isPending() { const pending = this.state !== LOOP.SwapState.SUCCESS && this.state !== LOOP.SwapState.FAILED; - return recent || pending; + return pending; } /** @@ -79,6 +80,11 @@ export default class Swap { return new Date(this.initiationTime / 1000 / 1000); } + /** The date this swap was last updated as a JS Date object */ + @computed get updatedOn() { + return new Date(this.lastUpdateTime / 1000 / 1000); + } + /** * Updates this swap model using data provided from the Loop GRPC api * @param loopSwap the swap data @@ -89,6 +95,7 @@ export default class Swap { this.type = loopSwap.type; this.amount = loopSwap.amt; this.initiationTime = loopSwap.initiationTime; + this.lastUpdateTime = loopSwap.lastUpdateTime; this.state = loopSwap.state; } } diff --git a/app/src/store/store.ts b/app/src/store/store.ts index 97b499f79..b6d64be5e 100644 --- a/app/src/store/store.ts +++ b/app/src/store/store.ts @@ -8,6 +8,7 @@ import { NodeStore, SettingsStore, SwapStore, + UiStore, } from './stores'; /** @@ -17,14 +18,12 @@ export class Store { // // Child Stores // - @observable buildSwapStore = new BuildSwapStore(this); - @observable channelStore = new ChannelStore(this); - @observable swapStore = new SwapStore(this); - @observable nodeStore = new NodeStore(this); - @observable settingsStore = new SettingsStore(this); - // a flag to indicate when the store has completed all of its - // API requests requested during initialization - @observable initialized = false; + buildSwapStore = new BuildSwapStore(this); + channelStore = new ChannelStore(this); + swapStore = new SwapStore(this); + nodeStore = new NodeStore(this); + settingsStore = new SettingsStore(this); + uiStore = new UiStore(this); /** the backend api services to be used by child stores */ api: { @@ -35,6 +34,10 @@ export class Store { /** the logger for actions to use when modifying state */ log: Logger; + // a flag to indicate when the store has completed all of its + // API requests requested during initialization + @observable initialized = false; + constructor(lnd: LndApi, loop: LoopApi, log: Logger) { this.api = { lnd, loop }; this.log = log; diff --git a/app/src/store/stores/buildSwapStore.ts b/app/src/store/stores/buildSwapStore.ts index e7df6a68a..4bc362e74 100644 --- a/app/src/store/stores/buildSwapStore.ts +++ b/app/src/store/stores/buildSwapStore.ts @@ -276,6 +276,7 @@ class BuildSwapStore { this._store.log.info('completed loop', toJS(res)); // hide the swap UI after it is complete this.cancel(); + this._store.uiStore.toggleProcessingSwaps(); this._store.swapStore.fetchSwaps(); } catch (error) { this.swapError = error; diff --git a/app/src/store/stores/index.ts b/app/src/store/stores/index.ts index 0ed1ca1db..d68799356 100644 --- a/app/src/store/stores/index.ts +++ b/app/src/store/stores/index.ts @@ -3,3 +3,4 @@ export { default as ChannelStore } from './channelStore'; export { default as NodeStore } from './nodeStore'; export { default as SettingsStore } from './settingsStore'; export { default as SwapStore } from './swapStore'; +export { default as UiStore } from './uiStore'; diff --git a/app/src/store/stores/swapStore.ts b/app/src/store/stores/swapStore.ts index de990cf41..a804ce7df 100644 --- a/app/src/store/stores/swapStore.ts +++ b/app/src/store/stores/swapStore.ts @@ -9,6 +9,7 @@ import { toJS, values, } from 'mobx'; +import { IS_PROD } from 'config'; import { Store } from 'store'; import { Swap } from '../models'; @@ -30,42 +31,49 @@ export default class SwapStore { // automatically start & stop polling for swaps if there are any pending this.stopAutoPolling = reaction( - () => this.processingSwaps.length, + () => this.pendingSwaps.length, (length: number) => { if (length > 0) { this.startPolling(); } else { this.stopPolling(); + // also update our channels and balances when the loop is complete + this._store.channelStore.fetchChannels(); + this._store.nodeStore.fetchBalances(); } }, ); } - /** - * an array of swaps sorted by created date descending - */ + /** swaps sorted by created date descending */ @computed get sortedSwaps() { return values(this.swaps) .slice() .sort((a, b) => b.initiationTime - a.initiationTime); } - /** - * an array of the two most recent swaps - */ - @computed get recentSwaps() { + /** the last two swaps */ + @computed get lastTwoSwaps() { return this.sortedSwaps.slice(0, 2); } - /** - * an array of swaps that are currently processing - */ + /** swaps that are currently processing or recently completed */ @computed get processingSwaps() { return this.sortedSwaps.filter( - s => s.isProcessing || this.dismissedSwapIds.includes(s.id), + s => s.isPending || (s.isRecent && !this.dismissedSwapIds.includes(s.id)), ); } + /** swaps that are currently pending */ + @computed get pendingSwaps() { + return this.sortedSwaps.filter(s => s.isPending); + } + + /** swaps that were completed in the past 5 minutes */ + @computed get recentSwaps() { + return this.sortedSwaps.filter(s => s.isRecent); + } + @action.bound dismissSwap(swapId: string) { this.dismissedSwapIds.push(swapId); @@ -105,7 +113,8 @@ export default class SwapStore { startPolling() { if (this.pollingInterval) this.stopPolling(); this._store.log.info('start polling for swap updates'); - this.pollingInterval = setInterval(this.fetchSwaps, 1000); + const ms = IS_PROD ? 60 * 1000 : 1000; + this.pollingInterval = setInterval(this.fetchSwaps, ms); } @action.bound diff --git a/app/src/store/stores/uiStore.ts b/app/src/store/stores/uiStore.ts new file mode 100644 index 000000000..5699f36c9 --- /dev/null +++ b/app/src/store/stores/uiStore.ts @@ -0,0 +1,17 @@ +import { action, observable } from 'mobx'; +import { Store } from 'store'; + +export default class UiStore { + private _store: Store; + + @observable processingSwapsVisible = false; + + constructor(store: Store) { + this._store = store; + } + + @action.bound + toggleProcessingSwaps() { + this.processingSwapsVisible = !this.processingSwapsVisible; + } +} From fb8a16c996b0ce9cf2cad81b5945c640e68383fd Mon Sep 17 00:00:00 2001 From: jamaljsr Date: Thu, 14 May 2020 16:21:15 -0400 Subject: [PATCH 4/4] test: add unit tests for processing swap updates --- .../__stories__/ProcessingSwaps.stories.tsx | 2 +- .../components/loop/ProcessingSwaps.spec.tsx | 133 ++++++++++++++++++ app/src/__tests__/store/swapStore.spec.ts | 34 +++++ app/src/store/stores/swapStore.ts | 9 +- 4 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 app/src/__tests__/components/loop/ProcessingSwaps.spec.tsx diff --git a/app/src/__stories__/ProcessingSwaps.stories.tsx b/app/src/__stories__/ProcessingSwaps.stories.tsx index a13b60d84..c52421493 100644 --- a/app/src/__stories__/ProcessingSwaps.stories.tsx +++ b/app/src/__stories__/ProcessingSwaps.stories.tsx @@ -47,7 +47,7 @@ const mockSwap = (type: number, state: number, id?: string) => { swap.id = `${id || ''}${swap.id}`; swap.type = type; swap.state = state; - swap.initiationTime = Date.now() * 1000 * 1000; + swap.lastUpdateTime = Date.now() * 1000 * 1000; return swap; }; // create a list of swaps to use for stories diff --git a/app/src/__tests__/components/loop/ProcessingSwaps.spec.tsx b/app/src/__tests__/components/loop/ProcessingSwaps.spec.tsx new file mode 100644 index 000000000..87f8b0534 --- /dev/null +++ b/app/src/__tests__/components/loop/ProcessingSwaps.spec.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import * as LOOP from 'types/generated/loop_pb'; +import { fireEvent } from '@testing-library/react'; +import { ellipseInside } from 'util/strings'; +import { renderWithProviders } from 'util/tests'; +import { loopListSwaps } from 'util/tests/sampleData'; +import { createStore, Store } from 'store'; +import { Swap } from 'store/models'; +import ProcessingSwaps from 'components/loop/processing/ProcessingSwaps'; + +const { LOOP_IN, LOOP_OUT } = LOOP.SwapType; +const { + INITIATED, + PREIMAGE_REVEALED, + HTLC_PUBLISHED, + SUCCESS, + INVOICE_SETTLED, + FAILED, +} = LOOP.SwapState; +const width = (el: any) => window.getComputedStyle(el).width; + +describe('ProcessingSwaps component', () => { + let store: Store; + + const addSwap = (type: number, state: number, id?: string) => { + const swap = new Swap(loopListSwaps.swapsList[0]); + swap.id = `${id || ''}${swap.id}`; + swap.type = type; + swap.state = state; + swap.lastUpdateTime = Date.now() * 1000 * 1000; + store.swapStore.swaps.set(swap.id, swap); + return swap; + }; + + beforeEach(async () => { + store = createStore(); + }); + + const render = () => { + return renderWithProviders(, store); + }; + + it('should display the title', async () => { + const { getByText } = render(); + expect(getByText('Processing Loops')).toBeInTheDocument(); + }); + + it('should display an INITIATED Loop In', () => { + const { getByText, getByTitle } = render(); + const swap = addSwap(LOOP_IN, INITIATED); + expect(getByText('dot.svg')).toHaveClass('warn'); + expect(getByText(ellipseInside(swap.id))).toBeInTheDocument(); + expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); + expect(width(getByTitle(swap.stateLabel))).toBe('25%'); + }); + + it('should display an HTLC_PUBLISHED Loop In', () => { + const { getByText, getByTitle } = render(); + const swap = addSwap(LOOP_IN, HTLC_PUBLISHED); + expect(getByText('dot.svg')).toHaveClass('warn'); + expect(getByText(ellipseInside(swap.id))).toBeInTheDocument(); + expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); + expect(width(getByTitle(swap.stateLabel))).toBe('50%'); + }); + + it('should display an INVOICE_SETTLED Loop In', () => { + const { getByText, getByTitle } = render(); + const swap = addSwap(LOOP_IN, INVOICE_SETTLED); + expect(getByText('dot.svg')).toHaveClass('warn'); + expect(getByText(ellipseInside(swap.id))).toBeInTheDocument(); + expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); + expect(width(getByTitle(swap.stateLabel))).toBe('75%'); + }); + + it('should display an SUCCESS Loop In', () => { + const { getByText, getByTitle } = render(); + const swap = addSwap(LOOP_IN, SUCCESS); + expect(getByText(ellipseInside(swap.id))).toBeInTheDocument(); + expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); + expect(width(getByTitle(swap.stateLabel))).toBe('100%'); + }); + + it('should display an FAILED Loop In', () => { + const { getByText } = render(); + const swap = addSwap(LOOP_IN, FAILED); + expect(getByText(ellipseInside(swap.id))).toBeInTheDocument(); + expect(getByText(swap.stateLabel)).toBeInTheDocument(); + expect(getByText('close.svg')).toBeInTheDocument(); + }); + + it('should display an INITIATED Loop Out', () => { + const { getByText, getByTitle } = render(); + const swap = addSwap(LOOP_OUT, INITIATED); + expect(getByText('dot.svg')).toHaveClass('warn'); + expect(getByText(ellipseInside(swap.id))).toBeInTheDocument(); + expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); + expect(width(getByTitle(swap.stateLabel))).toBe('33%'); + }); + + it('should display an PREIMAGE_REVEALED Loop Out', () => { + const { getByText, getByTitle } = render(); + const swap = addSwap(LOOP_OUT, PREIMAGE_REVEALED); + expect(getByText('dot.svg')).toHaveClass('warn'); + expect(getByText(ellipseInside(swap.id))).toBeInTheDocument(); + expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); + expect(width(getByTitle(swap.stateLabel))).toBe('66%'); + }); + + it('should display an SUCCESS Loop Out', () => { + const { getByText, getByTitle } = render(); + const swap = addSwap(LOOP_OUT, SUCCESS); + expect(getByText('dot.svg')).toHaveClass('success'); + expect(getByText(ellipseInside(swap.id))).toBeInTheDocument(); + expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); + expect(width(getByTitle(swap.stateLabel))).toBe('100%'); + }); + + it('should display an FAILED Loop Out', () => { + const { getByText } = render(); + const swap = addSwap(LOOP_OUT, FAILED); + expect(getByText(ellipseInside(swap.id))).toBeInTheDocument(); + expect(getByText(swap.stateLabel)).toBeInTheDocument(); + expect(getByText('close.svg')).toBeInTheDocument(); + }); + + it('should dismiss a failed Loop', () => { + const { getByText } = render(); + addSwap(LOOP_OUT, FAILED); + expect(store.swapStore.dismissedSwapIds).toHaveLength(0); + fireEvent.click(getByText('close.svg')); + expect(store.swapStore.dismissedSwapIds).toHaveLength(1); + }); +}); diff --git a/app/src/__tests__/store/swapStore.spec.ts b/app/src/__tests__/store/swapStore.spec.ts index d9dbd195d..294604a71 100644 --- a/app/src/__tests__/store/swapStore.spec.ts +++ b/app/src/__tests__/store/swapStore.spec.ts @@ -1,4 +1,5 @@ import * as LOOP from 'types/generated/loop_pb'; +import { waitFor } from '@testing-library/react'; import { loopListSwaps } from 'util/tests/sampleData'; import { createStore, SwapStore } from 'store'; @@ -55,4 +56,37 @@ describe('SwapStore', () => { swap.type = type; expect(swap.typeName).toEqual(label); }); + + it('should poll for swap updates', async () => { + await store.fetchSwaps(); + const swap = store.sortedSwaps[0]; + // create a pending swap to trigger auto-polling + swap.state = LOOP.SwapState.INITIATED; + expect(store.pendingSwaps).toHaveLength(1); + // wait for polling to start + await waitFor(() => { + expect(store.pollingInterval).toBeDefined(); + }); + // change the swap to complete + swap.state = LOOP.SwapState.SUCCESS; + expect(store.pendingSwaps).toHaveLength(0); + // confirm polling has stopped + await waitFor(() => { + expect(store.pollingInterval).toBeUndefined(); + }); + }); + + it('should handle startPolling when polling is already running', () => { + expect(store.pollingInterval).toBeUndefined(); + store.startPolling(); + expect(store.pollingInterval).toBeDefined(); + store.startPolling(); + expect(store.pollingInterval).toBeDefined(); + }); + + it('should handle stopPolling when polling is already stopped', () => { + expect(store.pollingInterval).toBeUndefined(); + store.stopPolling(); + expect(store.pollingInterval).toBeUndefined(); + }); }); diff --git a/app/src/store/stores/swapStore.ts b/app/src/store/stores/swapStore.ts index a804ce7df..d0caaa6ef 100644 --- a/app/src/store/stores/swapStore.ts +++ b/app/src/store/stores/swapStore.ts @@ -9,7 +9,7 @@ import { toJS, values, } from 'mobx'; -import { IS_PROD } from 'config'; +import { IS_PROD, IS_TEST } from 'config'; import { Store } from 'store'; import { Swap } from '../models'; @@ -69,11 +69,6 @@ export default class SwapStore { return this.sortedSwaps.filter(s => s.isPending); } - /** swaps that were completed in the past 5 minutes */ - @computed get recentSwaps() { - return this.sortedSwaps.filter(s => s.isRecent); - } - @action.bound dismissSwap(swapId: string) { this.dismissedSwapIds.push(swapId); @@ -113,7 +108,7 @@ export default class SwapStore { startPolling() { if (this.pollingInterval) this.stopPolling(); this._store.log.info('start polling for swap updates'); - const ms = IS_PROD ? 60 * 1000 : 1000; + const ms = IS_PROD ? 60 * 1000 : IS_TEST ? 100 : 1000; this.pollingInterval = setInterval(this.fetchSwaps, ms); }