diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 2c6d64a20d..cef9336486 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -576,6 +576,7 @@ en: all: 'All' center: mark_all_read: 'Mark all as read' + mark_as_read: 'Mark as read' text_update_date: "%{date} by" total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed." settings: diff --git a/frontend/src/app/core/global_search/tabs/global-search-tabs.component.ts b/frontend/src/app/core/global_search/tabs/global-search-tabs.component.ts index 8544c36740..07e39af9b9 100644 --- a/frontend/src/app/core/global_search/tabs/global-search-tabs.component.ts +++ b/frontend/src/app/core/global_search/tabs/global-search-tabs.component.ts @@ -26,7 +26,12 @@ // See docs/COPYRIGHT.rdoc for more details. //++ -import { ChangeDetectorRef, Component, OnDestroy } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + OnDestroy, + Injector, +} from '@angular/core'; import { Subscription } from 'rxjs'; import { GlobalSearchService } from 'core-app/core/global_search/services/global-search.service'; import { ScrollableTabsComponent } from 'core-app/shared/components/tabs/scrollable-tabs/scrollable-tabs.component'; @@ -46,9 +51,12 @@ export class GlobalSearchTabsComponent extends ScrollableTabsComponent implement public classes:string[] = ['global-search--tabs', 'scrollable-tabs']; - constructor(readonly globalSearchService:GlobalSearchService, - cdRef:ChangeDetectorRef) { - super(cdRef); + constructor( + readonly globalSearchService:GlobalSearchService, + public injector:Injector, + cdRef:ChangeDetectorRef, + ) { + super(cdRef, injector); } ngOnInit() { diff --git a/frontend/src/app/features/in-app-notifications/bell/in-app-notification-bell.component.html b/frontend/src/app/features/in-app-notifications/bell/in-app-notification-bell.component.html index 83a445e898..6c7fc8ca1e 100644 --- a/frontend/src/app/features/in-app-notifications/bell/in-app-notification-bell.component.html +++ b/frontend/src/app/features/in-app-notifications/bell/in-app-notification-bell.component.html @@ -2,15 +2,14 @@ class="op-ian-bell op-app-menu--item-action" data-qa-selector="op-ian-bell" [href]="notificationsPath()" - > - - +> + - - + [textContent]="unreadCount > 99 ? '' : unreadCount" + > \ No newline at end of file diff --git a/frontend/src/app/features/in-app-notifications/bell/in-app-notification-bell.component.ts b/frontend/src/app/features/in-app-notifications/bell/in-app-notification-bell.component.ts index 434bce9255..9d0570947f 100644 --- a/frontend/src/app/features/in-app-notifications/bell/in-app-notification-bell.component.ts +++ b/frontend/src/app/features/in-app-notifications/bell/in-app-notification-bell.component.ts @@ -1,9 +1,14 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; 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 { OpModalService } from 'core-app/shared/components/modal/modal.service'; -import { merge, timer } from 'rxjs'; -import { filter, switchMap } from 'rxjs/operators'; +import { timer, combineLatest } from 'rxjs'; +import { + filter, + switchMap, + map, +} from 'rxjs/operators'; import { ActiveWindowService } from 'core-app/core/active-window/active-window.service'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; @@ -15,18 +20,22 @@ const POLLING_INTERVAL = 10000; templateUrl: './in-app-notification-bell.component.html', styleUrls: ['./in-app-notification-bell.component.sass'], changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + InAppNotificationsService, + InAppNotificationsStore, + InAppNotificationsQuery, + ], }) -export class InAppNotificationBellComponent { - polling$ = timer(10, POLLING_INTERVAL) - .pipe( - filter(() => this.activeWindow.isActive), - switchMap(() => this.inAppService.count$()), - ); +export class InAppNotificationBellComponent implements OnInit { + polling$ = timer(10, POLLING_INTERVAL).pipe( + filter(() => this.activeWindow.isActive), + switchMap(() => this.inAppService.fetchNotifications()), + ); - unreadCount$ = merge( + unreadCount$ = combineLatest([ + this.inAppQuery.notLoaded$, this.polling$, - this.inAppQuery.unreadCount$, - ); + ]).pipe(map(([count]) => count)); constructor( readonly inAppQuery:InAppNotificationsQuery, @@ -34,7 +43,11 @@ export class InAppNotificationBellComponent { readonly activeWindow:ActiveWindowService, readonly modalService:OpModalService, readonly pathHelper:PathHelperService, - ) {} + ) { } + + ngOnInit():void { + this.inAppService.setPageSize(0); + } notificationsPath():string { return this.pathHelper.notificationsPath(); diff --git a/frontend/src/app/features/in-app-notifications/center/in-app-notification-center-page.component.ts b/frontend/src/app/features/in-app-notifications/center/in-app-notification-center-page.component.ts index e71874709e..36a5e710fa 100644 --- a/frontend/src/app/features/in-app-notifications/center/in-app-notification-center-page.component.ts +++ b/frontend/src/app/features/in-app-notifications/center/in-app-notification-center-page.component.ts @@ -20,6 +20,9 @@ import { NotificationSettingsButtonComponent } from 'core-app/features/in-app-no import { ActivateFacetButtonComponent } from 'core-app/features/in-app-notifications/center/toolbar/facet/activate-facet-button.component'; import { MarkAllAsReadButtonComponent } from 'core-app/features/in-app-notifications/center/toolbar/mark-all-as-read/mark-all-as-read-button.component'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; +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 { BackRouteOptions, BackRoutingService, @@ -31,6 +34,11 @@ import { '../../work-packages/routing/partitioned-query-space-page/partitioned-query-space-page.component.sass', ], changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + InAppNotificationsService, + InAppNotificationsStore, + InAppNotificationsQuery, + ], }) export class InAppNotificationCenterPageComponent extends UntilDestroyedMixin implements OnInit { text = { diff --git a/frontend/src/app/features/in-app-notifications/center/in-app-notification-center.component.html b/frontend/src/app/features/in-app-notifications/center/in-app-notification-center.component.html index a2d0f115b0..abe43f402a 100644 --- a/frontend/src/app/features/in-app-notifications/center/in-app-notification-center.component.html +++ b/frontend/src/app/features/in-app-notifications/center/in-app-notification-center.component.html @@ -26,9 +26,9 @@ diff --git a/frontend/src/app/features/in-app-notifications/center/in-app-notification-center.component.ts b/frontend/src/app/features/in-app-notifications/center/in-app-notification-center.component.ts index db1cb1f8f1..96b420d7fd 100644 --- a/frontend/src/app/features/in-app-notifications/center/in-app-notification-center.component.ts +++ b/frontend/src/app/features/in-app-notifications/center/in-app-notification-center.component.ts @@ -6,11 +6,11 @@ import { OnInit, } from '@angular/core'; import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { InAppNotificationsQuery } from 'core-app/features/in-app-notifications/store/in-app-notifications.query'; -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 { map } from 'rxjs/operators'; import { StateService } from '@uirouter/angular'; +import { InAppNotificationsQuery } from 'core-app/features/in-app-notifications/store/in-app-notifications.query'; +import { InAppNotificationsService } from 'core-app/features/in-app-notifications/store/in-app-notifications.service'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { UIRouterGlobals } from '@uirouter/core'; @@ -24,17 +24,16 @@ export class InAppNotificationCenterComponent implements OnInit { activeFacet$ = this.ianQuery.activeFacet$; notifications$ = this - .ianQuery + .ianService + .query .aggregatedNotifications$ .pipe( map((items) => Object.values(items)), ); - notificationsCount$ = this.ianQuery.selectCount(); + hasNotifications$ = this.ianService.query.hasNotifications$; - hasNotifications$ = this.ianQuery.hasNotifications$; - - hasMoreThanPageSize$ = this.ianQuery.hasMoreThanPageSize$; + hasMoreThanPageSize$ = this.ianService.query.hasMoreThanPageSize$; noResultText$ = this .activeFacet$ @@ -42,6 +41,17 @@ export class InAppNotificationCenterComponent implements OnInit { map((facet:'unread'|'all') => this.text.no_results[facet] || this.text.no_results.unread), ); + totalCountWarning$ = this + .ianService + .query + .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; facets:string[] = ['unread', 'all']; @@ -65,20 +75,11 @@ export class InAppNotificationCenterComponent implements OnInit { readonly ianQuery:InAppNotificationsQuery, readonly uiRouterGlobals:UIRouterGlobals, readonly state:StateService, - ) { - } + ) { } ngOnInit():void { - this.ianService.get(); - } - - 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 }, - ); + this.ianService.setActiveFacet('unread'); + this.ianService.setActiveFilters([]); } openSplitView($event:WorkPackageResource):void { diff --git a/frontend/src/app/features/in-app-notifications/center/toolbar/facet/activate-facet-button.component.ts b/frontend/src/app/features/in-app-notifications/center/toolbar/facet/activate-facet-button.component.ts index 14c4e38181..a005c620de 100644 --- a/frontend/src/app/features/in-app-notifications/center/toolbar/facet/activate-facet-button.component.ts +++ b/frontend/src/app/features/in-app-notifications/center/toolbar/facet/activate-facet-button.component.ts @@ -30,6 +30,5 @@ export class ActivateFacetButtonComponent { activateFacet(facet:string):void { this.ianService.setActiveFacet(facet); - this.ianService.get(); } } diff --git a/frontend/src/app/features/in-app-notifications/store/in-app-notifications.query.ts b/frontend/src/app/features/in-app-notifications/store/in-app-notifications.query.ts index 1d94ac55d1..4ef2e8044e 100644 --- a/frontend/src/app/features/in-app-notifications/store/in-app-notifications.query.ts +++ b/frontend/src/app/features/in-app-notifications/store/in-app-notifications.query.ts @@ -8,11 +8,16 @@ import { } from './in-app-notifications.store'; import { InAppNotification } from 'core-app/features/in-app-notifications/store/in-app-notification.model'; -@Injectable({ providedIn: 'root' }) +@Injectable() export class InAppNotificationsQuery extends QueryEntity { /** Select the active filter facet */ activeFacet$ = this.select('activeFacet'); + activeFetchParameters$ = this.select(['activeFacet', 'pageSize', 'activeFilters']); + + /** Select the active filter facet */ + notLoaded$ = this.select('notLoaded'); + /** Get the faceted items */ faceted$ = this.activeFacet$ .pipe( @@ -37,17 +42,17 @@ export class InAppNotificationsQuery extends QueryEntity count > 0)); - /** Get the unread items */ unread$ = this.selectAll({ filterBy: ({ readIAN }) => !readIAN, }); + /** Get the number of unread items */ + unreadCount$ = this.unread$.pipe(map((notifications) => notifications.length)); + + /** Do we have any unread items? */ + hasUnread$ = this.unreadCount$.pipe(map((count) => count > 0)); + /** Get all items that shall be kept in the notification center */ unreadOrKept$ = this.selectAll({ filterBy: ({ readIAN, keep }) => !readIAN || !!keep, @@ -56,11 +61,11 @@ export class InAppNotificationsQuery extends QueryEntity count > 0)); - /** Determine whether the pageSize is not sufficient to load all notifcations */ + /** Determine whether the pageSize is not sufficient to load all notifications */ hasMoreThanPageSize$ = this .select() .pipe( - map(({ notShowing }) => notShowing > 0), + map(({ notLoaded }) => notLoaded > 0), ); constructor(protected store:InAppNotificationsStore) { diff --git a/frontend/src/app/features/in-app-notifications/store/in-app-notifications.service.ts b/frontend/src/app/features/in-app-notifications/store/in-app-notifications.service.ts index c50d263e14..bfb993c401 100644 --- a/frontend/src/app/features/in-app-notifications/store/in-app-notifications.service.ts +++ b/frontend/src/app/features/in-app-notifications/store/in-app-notifications.service.ts @@ -1,62 +1,65 @@ import { Injectable } from '@angular/core'; +import { + debounceTime, + switchMap, + take, + tap, + catchError, +} from 'rxjs/operators'; +import { Subscription, Observable } from 'rxjs'; 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 { map, switchMap, tap } from 'rxjs/operators'; 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 { take } from 'rxjs/internal/operators/take'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { InAppNotificationsStore } from './in-app-notifications.store'; -import { InAppNotification, NOTIFICATIONS_MAX_SIZE } from './in-app-notification.model'; +import { InAppNotification } from './in-app-notification.model'; +import { IHALCollection } from 'core-app/core/apiv3/types/hal-collection.type'; -@Injectable({ providedIn: 'root' }) +@Injectable() export class InAppNotificationsService { constructor( private store:InAppNotificationsStore, - private query:InAppNotificationsQuery, + public query:InAppNotificationsQuery, private apiV3Service:APIV3Service, private notifications:NotificationsService, ) { + this.query.activeFetchParameters$ + .pipe( + debounceTime(0), + switchMap(() => this.fetchNotifications()), + ).subscribe(); } - get():void { + fetchNotifications():Observable> { this.store.setLoading(true); - const facet = this.query.getValue().activeFacet; + const { + activeFacet, + activeFilters, + pageSize, + } = this.query.getValue(); - this + return this .apiV3Service .notifications - .facet(facet, { pageSize: NOTIFICATIONS_MAX_SIZE }) + .facet(activeFacet, { + pageSize, + filters: activeFilters, + }) .pipe( - tap((events) => this.sideLoadInvolvedWorkPackages(events._embedded.elements)), - ) - .subscribe( - (events) => { + tap((events) => { + this.sideLoadInvolvedWorkPackages(events._embedded.elements); applyTransaction(() => { this.store.set(events._embedded.elements); - this.store.update({ notShowing: events.total - events.count }); + this.store.update({ notLoaded: events.total - events.count }); }); - }, - (error) => { + this.store.setLoading(false); + }), + catchError((error) => { this.notifications.addError(error); - }, - ) - .add( - () => this.store.setLoading(false), - ); - } - - count$():Observable { - return this - .apiV3Service - .notifications - .unread({ pageSize: 0 }) - .pipe( - map((events) => events.total), - tap((unreadCount) => { - this.store.update({ unreadCount }); + throw error; }), ); } @@ -65,12 +68,20 @@ export class InAppNotificationsService { this.store.update(id, inAppNotification); } + setPageSize(pageSize:number):void { + this.store.update((state) => ({ ...state, pageSize })); + } + setActiveFacet(facet:string):void { this.store.update((state) => ({ ...state, activeFacet: facet })); } - markAllRead():void { - this.query + setActiveFilters(filters:ApiV3ListFilter[]):void { + this.store.update((state) => ({ ...state, activeFilters: filters })); + } + + markAllRead():Subscription { + return this.query .unread$ .pipe( take(1), @@ -85,10 +96,10 @@ export class InAppNotificationsService { }); } - markAsRead(notifications:InAppNotification[], keep = false):void { + markAsRead(notifications:InAppNotification[], keep = false):Subscription { const ids = notifications.map((n) => n.id); - this + return this .apiV3Service .notifications .markRead(ids) @@ -105,7 +116,7 @@ export class InAppNotificationsService { }); } - private sideLoadInvolvedWorkPackages(elements:InAppNotification[]) { + private sideLoadInvolvedWorkPackages(elements:InAppNotification[]):void { const wpIds = elements.map((element) => { const href = element._links.resource?.href; return href && HalResource.matchFromLink(href, 'work_packages'); @@ -116,22 +127,4 @@ export class InAppNotificationsService { .work_packages .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, - }, - ); - } } diff --git a/frontend/src/app/features/in-app-notifications/store/in-app-notifications.store.ts b/frontend/src/app/features/in-app-notifications/store/in-app-notifications.store.ts index c53b8280da..97968fc52d 100644 --- a/frontend/src/app/features/in-app-notifications/store/in-app-notifications.store.ts +++ b/frontend/src/app/features/in-app-notifications/store/in-app-notifications.store.ts @@ -1,26 +1,28 @@ import { Injectable } from '@angular/core'; import { EntityState, EntityStore, StoreConfig } from '@datorama/akita'; -import { InAppNotification } from './in-app-notification.model'; +import { InAppNotification, NOTIFICATIONS_MAX_SIZE } from './in-app-notification.model'; +import { ApiV3ListFilter } from 'core-app/core/apiv3/paths/apiv3-list-resource.interface'; export interface InAppNotificationsState extends EntityState { - /** The entities in the store might not all be unread so we keep separate count */ - unreadCount:number; /** Number of elements not showing after max values loaded */ - notShowing:number; + notLoaded:number; + pageSize:number; activeFacet:string; + activeFilters:ApiV3ListFilter[]; expanded:boolean; } export function createInitialState():InAppNotificationsState { return { - unreadCount: 0, - notShowing: 0, + notLoaded: 0, + pageSize: NOTIFICATIONS_MAX_SIZE, activeFacet: 'unread', + activeFilters: [], expanded: false, }; } -@Injectable({ providedIn: 'root' }) +@Injectable() @StoreConfig({ name: 'in-app-notifications' }) export class InAppNotificationsStore extends EntityStore { constructor() { diff --git a/frontend/src/app/features/work-packages/components/wp-buttons/wp-mark-notification-button/work-package-mark-notification-button.component.html b/frontend/src/app/features/work-packages/components/wp-buttons/wp-mark-notification-button/work-package-mark-notification-button.component.html new file mode 100644 index 0000000000..f76a5e0ee4 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-buttons/wp-mark-notification-button/work-package-mark-notification-button.component.html @@ -0,0 +1,8 @@ + diff --git a/frontend/src/app/features/work-packages/components/wp-buttons/wp-mark-notification-button/work-package-mark-notification-button.component.ts b/frontend/src/app/features/work-packages/components/wp-buttons/wp-mark-notification-button/work-package-mark-notification-button.component.ts new file mode 100644 index 0000000000..9567dbd5b4 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-buttons/wp-mark-notification-button/work-package-mark-notification-button.component.ts @@ -0,0 +1,31 @@ +import { + Component, ChangeDetectionStrategy, Input, +} from '@angular/core'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { InAppNotificationsService } from 'core-app/features/in-app-notifications/store/in-app-notifications.service'; +import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; + +@Component({ + selector: 'op-work-package-mark-notification-button', + templateUrl: './work-package-mark-notification-button.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WorkPackageMarkNotificationButtonComponent { + @Input() public workPackage:WorkPackageResource; + + @Input() public buttonClasses:string; + + text = { + mark_as_read: this.I18n.t('js.notifications.center.mark_as_read'), + }; + + constructor( + private I18n:I18nService, + private ianService:InAppNotificationsService, + ) { + } + + markAllBelongingNotificationsAsRead():void { + this.ianService.markAllRead(); + } +} diff --git a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-base.controller.ts b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-base.controller.ts index d6d51dc67e..d34daf2c89 100644 --- a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-base.controller.ts +++ b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-base.controller.ts @@ -28,7 +28,6 @@ import { ChangeDetectorRef, Directive, OnInit } from '@angular/core'; import { Transition } from '@uirouter/core'; -import { combineLatest } from 'rxjs'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-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'; @@ -36,7 +35,10 @@ import { WorkPackagesActivityService } from 'core-app/features/work-packages/com import { I18nService } from 'core-app/core/i18n/i18n.service'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; import { APIV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { InAppNotification, NOTIFICATIONS_MAX_SIZE } 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'; +import { InAppNotificationsService } from 'core-app/features/in-app-notifications/store/in-app-notifications.service'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; @Directive() export class ActivityPanelBaseController extends UntilDestroyedMixin implements OnInit { @@ -65,47 +67,40 @@ export class ActivityPanelBaseController extends UntilDestroyedMixin implements showAll: this.I18n.t('js.label_activity_show_all'), }; - constructor(readonly apiV3Service:APIV3Service, + constructor( + readonly apiV3Service:APIV3Service, readonly I18n:I18nService, readonly cdRef:ChangeDetectorRef, readonly $transition:Transition, - readonly wpActivity:WorkPackagesActivityService) { + readonly wpActivity:WorkPackagesActivityService, + readonly ianService:InAppNotificationsService, + ) { super(); this.reverse = wpActivity.isReversed; this.togglerText = this.text.commentsOnly; } - ngOnInit() { - combineLatest([ - this - .apiV3Service - .work_packages - .id(this.workPackageId) - .requireAndStream() - .pipe(this.untilDestroyed()), - this - .apiV3Service - .notifications - .facet( - 'unread', - { - pageSize: NOTIFICATIONS_MAX_SIZE, - filters: [ - ['resourceId', '=', [this.workPackageId]], - ['resourceType', '=', ['WorkPackage']], - ], - }, - ), - ]) - .subscribe(([wp, notificationCollection]) => { - this.notifications = notificationCollection._embedded.elements; + ngOnInit():void { + this + .apiV3Service + .work_packages + .id(this.workPackageId) + .requireAndStream() + .pipe(this.untilDestroyed()) + .subscribe((wp) => { this.workPackage = wp; this.wpActivity.require(this.workPackage).then((activities:any) => { this.updateActivities(activities); this.cdRef.detectChanges(); }); }); + + this.ianService.setActiveFacet('unread'); + this.ianService.setActiveFilters([ + ['resourceId', '=', [this.workPackageId]], + ['resourceType', '=', ['WorkPackage']], + ]); } protected updateActivities(activities:HalResource[]) { @@ -137,11 +132,17 @@ export class ActivityPanelBaseController extends UntilDestroyedMixin implements .filter((activity:HalResource) => !!_.get(activity, 'comment.html')); } - protected hasUnreadNotification(activityHref:string):boolean { - return !!this.notifications.find((notification) => notification._links.activity?.href === activityHref); + protected hasUnreadNotification(activityHref:string):Observable { + return this + .ianService + .query + .unread$ + .pipe( + map((notifications) => !!notifications.find((notification) => notification._links.activity?.href === activityHref)), + ); } - public toggleComments() { + public toggleComments():void { this.onlyComments = !this.onlyComments; this.updateActivities(this.unfilteredActivities); diff --git a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-on-overview.html b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-on-overview.html index 950822a2c4..662f426550 100644 --- a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-on-overview.html +++ b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-on-overview.html @@ -6,6 +6,7 @@ diff --git a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-tab.html b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-tab.html index 79e9437e83..dc32983e55 100644 --- a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-tab.html +++ b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-tab.html @@ -28,7 +28,7 @@ [workPackage]="workPackage" [activity]="inf.activity" [activityNo]="inf.number(inf.isReversed)" - [hasUnreadNotification]="hasUnreadNotification(inf.href)" + [hasUnreadNotification]="hasUnreadNotification(inf.href) | async" [isInitial]="inf.isInitial()" > diff --git a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service.ts b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service.ts index d58c431ef7..0ebddfa894 100644 --- a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service.ts @@ -105,6 +105,11 @@ export class KeepTabService { return this.currentTab; } + get currentTabIdentifier():string|undefined { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return this.uiRouterGlobals.params.tabIdentifier; + } + protected notify() { // Notify when updated this.subject.next({ diff --git a/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-notifications-count.function.ts b/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-notifications-count.function.ts index 34bfd313a9..8137662cdc 100644 --- a/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-notifications-count.function.ts +++ b/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-notifications-count.function.ts @@ -4,27 +4,16 @@ import { map } from 'rxjs/operators'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { APIV3Service } from 'core-app/core/apiv3/api-v3.service'; import { NOTIFICATIONS_MAX_SIZE } from 'core-app/features/in-app-notifications/store/in-app-notification.model'; +import { InAppNotificationsService } from 'core-app/features/in-app-notifications/store/in-app-notifications.service'; export function workPackageNotificationsCount( workPackage:WorkPackageResource, injector:Injector, ):Observable { - const apiV3Service = injector.get(APIV3Service); - const wpId = workPackage.id?.toString() || ''; + const ianService = injector.get(InAppNotificationsService); - return apiV3Service - .notifications - .facet( - 'unread', - { - pageSize: NOTIFICATIONS_MAX_SIZE, - filters: [ - ['resourceId', '=', [wpId]], - ['resourceType', '=', ['WorkPackage']], - ], - }, - ) + return ianService.query.unread$ .pipe( - map((data) => data._embedded.elements.length), + map((data) => data.length), ); } diff --git a/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service.spec.ts b/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service.spec.ts index 352971167b..03df750322 100644 --- a/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service.spec.ts @@ -50,7 +50,7 @@ describe('WpTabsService', () => { describe('displayableTabs()', () => { it('returns just the displayable tab', () => { - expect(service.getDisplayableTabs(workPackage)).toEqual([displayableTab]); + expect(service.getDisplayableTabs(workPackage)[0].id).toEqual(displayableTab.id); }); }); diff --git a/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service.ts b/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service.ts index 04dc523284..f9c11f7303 100644 --- a/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service.ts @@ -1,8 +1,9 @@ import { Injectable, Injector } from '@angular/core'; +import { from } from 'rxjs'; +import { StateService } from '@uirouter/core'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { WpTabDefinition } from 'core-app/features/work-packages/components/wp-tabs/components/wp-tab-wrapper/tab'; import { WorkPackageRelationsTabComponent } from 'core-app/features/work-packages/components/wp-single-view-tabs/relations-tab/relations-tab.component'; -import { StateService } from '@uirouter/core'; import { WorkPackageOverviewTabComponent } from 'core-app/features/work-packages/components/wp-single-view-tabs/overview-tab/overview-tab.component'; import { WorkPackageActivityTabComponent } from 'core-app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-tab.component'; import { WorkPackageWatchersTabComponent } from 'core-app/features/work-packages/components/wp-single-view-tabs/watchers-tab/watchers-tab.component'; @@ -45,7 +46,9 @@ export class WorkPackageTabsService { .map( (tab) => ({ ...tab, - ...!!tab.count && { counter: tab.count(workPackage, this.injector) }, + counter: tab.count + ? (injector:Injector) => tab.count!(workPackage, injector || this.injector) // eslint-disable-line @typescript-eslint/no-non-null-assertion + : (_:Injector) => from([0]), // eslint-disable-line @typescript-eslint/no-unused-vars }), ); } diff --git a/frontend/src/app/features/work-packages/openproject-work-packages.module.ts b/frontend/src/app/features/work-packages/openproject-work-packages.module.ts index e6ec03a6d9..3692a940ee 100644 --- a/frontend/src/app/features/work-packages/openproject-work-packages.module.ts +++ b/frontend/src/app/features/work-packages/openproject-work-packages.module.ts @@ -171,6 +171,7 @@ import { WorkPackageFilterByTextInputComponent } from 'core-app/features/work-pa import { FilterIntegerValueComponent } from 'core-app/features/work-packages/components/filters/filter-integer-value/filter-integer-value.component'; import { WorkPackageFilterContainerComponent } from 'core-app/features/work-packages/components/filters/filter-container/filter-container.directive'; import { FilterBooleanValueComponent } from 'core-app/features/work-packages/components/filters/filter-boolean-value/filter-boolean-value.component'; +import { WorkPackageMarkNotificationButtonComponent } from 'core-app/features/work-packages/components/wp-buttons/wp-mark-notification-button/work-package-mark-notification-button.component'; import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; @NgModule({ @@ -389,6 +390,8 @@ import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; WorkPackageSingleCardComponent, WorkPackageViewToggleButtonComponent, + // Notifications + WorkPackageMarkNotificationButtonComponent, ], exports: [ WorkPackagesTableComponent, diff --git a/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.component.ts b/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.component.ts index 5e3c836753..f2d49bd050 100644 --- a/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.component.ts +++ b/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.component.ts @@ -29,9 +29,12 @@ import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { StateService } from '@uirouter/core'; import { Component, Injector, OnInit } from '@angular/core'; +import { Observable, of } from 'rxjs'; 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 { 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 { WorkPackageNotificationService } from 'core-app/features/work-packages/services/notifications/work-package-notification.service'; @@ -42,6 +45,9 @@ import { WorkPackageNotificationService } from 'core-app/features/work-packages/ host: { class: 'work-packages-page--ui-view' }, providers: [ { provide: HalResourceNotificationService, useExisting: WorkPackageNotificationService }, + InAppNotificationsService, + InAppNotificationsStore, + InAppNotificationsQuery, ], }) export class WorkPackagesFullViewComponent extends WorkPackageSingleViewBase implements OnInit { @@ -50,13 +56,18 @@ export class WorkPackagesFullViewComponent extends WorkPackageSingleViewBase imp public displayWatchButton:boolean; + public displayNotificationsButton$:Observable = this.ianService.query.hasUnread$; + public watchers:any; stateName$ = of('work-packages.new'); - constructor(public injector:Injector, + constructor( + public injector:Injector, public wpTableSelection:WorkPackageViewSelectionService, - readonly $state:StateService) { + readonly $state:StateService, + readonly ianService:InAppNotificationsService, + ) { super(injector, $state.params.workPackageId); } @@ -78,6 +89,14 @@ export class WorkPackagesFullViewComponent extends WorkPackageSingleViewBase imp // Set Focused WP this.wpTableFocus.updateFocus(this.workPackage.id!); + if (this.workPackage.id) { + this.ianService.setActiveFacet('unread'); + this.ianService.setActiveFilters([ + ['resourceId', '=', [this.workPackage.id]], + ['resourceType', '=', ['WorkPackage']], + ]); + } + this.setWorkPackageScopeProperties(this.workPackage); } diff --git a/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.html b/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.html index 609c58c660..dcde90f9da 100644 --- a/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.html +++ b/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.html @@ -30,6 +30,13 @@ [showText]="false"> +
  • + +
  • diff --git a/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.component.ts b/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.component.ts index 446bc76749..ac9d31fcdc 100644 --- a/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.component.ts +++ b/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.component.ts @@ -27,11 +27,17 @@ //++ import { - ChangeDetectionStrategy, Component, Injector, OnInit, + ChangeDetectionStrategy, + Component, + Injector, + OnInit, } from '@angular/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 { 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 { 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'; @@ -39,6 +45,7 @@ import { WorkPackageSingleViewBase } from 'core-app/features/work-packages/routi 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 { BackRoutingService } from 'core-app/features/work-packages/components/back-routing/back-routing.service'; +import { Observable } from 'rxjs'; @Component({ templateUrl: './wp-split-view.html', @@ -46,20 +53,28 @@ import { BackRoutingService } from 'core-app/features/work-packages/components/b selector: 'wp-split-view-entry', providers: [ { provide: HalResourceNotificationService, useClass: WorkPackageNotificationService }, + InAppNotificationsService, + InAppNotificationsStore, + InAppNotificationsQuery, ], }) export class WorkPackageSplitViewComponent extends WorkPackageSingleViewBase implements OnInit { /** Reference to the base route e.g., work-packages.partitioned.list or bim.partitioned.split */ private baseRoute:string = this.$state.current.data.baseRoute; - constructor(public injector:Injector, + public displayNotificationsButton$:Observable = this.ianService.query.hasUnread$; + + constructor( + public injector:Injector, public states:States, public firstRoute:FirstRouteService, public keepTab:KeepTabService, public wpTableSelection:WorkPackageViewSelectionService, public wpTableFocus:WorkPackageViewFocusService, readonly $state:StateService, - readonly backRouting:BackRoutingService) { + readonly backRouting:BackRoutingService, + readonly ianService:InAppNotificationsService, + ) { super(injector, $state.params.workPackageId); } @@ -95,17 +110,25 @@ export class WorkPackageSplitViewComponent extends WorkPackageSingleViewBase imp ); } }); + + if (wpId) { + this.ianService.setActiveFacet('unread'); + this.ianService.setActiveFilters([ + ['resourceId', '=', [wpId]], + ['resourceType', '=', ['WorkPackage']], + ]); + } } - public get shouldFocus() { + get shouldFocus():boolean { return this.$state.params.focus === true; } - public showBackButton():boolean { + showBackButton():boolean { return this.baseRoute.includes('bim'); } - public backToList() { + backToList():void { this.backRouting.goToBaseState(); } } diff --git a/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.html b/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.html index 610a72f625..08463a9e72 100644 --- a/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.html +++ b/frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.html @@ -6,9 +6,22 @@ - +
    + - + +
    + +
    - + + ; + @Input() count:number; } diff --git a/frontend/src/app/shared/components/tabs/tab.interface.ts b/frontend/src/app/shared/components/tabs/tab.interface.ts index f179673690..bc7da26d5a 100644 --- a/frontend/src/app/shared/components/tabs/tab.interface.ts +++ b/frontend/src/app/shared/components/tabs/tab.interface.ts @@ -1,4 +1,5 @@ import { Observable } from 'rxjs'; +import { Injector } from '@angular/core'; export interface TabDefinition { /** Internal identifier of the tab */ @@ -12,7 +13,7 @@ export interface TabDefinition { /** UI router params to use uiParams with */ routeParams?:unknown; /** Show a tab count with this observable's result */ - counter?:Observable; + counter?:(injector?:Injector) => Observable; /** Whether the counter should be shown as number in brackets or within a bubble */ showCountAsBubble?:boolean; /** Disable the tab, optionally with an explanatory title */ diff --git a/frontend/src/global_styles/content/work_packages/new/_split_view.sass b/frontend/src/global_styles/content/work_packages/new/_split_view.sass index fb725d8e76..4970cdb055 100644 --- a/frontend/src/global_styles/content/work_packages/new/_split_view.sass +++ b/frontend/src/global_styles/content/work_packages/new/_split_view.sass @@ -2,6 +2,8 @@ // Override the top header in create mode &.-create-mode top: 0 + overflow-y: auto + // Details header containing switch icon and status|type row .work-packages--new-details-header margin: 0.375em 0 diff --git a/frontend/src/global_styles/layout/_boards.sass b/frontend/src/global_styles/layout/_boards.sass index db573c708c..fe79cf46e5 100644 --- a/frontend/src/global_styles/layout/_boards.sass +++ b/frontend/src/global_styles/layout/_boards.sass @@ -1,7 +1,5 @@ // Let board list span whole screen .router--boards-full-view - @include extended-content--bottom - @include extended-content--right @include extended-content--left #content diff --git a/frontend/src/global_styles/layout/work_packages/_details_view.sass b/frontend/src/global_styles/layout/work_packages/_details_view.sass index b03d4e3302..60349645bc 100644 --- a/frontend/src/global_styles/layout/work_packages/_details_view.sass +++ b/frontend/src/global_styles/layout/work_packages/_details_view.sass @@ -49,8 +49,8 @@ body.router--work-packages-partitioned-split-view-new height: 100% position: relative width: 100% - // Min-width is actually 645px but the border already needs 2px - min-width: 643px + // Min-width is actually 530px but the border already needs 2px + min-width: 528px @media only screen and (max-width: 1280px) @at-root @@ -76,15 +76,14 @@ body.router--work-packages-partitioned-split-view-new margin: .5rem .5rem 0 0 .work-packages--details-content - position: absolute - top: 50px - bottom: 55px - width: 100% - overflow-x: hidden - overflow-y: scroll - padding: 0 5px 0 20px - - @include styled-scroll-bar + display: flex + flex-direction: column + position: absolute + top: 50px + bottom: 55px + width: 100% + overflow: hidden + padding: 0 5px 10px 20px .work-packages--details @@ -109,6 +108,25 @@ body.router--work-packages-partitioned-split-view-new padding: 5px 0 5px 5px font-size: 18px + .work-packages--details-form + display: flex + flex-direction: column + overflow: hidden + + .work-package-details-tab + overflow-y: auto + overflow-x: hidden + @include styled-scroll-bar + + .work-packages--breadcrumb + display: grid + grid-template-columns: 1fr auto + grid-column-gap: 10px + align-items: center + + .work-packages--details-button button + margin-bottom: 0 + .work-packages--type-selector flex-shrink: 0 diff --git a/frontend/src/global_styles/layout/work_packages/_full_view.sass b/frontend/src/global_styles/layout/work_packages/_full_view.sass index 784061be28..2bf9cf716d 100644 --- a/frontend/src/global_styles/layout/work_packages/_full_view.sass +++ b/frontend/src/global_styles/layout/work_packages/_full_view.sass @@ -96,7 +96,7 @@ line-height: calc(var(--work-package-details--tab-height) - 10px) .work-packages-full-view--split-right - min-width: 645px + min-width: 530px overflow-y: scroll overflow-x: auto position: relative diff --git a/frontend/src/global_styles/layout/work_packages/_print.sass b/frontend/src/global_styles/layout/work_packages/_print.sass index cb7f113ce7..3e46deb815 100644 --- a/frontend/src/global_styles/layout/work_packages/_print.sass +++ b/frontend/src/global_styles/layout/work_packages/_print.sass @@ -6,6 +6,7 @@ @media print // -------------------- Work Package views -------------------- .router--work-packages-partitioned-split-view, + .router--work-packages-partitioned-split-view-details, .router--work-packages-full-view, .router--work-packages-full-create #wrapper @@ -18,6 +19,7 @@ overflow: visible !important position: relative grid-template-columns: auto + height: 100vh #content-wrapper, #content diff --git a/frontend/src/global_styles/layout/work_packages/_table.sass b/frontend/src/global_styles/layout/work_packages/_table.sass index f1bc9c6c0e..0dce47a91f 100644 --- a/frontend/src/global_styles/layout/work_packages/_table.sass +++ b/frontend/src/global_styles/layout/work_packages/_table.sass @@ -27,6 +27,7 @@ //++ .router--work-packages-partitioned-split-view, +.router--work-packages-partitioned-split-view-details, .router--work-packages-full-view, .router--work-packages-full-create .in_modal & @@ -34,7 +35,8 @@ top: 12px -.router--work-packages-partitioned-split-view:not(.router--work-packages-full-create) +.router--work-packages-partitioned-split-view:not(.router--work-packages-full-create), +.router--work-packages-partitioned-split-view-details:not(.router--work-packages-full-create) @include extended-content--bottom @include extended-content--right diff --git a/spec/features/notifications/notification_center/notification_center_spec.rb b/spec/features/notifications/notification_center/notification_center_spec.rb index bba40b4234..6c31b92075 100644 --- a/spec/features/notifications/notification_center/notification_center_spec.rb +++ b/spec/features/notifications/notification_center/notification_center_spec.rb @@ -77,12 +77,16 @@ describe "Notification center", type: :feature, js: true, with_settings: { journ center.expect_work_package_item notification2 center.mark_all_read - center.expect_bell_count 0 - notification.reload - expect(notification.read_ian).to be_truthy + retry_block do + notification.reload + raise "Expected notification to be marked read" unless notification.read_ian + end center.expect_no_item notification center.expect_no_item notification2 + + center.open + center.expect_bell_count 0 end it 'can open the split screen of the notification' do diff --git a/spec/features/work_packages/tabs/activity_notifications_spec.rb b/spec/features/work_packages/tabs/activity_notifications_spec.rb index a7f8083bb1..c31b29ddd2 100644 --- a/spec/features/work_packages/tabs/activity_notifications_spec.rb +++ b/spec/features/work_packages/tabs/activity_notifications_spec.rb @@ -20,14 +20,8 @@ describe 'Activity tab notifications', js: true, selenium: true do work_package end shared_let(:admin) { FactoryBot.create(:admin) } - shared_let(:full_view) { Pages::FullWorkPackage.new(work_package, project) } - before do - login_as(admin) - full_view.visit! - end - - context 'when there are notifications for the work package' do + shared_examples_for 'when there are notifications for the work package' do shared_let(:notification) do FactoryBot.create :notification, recipient: admin, @@ -35,24 +29,65 @@ describe 'Activity tab notifications', js: true, selenium: true do resource: work_package, journal: work_package.journals.last end - - it 'Shows a notification bubble with the right number' do + it 'shows a notification bubble with the right number' do expect(page).to have_selector('[data-qa-selector="tab-counter-Activity"]', text: '1') end - it 'Shows a notification icon next to activities that have an unread notification' do + it 'shows a notification icon next to activities that have an unread notification' do expect(page).to have_selector('[data-qa-selector="user-activity-bubble"]', count: 1) expect(page).to have_selector('[data-qa-activity-number="3"] [data-qa-selector="user-activity-bubble"]') end + + it 'shows a button to mark the notifications as read' do + expect(page).to have_selector('[data-qa-selector="mark-notification-read-button"]') + + # A click marks the notification as read ... + page.find('[data-qa-selector="mark-notification-read-button"]').click + + # ... and updates the view accordingly + expect(page).not_to have_selector('[data-qa-selector="mark-notification-read-button"]') + expect(page).not_to have_selector('[data-qa-selector="tab-counter-Activity"]') + expect(page).not_to have_selector('[data-qa-selector="user-activity-bubble"]') + end end - context 'when there are no notifications for the work package' do - it 'Shows no notification bubble' do + shared_examples_for 'when there are no notifications for the work package' do + it 'shows no notification bubble' do expect(page).not_to have_selector('[data-qa-selector="tab-counter-Activity"]') end - it 'Does not show any notification icons next to activities' do + it 'does not show any notification icons next to activities' do expect(page).not_to have_selector('[data-qa-selector="user-activity-bubble"]') end + + it 'shows no button to mark the notifications as read' do + expect(page).not_to have_selector('[data-qa-selector="mark-notification-read-button"]') + end + end + + context 'when on full view' do + shared_let(:full_view) { Pages::FullWorkPackage.new(work_package, project) } + + before do + login_as(admin) + full_view.visit_tab! 'activity' + end + + it_behaves_like 'when there are notifications for the work package' + + it_behaves_like 'when there are no notifications for the work package' + end + + context 'when on split view' do + shared_let(:split_view) { Pages::SplitWorkPackage.new(work_package, project) } + + before do + login_as(admin) + split_view.visit_tab! 'activity' + end + + it_behaves_like 'when there are notifications for the work package' + + it_behaves_like 'when there are no notifications for the work package' end end