@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,354 @@
1
+ import { resetDashboardCardRegistry } from '../card/utils/dashboard-card-registry';
2
+ import { CardConfig, SectionConfig } from '../models';
3
+ import { Dashboard } from './dashboard.component';
4
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
5
+
6
+ vi.mock('gridstack', () => ({}));
7
+
8
+ vi.mock('gridstack/dist/angular', async () => {
9
+ const { Component, EventEmitter, Input, Output } =
10
+ await import('@angular/core');
11
+
12
+ @Component({
13
+ selector: 'mfp-gridstack',
14
+ standalone: true,
15
+ template: '<ng-content />',
16
+ })
17
+ class GridstackComponent {
18
+ @Input() options?: unknown;
19
+ @Output() readonly changeCB = new EventEmitter<unknown>();
20
+
21
+ gridstackItems = {
22
+ toArray: () => [],
23
+ };
24
+ }
25
+
26
+ @Component({
27
+ selector: 'mfp-gridstack-item',
28
+ standalone: true,
29
+ template: '<ng-content />',
30
+ })
31
+ class GridstackItemComponent {
32
+ @Input() options?: unknown;
33
+ }
34
+
35
+ return {
36
+ GridstackComponent,
37
+ GridstackItemComponent,
38
+ };
39
+ });
40
+
41
+ type Fixture = ComponentFixture<Dashboard>;
42
+
43
+ function setup(): { fixture: Fixture; component: Dashboard } {
44
+ const fixture = TestBed.createComponent(Dashboard);
45
+ const component = fixture.componentInstance;
46
+ return { fixture, component };
47
+ }
48
+
49
+ function root(fixture: Fixture): ShadowRoot | HTMLElement {
50
+ return fixture.nativeElement.shadowRoot ?? fixture.nativeElement;
51
+ }
52
+
53
+ describe('Dashboard', () => {
54
+ beforeEach(async () => {
55
+ resetDashboardCardRegistry();
56
+ await TestBed.configureTestingModule({
57
+ imports: [Dashboard],
58
+ }).compileComponents();
59
+ });
60
+
61
+ it('creates and applies the configured background image', () => {
62
+ const { fixture, component } = setup();
63
+
64
+ fixture.componentRef.setInput('config', {
65
+ title: 'Operations',
66
+ backgroundImageUrl: 'https://example.com/bg.png',
67
+ });
68
+ fixture.detectChanges();
69
+
70
+ expect(component).toBeTruthy();
71
+ expect(fixture.nativeElement.style.backgroundImage).toContain(
72
+ 'https://example.com/bg.png',
73
+ );
74
+ });
75
+
76
+ it('renders dashboard metadata, sections and loose cards from the provided inputs', () => {
77
+ const { fixture, component } = setup();
78
+
79
+ fixture.componentRef.setInput('config', {
80
+ title: 'Operations',
81
+ description: 'Platform status',
82
+ });
83
+ component.sections.set([{ id: 'alpha', title: 'Alpha' }]);
84
+ component.cards.set([
85
+ { id: 'card-1', component: 'mfp-a', sectionId: 'alpha' },
86
+ { id: 'card-2', component: 'mfp-b' },
87
+ ]);
88
+ fixture.detectChanges();
89
+
90
+ expect(root(fixture).textContent).toContain('Operations');
91
+ expect(root(fixture).textContent).toContain('Platform status');
92
+ expect(
93
+ root(fixture).querySelectorAll('mfp-dashboard-section'),
94
+ ).toHaveLength(1);
95
+ expect(root(fixture).querySelectorAll('gridstack-item')).toHaveLength(1);
96
+ expect(
97
+ root(fixture).querySelector('.mfp-dashboard__toolbar ui5-button'),
98
+ ).toBeNull();
99
+ });
100
+
101
+ it('switches the template into edit mode and opens the add-card dialog from the toolbar', () => {
102
+ const { fixture, component } = setup();
103
+
104
+ fixture.componentRef.setInput('config', {
105
+ title: 'Operations',
106
+ editable: true,
107
+ });
108
+ component.cards.set([{ id: 'card-1', component: 'mfp-a' }]);
109
+ fixture.detectChanges();
110
+
111
+ const editButton = root(fixture).querySelector(
112
+ '.mfp-dashboard__toolbar ui5-button',
113
+ );
114
+
115
+ editButton?.dispatchEvent(new Event('click'));
116
+ fixture.detectChanges();
117
+
118
+ const addCardButton = root(fixture).querySelector('#add-card-btn');
119
+
120
+ expect(component.editMode()).toBe(true);
121
+ expect(addCardButton).not.toBeNull();
122
+ expect(
123
+ root(fixture).querySelectorAll('.mfp-dashboard__edit-bar ui5-button'),
124
+ ).toHaveLength(2);
125
+
126
+ addCardButton?.dispatchEvent(new Event('click'));
127
+ fixture.detectChanges();
128
+
129
+ expect(component.cardDialogOpen()).toBe(true);
130
+ });
131
+
132
+ it('computes added components, section cards, loose cards and drag options from state', () => {
133
+ const { component } = setup();
134
+ const cards: CardConfig[] = [
135
+ { id: 'card-1', component: 'mfp-a', sectionId: 'alpha' },
136
+ { id: 'card-2', component: 'mfp-b' },
137
+ { id: 'card-3', component: 'mfp-c' },
138
+ ];
139
+ const gridOptions = (
140
+ component as unknown as { gridOptions: () => { disableDrag: boolean } }
141
+ ).gridOptions;
142
+
143
+ component.cards.set(cards);
144
+
145
+ expect(component.addedCardsIds()).toEqual(
146
+ new Set(['card-1', 'card-2', 'card-3']),
147
+ );
148
+ expect(component.sectionCards()('alpha')).toEqual([cards[0]]);
149
+ expect(component.looseCards()).toEqual([cards[1], cards[2]]);
150
+ expect(gridOptions().disableDrag).toBe(true);
151
+
152
+ component.editMode.set(true);
153
+
154
+ expect(gridOptions().disableDrag).toBe(false);
155
+ });
156
+
157
+ it('captures snapshots and grid positions when entering edit mode', () => {
158
+ const { component } = setup();
159
+ const sections: SectionConfig[] = [{ id: 'alpha', title: 'Alpha' }];
160
+ const cards: CardConfig[] = [
161
+ { id: 'card-1', component: 'mfp-a', x: 0, y: 0 },
162
+ { id: 'card-2', component: 'mfp-b', sectionId: 'alpha', x: 1, y: 1 },
163
+ ];
164
+
165
+ component.sections.set(sections);
166
+ component.cards.set(cards);
167
+ (component as unknown as { gridStackItems: () => unknown }).gridStackItems =
168
+ () => ({
169
+ gridstackItems: {
170
+ toArray: () => [
171
+ { options: { id: 'card-1', x: 4, y: 2 } },
172
+ { options: { id: 'card-2', x: 1, y: 3 } },
173
+ ],
174
+ },
175
+ });
176
+
177
+ component.enterEditMode();
178
+
179
+ expect(component.editMode()).toBe(true);
180
+ expect(
181
+ (component as unknown as { sectionsSnapshot: SectionConfig[] })
182
+ .sectionsSnapshot,
183
+ ).toEqual(sections);
184
+ expect(
185
+ (component as unknown as { cardsSnapshot: CardConfig[] }).cardsSnapshot,
186
+ ).toEqual(cards);
187
+ expect(component.cardsPosition.get('card-1')).toEqual({ x: 4, y: 2 });
188
+ expect(component.cardsPosition.get('card-2')).toEqual({ x: 1, y: 3 });
189
+ });
190
+
191
+ it('emits the saved payload and persists the latest order on save', () => {
192
+ const { component } = setup();
193
+ const sections: SectionConfig[] = [{ id: 'alpha', title: 'Alpha' }];
194
+ const cards: CardConfig[] = [{ id: 'card-1', component: 'mfp-a' }];
195
+ const emitted: { sections: SectionConfig[]; cards: CardConfig[] }[] = [];
196
+
197
+ component.sections.set(sections);
198
+ component.cards.set(cards);
199
+ component.editMode.set(true);
200
+ component.saved.subscribe((value) => emitted.push(value));
201
+ component.onOrderChange({
202
+ nodes: [{ id: 'card-1', x: 7, y: 5 }],
203
+ } as never);
204
+
205
+ component.saveEdit();
206
+
207
+ expect(emitted).toEqual([
208
+ {
209
+ sections,
210
+ cards: [{ id: 'card-1', component: 'mfp-a', x: 7, y: 5 }],
211
+ },
212
+ ]);
213
+ expect(component.cardsPosition.get('card-1')).toEqual({ x: 7, y: 5 });
214
+ expect(component.editMode()).toBe(false);
215
+ });
216
+
217
+ it('restores snapshot data and saved positions when edit mode is cancelled', () => {
218
+ const { component } = setup();
219
+ const sections: SectionConfig[] = [{ id: 'alpha', title: 'Alpha' }];
220
+ const cards: CardConfig[] = [
221
+ { id: 'card-1', component: 'mfp-a', x: 0, y: 0 },
222
+ { id: 'card-2', component: 'mfp-b', sectionId: 'alpha', x: 1, y: 1 },
223
+ ];
224
+
225
+ component.sections.set(sections);
226
+ component.cards.set(cards);
227
+ (component as unknown as { gridStackItems: () => unknown }).gridStackItems =
228
+ () => ({
229
+ gridstackItems: {
230
+ toArray: () => [
231
+ { options: { id: 'card-1', x: 3, y: 6 } },
232
+ { options: { id: 'card-2', x: 8, y: 1 } },
233
+ ],
234
+ },
235
+ });
236
+ component.enterEditMode();
237
+ component.sections.set([{ id: 'beta', title: 'Beta' }]);
238
+ component.cards.set([{ id: 'temp', component: 'mfp-temp', x: 9, y: 9 }]);
239
+ component.cardDialogOpen.set(true);
240
+
241
+ component.cancelEdit();
242
+
243
+ expect(component.sections()).toEqual(sections);
244
+ expect(component.cards()).toEqual([
245
+ { id: 'card-1', component: 'mfp-a', x: 3, y: 6 },
246
+ { id: 'card-2', component: 'mfp-b', sectionId: 'alpha', x: 8, y: 1 },
247
+ ]);
248
+ expect(component.cardDialogOpen()).toBe(false);
249
+ expect(component.editMode()).toBe(false);
250
+ });
251
+
252
+ it('removes sections together with their cards and removes loose cards by id', () => {
253
+ const { component } = setup();
254
+
255
+ component.sections.set([
256
+ { id: 'alpha', title: 'Alpha' },
257
+ { id: 'beta', title: 'Beta' },
258
+ ]);
259
+ component.cards.set([
260
+ { id: 'card-1', component: 'mfp-a', sectionId: 'alpha' },
261
+ { id: 'card-2', component: 'mfp-b', sectionId: 'beta' },
262
+ { id: 'card-3', component: 'mfp-c' },
263
+ ]);
264
+
265
+ component.removeSection('alpha');
266
+ component.removeCard('card-3');
267
+
268
+ expect(component.sections()).toEqual([{ id: 'beta', title: 'Beta' }]);
269
+ expect(component.cards()).toEqual([
270
+ { id: 'card-2', component: 'mfp-b', sectionId: 'beta' },
271
+ ]);
272
+ });
273
+
274
+ it('opens and closes the add-card dialog', () => {
275
+ const { component } = setup();
276
+
277
+ component.openCardPanel();
278
+ expect(component.cardDialogOpen()).toBe(true);
279
+
280
+ component.closeCardPanel();
281
+ expect(component.cardDialogOpen()).toBe(false);
282
+ });
283
+
284
+ it('adds new cards and closes the panel', () => {
285
+ const { component } = setup();
286
+
287
+ component.cards.set([{ id: 'card-1', component: 'mfp-a' }]);
288
+ component.cardDialogOpen.set(true);
289
+
290
+ component.onCardsAdded([
291
+ {
292
+ id: 'template-card',
293
+ component: 'mfp-b',
294
+ label: 'Table',
295
+ componentInputs: { size: 'L' },
296
+ },
297
+ ]);
298
+
299
+ expect(component.cards()).toEqual([
300
+ { id: 'card-1', component: 'mfp-a' },
301
+ {
302
+ id: 'template-card',
303
+ component: 'mfp-b',
304
+ label: 'Table',
305
+ componentInputs: { size: 'L' },
306
+ },
307
+ ]);
308
+ expect(component.cardDialogOpen()).toBe(false);
309
+ });
310
+
311
+ it('preserves card constraint fields (maxH/maxW/minH/minW) through saveEdit', () => {
312
+ const { component } = setup();
313
+ const cards: CardConfig[] = [
314
+ {
315
+ id: 'card-1',
316
+ component: 'mfp-a',
317
+ x: 0,
318
+ y: 0,
319
+ maxH: 4,
320
+ maxW: 6,
321
+ minH: 1,
322
+ minW: 2,
323
+ },
324
+ ];
325
+
326
+ component.cards.set(cards);
327
+ component.editMode.set(true);
328
+ component.saved.subscribe(() => false);
329
+ component.onOrderChange({
330
+ nodes: [{ id: 'card-1', x: 1, y: 2 }],
331
+ } as never);
332
+
333
+ component.saveEdit();
334
+
335
+ expect(component.cards()[0]).toMatchObject({
336
+ maxH: 4,
337
+ maxW: 6,
338
+ minH: 1,
339
+ minW: 2,
340
+ });
341
+ });
342
+
343
+ it('still closes the add-card dialog when no cards were selected', () => {
344
+ const { component } = setup();
345
+
346
+ component.cardDialogOpen.set(true);
347
+ component.cards.set([{ id: 'card-1', component: 'mfp-a' }]);
348
+
349
+ component.onCardsAdded([]);
350
+
351
+ expect(component.cards()).toEqual([{ id: 'card-1', component: 'mfp-a' }]);
352
+ expect(component.cardDialogOpen()).toBe(false);
353
+ });
354
+ });
@@ -0,0 +1,238 @@
1
+ import { ButtonSettings } from '../../models/ui-definition';
2
+ import { AddCardDialog } from '../add-card-dialog/add-card-dialog.component';
3
+ import { addComponentToRegistry } from '../card/utils/dashboard-card-registry';
4
+ import { DashboardCard } from '../card/dashboard-card.component';
5
+ import { CardConfig, DashboardConfig, SectionConfig } from '../models';
6
+ import { CELL_HEIGHT, COMPACT_BREAKPOINT } from '../models/constants';
7
+ import { DashboardSection } from '../section/dashboard-section.component';
8
+ import {
9
+ Component,
10
+ ElementRef,
11
+ OnDestroy,
12
+ OnInit,
13
+ Type,
14
+ ViewEncapsulation,
15
+ computed,
16
+ inject,
17
+ input,
18
+ linkedSignal,
19
+ model,
20
+ output,
21
+ signal,
22
+ viewChild,
23
+ } from '@angular/core';
24
+ import { Button } from '@fundamental-ngx/ui5-webcomponents/button';
25
+ import { Menu } from '@fundamental-ngx/ui5-webcomponents/menu';
26
+ import { MenuItem } from '@fundamental-ngx/ui5-webcomponents/menu-item';
27
+ import { MenuSeparator } from '@fundamental-ngx/ui5-webcomponents/menu-separator';
28
+ import { Text } from '@fundamental-ngx/ui5-webcomponents/text';
29
+ import { Title } from '@fundamental-ngx/ui5-webcomponents/title';
30
+ import '@ui5/webcomponents-icons/dist/action-settings.js';
31
+ import '@ui5/webcomponents-icons/dist/menu2.js';
32
+ import { GridStackNode, GridStackOptions } from 'gridstack';
33
+ import {
34
+ GridstackComponent,
35
+ GridstackItemComponent,
36
+ nodesCB,
37
+ } from 'gridstack/dist/angular';
38
+
39
+ document.body.classList.add('ui5-content-density-compact');
40
+
41
+ @Component({
42
+ selector: 'mfp-dashboard',
43
+ imports: [
44
+ GridstackComponent,
45
+ GridstackItemComponent,
46
+ AddCardDialog,
47
+ DashboardSection,
48
+ DashboardCard,
49
+ Button,
50
+ Menu,
51
+ MenuItem,
52
+ MenuSeparator,
53
+ Title,
54
+ Text,
55
+ ],
56
+ templateUrl: './dashboard.component.html',
57
+ styleUrl: './dashboard.component.scss',
58
+ encapsulation: ViewEncapsulation.None,
59
+ host: {
60
+ '[style.background-image]':
61
+ 'config().backgroundImageUrl ? "url(" + config().backgroundImageUrl + ")" : null',
62
+ },
63
+ })
64
+ export class Dashboard implements OnInit, OnDestroy {
65
+ static registerAngularComponents(componentTypes: Type<unknown>[]): void {
66
+ addComponentToRegistry(componentTypes);
67
+ }
68
+
69
+ config = input.required<DashboardConfig>();
70
+ sections = model<SectionConfig[]>([]);
71
+ cards = model<CardConfig[]>([]);
72
+ availableCards = input<CardConfig[]>([]);
73
+
74
+ readonly saved = output<{ sections: SectionConfig[]; cards: CardConfig[] }>();
75
+ readonly actionButtonClick = output<{
76
+ event: MouseEvent;
77
+ action: ButtonSettings;
78
+ }>();
79
+
80
+ editMode = signal(false);
81
+ compactToolbar = signal(false);
82
+ toolbarMenuOpen = signal(false);
83
+
84
+ private sectionsSnapshot: SectionConfig[] = [];
85
+ private cardsSnapshot: CardConfig[] = [];
86
+ private gridStackItems = viewChild.required<GridstackComponent>('grid');
87
+ private resizeObserver?: ResizeObserver;
88
+ private readonly hostEl = inject(ElementRef<HTMLElement>);
89
+
90
+ protected gridOptions = computed(
91
+ (): GridStackOptions => ({
92
+ cellHeight: CELL_HEIGHT,
93
+ disableResize: !this.editMode(),
94
+ disableDrag: !this.editMode(),
95
+ columnOpts: {
96
+ breakpointForWindow: true,
97
+ breakpoints: [
98
+ { w: 1440, c: 12, layout: 'none' },
99
+ { w: 1024, c: 8, layout: 'compact' },
100
+ { w: 600, c: 1, layout: 'list' },
101
+ ],
102
+ },
103
+ }),
104
+ );
105
+
106
+ cardDialogOpen = signal(false);
107
+ customActions = computed(() => this.config().customActions ?? []);
108
+ addedCardsIds = computed(() => new Set(this.cards().map((c) => c.id)));
109
+
110
+ editViewButton = computed(() => ({
111
+ icon: 'action-settings',
112
+ design: 'Transparent' as const,
113
+ tooltip: 'Edit View',
114
+ text: '',
115
+ ...this.config().buttonsSettings?.editViewButton,
116
+ }));
117
+
118
+ addCardButton = computed(() => ({
119
+ icon: '',
120
+ design: 'Default' as const,
121
+ tooltip: '',
122
+ text: '+ Add Card',
123
+ ...this.config().buttonsSettings?.addCardButton,
124
+ }));
125
+
126
+ sectionCards = computed(() => {
127
+ const all = this.cards();
128
+ return (sectionId: string) => all.filter((c) => c.sectionId === sectionId);
129
+ });
130
+
131
+ cardsPosition = new Map<string, { x?: number; y?: number }>();
132
+ looseCards = linkedSignal(() => this.cards().filter((c) => !c.sectionId));
133
+
134
+ private newGridStackNodes: GridStackNode[] = [];
135
+
136
+ ngOnInit(): void {
137
+ this.resizeObserver = new ResizeObserver((entries) => {
138
+ const width = entries[0]?.contentRect.width ?? 0;
139
+ this.compactToolbar.set(width < COMPACT_BREAKPOINT);
140
+ });
141
+ this.resizeObserver.observe(this.hostEl.nativeElement);
142
+ }
143
+
144
+ ngOnDestroy(): void {
145
+ this.resizeObserver?.disconnect();
146
+ }
147
+
148
+ onMenuItemClick(actionId: string, event: Event): void {
149
+ if (actionId === 'edit-view') {
150
+ this.enterEditMode();
151
+ return;
152
+ }
153
+ const action = this.customActions().find((a) => a.action === actionId);
154
+ if (action) {
155
+ this.actionButtonClick.emit({ event: event as MouseEvent, action });
156
+ }
157
+ }
158
+
159
+ enterEditMode(): void {
160
+ const gridStackNodes = this.gridStackItems()
161
+ .gridstackItems?.toArray()
162
+ .map((node) => node.options);
163
+
164
+ if (gridStackNodes) {
165
+ this.saveCardsPosition(gridStackNodes);
166
+ }
167
+
168
+ this.sectionsSnapshot = [...this.sections()];
169
+ this.cardsSnapshot = [...this.cards()];
170
+ this.editMode.set(true);
171
+ }
172
+
173
+ saveEdit(): void {
174
+ this.saveCardsPosition(this.newGridStackNodes);
175
+ this.saved.emit({
176
+ sections: this.sections(),
177
+ cards: this.cards().map((c) => {
178
+ const pos = this.cardsPosition.get(c.id);
179
+ return { ...c, x: pos?.x, y: pos?.y };
180
+ }),
181
+ });
182
+ this.editMode.set(false);
183
+ }
184
+
185
+ cancelEdit(): void {
186
+ this.sections.set(this.sectionsSnapshot);
187
+ this.cards.set(
188
+ this.cardsSnapshot.map((c) => {
189
+ const pos = this.cardsPosition.get(c.id);
190
+ return { ...c, x: pos?.x, y: pos?.y };
191
+ }),
192
+ );
193
+ this.cardDialogOpen.set(false);
194
+ this.editMode.set(false);
195
+ }
196
+
197
+ removeSection(id: string): void {
198
+ this.sections.update((list) => list.filter((s) => s.id !== id));
199
+ this.cards.update((list) => list.filter((c) => c.sectionId !== id));
200
+ }
201
+
202
+ removeCard(id: string): void {
203
+ this.cardsPosition.delete(id);
204
+ this.cards.update((list) => list.filter((c) => c.id !== id));
205
+ }
206
+
207
+ openCardPanel(): void {
208
+ this.cardDialogOpen.set(true);
209
+ }
210
+
211
+ closeCardPanel(): void {
212
+ this.cardDialogOpen.set(false);
213
+ }
214
+
215
+ onCardsAdded(cards: CardConfig[]): void {
216
+ if (cards.length > 0) {
217
+ this.cards.update((list) => [
218
+ ...list,
219
+ ...cards.map((ac) => ({
220
+ ...ac,
221
+ })),
222
+ ]);
223
+ }
224
+ this.closeCardPanel();
225
+ }
226
+
227
+ onOrderChange(event: nodesCB): void {
228
+ this.newGridStackNodes = event.nodes;
229
+ }
230
+
231
+ private saveCardsPosition(items: GridStackNode[]): void {
232
+ items.forEach((node) => {
233
+ if (node.id) {
234
+ this.cardsPosition.set(node.id, { x: node.x, y: node.y });
235
+ }
236
+ });
237
+ }
238
+ }
@@ -0,0 +1 @@
1
+ export * from './dashboard.component';
@@ -0,0 +1,5 @@
1
+ export * from './dashboard';
2
+ export * from './add-card-dialog/add-card-dialog.component';
3
+ export * from './card/dashboard-card.component';
4
+ export * from './section/dashboard-section.component';
5
+ export * from './models';
@@ -0,0 +1,2 @@
1
+ export const COMPACT_BREAKPOINT = 726;
2
+ export const CELL_HEIGHT = 10;
@@ -0,0 +1,50 @@
1
+ import { ButtonSettings } from '../../models/ui-definition';
2
+
3
+
4
+
5
+
6
+ export const CARD_TYPES = {
7
+ WC: 'wc',
8
+ ANGULAR: 'angular',
9
+ SAP_UI: 'sap-ui',
10
+ } as const;
11
+
12
+ export type CardsType = (typeof CARD_TYPES)[keyof typeof CARD_TYPES];
13
+
14
+ export interface CardConfig {
15
+ id: string;
16
+ w?: number;
17
+ h?: number;
18
+ x?: number;
19
+ y?: number;
20
+ maxH?: number;
21
+ maxW?: number;
22
+ minH?: number;
23
+ minW?: number;
24
+ sectionId?: string;
25
+ component: string;
26
+ type?: CardsType;
27
+ componentInputs?: Record<string, unknown>;
28
+ label?: string;
29
+ }
30
+
31
+ export interface SectionConfig {
32
+ id: string;
33
+ w?: number;
34
+ title?: string;
35
+ editable?: boolean;
36
+ }
37
+
38
+ export interface DashboardButtonsSettings {
39
+ editViewButton?: Partial<ButtonSettings>;
40
+ addCardButton?: Partial<ButtonSettings>;
41
+ }
42
+
43
+ export interface DashboardConfig {
44
+ title: string;
45
+ description?: string;
46
+ backgroundImageUrl?: string;
47
+ buttonsSettings?: DashboardButtonsSettings;
48
+ customActions?: ButtonSettings[];
49
+ editable?: boolean;
50
+ }
@@ -0,0 +1 @@
1
+ export * from './dashboard.model';
@@ -0,0 +1,28 @@
1
+ <div
2
+ class="section"
3
+ [class.section--edit]="editMode() && section().editable !== false"
4
+ >
5
+ @if (editMode() && section().editable !== false) {
6
+ <ui5-button
7
+ class="section__remove"
8
+ design="Default"
9
+ icon="decline"
10
+ (click)="removeSection.emit()"
11
+ >
12
+ </ui5-button>
13
+ }
14
+ @if (section().title) {
15
+ <div class="section__header">
16
+ <span class="section__title">{{ section().title }}</span>
17
+ </div>
18
+ }
19
+ <div class="section__grid" [style.--cols]="columns()">
20
+ @for (card of cards(); track card.id) {
21
+ <mfp-dashboard-card
22
+ [card]="card"
23
+ [editMode]="editMode() && section().editable !== false"
24
+ (removeCard)="removeCard.emit($any(card.id))"
25
+ />
26
+ }
27
+ </div>
28
+ </div>