@openmfp/webcomponents 0.6.1

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 (140) hide show
  1. package/.github/workflows/pipeline.yaml +41 -0
  2. package/.storybook/main.ts +34 -0
  3. package/.storybook/preview.ts +29 -0
  4. package/.storybook/tsconfig.json +11 -0
  5. package/AGENTS.md +153 -0
  6. package/CODEOWNERS +6 -0
  7. package/CONTRIBUTING.md +95 -0
  8. package/LICENSE +201 -0
  9. package/LICENSES/Apache-2.0.txt +73 -0
  10. package/README.md +91 -0
  11. package/REUSE.toml +9 -0
  12. package/angular.json +157 -0
  13. package/docs/dashboard.md +358 -0
  14. package/docs/declarative-form.md +178 -0
  15. package/docs/declarative-table-card.md +235 -0
  16. package/docs/declarative-table.md +315 -0
  17. package/eslint.config.js +41 -0
  18. package/package.json +73 -0
  19. package/projects/ngx/cards/favorites/favorites.component.html +12 -0
  20. package/projects/ngx/cards/favorites/favorites.component.scss +50 -0
  21. package/projects/ngx/cards/favorites/favorites.component.ts +19 -0
  22. package/projects/ngx/cards/public-api.ts +4 -0
  23. package/projects/ngx/cards/service-status/service-status-card.component.html +15 -0
  24. package/projects/ngx/cards/service-status/service-status-card.component.scss +87 -0
  25. package/projects/ngx/cards/service-status/service-status-card.component.ts +36 -0
  26. package/projects/ngx/cards/stories/visited-service-card.stories.ts +149 -0
  27. package/projects/ngx/cards/visited-service-card/visited-service-card.component.html +17 -0
  28. package/projects/ngx/cards/visited-service-card/visited-service-card.component.scss +34 -0
  29. package/projects/ngx/cards/visited-service-card/visited-service-card.component.ts +22 -0
  30. package/projects/ngx/cards/whats-new/whats-new.component.html +10 -0
  31. package/projects/ngx/cards/whats-new/whats-new.component.scss +25 -0
  32. package/projects/ngx/cards/whats-new/whats-new.component.ts +46 -0
  33. package/projects/ngx/declarative-ui/dashboard/add-card-dialog/add-card-dialog.component.html +28 -0
  34. package/projects/ngx/declarative-ui/dashboard/add-card-dialog/add-card-dialog.component.scss +44 -0
  35. package/projects/ngx/declarative-ui/dashboard/add-card-dialog/add-card-dialog.component.spec.ts +85 -0
  36. package/projects/ngx/declarative-ui/dashboard/add-card-dialog/add-card-dialog.component.ts +58 -0
  37. package/projects/ngx/declarative-ui/dashboard/card/dashboard-card.component.html +29 -0
  38. package/projects/ngx/declarative-ui/dashboard/card/dashboard-card.component.scss +63 -0
  39. package/projects/ngx/declarative-ui/dashboard/card/dashboard-card.component.spec.ts +255 -0
  40. package/projects/ngx/declarative-ui/dashboard/card/dashboard-card.component.ts +75 -0
  41. package/projects/ngx/declarative-ui/dashboard/card/utils/dashboard-card-registry.spec.ts +76 -0
  42. package/projects/ngx/declarative-ui/dashboard/card/utils/dashboard-card-registry.ts +109 -0
  43. package/projects/ngx/declarative-ui/dashboard/card/utils/index.ts +4 -0
  44. package/projects/ngx/declarative-ui/dashboard/card/utils/mount-angular-card.spec.ts +141 -0
  45. package/projects/ngx/declarative-ui/dashboard/card/utils/mount-angular-card.ts +44 -0
  46. package/projects/ngx/declarative-ui/dashboard/card/utils/mount-sap-card.spec.ts +142 -0
  47. package/projects/ngx/declarative-ui/dashboard/card/utils/mount-sap-card.ts +52 -0
  48. package/projects/ngx/declarative-ui/dashboard/card/utils/mount-wc-card.spec.ts +107 -0
  49. package/projects/ngx/declarative-ui/dashboard/card/utils/mount-wc-card.ts +22 -0
  50. package/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.html +134 -0
  51. package/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.scss +88 -0
  52. package/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.spec.ts +354 -0
  53. package/projects/ngx/declarative-ui/dashboard/dashboard/dashboard.component.ts +238 -0
  54. package/projects/ngx/declarative-ui/dashboard/dashboard/index.ts +1 -0
  55. package/projects/ngx/declarative-ui/dashboard/index.ts +5 -0
  56. package/projects/ngx/declarative-ui/dashboard/models/constants.ts +2 -0
  57. package/projects/ngx/declarative-ui/dashboard/models/dashboard.model.ts +50 -0
  58. package/projects/ngx/declarative-ui/dashboard/models/index.ts +1 -0
  59. package/projects/ngx/declarative-ui/dashboard/section/dashboard-section.component.html +28 -0
  60. package/projects/ngx/declarative-ui/dashboard/section/dashboard-section.component.scss +85 -0
  61. package/projects/ngx/declarative-ui/dashboard/section/dashboard-section.component.spec.ts +104 -0
  62. package/projects/ngx/declarative-ui/dashboard/section/dashboard-section.component.ts +23 -0
  63. package/projects/ngx/declarative-ui/form/declarative-form/declarative-form.component.html +62 -0
  64. package/projects/ngx/declarative-ui/form/declarative-form/declarative-form.component.scss +12 -0
  65. package/projects/ngx/declarative-ui/form/declarative-form/declarative-form.component.spec.ts +301 -0
  66. package/projects/ngx/declarative-ui/form/declarative-form/declarative-form.component.ts +166 -0
  67. package/projects/ngx/declarative-ui/form/declarative-form/index.ts +1 -0
  68. package/projects/ngx/declarative-ui/form/index.ts +2 -0
  69. package/projects/ngx/declarative-ui/form/models/form-field-definition.ts +15 -0
  70. package/projects/ngx/declarative-ui/form/models/index.ts +1 -0
  71. package/projects/ngx/declarative-ui/form/utils/set-property-by-path.ts +30 -0
  72. package/projects/ngx/declarative-ui/models/index.ts +2 -0
  73. package/projects/ngx/declarative-ui/models/resource.ts +5 -0
  74. package/projects/ngx/declarative-ui/models/ui-definition.ts +95 -0
  75. package/projects/ngx/declarative-ui/public-api.ts +4 -0
  76. package/projects/ngx/declarative-ui/stories/add-card-dialog.stories.ts +91 -0
  77. package/projects/ngx/declarative-ui/stories/background-lightblue.png +0 -0
  78. package/projects/ngx/declarative-ui/stories/dashboard.cards.ts +107 -0
  79. package/projects/ngx/declarative-ui/stories/dashboard.stories.ts +296 -0
  80. package/projects/ngx/declarative-ui/stories/declarative-form.stories.ts +149 -0
  81. package/projects/ngx/declarative-ui/stories/declarative-table-card.stories.ts +358 -0
  82. package/projects/ngx/declarative-ui/stories/declarative-table.stories.ts +363 -0
  83. package/projects/ngx/declarative-ui/stories/pods-table.config.ts +188 -0
  84. package/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.html +138 -0
  85. package/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.scss +21 -0
  86. package/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.spec.ts +345 -0
  87. package/projects/ngx/declarative-ui/table/declarative-table/declarative-table.component.ts +61 -0
  88. package/projects/ngx/declarative-ui/table/declarative-table/index.ts +1 -0
  89. package/projects/ngx/declarative-ui/table/index.ts +2 -0
  90. package/projects/ngx/declarative-ui/table/models/index.ts +14 -0
  91. package/projects/ngx/declarative-ui/table/models/table.model.ts +17 -0
  92. package/projects/ngx/declarative-ui/table/utils/cssRules.engine.spec.ts +146 -0
  93. package/projects/ngx/declarative-ui/table/utils/cssRules.engine.ts +69 -0
  94. package/projects/ngx/declarative-ui/table/utils/field-definition.utils.spec.ts +70 -0
  95. package/projects/ngx/declarative-ui/table/utils/field-definition.utils.ts +13 -0
  96. package/projects/ngx/declarative-ui/table/utils/proccess-fields.spec.ts +511 -0
  97. package/projects/ngx/declarative-ui/table/utils/proccess-fields.ts +71 -0
  98. package/projects/ngx/declarative-ui/table/utils/resource-field-by-path.spec.ts +372 -0
  99. package/projects/ngx/declarative-ui/table/utils/resource-field-by-path.ts +98 -0
  100. package/projects/ngx/declarative-ui/table/value-cell/boolean-value/boolean-cell.constants.ts +5 -0
  101. package/projects/ngx/declarative-ui/table/value-cell/boolean-value/boolean-value.component.html +1 -0
  102. package/projects/ngx/declarative-ui/table/value-cell/boolean-value/boolean-value.component.scss +0 -0
  103. package/projects/ngx/declarative-ui/table/value-cell/boolean-value/boolean-value.component.spec.ts +119 -0
  104. package/projects/ngx/declarative-ui/table/value-cell/boolean-value/boolean-value.component.ts +35 -0
  105. package/projects/ngx/declarative-ui/table/value-cell/link-value/link-value.component.html +7 -0
  106. package/projects/ngx/declarative-ui/table/value-cell/link-value/link-value.component.scss +0 -0
  107. package/projects/ngx/declarative-ui/table/value-cell/link-value/link-value.component.spec.ts +114 -0
  108. package/projects/ngx/declarative-ui/table/value-cell/link-value/link-value.component.ts +19 -0
  109. package/projects/ngx/declarative-ui/table/value-cell/secret-value/secret-value.component.html +7 -0
  110. package/projects/ngx/declarative-ui/table/value-cell/secret-value/secret-value.component.scss +10 -0
  111. package/projects/ngx/declarative-ui/table/value-cell/secret-value/secret-value.component.spec.ts +188 -0
  112. package/projects/ngx/declarative-ui/table/value-cell/secret-value/secret-value.component.ts +16 -0
  113. package/projects/ngx/declarative-ui/table/value-cell/value-cell.component.html +59 -0
  114. package/projects/ngx/declarative-ui/table/value-cell/value-cell.component.scss +33 -0
  115. package/projects/ngx/declarative-ui/table/value-cell/value-cell.component.spec.ts +316 -0
  116. package/projects/ngx/declarative-ui/table/value-cell/value-cell.component.ts +115 -0
  117. package/projects/ngx/declarative-ui/table-card/declarative-table-card.component.html +156 -0
  118. package/projects/ngx/declarative-ui/table-card/declarative-table-card.component.scss +123 -0
  119. package/projects/ngx/declarative-ui/table-card/declarative-table-card.component.spec.ts +786 -0
  120. package/projects/ngx/declarative-ui/table-card/declarative-table-card.component.ts +286 -0
  121. package/projects/ngx/declarative-ui/table-card/index.ts +2 -0
  122. package/projects/ngx/declarative-ui/table-card/models/configs.ts +46 -0
  123. package/projects/ngx/declarative-ui/tsconfig.lib.json +11 -0
  124. package/projects/ngx/declarative-ui/tsconfig.lib.prod.json +9 -0
  125. package/projects/ngx/declarative-ui/tsconfig.spec.json +9 -0
  126. package/projects/ngx/ng-package.json +8 -0
  127. package/projects/ngx/package.json +22 -0
  128. package/projects/ngx/public-api.ts +2 -0
  129. package/projects/ngx/tsconfig.lib.json +11 -0
  130. package/projects/ngx/tsconfig.lib.prod.json +9 -0
  131. package/projects/webcomponents/main.ts +92 -0
  132. package/projects/webcomponents/tsconfig.app.json +9 -0
  133. package/projects/webcomponents-dashboard/main.ts +15 -0
  134. package/projects/webcomponents-dashboard/tsconfig.app.json +9 -0
  135. package/renovate.json +6 -0
  136. package/scripts/bundle-wc.mjs +79 -0
  137. package/tsconfig.json +37 -0
  138. package/tsconfig.spec.json +8 -0
  139. package/tsconfig.storybook.json +16 -0
  140. package/vitest.config.ts +26 -0
@@ -0,0 +1,255 @@
1
+ import { DashboardCard } from './dashboard-card.component';
2
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
3
+
4
+ type Fixture = ComponentFixture<DashboardCard>;
5
+
6
+ function setup(): { fixture: Fixture; component: DashboardCard } {
7
+ const fixture = TestBed.createComponent(DashboardCard);
8
+ const component = fixture.componentInstance;
9
+ return { fixture, component };
10
+ }
11
+
12
+ function root(fixture: Fixture): ShadowRoot | HTMLElement {
13
+ return fixture.nativeElement.shadowRoot ?? fixture.nativeElement;
14
+ }
15
+
16
+ describe('DashboardCard', () => {
17
+ beforeEach(async () => {
18
+ await TestBed.configureTestingModule({
19
+ imports: [DashboardCard],
20
+ }).compileComponents();
21
+ });
22
+
23
+ it('uses w and h for grid placement on the host element', () => {
24
+ const { fixture } = setup();
25
+
26
+ fixture.componentRef.setInput('card', {
27
+ id: 'card-1',
28
+ component: 'demo-widget',
29
+ w: 3,
30
+ h: 2,
31
+ });
32
+ fixture.detectChanges();
33
+
34
+ expect(fixture.nativeElement.style.gridColumn).toBe('span 3');
35
+ expect(fixture.nativeElement.style.gridRow).toBe('span 2');
36
+ });
37
+
38
+ it('uses x and y as zero-based grid start coordinates when provided', () => {
39
+ const { fixture } = setup();
40
+
41
+ fixture.componentRef.setInput('card', {
42
+ id: 'card-1',
43
+ component: 'demo-widget',
44
+ w: 3,
45
+ h: 2,
46
+ x: 0,
47
+ y: 4,
48
+ });
49
+ fixture.detectChanges();
50
+
51
+ expect(fixture.nativeElement.style.gridColumn).toBe('1 / span 3');
52
+ expect(fixture.nativeElement.style.gridRow).toBe('5 / span 2');
53
+ });
54
+
55
+ it('renders a dynamic component and applies component inputs', () => {
56
+ const { fixture } = setup();
57
+
58
+ fixture.componentRef.setInput('card', {
59
+ id: 'card-1',
60
+ component: 'demo-widget',
61
+ componentInputs: { title: 'Pods', count: 3 },
62
+ });
63
+ fixture.detectChanges();
64
+
65
+ const element = root(fixture).querySelector('demo-widget') as
66
+ | (HTMLElement & { title?: string; count?: number })
67
+ | null;
68
+
69
+ expect(element).not.toBeNull();
70
+ expect(element?.title).toBe('Pods');
71
+ expect(element?.count).toBe(3);
72
+ });
73
+
74
+ it('replaces the rendered dynamic component when the card definition changes', () => {
75
+ const { fixture } = setup();
76
+
77
+ fixture.componentRef.setInput('card', {
78
+ id: 'card-1',
79
+ component: 'demo-widget',
80
+ componentInputs: { title: 'Pods' },
81
+ });
82
+ fixture.detectChanges();
83
+ fixture.componentRef.setInput('card', {
84
+ id: 'card-1',
85
+ component: 'next-widget',
86
+ componentInputs: { title: 'Services' },
87
+ });
88
+ fixture.detectChanges();
89
+
90
+ const current = root(fixture).querySelector('next-widget') as
91
+ | (HTMLElement & { title?: string })
92
+ | null;
93
+
94
+ expect(root(fixture).querySelector('demo-widget')).toBeNull();
95
+ expect(current?.title).toBe('Services');
96
+ });
97
+
98
+ it('shows a remove action in edit mode and emits when it is clicked', () => {
99
+ const { fixture, component } = setup();
100
+ let emitted = 0;
101
+
102
+ component.removeCard.subscribe(() => emitted++);
103
+ fixture.componentRef.setInput('card', {
104
+ id: 'card-1',
105
+ component: 'demo-widget',
106
+ });
107
+ fixture.componentRef.setInput('editMode', true);
108
+ fixture.detectChanges();
109
+
110
+ const button = root(fixture).querySelector('.card__remove');
111
+ button?.dispatchEvent(new Event('click'));
112
+
113
+ expect(button).not.toBeNull();
114
+ expect(emitted).toBe(1);
115
+ });
116
+
117
+ it('renders the fallback card shell when no dynamic component tag is provided', () => {
118
+ const { fixture } = setup();
119
+
120
+ fixture.componentRef.setInput('card', {
121
+ id: 'card-1',
122
+ component: '',
123
+ });
124
+ fixture.detectChanges();
125
+
126
+ expect(root(fixture).querySelector('.card__body')).not.toBeNull();
127
+ expect(root(fixture).querySelector('.component-card')).toBeNull();
128
+ });
129
+
130
+ describe('sap-ui type', () => {
131
+ let placeAt: ReturnType<typeof vi.fn>;
132
+ let destroy: ReturnType<typeof vi.fn>;
133
+ let sapRequire: ReturnType<typeof vi.fn>;
134
+ let ComponentContainer: ReturnType<typeof vi.fn>;
135
+
136
+ beforeEach(() => {
137
+ placeAt = vi.fn();
138
+ destroy = vi.fn();
139
+ ComponentContainer = vi.fn(function (this: Record<string, unknown>) {
140
+ this['placeAt'] = placeAt;
141
+ this['destroy'] = destroy;
142
+ });
143
+ sapRequire = vi
144
+ .fn()
145
+ .mockImplementation((_deps: unknown, cb: (ctor: unknown) => void) => {
146
+ cb(ComponentContainer);
147
+ });
148
+
149
+ (window as unknown as Record<string, unknown>)['sap'] = {
150
+ ui: { require: sapRequire },
151
+ };
152
+ });
153
+
154
+ afterEach(() => {
155
+ delete (window as unknown as Record<string, unknown>)['sap'];
156
+ });
157
+
158
+ it('calls sap.ui.require with ComponentContainer dependency', () => {
159
+ const { fixture } = setup();
160
+
161
+ fixture.componentRef.setInput('card', {
162
+ id: 'card-1',
163
+ component: 'my.sap.App',
164
+ type: 'sap-ui',
165
+ });
166
+ fixture.detectChanges();
167
+
168
+ expect(sapRequire).toHaveBeenCalledWith(
169
+ ['sap/ui/core/ComponentContainer'],
170
+ expect.any(Function),
171
+ );
172
+ });
173
+
174
+ it('mounts the SAP component with correct config', () => {
175
+ const { fixture } = setup();
176
+
177
+ fixture.componentRef.setInput('card', {
178
+ id: 'card-1',
179
+ component: 'my.sap.App',
180
+ type: 'sap-ui',
181
+ componentInputs: { env: 'prod' },
182
+ });
183
+ fixture.detectChanges();
184
+
185
+ expect(placeAt).toHaveBeenCalledTimes(1);
186
+ expect(placeAt.mock.calls[0][0]).toBeInstanceOf(HTMLElement);
187
+ });
188
+
189
+ it('destroys the SAP container when the card definition changes', () => {
190
+ const { fixture } = setup();
191
+
192
+ fixture.componentRef.setInput('card', {
193
+ id: 'card-1',
194
+ component: 'my.sap.App',
195
+ type: 'sap-ui',
196
+ });
197
+ fixture.detectChanges();
198
+
199
+ fixture.componentRef.setInput('card', {
200
+ id: 'card-1',
201
+ component: 'my.sap.Other',
202
+ type: 'sap-ui',
203
+ });
204
+ fixture.detectChanges();
205
+
206
+ expect(destroy).toHaveBeenCalledTimes(1);
207
+ });
208
+
209
+ it('does not mount a SAP container after the card is cleaned up', () => {
210
+ const requireResolver: { current?: (ctor: unknown) => void } = {};
211
+ sapRequire.mockImplementationOnce(
212
+ (_deps: unknown, cb: (ctor: unknown) => void) => {
213
+ requireResolver.current = cb;
214
+ },
215
+ );
216
+ const { fixture } = setup();
217
+
218
+ fixture.componentRef.setInput('card', {
219
+ id: 'card-1',
220
+ component: 'my.sap.App',
221
+ type: 'sap-ui',
222
+ });
223
+ fixture.detectChanges();
224
+
225
+ fixture.componentRef.setInput('card', {
226
+ id: 'card-1',
227
+ component: 'demo-widget',
228
+ });
229
+ fixture.detectChanges();
230
+
231
+ requireResolver.current?.(ComponentContainer);
232
+
233
+ expect(ComponentContainer).not.toHaveBeenCalled();
234
+ expect(placeAt).not.toHaveBeenCalled();
235
+ });
236
+
237
+ it('logs an error when window.sap is not available', () => {
238
+ delete (window as unknown as Record<string, unknown>)['sap'];
239
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(vi.fn());
240
+ const { fixture } = setup();
241
+
242
+ fixture.componentRef.setInput('card', {
243
+ id: 'card-1',
244
+ component: 'my.sap.App',
245
+ type: 'sap-ui',
246
+ });
247
+ fixture.detectChanges();
248
+
249
+ expect(consoleSpy).toHaveBeenCalledWith(
250
+ '[DashboardCard] SAP UI5 is not available on window.sap',
251
+ );
252
+ consoleSpy.mockRestore();
253
+ });
254
+ });
255
+ });
@@ -0,0 +1,75 @@
1
+ import { CARD_TYPES, CardConfig } from '../models';
2
+ import {
3
+ Component,
4
+ Renderer2,
5
+ ViewContainerRef,
6
+ ViewEncapsulation,
7
+ computed,
8
+ effect,
9
+ inject,
10
+ input,
11
+ output,
12
+ viewChild,
13
+ } from '@angular/core';
14
+ import { Button } from '@fundamental-ngx/ui5-webcomponents/button';
15
+ import { mountSapCard, mountAngularCard, mountWcCard } from './utils';
16
+
17
+ @Component({
18
+ selector: 'mfp-dashboard-card',
19
+ imports: [Button],
20
+ templateUrl: './dashboard-card.component.html',
21
+ styleUrl: './dashboard-card.component.scss',
22
+ encapsulation: ViewEncapsulation.Emulated,
23
+ host: {
24
+ '[style.grid-column]': 'gridColumn()',
25
+ '[style.grid-row]': 'gridRow()',
26
+ },
27
+ })
28
+ export class DashboardCard {
29
+ card = input.required<CardConfig>();
30
+ editMode = input<boolean>(false);
31
+ readonly removeCard = output<void>();
32
+ protected readonly gridColumn = computed(() => {
33
+ const width = this.card().w ?? 12;
34
+ return this.createGridTrack(this.card().x, width);
35
+ });
36
+ protected readonly gridRow = computed(() => {
37
+ const height = this.card().h ?? 100;
38
+ return this.createGridTrack(this.card().y, height);
39
+ });
40
+
41
+ private host = viewChild('elementHost', { read: ViewContainerRef });
42
+ private renderer = inject(Renderer2);
43
+
44
+ constructor() {
45
+ effect((onCleanup) => {
46
+ const host = this.host();
47
+ const cfg = this.card();
48
+ if (!host || !cfg.component) return;
49
+
50
+ host.clear();
51
+ host.element.nativeElement.innerHTML = '';
52
+
53
+ switch (cfg.type) {
54
+ case CARD_TYPES.SAP_UI:
55
+ mountSapCard(cfg, host, onCleanup);
56
+ break;
57
+ case CARD_TYPES.ANGULAR:
58
+ mountAngularCard(cfg, host, onCleanup);
59
+ break;
60
+ case CARD_TYPES.WC:
61
+ default:
62
+ mountWcCard(cfg, host, onCleanup, this.renderer);
63
+ break;
64
+ }
65
+ });
66
+ }
67
+
68
+ private createGridTrack(start: number | undefined, span: number): string {
69
+ if (start === undefined) {
70
+ return `span ${span}`;
71
+ }
72
+
73
+ return `${start + 1} / span ${span}`;
74
+ }
75
+ }
@@ -0,0 +1,76 @@
1
+ import {
2
+ addComponentToRegistry,
3
+ resetDashboardCardRegistry,
4
+ } from './dashboard-card-registry';
5
+ import { Component } from '@angular/core';
6
+
7
+ @Component({
8
+ selector: 'dashboard-test-card',
9
+ standalone: true,
10
+ template: 'dashboard test card',
11
+ })
12
+ class DashboardTestCard {}
13
+
14
+ @Component({
15
+ selector: '[dashboard-test-card]',
16
+ standalone: true,
17
+ template: 'dashboard attr card',
18
+ })
19
+ class DashboardAttrCard {}
20
+
21
+ @Component({
22
+ selector: 'dashboard-test-card',
23
+ standalone: true,
24
+ host: {
25
+ 'data-test-duplicate': 'true',
26
+ },
27
+ template: 'dashboard duplicate card',
28
+ })
29
+ class DashboardDuplicateCard {}
30
+
31
+ @Component({
32
+ selector: 'dashboard-non-standalone-card',
33
+ standalone: false,
34
+ template: 'dashboard non-standalone card',
35
+ })
36
+ class DashboardNonStandaloneCard {}
37
+
38
+ describe('dashboard card registry', () => {
39
+ beforeEach(() => {
40
+ resetDashboardCardRegistry();
41
+ });
42
+
43
+ it('registers standalone Angular components by selector', () => {
44
+ expect(() => addComponentToRegistry([DashboardTestCard])).not.toThrow();
45
+ });
46
+
47
+ it('rejects non-component registrations', () => {
48
+ class NotAComponent {}
49
+
50
+ expect(() => addComponentToRegistry([NotAComponent])).toThrowError(
51
+ 'Dashboard card registration failed: "NotAComponent" is not an Angular component.',
52
+ );
53
+ });
54
+
55
+ it('rejects selectors that are not a single element selector', () => {
56
+ expect(() => addComponentToRegistry([DashboardAttrCard])).toThrowError(
57
+ /must use a single element selector\. Received "\[dashboard-test-card\]"./,
58
+ );
59
+ });
60
+
61
+ it('rejects non-standalone Angular components', () => {
62
+ expect(() =>
63
+ addComponentToRegistry([DashboardNonStandaloneCard]),
64
+ ).toThrowError(
65
+ 'Dashboard card registration failed: "dashboard-non-standalone-card" must be a standalone Angular component.',
66
+ );
67
+ });
68
+
69
+ it('rejects duplicate selector registrations for different component types', () => {
70
+ addComponentToRegistry([DashboardTestCard]);
71
+
72
+ expect(() => addComponentToRegistry([DashboardDuplicateCard])).toThrowError(
73
+ 'Dashboard card registration failed: selector "dashboard-test-card" is already registered.',
74
+ );
75
+ });
76
+ });
@@ -0,0 +1,109 @@
1
+ import {
2
+ Type,
3
+ isDevMode,
4
+ isStandalone,
5
+ reflectComponentType,
6
+ } from '@angular/core';
7
+
8
+
9
+ export interface RegisteredDashboardCardComponent {
10
+ componentType: Type<unknown>;
11
+ selector: string;
12
+ inputs: ReadonlyMap<string, string>;
13
+ }
14
+
15
+ const ELEMENT_SELECTOR_PATTERN = /^[a-z](?:[a-z0-9-]*)$/;
16
+
17
+ const dashboardCardRegistry = new Map<
18
+ string,
19
+ RegisteredDashboardCardComponent
20
+ >();
21
+
22
+ export function addComponentToRegistry(componentTypes: Type<unknown>[]): void {
23
+ for (const componentType of componentTypes) {
24
+ const mirror = reflectComponentType(componentType);
25
+
26
+ if (!mirror) {
27
+ throw new Error(
28
+ `Dashboard card registration failed: "${getTypeName(componentType)}" is not an Angular component.`,
29
+ );
30
+ }
31
+
32
+ if (!isStandalone(componentType)) {
33
+ throw new Error(
34
+ `Dashboard card registration failed: "${mirror.selector}" must be a standalone Angular component.`,
35
+ );
36
+ }
37
+
38
+ const selector = toElementSelector(mirror.selector, componentType);
39
+ const existing = dashboardCardRegistry.get(selector);
40
+
41
+ if (existing && existing.componentType !== componentType) {
42
+ throw new Error(
43
+ `Dashboard card registration failed: selector "${selector}" is already registered.`,
44
+ );
45
+ }
46
+
47
+ dashboardCardRegistry.set(selector, {
48
+ componentType,
49
+ selector,
50
+ inputs: createInputMap(mirror.inputs),
51
+ });
52
+ }
53
+ }
54
+
55
+ export function getRegisteredDashboardCardComponent(
56
+ selector: string,
57
+ ): RegisteredDashboardCardComponent | undefined {
58
+ return dashboardCardRegistry.get(selector);
59
+ }
60
+
61
+ export function resetDashboardCardRegistry(): void {
62
+ dashboardCardRegistry.clear();
63
+ }
64
+
65
+ export function warnForUnknownDashboardCardInput(
66
+ selector: string,
67
+ inputName: string,
68
+ ): void {
69
+ if (!isDevMode()) return;
70
+
71
+ console.warn(
72
+ `Dashboard card "${selector}" ignores unknown Angular input "${inputName}".`,
73
+ );
74
+ }
75
+
76
+ function createInputMap(
77
+ inputs: readonly {
78
+ readonly propName: string;
79
+ readonly templateName: string;
80
+ }[],
81
+ ): ReadonlyMap<string, string> {
82
+ const bindings = new Map<string, string>();
83
+
84
+ for (const input of inputs) {
85
+ bindings.set(input.templateName, input.templateName);
86
+ bindings.set(input.propName, input.templateName);
87
+ }
88
+
89
+ return bindings;
90
+ }
91
+
92
+ function toElementSelector(
93
+ selector: string,
94
+ componentType: Type<unknown>,
95
+ ): string {
96
+ const normalized = selector.trim();
97
+
98
+ if (!ELEMENT_SELECTOR_PATTERN.test(normalized)) {
99
+ throw new Error(
100
+ `Dashboard card registration failed: "${getTypeName(componentType)}" must use a single element selector. Received "${selector}".`,
101
+ );
102
+ }
103
+
104
+ return normalized;
105
+ }
106
+
107
+ function getTypeName(componentType: Type<unknown>): string {
108
+ return componentType.name.replace(/^_+/, '') || 'AnonymousComponent';
109
+ }
@@ -0,0 +1,4 @@
1
+ export * from './dashboard-card-registry';
2
+ export * from './mount-angular-card';
3
+ export * from './mount-sap-card';
4
+ export * from './mount-wc-card';
@@ -0,0 +1,141 @@
1
+ import {
2
+ ChangeDetectorRef,
3
+ Component,
4
+ EffectCleanupRegisterFn,
5
+ ViewContainerRef,
6
+ input,
7
+ } from '@angular/core';
8
+ import { mountAngularCard } from './mount-angular-card';
9
+ import {
10
+ addComponentToRegistry,
11
+ resetDashboardCardRegistry,
12
+ } from './dashboard-card-registry';
13
+ import { CardConfig } from '../../models';
14
+
15
+ @Component({
16
+ selector: 'mfp-test-angular-card',
17
+ standalone: true,
18
+ template: '<span>angular card</span>',
19
+ })
20
+ class TestAngularCard {
21
+ title = input<string>();
22
+ count = input<number>();
23
+ }
24
+
25
+ function makeCleanup(): {
26
+ onCleanup: EffectCleanupRegisterFn;
27
+ runCleanup: () => void;
28
+ } {
29
+ let registered: (() => void) | undefined;
30
+ return {
31
+ onCleanup: (fn) => {
32
+ registered = fn;
33
+ },
34
+ runCleanup: () => registered?.(),
35
+ };
36
+ }
37
+
38
+ function makeMockVcr(): {
39
+ vcr: ViewContainerRef;
40
+ setInput: ReturnType<typeof vi.fn>;
41
+ clear: ReturnType<typeof vi.fn>;
42
+ } {
43
+ const setInput = vi.fn();
44
+ const detectChanges = vi.fn();
45
+ const componentRef = {
46
+ setInput,
47
+ changeDetectorRef: { detectChanges } as unknown as ChangeDetectorRef,
48
+ };
49
+ const clear = vi.fn();
50
+ const vcr = {
51
+ createComponent: vi.fn(() => componentRef),
52
+ clear,
53
+ } as unknown as ViewContainerRef;
54
+ return { vcr, setInput, clear };
55
+ }
56
+
57
+ function makeCfg(overrides: Partial<CardConfig> = {}): CardConfig {
58
+ return {
59
+ id: 'card-1',
60
+ component: 'mfp-test-angular-card',
61
+ type: 'angular',
62
+ ...overrides,
63
+ };
64
+ }
65
+
66
+ describe('mountAngularCard', () => {
67
+ beforeEach(() => {
68
+ resetDashboardCardRegistry();
69
+ addComponentToRegistry([TestAngularCard]);
70
+ });
71
+
72
+ afterEach(() => {
73
+ vi.restoreAllMocks();
74
+ resetDashboardCardRegistry();
75
+ });
76
+
77
+ it('creates the registered Angular component in the host', () => {
78
+ const { vcr } = makeMockVcr();
79
+ const { onCleanup } = makeCleanup();
80
+ mountAngularCard(makeCfg(), vcr, onCleanup);
81
+
82
+ expect(
83
+ (vcr.createComponent as ReturnType<typeof vi.fn>),
84
+ ).toHaveBeenCalledWith(TestAngularCard);
85
+ });
86
+
87
+ it('calls setInput for each provided componentInput', () => {
88
+ const { vcr, setInput } = makeMockVcr();
89
+ const { onCleanup } = makeCleanup();
90
+ mountAngularCard(
91
+ makeCfg({ componentInputs: { title: 'Pods', count: 3 } }),
92
+ vcr,
93
+ onCleanup,
94
+ );
95
+
96
+ expect(setInput).toHaveBeenCalledWith('title', 'Pods');
97
+ expect(setInput).toHaveBeenCalledWith('count', 3);
98
+ });
99
+
100
+ it('warns and skips unknown inputs in dev mode', () => {
101
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(vi.fn());
102
+ const { vcr, setInput } = makeMockVcr();
103
+ const { onCleanup } = makeCleanup();
104
+
105
+ mountAngularCard(
106
+ makeCfg({ componentInputs: { unknownProp: 'x' } }),
107
+ vcr,
108
+ onCleanup,
109
+ );
110
+
111
+ expect(warnSpy).toHaveBeenCalledWith(
112
+ expect.stringContaining('unknownProp'),
113
+ );
114
+ expect(setInput).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it('clears the host on cleanup', () => {
118
+ const { vcr, clear } = makeMockVcr();
119
+ const { onCleanup, runCleanup } = makeCleanup();
120
+ mountAngularCard(makeCfg(), vcr, onCleanup);
121
+
122
+ runCleanup();
123
+
124
+ expect(clear).toHaveBeenCalledTimes(1);
125
+ });
126
+
127
+ it('warns and returns without creating a component when selector is not registered', () => {
128
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(vi.fn());
129
+ const { vcr } = makeMockVcr();
130
+ const { onCleanup } = makeCleanup();
131
+
132
+ mountAngularCard(makeCfg({ component: 'unknown-card' }), vcr, onCleanup);
133
+
134
+ expect(
135
+ (vcr.createComponent as ReturnType<typeof vi.fn>),
136
+ ).not.toHaveBeenCalled();
137
+ expect(warnSpy).toHaveBeenCalledWith(
138
+ expect.stringContaining('"unknown-card" is not registered'),
139
+ );
140
+ });
141
+ });
@@ -0,0 +1,44 @@
1
+ import { CardConfig } from '../../models';
2
+ import {
3
+ getRegisteredDashboardCardComponent,
4
+ warnForUnknownDashboardCardInput,
5
+ } from './dashboard-card-registry';
6
+ import { EffectCleanupRegisterFn, ViewContainerRef } from '@angular/core';
7
+
8
+ export function mountAngularCard(
9
+ cfg: CardConfig,
10
+ angularHost: ViewContainerRef,
11
+ onCleanup: EffectCleanupRegisterFn,
12
+ ): void {
13
+ const registeredComponent = getRegisteredDashboardCardComponent(
14
+ cfg.component,
15
+ );
16
+
17
+ if (!registeredComponent) {
18
+ console.warn(
19
+ `[DashboardCard] Angular component "${cfg.component}" is not registered`,
20
+ );
21
+ return;
22
+ }
23
+
24
+ const componentRef = angularHost.createComponent(
25
+ registeredComponent.componentType,
26
+ );
27
+
28
+ for (const [inputName, value] of Object.entries(cfg.componentInputs ?? {})) {
29
+ const templateName = registeredComponent.inputs.get(inputName);
30
+
31
+ if (!templateName) {
32
+ warnForUnknownDashboardCardInput(cfg.component, inputName);
33
+ continue;
34
+ }
35
+
36
+ componentRef.setInput(templateName, value);
37
+ }
38
+
39
+ componentRef.changeDetectorRef.detectChanges();
40
+
41
+ onCleanup(() => {
42
+ angularHost.clear();
43
+ });
44
+ }