WIP: Implement scoped stores for notification state

pull/9599/head
Benjamin Bädorf 3 years ago
parent e6ce5f8a87
commit f582824e68
No known key found for this signature in database
GPG Key ID: 069CA2D117AB5CCF
  1. 8
      frontend/src/app/features/in-app-notifications/bell/in-app-notification-bell.component.ts
  2. 6
      frontend/src/app/features/in-app-notifications/center/in-app-notification-center.component.html
  3. 26
      frontend/src/app/features/in-app-notifications/center/in-app-notification-center.component.ts
  4. 9
      frontend/src/app/features/in-app-notifications/store/in-app-notifications.query.ts
  5. 105
      frontend/src/app/features/in-app-notifications/store/in-app-notifications.service.ts
  6. 9
      frontend/src/app/features/in-app-notifications/store/in-app-notifications.store.ts
  7. 10
      frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-base.controller.ts
  8. 8
      frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.component.ts
  9. 6
      frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.component.ts

@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { InAppNotificationsQuery } from 'core-app/features/in-app-notifications/store/in-app-notifications.query'; import { InAppNotificationsQuery } from 'core-app/features/in-app-notifications/store/in-app-notifications.query';
import { InAppNotificationsStore } from 'core-app/features/in-app-notifications/store/in-app-notifications.store';
import { InAppNotificationsService } from 'core-app/features/in-app-notifications/store/in-app-notifications.service'; import { InAppNotificationsService } from 'core-app/features/in-app-notifications/store/in-app-notifications.service';
import { OpModalService } from 'core-app/shared/components/modal/modal.service'; import { OpModalService } from 'core-app/shared/components/modal/modal.service';
import { merge, timer } from 'rxjs'; import { merge, timer } from 'rxjs';
@ -15,12 +16,17 @@ const POLLING_INTERVAL = 10000;
templateUrl: './in-app-notification-bell.component.html', templateUrl: './in-app-notification-bell.component.html',
styleUrls: ['./in-app-notification-bell.component.sass'], styleUrls: ['./in-app-notification-bell.component.sass'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
InAppNotificationsService,
InAppNotificationsStore,
InAppNotificationsQuery,
],
}) })
export class InAppNotificationBellComponent { export class InAppNotificationBellComponent {
polling$ = timer(10, POLLING_INTERVAL) polling$ = timer(10, POLLING_INTERVAL)
.pipe( .pipe(
filter(() => this.activeWindow.isActive), filter(() => this.activeWindow.isActive),
switchMap(() => this.inAppService.count$()), switchMap(() => this.inAppService.fetchCount()),
); );
unreadCount$ = merge( unreadCount$ = merge(

@ -26,9 +26,9 @@
</div> </div>
<div class="op-ian-center--footer"> <div class="op-ian-center--footer">
<p <p
class="op-ian-center--max-warning" class="op-ian-center--max-warning"
*ngIf="hasMoreThanPageSize$ | async" *ngIf="hasMoreThanPageSize$ | async"
[textContent]="totalCountWarning()" [textContent]="totalCountWarning$ | async"
> >
</p> </p>
</div> </div>

@ -7,6 +7,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { I18nService } from 'core-app/core/i18n/i18n.service'; import { I18nService } from 'core-app/core/i18n/i18n.service';
import { InAppNotificationsQuery } from 'core-app/features/in-app-notifications/store/in-app-notifications.query'; import { InAppNotificationsQuery } from 'core-app/features/in-app-notifications/store/in-app-notifications.query';
import { InAppNotificationsStore } from 'core-app/features/in-app-notifications/store/in-app-notifications.store';
import { InAppNotificationsService } from 'core-app/features/in-app-notifications/store/in-app-notifications.service'; import { InAppNotificationsService } from 'core-app/features/in-app-notifications/store/in-app-notifications.service';
import { NOTIFICATIONS_MAX_SIZE } from 'core-app/features/in-app-notifications/store/in-app-notification.model'; import { NOTIFICATIONS_MAX_SIZE } from 'core-app/features/in-app-notifications/store/in-app-notification.model';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
@ -19,6 +20,11 @@ import { UIRouterGlobals } from '@uirouter/core';
templateUrl: './in-app-notification-center.component.html', templateUrl: './in-app-notification-center.component.html',
styleUrls: ['./in-app-notification-center.component.sass'], styleUrls: ['./in-app-notification-center.component.sass'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
InAppNotificationsService,
InAppNotificationsStore,
InAppNotificationsQuery,
],
}) })
export class InAppNotificationCenterComponent implements OnInit { export class InAppNotificationCenterComponent implements OnInit {
activeFacet$ = this.ianQuery.activeFacet$; activeFacet$ = this.ianQuery.activeFacet$;
@ -42,6 +48,11 @@ export class InAppNotificationCenterComponent implements OnInit {
map((facet:'unread'|'all') => this.text.no_results[facet] || this.text.no_results.unread), map((facet:'unread'|'all') => this.text.no_results[facet] || this.text.no_results.unread),
); );
totalCountWarning$ = this.ianQuery.notLoaded$.pipe(map((notLoaded:number) => this.I18n.t(
'js.notifications.center.total_count_warning',
{ newest_count: NOTIFICATIONS_MAX_SIZE, more_count: notLoaded },
)));
maxSize = NOTIFICATIONS_MAX_SIZE; maxSize = NOTIFICATIONS_MAX_SIZE;
facets:string[] = ['unread', 'all']; facets:string[] = ['unread', 'all'];
@ -65,20 +76,11 @@ export class InAppNotificationCenterComponent implements OnInit {
readonly ianQuery:InAppNotificationsQuery, readonly ianQuery:InAppNotificationsQuery,
readonly uiRouterGlobals:UIRouterGlobals, readonly uiRouterGlobals:UIRouterGlobals,
readonly state:StateService, readonly state:StateService,
) { ) { }
}
ngOnInit():void { ngOnInit():void {
this.ianService.get(); this.ianService.setActiveFacet('unread');
} this.ianService.setActiveFilters([]);
totalCountWarning():string {
const state = this.ianQuery.getValue();
return this.I18n.t(
'js.notifications.center.total_count_warning',
{ newest_count: NOTIFICATIONS_MAX_SIZE, more_count: state.notShowing },
);
} }
openSplitView($event:WorkPackageResource):void { openSplitView($event:WorkPackageResource):void {

@ -8,11 +8,16 @@ import {
} from './in-app-notifications.store'; } from './in-app-notifications.store';
import { InAppNotification } from 'core-app/features/in-app-notifications/store/in-app-notification.model'; import { InAppNotification } from 'core-app/features/in-app-notifications/store/in-app-notification.model';
@Injectable({ providedIn: 'root' }) @Injectable()
export class InAppNotificationsQuery extends QueryEntity<InAppNotificationsState> { export class InAppNotificationsQuery extends QueryEntity<InAppNotificationsState> {
/** Select the active filter facet */ /** Select the active filter facet */
activeFacet$ = this.select('activeFacet'); activeFacet$ = this.select('activeFacet');
activeFetchParameters$ = this.select(['activeFacet', 'activeFilters']);
/** Select the active filter facet */
notLoaded$ = this.select('notLoaded');
/** Get the faceted items */ /** Get the faceted items */
faceted$ = this.activeFacet$ faceted$ = this.activeFacet$
.pipe( .pipe(
@ -60,7 +65,7 @@ export class InAppNotificationsQuery extends QueryEntity<InAppNotificationsState
hasMoreThanPageSize$ = this hasMoreThanPageSize$ = this
.select() .select()
.pipe( .pipe(
map(({ notShowing }) => notShowing > 0), map(({ notLoaded }) => notLoaded > 0),
); );
constructor(protected store:InAppNotificationsStore) { constructor(protected store:InAppNotificationsStore) {

@ -1,17 +1,15 @@
import { EventEmitter, Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { map, switchMap, tap, take, debounceTime } from 'rxjs/operators';
import { applyTransaction, ID, setLoading } from '@datorama/akita'; import { applyTransaction, ID, setLoading } from '@datorama/akita';
import { Observable } from 'rxjs'; import { ApiV3ListFilter } from 'core-app/core/apiv3/paths/apiv3-list-resource.interface';
import { APIV3Service } from 'core-app/core/apiv3/api-v3.service'; import { APIV3Service } from 'core-app/core/apiv3/api-v3.service';
import { map, switchMap, tap } from 'rxjs/operators';
import { NotificationsService } from 'core-app/shared/components/notifications/notifications.service'; import { NotificationsService } from 'core-app/shared/components/notifications/notifications.service';
import { InAppNotificationsQuery } from 'core-app/features/in-app-notifications/store/in-app-notifications.query'; import { InAppNotificationsQuery } from 'core-app/features/in-app-notifications/store/in-app-notifications.query';
import { take } from 'rxjs/internal/operators/take';
import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { InAppNotificationsStore } from './in-app-notifications.store'; import { InAppNotificationsStore } from './in-app-notifications.store';
import { InAppNotification, NOTIFICATIONS_MAX_SIZE } from './in-app-notification.model'; import { InAppNotification, NOTIFICATIONS_MAX_SIZE } from './in-app-notification.model';
import { IHALCollection } from 'core-app/core/apiv3/types/hal-collection.type';
@Injectable({ providedIn: 'root' }) @Injectable()
export class InAppNotificationsService { export class InAppNotificationsService {
constructor( constructor(
private store:InAppNotificationsStore, private store:InAppNotificationsStore,
@ -19,43 +17,50 @@ export class InAppNotificationsService {
private apiV3Service:APIV3Service, private apiV3Service:APIV3Service,
private notifications:NotificationsService, private notifications:NotificationsService,
) { ) {
this.query.activeFetchParameters$
.pipe(debounceTime(0))
.subscribe(() => {
this.fetchNotifications();
this.fetchCount();
});
} }
public notificationsOfWpLoaded:EventEmitter<IHALCollection<InAppNotification>> = new EventEmitter<IHALCollection<InAppNotification>>(); fetchNotifications() {
get():void {
this.store.setLoading(true); this.store.setLoading(true);
const facet = this.query.getValue().activeFacet; const { activeFacet, activeFilters } = this.query.getValue();
this const call = this
.apiV3Service .apiV3Service
.notifications .notifications
.facet(facet, { pageSize: NOTIFICATIONS_MAX_SIZE }) .facet(activeFacet, {
pageSize: NOTIFICATIONS_MAX_SIZE,
filters: activeFilters,
});
call
.pipe( .pipe(
tap((events) => this.sideLoadInvolvedWorkPackages(events._embedded.elements)), tap((events) => this.sideLoadInvolvedWorkPackages(events._embedded.elements)),
) )
.subscribe( .subscribe(
(events) => { (events) => applyTransaction(() => {
applyTransaction(() => { this.store.set(events._embedded.elements);
this.store.set(events._embedded.elements); this.store.update({ notShowing: events.total - events.count });
this.store.update({ notShowing: events.total - events.count }); }),
}); (error) => this.notifications.addError(error),
},
(error) => {
this.notifications.addError(error);
},
) )
.add( .add(() => this.store.setLoading(false));
() => this.store.setLoading(false),
); return call;
} }
count$():Observable<number> { fetchCount() {
const { activeFilters } = this.query.getValue();
return this return this
.apiV3Service .apiV3Service
.notifications .notifications
.unread({ pageSize: 0 }) .unread({ pageSize: 0, filters: activeFilters })
.pipe( .pipe(
map((events) => events.total), map((events) => events.total),
tap((unreadCount) => { tap((unreadCount) => {
@ -72,8 +77,12 @@ export class InAppNotificationsService {
this.store.update((state) => ({ ...state, activeFacet: facet })); this.store.update((state) => ({ ...state, activeFacet: facet }));
} }
markAllRead():void { setActiveFilters(filters:ApiV3ListFilter[]):void {
this.query this.store.update((state) => ({ ...state, activeFilters: filters }));
}
markAllRead() {
return this.query
.unread$ .unread$
.pipe( .pipe(
take(1), take(1),
@ -88,10 +97,10 @@ export class InAppNotificationsService {
}); });
} }
markAsRead(notifications:InAppNotification[], keep = false):void { markAsRead(notifications:InAppNotification[], keep = false) {
const ids = notifications.map((n) => n.id); const ids = notifications.map((n) => n.id);
this return this
.apiV3Service .apiV3Service
.notifications .notifications
.markRead(ids) .markRead(ids)
@ -108,24 +117,6 @@ export class InAppNotificationsService {
}); });
} }
loadNotificationsOfWorkPackage(workPackageId:string):void {
this
.apiV3Service
.notifications
.facet(
'unread',
{
pageSize: NOTIFICATIONS_MAX_SIZE,
filters: [
['resourceId', '=', [workPackageId]],
['resourceType', '=', ['WorkPackage']],
],
},
).subscribe((notificationCollection) => {
this.notificationsOfWpLoaded.emit(notificationCollection);
});
}
private sideLoadInvolvedWorkPackages(elements:InAppNotification[]) { private sideLoadInvolvedWorkPackages(elements:InAppNotification[]) {
const wpIds = elements.map((element) => { const wpIds = elements.map((element) => {
const href = element._links.resource?.href; const href = element._links.resource?.href;
@ -137,22 +128,4 @@ export class InAppNotificationsService {
.work_packages .work_packages
.requireAll(_.compact(wpIds)); .requireAll(_.compact(wpIds));
} }
collapse(notification:InAppNotification):void {
this.store.update(
notification.id,
{
expanded: false,
},
);
}
expand(notification:InAppNotification):void {
this.store.update(
notification.id,
{
expanded: true,
},
);
}
} }

@ -1,26 +1,29 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { EntityState, EntityStore, StoreConfig } from '@datorama/akita'; import { EntityState, EntityStore, StoreConfig } from '@datorama/akita';
import { InAppNotification } from './in-app-notification.model'; import { InAppNotification } from './in-app-notification.model';
import { ApiV3ListFilter } from 'core-app/core/apiv3/paths/apiv3-list-resource.interface';
export interface InAppNotificationsState extends EntityState<InAppNotification> { export interface InAppNotificationsState extends EntityState<InAppNotification> {
/** The entities in the store might not all be unread so we keep separate count */ /** The entities in the store might not all be unread so we keep separate count */
unreadCount:number; unreadCount:number;
/** Number of elements not showing after max values loaded */ /** Number of elements not showing after max values loaded */
notShowing:number; notLoaded:number;
activeFacet:string; activeFacet:string;
activeFilters:ApiV3ListFilter[];
expanded:boolean; expanded:boolean;
} }
export function createInitialState():InAppNotificationsState { export function createInitialState():InAppNotificationsState {
return { return {
unreadCount: 0, unreadCount: 0,
notShowing: 0, notLoaded: 0,
activeFacet: 'unread', activeFacet: 'unread',
activeFilters: [],
expanded: false, expanded: false,
}; };
} }
@Injectable({ providedIn: 'root' }) @Injectable()
@StoreConfig({ name: 'in-app-notifications' }) @StoreConfig({ name: 'in-app-notifications' })
export class InAppNotificationsStore extends EntityStore<InAppNotificationsState> { export class InAppNotificationsStore extends EntityStore<InAppNotificationsState> {
constructor() { constructor() {

@ -28,7 +28,6 @@
import { ChangeDetectorRef, Directive, OnInit } from '@angular/core'; import { ChangeDetectorRef, Directive, OnInit } from '@angular/core';
import { Transition } from '@uirouter/core'; import { Transition } from '@uirouter/core';
import { combineLatest } from 'rxjs';
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { ActivityEntryInfo } from 'core-app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-entry-info'; import { ActivityEntryInfo } from 'core-app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-entry-info';
@ -95,6 +94,15 @@ export class ActivityPanelBaseController extends UntilDestroyedMixin implements
}); });
}); });
this.ianService.setActiveFacet('unread');
this.ianService.setActiveFilters([
['resourceId', '=', [workPackageId]],
['resourceType', '=', ['WorkPackage']],
]);
this.ianService.fetchNotifications();
this.ianService.loadNotificationsOfWorkPackage(this.workPackageId); this.ianService.loadNotificationsOfWorkPackage(this.workPackageId);
this.ianService.notificationsOfWpLoaded.subscribe((notificationCollection) => { this.ianService.notificationsOfWpLoaded.subscribe((notificationCollection) => {
this.notifications = notificationCollection._embedded.elements; this.notifications = notificationCollection._embedded.elements;

@ -29,9 +29,12 @@
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { StateService } from '@uirouter/core'; import { StateService } from '@uirouter/core';
import { Component, Injector, OnInit } from '@angular/core'; import { Component, Injector, OnInit } from '@angular/core';
import { of } from 'rxjs';
import { WorkPackageViewSelectionService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-selection.service'; import { WorkPackageViewSelectionService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-selection.service';
import { WorkPackageSingleViewBase } from 'core-app/features/work-packages/routing/wp-view-base/work-package-single-view.base'; import { WorkPackageSingleViewBase } from 'core-app/features/work-packages/routing/wp-view-base/work-package-single-view.base';
import { of } from 'rxjs'; import { InAppNotificationsQuery } from 'core-app/features/in-app-notifications/store/in-app-notifications.query';
import { InAppNotificationsStore } from 'core-app/features/in-app-notifications/store/in-app-notifications.store';
import { InAppNotificationsService } from 'core-app/features/in-app-notifications/store/in-app-notifications.service';
import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service';
import { WorkPackageNotificationService } from 'core-app/features/work-packages/services/notifications/work-package-notification.service'; import { WorkPackageNotificationService } from 'core-app/features/work-packages/services/notifications/work-package-notification.service';
import { InAppNotificationsService } from 'core-app/features/in-app-notifications/store/in-app-notifications.service'; import { InAppNotificationsService } from 'core-app/features/in-app-notifications/store/in-app-notifications.service';
@ -43,6 +46,9 @@ import { InAppNotificationsService } from 'core-app/features/in-app-notification
host: { class: 'work-packages-page--ui-view' }, host: { class: 'work-packages-page--ui-view' },
providers: [ providers: [
{ provide: HalResourceNotificationService, useExisting: WorkPackageNotificationService }, { provide: HalResourceNotificationService, useExisting: WorkPackageNotificationService },
InAppNotificationsService,
InAppNotificationsStore,
InAppNotificationsQuery,
], ],
}) })
export class WorkPackagesFullViewComponent extends WorkPackageSingleViewBase implements OnInit { export class WorkPackagesFullViewComponent extends WorkPackageSingleViewBase implements OnInit {

@ -30,6 +30,9 @@ import { ChangeDetectionStrategy, Component, Injector, OnInit } from '@angular/c
import { StateService } from '@uirouter/core'; import { StateService } from '@uirouter/core';
import { WorkPackageViewFocusService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-focus.service'; import { WorkPackageViewFocusService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-focus.service';
import { States } from 'core-app/core/states/states.service'; import { States } from 'core-app/core/states/states.service';
import { InAppNotificationsQuery } from 'core-app/features/in-app-notifications/store/in-app-notifications.query';
import { InAppNotificationsStore } from 'core-app/features/in-app-notifications/store/in-app-notifications.store';
import { InAppNotificationsService } from 'core-app/features/in-app-notifications/store/in-app-notifications.service';
import { FirstRouteService } from 'core-app/core/routing/first-route-service'; import { FirstRouteService } from 'core-app/core/routing/first-route-service';
import { KeepTabService } from 'core-app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service'; import { KeepTabService } from 'core-app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service';
import { WorkPackageViewSelectionService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-selection.service'; import { WorkPackageViewSelectionService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-selection.service';
@ -45,6 +48,9 @@ import { InAppNotificationsService } from 'core-app/features/in-app-notification
selector: 'wp-split-view-entry', selector: 'wp-split-view-entry',
providers: [ providers: [
{ provide: HalResourceNotificationService, useClass: WorkPackageNotificationService }, { provide: HalResourceNotificationService, useClass: WorkPackageNotificationService },
InAppNotificationsService,
InAppNotificationsStore,
InAppNotificationsQuery,
], ],
}) })
export class WorkPackageSplitViewComponent extends WorkPackageSingleViewBase implements OnInit { export class WorkPackageSplitViewComponent extends WorkPackageSingleViewBase implements OnInit {

Loading…
Cancel
Save