Skip to content

Commit 2e6ae91

Browse files
committed
channels: add sorting to the channel list
1 parent 9d5c264 commit 2e6ae91

File tree

12 files changed

+283
-27
lines changed

12 files changed

+283
-27
lines changed

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,5 +173,40 @@ describe('LoopPage component', () => {
173173
expect(getByText('Review Loop amount and fee')).toBeInTheDocument();
174174
expect(store.buildSwapStore.processingTimeout).toBeUndefined();
175175
});
176+
177+
it('should sort the channel list', () => {
178+
const { getByText, store } = render();
179+
expect(getByText('Capacity')).toBeInTheDocument();
180+
expect(store.settingsStore.channelSort.field).toBeUndefined();
181+
expect(store.settingsStore.channelSort.descending).toBe(true);
182+
183+
fireEvent.click(getByText('Can Receive'));
184+
expect(store.settingsStore.channelSort.field).toBe('remoteBalance');
185+
186+
fireEvent.click(getByText('Can Send'));
187+
expect(store.settingsStore.channelSort.field).toBe('localBalance');
188+
189+
fireEvent.click(getByText('In Fee %'));
190+
expect(store.settingsStore.channelSort.field).toBe('remoteFeeRate');
191+
192+
fireEvent.click(getByText('Uptime %'));
193+
expect(store.settingsStore.channelSort.field).toBe('uptimePercent');
194+
195+
fireEvent.click(getByText('Peer/Alias'));
196+
expect(store.settingsStore.channelSort.field).toBe('aliasLabel');
197+
198+
fireEvent.click(getByText('Capacity'));
199+
expect(store.settingsStore.channelSort.field).toBe('capacity');
200+
expect(store.settingsStore.channelSort.descending).toBe(false);
201+
202+
fireEvent.click(getByText('Capacity'));
203+
expect(store.settingsStore.channelSort.field).toBe('capacity');
204+
expect(store.settingsStore.channelSort.descending).toBe(true);
205+
206+
expect(getByText('slash.svg')).toBeInTheDocument();
207+
fireEvent.click(getByText('slash.svg'));
208+
expect(store.settingsStore.channelSort.field).toBeUndefined();
209+
expect(store.settingsStore.channelSort.descending).toBe(true);
210+
});
176211
});
177212
});

app/src/assets/icons/arrow-down.svg

Lines changed: 4 additions & 0 deletions
Loading

app/src/assets/icons/arrow-up.svg

Lines changed: 4 additions & 0 deletions
Loading

app/src/assets/icons/slash.svg

Lines changed: 4 additions & 0 deletions
Loading

app/src/components/base/icons.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { ReactComponent as ArrowDownIcon } from 'assets/icons/arrow-down.svg';
12
import { ReactComponent as ArrowLeftIcon } from 'assets/icons/arrow-left.svg';
23
import { ReactComponent as ArrowRightIcon } from 'assets/icons/arrow-right.svg';
4+
import { ReactComponent as ArrowUpIcon } from 'assets/icons/arrow-up.svg';
35
import { ReactComponent as BitcoinIcon } from 'assets/icons/bitcoin.svg';
46
import { ReactComponent as BoltIcon } from 'assets/icons/bolt.svg';
57
import { ReactComponent as CheckIcon } from 'assets/icons/check.svg';
@@ -16,10 +18,11 @@ import { ReactComponent as MaximizeIcon } from 'assets/icons/maximize.svg';
1618
import { ReactComponent as MenuIcon } from 'assets/icons/menu.svg';
1719
import { ReactComponent as MinimizeIcon } from 'assets/icons/minimize.svg';
1820
import { ReactComponent as RefreshIcon } from 'assets/icons/refresh-cw.svg';
21+
import { ReactComponent as CancelIcon } from 'assets/icons/slash.svg';
1922
import { styled } from 'components/theme';
2023

2124
interface IconProps {
22-
size?: 'small' | 'medium' | 'large';
25+
size?: 'x-small' | 'small' | 'medium' | 'large';
2326
onClick?: () => void;
2427
}
2528

@@ -39,6 +42,13 @@ const Icon = styled.span<IconProps>`
3942
}
4043
`}
4144
45+
${props =>
46+
props.size === 'x-small' &&
47+
`
48+
width: 16px;
49+
height: 16px;
50+
`}
51+
4252
${props =>
4353
props.size === 'small' &&
4454
`
@@ -61,10 +71,13 @@ const Icon = styled.span<IconProps>`
6171
`}
6272
`;
6373

74+
export const ArrowLeft = Icon.withComponent(ArrowLeftIcon);
6475
export const ArrowRight = Icon.withComponent(ArrowRightIcon);
76+
export const ArrowUp = Icon.withComponent(ArrowUpIcon);
77+
export const ArrowDown = Icon.withComponent(ArrowDownIcon);
78+
export const Cancel = Icon.withComponent(CancelIcon);
6579
export const Clock = Icon.withComponent(ClockIcon);
6680
export const Download = Icon.withComponent(DownloadIcon);
67-
export const ArrowLeft = Icon.withComponent(ArrowLeftIcon);
6881
export const Bolt = Icon.withComponent(BoltIcon);
6982
export const Bitcoin = Icon.withComponent(BitcoinIcon);
7083
export const Check = Icon.withComponent(CheckIcon);
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React, { useCallback } from 'react';
2+
import { SortParams } from 'types/state';
3+
import { ArrowDown, ArrowUp, HeaderFour } from 'components/base';
4+
import { styled } from 'components/theme';
5+
6+
const Styled = {
7+
HeaderFour: styled(HeaderFour)<{ selected: boolean }>`
8+
${props =>
9+
props.selected &&
10+
`
11+
color: ${props.theme.colors.white};
12+
`}
13+
14+
&:hover {
15+
cursor: pointer;
16+
color: ${props => props.theme.colors.white};
17+
}
18+
`,
19+
Icon: styled.span`
20+
display: inline-block;
21+
margin-left: 6px;
22+
23+
svg {
24+
padding: 0;
25+
}
26+
`,
27+
};
28+
29+
interface Props<T> {
30+
field: keyof T;
31+
sort: SortParams<T>;
32+
onSort: (field: SortParams<T>['field'], descending: boolean) => void;
33+
}
34+
35+
const SortableHeader = <T,>({
36+
field,
37+
sort,
38+
onSort,
39+
children,
40+
}: React.PropsWithChildren<Props<T>>) => {
41+
const selected = field === sort.field;
42+
const SortIcon = sort.descending ? ArrowDown : ArrowUp;
43+
44+
const handleSortClick = useCallback(() => {
45+
const descending = selected ? !sort.descending : false;
46+
onSort(field, descending);
47+
}, [selected, sort.descending, field, onSort]);
48+
49+
const { HeaderFour, Icon } = Styled;
50+
return (
51+
<HeaderFour selected={selected} onClick={handleSortClick}>
52+
{children}
53+
{selected && (
54+
<Icon>
55+
<SortIcon size="x-small" />
56+
</Icon>
57+
)}
58+
</HeaderFour>
59+
);
60+
};
61+
62+
export default SortableHeader;

app/src/components/loop/ChannelRow.tsx

Lines changed: 75 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { observer } from 'mobx-react-lite';
33
import { usePrefixedTranslation } from 'hooks';
44
import { useStore } from 'store';
55
import { Channel } from 'store/models';
6-
import { Column, HeaderFour, Row } from 'components/base';
6+
import { Cancel, Column, HeaderFour, Row } from 'components/base';
77
import Checkbox from 'components/common/Checkbox';
8+
import SortableHeader from 'components/common/SortableHeader';
89
import Tip from 'components/common/Tip';
910
import Unit from 'components/common/Unit';
1011
import { styled } from 'components/theme';
@@ -57,6 +58,10 @@ const Styled = {
5758
max-width: 20%;
5859
}
5960
`,
61+
ClearSortIcon: styled(Cancel)`
62+
padding: 2px;
63+
margin-left: 4px;
64+
`,
6065
StatusIcon: styled.span`
6166
color: ${props => props.theme.colors.pink};
6267
`,
@@ -81,52 +86,100 @@ const ChannelAliasTip: React.FC<{ channel: Channel }> = ({ channel }) => {
8186
);
8287
};
8388

84-
export const ChannelRowHeader: React.FC = () => {
89+
const RowHeader: React.FC = () => {
8590
const { l } = usePrefixedTranslation('cmps.loop.ChannelRowHeader');
86-
const { Column, ActionColumn, WideColumn } = Styled;
91+
const { settingsStore } = useStore();
92+
93+
const { Column, ActionColumn, WideColumn, ClearSortIcon } = Styled;
8794
return (
8895
<Row>
89-
<ActionColumn></ActionColumn>
96+
<ActionColumn>
97+
{settingsStore.channelSort.field && (
98+
<Tip overlay={l('resetSort')} placement="right">
99+
<HeaderFour>
100+
<ClearSortIcon size="x-small" onClick={settingsStore.resetChannelSort} />
101+
</HeaderFour>
102+
</Tip>
103+
)}
104+
</ActionColumn>
90105
<Column right>
91-
<HeaderFour data-tour="channel-list-receive">{l('canReceive')}</HeaderFour>
106+
<SortableHeader<Channel>
107+
field="remoteBalance"
108+
sort={settingsStore.channelSort}
109+
onSort={settingsStore.setChannelSort}
110+
>
111+
<span data-tour="channel-list-receive">{l('canReceive')}</span>
112+
</SortableHeader>
92113
</Column>
93-
<WideColumn cols={2} colsXl={3}></WideColumn>
114+
<WideColumn cols={2} colsXl={3} />
94115
<Column>
95-
<HeaderFour data-tour="channel-list-send">{l('canSend')}</HeaderFour>
116+
<SortableHeader<Channel>
117+
field="localBalance"
118+
sort={settingsStore.channelSort}
119+
onSort={settingsStore.setChannelSort}
120+
>
121+
<span data-tour="channel-list-send">{l('canSend')}</span>
122+
</SortableHeader>
96123
</Column>
97-
<Column cols={1}>
98-
<Tip overlay={l('feeRateTip')} capitalize={false}>
99-
<HeaderFour data-tour="channel-list-fee">{l('feeRate')}</HeaderFour>
100-
</Tip>
124+
<Column>
125+
<SortableHeader<Channel>
126+
field="remoteFeeRate"
127+
sort={settingsStore.channelSort}
128+
onSort={settingsStore.setChannelSort}
129+
>
130+
<Tip overlay={l('feeRateTip')} capitalize={false}>
131+
<span data-tour="channel-list-fee">{l('feeRate')}</span>
132+
</Tip>
133+
</SortableHeader>
101134
</Column>
102-
<Column cols={1}>
103-
<HeaderFour data-tour="channel-list-uptime">{l('upTime')}</HeaderFour>
135+
<Column>
136+
<SortableHeader<Channel>
137+
field="uptimePercent"
138+
sort={settingsStore.channelSort}
139+
onSort={settingsStore.setChannelSort}
140+
>
141+
<span data-tour="channel-list-uptime">{l('upTime')}</span>
142+
</SortableHeader>
104143
</Column>
105144
<WideColumn cols={2}>
106-
<HeaderFour data-tour="channel-list-peer">{l('peer')}</HeaderFour>
145+
<SortableHeader<Channel>
146+
field="aliasLabel"
147+
sort={settingsStore.channelSort}
148+
onSort={settingsStore.setChannelSort}
149+
>
150+
<span data-tour="channel-list-peer">{l('peer')}</span>
151+
</SortableHeader>
107152
</WideColumn>
108153
<Column right last>
109-
<HeaderFour data-tour="channel-list-capacity">{l('capacity')}</HeaderFour>
154+
<SortableHeader<Channel>
155+
field="capacity"
156+
sort={settingsStore.channelSort}
157+
onSort={settingsStore.setChannelSort}
158+
>
159+
<span data-tour="channel-list-capacity">{l('capacity')}</span>
160+
</SortableHeader>
110161
</Column>
111162
</Row>
112163
);
113164
};
114165

166+
export const ChannelRowHeader = observer(RowHeader);
167+
115168
interface Props {
116169
channel: Channel;
117170
style?: CSSProperties;
118171
}
119172

120173
const ChannelRow: React.FC<Props> = ({ channel, style }) => {
121-
const store = useStore();
174+
const { buildSwapStore } = useStore();
122175

123-
const editable = store.buildSwapStore.listEditable;
124-
const disabled = store.buildSwapStore.showWizard;
125-
const checked = store.buildSwapStore.selectedChanIds.includes(channel.chanId);
176+
const editable = buildSwapStore.listEditable;
177+
const disabled = buildSwapStore.showWizard;
178+
const checked = buildSwapStore.selectedChanIds.includes(channel.chanId);
126179
const dimmed = editable && disabled && !checked;
127180

128181
const handleRowChecked = () => {
129-
store.buildSwapStore.toggleSelectedChannel(channel.chanId);
182+
buildSwapStore.toggleSelectedChannel(channel.chanId);
130183
};
131184

132185
const { Row, Column, ActionColumn, WideColumn, StatusIcon, Check, Balance } = Styled;
@@ -155,12 +208,12 @@ const ChannelRow: React.FC<Props> = ({ channel, style }) => {
155208
<Column>
156209
<Unit sats={channel.localBalance} suffix={false} />
157210
</Column>
158-
<Column cols={1}>
211+
<Column>
159212
<Tip overlay={`${channel.remoteFeeRate} ppm`} placement="left" capitalize={false}>
160213
<span>{channel.remoteFeePct}</span>
161214
</Tip>
162215
</Column>
163-
<Column cols={1}>{channel.uptimePercent}</Column>
216+
<Column>{channel.uptimePercent}</Column>
164217
<WideColumn cols={2}>
165218
<Tip
166219
overlay={<ChannelAliasTip channel={channel} />}

app/src/i18n/locales/en-US.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"cmps.loop.ChannelIcon.processing.in": "Loop In currently in progress",
2424
"cmps.loop.ChannelIcon.processing.out": "Loop Out currently in progress",
2525
"cmps.loop.ChannelIcon.processing.both": "Loop In and Loop Out currently in progress",
26+
"cmps.loop.ChannelRowHeader.resetSort": "Reset Sorting",
2627
"cmps.loop.ChannelRowHeader.canReceive": "Can Receive",
2728
"cmps.loop.ChannelRowHeader.canSend": "Can Send",
2829
"cmps.loop.ChannelRowHeader.feeRate": "In Fee %",

app/src/store/models/channel.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { action, computed, observable } from 'mobx';
22
import * as LND from 'types/generated/lnd_pb';
33
import * as LOOP from 'types/generated/loop_pb';
4+
import { SortParams } from 'types/state';
45
import Big from 'big.js';
56
import { getBalanceStatus } from 'util/balances';
67
import { percentage } from 'util/bigmath';
@@ -84,7 +85,7 @@ export default class Channel {
8485
/**
8586
* The order to sort this channel based on the current mode
8687
*/
87-
@computed get sortOrder() {
88+
@computed get balanceModeOrder() {
8889
const mode = this._store.settingsStore.balanceMode;
8990
switch (mode) {
9091
case BalanceMode.routing:
@@ -154,6 +155,44 @@ export default class Channel {
154155
this.lifetime = Big(lndChannel.lifetime);
155156
}
156157

158+
/**
159+
* Compares a specific field of two channels for sorting
160+
* @param a the first channel to compare
161+
* @param b the second channel to compare
162+
* @param sortBy the field and direction to sort the two channels by
163+
* @returns a positive number if `a`'s field is greater than `b`'s,
164+
* a negative number if `a`'s field is less than `b`'s, or zero otherwise
165+
*/
166+
static compare(a: Channel, b: Channel, field: SortParams<Channel>['field']): number {
167+
let order = 0;
168+
switch (field) {
169+
case 'remoteBalance':
170+
order = +a.remoteBalance.sub(b.remoteBalance);
171+
break;
172+
case 'localBalance':
173+
order = +a.localBalance.sub(b.localBalance);
174+
break;
175+
case 'remoteFeeRate':
176+
order = a.remoteFeeRate - b.remoteFeeRate;
177+
break;
178+
case 'uptimePercent':
179+
order = a.uptimePercent - b.uptimePercent;
180+
break;
181+
case 'aliasLabel':
182+
order = a.aliasLabel.toLowerCase() > b.aliasLabel.toLowerCase() ? 1 : -1;
183+
break;
184+
case 'capacity':
185+
order = +a.capacity.sub(b.capacity);
186+
break;
187+
case 'balanceModeOrder':
188+
default:
189+
order = a.balanceModeOrder - b.balanceModeOrder;
190+
break;
191+
}
192+
193+
return order;
194+
}
195+
157196
/**
158197
* Specifies which properties of this class should be exported to CSV
159198
* @param key must match the name of a property on this class

0 commit comments

Comments
 (0)