diff --git a/api-goldens/dashboards-ng/index.api.md b/api-goldens/dashboards-ng/index.api.md index 5e4e85bc72..659dc97204 100644 --- a/api-goldens/dashboards-ng/index.api.md +++ b/api-goldens/dashboards-ng/index.api.md @@ -194,6 +194,8 @@ export class SiFlexibleDashboardComponent implements OnInit, OnChanges, OnDestro readonly dashboard: _angular_core.Signal; readonly dashboardId: _angular_core.InputSignal; readonly editable: _angular_core.ModelSignal; + // (undocumented) + readonly enableMultiSelect: _angular_core.InputSignalWithTransform; readonly grid: _angular_core.Signal; readonly heading: _angular_core.InputSignal; readonly hideAddWidgetInstanceButton: _angular_core.InputSignal; @@ -240,7 +242,10 @@ export class SiGridComponent implements OnInit, OnChanges, OnDestroy { // @public export class SiWidgetCatalogComponent extends SiWidgetEditorBase implements OnInit { - readonly closed: _angular_core.OutputEmitterRef | undefined>; + constructor(); + readonly closed: _angular_core.OutputEmitterRef[] | undefined>; + // (undocumented) + readonly enableMultiSelect: _angular_core.InputSignalWithTransform; readonly searchPlaceholder: _angular_core.InputSignal<_siemens_element_translate_ng_translate.TranslatableString>; widgetCatalog: Widget[]; } @@ -319,6 +324,7 @@ export interface WidgetConfig { minHeight?: number; minWidth?: number; payload?: any; + setupPending?: boolean; version?: string; widgetId: string; width?: number; diff --git a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--edit-dashboards-demo-chromium-dark-linux.png b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--edit-dashboards-demo-chromium-dark-linux.png index ee6dfae6f9..62bf71fd14 100644 --- a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--edit-dashboards-demo-chromium-dark-linux.png +++ b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--edit-dashboards-demo-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c7f8d4945055bfd0b7666c4c1d8599943e41f31f273d807d24a7c5b6b000be5b -size 57721 +oid sha256:5c94fe2709f460a815c13c73bece2414e50d56f1ab1a217a9f714f195f5a4cb9 +size 53743 diff --git a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--edit-dashboards-demo-chromium-light-linux.png b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--edit-dashboards-demo-chromium-light-linux.png index a23e57f6b7..5c8cdd18ed 100644 --- a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--edit-dashboards-demo-chromium-light-linux.png +++ b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--edit-dashboards-demo-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:38c8ab49cf9b0071539d750da6fa8ef3673cb22eb936be1898b1189b127717d9 -size 54070 +oid sha256:8a59f297930c78a818d7b9dc9a23271495671349dec5c560535225999685150b +size 50410 diff --git a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--edit.yaml b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--edit.yaml index abd01808b3..0a2a3c3e73 100644 --- a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--edit.yaml +++ b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--edit.yaml @@ -12,47 +12,61 @@ - textbox "Search widget" - text: Widget catalog list - listbox "Widget catalog list": - - option "report status none Hello World A dummy widget for testing." [selected]: + - option "report status none Hello World A dummy widget for testing.": + - checkbox - status "report status none" - text: "" - option "user status none Contact Add a contact card to your dashboard.": + - checkbox - status "user status none" - text: "" - option "trend status none Line Chart A line chart is a type of chart used to show information that changes over time. Line charts are created by plotting a series of several points and connecting them with a straight line. Line charts are used to track changes over short and long periods.": + - checkbox - status "trend status none" - text: "" - option "trend status none Bar Chart This is a bar chart widget.": + - checkbox - status "trend status none" - text: "" - option "trend status none Circle Chart This is a cart with a circle.": + - checkbox - status "trend status none" - text: "" - option "trend status none Gauge Chart A nice gauge charts": + - checkbox - status "trend status none" - text: "" - option "trend status none List Widget Displays a list of items": + - checkbox - status "trend status none" - text: "" - option "trend status none Timeline Widget Displays events or steps over a period of time": + - checkbox - status "trend status none" - text: "" - option "trend status none Value Widget Displays a single KPI": + - checkbox - status "trend status none" - text: "" - option "status none Note (web-component)": + - checkbox - status "status none" - text: "" - option "status none Contact (web-component)": + - checkbox - status "status none" - text: "" - option "status none Chart (web-component)": + - checkbox - status "status none" - text: "" - option "status none Download (module-federation)": + - checkbox - status "status none" - text: "" - option "status none Upload (module-federation)": + - checkbox - status "status none" - text: "" - button "Cancel" - - button "Next" \ No newline at end of file + - button "Add" [disabled] \ No newline at end of file diff --git a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--esm-edit-dashboards-demo-esm-chromium-dark-linux.png b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--esm-edit-dashboards-demo-esm-chromium-dark-linux.png index ee6dfae6f9..62bf71fd14 100644 --- a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--esm-edit-dashboards-demo-esm-chromium-dark-linux.png +++ b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--esm-edit-dashboards-demo-esm-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c7f8d4945055bfd0b7666c4c1d8599943e41f31f273d807d24a7c5b6b000be5b -size 57721 +oid sha256:5c94fe2709f460a815c13c73bece2414e50d56f1ab1a217a9f714f195f5a4cb9 +size 53743 diff --git a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--esm-edit-dashboards-demo-esm-chromium-light-linux.png b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--esm-edit-dashboards-demo-esm-chromium-light-linux.png index a23e57f6b7..5c8cdd18ed 100644 --- a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--esm-edit-dashboards-demo-esm-chromium-light-linux.png +++ b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--esm-edit-dashboards-demo-esm-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:38c8ab49cf9b0071539d750da6fa8ef3673cb22eb936be1898b1189b127717d9 -size 54070 +oid sha256:8a59f297930c78a818d7b9dc9a23271495671349dec5c560535225999685150b +size 50410 diff --git a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--esm-edit.yaml b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--esm-edit.yaml index 8aa24f82d4..91fa65c910 100644 --- a/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--esm-edit.yaml +++ b/playwright/snapshots/dashboard.spec.ts-snapshots/dashboard--esm-edit.yaml @@ -12,47 +12,61 @@ - textbox "Search widget" - text: Widget catalog list - listbox "Widget catalog list": - - option "report status none Hello World A dummy widget for testing." [selected]: + - option "report status none Hello World A dummy widget for testing.": + - checkbox - status "report status none" - text: "" - option "user status none Contact Add a contact card to your dashboard.": + - checkbox - status "user status none" - text: "" - option "trend status none Line Chart A line chart is a type of chart used to show information that changes over time. Line charts are created by plotting a series of several points and connecting them with a straight line. Line charts are used to track changes over short and long periods.": + - checkbox - status "trend status none" - text: "" - option "trend status none Bar Chart This is a bar chart widget.": + - checkbox - status "trend status none" - text: "" - option "trend status none Circle Chart This is a cart with a circle.": + - checkbox - status "trend status none" - text: "" - option "trend status none Gauge Chart A nice gauge charts": + - checkbox - status "trend status none" - text: "" - option "trend status none List Widget Displays a list of items": + - checkbox - status "trend status none" - text: "" - option "trend status none Timeline Widget Displays events or steps over a period of time": + - checkbox - status "trend status none" - text: "" - option "trend status none Value Widget Displays a single KPI": + - checkbox - status "trend status none" - text: "" - option "status none Note (web-component)": + - checkbox - status "status none" - text: "" - option "status none Contact (web-component)": + - checkbox - status "status none" - text: "" - option "status none Chart (web-component)": + - checkbox - status "status none" - text: "" - option "status none Download (native-federation)": + - checkbox - status "status none" - text: "" - option "status none Upload (module-federation on native-federation shell)": + - checkbox - status "status none" - text: "" - button "Cancel" - - button "Next" \ No newline at end of file + - button "Add" [disabled] \ No newline at end of file diff --git a/projects/dashboards-demo/src/app/components/widget-catalog/custom-widget-catalog.component.html b/projects/dashboards-demo/src/app/components/widget-catalog/custom-widget-catalog.component.html index dd865a526c..9414dcdc76 100644 --- a/projects/dashboards-demo/src/app/components/widget-catalog/custom-widget-catalog.component.html +++ b/projects/dashboards-demo/src/app/components/widget-catalog/custom-widget-catalog.component.html @@ -13,8 +13,8 @@ - + diff --git a/projects/dashboards-demo/src/app/components/widget-catalog/custom-widget-catalog.component.ts b/projects/dashboards-demo/src/app/components/widget-catalog/custom-widget-catalog.component.ts index 3b44c2e74d..5f39eb5e99 100644 --- a/projects/dashboards-demo/src/app/components/widget-catalog/custom-widget-catalog.component.ts +++ b/projects/dashboards-demo/src/app/components/widget-catalog/custom-widget-catalog.component.ts @@ -16,7 +16,7 @@ import { HELLO_DESCRIPTOR } from '../../widgets/hello-widget/widget-descriptors' styleUrl: './custom-widget-catalog.component.scss' }) export class CustomWidgetCatalogComponent extends SiWidgetCatalogComponent { - override readonly closed = output | undefined>(); + override readonly closed = output[] | undefined>(); override widgetCatalog: Widget[] = []; @@ -27,7 +27,7 @@ export class CustomWidgetCatalogComponent extends SiWidgetCatalogComponent { } override onAddWidget(): void { - const selected = this.selected(); + const selected = this.selectedWidgets()[0]; if (selected) { const widgetConfig: Omit = { heading: selected.name, @@ -37,7 +37,7 @@ export class CustomWidgetCatalogComponent extends SiWidgetCatalogComponent { ...selected.defaults, payload: { ...selected.payload } }; - this.closed.emit(widgetConfig); + this.closed.emit([widgetConfig]); } } diff --git a/projects/dashboards-demo/src/app/pages/dashboard/dashboard.component.html b/projects/dashboards-demo/src/app/pages/dashboard/dashboard.component.html index b87b6fb439..858ca0a35d 100644 --- a/projects/dashboards-demo/src/app/pages/dashboard/dashboard.component.html +++ b/projects/dashboards-demo/src/app/pages/dashboard/dashboard.component.html @@ -3,6 +3,7 @@ heading="Sample Dashboard" [widgetCatalog]="widgetCatalog" [showEditButtonLabel]="true" + [enableMultiSelect]="true" (isModified)="appStateService.editable$.next($event)" > diff --git a/projects/dashboards-ng/src/components/flexible-dashboard/si-flexible-dashboard.component.spec.ts b/projects/dashboards-ng/src/components/flexible-dashboard/si-flexible-dashboard.component.spec.ts index f128bbea21..f2b6627ef9 100644 --- a/projects/dashboards-ng/src/components/flexible-dashboard/si-flexible-dashboard.component.spec.ts +++ b/projects/dashboards-ng/src/components/flexible-dashboard/si-flexible-dashboard.component.spec.ts @@ -41,10 +41,10 @@ let widgetConfig: Omit; template: '' }) export class SiWidgetCatalogMockComponent extends SiWidgetCatalogComponent implements OnInit { - static staticClosed: OutputEmitterRef | undefined> | undefined = + static staticClosed: OutputEmitterRef[] | undefined> | undefined = undefined; - override readonly closed = output | undefined>(); + override readonly closed = output[] | undefined>(); override ngOnInit(): void { SiWidgetCatalogMockComponent.staticClosed ??= this.closed; @@ -164,7 +164,7 @@ describe('SiFlexibleDashboardComponent', () => { fixture.detectChanges(); component.showWidgetCatalog(); fixture.detectChanges(); - SiWidgetCatalogMockComponent.staticClosed?.emit({ widgetId: 'widgetId' }); + SiWidgetCatalogMockComponent.staticClosed?.emit([{ widgetId: 'widgetId' }]); vi.advanceTimersByTime(200); await fixture.whenStable(); expect(widgetConfig).toBeDefined(); diff --git a/projects/dashboards-ng/src/components/flexible-dashboard/si-flexible-dashboard.component.ts b/projects/dashboards-ng/src/components/flexible-dashboard/si-flexible-dashboard.component.ts index 3f9f39512c..8579548f2b 100644 --- a/projects/dashboards-ng/src/components/flexible-dashboard/si-flexible-dashboard.component.ts +++ b/projects/dashboards-ng/src/components/flexible-dashboard/si-flexible-dashboard.component.ts @@ -4,6 +4,7 @@ */ import { AsyncPipe } from '@angular/common'; import { + booleanAttribute, Component, computed, inject, @@ -155,6 +156,11 @@ export class SiFlexibleDashboardComponent implements OnInit, OnChanges, OnDestro */ readonly secondaryEditActions = input([]); + /** @defaultValue false */ + readonly enableMultiSelect = input(false, { + transform: booleanAttribute + }); + /** * The grid component is the actual container for the widgets. */ @@ -275,13 +281,12 @@ export class SiFlexibleDashboardComponent implements OnInit, OnChanges, OnDestro const componentType = this.widgetCatalogComponent() ?? SiWidgetCatalogComponent; const catalogRef = this.catalogHost().createComponent(componentType, { bindings: [ + inputBinding('enableMultiSelect', this.enableMultiSelect), inputBinding('searchPlaceholder', this.searchPlaceholder), - outputBinding | undefined>('closed', widgetConfig => { + outputBinding[] | undefined>('closed', widgetConfigs => { this.viewState.set('dashboard'); this.catalogHost().clear(); - if (widgetConfig) { - this.grid().addWidgetInstance(widgetConfig); - } + widgetConfigs?.forEach(config => this.grid().addWidgetInstance(config)); }) ] }); diff --git a/projects/dashboards-ng/src/components/gridstack-wrapper/si-gridstack-wrapper.component.ts b/projects/dashboards-ng/src/components/gridstack-wrapper/si-gridstack-wrapper.component.ts index 6c811ce624..7836022081 100644 --- a/projects/dashboards-ng/src/components/gridstack-wrapper/si-gridstack-wrapper.component.ts +++ b/projects/dashboards-ng/src/components/gridstack-wrapper/si-gridstack-wrapper.component.ts @@ -70,9 +70,9 @@ export class SiGridstackWrapperComponent implements OnInit, OnChanges { * * @defaultValue new Map() */ - readonly widgetCatalogMap = input>( - new Map() - ); + readonly widgetCatalogMap = input< + Map + >(new Map()); /** * Emits dashboard grid events. @@ -215,11 +215,16 @@ export class SiGridstackWrapperComponent implements OnInit, OnChanges { () => this.widgetCatalogMap().get(item.widgetId)?.componentFactory ); + const iconClass = computed( + () => this.widgetCatalogMap().get(item.widgetId)?.iconClass ?? 'element-apps' + ); + const componentRef = this.gridstackContainer()!.createComponent(SiWidgetHostComponent, { bindings: [ inputBinding('widgetConfig', configSignal), inputBinding('editable', this.editable), inputBinding('componentFactory', componentFactory), + inputBinding('iconClass', iconClass), outputBinding('remove', widgetId => { this.widgetInstanceRemove.emit(widgetId); }), diff --git a/projects/dashboards-ng/src/components/widget-catalog/si-widget-catalog.component.html b/projects/dashboards-ng/src/components/widget-catalog/si-widget-catalog.component.html index ab4076c025..9849e801b3 100644 --- a/projects/dashboards-ng/src/components/widget-catalog/si-widget-catalog.component.html +++ b/projects/dashboards-ng/src/components/widget-catalog/si-widget-catalog.component.html @@ -1,7 +1,7 @@
@if (view() === 'list') {
-
+
@for (widget of filteredWidgetCatalog; track $index) {
  • + @if (enableMultiSelect()) { +
    + +
    + }
    {{ widget.name | translate }} diff --git a/projects/dashboards-ng/src/components/widget-catalog/si-widget-catalog.component.scss b/projects/dashboards-ng/src/components/widget-catalog/si-widget-catalog.component.scss index 275c75869f..d1251d185b 100644 --- a/projects/dashboards-ng/src/components/widget-catalog/si-widget-catalog.component.scss +++ b/projects/dashboards-ng/src/components/widget-catalog/si-widget-catalog.component.scss @@ -11,6 +11,10 @@ .list-group-item { cursor: pointer; + + .form-check { + block-size: 2.5rem; + } } .catalog-footer { diff --git a/projects/dashboards-ng/src/components/widget-catalog/si-widget-catalog.component.spec.ts b/projects/dashboards-ng/src/components/widget-catalog/si-widget-catalog.component.spec.ts index cfcc45d6aa..f3d7a0130f 100644 --- a/projects/dashboards-ng/src/components/widget-catalog/si-widget-catalog.component.spec.ts +++ b/projects/dashboards-ng/src/components/widget-catalog/si-widget-catalog.component.spec.ts @@ -16,6 +16,7 @@ import { firstValueFrom, NEVER } from 'rxjs'; import { TEST_WIDGET } from '../../../test/test-widget/test-widget'; import { createTestingWidget, TestingModule } from '../../../test/testing.module'; +import { WidgetConfig } from '../../model/widgets.model'; import { SiWidgetCatalogComponent } from './si-widget-catalog.component'; describe('SiWidgetCatalogComponent', () => { @@ -25,7 +26,9 @@ describe('SiWidgetCatalogComponent', () => { const buttonsByName = (label: string): DebugElement[] => { return fixture.debugElement .queryAll(By.css('button')) - .filter((debugElement: DebugElement) => debugElement.nativeElement.innerHTML === label); + .filter( + (debugElement: DebugElement) => debugElement.nativeElement.textContent.trim() === label + ); }; beforeEach(async () => { @@ -86,8 +89,9 @@ describe('SiWidgetCatalogComponent', () => { const widgetConfigPromise = firstValueFrom(outputToObservable(component.closed)); buttonsByName('Add')[0].nativeElement.click(); fixture.detectChanges(); - const widgetConfig = await widgetConfigPromise; - expect(widgetConfig?.widgetId).toBe('id-1234'); + const widgetConfigs = (await widgetConfigPromise) as Omit[]; + expect(widgetConfigs).toHaveLength(1); + expect(widgetConfigs[0].widgetId).toBe('id-1234'); }); }); @@ -288,6 +292,85 @@ describe('SiWidgetCatalogComponent', () => { ).not.toBe('SI-TEST-WIDGET-EDITOR'); }); + describe('List multi selection', () => { + const widgetWithoutEditor = createTestingWidget('widgetA', 'a-1'); + const widgetWithoutEditor2 = createTestingWidget('widgetB', 'b-1'); + const widgetWithEditor = createTestingWidget('widgetC', 'c-1', 'CComponent', 'CEditor'); + + const checkboxes = (): DebugElement[] => + fixture.debugElement.queryAll(By.css('.form-check-input')); + + beforeEach(() => { + component.widgetCatalog = [widgetWithoutEditor, widgetWithoutEditor2, widgetWithEditor]; + fixture.componentRef.setInput('enableMultiSelect', true); + fixture.detectChanges(); + }); + + it('should always show checkboxes in list view', () => { + expect(checkboxes()).toHaveLength(3); + }); + + it('should allow selecting widgets with editor components', async () => { + const firstCheckbox = checkboxes()[0].nativeElement as HTMLInputElement; + if (firstCheckbox.checked) { + firstCheckbox.click(); + await fixture.whenStable(); + } + + const editorCheckbox = checkboxes()[2].nativeElement as HTMLInputElement; + expect(editorCheckbox.disabled).toBe(false); + + editorCheckbox.click(); + await fixture.whenStable(); + + expect(editorCheckbox.checked).toBe(true); + expect(buttonsByName('Next')).toHaveLength(1); + }); + + it('should show add for multi-selection and hide next', async () => { + const cbs = checkboxes().map(cb => cb.nativeElement as HTMLInputElement); + if (cbs[0].checked) { + cbs[0].click(); + await fixture.whenStable(); + } + cbs[1].click(); + await fixture.whenStable(); + cbs[2].click(); + await fixture.whenStable(); + + expect(buttonsByName('Next')).toHaveLength(0); + expect(buttonsByName('Add')).toHaveLength(1); + expect(buttonsByName('Add')[0].attributes.disabled).toBeUndefined(); + }); + + it('should emit deferred config for editor widgets in multi-selection', async () => { + const cbs = checkboxes().map(cb => cb.nativeElement as HTMLInputElement); + if (cbs[0].checked) { + cbs[0].click(); + await fixture.whenStable(); + } + cbs[1].click(); + await fixture.whenStable(); + cbs[2].click(); + await fixture.whenStable(); + + const closedPromise = firstValueFrom(outputToObservable(component.closed)); + buttonsByName('Add')[0].nativeElement.click(); + await fixture.whenStable(); + + const result = (await closedPromise) as Omit[]; + expect(result).toBeDefined(); + expect(result).toHaveLength(2); + expect(result.find(config => config.widgetId === 'b-1')?.setupPending).toBe(undefined); + expect(result.find(config => config.widgetId === 'c-1')?.setupPending).toBe(true); + }); + + it('should disable add button when no widget is selected', async () => { + await fixture.whenStable(); + expect(buttonsByName('Add')[0].attributes.disabled).toBeDefined(); + }); + }); + describe('Widget name and description translation', () => { const translations: Record = { 'WIDGET.NAME_KEY': 'Translated Widget Name', diff --git a/projects/dashboards-ng/src/components/widget-catalog/si-widget-catalog.component.ts b/projects/dashboards-ng/src/components/widget-catalog/si-widget-catalog.component.ts index 0c0e77dd2f..48f4bcbae6 100644 --- a/projects/dashboards-ng/src/components/widget-catalog/si-widget-catalog.component.ts +++ b/projects/dashboards-ng/src/components/widget-catalog/si-widget-catalog.component.ts @@ -4,15 +4,16 @@ */ import { CdkListbox, CdkOption } from '@angular/cdk/listbox'; import { + booleanAttribute, Component, computed, + effect, inject, input, isSignal, OnInit, output, - signal, - viewChild + signal } from '@angular/core'; import { SiActionDialogService } from '@siemens/element-ng/action-modal'; import { SiCircleStatusComponent } from '@siemens/element-ng/circle-status'; @@ -47,6 +48,10 @@ import { SiWidgetEditorBase } from '../si-widget-editor-base'; styleUrl: './si-widget-catalog.component.scss' }) export class SiWidgetCatalogComponent extends SiWidgetEditorBase implements OnInit { + /** @defaultValue false */ + readonly enableMultiSelect = input(false, { + transform: booleanAttribute + }); /** * Placeholder text for the search input field in the widget catalog. * @@ -60,10 +65,11 @@ export class SiWidgetCatalogComponent extends SiWidgetEditorBase implements OnIn ); /** * Emits when the catalog is `closed`, either by canceling or by adding or saving - * a widget configuration. On cancel `undefined` is emitted, otherwise the related - * widget configuration is emitted. + * widget configurations. On cancel `undefined` is emitted, otherwise an array of + * the related widget configurations is emitted. In single-select mode the array + * always contains exactly one entry. */ - readonly closed = output | undefined>(); + readonly closed = output[] | undefined>(); /** * View defines if the catalog widget list or the widget editor is visible. @@ -90,10 +96,21 @@ export class SiWidgetCatalogComponent extends SiWidgetEditorBase implements OnIn * Array used to hold the search result on the widget catalog. * @defaultValue [] */ protected filteredWidgetCatalog: Widget[] = []; + protected readonly selectedWidgets = signal([]); + protected readonly hasSelection = computed(() => this.selectedWidgets().length > 0); + /** + * @deprecated Use `selectedWidgets` and `hasSelection` instead. + * This property only holds the first selected widget and is not updated when multiple selection is allowed. + * It will be removed in one of the next major releases. + */ protected readonly selected = signal(undefined); + private readonly singleSelectedWidget = computed(() => { + const selectedWidgets = this.selectedWidgets(); + return selectedWidgets.length === 1 ? selectedWidgets[0] : undefined; + }); private widgetConfig?: Omit; - private readonly hasEditor = computed( - () => !!this.selected()?.componentFactory.editorComponentName + private readonly singleSelectedWidgetHasEditor = computed( + () => !!this.singleSelectedWidget()?.componentFactory.editorComponentName ); private readonly translateService = injectSiTranslateService(); @@ -123,20 +140,24 @@ export class SiWidgetCatalogComponent extends SiWidgetEditorBase implements OnIn ); protected readonly showAddButton = computed(() => - this.view() === 'list' ? !this.hasEditor() : true + this.view() === 'list' ? !this.singleSelectedWidgetHasEditor() : true ); protected readonly showNextButton = computed(() => - this.view() === 'list' ? this.hasEditor() : this.editorWizardState() !== undefined + this.view() === 'list' + ? this.singleSelectedWidgetHasEditor() + : this.editorWizardState() !== undefined ); protected readonly showPreviousButton = computed(() => this.view() === 'editor'); - protected readonly disableAddButton = computed(() => !this.selected() || this.invalidConfig()); + protected readonly disableAddButton = computed(() => + this.view() === 'list' ? !this.hasSelection() : this.invalidConfig() + ); protected readonly disableNextButton = computed(() => { const wizardState = this.editorWizardState(); if (this.view() === 'list') { - return !this.selected(); + return !this.singleSelectedWidgetHasEditor(); } else if (!wizardState) { return true; } else if (!wizardState.hasNext) { @@ -149,12 +170,21 @@ export class SiWidgetCatalogComponent extends SiWidgetEditorBase implements OnIn }); private dialogService = inject(SiActionDialogService); - private readonly widgetCdkListbox = viewChild(CdkListbox); + + constructor() { + super(); + effect(() => { + const selected = this.selected(); + if (selected) { + this.selectedWidgets.set([selected]); + } + }); + } ngOnInit(): void { this.filteredWidgetCatalog = this.widgetCatalog; - if (this.widgetCatalog.length > 0) { - this.selectWidget(this.widgetCatalog[0]); + if (this.widgetCatalog.length > 0 && !this.enableMultiSelect()) { + this.selectWidgets([this.widgetCatalog[0]]); } } @@ -170,10 +200,10 @@ export class SiWidgetCatalogComponent extends SiWidgetEditorBase implements OnIn return name.toLowerCase().includes(term); }); } - if (this.filteredWidgetCatalog.length > 0) { - this.selectWidget(this.filteredWidgetCatalog[0]); - } else { - this.selectWidget(undefined); + // In multi selection mode, filter is independent of the selection, + // so we don't need to sync the selection with the filtered catalog. + if (!this.enableMultiSelect()) { + this.syncSelectionWithFilteredCatalog(); } } @@ -234,7 +264,7 @@ export class SiWidgetCatalogComponent extends SiWidgetEditorBase implements OnIn } private setupWidgetInstanceEditor(): void { - const selected = this.selected(); + const selected = this.singleSelectedWidget(); if (!selected) { return; } @@ -260,13 +290,33 @@ export class SiWidgetCatalogComponent extends SiWidgetEditorBase implements OnIn } protected onAddWidget(): void { - const selected = this.selected(); - if (!selected) { + if (this.view() === 'list') { + const selectedWidgets = this.selectedWidgets(); + if (selectedWidgets.length === 0) { + return; + } + + if (!this.enableMultiSelect() && selectedWidgets.length === 1) { + const [selectedWidget] = selectedWidgets; + if (selectedWidget.componentFactory.editorComponentName) { + return; + } + this.closed.emit([createWidgetConfig(selectedWidget)]); + return; + } + + const configs = selectedWidgets.map(widget => this.createConfigForSelection(widget)); + this.closed.emit(configs); + return; + } + + const selectedWidget = this.singleSelectedWidget(); + if (!selectedWidget) { return; } if (!this.widgetConfig) { - this.widgetConfig = createWidgetConfig(selected); + this.widgetConfig = createWidgetConfig(selectedWidget); } else { // Make sure we use the same config object as the editor if (isSignal(this.widgetInstanceEditor?.config)) { @@ -275,16 +325,42 @@ export class SiWidgetCatalogComponent extends SiWidgetEditorBase implements OnIn this.widgetConfig = this.widgetInstanceEditor?.config ?? this.widgetConfig; } } - this.closed.emit(this.widgetConfig); + + this.closed.emit([this.widgetConfig]); } - protected selectWidget(widget?: Widget): void { - this.selected.set(widget); - if (widget) { - // need to keep this in setTimeout to avoid ExpressionChangedAfterItHasBeenCheckedError - setTimeout(() => { - this.widgetCdkListbox()?.selectValue(widget); - }); + protected selectWidgets(widgets: readonly Widget[]): void { + if (this.enableMultiSelect()) { + this.selectedWidgets.set([...widgets]); + } else { + this.selected.set(widgets.length > 0 ? widgets[0] : undefined); + } + } + + private syncSelectionWithFilteredCatalog(): void { + const filteredWidgets = new Set(this.filteredWidgetCatalog); + const selectedFilteredWidgets = this.selectedWidgets().filter(widget => + filteredWidgets.has(widget) + ); + + if (selectedFilteredWidgets.length > 0) { + this.selectedWidgets.set(selectedFilteredWidgets); + return; + } + + if (this.filteredWidgetCatalog.length > 0) { + this.selectedWidgets.set([this.filteredWidgetCatalog[0]]); + return; + } + + this.selectedWidgets.set([]); + } + + private createConfigForSelection(widget: Widget): Omit { + const config = createWidgetConfig(widget); + if (this.enableMultiSelect() && widget.componentFactory.editorComponentName) { + return { ...config, setupPending: true }; } + return config; } } diff --git a/projects/dashboards-ng/src/components/widget-host/si-widget-host.component.html b/projects/dashboards-ng/src/components/widget-host/si-widget-host.component.html index 0ac3f1f76c..f913a899d9 100644 --- a/projects/dashboards-ng/src/components/widget-host/si-widget-host.component.html +++ b/projects/dashboards-ng/src/components/widget-host/si-widget-host.component.html @@ -20,6 +20,7 @@
    } +
    + @if (setupPending()) { + + }
    - - @if (widgetInstanceFooter) { + @if (widgetInstanceFooter && !setupPending()) { diff --git a/projects/dashboards-ng/src/components/widget-host/si-widget-host.component.scss b/projects/dashboards-ng/src/components/widget-host/si-widget-host.component.scss index d0051b3121..2e03a524cd 100644 --- a/projects/dashboards-ng/src/components/widget-host/si-widget-host.component.scss +++ b/projects/dashboards-ng/src/components/widget-host/si-widget-host.component.scss @@ -68,3 +68,12 @@ position: absolute; z-index: 100; } + +// Ensure the empty state can scroll within small widget cards +// instead of overflowing the card body. +si-empty-state { + display: block; + block-size: 100%; + inline-size: 100%; + overflow: auto; +} diff --git a/projects/dashboards-ng/src/components/widget-host/si-widget-host.component.spec.ts b/projects/dashboards-ng/src/components/widget-host/si-widget-host.component.spec.ts index a325556889..6596e94a55 100644 --- a/projects/dashboards-ng/src/components/widget-host/si-widget-host.component.spec.ts +++ b/projects/dashboards-ng/src/components/widget-host/si-widget-host.component.spec.ts @@ -6,7 +6,7 @@ import { CommonModule } from '@angular/common'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { outputToObservable } from '@angular/core/rxjs-interop'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserModule } from '@angular/platform-browser'; +import { BrowserModule, By } from '@angular/platform-browser'; import { DeleteConfirmationDialogResult, SiActionDialogService @@ -81,6 +81,41 @@ describe('SiWidgetHostComponent', () => { vi.useRealTimers(); }); + it('should show configuration placeholder when widget requires configuration', async () => { + fixture.componentRef.setInput('widgetConfig', { + ...TEST_WIDGET_CONFIG_0, + setupPending: true + }); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.widgetHost().length).toBe(0); + expect(fixture.debugElement.query(By.css('si-empty-state'))).toBeTruthy(); + }); + + it('should attach widget instance after configuration is completed', async () => { + fixture.componentRef.setInput('widgetConfig', { + ...TEST_WIDGET_CONFIG_0, + setupPending: true + }); + fixture.detectChanges(); + await fixture.whenStable(); + expect(component.widgetHost().length).toBe(0); + + fixture.componentRef.setInput('widgetConfig', { + ...TEST_WIDGET_CONFIG_0, + setupPending: false + }); + fixture.detectChanges(); + + vi.useFakeTimers(); + vi.advanceTimersByTime(0); + await fixture.whenStable(); + expect(component.widgetHost().length).toBe(1); + vi.useRealTimers(); + }); + it('#editAction should call onEdit', async () => { fixture.detectChanges(); vi.useFakeTimers(); diff --git a/projects/dashboards-ng/src/components/widget-host/si-widget-host.component.ts b/projects/dashboards-ng/src/components/widget-host/si-widget-host.component.ts index 0505e4ace3..80bddd2ed6 100644 --- a/projects/dashboards-ng/src/components/widget-host/si-widget-host.component.ts +++ b/projects/dashboards-ng/src/components/widget-host/si-widget-host.component.ts @@ -4,6 +4,7 @@ */ import { NgTemplateOutlet } from '@angular/common'; import { + AfterViewInit, Component, ComponentRef, computed, @@ -14,7 +15,6 @@ import { input, isSignal, OnChanges, - OnInit, output, SimpleChanges, TemplateRef, @@ -29,6 +29,7 @@ import { SiActionDialogService } from '@siemens/element-ng/action-modal'; import type { MenuItem as MenuItemLegacy } from '@siemens/element-ng/common'; import { ContentActionBarMainItem, ViewType } from '@siemens/element-ng/content-action-bar'; import { SiDashboardCardComponent } from '@siemens/element-ng/dashboard'; +import { SiEmptyStateComponent } from '@siemens/element-ng/empty-state'; import { MenuItem } from '@siemens/element-ng/menu'; import { t } from '@siemens/element-translate-ng/translate'; @@ -42,14 +43,14 @@ import { setupWidgetInstance } from '../../widget-loader'; @Component({ selector: 'si-widget-host', - imports: [SiDashboardCardComponent, NgTemplateOutlet], + imports: [SiDashboardCardComponent, SiEmptyStateComponent, NgTemplateOutlet], templateUrl: './si-widget-host.component.html', styleUrl: './si-widget-host.component.scss', host: { class: 'grid-stack-item' } }) -export class SiWidgetHostComponent implements OnInit, OnChanges { +export class SiWidgetHostComponent implements AfterViewInit, OnChanges { private readonly siModal = inject(SiActionDialogService); private readonly injector = inject(Injector); private readonly envInjector = inject(EnvironmentInjector); @@ -62,6 +63,13 @@ export class SiWidgetHostComponent implements OnInit, OnChanges { */ readonly componentFactory = input(); + /** + * CSS icon class for the widget, displayed in the configuration placeholder. + * + * @defaultValue 'element-apps' + */ + readonly iconClass = input('element-apps'); + /** * Sets the widget host into editable mode. * @@ -78,6 +86,10 @@ export class SiWidgetHostComponent implements OnInit, OnChanges { protected labelEdit = t(() => $localize`:@@DASHBOARD.WIDGET.EDIT:Edit`); protected labelRemove = t(() => $localize`:@@DASHBOARD.WIDGET.REMOVE:Remove`); + protected labelSetup = t(() => $localize`:@@DASHBOARD.WIDGET.SETUP:Setup required`); + protected labelSetupMessage = t( + () => $localize`:@@DASHBOARD.WIDGET.SETUP_MESSAGE:Edit widget to display data` + ); protected labelExpand = t(() => $localize`:@@DASHBOARD.WIDGET.EXPAND:Expand`); protected labelRestore = t(() => $localize`:@@DASHBOARD.WIDGET.RESTORE:Restore`); protected labelDialogMessage = t( @@ -94,8 +106,11 @@ export class SiWidgetHostComponent implements OnInit, OnChanges { () => $localize`:@@DASHBOARD.REMOVE_WIDGET_CONFIRMATION_DIALOG.CANCEL:Cancel` ); + protected readonly emptyStateIcon = computed(() => `${this.iconClass()} si-display-xl`); + widgetInstance?: WidgetInstance; widgetRef?: ComponentRef; + private attaching = false; /** @defaultValue [] */ primaryActions: (MenuItemLegacy | ContentActionBarMainItem)[] = []; /** @defaultValue [] */ @@ -149,11 +164,18 @@ export class SiWidgetHostComponent implements OnInit, OnChanges { protected readonly accentLine = computed(() => { const { accentLine } = this.widgetConfig(); - return accentLine ? 'accent-' + accentLine : ''; + return accentLine && !this.setupPending() ? 'accent-' + accentLine : ''; }); + protected readonly setupPending = computed(() => !!this.widgetConfig().setupPending); ngOnChanges(changes: SimpleChanges): void { - if (changes.widgetConfig) { + if (changes.componentFactory && !changes.componentFactory.firstChange) { + this.detachWidgetInstance(); + this.syncWidgetAttachment(); + } + + if (changes.widgetConfig && !changes.widgetConfig.firstChange) { + this.syncWidgetAttachment(); if (this.widgetRef) { if (isSignal(this.widgetRef.instance.config)) { this.widgetRef.setInput('config', this.widgetConfig()); @@ -168,13 +190,30 @@ export class SiWidgetHostComponent implements OnInit, OnChanges { } } - ngOnInit(): void { - this.attachWidgetInstance(); + ngAfterViewInit(): void { + this.syncWidgetAttachment(); + } + + private syncWidgetAttachment(): void { + if (this.widgetConfig().setupPending) { + this.detachWidgetInstance(); + this.setupEditable(this.editable()); + return; + } + + if (!this.widgetRef) { + this.attachWidgetInstance(); + } } private attachWidgetInstance(): void { + if (this.widgetRef || this.attaching) { + return; + } + const componentFactory = this.componentFactory(); if (componentFactory) { + this.attaching = true; setupWidgetInstance( componentFactory, this.widgetHost(), @@ -182,6 +221,7 @@ export class SiWidgetHostComponent implements OnInit, OnChanges { this.envInjector ).subscribe({ next: (widgetRef: ComponentRef) => { + this.attaching = false; this.widgetInstance = widgetRef.instance; this.widgetRef = widgetRef; if (this.widgetInstance.configChange) { @@ -200,13 +240,24 @@ export class SiWidgetHostComponent implements OnInit, OnChanges { this.widgetInstanceFooter = this.widgetInstance.footer; this.setupEditable(this.editable()); }, - error: error => console.error('Error: ', error) + error: error => { + this.attaching = false; + console.error('Error: ', error); + } }); } else { console.error(`Cannot find widget with id ${this.widgetConfig().widgetId}`); } } + private detachWidgetInstance(): void { + this.widgetRef?.destroy(); + this.widgetRef = undefined; + this.widgetInstance = undefined; + this.widgetInstanceFooter = undefined; + this.widgetHost().clear(); + } + setupEditable(editable: boolean, widgetConfig?: WidgetConfigEvent): void { widgetConfig ??= { primaryActions: this.widgetInstance?.primaryActions, diff --git a/projects/dashboards-ng/src/components/widget-instance-editor-dialog/si-widget-instance-editor-dialog.component.ts b/projects/dashboards-ng/src/components/widget-instance-editor-dialog/si-widget-instance-editor-dialog.component.ts index 1f4ab52897..946f620b16 100644 --- a/projects/dashboards-ng/src/components/widget-instance-editor-dialog/si-widget-instance-editor-dialog.component.ts +++ b/projects/dashboards-ng/src/components/widget-instance-editor-dialog/si-widget-instance-editor-dialog.component.ts @@ -155,6 +155,10 @@ export class SiWidgetInstanceEditorDialogComponent extends SiWidgetEditorBase im } } + if (this.widgetConfig().setupPending) { + this.widgetConfig.update(widgetConfig => ({ ...widgetConfig, setupPending: false })); + } + this.closed.emit(this.widgetConfig()); } } diff --git a/projects/dashboards-ng/src/model/widgets.model.ts b/projects/dashboards-ng/src/model/widgets.model.ts index 4de5660412..5d4e525009 100644 --- a/projects/dashboards-ng/src/model/widgets.model.ts +++ b/projects/dashboards-ng/src/model/widgets.model.ts @@ -189,6 +189,14 @@ export interface WidgetConfig { expandable?: boolean; /** A widget specific payload object. Placeholder to pass in additional configuration. */ payload?: any; + /** + * If true, the widget host renders a placeholder and waits for explicit + * configuration before instantiating the widget instance component. + * + * Indicates that initial setup by the user is still pending. The flag + * flips to `false` once the setup has been completed. + */ + setupPending?: boolean; actionBarViewType?: ViewType; isNotRemovable?: boolean; immutable?: boolean; diff --git a/projects/dashboards-ng/translate/si-translatable-keys.interface.ts b/projects/dashboards-ng/translate/si-translatable-keys.interface.ts index 019359559d..02b0bbf744 100644 --- a/projects/dashboards-ng/translate/si-translatable-keys.interface.ts +++ b/projects/dashboards-ng/translate/si-translatable-keys.interface.ts @@ -15,6 +15,8 @@ export interface SiTranslatableKeys { 'DASHBOARD.WIDGET.EXPAND'?: string; 'DASHBOARD.WIDGET.REMOVE'?: string; 'DASHBOARD.WIDGET.RESTORE'?: string; + 'DASHBOARD.WIDGET.SETUP'?: string; + 'DASHBOARD.WIDGET.SETUP_MESSAGE'?: string; 'DASHBOARD.WIDGET_EDITOR_DIALOG.CANCEL'?: string; 'DASHBOARD.WIDGET_EDITOR_DIALOG.DISCARD_CONFIG_CHANGE_DIALOG.CANCEL'?: string; 'DASHBOARD.WIDGET_EDITOR_DIALOG.DISCARD_CONFIG_CHANGE_DIALOG.DISCARD'?: string;