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

tenthe 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 66dc262b02 feat: refactored Asset Link Table (#4646)
66dc262b02 is described below

commit 66dc262b020e497aeaedc9157708c2e8f3b400df
Author: Jacqueline Höllig <[email protected]>
AuthorDate: Tue Jul 7 08:06:36 2026 +0200

    feat: refactored Asset Link Table (#4646)
    
    Co-authored-by: Philipp Zehnder <[email protected]>
---
 ui/cypress/support/utils/asset/AssetBtns.ts        |   2 +-
 .../assets/dialog/base-asset-links.directive.ts    |  49 ++-
 ui/src/app/assets/dialog/base-asset-links.model.ts |  33 ++
 .../asset-link-table.component.html                | 226 ++++++++++++
 .../asset-link-table.component.scss                |  64 ++++
 .../asset-link-table/asset-link-table.component.ts | 325 +++++++++++++++++
 .../asset-link-table/asset-link-table.model.ts     |  29 ++
 .../manage-asset-links-dialog.component.html       | 402 +--------------------
 .../manage-asset-links-dialog.component.ts         | 162 ++++++---
 .../components/shortcuts/shortcuts.component.ts    |   3 +-
 10 files changed, 839 insertions(+), 456 deletions(-)

diff --git a/ui/cypress/support/utils/asset/AssetBtns.ts 
b/ui/cypress/support/utils/asset/AssetBtns.ts
index ff867b497b..64b33e90e9 100644
--- a/ui/cypress/support/utils/asset/AssetBtns.ts
+++ b/ui/cypress/support/utils/asset/AssetBtns.ts
@@ -87,7 +87,7 @@ export class AssetBtns {
     }
 
     public static adapterCheckbox(adapterName: string) {
-        return cy.dataCy('select-adapters-checkbox-' + adapterName, {
+        return cy.dataCy('select-adapter-checkbox-' + adapterName, {
             timeout: 10000,
         });
     }
diff --git a/ui/src/app/assets/dialog/base-asset-links.directive.ts 
b/ui/src/app/assets/dialog/base-asset-links.directive.ts
index e11781ba01..ebaab2e9f8 100644
--- a/ui/src/app/assets/dialog/base-asset-links.directive.ts
+++ b/ui/src/app/assets/dialog/base-asset-links.directive.ts
@@ -18,23 +18,24 @@
 
 import { Directive, inject } from '@angular/core';
 import {
-    AdapterDescription,
     AdapterService,
     ChartService,
-    Dashboard,
     DashboardService,
-    DataExplorerWidgetModel,
-    DataLakeMeasure,
     DatalakeRestService,
     FileMetadata,
     FilesService,
     GenericStorageService,
-    Pipeline,
     PipelineElementService,
     PipelineService,
     SpDataStream,
 } from '@streampipes/platform-services';
 import { zip } from 'rxjs';
+import { map } from 'rxjs/operators';
+import {
+    AssetLinkChartResource,
+    AssetLinkMeasurementResource,
+    AssetLinkNamedResource,
+} from './base-asset-links.model';
 
 @Directive()
 export abstract class BaseAssetLinksDirective {
@@ -48,12 +49,12 @@ export abstract class BaseAssetLinksDirective {
     protected filesService = inject(FilesService);
 
     // Resources
-    pipelines: Pipeline[];
-    charts: DataExplorerWidgetModel[];
-    dashboards: Dashboard[];
-    dataLakeMeasures: DataLakeMeasure[];
+    pipelines: AssetLinkNamedResource[];
+    charts: AssetLinkChartResource[];
+    dashboards: AssetLinkNamedResource[];
+    dataLakeMeasures: AssetLinkMeasurementResource[];
     dataSources: SpDataStream[];
-    adapters: AdapterDescription[];
+    adapters: AssetLinkNamedResource[];
     files: FileMetadata[];
 
     allResources: any[] = [];
@@ -64,13 +65,31 @@ export abstract class BaseAssetLinksDirective {
 
     getAllResources() {
         zip(
-            this.pipelineService.getPipelines(),
-            this.chartService.getAllCharts(),
-            this.dashboardService.getDashboards(),
+            this.pipelineService
+                .getPipelineSummary()
+                .pipe(map(summary => summary.resources)),
+            this.chartService.getChartSummary().pipe(
+                map(summary =>
+                    summary.resources.map(chart => ({
+                        elementId: chart.elementId,
+                        name: chart.name,
+                        baseAppearanceConfig: {
+                            widgetTitle: chart.name,
+                        },
+                    })),
+                ),
+            ),
+            this.dashboardService
+                .getDashboardSummary()
+                .pipe(map(summary => summary.resources)),
             this.pipelineElementService.getDataStreams(),
-            this.dataLakeService.getAllMeasurementSeries(),
+            this.dataLakeService
+                .getMeasurementSummary()
+                .pipe(map(summary => summary.resources)),
             this.filesService.getFileMetadata(),
-            this.adapterService.getAdapters(),
+            this.adapterService
+                .getAdapterSummary()
+                .pipe(map(summary => summary.resources)),
         ).subscribe(
             ([
                 pipelines,
diff --git a/ui/src/app/assets/dialog/base-asset-links.model.ts 
b/ui/src/app/assets/dialog/base-asset-links.model.ts
new file mode 100644
index 0000000000..e5db03c894
--- /dev/null
+++ b/ui/src/app/assets/dialog/base-asset-links.model.ts
@@ -0,0 +1,33 @@
+/*
+ * 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.
+ *
+ */
+
+export interface AssetLinkNamedResource {
+    elementId: string;
+    name: string;
+}
+
+export interface AssetLinkChartResource extends AssetLinkNamedResource {
+    baseAppearanceConfig: {
+        widgetTitle: string;
+    };
+}
+
+export interface AssetLinkMeasurementResource {
+    elementId: string;
+    measureName: string;
+}
diff --git 
a/ui/src/app/assets/dialog/manage-asset-links/asset-link-table/asset-link-table.component.html
 
b/ui/src/app/assets/dialog/manage-asset-links/asset-link-table/asset-link-table.component.html
new file mode 100644
index 0000000000..5b92b8924c
--- /dev/null
+++ 
b/ui/src/app/assets/dialog/manage-asset-links/asset-link-table/asset-link-table.component.html
@@ -0,0 +1,226 @@
+<!--
+~ 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.
+~
+-->
+
+<div fxLayout="column" fxLayoutGap="8px">
+    <div
+        fxLayout="row wrap"
+        fxLayoutAlign="end center"
+        fxLayoutGap="8px"
+        class="asset-link-table-toolbar"
+    >
+        <button
+            mat-flat-button
+            class="mat-basic"
+            data-cy="asset-link-table-select-all"
+            [disabled]="!hasUnselectedFilteredResources()"
+            (click)="selectAll()"
+        >
+            <mat-icon>queue</mat-icon>
+            {{ 'Select all' | translate }}
+        </button>
+        <button
+            mat-flat-button
+            class="mat-basic"
+            data-cy="asset-link-table-deselect-all"
+            [disabled]="!hasSelectedFilteredResources()"
+            (click)="deselectAll()"
+        >
+            <mat-icon>filter_none</mat-icon>
+            {{ 'Deselect all' | translate }}
+        </button>
+
+        <mat-form-field class="form-field-small asset-link-table-search">
+            <mat-icon matPrefix>search</mat-icon>
+            <input
+                #searchInput
+                matInput
+                [placeholder]="'Search' | translate"
+                [value]="searchTerm"
+                (input)="updateSearch($any($event.target).value)"
+            />
+            @if (searchTerm) {
+                <button
+                    mat-icon-button
+                    matSuffix
+                    type="button"
+                    [attr.aria-label]="'Clear search' | translate"
+                    (click)="clearSearch()"
+                >
+                    <mat-icon>close</mat-icon>
+                </button>
+            }
+        </mat-form-field>
+
+        <mat-button-toggle-group
+            [value]="viewMode"
+            (change)="updateViewMode($event.value)"
+        >
+            <mat-button-toggle value="grouped">
+                {{ 'Grouped' | translate }}
+            </mat-button-toggle>
+            <mat-button-toggle value="list">
+                {{ 'List' | translate }}
+            </mat-button-toggle>
+        </mat-button-toggle-group>
+    </div>
+
+    <table
+        mat-table
+        matSort
+        [matSortActive]="sort.active"
+        [matSortDirection]="sort.direction"
+        class="sp-table"
+        [dataSource]="renderedRows"
+        [multiTemplateDataRows]="viewMode === 'grouped'"
+        (matSortChange)="updateSort($event)"
+    >
+        <ng-container matColumnDef="selected">
+            <th mat-header-cell *matHeaderCellDef></th>
+            <td mat-cell *matCellDef="let resource">
+                <mat-checkbox
+                    color="accent"
+                    [checked]="isResourceSelected(resource.resourceId)"
+                    [attr.data-cy]="
+                        'select-' +
+                        resource.resourceType
+                            .toLowerCase()
+                            .replace(/\s+/g, '-') +
+                        '-checkbox-' +
+                        resource.resourceName
+                    "
+                    (change)="updateSelection($event.checked, resource)"
+                    (click)="$event.stopPropagation()"
+                >
+                </mat-checkbox>
+            </td>
+        </ng-container>
+
+        <ng-container matColumnDef="type">
+            <th mat-header-cell mat-sort-header *matHeaderCellDef>
+                {{ 'Type' | translate }}
+            </th>
+            <td mat-cell *matCellDef="let resource">
+                <div
+                    fxLayout="column"
+                    fxLayoutAlign="start start"
+                    class="truncate"
+                >
+                    <div
+                        class="chip mt-sm mb-sm"
+                        [ngStyle]="{ color: getLinkTypeColor(resource) }"
+                    >
+                        <mat-icon class="link-type-icon">
+                            {{ getLinkType(resource)?.linkIcon || 'link' }}
+                        </mat-icon>
+                        <span class="text-sm">
+                            {{
+                                getLinkType(resource)?.linkLabel ||
+                                    resource.resourceType
+                            }}
+                        </span>
+                    </div>
+                </div>
+            </td>
+        </ng-container>
+
+        <ng-container matColumnDef="linkLabel">
+            <th mat-header-cell mat-sort-header *matHeaderCellDef>
+                {{ 'Name' | translate }}
+            </th>
+            <td mat-cell *matCellDef="let resource">
+                <div
+                    fxLayout="column"
+                    fxLayoutAlign="start start"
+                    class="truncate"
+                >
+                    <span>{{ resource.resourceName }}</span>
+                </div>
+            </td>
+        </ng-container>
+
+        <ng-container matColumnDef="groupHeader">
+            <td
+                mat-cell
+                *matCellDef="let group"
+                class="asset-link-table-group-header-cell"
+                [attr.colspan]="displayedColumns.length"
+            >
+                <div
+                    fxLayout="row"
+                    fxLayoutAlign="start center"
+                    fxLayoutGap="8px"
+                    class="asset-link-table-group-header"
+                >
+                    <button
+                        mat-icon-button
+                        type="button"
+                        class="asset-link-table-group-toggle"
+                        (click)="toggleGroup(group.id)"
+                    >
+                        <mat-icon>
+                            {{
+                                group.collapsed
+                                    ? 'chevron_right'
+                                    : 'expand_more'
+                            }}
+                        </mat-icon>
+                    </button>
+                    <div
+                        class="chip asset-link-table-group-chip"
+                        [ngStyle]="{ color: group.color }"
+                    >
+                        <mat-icon class="link-type-icon">
+                            {{ group.icon }}
+                        </mat-icon>
+                        <span class="text-sm">
+                            {{ group.title | translate }}
+                        </span>
+                    </div>
+                    <span class="asset-link-table-group-count text-sm">
+                        {{ group.count }}
+                    </span>
+                </div>
+            </td>
+        </ng-container>
+
+        <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
+        <tr
+            mat-row
+            *matRowDef="
+                let row;
+                columns: groupHeaderColumns;
+                when: isGroupHeaderRow
+            "
+            class="asset-link-table-group-row"
+        ></tr>
+        <tr
+            mat-row
+            *matRowDef="let row; columns: displayedColumns; when: isDataRow"
+        ></tr>
+
+        <tr class="mat-row" *matNoDataRow>
+            <td
+                class="mat-cell"
+                data-cy="no-table-entries"
+                [colSpan]="displayedColumns.length"
+            >
+                {{ 'No entries available.' | translate }}
+            </td>
+        </tr>
+    </table>
+</div>
diff --git 
a/ui/src/app/assets/dialog/manage-asset-links/asset-link-table/asset-link-table.component.scss
 
b/ui/src/app/assets/dialog/manage-asset-links/asset-link-table/asset-link-table.component.scss
new file mode 100644
index 0000000000..32b3cdb973
--- /dev/null
+++ 
b/ui/src/app/assets/dialog/manage-asset-links/asset-link-table/asset-link-table.component.scss
@@ -0,0 +1,64 @@
+/*
+ * 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.
+ *
+ */
+
+.asset-link-table-toolbar {
+    width: 100%;
+}
+
+.asset-link-table-search {
+    min-width: 16.25rem;
+}
+
+.asset-link-table-group-header-cell {
+    padding: 0;
+}
+
+.asset-link-table-group-header {
+    background: var(--color-bg-1);
+    border-top: 1px solid var(--color-bg-2);
+    padding: var(--space-xs) var(--space-sm);
+}
+
+.asset-link-table-group-toggle {
+    flex: 0 0 auto;
+}
+
+.chip {
+    display: inline-flex;
+    align-items: center;
+    gap: var(--space-xs);
+    padding: var(--space-2xs) var(--space-sm);
+    border-radius: var(--radius-sm);
+    font-weight: 600;
+}
+
+.asset-link-table-group-chip {
+    padding-left: 0;
+}
+
+.asset-link-table-group-count {
+    border-radius: var(--radius-sm);
+    background: var(--color-bg-2);
+    padding: var(--space-2xs) var(--space-sm);
+}
+
+.link-type-icon {
+    font-size: var(--font-size-md);
+    height: var(--font-size-md);
+    width: var(--font-size-md);
+}
diff --git 
a/ui/src/app/assets/dialog/manage-asset-links/asset-link-table/asset-link-table.component.ts
 
b/ui/src/app/assets/dialog/manage-asset-links/asset-link-table/asset-link-table.component.ts
new file mode 100644
index 0000000000..16a15e71f0
--- /dev/null
+++ 
b/ui/src/app/assets/dialog/manage-asset-links/asset-link-table/asset-link-table.component.ts
@@ -0,0 +1,325 @@
+/*
+ * 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 {
+    Component,
+    ElementRef,
+    EventEmitter,
+    HostListener,
+    Input,
+    Output,
+    ViewChild,
+} from '@angular/core';
+import {
+    AssetLinkResourceRow,
+    AssetLinkSelectionChange,
+} from './asset-link-table.model';
+import { AssetLinkType } from '@streampipes/platform-services';
+import {
+    MatCell,
+    MatCellDef,
+    MatColumnDef,
+    MatHeaderCell,
+    MatHeaderCellDef,
+    MatHeaderRow,
+    MatHeaderRowDef,
+    MatNoDataRow,
+    MatRow,
+    MatRowDef,
+    MatTable,
+} from '@angular/material/table';
+import { MatCheckbox } from '@angular/material/checkbox';
+import { TranslatePipe } from '@ngx-translate/core';
+import { MatSort, MatSortHeader, Sort } from '@angular/material/sort';
+import {
+    MatFormField,
+    MatPrefix,
+    MatSuffix,
+} from '@angular/material/form-field';
+import { MatInput } from '@angular/material/input';
+import { MatIcon } from '@angular/material/icon';
+import { MatButton, MatIconButton } from '@angular/material/button';
+import { NgStyle } from '@angular/common';
+import {
+    MatButtonToggle,
+    MatButtonToggleGroup,
+} from '@angular/material/button-toggle';
+import {
+    FlexDirective,
+    LayoutAlignDirective,
+    LayoutDirective,
+} from '@ngbracket/ngx-layout/flex';
+import { LayoutGapDirective } from '@ngbracket/ngx-layout';
+
+interface AssetLinkGroupHeaderRow {
+    groupHeader: true;
+    id: string;
+    title: string;
+    icon: string;
+    color: string;
+    count: number;
+    collapsed: boolean;
+}
+
+type AssetLinkTableRow = AssetLinkResourceRow | AssetLinkGroupHeaderRow;
+type AssetLinkViewMode = 'grouped' | 'list';
+
+@Component({
+    selector: 'sp-manage-asset-link-table',
+    templateUrl: './asset-link-table.component.html',
+    styleUrls: ['./asset-link-table.component.scss'],
+    imports: [
+        FlexDirective,
+        LayoutDirective,
+        LayoutAlignDirective,
+        LayoutGapDirective,
+        MatButtonToggle,
+        MatButtonToggleGroup,
+        MatButton,
+        MatCell,
+        MatCellDef,
+        MatCheckbox,
+        MatColumnDef,
+        MatFormField,
+        MatHeaderCell,
+        MatHeaderCellDef,
+        MatHeaderRow,
+        MatHeaderRowDef,
+        MatIcon,
+        MatIconButton,
+        MatInput,
+        MatNoDataRow,
+        MatPrefix,
+        MatRow,
+        MatRowDef,
+        MatSort,
+        MatSortHeader,
+        MatSuffix,
+        MatTable,
+        NgStyle,
+        TranslatePipe,
+    ],
+})
+export class AssetLinkTableComponent {
+    @ViewChild('searchInput')
+    searchInput?: ElementRef<HTMLInputElement>;
+
+    @Input()
+    resources: AssetLinkResourceRow[] = [];
+
+    @Input()
+    selectedResourceIds: string[] = [];
+
+    @Input()
+    assetLinkTypes: AssetLinkType[] = [];
+
+    @Output()
+    selectionChange = new EventEmitter<AssetLinkSelectionChange>();
+
+    displayedColumns = ['selected', 'type', 'linkLabel'];
+    groupHeaderColumns = ['groupHeader'];
+    searchTerm = '';
+    viewMode: AssetLinkViewMode = 'grouped';
+    sort: Sort = { active: 'resourceName', direction: 'asc' };
+    collapsedGroupIds: Set<string> = new Set<string>();
+
+    get renderedRows(): AssetLinkTableRow[] {
+        const rows = this.filteredAndSortedResources;
+
+        if (this.viewMode === 'list') {
+            return rows;
+        }
+
+        const groupedRows = new Map<string, AssetLinkResourceRow[]>();
+        rows.forEach(row => {
+            const groupRows = groupedRows.get(row.assetLinkType) ?? [];
+            groupRows.push(row);
+            groupedRows.set(row.assetLinkType, groupRows);
+        });
+
+        return Array.from(groupedRows.entries())
+            .sort((left, right) =>
+                
left[1][0].resourceType.localeCompare(right[1][0].resourceType),
+            )
+            .flatMap(([id, groupRows]) => [
+                {
+                    groupHeader: true as const,
+                    id,
+                    title:
+                        this.getLinkType(groupRows[0])?.linkLabel ??
+                        groupRows[0].resourceType,
+                    icon: this.getLinkType(groupRows[0])?.linkIcon ?? 'link',
+                    color: this.getLinkTypeColor(groupRows[0]),
+                    count: groupRows.length,
+                    collapsed: this.isGroupCollapsed(id),
+                },
+                ...(this.isGroupCollapsed(id) ? [] : groupRows),
+            ]);
+    }
+
+    isResourceSelected(resourceId: string): boolean {
+        return this.selectedResourceIds.includes(resourceId);
+    }
+
+    updateSelection(checked: boolean, resource: AssetLinkResourceRow): void {
+        this.selectionChange.emit({ checked, resource });
+    }
+
+    selectAll(): void {
+        this.filteredAndSortedResources
+            .filter(resource => !this.isResourceSelected(resource.resourceId))
+            .forEach(resource =>
+                this.selectionChange.emit({ checked: true, resource }),
+            );
+    }
+
+    deselectAll(): void {
+        this.filteredAndSortedResources
+            .filter(resource => this.isResourceSelected(resource.resourceId))
+            .forEach(resource =>
+                this.selectionChange.emit({ checked: false, resource }),
+            );
+    }
+
+    hasUnselectedFilteredResources(): boolean {
+        return this.filteredAndSortedResources.some(
+            resource => !this.isResourceSelected(resource.resourceId),
+        );
+    }
+
+    hasSelectedFilteredResources(): boolean {
+        return this.filteredAndSortedResources.some(resource =>
+            this.isResourceSelected(resource.resourceId),
+        );
+    }
+
+    updateSearch(searchTerm: string): void {
+        this.searchTerm = searchTerm;
+    }
+
+    clearSearch(): void {
+        this.searchTerm = '';
+    }
+
+    @HostListener('document:keydown', ['$event'])
+    handleGlobalKeydown(event: KeyboardEvent): void {
+        const key = event.key.toLowerCase();
+        const ctrl = event.ctrlKey || event.metaKey;
+
+        if (ctrl && key === 'f') {
+            this.focusSearchInput();
+            event.preventDefault();
+            event.stopPropagation();
+        }
+    }
+
+    updateViewMode(viewMode: AssetLinkViewMode): void {
+        this.viewMode = viewMode;
+    }
+
+    toggleGroup(groupId: string): void {
+        if (this.collapsedGroupIds.has(groupId)) {
+            this.collapsedGroupIds.delete(groupId);
+        } else {
+            this.collapsedGroupIds.add(groupId);
+        }
+        this.collapsedGroupIds = new Set(this.collapsedGroupIds);
+    }
+
+    isGroupCollapsed(groupId: string): boolean {
+        return this.collapsedGroupIds.has(groupId);
+    }
+
+    updateSort(sort: Sort): void {
+        this.sort = sort;
+    }
+
+    getLinkType(resource: AssetLinkResourceRow): AssetLinkType | undefined {
+        return this.assetLinkTypes.find(
+            linkType => linkType.linkType === resource.assetLinkType,
+        );
+    }
+
+    getLinkTypeColor(resource: AssetLinkResourceRow): string {
+        return this.getLinkType(resource)?.linkColor ?? 'var(--color-primary)';
+    }
+
+    isGroupHeaderRow = (_: number, row: AssetLinkTableRow) =>
+        this.hasGroupHeaderMarker(row);
+
+    isDataRow = (_: number, row: AssetLinkTableRow) =>
+        !this.hasGroupHeaderMarker(row);
+
+    private get filteredAndSortedResources(): AssetLinkResourceRow[] {
+        const normalizedSearchTerm = 
this.searchTerm.trim().toLocaleLowerCase();
+        const rows = normalizedSearchTerm
+            ? this.resources.filter(resource =>
+                  [resource.resourceName, resource.resourceType].some(value =>
+                      value.toLocaleLowerCase().includes(normalizedSearchTerm),
+                  ),
+              )
+            : [...this.resources];
+
+        return rows.sort((left, right) => this.compareRows(left, right));
+    }
+
+    private compareRows(
+        left: AssetLinkResourceRow,
+        right: AssetLinkResourceRow,
+    ): number {
+        const direction = this.sort.direction === 'desc' ? -1 : 1;
+
+        if (!this.sort.active || !this.sort.direction) {
+            return left.resourceName.localeCompare(right.resourceName);
+        }
+
+        const leftValue = this.getSortValue(left, this.sort.active);
+        const rightValue = this.getSortValue(right, this.sort.active);
+
+        return (
+            String(leftValue ?? '').localeCompare(String(rightValue ?? '')) *
+            direction
+        );
+    }
+
+    private getSortValue(
+        row: AssetLinkResourceRow,
+        column: string,
+    ): string | undefined {
+        if (column === 'type') {
+            return row.resourceType;
+        }
+
+        if (column === 'linkLabel') {
+            return row.resourceName;
+        }
+
+        return row[column as keyof AssetLinkResourceRow];
+    }
+
+    private hasGroupHeaderMarker(
+        row: AssetLinkTableRow,
+    ): row is AssetLinkGroupHeaderRow {
+        return !!(row as AssetLinkGroupHeaderRow).groupHeader;
+    }
+
+    private focusSearchInput(): void {
+        this.searchInput?.nativeElement.focus();
+        this.searchInput?.nativeElement.select();
+    }
+}
diff --git 
a/ui/src/app/assets/dialog/manage-asset-links/asset-link-table/asset-link-table.model.ts
 
b/ui/src/app/assets/dialog/manage-asset-links/asset-link-table/asset-link-table.model.ts
new file mode 100644
index 0000000000..e0abbf19a6
--- /dev/null
+++ 
b/ui/src/app/assets/dialog/manage-asset-links/asset-link-table/asset-link-table.model.ts
@@ -0,0 +1,29 @@
+/*
+ * 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.
+ *
+ */
+
+export interface AssetLinkResourceRow {
+    resourceId: string;
+    resourceName: string;
+    resourceType: string;
+    assetLinkType: string;
+}
+
+export interface AssetLinkSelectionChange {
+    checked: boolean;
+    resource: AssetLinkResourceRow;
+}
diff --git 
a/ui/src/app/assets/dialog/manage-asset-links/manage-asset-links-dialog.component.html
 
b/ui/src/app/assets/dialog/manage-asset-links/manage-asset-links-dialog.component.html
index 44353b24ac..7bcd39cc0c 100644
--- 
a/ui/src/app/assets/dialog/manage-asset-links/manage-asset-links-dialog.component.html
+++ 
b/ui/src/app/assets/dialog/manage-asset-links/manage-asset-links-dialog.component.html
@@ -19,399 +19,15 @@
 <div class="sp-dialog-container">
     <div class="sp-dialog-content p-15">
         @if (clonedAssetLinks) {
-            <div fxFlex="100" fxLayout="column">
-                <sp-split-section [level]="3" [title]="'Adapters' | translate">
-                    <div
-                        section-actions
-                        fxLayout="row"
-                        fxLayoutAlign="end center"
-                        fxFlex
-                    >
-                        <button
-                            mat-button
-                            mat-flat-button
-                            color="accent"
-                            class="small-button"
-                            (click)="
-                                selectAll(
-                                    adapters,
-                                    elementIdFunction,
-                                    nameFunction,
-                                    'adapter'
-                                )
-                            "
-                            style="margin-right: 5px; margin-left: 15px"
-                        >
-                            <span>{{ 'Select All' | translate }}</span>
-                        </button>
-                        <button
-                            mat-button
-                            mat-flat-button
-                            class="mat-basic small-button"
-                            (click)="deselectAll(adapters, elementIdFunction)"
-                            style="margin-right: 10px; margin-left: 5px"
-                        >
-                            <span>{{ 'Deselect All' | translate }}</span>
-                        </button>
-                    </div>
-                    @for (element of adapters; track element) {
-                        <div fxLayout="row">
-                            <mat-checkbox
-                                color="accent"
-                                [checked]="linkSelected(element.elementId)"
-                                [attr.data-cy]="
-                                    'select-adapters-checkbox-' + element.name
-                                "
-                                (change)="
-                                    selectLink(
-                                        $event.checked,
-                                        element.elementId,
-                                        element.name,
-                                        'adapter'
-                                    )
-                                "
-                            >
-                                {{ element.name }}
-                            </mat-checkbox>
-                        </div>
-                    }
-                </sp-split-section>
-                <sp-split-section [level]="3" [title]="'Charts' | translate">
-                    <div
-                        section-actions
-                        fxLayout="row"
-                        fxLayoutAlign="end center"
-                        fxFlex
-                    >
-                        <button
-                            mat-button
-                            mat-flat-button
-                            color="accent"
-                            class="small-button"
-                            (click)="
-                                selectAll(
-                                    charts,
-                                    elementIdFunction,
-                                    widgetNameFunction,
-                                    'chart'
-                                )
-                            "
-                            style="margin-right: 5px; margin-left: 15px"
-                        >
-                            <span>{{ 'Select All' | translate }}</span>
-                        </button>
-                        <button
-                            mat-button
-                            mat-flat-button
-                            class="mat-basic small-button"
-                            (click)="deselectAll(charts, elementIdFunction)"
-                            style="margin-right: 10px; margin-left: 5px"
-                        >
-                            <span>{{ 'Deselect All' | translate }}</span>
-                        </button>
-                    </div>
-                    @for (element of charts; track element) {
-                        <div fxLayout="row">
-                            <mat-checkbox
-                                color="accent"
-                                [checked]="linkSelected(element.elementId)"
-                                (change)="
-                                    selectLink(
-                                        $event.checked,
-                                        element.elementId,
-                                        element.baseAppearanceConfig
-                                            .widgetTitle,
-                                        'chart'
-                                    )
-                                "
-                            >
-                                {{ element.baseAppearanceConfig.widgetTitle }}
-                            </mat-checkbox>
-                        </div>
-                    }
-                </sp-split-section>
-                <sp-split-section
-                    [level]="3"
-                    [title]="'Dashboards' | translate"
-                >
-                    <div
-                        section-actions
-                        fxLayout="row"
-                        fxLayoutAlign="end center"
-                        fxFlex
-                    >
-                        <button
-                            mat-button
-                            mat-flat-button
-                            color="accent"
-                            class="small-button"
-                            (click)="
-                                selectAll(
-                                    dashboards,
-                                    elementIdFunction,
-                                    nameFunction,
-                                    'dashboard'
-                                )
-                            "
-                            style="margin-right: 5px; margin-left: 15px"
-                        >
-                            <span>{{ 'Select All' | translate }}</span>
-                        </button>
-                        <button
-                            mat-button
-                            mat-flat-button
-                            class="mat-basic small-button"
-                            (click)="deselectAll(dashboards, 
elementIdFunction)"
-                            style="margin-right: 10px; margin-left: 5px"
-                        >
-                            <span>{{ 'Deselect All' | translate }}</span>
-                        </button>
-                    </div>
-
-                    @for (element of dashboards; track element) {
-                        <div fxLayout="row">
-                            <mat-checkbox
-                                color="accent"
-                                [checked]="linkSelected(element.elementId)"
-                                (change)="
-                                    selectLink(
-                                        $event.checked,
-                                        element.elementId,
-                                        element.name,
-                                        'dashboard'
-                                    )
-                                "
-                            >
-                                {{ element.name }}
-                            </mat-checkbox>
-                        </div>
-                    }
-                </sp-split-section>
-                <sp-split-section
-                    [level]="3"
-                    [title]="'Data Lake Storage' | translate"
-                >
-                    <div
-                        section-actions
-                        fxLayout="row"
-                        fxLayoutAlign="end center"
-                        fxFlex
-                    >
-                        <button
-                            mat-button
-                            mat-flat-button
-                            color="accent"
-                            class="small-button"
-                            (click)="
-                                selectAll(
-                                    dataLakeMeasures,
-                                    elementIdFunction,
-                                    measureNameFunction,
-                                    'measurement'
-                                )
-                            "
-                            style="margin-right: 5px; margin-left: 15px"
-                        >
-                            <span>{{ 'Select All' | translate }}</span>
-                        </button>
-                        <button
-                            mat-button
-                            mat-flat-button
-                            class="mat-basic small-button"
-                            (click)="
-                                deselectAll(dataLakeMeasures, 
elementIdFunction)
-                            "
-                            style="margin-right: 10px; margin-left: 5px"
-                        >
-                            <span>{{ 'Deselect All' | translate }}</span>
-                        </button>
-                    </div>
-                    @for (element of dataLakeMeasures; track element) {
-                        <div fxLayout="row">
-                            <mat-checkbox
-                                color="accent"
-                                [checked]="linkSelected(element.elementId)"
-                                (change)="
-                                    selectLink(
-                                        $event.checked,
-                                        element.elementId,
-                                        element.measureName,
-                                        'measurement'
-                                    )
-                                "
-                            >
-                                {{ element.measureName }}
-                            </mat-checkbox>
-                        </div>
-                    }
-                </sp-split-section>
-                <sp-split-section
-                    [level]="3"
-                    [title]="'Data Streams' | translate"
-                >
-                    <div
-                        section-actions
-                        fxLayout="row"
-                        fxLayoutAlign="end center"
-                        fxFlex
-                    >
-                        <button
-                            mat-button
-                            mat-flat-button
-                            color="accent"
-                            class="small-button"
-                            (click)="
-                                selectAll(
-                                    dataSources,
-                                    elementIdFunction,
-                                    nameFunction,
-                                    'data-source'
-                                )
-                            "
-                            style="margin-right: 5px; margin-left: 15px"
-                        >
-                            <span>{{ 'Select All' | translate }}</span>
-                        </button>
-                        <button
-                            mat-button
-                            mat-flat-button
-                            class="mat-basic small-button"
-                            (click)="
-                                deselectAll(dataSources, elementIdFunction)
-                            "
-                            style="margin-right: 10px; margin-left: 5px"
-                        >
-                            <span>{{ 'Deselect All' | translate }}</span>
-                        </button>
-                    </div>
-                    @for (source of dataSources; track source) {
-                        <div fxLayout="row">
-                            <mat-checkbox
-                                color="accent"
-                                [checked]="linkSelected(source.elementId)"
-                                [attr.data-cy]="
-                                    'select-data-stream-checkbox-' + 
source.name
-                                "
-                                (change)="
-                                    selectLink(
-                                        $event.checked,
-                                        source.elementId,
-                                        source.name,
-                                        'data-source'
-                                    )
-                                "
-                            >
-                                {{ source.name }}
-                            </mat-checkbox>
-                        </div>
-                    }
-                </sp-split-section>
-                <sp-split-section [level]="3" [title]="'Files' | translate">
-                    <div
-                        section-actions
-                        fxLayout="row"
-                        fxLayoutAlign="end center"
-                        fxFlex
-                    >
-                        <button
-                            mat-button
-                            mat-flat-button
-                            color="accent"
-                            class="small-button"
-                            (click)="
-                                selectAll(
-                                    files,
-                                    fileIdFunction,
-                                    filenameFunction,
-                                    'file'
-                                )
-                            "
-                            style="margin-right: 5px; margin-left: 15px"
-                        >
-                            <span>{{ 'Select All' | translate }}</span>
-                        </button>
-                        <button
-                            mat-button
-                            mat-flat-button
-                            class="mat-basic small-button"
-                            (click)="deselectAll(files, fileIdFunction)"
-                            style="margin-right: 10px; margin-left: 5px"
-                        >
-                            <span>{{ 'Deselect All' | translate }}</span>
-                        </button>
-                    </div>
-                    @for (element of files; track element) {
-                        <div fxLayout="row">
-                            <mat-checkbox
-                                color="accent"
-                                [checked]="linkSelected(element.fileId)"
-                                (change)="
-                                    selectLink(
-                                        $event.checked,
-                                        element.fileId,
-                                        element.filename,
-                                        'file'
-                                    )
-                                "
-                            >
-                                {{ element.filename }}
-                            </mat-checkbox>
-                        </div>
-                    }
-                </sp-split-section>
-                <sp-split-section [level]="3" [title]="'Pipelines' | 
translate">
-                    <div
-                        section-actions
-                        fxLayout="row"
-                        fxLayoutAlign="end center"
-                        fxFlex
-                    >
-                        <button
-                            mat-button
-                            mat-flat-button
-                            color="accent"
-                            class="small-button"
-                            (click)="
-                                selectAll(
-                                    pipelines,
-                                    elementIdFunction,
-                                    nameFunction,
-                                    'pipeline'
-                                )
-                            "
-                            style="margin-right: 5px; margin-left: 15px"
-                        >
-                            <span>{{ 'Select All' | translate }}</span>
-                        </button>
-                        <button
-                            mat-button
-                            mat-flat-button
-                            class="mat-basic small-button"
-                            (click)="deselectAll(pipelines, elementIdFunction)"
-                            style="margin-right: 10px; margin-left: 5px"
-                        >
-                            <span>{{ 'Deselect All' | translate }}</span>
-                        </button>
-                    </div>
-                    @for (pipeline of pipelines; track pipeline) {
-                        <div fxLayout="row">
-                            <mat-checkbox
-                                color="accent"
-                                [checked]="linkSelected(pipeline.elementId)"
-                                (change)="
-                                    selectLink(
-                                        $event.checked,
-                                        pipeline.elementId,
-                                        pipeline.name,
-                                        'pipeline'
-                                    )
-                                "
-                                >{{ pipeline.name }}
-                            </mat-checkbox>
-                        </div>
-                    }
-                </sp-split-section>
-            </div>
+            <sp-manage-asset-link-table
+                fxFlex="100"
+                [resources]="resourceRows"
+                [selectedResourceIds]="selectedResourceIds"
+                [assetLinkTypes]="assetLinkTypes"
+                (selectionChange)="selectLink($event)"
+                data-cy="manage-asset-links-table"
+            >
+            </sp-manage-asset-link-table>
         }
     </div>
     <mat-divider></mat-divider>
diff --git 
a/ui/src/app/assets/dialog/manage-asset-links/manage-asset-links-dialog.component.ts
 
b/ui/src/app/assets/dialog/manage-asset-links/manage-asset-links-dialog.component.ts
index 57d181302a..ddf8d37c14 100644
--- 
a/ui/src/app/assets/dialog/manage-asset-links/manage-asset-links-dialog.component.ts
+++ 
b/ui/src/app/assets/dialog/manage-asset-links/manage-asset-links-dialog.component.ts
@@ -16,8 +16,8 @@
  *
  */
 
-import { Component, Input, OnInit, inject } from '@angular/core';
-import { DialogRef, SplitSectionComponent } from '@streampipes/shared-ui';
+import { Component, HostListener, Input, OnInit, inject } from '@angular/core';
+import { DialogRef } from '@streampipes/shared-ui';
 import { AssetLink, AssetLinkType } from '@streampipes/platform-services';
 import { BaseAssetLinksDirective } from '../base-asset-links.directive';
 import {
@@ -26,9 +26,13 @@ import {
     LayoutDirective,
 } from '@ngbracket/ngx-layout/flex';
 import { MatButton } from '@angular/material/button';
-import { MatCheckbox } from '@angular/material/checkbox';
 import { MatDivider } from '@angular/material/divider';
 import { TranslatePipe } from '@ngx-translate/core';
+import {
+    AssetLinkResourceRow,
+    AssetLinkSelectionChange,
+} from './asset-link-table/asset-link-table.model';
+import { AssetLinkTableComponent } from 
'./asset-link-table/asset-link-table.component';
 
 @Component({
     selector: 'sp-manage-asset-links-dialog-component',
@@ -36,11 +40,10 @@ import { TranslatePipe } from '@ngx-translate/core';
     imports: [
         FlexDirective,
         LayoutDirective,
-        SplitSectionComponent,
         LayoutAlignDirective,
         MatButton,
-        MatCheckbox,
         MatDivider,
+        AssetLinkTableComponent,
         TranslatePipe,
     ],
 })
@@ -59,12 +62,7 @@ export class SpManageAssetLinksDialogComponent
 
     clonedAssetLinks: AssetLink[] = [];
 
-    elementIdFunction = el => el.elementId;
-    fileIdFunction = el => el.fileId;
-    nameFunction = el => el.name;
-    filenameFunction = el => el.filename;
-    measureNameFunction = el => el.measureName;
-    widgetNameFunction = el => el.baseAppearanceConfig.widgetTitle;
+    resourceRows: AssetLinkResourceRow[] = [];
 
     ngOnInit(): void {
         super.onInit();
@@ -75,6 +73,10 @@ export class SpManageAssetLinksDialogComponent
         ];
     }
 
+    get selectedResourceIds(): string[] {
+        return this.clonedAssetLinks.map(assetLink => assetLink.resourceId);
+    }
+
     cancel(): void {
         this.dialogRef.close();
     }
@@ -84,7 +86,82 @@ export class SpManageAssetLinksDialogComponent
         this.dialogRef.close(this.assetLinks);
     }
 
-    afterResourcesLoaded(): void {}
+    @HostListener('document:keydown', ['$event'])
+    handleGlobalKeydown(event: KeyboardEvent): void {
+        const key = event.key.toLowerCase();
+        const ctrl = event.ctrlKey || event.metaKey;
+
+        if (key === 'escape') {
+            this.cancel();
+            event.preventDefault();
+            event.stopPropagation();
+        } else if (ctrl && key === 's') {
+            this.store();
+            event.preventDefault();
+            event.stopPropagation();
+        }
+    }
+
+    afterResourcesLoaded(): void {
+        this.resourceRows = [
+            ...this.adapters.map(adapter =>
+                this.makeResourceRow(
+                    adapter.elementId,
+                    adapter.name,
+                    'Adapter',
+                    'adapter',
+                ),
+            ),
+            ...this.charts.map(chart =>
+                this.makeResourceRow(
+                    chart.elementId,
+                    chart.baseAppearanceConfig.widgetTitle,
+                    'Chart',
+                    'chart',
+                ),
+            ),
+            ...this.dashboards.map(dashboard =>
+                this.makeResourceRow(
+                    dashboard.elementId,
+                    dashboard.name,
+                    'Dashboard',
+                    'dashboard',
+                ),
+            ),
+            ...this.dataLakeMeasures.map(measure =>
+                this.makeResourceRow(
+                    measure.elementId,
+                    measure.measureName,
+                    'Data Lake Storage',
+                    'measurement',
+                ),
+            ),
+            ...this.dataSources.map(source =>
+                this.makeResourceRow(
+                    source.elementId,
+                    source.name,
+                    'Data Stream',
+                    'data-source',
+                ),
+            ),
+            ...this.files.map(file =>
+                this.makeResourceRow(
+                    file.fileId,
+                    file.filename,
+                    'File',
+                    'file',
+                ),
+            ),
+            ...this.pipelines.map(pipeline =>
+                this.makeResourceRow(
+                    pipeline.elementId,
+                    pipeline.name,
+                    'Pipeline',
+                    'pipeline',
+                ),
+            ),
+        ];
+    }
 
     linkSelected(resourceId: string): boolean {
         return (
@@ -93,21 +170,27 @@ export class SpManageAssetLinksDialogComponent
         );
     }
 
-    selectLink(
-        checked: boolean,
-        resourceId: string,
-        label: string,
-        assetLinkType: string,
-    ): void {
-        if (checked) {
+    selectLink(event: AssetLinkSelectionChange): void {
+        const resource = event.resource;
+        if (event.checked) {
+            if (this.linkSelected(resource.resourceId)) {
+                return;
+            }
+
             this.clonedAssetLinks.push(
-                this.makeLink(resourceId, label, assetLinkType),
+                this.makeLink(
+                    resource.resourceId,
+                    resource.resourceName,
+                    resource.assetLinkType,
+                ),
             );
         } else {
             const index = this.clonedAssetLinks.findIndex(
-                al => al.resourceId === resourceId,
+                al => al.resourceId === resource.resourceId,
             );
-            this.clonedAssetLinks.splice(index, 1);
+            if (index > -1) {
+                this.clonedAssetLinks.splice(index, 1);
+            }
         }
     }
 
@@ -129,30 +212,17 @@ export class SpManageAssetLinksDialogComponent
         };
     }
 
-    selectAll(
-        elements: any[],
-        idFunction: any,
-        nameFunction: any,
+    private makeResourceRow(
+        resourceId: string,
+        resourceName: string,
+        resourceType: string,
         assetLinkType: string,
-    ): void {
-        elements.forEach(el => {
-            const id = idFunction(el);
-            const elementName = nameFunction(el);
-            if (!this.linkSelected(id)) {
-                this.selectLink(true, id, elementName, assetLinkType);
-            }
-        });
-    }
-
-    deselectAll(elements: any[], idFunction: any): void {
-        elements.forEach(el => {
-            const id = idFunction(el);
-            const index = this.clonedAssetLinks.findIndex(
-                al => al.resourceId === id,
-            );
-            if (index > -1) {
-                this.clonedAssetLinks.splice(index, 1);
-            }
-        });
+    ): AssetLinkResourceRow {
+        return {
+            resourceId,
+            resourceName,
+            resourceType,
+            assetLinkType,
+        };
     }
 }
diff --git a/ui/src/app/help/components/shortcuts/shortcuts.component.ts 
b/ui/src/app/help/components/shortcuts/shortcuts.component.ts
index d48e04f8be..cdd21513f2 100644
--- a/ui/src/app/help/components/shortcuts/shortcuts.component.ts
+++ b/ui/src/app/help/components/shortcuts/shortcuts.component.ts
@@ -30,7 +30,8 @@ interface ShortcutDefinition {
 
 const SHORTCUT_TRANSLATION_KEYS = {
     title: 'Shortcuts',
-    saveContext: "'Ctrl/Cmd + S' in chart/dashboard/pipeline edit view",
+    saveContext:
+        "'Ctrl/Cmd + S' in chart/dashboard/pipeline edit view or asset link 
dialog",
     saveDescription: 'Saves the current state',
     editContext: "'E' in dashboard/pipeline panel",
     editDescription: 'Enters edit mode.',

Reply via email to