Skip to content

Implement CSV export for channels and swaps #42

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 23, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -74,6 +76,7 @@
]
},
"jest": {
"globalSetup": "./src/setupTestsGlobal.ts",
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}",
"!src/**/*.d.ts",
Expand Down
3 changes: 3 additions & 0 deletions app/src/__mocks__/file-saver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const saveAs = jest.fn();

export default { saveAs };
7 changes: 7 additions & 0 deletions app/src/__tests__/components/history/HistoryPage.spec.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
});
});
12 changes: 12 additions & 0 deletions app/src/__tests__/components/loop/LoopPage.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
22 changes: 22 additions & 0 deletions app/src/__tests__/timezone.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
23 changes: 23 additions & 0 deletions app/src/__tests__/util/csv.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
4 changes: 2 additions & 2 deletions app/src/components/history/HistoryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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}
/>
<HistoryList />
</Wrapper>
Expand Down
4 changes: 2 additions & 2 deletions app/src/components/loop/LoopPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -32,7 +32,7 @@ const LoopPage: React.FC = () => {
<PageHeader
title={l('pageTitle')}
onHistoryClick={uiStore.goToHistory}
onExportClick={() => alert('TODO: Export CSV of Channels')}
onExportClick={channelStore.exportChannels}
/>
<LoopTiles />
<LoopActions />
Expand Down
3 changes: 3 additions & 0 deletions app/src/setupTestsGlobal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default () => {
process.env.TZ = 'UTC';
};
16 changes: 16 additions & 0 deletions app/src/store/models/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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',
};
}
15 changes: 15 additions & 0 deletions app/src/store/models/swap.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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',
};
}
12 changes: 10 additions & 2 deletions app/src/store/store.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -39,6 +40,9 @@ export class Store {
/** the wrapper class around persistent storage */
storage: AppStorage<PersistentSettings>;

/** 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;
Expand All @@ -47,11 +51,13 @@ export class Store {
lnd: LndApi,
loop: LoopApi,
storage: AppStorage<PersistentSettings>,
csv: CsvExporter,
log: Logger,
) {
this.api = { lnd, loop };
this.log = log;
this.storage = storage;
this.csv = csv;
this.log = log;
}

/**
Expand All @@ -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,
Expand All @@ -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();

Expand Down
7 changes: 7 additions & 0 deletions app/src/store/stores/channelStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
7 changes: 7 additions & 0 deletions app/src/store/stores/swapStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
64 changes: 64 additions & 0 deletions app/src/util/csv.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

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}"`;
}
}
10 changes: 10 additions & 0 deletions app/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2226,6 +2226,11 @@
resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"
integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==

"@types/[email protected]":
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"
Expand Down Expand Up @@ -6459,6 +6464,11 @@ [email protected], file-loader@^4.2.0:
loader-utils "^1.2.3"
schema-utils "^2.5.0"

[email protected]:
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"
Expand Down