This is an automated email from the ASF dual-hosted git repository. zehnder pushed a commit to branch add-asset-infos-to-resource-table in repository https://gitbox.apache.org/repos/asf/streampipes.git
commit 31baa0a23e0d816c5ad0d9ef3f400fd9675e2ebe Author: Philipp Zehnder <[email protected]> AuthorDate: Thu Mar 12 10:46:41 2026 +0100 feat(#4247): Show asset context sp-table and add grouping by --- .../{ => sp-actions}/sp-table-actions.directive.ts | 0 .../sp-table-multi-actions.directive.ts | 0 .../sp-table-asset-context.service.ts | 178 +++++++++ .../components/sp-table/sp-table.component.html | 373 +++++++++++++------ .../components/sp-table/sp-table.component.scss | 82 ++++- .../lib/components/sp-table/sp-table.component.ts | 406 ++++++++++++--------- .../src/lib/components/sp-table/sp-table.model.ts | 65 ++++ .../streampipes/shared-ui/src/public-api.ts | 6 +- .../pipeline-overview.component.ts | 2 +- 9 files changed, 813 insertions(+), 299 deletions(-) diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table-actions.directive.ts b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-actions/sp-table-actions.directive.ts similarity index 100% rename from ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table-actions.directive.ts rename to ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-actions/sp-table-actions.directive.ts diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table-multi-actions.directive.ts b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-actions/sp-table-multi-actions.directive.ts similarity index 100% rename from ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table-multi-actions.directive.ts rename to ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-actions/sp-table-multi-actions.directive.ts diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-asset-context/sp-table-asset-context.service.ts b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-asset-context/sp-table-asset-context.service.ts new file mode 100644 index 0000000000..368ba67faf --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-asset-context/sp-table-asset-context.service.ts @@ -0,0 +1,178 @@ +/* + * 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 { Injectable } from '@angular/core'; +import { SpAsset, SpLabel } from '@streampipes/platform-services'; +import { AssetBrowserData } from '../../asset-browser/asset-browser.model'; +import { + SpTableAssetContextValue, + SpTableResolvedAssetContext, +} from '../sp-table.model'; + +@Injectable({ providedIn: 'root' }) +export class SpTableAssetContextService { + buildAssetContextIndex( + assetData?: AssetBrowserData, + ): Map<string, Map<string, SpTableResolvedAssetContext>> { + const index = new Map< + string, + Map<string, SpTableResolvedAssetContext> + >(); + if (!assetData) { + return index; + } + + const sitesById = new Map( + assetData.sites.map(site => [site._id, site.label]), + ); + const labelsById = new Map( + assetData.labels + .filter( + (label): label is SpLabel & { _id: string } => !!label._id, + ) + .map(label => [label._id, label]), + ); + + assetData.assets.forEach(asset => + this.collectAssetContexts( + asset, + index, + sitesById, + labelsById, + asset.assetId, + asset.assetName, + [], + [], + null, + ), + ); + + return index; + } + + private collectAssetContexts( + asset: SpAsset, + index: Map<string, Map<string, SpTableResolvedAssetContext>>, + sitesById: Map<string, string>, + labelsById: Map<string, SpLabel>, + topLevelAssetId: string, + topLevelAssetLabel: string, + hierarchy: string[], + inheritedLabels: SpLabel[], + inheritedSiteLabel: string | null, + ): void { + const currentHierarchy = [...hierarchy, asset.assetName].filter( + Boolean, + ); + const currentLabels = this.mergeLabels( + inheritedLabels, + (asset.labelIds ?? []) + .map(labelId => labelsById.get(labelId)) + .filter((label): label is SpLabel => !!label), + ); + const siteLabel = + (asset.assetSite?.siteId && + sitesById.get(asset.assetSite.siteId)) ?? + asset.assetSite?.area ?? + inheritedSiteLabel; + + (asset.assetLinks ?? []).forEach(link => { + const contextsByResource = + index.get(link.linkType) ?? + new Map<string, SpTableResolvedAssetContext>(); + const currentContext = + contextsByResource.get(link.resourceId) ?? + new SpTableResolvedAssetContext(); + + currentContext.assets = this.uniqueBy( + [ + ...currentContext.assets, + new SpTableAssetContextValue( + topLevelAssetId, + topLevelAssetLabel, + currentHierarchy.join(' / '), + ), + ], + item => item.id, + ); + currentContext.sites = this.uniqueBy( + siteLabel + ? [ + ...currentContext.sites, + new SpTableAssetContextValue( + asset.assetSite?.siteId ?? siteLabel, + siteLabel, + ), + ] + : currentContext.sites, + item => item.id, + ); + currentContext.labels = this.uniqueBy( + [...currentContext.labels, ...currentLabels], + label => label._id ?? label.label, + ); + currentContext.sortValue = [ + currentContext.sites.map(site => site.label).join(' '), + currentContext.assets + .map(assetItem => assetItem.label) + .join(' '), + currentContext.labels.map(label => label.label).join(' '), + ].join(' '); + + contextsByResource.set(link.resourceId, currentContext); + index.set(link.linkType, contextsByResource); + }); + + (asset.assets ?? []).forEach(child => + this.collectAssetContexts( + child, + index, + sitesById, + labelsById, + topLevelAssetId, + topLevelAssetLabel, + currentHierarchy, + currentLabels, + siteLabel, + ), + ); + } + + private mergeLabels(base: SpLabel[], additional: SpLabel[]): SpLabel[] { + return this.uniqueBy( + [...base, ...additional], + label => label._id ?? label.label, + ); + } + + private uniqueBy<T>( + items: T[], + getKey: (item: T) => string | undefined, + ): T[] { + const seen = new Set<string>(); + return items.filter(item => { + const key = getKey(item); + if (!key || seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); + } +} 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 5252adb559..86da87ad6f 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,6 +17,26 @@ --> <div fxLayout="column"> + @if (shouldShowGroupingControls && !showSelectionCheckboxes) { + <div + class="grouping-toolbar grouping-toolbar--standalone" + fxLayout="row wrap" + fxLayoutAlign="space-between center" + fxLayoutGap="12px" + > + <div class="grouping-toolbar__spacer" fxFlex></div> + <div + class="grouping-toolbar__controls" + fxLayout="row wrap" + fxLayoutAlign="end center" + fxLayoutGap="12px" + > + <ng-container *ngTemplateOutlet="groupingControls"> + </ng-container> + </div> + </div> + } + @if (showSelectionCheckboxes) { <div class="selection-toolbar" @@ -26,6 +46,7 @@ fxLayoutGap="8px" > <div + class="selection-toolbar__left" fxLayout="row wrap" fxLayoutAlign="start center" fxLayoutGap="8px" @@ -56,16 +77,15 @@ {{ '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" - > + @if (selectedRows.length && hasMultiActionsToolbarControls()) { + <div + class="selection-toolbar__actions-inline" + fxLayout="row" + fxLayoutAlign="start center" + fxLayoutGap="5px" + > + @if (hasBuiltInMultiActionSelect()) { <mat-form-field class="form-field-small"> <mat-select data-cy="sp-table-multi-action-select" @@ -100,21 +120,19 @@ } </mat-select> </mat-form-field> - </sp-form-field> - } + } - @if (multiActionsTemplate) { - <ng-container - *ngTemplateOutlet=" - multiActionsTemplate; - context: multiActionsContext - " - > - </ng-container> - } + @if (multiActionsTemplate) { + <ng-container + *ngTemplateOutlet=" + multiActionsTemplate; + context: multiActionsContext + " + > + </ng-container> + } - @if (showMultiActionsExecuteButton) { - <sp-form-field [level]="3" label=" " margin="0"> + @if (showMultiActionsExecuteButton) { <button mat-flat-button data-cy="sp-table-multi-action-execute" @@ -125,14 +143,65 @@ > {{ multiActionsExecuteLabel | translate }} </button> - </sp-form-field> - } + } + </div> + } + </div> + + @if (shouldShowGroupingControls) { + <div + class="selection-toolbar__right" + fxLayout="row" + fxLayoutAlign="end center" + fxLayoutGap="8px" + > + <ng-container *ngTemplateOutlet="groupingControls"> + </ng-container> </div> } </div> } - <table mat-table class="sp-table" [dataSource]="dataSource"> + <ng-template #groupingControls> + @if (viewMode === 'grouped') { + <mat-form-field class="form-field-small grouping-toolbar__select"> + <mat-select + [value]="groupBy" + (valueChange)="setGrouping($event)" + > + <mat-option value="label"> + {{ 'Label' | translate }} + </mat-option> + <mat-option value="site"> + {{ 'Site' | translate }} + </mat-option> + <mat-option value="asset"> + {{ 'Asset' | translate }} + </mat-option> + </mat-select> + </mat-form-field> + } + + <mat-button-toggle-group + class="view-mode-toggle" + [value]="viewMode" + (change)="setViewMode($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> + </ng-template> + + <table + mat-table + class="sp-table" + [dataSource]="renderedDataSource" + [multiTemplateDataRows]="viewMode === 'grouped'" + > <ng-content></ng-content> @if (showSelectionCheckboxes) { @@ -173,53 +242,13 @@ {{ assetContextColumnLabel | translate }} </th> <td mat-cell *matCellDef="let element"> - @if (getAssetContext(element); as assetContext) { - <div - class="asset-context-cell" - fxLayout="row" - fxLayoutAlign="start center" - fxLayoutGap="6px" - > - @for (site of assetContext.sites; track site.id) { - <sp-label - size="small" - tone="info" - variant="soft" - [labelText]="site.label" - > - </sp-label> - } - @for ( - asset of assetContext.assets; - track asset.id - ) { - <sp-label - size="small" - tone="neutral" - variant="soft" - [labelText]="asset.label" - [matTooltip]="asset.tooltip" - > - </sp-label> - } - @for ( - label of assetContext.labels; - track label._id ?? label.label - ) { - <sp-label - size="small" - variant="soft" - [color]="label.color" - [labelText]="label.label" - > - </sp-label> - } - </div> - } @else { - <span class="asset-context-empty">{{ - 'No asset link' | translate - }}</span> - } + <ng-container + *ngTemplateOutlet=" + assetContextCell; + context: { $implicit: element } + " + > + </ng-container> </td> </ng-container> } @@ -228,58 +257,75 @@ <ng-container matColumnDef="actions"> <th mat-header-cell *matHeaderCellDef></th> <td mat-cell *matCellDef="let element"> - <div fxLayoutAlign="end center"> - @if (featureCardId) { - <button - [matTooltip]="'Preview' | translate" - mat-icon-button - (click)=" - openFeatureCard(element); - $event.stopPropagation() - " - > - <mat-icon>preview</mat-icon> - </button> - } + <ng-container + *ngTemplateOutlet=" + actionsCell; + context: { $implicit: element } + " + > + </ng-container> + </td> + </ng-container> + } + + <ng-container [matColumnDef]="groupHeaderColumnId"> + <td + mat-cell + *matCellDef="let group" + class="grouped-table-cell" + [attr.colspan]="renderedColumns.length" + > + @if (viewMode === 'grouped') { + <div + class="grouped-table-header" + fxLayout="row wrap" + fxLayoutAlign="space-between center" + fxLayoutGap="8px" + > <div - [matMenuTriggerFor]="menu" - #menuTrigger="matMenuTrigger" - (mouseenter)="mouseEnter(menuTrigger)" - (mouseleave)="mouseLeave(menuTrigger)" + fxLayout="row wrap" + fxLayoutAlign="start center" + fxLayoutGap="8px" > - <button - mat-icon-button - [matMenuTriggerFor]="menu" - #menuTrigger="matMenuTrigger" - (click)="$event.stopPropagation()" - [attr.data-cy]="'more-options'" + @if (group.color) { + <sp-label + size="small" + variant="soft" + [color]="group.color" + [labelText]="group.title" + > + </sp-label> + } @else { + <h4 class="grouped-table-title"> + {{ group.title }} + </h4> + } + <sp-label + size="small" + tone="neutral" + variant="soft" + [labelText]="group.count" > - <mat-icon>more_vert</mat-icon> - </button> + </sp-label> </div> - <mat-menu #menu="matMenu" [hasBackdrop]="false"> - <div - (mouseenter)="mouseEnter(menuTrigger)" - (mouseleave)="mouseLeave(menuTrigger)" - > - <ng-container - *ngTemplateOutlet=" - actionsTemplate; - context: { $implicit: element } - " - > - </ng-container> - </div> - </mat-menu> </div> - </td> - </ng-container> - } + } + </td> + </ng-container> <tr mat-header-row *matHeaderRowDef="renderedColumns"></tr> <tr mat-row - *matRowDef="let row; columns: renderedColumns" + *matRowDef=" + let row; + columns: groupHeaderColumns; + when: isGroupHeaderRow + " + class="grouped-table-row grouped-table-row--header" + ></tr> + <tr + mat-row + *matRowDef="let row; columns: renderedColumns; when: isDataRow" (click)="rowClicked.emit(row)" [ngClass]="rowsClickable ? 'cursor-pointer' : ''" ></tr> @@ -294,6 +340,7 @@ </td> </tr> </table> + <div fxFlex="100" fxLayoutAlign="end end" class="paginator-container"> <mat-paginator #paginator @@ -306,3 +353,99 @@ </mat-paginator> </div> </div> + +<ng-template #actionsCell let-element> + <div fxLayoutAlign="end center"> + @if (featureCardId) { + <button + [matTooltip]="'Preview' | translate" + mat-icon-button + (click)="openFeatureCard(element); $event.stopPropagation()" + > + <mat-icon>preview</mat-icon> + </button> + } + <div + [matMenuTriggerFor]="menu" + #menuTrigger="matMenuTrigger" + (mouseenter)="mouseEnter(menuTrigger)" + (mouseleave)="mouseLeave(menuTrigger)" + > + <button + mat-icon-button + [matMenuTriggerFor]="menu" + #menuTrigger="matMenuTrigger" + (click)="$event.stopPropagation()" + [attr.data-cy]="'more-options'" + > + <mat-icon>more_vert</mat-icon> + </button> + </div> + <mat-menu #menu="matMenu" [hasBackdrop]="false"> + <div + (mouseenter)="mouseEnter(menuTrigger)" + (mouseleave)="mouseLeave(menuTrigger)" + > + <ng-container + *ngTemplateOutlet=" + actionsTemplate; + context: { $implicit: element } + " + > + </ng-container> + </div> + </mat-menu> + </div> +</ng-template> + +<ng-template #assetContextCell let-element> + @if (getAssetContext(element); as assetContext) { + <div class="asset-context-cell"> + @if ( + showGroupedSitesInAssetContext && assetContext.sites.length > 0 + ) { + @for (site of assetContext.sites; track site.id) { + <sp-label + size="small" + tone="info" + variant="soft" + [labelText]="site.label" + > + </sp-label> + } + } + @if ( + showGroupedAssetsInAssetContext && + assetContext.assets.length > 0 + ) { + @for (asset of assetContext.assets; track asset.id) { + <sp-label + size="small" + tone="neutral" + variant="soft" + [labelText]="asset.label" + [matTooltip]="asset.tooltip" + > + </sp-label> + } + } + @if ( + showGroupedLabelsInAssetContext && + assetContext.labels.length > 0 + ) { + @for ( + label of assetContext.labels; + track label._id ?? label.label + ) { + <sp-label + size="small" + variant="soft" + [color]="label.color" + [labelText]="label.label" + > + </sp-label> + } + } + </div> + } +</ng-template> 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 f3f8e52aee..a9e2327893 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 @@ -22,6 +22,56 @@ .selection-toolbar { border-bottom: 1px solid var(--color-bg-3); + margin-bottom: 12px; + padding-bottom: 4px; +} + +.selection-toolbar__left { + flex: 1 1 auto; +} + +.grouping-toolbar__select { + width: 180px; +} + +.grouping-toolbar { + padding: 8px 0 16px; +} + +.grouping-toolbar--standalone { + margin-left: auto; +} + +.grouping-toolbar__controls { + margin-left: auto; +} + +.selection-toolbar__actions-inline { + margin-left: 4px; + align-items: center; +} + +.selection-toolbar__right { + margin-left: auto; + align-self: center; +} + +.view-mode-toggle { + align-self: center; +} + +.selection-toolbar__actions-inline .mat-mdc-form-field, +.grouping-toolbar__controls .mat-mdc-form-field { + margin-bottom: 0; +} + +.selection-toolbar__actions-inline button[mat-flat-button], +.grouping-toolbar__controls .view-mode-toggle { + align-self: center; +} + +.selection-toolbar__actions { + margin-left: auto; } .mat-mdc-row:hover { @@ -38,8 +88,8 @@ } .right-column { - text-align: right; /* align contents inside cell */ - margin-left: auto; /* push this column to the far right */ + text-align: right; + margin-left: auto; } .checkbox-multi-select { @@ -47,13 +97,37 @@ } .asset-context-cell { - display: flex; + display: inline-flex; flex-wrap: wrap; + align-items: center; min-width: 220px; - padding: 6px 0; + padding: 2px 0; + gap: 6px; } .asset-context-empty { color: var(--color-text-2); font-size: 12px; } + +.grouped-table-row--header { + height: auto; +} + +.grouped-table-cell { + padding: 0; + border-bottom: none; +} + +.grouped-table-header { + padding: 12px 16px; + border-top: 1px solid var(--color-bg-3); + border-bottom: 1px solid var(--color-bg-3); + background: var(--color-bg-1); +} + +.grouped-table-title { + margin: 0; + font-size: 16px; + font-weight: 700; +} 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 87b4095f23..7a08a07feb 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 @@ -51,9 +51,9 @@ import { MatTableDataSource, } from '@angular/material/table'; import { MatPaginator, PageEvent } from '@angular/material/paginator'; -import { SpTableActionsDirective } from './sp-table-actions.directive'; +import { SpTableActionsDirective } from './sp-actions/sp-table-actions.directive'; import { MatMenu, MatMenuTrigger } from '@angular/material/menu'; -import { SpTableMultiActionsDirective } from './sp-table-multi-actions.directive'; +import { SpTableMultiActionsDirective } from './sp-actions/sp-table-multi-actions.directive'; import { LocalStorageService } from '../../services/local-storage-settings.service'; import { FeatureCardService } from '../feature-card-host/feature-card.service'; import { @@ -72,44 +72,40 @@ 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'; import { SpAssetBrowserService } from '../asset-browser/asset-browser.service'; -import { AssetBrowserData } from '../asset-browser/asset-browser.model'; import { SpLabelComponent } from '../sp-label/sp-label.component'; -import { SpAsset, SpLabel } from '@streampipes/platform-services'; - -export interface SpTableMultiActionOption { - value: string; - label: string; - icon?: string; - disabled?: boolean; -} +import { + MatButtonToggle, + MatButtonToggleGroup, +} from '@angular/material/button-toggle'; +import { + SpTableAssetContextConfig, + SpTableMultiActionExecuteEvent, + SpTableMultiActionOption, + SpTableResolvedAssetContext, +} from './sp-table.model'; +import { SpTableAssetContextService } from './sp-asset-context/sp-table-asset-context.service'; -export interface SpTableMultiActionExecuteEvent<T> { - selectedRows: T[]; - action: string | null; -} +type SpTableGroupViewMode = 'list' | 'grouped'; +type SpTableGroupingMode = 'label' | 'site' | 'asset'; -export interface SpTableAssetContextConfig { - resourceLinkType: string; - resourceIdKey?: string; - columnId?: string; - columnLabel?: string; - hideBelowWidth?: number; +interface SpTableGroupedSection<T> { + id: string; + title: string; + color?: string; + count: number; + rows: T[]; } -interface SpTableAssetContextValue { +interface SpTableGroupHeaderRow { + __spGroupHeader: true; id: string; - label: string; - tooltip?: string; + title: string; + color?: string; + count: number; } -interface SpTableAssetContext { - assets: SpTableAssetContextValue[]; - sites: SpTableAssetContextValue[]; - labels: SpLabel[]; - sortValue: string; -} +type SpTableRenderedRow<T> = T | SpTableGroupHeaderRow; @Component({ selector: 'sp-table', @@ -134,6 +130,8 @@ interface SpTableAssetContext { MatMenu, MatSelect, MatOption, + MatButtonToggleGroup, + MatButtonToggle, NgTemplateOutlet, MatHeaderRowDef, MatHeaderRow, @@ -146,7 +144,6 @@ interface SpTableAssetContext { MatPaginator, TranslatePipe, LayoutGapDirective, - FormFieldComponent, SpLabelComponent, ], }) @@ -155,13 +152,16 @@ export class SpTableComponent<T> { readonly selectionColumnId = 'spSelection'; readonly defaultAssetContextColumnId = 'assetContext'; + readonly groupHeaderColumnId = 'spGroupHeader'; @ContentChildren(MatHeaderRowDef) headerRowDefs: QueryList<MatHeaderRowDef>; @ContentChildren(MatRowDef) rowDefs: QueryList<MatRowDef<T>>; @ContentChildren(MatColumnDef) columnDefs: QueryList<MatColumnDef>; @ContentChild(MatNoDataRow) noDataRow: MatNoDataRow; - @ViewChild(MatTable, { static: true }) table: MatTable<T>; + @ViewChild(MatTable, { static: true }) table: MatTable< + SpTableRenderedRow<T> + >; @Input() columns: string[]; @Input() rowsClickable = false; @@ -195,18 +195,22 @@ export class SpTableComponent<T> trigger: MatMenuTrigger | undefined = undefined; visiblePageRows: T[] = []; selectedMultiAction: string | null = null; + viewMode: SpTableGroupViewMode = 'list'; + groupBy: SpTableGroupingMode = 'asset'; + groupedSections: SpTableGroupedSection<T>[] = []; readonly selection = new SelectionModel<T>(true, []); private localStorageService = inject(LocalStorageService); private featureCardService = inject(FeatureCardService); private assetBrowserService = inject(SpAssetBrowserService); + private assetContextService = inject(SpTableAssetContextService); private renderedDataSubscription?: Subscription; private assetDataSubscription?: Subscription; private viewInitialized = false; private assetContextIndex = new Map< string, - Map<string, SpTableAssetContext> + Map<string, SpTableResolvedAssetContext> >(); private compactLayout = false; @@ -219,8 +223,10 @@ export class SpTableComponent<T> ); this.assetDataSubscription = this.assetBrowserService.assetData$.subscribe(assetData => { - this.assetContextIndex = this.buildAssetContextIndex(assetData); + this.assetContextIndex = + this.assetContextService.buildAssetContextIndex(assetData); this.applyAssetContextSortingAccessor(); + this.refreshRenderedRows(); }); this.updateCompactLayout(); } @@ -238,7 +244,9 @@ export class SpTableComponent<T> this.headerRowDefs.forEach(headerRowDef => this.table.addHeaderRowDef(headerRowDef), ); - this.table.setNoDataRow(this.noDataRow); + if (this.noDataRow) { + this.table.setNoDataRow(this.noDataRow); + } } ngOnChanges(changes: SimpleChanges) { @@ -264,9 +272,10 @@ export class SpTableComponent<T> this.ensureValidSelectedMultiAction(); } - if (changes['assetContextConfig'] || changes['dataSource']) { + if (changes['assetContextConfig']) { this.updateCompactLayout(); this.applyAssetContextSortingAccessor(); + this.refreshRenderedRows(); } } @@ -300,6 +309,9 @@ export class SpTableComponent<T> onPage(event: PageEvent) { this.localStorageService.set('paginator-page-size', event.pageSize); + if (this.viewMode === 'grouped') { + this.refreshRenderedRows(); + } } openFeatureCard(element: T) { @@ -323,6 +335,10 @@ export class SpTableComponent<T> return [this.selectionColumnId, ...baseColumns]; } + get groupHeaderColumns(): string[] { + return [this.groupHeaderColumnId]; + } + get assetContextColumnId(): string { return ( this.assetContextConfig?.columnId ?? @@ -334,7 +350,38 @@ export class SpTableComponent<T> return this.assetContextConfig?.columnLabel ?? 'Asset Context'; } - getAssetContext(row: T): SpTableAssetContext | undefined { + get shouldShowGroupingControls(): boolean { + return !!this.assetContextConfig; + } + + get renderedDataSource(): MatTableDataSource<T> | SpTableRenderedRow<T>[] { + return this.viewMode === 'grouped' + ? this.groupedSections.flatMap(section => [ + { + __spGroupHeader: true as const, + id: section.id, + title: section.title, + color: section.color, + count: section.count, + }, + ...section.rows, + ]) + : this.dataSource; + } + + get showGroupedLabelsInAssetContext(): boolean { + return this.viewMode !== 'grouped' || this.groupBy !== 'label'; + } + + get showGroupedSitesInAssetContext(): boolean { + return this.viewMode !== 'grouped' || this.groupBy !== 'site'; + } + + get showGroupedAssetsInAssetContext(): boolean { + return this.viewMode !== 'grouped' || this.groupBy !== 'asset'; + } + + getAssetContext(row: T): SpTableResolvedAssetContext | undefined { const config = this.assetContextConfig; if (!config) { return undefined; @@ -436,8 +483,38 @@ export class SpTableComponent<T> ); } + setViewMode(mode: SpTableGroupViewMode) { + if (mode === 'grouped') { + this.groupBy = 'asset'; + } + + if (this.viewMode === mode) { + this.refreshRenderedRows(); + return; + } + + this.viewMode = mode; + this.bindDataSource(); + this.refreshRenderedRows(); + } + + setGrouping(mode: SpTableGroupingMode) { + if (this.groupBy === mode) { + return; + } + + this.groupBy = mode; + this.refreshRenderedRows(); + } + + isGroupHeaderRow = (_: number, row: SpTableRenderedRow<T>) => + this.hasGroupHeaderMarker(row); + + isDataRow = (_: number, row: SpTableRenderedRow<T>) => + !this.hasGroupHeaderMarker(row); + private bindDataSource() { - if (!this.dataSource || !this.paginator) { + if (!this.dataSource) { return; } @@ -445,13 +522,39 @@ export class SpTableComponent<T> this.renderedDataSubscription?.unsubscribe(); this.renderedDataSubscription = this.dataSource.connect().subscribe({ - next: rows => { - this.visiblePageRows = rows ?? []; - this.pruneSelection(); - }, + next: rows => this.updateRenderedState(rows ?? []), }); } + private refreshRenderedRows() { + this.updateRenderedState(this.getCurrentPageRows(), false); + } + + 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[], pruneSelection = true) { + this.visiblePageRows = rows; + this.rebuildGroupedSections(rows); + + if (pruneSelection) { + this.pruneSelection(); + } + + if (this.viewInitialized) { + this.table.renderRows(); + } + } + private pruneSelection() { if (!this.selection.hasValue() || !this.dataSource) { return; @@ -559,126 +662,92 @@ export class SpTableComponent<T> }; } - private buildAssetContextIndex( - assetData?: AssetBrowserData, - ): Map<string, Map<string, SpTableAssetContext>> { - const index = new Map<string, Map<string, SpTableAssetContext>>(); - if (!assetData) { - return index; + private rebuildGroupedSections(rows: T[]) { + if (!this.assetContextConfig || this.viewMode !== 'grouped') { + this.groupedSections = []; + return; } - const sitesById = new Map( - assetData.sites.map(site => [site._id, site.label]), - ); - const labelsById = new Map( - assetData.labels - .filter( - (label): label is SpLabel & { _id: string } => !!label._id, - ) - .map(label => [label._id, label]), - ); + const grouped = new Map<string, SpTableGroupedSection<T>>(); + + rows.forEach(row => { + this.resolveGroups(row).forEach(group => { + const current = grouped.get(group.id) ?? { + id: group.id, + title: group.title, + color: group.color, + count: 0, + rows: [], + }; + current.rows.push(row); + current.count += 1; + grouped.set(group.id, current); + }); + }); - assetData.assets.forEach(asset => - this.collectAssetContexts( - asset, - index, - sitesById, - labelsById, - [], - [], - null, - ), - ); + this.groupedSections = Array.from(grouped.values()) + .sort((left, right) => left.title.localeCompare(right.title)) + .map(group => ({ + ...group, + rows: [...group.rows], + })); + } - return index; + private resolveGroups( + row: T, + ): { id: string; title: string; color?: string }[] { + const assetContext = this.getAssetContext(row); + + if (this.groupBy === 'label') { + return this.resolveLabelGroups(assetContext); + } + + if (this.groupBy === 'site') { + return this.resolveSiteGroups(assetContext); + } + + return this.resolveAssetGroups(assetContext); } - private collectAssetContexts( - asset: SpAsset, - index: Map<string, Map<string, SpTableAssetContext>>, - sitesById: Map<string, string>, - labelsById: Map<string, SpLabel>, - hierarchy: string[], - inheritedLabels: SpLabel[], - inheritedSiteLabel: string | null, - ): void { - const currentHierarchy = [...hierarchy, asset.assetName].filter( - Boolean, - ); - const topLevelAsset = currentHierarchy[0] ?? asset.assetName; - const currentLabels = this.mergeLabels( - inheritedLabels, - (asset.labelIds ?? []) - .map(labelId => labelsById.get(labelId)) - .filter((label): label is SpLabel => !!label), - ); - const siteLabel = - (asset.assetSite?.siteId && - sitesById.get(asset.assetSite.siteId)) ?? - asset.assetSite?.area ?? - inheritedSiteLabel; - - (asset.assetLinks ?? []).forEach(link => { - const contextsByResource = - index.get(link.linkType) ?? - new Map<string, SpTableAssetContext>(); - const currentContext = contextsByResource.get(link.resourceId) ?? { - assets: [], - sites: [], - labels: [], - sortValue: '', - }; - - currentContext.assets = this.uniqueBy( - [ - ...currentContext.assets, - { - id: asset.assetId, - label: topLevelAsset, - tooltip: currentHierarchy.join(' / '), - }, - ], - item => item.id, - ); - currentContext.sites = this.uniqueBy( - siteLabel - ? [ - ...currentContext.sites, - { - id: asset.assetSite?.siteId ?? siteLabel, - label: siteLabel, - }, - ] - : currentContext.sites, - item => item.id, - ); - currentContext.labels = this.uniqueBy( - [...currentContext.labels, ...currentLabels], - label => label._id ?? label.label, - ); - currentContext.sortValue = [ - currentContext.sites.map(site => site.label).join(' '), - currentContext.assets - .map(assetItem => assetItem.label) - .join(' '), - currentContext.labels.map(label => label.label).join(' '), - ].join(' '); - - contextsByResource.set(link.resourceId, currentContext); - index.set(link.linkType, contextsByResource); - }); + private resolveLabelGroups( + assetContext?: SpTableResolvedAssetContext, + ): { id: string; title: string; color?: string }[] { + const labels = assetContext?.labels ?? []; + return labels.length + ? labels.map(label => ({ + id: `label:${label._id ?? label.label}`, + title: label.label, + color: label.color, + })) + : [this.createUnassignedGroup()]; + } - (asset.assets ?? []).forEach(child => - this.collectAssetContexts( - child, - index, - sitesById, - labelsById, - currentHierarchy, - currentLabels, - siteLabel, - ), - ); + private resolveSiteGroups( + assetContext?: SpTableResolvedAssetContext, + ): { id: string; title: string; color?: string }[] { + const sites = assetContext?.sites ?? []; + return sites.length + ? sites.map(site => ({ + id: `site:${site.id}`, + title: site.label, + })) + : [this.createUnassignedGroup()]; + } + + private resolveAssetGroups( + assetContext?: SpTableResolvedAssetContext, + ): { id: string; title: string; color?: string }[] { + const assets = assetContext?.assets ?? []; + return assets.length + ? assets.map(asset => ({ + id: `asset:${asset.id}`, + title: asset.label, + })) + : [this.createUnassignedGroup()]; + } + + private createUnassignedGroup(): { id: string; title: string } { + return { id: 'unassigned', title: 'Unassigned' }; } private getAssetContextResourceId( @@ -689,26 +758,9 @@ export class SpTableComponent<T> return (row as Record<string, string | undefined>)?.[key]; } - private mergeLabels(base: SpLabel[], additional: SpLabel[]): SpLabel[] { - return this.uniqueBy( - [...base, ...additional], - label => label._id ?? label.label, - ); - } - - private uniqueBy<T>( - items: T[], - getKey: (item: T) => string | undefined, - ): T[] { - const seen = new Set<string>(); - return items.filter(item => { - const key = getKey(item); - if (!key || seen.has(key)) { - return false; - } - - seen.add(key); - return true; - }); + private hasGroupHeaderMarker( + row: SpTableRenderedRow<T>, + ): row is SpTableGroupHeaderRow { + return !!(row as SpTableGroupHeaderRow).__spGroupHeader; } } diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.model.ts b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.model.ts new file mode 100644 index 0000000000..3608a29b13 --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.model.ts @@ -0,0 +1,65 @@ +/* + * 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 { SpLabel } from '@streampipes/platform-services'; + +export interface SpTableMultiActionOption { + value: string; + label: string; + icon?: string; + disabled?: boolean; +} + +export interface SpTableMultiActionExecuteEvent<T> { + selectedRows: T[]; + action: string | null; +} + +export interface SpTableAssetContextConfig { + resourceLinkType: string; + resourceIdKey?: string; + columnId?: string; + columnLabel?: string; + hideBelowWidth?: number; +} + +export class SpTableAssetContextValue { + id: string; + label: string; + tooltip?: string; + + constructor(id: string, label: string, tooltip?: string) { + this.id = id; + this.label = label; + this.tooltip = tooltip; + } +} + +export class SpTableResolvedAssetContext { + assets: SpTableAssetContextValue[]; + sites: SpTableAssetContextValue[]; + labels: SpLabel[]; + sortValue: string; + + constructor() { + this.assets = []; + this.sites = []; + this.labels = []; + this.sortValue = ''; + } +} diff --git a/ui/projects/streampipes/shared-ui/src/public-api.ts b/ui/projects/streampipes/shared-ui/src/public-api.ts index 446a78381e..fb7d075f89 100644 --- a/ui/projects/streampipes/shared-ui/src/public-api.ts +++ b/ui/projects/streampipes/shared-ui/src/public-api.ts @@ -43,8 +43,10 @@ export * from './lib/components/sp-exception-message/exception-details-dialog/ex export * from './lib/components/sp-exception-message/exception-details/exception-details.component'; 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/sp-table/sp-actions/sp-table-actions.directive'; +export * from './lib/components/sp-table/sp-actions/sp-table-multi-actions.directive'; +export * from './lib/components/sp-table/sp-table.model'; +export * from './lib/components/sp-table/sp-asset-context/sp-table-asset-context.service'; 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/pipelines/components/pipeline-overview/pipeline-overview.component.ts b/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview.component.ts index 591248d91b..a4c3037c19 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 @@ -132,7 +132,7 @@ export class PipelineOverviewComponent implements OnInit, OnDestroy { private dialogService = inject(DialogService); ngOnInit() { - this.userSub = this.currentUserService.user$.subscribe(user => { + this.userSub = this.currentUserService.user$.subscribe(() => { this.hasPipelineWritePrivileges = this.authService.hasRole( UserPrivilege.PRIVILEGE_WRITE_PIPELINE, );
