diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/index.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/index.ts index a6cd77084..bde7584a9 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/index.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/index.ts @@ -1,3 +1,4 @@ export * from './event-display.service'; export * from './extras/event-data-import'; export * from './extras/attribute.pipe'; +export * from './notification.service'; diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/notification.service.component.test.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/notification.service.component.test.ts new file mode 100644 index 000000000..611598090 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/notification.service.component.test.ts @@ -0,0 +1,53 @@ +import { NotificationService } from './notification.service'; + +describe('NotificationService', () => { + let service: NotificationService; + let received: any[]; + + beforeEach(() => { + service = new NotificationService(); + received = []; + service.subscribeToNotifications((n) => received.push(n)); + }); + + it('should emit success notification', () => { + service.success('Operation complete'); + expect(received[0].severity).toBe('success'); + expect(received[0].message).toBe('Operation complete'); + expect(received[0].duration).toBe(5000); + }); + + it('should emit info notification', () => { + service.info('Loading file'); + expect(received[0].severity).toBe('info'); + expect(received[0].duration).toBe(5000); + }); + + it('should emit warning notification', () => { + service.warning('File may be malformed'); + expect(received[0].severity).toBe('warning'); + expect(received[0].duration).toBe(8000); + }); + + it('should emit error notification with no auto-dismiss', () => { + service.error('Could not parse event file'); + expect(received[0].severity).toBe('error'); + expect(received[0].duration).toBe(0); + }); + + it('should respect custom duration', () => { + service.success('Custom', 3000); + expect(received[0].duration).toBe(3000); + }); + + it('should support multiple subscribers', () => { + const second: any[] = []; + const unsub = service.subscribeToNotifications((n) => second.push(n)); + + service.info('Broadcast test'); + expect(received.length).toBe(1); + expect(second.length).toBe(1); + + unsub(); + }); +}); diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/notification.service.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/notification.service.ts new file mode 100644 index 000000000..ed6a7b0af --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/notification.service.ts @@ -0,0 +1,106 @@ +import { Injectable } from '@angular/core'; +import { ActiveVariable } from 'phoenix-event-display'; + +/** Severity levels for notifications. */ +export type NotificationSeverity = 'success' | 'info' | 'warning' | 'error'; + +/** A notification message with severity and content. */ +export interface Notification { + /** The message to display to the user. */ + message: string; + /** The severity level of the notification. */ + severity: NotificationSeverity; + /** Duration in milliseconds before auto-dismiss. 0 means no auto-dismiss. */ + duration: number; +} + +/** + * Service for displaying user-facing notifications with severity levels. + * Provides success, info, warning, and error notifications with + * configurable auto-dismiss durations. Replaces silent console errors + * with actionable user feedback across all Phoenix components. + */ +@Injectable({ + providedIn: 'root', +}) +export class NotificationService { + /** Default auto-dismiss durations in milliseconds per severity level. */ + private readonly defaultDurations: Record = { + success: 5000, + info: 5000, + warning: 8000, + error: 0, + }; + + /** Active variable that stores the latest notification. */ + private notification = new ActiveVariable(); + + /** + * Subscribe to incoming notifications. + * @param callback Function called when a notification is emitted. + * @returns A function that can be called to unsubscribe. + */ + subscribeToNotifications( + callback: (notification: Notification) => void, + ): () => void { + return this.notification.onUpdate(callback); + } + + /** + * Display a success notification. + * Auto-dismisses after 5 seconds by default. + * @param message The message to display. + * @param duration Optional custom duration in ms. 0 means no auto-dismiss. + */ + success(message: string, duration?: number): void { + this.notify(message, 'success', duration); + } + + /** + * Display an info notification. + * Auto-dismisses after 5 seconds by default. + * @param message The message to display. + * @param duration Optional custom duration in ms. 0 means no auto-dismiss. + */ + info(message: string, duration?: number): void { + this.notify(message, 'info', duration); + } + + /** + * Display a warning notification. + * Auto-dismisses after 8 seconds by default. + * @param message The message to display. + * @param duration Optional custom duration in ms. 0 means no auto-dismiss. + */ + warning(message: string, duration?: number): void { + this.notify(message, 'warning', duration); + } + + /** + * Display an error notification. + * Does not auto-dismiss by default — requires manual dismissal. + * @param message The message to display. + * @param duration Optional custom duration in ms. 0 means no auto-dismiss. + */ + error(message: string, duration?: number): void { + this.notify(message, 'error', duration); + } + + /** + * Internal method to emit a notification. + * @param message The message to display. + * @param severity The severity level. + * @param duration Optional custom duration override. + */ + private notify( + message: string, + severity: NotificationSeverity, + duration?: number, + ): void { + this.notification.update({ + message, + severity, + duration: duration ?? this.defaultDurations[severity], + }); + } +}