From 0ce1b4518d71d1d7694283644a36f793b2031319 Mon Sep 17 00:00:00 2001 From: jamaljsr Date: Wed, 20 May 2020 10:50:35 -0400 Subject: [PATCH 1/3] csv: implement CSV export of channels and swaps --- app/package.json | 2 + app/src/components/history/HistoryPage.tsx | 4 +- app/src/components/loop/LoopPage.tsx | 4 +- app/src/store/models/channel.ts | 16 ++++++ app/src/store/models/swap.ts | 15 +++++ app/src/store/store.ts | 12 +++- app/src/store/stores/channelStore.ts | 7 +++ app/src/store/stores/swapStore.ts | 7 +++ app/src/util/csv.ts | 64 ++++++++++++++++++++++ app/yarn.lock | 10 ++++ 10 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 app/src/util/csv.ts diff --git a/app/package.json b/app/package.json index f1aef493a..9eae7f9c9 100644 --- a/app/package.json +++ b/app/package.json @@ -21,9 +21,11 @@ "@emotion/core": "10.0.28", "@emotion/styled": "10.0.27", "@improbable-eng/grpc-web": "0.12.0", + "@types/file-saver": "2.0.1", "@types/react-virtualized": "^9.21.9", "debug": "4.1.1", "emotion-theming": "10.0.27", + "file-saver": "2.0.2", "i18next": "19.4.4", "i18next-browser-languagedetector": "4.1.1", "lottie-web": "5.6.8", diff --git a/app/src/components/history/HistoryPage.tsx b/app/src/components/history/HistoryPage.tsx index 2d3e1489c..a5303581b 100644 --- a/app/src/components/history/HistoryPage.tsx +++ b/app/src/components/history/HistoryPage.tsx @@ -14,7 +14,7 @@ const Styled = { const HistoryPage: React.FC = () => { const { l } = usePrefixedTranslation('cmps.history.HistoryPage'); - const { uiStore } = useStore(); + const { uiStore, swapStore } = useStore(); const { Wrapper } = Styled; return ( @@ -23,7 +23,7 @@ const HistoryPage: React.FC = () => { title={l('pageTitle')} backText={l('backText')} onBackClick={uiStore.goToLoop} - onExportClick={() => alert('TODO: Export CSV of Swaps')} + onExportClick={swapStore.exportSwaps} /> diff --git a/app/src/components/loop/LoopPage.tsx b/app/src/components/loop/LoopPage.tsx index 6310ca077..f3db507ad 100644 --- a/app/src/components/loop/LoopPage.tsx +++ b/app/src/components/loop/LoopPage.tsx @@ -18,7 +18,7 @@ const Styled = { const LoopPage: React.FC = () => { const { l } = usePrefixedTranslation('cmps.loop.LoopPage'); - const { uiStore, buildSwapStore } = useStore(); + const { uiStore, buildSwapStore, channelStore } = useStore(); const { PageWrap } = Styled; return ( @@ -32,7 +32,7 @@ const LoopPage: React.FC = () => { alert('TODO: Export CSV of Channels')} + onExportClick={channelStore.exportChannels} /> diff --git a/app/src/store/models/channel.ts b/app/src/store/models/channel.ts index 232eea596..1262769db 100644 --- a/app/src/store/models/channel.ts +++ b/app/src/store/models/channel.ts @@ -2,6 +2,7 @@ import { action, computed, observable } from 'mobx'; import * as LND from 'types/generated/lnd_pb'; import { getBalanceStatus } from 'util/balances'; import { BalanceMode, BalanceModes } from 'util/constants'; +import { CsvColumns } from 'util/csv'; import { Store } from 'store/store'; export default class Channel { @@ -80,4 +81,19 @@ export default class Channel { this.uptime = lndChannel.uptime; this.lifetime = lndChannel.lifetime; } + + /** + * Specifies which properties of this class should be exported to CSV + * @param key must match the name of a property on this class + * @param value the user-friendly name displayed in the CSV header + */ + static csvColumns: CsvColumns = { + chanId: 'Channel ID', + remotePubkey: 'Remote Pubkey', + capacity: 'Capacity', + localBalance: 'Local Balance', + remoteBalance: 'Remote Balance', + active: 'Active', + uptimePercent: 'Uptime Percent', + }; } diff --git a/app/src/store/models/swap.ts b/app/src/store/models/swap.ts index e2c68d999..efe98b295 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 { CsvColumns } from 'util/csv'; import { ellipseInside } from 'util/strings'; export default class Swap { @@ -108,4 +109,18 @@ export default class Swap { this.lastUpdateTime = loopSwap.lastUpdateTime; this.state = loopSwap.state; } + + /** + * Specifies which properties of this class should be exported to CSV + * @param key must match the name of a property on this class + * @param value the user-friendly name displayed in the CSV header + */ + static csvColumns: CsvColumns = { + id: 'Swap ID', + typeName: 'Type', + amount: 'Amount', + stateLabel: 'Status', + createdOnLabel: 'Created On', + updatedOnLabel: 'Updated On', + }; } diff --git a/app/src/store/store.ts b/app/src/store/store.ts index 990776238..acc6fde96 100644 --- a/app/src/store/store.ts +++ b/app/src/store/store.ts @@ -1,6 +1,7 @@ import { observable } from 'mobx'; import { IS_DEV, IS_TEST } from 'config'; import AppStorage from 'util/appStorage'; +import CsvExporter from 'util/csv'; import { actionLog, Logger } from 'util/log'; import { GrpcClient, LndApi, LoopApi } from 'api'; import { @@ -39,6 +40,9 @@ export class Store { /** the wrapper class around persistent storage */ storage: AppStorage; + /** the class to use for exporting lists of models to CSV */ + csv: CsvExporter; + // a flag to indicate when the store has completed all of its // API requests requested during initialization @observable initialized = false; @@ -47,11 +51,13 @@ export class Store { lnd: LndApi, loop: LoopApi, storage: AppStorage, + csv: CsvExporter, log: Logger, ) { this.api = { lnd, loop }; - this.log = log; this.storage = storage; + this.csv = csv; + this.log = log; } /** @@ -69,6 +75,7 @@ export class Store { /** * Creates an initialized Store instance with the dependencies injected * @param grpcClient an alternate GrpcClient to use instead of the default + * @param appStorage an alternate AppStorage to use instead of the default */ export const createStore = ( grpcClient?: GrpcClient, @@ -78,8 +85,9 @@ export const createStore = ( const storage = appStorage || new AppStorage(); const lndApi = new LndApi(grpc); const loopApi = new LoopApi(grpc); + const csv = new CsvExporter(); - const store = new Store(lndApi, loopApi, storage, actionLog); + const store = new Store(lndApi, loopApi, storage, csv, actionLog); // initialize the store immediately to fetch API data, except when running unit tests if (!IS_TEST) store.init(); diff --git a/app/src/store/stores/channelStore.ts b/app/src/store/stores/channelStore.ts index c5d947585..9a38c7397 100644 --- a/app/src/store/stores/channelStore.ts +++ b/app/src/store/stores/channelStore.ts @@ -74,4 +74,11 @@ export default class ChannelStore { this._store.log.info('updated channelStore.channels', toJS(this.channels)); }); } + + /** exports the sorted list of channels to CSV file */ + @action.bound + exportChannels() { + this._store.log.info('exporting Channels to a CSV file'); + this._store.csv.export('channels', Channel.csvColumns, toJS(this.sortedChannels)); + } } diff --git a/app/src/store/stores/swapStore.ts b/app/src/store/stores/swapStore.ts index d0caaa6ef..fcce42d76 100644 --- a/app/src/store/stores/swapStore.ts +++ b/app/src/store/stores/swapStore.ts @@ -123,4 +123,11 @@ export default class SwapStore { this._store.log.info('polling was already stopped'); } } + + /** exports the sorted list of swaps to CSV file */ + @action.bound + exportSwaps() { + this._store.log.info('exporting Swaps to a CSV file'); + this._store.csv.export('swaps', Swap.csvColumns, toJS(this.sortedSwaps)); + } } diff --git a/app/src/util/csv.ts b/app/src/util/csv.ts new file mode 100644 index 000000000..0bf7f699b --- /dev/null +++ b/app/src/util/csv.ts @@ -0,0 +1,64 @@ +import { saveAs } from 'file-saver'; + +const ROW_SEPARATOR = '\n'; +const COL_SEPARATOR = ','; + +/** + * A mapping of object property names to CSV file header names + * @param key the property name used to pluck a value from each object + * @param value the header text to display in the first row of this column + */ +export type CsvColumns = Record; + +export default class CsvExporter { + /** + * Exports data to a CSV file and prompts the user to download via the browser + * @param fileName the file name without the `csv` extension + * @param columns the columns containing keys to pluck off of each object + * @param data an array of objects containing the data + */ + export(fileName: string, columns: CsvColumns, data: any[]) { + const content = this.convert(columns, data); + const blob = new Blob([content], { type: 'text/csv;charset=utf-8' }); + saveAs(blob, `${fileName}.csv`); + } + + /** + * Converts and array of data objects into a CSV formatted string using + * the columns mapping to specify which properties to include + * @param columns the columns containing keys to pluck off of each object + * @param data an array of objects containing the data + */ + convert(columns: CsvColumns, data: any[]) { + // an array of rows in the CSV file + const rows: string[] = []; + + // add the header row + rows.push(Object.values(columns).map(this.wrap).join(',')); + + // add each row of data + data.forEach(record => { + // convert each object of data into an array of the values + const values = Object.keys(columns).reduce((cols, dataKey) => { + const value = record[dataKey]; + cols.push(this.wrap(value ? value : '')); + return cols; + }, [] as string[]); + + // convert the values to string and add to the content array + rows.push(values.join(COL_SEPARATOR)); + }); + + // convert the rows into a string + return rows.join(ROW_SEPARATOR); + } + + /** + * Wraps a value in double quotes. If the value contains a + * separator character, it would break the CSV structure + * @param value the value to wrap + */ + wrap(value: string) { + return `"${value}"`; + } +} diff --git a/app/yarn.lock b/app/yarn.lock index d3e2bf110..7e7c88639 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -2226,6 +2226,11 @@ resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g== +"@types/file-saver@2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.1.tgz#e18eb8b069e442f7b956d313f4fadd3ef887354e" + integrity sha512-g1QUuhYVVAamfCifK7oB7G3aIl4BbOyzDOqVyUfEr4tfBKrXfeH+M+Tg7HKCXSrbzxYdhyCP7z9WbKo0R2hBCw== + "@types/glob@^7.1.1": version "7.1.1" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575" @@ -6459,6 +6464,11 @@ file-loader@4.3.0, file-loader@^4.2.0: loader-utils "^1.2.3" schema-utils "^2.5.0" +file-saver@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.2.tgz#06d6e728a9ea2df2cce2f8d9e84dfcdc338ec17a" + integrity sha512-Wz3c3XQ5xroCxd1G8b7yL0Ehkf0TC9oYC6buPFkNnU9EnaPlifeAFCyCh+iewXTyFRcg0a6j3J7FmJsIhlhBdw== + file-system-cache@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/file-system-cache/-/file-system-cache-1.0.5.tgz#84259b36a2bbb8d3d6eb1021d3132ffe64cfff4f" From 41c6a96a6bdc99e4a81c7eeb66523cfd2c928ee2 Mon Sep 17 00:00:00 2001 From: jamaljsr Date: Wed, 20 May 2020 11:58:17 -0400 Subject: [PATCH 2/3] test: add unit tests for CSV export --- app/src/__mocks__/file-saver.ts | 3 +++ .../components/history/HistoryPage.spec.tsx | 7 ++++++ .../components/loop/LoopPage.spec.tsx | 12 ++++++++++ app/src/__tests__/util/csv.spec.ts | 23 +++++++++++++++++++ 4 files changed, 45 insertions(+) create mode 100644 app/src/__mocks__/file-saver.ts create mode 100644 app/src/__tests__/util/csv.spec.ts diff --git a/app/src/__mocks__/file-saver.ts b/app/src/__mocks__/file-saver.ts new file mode 100644 index 000000000..be6321447 --- /dev/null +++ b/app/src/__mocks__/file-saver.ts @@ -0,0 +1,3 @@ +export const saveAs = jest.fn(); + +export default { saveAs }; diff --git a/app/src/__tests__/components/history/HistoryPage.spec.tsx b/app/src/__tests__/components/history/HistoryPage.spec.tsx index 1759e2ed5..f8941db95 100644 --- a/app/src/__tests__/components/history/HistoryPage.spec.tsx +++ b/app/src/__tests__/components/history/HistoryPage.spec.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { fireEvent } from '@testing-library/react'; +import { saveAs } from 'file-saver'; import { renderWithProviders } from 'util/tests'; import { createStore, Store } from 'store'; import HistoryPage from 'components/history/HistoryPage'; @@ -50,4 +51,10 @@ describe('HistoryPage', () => { fireEvent.click(getByText('arrow-left.svg')); expect(store.uiStore.page).toEqual('loop'); }); + + it('should export channels', () => { + const { getByText } = render(); + fireEvent.click(getByText('download.svg')); + expect(saveAs).toBeCalledWith(expect.any(Blob), 'swaps.csv'); + }); }); diff --git a/app/src/__tests__/components/loop/LoopPage.spec.tsx b/app/src/__tests__/components/loop/LoopPage.spec.tsx index 953184051..ce43fd73b 100644 --- a/app/src/__tests__/components/loop/LoopPage.spec.tsx +++ b/app/src/__tests__/components/loop/LoopPage.spec.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { SwapStatus } from 'types/generated/loop_pb'; import { grpc } from '@improbable-eng/grpc-web'; import { fireEvent, waitFor } from '@testing-library/react'; +import { saveAs } from 'file-saver'; import { formatSats } from 'util/formatters'; import { renderWithProviders } from 'util/tests'; import { loopListSwaps } from 'util/tests/sampleData'; @@ -57,6 +58,17 @@ describe('LoopPage component', () => { expect(await findByText('525,000 sats')).toBeInTheDocument(); }); + it('should display the export icon', () => { + const { getByText } = render(); + expect(getByText('download.svg')).toBeInTheDocument(); + }); + + it('should export channels', () => { + const { getByText } = render(); + fireEvent.click(getByText('download.svg')); + expect(saveAs).toBeCalledWith(expect.any(Blob), 'channels.csv'); + }); + describe('Swap Process', () => { it('should display actions bar when Loop button is clicked', () => { const { getByText } = render(); diff --git a/app/src/__tests__/util/csv.spec.ts b/app/src/__tests__/util/csv.spec.ts new file mode 100644 index 000000000..4e511e738 --- /dev/null +++ b/app/src/__tests__/util/csv.spec.ts @@ -0,0 +1,23 @@ +import { saveAs } from 'file-saver'; +import CsvExporter from 'util/csv'; +import { loopListSwaps } from 'util/tests/sampleData'; +import { Swap } from 'store/models'; + +describe('csv Util', () => { + const csv = new CsvExporter(); + const swaps = [new Swap(loopListSwaps.swapsList[0])]; + + it('should export using the .csv extension', () => { + csv.export('swaps', Swap.csvColumns, swaps); + expect(saveAs).toBeCalledWith(expect.any(Blob), 'swaps.csv'); + }); + + it('should convert swap data to the correct string', () => { + const actual = csv.convert(Swap.csvColumns, swaps); + const expected = [ + '"Swap ID","Type","Amount","Status","Created On","Updated On"', + '"f4eb118383c2b09d8c7289ce21c25900cfb4545d46c47ed23a31ad2aa57ce830","Loop Out","500000","Failed","4/8/2020 7:59:13 PM","4/8/2020 10:12:49 PM"', + ].join('\n'); + expect(actual).toEqual(expected); + }); +}); From 51f76c701849cd19d59ca1b2d156dcbcd7cd0c39 Mon Sep 17 00:00:00 2001 From: jamaljsr Date: Wed, 20 May 2020 12:46:44 -0400 Subject: [PATCH 3/3] test: always use UTC timezone for unit tests --- app/package.json | 1 + app/src/__tests__/timezone.ts | 22 ++++++++++++++++++++++ app/src/__tests__/util/csv.spec.ts | 2 +- app/src/setupTestsGlobal.ts | 3 +++ 4 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 app/src/__tests__/timezone.ts create mode 100644 app/src/setupTestsGlobal.ts diff --git a/app/package.json b/app/package.json index 9eae7f9c9..8ab1b5af2 100644 --- a/app/package.json +++ b/app/package.json @@ -76,6 +76,7 @@ ] }, "jest": { + "globalSetup": "./src/setupTestsGlobal.ts", "collectCoverageFrom": [ "src/**/*.{js,jsx,ts,tsx}", "!src/**/*.d.ts", diff --git a/app/src/__tests__/timezone.ts b/app/src/__tests__/timezone.ts new file mode 100644 index 000000000..f0c29793d --- /dev/null +++ b/app/src/__tests__/timezone.ts @@ -0,0 +1,22 @@ +import { loopListSwaps } from 'util/tests/sampleData'; +import { Swap } from 'store/models'; + +/** + * These test just ensure that the test runner is executing with + * the system time zone set to UTC. This prevents tests from passing + * on one machine and failing on another due to different time zones + * + * The `process.env.TZ` value is set to UTC in the jest global + * config file setupTestsGlobal.ts + */ +describe('Timezone', () => { + it('should always run unit tests in UTC', () => { + expect(new Date().getTimezoneOffset()).toBe(0); + }); + + it('should format the swap timestamps correctly', () => { + const swap = new Swap(loopListSwaps.swapsList[0]); + expect(swap.createdOnLabel).toEqual('4/8/2020 11:59:13 PM'); + expect(swap.updatedOnLabel).toEqual('4/9/2020 2:12:49 AM'); + }); +}); diff --git a/app/src/__tests__/util/csv.spec.ts b/app/src/__tests__/util/csv.spec.ts index 4e511e738..a7805e733 100644 --- a/app/src/__tests__/util/csv.spec.ts +++ b/app/src/__tests__/util/csv.spec.ts @@ -16,7 +16,7 @@ describe('csv Util', () => { const actual = csv.convert(Swap.csvColumns, swaps); const expected = [ '"Swap ID","Type","Amount","Status","Created On","Updated On"', - '"f4eb118383c2b09d8c7289ce21c25900cfb4545d46c47ed23a31ad2aa57ce830","Loop Out","500000","Failed","4/8/2020 7:59:13 PM","4/8/2020 10:12:49 PM"', + '"f4eb118383c2b09d8c7289ce21c25900cfb4545d46c47ed23a31ad2aa57ce830","Loop Out","500000","Failed","4/8/2020 11:59:13 PM","4/9/2020 2:12:49 AM"', ].join('\n'); expect(actual).toEqual(expected); }); diff --git a/app/src/setupTestsGlobal.ts b/app/src/setupTestsGlobal.ts new file mode 100644 index 000000000..67ca0db63 --- /dev/null +++ b/app/src/setupTestsGlobal.ts @@ -0,0 +1,3 @@ +export default () => { + process.env.TZ = 'UTC'; +};