diff --git a/app/package.json b/app/package.json
index f1aef493a..8ab1b5af2 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",
@@ -74,6 +76,7 @@
]
},
"jest": {
+ "globalSetup": "./src/setupTestsGlobal.ts",
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}",
"!src/**/*.d.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__/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
new file mode 100644
index 000000000..a7805e733
--- /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 11:59:13 PM","4/9/2020 2:12:49 AM"',
+ ].join('\n');
+ expect(actual).toEqual(expected);
+ });
+});
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/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';
+};
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"