This is an automated email from the ASF dual-hosted git repository. dominikriemer pushed a commit to branch add-card-layout in repository https://gitbox.apache.org/repos/asf/streampipes.git
commit 34547593076408a12f5ad42d31ba47965660756e Author: Dominik Riemer <[email protected]> AuthorDate: Thu Nov 5 09:38:20 2026 +0100 feat: Add card layout to overview tables --- DESIGN.md | 2 + ui/STYLEGUIDE.md | 69 ++++++ ui/deployment/i18n/de.json | 5 + ui/deployment/i18n/en.json | 5 + ui/deployment/i18n/pl.json | 5 + .../overview-card-facts.component.spec.ts | 50 +++++ .../overview-card/overview-card-facts.component.ts | 73 +++++++ .../overview-card-grid.component.scss} | 23 +- .../overview-card/overview-card-grid.component.ts} | 20 +- .../overview-card/overview-card.component.html | 66 ++++++ .../overview-card/overview-card.component.scss | 158 ++++++++++++++ .../overview-card/overview-card.component.spec.ts | 122 +++++++++++ .../overview-card/overview-card.component.ts | 47 +++++ .../sp-actions/sp-table-card.directive.ts} | 26 +-- .../components/sp-table/sp-table.component.html | 67 +++++- .../components/sp-table/sp-table.component.scss | 37 ++++ .../components/sp-table/sp-table.component.spec.ts | 235 ++++++++++++++++++++- .../lib/components/sp-table/sp-table.component.ts | 83 +++++++- .../status-label/status-label.component.html | 31 +++ .../status-label/status-label.component.scss | 71 +++++++ .../status-label/status-label.component.spec.ts | 71 +++++++ .../status-label/status-label.component.ts | 55 +++++ .../streampipes/shared-ui/src/public-api.ts | 5 + .../adapter-overview-card.component.html | 109 ++++++++++ .../adapter-overview-card.component.scss} | 25 +-- .../adapter-overview-card.component.spec.ts | 190 +++++++++++++++++ .../adapter-overview-card.component.ts | 113 ++++++++++ .../adapter-status-light.component.html | 25 --- .../existing-adapters.component.html | 65 +++++- .../existing-adapters.component.scss | 12 ++ .../existing-adapters.component.ts | 22 +- .../pipeline-overview-card.component.html | 131 ++++++++++++ .../pipeline-overview-card.component.scss | 70 ++++++ .../pipeline-overview-card.component.spec.ts | 183 ++++++++++++++++ .../pipeline-overview-card.component.ts | 74 +++++++ .../pipeline-overview.component.html | 102 ++++++--- .../pipeline-overview.component.scss | 11 + .../pipeline-overview.component.ts | 22 +- .../pipeline-overview/pipeline-status.spec.ts | 43 ++++ .../pipeline-overview/pipeline-status.ts} | 33 +-- ui/src/scss/sp/buttons-mat3.scss | 41 +++- 41 files changed, 2437 insertions(+), 160 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 0c151fdd56..e14a7b930b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -294,6 +294,8 @@ Shared components are compact, dependable, and familiar. Prefer the existing than introduce a new hue. Keyboard focus uses the shared three-pixel focus ring. - **Secondary / Warning:** Secondary actions use a subtle primary-tinted surface; destructive actions use the dedicated error role. Both stay flat at rest. +- **Tertiary:** Actions that repeat many times on a page, such as card footers, stay + transparent at rest and show the primary tint only on hover. ### Chips diff --git a/ui/STYLEGUIDE.md b/ui/STYLEGUIDE.md index adb7b2a614..e1c394228b 100644 --- a/ui/STYLEGUIDE.md +++ b/ui/STYLEGUIDE.md @@ -162,6 +162,74 @@ In some views it might make sense to organize the layout based on panels. Use level to control the size of the section header and margins. In views with enough space, we use level=2. In dialogs and in dense layouts, we use level=3. +#### Status + +Use `sp-status-label` for the state of a resource, such as running or stopped. Hide +the text where space is tight, for example in table cells: the label then becomes +the tooltip and accessible name of the symbol. Use `sp-label` for values such as +counts, and for tags such as asset labels, which have no state. + +```html +<sp-status-label tone="success" [label]="A"></sp-status-label> +<sp-status-label + tone="success" + [label]="A" + [showLabel]="false" +></sp-status-label> +``` + +#### Overview cards + +Use `sp-overview-card` for card views of overview pages. The shell provides the +title, description and surface; feature cards project their own icon, header +actions, content and footer actions. Unused slots collapse. Use `titleClickable` and +`titleClick` when the title opens the resource, like a table row does. Pass the module colour as +`accentColor`. + +```html +<sp-overview-card [title]="A" [description]="B"> + <img cardLeading alt="" /> + <button cardHeaderActions mat-icon-button> + <mat-icon>more_vert</mat-icon> + </button> + <sp-overview-card-facts> + <div spOverviewCardFact [label]="E"> + <sp-status-label tone="success" [label]="C"></sp-status-label> + </div> + </sp-overview-card-facts> + <ng-container cardAssetContext></ng-container> + <button cardFooter type="button" mat-flat-button class="btn-tertiary"> + <mat-icon aria-hidden="true">edit</mat-icon> + <span>D</span> + </button> +</sp-overview-card> +``` + +To add a card view to an `sp-table`, declare a `spTableCard` template. It adds the +cards toggle and renders the current page in an `sp-overview-card-grid`. Its +`actions` and `assetContext` context values are the table's cell templates, so the +feature card link and the action menu stay the same in every view. On cards, the +asset context stays on one line and summarizes further entries as `+N`. + +```html +<ng-template + spTableCard + let-row + let-actions="actions" + let-assetContext="assetContext" +> + <sp-overview-card [title]="row.name"> + <ng-container + cardHeaderActions + *ngTemplateOutlet="actions; context: { $implicit: row }" + ></ng-container> + <ng-container + *ngTemplateOutlet="assetContext; context: { $implicit: row }" + ></ng-container> + </sp-overview-card> +</ng-template> +``` + #### Buttons Buttons are defined as follows: @@ -179,6 +247,7 @@ There are different forms of buttons that we can use: - Primary buttons are rendered when no other CSS classes are applied. Primary buttons serve to identify an action. - Secondary buttons are applied with the `btn-secondary` css class (for legacy reasons, also `mat-basic` is possible) +- Tertiary buttons are applied with the `btn-tertiary` css class. They are transparent until hovered. Use them for actions that repeat many times on a page, such as card footers, where tinted buttons would compete with the content. - Warning/Error buttons are applied with the `btn-warn` css class. Smaller buttons can be applied with the `small-button` css class. Use small buttons only in dense layouts. diff --git a/ui/deployment/i18n/de.json b/ui/deployment/i18n/de.json index 8adad43d32..8048320212 100644 --- a/ui/deployment/i18n/de.json +++ b/ui/deployment/i18n/de.json @@ -191,6 +191,7 @@ "Cancel": "Abbrechen", "Car": "Auto", "Card settings": "Karteneinstellungen", + "Cards": "Karten", "Caution when changing data types": "Vorsicht bei Änderung des Datentyps", "Certificate Details": "Zertifikat-Details", "Certificate details": "Zertifikat-Details", @@ -744,6 +745,7 @@ "Message Type": "Art der Meldung", "Message expired": "Meldung abgelaufen", "Messages": "Nachrichten", + "Messages sent since last start": "Seit dem letzten Start gesendete Nachrichten", "Millisecond": "Millisekunde", "Min": "Min", "Min Slice Angle": "Minimaler Segmentwinkel", @@ -825,6 +827,7 @@ "Node Details": "Details zum Knoten", "None": "Keine", "Nothing changed": "Keine Änderungen", + "Notifications": "Benachrichtigungen", "Number": "Nummer", "Number of bins": "Anzahl der Bins", "Number of major segments/ticks on the gauge arc.": "Anzahl der Hauptsegmente/-markierungen auf dem Tacho-Bogen.", @@ -1166,6 +1169,7 @@ "State": "Status", "Status": "Status", "Sticky headers": "Fixierte Kopfzeilen", + "Stop": "Stopp", "Stop Pipeline": "Pipeline stoppen", "Stop and delete pipeline": "Pipeline stoppen und löschen", "Stop pipeline": "Pipeline stoppen", @@ -1430,6 +1434,7 @@ "install": "installieren", "left axis": "linke Achse", "marked as timestamp": "als Zeitstempel markiert", + "n/a": "k. A.", "needs manual review after this update.": "muss nach dieser Aktualisierung manuell geprüft werden.", "new filename": "neuer Dateiname", "new filename:": "neuer Dateiname:", diff --git a/ui/deployment/i18n/en.json b/ui/deployment/i18n/en.json index 96d6c8b72e..323249246c 100644 --- a/ui/deployment/i18n/en.json +++ b/ui/deployment/i18n/en.json @@ -191,6 +191,7 @@ "Cancel": null, "Car": null, "Card settings": null, + "Cards": null, "Caution when changing data types": null, "Certificate Details": null, "Certificate details": null, @@ -744,6 +745,7 @@ "Message Type": null, "Message expired": null, "Messages": null, + "Messages sent since last start": null, "Millisecond": null, "Min": null, "Min Slice Angle": null, @@ -825,6 +827,7 @@ "Node Details": null, "None": null, "Nothing changed": null, + "Notifications": null, "Number": null, "Number of bins": null, "Number of major segments/ticks on the gauge arc.": null, @@ -1166,6 +1169,7 @@ "State": null, "Status": null, "Sticky headers": null, + "Stop": null, "Stop Pipeline": null, "Stop and delete pipeline": null, "Stop pipeline": null, @@ -1430,6 +1434,7 @@ "install": null, "left axis": null, "marked as timestamp": null, + "n/a": null, "needs manual review after this update.": null, "new filename": null, "new filename:": null, diff --git a/ui/deployment/i18n/pl.json b/ui/deployment/i18n/pl.json index 7829c5162d..218b43ad89 100644 --- a/ui/deployment/i18n/pl.json +++ b/ui/deployment/i18n/pl.json @@ -191,6 +191,7 @@ "Cancel": "Anuluj", "Car": "Samochód", "Card settings": "Ustawienia karty", + "Cards": "Karty", "Caution when changing data types": "Uwaga przy zmianie typów danych", "Certificate Details": "Szczegóły certyfikatu", "Certificate details": "Szczegóły certyfikatu", @@ -744,6 +745,7 @@ "Message Type": "Rodzaj komunikatu", "Message expired": "Komunikat wygasł", "Messages": "Wiadomości", + "Messages sent since last start": "Wiadomości wysłane od ostatniego uruchomienia", "Millisecond": "Milisekunda", "Min": "Min", "Min Slice Angle": "Minimalny kąt segmentu", @@ -825,6 +827,7 @@ "Node Details": "Szczegóły węzła", "None": "Brak", "Nothing changed": "Brak zmian", + "Notifications": "Powiadomienia", "Number": "Liczba", "Number of bins": "Liczba przedziałów", "Number of major segments/ticks on the gauge arc.": "Liczba głównych segmentów/podziałek na łuku wskaźnika.", @@ -1166,6 +1169,7 @@ "State": "Stan", "Status": "Status", "Sticky headers": "Przyklejone nagłówki", + "Stop": "Zatrzymaj", "Stop Pipeline": "Zatrzymaj strumień", "Stop and delete pipeline": "Zatrzymaj i usuń strumień", "Stop pipeline": "Zatrzymaj strumień", @@ -1430,6 +1434,7 @@ "install": "zainstaluj", "left axis": "lewa oś", "marked as timestamp": "oznaczone jako znacznik czasu", + "n/a": "b.d.", "needs manual review after this update.": "wymaga ręcznego sprawdzenia po tej aktualizacji.", "new filename": "nowa nazwa pliku", "new filename:": "nowa nazwa pliku:", diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card-facts.component.spec.ts b/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card-facts.component.spec.ts new file mode 100644 index 0000000000..44a3641d94 --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card-facts.component.spec.ts @@ -0,0 +1,50 @@ +/* + * 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 } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it } from 'vitest'; +import { + SpOverviewCardFactComponent, + SpOverviewCardFactsComponent, +} from './overview-card-facts.component'; + +@Component({ + imports: [SpOverviewCardFactComponent, SpOverviewCardFactsComponent], + template: ` + <sp-overview-card-facts> + <div spOverviewCardFact label="Status">Running</div> + <div spOverviewCardFact label="Created">Today</div> + </sp-overview-card-facts> + `, +}) +class FactsHostComponent {} + +describe('SpOverviewCardFactsComponent', () => { + it('renders the facts as a description list', () => { + const fixture = TestBed.createComponent(FactsHostComponent); + fixture.detectChanges(); + const groups = (fixture.nativeElement as HTMLElement).querySelectorAll( + 'dl > div', + ); + + expect(groups.length).toBe(2); + expect(groups[0].querySelector('dt').textContent).toBe('Status'); + expect(groups[0].querySelector('dd').textContent).toBe('Running'); + }); +}); diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card-facts.component.ts b/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card-facts.component.ts new file mode 100644 index 0000000000..a766ce2a26 --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card-facts.component.ts @@ -0,0 +1,73 @@ +/* + * 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 { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +/** Row of `spOverviewCardFact` items with equal column widths. */ +@Component({ + selector: 'sp-overview-card-facts', + template: '<dl><ng-content /></dl>', + styles: ` + :host { + display: block; + } + + dl { + display: grid; + grid-auto-columns: minmax(0, 1fr); + grid-auto-flow: column; + gap: var(--space-sm); + margin: 0; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SpOverviewCardFactsComponent {} + +/** Labelled value in `sp-overview-card-facts`. */ +@Component({ + // A description list only allows dt and dd within a div. + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'div[spOverviewCardFact]', + template: ` + <dt>{{ label() }}</dt> + <dd><ng-content /></dd> + `, + styles: ` + :host { + min-width: 0; + } + + dt { + color: var(--mat-sys-on-surface-variant); + font-size: var(--font-size-xs); + } + + dd { + margin: var(--space-2xs) 0 0; + font-size: var(--font-size-sm); + font-variant-numeric: tabular-nums; + font-weight: var(--font-weight-medium); + line-height: 1.6rem; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SpOverviewCardFactComponent { + readonly label = input.required<string>(); +} diff --git a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss b/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card-grid.component.scss similarity index 78% copy from ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss copy to ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card-grid.component.scss index 2376ff86ae..29c07881ee 100644 --- a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss +++ b/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card-grid.component.scss @@ -16,21 +16,10 @@ * */ -.mat-mdc-header-cell { - font-weight: bold; -} - -.adapter-icon { - padding-top: 5px; - padding-bottom: 5px; - max-height: 35px; -} - -.real-time { - cursor: pointer; - color: #ffeb3b; -} - -.icon { - max-width: 60px; +:host { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 23.75rem), 1fr)); + grid-auto-rows: 1fr; + align-items: stretch; + gap: var(--space-lg); } diff --git a/ui/src/app/connect/components/existing-adapters/adapter-status-light/adapter-status-light.component.ts b/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card-grid.component.ts similarity index 64% rename from ui/src/app/connect/components/existing-adapters/adapter-status-light/adapter-status-light.component.ts rename to ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card-grid.component.ts index 9740b95423..bb755db2bf 100644 --- a/ui/src/app/connect/components/existing-adapters/adapter-status-light/adapter-status-light.component.ts +++ b/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card-grid.component.ts @@ -16,19 +16,13 @@ * */ -import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; -import { - LayoutAlignDirective, - LayoutDirective, -} from '@ngbracket/ngx-layout/flex'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +/** Responsive, equal-height grid for `sp-overview-card` items. */ @Component({ - selector: 'sp-adapter-status-light', - templateUrl: './adapter-status-light.component.html', - changeDetection: ChangeDetectionStrategy.Eager, - imports: [LayoutDirective, LayoutAlignDirective], + selector: 'sp-overview-card-grid', + template: '<ng-content />', + styleUrls: ['./overview-card-grid.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AdapterStatusLightComponent { - @Input() - adapterRunning: boolean; -} +export class SpOverviewCardGridComponent {} diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card.component.html b/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card.component.html new file mode 100644 index 0000000000..561effb237 --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card.component.html @@ -0,0 +1,66 @@ +<!-- + ~ 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. + ~ + --> + +<article + class="card" + [style.--card-accent]="accentColor()" + [attr.aria-label]="title()" +> + <header class="card-header"> + <div class="card-leading"> + <ng-content select="[cardLeading]" /> + </div> + <div class="card-identity"> + <div + class="card-title" + role="heading" + aria-level="2" + [matTooltip]="title()" + > + @if (titleClickable()) { + <button + type="button" + class="card-title-link" + (click)="titleClick.emit()" + > + {{ title() }} + </button> + } @else { + {{ title() }} + } + </div> + @if (description()) { + <p class="card-description" [matTooltip]="description()"> + {{ description() }} + </p> + } + </div> + <div class="card-header-actions"> + <ng-content select="[cardHeaderActions]" /> + </div> + </header> + <div class="card-body"> + <ng-content /> + </div> + <div class="card-context"> + <ng-content select="[cardAssetContext]" /> + </div> + <footer class="card-footer"> + <ng-content select="[cardFooter]" /> + </footer> +</article> diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card.component.scss b/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card.component.scss new file mode 100644 index 0000000000..274bbeaf3c --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card.component.scss @@ -0,0 +1,158 @@ +/* + * 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. + * + */ + +:host { + display: block; + height: 100%; + min-width: 0; +} + +.card { + box-sizing: border-box; + display: flex; + flex-direction: column; + height: 100%; + min-width: 0; + overflow: hidden; + background: var(--color-bg-0); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + color: var(--mat-sys-on-surface); +} + +/* Reserves the title and two description lines, so that the sections of + neighbouring cards stay aligned regardless of their text length. */ +.card-header { + --card-title-line-height: 1.3; + --card-description-line-height: 1.4; + + box-sizing: border-box; + display: flex; + align-items: flex-start; + gap: var(--space-sm); + min-height: calc( + var(--font-size-lg) * var(--card-title-line-height) + var(--space-2xs) + + var(--font-size-sm) * var(--card-description-line-height) * 2 + + var(--space-md) * 2 + ); + padding: var(--space-md) var(--space-sm) var(--space-md) var(--space-lg); +} + +.card-leading { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + border-radius: var(--radius-sm); + background: color-mix( + in srgb, + var(--card-accent, var(--color-primary)) 12%, + transparent + ); + color: var(--card-accent, var(--color-primary)); +} + +.card-identity { + flex: 1; + min-width: 0; +} + +.card-title { + overflow: hidden; + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + line-height: var(--card-title-line-height); + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-title-link { + display: block; + max-width: 100%; + padding: 0; + overflow: hidden; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-title-link:hover { + color: var(--mat-sys-primary); + text-decoration: underline; +} + +.card-title-link:focus-visible { + outline: 2px solid var(--mat-sys-primary); + outline-offset: -2px; +} + +.card-description { + display: -webkit-box; + margin: var(--space-2xs) 0 0; + overflow: hidden; + overflow-wrap: anywhere; + color: var(--mat-sys-on-surface-variant); + font-size: var(--font-size-sm); + line-height: var(--card-description-line-height); + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.card-header-actions { + display: flex; + flex-shrink: 0; + align-items: center; + gap: var(--space-2xs); +} + +.card-body { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--space-md); + min-width: 0; + padding: 0 var(--space-lg) var(--space-md); +} + +/* The side padding lines up the icon of a btn-tertiary with the card content. */ +.card-context { + min-width: 0; + padding: 0 var(--space-lg) var(--space-md); +} + +.card-footer { + display: flex; + gap: var(--space-2xs); + padding: var(--space-xs) calc(var(--space-lg) - 0.75rem - 1px); + border-top: 1px solid var(--color-border-subtle); +} + +.card-body:empty, +.card-context:empty, +.card-footer:empty, +.card-leading:empty, +.card-header-actions:empty { + display: none; +} diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card.component.spec.ts b/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card.component.spec.ts new file mode 100644 index 0000000000..9e6118bf3e --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card.component.spec.ts @@ -0,0 +1,122 @@ +/* + * 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 } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it } from 'vitest'; +import { SpOverviewCardGridComponent } from './overview-card-grid.component'; +import { SpOverviewCardComponent } from './overview-card.component'; + +@Component({ + imports: [SpOverviewCardComponent, SpOverviewCardGridComponent], + template: ` + <sp-overview-card-grid> + <sp-overview-card id="full" title="Pump" description="Main hall"> + <img cardLeading alt="" /> + <button cardHeaderActions>Settings</button> + <p id="content">Content</p> + <span id="assets" cardAssetContext>Assets</span> + <a cardFooter href="#">Details</a> + </sp-overview-card> + <sp-overview-card id="minimal" title="Valve" /> + </sp-overview-card-grid> + `, +}) +class OverviewCardHostComponent {} + +describe('SpOverviewCardComponent', () => { + function render(): HTMLElement { + const fixture = TestBed.createComponent(OverviewCardHostComponent); + fixture.detectChanges(); + return fixture.nativeElement; + } + + it('labels the card and renders its identity', () => { + const card = render().querySelector('#full article'); + + expect(card.getAttribute('aria-label')).toBe('Pump'); + expect(card.querySelector('[role="heading"]').textContent).toContain( + 'Pump', + ); + expect(card.querySelector('.card-description').textContent.trim()).toBe( + 'Main hall', + ); + }); + + it('projects content into the header, body and footer slots', () => { + const card = render().querySelector('#full'); + + expect(card.querySelector('.card-leading img')).not.toBeNull(); + expect( + card.querySelector('.card-header-actions button').textContent, + ).toBe('Settings'); + expect(card.querySelector('.card-body #content')).not.toBeNull(); + expect(card.querySelector('.card-context #assets')).not.toBeNull(); + expect(card.querySelector('.card-footer a').textContent).toBe( + 'Details', + ); + }); + + it('renders a plain title unless it is clickable', () => { + expect(render().querySelector('.card-title-link')).toBeNull(); + }); + + it('emits the title click when the title is clickable', () => { + const fixture = TestBed.createComponent(SpOverviewCardComponent); + fixture.componentRef.setInput('title', 'Pump'); + fixture.componentRef.setInput('titleClickable', true); + let clicks = 0; + fixture.componentInstance.titleClick.subscribe(() => clicks++); + fixture.detectChanges(); + + (fixture.nativeElement as HTMLElement) + .querySelector<HTMLElement>('.card-title-link') + .click(); + + expect(clicks).toBe(1); + }); + + it('applies the accent colour', () => { + const fixture = TestBed.createComponent(SpOverviewCardComponent); + fixture.componentRef.setInput('title', 'Pump'); + fixture.componentRef.setInput('accentColor', 'rgb(1, 2, 3)'); + fixture.detectChanges(); + const card = ( + fixture.nativeElement as HTMLElement + ).querySelector<HTMLElement>('article'); + + expect(card.style.getPropertyValue('--card-accent')).toBe( + 'rgb(1, 2, 3)', + ); + }); + + it('leaves unused slots empty so they collapse', () => { + const card = render().querySelector('#minimal'); + + expect(card.querySelector('.card-description')).toBeNull(); + [ + '.card-leading', + '.card-header-actions', + '.card-body', + '.card-context', + '.card-footer', + ].forEach(slot => + expect(card.querySelector(slot).matches(':empty'), slot).toBe(true), + ); + }); +}); diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card.component.ts b/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card.component.ts new file mode 100644 index 0000000000..9b404271eb --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/overview-card/overview-card.component.ts @@ -0,0 +1,47 @@ +/* + * 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 { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core'; +import { MatTooltip } from '@angular/material/tooltip'; + +/** + * Generic card shell for overview pages: identity header, content and footer. + * Feature cards supply their own content through the projection slots. + */ +@Component({ + selector: 'sp-overview-card', + templateUrl: './overview-card.component.html', + styleUrls: ['./overview-card.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatTooltip], +}) +export class SpOverviewCardComponent { + readonly title = input.required<string>(); + readonly description = input<string>(); + /** Renders the title as a button that opens the resource. */ + readonly titleClickable = input(false); + /** Colour of the leading icon, usually the colour of the module. */ + readonly accentColor = input<string>(); + + readonly titleClick = output<void>(); +} diff --git a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-actions/sp-table-card.directive.ts similarity index 65% copy from ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss copy to ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-actions/sp-table-card.directive.ts index 2376ff86ae..3c6e6f6428 100644 --- a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss +++ b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-actions/sp-table-card.directive.ts @@ -16,21 +16,13 @@ * */ -.mat-mdc-header-cell { - font-weight: bold; -} +import { Directive } from '@angular/core'; -.adapter-icon { - padding-top: 5px; - padding-bottom: 5px; - max-height: 35px; -} - -.real-time { - cursor: pointer; - color: #ffeb3b; -} - -.icon { - max-width: 60px; -} +/** + * Marks the template of a table row in the card view. Declaring it enables the + * card view mode of `sp-table`. The template context contains the row as + * `$implicit` and the table's `actions` and `assetContext` cell templates, which + * both expect the row as their `$implicit` context. + */ +@Directive({ selector: 'ng-template[spTableCard]' }) +export class SpTableCardDirective {} 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 9839a816d3..973140231b 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 @@ -24,7 +24,7 @@ > @if ( (shouldShowGroupingControls || shouldShowNameSearch) && - !showSelectionCheckboxes + !showSelectionToolbar ) { <div class="grouping-toolbar" @@ -52,7 +52,7 @@ </div> } - @if (showSelectionCheckboxes) { + @if (showSelectionToolbar) { <div class="selection-toolbar" [class.no-toolbar-border-top]="noToolbarBorderTop" @@ -241,12 +241,43 @@ <mat-button-toggle value="list"> {{ 'List' | translate }} </mat-button-toggle> + @if (cardTemplate) { + <mat-button-toggle value="cards" data-cy="sp-table-view-cards"> + {{ 'Cards' | translate }} + </mat-button-toggle> + } </mat-button-toggle-group> </ng-template> + @if (showCardView) { + <div class="sp-table-cards"> + <sp-overview-card-grid data-cy="sp-table-card-grid"> + @for (row of visiblePageRows; track getSelectionKey(row)) { + <ng-container + *ngTemplateOutlet=" + cardTemplate; + context: { + $implicit: row, + actions: actionsCell, + assetContext: cardAssetContext, + } + " + > + </ng-container> + } + </sp-overview-card-grid> + @if (!visiblePageRows.length) { + <p class="sp-table-cards__empty" data-cy="no-table-entries"> + {{ 'No entries available.' | translate }} + </p> + } + </div> + } + <table mat-table class="sp-table" + [class.sp-table--hidden]="showCardView" [dataSource]="renderedRows$" [multiTemplateDataRows]="viewMode === 'grouped'" > @@ -494,3 +525,35 @@ </div> } </ng-template> + +<ng-template #cardAssetContext let-element> + @if (getCardAssetContext(element); as assetContext) { + <div class="card-asset-context"> + @for (item of assetContext.visible; track item.id) { + <sp-label + size="small" + variant="soft" + [tone]="item.tone" + [color]="item.color" + [labelText]="item.label" + [matTooltip]="item.tooltip ?? item.label" + > + </sp-label> + } + @if (assetContext.hiddenLabels.length) { + <sp-label + class="card-asset-context__more" + size="small" + tone="neutral" + variant="soft" + tabindex="0" + data-cy="sp-table-card-asset-more" + [labelText]="'+' + assetContext.hiddenLabels.length" + [matTooltip]="assetContext.hiddenLabels.join(', ')" + [attr.aria-label]="assetContext.hiddenLabels.join(', ')" + > + </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 139be8b45f..ebd28b05ef 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 @@ -259,3 +259,40 @@ font-weight: var(--font-weight-semibold); color: var(--mat-sys-on-surface); } + +.sp-table--hidden { + display: none; +} + +.sp-table-cards { + padding: var(--space-md) 0; +} + +.sp-table-cards__empty { + margin: 0; + color: var(--mat-sys-on-surface-variant); + font-size: var(--font-size-sm); +} + +.card-asset-context { + display: flex; + align-items: center; + gap: var(--space-xs); + min-width: 0; +} + +.card-asset-context sp-label { + flex: 0 1 auto; + min-width: 0; +} + +.card-asset-context sp-label ::ng-deep .sp-label { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-asset-context .card-asset-context__more { + flex: none; +} 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 index b10c6a7724..e4092ce325 100644 --- 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 @@ -16,6 +16,7 @@ * */ +import { NgTemplateOutlet } from '@angular/common'; import { Component, viewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; @@ -29,10 +30,15 @@ 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 { SpTableCardDirective } from './sp-actions/sp-table-card.directive'; +import { SpTableActionsDirective } from './sp-actions/sp-table-actions.directive'; 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'; +import { + SpTableAssetContextConfig, + SpTableResolvedAssetContext, +} from './sp-table.model'; interface TestRow { _id: string; @@ -245,3 +251,230 @@ describe('SpTableComponent grouped view', () => { ]); }); }); + +@Component({ + selector: 'sp-table-card-host', + imports: [ + SpTableComponent, + SpTableCardDirective, + SpTableActionsDirective, + NgTemplateOutlet, + MatColumnDef, + MatHeaderCellDef, + MatCellDef, + ], + template: ` + <sp-table + [dataSource]="dataSource" + [columns]="columns" + [assetContextConfig]="assetContextConfig" + [nameSearchConfig]="{ enabled: true }" + [showActionsMenu]="true" + [showSelectionCheckboxes]="true" + featureCardId="adapter" + resourceIdKey="_id" + > + <ng-container matColumnDef="name"> + <th mat-header-cell *matHeaderCellDef>Name</th> + <td mat-cell *matCellDef="let row">{{ row.name }}</td> + </ng-container> + <ng-template + spTableCard + let-row + let-actions="actions" + let-assetContext="assetContext" + > + <article class="test-card"> + <span class="test-card-name">{{ row.name }}</span> + <ng-container + *ngTemplateOutlet="actions; context: { $implicit: row }" + ></ng-container> + <ng-container + *ngTemplateOutlet=" + assetContext; + context: { $implicit: row } + " + ></ng-container> + </article> + </ng-template> + <ng-template spTableActions></ng-template> + </sp-table> + `, +}) +class SpTableCardHostComponent { + readonly table = viewChild.required(SpTableComponent<TestRow>); + + dataSource = new MatTableDataSource<TestRow>([ + { _id: 'r1', name: 'Alpha' }, + { _id: 'r2', name: 'Beta' }, + ]); + columns = ['name', 'actions']; + assetContextConfig: SpTableAssetContextConfig = { + resourceLinkType: 'pipeline', + resourceIdKey: '_id', + hideBelowWidth: 0, + }; +} + +describe('SpTableComponent card view', () => { + const openFeatureCard = vi.fn(); + + async function setup<T>(component: new () => T) { + window.matchMedia ??= query => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }); + openFeatureCard.mockClear(); + await TestBed.configureTestingModule({ + imports: [component], + providers: [ + provideNoopAnimations(), + provideTranslateService(), + { + provide: SpAssetBrowserService, + useValue: { + assetData$: new BehaviorSubject<AssetBrowserData>( + assetData, + ), + }, + }, + { provide: FeatureCardService, useValue: { openFeatureCard } }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(component); + fixture.detectChanges(); + return fixture; + } + + async function setupInCardMode() { + const fixture = await setup(SpTableCardHostComponent); + fixture.componentInstance.table().setViewMode('cards'); + fixture.detectChanges(); + return fixture; + } + + const query = ( + fixture: ComponentFixture<unknown>, + selector: string, + ): HTMLElement[] => + Array.from(fixture.nativeElement.querySelectorAll(selector)); + + afterEach(() => TestBed.resetTestingModule()); + + it('offers the cards toggle only with a card template', async () => { + const withoutTemplate = await setup(SpTableHostComponent); + expect( + query(withoutTemplate, '[data-cy="sp-table-view-cards"]').length, + ).toBe(0); + TestBed.resetTestingModule(); + + const withTemplate = await setup(SpTableCardHostComponent); + expect( + query(withTemplate, '[data-cy="sp-table-view-cards"]').length, + ).toBe(1); + expect(query(withTemplate, '.test-card').length).toBe(0); + expect(query(withTemplate, 'tr[mat-row]').length).toBe(2); + }); + + it('renders the current page rows as cards instead of table rows', async () => { + const fixture = await setupInCardMode(); + + expect( + query(fixture, '.test-card-name').map(card => card.textContent), + ).toEqual(['Alpha', 'Beta']); + expect(query(fixture, 'tr[mat-row]').length).toBe(0); + expect( + query(fixture, 'table.sp-table')[0].classList.contains( + 'sp-table--hidden', + ), + ).toBe(true); + }); + + it('returns to the table rows when leaving the card view', async () => { + const fixture = await setupInCardMode(); + + fixture.componentInstance.table().setViewMode('list'); + fixture.detectChanges(); + + expect(query(fixture, '.test-card').length).toBe(0); + expect(query(fixture, 'tr[mat-row]').length).toBe(2); + }); + + it('opens the feature card of the row from the card actions', async () => { + const fixture = await setupInCardMode(); + + const previewButton = query( + fixture, + '.test-card:nth-of-type(2) button', + )[0]; + previewButton.click(); + + expect(openFeatureCard).toHaveBeenCalledWith('adapter', 'r2'); + }); + + it('renders the asset context of the row on its card', async () => { + const fixture = await setupInCardMode(); + + expect( + query(fixture, '.test-card')[0].querySelector('sp-label') + .textContent, + ).toContain('Press'); + }); + + it('limits the card asset context and summarizes the rest', async () => { + const fixture = await setup(SpTableCardHostComponent); + const table = fixture.componentInstance.table(); + vi.spyOn(table, 'getAssetContext').mockReturnValue({ + sites: [{ id: 's1', label: 'Plant A' }], + assets: [{ id: 'a1', label: 'Line 1' }], + labels: [ + { _id: 'l1', label: 'Critical', color: '#f00' }, + { _id: 'l2', label: 'Demo', color: '#00f' }, + ], + sortValue: '', + } as SpTableResolvedAssetContext); + + table.setViewMode('cards'); + fixture.detectChanges(); + + const card = query(fixture, '.test-card')[0]; + expect( + Array.from(card.querySelectorAll('sp-label')).map(label => + label.textContent.trim(), + ), + ).toEqual(['Plant A', 'Line 1', '+2']); + expect( + card + .querySelector('[data-cy="sp-table-card-asset-more"]') + .getAttribute('aria-label'), + ).toBe('Critical, Demo'); + }); + + it('replaces the selection toolbar but keeps search and view controls', async () => { + const fixture = await setupInCardMode(); + + expect( + query(fixture, '[data-cy="sp-table-selection-toolbar"]').length, + ).toBe(0); + expect(query(fixture, '.table-search-field').length).toBe(1); + expect(query(fixture, '.view-mode-toggle').length).toBe(1); + }); + + it('shows the empty message when there are no rows', async () => { + const fixture = await setupInCardMode(); + + fixture.componentInstance.dataSource.data = []; + fixture.detectChanges(); + + expect( + query(fixture, '.sp-table-cards [data-cy="no-table-entries"]') + .length, + ).toBe(1); + }); +}); 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 f4145a8921..1f7219288f 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 @@ -56,6 +56,8 @@ import { MatPaginator, PageEvent } from '@angular/material/paginator'; import { SpTableActionsDirective } from './sp-actions/sp-table-actions.directive'; import { MatMenu, MatMenuTrigger } from '@angular/material/menu'; import { SpTableMultiActionsDirective } from './sp-actions/sp-table-multi-actions.directive'; +import { SpTableCardDirective } from './sp-actions/sp-table-card.directive'; +import { SpOverviewCardGridComponent } from '../overview-card/overview-card-grid.component'; import { LocalStorageService } from '../../services/local-storage-settings.service'; import { FeatureCardService } from '../feature-card-host/feature-card.service'; import { @@ -100,7 +102,7 @@ import { } from './sp-table.model'; import { SpTableAssetContextService } from './sp-asset-context/sp-table-asset-context.service'; -type SpTableGroupViewMode = 'list' | 'grouped'; +type SpTableViewMode = 'list' | 'grouped' | 'cards'; type SpTableGroupingMode = 'label' | 'site' | 'asset'; interface SpTableGroupedSection<T> { @@ -121,6 +123,16 @@ interface SpTableGroupHeaderRow { type SpTableRenderedRow<T> = T | SpTableGroupHeaderRow; +interface SpTableCardAssetContextItem { + id: string; + label: string; + tone?: 'info' | 'neutral'; + color?: string; + tooltip?: string; +} + +const CARD_ASSET_CONTEXT_LIMIT = 2; + @Component({ selector: 'sp-table', templateUrl: './sp-table.component.html', @@ -163,6 +175,7 @@ type SpTableRenderedRow<T> = T | SpTableGroupHeaderRow; TranslatePipe, LayoutGapDirective, SpLabelComponent, + SpOverviewCardGridComponent, ], }) export class SpTableComponent<T> @@ -226,11 +239,13 @@ export class SpTableComponent<T> actionsTemplate?: TemplateRef<any>; @ContentChild(SpTableMultiActionsDirective, { read: TemplateRef }) multiActionsTemplate?: TemplateRef<any>; + @ContentChild(SpTableCardDirective, { read: TemplateRef }) + cardTemplate?: TemplateRef<any>; visiblePageRows: T[] = []; selectedMultiAction: string | null = null; nameSearchTerm = ''; - viewMode: SpTableGroupViewMode = 'list'; + viewMode: SpTableViewMode = 'list'; groupBy: SpTableGroupingMode = 'asset'; groupedSections: SpTableGroupedSection<T>[] = []; renderedGroupedRows: SpTableRenderedRow<T>[] = []; @@ -238,7 +253,8 @@ export class SpTableComponent<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. + * mode, so switching the view mode never swaps the table's data source. It + * stays empty in card mode, where the hidden table has nothing to render. */ readonly renderedRows$ = new BehaviorSubject<SpTableRenderedRow<T>[]>([]); @@ -387,6 +403,14 @@ export class SpTableComponent<T> return !!this.assetContextConfig; } + get showCardView(): boolean { + return this.viewMode === 'cards' && !!this.cardTemplate; + } + + get showSelectionToolbar(): boolean { + return this.showSelectionCheckboxes && !this.showCardView; + } + get shouldShowNameSearch(): boolean { return !!this.nameSearchConfig?.enabled; } @@ -427,6 +451,43 @@ export class SpTableComponent<T> ?.get(resourceId); } + getCardAssetContext( + row: T, + ): + | { visible: SpTableCardAssetContextItem[]; hiddenLabels: string[] } + | undefined { + const assetContext = this.getAssetContext(row); + if (!assetContext) { + return undefined; + } + + const items: SpTableCardAssetContextItem[] = [ + ...assetContext.sites.map(site => ({ + id: `site:${site.id}`, + label: site.label, + tone: 'info' as const, + })), + ...assetContext.assets.map(asset => ({ + id: `asset:${asset.id}`, + label: asset.label, + tone: 'neutral' as const, + tooltip: asset.tooltip, + })), + ...assetContext.labels.map(label => ({ + id: `label:${label._id ?? label.label}`, + label: label.label, + color: label.color, + })), + ]; + + return { + visible: items.slice(0, CARD_ASSET_CONTEXT_LIMIT), + hiddenLabels: items + .slice(CARD_ASSET_CONTEXT_LIMIT) + .map(item => item.label), + }; + } + get selectedRows(): T[] { return this.selection.selected; } @@ -513,7 +574,7 @@ export class SpTableComponent<T> ); } - setViewMode(mode: SpTableGroupViewMode) { + setViewMode(mode: SpTableViewMode) { if (mode === 'grouped') { this.groupBy = 'asset'; } @@ -675,9 +736,7 @@ export class SpTableComponent<T> this.reconcileSelection(); } - this.renderedRows$.next( - this.viewMode === 'grouped' ? this.renderedGroupedRows : rows, - ); + this.renderedRows$.next(this.getTableRows(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, @@ -685,6 +744,14 @@ export class SpTableComponent<T> this.changeDetectorRef.markForCheck(); } + private getTableRows(rows: T[]): SpTableRenderedRow<T>[] { + if (this.showCardView) { + return []; + } + + return this.viewMode === 'grouped' ? this.renderedGroupedRows : rows; + } + private reconcileSelection() { if (!this.selection.hasValue() || !this.dataSource) { return; @@ -716,7 +783,7 @@ export class SpTableComponent<T> this.emitSelection(); } - private getSelectionKey(row: T): unknown { + getSelectionKey(row: T): unknown { const resourceId = (row as Record<string, unknown>)[this.resourceIdKey]; return resourceId ?? row; diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/status-label/status-label.component.html b/ui/projects/streampipes/shared-ui/src/lib/components/status-label/status-label.component.html new file mode 100644 index 0000000000..b32a563d01 --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/status-label/status-label.component.html @@ -0,0 +1,31 @@ +<!-- + ~ 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. + ~ + --> + +@if (showLabel()) { + <span class="status-text" [class]="'status-text--' + tone()"> + <span class="status-dot" aria-hidden="true"></span> + {{ label() }} + </span> +} @else { + <span + role="img" + [class]="lightClass()" + [matTooltip]="label()" + [attr.aria-label]="label()" + ></span> +} diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/status-label/status-label.component.scss b/ui/projects/streampipes/shared-ui/src/lib/components/status-label/status-label.component.scss new file mode 100644 index 0000000000..f8bde26591 --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/status-label/status-label.component.scss @@ -0,0 +1,71 @@ +/* + * 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. + * + */ + +:host { + display: inline-flex; + align-items: center; +} + +.light { + flex-shrink: 0; +} + +/* The dot and the rounded rectangle set status apart from the pill of sp-label. + The text mixes the tone with the text colour to stay readable on the tint in + both themes; the dot keeps the pure tone. */ +.status-text { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-2xs) var(--space-xs); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--status-tone) 12%, transparent); + color: color-mix( + in srgb, + var(--status-tone) 50%, + var(--mat-sys-on-surface) + ); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + line-height: 1.25; + white-space: nowrap; +} + +.status-text--success { + --status-tone: var(--color-success); +} + +.status-text--warning { + --status-tone: var(--color-warning); +} + +.status-text--error { + --status-tone: var(--color-error); +} + +.status-text--neutral { + --status-tone: var(--color-neutral); +} + +.status-dot { + flex-shrink: 0; + width: 0.625rem; + height: 0.625rem; + border-radius: 50%; + background: var(--status-tone); +} diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/status-label/status-label.component.spec.ts b/ui/projects/streampipes/shared-ui/src/lib/components/status-label/status-label.component.spec.ts new file mode 100644 index 0000000000..2145a503b2 --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/status-label/status-label.component.spec.ts @@ -0,0 +1,71 @@ +/* + * 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 { ComponentFixture, TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { SpStatusLabelComponent, SpStatusTone } from './status-label.component'; + +describe('SpStatusLabelComponent', () => { + let fixture: ComponentFixture<SpStatusLabelComponent>; + + function render(tone: SpStatusTone, showLabel: boolean): HTMLElement { + fixture.componentRef.setInput('tone', tone); + fixture.componentRef.setInput('label', 'Running'); + fixture.componentRef.setInput('showLabel', showLabel); + fixture.detectChanges(); + return fixture.nativeElement; + } + + beforeEach(() => { + fixture = TestBed.createComponent(SpStatusLabelComponent); + }); + + it.each([ + ['success', 'light-green'], + ['warning', 'light-yellow'], + ['error', 'light-red'], + ['neutral', 'light-neutral'], + ] as [SpStatusTone, string][])( + 'shows the %s tone as %s', + (tone, lightClass) => { + const symbol = render(tone, false).querySelector('.light'); + + expect(symbol.classList.contains(lightClass)).toBe(true); + }, + ); + + it('shows a coloured dot with the text', () => { + const host = render('success', true); + const text = host.querySelector('.status-text'); + + expect(text.textContent.trim()).toBe('Running'); + expect(text.classList.contains('status-text--success')).toBe(true); + expect(text.querySelector('.status-dot')).not.toBeNull(); + expect(host.querySelector('.light')).toBeNull(); + expect(host.querySelector('[role="img"]')).toBeNull(); + }); + + it('names the symbol when the text is not shown', () => { + const host = render('success', false); + const symbol = host.querySelector('.light'); + + expect(host.querySelector('.status-text')).toBeNull(); + expect(symbol.getAttribute('role')).toBe('img'); + expect(symbol.getAttribute('aria-label')).toBe('Running'); + }); +}); diff --git a/ui/projects/streampipes/shared-ui/src/lib/components/status-label/status-label.component.ts b/ui/projects/streampipes/shared-ui/src/lib/components/status-label/status-label.component.ts new file mode 100644 index 0000000000..84f5dea07b --- /dev/null +++ b/ui/projects/streampipes/shared-ui/src/lib/components/status-label/status-label.component.ts @@ -0,0 +1,55 @@ +/* + * 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 { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { MatTooltip } from '@angular/material/tooltip'; + +export type SpStatusTone = 'success' | 'warning' | 'error' | 'neutral'; + +const LIGHT_CLASSES: Record<SpStatusTone, string> = { + success: 'light-green', + warning: 'light-yellow', + error: 'light-red', + neutral: 'light-neutral', +}; + +/** + * Status as a tinted label with a coloured dot, or as a status light without + * text. Its dot and shape set it apart from the pill of `sp-label`, which is + * used for tags and values. The label is required in both modes: without + * visible text, it is the tooltip and accessible name of the light. + */ +@Component({ + selector: 'sp-status-label', + templateUrl: './status-label.component.html', + styleUrls: ['./status-label.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatTooltip], +}) +export class SpStatusLabelComponent { + readonly tone = input.required<SpStatusTone>(); + readonly label = input.required<string>(); + readonly showLabel = input(true); + + readonly lightClass = computed(() => `light ${LIGHT_CLASSES[this.tone()]}`); +} diff --git a/ui/projects/streampipes/shared-ui/src/public-api.ts b/ui/projects/streampipes/shared-ui/src/public-api.ts index f9dd701a08..a1b5cb6724 100644 --- a/ui/projects/streampipes/shared-ui/src/public-api.ts +++ b/ui/projects/streampipes/shared-ui/src/public-api.ts @@ -54,6 +54,9 @@ export * from './lib/components/property-scope-badge/property-scope-badge.compon export * from './lib/components/search-select/search-select.component'; export * from './lib/components/search-select/search-select-template.directive'; export * from './lib/components/progress-bar/progress-bar.component'; +export * from './lib/components/overview-card/overview-card.component'; +export * from './lib/components/overview-card/overview-card-grid.component'; +export * from './lib/components/overview-card/overview-card-facts.component'; export * from './lib/components/split-section/split-section.component'; export * from './lib/components/split-button/split-button.component'; export * from './lib/components/sp-spinner/sp-spinner.component'; @@ -61,8 +64,10 @@ export * from './lib/components/sp-exception-message/sp-exception-message.compon export * from './lib/components/sp-exception-message/exception-details-dialog/exception-details-dialog.component'; 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/status-label/status-label.component'; export * from './lib/components/sp-table/sp-table.component'; export * from './lib/components/sp-table/sp-actions/sp-table-actions.directive'; +export * from './lib/components/sp-table/sp-actions/sp-table-card.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'; diff --git a/ui/src/app/connect/components/existing-adapters/adapter-overview-card/adapter-overview-card.component.html b/ui/src/app/connect/components/existing-adapters/adapter-overview-card/adapter-overview-card.component.html new file mode 100644 index 0000000000..dec8e9bb87 --- /dev/null +++ b/ui/src/app/connect/components/existing-adapters/adapter-overview-card/adapter-overview-card.component.html @@ -0,0 +1,109 @@ +<!-- + ~ 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. + ~ + --> + +<sp-overview-card + [title]="adapter().name" + [description]="adapter().description" + [titleClickable]="true" + [accentColor]="accentColor()" + (titleClick)="showDetails.emit()" + data-cy="adapter-overview-card" +> + @if (iconUrl(); as icon) { + <img cardLeading class="adapter-icon" [src]="icon" alt="" /> + } + <ng-content + select="[cardHeaderActions]" + ngProjectAs="[cardHeaderActions]" + ></ng-content> + + <sp-overview-card-facts> + <div spOverviewCardFact [label]="'Status' | translate"> + <sp-status-label + [tone]="adapter().running ? 'success' : 'neutral'" + [label]=" + (adapter().running ? 'Running' : 'Stopped') | translate + " + data-cy="adapter-card-status" + ></sp-status-label> + </div> + <div spOverviewCardFact [label]="'Messages' | translate"> + <span + [matTooltip]="'Messages sent since last start' | translate" + data-cy="adapter-card-messages" + >{{ messageCount() }}</span + > + </div> + <div spOverviewCardFact [label]="'Last message' | translate"> + <span + [matTooltip]="exactLastMessage()" + data-cy="adapter-card-last-message" + >{{ lastMessage() ?? ('n/a' | translate) }}</span + > + </div> + </sp-overview-card-facts> + <ng-content + select="[cardAssetContext]" + ngProjectAs="[cardAssetContext]" + ></ng-content> + + @if (operationInProgress()) { + <div + cardFooter + class="operation-in-progress" + data-cy="adapter-operation-in-progress-spinner" + > + <sp-spinner [diameter]="20"></sp-spinner> + </div> + } @else if (adapter().running) { + <button + cardFooter + type="button" + mat-flat-button + class="btn-tertiary" + data-cy="adapter-card-stop" + (click)="stopAdapter.emit()" + > + <mat-icon aria-hidden="true">stop</mat-icon> + <span>{{ 'Stop' | translate }}</span> + </button> + } @else { + <button + cardFooter + type="button" + mat-flat-button + class="btn-tertiary" + data-cy="adapter-card-start" + (click)="startAdapter.emit()" + > + <mat-icon aria-hidden="true">play_arrow</mat-icon> + <span>{{ 'Start' | translate }}</span> + </button> + } + <button + cardFooter + type="button" + mat-flat-button + class="btn-tertiary" + data-cy="adapter-card-edit" + (click)="editAdapter.emit()" + > + <mat-icon aria-hidden="true">edit</mat-icon> + <span>{{ 'Edit' | translate }}</span> + </button> +</sp-overview-card> diff --git a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss b/ui/src/app/connect/components/existing-adapters/adapter-overview-card/adapter-overview-card.component.scss similarity index 75% copy from ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss copy to ui/src/app/connect/components/existing-adapters/adapter-overview-card/adapter-overview-card.component.scss index 2376ff86ae..7c1cad53b7 100644 --- a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss +++ b/ui/src/app/connect/components/existing-adapters/adapter-overview-card/adapter-overview-card.component.scss @@ -16,21 +16,22 @@ * */ -.mat-mdc-header-cell { - font-weight: bold; +:host { + display: block; + height: 100%; + min-width: 0; } .adapter-icon { - padding-top: 5px; - padding-bottom: 5px; - max-height: 35px; + display: block; + max-width: 1.75rem; + max-height: 1.75rem; } -.real-time { - cursor: pointer; - color: #ffeb3b; -} - -.icon { - max-width: 60px; +.operation-in-progress { + display: flex; + align-items: center; + justify-content: center; + min-width: 5rem; + min-height: 2rem; } diff --git a/ui/src/app/connect/components/existing-adapters/adapter-overview-card/adapter-overview-card.component.spec.ts b/ui/src/app/connect/components/existing-adapters/adapter-overview-card/adapter-overview-card.component.spec.ts new file mode 100644 index 0000000000..8b2fef5db6 --- /dev/null +++ b/ui/src/app/connect/components/existing-adapters/adapter-overview-card/adapter-overview-card.component.spec.ts @@ -0,0 +1,190 @@ +/* + * 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, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideTranslateService, TranslateService } from '@ngx-translate/core'; +import { + AdapterSummaryDto, + SpMetricsEntry, +} from '@streampipes/platform-services'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AdapterOverviewCardComponent } from './adapter-overview-card.component'; + +const LAST_MESSAGE = new Date(2026, 10, 4, 20, 14).getTime(); + +@Component({ + imports: [AdapterOverviewCardComponent], + template: ` + <sp-adapter-overview-card + [adapter]="adapter()" + [metrics]="metrics()" + [iconUrl]="iconUrl()" + [operationInProgress]="operationInProgress()" + [currentTime]="currentTime()" + (startAdapter)="startAdapter()" + (stopAdapter)="stopAdapter()" + (showDetails)="showDetails()" + (editAdapter)="editAdapter()" + > + <button id="preview" cardHeaderActions>Preview</button> + <span id="asset" cardAssetContext>Hall 3</span> + </sp-adapter-overview-card> + `, +}) +class AdapterOverviewCardHostComponent { + adapter = signal({ + elementId: 'a1', + name: 'Hall 3 OPC UA', + description: 'Line 1 machine signals', + running: true, + createdAt: new Date(2026, 1, 2, 8, 30).getTime(), + } as AdapterSummaryDto); + metrics = signal<SpMetricsEntry | undefined>({ + messagesOut: { counter: 12480 }, + lastTimestamp: LAST_MESSAGE, + } as SpMetricsEntry); + iconUrl = signal<string | undefined>('/icon.png'); + currentTime = signal(LAST_MESSAGE + 2 * 60 * 1000); + operationInProgress = signal(false); + startAdapter = vi.fn(); + stopAdapter = vi.fn(); + showDetails = vi.fn(); + editAdapter = vi.fn(); +} + +describe('AdapterOverviewCardComponent', () => { + let fixture: ComponentFixture<AdapterOverviewCardHostComponent>; + let host: AdapterOverviewCardHostComponent; + + const byCy = (id: string): HTMLElement => + fixture.nativeElement.querySelector(`[data-cy="${id}"]`); + const text = (id: string) => byCy(id)?.textContent.trim(); + + beforeEach(async () => { + window.matchMedia ??= query => + ({ + matches: false, + media: query, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + }) as unknown as MediaQueryList; + await TestBed.configureTestingModule({ + imports: [AdapterOverviewCardHostComponent], + providers: [provideTranslateService()], + }).compileComponents(); + fixture = TestBed.createComponent(AdapterOverviewCardHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('shows identity, status and metrics', () => { + const card = fixture.nativeElement as HTMLElement; + + expect(card.querySelector('[role="heading"]').textContent).toContain( + 'Hall 3 OPC UA', + ); + expect(card.querySelector('.card-leading img')).not.toBeNull(); + expect(text('adapter-card-status')).toBe('Running'); + expect(text('adapter-card-messages')).toBe('12,480'); + expect(text('adapter-card-last-message')).toBe('2 minutes ago'); + }); + + it('shows older messages with their exact time', async () => { + host.currentTime.set(LAST_MESSAGE + 2 * 24 * 60 * 60 * 1000); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(text('adapter-card-last-message')).toContain('2026'); + }); + + it('formats the message count in the current language', async () => { + TestBed.inject(TranslateService).use('de'); + host.metrics.update(metrics => ({ ...metrics })); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(text('adapter-card-messages')).toBe('12.480'); + }); + + it('falls back when there are no metrics or icon', async () => { + host.metrics.set(undefined); + host.iconUrl.set(undefined); + await fixture.whenStable(); + fixture.detectChanges(); + const card = fixture.nativeElement as HTMLElement; + + expect(text('adapter-card-messages')).toBe('0'); + expect(text('adapter-card-last-message')).toBe('n/a'); + expect(card.querySelector('.card-leading').matches(':empty')).toBe( + true, + ); + }); + + it('projects the table actions and asset context', () => { + const card = fixture.nativeElement as HTMLElement; + + expect( + card.querySelector('.card-header-actions #preview'), + ).not.toBeNull(); + expect(card.querySelector('.card-context #asset')).not.toBeNull(); + }); + + it('offers stop for a running adapter', () => { + expect(byCy('adapter-card-start')).toBeNull(); + + byCy('adapter-card-stop').click(); + + expect(host.stopAdapter).toHaveBeenCalledOnce(); + }); + + it('offers start for a stopped adapter', async () => { + host.adapter.update(adapter => ({ ...adapter, running: false })); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(text('adapter-card-status')).toBe('Stopped'); + expect(byCy('adapter-card-stop')).toBeNull(); + + byCy('adapter-card-start').click(); + + expect(host.startAdapter).toHaveBeenCalledOnce(); + }); + + it('opens the details from the title and edits from the footer', () => { + (fixture.nativeElement as HTMLElement) + .querySelector<HTMLElement>('.card-title-link') + .click(); + byCy('adapter-card-edit').click(); + + expect(host.showDetails).toHaveBeenCalledOnce(); + expect(host.editAdapter).toHaveBeenCalledOnce(); + }); + + it('replaces start and stop while an operation is in progress', async () => { + host.operationInProgress.set(true); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(byCy('adapter-operation-in-progress-spinner')).not.toBeNull(); + expect(byCy('adapter-card-stop')).toBeNull(); + expect(byCy('adapter-card-edit')).not.toBeNull(); + }); +}); diff --git a/ui/src/app/connect/components/existing-adapters/adapter-overview-card/adapter-overview-card.component.ts b/ui/src/app/connect/components/existing-adapters/adapter-overview-card/adapter-overview-card.component.ts new file mode 100644 index 0000000000..23d5b641bd --- /dev/null +++ b/ui/src/app/connect/components/existing-adapters/adapter-overview-card/adapter-overview-card.component.ts @@ -0,0 +1,113 @@ +/* + * 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 { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + output, +} from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { + AdapterSummaryDto, + SpMetricsEntry, +} from '@streampipes/platform-services'; +import { + SpOverviewCardComponent, + SpOverviewCardFactComponent, + SpOverviewCardFactsComponent, + SpSpinnerComponent, + SpStatusLabelComponent, +} from '@streampipes/shared-ui'; +import { LastUpdatedFormatterService } from '../../../../core-services/time-formatting/last-updated-formatter.service'; + +@Component({ + selector: 'sp-adapter-overview-card', + templateUrl: './adapter-overview-card.component.html', + styleUrls: ['./adapter-overview-card.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + MatButton, + MatIcon, + MatTooltip, + SpOverviewCardComponent, + SpOverviewCardFactComponent, + SpOverviewCardFactsComponent, + SpSpinnerComponent, + SpStatusLabelComponent, + TranslatePipe, + ], +}) +export class AdapterOverviewCardComponent { + readonly adapter = input.required<AdapterSummaryDto>(); + readonly metrics = input<SpMetricsEntry>(); + readonly iconUrl = input<string>(); + readonly accentColor = input<string>(); + readonly operationInProgress = input(false); + readonly currentTime = input(Date.now()); + + readonly startAdapter = output<void>(); + readonly stopAdapter = output<void>(); + readonly showDetails = output<void>(); + readonly editAdapter = output<void>(); + + private readonly lastUpdatedFormatter = inject(LastUpdatedFormatterService); + private readonly translate = inject(TranslateService); + + private get locale(): string { + return this.translate.getCurrentLang() || 'en'; + } + + private readonly lastTimestamp = computed( + () => this.metrics()?.lastTimestamp, + ); + + readonly messageCount = computed(() => + new Intl.NumberFormat(this.locale).format( + this.metrics()?.messagesOut?.counter ?? 0, + ), + ); + readonly exactLastMessage = computed(() => + this.lastTimestamp() + ? this.lastUpdatedFormatter.formatExactLastUpdatedAt( + this.lastTimestamp(), + ) + : undefined, + ); + readonly lastMessage = computed(() => { + const lastTimestamp = this.lastTimestamp(); + if (!lastTimestamp) { + return undefined; + } + + return ( + this.lastUpdatedFormatter.formatRelativeLastUpdatedAt( + lastTimestamp, + this.currentTime(), + ) ?? + new Intl.DateTimeFormat(this.locale, { + dateStyle: 'medium', + }).format(lastTimestamp) + ); + }); +} diff --git a/ui/src/app/connect/components/existing-adapters/adapter-status-light/adapter-status-light.component.html b/ui/src/app/connect/components/existing-adapters/adapter-status-light/adapter-status-light.component.html deleted file mode 100644 index 93c2cffba2..0000000000 --- a/ui/src/app/connect/components/existing-adapters/adapter-status-light/adapter-status-light.component.html +++ /dev/null @@ -1,25 +0,0 @@ -<!-- -~ 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="row" fxLayoutAlign="start center"> - @if (adapterRunning) { - <div class="light light-green"></div> - } - @if (!adapterRunning) { - <div class="light light-neutral"></div> - } -</div> diff --git a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.html b/ui/src/app/connect/components/existing-adapters/existing-adapters.component.html index cfc81bae46..c7ef6bcbb1 100644 --- a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.html +++ b/ui/src/app/connect/components/existing-adapters/existing-adapters.component.html @@ -77,9 +77,14 @@ {{ 'Status' | translate }} </th> <td mat-cell *matCellDef="let adapter"> - <sp-adapter-status-light - [adapterRunning]="adapter.running" - ></sp-adapter-status-light> + <sp-status-label + [tone]="adapter.running ? 'success' : 'neutral'" + [label]=" + (adapter.running ? 'Running' : 'Stopped') + | translate + " + [showLabel]="false" + ></sp-status-label> </td> </ng-container> <ng-container matColumnDef="start"> @@ -135,16 +140,20 @@ <th mat-header-cell mat-sort-header *matHeaderCellDef> {{ 'Name' | translate }} </th> - <td mat-cell *matCellDef="let adapter"> - <div - fxLayout="column" - fxLayoutAlign="start start" - class="truncate" - > + <td + mat-cell + class="adapter-name-cell" + *matCellDef="let adapter" + > + <div fxLayout="column" fxLayoutAlign="start start"> <span data-cy="adapter-name">{{ adapter.name }}</span> - <small> {{ adapter.description }}</small> + <small + class="truncate" + [matTooltip]="adapter.description" + >{{ adapter.description }}</small + > </div> </td> </ng-container> @@ -222,6 +231,42 @@ </td> </ng-container> + <ng-template + spTableCard + let-adapter + let-actions="actions" + let-assetContext="assetContext" + > + <sp-adapter-overview-card + [adapter]="adapter" + [metrics]="adapterMetrics[adapter.elementId]" + [currentTime]="currentTime()" + [iconUrl]="adapter.icon || getIconUrl(adapter)" + [accentColor]="pageHeaderAssetLinkType?.linkColor" + [operationInProgress]=" + adapter.elementId === operationInProgressAdapterId + " + (startAdapter)="startAdapter(adapter)" + (stopAdapter)="stopAdapter(adapter)" + (showDetails)="navigateToDetailsOverviewPage(adapter)" + (editAdapter)="editAdapter(adapter)" + > + <ng-container + cardHeaderActions + *ngTemplateOutlet=" + actions; + context: { $implicit: adapter } + " + ></ng-container> + <ng-container + cardAssetContext + *ngTemplateOutlet=" + assetContext; + context: { $implicit: adapter } + " + ></ng-container> + </sp-adapter-overview-card> + </ng-template> <ng-template spTableActions let-element> <button mat-menu-item diff --git a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss b/ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss index 2376ff86ae..e57333ce43 100644 --- a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss +++ b/ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss @@ -34,3 +34,15 @@ .icon { max-width: 60px; } + +/* Lets the name column take the remaining width and truncate the description, + because a table column otherwise grows to fit text that does not wrap. */ +.adapter-name-cell { + width: 100%; + max-width: 0; +} + +.adapter-name-cell span, +.adapter-name-cell small { + max-width: 100%; +} diff --git a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.ts b/ui/src/app/connect/components/existing-adapters/existing-adapters.component.ts index 2ae4ab693b..e5d6242448 100644 --- a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.ts +++ b/ui/src/app/connect/components/existing-adapters/existing-adapters.component.ts @@ -21,6 +21,7 @@ import { inject, OnDestroy, OnInit, + signal, ViewChild, ChangeDetectionStrategy, } from '@angular/core'; @@ -55,8 +56,10 @@ import { SpLabelComponent, SpPageHeaderComponent, SpSpinnerComponent, + SpStatusLabelComponent, SpTableActionsDirective, SpTableAssetContextConfig, + SpTableCardDirective, SpTableComponent, SpTableMultiActionExecuteEvent, SpTableMultiActionOption, @@ -68,7 +71,7 @@ import { AdapterStartedDialog } from '../../dialog/adapter-started/adapter-start import { MatSort, MatSortHeader } from '@angular/material/sort'; import { Router } from '@angular/router'; import { SpConnectRoutes } from '../../connect.breadcrumb'; -import { Subscription } from 'rxjs'; +import { interval, Subscription } from 'rxjs'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { FlexDirective, @@ -78,9 +81,9 @@ import { import { MatButton, MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; -import { AdapterStatusLightComponent } from './adapter-status-light/adapter-status-light.component'; +import { AdapterOverviewCardComponent } from './adapter-overview-card/adapter-overview-card.component'; import { MatMenuItem } from '@angular/material/menu'; -import { AsyncPipe, DatePipe } from '@angular/common'; +import { AsyncPipe, DatePipe, NgTemplateOutlet } from '@angular/common'; @Component({ selector: 'sp-existing-adapters', @@ -105,10 +108,13 @@ import { AsyncPipe, DatePipe } from '@angular/common'; MatSortHeader, MatCellDef, MatCell, - AdapterStatusLightComponent, + SpStatusLabelComponent, SpSpinnerComponent, SpLabelComponent, SpTableActionsDirective, + SpTableCardDirective, + AdapterOverviewCardComponent, + NgTemplateOutlet, MatMenuItem, DatePipe, TranslatePipe, @@ -148,7 +154,10 @@ export class ExistingAdaptersComponent implements OnInit, OnDestroy { { value: 'stop', label: 'Stop selected', icon: 'stop' }, ]; + readonly currentTime = signal(Date.now()); + assetFilter$: Subscription; + currentTime$: Subscription; user$: Subscription; currentFilterIds: Set<string> = new Set<string>(); @@ -191,6 +200,9 @@ export class ExistingAdaptersComponent implements OnInit, OnDestroy { } ngOnInit(): void { + this.currentTime$ = interval(30000).subscribe(() => + this.currentTime.set(Date.now()), + ); this.assetFilterService.applyAssetLinkType('adapter'); this.assetFilter$ = this.assetFilterService.currentAssetFilter$.subscribe(filter => { @@ -314,6 +326,7 @@ export class ExistingAdaptersComponent implements OnInit, OnDestroy { .getMetricsInfoForAdapters(filteredElementIds) .subscribe(metrics => { this.adapterMetrics = metrics; + this.currentTime.set(Date.now()); this.dataSource.data = [...this.filteredAdapters]; }); } @@ -487,5 +500,6 @@ export class ExistingAdaptersComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.user$?.unsubscribe(); this.assetFilter$?.unsubscribe(); + this.currentTime$?.unsubscribe(); } } diff --git a/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview-card/pipeline-overview-card.component.html b/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview-card/pipeline-overview-card.component.html new file mode 100644 index 0000000000..b0b62a364e --- /dev/null +++ b/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview-card/pipeline-overview-card.component.html @@ -0,0 +1,131 @@ +<!-- + ~ Licensed to the Apache Software Foundation (ASF) under one or more + ~ contributor license agreements. See the NOTICE file distributed with + ~ this work for additional information regarding copyright ownership. + ~ The ASF licenses this file to You under the Apache License, Version 2.0 + ~ (the "License"); you may not use this file except in compliance with + ~ the License. You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + ~ + --> + +<sp-overview-card + [title]="pipeline().name" + [description]="pipeline().description" + [titleClickable]="true" + [accentColor]="accentColor()" + (titleClick)="showDetails.emit()" + data-cy="pipeline-overview-card" +> + @if (icon(); as moduleIcon) { + <mat-icon cardLeading aria-hidden="true">{{ moduleIcon }}</mat-icon> + } + <ng-content + select="[cardHeaderActions]" + ngProjectAs="[cardHeaderActions]" + ></ng-content> + + <sp-overview-card-facts> + <div spOverviewCardFact [label]="'Status' | translate"> + <sp-status-label + [tone]="status().tone" + [label]="status().label | translate" + data-cy="pipeline-card-status" + ></sp-status-label> + </div> + <div spOverviewCardFact [label]="'Notifications' | translate"> + @if (notificationCount()) { + <button + type="button" + class="notifications-link" + [disabled]="!hasWritePrivileges()" + [attr.aria-label]=" + notificationCount() + + ' ' + + ('Notifications' | translate) + " + data-cy="pipeline-card-notifications" + (click)="showNotifications.emit()" + > + <mat-icon aria-hidden="true">warning</mat-icon> + <span>{{ notificationCount() }}</span> + </button> + } @else { + <span data-cy="pipeline-card-no-notifications">{{ + 'None' | translate + }}</span> + } + </div> + <div spOverviewCardFact [label]="'Created' | translate"> + <span + [matTooltip]="pipeline().createdAt | date: 'dd.MM.yyyy HH:mm'" + data-cy="pipeline-card-created" + >{{ pipeline().createdAt | date: 'dd.MM.yyyy' }}</span + > + </div> + </sp-overview-card-facts> + @if (!pipeline().valid) { + <p class="invalid-message" data-cy="pipeline-card-invalid"> + <mat-icon aria-hidden="true">sync_problem</mat-icon> + <span>{{ + 'Invalid pipeline: Modify to fix problems' | translate + }}</span> + </p> + } + <ng-content + select="[cardAssetContext]" + ngProjectAs="[cardAssetContext]" + ></ng-content> + + @if (pipeline().running) { + <button + cardFooter + type="button" + mat-flat-button + class="btn-tertiary" + [disabled]="!hasWritePrivileges() || operationInProgress()" + data-cy="pipeline-card-stop" + (click)="stopPipeline.emit()" + > + <mat-icon aria-hidden="true">stop</mat-icon> + <span>{{ 'Stop' | translate }}</span> + </button> + } @else { + <button + cardFooter + type="button" + mat-flat-button + class="btn-tertiary" + [disabled]=" + !hasWritePrivileges() || + operationInProgress() || + !pipeline().valid + " + data-cy="pipeline-card-start" + (click)="startPipeline.emit()" + > + <mat-icon aria-hidden="true">play_arrow</mat-icon> + <span>{{ 'Start' | translate }}</span> + </button> + } + @if (hasWritePrivileges()) { + <button + cardFooter + type="button" + mat-flat-button + class="btn-tertiary" + data-cy="pipeline-card-edit" + (click)="editPipeline.emit()" + > + <mat-icon aria-hidden="true">edit</mat-icon> + <span>{{ 'Edit' | translate }}</span> + </button> + } +</sp-overview-card> diff --git a/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview-card/pipeline-overview-card.component.scss b/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview-card/pipeline-overview-card.component.scss new file mode 100644 index 0000000000..2839aad254 --- /dev/null +++ b/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview-card/pipeline-overview-card.component.scss @@ -0,0 +1,70 @@ +/* + * 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. + * + */ + +:host { + display: block; + height: 100%; + min-width: 0; +} + +.notifications-link { + display: inline-flex; + align-items: center; + gap: var(--space-2xs); + padding: 0; + border: 0; + border-radius: var(--radius-xs); + background: transparent; + color: var(--mat-sys-on-surface); + cursor: pointer; + font: inherit; +} + +.notifications-link:hover:not(:disabled) { + text-decoration: underline; +} + +.notifications-link:disabled { + cursor: default; +} + +.notifications-link:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.notifications-link mat-icon, +.invalid-message mat-icon { + flex-shrink: 0; + width: 1.125rem; + height: 1.125rem; + font-size: 1.125rem; +} + +.notifications-link mat-icon { + color: var(--color-warning); +} + +.invalid-message { + display: flex; + align-items: center; + gap: var(--space-xs); + margin: 0; + color: var(--mat-sys-error); + font-size: var(--font-size-xs); +} diff --git a/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview-card/pipeline-overview-card.component.spec.ts b/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview-card/pipeline-overview-card.component.spec.ts new file mode 100644 index 0000000000..6b3a586981 --- /dev/null +++ b/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview-card/pipeline-overview-card.component.spec.ts @@ -0,0 +1,183 @@ +/* + * 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, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideTranslateService } from '@ngx-translate/core'; +import { PipelineSummaryDto } from '@streampipes/platform-services'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PipelineOverviewCardComponent } from './pipeline-overview-card.component'; + +@Component({ + imports: [PipelineOverviewCardComponent], + template: ` + <sp-pipeline-overview-card + [pipeline]="pipeline()" + icon="play_arrow" + accentColor="rgb(1, 2, 3)" + [hasWritePrivileges]="hasWritePrivileges()" + [operationInProgress]="operationInProgress()" + (startPipeline)="startPipeline()" + (stopPipeline)="stopPipeline()" + (showDetails)="showDetails()" + (editPipeline)="editPipeline()" + (showNotifications)="showNotifications()" + > + <button id="preview" cardHeaderActions>Preview</button> + <span id="asset" cardAssetContext>Hall 3</span> + </sp-pipeline-overview-card> + `, +}) +class PipelineOverviewCardHostComponent { + pipeline = signal({ + elementId: 'p1', + name: 'Pump flow monitoring', + description: 'Detects flow rate anomalies', + createdAt: new Date(2026, 2, 12, 9, 41).getTime(), + running: true, + healthStatus: 'OK', + pipelineNotifications: [], + valid: true, + } as PipelineSummaryDto); + hasWritePrivileges = signal(true); + operationInProgress = signal(false); + startPipeline = vi.fn(); + stopPipeline = vi.fn(); + showDetails = vi.fn(); + editPipeline = vi.fn(); + showNotifications = vi.fn(); +} + +describe('PipelineOverviewCardComponent', () => { + let fixture: ComponentFixture<PipelineOverviewCardHostComponent>; + let host: PipelineOverviewCardHostComponent; + + const card = () => fixture.nativeElement as HTMLElement; + const byCy = (id: string) => + card().querySelector<HTMLButtonElement>(`[data-cy="${id}"]`); + const text = (id: string) => byCy(id)?.textContent.trim(); + + async function update(changes: Partial<PipelineSummaryDto>) { + host.pipeline.update(pipeline => ({ + ...pipeline, + ...changes, + })) as void; + await fixture.whenStable(); + fixture.detectChanges(); + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PipelineOverviewCardHostComponent], + providers: [provideTranslateService()], + }).compileComponents(); + fixture = TestBed.createComponent(PipelineOverviewCardHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('shows identity, status and facts', () => { + expect(card().querySelector('.card-title-link').textContent).toContain( + 'Pump flow monitoring', + ); + expect(text('pipeline-card-status')).toBe('Running'); + expect(text('pipeline-card-no-notifications')).toBe('None'); + expect(text('pipeline-card-created')).toBe('12.03.2026'); + }); + + it('shows the module icon', () => { + expect(card().querySelector('.card-leading mat-icon').textContent).toBe( + 'play_arrow', + ); + }); + + it('projects the table actions and asset context', () => { + expect( + card().querySelector('.card-header-actions #preview'), + ).not.toBeNull(); + expect(card().querySelector('.card-context #asset')).not.toBeNull(); + }); + + it('opens the details from the title and edits from the footer', () => { + card().querySelector<HTMLElement>('.card-title-link').click(); + byCy('pipeline-card-edit').click(); + + expect(host.showDetails).toHaveBeenCalledOnce(); + expect(host.editPipeline).toHaveBeenCalledOnce(); + }); + + it('offers stop for a running pipeline', () => { + expect(byCy('pipeline-card-start')).toBeNull(); + + byCy('pipeline-card-stop').click(); + + expect(host.stopPipeline).toHaveBeenCalledOnce(); + }); + + it('offers start for a stopped pipeline', async () => { + await update({ running: false }); + + expect(text('pipeline-card-status')).toBe('Stopped'); + expect(byCy('pipeline-card-stop')).toBeNull(); + + byCy('pipeline-card-start').click(); + + expect(host.startPipeline).toHaveBeenCalledOnce(); + }); + + it('shows the health of a running pipeline', async () => { + await update({ healthStatus: 'FAILURE' }); + + expect(text('pipeline-card-status')).toBe('Failed'); + }); + + it('opens the notifications from their count', async () => { + await update({ pipelineNotifications: ['first', 'second'] }); + + expect(text('pipeline-card-notifications')).toContain('2'); + expect(byCy('pipeline-card-no-notifications')).toBeNull(); + + byCy('pipeline-card-notifications').click(); + + expect(host.showNotifications).toHaveBeenCalledOnce(); + }); + + it('explains an invalid pipeline and prevents starting it', async () => { + await update({ running: false, valid: false as true }); + + expect(byCy('pipeline-card-invalid')).not.toBeNull(); + expect(byCy('pipeline-card-start').disabled).toBe(true); + }); + + it('blocks actions while an operation is in progress', async () => { + host.operationInProgress.set(true); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(byCy('pipeline-card-stop').disabled).toBe(true); + }); + + it('keeps read-only users from changing the pipeline', async () => { + host.hasWritePrivileges.set(false); + await update({ pipelineNotifications: ['first'] }); + + expect(byCy('pipeline-card-stop').disabled).toBe(true); + expect(byCy('pipeline-card-edit')).toBeNull(); + expect(byCy('pipeline-card-notifications').disabled).toBe(true); + }); +}); diff --git a/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview-card/pipeline-overview-card.component.ts b/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview-card/pipeline-overview-card.component.ts new file mode 100644 index 0000000000..311cbefe79 --- /dev/null +++ b/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview-card/pipeline-overview-card.component.ts @@ -0,0 +1,74 @@ +/* + * 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 { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { TranslatePipe } from '@ngx-translate/core'; +import { PipelineSummaryDto } from '@streampipes/platform-services'; +import { + SpOverviewCardComponent, + SpOverviewCardFactComponent, + SpOverviewCardFactsComponent, + SpStatusLabelComponent, +} from '@streampipes/shared-ui'; +import { getPipelineStatus } from '../pipeline-status'; + +@Component({ + selector: 'sp-pipeline-overview-card', + templateUrl: './pipeline-overview-card.component.html', + styleUrls: ['./pipeline-overview-card.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + DatePipe, + MatButton, + MatIcon, + MatTooltip, + SpOverviewCardComponent, + SpOverviewCardFactComponent, + SpOverviewCardFactsComponent, + SpStatusLabelComponent, + TranslatePipe, + ], +}) +export class PipelineOverviewCardComponent { + readonly pipeline = input.required<PipelineSummaryDto>(); + readonly icon = input<string>(); + readonly accentColor = input<string>(); + readonly hasWritePrivileges = input(false); + readonly operationInProgress = input(false); + + readonly startPipeline = output<void>(); + readonly stopPipeline = output<void>(); + readonly showDetails = output<void>(); + readonly editPipeline = output<void>(); + readonly showNotifications = output<void>(); + + readonly status = computed(() => getPipelineStatus(this.pipeline())); + readonly notificationCount = computed( + () => this.pipeline().pipelineNotifications?.length ?? 0, + ); +} diff --git a/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview.component.html b/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview.component.html index cd777b6b73..adc691fce1 100644 --- a/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview.component.html +++ b/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview.component.html @@ -16,6 +16,7 @@ ~ --> +@let pipelineLinkType = pipelineLinkType$ | async; <sp-table [dataSource]="dataSource" [noToolbarBorderTop]="true" @@ -48,31 +49,17 @@ <td mat-cell *matCellDef="let pipeline"> <div> <div fxLayout="row" fxLayoutAlign="start center" fxFlex="150px"> - @if (pipeline.running && pipeline.healthStatus === 'OK') { - <div - data-cy="status-pipeline-green" - class="light light-green" - ></div> - } - @if ( - pipeline.running && - (pipeline.healthStatus === 'REQUIRES_ATTENTION' || - pipeline.healthStatus === - 'HANDLE_MEASUREMENT_UPDATE') - ) { - <div class="light light-yellow"></div> - } - @if ( - pipeline.running && pipeline.healthStatus === 'FAILURE' - ) { - <div class="light light-red"></div> - } - @if (!pipeline.running) { - <div - class="light light-neutral" - [matTooltip]="'Stopped' | translate" - ></div> - } + @let status = getStatus(pipeline); + <sp-status-label + [tone]="status.tone" + [label]="status.label | translate" + [showLabel]="false" + [attr.data-cy]=" + pipeline.running && pipeline.healthStatus === 'OK' + ? 'status-pipeline-green' + : null + " + ></sp-status-label> <div class="notification-icon"> @if (pipeline.pipelineNotifications.length > 0) { <button @@ -172,14 +159,14 @@ <th mat-header-cell mat-sort-header *matHeaderCellDef> {{ 'Name' | translate }} </th> - <td mat-cell *matCellDef="let pipeline" class="truncate"> - <div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="5px"> - <div fxLayout="column" fxLayoutAlign="start start"> - <span class="text-sm" data-cy="pipeline-name-cell">{{ - pipeline.name - }}</span> - <small> {{ pipeline.description }}</small> - </div> + <td mat-cell class="pipeline-name-cell" *matCellDef="let pipeline"> + <div fxLayout="column" fxLayoutAlign="start start"> + <span class="text-sm" data-cy="pipeline-name-cell">{{ + pipeline.name + }}</span> + <small class="truncate" [matTooltip]="pipeline.description">{{ + pipeline.description + }}</small> </div> </td> </ng-container> @@ -195,6 +182,55 @@ </td> </ng-container> + <ng-template + spTableCard + let-pipeline + let-actions="actions" + let-assetContext="assetContext" + > + <sp-pipeline-overview-card + [pipeline]="pipeline" + [icon]="pipelineLinkType?.linkIcon" + [accentColor]="pipelineLinkType?.linkColor" + [hasWritePrivileges]="hasPipelineWritePrivileges" + [operationInProgress]="starting || stopping" + (startPipeline)=" + pipelineOperationsService.startPipeline( + pipeline.elementId, + refreshPipelinesEmitter, + toggleRunningOperation + ) + " + (stopPipeline)=" + pipelineOperationsService.stopPipeline( + pipeline.elementId, + refreshPipelinesEmitter, + toggleRunningOperation + ) + " + (showDetails)=" + pipelineOperationsService.showPipelineDetails( + pipeline.elementId + ) + " + (editPipeline)=" + pipelineOperationsService.modifyPipeline(pipeline.elementId) + " + (showNotifications)="openPipelineNotificationsDialog(pipeline)" + > + <ng-container + cardHeaderActions + *ngTemplateOutlet="actions; context: { $implicit: pipeline }" + ></ng-container> + <ng-container + cardAssetContext + *ngTemplateOutlet=" + assetContext; + context: { $implicit: pipeline } + " + ></ng-container> + </sp-pipeline-overview-card> + </ng-template> <ng-template spTableActions let-element> <button mat-menu-item diff --git a/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview.component.scss b/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview.component.scss index 69286e5207..301193e529 100644 --- a/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview.component.scss +++ b/ui/src/app/pipelines/components/pipeline-overview/pipeline-overview.component.scss @@ -36,3 +36,14 @@ .notification-icon { width: 50px; } + +/* Lets the name column take the remaining width and truncate the description, + because a table column otherwise grows to fit text that does not wrap. */ +.pipeline-name-cell { + width: 100%; + max-width: 0; +} + +.pipeline-name-cell small { + max-width: 100%; +} 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 0cc4351f6e..5d2db38b88 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 @@ -46,8 +46,11 @@ import { DialogRef, DialogService, PanelType, + SpAssetBrowserService, + SpStatusLabelComponent, SpTableActionsDirective, SpTableAssetContextConfig, + SpTableCardDirective, SpTableComponent, SpTableMultiActionExecuteEvent, SpTableMultiActionOption, @@ -57,14 +60,15 @@ import { FlexDirective, LayoutAlignDirective, LayoutDirective, - LayoutGapDirective, } from '@ngbracket/ngx-layout/flex'; import { MatTooltip } from '@angular/material/tooltip'; import { MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatMenuItem } from '@angular/material/menu'; -import { DatePipe } from '@angular/common'; +import { AsyncPipe, DatePipe, NgTemplateOutlet } from '@angular/common'; import { TranslatePipe } from '@ngx-translate/core'; +import { PipelineOverviewCardComponent } from './pipeline-overview-card/pipeline-overview-card.component'; +import { getPipelineStatus, PipelineStatus } from './pipeline-status'; @Component({ selector: 'sp-pipeline-overview', @@ -86,8 +90,12 @@ import { TranslatePipe } from '@ngx-translate/core'; MatTooltip, MatIconButton, MatIcon, - LayoutGapDirective, + AsyncPipe, + NgTemplateOutlet, + PipelineOverviewCardComponent, + SpStatusLabelComponent, SpTableActionsDirective, + SpTableCardDirective, MatMenuItem, DatePipe, TranslatePipe, @@ -132,6 +140,10 @@ export class PipelineOverviewComponent implements OnInit, OnDestroy { private authService = inject(AuthService); private currentUserService = inject(CurrentUserService); private dialogService = inject(DialogService); + private assetBrowserService = inject(SpAssetBrowserService); + + readonly pipelineLinkType$ = + this.assetBrowserService.getAssetLinkType$('pipeline'); ngOnInit() { this.userSub = this.currentUserService.user$.subscribe(() => { @@ -150,6 +162,10 @@ export class PipelineOverviewComponent implements OnInit, OnDestroy { } } + getStatus(pipeline: PipelineSummaryDto): PipelineStatus { + return getPipelineStatus(pipeline); + } + openPipelineNotificationsDialog(pipeline: PipelineSummaryDto) { this.pipelineOperationsService.showPipelineNotificationsDialog( pipeline, diff --git a/ui/src/app/pipelines/components/pipeline-overview/pipeline-status.spec.ts b/ui/src/app/pipelines/components/pipeline-overview/pipeline-status.spec.ts new file mode 100644 index 0000000000..fb457a8f05 --- /dev/null +++ b/ui/src/app/pipelines/components/pipeline-overview/pipeline-status.spec.ts @@ -0,0 +1,43 @@ +/* + * 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 { describe, expect, it } from 'vitest'; +import { getPipelineStatus } from './pipeline-status'; + +describe('getPipelineStatus', () => { + it('reports a stopped pipeline regardless of its last health', () => { + expect( + getPipelineStatus({ running: false, healthStatus: 'FAILURE' }), + ).toEqual({ tone: 'neutral', label: 'Stopped' }); + }); + + it.each([ + ['OK', 'success', 'Running'], + ['REQUIRES_ATTENTION', 'warning', 'Warning'], + ['HANDLE_MEASUREMENT_UPDATE', 'warning', 'Warning'], + ['FAILURE', 'error', 'Failed'], + ] as const)( + 'maps the running health %s to %s', + (healthStatus, tone, label) => { + expect(getPipelineStatus({ running: true, healthStatus })).toEqual({ + tone, + label, + }); + }, + ); +}); diff --git a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss b/ui/src/app/pipelines/components/pipeline-overview/pipeline-status.ts similarity index 52% copy from ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss copy to ui/src/app/pipelines/components/pipeline-overview/pipeline-status.ts index 2376ff86ae..6df80233af 100644 --- a/ui/src/app/connect/components/existing-adapters/existing-adapters.component.scss +++ b/ui/src/app/pipelines/components/pipeline-overview/pipeline-status.ts @@ -16,21 +16,28 @@ * */ -.mat-mdc-header-cell { - font-weight: bold; -} +import { PipelineSummaryDto } from '@streampipes/platform-services'; +import { SpStatusTone } from '@streampipes/shared-ui'; -.adapter-icon { - padding-top: 5px; - padding-bottom: 5px; - max-height: 35px; +export interface PipelineStatus { + tone: SpStatusTone; + label: string; } -.real-time { - cursor: pointer; - color: #ffeb3b; -} +export function getPipelineStatus( + pipeline: Pick<PipelineSummaryDto, 'running' | 'healthStatus'>, +): PipelineStatus { + if (!pipeline.running) { + return { tone: 'neutral', label: 'Stopped' }; + } -.icon { - max-width: 60px; + switch (pipeline.healthStatus) { + case 'FAILURE': + return { tone: 'error', label: 'Failed' }; + case 'REQUIRES_ATTENTION': + case 'HANDLE_MEASUREMENT_UPDATE': + return { tone: 'warning', label: 'Warning' }; + default: + return { tone: 'success', label: 'Running' }; + } } diff --git a/ui/src/scss/sp/buttons-mat3.scss b/ui/src/scss/sp/buttons-mat3.scss index a0264c3ca9..49570bb2cf 100644 --- a/ui/src/scss/sp/buttons-mat3.scss +++ b/ui/src/scss/sp/buttons-mat3.scss @@ -155,7 +155,7 @@ .mat-mdc-unelevated-button:not(.mat-basic):not(.btn-secondary):not( .btn-secondary-outline - ):not(.btn-warn):hover:not(:disabled) { + ):not(.btn-tertiary):not(.btn-warn):hover:not(:disabled) { --mat-button-filled-container-color: color-mix( in srgb, var(--color-primary) 88%, @@ -166,7 +166,7 @@ .mat-mdc-unelevated-button:not(.mat-basic):not(.btn-secondary):not( .btn-secondary-outline - ):not(.btn-warn):active:not(:disabled) { + ):not(.btn-tertiary):not(.btn-warn):active:not(:disabled) { --mat-button-filled-container-color: color-mix( in srgb, var(--color-primary) 82%, @@ -227,6 +227,43 @@ ); } + .btn-tertiary { + --btn-tertiary-padding: 0.75rem; + + border: 1px solid transparent; + + @include mat.button-overrides( + ( + filled-container-color: transparent, + filled-label-text-color: var(--color-primary), + filled-state-layer-color: var(--color-primary), + filled-hover-state-layer-opacity: 0, + filled-pressed-state-layer-opacity: 0, + filled-disabled-container-color: transparent, + filled-horizontal-padding: var(--btn-tertiary-padding), + filled-icon-offset: 0, + ) + ); + } + + .btn-tertiary:hover:not(:disabled) { + border-color: transparent; + background: color-mix( + in srgb, + var(--color-primary) 8%, + var(--color-bg-0) + ); + } + + .btn-tertiary:active:not(:disabled) { + border-color: transparent; + background: color-mix( + in srgb, + var(--color-primary) 12%, + var(--color-bg-0) + ); + } + .btn-warn { border: 1px solid transparent;
