diff --git a/package-lock.json b/package-lock.json index da407134f..21f5ad4d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3051,6 +3051,12 @@ "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==", "dev": true }, + "@types/file-saver": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.1.tgz", + "integrity": "sha512-g1QUuhYVVAamfCifK7oB7G3aIl4BbOyzDOqVyUfEr4tfBKrXfeH+M+Tg7HKCXSrbzxYdhyCP7z9WbKo0R2hBCw==", + "dev": true + }, "@types/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.0.tgz", @@ -3108,6 +3114,15 @@ "integrity": "sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==", "dev": true }, + "@types/json2csv": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@types/json2csv/-/json2csv-5.0.1.tgz", + "integrity": "sha512-1r5GCTyFtdQ53CRSIctzWZCmtDXvxtzM77SzOqPB4woMeGcc3rhUMzPqEQH3rokG1k/QLzlC5Qe5Ih8NuFN70Q==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/lodash": { "version": "4.14.149", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz", @@ -11610,6 +11625,11 @@ "schema-utils": "^2.5.0" } }, + "file-saver": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.2.tgz", + "integrity": "sha512-Wz3c3XQ5xroCxd1G8b7yL0Ehkf0TC9oYC6buPFkNnU9EnaPlifeAFCyCh+iewXTyFRcg0a6j3J7FmJsIhlhBdw==" + }, "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -18082,6 +18102,23 @@ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, + "json2csv": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json2csv/-/json2csv-5.0.1.tgz", + "integrity": "sha512-QFMifUX1y8W2tKi2TwZpnzf2rHdZvzdmgZUMEMDF46F90f4a9mUeWfx/qg4kzXSZYJYc3cWA5O+eLXk5lj9g8g==", + "requires": { + "commander": "^5.0.0", + "jsonparse": "^1.3.1", + "lodash.get": "^4.4.2" + }, + "dependencies": { + "commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==" + } + } + }, "json3": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.3.tgz", @@ -18103,6 +18140,11 @@ "graceful-fs": "^4.1.6" } }, + "jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=" + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -18376,8 +18418,7 @@ "lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", - "dev": true + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" }, "lodash.isequal": { "version": "4.5.0", diff --git a/package.json b/package.json index 1526acbd1..ca2d685f7 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "animate.css": "^3.7.2", "await-lock": "^2.0.1", "axios": "^0.19.0", + "file-saver": "^2.0.2", + "json2csv": "^5.0.1", "request": "^2.88.0", "rss-parser": "^3.7.3", "rxjs": "^6.5.2", @@ -56,9 +58,11 @@ "vuex": "^3.0.1" }, "devDependencies": { - "@babel/core": "^7.7.2", "@babel/compat-data": "^7.9.6", + "@babel/core": "^7.7.2", + "@types/file-saver": "^2.0.1", "@types/jest": "^23.3.1", + "@types/json2csv": "^5.0.1", "@types/lodash": "^4.14.149", "@types/node": "^9.6.50", "@types/request": "^2.47.1", diff --git a/src/components/TransactionList/TransactionList.less b/src/components/TransactionList/TransactionList.less index 45a2cd9c6..d64d2566e 100644 --- a/src/components/TransactionList/TransactionList.less +++ b/src/components/TransactionList/TransactionList.less @@ -89,7 +89,10 @@ text-align: right; width: 100%; display: block; - + .download-transaction { + margin-top: 0.08rem; + margin-left: 0.3rem; + } .ivu-page-item { min-width: 0.31rem; height: 0.3rem; diff --git a/src/components/TransactionList/TransactionList.vue b/src/components/TransactionList/TransactionList.vue index 1323386f6..1140fe5c2 100644 --- a/src/components/TransactionList/TransactionList.vue +++ b/src/components/TransactionList/TransactionList.vue @@ -10,6 +10,12 @@ />
+
@@ -27,6 +33,11 @@ :transaction="activePartialTransaction" @close="onCloseCosignatureModal" /> + diff --git a/src/components/TransactionList/TransactionListTs.ts b/src/components/TransactionList/TransactionListTs.ts index c40ab3b96..d31cf7040 100644 --- a/src/components/TransactionList/TransactionListTs.ts +++ b/src/components/TransactionList/TransactionListTs.ts @@ -29,8 +29,9 @@ import PageTitle from '@/components/PageTitle/PageTitle.vue' import TransactionListFilters from '@/components/TransactionList/TransactionListFilters/TransactionListFilters.vue' // @ts-ignore import TransactionTable from '@/components/TransactionList/TransactionTable/TransactionTable.vue' -import { TransactionGroupState } from '@/store/Transaction' - +// @ts-ignore +import ModalTransactionExport from '@/views/modals/ModalTransactionExport/ModalTransactionExport.vue' +import { TransactionGroup } from '@/store/Transaction' @Component({ components: { ModalTransactionCosignature, @@ -38,6 +39,7 @@ import { TransactionGroupState } from '@/store/Transaction' PageTitle, TransactionListFilters, TransactionTable, + ModalTransactionExport, }, computed: { ...mapGetters({ @@ -126,6 +128,12 @@ export class TransactionListTs extends Vue { */ public isAwaitingCosignature: boolean = false + /** + * Whether currently viewing export + * @var {boolean} + */ + public isViewingExportModal: boolean = false + public getEmptyMessage() { return this.displayedTransactionStatus === TransactionGroupState.all ? 'no_data_transactions' @@ -233,4 +241,15 @@ export class TransactionListTs extends Vue { else if (page < 1) page = 1 this.currentPage = page } + + public get hasTransactionExportModal(): boolean { + return this.isViewingExportModal + } + + public set hasTransactionExportModal(f: boolean) { + this.isViewingExportModal = f + } + public downloadTransaction() { + this.hasTransactionExportModal = true + } } diff --git a/src/core/utils/CSVHelpers.ts b/src/core/utils/CSVHelpers.ts new file mode 100644 index 000000000..6242d3d4f --- /dev/null +++ b/src/core/utils/CSVHelpers.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2020 NEM Foundation (https://nem.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and limitations under the License. + * + */ +import { Parser } from 'json2csv' +import FileSaver from 'file-saver' +import store from '@/store/index.ts' +import { TransactionViewFactory } from '@/core/transactions/TransactionViewFactory' +import { TransactionView } from '@/core/transactions/TransactionView' +import { AggregateTransaction, Transaction } from 'symbol-sdk' +import * as _ from 'lodash' + +export class CSVHelpers { + protected static views: TransactionView[] = [] + private static transactionsArray = [] + + /** + * Export to csv file + * @param data array of transactions + * returns new array with parsed aggregate transaction objects + */ + private static constructAggregateTransactionsObject(transaction: AggregateTransaction) { + let merged = {} + const result = {} + this.views = [ + TransactionViewFactory.getView(store, transaction), + ...transaction.innerTransactions.map((tx) => TransactionViewFactory.getView(store, tx)), + ] + this.views.forEach((value) => { + let mergedRow = {} + mergedRow = value.headerItems.concat(value.detailItems) + merged = _.defaults(merged, mergedRow) + }) + + const mergedArray = Object.entries(merged) + for (let i = 0; i < mergedArray.length; i++) { + if (mergedArray[i][1]['key'] == 'paid_fee') { + result[mergedArray[i][1]['key']] = mergedArray[i][1]['value'].maxFee.compact().toString() + } else if (mergedArray[i][1]['key'] == 'transfer_target') { + result[mergedArray[i]['key']] = mergedArray[i][1]['value'].address + } else { + result[mergedArray[i][1]['key']] = mergedArray[i][1]['value'] + } + } + return result + } + + /** + * Export to csv file + * @param data array of transactions + * returns new array with parsed transaction objects + */ + private static constructTransactionsObject(transaction: Transaction) { + this.views = [TransactionViewFactory.getView(store, transaction)] + const result = {} + const value = this.views[0].headerItems.concat(this.views[0].detailItems) + for (let i = 0; i < value.length; i++) { + if (value[i].key == 'paid_fee') { + result[value[i].key] = value[i].value.maxFee.compact().toString() + } else if (value[i].key == 'transfer_target') { + result[value[i].key] = value[i].value.address + } else if (value[i].key.startsWith('Mosaic')) { + result[value[i].key] = value[i].value.amount + ' (' + value[i].value.mosaicHex + ')' + } else { + result[value[i].key] = value[i].value + } + } + return result + } + + /** + * Export to csv file + * @param data array of transactions + * returns new array with parsed transaction objects + */ + + private static prepareTransactions(data: []): any[] { + this.transactionsArray = [] + data.forEach((transaction) => { + let result = {} + if (transaction instanceof AggregateTransaction) { + result = this.constructAggregateTransactionsObject(transaction) + } else { + result = this.constructTransactionsObject(transaction) + } + this.transactionsArray.push(result) + }) + return this.transactionsArray + } + + /** + * Export to csv file + * @param data The json data + * @param filename Prefix the name of the CSV file + */ + public static exportCSV(data: any, filename: string) { + const json2csvParser = new Parser() + const parsedTransactions = this.prepareTransactions(data) + const csvData = json2csvParser.parse(parsedTransactions) + const blob = new Blob(['\uFEFF' + csvData], { type: 'text/plain;charset=utf-8;' }) + const exportFilename = `${filename}-${Date.now()}.csv` + return FileSaver.saveAs(blob, exportFilename) + } +} diff --git a/src/language/en-US.json b/src/language/en-US.json index 3353534ee..7a7eac2b2 100644 --- a/src/language/en-US.json +++ b/src/language/en-US.json @@ -42,6 +42,8 @@ "accounts_backup_title_keystore": "Download keys", "accounts_backup_title_mnemonic": "Mnemonic passphrase", "accounts_backup_title_qrcode": "Encrypted Mnemonic QRCode", + "accounts_backup_transactions": "Export the transaction list to a CSV file", + "accounts_backup_transactions_description": "Download all the transaction information, save it locally and view it even if you don't have an Internet connection", "accounts_change_password_description": "Change your password", "accounts_change_password_title": "Change Password", "accounts_create_invoice": "Create an invoice", @@ -292,6 +294,7 @@ "modal_account_unlock_title": "Unlock your account", "modal_title_backup_mnemonic_display": "Display Mnemonic Passphrase", "modal_title_backup_mnemonic_qrcode": "Export Mnemonic QR Code", + "modal_title_backup_transaction": "Export Transactions", "modal_title_debug_console": "Diagnostic Console", "modal_title_enter_account_name": "Configure your new account", "modal_title_extend_namespace_duration": "Extend namespace duration", diff --git a/src/language/ja-JP.json b/src/language/ja-JP.json index 278577043..2a94213f9 100644 --- a/src/language/ja-JP.json +++ b/src/language/ja-JP.json @@ -42,6 +42,8 @@ "accounts_backup_title_keystore": "キーのダウンロード", "accounts_backup_title_mnemonic": "ニーモニックパス語群", "accounts_backup_title_qrcode": "ニーモニックQRコードの暗号化", + "accounts_backup_transactions": "トランザクションリストをCSVファイルにエクスポートする", + "accounts_backup_transactions_description": "すべての取引情報をダウンロードしてローカルに保存し、インターネットに接続していない場合でも表示します", "accounts_change_password_description": "パスワードを変更", "accounts_change_password_title": "パスワードを変更", "accounts_create_invoice": "インボイスの作成", @@ -292,6 +294,7 @@ "modal_account_unlock_title": "アカウントのアンロック", "modal_title_backup_mnemonic_display": "ニーモニックパス語群の表示", "modal_title_backup_mnemonic_qrcode": "ニーモニックQRコードのエクスポート", + "modal_title_backup_transaction": "輸出取引", "modal_title_debug_console": "診断コンソール", "modal_title_enter_account_name": "新しいアカウントを設定する", "modal_title_extend_namespace_duration": "ネームスペース期限の延長", diff --git a/src/language/zh-CN.json b/src/language/zh-CN.json index 42e7b7e57..5c16ed476 100644 --- a/src/language/zh-CN.json +++ b/src/language/zh-CN.json @@ -43,6 +43,8 @@ "accounts_backup_title_mnemonic": "查看助记词", "accounts_backup_title_qrcode": "备份助记词", "accounts_change_password_description": "更改密码", + "accounts_backup_transactions": "将交易清单汇出至CSV档案", + "accounts_backup_transactions_description": "下载所有交易信息,保存在本地并查看,即使您没有互联网连接", "accounts_change_password_title": "修改密码", "accounts_create_invoice": "创建发票", "accounts_flags_default_account": "默认钱包", @@ -292,6 +294,7 @@ "modal_account_unlock_title": "解锁钱包", "modal_title_backup_mnemonic_display": "显示助记词", "modal_title_backup_mnemonic_qrcode": "导出助记词二维码", + "modal_title_backup_transaction": "出口交易", "modal_title_debug_console": "诊断控制台", "modal_title_enter_account_name": "配置新钱包", "modal_title_extend_namespace_duration": "延长命名空间持续时间", diff --git a/src/views/modals/ModalTransactionExport/ModalTransactionExport.less b/src/views/modals/ModalTransactionExport/ModalTransactionExport.less new file mode 100644 index 000000000..be9ab556f --- /dev/null +++ b/src/views/modals/ModalTransactionExport/ModalTransactionExport.less @@ -0,0 +1,54 @@ +/* + * Copyright 2020 NEM Foundation (https://nem.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and limitations under the License. + * + */ +@import '../../resources/css/variables.less'; + +.body { + width: 100%; + display: grid; + grid-template-rows: 1.4rem 2rem 1rem; + grid-template-columns: 100%; + + .explain { + padding: 0.2rem; + padding-left: 0.4rem; + font-size: @normalFont; + + .subtitle { + color: @primary; + font-weight: @boldest; + } + + p { + padding-top: 0.05rem; + text-align: justify; + } + } + + .qr-image { + width: 2rem; + height: 2rem; + margin: auto; + } + + .download-button { + margin: auto; + padding: 0.1rem 0.2rem; + border-radius: 0.04rem; + span { + vertical-align: text-bottom; + } + } +} diff --git a/src/views/modals/ModalTransactionExport/ModalTransactionExport.vue b/src/views/modals/ModalTransactionExport/ModalTransactionExport.vue new file mode 100644 index 000000000..e26e30b07 --- /dev/null +++ b/src/views/modals/ModalTransactionExport/ModalTransactionExport.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/src/views/modals/ModalTransactionExport/ModalTransactionExportTs.ts b/src/views/modals/ModalTransactionExport/ModalTransactionExportTs.ts new file mode 100644 index 000000000..e08a61cba --- /dev/null +++ b/src/views/modals/ModalTransactionExport/ModalTransactionExportTs.ts @@ -0,0 +1,109 @@ +/* + * Copyright 2020 NEM Foundation (https://nem.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and limitations under the License. + * + */ +import { Component, Prop, Vue } from 'vue-property-decorator' +import { mapGetters } from 'vuex' +import { Transaction } from 'symbol-sdk' + +// child components +// @ts-ignore +import FormProfileUnlock from '@/views/forms/FormProfileUnlock/FormProfileUnlock.vue' +import { CSVHelpers } from '@/core/utils/CSVHelpers' + +@Component({ + components: { FormProfileUnlock }, + computed: { + ...mapGetters({ + confirmedTransactions: 'transaction/confirmedTransactions', + partialTransactions: 'transaction/partialTransactions', + unconfirmedTransactions: 'transaction/unconfirmedTransactions', + }), + }, +}) +export class ModalTransactionExportTs extends Vue { + @Prop({ + default: false, + }) + visible: boolean + + public hasTransactionExportInfo: boolean = false + + /** + * List of confirmed transactions (per-request) + */ + public confirmedTransactions: Transaction[] + + /** + * List of unconfirmed transactions (per-request) + */ + public unconfirmedTransactions: Transaction[] + + /** + * List of confirmed transactions (per-request) + */ + public partialTransactions: Transaction[] + + /** + * Visibility state + * @type {boolean} + */ + get show(): boolean { + return this.visible + } + + /** + * Emits close event + */ + set show(val) { + if (!val) { + this.$emit('close') + } + } + + /** + * Hook called when the account has been unlocked + * @return {boolean} + */ + public onAccountUnlocked(): boolean { + this.hasTransactionExportInfo = true + return true + } + + /** + * Hook called when child component FormProfileUnlock or + * HardwareConfirmationButton emit the 'error' event. + * @param {string} message + * @return {void} + */ + public onError(error: string) { + this.$emit('error', error) + } + + /** + * The download transactions csv + * @return {void} + */ + public onDownloadTx() { + const transactions = this.getTransactions() + CSVHelpers.exportCSV(transactions, 'transactions') + } + + /** + * Transactions list + */ + private getTransactions() { + return [...this.unconfirmedTransactions, ...this.partialTransactions, ...this.confirmedTransactions] + } +}