@opendevstack/ngx-appshell 19.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +131 -0
  2. package/fesm2022/opendevstack-ngx-appshell.mjs +727 -0
  3. package/fesm2022/opendevstack-ngx-appshell.mjs.map +1 -0
  4. package/index.d.ts +5 -0
  5. package/lib/components/appshell-breadcrumb/appshell-breadcrumb.component.d.ts +7 -0
  6. package/lib/components/appshell-chip/appshell-chip.component.d.ts +6 -0
  7. package/lib/components/appshell-filters/appshell-filters.component.d.ts +12 -0
  8. package/lib/components/appshell-header/appshell-header.component.d.ts +24 -0
  9. package/lib/components/appshell-icon/appshell-icon.component.d.ts +12 -0
  10. package/lib/components/appshell-layout/appshell-layout.component.d.ts +25 -0
  11. package/lib/components/appshell-page-header/appshell-page-header.component.d.ts +18 -0
  12. package/lib/components/appshell-platform-header/appshell-platform-header.component.d.ts +37 -0
  13. package/lib/components/appshell-platform-layout/appshell-platform-layout.component.d.ts +30 -0
  14. package/lib/components/appshell-product-card/appshell-product-card.component.d.ts +23 -0
  15. package/lib/components/appshell-product-card-v2/appshell-product-card-v2.component.d.ts +16 -0
  16. package/lib/components/appshell-select/appshell-select.component.d.ts +9 -0
  17. package/lib/components/appshell-sidebar-menu/appshell-sidebar-menu.component.d.ts +12 -0
  18. package/lib/components/appshell-toast/appshell-toast.component.d.ts +9 -0
  19. package/lib/components/appshell-toasts/appshell-toasts.component.d.ts +14 -0
  20. package/lib/components/index.d.ts +15 -0
  21. package/lib/directives/appshell-link.directive.d.ts +15 -0
  22. package/lib/directives/index.d.ts +1 -0
  23. package/lib/models/appshell-button.d.ts +5 -0
  24. package/lib/models/appshell-filter.d.ts +4 -0
  25. package/lib/models/appshell-link.d.ts +6 -0
  26. package/lib/models/appshell-links-group.d.ts +5 -0
  27. package/lib/models/appshell-notification.d.ts +10 -0
  28. package/lib/models/appshell-picker.d.ts +9 -0
  29. package/lib/models/appshell-product.d.ts +12 -0
  30. package/lib/models/appshell-tag.d.ts +4 -0
  31. package/lib/models/appshell-toast.d.ts +6 -0
  32. package/lib/models/appshell-user.d.ts +5 -0
  33. package/lib/models/index.d.ts +10 -0
  34. package/lib/screens/appshell-notifications-screen/appshell-notifications-screen.component.d.ts +23 -0
  35. package/lib/screens/appshell-product-catalog-screen/appshell-product-catalog-screen.component.d.ts +16 -0
  36. package/lib/screens/appshell-product-view-screen/appshell-product-view-screen.component.d.ts +19 -0
  37. package/lib/screens/index.d.ts +3 -0
  38. package/lib/services/appshell-toast.service.d.ts +12 -0
  39. package/lib/services/icon-registry.service.d.ts +14 -0
  40. package/lib/services/index.d.ts +2 -0
  41. package/opendevstack-ngx-appshell-19.0.5.tgz +0 -0
  42. package/package.json +44 -0
  43. package/public-api.d.ts +5 -0
  44. package/schematics/azure-login/files/app-config/config.json.template +12 -0
  45. package/schematics/azure-login/files/app-config-service/app-config.service.spec.ts.template +48 -0
  46. package/schematics/azure-login/files/app-config-service/app-config.service.ts.template +39 -0
  47. package/schematics/azure-login/files/azure-config/azure.config.ts.template +94 -0
  48. package/schematics/azure-login/files/azure-service/azure.service.spec.ts.template +311 -0
  49. package/schematics/azure-login/files/azure-service/azure.service.ts.template +161 -0
  50. package/schematics/azure-login/index.d.ts +2 -0
  51. package/schematics/azure-login/index.js +325 -0
  52. package/schematics/azure-login/index.js.map +1 -0
  53. package/schematics/azure-login/schema.json +8 -0
  54. package/schematics/collection.json +19 -0
  55. package/schematics/nats-notifications/files/nats-service/nats.service.spec.ts.template +473 -0
  56. package/schematics/nats-notifications/files/nats-service/nats.service.ts.template +255 -0
  57. package/schematics/nats-notifications/files/notifications-screen/notifications-screen.component.html.template +7 -0
  58. package/schematics/nats-notifications/files/notifications-screen/notifications-screen.component.spec.ts.template +152 -0
  59. package/schematics/nats-notifications/files/notifications-screen/notifications-screen.component.ts.template +61 -0
  60. package/schematics/nats-notifications/index.d.ts +2 -0
  61. package/schematics/nats-notifications/index.js +502 -0
  62. package/schematics/nats-notifications/index.js.map +1 -0
  63. package/schematics/nats-notifications/schema.json +8 -0
  64. package/schematics/ng-add/files/_fonts.scss +85 -0
  65. package/schematics/ng-add/files/styles.scss +47 -0
  66. package/schematics/ng-add/index.d.ts +2 -0
  67. package/schematics/ng-add/index.js +442 -0
  68. package/schematics/ng-add/index.js.map +1 -0
  69. package/styles/appshell-typography-config.scss +19 -0
  70. package/styles/appshell.theme.scss +75 -0
  71. package/styles/palette.css +92 -0
@@ -0,0 +1,255 @@
1
+ import { Injectable, OnDestroy } from '@angular/core';
2
+ import { BehaviorSubject, Observable, Subject } from 'rxjs';
3
+ import { wsconnect, NatsConnection, Subscription } from '@nats-io/nats-core';
4
+ import { jetstreamManager } from '@nats-io/jetstream';
5
+ import { KV, Kvm, KvEntry } from '@nats-io/kv';
6
+
7
+ export interface NatsMessage {
8
+ id: string;
9
+ subject: string;
10
+ data: any;
11
+ read: boolean;
12
+ }
13
+
14
+ @Injectable({
15
+ providedIn: 'root'
16
+ })
17
+ export class NatsService implements OnDestroy {
18
+ private connection: NatsConnection | null = null;
19
+ private kv: KV | null = null;
20
+ private subscriptions: Map<string, Subscription> = new Map();
21
+ private messagesSubject: BehaviorSubject<NatsMessage[]> = new BehaviorSubject<NatsMessage[]>([]);
22
+ private unreadMessagesCountSubject: BehaviorSubject<number> = new BehaviorSubject<number>(0);
23
+ private liveMessageSubject: BehaviorSubject<NatsMessage | null> = new BehaviorSubject<NatsMessage | null>(null);
24
+ private connectionError = new Subject<Error>();
25
+
26
+ public messages$: Observable<NatsMessage[]> = this.messagesSubject.asObservable();
27
+ public unreadMessagesCount$: Observable<number> = this.unreadMessagesCountSubject.asObservable();
28
+ public liveMessage$: Observable<NatsMessage | null> = this.liveMessageSubject.asObservable();
29
+ public connectionError$: Observable<Error> = this.connectionError.asObservable();
30
+
31
+ constructor() {}
32
+
33
+ public async initialize(serverUrl: string): Promise<void> {
34
+ try {
35
+ this.connection = await wsconnect({ servers: serverUrl });
36
+ } catch (error) {
37
+ console.error('Failed to initialize NATS service', error);
38
+ this.connectionError.next(error as Error);
39
+ throw error;
40
+ }
41
+ }
42
+
43
+ public async initializeUser(user: string): Promise<void> {
44
+ try {
45
+ await this.initializeKvStore(user);
46
+ await this.loadMessages(user);
47
+ } catch (error) {
48
+ console.error('Failed to initialize NATS service for user', error);
49
+ this.connectionError.next(error as Error);
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ private async initializeKvStore(user: string): Promise<void> {
55
+ if (!this.connection) {
56
+ throw new Error('NATS connection not established properly');
57
+ }
58
+
59
+ const bucketName = `bucket-${user}`;
60
+
61
+ const publicKey = this.getPublicKey(user);
62
+ const privateKey = this.getPrivateKey(user);
63
+
64
+ try {
65
+ const kvm: Kvm = new Kvm(this.connection);
66
+ this.kv = await kvm.create(bucketName);
67
+
68
+ const publicKv: KvEntry | null = await this.kv.get(publicKey);
69
+ const privateKv: KvEntry | null = await this.kv.get(privateKey);
70
+
71
+ if(!publicKv) {
72
+ await this.kv.put(publicKey, '[]');
73
+ }
74
+ if(!privateKv) {
75
+ await this.kv.put(privateKey, '[]');
76
+ }
77
+ } catch (error) {
78
+ console.error('Failed to initialize KV store for user', error);
79
+ throw error;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Load initial messages for all subjects from KV store
85
+ */
86
+ private async loadMessages(user: string): Promise<void> {
87
+ if (!this.connection || !this.kv) {
88
+ throw new Error('NATS connection or KV client not initialized');
89
+ }
90
+
91
+ const subjects = ['app.com.notifications.public', `app.com.notifications.private.${user}`];
92
+
93
+ try {
94
+ const jsm = await jetstreamManager(this.connection);
95
+ const messages: NatsMessage[] = [];
96
+
97
+ for (const subjectName of subjects) {
98
+ const streamName = await jsm.streams.find(subjectName);
99
+ if (!streamName) {
100
+ console.error(`Stream not found for subject: ${subjectName}`);
101
+ return;
102
+ }
103
+ const createdConsumer = await jsm.consumers.add(streamName, { deliver_policy: 'all', filter_subject: subjectName });
104
+ const consumer = await jsm.jetstream().consumers.get(streamName, createdConsumer.name);
105
+ const iter = await consumer.consume();
106
+ const readIds = await this.getReadMessageIds(subjectName);
107
+ (async () => {
108
+ for await (const msg of iter) {
109
+ try {
110
+ const message: NatsMessage = {
111
+ id: msg.headers?.get('Nats-Msg-Id') || '',
112
+ subject: msg.subject,
113
+ data: msg.json(),
114
+ read: readIds.includes(msg.headers?.get('Nats-Msg-Id') || '')
115
+ };
116
+ if (this.isValidMessage(message.data)) {
117
+ messages.push(message);
118
+ if (message.data.date) {
119
+ const messageDate = new Date(message.data.date);
120
+ if (Date.now() - messageDate.getTime() <= 5000) { // We add a treshold of 5 seconds to show it as live message
121
+ this.liveMessageSubject.next(message);
122
+ }
123
+ }
124
+ }
125
+ if (msg.info.pending === 0) {
126
+ messages.sort((a, b) => new Date(b.data.date).getTime() - new Date(a.data.date).getTime());
127
+ this.messagesSubject.next(messages);
128
+ this.unreadMessagesCountSubject.next(messages.filter(m => !m.read).length);
129
+ }
130
+ } catch (error) {
131
+ console.error('Error processing message:', error);
132
+ }
133
+ }
134
+ })();
135
+ }
136
+ } catch (error) {
137
+ console.error('Failed to load initial messages from stream', error);
138
+ throw error;
139
+ }
140
+ }
141
+
142
+
143
+ /**
144
+ * Mark multiple messages as read by storing their IDs in the KV store
145
+ * @param subject The subject the messages belong to
146
+ * @param messageIds The array of message IDs to mark as read
147
+ */
148
+ public async readMessages(subject: string, messageIds: string[]): Promise<void> {
149
+ if (!this.kv) {
150
+ throw new Error('KV store not initialized');
151
+ }
152
+
153
+ try {
154
+ const entry = await this.kv.get(subject);
155
+ let readMessageIds: string[] = [];
156
+
157
+ if (entry) {
158
+ readMessageIds = JSON.parse(entry.string());
159
+ }
160
+
161
+ const newReadMessageIds = messageIds.filter(id => !readMessageIds.includes(id));
162
+ if (newReadMessageIds.length > 0) {
163
+ readMessageIds.push(...newReadMessageIds);
164
+ await this.kv.put(subject, JSON.stringify(readMessageIds));
165
+ }
166
+ this.messagesSubject.next(this.messagesSubject.getValue().map(message => {
167
+ if (messageIds.includes(message.id)) {
168
+ message.read = true;
169
+ }
170
+ return message;
171
+ }));
172
+ this.unreadMessagesCountSubject.next(Math.max(0, this.messagesSubject.getValue().filter(m => !m.read && !readMessageIds.includes(m.id) ).length));
173
+ } catch (error) {
174
+ console.error(`Failed to mark messages as read: ${subject} - ${messageIds.join(', ')}`, error);
175
+ throw error;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Get the list of read message IDs for a specific key from the KV store
181
+ * @param key The key from the KV to get read message IDs for
182
+ * @returns Array of read message IDs
183
+ */
184
+ public async getReadMessageIds(key: string): Promise<string[]> {
185
+ if (!this.kv) {
186
+ throw new Error('KV store not initialized');
187
+ }
188
+
189
+ try {
190
+ const entry = await this.kv.get(key);
191
+
192
+ if (!entry) {
193
+ return [];
194
+ }
195
+
196
+ return JSON.parse(entry.string());
197
+ } catch (error) {
198
+ console.error(`Failed to get read messages for key: ${key}`, error);
199
+ throw error;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Check if a specific message has been read
205
+ * @param subject The subject the message belongs to in the KV store
206
+ * @param messageId The ID of the message to check
207
+ * @returns Boolean indicating if the message has been read
208
+ */
209
+ public async isMessageRead(subject: string, messageId: string): Promise<boolean> {
210
+ const readMessageIds = await this.getReadMessageIds(subject);
211
+ return readMessageIds.includes(messageId);
212
+ }
213
+
214
+ /**
215
+ * Unsubscribe from all subjects, clear subscriptions and close the connection
216
+ */
217
+ public async close(): Promise<void> {
218
+ for (const [subject, subscription] of this.subscriptions.entries()) {
219
+ try {
220
+ await subscription.unsubscribe();
221
+ } catch (error) {
222
+ console.error(`Error unsubscribing from ${subject}:`, error);
223
+ }
224
+ }
225
+
226
+ this.subscriptions.clear();
227
+
228
+ if (this.connection) {
229
+ await this.connection.close();
230
+ this.connection = null;
231
+ }
232
+ }
233
+
234
+ private getPublicKey(user: string): string {
235
+ return `app.com.notifications.public.${user}.read`;
236
+ }
237
+
238
+ private getPrivateKey(user: string): string {
239
+ return `app.com.notifications.private.${user}.read`;
240
+ }
241
+
242
+ isValidMessage(message: any): boolean {
243
+ const requiredFields = ['type', 'title', 'date'];
244
+ const hasRequiredFields = requiredFields.every(field => message.hasOwnProperty(field) && message[field] && message[field] !== '');
245
+ const isValidDate = !isNaN(Date.parse(message.date));
246
+
247
+ return hasRequiredFields && isValidDate;
248
+ }
249
+
250
+ ngOnDestroy(): void {
251
+ this.close().catch(error => {
252
+ console.error('Error closing NATS connection', error);
253
+ });
254
+ }
255
+ }
@@ -0,0 +1,7 @@
1
+ <appshell-notifications-screen
2
+ [pageTitle]="'Notifications'"
3
+ [breadcrumbLinks]="breadcrumbLinks"
4
+ [notifications]="notifications"
5
+ (readNotification)="markAsRead($event)"
6
+ (readAllNotifications)="markAllAsRead()"
7
+ />
@@ -0,0 +1,152 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
3
+ import { provideHttpClient } from '@angular/common/http';
4
+ import { NotificationsScreenComponent } from './notifications-screen.component';
5
+ import { AppShellNotification } from '@appshell/ngx-appshell';
6
+ import { NatsMessage, NatsService } from '../../services/nats.service';
7
+ import { Subject } from 'rxjs';
8
+ import { provideMarkdown } from 'ngx-markdown';
9
+
10
+ describe('NotificationsScreenComponent', () => {
11
+ let component: NotificationsScreenComponent;
12
+ let fixture: ComponentFixture<NotificationsScreenComponent>;
13
+ let mockNatsService: jasmine.SpyObj<NatsService>;
14
+ let natsMessages$: Subject<NatsMessage[]>;
15
+
16
+ beforeEach(async () => {
17
+ natsMessages$ = new Subject<NatsMessage[]>();
18
+ mockNatsService = jasmine.createSpyObj('NatsService', ['initialize', 'initializeUser', 'isValidMessage', 'readMessages'], { messages$: natsMessages$.asObservable() });
19
+
20
+ await TestBed.configureTestingModule({
21
+ imports: [BrowserAnimationsModule, NotificationsScreenComponent],
22
+ providers: [
23
+ provideHttpClient(),
24
+ provideMarkdown(),
25
+ { provide: NatsService, useValue: mockNatsService }
26
+ ]
27
+ })
28
+ .compileComponents();
29
+
30
+ fixture = TestBed.createComponent(NotificationsScreenComponent);
31
+ component = fixture.componentInstance;
32
+ fixture.detectChanges();
33
+ });
34
+
35
+ it('should create', () => {
36
+ expect(component).toBeTruthy();
37
+ });
38
+
39
+ it('should subscribe to natsService messages$ and populate notifications', () => {
40
+ const mockMessages = [
41
+ {
42
+ id: '1',
43
+ data: { type: 'info', title: 'Test Title 1', message: 'Test Message 1', date: '2023-01-01T00:00:00Z' },
44
+ read: false,
45
+ subject: 'subject1'
46
+ },
47
+ {
48
+ id: '2',
49
+ data: { type: 'warning', title: 'Test Title 2', message: 'Test Message 2', date: '2023-01-02T00:00:00Z' },
50
+ read: true,
51
+ subject: 'subject2'
52
+ }
53
+ ];
54
+
55
+ mockNatsService.isValidMessage.and.returnValues(true, false);
56
+ natsMessages$.next(mockMessages);
57
+
58
+ expect(component.notifications).toEqual([
59
+ {
60
+ id: '1',
61
+ type: 'info',
62
+ title: 'Test Title 1',
63
+ message: 'Test Message 1',
64
+ date: new Date('2023-01-01T00:00:00Z'),
65
+ read: false,
66
+ subject: 'subject1'
67
+ }
68
+ ]);
69
+ });
70
+
71
+ it('should mark a notification as read and call natsService.readMessages', () => {
72
+ const mockNotification: AppShellNotification = {
73
+ id: '1',
74
+ type: 'info',
75
+ title: 'Test Title',
76
+ message: 'Test Message',
77
+ date: new Date('2023-01-01T00:00:00Z'),
78
+ read: false,
79
+ subject: 'subject1'
80
+ };
81
+
82
+ component.notifications = [mockNotification];
83
+ component.markAsRead(mockNotification);
84
+
85
+ expect(mockNatsService.readMessages).toHaveBeenCalledWith('subject1', ['1']);
86
+ });
87
+
88
+ it('should not mark a notification as read if it does not exist in notifications', () => {
89
+ const mockNotification: AppShellNotification = {
90
+ id: '1',
91
+ type: 'info',
92
+ title: 'Test Title',
93
+ message: 'Test Message',
94
+ date: new Date('2023-01-01T00:00:00Z'),
95
+ read: false,
96
+ subject: 'subject1'
97
+ };
98
+
99
+ component.notifications = [];
100
+
101
+ component.markAsRead(mockNotification);
102
+
103
+ expect(mockNatsService.readMessages).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it('should mark all notifications as read and call natsService.readMessages for each subject', () => {
107
+ const mockNotifications: AppShellNotification[] = [
108
+ {
109
+ id: '1',
110
+ type: 'info',
111
+ title: 'Test Title 1',
112
+ message: 'Test Message 1',
113
+ date: new Date('2023-01-01T00:00:00Z'),
114
+ read: false,
115
+ subject: 'subject1'
116
+ },
117
+ {
118
+ id: '2',
119
+ type: 'success',
120
+ title: 'Test Title 2',
121
+ message: 'Test Message 2',
122
+ date: new Date('2023-01-02T00:00:00Z'),
123
+ read: false,
124
+ subject: 'subject1'
125
+ },
126
+ {
127
+ id: '3',
128
+ type: 'error',
129
+ title: 'Test Title 3',
130
+ message: 'Test Message 3',
131
+ date: new Date('2023-01-03T00:00:00Z'),
132
+ read: false,
133
+ subject: 'subject2'
134
+ }
135
+ ];
136
+
137
+ component.notifications = mockNotifications;
138
+
139
+ component.markAllAsRead();
140
+
141
+ expect(mockNatsService.readMessages).toHaveBeenCalledWith('subject1', ['1', '2']);
142
+ expect(mockNatsService.readMessages).toHaveBeenCalledWith('subject2', ['3']);
143
+ });
144
+
145
+ it('should not call natsService.readMessages if there are no notifications', () => {
146
+ component.notifications = [];
147
+
148
+ component.markAllAsRead();
149
+
150
+ expect(mockNatsService.readMessages).not.toHaveBeenCalled();
151
+ });
152
+ });
@@ -0,0 +1,61 @@
1
+ import { Component } from '@angular/core';
2
+ import { AppShellNotificationsScreenComponent, AppShellLink, AppShellNotification } from '@appshell/ngx-appshell';
3
+ import { Subscription } from 'rxjs';
4
+ import { NatsService } from '../../services/nats.service';
5
+
6
+ @Component({
7
+ selector: 'app-notifications-screen',
8
+ standalone: true,
9
+ imports: [AppShellNotificationsScreenComponent],
10
+ templateUrl: './notifications-screen.component.html',
11
+ styleUrl: './notifications-screen.component.scss'
12
+ })
13
+ export class NotificationsScreenComponent {
14
+
15
+ notifications: AppShellNotification[] = [];
16
+ private messageSubscription!: Subscription;
17
+
18
+ constructor(private natsService: NatsService) {
19
+ this.messageSubscription = this.natsService.messages$.subscribe((messages) => {
20
+ this.notifications = messages.filter((message) => this.natsService.isValidMessage(message.data)).map((message) => ({
21
+ id: message.id,
22
+ type: message.data.type,
23
+ title: message.data.title,
24
+ message: message.data.message,
25
+ date: new Date(message.data.date),
26
+ read: message.read,
27
+ subject: message.subject
28
+ }));
29
+ });
30
+ }
31
+
32
+ breadcrumbLinks: AppShellLink[] = [
33
+ {anchor: '', label: 'Notifications'}
34
+ ]
35
+
36
+ markAsRead(notification: AppShellNotification) {
37
+ const notif = this.notifications.find(n => n === notification);
38
+ if (!notif) {
39
+ return;
40
+ }
41
+ this.natsService.readMessages(notification.subject, [notification.id]);
42
+ }
43
+
44
+ markAllAsRead() {
45
+ const groupedBySubject = this.notifications.reduce((acc, notification) => {
46
+ if (!acc[notification.subject]) {
47
+ acc[notification.subject] = [];
48
+ }
49
+ acc[notification.subject].push(notification.id);
50
+ return acc;
51
+ }, {} as Record<string, string[]>);
52
+
53
+ Object.entries(groupedBySubject).forEach(([subject, ids]) => {
54
+ this.natsService.readMessages(subject, ids);
55
+ });
56
+ }
57
+
58
+ ngOnDestroy(): void {
59
+ this.messageSubscription.unsubscribe();
60
+ }
61
+ }
@@ -0,0 +1,2 @@
1
+ import { Rule } from '@angular-devkit/schematics';
2
+ export declare function natsNotificationsGenerator(): Rule;