feat: add colored channel balance component

This commit is contained in:
jamaljsr 2020-04-25 13:10:49 -04:00
parent 6ff4b753fb
commit 5bd334a4fe
11 changed files with 306 additions and 49 deletions

View file

@ -0,0 +1,54 @@
import React from 'react';
import { BalanceLevel } from 'types/state';
import { StoryContext } from '@storybook/addons';
import { Store } from 'store';
import ChannelBalance from 'components/loop/ChannelBalance';
export default {
title: 'Channel Balance',
component: ChannelBalance,
parameters: { centered: true },
};
export const Good = (ctx: StoryContext) => {
// grab the store from the Storybook parameter defined in preview.tsx
const store = ctx.parameters.store as Store;
const channel = {
...store.channels[0],
localPercent: 59,
balanceLevel: BalanceLevel.good,
};
return <ChannelBalance channel={channel} />;
};
export const Warn = (ctx: StoryContext) => {
// grab the store from the Storybook parameter defined in preview.tsx
const store = ctx.parameters.store as Store;
const channel = {
...store.channels[0],
localPercent: 28,
balanceLevel: BalanceLevel.warn,
};
return <ChannelBalance channel={channel} />;
};
export const Bad = (ctx: StoryContext) => {
// grab the store from the Storybook parameter defined in preview.tsx
const store = ctx.parameters.store as Store;
const channel = {
...store.channels[0],
localPercent: 91,
balanceLevel: BalanceLevel.bad,
};
return <ChannelBalance channel={channel} />;
};
export const Inactive = (ctx: StoryContext) => {
// grab the store from the Storybook parameter defined in preview.tsx
const store = ctx.parameters.store as Store;
const channel = {
...store.channels[0],
active: false,
};
return <ChannelBalance channel={channel} />;
};

View file

@ -1,5 +1,4 @@
import React, { useEffect } from 'react';
import { toJS } from 'mobx';
import React from 'react';
import { StoryContext } from '@storybook/addons';
import { Store } from 'store';
import ChannelList from 'components/loop/ChannelList';
@ -10,45 +9,27 @@ export default {
parameters: { contained: true },
};
export const NoChannels = (ctx: StoryContext) => {
// grab the store from the Storybook parameter defined in preview.tsx
const store = ctx.parameters.store as Store;
useEffect(() => {
// convert the store state to pure JS so it can be reverted on unmount
const channels = toJS(store.channels);
store.channels = [];
// change back to sample data when the component is unmounted
return () => {
store.channels = channels;
};
}, []);
return <ChannelList channels={store.channels} />;
export const NoChannels = () => {
return <ChannelList channels={[]} />;
};
export const FewChannels = (ctx: StoryContext) => {
// grab the store from the Storybook parameter defined in preview.tsx
const store = ctx.parameters.store as Store;
useEffect(() => {
// convert the store state to pure JS so it can be reverted on unmount
const channels = toJS(store.channels);
store.channels = channels.slice(0, 5);
// change back to sample data when the component is unmounted
return () => {
store.channels = channels;
};
}, []);
return <ChannelList channels={store.channels} />;
return <ChannelList channels={store.channels.slice(0, 10)} />;
};
export const ManyChannels = (ctx: StoryContext) => {
// grab the store from the Storybook parameter defined in preview.tsx
const store = ctx.parameters.store as Store;
return <ChannelList channels={store.channels} />;
};
export const SortedChannels = (ctx: StoryContext) => {
// grab the store from the Storybook parameter defined in preview.tsx
const store = ctx.parameters.store as Store;
const channels = store.channels
.slice()
.sort((a, b) => b.balancePercent - a.balancePercent);
return <ChannelList channels={channels} />;
};

View file

@ -1,4 +1,7 @@
import React from 'react';
import React, { useEffect } from 'react';
import { toJS } from 'mobx';
import { StoryContext } from '@storybook/addons';
import { Store } from 'store';
import LoopPage from 'components/loop/LoopPage';
export default {
@ -6,4 +9,21 @@ export default {
component: LoopPage,
};
export const Default = () => <LoopPage />;
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 channels = toJS(store.channels);
// only use a small set of channels
store.channels = channels.slice(0, 25);
// change back to sample data when the component is unmounted
return () => {
store.channels = channels;
};
}, []);
return <LoopPage />;
};
export const ManyChannels = () => <LoopPage />;

View file

@ -1,3 +1,4 @@
import { lndListChannels } from 'util/tests/sampleData';
import ChannelAction from 'action/channel';
import { GrpcClient, LndApi } from 'api';
import { Store } from 'store';
@ -16,6 +17,6 @@ describe('ChannelAction', () => {
it('should fetch list of channels', async () => {
expect(store.channels).toEqual([]);
await channel.getChannels();
expect(store.channels).toHaveLength(1);
expect(store.channels).toHaveLength(lndListChannels.channelsList.length);
});
});

View file

@ -0,0 +1,63 @@
import React from 'react';
import { BalanceLevel, Channel } from 'types/state';
import { renderWithProviders } from 'util/tests';
import ChannelBalance from 'components/loop/ChannelBalance';
describe('ChannelBalance component', () => {
const channel: Channel = {
active: true,
capacity: 15000000,
chanId: '150633093070848',
localBalance: 9990950,
remoteBalance: 5000000,
remotePubkey: '02ac59099da6d4bd818e6a81098f5d54580b7c3aa8255c707fa0f95ca89b02cb8c',
uptime: 100,
localPercent: 67,
balancePercent: 67,
balanceLevel: BalanceLevel.warn,
};
const bgColor = (el: any) => window.getComputedStyle(el).backgroundColor;
const width = (el: any) => window.getComputedStyle(el).width;
const render = () => {
const result = renderWithProviders(<ChannelBalance channel={channel} />);
const el = result.container.children[0];
return {
...result,
el,
remote: el.children[0],
local: el.children[2],
};
};
it('should display a good balance', () => {
channel.localPercent = 55;
channel.balanceLevel = BalanceLevel.good;
const { el, remote, local } = render();
expect(el.children.length).toBe(3);
expect(width(local)).toBe('55%');
expect(bgColor(local)).toBe('rgb(70, 232, 14)');
expect(bgColor(remote)).toBe('rgb(70, 232, 14)');
});
it('should display a warning balance', () => {
channel.localPercent = 72;
channel.balanceLevel = BalanceLevel.warn;
const { el, remote, local } = render();
expect(el.children.length).toBe(3);
expect(width(local)).toBe('72%');
expect(bgColor(local)).toBe('rgb(246, 107, 28)');
expect(bgColor(remote)).toBe('rgb(246, 107, 28)');
});
it('should display a bad balance', () => {
channel.localPercent = 93;
channel.balanceLevel = BalanceLevel.bad;
const { el, remote, local } = render();
expect(el.children.length).toBe(3);
expect(width(local)).toBe('93%');
expect(bgColor(local)).toBe('rgb(245, 64, 110)');
expect(bgColor(remote)).toBe('rgb(245, 64, 110)');
});
});

View file

@ -1,5 +1,6 @@
import React from 'react';
import { SwapStatus } from 'types/generated/loop_pb';
import { wait } from '@testing-library/react';
import { renderWithProviders } from 'util/tests';
import { loopListSwaps } from 'util/tests/sampleData';
import LoopPage from 'components/loop/LoopPage';
@ -22,10 +23,11 @@ describe('LoopPage component', () => {
});
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();
const { getByText, store } = render();
// wait for the channels to be fetched async before checking the UI labels
await wait(() => expect(store.totalInbound).toBeGreaterThan(0));
expect(getByText(`${store.totalInbound.toLocaleString()} SAT`)).toBeInTheDocument();
expect(getByText(`${store.totalOutbound.toLocaleString()} SAT`)).toBeInTheDocument();
});
it('should display the loop history records', async () => {

View file

@ -1,4 +1,5 @@
import { action, toJS } from 'mobx';
import { BalanceLevel } from 'types/state';
import { actionLog as log } from 'util/log';
import { LndApi } from 'api';
import { Store } from 'store';
@ -30,9 +31,40 @@ class ChannelAction {
remoteBalance: c.remoteBalance,
uptime: Math.floor((c.uptime * 100) / c.lifetime),
active: c.active,
localPercent: this._calcLocalPercent(c.localBalance, c.remoteBalance),
balancePercent: this._calcBalancePercent(c.localBalance, c.remoteBalance),
balanceLevel: this._calcBalanceLevel(c.localBalance, c.remoteBalance),
}));
log.info('updated store.channels', toJS(this._store.channels));
}
/**
* Determines the local balance percentage of a channel based on the local and
* remote balances
* @param local the local balance of the channel
* @param remote the remote balance of the channel
*/
private _calcLocalPercent(local: number, remote: number): number {
return Math.round((local * 100) / (local + remote));
}
private _calcBalancePercent(local: number, remote: number): number {
const pct = this._calcLocalPercent(local, remote);
return pct >= 50 ? pct : 100 - pct;
}
/**
* Determines the balance level of a channel based on the percentage on each side
* @param local the local balance of the channel
* @param remote the remote balance of the channel
*/
private _calcBalanceLevel(local: number, remote: number): BalanceLevel {
const pct = this._calcBalancePercent(local, remote);
if (pct > 85) return BalanceLevel.bad;
if (pct > 65) return BalanceLevel.warn;
return BalanceLevel.good;
}
}
export default ChannelAction;

View file

@ -0,0 +1,59 @@
import React from 'react';
import { BalanceLevel, Channel } from 'types/state';
import { levelToColor, styled } from 'components/theme';
const Styled = {
Wrapper: styled.div<{ pct: number; level: BalanceLevel; active: boolean }>`
display: flex;
width: 100%;
> div {
min-width: 10px;
&:first-of-type {
flex-grow: 1;
background-color: ${props =>
levelToColor(props.level, props.active, props.theme)};
}
&:last-of-type {
width: ${props => props.pct}%;
background-color: ${props =>
levelToColor(props.level, props.active, props.theme)};
}
}
`,
Section: styled.div`
height: 4px;
border-radius: 2px;
`,
Gap: styled.div`
width: 10px;
background-color: transparent;
`,
};
interface Props {
channel: Channel;
className?: string;
}
const ChannelBalance: React.FC<Props> = ({ channel, className }) => {
const { active, localPercent, balanceLevel } = channel;
const { Wrapper, Section, Gap } = Styled;
return (
<Wrapper
pct={localPercent}
level={balanceLevel}
active={active}
className={className}
>
<Section />
<Gap />
<Section />
</Wrapper>
);
};
export default ChannelBalance;

View file

@ -4,6 +4,7 @@ import { Column, Row } from 'components/common/grid';
import { Dot } from 'components/common/icons';
import { Title } from 'components/common/text';
import { styled } from 'components/theme';
import ChannelBalance from './ChannelBalance';
/**
* the virtualized list requires each row to have a specified
@ -30,12 +31,8 @@ const Styled = {
margin-left: 15px;
color: ${props => props.theme.colors.pink};
`,
Balance: styled.div`
Balance: styled(ChannelBalance)`
margin-top: ${ROW_HEIGHT / 2 - 2}px;
height: 4px;
width: 100%;
background-color: ${props => props.theme.colors.pink};
border-radius: 2px;
`,
};
@ -73,12 +70,12 @@ const ChannelRow: React.FC<Props> = ({ channel, style }) => {
<StatusIcon>
<Dot />
</StatusIcon>
{channel.localBalance.toLocaleString()}
{channel.remoteBalance.toLocaleString()}
</Column>
<Column cols={3}>
<Balance />
<Balance channel={channel} />
</Column>
<Column>{channel.remoteBalance.toLocaleString()}</Column>
<Column>{channel.localBalance.toLocaleString()}</Column>
<Column>{channel.uptime}</Column>
<Column>{channel.remotePubkey}</Column>
<Column right>{channel.capacity.toLocaleString()}</Column>

View file

@ -1,8 +1,37 @@
import React from 'react';
import { BalanceLevel } from 'types/state';
import emotionStyled, { CreateStyled } from '@emotion/styled/macro';
import { ThemeProvider as EmotionThemeProvider } from 'emotion-theming';
const theme = {
export interface Theme {
fonts: {
light: string;
regular: string;
semiBold: string;
bold: string;
extraBold: string;
};
sizes: {
s: string;
m: string;
l: string;
xl: string;
};
colors: {
blue: string;
darkBlue: string;
gray: string;
darkGray: string;
white: string;
whitish: string;
pink: string;
green: string;
orange: string;
tileBack: string;
};
}
const theme: Theme = {
fonts: {
light: "'OpenSans Light'",
regular: "'OpenSans Regular'",
@ -24,11 +53,21 @@ const theme = {
white: '#ffffff',
whitish: '#f5f5f5',
pink: '#f5406e',
green: '#46E80E',
orange: '#f66b1c',
tileBack: 'rgba(245,245,245,0.04)',
},
};
export const styled = emotionStyled as CreateStyled<typeof theme>;
export const levelToColor = (level: BalanceLevel, active: boolean, theme: Theme) => {
if (!active) return theme.colors.gray;
if (level === BalanceLevel.bad) return theme.colors.pink;
if (level === BalanceLevel.warn) return theme.colors.orange;
return theme.colors.green;
};
export const styled = emotionStyled as CreateStyled<Theme>;
export const ThemeProvider: React.FC = ({ children }) => {
return <EmotionThemeProvider theme={theme}>{children}</EmotionThemeProvider>;

View file

@ -12,6 +12,12 @@ export interface NodeBalances {
channelBalance: number;
}
export enum BalanceLevel {
good = 'good',
warn = 'warn',
bad = 'bad',
}
export interface Channel {
chanId: string;
remotePubkey: string;
@ -20,6 +26,9 @@ export interface Channel {
remoteBalance: number;
uptime: number;
active: boolean;
localPercent: number;
balancePercent: number;
balanceLevel: BalanceLevel;
}
export interface Swap {