This is an automated email from the ASF dual-hosted git repository.

riemer pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/streampipes.git


The following commit(s) were added to refs/heads/dev by this push:
     new e9f0c39cc0 feat(#4190): Add multi-select actions (#4191)
e9f0c39cc0 is described below

commit e9f0c39cc058d88633e5355f7731bf2637f5b76f
Author: Dominik Riemer <[email protected]>
AuthorDate: Thu Feb 26 14:29:57 2026 +0100

    feat(#4190): Add multi-select actions (#4191)
---
 ui/cypress/support/utils/pipeline/PipelineUtils.ts |   4 +-
 .../tests/connect/allAdapterActions.smoke.spec.ts  |  22 +-
 .../tests/pipeline/pipelineMultiSelect.spec.ts     | 131 ++++++++++
 .../sp-table/sp-paginator/sp-paginator.service.ts  |   5 +-
 ...nt.scss => sp-table-multi-actions.directive.ts} |  25 +-
 .../components/sp-table/sp-table.component.html    | 153 +++++++++++-
 .../components/sp-table/sp-table.component.scss    |  10 +-
 .../lib/components/sp-table/sp-table.component.ts  | 272 ++++++++++++++++++++-
 .../streampipes/shared-ui/src/public-api.ts        |   1 +
 .../existing-adapters.component.html               |  24 +-
 .../existing-adapters.component.ts                 |  34 ++-
 .../pipeline-overview.component.html               |   7 +-
 .../pipeline-overview.component.ts                 |  70 ++++++
 .../start-all-pipelines-dialog.component.ts        |   5 +-
 ui/src/app/pipelines/pipelines.component.html      |  22 --
 ui/src/scss/sp/forms.scss                          |  12 +
 16 files changed, 702 insertions(+), 95 deletions(-)

diff --git a/ui/cypress/support/utils/pipeline/PipelineUtils.ts 
b/ui/cypress/support/utils/pipeline/PipelineUtils.ts
index 2d66f99bd8..697b2ed36d 100644
--- a/ui/cypress/support/utils/pipeline/PipelineUtils.ts
+++ b/ui/cypress/support/utils/pipeline/PipelineUtils.ts
@@ -166,7 +166,7 @@ export class PipelineUtils {
 
     public static startPipelineWithAssetLinkage(
         pipelineInput?: PipelineInput,
-        assetNameList?: String[],
+        assetNameList?: string[],
     ) {
         // Save and start pipeline
         PipelineBtns.savePipelineBtn().click();
@@ -207,7 +207,7 @@ export class PipelineUtils {
         cy.dataCy('sp-editor-pipeline-name').type(newPipelineName);
     }
 
-    public static finalizePipelineStart(assetNameList?: String[]) {
+    public static finalizePipelineStart(assetNameList?: string[]) {
         PipelineBtns.navigateToOverviewCheckbox().children().click();
         if (assetNameList) {
             PipelineUtils.addToAsset(assetNameList);
diff --git a/ui/cypress/tests/connect/allAdapterActions.smoke.spec.ts 
b/ui/cypress/tests/connect/allAdapterActions.smoke.spec.ts
index 8b1b2f453c..c21ee2a2cd 100644
--- a/ui/cypress/tests/connect/allAdapterActions.smoke.spec.ts
+++ b/ui/cypress/tests/connect/allAdapterActions.smoke.spec.ts
@@ -17,7 +17,6 @@
  */
 
 import { ConnectUtils } from '../../support/utils/connect/ConnectUtils';
-import { ConnectBtns } from '../../support/utils/connect/ConnectBtns';
 
 describe('Testing Start/Stop All Adapters', () => {
     beforeEach('Setup Test', () => {
@@ -28,12 +27,25 @@ describe('Testing Start/Stop All Adapters', () => {
     });
 
     it('Test start/stop all adapters', () => {
-        // Clicking the stop all adapters button
-        ConnectBtns.stopAllAdapters().click();
+        cy.wait(1000);
+        // Select visible adapters and stop them via the shared multi-select 
toolbar
+        cy.dataCy('sp-table-select-all-checkbox')
+            .find('input[type="checkbox"]')
+            .check({ force: true });
+        cy.dataCy('sp-table-multi-action-select').click();
+        cy.dataCy('sp-table-multi-action-option-stop').click();
+        cy.dataCy('sp-table-multi-action-execute').click();
         // Navigating through the stop all adapters dialog box
         ConnectUtils.allAdapterActionsDialog();
-        // Clicking the start all adapters button
-        ConnectBtns.startAllAdapters().click();
+
+        cy.wait(1000);
+        // Select visible adapters again and start them via the shared 
multi-select toolbar
+        cy.dataCy('sp-table-select-all-checkbox')
+            .find('input[type="checkbox"]')
+            .check({ force: true });
+        cy.dataCy('sp-table-multi-action-select').click();
+        cy.dataCy('sp-table-multi-action-option-start').click();
+        cy.dataCy('sp-table-multi-action-execute').click();
         // Navigating through the start all adapters dialog box
         ConnectUtils.allAdapterActionsDialog();
     });
diff --git a/ui/cypress/tests/pipeline/pipelineMultiSelect.spec.ts 
b/ui/cypress/tests/pipeline/pipelineMultiSelect.spec.ts
new file mode 100644
index 0000000000..eacdb8e435
--- /dev/null
+++ b/ui/cypress/tests/pipeline/pipelineMultiSelect.spec.ts
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { ConnectUtils } from '../../support/utils/connect/ConnectUtils';
+import { PipelineUtils } from '../../support/utils/pipeline/PipelineUtils';
+import { PipelineBuilder } from '../../support/builder/PipelineBuilder';
+import { PipelineElementBuilder } from 
'../../support/builder/PipelineElementBuilder';
+
+describe('Pipeline Overview Multi Select', () => {
+    const adapterName = 'multi-select-simulator';
+    const pipelineNames = [
+        'Pipeline Multi Select 1',
+        'Pipeline Multi Select 2',
+    ];
+
+    beforeEach('Setup Test', () => {
+        cy.initStreamPipesTest();
+
+        ConnectUtils.addMachineDataSimulator(adapterName);
+
+        pipelineNames.forEach(pipelineName => {
+            const pipelineInput = PipelineBuilder.create(pipelineName)
+                .addSource(adapterName)
+                .addSink(
+                    PipelineElementBuilder.create('data_lake')
+                        .addInput('input', 'db_measurement', 'demo')
+                        .build(),
+                )
+                .build();
+
+            PipelineUtils.addPipeline(pipelineInput);
+        });
+
+        PipelineUtils.goToPipelines();
+        cy.wait(1000);
+        cy.dataCy('all-pipelines-table', { timeout: 10000 }).should(
+            'be.visible',
+        );
+    });
+
+    it('supports selecting rows and bulk action state changes', () => {
+        cy.dataCy('sp-table-selection-toolbar').should('be.visible');
+        cy.dataCy('sp-table-row-checkbox').should('have.length', 2);
+
+        cy.dataCy('sp-table-multi-action-execute').should('be.disabled');
+        cy.dataCy('sp-table-select-none').should('be.disabled');
+
+        cy.dataCy('sp-table-row-checkbox')
+            .eq(0)
+            .find('input[type="checkbox"]')
+            .check({ force: true });
+        cy.dataCy('sp-table-select-none').should('not.be.disabled');
+        cy.dataCy('sp-table-multi-action-execute').should('be.disabled');
+
+        cy.dataCy('sp-table-multi-action-select').click();
+        cy.dataCy('sp-table-multi-action-option-stop').click();
+        cy.dataCy('sp-table-multi-action-execute').should('not.be.disabled');
+
+        cy.dataCy('sp-table-row-checkbox')
+            .eq(0)
+            .find('input')
+            .should('be.checked');
+        cy.dataCy('sp-table-row-checkbox')
+            .eq(1)
+            .find('input')
+            .should('not.be.checked');
+
+        cy.dataCy('sp-table-select-visible').click();
+        cy.dataCy('sp-table-row-checkbox')
+            .eq(0)
+            .find('input')
+            .should('be.checked');
+        cy.dataCy('sp-table-row-checkbox')
+            .eq(1)
+            .find('input')
+            .should('be.checked');
+        cy.dataCy('sp-table-multi-action-execute').should('not.be.disabled');
+
+        cy.dataCy('sp-table-select-none').click();
+        cy.dataCy('sp-table-row-checkbox')
+            .eq(0)
+            .find('input')
+            .should('not.be.checked');
+        cy.dataCy('sp-table-row-checkbox')
+            .eq(1)
+            .find('input')
+            .should('not.be.checked');
+        cy.dataCy('sp-table-multi-action-execute').should('be.disabled');
+
+        cy.dataCy('sp-table-select-all-checkbox')
+            .find('input[type="checkbox"]')
+            .check({ force: true });
+        cy.dataCy('sp-table-row-checkbox')
+            .eq(0)
+            .find('input')
+            .should('be.checked');
+        cy.dataCy('sp-table-row-checkbox')
+            .eq(1)
+            .find('input')
+            .should('be.checked');
+        cy.dataCy('sp-table-multi-action-execute').should('not.be.disabled');
+
+        cy.dataCy('sp-table-select-all-checkbox')
+            .find('input[type="checkbox"]')
+            .uncheck({ force: true });
+        cy.dataCy('sp-table-row-checkbox')
+            .eq(0)
+            .find('input')
+            .should('not.be.checked');
+        cy.dataCy('sp-table-row-checkbox')
+            .eq(1)
+            .find('input')
+            .should('not.be.checked');
+        cy.dataCy('sp-table-multi-action-execute').should('be.disabled');
+    });
+});
diff --git 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-paginator/sp-paginator.service.ts
 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-paginator/sp-paginator.service.ts
index 0fea67bfd5..6cfcb9a00c 100644
--- 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-paginator/sp-paginator.service.ts
+++ 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-paginator/sp-paginator.service.ts
@@ -32,15 +32,14 @@ export class PaginatorService extends MatPaginatorIntl {
             pageSize: number,
             length: number,
         ) => {
-            const start = page * pageSize + 1;
+            const start = Math.min(page * pageSize + 1, length);
             const end = Math.min((page + 1) * pageSize, length);
-            const total = length;
             const rangeLabel =
                 start +
                 ' - ' +
                 end +
                 this.translateService.instant(' of ') +
-                total +
+                length +
                 this.translateService.instant(' items ');
             return rangeLabel;
         };
diff --git 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table-multi-actions.directive.ts
similarity index 65%
copy from 
ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
copy to 
ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table-multi-actions.directive.ts
index 4d0b280941..21511f378f 100644
--- 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
+++ 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table-multi-actions.directive.ts
@@ -1,4 +1,4 @@
-/*!
+/*
  * Licensed to the Apache Software Foundation (ASF) under one or more
  * contributor license agreements.  See the NOTICE file distributed with
  * this work for additional information regarding copyright ownership.
@@ -16,24 +16,7 @@
  *
  */
 
-.paginator-container {
-    border-top: 1px solid rgba(0, 0, 0, 0.12);
-}
+import { Directive } from '@angular/core';
 
-.mat-mdc-row:hover {
-    background-color: var(--color-bg-1);
-}
-
-.mat-mdc-no-data-row {
-    height: var(--mat-table-row-item-container-height, 52px);
-    text-align: center;
-}
-
-.cursor-pointer {
-    cursor: pointer;
-}
-
-.right-column {
-    text-align: right; /* align contents inside cell */
-    margin-left: auto; /* push this column to the far right */
-}
+@Directive({ selector: 'ng-template[spTableMultiActions]' })
+export class SpTableMultiActionsDirective {}
diff --git 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.html
 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.html
index 340dfff202..6ff7d89f3c 100644
--- 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.html
+++ 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.html
@@ -17,9 +17,156 @@
   -->
 
 <div fxLayout="column">
+    @if (showSelectionCheckboxes) {
+        <div
+            class="selection-toolbar"
+            data-cy="sp-table-selection-toolbar"
+            fxLayout="row wrap"
+            fxLayoutAlign="space-between center"
+            fxLayoutGap="8px"
+        >
+            <div
+                fxLayout="row wrap"
+                fxLayoutAlign="start center"
+                fxLayoutGap="8px"
+            >
+                <div
+                    fxLayout="row wrap"
+                    fxLayoutAlign="start center"
+                    fxLayoutGap="4px"
+                >
+                    <button
+                        mat-flat-button
+                        class="mat-basic"
+                        data-cy="sp-table-select-visible"
+                        (click)="selectVisiblePageRows()"
+                        [disabled]="!visiblePageRows.length"
+                    >
+                        <mat-icon>select_all</mat-icon>
+                        {{ 'Select visible' | translate }}
+                    </button>
+                    <button
+                        mat-flat-button
+                        class="mat-basic"
+                        data-cy="sp-table-select-none"
+                        (click)="clearSelection()"
+                        [disabled]="!selectedRows.length"
+                    >
+                        <mat-icon>deselect</mat-icon>
+                        {{ 'Select none' | translate }}
+                    </button>
+                </div>
+            </div>
+
+            @if (hasMultiActionsToolbarControls()) {
+                <div fxLayout="row" fxLayoutGap="5px">
+                    @if (hasBuiltInMultiActionSelect()) {
+                        <sp-form-field
+                            [level]="3"
+                            [label]="multiActionsSelectLabel | translate"
+                            margin="0"
+                        >
+                            <mat-form-field class="form-field-small">
+                                <mat-select
+                                    data-cy="sp-table-multi-action-select"
+                                    panelClass="small-select-panel"
+                                    [placeholder]="'Select action' | translate"
+                                    [value]="selectedMultiAction"
+                                    (valueChange)="
+                                        onSelectedMultiActionChange($event)
+                                    "
+                                >
+                                    @for (
+                                        actionOption of multiActionOptions;
+                                        track actionOption.value
+                                    ) {
+                                        <mat-option
+                                            [value]="actionOption.value"
+                                            [attr.data-cy]="
+                                                
'sp-table-multi-action-option-' +
+                                                actionOption.value
+                                            "
+                                            [disabled]="actionOption.disabled"
+                                        >
+                                            @if (actionOption.icon) {
+                                                <mat-icon
+                                                    
class="selection-toolbar__action-option-icon"
+                                                >
+                                                    {{ actionOption.icon }}
+                                                </mat-icon>
+                                            }
+                                            {{ actionOption.label | translate 
}}
+                                        </mat-option>
+                                    }
+                                </mat-select>
+                            </mat-form-field>
+                        </sp-form-field>
+                    }
+
+                    @if (multiActionsTemplate) {
+                        <ng-container
+                            *ngTemplateOutlet="
+                                multiActionsTemplate;
+                                context: multiActionsContext
+                            "
+                        >
+                        </ng-container>
+                    }
+
+                    @if (showMultiActionsExecuteButton) {
+                        <sp-form-field [level]="3" label="&nbsp;" margin="0">
+                            <button
+                                mat-flat-button
+                                data-cy="sp-table-multi-action-execute"
+                                (click)="emitMultiActionsExecute()"
+                                [disabled]="
+                                    isMultiActionsExecuteButtonDisabled()
+                                "
+                            >
+                                {{ multiActionsExecuteLabel | translate }}
+                            </button>
+                        </sp-form-field>
+                    }
+                </div>
+            }
+        </div>
+    }
+
     <table mat-table class="sp-table" [dataSource]="dataSource">
         <ng-content></ng-content>
 
+        @if (showSelectionCheckboxes) {
+            <ng-container [matColumnDef]="selectionColumnId">
+                <th
+                    mat-header-cell
+                    *matHeaderCellDef
+                    class="checkbox-multi-select"
+                >
+                    <mat-checkbox
+                        data-cy="sp-table-select-all-checkbox"
+                        [checked]="areAllVisibleRowsSelected()"
+                        [indeterminate]="areSomeVisibleRowsSelected()"
+                        (change)="toggleSelectAllVisibleRows($event.checked)"
+                        (click)="$event.stopPropagation()"
+                    >
+                    </mat-checkbox>
+                </th>
+                <td
+                    mat-cell
+                    *matCellDef="let element"
+                    class="checkbox-multi-select"
+                >
+                    <mat-checkbox
+                        data-cy="sp-table-row-checkbox"
+                        [checked]="isRowSelected(element)"
+                        (change)="toggleRowSelection(element, $event.checked)"
+                        (click)="$event.stopPropagation()"
+                    >
+                    </mat-checkbox>
+                </td>
+            </ng-container>
+        }
+
         @if (showActionsMenu) {
             <ng-container matColumnDef="actions">
                 <th mat-header-cell *matHeaderCellDef></th>
@@ -72,10 +219,10 @@
             </ng-container>
         }
 
-        <tr mat-header-row *matHeaderRowDef="columns"></tr>
+        <tr mat-header-row *matHeaderRowDef="renderedColumns"></tr>
         <tr
             mat-row
-            *matRowDef="let row; columns: columns"
+            *matRowDef="let row; columns: renderedColumns"
             (click)="rowClicked.emit(row)"
             [ngClass]="rowsClickable ? 'cursor-pointer' : ''"
         ></tr>
@@ -84,7 +231,7 @@
             <td
                 data-cy="no-table-entries"
                 class="mat-cell"
-                [colSpan]="columns.length"
+                [colSpan]="renderedColumns.length"
             >
                 {{ 'No entries available.' | translate }}
             </td>
diff --git 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
index 4d0b280941..e58390be1f 100644
--- 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
+++ 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
@@ -17,7 +17,11 @@
  */
 
 .paginator-container {
-    border-top: 1px solid rgba(0, 0, 0, 0.12);
+    border-top: 1px solid var(--color-bg-3);
+}
+
+.selection-toolbar {
+    border-bottom: 1px solid var(--color-bg-3);
 }
 
 .mat-mdc-row:hover {
@@ -37,3 +41,7 @@
     text-align: right; /* align contents inside cell */
     margin-left: auto; /* push this column to the far right */
 }
+
+.checkbox-multi-select {
+    width: 100px;
+}
diff --git 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.ts
 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.ts
index f750f75381..bdae3abb34 100644
--- 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.ts
+++ 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.ts
@@ -25,12 +25,16 @@ import {
     EventEmitter,
     inject,
     Input,
+    OnChanges,
+    OnDestroy,
     Output,
     QueryList,
     Signal,
+    SimpleChanges,
     TemplateRef,
     ViewChild,
 } from '@angular/core';
+import { SelectionModel } from '@angular/cdk/collections';
 import {
     MatCell,
     MatCellDef,
@@ -48,6 +52,7 @@ import {
 import { MatPaginator, PageEvent } from '@angular/material/paginator';
 import { SpTableActionsDirective } from './sp-table-actions.directive';
 import { MatMenu, MatMenuTrigger } from '@angular/material/menu';
+import { SpTableMultiActionsDirective } from 
'./sp-table-multi-actions.directive';
 import { LocalStorageService } from 
'../../services/local-storage-settings.service';
 import { FeatureCardService } from '../feature-card-host/feature-card.service';
 import {
@@ -55,12 +60,30 @@ import {
     LayoutAlignDirective,
     LayoutDirective,
 } from '@ngbracket/ngx-layout/flex';
-import { MatIconButton } from '@angular/material/button';
+import { LayoutGapDirective } from '@ngbracket/ngx-layout';
+import { MatButton, MatIconButton } from '@angular/material/button';
 import { MatTooltip } from '@angular/material/tooltip';
 import { MatIcon } from '@angular/material/icon';
 import { NgClass, NgTemplateOutlet } from '@angular/common';
 import { ClassDirective } from '@ngbracket/ngx-layout/extended';
 import { TranslatePipe } from '@ngx-translate/core';
+import { MatCheckbox } from '@angular/material/checkbox';
+import { MatFormField } from '@angular/material/form-field';
+import { Subscription } from 'rxjs';
+import { MatOption, MatSelect } from '@angular/material/select';
+import { FormFieldComponent } from '../form-field/form-field.component';
+
+export interface SpTableMultiActionOption {
+    value: string;
+    label: string;
+    icon?: string;
+    disabled?: boolean;
+}
+
+export interface SpTableMultiActionExecuteEvent<T> {
+    selectedRows: T[];
+    action: string | null;
+}
 
 @Component({
     selector: 'sp-table',
@@ -76,10 +99,15 @@ import { TranslatePipe } from '@ngx-translate/core';
         MatCell,
         LayoutAlignDirective,
         MatIconButton,
+        MatButton,
         MatTooltip,
         MatIcon,
+        MatCheckbox,
+        MatFormField,
         MatMenuTrigger,
         MatMenu,
+        MatSelect,
+        MatOption,
         NgTemplateOutlet,
         MatHeaderRowDef,
         MatHeaderRow,
@@ -91,9 +119,15 @@ import { TranslatePipe } from '@ngx-translate/core';
         FlexDirective,
         MatPaginator,
         TranslatePipe,
+        LayoutGapDirective,
+        FormFieldComponent,
     ],
 })
-export class SpTableComponent<T> implements AfterViewInit, AfterContentInit {
+export class SpTableComponent<T>
+    implements AfterViewInit, AfterContentInit, OnChanges, OnDestroy
+{
+    readonly selectionColumnId = 'spSelection';
+
     @ContentChildren(MatHeaderRowDef) headerRowDefs: 
QueryList<MatHeaderRowDef>;
     @ContentChildren(MatRowDef) rowDefs: QueryList<MatRowDef<T>>;
     @ContentChildren(MatColumnDef) columnDefs: QueryList<MatColumnDef>;
@@ -104,22 +138,41 @@ export class SpTableComponent<T> implements 
AfterViewInit, AfterContentInit {
     @Input() columns: string[];
     @Input() rowsClickable = false;
     @Input() showActionsMenu = false;
+    @Input() showSelectionCheckboxes = false;
+    @Input() showMultiActionsExecuteButton = false;
+    @Input() multiActionsExecuteLabel = 'Execute';
+    @Input() multiActionsExecuteDisabled = false;
+    @Input() multiActionsSelectLabel = 'Action';
+    @Input() multiActionOptions: SpTableMultiActionOption[] = [];
     @Input() featureCardId: string;
     @Input() resourceIdKey = 'elementId';
 
     @Input() dataSource: MatTableDataSource<T>;
 
     @Output() rowClicked = new EventEmitter<T>();
+    @Output() selectionChanged = new EventEmitter<T[]>();
+    @Output() multiActionsExecute = new EventEmitter<
+        SpTableMultiActionExecuteEvent<T>
+    >();
+    @Output() multiActionSelectionChanged = new EventEmitter<string | null>();
 
     @ViewChild('paginator') paginator: MatPaginator;
     @ContentChild(SpTableActionsDirective, { read: TemplateRef })
     actionsTemplate?: TemplateRef<any>;
+    @ContentChild(SpTableMultiActionsDirective, { read: TemplateRef })
+    multiActionsTemplate?: TemplateRef<any>;
 
     timedOutCloser: any;
     trigger: MatMenuTrigger | undefined = undefined;
+    visiblePageRows: T[] = [];
+    selectedMultiAction: string | null = null;
+
+    readonly selection = new SelectionModel<T>(true, []);
 
     private localStorageService = inject(LocalStorageService);
     private featureCardService = inject(FeatureCardService);
+    private renderedDataSubscription?: Subscription;
+    private viewInitialized = false;
 
     readonly pageSize: Signal<number>;
 
@@ -131,7 +184,8 @@ export class SpTableComponent<T> implements AfterViewInit, 
AfterContentInit {
     }
 
     ngAfterViewInit() {
-        this.dataSource.paginator = this.paginator;
+        this.viewInitialized = true;
+        this.bindDataSource();
     }
 
     ngAfterContentInit() {
@@ -145,6 +199,34 @@ export class SpTableComponent<T> implements AfterViewInit, 
AfterContentInit {
         this.table.setNoDataRow(this.noDataRow);
     }
 
+    ngOnChanges(changes: SimpleChanges) {
+        if (changes['dataSource']) {
+            this.selection.clear();
+            this.emitSelection();
+            this.visiblePageRows = [];
+            if (this.viewInitialized) {
+                this.bindDataSource();
+            }
+        }
+
+        if (
+            changes['showSelectionCheckboxes'] &&
+            !this.showSelectionCheckboxes &&
+            this.selection.hasValue()
+        ) {
+            this.selection.clear();
+            this.emitSelection();
+        }
+
+        if (changes['multiActionOptions']) {
+            this.ensureValidSelectedMultiAction();
+        }
+    }
+
+    ngOnDestroy() {
+        this.renderedDataSubscription?.unsubscribe();
+    }
+
     mouseEnter(trigger) {
         if (this.timedOutCloser) {
             clearTimeout(this.timedOutCloser);
@@ -173,4 +255,188 @@ export class SpTableComponent<T> implements 
AfterViewInit, AfterContentInit {
             element[this.resourceIdKey],
         );
     }
+
+    get renderedColumns(): string[] {
+        const baseColumns = this.columns ?? [];
+        if (
+            !this.showSelectionCheckboxes ||
+            baseColumns.includes(this.selectionColumnId)
+        ) {
+            return baseColumns;
+        }
+
+        return [this.selectionColumnId, ...baseColumns];
+    }
+
+    get selectedRows(): T[] {
+        return this.selection.selected;
+    }
+
+    get multiActionsContext() {
+        return {
+            $implicit: this.selectedRows,
+            selectedRows: this.selectedRows,
+            selectedCount: this.selectedRows.length,
+            visiblePageRows: this.visiblePageRows,
+            visiblePageRowCount: this.visiblePageRows.length,
+        };
+    }
+
+    hasBuiltInMultiActionSelect(): boolean {
+        return this.multiActionOptions?.length > 0;
+    }
+
+    hasMultiActionsToolbarControls(): boolean {
+        return (
+            this.hasBuiltInMultiActionSelect() ||
+            !!this.multiActionsTemplate ||
+            this.showMultiActionsExecuteButton
+        );
+    }
+
+    isRowSelected(row: T): boolean {
+        return this.selection.isSelected(row);
+    }
+
+    toggleRowSelection(row: T, checked: boolean) {
+        if (checked) {
+            this.selection.select(row);
+        } else {
+            this.selection.deselect(row);
+        }
+
+        this.emitSelection();
+    }
+
+    selectVisiblePageRows() {
+        if (!this.visiblePageRows.length) {
+            return;
+        }
+
+        this.selection.select(...this.visiblePageRows);
+        this.emitSelection();
+    }
+
+    clearSelection() {
+        if (!this.selection.hasValue()) {
+            return;
+        }
+
+        this.selection.clear();
+        this.emitSelection();
+    }
+
+    toggleSelectAllVisibleRows(checked: boolean) {
+        if (checked) {
+            this.selectVisiblePageRows();
+            return;
+        }
+
+        if (!this.visiblePageRows.length) {
+            return;
+        }
+
+        this.selection.deselect(...this.visiblePageRows);
+        this.emitSelection();
+    }
+
+    areAllVisibleRowsSelected(): boolean {
+        return (
+            this.visiblePageRows.length > 0 &&
+            this.visiblePageRows.every(row => this.selection.isSelected(row))
+        );
+    }
+
+    areSomeVisibleRowsSelected(): boolean {
+        return (
+            this.visiblePageRows.some(row => this.selection.isSelected(row)) &&
+            !this.areAllVisibleRowsSelected()
+        );
+    }
+
+    private bindDataSource() {
+        if (!this.dataSource || !this.paginator) {
+            return;
+        }
+
+        this.dataSource.paginator = this.paginator;
+
+        this.renderedDataSubscription?.unsubscribe();
+        this.renderedDataSubscription = this.dataSource.connect().subscribe({
+            next: rows => {
+                this.visiblePageRows = rows ?? [];
+                this.pruneSelection();
+            },
+        });
+    }
+
+    private pruneSelection() {
+        if (!this.selection.hasValue() || !this.dataSource) {
+            return;
+        }
+
+        const availableRows = new Set(this.dataSource.filteredData ?? []);
+        const rowsToRemove = this.selection.selected.filter(
+            row => !availableRows.has(row),
+        );
+
+        if (!rowsToRemove.length) {
+            return;
+        }
+
+        this.selection.deselect(...rowsToRemove);
+        this.emitSelection();
+    }
+
+    private emitSelection() {
+        this.selectionChanged.emit(this.selection.selected);
+    }
+
+    emitMultiActionsExecute() {
+        this.multiActionsExecute.emit({
+            selectedRows: this.selection.selected,
+            action: this.selectedMultiAction,
+        });
+    }
+
+    onSelectedMultiActionChange(action: string | null) {
+        this.selectedMultiAction = action;
+        this.multiActionSelectionChanged.emit(action);
+    }
+
+    isMultiActionsExecuteButtonDisabled(): boolean {
+        if (
+            !this.selection.selected.length ||
+            this.multiActionsExecuteDisabled
+        ) {
+            return true;
+        }
+
+        if (this.hasBuiltInMultiActionSelect() && !this.selectedMultiAction) {
+            return true;
+        }
+
+        const selectedOption = this.multiActionOptions?.find(
+            option => option.value === this.selectedMultiAction,
+        );
+
+        return !!selectedOption?.disabled;
+    }
+
+    private ensureValidSelectedMultiAction() {
+        if (!this.selectedMultiAction) {
+            return;
+        }
+
+        const actionStillExists = (this.multiActionOptions ?? []).some(
+            option => option.value === this.selectedMultiAction,
+        );
+
+        if (actionStillExists) {
+            return;
+        }
+
+        this.selectedMultiAction = null;
+        this.multiActionSelectionChanged.emit(null);
+    }
 }
diff --git a/ui/projects/streampipes/shared-ui/src/public-api.ts 
b/ui/projects/streampipes/shared-ui/src/public-api.ts
index 94f476cbe4..5b4a0b2060 100644
--- a/ui/projects/streampipes/shared-ui/src/public-api.ts
+++ b/ui/projects/streampipes/shared-ui/src/public-api.ts
@@ -44,6 +44,7 @@ export * from 
'./lib/components/sp-exception-message/exception-details/exception
 export * from './lib/components/sp-label/sp-label.component';
 export * from './lib/components/sp-table/sp-table.component';
 export * from './lib/components/sp-table/sp-table-actions.directive';
+export * from './lib/components/sp-table/sp-table-multi-actions.directive';
 export * from './lib/components/alert-banner/alert-banner.component';
 export * from './lib/components/time-selector/time-selector.model';
 export * from './lib/components/time-selector/time-range-selector.component';
diff --git 
a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.html
 
b/ui/src/app/connect/components/existing-adapters/existing-adapters.component.html
index c389dcb574..4606e92016 100644
--- 
a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.html
+++ 
b/ui/src/app/connect/components/existing-adapters/existing-adapters.component.html
@@ -34,26 +34,6 @@
                 'New adapter' | translate
             }}
         </button>
-        <button
-            mat-flat-button
-            class="mat-basic"
-            data-cy="start-all-adapters-btn"
-            [disabled]="checkCurrentSelectionStatus(false)"
-            (click)="startAllAdapters(true)"
-        >
-            <mat-icon>play_arrow</mat-icon>
-            <span>{{ 'Start all adapters' | translate }}</span>
-        </button>
-        <button
-            mat-flat-button
-            class="mat-basic"
-            data-cy="stop-all-adapters-btn"
-            [disabled]="checkCurrentSelectionStatus(true)"
-            (click)="startAllAdapters(false)"
-        >
-            <mat-icon>stop</mat-icon>
-            <span>{{ 'Stop all adapters' | translate }}</span>
-        </button>
         <div fxFlex fxLayout="row" fxLayoutAlign="end center">
             <sp-connect-filter-toolbar
                 class="filter-bar-margin"
@@ -99,8 +79,12 @@
                 resourceIdKey="elementId"
                 [columns]="displayedColumns"
                 [dataSource]="dataSource"
+                [showSelectionCheckboxes]="true"
+                [showMultiActionsExecuteButton]="true"
+                [multiActionOptions]="bulkAdapterActionOptions"
                 [showActionsMenu]="true"
                 [rowsClickable]="true"
+                (multiActionsExecute)="startStopSelectedAdapters($event)"
                 (rowClicked)="navigateToDetailsOverviewPage($event)"
                 data-cy="all-adapters-table"
                 matSort
diff --git 
a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.ts
 
b/ui/src/app/connect/components/existing-adapters/existing-adapters.component.ts
index 75c8ded033..ec24f0f501 100644
--- 
a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.ts
+++ 
b/ui/src/app/connect/components/existing-adapters/existing-adapters.component.ts
@@ -45,6 +45,8 @@ import {
     SpBreadcrumbService,
     SpExceptionDetailsDialogComponent,
     SpLabelComponent,
+    SpTableMultiActionExecuteEvent,
+    SpTableMultiActionOption,
     SpTableActionsDirective,
     SpTableComponent,
 } from '@streampipes/shared-ui';
@@ -132,6 +134,10 @@ export class ExistingAdaptersComponent implements OnInit, 
OnDestroy {
 
     adapterMetrics: Record<string, SpMetricsEntry> = {};
     tutorialActive = false;
+    readonly bulkAdapterActionOptions: SpTableMultiActionOption[] = [
+        { value: 'start', label: 'Start selected', icon: 'play_arrow' },
+        { value: 'stop', label: 'Stop selected', icon: 'stop' },
+    ];
 
     assetFilter$: Subscription;
     user$: Subscription;
@@ -207,26 +213,28 @@ export class ExistingAdaptersComponent implements OnInit, 
OnDestroy {
         );
     }
 
-    checkCurrentSelectionStatus(status) {
-        let active = true;
-        this.existingAdapters.forEach(adapter => {
-            if (adapter.running == status) {
-                active = false;
-            }
-        });
-        return active;
-    }
+    startStopSelectedAdapters(
+        event: SpTableMultiActionExecuteEvent<AdapterDescription>,
+    ) {
+        if (event.action !== 'start' && event.action !== 'stop') {
+            return;
+        }
+
+        const selectedAdapters = event.selectedRows ?? [];
+        if (!selectedAdapters.length) {
+            return;
+        }
 
-    startAllAdapters(action: boolean) {
+        const action = event.action === 'start';
         const dialogRef: DialogRef<AllAdapterActionsComponent> =
             this.dialogService.open(AllAdapterActionsComponent, {
                 panelType: PanelType.STANDARD_PANEL,
                 title: action
-                    ? this.translate.instant('Start all adapters')
-                    : this.translate.instant('Stop all adapters'),
+                    ? this.translate.instant('Start selected adapters')
+                    : this.translate.instant('Stop selected adapters'),
                 width: '70vw',
                 data: {
-                    adapters: this.existingAdapters,
+                    adapters: selectedAdapters,
                     action: action,
                 },
             });
diff --git 
a/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview.component.html
 
b/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview.component.html
index 4d41790d41..6d36ff69df 100644
--- 
a/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview.component.html
+++ 
b/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview.component.html
@@ -19,10 +19,16 @@
 <sp-table
     [dataSource]="dataSource"
     [columns]="displayedColumns"
+    [showSelectionCheckboxes]="hasPipelineWritePrivileges"
+    [showMultiActionsExecuteButton]="true"
+    [multiActionOptions]="
+        hasPipelineWritePrivileges ? bulkPipelineActionOptions : []
+    "
     featureCardId="pipeline"
     resourceIdKey="_id"
     [showActionsMenu]="true"
     [rowsClickable]="true"
+    (multiActionsExecute)="executeSelectedPipelineAction($event)"
     (rowClicked)="
         pipelineOperationsService.showPipelineDetails($event.elementId)
     "
@@ -133,7 +139,6 @@
                 }
                 @if (pipeline.running) {
                     <button
-                        color="accent"
                         mat-icon-button
                         [matTooltip]="'Stop pipeline' | translate"
                         matTooltipPosition="above"
diff --git 
a/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview.component.ts
 
b/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview.component.ts
index 299c77a774..e37e1a43ab 100644
--- 
a/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview.component.ts
+++ 
b/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview.component.ts
@@ -27,6 +27,7 @@ import {
     Output,
     ViewChild,
 } from '@angular/core';
+import { StartAllPipelinesDialogComponent } from 
'../../dialog/start-all-pipelines/start-all-pipelines-dialog.component';
 import { PipelineOperationsService } from 
'../../services/pipeline-operations.service';
 import {
     MatCell,
@@ -41,6 +42,11 @@ import { AuthService } from '../../../services/auth.service';
 import { UserPrivilege } from '../../../_enums/user-privilege.enum';
 import {
     CurrentUserService,
+    DialogRef,
+    DialogService,
+    PanelType,
+    SpTableMultiActionExecuteEvent,
+    SpTableMultiActionOption,
     SpTableActionsDirective,
     SpTableComponent,
 } from '@streampipes/shared-ui';
@@ -105,12 +111,18 @@ export class PipelineOverviewComponent implements OnInit, 
OnDestroy {
     starting = false;
     stopping = false;
     hasPipelineWritePrivileges = false;
+    readonly bulkPipelineActionOptions: SpTableMultiActionOption[] = [
+        { value: 'start', label: 'Start selected', icon: 'play_arrow' },
+        { value: 'stop', label: 'Stop selected', icon: 'stop' },
+        { value: 'forceStop', label: 'Force stop selected', icon: 'stop' },
+    ];
 
     userSub: Subscription;
 
     public pipelineOperationsService = inject(PipelineOperationsService);
     private authService = inject(AuthService);
     private currentUserService = inject(CurrentUserService);
+    private dialogService = inject(DialogService);
 
     ngOnInit() {
         this.userSub = this.currentUserService.user$.subscribe(user => {
@@ -161,6 +173,64 @@ export class PipelineOverviewComponent implements OnInit, 
OnDestroy {
         });
     }
 
+    startStopSelectedPipelines(
+        selectedPipelines: Pipeline[],
+        action: boolean,
+        forceStop = false,
+    ) {
+        const pipelines = selectedPipelines.filter(pipeline =>
+            action ? !pipeline.running && pipeline.valid : pipeline.running,
+        );
+
+        if (!pipelines.length) {
+            return;
+        }
+
+        const dialogRef: DialogRef<StartAllPipelinesDialogComponent> =
+            this.dialogService.open(StartAllPipelinesDialogComponent, {
+                panelType: PanelType.STANDARD_PANEL,
+                title: (action ? 'Start' : 'Stop') + ' selected pipelines',
+                width: '70vw',
+                data: {
+                    pipelines,
+                    action,
+                    forceStop,
+                },
+            });
+
+        dialogRef.afterClosed().subscribe(refresh => {
+            if (refresh) {
+                this.refreshPipelinesEmitter.emit(true);
+            }
+        });
+    }
+
+    executeSelectedPipelineAction(
+        event: SpTableMultiActionExecuteEvent<Pipeline>,
+    ) {
+        if (
+            !this.hasPipelineWritePrivileges ||
+            this.starting ||
+            this.stopping
+        ) {
+            return;
+        }
+
+        if (
+            event.action !== 'start' &&
+            event.action !== 'stop' &&
+            event.action !== 'forceStop'
+        ) {
+            return;
+        }
+
+        this.startStopSelectedPipelines(
+            event.selectedRows,
+            event.action === 'start',
+            event.action === 'forceStop',
+        );
+    }
+
     ngOnDestroy() {
         this.userSub?.unsubscribe();
     }
diff --git 
a/ui/src/app/pipelines/dialog/start-all-pipelines/start-all-pipelines-dialog.component.ts
 
b/ui/src/app/pipelines/dialog/start-all-pipelines/start-all-pipelines-dialog.component.ts
index 0b9d576e02..3d62cda9ce 100644
--- 
a/ui/src/app/pipelines/dialog/start-all-pipelines/start-all-pipelines-dialog.component.ts
+++ 
b/ui/src/app/pipelines/dialog/start-all-pipelines/start-all-pipelines-dialog.component.ts
@@ -33,6 +33,9 @@ export class StartAllPipelinesDialogComponent implements 
OnInit {
     @Input()
     pipelines: Pipeline[];
 
+    @Input()
+    forceStop = false;
+
     pipelinesToModify: Pipeline[];
     installationStatus: any;
     installationFinished: boolean;
@@ -133,7 +136,7 @@ export class StartAllPipelinesDialogComponent implements 
OnInit {
 
     stopPipeline(pipeline, index) {
         this.pipelineService
-            .stopPipeline(pipeline._id)
+            .stopPipeline(pipeline._id, this.forceStop)
             .subscribe(
                 data => {
                     this.installationStatus[index].status = data.success
diff --git a/ui/src/app/pipelines/pipelines.component.html 
b/ui/src/app/pipelines/pipelines.component.html
index c6d9841754..4ff79306f7 100644
--- a/ui/src/app/pipelines/pipelines.component.html
+++ b/ui/src/app/pipelines/pipelines.component.html
@@ -38,28 +38,6 @@
                 }}
             </button>
         }
-        @if (hasPipelineWritePrivileges) {
-            <button
-                mat-flat-button
-                class="mat-basic"
-                (click)="startAllPipelines(true)"
-                [disabled]="checkCurrentSelectionStatus(false)"
-            >
-                <mat-icon>play_arrow</mat-icon>
-                <span>{{ 'Start All Pipelines' | translate }}</span>
-            </button>
-        }
-        @if (hasPipelineWritePrivileges) {
-            <button
-                mat-flat-button
-                class="mat-basic"
-                (click)="startAllPipelines(false)"
-                [disabled]="checkCurrentSelectionStatus(true)"
-            >
-                <mat-icon>stop</mat-icon>
-                <span>{{ 'Stop all pipelines' | translate }}</span>
-            </button>
-        }
         <span fxFlex></span>
         <button
             mat-icon-button
diff --git a/ui/src/scss/sp/forms.scss b/ui/src/scss/sp/forms.scss
index bd31a182e5..335228e26c 100644
--- a/ui/src/scss/sp/forms.scss
+++ b/ui/src/scss/sp/forms.scss
@@ -97,6 +97,18 @@ mat-form-field.mat-mdc-form-field.form-field-size-smaller {
     }
 }
 
+.small-select-panel {
+    --mat-option-label-text-size: var(--font-size-md);
+    font-size: var(--font-size-md);
+
+    .mat-icon {
+        width: var(--font-size-md);
+        height: var(--font-size-md);
+        font-size: var(--font-size-md);
+        line-height: var(--font-size-md);
+    }
+}
+
 .form-field-smaller {
     .mat-mdc-form-field-input-control.mat-mdc-form-field-input-control {
         letter-spacing: 0;


Reply via email to