Skip to content

Commit

Permalink
[Feat]: Notification List UI Implementation (#2711)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Sebastian Leidig <[email protected]>
  • Loading branch information
Ayush8923 and sleidig committed Dec 27, 2024
1 parent c2ba7dd commit 020b00d
Show file tree
Hide file tree
Showing 14 changed files with 619 additions and 0 deletions.
2 changes: 2 additions & 0 deletions src/app/core/ui/ui/ui.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@

<app-sync-status></app-sync-status>

<app-notification />

<app-language-select
*ngIf="siteSettings.displayLanguageSelect"
></app-language-select>
Expand Down
2 changes: 2 additions & 0 deletions src/app/core/ui/ui/ui.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { LoginStateSubject } from "../../session/session-type";
import { LoginState } from "../../session/session-states/login-state.enum";
import { SessionManagerService } from "../../session/session-service/session-manager.service";
import { SetupWizardButtonComponent } from "../../admin/setup-wizard/setup-wizard-button/setup-wizard-button.component";
import { NotificationComponent } from "../../../features/notification/notification.component";

/**
* The main user interface component as root element for the app structure
Expand Down Expand Up @@ -67,6 +68,7 @@ import { SetupWizardButtonComponent } from "../../admin/setup-wizard/setup-wizar
PrimaryActionComponent,
DisplayImgComponent,
SetupWizardButtonComponent,
NotificationComponent,
],
standalone: true,
})
Expand Down
12 changes: 12 additions & 0 deletions src/app/features/notification/close-only-submenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { MatMenuTrigger } from "@angular/material/menu";

/**
* Close the mat-menu of the given menu trigger
* and stop propagation of the event to avoid closing parent menus as well.
* @param menu
* @param event
*/
export function closeOnlySubmenu(menu: MatMenuTrigger, event: MouseEvent) {
menu.closeMenu();
event.stopPropagation();
}
57 changes: 57 additions & 0 deletions src/app/features/notification/mock-notification.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Injectable } from "@angular/core";
import moment from "moment";
import { v4 as uuid } from "uuid";

// TODO: Need to remove this file once the notification UI testing is completed.

@Injectable({
providedIn: "root",
})
export class MockNotificationsService {
mockNotifications = [
createDummyNotification(true, moment().subtract(1, "hour").toDate()),
createDummyNotification(true, moment().subtract(1, "day").toDate()),
createDummyNotification(true, moment().subtract(1, "week").toDate()),
createDummyNotification(true, moment().subtract(1, "month").toDate()),
createDummyNotification(true, moment().subtract(1, "year").toDate()),
createDummyNotification(true, moment().subtract(1, "year").toDate()),
createDummyNotification(true, moment().subtract(1, "year").toDate()),
createDummyNotification(true, moment().subtract(1, "year").toDate()),
createDummyNotification(true, moment().subtract(1, "year").toDate()),
createDummyNotification(true, moment().subtract(1, "year").toDate()),
createDummyNotification(true, moment().subtract(1, "year").toDate()),
createDummyNotification(true, moment().subtract(1, "year").toDate()),
createDummyNotification(true, moment().subtract(1, "year").toDate()),
createDummyNotification(true, moment().subtract(1, "year").toDate()),
createDummyNotification(true, moment().subtract(1, "year").toDate()),
createDummyNotification(true, moment().subtract(1, "year").toDate()),
createDummyNotification(true, moment().subtract(1, "year").toDate()),
];

getNotifications() {
return this.mockNotifications;
}
}

function createDummyNotification(
read: boolean = true,
date: Date = new Date(),
title: string = "Dummy Notification Title",
) {
return {
_id: "Notification:" + uuid(),
_rev: "1-4d6e4511f7e4dd8679e19f0cc2d9c22e",
title: title,
body: "This is a dummy notification body.",
actionURL: "http://localhost:4200",
sentBy: "demo",
fcmToken: "",
readStatus: read,
created: {
at: date.toISOString(),
},
updated: {
at: date.toISOString(),
},
};
}
22 changes: 22 additions & 0 deletions src/app/features/notification/model/notification-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Entity } from "../../../core/entity/model/entity";
import { DatabaseField } from "../../../core/entity/database-field.decorator";
import { DatabaseEntity } from "../../../core/entity/database-entity.decorator";

/**
* This represents one specific notification event for one specific user,
* displayed in the UI through the notification indicator in the toolbar.
*/
@DatabaseEntity("NotificationEvent")
export class NotificationEvent extends Entity {
@DatabaseField() title: string;
@DatabaseField() body: string;
@DatabaseField() actionURL: string;

@DatabaseField() sentBy: string;
@DatabaseField() fcmToken: string;
@DatabaseField() readStatus: boolean;

public override toString(): string {
return `Notification: ${this.title} - ${this.body}`;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<div
class="flex-row justify-space-between align-center notification-item-wrapper"
[ngClass]="{ 'read-notification': notification.readStatus }"
>
<div class="notification-details">
<div i18n class="notification-title">{{ notification.title }}</div>
<div i18n class="notification-body">{{ notification.body }}</div>
<div
i18n
class="notification-time"
[matTooltip]="notification.created?.at | date: 'short'"
>
{{ notification.created?.at | notificationTime }}
</div>
</div>

<!-- Blue indicator for unread/read notifications -->
<span
[ngClass]="notification.readStatus ? 'read-indicator' : 'unread-indicator'"
matTooltip="{{
notification.readStatus ? 'Read notification' : 'Unread notification'
}}"
i18n-matTooltip
[matMenuTriggerFor]="actionsMenu"
#actionsMenuTrigger="matMenuTrigger"
></span>

<mat-menu #actionsMenu="matMenu">
<div (click)="closeOnlySubmenu(actionsMenuTrigger, $event)">
@if (!notification.readStatus) {
<button mat-menu-item (click)="updateReadStatus(true)">
<fa-icon icon="check"></fa-icon>
<span class="menu-option" i18n>Mark as read</span>
</button>
} @else {
<button mat-menu-item (click)="updateReadStatus(false)">
<fa-icon icon="eye-slash"></fa-icon>
<span class="menu-option" i18n>Mark as unread</span>
</button>
}

<button mat-menu-item (click)="handleDeleteNotification()">
<fa-icon icon="trash"></fa-icon>
<span class="menu-option" i18n>Delete notification</span>
</button>
</div>
</mat-menu>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
@use "variables/colors";

.notification-item-wrapper {
padding: 8px 10px 0px;
}

.notification-details {
flex: 1;
text-align: left;
}

.notification-title {
font-weight: bold;
}

.notification-body {
font-size: 0.85em;
}

.notification-time {
color: colors.$inactive;
font-size: 12px;
}

.indicator {
height: 12px;
width: 12px;
border-radius: 50%;
box-sizing: border-box;
display: inline-block;
margin-right: 8px;
cursor: pointer;
}

.unread-indicator {
@extend .indicator;
background-color: blue;
}

.read-indicator {
@extend .indicator;
background-color: transparent;
border: 2px solid colors.$inactive;
}

.menu-option {
margin-left: 12px;
}

.read-notification {
color: colors.$inactive;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";

import { NotificationItemComponent } from "./notification-item.component";
import { NotificationEvent } from "../model/notification-event";
import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";

describe("NotificationItemComponent", () => {
let component: NotificationItemComponent;
let fixture: ComponentFixture<NotificationItemComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
NotificationItemComponent,
FontAwesomeTestingModule,
NoopAnimationsModule,
],
}).compileComponents();

fixture = TestBed.createComponent(NotificationItemComponent);
component = fixture.componentInstance;

component.notification = new NotificationEvent();

fixture.detectChanges();
});

it("should create", () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { MatBadgeModule } from "@angular/material/badge";
import { CommonModule } from "@angular/common";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { MatMenu, MatMenuItem, MatMenuTrigger } from "@angular/material/menu";
import { MatButtonModule } from "@angular/material/button";
import { FormsModule } from "@angular/forms";
import { MatTooltipModule } from "@angular/material/tooltip";
import { MatTabsModule } from "@angular/material/tabs";
import { NotificationEvent } from "../model/notification-event";
import { NotificationTimePipe } from "../notification-time.pipe";
import { closeOnlySubmenu } from "../close-only-submenu";

@Component({
selector: "app-notification-item",
standalone: true,
imports: [
MatBadgeModule,
FontAwesomeModule,
MatMenu,
MatButtonModule,
MatMenuTrigger,
MatMenuItem,
FormsModule,
MatTooltipModule,
MatTabsModule,
CommonModule,
NotificationTimePipe,
],
templateUrl: "./notification-item.component.html",
styleUrl: "./notification-item.component.scss",
})
export class NotificationItemComponent {
@Input() notification: NotificationEvent;

@Output() readStatusChange = new EventEmitter<boolean>();
@Output() deleteClick = new EventEmitter<void>();

updateReadStatus(newStatus: boolean) {
this.readStatusChange.emit(newStatus);
}

handleDeleteNotification() {
this.deleteClick.emit();
}

protected readonly closeOnlySubmenu = closeOnlySubmenu;
}
50 changes: 50 additions & 0 deletions src/app/features/notification/notification-time.pipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Converts a timestamp into a human-readable time format,
* such as "Just Now", "5m", "2h", "Yesterday", "3d", or "Jan 2024".
*/
import { Pipe, PipeTransform } from "@angular/core";

@Pipe({
name: "notificationTime",
standalone: true,
})
export class NotificationTimePipe implements PipeTransform {
transform(value: any): string {
if (!value) return "";

const currentTime = new Date();
const notificationTime = new Date(value);
if (
!(notificationTime instanceof Date) ||
isNaN(notificationTime.getTime())
) {
return "";
}
const timeDifference = currentTime.getTime() - notificationTime.getTime();

const seconds = Math.floor(timeDifference / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);

if (seconds < 60) {
return $localize`Just Now`;
} else if (minutes < 60) {
return $localize`${minutes}m`;
} else if (hours < 24) {
return $localize`${hours}h ago`;
} else if (days === 1) {
return $localize`Yesterday`;
} else if (days < 7) {
return $localize`${days}d ago`;
} else if (days >= 7 && days < 30) {
return $localize`${days}d ago`;
} else {
const monthYear = notificationTime.toLocaleString("en-US", {
month: "short",
year: "numeric",
});
return monthYear;
}
}
}
Loading

0 comments on commit 020b00d

Please sign in to comment.