Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: u#2899 notification mark as read and navigation #536

Merged
merged 1 commit into from
Oct 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions javascript/apps/taiga/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
});
Original file line number Diff line number Diff line change
@@ -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<Action>;
let spectator: SpectatorService<NotificationsEffects>;

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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,26 @@
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(() => {
return this.actions$.pipe(
ofType(
AuthActions.setUser,
AuthActions.loginSuccess,
UserEventsActions.newNotification
UserEventsActions.newNotification,
UserActions.markNotificationAsReadSuccess,
UserEventsActions.notificationRead
),
exhaustMap(() => {
return this.usersApiService.notificationsCount().pipe(
Expand All @@ -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$;
})
);
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
})
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,16 @@
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({
providedIn: 'root',
})
export class ProjectFeatureShellResolverService {
constructor(
private store: Store,
private projectApiService: ProjectApiService,
private revokeInvitationService: RevokeInvitationService
) {}
Expand All @@ -30,9 +27,6 @@ export class ProjectFeatureShellResolverService {
const params = route.params as Record<string, string>;

return this.projectApiService.getProject(params['id']).pipe(
tap((project) => {
this.store.dispatch(ProjectActions.fetchProjectSuccess({ project }));
}),
catchError((httpResponse: HttpErrorResponse) => {
this.revokeInvitationService.shellResolverRevokeError(httpResponse);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@
</div>

<ng-template #notificationsDropdown>
<tg-notifications></tg-notifications>
<tg-notifications
(userNavigated)="openNotificationsDropdown = false"></tg-notifications>
</ng-template>

<ng-template #userDropdown>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ Copyright (c) 2023-present Kaleidos INC
}
}

.notification {
cursor: pointer;
}

.type {
color: var(--color-gray80);
}
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<ng-container *transloco="let t">
<div
class="notification"
(click)="markAsRead($event, notification)"
[class.unread]="!notification.readAt">
<ng-container *ngIf="notification.type === 'stories.assign'">
<div class="notification-inner">
Expand All @@ -30,16 +31,16 @@
})
"></p>

<p>
<p class="link">
<a
[routerLink]="[
'/project',
notification.content.projects.id,
notification.content.projects.slug,
notification.content.project.id,
notification.content.project.slug,
'stories',
notification.content.story.ref
]">
{{ notification.content.story.ref }}
#{{ notification.content.story.ref }}
{{ notification.content.story.title }}
</a>
</p>
Expand Down Expand Up @@ -68,16 +69,16 @@
})
"></p>

<p>
<p class="link">
<a
[routerLink]="[
'/project',
notification.content.projects.id,
notification.content.projects.slug,
notification.content.project.id,
notification.content.project.slug,
'stories',
notification.content.story.ref
]">
{{ notification.content.story.ref }}
#{{ notification.content.story.ref }}
{{ notification.content.story.title }}
</a>
</p>
Expand Down
Loading