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 }[] {