Repository: rotki/rotki

Issue:
Show all user trades/actions in a new page
## Problem Definition

We have the user data action but users can't actually explore it in any way in the UI. That's not good and we should definitely offer a way to display all actions/trades in a dedicated page.

That would mean showing user's:
- Exchange trades
- Exchange deposit/withdrawals
- DEX trades (when we support them)


## Considerations

Number of visible actions will be limited whether user has premium or not. Need to think a bit here on how to make this a premium feature but also available to normal users. At the moment can only think of limit in the number of trades/actions shown.

## Task

## Backend
The backend already has an api call for this. It's actually being used with a filter for external location for the OTC trades.

## Frontend

The frontend need a new dedicated page. User tactions or something like that. And there all trades, deposits/withdrawals of all locations should be displayed. This is going to be a very long list in most cases so we should think on optimizations and how to show the data in visually appealing and also useful way.

I would imagine it would make sense to have different tables for trades, different for asset movements (deposit/withdrawals) etc. And perhaps a differen component per location. Or simply offer filtering.



Discussion:

Resolving patch:
diff --git a/tools/data_faker/data_faker/actions.py b/tools/data_faker/data_faker/actions.py
--- a/tools/data_faker/data_faker/actions.py
+++ b/tools/data_faker/data_faker/actions.py
@@ -79,7 +79,7 @@ def __init__(
                 if asset in FIAT_CURRENCIES:
                     self.rotki.data.db.add_manually_tracked_balances([ManuallyTrackedBalance(
                         asset=asset,
-                        label=f'{asset.identifier} balance',
+                        label=f'{asset.identifier} balance {timestamp}',
                         amount=value,
                         location=Location.BANKS,
                         tags=None,
@@ -104,8 +104,11 @@ def generate_history(self) -> None:
             current_ts, save_balances, make_trade = self.get_next_ts()
 
             if make_trade:
-                self.create_action(created_trades, current_ts)
-                created_trades += 1
+                try:
+                    self.create_action(created_trades, current_ts)
+                    created_trades += 1
+                except Exception as e:
+                    logger.error(f'failed to create trade: {e}')
 
             if save_balances:
                 self.maybe_save_balances(save_ts=current_ts)


Test patch:
diff --git a/frontend/app/tests/e2e/pages/history-page.ts b/frontend/app/tests/e2e/pages/history-page.ts
new file mode 100644
--- /dev/null
+++ b/frontend/app/tests/e2e/pages/history-page.ts
@@ -0,0 +1,67 @@
+import { OTCTrade } from '../support/types';
+
+export class HistoryPage {
+  visit() {
+    cy.get('.v-app-bar__nav-icon').click();
+    cy.get('.navigation__history').click();
+  }
+
+  addTrade(otcData: OTCTrade) {
+    cy.get('.closed-trades__add-trade').click();
+    cy.get('.otc-form__date')
+      .type(`{selectall}{backspace}${otcData.time}`)
+      .click(); // Click is needed to hide the popup
+    cy.get('.otc-form__pair').type(otcData.pair);
+    cy.get('.otc-form__type input').check(otcData.trade_type, { force: true });
+    cy.get('.otc-form__amount').type(otcData.amount);
+    cy.get('.otc-form__rate input').type(otcData.rate);
+    cy.get('.otc-form__fee').type(otcData.fee);
+    cy.get('.otc-form__fee-currency').type(otcData.fee_currency);
+    cy.get(`#asset-${otcData.fee_currency.toLocaleLowerCase()}`).click();
+    cy.get('.otc-form__link').type(otcData.link);
+    cy.get('.otc-form__notes').type(otcData.notes);
+    cy.get('.big-dialog__buttons__confirm').click();
+  }
+
+  confirmDelete() {
+    cy.get('.confirm-dialog__title').should('contain', 'Delete Trade');
+    cy.get('.confirm-dialog__buttons__confirm').click();
+    cy.get('.confirm-dialog__title').should('not.be.visible');
+  }
+
+  visibleEntries(visible: number) {
+    cy.get('.closed-trades tbody').find('tr').should('have.length', visible);
+  }
+
+  tradeIsVisible(position: number, otcTrade: OTCTrade) {
+    cy.get('.closed-trades tbody > tr').eq(position).as('row');
+
+    cy.get('@row').find('td').eq(2).should('contain', otcTrade.pair);
+    cy.get('@row')
+      .find('td')
+      .eq(4)
+      .find('.amount-display__value')
+      .should('contain', otcTrade.amount);
+    cy.get('@row').find('td').eq(1).should('contain', otcTrade.trade_type);
+  }
+
+  editTrade(position: number, amount: string) {
+    cy.get('.closed-trades tbody > tr')
+      .eq(position)
+      .find('button.closed-trades__trade__actions__edit')
+      .click();
+
+    cy.get('.otc-form__amount input').clear();
+    cy.get('.otc-form__amount').type(amount);
+
+    cy.get('.big-dialog__buttons__confirm').click();
+    cy.get('.big-dialog').should('not.be.visible');
+  }
+
+  deleteTrade(position: number) {
+    cy.get('.closed-trades tbody > tr')
+      .eq(position)
+      .find('.closed-trades__trade__actions__delete')
+      .click();
+  }
+}
diff --git a/frontend/app/tests/e2e/pages/otc-page.ts b/frontend/app/tests/e2e/pages/otc-page.ts
deleted file mode 100644
--- a/frontend/app/tests/e2e/pages/otc-page.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { OTCTrade } from '../support/types';
-
-export class OtcPage {
-  visit() {
-    cy.get('.v-app-bar__nav-icon').click();
-    cy.get('.navigation__trades').click();
-    cy.get('.navigation__otc-trades').click();
-  }
-
-  addTrade(otcData: OTCTrade) {
-    cy.get('.otc-form__date').type(otcData.time).click(); // Click is needed to hide the popup
-    cy.get('.otc-form__pair').type(otcData.pair);
-    cy.get('.otc-form__type input').check(otcData.trade_type, { force: true });
-    cy.get('.otc-form__amount').type(otcData.amount);
-    cy.get('.otc-form__rate').type(otcData.rate);
-    cy.get('.otc-form__fee').type(otcData.fee);
-    cy.get('.otc-form__fee-currency').type(otcData.fee_currency);
-    cy.get('.otc-form__link').type(otcData.link);
-    cy.get('.otc-form__notes').type(otcData.notes);
-    cy.get('.otc-form__buttons__save').click();
-  }
-
-  confirmSuccess() {
-    cy.get('.message-dialog__title').should('contain', 'Success');
-    cy.get('.message-dialog__buttons__confirm').click();
-  }
-
-  confirmDelete() {
-    cy.get('.confirm-dialog__title').should('contain', 'Delete OTC Trade');
-    cy.get('.confirm-dialog__buttons__confirm').click();
-    cy.get('.confirm-dialog__title').should('not.be.visible');
-  }
-
-  visibleEntries(visible: number) {
-    cy.get('.otc-trades tbody').find('tr').should('have.length', visible);
-  }
-
-  tradeIsVisible(position: number, otcTrade: OTCTrade) {
-    cy.get('.otc-trades tbody > tr').eq(position).as('row');
-
-    cy.get('@row')
-      .find('.otc-trades__trade__pair')
-      .should('contain', otcTrade.pair);
-
-    cy.get('@row')
-      .find('.otc-trades__trade__amount')
-      .should('contain', otcTrade.amount);
-
-    cy.get('@row')
-      .find('.otc-trades__trade__type')
-      .should('contain', otcTrade.trade_type);
-  }
-
-  editTrade(position: number, amount: string) {
-    cy.get('.otc-trades tbody > tr')
-      .eq(position)
-      .find('button.otc-trades__trade__actions__edit')
-      .click();
-
-    cy.get('.otc-form__amount input').clear();
-    cy.get('.otc-form__amount').type(amount);
-
-    cy.get('.otc-form__buttons__save').click();
-  }
-
-  deleteTrade(position: number) {
-    cy.get('.otc-trades tbody > tr')
-      .eq(position)
-      .find('.otc-trades__trade__actions__delete')
-      .click();
-  }
-}
diff --git a/frontend/app/tests/e2e/pages/rotki-app.ts b/frontend/app/tests/e2e/pages/rotki-app.ts
--- a/frontend/app/tests/e2e/pages/rotki-app.ts
+++ b/frontend/app/tests/e2e/pages/rotki-app.ts
@@ -1,5 +1,5 @@
 import axios from 'axios';
-import { ActionResult } from '@/model/action-result';
+import { ActionResult } from '@/services/types-api';
 
 export class RotkiApp {
   visit() {
@@ -34,7 +34,7 @@ export class RotkiApp {
   logout() {
     cy.get('.user-dropdown').click();
     cy.get('.user-dropdown__logout').click();
-    cy.get('.confirm-dialog__buttons__confirm').click();
+    cy.get('.confirm-dialog__buttons__confirm').filter(':visible').click();
     cy.get('.login__fields__username').should('be.visible');
   }
 
diff --git a/frontend/app/tests/e2e/specs/otc-trades.spec.ts b/frontend/app/tests/e2e/specs/history.spec.ts
similarity index 76%
rename from frontend/app/tests/e2e/specs/otc-trades.spec.ts
rename to frontend/app/tests/e2e/specs/history.spec.ts
--- a/frontend/app/tests/e2e/specs/otc-trades.spec.ts
+++ b/frontend/app/tests/e2e/specs/history.spec.ts
@@ -1,18 +1,18 @@
 import { Guid } from '../../common/guid';
-import { OtcPage } from '../pages/otc-page';
+import { HistoryPage } from '../pages/history-page';
 import { RotkiApp } from '../pages/rotki-app';
 import { OTCTrade } from '../support/types';
 
-describe('otc trades', () => {
+describe('trades', () => {
   let username: string;
   let app: RotkiApp;
-  let page: OtcPage;
+  let page: HistoryPage;
   let otcData: OTCTrade[];
 
   before(() => {
     username = Guid.newGuid().toString();
     app = new RotkiApp();
-    page = new OtcPage();
+    page = new HistoryPage();
     app.visit();
     app.createAccount(username);
     cy.fixture('otc').then(otc => {
@@ -24,12 +24,10 @@ describe('otc trades', () => {
     app.logout();
   });
 
-  it('add two trades', () => {
+  it('add two external trades', () => {
     page.visit();
     page.addTrade(otcData[0]);
-    page.confirmSuccess();
     page.addTrade(otcData[1]);
-    page.confirmSuccess();
   });
 
   it('displays two trades', () => {
@@ -38,16 +36,15 @@ describe('otc trades', () => {
     page.tradeIsVisible(1, otcData[1]);
   });
 
-  it('edit trade', () => {
+  it('edit external trade', () => {
     page.editTrade(0, '123.2');
-    page.confirmSuccess();
     page.tradeIsVisible(0, {
       ...otcData[0],
       amount: '123.2'
     });
   });
 
-  it('delete trade', () => {
+  it('delete external trade', () => {
     page.deleteTrade(0);
     page.confirmDelete();
     page.visibleEntries(1);
diff --git a/frontend/app/tests/unit/components/helper/tab-navigation.spec.ts b/frontend/app/tests/unit/components/helper/tab-navigation.spec.ts
new file mode 100644
--- /dev/null
+++ b/frontend/app/tests/unit/components/helper/tab-navigation.spec.ts
@@ -0,0 +1,43 @@
+import { mount, ThisTypedMountOptions, Wrapper } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuetify from 'vuetify';
+import Vuex from 'vuex';
+import TabNavigation from '@/components/helper/TabNavigation.vue';
+import store from '@/store/store';
+
+Vue.use(Vuetify);
+Vue.use(Vuex);
+
+describe('TabNavigation.vue', () => {
+  let wrapper: Wrapper<TabNavigation>;
+  let nav: any;
+  const data = [{ name: 'tab', routeTo: '/route/to/tab', hidden: true }];
+
+  function createWrapper(options: ThisTypedMountOptions<TabNavigation> = {}) {
+    const vuetify = new Vuetify();
+    return mount(TabNavigation, {
+      store,
+      vuetify,
+      ...options
+    });
+  }
+
+  beforeAll(() => {
+    wrapper = createWrapper({
+      propsData: {
+        tabContents: data
+      },
+      mocks: { $route: { path: '/dashboard/info/' } }
+    });
+    nav = wrapper.vm as any;
+  });
+
+  test('gets proper class out of path', async () => {
+    const className = nav.getClass('/dashboard/info');
+    expect(className).toEqual('dashboard__info');
+  });
+
+  test('do not return any tabs that are hidden', async () => {
+    expect(nav.visibleTabs).toMatchObject([]);
+  });
+});
diff --git a/frontend/app/tests/unit/i18n.ts b/frontend/app/tests/unit/i18n.ts
--- a/frontend/app/tests/unit/i18n.ts
+++ b/frontend/app/tests/unit/i18n.ts
@@ -2,6 +2,7 @@ import Vue from 'vue';
 
 function I18n(vue: typeof Vue): void {
   vue.prototype.$t = (key: string) => key;
+  vue.prototype.$tc = (key: string) => key;
 }
 
 Vue.use(I18n);
diff --git a/frontend/app/tests/unit/services/axios-transformers.spec.ts b/frontend/app/tests/unit/services/axios-transformers.spec.ts
new file mode 100644
--- /dev/null
+++ b/frontend/app/tests/unit/services/axios-transformers.spec.ts
@@ -0,0 +1,29 @@
+import {
+  axiosCamelCaseTransformer,
+  axiosSnakeCaseTransformer,
+  setupJsonTransformer
+} from '@/services/axios-tranformers';
+import { bigNumberify } from '@/utils/bignumbers';
+
+describe('axios transformers', () => {
+  test('transform json to camelCase', async () => {
+    const json = '{"amount":"10","test_label":"label","data":[{"amount":"2"}]}';
+    const parsed = setupJsonTransformer(['amount'])(json);
+    expect(axiosCamelCaseTransformer(parsed)).toMatchObject({
+      amount: bigNumberify(10),
+      testLabel: 'label',
+      data: [{ amount: bigNumberify(2) }]
+    });
+  });
+
+  test('transform object to snake_case', async () => {
+    const object = {
+      data: [{ usdValue: bigNumberify(10) }, { usdValue: bigNumberify(11) }],
+      label: 'test'
+    };
+
+    expect(JSON.stringify(axiosSnakeCaseTransformer(object))).toMatch(
+      '{"data":[{"usd_value":"10"},{"usd_value":"11"}],"label":"test"}'
+    );
+  });
+});
diff --git a/frontend/app/tests/unit/store/balances/getters.spec.ts b/frontend/app/tests/unit/store/balances/getters.spec.ts
--- a/frontend/app/tests/unit/store/balances/getters.spec.ts
+++ b/frontend/app/tests/unit/store/balances/getters.spec.ts
@@ -3,7 +3,7 @@ import { AssetBalance } from '@/model/blockchain-balances';
 import { Location } from '@/services/types-common';
 import { SupportedAsset } from '@/services/types-model';
 import { getters } from '@/store/balances/getters';
-import { BalanceState } from '@/store/balances/state';
+import { BalanceState } from '@/store/balances/types';
 import { bigNumberify } from '@/utils/bignumbers';
 import { stub } from '../../../common/utils';