@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,786 @@
1
+ import { FormFieldChangeEvent, FormFieldDefinition } from '../form/models';
2
+ import {
3
+ ButtonSettings,
4
+ GenericResource,
5
+ TableFieldDefinition,
6
+ ValueCellButtonClickEvent,
7
+ } from '../table/models';
8
+ import { DeclarativeTableCard } from './declarative-table-card.component';
9
+ import {
10
+ DeleteResourceConfirmationConfig,
11
+ ResourceFormConfig,
12
+ TableCardConfig,
13
+ TableCardFormState,
14
+ TableConfig,
15
+ } from './models/configs';
16
+ import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from '@angular/core';
17
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ type TableCardCreateConfig = ResourceFormConfig;
24
+ type TableCardReadConfig = TableConfig;
25
+ type TableCardEditConfig = ResourceFormConfig & {
26
+ editButtonSettings?: Partial<ButtonSettings>;
27
+ };
28
+ type TableCardDeleteConfig = DeleteResourceConfirmationConfig & {
29
+ deleteButtonSettings?: Partial<ButtonSettings>;
30
+ };
31
+
32
+ type Comp = DeclarativeTableCard<GenericResource>;
33
+ type Fixture = ComponentFixture<Comp>;
34
+
35
+ const COLUMNS: TableFieldDefinition[] = [
36
+ { label: 'Name', property: 'metadata.name' },
37
+ { label: 'Namespace', property: 'metadata.namespace' },
38
+ ];
39
+
40
+ const READ_CONFIG: TableCardReadConfig = {
41
+ fields: COLUMNS,
42
+ };
43
+
44
+ const RESOURCES: GenericResource[] = [
45
+ { id: '1', metadata: { name: 'pod-alpha', namespace: 'default' } },
46
+ { id: '2', metadata: { name: 'pod-beta', namespace: 'kube-system' } },
47
+ ];
48
+
49
+ const FORM_FIELDS: FormFieldDefinition[] = [
50
+ { name: 'metadata.name', label: 'Name', required: true },
51
+ { name: 'metadata.namespace', label: 'Namespace' },
52
+ ];
53
+
54
+ const CREATE_CONFIG: TableCardCreateConfig = {
55
+ fields: FORM_FIELDS,
56
+ title: 'Create Resource',
57
+ confirmLabel: 'Create',
58
+ cancelLabel: 'Cancel',
59
+ };
60
+
61
+ const EDIT_CONFIG: TableCardEditConfig = {
62
+ fields: [
63
+ { name: 'metadata.name', label: 'Name', required: true, disabled: true },
64
+ { name: 'metadata.namespace', label: 'Namespace' },
65
+ ],
66
+ title: 'Edit Resource',
67
+ confirmLabel: 'Save',
68
+ cancelLabel: 'Cancel',
69
+ };
70
+
71
+ const DELETE_CONFIG: TableCardDeleteConfig = {
72
+ title: 'Confirm Delete',
73
+ message: 'This action cannot be undone.',
74
+ confirmLabel: 'Delete',
75
+ cancelLabel: 'Cancel',
76
+ };
77
+
78
+ function makeEvent(
79
+ action: string,
80
+ resource?: GenericResource,
81
+ ): ValueCellButtonClickEvent<GenericResource> {
82
+ return {
83
+ event: new MouseEvent('click'),
84
+ field: {
85
+ label: '',
86
+ uiSettings: {
87
+ displayAs: 'button',
88
+ buttonSettings: { action, icon: action },
89
+ },
90
+ },
91
+ resource,
92
+ };
93
+ }
94
+
95
+ function setup(
96
+ opts: {
97
+ headerTooltip?: string;
98
+ readConfig?: TableCardReadConfig;
99
+ resources?: GenericResource[];
100
+ header?: string;
101
+ createConfig?: TableCardCreateConfig;
102
+ editConfig?: TableCardEditConfig;
103
+ deleteConfig?: TableCardDeleteConfig;
104
+ createFormState?: TableCardFormState;
105
+ editFormState?: TableCardFormState;
106
+ } = {},
107
+ ): { fixture: Fixture; component: Comp } {
108
+ const fixture: Fixture = TestBed.createComponent(
109
+ DeclarativeTableCard as unknown as typeof DeclarativeTableCard<GenericResource>,
110
+ );
111
+ const component = fixture.componentInstance as Comp;
112
+
113
+ const config: TableCardConfig = {
114
+ header: opts.header || '',
115
+ headerTooltip: opts.headerTooltip,
116
+ tableConfig: opts.readConfig ?? READ_CONFIG,
117
+ createResourceFormConfig: opts.createConfig,
118
+ editResourceFormConfig: opts.editConfig,
119
+ deleteResourceConfirmationConfig: opts.deleteConfig,
120
+ buttonSettings: {
121
+ editButton: opts.editConfig?.editButtonSettings,
122
+ deleteButton: opts.deleteConfig?.deleteButtonSettings,
123
+ },
124
+ };
125
+
126
+ fixture.componentRef.setInput('config', config);
127
+ fixture.componentRef.setInput('resources', opts.resources ?? RESOURCES);
128
+ fixture.componentRef.setInput('createFormState', opts.createFormState ?? {});
129
+ fixture.componentRef.setInput('editFormState', opts.editFormState ?? {});
130
+
131
+ fixture.detectChanges();
132
+ return { fixture, component };
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Tests
137
+ // ---------------------------------------------------------------------------
138
+
139
+ describe('DeclarativeTableCard', () => {
140
+ beforeEach(async () => {
141
+ await TestBed.configureTestingModule({
142
+ imports: [
143
+ DeclarativeTableCard as unknown as typeof DeclarativeTableCard<GenericResource>,
144
+ ],
145
+ schemas: [CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA],
146
+ }).compileComponents();
147
+ });
148
+
149
+ // -------------------------------------------------------------------------
150
+ // 1. Component creation
151
+ // -------------------------------------------------------------------------
152
+
153
+ it('should create', () => {
154
+ const { component } = setup();
155
+ expect(component).toBeTruthy();
156
+ });
157
+
158
+ // -------------------------------------------------------------------------
159
+ // 2. DOM: mfp-declarative-table rendered
160
+ // -------------------------------------------------------------------------
161
+
162
+ describe('DOM: mfp-declarative-table', () => {
163
+ it('renders mfp-declarative-table in the host element', () => {
164
+ const { fixture } = setup();
165
+ const root: ShadowRoot | HTMLElement =
166
+ fixture.nativeElement.shadowRoot ?? fixture.nativeElement;
167
+ expect(root.querySelector('mfp-declarative-table')).not.toBeNull();
168
+ });
169
+ });
170
+
171
+ // -------------------------------------------------------------------------
172
+ // 3. header input
173
+ // -------------------------------------------------------------------------
174
+
175
+ describe('header input', () => {
176
+ it('renders the header title when header is provided', () => {
177
+ const { fixture } = setup({ header: 'My Pods' });
178
+ const root: ShadowRoot | HTMLElement =
179
+ fixture.nativeElement.shadowRoot ?? fixture.nativeElement;
180
+ const title = root.querySelector('.card__title');
181
+ expect(title).not.toBeNull();
182
+ expect(title?.textContent?.trim()).toBe('My Pods');
183
+ });
184
+ });
185
+
186
+ // -------------------------------------------------------------------------
187
+ // 4. headerTooltip input
188
+ // -------------------------------------------------------------------------
189
+
190
+ describe('headerTooltip input', () => {
191
+ it('renders info icon when headerTooltip is provided', () => {
192
+ const { fixture } = setup({
193
+ header: 'My Pods',
194
+ headerTooltip: 'Some tooltip',
195
+ });
196
+ fixture.detectChanges();
197
+ const root: ShadowRoot | HTMLElement =
198
+ fixture.nativeElement.shadowRoot ?? fixture.nativeElement;
199
+ const icon = root.querySelector('ui5-icon[name="hint"]');
200
+ expect(icon).not.toBeNull();
201
+ expect(icon?.getAttribute('accessible-name')).toBe('Some tooltip');
202
+ });
203
+
204
+ it('does not render info icon when headerTooltip is not provided', () => {
205
+ const { fixture } = setup({ headerTooltip: undefined });
206
+ const root: ShadowRoot | HTMLElement =
207
+ fixture.nativeElement.shadowRoot ?? fixture.nativeElement;
208
+ expect(root.querySelector('ui5-icon[name="hint"]')).toBeNull();
209
+ });
210
+ });
211
+
212
+ // -------------------------------------------------------------------------
213
+ // 5. Search behaviour
214
+ // -------------------------------------------------------------------------
215
+
216
+ describe('search', () => {
217
+ it('searchExpanded starts as false', () => {
218
+ const { component } = setup();
219
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
220
+ expect((component as any).searchExpanded()).toBe(false);
221
+ });
222
+
223
+ it('toggleSearch() sets searchExpanded to true on first call', () => {
224
+ const { component } = setup();
225
+ component.toggleSearch();
226
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
227
+ expect((component as any).searchExpanded()).toBe(true);
228
+ });
229
+
230
+ it('toggleSearch() starts collapsing on second call when already expanded', () => {
231
+ const { component } = setup();
232
+ component.toggleSearch(); // expand
233
+ component.toggleSearch(); // collapse
234
+ // searchCollapsing should be set; searchExpanded still true until animation ends
235
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
236
+ expect((component as any).searchCollapsing()).toBe(true);
237
+ });
238
+
239
+ it('onSearchBlur() collapses search when value is empty', () => {
240
+ const { component } = setup();
241
+ component.toggleSearch();
242
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
243
+ (component as any).searchControl.setValue('');
244
+ component.onSearchBlur();
245
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
246
+ expect((component as any).searchCollapsing()).toBe(true);
247
+ });
248
+
249
+ it('onSearchBlur() does not collapse when value is non-empty', () => {
250
+ const { component } = setup();
251
+ component.toggleSearch();
252
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
253
+ (component as any).searchControl.setValue('abc');
254
+ component.onSearchBlur();
255
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
256
+ expect((component as any).searchCollapsing()).toBe(false);
257
+ });
258
+
259
+ it('onSearchAnimationEnd() resets search state after collapse animation', () => {
260
+ const { component } = setup();
261
+ component.toggleSearch(); // expand
262
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
263
+ (component as any).searchControl.setValue('query');
264
+ component.toggleSearch(); // start collapsing
265
+ component.onSearchAnimationEnd();
266
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
267
+ expect((component as any).searchCollapsing()).toBe(false);
268
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
269
+ expect((component as any).searchExpanded()).toBe(false);
270
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
271
+ expect((component as any).searchControl.value).toBe('');
272
+ });
273
+
274
+ it('onSearchAnimationEnd() does nothing when not collapsing', () => {
275
+ const { component } = setup();
276
+ component.toggleSearch();
277
+ // Not in collapsing state — should be a no-op
278
+ component.onSearchAnimationEnd();
279
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
280
+ expect((component as any).searchExpanded()).toBe(true);
281
+ });
282
+ });
283
+
284
+ // -------------------------------------------------------------------------
285
+ // 6. Create button visibility
286
+ // -------------------------------------------------------------------------
287
+
288
+ describe('create button', () => {
289
+ it('create button is absent when createConfig is not provided', () => {
290
+ const { fixture } = setup();
291
+ const root: ShadowRoot | HTMLElement =
292
+ fixture.nativeElement.shadowRoot ?? fixture.nativeElement;
293
+ expect(root.querySelector('.card__create-btn')).toBeNull();
294
+ });
295
+
296
+ it('create button is present when createConfig is provided', () => {
297
+ const { fixture } = setup({ createConfig: CREATE_CONFIG });
298
+ const root: ShadowRoot | HTMLElement =
299
+ fixture.nativeElement.shadowRoot ?? fixture.nativeElement;
300
+ expect(root.querySelector('.card__create-btn')).not.toBeNull();
301
+ });
302
+ });
303
+
304
+ // -------------------------------------------------------------------------
305
+ // 7. effectiveColumns() computed
306
+ // -------------------------------------------------------------------------
307
+
308
+ describe('effectiveColumns()', () => {
309
+ it('returns only readConfig.fields when no edit or delete config is set', () => {
310
+ const { component } = setup();
311
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
312
+ const cols = (component as any).effectiveColumns();
313
+ expect(cols).toHaveLength(COLUMNS.length);
314
+ expect(cols).toEqual(COLUMNS);
315
+ });
316
+
317
+ it('adds an edit action column when editConfig is provided', () => {
318
+ const { component } = setup({ editConfig: EDIT_CONFIG });
319
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
320
+ const cols = (component as any).effectiveColumns();
321
+ expect(cols).toHaveLength(COLUMNS.length + 1);
322
+ const editCol = cols.find(
323
+ (c: TableFieldDefinition) =>
324
+ c.uiSettings?.buttonSettings?.action === 'edit',
325
+ );
326
+ expect(editCol).toBeDefined();
327
+ expect(editCol.uiSettings.buttonSettings.icon).toBe('edit');
328
+ });
329
+
330
+ it('adds a delete action column when deleteConfig is provided', () => {
331
+ const { component } = setup({ deleteConfig: DELETE_CONFIG });
332
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
333
+ const cols = (component as any).effectiveColumns();
334
+ expect(cols).toHaveLength(COLUMNS.length + 1);
335
+ const deleteCol = cols.find(
336
+ (c: TableFieldDefinition) =>
337
+ c.uiSettings?.buttonSettings?.action === 'delete',
338
+ );
339
+ expect(deleteCol).toBeDefined();
340
+ });
341
+
342
+ it('adds both edit and delete columns when both configs are provided', () => {
343
+ const { component } = setup({
344
+ editConfig: EDIT_CONFIG,
345
+ deleteConfig: DELETE_CONFIG,
346
+ });
347
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
348
+ const cols = (component as any).effectiveColumns();
349
+ expect(cols).toHaveLength(COLUMNS.length + 2);
350
+ const editCol = cols.find(
351
+ (c: TableFieldDefinition) =>
352
+ c.uiSettings?.buttonSettings?.action === 'edit',
353
+ );
354
+ const deleteCol = cols.find(
355
+ (c: TableFieldDefinition) =>
356
+ c.uiSettings?.buttonSettings?.action === 'delete',
357
+ );
358
+ expect(editCol).toBeDefined();
359
+ expect(deleteCol).toBeDefined();
360
+ });
361
+
362
+ it('delete column uses "decline" icon by default', () => {
363
+ const { component } = setup({ deleteConfig: DELETE_CONFIG });
364
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
365
+ const cols = (component as any).effectiveColumns();
366
+ const deleteCol = cols.find(
367
+ (c: TableFieldDefinition) =>
368
+ c.uiSettings?.buttonSettings?.action === 'delete',
369
+ );
370
+ expect(deleteCol.uiSettings.buttonSettings.icon).toBe('decline');
371
+ });
372
+
373
+ it('respects custom icon from editConfig.editButtonSettings', () => {
374
+ const customEditConfig: TableCardEditConfig = {
375
+ ...EDIT_CONFIG,
376
+ editButtonSettings: { icon: 'pen-tool' },
377
+ };
378
+ const { component } = setup({ editConfig: customEditConfig });
379
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
380
+ const cols = (component as any).effectiveColumns();
381
+ const editCol = cols.find(
382
+ (c: TableFieldDefinition) =>
383
+ c.uiSettings?.buttonSettings?.action === 'edit',
384
+ );
385
+ expect(editCol.uiSettings.buttonSettings.icon).toBe('pen-tool');
386
+ });
387
+
388
+ it('respects custom icon from deleteConfig.deleteButtonSettings', () => {
389
+ const customDeleteConfig: TableCardDeleteConfig = {
390
+ ...DELETE_CONFIG,
391
+ deleteButtonSettings: { icon: 'trash' },
392
+ };
393
+ const { component } = setup({ deleteConfig: customDeleteConfig });
394
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
395
+ const cols = (component as any).effectiveColumns();
396
+ const deleteCol = cols.find(
397
+ (c: TableFieldDefinition) =>
398
+ c.uiSettings?.buttonSettings?.action === 'delete',
399
+ );
400
+ expect(deleteCol.uiSettings.buttonSettings.icon).toBe('trash');
401
+ });
402
+ });
403
+
404
+ // -------------------------------------------------------------------------
405
+ // 8. editInitialValue() computed
406
+ // -------------------------------------------------------------------------
407
+
408
+ describe('editInitialValue()', () => {
409
+ it('returns empty object when pendingResource is null', () => {
410
+ const { component } = setup({ editConfig: EDIT_CONFIG });
411
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
412
+ expect((component as any).editInitialValue()).toEqual({});
413
+ });
414
+
415
+ it('returns empty object when editConfig is not set', () => {
416
+ const { component } = setup();
417
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
418
+ expect((component as any).editInitialValue()).toEqual({});
419
+ });
420
+
421
+ it('builds initial values from pendingResource fields when editConfig is set', () => {
422
+ const { component } = setup({ editConfig: EDIT_CONFIG });
423
+ const resource = RESOURCES[0];
424
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
425
+ (component as any).pendingResource.set(resource);
426
+
427
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
428
+ const values = (component as any).editInitialValue();
429
+ expect(values['metadata.name']).toBe('pod-alpha');
430
+ expect(values['metadata.namespace']).toBe('default');
431
+ });
432
+ });
433
+
434
+ // -------------------------------------------------------------------------
435
+ // 9. onButtonClick()
436
+ // -------------------------------------------------------------------------
437
+
438
+ describe('onButtonClick()', () => {
439
+ it('intercepts action="edit": sets pendingResource and opens editDialogOpen', () => {
440
+ const { component } = setup({ editConfig: EDIT_CONFIG });
441
+ const resource = RESOURCES[0];
442
+ component.onButtonClick(makeEvent('edit', resource));
443
+
444
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
445
+ expect((component as any).pendingResource()).toBe(resource);
446
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
447
+ expect((component as any).editDialogOpen()).toBe(true);
448
+ });
449
+
450
+ it('intercepts action="edit": does not emit actionButtonClick', () => {
451
+ const { component } = setup({ editConfig: EDIT_CONFIG });
452
+ const emitted: unknown[] = [];
453
+ component.actionButtonClick.subscribe((e) => emitted.push(e));
454
+ component.onButtonClick(makeEvent('edit', RESOURCES[0]));
455
+ expect(emitted).toHaveLength(0);
456
+ });
457
+
458
+ it('intercepts action="delete": sets pendingResource and opens deleteDialogOpen', () => {
459
+ const { component } = setup({ deleteConfig: DELETE_CONFIG });
460
+ const resource = RESOURCES[1];
461
+ component.onButtonClick(makeEvent('delete', resource));
462
+
463
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
464
+ expect((component as any).pendingResource()).toBe(resource);
465
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
466
+ expect((component as any).deleteDialogOpen()).toBe(true);
467
+ });
468
+
469
+ it('intercepts action="delete": does not emit actionButtonClick', () => {
470
+ const { component } = setup({ deleteConfig: DELETE_CONFIG });
471
+ const emitted: unknown[] = [];
472
+ component.actionButtonClick.subscribe((e) => emitted.push(e));
473
+ component.onButtonClick(makeEvent('delete', RESOURCES[0]));
474
+ expect(emitted).toHaveLength(0);
475
+ });
476
+
477
+ it('forwards other actions via actionButtonClick output', () => {
478
+ const { component } = setup();
479
+ const emitted: ValueCellButtonClickEvent<GenericResource>[] = [];
480
+ component.actionButtonClick.subscribe((e) => emitted.push(e));
481
+
482
+ const event = makeEvent('navigate', RESOURCES[0]);
483
+ component.onButtonClick(event);
484
+
485
+ expect(emitted).toHaveLength(1);
486
+ expect(emitted[0]).toBe(event);
487
+ });
488
+
489
+ it('forwards action="edit" without a resource via actionButtonClick', () => {
490
+ const { component } = setup();
491
+ const emitted: unknown[] = [];
492
+ component.actionButtonClick.subscribe((e) => emitted.push(e));
493
+ component.onButtonClick(makeEvent('edit', undefined));
494
+ expect(emitted).toHaveLength(1);
495
+ });
496
+
497
+ it('forwards action="delete" without a resource via actionButtonClick', () => {
498
+ const { component } = setup();
499
+ const emitted: unknown[] = [];
500
+ component.actionButtonClick.subscribe((e) => emitted.push(e));
501
+ component.onButtonClick(makeEvent('delete', undefined));
502
+ expect(emitted).toHaveLength(1);
503
+ });
504
+ });
505
+
506
+ // -------------------------------------------------------------------------
507
+ // 10. form state and submit flow
508
+ // -------------------------------------------------------------------------
509
+
510
+ describe('form state and submit flow', () => {
511
+ const fieldChange: FormFieldChangeEvent = {
512
+ fieldProperty: 'metadata.name',
513
+ value: 'new-pod',
514
+ };
515
+
516
+ it('emits createFieldChange on field change', () => {
517
+ const { component } = setup({ createConfig: CREATE_CONFIG });
518
+ const emitted: FormFieldChangeEvent[] = [];
519
+ component.createFieldChange.subscribe((event) => emitted.push(event));
520
+
521
+ component.onCreateFieldChange(fieldChange);
522
+
523
+ expect(emitted).toEqual([fieldChange]);
524
+ });
525
+
526
+ it('emits editFieldChange with pending resource', () => {
527
+ const { component } = setup({ editConfig: EDIT_CONFIG });
528
+ const resource = RESOURCES[0];
529
+ const emitted: {
530
+ resource: GenericResource;
531
+ formChangeEvent: FormFieldChangeEvent;
532
+ }[] = [];
533
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
534
+ (component as any).pendingResource.set(resource);
535
+ component.editFieldChange.subscribe((event) => emitted.push(event));
536
+
537
+ component.onEditFieldChange(fieldChange);
538
+
539
+ expect(emitted).toEqual([{ resource, formChangeEvent: fieldChange }]);
540
+ });
541
+
542
+ it('does not emit editFieldChange without pending resource', () => {
543
+ const { component } = setup({ editConfig: EDIT_CONFIG });
544
+ const emitted: unknown[] = [];
545
+ component.editFieldChange.subscribe((event) => emitted.push(event));
546
+
547
+ component.onEditFieldChange(fieldChange);
548
+
549
+ expect(emitted).toHaveLength(0);
550
+ });
551
+
552
+ it('emits createSubmit and leaves create dialog open', () => {
553
+ const { component } = setup({ createConfig: CREATE_CONFIG });
554
+ const value = { metadata: { name: 'new-pod' } };
555
+ const emitted: Record<string, unknown>[] = [];
556
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
557
+ (component as any).createDialogOpen.set(true);
558
+ component.createSubmit.subscribe((event) => emitted.push(event));
559
+
560
+ component.onCreateSubmit(value);
561
+
562
+ expect(emitted).toEqual([value]);
563
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
564
+ expect((component as any).createDialogOpen()).toBe(true);
565
+ });
566
+
567
+ it('emits editSubmit with resource and leaves edit dialog open', () => {
568
+ const { component } = setup({ editConfig: EDIT_CONFIG });
569
+ const resource = RESOURCES[0];
570
+ const value = { metadata: { namespace: 'staging' } };
571
+ const emitted: {
572
+ resource: GenericResource;
573
+ value: Record<string, unknown>;
574
+ }[] = [];
575
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
576
+ (component as any).pendingResource.set(resource);
577
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
578
+ (component as any).editDialogOpen.set(true);
579
+ component.editSubmit.subscribe((event) => emitted.push(event));
580
+
581
+ component.onEditSubmit(value);
582
+
583
+ expect(emitted).toEqual([{ resource, value }]);
584
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
585
+ expect((component as any).editDialogOpen()).toBe(true);
586
+ });
587
+
588
+ it('does not emit editSubmit without pending resource', () => {
589
+ const { component } = setup({ editConfig: EDIT_CONFIG });
590
+ const emitted: unknown[] = [];
591
+ component.editSubmit.subscribe((event) => emitted.push(event));
592
+
593
+ component.onEditSubmit({ metadata: { namespace: 'staging' } });
594
+
595
+ expect(emitted).toHaveLength(0);
596
+ });
597
+
598
+ it('emits deleteSubmit and leaves delete dialog open', () => {
599
+ const { component } = setup({ deleteConfig: DELETE_CONFIG });
600
+ const resource = RESOURCES[0];
601
+ const emitted: GenericResource[] = [];
602
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
603
+ (component as any).pendingResource.set(resource);
604
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
605
+ (component as any).deleteDialogOpen.set(true);
606
+ component.deleteSubmit.subscribe((event) => emitted.push(event));
607
+
608
+ component.onDeleteSubmit();
609
+
610
+ expect(emitted).toEqual([resource]);
611
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
612
+ expect((component as any).deleteDialogOpen()).toBe(true);
613
+ });
614
+ });
615
+
616
+ // -------------------------------------------------------------------------
617
+ // 11. close methods
618
+ // -------------------------------------------------------------------------
619
+
620
+ describe('close methods', () => {
621
+ it('closeCreateDialog() closes the create dialog', () => {
622
+ const { component } = setup({ createConfig: CREATE_CONFIG });
623
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
624
+ (component as any).createDialogOpen.set(true);
625
+
626
+ component.closeCreateDialog();
627
+
628
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
629
+ expect((component as any).createDialogOpen()).toBe(false);
630
+ });
631
+
632
+ it('closeEditDialog() closes the edit dialog', () => {
633
+ const { component } = setup({ editConfig: EDIT_CONFIG });
634
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
635
+ (component as any).editDialogOpen.set(true);
636
+
637
+ component.closeEditDialog();
638
+
639
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
640
+ expect((component as any).editDialogOpen()).toBe(false);
641
+ });
642
+
643
+ it('closeDeleteDialog() closes the delete dialog', () => {
644
+ const { component } = setup({ deleteConfig: DELETE_CONFIG });
645
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
646
+ (component as any).deleteDialogOpen.set(true);
647
+
648
+ component.closeDeleteDialog();
649
+
650
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
651
+ expect((component as any).deleteDialogOpen()).toBe(false);
652
+ });
653
+ });
654
+
655
+ // -------------------------------------------------------------------------
656
+ // 12. runtime form state
657
+ // -------------------------------------------------------------------------
658
+
659
+ describe('runtime form state', () => {
660
+ it('disables the create submit button when fieldErrors has errors', () => {
661
+ const { fixture, component } = setup({
662
+ createConfig: CREATE_CONFIG,
663
+ createFormState: {
664
+ fieldErrors: { 'metadata.name': 'required' },
665
+ },
666
+ });
667
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
668
+ (component as any).createDialogOpen.set(true);
669
+ fixture.detectChanges();
670
+
671
+ const root: ShadowRoot | HTMLElement =
672
+ fixture.nativeElement.shadowRoot ?? fixture.nativeElement;
673
+ const submitButton = root.querySelector(
674
+ '.dialog__footer ui5-button[design="Emphasized"]',
675
+ ) as HTMLElement & { disabled: boolean };
676
+
677
+ expect(submitButton.disabled).toBe(true);
678
+ });
679
+
680
+ it('enables the create submit button when fieldErrors is empty', () => {
681
+ const { fixture, component } = setup({
682
+ createConfig: CREATE_CONFIG,
683
+ createFormState: { fieldErrors: {} },
684
+ });
685
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
686
+ (component as any).createDialogOpen.set(true);
687
+ fixture.detectChanges();
688
+
689
+ const root: ShadowRoot | HTMLElement =
690
+ fixture.nativeElement.shadowRoot ?? fixture.nativeElement;
691
+ const submitButton = root.querySelector(
692
+ '.dialog__footer ui5-button[design="Emphasized"]',
693
+ ) as HTMLElement & { disabled: boolean };
694
+
695
+ expect(submitButton.disabled).toBe(false);
696
+ });
697
+
698
+ it('disables the edit submit button when fieldErrors has errors', () => {
699
+ const { fixture, component } = setup({
700
+ editConfig: EDIT_CONFIG,
701
+ editFormState: {
702
+ fieldErrors: { 'metadata.name': 'required' },
703
+ },
704
+ });
705
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
706
+ (component as any).editDialogOpen.set(true);
707
+ fixture.detectChanges();
708
+
709
+ const root: ShadowRoot | HTMLElement =
710
+ fixture.nativeElement.shadowRoot ?? fixture.nativeElement;
711
+ const submitButton = root.querySelector(
712
+ '.dialog__footer ui5-button[design="Emphasized"]',
713
+ ) as HTMLElement & { disabled: boolean };
714
+
715
+ expect(submitButton.disabled).toBe(true);
716
+ });
717
+
718
+ it('enables the edit submit button when fieldErrors is empty', () => {
719
+ const { fixture, component } = setup({
720
+ editConfig: EDIT_CONFIG,
721
+ editFormState: { fieldErrors: {} },
722
+ });
723
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
724
+ (component as any).editDialogOpen.set(true);
725
+ fixture.detectChanges();
726
+
727
+ const root: ShadowRoot | HTMLElement =
728
+ fixture.nativeElement.shadowRoot ?? fixture.nativeElement;
729
+ const submitButton = root.querySelector(
730
+ '.dialog__footer ui5-button[design="Emphasized"]',
731
+ ) as HTMLElement & { disabled: boolean };
732
+
733
+ expect(submitButton.disabled).toBe(false);
734
+ });
735
+ });
736
+
737
+ // -------------------------------------------------------------------------
738
+ // 14. Pass-through outputs
739
+ // -------------------------------------------------------------------------
740
+
741
+ describe('pass-through outputs', () => {
742
+ it('exposes tableRowClicked output', () => {
743
+ const { component } = setup();
744
+ expect(typeof component.tableRowClicked.emit).toBe('function');
745
+ expect(typeof component.tableRowClicked.subscribe).toBe('function');
746
+ });
747
+
748
+ it('exposes loadMoreResources output', () => {
749
+ const { component } = setup();
750
+ expect(typeof component.loadMoreResources.emit).toBe('function');
751
+ });
752
+
753
+ it('exposes paginationLimitChanged output', () => {
754
+ const { component } = setup();
755
+ expect(typeof component.paginationLimitChanged.emit).toBe('function');
756
+ });
757
+
758
+ it('exposes searchChanged output', () => {
759
+ const { component } = setup();
760
+ expect(typeof component.searchChanged.emit).toBe('function');
761
+ });
762
+ });
763
+
764
+ // -------------------------------------------------------------------------
765
+ // 15. readConfig pagination pass-through
766
+ // -------------------------------------------------------------------------
767
+
768
+ describe('readConfig pagination', () => {
769
+ it('effectiveColumns() uses fields from readConfig', () => {
770
+ const customColumns: TableFieldDefinition[] = [
771
+ { label: 'Phase', property: 'status.phase' },
772
+ ];
773
+ const { component } = setup({
774
+ readConfig: {
775
+ fields: customColumns,
776
+ totalItemsCount: 42,
777
+ paginationLimit: 10,
778
+ hasMore: true,
779
+ },
780
+ });
781
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
782
+ const cols = (component as any).effectiveColumns();
783
+ expect(cols[0]).toEqual(customColumns[0]);
784
+ });
785
+ });
786
+ });