This is an automated email from the ASF dual-hosted git repository.
riemer pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/streampipes.git
The following commit(s) were added to refs/heads/dev by this push:
new c7fdf77ce7 feat: Add data preview table (#4207)
c7fdf77ce7 is described below
commit c7fdf77ce7248b9b7a8c94396ae06690fa3957b8
Author: Dominik Riemer <[email protected]>
AuthorDate: Sun Mar 1 09:38:58 2026 +0100
feat: Add data preview table (#4207)
---
.../support/utils/dataExplorer/DataExplorerBtns.ts | 24 +
.../dataExplorer/chartDataPreview.smoke.spec.ts | 48 ++
.../chart-container/chart-container.component.ts | 14 +-
.../base/base-data-explorer-widget.directive.ts | 9 +
.../config/table-widget-config.component.html | 93 +++-
.../config/table-widget-config.component.scss | 35 ++
.../table/config/table-widget-config.component.ts | 152 +++++-
.../charts/table/model/table-widget.model.ts | 3 +
.../charts/table/table-widget.component.html | 223 +++++----
.../charts/table/table-widget.component.scss | 109 ++++-
.../charts/table/table-widget.component.ts | 527 ++++++++++++++++-----
.../models/dataview-dashboard.model.ts | 1 +
.../chart-view/chart-view.component.html | 75 ++-
.../chart-view/chart-view.component.scss | 37 --
.../components/chart-view/chart-view.component.ts | 9 +
.../data-settings/chart-data-settings.component.ts | 3 +
.../chart-data-preview.component.html | 111 +++++
.../chart-data-preview.component.scss | 137 ++++++
.../chart-data-preview.component.ts | 204 ++++++++
ui/src/scss/sp/_variables.scss | 33 ++
ui/src/scss/sp/forms.scss | 126 +++--
21 files changed, 1613 insertions(+), 360 deletions(-)
diff --git a/ui/cypress/support/utils/dataExplorer/DataExplorerBtns.ts
b/ui/cypress/support/utils/dataExplorer/DataExplorerBtns.ts
index ee99646fe0..741f17d019 100644
--- a/ui/cypress/support/utils/dataExplorer/DataExplorerBtns.ts
+++ b/ui/cypress/support/utils/dataExplorer/DataExplorerBtns.ts
@@ -126,6 +126,30 @@ export class DataExplorerBtns {
return cy.dataCy('save-data-explorer-go-back-to-overview');
}
+ public static chartDataPreview() {
+ return cy.dataCy('chart-data-preview');
+ }
+
+ public static chartDataPreviewHeader() {
+ return cy.dataCy('chart-data-preview-header');
+ }
+
+ public static chartDataPreviewToggle() {
+ return cy.dataCy('chart-data-preview-toggle');
+ }
+
+ public static chartDataPreviewTable() {
+ return cy.dataCy('chart-data-preview-table');
+ }
+
+ public static chartDataPreviewCell(columnName: string) {
+ return cy.dataCy(`chart-data-preview-cell-${columnName}`);
+ }
+
+ public static chartDataPreviewEmpty() {
+ return cy.dataCy('chart-data-preview-empty');
+ }
+
public static addNewWidgetBtn() {
return cy.dataCy('add-new-widget');
}
diff --git a/ui/cypress/tests/dataExplorer/chartDataPreview.smoke.spec.ts
b/ui/cypress/tests/dataExplorer/chartDataPreview.smoke.spec.ts
new file mode 100644
index 0000000000..9d95809e77
--- /dev/null
+++ b/ui/cypress/tests/dataExplorer/chartDataPreview.smoke.spec.ts
@@ -0,0 +1,48 @@
+/*
+ * 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 { DataExplorerWidget } from '../../support/model/DataExplorerWidget';
+import { PrepareTestDataUtils } from
'../../support/utils/PrepareTestDataUtils';
+import { DataExplorerBtns } from
'../../support/utils/dataExplorer/DataExplorerBtns';
+import { DataExplorerUtils } from
'../../support/utils/dataExplorer/DataExplorerUtils';
+
+describe('Test Chart Data Preview in Data Explorer', () => {
+ beforeEach('Setup Test', () => {
+ DataExplorerUtils.initDataLakeTests();
+ });
+
+ it('Shows and toggles the chart data preview', () => {
+ DataExplorerUtils.addDataViewAndWidget(
+ 'preview-view',
+ PrepareTestDataUtils.dataName,
+ DataExplorerWidget.TIME_SERIES,
+ );
+
+ DataExplorerBtns.chartDataPreviewHeader().should('be.visible');
+ DataExplorerBtns.chartDataPreviewTable().should('not.exist');
+
+ DataExplorerBtns.chartDataPreviewToggle().click();
+
+ DataExplorerBtns.chartDataPreviewTable().should('be.visible');
+ DataExplorerBtns.chartDataPreviewCell('time')
+ .should('exist')
+ .and('have.length.at.least', 1);
+
+ DataExplorerBtns.chartDataPreviewToggle().click();
+ DataExplorerBtns.chartDataPreviewTable().should('not.exist');
+ });
+});
diff --git
a/ui/src/app/chart-shared/components/chart-container/chart-container.component.ts
b/ui/src/app/chart-shared/components/chart-container/chart-container.component.ts
index 0c62028bab..a9f4384877 100644
---
a/ui/src/app/chart-shared/components/chart-container/chart-container.component.ts
+++
b/ui/src/app/chart-shared/components/chart-container/chart-container.component.ts
@@ -38,6 +38,7 @@ import {
ExtendedTimeSettings,
QuickTimeSelection,
SpLogMessage,
+ SpQueryResult,
TimeSelectionConstants,
TimeSettings,
} from '@streampipes/platform-services';
@@ -152,6 +153,8 @@ export class ChartContainerComponent
@Output() deleteCallback: EventEmitter<number> = new
EventEmitter<number>();
@Output() startEditModeEmitter: EventEmitter<DataExplorerWidgetModel> =
new EventEmitter<DataExplorerWidgetModel>();
+ @Output() queryResultsEmitter: EventEmitter<SpQueryResult[]> =
+ new EventEmitter<SpQueryResult[]>();
title = '';
widgetLoaded = false;
@@ -352,7 +355,15 @@ export class ChartContainerComponent
this.handleTimer(ev),
);
const error$ = this.componentRef.instance.errorCallback.subscribe(
- ev => (this.errorMessage = ev),
+ ev => {
+ this.errorMessage = ev;
+ if (ev) {
+ this.queryResultsEmitter.emit([]);
+ }
+ },
+ );
+ const data$ =
this.componentRef.instance.dataReceivedCallback.subscribe(
+ results => this.queryResultsEmitter.emit(results),
);
this.componentRef.onDestroy(destroy => {
@@ -360,6 +371,7 @@ export class ChartContainerComponent
remove$?.unsubscribe();
timer$?.unsubscribe();
error$?.unsubscribe();
+ data$?.unsubscribe();
});
}
diff --git
a/ui/src/app/chart-shared/components/charts/base/base-data-explorer-widget.directive.ts
b/ui/src/app/chart-shared/components/charts/base/base-data-explorer-widget.directive.ts
index db21d8b508..a1f862a65b 100644
---
a/ui/src/app/chart-shared/components/charts/base/base-data-explorer-widget.directive.ts
+++
b/ui/src/app/chart-shared/components/charts/base/base-data-explorer-widget.directive.ts
@@ -70,6 +70,11 @@ export abstract class BaseDataExplorerWidgetDirective<
errorCallback: EventEmitter<SpLogMessage> =
new EventEmitter<SpLogMessage>();
+ @Output()
+ dataReceivedCallback: EventEmitter<SpQueryResult[]> = new EventEmitter<
+ SpQueryResult[]
+ >();
+
@Input() editMode: boolean;
@Input() kioskMode: boolean;
@Input() dataViewMode: boolean;
@@ -141,6 +146,7 @@ export abstract class BaseDataExplorerWidgetDirective<
catchError(err => {
this.timerCallback.emit(false);
this.errorCallback.emit(err.error);
+ this.dataReceivedCallback.emit([]);
return [];
}),
);
@@ -271,11 +277,14 @@ export abstract class BaseDataExplorerWidgetDirective<
const spQueryResult = spQueryResults[0];
if (spQueryResult.total === 0) {
+ this.dataReceivedCallback.emit([]);
this.setShownComponents(true, false, false, false);
} else if (spQueryResult['spQueryStatus'] === 'TOO_MUCH_DATA') {
this.amountOfTooMuchEvents = spQueryResult.total;
+ this.dataReceivedCallback.emit([]);
this.setShownComponents(false, false, false, true);
} else {
+ this.dataReceivedCallback.emit(spQueryResults);
this.onDataReceived(spQueryResults);
}
}
diff --git
a/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.html
b/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.html
index 41fe5fd66d..abed075a07 100644
---
a/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.html
+++
b/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.html
@@ -31,20 +31,83 @@
"
>
</sp-select-properties-config>
- <sp-split-section
- [level]="3"
- [title]="'Search' | translate"
- marginTop="5px"
- >
- <mat-form-field appearance="outline" fxFlex="100" color="accent">
- <mat-label>{{ 'Filter' | translate }}</mat-label>
- <input
- [(ngModel)]="
- currentlyConfiguredWidget.visualizationConfig.searchValue
- "
- matInput
- (input)="onFilterChange($event.target.value)"
- />
- </mat-form-field>
+ <sp-split-section [level]="3" [title]="'Settings' | translate">
+ <sp-form-field [level]="3" [label]="'Filter' | translate">
+ <mat-form-field fxFlex="100">
+ <input
+ [(ngModel)]="
+ currentlyConfiguredWidget.visualizationConfig
+ .searchValue
+ "
+ matInput
+ (input)="onFilterChange($event.target.value)"
+ />
+ </mat-form-field>
+ </sp-form-field>
+ <sp-form-field [level]="3" [label]="'Page size' | translate">
+ <mat-form-field fxFlex="100">
+ <mat-select
+ [value]="
+ currentlyConfiguredWidget.visualizationConfig.pageSize
+ "
+ (valueChange)="setPageSize($event)"
+ >
+ @for (pageSize of pageSizeOptions; track pageSize) {
+ <mat-option [value]="pageSize">
+ {{ pageSize }}
+ </mat-option>
+ }
+ </mat-select>
+ </mat-form-field>
+ </sp-form-field>
+ </sp-split-section>
+ <sp-split-section [level]="3" [title]="'Value Highlighting' | translate">
+ <div fxLayout="column" class="highlight-color-list">
+ @for (field of highlightableFields; track colorKey(field)) {
+ <div
+ class="highlight-color-row"
+ fxLayout="row"
+ fxLayoutAlign="space-between center"
+ >
+ <mat-checkbox
+ [checked]="isHighlighted(field)"
+ (change)="toggleHighlightedField(field)"
+ >
+ <div class="highlight-color-label">
+ <b>{{ field.runtimeName }}</b>
+ <span class="highlight-color-measure">
+ {{
+ '(' +
+ field.measure +
+ ', ' +
+ (fieldTypeLabel(field) | translate) +
+ ')'
+ }}
+ </span>
+ </div>
+ </mat-checkbox>
+ <input
+ class="highlight-color-picker"
+ [colorPicker]="getHighlightColor(field)"
+ [style.background]="getHighlightColor(field)"
+ [cpPosition]="'auto'"
+ [cpPresetColors]="presetColors"
+ (colorPickerChange)="setHighlightColor(field, $event)"
+ [disabled]="!isHighlighted(field)"
+ [attr.aria-label]="
+ 'Select highlight color for ' + field.runtimeName
+ | translate
+ "
+ />
+ </div>
+ } @empty {
+ <sp-alert-banner
+ type="info"
+ [title]="
+ 'No numeric or boolean fields available.' | translate
+ "
+ ></sp-alert-banner>
+ }
+ </div>
</sp-split-section>
</sp-visualization-config-outer>
diff --git
a/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.scss
b/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.scss
index 13cbc4aacb..2bc4d58126 100644
---
a/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.scss
+++
b/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.scss
@@ -15,3 +15,38 @@
* limitations under the License.
*
*/
+
+.highlight-color-list {
+ gap: var(--space-sm);
+}
+
+.highlight-color-label {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ font-size: var(--font-size-sm);
+}
+
+.highlight-color-measure {
+ color: var(--color-secondary-text);
+ font-size: var(--font-size-xs);
+}
+
+.highlight-color-picker {
+ margin-left: auto;
+ display: block;
+ width: calc(var(--space-md) * 2.4);
+ height: calc(var(--space-md) * 2.4);
+ padding: 0;
+ border: 1px solid var(--color-tab-border);
+ border-radius: var(--button-border-radius);
+ cursor: pointer;
+ appearance: none;
+ -webkit-appearance: none;
+ background-clip: padding-box;
+}
+
+.highlight-color-picker:disabled {
+ cursor: default;
+ opacity: 0.45;
+}
diff --git
a/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.ts
b/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.ts
index 2a44320c29..d374a7fc31 100644
---
a/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.ts
+++
b/ui/src/app/chart-shared/components/charts/table/config/table-widget-config.component.ts
@@ -24,12 +24,23 @@ import { ChartFieldProviderService } from
'../../../../services/chart-field-prov
import { DataExplorerField } from '@streampipes/platform-services';
import { SpVisualizationConfigOuterComponent } from
'../../../chart-config/visualization-config-outer/visualization-config-outer.component';
import { SelectMultiplePropertiesConfigComponent } from
'../../../chart-config/select-multiple-properties-config/select-multiple-properties-config.component';
-import { SplitSectionComponent } from '@streampipes/shared-ui';
+import {
+ FormFieldComponent,
+ SpAlertBannerComponent,
+ SplitSectionComponent,
+} from '@streampipes/shared-ui';
import { MatFormField, MatLabel } from '@angular/material/form-field';
-import { FlexDirective } from '@ngbracket/ngx-layout/flex';
+import {
+ FlexDirective,
+ LayoutAlignDirective,
+ LayoutDirective,
+} from '@ngbracket/ngx-layout/flex';
import { MatInput } from '@angular/material/input';
import { FormsModule } from '@angular/forms';
import { TranslatePipe } from '@ngx-translate/core';
+import { MatOption, MatSelect } from '@angular/material/select';
+import { ColorPickerDirective } from 'ngx-color-picker';
+import { MatCheckbox } from '@angular/material/checkbox';
@Component({
selector: 'sp-data-explorer-table-widget-config',
@@ -44,13 +55,32 @@ import { TranslatePipe } from '@ngx-translate/core';
MatLabel,
MatInput,
FormsModule,
+ MatSelect,
+ MatOption,
+ ColorPickerDirective,
+ LayoutDirective,
+ LayoutAlignDirective,
+ MatCheckbox,
TranslatePipe,
+ FormFieldComponent,
+ SpAlertBannerComponent,
],
})
export class TableWidgetConfigComponent extends BaseWidgetConfig<
TableWidgetModel,
TableVisConfig
> {
+ readonly pageSizeOptions = [10, 20, 50, 100, 250, 500];
+ readonly presetColors = [
+ '#39B54A',
+ '#1B1464',
+ '#2563EB',
+ '#F59E0B',
+ '#DC2626',
+ '#14B8A6',
+ '#9333EA',
+ ];
+
constructor(
widgetConfigurationService: ChartConfigurationService,
fieldService: ChartFieldProviderService,
@@ -70,6 +100,79 @@ export class TableWidgetConfigComponent extends
BaseWidgetConfig<
this.triggerViewRefresh();
}
+ setHighlightedColumns(highlightedColumns: DataExplorerField[]) {
+ this.currentlyConfiguredWidget.visualizationConfig.highlightedColumns =
+ highlightedColumns;
+ this.syncHighlightColorMap();
+ this.triggerViewRefresh();
+ }
+
+ setPageSize(pageSize: number): void {
+ this.currentlyConfiguredWidget.visualizationConfig.pageSize = pageSize;
+ this.triggerViewRefresh();
+ }
+
+ colorKey(field: DataExplorerField): string {
+ return `${field.fullDbName}:${field.sourceIndex}`;
+ }
+
+ getHighlightColor(field: DataExplorerField): string {
+ return (
+ this.currentlyConfiguredWidget.visualizationConfig
+ .highlightedColumnColors?.[this.colorKey(field)] ??
+ this.defaultHighlightColor(field)
+ );
+ }
+
+ setHighlightColor(field: DataExplorerField, color: string): void {
+
this.currentlyConfiguredWidget.visualizationConfig.highlightedColumnColors[
+ this.colorKey(field)
+ ] = color;
+ this.triggerViewRefresh();
+ }
+
+ get highlightableFields(): DataExplorerField[] {
+ return this.fieldProvider.allFields.filter(
+ field =>
+ field.fieldCharacteristics.numeric ||
+ field.fieldCharacteristics.binary,
+ );
+ }
+
+ isHighlighted(field: DataExplorerField): boolean {
+ return !!(
+ this.currentlyConfiguredWidget.visualizationConfig
+ .highlightedColumns ?? []
+ ).find(
+ highlightedField =>
+ highlightedField.fullDbName === field.fullDbName &&
+ highlightedField.sourceIndex === field.sourceIndex,
+ );
+ }
+
+ toggleHighlightedField(field: DataExplorerField): void {
+ const highlightedColumns =
+ this.currentlyConfiguredWidget.visualizationConfig
+ .highlightedColumns ?? [];
+
+ if (this.isHighlighted(field)) {
+
this.currentlyConfiguredWidget.visualizationConfig.highlightedColumns =
+ highlightedColumns.filter(
+ highlightedField =>
+ !(
+ highlightedField.fullDbName === field.fullDbName &&
+ highlightedField.sourceIndex === field.sourceIndex
+ ),
+ );
+ } else {
+
this.currentlyConfiguredWidget.visualizationConfig.highlightedColumns =
+ [...highlightedColumns, field];
+ }
+
+ this.syncHighlightColorMap();
+ this.triggerViewRefresh();
+ }
+
protected applyWidgetConfig(config: TableVisConfig): void {
config.selectedColumns = this.fieldService.getSelectedFields(
config.selectedColumns,
@@ -80,10 +183,55 @@ export class TableWidgetConfigComponent extends
BaseWidgetConfig<
: this.fieldProvider.allFields;
},
);
+ config.highlightedColumns = this.fieldService.getSelectedFields(
+ config.highlightedColumns ?? [],
+ this.highlightableFields,
+ () => [],
+ );
+ config.highlightedColumnColors ??= {};
+ this.syncHighlightColorMap();
+ config.pageSize ??= 20;
config.searchValue ??= '';
}
protected requiredFieldsForChartPresent(): boolean {
return true;
}
+
+ private syncHighlightColorMap(): void {
+ const activeColorKeys = new Set(
+ (
+ this.currentlyConfiguredWidget.visualizationConfig
+ .highlightedColumns ?? []
+ ).map(field => this.colorKey(field)),
+ );
+ const currentColorMap =
+ this.currentlyConfiguredWidget.visualizationConfig
+ .highlightedColumnColors ?? {};
+
+ const nextColorMap = Object.fromEntries(
+ Object.entries(currentColorMap).filter(([key]) =>
+ activeColorKeys.has(key),
+ ),
+ );
+
+ (
+ this.currentlyConfiguredWidget.visualizationConfig
+ .highlightedColumns ?? []
+ ).forEach(field => {
+ const key = this.colorKey(field);
+ nextColorMap[key] ??= this.defaultHighlightColor(field);
+ });
+
+
this.currentlyConfiguredWidget.visualizationConfig.highlightedColumnColors =
+ nextColorMap;
+ }
+
+ private defaultHighlightColor(field: DataExplorerField): string {
+ return this.presetColors[field.sourceIndex % this.presetColors.length];
+ }
+
+ fieldTypeLabel(field: DataExplorerField): string {
+ return field.fieldCharacteristics.binary ? 'Boolean' : 'Numeric';
+ }
}
diff --git
a/ui/src/app/chart-shared/components/charts/table/model/table-widget.model.ts
b/ui/src/app/chart-shared/components/charts/table/model/table-widget.model.ts
index 576af48c53..1b7372bc6e 100644
---
a/ui/src/app/chart-shared/components/charts/table/model/table-widget.model.ts
+++
b/ui/src/app/chart-shared/components/charts/table/model/table-widget.model.ts
@@ -25,6 +25,9 @@ import { DataExplorerVisConfig } from
'../../../../models/dataview-dashboard.mod
export interface TableVisConfig extends DataExplorerVisConfig {
selectedColumns: DataExplorerField[];
+ highlightedColumns?: DataExplorerField[];
+ highlightedColumnColors?: Record<string, string>;
+ pageSize?: number;
searchValue: string;
}
diff --git
a/ui/src/app/chart-shared/components/charts/table/table-widget.component.html
b/ui/src/app/chart-shared/components/charts/table/table-widget.component.html
index 94981846a4..0ac5c257ed 100644
---
a/ui/src/app/chart-shared/components/charts/table/table-widget.component.html
+++
b/ui/src/app/chart-shared/components/charts/table/table-widget.component.html
@@ -22,7 +22,6 @@
[ngStyle]="{
background: dataExplorerWidget.baseAppearanceConfig.backgroundColor,
color: dataExplorerWidget.baseAppearanceConfig.textColor,
- overflowX: 'auto',
}"
>
@if (showNoDataInDateRange) {
@@ -39,126 +38,120 @@
</sp-too-much-data>
}
- <div class="table-container">
- @if (showData) {
- <table
- data-cy="data-explorer-table"
- mat-table
- [dataSource]="dataSource"
- matSort
- (matSortChange)="sortData($event)"
- class="table-widget"
- >
- <ng-container [matColumnDef]="'time'">
- <div>
- <th
- mat-header-cell
- mat-sort-header
- *matHeaderCellDef
- [ngStyle]="{
- background:
- dataExplorerWidget.baseAppearanceConfig
- .backgroundColor,
- color: dataExplorerWidget.baseAppearanceConfig
- .textColor,
- }"
- >
- <label class="column-header">{{
- 'Time' | translate
- }}</label>
- </th>
- <td
- mat-cell
- data-cy="data-explorer-table-row-timestamp"
- *matCellDef="let row"
- style="text-align: left"
- >
- {{ row['time'] | date: 'yyyy-MM-dd HH:mm:ss.SSS' }}
- </td>
- </div>
- </ng-container>
- @for (
- element of dataExplorerWidget.visualizationConfig
- .selectedColumns;
- track element
- ) {
- <ng-container [matColumnDef]="element.fullDbName">
- @if (!(element.fullDbName === 'time')) {
- <div>
+ @if (showData) {
+ <div class="analytics-table-shell" fxLayout="column" fxFlex>
+ <div class="analytics-table-scroll" fxFlex>
+ <table data-cy="data-explorer-table" class="analytics-table">
+ <thead>
+ <tr>
+ @for (
+ column of columnNames;
+ track trackColumn($index, column)
+ ) {
<th
- mat-header-cell
- mat-sort-header
- *matHeaderCellDef
- [ngStyle]="{
- background:
- dataExplorerWidget
- .baseAppearanceConfig
- .backgroundColor,
- color: dataExplorerWidget
- .baseAppearanceConfig.textColor,
+ [ngClass]="{
+ 'time-column': column === 'time',
+ 'numeric-cell':
isNumericColumn(column),
+ 'highlighted-column':
+ isHighlightedColumn(column),
}"
>
- <label class="column-header">{{
- element.fullDbName
- }}</label>
+ <button
+ type="button"
+ class="sort-trigger"
+ (click)="sortBy(column)"
+ >
+ <span>
+ @if (column === 'time') {
+ {{ 'Time' | translate }}
+ } @else {
+ {{ headerLabel(column) }}
+ }
+ </span>
+ <mat-icon class="sort-icon">
+ {{ sortIcon(column) }}
+ </mat-icon>
+ </button>
</th>
+ }
+ </tr>
+ </thead>
+ <tbody>
+ @if (pagedRows.length > 0) {
+ @for (
+ row of pagedRows;
+ track trackRow($index, row)
+ ) {
+ <tr>
+ @for (
+ column of columnNames;
+ track trackColumn($index, column)
+ ) {
+ <td
+ [ngClass]="{
+ 'time-column':
+ column === 'time',
+ 'numeric-cell':
+ isNumericColumn(column),
+ 'highlighted-column':
+
isHighlightedColumn(column),
+ }"
+ [ngStyle]="
+ getCellStyle(row, column)
+ "
+ [attr.data-cy]="
+ column === 'time'
+ ?
'data-explorer-table-row-timestamp'
+ :
'data-explorer-table-row-' +
+ column
+ "
+ >
+ @if (column === 'time') {
+ {{
+ row[column]
+ | date
+ : 'yyyy-MM-dd
HH:mm:ss.SSS'
+ }}
+ } @else {
+ {{
+ formatCellValue(
+ column,
+ row[column]
+ )
+ }}
+ }
+ </td>
+ }
+ </tr>
+ }
+ } @else {
+ <tr>
<td
- mat-cell
- *matCellDef="let row"
- [attr.data-cy]="
- 'data-explorer-table-row-' +
- element.fullDbName
- "
- style="text-align: left"
+ class="table-empty-row"
+ [attr.colspan]="columnNames.length"
>
- {{ row[element.fullDbName] }}
+ {{
+ 'No table rows match the current
filter.'
+ | translate
+ }}
</td>
- </div>
+ </tr>
}
- </ng-container>
- }
- @for (element of groupByColumnNames; track element) {
- <ng-container [matColumnDef]="element">
- <th
- mat-header-cell
- mat-sort-header
- *matHeaderCellDef
- [ngStyle]="{
- background:
- dataExplorerWidget.baseAppearanceConfig
- .backgroundColor,
- color: dataExplorerWidget.baseAppearanceConfig
- .textColor,
- }"
- >
- <label class="column-header">{{ element }}</label>
- </th>
- <td
- mat-cell
- *matCellDef="let row"
- [attr.data-cy]="
- 'data-explorer-table-row-' + element
- "
- style="text-align: left"
- >
- {{ row[element] }}
- </td>
- </ng-container>
- }
- <tr
- mat-header-row
- *matHeaderRowDef="columnNames; sticky: true"
- ></tr>
- <tr mat-row *matRowDef="let row; columns: columnNames"></tr>
- </table>
- }
- <mat-paginator
- color="accent"
- [length]="100"
- [pageSize]="500"
- [pageSizeOptions]="[10, 20, 50, 100, 500, 1000]"
- aria-label="Select page"
- >
- </mat-paginator>
- </div>
+ </tbody>
+ </table>
+ </div>
+
+ <mat-paginator
+ color="accent"
+ [length]="filteredRows.length"
+ [pageIndex]="pageIndex"
+ [pageSize]="pageSize"
+ [pageSizeOptions]="pageSizeOptions"
+ [showFirstLastButtons]="true"
+ aria-label="Select page"
+ (page)="onPage($event)"
+ >
+ </mat-paginator>
+ </div>
+ }
</div>
diff --git
a/ui/src/app/chart-shared/components/charts/table/table-widget.component.scss
b/ui/src/app/chart-shared/components/charts/table/table-widget.component.scss
index a94e38aa13..3d9e2ee4f0 100644
---
a/ui/src/app/chart-shared/components/charts/table/table-widget.component.scss
+++
b/ui/src/app/chart-shared/components/charts/table/table-widget.component.scss
@@ -16,31 +16,110 @@
*
*/
-.table-container {
- margin-left: 10px;
- margin-right: 10px;
+.analytics-table-shell {
+ min-height: 0;
+ background: inherit;
+}
+
+.analytics-table-scroll {
+ min-height: 0;
+ overflow: auto;
+}
+
+.analytics-table {
+ width: max-content;
+ min-width: 100%;
+ border-collapse: separate;
+ border-spacing: 0;
+ font-size: var(--font-size-xs);
+ line-height: var(--line-height-tight);
+}
+
+.analytics-table th,
+.analytics-table td {
+ padding: var(--space-xs) var(--space-sm);
+ border-right: 1px solid var(--color-border-subtle, var(--color-bg-3));
+ border-bottom: 1px solid var(--color-border-subtle, var(--color-bg-3));
+ white-space: nowrap;
+ max-width: 16rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.analytics-table th {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ padding: 0;
+ background: var(--color-bg-0);
+}
+
+.analytics-table .time-column {
+ position: sticky;
+ left: 0;
+}
+
+.analytics-table th.time-column {
+ z-index: 4;
+ min-width: 11rem;
}
-.table-widget {
+.analytics-table td.time-column {
+ z-index: 1;
+ min-width: 11rem;
+ background: var(--color-bg-0);
+}
+
+.analytics-table tbody tr:nth-child(even) td {
+ background: color-mix(in srgb, var(--color-bg-2) 55%, var(--color-bg-0));
+}
+
+.analytics-table tbody tr:nth-child(even) td.time-column {
+ background: color-mix(in srgb, var(--color-bg-2) 55%, var(--color-bg-0));
+}
+
+.analytics-table tbody tr:hover td {
+ background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-0));
+}
+
+.analytics-table tbody tr:hover td.time-column {
+ background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-0));
+}
+
+.sort-trigger {
width: 100%;
- background: inherit;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-sm);
+ padding: var(--space-xs) var(--space-sm);
+ border: 0;
+ background: transparent;
color: inherit;
+ font: inherit;
+ font-weight: var(--font-weight-semibold);
+ cursor: pointer;
}
-.column-header {
- font-size: var(--text-sm);
- font-weight: bold;
+.sort-icon {
+ width: 1rem;
+ height: 1rem;
+ font-size: 1rem;
+ line-height: 1rem;
+ color: var(--color-secondary-text);
}
-tr.mat-row,
-tr.mat-footer-row {
- height: 24px;
+.numeric-cell {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
}
-.table-widget .mat-cell {
- color: inherit;
+.highlighted-column .sort-trigger {
+ color: var(--color-primary);
}
-.table-widget .mat-header-cell {
- color: inherit;
+.table-empty-row {
+ padding: var(--space-lg);
+ text-align: center;
+ color: var(--color-secondary-text);
}
diff --git
a/ui/src/app/chart-shared/components/charts/table/table-widget.component.ts
b/ui/src/app/chart-shared/components/charts/table/table-widget.component.ts
index fc1ff9b967..46ad31dfac 100644
--- a/ui/src/app/chart-shared/components/charts/table/table-widget.component.ts
+++ b/ui/src/app/chart-shared/components/charts/table/table-widget.component.ts
@@ -16,35 +16,33 @@
*
*/
-import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
-import { MatSort, MatSortHeader } from '@angular/material/sort';
-import {
- MatCell,
- MatCellDef,
- MatColumnDef,
- MatHeaderCell,
- MatHeaderCellDef,
- MatHeaderRow,
- MatHeaderRowDef,
- MatRow,
- MatRowDef,
- MatTable,
- MatTableDataSource,
-} from '@angular/material/table';
+import { DatePipe, NgClass, NgStyle } from '@angular/common';
+import { Component, ViewChild } from '@angular/core';
+import { MatIcon } from '@angular/material/icon';
+import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { BaseDataExplorerWidgetDirective } from
'../base/base-data-explorer-widget.directive';
import { TableWidgetModel } from './model/table-widget.model';
import {
DataExplorerField,
SpQueryResult,
} from '@streampipes/platform-services';
-import { MatPaginator } from '@angular/material/paginator';
import { FlexDirective, LayoutDirective } from '@ngbracket/ngx-layout/flex';
-import { DatePipe, NgStyle } from '@angular/common';
-import { StyleDirective } from '@ngbracket/ngx-layout/extended';
import { NoDataInDateRangeComponent } from
'../base/no-data/no-data-in-date-range.component';
import { TooMuchDataComponent } from
'../base/too-much-data/too-much-data.component';
import { TranslatePipe } from '@ngx-translate/core';
+type SortDirection = 'asc' | 'desc' | '';
+
+interface TableRow {
+ __rowIndex: number;
+ [key: string]: unknown;
+}
+
+interface NumericColumnStats {
+ min: number;
+ max: number;
+}
+
@Component({
selector: 'sp-data-explorer-table-widget',
templateUrl: './table-widget.component.html',
@@ -53,133 +51,138 @@ import { TranslatePipe } from '@ngx-translate/core';
LayoutDirective,
FlexDirective,
NgStyle,
- StyleDirective,
+ NgClass,
NoDataInDateRangeComponent,
TooMuchDataComponent,
- MatTable,
- MatSort,
- MatColumnDef,
- MatHeaderCellDef,
- MatHeaderCell,
- MatSortHeader,
- MatCellDef,
- MatCell,
- MatHeaderRowDef,
- MatHeaderRow,
- MatRowDef,
- MatRow,
MatPaginator,
+ MatIcon,
DatePipe,
TranslatePipe,
],
})
-export class TableWidgetComponent
- extends BaseDataExplorerWidgetDirective<TableWidgetModel>
- implements OnInit, OnDestroy
-{
- @ViewChild(MatSort, { static: true }) sort: MatSort;
- @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
-
- dataSource = new MatTableDataSource();
- columnNames: string[];
- groupByColumnNames: string[];
-
- ngOnInit(): void {
- super.ngOnInit();
- this.dataSource.sort = this.sort;
- this.dataSource.paginator = this.paginator;
- this.regenerateColumnNames();
- }
+export class TableWidgetComponent extends
BaseDataExplorerWidgetDirective<TableWidgetModel> {
+ private static readonly DEFAULT_PAGE_SIZE = 20;
+
+ @ViewChild(MatPaginator) paginator: MatPaginator;
+
+ readonly pageSizeOptions = [10, 20, 50, 100, 250, 500];
+
+ rows: TableRow[] = [];
+ filteredRows: TableRow[] = [];
+ pagedRows: TableRow[] = [];
+ columnNames: string[] = [];
+ groupByColumnNames: string[] = [];
+ pageSize = TableWidgetComponent.DEFAULT_PAGE_SIZE;
+ pageIndex = 0;
+ sortColumn = '';
+ sortDirection: SortDirection = '';
+
+ private numericColumnStats: Record<string, NumericColumnStats> = {};
regenerateColumnNames(): void {
this.groupByColumnNames = this.makeGroupByColumns(
- this.dataExplorerWidget.visualizationConfig.selectedColumns,
+ this.dataExplorerWidget.visualizationConfig.selectedColumns ?? [],
);
- this.columnNames = ['time'].concat(
- this.dataExplorerWidget.visualizationConfig.selectedColumns.map(
- c => c.fullDbName,
- ),
- ...this.groupByColumnNames,
+
+ this.columnNames = Array.from(
+ new Set([
+ 'time',
+ ...(
+ this.dataExplorerWidget.visualizationConfig
+ .selectedColumns ?? []
+ ).map(column => column.fullDbName),
+ ...this.groupByColumnNames,
+ ]),
);
}
makeGroupByColumns(selectedColumns: DataExplorerField[]): string[] {
return this.dataExplorerWidget.dataConfig.sourceConfigs.flatMap(sc => {
- return sc.queryConfig.groupBy
- .filter(g => g.selected)
+ return (sc.queryConfig.groupBy ?? [])
+ .filter(groupBy => groupBy.selected)
.filter(
- g =>
+ groupBy =>
selectedColumns.find(
- column => column.runtimeName === g.runtimeName,
+ column =>
+ column.runtimeName === groupBy.runtimeName,
) === undefined,
)
- .map(g => g.runtimeName);
+ .map(groupBy => groupBy.runtimeName);
});
}
- transformData(spQueryResult: SpQueryResult) {
+ transformData(spQueryResult: SpQueryResult, rowOffset: number): TableRow[]
{
+ let nextRowIndex = rowOffset;
return spQueryResult.allDataSeries.flatMap(series =>
series.rows.map(row =>
- this.createTableObject(spQueryResult.headers, row,
series.tags),
+ this.createTableObject(
+ spQueryResult.headers,
+ row,
+ series.tags,
+ nextRowIndex++,
+ ),
),
);
}
createTableObject(
keys: string[],
- values: any[],
+ values: unknown[],
tags: Record<string, string>,
- ) {
- const row = keys.reduce((object, key, index) => {
- object[key] = values[index];
- return object;
- }, {});
- if (tags !== null) {
+ rowIndex: number,
+ ): TableRow {
+ const row = keys.reduce(
+ (object, key, index) => {
+ object[key] = values[index];
+ return object;
+ },
+ { __rowIndex: rowIndex } as TableRow,
+ );
+
+ if (tags) {
Object.keys(tags).forEach(key => {
row[key] = tags[key];
});
}
+
return row;
}
- ngOnDestroy(): void {
- this.dataSource.data = [];
+ onPage(event: PageEvent): void {
+ this.pageIndex = event.pageIndex;
+ this.pageSize = event.pageSize;
+ this.dataExplorerWidget.visualizationConfig.pageSize = event.pageSize;
+ this.updatePagedRows();
}
- sortData(event) {
- if (event.direction === 'asc') {
- this.dataSource.data = this.dataSource.data.sort((a, b) =>
- a[event.active] > b[event.active]
- ? 1
- : b[event.active] > a[event.active]
- ? -1
- : 0,
- );
+ sortBy(column: string): void {
+ if (this.sortColumn !== column) {
+ this.sortColumn = column;
+ this.sortDirection = 'asc';
+ } else if (this.sortDirection === 'asc') {
+ this.sortDirection = 'desc';
+ } else if (this.sortDirection === 'desc') {
+ this.sortDirection = '';
+ this.sortColumn = '';
+ } else {
+ this.sortDirection = 'asc';
}
- if (event.direction === 'desc') {
- this.dataSource.data = this.dataSource.data.sort((a, b) =>
- a[event.active] > b[event.active]
- ? -1
- : b[event.active] > a[event.active]
- ? 1
- : 0,
- );
- }
+ this.applyTableState(false);
+ }
- if (event.direction === '') {
- this.dataSource.data = this.dataSource.data.sort((a, b) =>
- a['timestamp'] > b['timestamp']
- ? 1
- : b['timestamp'] > a['timestamp']
- ? -1
- : 0,
- );
+ sortIcon(column: string): string {
+ if (this.sortColumn !== column || this.sortDirection === '') {
+ return 'unfold_more';
}
+
+ return this.sortDirection === 'asc' ? 'north' : 'south';
}
public refreshView() {
- this.refreshColumns();
+ this.ensureDefaults();
+ this.regenerateColumnNames();
+ this.applyTableState(true);
}
onResize(_width: number, _height: number) {}
@@ -187,11 +190,20 @@ export class TableWidgetComponent
beforeDataFetched() {}
onDataReceived(spQueryResults: SpQueryResult[]) {
+ this.ensureDefaults();
this.regenerateColumnNames();
- const transformedData = spQueryResults
- .map(spQueryResult => this.transformData(spQueryResult))
- .flat();
- this.dataSource.data = [...transformedData];
+
+ let rowOffset = 1;
+ this.rows = spQueryResults.flatMap(spQueryResult => {
+ const transformedRows = this.transformData(
+ spQueryResult,
+ rowOffset,
+ );
+ rowOffset += transformedRows.length;
+ return transformedRows;
+ });
+
+ this.applyTableState(true);
this.setShownComponents(false, true, false, false);
}
@@ -199,22 +211,313 @@ export class TableWidgetComponent
addedFields: DataExplorerField[],
removedFields: DataExplorerField[],
) {
+ const fieldUpdateInfo = {
+ addedFields,
+ removedFields,
+ fieldProvider: this.fieldProvider,
+ };
+
this.dataExplorerWidget.visualizationConfig.selectedColumns =
this.fieldUpdateService.updateFieldSelection(
- this.dataExplorerWidget.visualizationConfig.selectedColumns,
- {
- addedFields,
- removedFields,
- fieldProvider: this.fieldProvider,
- },
+ this.dataExplorerWidget.visualizationConfig.selectedColumns ??
+ [],
+ fieldUpdateInfo,
() => true,
);
- this.refreshColumns();
+
+ this.dataExplorerWidget.visualizationConfig.highlightedColumns = (
+ this.dataExplorerWidget.visualizationConfig.highlightedColumns ??
[]
+ ).filter(
+ field =>
+ !removedFields.find(
+ removedField =>
+ removedField.fullDbName === field.fullDbName,
+ ),
+ );
+
+ this.refreshView();
}
- refreshColumns(): void {
- this.dataSource.filter =
- this.dataExplorerWidget.visualizationConfig.searchValue;
- this.regenerateColumnNames();
+ isNumericColumn(column: string): boolean {
+ return !!this.fieldProvider.numericFields.find(
+ field => field.fullDbName === column,
+ );
+ }
+
+ isHighlightedColumn(column: string): boolean {
+ return !!(
+ this.dataExplorerWidget.visualizationConfig.highlightedColumns ??
[]
+ ).find(field => field.fullDbName === column);
+ }
+
+ headerLabel(column: string): string {
+ return column === 'time' ? 'Time' : column;
+ }
+
+ formatCellValue(column: string, value: unknown): unknown {
+ if (column === 'time') {
+ return value;
+ }
+
+ if (typeof value === 'object' && value !== null) {
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
+ }
+
+ return value ?? '';
+ }
+
+ getCellStyle(row: TableRow, column: string): Record<string, string> {
+ if (!this.isHighlightedColumn(column)) {
+ return {};
+ }
+
+ const highlightValue = this.getHighlightStrength(row[column], column);
+ if (highlightValue === undefined) {
+ return {};
+ }
+ const intensity = Math.round(8 + highlightValue * 26);
+ const highlightColor = this.getHighlightColor(column);
+
+ return {
+ background: `color-mix(in srgb, ${highlightColor} ${intensity}%,
var(--color-bg-0))`,
+ };
+ }
+
+ trackColumn(_index: number, column: string): string {
+ return column;
+ }
+
+ trackRow(_index: number, row: TableRow): number {
+ return row.__rowIndex;
+ }
+
+ private ensureDefaults(): void {
+ this.dataExplorerWidget.visualizationConfig.searchValue ??= '';
+ this.dataExplorerWidget.visualizationConfig.highlightedColumns ??= [];
+ this.dataExplorerWidget.visualizationConfig.highlightedColumnColors ??=
+ {};
+ this.dataExplorerWidget.visualizationConfig.pageSize ??=
+ TableWidgetComponent.DEFAULT_PAGE_SIZE;
+
+ this.pageSize = this.pageSizeOptions.includes(
+ this.dataExplorerWidget.visualizationConfig.pageSize,
+ )
+ ? this.dataExplorerWidget.visualizationConfig.pageSize
+ : TableWidgetComponent.DEFAULT_PAGE_SIZE;
+ }
+
+ private applyTableState(resetPageIndex: boolean): void {
+ this.filteredRows = this.filterRows(this.rows);
+ this.numericColumnStats = this.computeNumericStats(this.filteredRows);
+ this.filteredRows = this.sortRows(this.filteredRows);
+
+ if (resetPageIndex) {
+ this.pageIndex = 0;
+ this.paginator?.firstPage();
+ }
+
+ this.ensureValidPageIndex();
+ this.updatePagedRows();
+ }
+
+ private filterRows(rows: TableRow[]): TableRow[] {
+ const searchTerm = (
+ this.dataExplorerWidget.visualizationConfig.searchValue ?? ''
+ )
+ .trim()
+ .toLowerCase();
+
+ if (!searchTerm) {
+ return [...rows];
+ }
+
+ return rows.filter(row =>
+ this.columnNames.some(column =>
+ String(this.formatCellValue(column, row[column]))
+ .toLowerCase()
+ .includes(searchTerm),
+ ),
+ );
+ }
+
+ private sortRows(rows: TableRow[]): TableRow[] {
+ if (!this.sortColumn || this.sortDirection === '') {
+ return [...rows];
+ }
+
+ const directionMultiplier = this.sortDirection === 'asc' ? 1 : -1;
+ return [...rows].sort((rowA, rowB) => {
+ const comparison = this.compareValues(
+ rowA[this.sortColumn],
+ rowB[this.sortColumn],
+ this.sortColumn,
+ );
+
+ return comparison * directionMultiplier;
+ });
+ }
+
+ private compareValues(
+ valueA: unknown,
+ valueB: unknown,
+ column: string,
+ ): number {
+ const normalizedA = this.normalizeSortValue(valueA, column);
+ const normalizedB = this.normalizeSortValue(valueB, column);
+
+ if (normalizedA === normalizedB) {
+ return 0;
+ }
+
+ if (normalizedA === null) {
+ return 1;
+ }
+
+ if (normalizedB === null) {
+ return -1;
+ }
+
+ return normalizedA > normalizedB ? 1 : -1;
+ }
+
+ private normalizeSortValue(
+ value: unknown,
+ column: string,
+ ): number | string | null {
+ if (value === null || value === undefined || value === '') {
+ return null;
+ }
+
+ if (column === 'time') {
+ const timestamp = new Date(value as string | number).getTime();
+ return Number.isNaN(timestamp) ? null : timestamp;
+ }
+
+ const numericValue = this.toNumber(value);
+ if (numericValue !== undefined) {
+ return numericValue;
+ }
+
+ if (typeof value === 'boolean') {
+ return value ? 1 : 0;
+ }
+
+ return String(value).toLowerCase();
+ }
+
+ private computeNumericStats(
+ rows: TableRow[],
+ ): Record<string, NumericColumnStats> {
+ return (
+ this.dataExplorerWidget.visualizationConfig.highlightedColumns ??
[]
+ )
+ .map(field => field.fullDbName)
+ .reduce(
+ (stats, column) => {
+ const values = rows
+ .map(row => this.toNumber(row[column]))
+ .filter(
+ (value): value is number => value !== undefined,
+ );
+
+ if (values.length > 0) {
+ stats[column] = {
+ min: Math.min(...values),
+ max: Math.max(...values),
+ };
+ }
+
+ return stats;
+ },
+ {} as Record<string, NumericColumnStats>,
+ );
+ }
+
+ private getHighlightColor(column: string): string {
+ const field = (
+ this.dataExplorerWidget.visualizationConfig.highlightedColumns ??
[]
+ ).find(highlightedField => highlightedField.fullDbName === column);
+
+ if (!field) {
+ return 'var(--color-primary)';
+ }
+
+ return (
+ this.dataExplorerWidget.visualizationConfig
+ .highlightedColumnColors?.[
+ `${field.fullDbName}:${field.sourceIndex}`
+ ] ?? 'var(--color-primary)'
+ );
+ }
+
+ private getHighlightStrength(
+ value: unknown,
+ column: string,
+ ): number | undefined {
+ const booleanValue = this.toBoolean(value);
+ if (booleanValue !== undefined) {
+ return booleanValue ? 1 : 0;
+ }
+
+ const numericValue = this.toNumber(value);
+ const stats = this.numericColumnStats[column];
+ if (numericValue === undefined || !stats) {
+ return undefined;
+ }
+
+ return stats.max === stats.min
+ ? 0.5
+ : (numericValue - stats.min) / (stats.max - stats.min);
+ }
+
+ private toNumber(value: unknown): number | undefined {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value;
+ }
+
+ if (typeof value === 'string' && value.trim() !== '') {
+ const numericValue = Number(value);
+ return Number.isFinite(numericValue) ? numericValue : undefined;
+ }
+
+ return undefined;
+ }
+
+ private toBoolean(value: unknown): boolean | undefined {
+ if (typeof value === 'boolean') {
+ return value;
+ }
+
+ if (typeof value === 'string') {
+ const normalizedValue = value.trim().toLowerCase();
+ if (normalizedValue === 'true') {
+ return true;
+ }
+ if (normalizedValue === 'false') {
+ return false;
+ }
+ }
+
+ return undefined;
+ }
+
+ private ensureValidPageIndex(): void {
+ const maxPageIndex =
+ this.filteredRows.length > 0
+ ? Math.floor((this.filteredRows.length - 1) / this.pageSize)
+ : 0;
+ this.pageIndex = Math.min(this.pageIndex, maxPageIndex);
+ }
+
+ private updatePagedRows(): void {
+ const startIndex = this.pageIndex * this.pageSize;
+ this.pagedRows = this.filteredRows.slice(
+ startIndex,
+ startIndex + this.pageSize,
+ );
}
}
diff --git a/ui/src/app/chart-shared/models/dataview-dashboard.model.ts
b/ui/src/app/chart-shared/models/dataview-dashboard.model.ts
index e792765b6f..3f6e310754 100644
--- a/ui/src/app/chart-shared/models/dataview-dashboard.model.ts
+++ b/ui/src/app/chart-shared/models/dataview-dashboard.model.ts
@@ -36,6 +36,7 @@ export interface BaseWidgetData<T extends
DataExplorerWidgetModel> {
removeWidgetCallback: EventEmitter<boolean>;
timerCallback: EventEmitter<boolean>;
errorCallback: EventEmitter<SpLogMessage>;
+ dataReceivedCallback: EventEmitter<SpQueryResult[]>;
editMode: boolean;
kioskMode: boolean;
diff --git a/ui/src/app/chart/components/chart-view/chart-view.component.html
b/ui/src/app/chart/components/chart-view/chart-view.component.html
index d34c1dab33..cdfb4ae0af 100644
--- a/ui/src/app/chart/components/chart-view/chart-view.component.html
+++ b/ui/src/app/chart/components/chart-view/chart-view.component.html
@@ -45,7 +45,10 @@
</div>
} @else {
<mat-drawer-container
- class="designer-panel-container h-100 dashboard-grid"
+ fxFlex="100"
+ fxLayout="column"
+ class="h-100"
+ style="width: 100%; min-height: 0"
>
<mat-drawer
[opened]="editMode"
@@ -70,27 +73,55 @@
</div>
</sp-sidebar-resize>
</mat-drawer>
- <mat-drawer-content class="h-100 dashboard-grid">
- <div #panel fxFlex="100" fxLayout="column">
- @if (
- dataView &&
- dataView.dataConfig?.sourceConfigs?.length > 0
- ) {
- <sp-chart-container
- fxFlex
- [dataViewMode]="true"
- [editMode]="editMode"
- [configuredWidget]="dataView"
- [timeSettings]="timeSettings"
- [observableGenerator]="observableGenerator"
- [dataLakeMeasure]="
- dataView.dataConfig.sourceConfigs[0]
- .measure
- "
- (startEditModeEmitter)="editDataView()"
- >
- </sp-chart-container>
- }
+ <mat-drawer-content
+ class="h-100"
+ fxFlex
+ fxLayout="column"
+ style="min-height: 0"
+ >
+ <div
+ fxFlex
+ fxLayout="column"
+ style="min-height: 0; overflow: hidden"
+ >
+ <div
+ #panel
+ fxLayout="column"
+ fxFlex
+ style="min-height: 0; overflow: hidden"
+ >
+ @if (
+ dataView &&
+ dataView.dataConfig?.sourceConfigs?.length
>
+ 0
+ ) {
+ <sp-chart-container
+ fxFlex
+ style="display: block; min-height: 0"
+ [dataViewMode]="true"
+ [editMode]="editMode"
+ [configuredWidget]="dataView"
+ [timeSettings]="timeSettings"
+ [observableGenerator]="
+ observableGenerator
+ "
+ [dataLakeMeasure]="
+
dataView.dataConfig.sourceConfigs[0]
+ .measure
+ "
+ (startEditModeEmitter)="editDataView()"
+ (queryResultsEmitter)="
+ onQueryResultsChanged($event)
+ "
+ >
+ </sp-chart-container>
+ }
+ </div>
+
+ <sp-chart-data-preview
+ [queryResults]="latestQueryResults"
+ >
+ </sp-chart-data-preview>
</div>
</mat-drawer-content>
</mat-drawer-container>
diff --git a/ui/src/app/chart/components/chart-view/chart-view.component.scss
b/ui/src/app/chart/components/chart-view/chart-view.component.scss
index b286235804..22e68622e7 100644
--- a/ui/src/app/chart/components/chart-view/chart-view.component.scss
+++ b/ui/src/app/chart/components/chart-view/chart-view.component.scss
@@ -16,38 +16,6 @@
*
*/
-.fixed-height {
- height: 50px;
-}
-
-.data-explorer-options {
- padding: 0px;
-}
-
-.data-explorer-options-item {
- display: inline;
- margin-right: 10px;
-}
-
-.m-20 {
- margin: 20px;
-}
-
-.h-100 {
- height: 100%;
-}
-
-.dashboard-grid {
- display: flex;
- flex-direction: column;
- flex: 1 1 100%;
-}
-
-.designer-panel-container {
- width: 100%;
- height: 100%;
-}
-
.designer-panel {
border: 1px solid var(--color-tab-border);
width: auto;
@@ -55,8 +23,3 @@
display: inline-block;
overflow-x: hidden;
}
-
-.widget-title-text {
- white-space: nowrap;
- margin-right: 12px;
-}
diff --git a/ui/src/app/chart/components/chart-view/chart-view.component.ts
b/ui/src/app/chart/components/chart-view/chart-view.component.ts
index 10c442c48b..08f897d66b 100644
--- a/ui/src/app/chart/components/chart-view/chart-view.component.ts
+++ b/ui/src/app/chart/components/chart-view/chart-view.component.ts
@@ -32,6 +32,7 @@ import {
FieldConfig,
LinkageData,
TimeSelectionConstants,
+ SpQueryResult,
TimeSettings,
} from '@streampipes/platform-services';
import {
@@ -77,6 +78,7 @@ import {
} from '@angular/material/sidenav';
import { ChartDesignerPanelComponent } from
'./designer-panel/chart-designer-panel.component';
import { ChartContainerComponent } from
'../../../chart-shared/components/chart-container/chart-container.component';
+import { ChartDataPreviewComponent } from
'./query-result-preview/chart-data-preview.component';
@Component({
selector: 'sp-chart-data-view',
@@ -94,6 +96,7 @@ import { ChartContainerComponent } from
'../../../chart-shared/components/chart-
ChartDesignerPanelComponent,
MatDrawerContent,
ChartContainerComponent,
+ ChartDataPreviewComponent,
TranslatePipe,
],
})
@@ -134,6 +137,7 @@ export class ChartViewComponent
queryParams$: Subscription;
chartNotFound = false;
+ latestQueryResults: SpQueryResult[] = [];
observableGenerator =
this.dataExplorerSharedService.defaultObservableGenerator();
@@ -212,6 +216,7 @@ export class ChartViewComponent
loadDataView(dataViewId: string): void {
this.dataViewLoaded = false;
+ this.latestQueryResults = [];
this.dataViewService
.getChart(dataViewId)
.pipe(
@@ -306,6 +311,10 @@ export class ChartViewComponent
this.routingService.navigateToChart(true, this.dataView.elementId);
}
+ onQueryResultsChanged(results: SpQueryResult[]): void {
+ this.latestQueryResults = results ?? [];
+ }
+
makeDefaultTimeSettings(): TimeSettings {
return this.timeSelectionService.getDefaultTimeSettings();
}
diff --git
a/ui/src/app/chart/components/chart-view/designer-panel/data-settings/chart-data-settings.component.ts
b/ui/src/app/chart/components/chart-view/designer-panel/data-settings/chart-data-settings.component.ts
index 504be6a44e..89e108b7e8 100644
---
a/ui/src/app/chart/components/chart-view/designer-panel/data-settings/chart-data-settings.component.ts
+++
b/ui/src/app/chart/components/chart-view/designer-panel/data-settings/chart-data-settings.component.ts
@@ -236,6 +236,9 @@ export class ChartDataSettingsComponent implements OnInit {
makeVisualizationConfig(fields: FieldProvider): TableVisConfig {
return {
configurationValid: true,
+ highlightedColumns: [],
+ highlightedColumnColors: {},
+ pageSize: 20,
searchValue: '',
selectedColumns: fields.allFields,
};
diff --git
a/ui/src/app/chart/components/chart-view/query-result-preview/chart-data-preview.component.html
b/ui/src/app/chart/components/chart-view/query-result-preview/chart-data-preview.component.html
new file mode 100644
index 0000000000..4c0fe0fa46
--- /dev/null
+++
b/ui/src/app/chart/components/chart-view/query-result-preview/chart-data-preview.component.html
@@ -0,0 +1,111 @@
+<!--
+~ 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 class="preview-shell" fxLayout="column" data-cy="chart-data-preview">
+ <div
+ class="preview-header"
+ fxLayout="row"
+ fxLayoutAlign="space-between center"
+ data-cy="chart-data-preview-header"
+ >
+ <div class="preview-title" fxFlex>
+ {{ 'Data preview' | translate }}
+ </div>
+ <div
+ class="preview-header-actions"
+ fxLayout="row"
+ fxLayoutAlign="end center"
+ >
+ <div class="preview-meta">
+ {{ totalRows }} {{ 'rows' | translate }}
+ @if (!allRowsRendered) {
+ <span class="preview-meta-note">
+ ({{ 'showing first' | translate }} {{ rows.length }})
+ </span>
+ }
+ </div>
+ <button
+ mat-icon-button
+ (click)="toggleExpanded()"
+ data-cy="chart-data-preview-toggle"
+ [attr.aria-label]="
+ (expanded ? 'Collapse preview' : 'Expand preview')
+ | translate
+ "
+ >
+ <mat-icon>{{
+ expanded ? 'expand_more' : 'expand_less'
+ }}</mat-icon>
+ </button>
+ </div>
+ </div>
+
+ @if (expanded && rows.length > 0 && columns.length > 0) {
+ <div
+ class="preview-table-scroll"
+ fxFlex
+ data-cy="chart-data-preview-table-scroll"
+ >
+ <table class="preview-table" data-cy="chart-data-preview-table">
+ <thead>
+ <tr>
+ <th class="index-col">#</th>
+ @for (
+ column of columns;
+ track trackColumn($index, column)
+ ) {
+ <th [title]="displayColumnName(column)">
+ {{ displayColumnName(column) }}
+ </th>
+ }
+ </tr>
+ </thead>
+ <tbody>
+ @for (row of rows; track trackRow($index)) {
+ <tr>
+ <td class="index-col">{{ $index + 1 }}</td>
+ @for (
+ column of columns;
+ track trackColumn($index, column)
+ ) {
+ <td
+ [title]="stringify(row[column])"
+ [attr.data-cy]="
+ 'chart-data-preview-cell-' + column
+ "
+ >
+ {{ formatCellValue(column, row[column]) }}
+ </td>
+ }
+ </tr>
+ }
+ </tbody>
+ </table>
+ </div>
+ } @else if (expanded) {
+ <div
+ class="preview-empty"
+ fxFlex
+ fxLayout
+ fxLayoutAlign="center center"
+ data-cy="chart-data-preview-empty"
+ >
+ {{ 'Run or adjust the query to see a row preview.' | translate }}
+ </div>
+ }
+</div>
diff --git
a/ui/src/app/chart/components/chart-view/query-result-preview/chart-data-preview.component.scss
b/ui/src/app/chart/components/chart-view/query-result-preview/chart-data-preview.component.scss
new file mode 100644
index 0000000000..3dcfdbb1dd
--- /dev/null
+++
b/ui/src/app/chart/components/chart-view/query-result-preview/chart-data-preview.component.scss
@@ -0,0 +1,137 @@
+/*
+ * 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;
+ min-height: 0;
+ height: var(--size-data-preview-collapsed);
+ transition: height 160ms ease;
+ color: var(--color-default-text);
+}
+
+.preview-shell {
+ height: 100%;
+ min-height: 0;
+ box-sizing: border-box;
+ border-top: 1px solid var(--color-bg-2);
+ border-radius: 0;
+ background: var(--color-bg-0);
+ overflow: hidden;
+}
+
+.preview-header {
+ gap: var(--space-sm);
+ padding: var(--padding-panel-header-y) var(--padding-panel-header-x);
+ border-bottom: 1px solid var(--color-tab-border);
+ background: var(--color-panel-header-accent);
+ min-height: var(--size-panel-header-compact);
+ box-sizing: border-box;
+}
+
+.preview-title {
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+ line-height: var(--line-height-normal);
+}
+
+.preview-header-actions {
+ gap: var(--space-2xs);
+}
+
+.preview-meta {
+ font-size: var(--font-size-xs);
+ color: var(--color-secondary-text);
+ white-space: nowrap;
+}
+
+.preview-meta-note {
+ margin-left: var(--space-2xs);
+}
+
+.preview-table-scroll {
+ min-height: 0;
+ overflow: auto;
+}
+
+.preview-table {
+ width: max-content;
+ min-width: 100%;
+ border-collapse: separate;
+ border-spacing: 0;
+ font-size: var(--font-size-xs);
+ line-height: var(--line-height-tight);
+ background: var(--color-bg-0);
+}
+
+.preview-table th,
+.preview-table td {
+ padding: var(--padding-table-cell-compact-y)
+ var(--padding-table-cell-compact-x);
+ text-align: left;
+ border-bottom: 1px solid var(--color-border-subtle);
+ border-right: 1px solid var(--color-border-subtle);
+ white-space: nowrap;
+ max-width: 16rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.preview-table th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: var(--color-bg-0);
+ color: var(--color-default-text);
+ font-weight: var(--font-weight-semibold);
+}
+
+.preview-table tbody tr:nth-child(even) td {
+ background: var(--color-surface-subtle);
+}
+
+.preview-table tbody tr:hover td {
+ background: var(--color-surface-selected-subtle);
+}
+
+.preview-table th.index-col,
+.preview-table td.index-col {
+ position: sticky;
+ left: 0;
+ z-index: 2;
+ min-width: var(--size-table-index-column);
+ max-width: var(--size-table-index-column);
+ text-align: right;
+ color: var(--color-secondary-text);
+ background: var(--color-bg-0);
+}
+
+.preview-table tbody tr:nth-child(even) td.index-col {
+ background: var(--color-surface-subtle);
+}
+
+.preview-table tbody tr:hover td.index-col {
+ background: var(--color-surface-selected-subtle);
+}
+
+.preview-empty {
+ min-height: 0;
+ padding: var(--space-md);
+ text-align: center;
+ color: var(--color-secondary-text);
+ font-size: var(--font-size-xs);
+}
diff --git
a/ui/src/app/chart/components/chart-view/query-result-preview/chart-data-preview.component.ts
b/ui/src/app/chart/components/chart-view/query-result-preview/chart-data-preview.component.ts
new file mode 100644
index 0000000000..68c827acc5
--- /dev/null
+++
b/ui/src/app/chart/components/chart-view/query-result-preview/chart-data-preview.component.ts
@@ -0,0 +1,204 @@
+/*
+ * 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,
+ HostBinding,
+ Input,
+ OnChanges,
+ SimpleChanges,
+} from '@angular/core';
+import { MatIcon } from '@angular/material/icon';
+import { MatIconButton } from '@angular/material/button';
+import {
+ FlexDirective,
+ LayoutAlignDirective,
+ LayoutDirective,
+} from '@ngbracket/ngx-layout/flex';
+import { SpQueryResult } from '@streampipes/platform-services';
+import { TranslatePipe } from '@ngx-translate/core';
+
+type PreviewRow = Record<string, unknown>;
+
+@Component({
+ selector: 'sp-chart-data-preview',
+ templateUrl: './chart-data-preview.component.html',
+ styleUrls: ['./chart-data-preview.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [
+ TranslatePipe,
+ MatIconButton,
+ MatIcon,
+ LayoutDirective,
+ LayoutAlignDirective,
+ FlexDirective,
+ ],
+})
+export class ChartDataPreviewComponent implements OnChanges {
+ private static readonly MAX_PREVIEW_ROWS = 2000;
+ private readonly datePipe = new DatePipe('en-US');
+
+ @Input() queryResults: SpQueryResult[] = [];
+ @Input() defaultExpanded = false;
+
+ columns: string[] = [];
+ rows: PreviewRow[] = [];
+ totalRows = 0;
+ allRowsRendered = true;
+ expanded = false;
+
+ @HostBinding('style.height')
+ get hostHeight(): string {
+ return this.expanded
+ ? 'var(--size-data-preview-expanded)'
+ : 'var(--size-data-preview-collapsed)';
+ }
+
+ @HostBinding('class.expanded')
+ get hostExpandedClass(): boolean {
+ return this.expanded;
+ }
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes.defaultExpanded) {
+ this.expanded = !!this.defaultExpanded;
+ }
+ if (changes.queryResults) {
+ this.rebuildPreview(this.queryResults ?? []);
+ }
+ }
+
+ private rebuildPreview(queryResults: SpQueryResult[]): void {
+ const headerColumns: string[] = [];
+ const tagColumns: string[] = [];
+ const flattenedRows: PreviewRow[] = [];
+ const showSourceColumn = queryResults.length > 1;
+
+ this.totalRows = 0;
+
+ queryResults.forEach(queryResult => {
+ queryResult.headers?.forEach(header =>
+ this.pushUnique(headerColumns, header),
+ );
+
+ queryResult.allDataSeries?.forEach(series => {
+ const tags = series.tags ?? {};
+ Object.keys(tags).forEach(tagKey =>
+ this.pushUnique(tagColumns, tagKey),
+ );
+
+ series.rows?.forEach(values => {
+ this.totalRows += 1;
+ if (
+ flattenedRows.length >=
+ ChartDataPreviewComponent.MAX_PREVIEW_ROWS
+ ) {
+ return;
+ }
+
+ const row: PreviewRow = {};
+ queryResult.headers?.forEach((header, index) => {
+ row[header] = values?.[index];
+ });
+
+ Object.entries(tags).forEach(([key, value]) => {
+ row[key] = value;
+ });
+
+ if (showSourceColumn) {
+ row['_source'] =
+ queryResult.forId || queryResult.sourceIndex;
+ }
+
+ flattenedRows.push(row);
+ });
+ });
+ });
+
+ const orderedHeaderColumns = this.orderHeaderColumns(headerColumns);
+ this.columns = showSourceColumn
+ ? ['_source', ...orderedHeaderColumns, ...tagColumns]
+ : [...orderedHeaderColumns, ...tagColumns];
+ this.rows = flattenedRows;
+ this.allRowsRendered =
+ this.totalRows <= ChartDataPreviewComponent.MAX_PREVIEW_ROWS;
+ }
+
+ private orderHeaderColumns(columns: string[]): string[] {
+ const timeColumns = columns.filter(column => column === 'time');
+ const otherColumns = columns.filter(column => column !== 'time');
+ return [...timeColumns, ...otherColumns];
+ }
+
+ private pushUnique(collection: string[], value: string): void {
+ if (value !== undefined && !collection.includes(value)) {
+ collection.push(value);
+ }
+ }
+
+ trackColumn(_index: number, column: string): string {
+ return column;
+ }
+
+ trackRow(index: number): number {
+ return index;
+ }
+
+ isTimeColumn(column: string): boolean {
+ return column === 'time';
+ }
+
+ displayColumnName(column: string): string {
+ return column === '_source' ? 'Source' : column;
+ }
+
+ isValidDateValue(value: unknown): boolean {
+ if (value === null || value === undefined || value === '') {
+ return false;
+ }
+
+ const date = new Date(value as string | number);
+ return !Number.isNaN(date.getTime());
+ }
+
+ stringify(value: unknown): string {
+ if (value === null || value === undefined) {
+ return '-';
+ }
+ return String(value);
+ }
+
+ formatCellValue(column: string, value: unknown): string {
+ if (this.isTimeColumn(column) && this.isValidDateValue(value)) {
+ return (
+ this.datePipe.transform(
+ value as string | number | Date,
+ 'yyyy-MM-dd HH:mm:ss.SSS',
+ ) ?? this.stringify(value)
+ );
+ }
+
+ return this.stringify(value);
+ }
+
+ toggleExpanded(): void {
+ this.expanded = !this.expanded;
+ }
+}
diff --git a/ui/src/scss/sp/_variables.scss b/ui/src/scss/sp/_variables.scss
index d95fc84247..7900e374dd 100644
--- a/ui/src/scss/sp/_variables.scss
+++ b/ui/src/scss/sp/_variables.scss
@@ -100,6 +100,39 @@
--mat-sidenav-container-divider-color: var(--color-bg-3);
--mat-button-toggle-height: 30px;
--mat-table-header-headline-color: var(--mat-sys-on-surface);
+
+ --size-panel-header-compact: calc(var(--space-md) * 3.4);
+ --size-icon-button-compact: calc(var(--space-md) * 2.2);
+ --size-icon-compact: calc(var(--space-md) * 1.4);
+ --size-table-index-column: calc(var(--space-md) * 4);
+ --size-data-preview-collapsed: calc(var(--size-panel-header-compact) +
2px);
+ --size-data-preview-expanded: 16.25rem;
+
+ --padding-table-cell-compact-y: var(--space-xs);
+ --padding-table-cell-compact-x: var(--space-sm);
+ --padding-panel-header-y: var(--space-sm);
+ --padding-panel-header-x: var(--space-md);
+
+ --color-surface-subtle: color-mix(
+ in srgb,
+ var(--color-bg-2) 55%,
+ var(--color-bg-0)
+ );
+ --color-surface-selected-subtle: color-mix(
+ in srgb,
+ var(--color-primary) 10%,
+ var(--color-bg-0)
+ );
+ --color-border-subtle: color-mix(
+ in srgb,
+ var(--color-bg-3) 72%,
+ transparent
+ );
+ --color-panel-header-accent: color-mix(
+ in srgb,
+ var(--color-primary) 5%,
+ var(--color-bg-0)
+ );
}
.dark-mode {
diff --git a/ui/src/scss/sp/forms.scss b/ui/src/scss/sp/forms.scss
index 335228e26c..256224c3f5 100644
--- a/ui/src/scss/sp/forms.scss
+++ b/ui/src/scss/sp/forms.scss
@@ -16,6 +16,8 @@
*
*/
+@use '@angular/material' as mat;
+
mat-form-field.mat-mdc-form-field.form-field-size {
font-size: 12px;
}
@@ -46,54 +48,96 @@ mat-form-field.mat-mdc-form-field.form-field-size-smaller {
}
.form-field-small {
- .mat-mdc-input-element {
- font-size: 11pt;
- }
-
- .mat-mdc-form-field-flex {
- max-height: 30px;
- }
-
- .mat-mdc-text-field-wrapper.mdc-text-field--outlined
- .mat-mdc-form-field-infix {
- padding-top: 0;
- padding-bottom: 0;
- line-height: 30px;
+ .mat-mdc-input-element,
+ .mat-mdc-select-value-text {
font-size: 11pt;
- min-height: 30px;
- }
-
- .mdc-text-field__input.smaller-font-size {
- font-size: 10pt;
}
.mat-mdc-text-field-wrapper {
- max-height: 30px;
background: var(--color-bg-0);
}
- .mat-mdc-form-field {
- max-height: 30px;
- min-height: 0;
- }
-
- .mat-mdc-select-value-text {
- font-size: 11pt;
- }
-
- .mat-mdc-text-field-wrapper
- .mat-mdc-form-field-flex
- .mat-mdc-floating-label {
- top: 14px;
- }
-
- .mat-mdc-text-field-wrapper
- .mat-mdc-form-field-flex
- .mdc-notched-outline--upgraded
- .mdc-floating-label--float-above {
- --mat-mdc-form-field-label-transform: translateY(-21.75px)
- scale(var(--mat-mdc-form-field-floating-label-scale, 0.75));
- transform: var(--mat-mdc-form-field-label-transform);
+ // ---- Single-line controls: compact height ----
+ // Apply compact sizing only when this is NOT a textarea field.
+ &:not(.form-field-small--textarea) {
+ .mat-mdc-form-field-flex,
+ .mat-mdc-text-field-wrapper {
+ max-height: 30px;
+ }
+
+ .mat-mdc-text-field-wrapper.mdc-text-field--outlined
+ .mat-mdc-form-field-infix {
+ padding-top: 0;
+ padding-bottom: 0;
+ line-height: 30px;
+ min-height: 30px;
+ font-size: 11pt;
+ }
+
+ .mat-mdc-form-field {
+ max-height: 30px;
+ min-height: 0;
+ }
+
+ .mat-mdc-text-field-wrapper
+ .mat-mdc-form-field-flex
+ .mat-mdc-floating-label {
+ top: 14px;
+ }
+
+ .mat-mdc-text-field-wrapper
+ .mat-mdc-form-field-flex
+ .mdc-notched-outline--upgraded
+ .mdc-floating-label--float-above {
+ --mat-mdc-form-field-label-transform: translateY(-21.75px)
+ scale(var(--mat-mdc-form-field-floating-label-scale, 0.75));
+ transform: var(--mat-mdc-form-field-label-transform);
+ }
+ }
+
+ &.form-field-small--textarea,
+ .form-field-small--textarea {
+ // reset compact sizing on the textarea form-field host itself
+ &.mat-mdc-form-field {
+ max-height: none;
+ min-height: 0;
+ height: auto;
+ }
+
+ .mat-mdc-form-field-flex,
+ .mat-mdc-text-field-wrapper,
+ .mat-mdc-form-field {
+ max-height: none;
+ height: auto;
+ }
+
+ // let infix wrap content; give a small but reasonable min height
+ .mat-mdc-text-field-wrapper.mdc-text-field--outlined
+ .mat-mdc-form-field-infix {
+ line-height: normal;
+ min-height: 60px; // tweak to taste
+ height: auto;
+ padding-top: 6px;
+ padding-bottom: 6px;
+ }
+
+ // ensure textarea itself can show multiple lines
+ textarea.mat-mdc-input-element {
+ resize: vertical; // optional
+ line-height: 1.3;
+ }
+
+ // label positioning: let MDC handle it (avoid your hard-coded top)
+ .mat-mdc-text-field-wrapper
+ .mat-mdc-form-field-flex
+ .mat-mdc-floating-label {
+ top: unset;
+ }
+ }
+
+ // Optional: your special smaller font class
+ .mdc-text-field__input.smaller-font-size {
+ font-size: 10pt;
}
}