Merge pull request #9599 from opf/feature/38339-add-mark-notification-button-to-split-screen

Place "Mark as read" button in WP full & split view
pull/9614/head
Henriette Darge 3 years ago committed by GitHub
commit 696d98d68a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      config/locales/js-en.yml
  2. 16
      frontend/src/app/core/global_search/tabs/global-search-tabs.component.ts
  3. 11
      frontend/src/app/features/in-app-notifications/bell/in-app-notification-bell.component.html
  4. 39
      frontend/src/app/features/in-app-notifications/bell/in-app-notification-bell.component.ts
  5. 8
      frontend/src/app/features/in-app-notifications/center/in-app-notification-center-page.component.ts
  6. 6
      frontend/src/app/features/in-app-notifications/center/in-app-notification-center.component.html
  7. 39
      frontend/src/app/features/in-app-notifications/center/in-app-notification-center.component.ts
  8. 1
      frontend/src/app/features/in-app-notifications/center/toolbar/facet/activate-facet-button.component.ts
  9. 23
      frontend/src/app/features/in-app-notifications/store/in-app-notifications.query.ts
  10. 105
      frontend/src/app/features/in-app-notifications/store/in-app-notifications.service.ts
  11. 16
      frontend/src/app/features/in-app-notifications/store/in-app-notifications.store.ts
  12. 8
      frontend/src/app/features/work-packages/components/wp-buttons/wp-mark-notification-button/work-package-mark-notification-button.component.html
  13. 31
      frontend/src/app/features/work-packages/components/wp-buttons/wp-mark-notification-button/work-package-mark-notification-button.component.ts
  14. 63
      frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-base.controller.ts
  15. 1
      frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-on-overview.html
  16. 2
      frontend/src/app/features/work-packages/components/wp-single-view-tabs/activity-panel/activity-tab.html
  17. 5
      frontend/src/app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service.ts
  18. 19
      frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-notifications-count.function.ts
  19. 2
      frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service.spec.ts
  20. 7
      frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service.ts
  21. 3
      frontend/src/app/features/work-packages/openproject-work-packages.module.ts
  22. 25
      frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.component.ts
  23. 7
      frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.html
  24. 35
      frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.component.ts
  25. 17
      frontend/src/app/features/work-packages/routing/wp-split-view/wp-split-view.html
  26. 2
      frontend/src/app/shared/components/resizer/resizer/wp-resizer.component.ts
  27. 15
      frontend/src/app/shared/components/tabs/content-tabs/content-tabs.component.ts
  28. 4
      frontend/src/app/shared/components/tabs/scrollable-tabs/scrollable-tabs.component.html
  29. 7
      frontend/src/app/shared/components/tabs/scrollable-tabs/scrollable-tabs.component.ts
  30. 2
      frontend/src/app/shared/components/tabs/tab-badges/tab-count.component.html
  31. 3
      frontend/src/app/shared/components/tabs/tab-badges/tab-count.component.ts
  32. 3
      frontend/src/app/shared/components/tabs/tab.interface.ts
  33. 2
      frontend/src/global_styles/content/work_packages/new/_split_view.sass
  34. 2
      frontend/src/global_styles/layout/_boards.sass
  35. 40
      frontend/src/global_styles/layout/work_packages/_details_view.sass
  36. 2
      frontend/src/global_styles/layout/work_packages/_full_view.sass
  37. 2
      frontend/src/global_styles/layout/work_packages/_print.sass
  38. 4
      frontend/src/global_styles/layout/work_packages/_table.sass
  39. 10
      spec/features/notifications/notification_center/notification_center_spec.rb
  40. 61
      spec/features/work_packages/tabs/activity_notifications_spec.rb

@ -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:

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

@ -2,15 +2,14 @@
class="op-ian-bell op-app-menu--item-action"
data-qa-selector="op-ian-bell"
[href]="notificationsPath()"
>
<op-icon icon-classes="icon-bell">
</op-icon>
>
<op-icon icon-classes="icon-bell"></op-icon>
<ng-container *ngIf="(unreadCount$ | async) as unreadCount">
<span
<span
*ngIf="unreadCount > 0"
class="op-ian-bell--indicator"
data-qa-selector="op-ian-notifications-count"
[textContent]="unreadCount > 99 ? '' : unreadCount">
</span>
[textContent]="unreadCount > 99 ? '' : unreadCount"
></span>
</ng-container>
</a>

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

@ -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 = {

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

@ -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 {

@ -30,6 +30,5 @@ export class ActivateFacetButtonComponent {
activateFacet(facet:string):void {
this.ianService.setActiveFacet(facet);
this.ianService.get();
}
}

@ -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<InAppNotificationsState> {
/** 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<InAppNotificationsState
)),
);
/** Get the number of unread items */
unreadCount$ = this.select('unreadCount');
/** Do we have any unread items? */
hasUnread$ = this.unreadCount$.pipe(map((count) => 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<InAppNotificationsState
/** Do we have any notification that shall be visible the notification center? */
hasNotifications$ = this.selectCount().pipe(map((count) => 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) {

@ -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<IHALCollection<InAppNotification>> {
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<number> {
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,
},
);
}
}

@ -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<InAppNotification> {
/** 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<InAppNotificationsState> {
constructor() {

@ -0,0 +1,8 @@
<button
(click)="markAllBelongingNotificationsAsRead()"
[attr.title]="text.mark_as_read"
[ngClass]="buttonClasses"
class="button">
<op-icon icon-classes="button--icon icon-mark-read"></op-icon>
</button>

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

@ -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<boolean> {
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);

@ -6,6 +6,7 @@
<activity-entry [workPackage]="workPackage"
[activity]="inf.activity"
[activityNo]="inf.number(inf.isReversed)"
[hasUnreadNotification]="hasUnreadNotification(inf.href) | async"
[isInitial]="inf.isInitial()">
</activity-entry>
</div>

@ -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()"
></activity-entry>
</div>

@ -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({

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

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

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

@ -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,

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

@ -30,6 +30,13 @@
[showText]="false">
</wp-watcher-button>
</li>
<li class="toolbar-item" *ngIf="displayNotificationsButton$ | async">
<op-work-package-mark-notification-button
[workPackage]="workPackage"
buttonClasses="toolbar-icon"
data-qa-selector="mark-notification-read-button"
></op-work-package-mark-notification-button>
</li>
<li class="toolbar-item hidden-for-mobile">
<zen-mode-toggle-button>
</zen-mode-toggle-button>

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

@ -6,9 +6,22 @@
<span class="hidden-for-sighted" tabindex="-1" [opAutofocus]="shouldFocus" [textContent]="focusAnchorLabel">
</span>
<wp-breadcrumb [workPackage]="workPackage"></wp-breadcrumb>
<div class="work-packages--breadcrumb">
<wp-breadcrumb [workPackage]="workPackage"></wp-breadcrumb>
<edit-form [resource]="workPackage">
<op-work-package-mark-notification-button
*ngIf="(displayNotificationsButton$ | async) && keepTab.currentTabIdentifier === 'activity'"
class="work-packages--details-button"
[workPackage]="workPackage"
buttonClasses="-round"
data-qa-selector="mark-notification-read-button"
></op-work-package-mark-notification-button>
</div>
<edit-form
[resource]="workPackage"
class="work-packages--details-form"
>
<div class="wp-show--header-container">
<op-back-button *ngIf="showBackButton()"
linkClass="work-packages-back-button"

@ -69,7 +69,7 @@ export class WpResizerDirective extends UntilDestroyedMixin implements OnInit, A
private resizer:HTMLElement;
// Min-width this element is allowed to have
private elementMinWidth = 645;
private elementMinWidth = 530;
public moving = false;

@ -27,7 +27,11 @@
//++
import {
ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
Injector,
} from '@angular/core';
import { GonService } from 'core-app/core/gon/gon.service';
import { StateService } from '@uirouter/core';
@ -52,12 +56,15 @@ interface GonTab extends TabDefinition {
export class ContentTabsComponent extends ScrollableTabsComponent {
public classes:string[] = ['content--tabs', 'scrollable-tabs'];
constructor(readonly elementRef:ElementRef,
constructor(
readonly elementRef:ElementRef,
readonly $state:StateService,
readonly gon:GonService,
cdRef:ChangeDetectorRef,
readonly I18n:I18nService) {
super(cdRef);
readonly I18n:I18nService,
public injector:Injector,
) {
super(cdRef, injector);
const gonTabs = JSON.parse((this.gon.get('contentTabs') as any).tabs);
const currentTab = JSON.parse((this.gon.get('contentTabs') as any).selected);

@ -38,10 +38,10 @@
<span [textContent]="tab.name"></span>
<op-tab-count
*ngIf="tab.counter && tab.showCountAsBubble"
[counter]="tab.counter"
[count]="tab.counter(injector) | async"
[attr.data-qa-selector]="'tab-counter-' + tab.name"
></op-tab-count>
<ng-container *ngIf="tab.counter | async as tabCounter">
<ng-container *ngIf="tab.counter(injector) | async as tabCounter">
<span
*ngIf="tabCounter > 0 && !tab.showCountAsBubble"
data-qa-selector="tab-count"

@ -6,6 +6,7 @@ import {
ElementRef,
EventEmitter,
Input,
Injector,
OnChanges,
Output,
SimpleChanges,
@ -47,8 +48,10 @@ export class ScrollableTabsComponent implements AfterViewInit, OnChanges {
private pane:Element;
constructor(private cdRef:ChangeDetectorRef) {
}
constructor(
private cdRef:ChangeDetectorRef,
public injector:Injector,
) { }
ngAfterViewInit():void {
this.container = this.scrollContainer.nativeElement;

@ -1,4 +1,4 @@
<ng-container *ngIf="(counter$ | async) as count">
<ng-container *ngIf="count">
<span
class="op-tab-count"
*ngIf="count > 0"

@ -1,5 +1,4 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Observable } from 'rxjs';
@Component({
selector: 'op-tab-count',
@ -8,5 +7,5 @@ import { Observable } from 'rxjs';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TabCountComponent {
@Input('counter') counter$:Observable<number>;
@Input() count:number;
}

@ -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<number>;
counter?:(injector?:Injector) => Observable<number>;
/** Whether the counter should be shown as number in brackets or within a bubble */
showCountAsBubble?:boolean;
/** Disable the tab, optionally with an explanatory title */

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

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

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

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

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

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

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

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

Loading…
Cancel
Save