mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
✨ feat(shell-notifications): add notification display component
Add feature library for rendering grouped notifications with: - Grouped notification display with collapsible sections - Unread/read status indication - Relative timestamps via date-fns - Action buttons supporting navigation and callback types
This commit is contained in:
98
libs/shell/notifications/README.md
Normal file
98
libs/shell/notifications/README.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# shell-notifications
|
||||
|
||||
> **Type:** Feature Library
|
||||
> **Domain:** Shell
|
||||
> **Path:** `libs/shell/notifications`
|
||||
|
||||
## Overview
|
||||
|
||||
Notification display component that renders grouped notifications with actions. Used within the header's notification toggle overlay panel.
|
||||
|
||||
## Features
|
||||
|
||||
- Grouped notification display with collapsible sections
|
||||
- Unread/read status indication
|
||||
- Relative timestamps (e.g., "5 minutes ago")
|
||||
- Action buttons with navigation or callback support
|
||||
|
||||
## Installation
|
||||
|
||||
```typescript
|
||||
import { ShellNotificationsComponent } from '@isa/shell/notifications';
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
@Component({
|
||||
selector: 'app-notification-panel',
|
||||
standalone: true,
|
||||
imports: [ShellNotificationsComponent],
|
||||
template: `<shell-notifications />`
|
||||
})
|
||||
export class NotificationPanelComponent {}
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### ShellNotificationsComponent
|
||||
|
||||
**Selector:** `shell-notifications`
|
||||
|
||||
Main container that displays notifications grouped by category.
|
||||
|
||||
**Behavior:**
|
||||
- Groups notifications by `group` property
|
||||
- Sorts within groups: unread first, then by timestamp (newest first)
|
||||
- Empty state when no notifications
|
||||
|
||||
### ShellNotificationComponent
|
||||
|
||||
**Selector:** `shell-notification`
|
||||
|
||||
Individual notification card with action support.
|
||||
|
||||
**Inputs:**
|
||||
- `notification` (required): `Notification` object to display
|
||||
|
||||
**Outputs:**
|
||||
- `actionTriggered`: Emits when notification action is triggered
|
||||
|
||||
**Action Types:**
|
||||
- `navigate` - Navigates to internal or external route
|
||||
- `callback` - Executes custom callback function
|
||||
|
||||
## E2E Testing
|
||||
|
||||
| Element | data-what | data-which | Purpose |
|
||||
|---------|-----------|------------|---------|
|
||||
| Container | `container` | `notifications-list` | Main list wrapper |
|
||||
| Group header | `notification-group-header` | `{groupName}` | Group title |
|
||||
| Separator | `separator` | `separator-{groupName}` | Visual divider |
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Uses `role="feed"` for notification list
|
||||
- Group separators marked with `aria-hidden="true"`
|
||||
- `aria-label="Benachrichtigungen"` on main container
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Internal:**
|
||||
- `@isa/shell/common` - NotificationsService, Notification type
|
||||
- `@isa/core/logging` - Logger factory
|
||||
- `@isa/icons` - Action icons
|
||||
|
||||
**External:**
|
||||
- `date-fns` - Relative time formatting
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
npx nx test shell-notifications
|
||||
```
|
||||
|
||||
## Related Libraries
|
||||
|
||||
- [`@isa/shell/common`](../common) - NotificationsService
|
||||
- [`@isa/shell/header`](../header) - Header notification toggle
|
||||
34
libs/shell/notifications/eslint.config.cjs
Normal file
34
libs/shell/notifications/eslint.config.cjs
Normal file
@@ -0,0 +1,34 @@
|
||||
const nx = require('@nx/eslint-plugin');
|
||||
const baseConfig = require('../../../eslint.config.js');
|
||||
|
||||
module.exports = [
|
||||
...baseConfig,
|
||||
...nx.configs['flat/angular'],
|
||||
...nx.configs['flat/angular-template'],
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
rules: {
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'attribute',
|
||||
prefix: 'shell',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'shell',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
20
libs/shell/notifications/project.json
Normal file
20
libs/shell/notifications/project.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "shell-notifications",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/shell/notifications/src",
|
||||
"prefix": "shell",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/vite:test",
|
||||
"outputs": ["{options.reportsDirectory}"],
|
||||
"options": {
|
||||
"reportsDirectory": "../../../coverage/libs/shell/notifications"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
libs/shell/notifications/src/index.ts
Normal file
1
libs/shell/notifications/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/shell-notifications.component';
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<article
|
||||
class="flex flex-col gap-4 rounded-lg p-4"
|
||||
[class.bg-isa-secondary-100]="!notification().markedAsRead"
|
||||
data-what="notification-item"
|
||||
[attr.data-which]="notification().id"
|
||||
[attr.data-unread]="!notification().markedAsRead"
|
||||
role="article"
|
||||
[attr.aria-label]="notification().title + ': ' + notification().message"
|
||||
>
|
||||
<!-- Content -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="isa-text-body-2-bold text-isa-black">
|
||||
{{ notification().title }}
|
||||
</h3>
|
||||
<time
|
||||
class="isa-text-caption-regular flex-shrink-0 text-isa-neutral-600"
|
||||
[attr.datetime]="notification().timestamp | date: 'yyyy-MM-ddTHH:mm:ss'"
|
||||
[attr.aria-label]="'Received ' + relativeTime()"
|
||||
>
|
||||
{{ relativeTime() }}
|
||||
</time>
|
||||
</div>
|
||||
<p class="isa-text-body-2-bold text-isa-black">
|
||||
{{ notification().message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end">
|
||||
@if (!notification().markedAsRead) {
|
||||
<!-- Action button - primary -->
|
||||
<button
|
||||
type="button"
|
||||
class="isa-text-body-2-bold inline-flex items-center gap-2 rounded-full bg-isa-secondary-600 px-3 py-1.5 text-isa-white transition-colors hover:bg-isa-secondary-700"
|
||||
(click)="onAction()"
|
||||
data-what="notification-action-button"
|
||||
[attr.data-which]="notification().id"
|
||||
[attr.aria-label]="notification().action.label + ' for ' + notification().title"
|
||||
>
|
||||
<ng-icon name="isaActionCheck" size="12" />
|
||||
<span>{{ notification().action.label }}</span>
|
||||
</button>
|
||||
} @else {
|
||||
<!-- Action button - completed -->
|
||||
<button
|
||||
type="button"
|
||||
class="isa-text-body-2-bold inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-isa-accent-green transition-colors hover:bg-isa-neutral-100"
|
||||
(click)="onAction()"
|
||||
data-what="notification-action-button"
|
||||
[attr.data-which]="notification().id"
|
||||
[attr.aria-label]="notification().action.label + ' for ' + notification().title"
|
||||
>
|
||||
<ng-icon name="isaActionCheck" size="12" />
|
||||
<span>{{ notification().action.label }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { isaActionCheck } from '@isa/icons';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { Notification } from '@isa/shell/common';
|
||||
|
||||
@Component({
|
||||
selector: 'shell-notification',
|
||||
templateUrl: './notification.component.html',
|
||||
styleUrl: './notification.component.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [DatePipe, NgIcon],
|
||||
providers: [provideIcons({ isaActionCheck })],
|
||||
})
|
||||
export class ShellNotificationComponent {
|
||||
readonly #logger = logger({ component: 'ShellNotificationComponent' });
|
||||
readonly #router = inject(Router);
|
||||
|
||||
notification = input.required<Notification>();
|
||||
actionTriggered = output<Notification>();
|
||||
|
||||
relativeTime = computed(() =>
|
||||
formatDistanceToNow(this.notification().timestamp, { addSuffix: true }),
|
||||
);
|
||||
|
||||
onAction(): void {
|
||||
const notification = this.notification();
|
||||
const action = notification.action;
|
||||
|
||||
this.#logger.debug('Notification action triggered', () => ({
|
||||
notificationId: notification.id,
|
||||
actionType: action.type,
|
||||
actionLabel: action.label,
|
||||
}));
|
||||
|
||||
if (action.type === 'navigate') {
|
||||
if (action.target === 'external') {
|
||||
window.open(action.route, '_blank');
|
||||
this.#logger.debug('Navigating to external route', () => ({
|
||||
route: action.route,
|
||||
}));
|
||||
} else {
|
||||
this.#router.navigate([action.route]);
|
||||
this.#logger.debug('Navigating to internal route', () => ({
|
||||
route: action.route,
|
||||
}));
|
||||
}
|
||||
} else if (action.type === 'callback') {
|
||||
action.callback();
|
||||
}
|
||||
|
||||
this.actionTriggered.emit(notification);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
@let grouped = groupedNotifications();
|
||||
<div
|
||||
class="flex flex-col gap-2"
|
||||
data-what="container"
|
||||
data-which="notifications-list"
|
||||
role="feed"
|
||||
aria-label="Benachrichtigungen"
|
||||
>
|
||||
@for (group of grouped | keyvalue; track group.key) {
|
||||
<div
|
||||
class="isa-text-caption-caps"
|
||||
data-what="notification-group-header"
|
||||
[attr.data-which]="group.key"
|
||||
>
|
||||
{{ group.key }}
|
||||
</div>
|
||||
@for (notification of group.value; track notification.id) {
|
||||
<shell-notification [notification]="notification" />
|
||||
}
|
||||
<hr
|
||||
class="my-2 border-isa-neutral-200"
|
||||
data-what="separator"
|
||||
[attr.data-which]="'separator-' + group.key"
|
||||
role="separator"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { NotificationsService } from '@isa/shell/common';
|
||||
import { ShellNotificationComponent } from './components/notification/notification.component';
|
||||
import { Notification } from '@isa/shell/common';
|
||||
import { KeyValuePipe } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'shell-notifications',
|
||||
templateUrl: './shell-notifications.component.html',
|
||||
styleUrls: ['./shell-notifications.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ShellNotificationComponent, KeyValuePipe],
|
||||
})
|
||||
export class ShellNotificationsComponent {
|
||||
readonly notificationsService = inject(NotificationsService);
|
||||
|
||||
groupedNotifications = computed(() => {
|
||||
const notifications = this.notificationsService.get();
|
||||
|
||||
if (notifications.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const groups: Record<string, Notification[]> = {};
|
||||
for (const notification of notifications) {
|
||||
if (!groups[notification.group]) {
|
||||
groups[notification.group] = [];
|
||||
}
|
||||
groups[notification.group].push(notification);
|
||||
}
|
||||
|
||||
// Sort notifications by markedAsRead (unread first) and timestamp (newest first)
|
||||
for (const group in groups) {
|
||||
groups[group].sort((a, b) => {
|
||||
if (a.markedAsRead && !b.markedAsRead) return 1;
|
||||
if (!a.markedAsRead && b.markedAsRead) return -1;
|
||||
return b.timestamp - a.timestamp;
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
}
|
||||
13
libs/shell/notifications/src/test-setup.ts
Normal file
13
libs/shell/notifications/src/test-setup.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import '@angular/compiler';
|
||||
import '@analogjs/vitest-angular/setup-zone';
|
||||
|
||||
import {
|
||||
BrowserTestingModule,
|
||||
platformBrowserTesting,
|
||||
} from '@angular/platform-browser/testing';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
|
||||
getTestBed().initTestEnvironment(
|
||||
BrowserTestingModule,
|
||||
platformBrowserTesting(),
|
||||
);
|
||||
30
libs/shell/notifications/tsconfig.json
Normal file
30
libs/shell/notifications/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"importHelpers": true,
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"typeCheckHostBindings": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
libs/shell/notifications/tsconfig.lib.json
Normal file
27
libs/shell/notifications/tsconfig.lib.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/test-setup.ts",
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx"
|
||||
],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
29
libs/shell/notifications/tsconfig.spec.json
Normal file
29
libs/shell/notifications/tsconfig.spec.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.d.ts"
|
||||
],
|
||||
"files": ["src/test-setup.ts"]
|
||||
}
|
||||
27
libs/shell/notifications/vite.config.mts
Normal file
27
libs/shell/notifications/vite.config.mts
Normal file
@@ -0,0 +1,27 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import angular from '@analogjs/vite-plugin-angular';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../../node_modules/.vite/libs/shell/notifications',
|
||||
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory: '../../../coverage/libs/shell/notifications',
|
||||
provider: 'v8' as const,
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user