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
This commit is contained in:
NickolaiYurchenko 2021-05-21 18:28:49 +03:00 committed by Nikolay Yurchenko
parent 64e4225721
commit 20b98d31b8
10 changed files with 328 additions and 16 deletions

View File

@ -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<PayoutsSummary> {
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<Expectations> {
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,
);
}
}

View File

@ -0,0 +1,85 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<info-block>
<div class="balance-area" slot="body">
<header class="balance-area__header">
<h3 class="balance-area__header__title">Balance</h3>
</header>
<div class="balance-area__content">
<div class="balance-area__content__item">
<p class="balance-area__content__item__label">Undistributed Payouts</p>
<p class="balance-area__content__item__value">{{ undistributed | centsToDollars }}</p>
</div>
<div class="balance-area__content__item">
<p class="balance-area__content__item__label">Current Month Estimated Earnings</p>
<p class="balance-area__content__item__value">{{ currentMonthEstimation | centsToDollars }}</p>
</div>
</div>
</div>
</info-block>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import InfoBlock from '../common/InfoBlock.vue';
@Component({
components: { InfoBlock },
})
export default class BalanceArea extends Vue {
@Prop({default: 0})
public undistributed: number;
@Prop({default: 0})
public currentMonthEstimation: number;
}
</script>
<style scoped lang="scss">
.balance-area {
box-sizing: border-box;
width: 100%;
&__header {
&__title {
font-family: 'font_bold', sans-serif;
font-size: 24px;
line-height: 24px;
color: var(--c-title);
}
}
&__content {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-top: 34px;
&__item {
display: flex;
align-items: flex-end;
justify-content: space-between;
width: 100%;
&:not(:first-of-type) {
margin-top: 26px;
}
&__label {
font-size: 14px;
color: var(--c-gray);
font-family: 'font_medium', sans-serif;
}
&__value {
font-size: 18px;
color: var(--c-title);
font-family: 'font_semiBold', sans-serif;
}
}
}
}
</style>

View File

@ -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<PayoutsState, RootState> {
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<PayoutsState, RootState> {
// 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<PayoutsState, RootState> {
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<PayoutsState, RootState> {
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<PayoutsState, RootState>, nodeId: string | null): Promise<void> {
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.

View File

@ -12,17 +12,21 @@
</div>
<payouts-summary-table
class="payouts__left-area__table"
v-if="payoutsSummary.nodeSummary"
:node-payouts-summary="payoutsSummary.nodeSummary"
v-if="payouts.summary.nodeSummary"
:node-payouts-summary="payouts.summary.nodeSummary"
/>
</div>
<div class="payouts__right-area">
<details-area
:total-earned="payoutsSummary.totalEarned"
:total-held="payoutsSummary.totalHeld"
:total-paid="payoutsSummary.totalPaid"
:total-earned="payouts.summary.totalEarned"
:total-held="payouts.summary.totalHeld"
:total-paid="payouts.summary.totalPaid"
:period="period"
/>
<balance-area
:current-month-estimation="payouts.totalExpectations.currentMonthEstimation"
:undistributed="payouts.totalExpectations.undistributed"
/>
<!-- <payout-history-block />-->
</div>
</div>
@ -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;
}
/**

View File

@ -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;
}
}

View File

@ -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<PayoutsSummary> {
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<Expectations> {
return await this.payouts.expectations(nodeId);
}
}

View File

@ -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();
});
});

View File

@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BalanceArea renders correctly 1`] = `
<info-block-stub>
<div class="balance-area">
<header class="balance-area__header">
<h3 class="balance-area__header__title">Balance</h3>
</header>
<div class="balance-area__content">
<div class="balance-area__content__item">
<p class="balance-area__content__item__label">Undistributed Payouts</p>
<p class="balance-area__content__item__value">$10.00</p>
</div>
<div class="balance-area__content__item">
<p class="balance-area__content__item__label">Current Month Estimated Earnings</p>
<p class="balance-area__content__item__value">$660.00</p>
</div>
</div>
</div>
</info-block-stub>
`;

View File

@ -13,6 +13,7 @@ exports[`PayoutsPage renders correctly 1`] = `
</div>
<div class="payouts__right-area">
<details-area-stub totalearned="0" totalheld="0" totalpaid="0" period="All time"></details-area-stub>
<balance-area-stub undistributed="0" currentmonthestimation="0"></balance-area-stub>
</div>
</div>
</div>

View File

@ -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', () => {