diff --git a/libs/shell/notifications/README.md b/libs/shell/notifications/README.md new file mode 100644 index 000000000..8ccf8324e --- /dev/null +++ b/libs/shell/notifications/README.md @@ -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: `` +}) +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 diff --git a/libs/shell/notifications/eslint.config.cjs b/libs/shell/notifications/eslint.config.cjs new file mode 100644 index 000000000..0d748b70d --- /dev/null +++ b/libs/shell/notifications/eslint.config.cjs @@ -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: {}, + }, +]; diff --git a/libs/shell/notifications/project.json b/libs/shell/notifications/project.json new file mode 100644 index 000000000..f5d9852aa --- /dev/null +++ b/libs/shell/notifications/project.json @@ -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" + } + } +} diff --git a/libs/shell/notifications/src/index.ts b/libs/shell/notifications/src/index.ts new file mode 100644 index 000000000..9e07eee03 --- /dev/null +++ b/libs/shell/notifications/src/index.ts @@ -0,0 +1 @@ +export * from './lib/shell-notifications.component'; diff --git a/libs/shell/notifications/src/lib/components/notification/notification.component.css b/libs/shell/notifications/src/lib/components/notification/notification.component.css new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/libs/shell/notifications/src/lib/components/notification/notification.component.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/shell/notifications/src/lib/components/notification/notification.component.html b/libs/shell/notifications/src/lib/components/notification/notification.component.html new file mode 100644 index 000000000..8cce640f4 --- /dev/null +++ b/libs/shell/notifications/src/lib/components/notification/notification.component.html @@ -0,0 +1,59 @@ +
+ +
+
+

+ {{ notification().title }} +

+ +
+

+ {{ notification().message }} +

+
+ + +
+ @if (!notification().markedAsRead) { + + + } @else { + + + } +
+
diff --git a/libs/shell/notifications/src/lib/components/notification/notification.component.ts b/libs/shell/notifications/src/lib/components/notification/notification.component.ts new file mode 100644 index 000000000..57493dd9d --- /dev/null +++ b/libs/shell/notifications/src/lib/components/notification/notification.component.ts @@ -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(); + actionTriggered = output(); + + 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); + } +} diff --git a/libs/shell/notifications/src/lib/shell-notifications.component.css b/libs/shell/notifications/src/lib/shell-notifications.component.css new file mode 100644 index 000000000..e69de29bb diff --git a/libs/shell/notifications/src/lib/shell-notifications.component.html b/libs/shell/notifications/src/lib/shell-notifications.component.html new file mode 100644 index 000000000..9e098a887 --- /dev/null +++ b/libs/shell/notifications/src/lib/shell-notifications.component.html @@ -0,0 +1,28 @@ +@let grouped = groupedNotifications(); +
+ @for (group of grouped | keyvalue; track group.key) { +
+ {{ group.key }} +
+ @for (notification of group.value; track notification.id) { + + } + + } +
diff --git a/libs/shell/notifications/src/lib/shell-notifications.component.ts b/libs/shell/notifications/src/lib/shell-notifications.component.ts new file mode 100644 index 000000000..2ece13858 --- /dev/null +++ b/libs/shell/notifications/src/lib/shell-notifications.component.ts @@ -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 = {}; + 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; + }); +} diff --git a/libs/shell/notifications/src/test-setup.ts b/libs/shell/notifications/src/test-setup.ts new file mode 100644 index 000000000..cebf5ae72 --- /dev/null +++ b/libs/shell/notifications/src/test-setup.ts @@ -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(), +); diff --git a/libs/shell/notifications/tsconfig.json b/libs/shell/notifications/tsconfig.json new file mode 100644 index 000000000..3268ed4dc --- /dev/null +++ b/libs/shell/notifications/tsconfig.json @@ -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" + } + ] +} diff --git a/libs/shell/notifications/tsconfig.lib.json b/libs/shell/notifications/tsconfig.lib.json new file mode 100644 index 000000000..312ee86bb --- /dev/null +++ b/libs/shell/notifications/tsconfig.lib.json @@ -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"] +} diff --git a/libs/shell/notifications/tsconfig.spec.json b/libs/shell/notifications/tsconfig.spec.json new file mode 100644 index 000000000..5785a8a5f --- /dev/null +++ b/libs/shell/notifications/tsconfig.spec.json @@ -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"] +} diff --git a/libs/shell/notifications/vite.config.mts b/libs/shell/notifications/vite.config.mts new file mode 100644 index 000000000..9ad549d34 --- /dev/null +++ b/libs/shell/notifications/vite.config.mts @@ -0,0 +1,27 @@ +/// +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, + }, + }, +}));