diff --git a/app/src/__tests__/components/history/HistoryPage.spec.tsx b/app/src/__tests__/components/history/HistoryPage.spec.tsx index ad994de74..6f16d11d1 100644 --- a/app/src/__tests__/components/history/HistoryPage.spec.tsx +++ b/app/src/__tests__/components/history/HistoryPage.spec.tsx @@ -8,8 +8,9 @@ import HistoryPage from 'components/history/HistoryPage'; describe('HistoryPage', () => { let store: Store; - beforeEach(() => { + beforeEach(async () => { store = createStore(); + await store.swapStore.fetchSwaps(); }); const render = () => { @@ -35,9 +36,35 @@ describe('HistoryPage', () => { expect(getByText('Updated')).toBeInTheDocument(); }); - it('should export channels', () => { + it('should export loop history', () => { const { getByText } = render(); fireEvent.click(getByText('download.svg')); expect(saveAs).toBeCalledWith(expect.any(Blob), 'swaps.csv'); }); + + it('should sort the history list', () => { + const { getByText, store } = render(); + expect(store.settingsStore.historySort.field).toBe('lastUpdateTime'); + expect(store.settingsStore.historySort.descending).toBe(true); + + fireEvent.click(getByText('Status')); + expect(store.settingsStore.historySort.field).toBe('stateLabel'); + + fireEvent.click(getByText('Type')); + expect(store.settingsStore.historySort.field).toBe('typeName'); + + fireEvent.click(getByText('Amount')); + expect(store.settingsStore.historySort.field).toBe('amount'); + + fireEvent.click(getByText('Created')); + expect(store.settingsStore.historySort.field).toBe('initiationTime'); + + fireEvent.click(getByText('Updated')); + expect(store.settingsStore.historySort.field).toBe('lastUpdateTime'); + expect(store.settingsStore.historySort.descending).toBe(false); + + fireEvent.click(getByText('Updated')); + expect(store.settingsStore.historySort.field).toBe('lastUpdateTime'); + expect(store.settingsStore.historySort.descending).toBe(true); + }); }); diff --git a/app/src/__tests__/components/loop/LoopPage.spec.tsx b/app/src/__tests__/components/loop/LoopPage.spec.tsx index c928f5da6..40a3c12b4 100644 --- a/app/src/__tests__/components/loop/LoopPage.spec.tsx +++ b/app/src/__tests__/components/loop/LoopPage.spec.tsx @@ -173,5 +173,40 @@ describe('LoopPage component', () => { expect(getByText('Review Loop amount and fee')).toBeInTheDocument(); expect(store.buildSwapStore.processingTimeout).toBeUndefined(); }); + + it('should sort the channel list', () => { + const { getByText, store } = render(); + expect(getByText('Capacity')).toBeInTheDocument(); + expect(store.settingsStore.channelSort.field).toBeUndefined(); + expect(store.settingsStore.channelSort.descending).toBe(true); + + fireEvent.click(getByText('Can Receive')); + expect(store.settingsStore.channelSort.field).toBe('remoteBalance'); + + fireEvent.click(getByText('Can Send')); + expect(store.settingsStore.channelSort.field).toBe('localBalance'); + + fireEvent.click(getByText('In Fee %')); + expect(store.settingsStore.channelSort.field).toBe('remoteFeeRate'); + + fireEvent.click(getByText('Uptime %')); + expect(store.settingsStore.channelSort.field).toBe('uptimePercent'); + + fireEvent.click(getByText('Peer/Alias')); + expect(store.settingsStore.channelSort.field).toBe('aliasLabel'); + + fireEvent.click(getByText('Capacity')); + expect(store.settingsStore.channelSort.field).toBe('capacity'); + expect(store.settingsStore.channelSort.descending).toBe(false); + + fireEvent.click(getByText('Capacity')); + expect(store.settingsStore.channelSort.field).toBe('capacity'); + expect(store.settingsStore.channelSort.descending).toBe(true); + + expect(getByText('slash.svg')).toBeInTheDocument(); + fireEvent.click(getByText('slash.svg')); + expect(store.settingsStore.channelSort.field).toBeUndefined(); + expect(store.settingsStore.channelSort.descending).toBe(true); + }); }); }); diff --git a/app/src/assets/icons/arrow-down.svg b/app/src/assets/icons/arrow-down.svg new file mode 100644 index 000000000..42d174218 --- /dev/null +++ b/app/src/assets/icons/arrow-down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/assets/icons/arrow-up.svg b/app/src/assets/icons/arrow-up.svg new file mode 100644 index 000000000..37d9b5507 --- /dev/null +++ b/app/src/assets/icons/arrow-up.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/assets/icons/slash.svg b/app/src/assets/icons/slash.svg new file mode 100644 index 000000000..13cef0c4a --- /dev/null +++ b/app/src/assets/icons/slash.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/components/base/icons.tsx b/app/src/components/base/icons.tsx index 6208c738a..2b8f18051 100644 --- a/app/src/components/base/icons.tsx +++ b/app/src/components/base/icons.tsx @@ -1,5 +1,7 @@ +import { ReactComponent as ArrowDownIcon } from 'assets/icons/arrow-down.svg'; import { ReactComponent as ArrowLeftIcon } from 'assets/icons/arrow-left.svg'; import { ReactComponent as ArrowRightIcon } from 'assets/icons/arrow-right.svg'; +import { ReactComponent as ArrowUpIcon } from 'assets/icons/arrow-up.svg'; import { ReactComponent as BitcoinIcon } from 'assets/icons/bitcoin.svg'; import { ReactComponent as BoltIcon } from 'assets/icons/bolt.svg'; import { ReactComponent as CheckIcon } from 'assets/icons/check.svg'; @@ -16,10 +18,11 @@ import { ReactComponent as MaximizeIcon } from 'assets/icons/maximize.svg'; import { ReactComponent as MenuIcon } from 'assets/icons/menu.svg'; import { ReactComponent as MinimizeIcon } from 'assets/icons/minimize.svg'; import { ReactComponent as RefreshIcon } from 'assets/icons/refresh-cw.svg'; +import { ReactComponent as CancelIcon } from 'assets/icons/slash.svg'; import { styled } from 'components/theme'; interface IconProps { - size?: 'small' | 'medium' | 'large'; + size?: 'x-small' | 'small' | 'medium' | 'large'; onClick?: () => void; } @@ -39,6 +42,13 @@ const Icon = styled.span` } `} + ${props => + props.size === 'x-small' && + ` + width: 16px; + height: 16px; + `} + ${props => props.size === 'small' && ` @@ -61,10 +71,13 @@ const Icon = styled.span` `} `; +export const ArrowLeft = Icon.withComponent(ArrowLeftIcon); export const ArrowRight = Icon.withComponent(ArrowRightIcon); +export const ArrowUp = Icon.withComponent(ArrowUpIcon); +export const ArrowDown = Icon.withComponent(ArrowDownIcon); +export const Cancel = Icon.withComponent(CancelIcon); export const Clock = Icon.withComponent(ClockIcon); export const Download = Icon.withComponent(DownloadIcon); -export const ArrowLeft = Icon.withComponent(ArrowLeftIcon); export const Bolt = Icon.withComponent(BoltIcon); export const Bitcoin = Icon.withComponent(BitcoinIcon); export const Check = Icon.withComponent(CheckIcon); diff --git a/app/src/components/common/SortableHeader.tsx b/app/src/components/common/SortableHeader.tsx new file mode 100644 index 000000000..20694f255 --- /dev/null +++ b/app/src/components/common/SortableHeader.tsx @@ -0,0 +1,62 @@ +import React, { useCallback } from 'react'; +import { SortParams } from 'types/state'; +import { ArrowDown, ArrowUp, HeaderFour } from 'components/base'; +import { styled } from 'components/theme'; + +const Styled = { + HeaderFour: styled(HeaderFour)<{ selected: boolean }>` + ${props => + props.selected && + ` + color: ${props.theme.colors.white}; + `} + + &:hover { + cursor: pointer; + color: ${props => props.theme.colors.white}; + } + `, + Icon: styled.span` + display: inline-block; + margin-left: 6px; + + svg { + padding: 0; + } + `, +}; + +interface Props { + field: keyof T; + sort: SortParams; + onSort: (field: SortParams['field'], descending: boolean) => void; +} + +const SortableHeader = ({ + field, + sort, + onSort, + children, +}: React.PropsWithChildren>) => { + const selected = field === sort.field; + const SortIcon = sort.descending ? ArrowDown : ArrowUp; + + const handleSortClick = useCallback(() => { + const descending = selected ? !sort.descending : false; + onSort(field, descending); + }, [selected, sort.descending, field, onSort]); + + const { HeaderFour, Icon } = Styled; + return ( + + {children} + {selected && ( + + + + )} + + ); +}; + +export default SortableHeader; diff --git a/app/src/components/history/HistoryRow.tsx b/app/src/components/history/HistoryRow.tsx index ddd864d21..369f0b054 100644 --- a/app/src/components/history/HistoryRow.tsx +++ b/app/src/components/history/HistoryRow.tsx @@ -1,8 +1,10 @@ import React, { CSSProperties } from 'react'; import { observer } from 'mobx-react-lite'; import { usePrefixedTranslation } from 'hooks'; +import { useStore } from 'store'; import { Swap } from 'store/models'; -import { Column, HeaderFour, Row } from 'components/base'; +import { Column, Row } from 'components/base'; +import SortableHeader from 'components/common/SortableHeader'; import Unit from 'components/common/Unit'; import SwapDot from 'components/loop/SwapDot'; import { styled } from 'components/theme'; @@ -43,31 +45,65 @@ const Styled = { `, }; -export const HistoryRowHeader: React.FC = () => { +const RowHeader: React.FC = () => { const { l } = usePrefixedTranslation('cmps.history.HistoryRowHeader'); + const { settingsStore } = useStore(); + const { HeaderRow, ActionColumn, HeaderColumn } = Styled; return ( - {l('status')} + + field="stateLabel" + sort={settingsStore.historySort} + onSort={settingsStore.setHistorySort} + > + {l('status')} + - {l('type')} + + field="typeName" + sort={settingsStore.historySort} + onSort={settingsStore.setHistorySort} + > + {l('type')} + - {l('amount')} + + field="amount" + sort={settingsStore.historySort} + onSort={settingsStore.setHistorySort} + > + {l('amount')} + - {l('created')} + + field="initiationTime" + sort={settingsStore.historySort} + onSort={settingsStore.setHistorySort} + > + {l('created')} + - {l('updated')} + + field="lastUpdateTime" + sort={settingsStore.historySort} + onSort={settingsStore.setHistorySort} + > + {l('updated')} + ); }; +export const HistoryRowHeader = observer(RowHeader); + interface Props { swap: Swap; style?: CSSProperties; diff --git a/app/src/components/loop/ChannelRow.tsx b/app/src/components/loop/ChannelRow.tsx index 96e6d086e..afd531b9f 100644 --- a/app/src/components/loop/ChannelRow.tsx +++ b/app/src/components/loop/ChannelRow.tsx @@ -3,8 +3,9 @@ import { observer } from 'mobx-react-lite'; import { usePrefixedTranslation } from 'hooks'; import { useStore } from 'store'; import { Channel } from 'store/models'; -import { Column, HeaderFour, Row } from 'components/base'; +import { Cancel, Column, HeaderFour, Row } from 'components/base'; import Checkbox from 'components/common/Checkbox'; +import SortableHeader from 'components/common/SortableHeader'; import Tip from 'components/common/Tip'; import Unit from 'components/common/Unit'; import { styled } from 'components/theme'; @@ -57,6 +58,10 @@ const Styled = { max-width: 20%; } `, + ClearSortIcon: styled(Cancel)` + padding: 2px; + margin-left: 4px; + `, StatusIcon: styled.span` color: ${props => props.theme.colors.pink}; `, @@ -81,52 +86,100 @@ const ChannelAliasTip: React.FC<{ channel: Channel }> = ({ channel }) => { ); }; -export const ChannelRowHeader: React.FC = () => { +const RowHeader: React.FC = () => { const { l } = usePrefixedTranslation('cmps.loop.ChannelRowHeader'); - const { Column, ActionColumn, WideColumn } = Styled; + const { settingsStore } = useStore(); + + const { Column, ActionColumn, WideColumn, ClearSortIcon } = Styled; return ( - + + {settingsStore.channelSort.field && ( + + + + + + )} + - {l('canReceive')} + + field="remoteBalance" + sort={settingsStore.channelSort} + onSort={settingsStore.setChannelSort} + > + {l('canReceive')} + - + - {l('canSend')} + + field="localBalance" + sort={settingsStore.channelSort} + onSort={settingsStore.setChannelSort} + > + {l('canSend')} + - - - {l('feeRate')} - + + + field="remoteFeeRate" + sort={settingsStore.channelSort} + onSort={settingsStore.setChannelSort} + > + + {l('feeRate')} + + - - {l('upTime')} + + + field="uptimePercent" + sort={settingsStore.channelSort} + onSort={settingsStore.setChannelSort} + > + {l('upTime')} + - {l('peer')} + + field="aliasLabel" + sort={settingsStore.channelSort} + onSort={settingsStore.setChannelSort} + > + {l('peer')} + - {l('capacity')} + + field="capacity" + sort={settingsStore.channelSort} + onSort={settingsStore.setChannelSort} + > + {l('capacity')} + ); }; +export const ChannelRowHeader = observer(RowHeader); + interface Props { channel: Channel; style?: CSSProperties; } const ChannelRow: React.FC = ({ channel, style }) => { - const store = useStore(); + const { buildSwapStore } = useStore(); - const editable = store.buildSwapStore.listEditable; - const disabled = store.buildSwapStore.showWizard; - const checked = store.buildSwapStore.selectedChanIds.includes(channel.chanId); + const editable = buildSwapStore.listEditable; + const disabled = buildSwapStore.showWizard; + const checked = buildSwapStore.selectedChanIds.includes(channel.chanId); const dimmed = editable && disabled && !checked; const handleRowChecked = () => { - store.buildSwapStore.toggleSelectedChannel(channel.chanId); + buildSwapStore.toggleSelectedChannel(channel.chanId); }; const { Row, Column, ActionColumn, WideColumn, StatusIcon, Check, Balance } = Styled; @@ -155,12 +208,12 @@ const ChannelRow: React.FC = ({ channel, style }) => { - + {channel.remoteFeePct} - {channel.uptimePercent} + {channel.uptimePercent} } diff --git a/app/src/i18n/locales/en-US.json b/app/src/i18n/locales/en-US.json index 47f2f18a4..c06584cd5 100644 --- a/app/src/i18n/locales/en-US.json +++ b/app/src/i18n/locales/en-US.json @@ -23,6 +23,7 @@ "cmps.loop.ChannelIcon.processing.in": "Loop In currently in progress", "cmps.loop.ChannelIcon.processing.out": "Loop Out currently in progress", "cmps.loop.ChannelIcon.processing.both": "Loop In and Loop Out currently in progress", + "cmps.loop.ChannelRowHeader.resetSort": "Reset Sorting", "cmps.loop.ChannelRowHeader.canReceive": "Can Receive", "cmps.loop.ChannelRowHeader.canSend": "Can Send", "cmps.loop.ChannelRowHeader.feeRate": "In Fee %", diff --git a/app/src/store/models/channel.ts b/app/src/store/models/channel.ts index 87353576a..0c1e0c122 100644 --- a/app/src/store/models/channel.ts +++ b/app/src/store/models/channel.ts @@ -1,6 +1,7 @@ import { action, computed, observable } from 'mobx'; import * as LND from 'types/generated/lnd_pb'; import * as LOOP from 'types/generated/loop_pb'; +import { SortParams } from 'types/state'; import Big from 'big.js'; import { getBalanceStatus } from 'util/balances'; import { percentage } from 'util/bigmath'; @@ -84,7 +85,7 @@ export default class Channel { /** * The order to sort this channel based on the current mode */ - @computed get sortOrder() { + @computed get balanceModeOrder() { const mode = this._store.settingsStore.balanceMode; switch (mode) { case BalanceMode.routing: @@ -154,6 +155,44 @@ export default class Channel { this.lifetime = Big(lndChannel.lifetime); } + /** + * Compares a specific field of two channels for sorting + * @param a the first channel to compare + * @param b the second channel to compare + * @param sortBy the field and direction to sort the two channels by + * @returns a positive number if `a`'s field is greater than `b`'s, + * a negative number if `a`'s field is less than `b`'s, or zero otherwise + */ + static compare(a: Channel, b: Channel, field: SortParams['field']): number { + let order = 0; + switch (field) { + case 'remoteBalance': + order = +a.remoteBalance.sub(b.remoteBalance); + break; + case 'localBalance': + order = +a.localBalance.sub(b.localBalance); + break; + case 'remoteFeeRate': + order = a.remoteFeeRate - b.remoteFeeRate; + break; + case 'uptimePercent': + order = a.uptimePercent - b.uptimePercent; + break; + case 'aliasLabel': + order = a.aliasLabel.toLowerCase() > b.aliasLabel.toLowerCase() ? 1 : -1; + break; + case 'capacity': + order = +a.capacity.sub(b.capacity); + break; + case 'balanceModeOrder': + default: + order = a.balanceModeOrder - b.balanceModeOrder; + break; + } + + return order; + } + /** * Specifies which properties of this class should be exported to CSV * @param key must match the name of a property on this class diff --git a/app/src/store/models/swap.ts b/app/src/store/models/swap.ts index d947a7866..04bd9a108 100644 --- a/app/src/store/models/swap.ts +++ b/app/src/store/models/swap.ts @@ -1,6 +1,7 @@ import { action, computed, observable } from 'mobx'; import { now } from 'mobx-utils'; import * as LOOP from 'types/generated/loop_pb'; +import { SortParams } from 'types/state'; import Big from 'big.js'; import formatDate from 'date-fns/format'; import { CsvColumns } from 'util/csv'; @@ -112,6 +113,30 @@ export default class Swap { this.state = loopSwap.state; } + /** + * Compares a specific field of two swaps for sorting + * @param a the first swap to compare + * @param b the second swap to compare + * @param sortBy the field and direction to sort the two swaps by + * @returns a positive number if `a`'s field is greater than `b`'s, + * a negative number if `a`'s field is less than `b`'s, or zero otherwise + */ + static compare(a: Swap, b: Swap, field: SortParams['field']): number { + switch (field) { + case 'stateLabel': + return a.stateLabel.toLowerCase() > b.stateLabel.toLowerCase() ? 1 : -1; + case 'typeName': + return a.typeName.toLowerCase() > b.typeName.toLowerCase() ? 1 : -1; + case 'amount': + return +a.amount.sub(b.amount); + case 'initiationTime': + return a.initiationTime - b.initiationTime; + case 'lastUpdateTime': + default: + return a.lastUpdateTime - b.lastUpdateTime; + } + } + /** * Specifies which properties of this class should be exported to CSV * @param key must match the name of a property on this class diff --git a/app/src/store/stores/channelStore.ts b/app/src/store/stores/channelStore.ts index 6c2bb22d3..aef502aac 100644 --- a/app/src/store/stores/channelStore.ts +++ b/app/src/store/stores/channelStore.ts @@ -34,9 +34,11 @@ export default class ChannelStore { * an array of channels sorted by balance percent descending */ @computed get sortedChannels() { - return values(this.channels) + const { field, descending } = this._store.settingsStore.channelSort; + const channels = values(this.channels) .slice() - .sort((a, b) => b.sortOrder - a.sortOrder); + .sort((a, b) => Channel.compare(a, b, field)); + return descending ? channels.reverse() : channels; } /** diff --git a/app/src/store/stores/settingsStore.ts b/app/src/store/stores/settingsStore.ts index 24ac8fa7a..9dc4b33f1 100644 --- a/app/src/store/stores/settingsStore.ts +++ b/app/src/store/stores/settingsStore.ts @@ -1,12 +1,16 @@ import { action, autorun, observable, toJS } from 'mobx'; +import { SortParams } from 'types/state'; import { BalanceMode, Unit } from 'util/constants'; import { Store } from 'store'; +import { Channel, Swap } from 'store/models'; export interface PersistentSettings { sidebarVisible: boolean; unit: Unit; balanceMode: BalanceMode; tourAutoShown: boolean; + channelSort: SortParams; + historySort: SortParams; } export default class SettingsStore { @@ -27,6 +31,18 @@ export default class SettingsStore { /** specifies the mode to use to determine channel balance status */ @observable balanceMode: BalanceMode = BalanceMode.receive; + /** specifies the sorting field and direction for the channel list */ + @observable channelSort: SortParams = { + field: undefined, + descending: true, + }; + + /** specifies the sorting field and direction for the channel list */ + @observable historySort: SortParams = { + field: 'lastUpdateTime', + descending: true, + }; + /** the chosen language */ @observable lang = 'en-US'; @@ -66,6 +82,40 @@ export default class SettingsStore { this.balanceMode = mode; } + /** + * Sets the sort field and direction that the channel list should use + * @param field the channel field to sort by + * @param descending true of the order should be descending, otherwise false + */ + @action.bound + setChannelSort(field: SortParams['field'], descending: boolean) { + this.channelSort = { field, descending }; + this._store.log.info('updated channel list sort order', toJS(this.channelSort)); + } + + /** + * Resets the channel list sort order + */ + @action.bound + resetChannelSort() { + this.channelSort = { + field: undefined, + descending: true, + }; + this._store.log.info('reset channel list sort order', toJS(this.channelSort)); + } + + /** + * Sets the sort field and direction that the swap history list should use + * @param field the swap field to sort by + * @param descending true of the order should be descending, otherwise false + */ + @action.bound + setHistorySort(field: SortParams['field'], descending: boolean) { + this.historySort = { field, descending }; + this._store.log.info('updated history list sort order', toJS(this.historySort)); + } + /** * initialized the settings and auto-save when a setting is changed */ @@ -79,6 +129,8 @@ export default class SettingsStore { unit: this.unit, balanceMode: this.balanceMode, tourAutoShown: this.tourAutoShown, + channelSort: toJS(this.channelSort), + historySort: toJS(this.historySort), }; this._store.storage.set('settings', settings); this._store.log.info('saved settings to localStorage', settings); @@ -99,6 +151,8 @@ export default class SettingsStore { this.unit = settings.unit; this.balanceMode = settings.balanceMode; this.tourAutoShown = settings.tourAutoShown; + if (settings.channelSort) this.channelSort = settings.channelSort; + if (settings.historySort) this.historySort = settings.historySort; this._store.log.info('loaded settings', settings); } diff --git a/app/src/store/stores/swapStore.ts b/app/src/store/stores/swapStore.ts index e83a43dba..754a56fa5 100644 --- a/app/src/store/stores/swapStore.ts +++ b/app/src/store/stores/swapStore.ts @@ -37,9 +37,12 @@ export default class SwapStore { /** swaps sorted by created date descending */ @computed get sortedSwaps() { - return values(this.swaps) + const { field, descending } = this._store.settingsStore.historySort; + const swaps = values(this.swaps) .slice() - .sort((a, b) => b.initiationTime - a.initiationTime); + .sort((a, b) => Swap.compare(a, b, field)); + + return descending ? swaps.reverse() : swaps; } /** the last two swaps */ diff --git a/app/src/types/state.ts b/app/src/types/state.ts index 5df5730a3..6b377a81c 100644 --- a/app/src/types/state.ts +++ b/app/src/types/state.ts @@ -38,3 +38,8 @@ export interface Alert { /** the number of milliseconds before the toast closes automatically */ ms?: number; } + +export interface SortParams { + field?: keyof T; + descending: boolean; +}