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

dominikriemer 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 5ede26a1e4 fix: Avoid frozen tables in group mode (#5170)
5ede26a1e4 is described below

commit 5ede26a1e4936a47054a31e899c8fe700ccd808f
Author: Dominik Riemer <[email protected]>
AuthorDate: Sun Nov 1 22:23:24 2026 +0100

    fix: Avoid frozen tables in group mode (#5170)
---
 .../components/sp-table/sp-table.component.html    |   2 +-
 .../components/sp-table/sp-table.component.spec.ts | 247 +++++++++++++++++++++
 .../lib/components/sp-table/sp-table.component.ts  | 104 ++++++---
 3 files changed, 315 insertions(+), 38 deletions(-)

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 e7c5e70757..9839a816d3 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
@@ -247,7 +247,7 @@
     <table
         mat-table
         class="sp-table"
-        [dataSource]="renderedDataSource"
+        [dataSource]="renderedRows$"
         [multiTemplateDataRows]="viewMode === 'grouped'"
     >
         <ng-content></ng-content>
diff --git 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.spec.ts
 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.spec.ts
new file mode 100644
index 0000000000..b10c6a7724
--- /dev/null
+++ 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.spec.ts
@@ -0,0 +1,247 @@
+/*
+ * 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, viewChild } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { provideNoopAnimations } from '@angular/platform-browser/animations';
+import {
+    MatCellDef,
+    MatColumnDef,
+    MatHeaderCellDef,
+    MatTableDataSource,
+} from '@angular/material/table';
+import { provideTranslateService } from '@ngx-translate/core';
+import { BehaviorSubject } from 'rxjs';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { SpTableComponent } from './sp-table.component';
+import { SpAssetBrowserService } from '../asset-browser/asset-browser.service';
+import { AssetBrowserData } from '../asset-browser/asset-browser.model';
+import { FeatureCardService } from '../feature-card-host/feature-card.service';
+import { SpTableAssetContextConfig } from './sp-table.model';
+
+interface TestRow {
+    _id: string;
+    name: string;
+}
+
+@Component({
+    selector: 'sp-table-host',
+    imports: [SpTableComponent, MatColumnDef, MatHeaderCellDef, MatCellDef],
+    template: `
+        <sp-table
+            [dataSource]="dataSource"
+            [columns]="columns"
+            [assetContextConfig]="assetContextConfig"
+            resourceIdKey="_id"
+        >
+            <ng-container matColumnDef="name">
+                <th mat-header-cell *matHeaderCellDef>Name</th>
+                <td mat-cell *matCellDef="let row">{{ row.name }}</td>
+            </ng-container>
+        </sp-table>
+    `,
+})
+class SpTableHostComponent {
+    readonly table = viewChild.required(SpTableComponent<TestRow>);
+
+    dataSource = new MatTableDataSource<TestRow>([
+        { _id: 'r1', name: 'Alpha' },
+        { _id: 'r2', name: 'Beta' },
+        { _id: 'r3', name: 'Gamma' },
+    ]);
+    columns = ['name', 'assetContext'];
+    assetContextConfig: SpTableAssetContextConfig = {
+        resourceLinkType: 'pipeline',
+        resourceIdKey: '_id',
+        hideBelowWidth: 0,
+    };
+}
+
+const assetLink = (resourceId: string) => ({
+    resourceId,
+    linkType: 'pipeline',
+    linkLabel: resourceId,
+    editingDisabled: false,
+    navigationActive: true,
+    queryHint: '',
+});
+
+const assetData = {
+    assets: [
+        {
+            assetId: 'asset-1',
+            assetName: 'Press',
+            assetLinks: [assetLink('r1'), assetLink('r2')],
+            assets: [],
+            labelIds: [],
+        },
+    ],
+    assetLinks: [],
+    sites: [],
+    labels: [],
+} as unknown as AssetBrowserData;
+
+describe('SpTableComponent grouped view', () => {
+    let fixture: ComponentFixture<SpTableHostComponent>;
+    let host: SpTableHostComponent;
+    let assetData$: BehaviorSubject<AssetBrowserData | undefined>;
+
+    beforeEach(async () => {
+        window.matchMedia ??= query => ({
+            matches: false,
+            media: query,
+            onchange: null,
+            addListener: () => {},
+            removeListener: () => {},
+            addEventListener: () => {},
+            removeEventListener: () => {},
+            dispatchEvent: () => false,
+        });
+        assetData$ = new BehaviorSubject<AssetBrowserData | undefined>(
+            assetData,
+        );
+
+        await TestBed.configureTestingModule({
+            imports: [SpTableHostComponent],
+            providers: [
+                provideNoopAnimations(),
+                provideTranslateService(),
+                { provide: SpAssetBrowserService, useValue: { assetData$ } },
+                {
+                    provide: FeatureCardService,
+                    useValue: { openFeatureCard: () => {} },
+                },
+            ],
+        }).compileComponents();
+
+        fixture = TestBed.createComponent(SpTableHostComponent);
+        host = fixture.componentInstance;
+        fixture.detectChanges();
+    });
+
+    afterEach(() => {
+        fixture.destroy();
+        TestBed.resetTestingModule();
+    });
+
+    function renderedRowTexts(): string[] {
+        return Array.from(
+            fixture.nativeElement.querySelectorAll('tr[mat-row]'),
+        ).map(row =>
+            (row as HTMLElement).textContent.replace(/\s+/g, ' ').trim(),
+        );
+    }
+
+    function groupHeaderRows(): HTMLElement[] {
+        return Array.from(
+            fixture.nativeElement.querySelectorAll(
+                'tr.grouped-table-row--header',
+            ),
+        );
+    }
+
+    it('renders the list view with asset context cells', () => {
+        expect(groupHeaderRows().length).toBe(0);
+        expect(renderedRowTexts()).toEqual([
+            'Alpha Press',
+            'Beta Press',
+            'Gamma',
+        ]);
+    });
+
+    it('renders rows under group headers in the grouped view', () => {
+        host.table().setViewMode('grouped');
+        fixture.detectChanges();
+
+        expect(groupHeaderRows().length).toBe(2);
+        expect(renderedRowTexts()).toEqual([
+            'Press 2',
+            'Alpha',
+            'Beta',
+            'Unassigned 1',
+            'Gamma',
+        ]);
+    });
+
+    it('keeps the grouped rows stable across change detection passes', () => {
+        const connect = vi.spyOn(host.dataSource, 'connect');
+
+        host.table().setViewMode('grouped');
+        fixture.detectChanges();
+        const connectCallsAfterSwitch = connect.mock.calls.length;
+        const [firstHeaderRow] = groupHeaderRows();
+
+        fixture.detectChanges();
+        fixture.detectChanges();
+
+        expect(connect.mock.calls.length).toBe(connectCallsAfterSwitch);
+        expect(groupHeaderRows()[0]).toBe(firstHeaderRow);
+        expect(renderedRowTexts()).toEqual([
+            'Press 2',
+            'Alpha',
+            'Beta',
+            'Unassigned 1',
+            'Gamma',
+        ]);
+    });
+
+    it('switches back to the list view', () => {
+        host.table().setViewMode('grouped');
+        fixture.detectChanges();
+        host.table().setViewMode('list');
+        fixture.detectChanges();
+
+        expect(groupHeaderRows().length).toBe(0);
+        expect(renderedRowTexts()).toEqual([
+            'Alpha Press',
+            'Beta Press',
+            'Gamma',
+        ]);
+    });
+
+    it('re-renders the grouped view when asset data changes', () => {
+        host.table().setViewMode('grouped');
+        fixture.detectChanges();
+        assetData$.next(undefined);
+        fixture.detectChanges();
+
+        expect(renderedRowTexts()).toEqual([
+            'Unassigned 3',
+            'Alpha',
+            'Beta',
+            'Gamma',
+        ]);
+    });
+
+    it('re-renders the grouped view when the table data changes', () => {
+        host.table().setViewMode('grouped');
+        fixture.detectChanges();
+        host.dataSource.data = [
+            { _id: 'r1', name: 'Alpha' },
+            { _id: 'r4', name: 'Delta' },
+        ];
+        fixture.detectChanges();
+
+        expect(renderedRowTexts()).toEqual([
+            'Press 1',
+            'Alpha',
+            'Unassigned 1',
+            'Delta',
+        ]);
+    });
+});
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 6dd7ae4fd0..f4145a8921 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
@@ -19,6 +19,7 @@
 import {
     AfterContentInit,
     AfterViewInit,
+    ChangeDetectorRef,
     Component,
     ContentChild,
     ContentChildren,
@@ -77,6 +78,7 @@ import {
 } from '@angular/material/form-field';
 import { MatInput } from '@angular/material/input';
 import {
+    BehaviorSubject,
     debounceTime,
     distinctUntilChanged,
     Subject,
@@ -209,6 +211,12 @@ export class SpTableComponent<T>
 
     @ViewChild('paginator')
     set paginatorControl(paginator: MatPaginator) {
+        // Angular re-runs this setter whenever the view query is refreshed,
+        // which happens on every table render; only rebind on a real change.
+        if (paginator === this.paginator) {
+            return;
+        }
+
         this.paginator = paginator;
         if (this.viewInitialized) {
             this.bindDataSource();
@@ -227,13 +235,22 @@ export class SpTableComponent<T>
     groupedSections: SpTableGroupedSection<T>[] = [];
     renderedGroupedRows: SpTableRenderedRow<T>[] = [];
 
+    /**
+     * The only data source the mat-table is bound to. It emits the current
+     * page rows in list mode and the group headers plus their rows in grouped
+     * mode, so switching the view mode never swaps the table's data source.
+     */
+    readonly renderedRows$ = new BehaviorSubject<SpTableRenderedRow<T>[]>([]);
+
     readonly selection = new SelectionModel<T>(true, []);
 
+    private changeDetectorRef = inject(ChangeDetectorRef);
     private localStorageService = inject(LocalStorageService);
     private featureCardService = inject(FeatureCardService);
     private assetBrowserService = inject(SpAssetBrowserService);
     private assetContextService = inject(SpTableAssetContextService);
     private renderedDataSubscription?: Subscription;
+    private connectedDataSource?: MatTableDataSource<T>;
     private assetDataSubscription?: Subscription;
     private nameSearchSubscription?: Subscription;
     private columnDefsSubscription?: Subscription;
@@ -248,6 +265,7 @@ export class SpTableComponent<T>
         string,
         Map<string, SpTableResolvedAssetContext>
     >();
+    private groupHeaderRows = new Map<string, SpTableGroupHeaderRow>();
     private compactLayout = false;
 
     readonly pageSize: Signal<number>;
@@ -325,6 +343,7 @@ export class SpTableComponent<T>
 
     ngOnDestroy() {
         this.renderedDataSubscription?.unsubscribe();
+        this.connectedDataSource?.disconnect();
         this.assetDataSubscription?.unsubscribe();
         this.nameSearchSubscription?.unsubscribe();
         this.columnDefsSubscription?.unsubscribe();
@@ -337,9 +356,6 @@ export class SpTableComponent<T>
 
     onPage(event: PageEvent) {
         this.localStorageService.set('paginator-page-size', event.pageSize);
-        if (this.viewMode === 'grouped') {
-            this.refreshRenderedRows();
-        }
     }
 
     openFeatureCard(element: T) {
@@ -383,12 +399,6 @@ export class SpTableComponent<T>
         return this.nameSearchConfig.searchKey;
     }
 
-    get renderedDataSource(): MatTableDataSource<T> | SpTableRenderedRow<T>[] {
-        return this.viewMode === 'grouped'
-            ? this.renderedGroupedRows
-            : this.dataSource;
-    }
-
     get showGroupedLabelsInAssetContext(): boolean {
         return this.viewMode !== 'grouped' || this.groupBy !== 'label';
     }
@@ -514,7 +524,6 @@ export class SpTableComponent<T>
         }
 
         this.viewMode = mode;
-        this.bindDataSource();
         this.refreshRenderedRows();
     }
 
@@ -572,14 +581,24 @@ export class SpTableComponent<T>
         !this.hasGroupHeaderMarker(row);
 
     private bindDataSource() {
+        this.renderedDataSubscription?.unsubscribe();
+        this.renderedDataSubscription = undefined;
+        if (this.connectedDataSource !== this.dataSource) {
+            this.connectedDataSource?.disconnect();
+            this.connectedDataSource = undefined;
+        }
+
         if (!this.dataSource) {
+            this.updateRenderedState([]);
             return;
         }
 
         this.configureNameSearch();
         this.dataSource.paginator = this.showPaginator ? this.paginator : null;
 
-        this.renderedDataSubscription?.unsubscribe();
+        // The table itself is bound to renderedRows$, so this component owns
+        // the connection to the MatTableDataSource (paging, sorting, filter).
+        this.connectedDataSource = this.dataSource;
         this.renderedDataSubscription = this.dataSource.connect().subscribe({
             next: rows => this.updateRenderedState(rows ?? []),
         });
@@ -604,7 +623,7 @@ export class SpTableComponent<T>
     }
 
     private refreshRenderedRows() {
-        this.updateRenderedState(this.getCurrentPageRows(), false);
+        this.updateRenderedState(this.visiblePageRows, false);
     }
 
     private configureNameSearch() {
@@ -648,18 +667,6 @@ export class SpTableComponent<T>
         this.dataSource.filter = 
this.nameSearchTerm.trim().toLocaleLowerCase();
     }
 
-    private getCurrentPageRows(): T[] {
-        const rows =
-            this.dataSource?.filteredData ?? this.dataSource?.data ?? [];
-        if (!this.paginator) {
-            return rows;
-        }
-
-        const pageSize = this.paginator.pageSize || this.pageSize();
-        const startIndex = this.paginator.pageIndex * pageSize;
-        return rows.slice(startIndex, startIndex + pageSize);
-    }
-
     private updateRenderedState(rows: T[], reconcileSelection = true) {
         this.visiblePageRows = rows;
         this.rebuildGroupedSections(rows);
@@ -668,12 +675,14 @@ export class SpTableComponent<T>
             this.reconcileSelection();
         }
 
-        if (this.viewInitialized) {
-            if (this.viewMode === 'grouped') {
-                this.table.dataSource = this.renderedGroupedRows;
-            }
-            this.table.renderRows();
-        }
+        this.renderedRows$.next(
+            this.viewMode === 'grouped' ? this.renderedGroupedRows : rows,
+        );
+        // Rendered state usually changes from a subscription (data source,
+        // asset data), outside of any template event, so make sure the
+        // bindings that depend on it (group headers, asset context cells,
+        // selection toolbar) are re-checked under OnPush parents.
+        this.changeDetectorRef.markForCheck();
     }
 
     private reconcileSelection() {
@@ -832,17 +841,38 @@ export class SpTableComponent<T>
                 rows: [...group.rows],
             }));
         this.renderedGroupedRows = this.groupedSections.flatMap(section => [
-            {
-                __spGroupHeader: true as const,
-                id: section.id,
-                title: section.title,
-                color: section.color,
-                count: section.count,
-            },
+            this.getGroupHeaderRow(section),
             ...section.rows,
         ]);
     }
 
+    /**
+     * Reuses the header row object of a group across rebuilds so the table
+     * can diff by identity and keep the rendered header row instead of
+     * recreating it on every refresh.
+     */
+    private getGroupHeaderRow(
+        section: SpTableGroupedSection<T>,
+    ): SpTableGroupHeaderRow {
+        const existing = this.groupHeaderRows.get(section.id);
+        if (existing) {
+            existing.title = section.title;
+            existing.color = section.color;
+            existing.count = section.count;
+            return existing;
+        }
+
+        const headerRow: SpTableGroupHeaderRow = {
+            __spGroupHeader: true,
+            id: section.id,
+            title: section.title,
+            color: section.color,
+            count: section.count,
+        };
+        this.groupHeaderRows.set(section.id, headerRow);
+        return headerRow;
+    }
+
     private resolveGroups(
         row: T,
     ): { id: string; title: string; color?: string }[] {

Reply via email to