diff --git a/javascript/apps/taiga/src/app/app.component.ts b/javascript/apps/taiga/src/app/app.component.ts index bcd0a55c3..3b3063683 100644 --- a/javascript/apps/taiga/src/app/app.component.ts +++ b/javascript/apps/taiga/src/app/app.component.ts @@ -94,6 +94,15 @@ export class AppComponent { ); }); + this.wsService + .userEvents<{ notificationsIds: NotificationType['id'][] }>( + 'notifications.read' + ) + .pipe(takeUntilDestroyed()) + .subscribe(() => { + this.store.dispatch(UserEventsActions.notificationRead()); + }); + this.setBannerHeight(); } diff --git a/javascript/apps/taiga/src/app/modules/auth/data-access/+state/actions/user.actions.ts b/javascript/apps/taiga/src/app/modules/auth/data-access/+state/actions/user.actions.ts index a0ebb562e..b86b59d0a 100644 --- a/javascript/apps/taiga/src/app/modules/auth/data-access/+state/actions/user.actions.ts +++ b/javascript/apps/taiga/src/app/modules/auth/data-access/+state/actions/user.actions.ts @@ -23,12 +23,17 @@ export const UserActions = createActionGroup({ 'Fetch notifications success': props<{ notifications: NotificationType[]; }>(), + 'Mark notification as read': props<{ + notificationId: NotificationType['id']; + }>(), + 'Mark notification as read success': emptyProps(), }, }); export const UserEventsActions = createActionGroup({ source: 'User ws', events: { - 'new notification': props<{ notification: NotificationType }>(), + 'New notification': props<{ notification: NotificationType }>(), + 'Notification read': emptyProps(), }, }); diff --git a/javascript/apps/taiga/src/app/modules/auth/data-access/+state/effects/notifications.effects.spec.ts b/javascript/apps/taiga/src/app/modules/auth/data-access/+state/effects/notifications.effects.spec.ts new file mode 100644 index 000000000..fd70bc970 --- /dev/null +++ b/javascript/apps/taiga/src/app/modules/auth/data-access/+state/effects/notifications.effects.spec.ts @@ -0,0 +1,110 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) 2023-present Kaleidos INC + */ + +import { Observable } from 'rxjs'; +import { NotificationsEffects } from './notifications.effects'; +import { Action } from '@ngrx/store'; +import { SpectatorService, createServiceFactory } from '@ngneat/spectator/jest'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { AppService } from '~/app/services/app.service'; +import { UsersApiService } from '@taiga/api'; +import { UserActions, UserEventsActions } from '../actions/user.actions'; +import { cold, hot } from 'jest-marbles'; +import { NotificationMockFactory } from '@taiga/data'; +import { HttpErrorResponse } from '@angular/common/http'; + +describe('NotificationsEffects', () => { + // Debes configurar tus servicios mock y acciones antes de las pruebas + let actions$: Observable; + let spectator: SpectatorService; + + const createService = createServiceFactory({ + service: NotificationsEffects, + providers: [provideMockActions(() => actions$)], + imports: [], + mocks: [AppService, UsersApiService], + }); + + beforeEach(() => { + spectator = createService(); + }); + + it('should dispatch setNotificationNumber on successful notifications count fetch', () => { + const notifications = { + read: 1, + total: 4, + unread: 2, + }; + const action = UserEventsActions.notificationRead(); + const outcome = UserActions.setNotificationNumber({ notifications }); + const usersApiService = spectator.inject(UsersApiService); + const effects = spectator.inject(NotificationsEffects); + + actions$ = hot('-a', { a: action }); + const response = cold('-b|', { b: notifications }); + const expected = cold('--c', { c: outcome }); + + usersApiService.notificationsCount.mockReturnValue(response); + + expect(effects.notificationCount$).toBeObservable(expected); + }); + + it('should dispatch fetchNotificationsSuccess on successful notifications fetch', () => { + const notifications = [NotificationMockFactory()]; + const action = UserActions.initNotificationSection(); + const outcome = UserActions.fetchNotificationsSuccess({ notifications }); + const usersApiService = spectator.inject(UsersApiService); + const effects = spectator.inject(NotificationsEffects); + + actions$ = hot('-a', { a: action }); + const response = cold('-b|', { b: notifications }); + const expected = cold('--c', { c: outcome }); + + usersApiService.notifications.mockReturnValue(response); + + expect(effects.fetchNotifications$).toBeObservable(expected); + }); + + it('should dispatch markNotificationAsReadSuccess on successful mark as read', () => { + const notificationId = 'some_id'; + const action = UserActions.markNotificationAsRead({ notificationId }); + const outcome = UserActions.markNotificationAsReadSuccess(); + const usersApiService = spectator.inject(UsersApiService); + const effects = spectator.inject(NotificationsEffects); + + actions$ = hot('-a', { a: action }); + const response = cold('-b|', { b: {} }); + const expected = cold('--c', { c: outcome }); + + usersApiService.markNotificationAsRead.mockReturnValue(response); + + expect(effects.markNotificationAsRead$).toBeObservable(expected); + }); + + it('should handle errors', () => { + const notificationId = 'some_id'; + const action = UserActions.markNotificationAsRead({ notificationId }); + const error = new HttpErrorResponse({ + status: 500, + statusText: 'Internal Server Error', + }); + const usersApiService = spectator.inject(UsersApiService); + const appService = spectator.inject(AppService); + const effects = spectator.inject(NotificationsEffects); + + actions$ = hot('-a', { a: action }); + const response = cold('-#|', {}, error); + + usersApiService.markNotificationAsRead.mockReturnValue(response); + + expect(effects.markNotificationAsRead$).toSatisfyOnFlush(() => { + expect(effects.markNotificationAsRead$).toBeObservable(response); + expect(appService.errorManagement).toHaveBeenCalled(); + }); + }); +}); diff --git a/javascript/apps/taiga/src/app/modules/auth/data-access/+state/effects/notifications.effects.ts b/javascript/apps/taiga/src/app/modules/auth/data-access/+state/effects/notifications.effects.ts index 6499b9229..d07a6431d 100644 --- a/javascript/apps/taiga/src/app/modules/auth/data-access/+state/effects/notifications.effects.ts +++ b/javascript/apps/taiga/src/app/modules/auth/data-access/+state/effects/notifications.effects.ts @@ -9,15 +9,16 @@ import { Injectable, inject } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import * as AuthActions from '../actions/auth.actions'; -import { Store } from '@ngrx/store'; import { UsersApiService } from '@taiga/api'; -import { exhaustMap, map } from 'rxjs'; +import { catchError, exhaustMap, map } from 'rxjs'; import { UserActions, UserEventsActions } from '../actions/user.actions'; +import { HttpErrorResponse } from '@angular/common/http'; +import { AppService } from '~/app/services/app.service'; @Injectable({ providedIn: 'root' }) export class NotificationsEffects { private actions$ = inject(Actions); - private store = inject(Store); + private appService = inject(AppService); private usersApiService = inject(UsersApiService); public notificationCount$ = createEffect(() => { @@ -25,7 +26,9 @@ export class NotificationsEffects { ofType( AuthActions.setUser, AuthActions.loginSuccess, - UserEventsActions.newNotification + UserEventsActions.newNotification, + UserActions.markNotificationAsReadSuccess, + UserEventsActions.notificationRead ), exhaustMap(() => { return this.usersApiService.notificationsCount().pipe( @@ -49,4 +52,22 @@ export class NotificationsEffects { }) ); }); + + public markNotificationAsRead$ = createEffect(() => { + return this.actions$.pipe( + ofType(UserActions.markNotificationAsRead), + exhaustMap(({ notificationId }) => { + return this.usersApiService.markNotificationAsRead(notificationId).pipe( + map(() => { + return UserActions.markNotificationAsReadSuccess(); + }) + ); + }), + catchError((error: HttpErrorResponse, source$) => { + this.appService.errorManagement(error); + + return source$; + }) + ); + }); } diff --git a/javascript/apps/taiga/src/app/modules/auth/data-access/+state/reducers/auth.reducer.ts b/javascript/apps/taiga/src/app/modules/auth/data-access/+state/reducers/auth.reducer.ts index c445e763c..edf37a61a 100644 --- a/javascript/apps/taiga/src/app/modules/auth/data-access/+state/reducers/auth.reducer.ts +++ b/javascript/apps/taiga/src/app/modules/auth/data-access/+state/reducers/auth.reducer.ts @@ -92,6 +92,17 @@ export const reducer = createImmerReducer( on(UserActions.fetchNotificationsSuccess, (state, { notifications }) => { state.notifications = notifications; + return state; + }), + on(UserActions.markNotificationAsRead, (state, { notificationId }) => { + const notification = state.notifications.find( + (notification) => notification.id === notificationId + ); + + if (notification && state.notificationCount) { + notification.readAt = new Date().toISOString(); + } + return state; }) ); diff --git a/javascript/apps/taiga/src/app/modules/project/feature-shell/project-feature-shell-resolver.service.ts b/javascript/apps/taiga/src/app/modules/project/feature-shell/project-feature-shell-resolver.service.ts index 91a4feef8..871dc2d00 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-shell/project-feature-shell-resolver.service.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-shell/project-feature-shell-resolver.service.ts @@ -9,11 +9,9 @@ import { HttpErrorResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot } from '@angular/router'; -import { Store } from '@ngrx/store'; import { ProjectApiService } from '@taiga/api'; import { of } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; -import * as ProjectActions from '~/app/modules/project/data-access/+state/actions/project.actions'; +import { catchError } from 'rxjs/operators'; import { RevokeInvitationService } from '~/app/services/revoke-invitation.service'; @Injectable({ @@ -21,7 +19,6 @@ import { RevokeInvitationService } from '~/app/services/revoke-invitation.servic }) export class ProjectFeatureShellResolverService { constructor( - private store: Store, private projectApiService: ProjectApiService, private revokeInvitationService: RevokeInvitationService ) {} @@ -30,9 +27,6 @@ export class ProjectFeatureShellResolverService { const params = route.params as Record; return this.projectApiService.getProject(params['id']).pipe( - tap((project) => { - this.store.dispatch(ProjectActions.fetchProjectSuccess({ project })); - }), catchError((httpResponse: HttpErrorResponse) => { this.revokeInvitationService.shellResolverRevokeError(httpResponse); diff --git a/javascript/apps/taiga/src/app/modules/project/feature-shell/project-feature-shell.component.ts b/javascript/apps/taiga/src/app/modules/project/feature-shell/project-feature-shell.component.ts index aee0bdddb..183e12ef8 100644 --- a/javascript/apps/taiga/src/app/modules/project/feature-shell/project-feature-shell.component.ts +++ b/javascript/apps/taiga/src/app/modules/project/feature-shell/project-feature-shell.component.ts @@ -57,6 +57,7 @@ import { setNotificationClosed } from '../feature-overview/data-access/+state/ac import { TranslocoDirective } from '@ngneat/transloco'; import { ContextNotificationComponent } from '@taiga/ui/context-notification/context-notification.component'; import { ProjectNavigationComponent } from '../feature-navigation/project-feature-navigation.component'; +import * as ProjectActions from '~/app/modules/project/data-access/+state/actions/project.actions'; @UntilDestroy() @Component({ @@ -122,6 +123,14 @@ export class ProjectFeatureShellComponent implements OnDestroy, AfterViewInit { private route: ActivatedRoute, private appService: AppService ) { + this.route.data.subscribe((data) => { + this.store.dispatch( + ProjectActions.fetchProjectSuccess({ + project: data.project as Project, + }) + ); + }); + this.watchProject(); this.state.connect( 'project', diff --git a/javascript/apps/taiga/src/app/shared/navigation/navigation.component.html b/javascript/apps/taiga/src/app/shared/navigation/navigation.component.html index 0b0640086..8c276b113 100644 --- a/javascript/apps/taiga/src/app/shared/navigation/navigation.component.html +++ b/javascript/apps/taiga/src/app/shared/navigation/navigation.component.html @@ -138,7 +138,8 @@ - + diff --git a/javascript/apps/taiga/src/app/shared/navigation/notification/notification.component.css b/javascript/apps/taiga/src/app/shared/navigation/notification/notification.component.css index f90ca2c86..e3d5f438e 100644 --- a/javascript/apps/taiga/src/app/shared/navigation/notification/notification.component.css +++ b/javascript/apps/taiga/src/app/shared/navigation/notification/notification.component.css @@ -17,6 +17,10 @@ Copyright (c) 2023-present Kaleidos INC } } +.notification { + cursor: pointer; +} + .type { color: var(--color-gray80); } @@ -26,6 +30,10 @@ Copyright (c) 2023-present Kaleidos INC font-size: var(--font-size-small); } +.link { + font-weight: var(--font-weight-medium); +} + .notification-inner { display: flex; gap: var(--spacing-8); @@ -49,6 +57,10 @@ a { border-radius: 4px; position: relative; + .type { + font-weight: var(--font-weight-medium); + } + &::before { aspect-ratio: 1; background-color: var(--color-secondary); diff --git a/javascript/apps/taiga/src/app/shared/navigation/notification/notification.component.html b/javascript/apps/taiga/src/app/shared/navigation/notification/notification.component.html index a8d5ad5fe..a253c8ea0 100644 --- a/javascript/apps/taiga/src/app/shared/navigation/notification/notification.component.html +++ b/javascript/apps/taiga/src/app/shared/navigation/notification/notification.component.html @@ -9,6 +9,7 @@
@@ -30,16 +31,16 @@ }) ">

-

+

@@ -68,16 +69,16 @@ }) ">

-

+

diff --git a/javascript/apps/taiga/src/app/shared/navigation/notification/notification.component.ts b/javascript/apps/taiga/src/app/shared/navigation/notification/notification.component.ts index 1249e59d2..22a14a519 100644 --- a/javascript/apps/taiga/src/app/shared/navigation/notification/notification.component.ts +++ b/javascript/apps/taiga/src/app/shared/navigation/notification/notification.component.ts @@ -9,7 +9,10 @@ import { ChangeDetectionStrategy, Component, + ElementRef, + EventEmitter, Input, + Output, inject, } from '@angular/core'; import { CommonModule } from '@angular/common'; @@ -17,8 +20,9 @@ import { TranslocoDirective } from '@ngneat/transloco'; import { NotificationType } from '@taiga/data'; import { UserAvatarComponent } from '~/app/shared/user-avatar/user-avatar.component'; import { DateDistancePipe } from '~/app/shared/pipes/date-distance/date-distance.pipe'; -import { RouterLink } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; import { Store } from '@ngrx/store'; +import { UserActions } from '~/app/modules/auth/data-access/+state/actions/user.actions'; @Component({ selector: 'tg-notification', @@ -36,11 +40,37 @@ import { Store } from '@ngrx/store'; }) export class NotificationComponent { private store = inject(Store); + private router = inject(Router); + private el = inject(ElementRef) as ElementRef; @Input({ required: true }) public notification!: NotificationType; + @Output() + public userNavigated = new EventEmitter(); + public getTranslationKey(notification: NotificationType): string { return `navigation.notifications.types.${notification.type}`; } + + public markAsRead(event: MouseEvent, notification: NotificationType): void { + this.userNavigated.emit(); + + if (!notification.readAt) { + this.store.dispatch( + UserActions.markNotificationAsRead({ notificationId: notification.id }) + ); + } + + const target = event.target as HTMLElement; + + if (!(target instanceof HTMLAnchorElement)) { + const link = + this.el.nativeElement.querySelector('a')?.getAttribute('href') ?? ''; + + if (link) { + void this.router.navigateByUrl(link); + } + } + } } diff --git a/javascript/apps/taiga/src/app/shared/navigation/notifications/notifications.component.css b/javascript/apps/taiga/src/app/shared/navigation/notifications/notifications.component.css index 40d60d0e8..fd1e088d5 100644 --- a/javascript/apps/taiga/src/app/shared/navigation/notifications/notifications.component.css +++ b/javascript/apps/taiga/src/app/shared/navigation/notifications/notifications.component.css @@ -25,3 +25,12 @@ Copyright (c) 2023-present Kaleidos INC flex-direction: column; gap: var(--spacing-12); } + +.empty { + background: var(--color-gray10); + border: 1px solid var(--color-gray20); + color: var(--color-gray80); + font-style: italic; + padding: var(--spacing-8); + text-align: center; +} diff --git a/javascript/apps/taiga/src/app/shared/navigation/notifications/notifications.component.html b/javascript/apps/taiga/src/app/shared/navigation/notifications/notifications.component.html index 82152ce20..c14bcd5c4 100644 --- a/javascript/apps/taiga/src/app/shared/navigation/notifications/notifications.component.html +++ b/javascript/apps/taiga/src/app/shared/navigation/notifications/notifications.component.html @@ -26,7 +26,9 @@

- +
diff --git a/javascript/apps/taiga/src/app/shared/navigation/notifications/notifications.component.ts b/javascript/apps/taiga/src/app/shared/navigation/notifications/notifications.component.ts index 80cebd311..622373007 100644 --- a/javascript/apps/taiga/src/app/shared/navigation/notifications/notifications.component.ts +++ b/javascript/apps/taiga/src/app/shared/navigation/notifications/notifications.component.ts @@ -6,7 +6,13 @@ * Copyright (c) 2023-present Kaleidos INC */ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Output, + inject, +} from '@angular/core'; import { CommonModule } from '@angular/common'; import { TranslocoDirective } from '@ngneat/transloco'; import { Store } from '@ngrx/store'; @@ -37,6 +43,9 @@ export class NotificationsComponent { public model$ = this.state.select(); public trackByIndex = trackByIndex(); + @Output() + public userNavigated = new EventEmitter(); + constructor() { this.store.dispatch(UserActions.initNotificationSection()); diff --git a/javascript/libs/api/src/lib/users/users-api.service.ts b/javascript/libs/api/src/lib/users/users-api.service.ts index e142df36f..3cf6e0358 100644 --- a/javascript/libs/api/src/lib/users/users-api.service.ts +++ b/javascript/libs/api/src/lib/users/users-api.service.ts @@ -69,4 +69,11 @@ export class UsersApiService { `${this.config.apiUrl}/my/notifications` ); } + + public markNotificationAsRead(notificationId: string) { + return this.http.post( + `${this.config.apiUrl}/my/notifications/${notificationId}/read`, + {} + ); + } } diff --git a/javascript/libs/data/src/lib/notification.model.mock.ts b/javascript/libs/data/src/lib/notification.model.mock.ts index 7d4b572b6..983252736 100644 --- a/javascript/libs/data/src/lib/notification.model.mock.ts +++ b/javascript/libs/data/src/lib/notification.model.mock.ts @@ -6,7 +6,7 @@ * Copyright (c) 2023-present Kaleidos INC */ -import { randPastDate } from '@ngneat/falso'; +import { randPastDate, randUuid } from '@ngneat/falso'; import { NotificationStoryAssign } from './notification.model'; import { ProjectMockFactory } from './project.model.mock'; import { StoryMockFactory } from './story.model.mock'; @@ -20,6 +20,7 @@ export const NotificationMockFactory = (): NotificationStoryAssign => { const story = StoryMockFactory(); return { + id: randUuid(), type: 'stories.assign', createdBy: { color: createdBy.color, @@ -33,7 +34,7 @@ export const NotificationMockFactory = (): NotificationStoryAssign => { ref: story.ref, title: story.title, }, - projects: { + project: { id: project.id, name: project.name, slug: project.slug, diff --git a/javascript/libs/data/src/lib/notification.model.ts b/javascript/libs/data/src/lib/notification.model.ts index fc960c338..a304d9856 100644 --- a/javascript/libs/data/src/lib/notification.model.ts +++ b/javascript/libs/data/src/lib/notification.model.ts @@ -11,6 +11,7 @@ import { Story } from './story.model'; import { User } from './user.model'; export interface Notification { + id: string; createdBy: Pick; createdAt: string; readAt: string; @@ -20,7 +21,7 @@ export interface NotificationStoryAssign extends Notification { type: 'stories.assign'; content: { story: Pick; - projects: Pick; + project: Pick; assignedBy: Pick; assignedTo: Pick; }; @@ -29,7 +30,7 @@ export interface NotificationStoryUnassign extends Notification { type: 'stories.unassign'; content: { story: Pick; - projects: Pick; + project: Pick; unassignedBy: Pick; unassignedTo: Pick; };