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=" " 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;