web/storagenode: notifications domain and app types separations

WHAT:
notifications store, api, types refactoring;
service creation;
notifications module store tests creation;

WHY:
to separate domain and app types;
to be able to reuse code more easier;

Change-Id: I01c6584fc41bbf73e0b6f84501cc66bbebd50ace
This commit is contained in:
NickolaiYurchenko 2020-10-05 21:09:34 +03:00 committed by Nikolay Yurchenko
parent 074784e1b4
commit 3b388c21cf
13 changed files with 368 additions and 139 deletions

View File

@ -68,7 +68,6 @@ import { APPSTATE_ACTIONS } from '@/app/store/modules/appState';
import { NODE_ACTIONS } from '@/app/store/modules/node';
import { NOTIFICATIONS_ACTIONS } from '@/app/store/modules/notifications';
import { PAYOUT_ACTIONS } from '@/app/store/modules/payout';
import { NotificationsCursor } from '@/app/types/notifications';
const {
GET_NODE_INFO,
@ -90,18 +89,23 @@ const {
export default class SNOHeader extends Vue {
public isNotificationPopupShown: boolean = false;
public isOptionsShown: boolean = false;
private readonly FIRST_PAGE: number = 1;
/**
* Lifecycle hook before render.
* Fetches first page of notifications.
*/
public beforeMount(): void {
public async beforeMount(): Promise<void> {
await this.$store.dispatch(APPSTATE_ACTIONS.SET_LOADING, true);
try {
this.$store.dispatch(NODE_ACTIONS.GET_NODE_INFO);
this.$store.dispatch(NOTIFICATIONS_ACTIONS.GET_NOTIFICATIONS, new NotificationsCursor(1));
await this.$store.dispatch(NODE_ACTIONS.GET_NODE_INFO);
await this.$store.dispatch(NOTIFICATIONS_ACTIONS.GET_NOTIFICATIONS, this.FIRST_PAGE);
} catch (error) {
console.error(error.message);
}
await this.$store.dispatch(APPSTATE_ACTIONS.SET_LOADING, false);
}
public get nodeId(): string {
@ -192,7 +196,7 @@ export default class SNOHeader extends Vue {
}
try {
await this.$store.dispatch(NOTIFICATIONS_ACTIONS.GET_NOTIFICATIONS, new NotificationsCursor(1));
await this.$store.dispatch(NOTIFICATIONS_ACTIONS.GET_NOTIFICATIONS, this.FIRST_PAGE);
} catch (error) {
console.error(error.message);
}

View File

@ -12,10 +12,10 @@
<div
class="notification-popup-container__content"
:class="{'collapsed': isCollapsed}"
v-if="latestNotifications.length"
v-if="latest.length"
>
<SNONotification
v-for="notification in latestNotifications"
v-for="notification in latest"
:key="notification.id"
is-small="true"
:notification="notification"
@ -34,6 +34,7 @@ import { Component, Vue } from 'vue-property-decorator';
import SNONotification from '@/app/components/notifications/SNONotification.vue';
import { RouteConfig } from '@/app/router';
import { UINotification } from '@/app/types/notifications';
@Component({
components: {
@ -49,7 +50,7 @@ export default class NotificationsPopup extends Vue {
/**
* Represents first page of notifications.
*/
public get latestNotifications(): Notification[] {
public get latest(): UINotification[] {
return this.$store.state.notificationsModule.latestNotifications;
}
@ -57,7 +58,7 @@ export default class NotificationsPopup extends Vue {
* Indicates if popup is smaller than with scroll.
*/
public get isCollapsed(): boolean {
return this.latestNotifications.length < 4;
return this.latest.length < 4;
}
}
</script>

View File

@ -30,12 +30,12 @@
import { Component, Prop, Vue } from 'vue-property-decorator';
import { NOTIFICATIONS_ACTIONS } from '@/app/store/modules/notifications';
import { Notification } from '@/app/types/notifications';
import { UINotification } from '@/app/types/notifications';
@Component
export default class SNONotification extends Vue {
@Prop({default: () => new Notification()})
public readonly notification: Notification;
@Prop({default: () => new UINotification()})
public readonly notification: UINotification;
/**
* isSmall props indicates if component used in popup.

View File

@ -4,17 +4,19 @@
import Vue from 'vue';
import Vuex from 'vuex';
import { makeNotificationsModule } from '@/app/store/modules/notifications';
import { newNotificationsModule } from '@/app/store/modules/notifications';
import { makePayoutModule } from '@/app/store/modules/payout';
import { NotificationsHttpApi } from '@/storagenode/api/notifications';
import { PayoutHttpApi } from '@/storagenode/api/payout';
import { SNOApi } from '@/storagenode/api/storagenode';
import { NotificationsService } from '@/storagenode/notifications/service';
import { PayoutService } from '@/storagenode/payouts/service';
import { appStateModule } from './modules/appState';
import { makeNodeModule } from './modules/node';
const notificationsApi = new NotificationsHttpApi();
const notificationsService = new NotificationsService(notificationsApi);
const payoutApi = new PayoutHttpApi();
const payoutService = new PayoutService(payoutApi);
const nodeApi = new SNOApi();
@ -28,7 +30,7 @@ export const store = new Vuex.Store({
modules: {
node: makeNodeModule(nodeApi),
appStateModule,
notificationsModule: makeNotificationsModule(notificationsApi),
notificationsModule: newNotificationsModule(notificationsService),
payoutModule: makePayoutModule(payoutApi, payoutService),
},
});

View File

@ -1,12 +1,8 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
import {
Notification,
NotificationsApi,
NotificationsCursor,
NotificationsState,
} from '@/app/types/notifications';
import { NotificationsState, UINotification } from '@/app/types/notifications';
import { NotificationsService } from '@/storagenode/notifications/service';
export const NOTIFICATIONS_MUTATIONS = {
SET_NOTIFICATIONS: 'SET_NOTIFICATIONS',
@ -24,22 +20,22 @@ export const NOTIFICATIONS_ACTIONS = {
/**
* creates notifications module with all dependencies
*
* @param api - payments api
* @param service - payments service
*/
export function makeNotificationsModule(api: NotificationsApi) {
export function newNotificationsModule(service: NotificationsService) {
return {
state: new NotificationsState(),
mutations: {
[NOTIFICATIONS_MUTATIONS.SET_NOTIFICATIONS](state: NotificationsState, notificationsResponse: NotificationsState): void {
state.notifications = notificationsResponse.notifications;
state.pageCount = notificationsResponse.pageCount;
state.unreadCount = notificationsResponse.unreadCount;
[NOTIFICATIONS_MUTATIONS.SET_NOTIFICATIONS](state: NotificationsState, notificationsState: NotificationsState): void {
state.notifications = notificationsState.notifications;
state.pageCount = notificationsState.pageCount;
state.unreadCount = notificationsState.unreadCount;
},
[NOTIFICATIONS_MUTATIONS.SET_LATEST](state: NotificationsState, notificationsResponse: NotificationsState): void {
state.latestNotifications = notificationsResponse.notifications;
[NOTIFICATIONS_MUTATIONS.SET_LATEST](state: NotificationsState, notificationsState: NotificationsState): void {
state.latestNotifications = notificationsState.notifications;
},
[NOTIFICATIONS_MUTATIONS.MARK_AS_READ](state: NotificationsState, id: string): void {
state.notifications = state.notifications.map((notification: Notification) => {
state.notifications = state.notifications.map((notification: UINotification) => {
if (notification.id === id) {
notification.markAsRead();
}
@ -48,7 +44,7 @@ export function makeNotificationsModule(api: NotificationsApi) {
});
},
[NOTIFICATIONS_MUTATIONS.READ_ALL](state: NotificationsState): void {
state.notifications = state.notifications.map((notification: Notification) => {
state.notifications = state.notifications.map((notification: UINotification) => {
notification.markAsRead();
return notification;
@ -58,24 +54,26 @@ export function makeNotificationsModule(api: NotificationsApi) {
},
},
actions: {
[NOTIFICATIONS_ACTIONS.GET_NOTIFICATIONS]: async function ({commit}: any, cursor: NotificationsCursor): Promise<NotificationsState> {
const notificationsResponse = await api.get(cursor);
[NOTIFICATIONS_ACTIONS.GET_NOTIFICATIONS]: async function ({commit}: any, pageIndex: number): Promise<void> {
const notificationsResponse = await service.notifications(pageIndex);
commit(NOTIFICATIONS_MUTATIONS.SET_NOTIFICATIONS, notificationsResponse);
const notifications = notificationsResponse.page.notifications.map(notification => new UINotification(notification));
if (cursor.page === 1) {
commit(NOTIFICATIONS_MUTATIONS.SET_LATEST, notificationsResponse);
const notificationState = new NotificationsState(notifications, notificationsResponse.page.pageCount, notificationsResponse.unreadCount);
commit(NOTIFICATIONS_MUTATIONS.SET_NOTIFICATIONS, notificationState);
if (pageIndex === 1) {
commit(NOTIFICATIONS_MUTATIONS.SET_LATEST, notificationState);
}
return notificationsResponse;
},
[NOTIFICATIONS_ACTIONS.MARK_AS_READ]: async function ({commit}: any, id: string): Promise<any> {
await api.read(id);
[NOTIFICATIONS_ACTIONS.MARK_AS_READ]: async function ({commit}: any, id: string): Promise<void> {
await service.readSingeNotification(id);
commit(NOTIFICATIONS_MUTATIONS.MARK_AS_READ, id);
},
[NOTIFICATIONS_ACTIONS.READ_ALL]: async function ({commit}: any): Promise<any> {
await api.readAll();
[NOTIFICATIONS_ACTIONS.READ_ALL]: async function ({commit}: any): Promise<void> {
await service.readAllNotifications();
commit(NOTIFICATIONS_MUTATIONS.READ_ALL);
},

View File

@ -2,23 +2,39 @@
// See LICENSE for copying information.
import { NotificationIcon } from '@/app/utils/notificationIcons';
import { Notification, NotificationTypes } from '@/storagenode/notifications/notifications';
/**
* Holds all notifications module state.
*/
export class NotificationsState {
public latestNotifications: UINotification[] = [];
public constructor(
public notifications: UINotification[] = [],
public pageCount: number = 0,
public unreadCount: number = 0,
) { }
}
/**
* Describes notification entity.
*/
export class Notification {
export class UINotification {
public icon: NotificationIcon;
public isRead: boolean;
public id: string;
public senderId: string;
public type: NotificationTypes;
public title: string;
public message: string;
public readAt: Date | null;
public createdAt: Date;
public constructor(
public id: string = '',
public senderId: string = '',
public type: NotificationTypes = NotificationTypes.Custom,
public title: string = '',
public message: string = '',
public isRead: boolean = false,
public createdAt: Date = new Date(),
) {
public constructor(notification: Partial<UINotification> = new Notification()) {
Object.assign(this, notification);
this.setIcon();
this.isRead = !!this.readAt;
}
/**
@ -70,60 +86,3 @@ export class Notification {
}
}
}
/**
* Describes all current notifications types.
*/
export enum NotificationTypes {
Custom = 0,
AuditCheckFailure = 1,
UptimeCheckFailure = 2,
Disqualification = 3,
Suspension = 4,
}
/**
* Describes page offset for pagination.
*/
export class NotificationsCursor {
public constructor(
public page: number = 0,
public limit: number = 7,
) { }
}
/**
* Holds all notifications module state.
*/
export class NotificationsState {
public latestNotifications: Notification[] = [];
public constructor(
public notifications: Notification[] = [],
public pageCount: number = 0,
public unreadCount: number = 0,
) { }
}
/**
* Exposes all notifications-related functionality.
*/
export interface NotificationsApi {
/**
* Fetches notifications.
* @throws Error
*/
get(cursor: NotificationsCursor): Promise<NotificationsState>;
/**
* Marks single notification as read.
* @throws Error
*/
read(id: string): Promise<void>;
/**
* Marks all notification as read.
* @throws Error
*/
readAll(): Promise<void>;
}

View File

@ -20,7 +20,6 @@ import { APPSTATE_ACTIONS } from '@/app/store/modules/appState';
import { NODE_ACTIONS } from '@/app/store/modules/node';
import { NOTIFICATIONS_ACTIONS } from '@/app/store/modules/notifications';
import { PAYOUT_ACTIONS } from '@/app/store/modules/payout';
import { NotificationsCursor } from '@/app/types/notifications';
@Component ({
components: {
@ -43,7 +42,7 @@ export default class Dashboard extends Vue {
}
try {
await this.$store.dispatch(NOTIFICATIONS_ACTIONS.GET_NOTIFICATIONS, new NotificationsCursor(1));
await this.$store.dispatch(NOTIFICATIONS_ACTIONS.GET_NOTIFICATIONS, 1);
} catch (error) {
console.error(error);
}

View File

@ -54,7 +54,7 @@ import VPagination from '@/app/components/VPagination.vue';
import BackArrowIcon from '@/../static/images/notifications/backArrow.svg';
import { NOTIFICATIONS_ACTIONS } from '@/app/store/modules/notifications';
import { Notification, NotificationsCursor } from '@/app/types/notifications';
import { UINotification } from '@/app/types/notifications';
@Component ({
components: {
@ -67,7 +67,7 @@ export default class NotificationsArea extends Vue {
/**
* Returns notification of current page.
*/
public get notifications(): Notification[] {
public get notifications(): UINotification[] {
return this.$store.state.notificationsModule.notifications;
}
@ -92,7 +92,7 @@ export default class NotificationsArea extends Vue {
*/
public async onPageClick(index: number): Promise<void> {
try {
await this.$store.dispatch(NOTIFICATIONS_ACTIONS.GET_NOTIFICATIONS, new NotificationsCursor(index));
await this.$store.dispatch(NOTIFICATIONS_ACTIONS.GET_NOTIFICATIONS, index);
} catch (error) {
console.error(error.message);
}

View File

@ -57,7 +57,6 @@ import { APPSTATE_ACTIONS } from '@/app/store/modules/appState';
import { NODE_ACTIONS } from '@/app/store/modules/node';
import { NOTIFICATIONS_ACTIONS } from '@/app/store/modules/notifications';
import { PAYOUT_ACTIONS } from '@/app/store/modules/payout';
import { NotificationsCursor } from '@/app/types/notifications';
import { PayoutPeriod, TotalHeldAndPaid } from '@/storagenode/payouts/payouts';
@Component ({
@ -88,7 +87,7 @@ export default class PayoutArea extends Vue {
}
try {
await this.$store.dispatch(NOTIFICATIONS_ACTIONS.GET_NOTIFICATIONS, new NotificationsCursor(1));
await this.$store.dispatch(NOTIFICATIONS_ACTIONS.GET_NOTIFICATIONS, 1);
} catch (error) {
console.error(error);
}

View File

@ -1,7 +1,12 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
import { Notification, NotificationsApi, NotificationsCursor, NotificationsState } from '@/app/types/notifications';
import {
NotificationsApi,
NotificationsCursor,
NotificationsPage,
NotificationsResponse,
} from '@/storagenode/notifications/notifications';
import { HttpClient } from '@/storagenode/utils/httpClient';
/**
@ -15,10 +20,10 @@ export class NotificationsHttpApi implements NotificationsApi {
/**
* Fetch notifications.
*
* @returns notifications state
* @returns notifications response.
* @throws Error
*/
public async get(cursor: NotificationsCursor): Promise<NotificationsState> {
public async get(cursor: NotificationsCursor): Promise<NotificationsResponse> {
const path = `${this.ROOT_PATH}/list?page=${cursor.page}&limit=${cursor.limit}`;
const response = await this.client.get(path);
@ -27,28 +32,12 @@ export class NotificationsHttpApi implements NotificationsApi {
}
const notificationResponse = await response.json();
let notifications: Notification[] = [];
let pageCount: number = 0;
let unreadCount: number = 0;
if (notificationResponse) {
notifications = notificationResponse.page.notifications.map(item =>
new Notification(
item.id,
item.senderId,
item.type,
item.title,
item.message,
!!item.readAt,
new Date(item.createdAt),
),
return new NotificationsResponse(
new NotificationsPage(notificationResponse.page.notifications, notificationResponse.page.pageCount),
notificationResponse.unreadCount,
notificationResponse.totalCount,
);
pageCount = notificationResponse.page.pageCount;
unreadCount = notificationResponse.unreadCount;
}
return new NotificationsState(notifications, pageCount, unreadCount);
}
/**

View File

@ -0,0 +1,88 @@
// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
/**
* Exposes all notifications-related functionality.
*/
export interface NotificationsApi {
/**
* Fetches notifications.
* @throws Error
*/
get(cursor: NotificationsCursor): Promise<NotificationsResponse>;
/**
* Marks single notification as read.
* @throws Error
*/
read(id: string): Promise<void>;
/**
* Marks all notification as read.
* @throws Error
*/
readAll(): Promise<void>;
}
/**
* Describes notification entity.
*/
export class Notification {
public constructor(
public id: string = '',
public senderId: string = '',
public type: NotificationTypes = NotificationTypes.Custom,
public title: string = '',
public message: string = '',
public readAt: Date | null = null,
public createdAt: Date = new Date(),
) {}
}
/**
* Describes all current notifications types.
*/
export enum NotificationTypes {
Custom = 0,
AuditCheckFailure = 1,
UptimeCheckFailure = 2,
Disqualification = 3,
Suspension = 4,
}
/**
* Describes page offset for pagination.
*/
export class NotificationsCursor {
private DEFAULT_LIMIT: number = 7;
public constructor(
public page: number = 0,
public limit: number = 0,
) {
if (!this.limit) {
this.limit = this.DEFAULT_LIMIT;
}
}
}
/**
* Describes response object from server.
*/
export class NotificationsResponse {
public constructor(
public page: NotificationsPage = new NotificationsPage(),
public unreadCount: number = 0,
public totalCount: number = 0,
) {}
}
/**
* Describes page related notification information.
*/
export class NotificationsPage {
public constructor(
public notifications: Notification[] = [],
public pageCount: number = 0,
) {}
}

View File

@ -0,0 +1,43 @@
// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
import { NotificationsApi, NotificationsCursor, NotificationsResponse } from '@/storagenode/notifications/notifications';
/**
* PayoutService is used to store and handle node paystub information.
* PayoutService exposes a business logic related to payouts.
*/
export class NotificationsService {
private readonly api: NotificationsApi;
public constructor(api: NotificationsApi) {
this.api = api;
}
/**
* Fetch notifications.
*
* @returns notifications response.
* @throws Error
*/
public async notifications(index: number, limit?: number): Promise<NotificationsResponse> {
const cursor = new NotificationsCursor(index, limit);
return await this.api.get(cursor);
}
/**
* Marks single notification as read on server.
* @param id
*/
public async readSingeNotification(id: string): Promise<void> {
await this.api.read(id);
}
/**
* Marks all notifications as read on server.
*/
public async readAllNotifications(): Promise<void> {
await this.api.readAll();
}
}

View File

@ -0,0 +1,147 @@
// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
import Vuex from 'vuex';
import { newNotificationsModule, NOTIFICATIONS_ACTIONS, NOTIFICATIONS_MUTATIONS } from '@/app/store/modules/notifications';
import { NotificationsState, UINotification } from '@/app/types/notifications';
import { NotificationsHttpApi } from '@/storagenode/api/notifications';
import {
Notification,
NotificationsPage,
NotificationsResponse,
NotificationTypes,
} from '@/storagenode/notifications/notifications';
import { NotificationsService } from '@/storagenode/notifications/service';
import { createLocalVue } from '@vue/test-utils';
const Vue = createLocalVue();
const notificationsApi = new NotificationsHttpApi();
const notificationsService = new NotificationsService(notificationsApi);
const notificationsModule = newNotificationsModule(notificationsService);
Vue.use(Vuex);
const store = new Vuex.Store({ modules: { notificationsModule } });
const state = store.state as any;
let notifications;
describe('mutations', () => {
beforeEach(() => {
createLocalVue().use(Vuex);
notifications = [
new UINotification(new Notification('1', '1', NotificationTypes.Disqualification, 'title1', 'message1', null)),
new UINotification(new Notification('2', '1', NotificationTypes.UptimeCheckFailure, 'title2', 'message2', null)),
];
});
it('sets notification state', (): void => {
const notificationsState = new NotificationsState(notifications, 2, 1);
store.commit(NOTIFICATIONS_MUTATIONS.SET_NOTIFICATIONS, notificationsState);
expect(state.notificationsModule.notifications.length).toBe(notifications.length);
expect(state.notificationsModule.pageCount).toBe(2);
expect(state.notificationsModule.unreadCount).toBe(1);
store.commit(NOTIFICATIONS_MUTATIONS.SET_LATEST, notificationsState);
expect(state.notificationsModule.latestNotifications.length).toBe(notifications.length);
});
it('sets single notification as read', (): void => {
store.commit(NOTIFICATIONS_MUTATIONS.MARK_AS_READ, '1');
const unreadNotificationsCount = state.notificationsModule.notifications.filter(e => !e.isRead).length;
expect(unreadNotificationsCount).toBe(1);
});
it('sets all notification as read', (): void => {
store.commit(NOTIFICATIONS_MUTATIONS.READ_ALL);
const unreadNotificationsCount = state.notificationsModule.notifications.filter(e => !e.isRead).length;
expect(unreadNotificationsCount).toBe(0);
});
});
describe('actions', () => {
beforeEach(() => {
jest.resetAllMocks();
notifications = [
new UINotification(new Notification('1', '1', NotificationTypes.Disqualification, 'title1', 'message1', null)),
new UINotification(new Notification('2', '1', NotificationTypes.UptimeCheckFailure, 'title2', 'message2', null)),
];
});
it('throws error on failed notifications fetch', async (): Promise<void> => {
jest.spyOn(notificationsApi, 'get').mockImplementation(() => { throw new Error(); });
try {
await store.dispatch(NOTIFICATIONS_ACTIONS.GET_NOTIFICATIONS, 1);
expect(true).toBe(false);
} catch (error) {
expect(state.notificationsModule.latestNotifications.length).toBe(notifications.length);
}
});
it('success fetches notifications', async (): Promise<void> => {
jest.spyOn(notificationsService, 'notifications')
.mockReturnValue(Promise.resolve(new NotificationsResponse(new NotificationsPage(notifications, 1), 2, 1)));
await store.dispatch(NOTIFICATIONS_ACTIONS.GET_NOTIFICATIONS, 1);
expect(state.notificationsModule.latestNotifications.length).toBe(notifications.length);
});
it('throws error on failed single notification read', async (): Promise<void> => {
jest.spyOn(notificationsApi, 'read').mockImplementation(() => { throw new Error(); });
try {
await store.dispatch(NOTIFICATIONS_ACTIONS.MARK_AS_READ, '1');
expect(true).toBe(false);
} catch (error) {
const unreadNotificationsCount = state.notificationsModule.notifications.filter(e => !e.isRead).length;
expect(unreadNotificationsCount).toBe(notifications.length);
}
});
it('success marks single notification as read', async (): Promise<void> => {
jest.spyOn(notificationsService, 'notifications')
.mockReturnValue(Promise.resolve(new NotificationsResponse(new NotificationsPage(notifications, 1), 2, 1)));
await store.dispatch(NOTIFICATIONS_ACTIONS.GET_NOTIFICATIONS, 1);
await store.dispatch(NOTIFICATIONS_ACTIONS.MARK_AS_READ, '1');
const unreadNotificationsCount = state.notificationsModule.notifications.filter(e => !e.isRead).length;
expect(unreadNotificationsCount).toBe(1);
});
it('throws error on failed all notifications read', async (): Promise<void> => {
jest.spyOn(notificationsApi, 'readAll').mockImplementation(() => { throw new Error(); });
try {
await store.dispatch(NOTIFICATIONS_ACTIONS.READ_ALL);
expect(true).toBe(false);
} catch (error) {
const unreadNotificationsCount = state.notificationsModule.notifications.filter(e => !e.isRead).length;
expect(unreadNotificationsCount).toBe(1);
}
});
it('success marks all notifications as read', async (): Promise<void> => {
await store.dispatch(NOTIFICATIONS_ACTIONS.READ_ALL);
const unreadNotificationsCount = state.notificationsModule.notifications.filter(e => !e.isRead).length;
expect(unreadNotificationsCount).toBe(0);
});
});