From 20b98d31b8f9a518f7417c33452a46c91b229316 Mon Sep 17 00:00:00 2001 From: NickolaiYurchenko Date: Fri, 21 May 2021 18:28:49 +0300 Subject: [PATCH] web/multinode: expectations block added added displaying of undistributed balance and current month expectations contains changes from https://review.dev.storj.io/c/storj/storj/+/4867 to be workable Change-Id: I9cb00d2db5b819a71ceeddf91d6863a6b4fa9034 --- web/multinode/src/api/payouts.ts | 47 +++++++++- .../app/components/payouts/BalanceArea.vue | 85 +++++++++++++++++++ web/multinode/src/app/store/payouts.ts | 38 ++++++++- web/multinode/src/app/views/PayoutsPage.vue | 34 ++++++-- web/multinode/src/payouts/index.ts | 17 ++++ web/multinode/src/payouts/service.ts | 20 ++++- .../components/payouts/BalanceArea.spec.ts | 30 +++++++ .../__snapshots__/BalanceArea.spec.ts.snap | 21 +++++ .../__snapshots__/PayoutsPage.spec.ts.snap | 1 + .../tests/unit/store/payouts.spec.ts | 51 ++++++++++- 10 files changed, 328 insertions(+), 16 deletions(-) create mode 100644 web/multinode/src/app/components/payouts/BalanceArea.vue create mode 100644 web/multinode/tests/unit/components/payouts/BalanceArea.spec.ts create mode 100644 web/multinode/tests/unit/components/payouts/__snapshots__/BalanceArea.spec.ts.snap diff --git a/web/multinode/src/api/payouts.ts b/web/multinode/src/api/payouts.ts index dda8bc69f..1c971b6aa 100644 --- a/web/multinode/src/api/payouts.ts +++ b/web/multinode/src/api/payouts.ts @@ -2,7 +2,7 @@ // See LICENSE for copying information. import { APIClient } from '@/api/index'; -import { NodePayoutsSummary, PayoutsSummary } from '@/payouts'; +import { Expectations, NodePayoutsSummary, PayoutsSummary } from '@/payouts'; /** * client for nodes controller of MND api. @@ -11,7 +11,7 @@ export class PayoutsClient extends APIClient { private readonly ROOT_PATH: string = '/api/v0/payouts'; /** - * handles fetch of payouts summary information. + * Handles fetch of payouts summary information. * * @param satelliteId - satellite id. * @param period - selected period. @@ -26,7 +26,13 @@ export class PayoutsClient extends APIClient { * Thrown if something goes wrong on server side. */ public async summary(satelliteId: string | null, period: string | null): Promise { - let path = `${this.ROOT_PATH}/summary`; + let path = `${this.ROOT_PATH}/`; + + if (satelliteId) { + path += `/satellites/${satelliteId}`; + } + + path += '/summaries'; if (period) { path += `/${period}`; @@ -52,4 +58,39 @@ export class PayoutsClient extends APIClient { )), ); } + + /** + * Handles fetch of payouts expectation such as estimated current month payout and undistributed payout. + * + * @param nodeId - node id. + * + * @throws {@link BadRequestError} + * This exception is thrown if the input is not a valid. + * + * @throws {@link UnauthorizedError} + * Thrown if the auth cookie is missing or invalid. + * + * @throws {@link InternalError} + * Thrown if something goes wrong on server side. + */ + public async expectations(nodeId: string | null): Promise { + let path = `${this.ROOT_PATH}/expectations`; + + if (nodeId) { + path += `/${nodeId}`; + } + + const response = await this.http.get(path); + + if (!response.ok) { + await this.handleError(response); + } + + const result = await response.json(); + + return new Expectations( + result.currentMonthEstimation, + result.undistributed, + ); + } } diff --git a/web/multinode/src/app/components/payouts/BalanceArea.vue b/web/multinode/src/app/components/payouts/BalanceArea.vue new file mode 100644 index 000000000..b843c800d --- /dev/null +++ b/web/multinode/src/app/components/payouts/BalanceArea.vue @@ -0,0 +1,85 @@ +// Copyright (C) 2021 Storj Labs, Inc. +// See LICENSE for copying information. + + + + + + diff --git a/web/multinode/src/app/store/payouts.ts b/web/multinode/src/app/store/payouts.ts index e9c828645..58e7a90de 100644 --- a/web/multinode/src/app/store/payouts.ts +++ b/web/multinode/src/app/store/payouts.ts @@ -5,7 +5,7 @@ import { ActionContext, ActionTree, GetterTree, Module, MutationTree } from 'vue import { RootState } from '@/app/store/index'; import { monthNames } from '@/app/types/date'; -import { PayoutsSummary } from '@/payouts'; +import { Expectations, PayoutsSummary } from '@/payouts'; import { Payouts } from '@/payouts/service'; /** @@ -14,6 +14,8 @@ import { Payouts } from '@/payouts/service'; export class PayoutsState { public summary: PayoutsSummary = new PayoutsSummary(); public selectedPayoutPeriod: string | null = null; + public selectedNodeExpectations: Expectations = new Expectations(); + public totalExpectations: Expectations = new Expectations(); } /** @@ -36,9 +38,12 @@ export class PayoutsModule implements Module { this.mutations = { setSummary: this.setSummary, setPayoutPeriod: this.setPayoutPeriod, + setCurrentNodeExpectations: this.setCurrentNodeExpectations, + setTotalExpectation: this.setTotalExpectation, }; this.actions = { summary: this.summary.bind(this), + expectations: this.expectations.bind(this), }; this.getters = { periodString: this.periodString, @@ -47,7 +52,7 @@ export class PayoutsModule implements Module { // Mutations /** - * populate mutation will set payouts state. + * setSummary mutation will set payouts summary state. * @param state - state of the module. * @param summary - payouts summary information depends on selected time and satellite. */ @@ -64,6 +69,24 @@ export class PayoutsModule implements Module { state.selectedPayoutPeriod = period; } + /** + * setCurrentNodeExpectations mutation will set payouts expectation for selected node. + * @param state - state of the module. + * @param expectations - payouts summary information depends on selected time and satellite. + */ + public setCurrentNodeExpectations(state: PayoutsState, expectations: Expectations): void { + state.selectedNodeExpectations = expectations; + } + + /** + * setTotalExpectation mutation will set total payouts expectation for all nodes. + * @param state - state of the module. + * @param expectations - payouts summary information depends on selected time and satellite. + */ + public setTotalExpectation(state: PayoutsState, expectations: Expectations): void { + state.totalExpectations = expectations; + } + // Actions /** * summary action loads payouts summary information. @@ -77,6 +100,17 @@ export class PayoutsModule implements Module { ctx.commit('setSummary', summary); } + /** + * expectations action loads payouts total or by node payout expectation information. + * @param ctx - context of the Vuex action. + * @param nodeId - node id. + */ + public async expectations(ctx: ActionContext, nodeId: string | null): Promise { + const expectations = await this.payouts.expectations(nodeId); + + ctx.commit(`${nodeId ? 'setCurrentNodeExpectations' : 'setTotalExpectation'}`, expectations); + } + // Getters /** * periodString is full name month and year representation of selected payout period. diff --git a/web/multinode/src/app/views/PayoutsPage.vue b/web/multinode/src/app/views/PayoutsPage.vue index 47dc92412..b1d026c23 100644 --- a/web/multinode/src/app/views/PayoutsPage.vue +++ b/web/multinode/src/app/views/PayoutsPage.vue @@ -12,17 +12,21 @@
+
@@ -34,16 +38,18 @@ import { Component, Vue } from 'vue-property-decorator'; import SatelliteSelectionDropdown from '@/app/components/common/SatelliteSelectionDropdown.vue'; import NodesTable from '@/app/components/myNodes/tables/NodesTable.vue'; +import BalanceArea from '@/app/components/payouts/BalanceArea.vue'; import DetailsArea from '@/app/components/payouts/DetailsArea.vue'; import PayoutHistoryBlock from '@/app/components/payouts/PayoutHistoryBlock.vue'; import PayoutPeriodCalendarButton from '@/app/components/payouts/PayoutPeriodCalendarButton.vue'; import PayoutsSummaryTable from '@/app/components/payouts/tables/payoutSummary/PayoutsSummaryTable.vue'; import { UnauthorizedError } from '@/api'; -import { PayoutsSummary } from '@/payouts'; +import { PayoutsState } from '@/app/store/payouts'; @Component({ components: { + BalanceArea, PayoutPeriodCalendarButton, PayoutHistoryBlock, DetailsArea, @@ -63,13 +69,23 @@ export default class PayoutsPage extends Vue { // TODO: notify error } + + try { + await this.$store.dispatch('payouts/expectations'); + } catch (error) { + if (error instanceof UnauthorizedError) { + // TODO: redirect to login screen. + } + + // TODO: notify error + } } /** - * payoutsSummary contains payouts summary from store. + * payoutsSummary contains payouts state from store. */ - public get payoutsSummary(): PayoutsSummary { - return this.$store.state.payouts.summary; + public get payouts(): PayoutsState { + return this.$store.state.payouts; } /** diff --git a/web/multinode/src/payouts/index.ts b/web/multinode/src/payouts/index.ts index 7252431d5..c04055838 100644 --- a/web/multinode/src/payouts/index.ts +++ b/web/multinode/src/payouts/index.ts @@ -75,3 +75,20 @@ export class PayoutPeriod { return new PayoutPeriod(parseInt(periodArray[0]), parseInt(periodArray[1]) - 1); } } + +/** + * PayoutsSummary is a representation of current month estimated payout and undistributed payouts. + */ +export class Expectations { + public constructor( + public currentMonthEstimation: number = 0, + public undistributed: number = 0, + ) { + this.currentMonthEstimation = this.convertToCents(this.currentMonthEstimation); + this.undistributed = this.convertToCents(this.undistributed); + } + + private convertToCents(value: number): number { + return value / PRICE_DIVIDER; + } +} diff --git a/web/multinode/src/payouts/service.ts b/web/multinode/src/payouts/service.ts index 38dfdf426..2b0dfb651 100644 --- a/web/multinode/src/payouts/service.ts +++ b/web/multinode/src/payouts/service.ts @@ -2,7 +2,7 @@ // See LICENSE for copying information. import { PayoutsClient } from '@/api/payouts'; -import { PayoutsSummary } from '@/payouts/index'; +import { Expectations, PayoutsSummary } from '@/payouts/index'; /** * exposes all payouts related logic @@ -32,4 +32,22 @@ export class Payouts { public async summary(satelliteId: string | null, period: string | null): Promise { return await this.payouts.summary(satelliteId, period); } + + /** + * fetches of payouts expectation such as estimated current month payout and undistributed payout. + * + * @param nodeId - node id. + * + * @throws {@link BadRequestError} + * This exception is thrown if the input is not a valid. + * + * @throws {@link UnauthorizedError} + * Thrown if the auth cookie is missing or invalid. + * + * @throws {@link InternalError} + * Thrown if something goes wrong on server side. + */ + public async expectations(nodeId: string | null): Promise { + return await this.payouts.expectations(nodeId); + } } diff --git a/web/multinode/tests/unit/components/payouts/BalanceArea.spec.ts b/web/multinode/tests/unit/components/payouts/BalanceArea.spec.ts new file mode 100644 index 000000000..c78834cbf --- /dev/null +++ b/web/multinode/tests/unit/components/payouts/BalanceArea.spec.ts @@ -0,0 +1,30 @@ +// Copyright (C) 2021 Storj Labs, Inc. +// See LICENSE for copying information. + +import Vuex from 'vuex'; + +import BalanceArea from '@/app/components/payouts/BalanceArea.vue'; + +import { Currency } from '@/app/utils/currency'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +localVue.filter('centsToDollars', (cents: number): string => { + return Currency.dollarsFromCents(cents); +}); + +describe('BalanceArea', (): void => { + it('renders correctly', (): void => { + const wrapper = shallowMount(BalanceArea, { + localVue, + propsData: { + currentMonthEstimation: 66000, + undistributed: 1000, + }, + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/web/multinode/tests/unit/components/payouts/__snapshots__/BalanceArea.spec.ts.snap b/web/multinode/tests/unit/components/payouts/__snapshots__/BalanceArea.spec.ts.snap new file mode 100644 index 000000000..9d134cac0 --- /dev/null +++ b/web/multinode/tests/unit/components/payouts/__snapshots__/BalanceArea.spec.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BalanceArea renders correctly 1`] = ` + +
+
+

Balance

+
+
+
+

Undistributed Payouts

+

$10.00

+
+
+

Current Month Estimated Earnings

+

$660.00

+
+
+
+
+`; diff --git a/web/multinode/tests/unit/components/views/__snapshots__/PayoutsPage.spec.ts.snap b/web/multinode/tests/unit/components/views/__snapshots__/PayoutsPage.spec.ts.snap index 203130ec1..040d0b797 100644 --- a/web/multinode/tests/unit/components/views/__snapshots__/PayoutsPage.spec.ts.snap +++ b/web/multinode/tests/unit/components/views/__snapshots__/PayoutsPage.spec.ts.snap @@ -13,6 +13,7 @@ exports[`PayoutsPage renders correctly 1`] = `
+
diff --git a/web/multinode/tests/unit/store/payouts.spec.ts b/web/multinode/tests/unit/store/payouts.spec.ts index 80153f840..f4eb1d6f9 100644 --- a/web/multinode/tests/unit/store/payouts.spec.ts +++ b/web/multinode/tests/unit/store/payouts.spec.ts @@ -3,7 +3,7 @@ import Vuex from 'vuex'; -import { PayoutsSummary } from '@/payouts'; +import { Expectations, PayoutsSummary } from '@/payouts'; import { createLocalVue } from '@vue/test-utils'; import store, { payoutsService } from '../mock/store'; @@ -11,6 +11,8 @@ import store, { payoutsService } from '../mock/store'; const state = store.state as any; const summary = new PayoutsSummary(5000000, 6000000, 9000000); +const expectations = new Expectations(4000000, 3000000); +const expectationsByNode = new Expectations(1000000, 2000000); const period = '2021-04'; describe('mutations', () => { @@ -29,12 +31,27 @@ describe('mutations', () => { expect(state.payouts.selectedPayoutPeriod).toBe(period); }); + + it('sets total expectations', () => { + store.commit('payouts/setTotalExpectation', expectations); + + expect(state.payouts.totalExpectations.currentMonthEstimation).toBe(expectations.currentMonthEstimation); + expect(state.payouts.selectedNodeExpectations.currentMonthEstimation).toBe(0); + }); + + it('sets selected node expectations', () => { + store.commit('payouts/setCurrentNodeExpectations', expectationsByNode); + + expect(state.payouts.selectedNodeExpectations.currentMonthEstimation).toBe(expectationsByNode.currentMonthEstimation); + }); }); describe('actions', () => { beforeEach(() => { jest.resetAllMocks(); store.commit('payouts/setSummary', new PayoutsSummary()); + store.commit('payouts/setCurrentNodeExpectations', new Expectations()); + store.commit('payouts/setTotalExpectation', new Expectations()); }); it('throws error on failed summary fetch', async () => { @@ -57,6 +74,38 @@ describe('actions', () => { expect(state.payouts.summary.totalPaid).toBe(summary.totalPaid); }); + + it('throws error on failed expectations fetch', async () => { + jest.spyOn(payoutsService, 'expectations').mockImplementation(() => { throw new Error(); }); + + try { + await store.dispatch('payouts/expectations'); + expect(true).toBe(false); + } catch (error) { + expect(state.payouts.totalExpectations.undistributed).toBe(0); + } + }); + + it('success fetches total expectations', async () => { + jest.spyOn(payoutsService, 'expectations').mockReturnValue( + Promise.resolve(expectations), + ); + + await store.dispatch('payouts/expectations'); + + expect(state.payouts.totalExpectations.undistributed).toBe(expectations.undistributed); + }); + + it('success fetches by node expectations', async () => { + jest.spyOn(payoutsService, 'expectations').mockReturnValue( + Promise.resolve(expectationsByNode), + ); + + await store.dispatch('payouts/expectations', 'nodeId'); + + expect(state.payouts.totalExpectations.undistributed).toBe(0); + expect(state.payouts.selectedNodeExpectations.undistributed).toBe(expectationsByNode.undistributed); + }); }); describe('getters', () => {