Merge pull request #17 from lightninglabs/feat/loop-page

Add Loop page with the top tiles
This commit is contained in:
Jamal James 2020-04-24 12:33:06 -04:00 committed by GitHub
commit 492636c81f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 458 additions and 151 deletions

4
.vscode/launch.json vendored
View file

@ -8,9 +8,9 @@
"name": "Debug Tests",
"type": "node",
"request": "launch",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/react-scripts",
"runtimeExecutable": "${workspaceRoot}/app/node_modules/.bin/react-scripts",
"args": ["test", "--runInBand", "--no-cache", "--watchAll=false"],
"cwd": "${workspaceRoot}",
"cwd": "${workspaceRoot}/app/",
"protocol": "inspector",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",

View file

@ -7,7 +7,7 @@ import { createActions } from '../src/action';
import { Background } from '../src/components/common/base';
import { ThemeProvider } from '../src/components/theme';
import { Store, StoreProvider } from '../src/store';
import { sampleApiResponses } from '../src/util/sampleData';
import { sampleApiResponses } from '../src/util/tests/sampleData';
/**
* Create a store with dummy data to use for stories
@ -17,15 +17,14 @@ const store = new Store();
/**
* Create dummy actions to use for stories
*/
// mock the GRPC client to return sample data instead of making an actual request
const grpc = {
request: (methodDescriptor: any) => {
const endpoint = `${methodDescriptor.service.serviceName}.${methodDescriptor.methodName}`;
const data = sampleApiResponses[endpoint] || {};
// the calling function expects the return value to have a `toObject` function
const response: any = {
toObject: () => data,
};
const response: any = { toObject: () => data };
return Promise.resolve(response);
},
};
@ -33,16 +32,30 @@ const actions = createActions(store, grpc);
// execute actions to populate the store data with the sample API responses
actions.node.getBalances();
actions.swap.listSwaps();
/**
* add the mobx store to Storybook parameters so that stories can manipulate it
*/
addParameters({ store });
/**
* decorator function to wrap all stories with the necessary providers
*/
addDecorator(storyFn => (
addDecorator((storyFn, ctx) => (
<StoreProvider store={store} actions={actions}>
<ThemeProvider>
<Background>{storyFn()}</Background>
{/* modify the bg styles so it isn't too big in docs mode */}
<Background style={{ minHeight: 'inherit', height: '100%' }}>
{/* wrap the component in a centered div for small components */}
{ctx.parameters.centered ? (
<div style={{ width: 300, margin: 'auto', padding: '100px 0' }}>
{storyFn()}
</div>
) : (
storyFn()
)}
</Background>
</ThemeProvider>
</StoreProvider>
));

View file

@ -11,6 +11,7 @@
@import '../node_modules/bootstrap/scss/buttons';
@import '../node_modules/bootstrap/scss/tables';
@import '../node_modules/bootstrap/scss/nav';
@import '../node_modules/bootstrap/scss/grid';
@font-face {
font-family: 'OpenSans Light';

View file

@ -2,7 +2,7 @@ import React from 'react';
import './App.scss';
import { Store, StoreProvider } from 'store';
import { Layout } from 'components/layout';
import SamplePage from 'components/pages/SamplePage';
import LoopPage from 'components/loop/LoopPage';
import { ThemeProvider } from 'components/theme';
const App = () => {
@ -11,7 +11,7 @@ const App = () => {
<StoreProvider store={store}>
<ThemeProvider>
<Layout>
<SamplePage />
<LoopPage />
</Layout>
</ThemeProvider>
</StoreProvider>

View file

@ -1,7 +1,7 @@
import { ProtobufMessage } from '@improbable-eng/grpc-web/dist/typings/message';
import { UnaryMethodDefinition } from '@improbable-eng/grpc-web/dist/typings/service';
import { UnaryRpcOptions } from '@improbable-eng/grpc-web/dist/typings/unary';
import { sampleApiResponses } from 'util/sampleData';
import { sampleApiResponses } from 'util/tests/sampleData';
// mock grpc module
export const grpc = {

View file

@ -0,0 +1,27 @@
import React from 'react';
import { StoryContext } from '@storybook/addons';
import { Store } from 'store';
import Tile from 'components/common/Tile';
import LoopHistory from 'components/loop/LoopHistory';
export default {
title: 'Loop History',
component: LoopHistory,
parameters: { centered: true },
};
export const Default = (ctx: StoryContext) => {
// grab the store from the Storybook parameter defined in preview.tsx
const { swaps } = ctx.parameters.store as Store;
return <LoopHistory swaps={swaps} />;
};
export const InsideTile = (ctx: StoryContext) => {
// grab the store from the Storybook parameter defined in preview.tsx
const { swaps } = ctx.parameters.store as Store;
return (
<Tile title="Loop History">
<LoopHistory swaps={swaps} />
</Tile>
);
};

View file

@ -0,0 +1,9 @@
import React from 'react';
import LoopPage from 'components/loop/LoopPage';
export default {
title: 'Loop Page',
component: LoopPage,
};
export const Default = () => <LoopPage />;

View file

@ -1,9 +1,31 @@
import React from 'react';
import React, { useEffect } from 'react';
import { StoryContext } from '@storybook/addons';
import { Store } from 'store';
import NodeStatus from 'components/NodeStatus';
export default {
title: 'Node Status',
component: NodeStatus,
parameters: { centered: true },
};
export const Default = () => <NodeStatus />;
export const Default = (ctx: StoryContext) => {
useEffect(() => {
// grab the store from the Storybook parameter defined in preview.tsx
const store = ctx.parameters.store as Store;
const { channelBalance, walletBalance } = store.balances || {
channelBalance: 0,
walletBalance: 0,
};
store.balances = { channelBalance: 0, walletBalance: 0 };
// change back to sample data when the component is unmounted
return () => {
store.balances = { channelBalance, walletBalance };
};
}, []);
return <NodeStatus />;
};
export const WithBalances = () => <NodeStatus />;

View file

@ -0,0 +1,27 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import Tile from 'components/common/Tile';
export default {
title: 'Tile',
component: Tile,
parameters: { centered: true },
};
export const Empty = () => <Tile title="Empty Tile" />;
export const WithText = () => <Tile title="Tile With Text" text="Sample Text" />;
export const WithChildren = () => (
<Tile title="Tile With Children">Sample child content</Tile>
);
export const WithArrowIcon = () => (
<Tile title="Tile With Arrow" onArrowClick={() => action('ArrowIcon')}>
Sample Text
</Tile>
);
export const InboundLiquidity = () => (
<Tile title="Inbound Liquidity" text="123,456,789 SAT" />
);

View file

@ -4,6 +4,6 @@ import App from '../App';
it('renders the App', () => {
const { getByText } = render(<App />);
const linkElement = getByText('Node Info');
const linkElement = getByText('Node Status');
expect(linkElement).toBeInTheDocument();
});

View file

@ -1,4 +1,4 @@
import { lndChannelBalance, lndWalletBalance } from 'util/sampleData';
import { lndChannelBalance, lndWalletBalance } from 'util/tests/sampleData';
import NodeAction from 'action/node';
import { GrpcClient, LndApi } from 'api';
import { Store } from 'store';

View file

@ -0,0 +1,32 @@
import { lndListChannels } from 'util/tests/sampleData';
import { createActions, StoreActions } from 'action';
import { Store } from 'store';
describe('SwapAction', () => {
let store: Store;
let actions: StoreActions;
beforeEach(() => {
store = new Store();
actions = createActions(store);
});
it('should compute inbound liquidity', async () => {
const inbound = lndListChannels.channelsList.reduce(
(sum, chan) => sum + chan.remoteBalance,
0,
);
await actions.channel.getChannels();
expect(store.totalInbound).toBe(inbound);
});
it('should compute outbound liquidity', async () => {
const outbound = lndListChannels.channelsList.reduce(
(sum, chan) => sum + chan.localBalance,
0,
);
await actions.channel.getChannels();
expect(store.totalOutbound).toBe(outbound);
});
});

View file

@ -0,0 +1,38 @@
import React, { ReactNode } from 'react';
import { fireEvent } from '@testing-library/react';
import { renderWithProviders } from 'util/tests';
import Tile from 'components/common/Tile';
describe('Tile component', () => {
const handleArrowClick = jest.fn();
const render = (text?: string, children?: ReactNode) => {
const cmp = (
<Tile title="Test Tile" text={text} onArrowClick={handleArrowClick}>
{children}
</Tile>
);
return renderWithProviders(cmp);
};
it('should display the title', () => {
const { getByText } = render();
expect(getByText('Test Tile')).toBeInTheDocument();
});
it('should display the text', () => {
const { getByText } = render('test text');
expect(getByText('test text')).toBeInTheDocument();
});
it('should display child components', () => {
const { getByText } = render(undefined, 'test child');
expect(getByText('test child')).toBeInTheDocument();
});
it('should handle the arrow click event', () => {
const { getByText } = render();
fireEvent.click(getByText('arrow-right.svg'));
expect(handleArrowClick).toBeCalled();
});
});

View file

@ -0,0 +1,43 @@
import React from 'react';
import { SwapStatus } from 'types/generated/loop_pb';
import { renderWithProviders } from 'util/tests';
import { loopListSwaps } from 'util/tests/sampleData';
import LoopPage from 'components/loop/LoopPage';
describe('LoopPage component', () => {
const render = () => {
return renderWithProviders(<LoopPage />);
};
it('should display the page title', () => {
const { getByText } = render();
expect(getByText('Lightning Loop')).toBeInTheDocument();
});
it('should display the three tiles', () => {
const { getByText } = render();
expect(getByText('Loop History')).toBeInTheDocument();
expect(getByText('Total Inbound Liquidity')).toBeInTheDocument();
expect(getByText('Total Outbound Liquidity')).toBeInTheDocument();
});
it('should display the liquidity numbers', async () => {
const { findByText } = render();
// these values are defined in sampleData.ts
expect(await findByText('4,501,409 SAT')).toBeInTheDocument();
expect(await findByText('9,988,660 SAT')).toBeInTheDocument();
});
it('should display the loop history records', async () => {
const { findByText } = render();
// convert from numeric timestamp to string (1586390353623905000 -> '4/15/2020')
const formatDate = (s: SwapStatus.AsObject) =>
new Date(s.initiationTime / 1000 / 1000).toLocaleDateString();
const [swap1, swap2] = loopListSwaps.swapsList;
expect(await findByText(formatDate(swap1))).toBeInTheDocument();
expect(await findByText('530,000 SAT')).toBeInTheDocument();
expect(await findByText(formatDate(swap2))).toBeInTheDocument();
expect(await findByText('525,000 SAT')).toBeInTheDocument();
});
});

View file

@ -29,7 +29,7 @@ class SwapAction {
.map(s => ({
id: s.id,
type: this._typeToString(s.type),
amount: BigInt(s.amt),
amount: s.amt,
createdOn: new Date(s.initiationTime / 1000 / 1000),
status: this._stateToString(s.state),
}));

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-right">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 274 B

View file

@ -0,0 +1,61 @@
import React from 'react';
import { styled } from 'components/theme';
import { ArrowRight } from './icons';
import { Title } from './text';
const Styled = {
TileWrap: styled.div`
min-height: 105px;
padding: 15px;
background-color: ${props => props.theme.colors.tileBack};
border-radius: 4px;
`,
Header: styled.div`
display: flex;
justify-content: space-between;
`,
ArrowIcon: styled(ArrowRight)`
width: 16px;
margin-top: -5px;
cursor: pointer;
`,
Text: styled.div`
font-size: ${props => props.theme.sizes.xl};
line-height: 37px;
letter-spacing: 0.43px;
margin-top: 10px;
`,
};
interface Props {
/**
* the title to display in the tile
*/
title: string;
/**
* optional text to display in the tile. if this is not
* provided, then the `children` will be displayed instead
*/
text?: string;
/**
* optional click handler for the arrow which will not be
* visible if this prop is not defined
*/
onArrowClick?: () => void;
}
const Tile: React.FC<Props> = ({ title, text, onArrowClick, children }) => {
const { TileWrap, Header, ArrowIcon, Text } = Styled;
return (
<TileWrap>
<Header>
<Title>{title}</Title>
{onArrowClick && <ArrowIcon title="arrow-right" onClick={onArrowClick} />}
</Header>
{text ? <Text>{text}</Text> : children}
</TileWrap>
);
};
export default Tile;

View file

@ -0,0 +1,18 @@
import React from 'react';
/**
* This component represents a Row in the bootstrap Grid layout
*/
export const Row: React.FC = ({ children }) => <div className="row">{children}</div>;
/**
* A column in the bootstrap Grid layout
* @param cols the number of columns wide (optional)
*/
export const Column: React.FC<{
cols?: number;
className?: string;
}> = ({ cols, children, className }) => {
const cls = (className || '') + (cols ? ` col-${cols}` : ' col');
return <div className={cls.trim()}>{children}</div>;
};

View file

@ -1,3 +1,4 @@
export { ReactComponent as Bolt } from 'assets/icons/bolt.svg';
export { ReactComponent as Bitcoin } from 'assets/icons/bitcoin.svg';
export { ReactComponent as Menu } from 'assets/icons/menu.svg';
export { ReactComponent as ArrowRight } from 'assets/icons/arrow-right.svg';

View file

@ -30,3 +30,12 @@ export const XLargeText = styled.span<BlockProps>`
font-size: ${props => props.theme.sizes.xl};
letter-spacing: 0.43px;
`;
export const PageTitle = styled.h2`
font-family: ${props => props.theme.fonts.light};
font-size: ${props => props.theme.sizes.l};
text-align: center;
text-transform: uppercase;
letter-spacing: 2.7px;
line-height: 30px;
`;

View file

@ -14,7 +14,8 @@ const Styled = {
Container: styled.div`
position: relative;
min-height: 100vh;
width: 1440px;
max-width: 1440px;
width: 100%;
margin: 0 auto;
`,
MenuIcon: styled(Menu)`

View file

@ -0,0 +1,40 @@
import React from 'react';
import { Swap } from 'types/state';
import { Column, Row } from 'components/common/grid';
import { SmallText } from 'components/common/text';
import { styled } from 'components/theme';
const Styled = {
RightColumn: styled(Column)`
text-align: right;
`,
SmallText: styled(SmallText)`
line-height: 1;
`,
};
interface Props {
swaps: Swap[];
}
const LoopHistory: React.FC<Props> = ({ swaps }) => {
const recentSwaps = swaps.slice(0, 2);
const { RightColumn, SmallText } = Styled;
return (
<>
{recentSwaps.map(swap => (
<Row key={swap.id}>
<Column cols={6}>
<SmallText>{swap.createdOn.toLocaleDateString()}</SmallText>
</Column>
<RightColumn cols={6}>
<SmallText>{`${swap.amount.toLocaleString()} SAT`}</SmallText>
</RightColumn>
</Row>
))}
</>
);
};
export default LoopHistory;

View file

@ -0,0 +1,63 @@
import React, { useEffect } from 'react';
import { observer } from 'mobx-react-lite';
import { usePrefixedTranslation } from 'hooks';
import { useActions, useStore } from 'store';
import { Column, Row } from 'components/common/grid';
import { PageTitle } from 'components/common/text';
import Tile from 'components/common/Tile';
import { styled } from 'components/theme';
import LoopHistory from './LoopHistory';
const Styled = {
PageWrap: styled.div`
padding: 40px 50px;
`,
TileSection: styled.section`
margin-top: 90px;
`,
};
const LoopPage: React.FC = () => {
const store = useStore();
const { node, channel, swap } = useActions();
const { l } = usePrefixedTranslation('cmps.loop.LoopPage');
useEffect(() => {
// fetch RPC data when the component mounts if there is no
if (store.channels.length === 0) {
channel.getChannels();
node.getBalances();
swap.listSwaps();
}
}, [store, node, channel, swap]);
const { PageWrap, TileSection } = Styled;
return (
<PageWrap>
<PageTitle>{l('pageTitle')}</PageTitle>
<TileSection>
<Row>
<Column>
<Tile title={l('history')} onArrowClick={() => null}>
<LoopHistory swaps={store.swaps} />
</Tile>
</Column>
<Column cols={4}>
<Tile
title={l('inbound')}
text={`${store.totalInbound.toLocaleString()} SAT`}
/>
</Column>
<Column cols={4}>
<Tile
title={l('outbound')}
text={`${store.totalOutbound.toLocaleString()} SAT`}
/>
</Column>
</Row>
</TileSection>
</PageWrap>
);
};
export default observer(LoopPage);

View file

@ -1,129 +0,0 @@
import React, { useEffect } from 'react';
import { observer } from 'mobx-react-lite';
import { usePrefixedTranslation } from 'hooks';
import { useActions, useStore } from 'store/provider';
const SamplePage: React.FC = () => {
const store = useStore();
const { node, channel, swap } = useActions();
const { l } = usePrefixedTranslation('App');
useEffect(() => {
// fetch node info when the component is mounted
const fetchInfo = async () => {
try {
await node.getInfo();
await node.getBalances();
} catch (error) {
console.log('Failed to fetch node info', error);
}
};
fetchInfo();
}, [node]);
const balances = store.balances || { channelBalance: 0, walletBalance: 0 };
return (
<>
<header className="text-center">
<h3>Lightning Loop</h3>
</header>
<section className="mt-4">
<h1>{l('App.nodeInfo')}</h1>
{store.info && (
<table className="table" style={{ color: '#fff' }}>
<tbody>
<tr>
<th>{l('pubkey')}</th>
<td>{store.info.identityPubkey}</td>
</tr>
<tr>
<th>{l('alias')}</th>
<td>{store.info.alias}</td>
</tr>
<tr>
<th>{l('version')}</th>
<td>{store.info.version}</td>
</tr>
<tr>
<th>{l('numChannels')}</th>
<td>{store.info.numActiveChannels}</td>
</tr>
<tr>
<th>{l('balances')}</th>
<td>{`${balances.channelBalance} in channels, ${balances.walletBalance} in wallet`}</td>
</tr>
</tbody>
</table>
)}
</section>
<section className="mt-4">
<h2>
{store.channels.length} Channels
<button
className="btn btn-outline-light float-right"
onClick={channel.getChannels}
>
Fetch
</button>
</h2>
<table className="table" style={{ color: '#fff' }}>
<thead>
<tr>
<td>Can Receive</td>
<td>Can Send</td>
<td>In Fee %</td>
<td>Up time %</td>
<td>Volume (24h)</td>
<td>Peer/Alias</td>
<td>Capacity</td>
</tr>
</thead>
<tbody>
{store.channels.map(c => (
<tr key={c.chanId}>
<td>{c.remoteBalance}</td>
<td>{c.localBalance}</td>
<td></td>
<td>{c.uptime}</td>
<td></td>
<td>{c.remotePubkey.substring(0, 12)}</td>
<td>{c.capacity}</td>
</tr>
))}
</tbody>
</table>
</section>
<section className="mt-4">
<h2>
{store.swaps.length} Swaps
<button className="btn btn-outline-light float-right" onClick={swap.listSwaps}>
Fetch
</button>
</h2>
<table className="table" style={{ color: '#fff' }}>
<thead>
<tr>
<td>Date</td>
<td>Type</td>
<td>Amount</td>
<td>Status</td>
</tr>
</thead>
<tbody>
{store.swaps.map(s => (
<tr key={s.id}>
<td>{s.createdOn.toString()}</td>
<td>{s.type}</td>
<td>{s.amount.toString()}</td>
<td>{s.status}</td>
</tr>
))}
</tbody>
</table>
</section>
</>
);
};
export default observer(SamplePage);

View file

@ -23,6 +23,7 @@ const theme = {
white: '#ffffff',
whitish: '#f5f5f5',
pink: '#f5406e',
tileBack: 'rgba(245,245,245,0.04)',
},
};

View file

@ -1,4 +1,8 @@
{
"cmps.loop.LoopPage.pageTitle": "Lightning Loop",
"cmps.loop.LoopPage.history": "Loop History",
"cmps.loop.LoopPage.inbound": "Total Inbound Liquidity",
"cmps.loop.LoopPage.outbound": "Total Outbound Liquidity",
"App.nodeInfo": "Node Info",
"App.pubkey": "Pubkey",
"App.alias": "Alias",

View file

@ -1,17 +1,40 @@
import { observable } from 'mobx';
import { computed, observable } from 'mobx';
import { Channel, NodeBalances, NodeInfo, Swap } from 'types/state';
/**
* The store used to manage global app state
*/
export class Store {
//
// App state
//
@observable sidebarCollapsed = false;
//
// API data
//
@observable info?: NodeInfo = undefined;
@observable balances?: NodeBalances = undefined;
@observable channels: Channel[] = [];
@observable swaps: Swap[] = [];
//
// computed data
//
/**
* the sum of remote balance of all channels
*/
@computed get totalInbound() {
return this.channels.reduce((sum, chan) => sum + chan.remoteBalance, 0);
}
/**
* the sum of local balance of all channels
*/
@computed get totalOutbound() {
return this.channels.reduce((sum, chan) => sum + chan.localBalance, 0);
}
}
// re-export from provider

View file

@ -25,7 +25,7 @@ export interface Channel {
export interface Swap {
id: string;
type: string;
amount: BigInt;
amount: number;
createdOn: Date;
status: string;
}

View file

@ -92,12 +92,12 @@ export const lndListChannels: LND.ListChannelsResponse.AsObject = {
export const loopListSwaps: LOOP.ListSwapsResponse.AsObject = {
swapsList: [...Array(7)].map((x, i) => ({
amt: 500000,
id: 'f4eb118383c2b09d8c7289ce21c25900cfb4545d46c47ed23a31ad2aa57ce835',
amt: 500000 + i * 5000,
id: `f4eb118383c2b09d8c7289ce21c25900cfb4545d46c47ed23a31ad2aa57ce83${i}`,
idBytes: '9OsRg4PCsJ2MconOIcJZAM+0VF1GxH7SOjGtKqV86DU=',
type: (i % 3) as any,
state: i as any,
initiationTime: 1586390353623905000,
initiationTime: 1586390353623905000 + i * 100000000000000,
lastUpdateTime: 1586398369729857000,
htlcAddress: 'bcrt1qzu4077erkr78k52yuf2rwkk6ayr6m3wtazdfz2qqmd7taa5vvy9s5d75gd',
costServer: 66,