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:
Lorenz Hilpert
2025-12-03 21:16:33 +01:00
parent 062a8044f2
commit daf79d55a5
15 changed files with 481 additions and 0 deletions

View 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

View 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: {},
},
];

View 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"
}
}
}

View File

@@ -0,0 +1 @@
export * from './lib/shell-notifications.component';

View File

@@ -0,0 +1,3 @@
:host {
display: block;
}

View File

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

View File

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

View File

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

View File

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

View 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(),
);

View 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"
}
]
}

View 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"]
}

View 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"]
}

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