Skip to content

Commit 28c9a96

Browse files
authored
Merge pull request #75 from lightninglabs/tour
loop: add in-app tour of the Loop process
2 parents cf85207 + 32b5de1 commit 28c9a96

34 files changed

+960
-68
lines changed

app/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@
4444
"react-router": "5.2.0",
4545
"react-scripts": "3.4.1",
4646
"react-toastify": "6.0.6",
47-
"react-virtualized": "9.21.2"
47+
"react-virtualized": "9.21.2",
48+
"reactour": "1.18.0",
49+
"styled-components": "5.1.1"
4850
},
4951
"devDependencies": {
5052
"@storybook/addon-actions": "5.3.19",
@@ -68,6 +70,7 @@
6870
"@types/react-dom": "16.9.8",
6971
"@types/react-router": "5.1.8",
7072
"@types/react-virtualized": "9.21.10",
73+
"@types/reactour": "1.17.1",
7174
"@typescript-eslint/eslint-plugin": "3.5.0",
7275
"@typescript-eslint/parser": "3.5.0",
7376
"cross-env": "7.0.2",

app/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import './App.scss';
33
import { createStore, StoreProvider } from 'store';
44
import AlertContainer from 'components/common/AlertContainer';
55
import { ThemeProvider } from 'components/theme';
6+
import TourHost from 'components/tour/TourHost';
67
import Routes from './Routes';
78

89
const App = () => {
@@ -13,6 +14,7 @@ const App = () => {
1314
<ThemeProvider>
1415
<Routes />
1516
<AlertContainer />
17+
<TourHost />
1618
</ThemeProvider>
1719
</StoreProvider>
1820
);

app/src/__stories__/LoopPage.stories.tsx

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useEffect } from 'react';
2-
import { observable, ObservableMap, values } from 'mobx';
2+
import { observable } from 'mobx';
3+
import { lndListChannelsMany } from 'util/tests/sampleData';
34
import { useStore } from 'store';
45
import { Channel } from 'store/models';
56
import { Layout } from 'components/layout';
@@ -11,35 +12,23 @@ export default {
1112
parameters: { contained: true },
1213
};
1314

14-
const channelSubset = (channels: ObservableMap<string, Channel>) => {
15-
const few = values(channels)
16-
.slice(0, 20)
17-
.reduce((result, c) => {
18-
result[c.chanId] = c;
19-
return result;
20-
}, {} as Record<string, Channel>);
21-
return observable.map(few);
15+
export const Default = () => {
16+
return <LoopPage />;
2217
};
2318

24-
export const Default = () => {
25-
const { channelStore } = useStore();
19+
export const ManyChannels = () => {
20+
const store = useStore();
2621
useEffect(() => {
27-
// only use a small set of channels
28-
channelStore.channels = channelSubset(channelStore.channels);
29-
}, [channelStore]);
30-
22+
store.channelStore.channels = observable.map();
23+
lndListChannelsMany.channelsList.forEach(c => {
24+
const chan = new Channel(store, c);
25+
store.channelStore.channels.set(chan.chanId, chan);
26+
});
27+
});
3128
return <LoopPage />;
3229
};
3330

34-
export const ManyChannels = () => <LoopPage />;
35-
3631
export const InsideLayout = () => {
37-
const { channelStore } = useStore();
38-
useEffect(() => {
39-
// only use a small set of channels
40-
channelStore.channels = channelSubset(channelStore.channels);
41-
}, [channelStore]);
42-
4332
return (
4433
<Layout>
4534
<LoopPage />

app/src/__stories__/StoryWrapper.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ThemeProvider } from 'components/theme';
1111

1212
// mock the GRPC client to return sample data instead of making an actual request
1313
const grpc = {
14+
useSampleData: true,
1415
request: (methodDescriptor: any, opts: any, metadata: any) => {
1516
// fail any authenticated requests to simulate incorrect login attempts
1617
if (metadata && metadata.authorization) throw new AuthenticationError();

app/src/__tests__/components/loop/LoopPage.spec.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ describe('LoopPage component', () => {
7979
expect(getByText('download.svg')).toBeInTheDocument();
8080
});
8181

82+
it('should display the help icon', () => {
83+
const { getByText } = render();
84+
expect(getByText('help-circle.svg')).toBeInTheDocument();
85+
});
86+
8287
it('should export channels', () => {
8388
const { getByText } = render();
8489
fireEvent.click(getByText('download.svg'));
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import React, { Suspense } from 'react';
2+
import { fireEvent, waitFor } from '@testing-library/react';
3+
import { renderWithProviders } from 'util/tests';
4+
import { prefixTranslation } from 'util/translate';
5+
import { createStore, Store } from 'store';
6+
import { Layout } from 'components/layout/Layout';
7+
import LoopPage from 'components/loop/LoopPage';
8+
import TourHost from 'components/tour/TourHost';
9+
10+
describe('TourHost component', () => {
11+
let store: Store;
12+
13+
beforeEach(async () => {
14+
store = createStore();
15+
await store.fetchAllData();
16+
});
17+
18+
const firstLine = (text: string) => text.split('\n')[0];
19+
20+
const render = () => {
21+
const cmp = (
22+
<Suspense fallback={null}>
23+
<Layout>
24+
<LoopPage />
25+
</Layout>
26+
<TourHost />
27+
</Suspense>
28+
);
29+
return renderWithProviders(cmp, store);
30+
};
31+
32+
it('should open and dismiss the tour', () => {
33+
const { getByText, queryByText } = render();
34+
fireEvent.click(getByText('help-circle.svg'));
35+
expect(getByText('Welcome to Lightning Terminal!')).toBeInTheDocument();
36+
fireEvent.click(getByText('No Thanks'));
37+
expect(queryByText('Welcome to Lightning Terminal!')).not.toBeInTheDocument();
38+
});
39+
40+
it('should open the sidebar if it is collapsed', () => {
41+
const { getByText } = render();
42+
store.settingsStore.sidebarVisible = false;
43+
store.settingsStore.autoCollapse = true;
44+
45+
fireEvent.click(getByText('help-circle.svg'));
46+
expect(getByText('Welcome to Lightning Terminal!')).toBeInTheDocument();
47+
48+
fireEvent.click(getByText("Yes! Let's Go"));
49+
expect(store.settingsStore.sidebarVisible).toBe(true);
50+
51+
fireEvent.click(getByText('Next'));
52+
expect(store.settingsStore.sidebarVisible).toBe(false);
53+
});
54+
55+
it('should walk through the full tour', async () => {
56+
const { getByText } = render();
57+
const { l } = prefixTranslation('cmps.tour.TextStep');
58+
59+
fireEvent.click(getByText('help-circle.svg'));
60+
expect(getByText('Welcome to Lightning Terminal!')).toBeInTheDocument();
61+
62+
fireEvent.click(getByText("Yes! Let's Go"));
63+
expect(getByText(l('nodeStatus'))).toBeInTheDocument();
64+
65+
// sample data is fetch after step #1 and we need to wait for it
66+
await waitFor(() => expect(store.swapStore.sortedSwaps).toHaveLength(7));
67+
68+
fireEvent.click(getByText('Next'));
69+
expect(getByText(l('history'))).toBeInTheDocument();
70+
71+
fireEvent.click(getByText('Next'));
72+
expect(getByText(l('inbound'))).toBeInTheDocument();
73+
74+
fireEvent.click(getByText('Next'));
75+
expect(getByText(l('outbound'))).toBeInTheDocument();
76+
77+
fireEvent.click(getByText('Next'));
78+
expect(getByText(l('channelList'))).toBeInTheDocument();
79+
80+
fireEvent.click(getByText('Next'));
81+
expect(getByText(l('channelListReceive'))).toBeInTheDocument();
82+
83+
fireEvent.click(getByText('Next'));
84+
expect(getByText(l('channelListSend'))).toBeInTheDocument();
85+
86+
fireEvent.click(getByText('Next'));
87+
expect(getByText(l('channelListFee'))).toBeInTheDocument();
88+
89+
fireEvent.click(getByText('Next'));
90+
expect(getByText(l('channelListUptime'))).toBeInTheDocument();
91+
92+
fireEvent.click(getByText('Next'));
93+
expect(getByText(l('channelListPeer'))).toBeInTheDocument();
94+
95+
fireEvent.click(getByText('Next'));
96+
expect(getByText(l('channelListCapacity'))).toBeInTheDocument();
97+
98+
fireEvent.click(getByText('Next'));
99+
expect(getByText(l('export'))).toBeInTheDocument();
100+
101+
fireEvent.click(getByText('Next'));
102+
expect(getByText(firstLine(l('loop')))).toBeInTheDocument();
103+
104+
fireEvent.click(getByText('Loop', { selector: 'button' }));
105+
expect(getByText(l('loopActions'))).toBeInTheDocument();
106+
107+
fireEvent.click(getByText('Next'));
108+
expect(getByText(firstLine(l('channelListSelect')))).toBeInTheDocument();
109+
110+
fireEvent.click(getByText('Next'));
111+
expect(getByText(firstLine(l('loopOut')))).toBeInTheDocument();
112+
113+
fireEvent.click(getByText('Loop Out', { selector: 'button' }));
114+
expect(getByText(firstLine(l('loopAmount')))).toBeInTheDocument();
115+
116+
fireEvent.click(getByText('Next', { selector: 'button' }));
117+
expect(getByText(firstLine(l('loopReview')))).toBeInTheDocument();
118+
119+
fireEvent.click(getByText('Confirm', { selector: 'button' }));
120+
expect(getByText(firstLine(l('loopProgress')))).toBeInTheDocument();
121+
122+
await waitFor(() => {
123+
expect(getByText(firstLine(l('processingSwaps')))).toBeInTheDocument();
124+
});
125+
126+
fireEvent.click(getByText('Next'));
127+
expect(getByText(l('swapProgress'))).toBeInTheDocument();
128+
129+
fireEvent.click(getByText('Next'));
130+
expect(getByText(firstLine(l('swapMinimize')))).toBeInTheDocument();
131+
132+
fireEvent.click(getByText('minimize.svg'));
133+
expect(getByText('Congratulations!')).toBeInTheDocument();
134+
135+
fireEvent.click(getByText('Close'));
136+
expect(() => getByText('Congratulations!')).toThrow();
137+
});
138+
});

app/src/api/grpc.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,14 @@ import {
88
import { DEV_HOST } from 'config';
99
import { AuthenticationError } from 'util/errors';
1010
import { grpcLog as log } from 'util/log';
11+
import { sampleApiResponses } from 'util/tests/sampleData';
1112

1213
class GrpcClient {
14+
/**
15+
* Indicates if the API should return sample data instead of making real GRPC requests
16+
*/
17+
useSampleData = false;
18+
1319
/**
1420
* Executes a single GRPC request and returns a promise which will resolve with the response
1521
* @param methodDescriptor the GRPC method to call on the service
@@ -22,6 +28,15 @@ class GrpcClient {
2228
metadata?: Metadata.ConstructorArg,
2329
): Promise<TRes> {
2430
return new Promise((resolve, reject) => {
31+
if (this.useSampleData) {
32+
const endpoint = `${methodDescriptor.service.serviceName}.${methodDescriptor.methodName}`;
33+
const data = sampleApiResponses[endpoint] || {};
34+
// the calling function expects the return value to have a `toObject` function
35+
const response: any = { toObject: () => data };
36+
resolve(response);
37+
return;
38+
}
39+
2540
const method = `${methodDescriptor.methodName}`;
2641
log.debug(`${method} request`, request.toObject());
2742
grpc.unary(methodDescriptor, {
@@ -59,6 +74,8 @@ class GrpcClient {
5974
onMessage: (res: TRes) => void,
6075
metadata?: Metadata.ConstructorArg,
6176
) {
77+
if (this.useSampleData) return;
78+
6279
const method = `${methodDescriptor.methodName}`;
6380
const client = grpc.client(methodDescriptor, {
6481
host: DEV_HOST,

app/src/api/lnd.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ interface LndEvents {
1313
* An API wrapper to communicate with the LND node via GRPC
1414
*/
1515
class LndApi extends BaseApi<LndEvents> {
16-
private _grpc: GrpcClient;
16+
_grpc: GrpcClient;
1717

1818
constructor(grpc: GrpcClient) {
1919
super();

app/src/api/loop.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ interface LoopEvents {
1414
* An API wrapper to communicate with the Loop daemon via GRPC
1515
*/
1616
class LoopApi extends BaseApi<LoopEvents> {
17-
private _grpc: GrpcClient;
17+
_grpc: GrpcClient;
1818

1919
constructor(grpc: GrpcClient) {
2020
super();

app/src/assets/icons/help-circle.svg

Lines changed: 5 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)