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 8e1f89965a feat: Full integration of filter feature for table widget,
excel like filte… (#4240)
8e1f89965a is described below
commit 8e1f89965a02ea0593e378b86596e850e42da466
Author: Nguyen-Bang Vu <[email protected]>
AuthorDate: Tue Mar 17 00:07:09 2026 +0700
feat: Full integration of filter feature for table widget, excel like
filte… (#4240)
---
ui/cypress/support/utils/chart/ChartBtns.ts | 16 +
.../tests/chart/dynamicColumnFilter.smoke.spec.ts | 55 ++
.../charts/table/table-widget.component.html | 371 ++++++++-
.../charts/table/table-widget.component.scss | 331 +++++++-
.../charts/table/table-widget.component.ts | 917 +++++++++++++++++----
5 files changed, 1495 insertions(+), 195 deletions(-)
diff --git a/ui/cypress/support/utils/chart/ChartBtns.ts
b/ui/cypress/support/utils/chart/ChartBtns.ts
index 68f0e3edb6..8c0c5bb2cc 100644
--- a/ui/cypress/support/utils/chart/ChartBtns.ts
+++ b/ui/cypress/support/utils/chart/ChartBtns.ts
@@ -229,4 +229,20 @@ export class ChartBtns {
public static matOptionByText(text: string | RegExp) {
return cy.get('mat-option').contains(text);
}
+
+ public static columnFilterTrigger(column: string) {
+ return cy.get(`[data-cy="column-filter-trigger-${column}"]`);
+ }
+
+ public static columnAdvancedFilterExpandBtn() {
+ return cy.get('[data-cy="column-advanced-filter-expand-btn"]');
+ }
+
+ public static columnAdvancedFilterOptionByText(text: string) {
+ return cy.get('.advanced-filter-options').contains(text);
+ }
+
+ public static columnAdvancedFilterApplyBtn() {
+ return cy.dataCy('column-advanced-filter-apply-btn');
+ }
}
diff --git a/ui/cypress/tests/chart/dynamicColumnFilter.smoke.spec.ts
b/ui/cypress/tests/chart/dynamicColumnFilter.smoke.spec.ts
new file mode 100644
index 0000000000..da8aff7015
--- /dev/null
+++ b/ui/cypress/tests/chart/dynamicColumnFilter.smoke.spec.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 { ChartUtils } from '../../support/utils/chart/ChartUtils';
+import { ChartBtns } from '../../support/utils/chart/ChartBtns';
+import { ChartWidgetTableUtils } from
'../../support/utils/chart/ChartWidgetTableUtils';
+
+describe('Dynamic Column Filters in Table Widget', () => {
+ beforeEach('Setup Test', () => {
+ cy.initStreamPipesTest();
+ ChartUtils.loadDataIntoDataLake('datalake/sample.csv');
+ });
+
+ it('Applies a Top 10 number filter on a numeric column', () => {
+ ChartUtils.addDataViewAndTableWidget(
+ 'DynamicColumnFilterWidget',
+ ChartUtils.ADAPTER_NAME,
+ );
+
+ ChartWidgetTableUtils.checkAmountOfRows(10);
+
+ // Open the column filter dropdown for the numeric column
+ ChartBtns.columnFilterTrigger('randomnumber').click({ force: true });
+
+ // Expand the number filters panel
+ ChartBtns.columnAdvancedFilterExpandBtn().click({ force: true });
+
+ // Select the 'Top 10' filter option
+ ChartBtns.columnAdvancedFilterOptionByText('Top 10').click();
+
+ // Apply the filter
+ ChartBtns.columnAdvancedFilterApplyBtn().click();
+
+ // Top 10 filter should return 10 or fewer rows
+ ChartWidgetTableUtils.chartTableRowTimestamp().should(
+ 'have.length.at.most',
+ 10,
+ );
+ });
+});
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 0ac5c257ed..ceb7795847 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,6 +22,7 @@
[ngStyle]="{
background: dataExplorerWidget.baseAppearanceConfig.backgroundColor,
color: dataExplorerWidget.baseAppearanceConfig.textColor,
+ position: 'relative',
}"
>
@if (showNoDataInDateRange) {
@@ -56,22 +57,50 @@
isHighlightedColumn(column),
}"
>
- <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>
+ <div class="th-inner">
+ <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>
+ <button
+ type="button"
+ class="column-filter-trigger"
+ [attr.data-cy]="
+ 'column-filter-trigger-' +
+ column
+ "
+ [ngClass]="{
+ 'filter-active':
+ hasActiveFilter(column),
+ 'filter-open':
+ isColumnFilterOpen(column),
+ }"
+ (click)="
+ toggleColumnFilter(
+ column,
+ $event
+ )
+ "
+ >
+ <mat-icon
+ class="column-filter-icon"
+ >
+ filter_list
+ </mat-icon>
+ </button>
+ </div>
</th>
}
</tr>
@@ -154,4 +183,314 @@
</mat-paginator>
</div>
}
+ @if (openFilterColumn) {
+ <div
+ class="column-filter-dropdown"
+ [ngStyle]="dropdownStyle"
+ (click)="onFilterDropdownClick($event)"
+ >
+ @if (hasActiveFilter(openFilterColumn)) {
+ <button
+ type="button"
+ class="clear-filter-btn"
+ (click)="clearColumnFilter(openFilterColumn)"
+ >
+ <mat-icon class="clear-filter-icon"
+ >filter_list_off</mat-icon
+ >
+ Clear Filter
+ </button>
+ }
+ <button
+ type="button"
+ class="advanced-filter-btn"
+ data-cy="column-advanced-filter-expand-btn"
+ (click)="toggleAdvancedPanel(openFilterColumn, $event)"
+ >
+ {{ getAdvancedFilterLabel(openFilterColumn) }}
+ <mat-icon class="advanced-arrow-icon">{{
+ showAdvancedPanel === openFilterColumn
+ ? 'expand_more'
+ : 'chevron_right'
+ }}</mat-icon>
+ </button>
+ @if (showAdvancedPanel === openFilterColumn) {
+ <div class="advanced-filter-panel">
+ <div class="advanced-filter-options">
+ @for (
+ opt of getAdvancedFilterOptions(openFilterColumn);
+ track opt
+ ) {
+ <button
+ type="button"
+ class="advanced-option-btn"
+ [ngClass]="{
+ 'advanced-option-selected':
+ selectedAdvancedType === opt,
+ }"
+ (click)="selectAdvancedType(opt)"
+ >
+ {{ opt }}
+ </button>
+ }
+ </div>
+ @if (selectedAdvancedType) {
+ <div class="advanced-filter-inputs">
+ @if (needsInput(selectedAdvancedType)) {
+ <div
+ [class.ts-mask-wrapper]="
+ openFilterColumn === 'time'
+ "
+ >
+ @if (openFilterColumn === 'time') {
+ <div
+ class="ts-overlay"
+ aria-hidden="true"
+ >
+ <span>{{
+ getTimestampTyped(
+ advancedInputValue
+ )
+ }}</span
+ ><span class="ts-faded">{{
+ getTimestampTemplate(
+ advancedInputValue
+ )
+ }}</span>
+ </div>
+ }
+ <input
+ type="text"
+ [class]="
+ openFilterColumn === 'time'
+ ? 'advanced-input ts-input'
+ : 'advanced-input'
+ "
+ title="Filter value"
+ [placeholder]="
+ openFilterColumn === 'time'
+ ? ''
+ : 'Value...'
+ "
+ [ngModel]="advancedInputValue"
+ (input)="
+ openFilterColumn === 'time'
+ ? onTimestampInput(
+ 'value',
+ $event
+ )
+ : null
+ "
+ (ngModelChange)="
+ openFilterColumn !== 'time'
+ ? (advancedInputValue = $event)
+ : null
+ "
+ (mousedown)="
+ openFilterColumn === 'time'
+ ? repositionToEnd($event)
+ : null
+ "
+ (click)="$event.stopPropagation()"
+ (keydown)="
+ onAdvancedInputKeydown(
+ $event,
+ openFilterColumn,
+ 1
+ )
+ "
+ />
+ </div>
+ }
+ @if (needsSecondInput(selectedAdvancedType)) {
+ <span class="advanced-and-label">and</span>
+ <div
+ [class.ts-mask-wrapper]="
+ openFilterColumn === 'time'
+ "
+ >
+ @if (openFilterColumn === 'time') {
+ <div
+ class="ts-overlay"
+ aria-hidden="true"
+ >
+ <span>{{
+ getTimestampTyped(
+ advancedInputValue2
+ )
+ }}</span
+ ><span class="ts-faded">{{
+ getTimestampTemplate(
+ advancedInputValue2
+ )
+ }}</span>
+ </div>
+ }
+ <input
+ type="text"
+ [class]="
+ openFilterColumn === 'time'
+ ? 'advanced-input ts-input'
+ : 'advanced-input'
+ "
+ title="Filter value"
+ [placeholder]="
+ openFilterColumn === 'time'
+ ? ''
+ : 'Value...'
+ "
+ [ngModel]="advancedInputValue2"
+ (input)="
+ openFilterColumn === 'time'
+ ? onTimestampInput(
+ 'value2',
+ $event
+ )
+ : null
+ "
+ (ngModelChange)="
+ openFilterColumn !== 'time'
+ ? (advancedInputValue2 =
$event)
+ : null
+ "
+ (mousedown)="
+ openFilterColumn === 'time'
+ ? repositionToEnd($event)
+ : null
+ "
+ (click)="$event.stopPropagation()"
+ (keydown)="
+ onAdvancedInputKeydown(
+ $event,
+ openFilterColumn,
+ 2
+ )
+ "
+ />
+ </div>
+ }
+ <div class="advanced-filter-actions">
+ <button
+ type="button"
+ class="advanced-apply-btn"
+ data-cy="column-advanced-filter-apply-btn"
+ (click)="
+ applyAdvancedFilter(openFilterColumn)
+ "
+ >
+ OK
+ </button>
+ <button
+ type="button"
+ class="advanced-cancel-btn"
+ (click)="cancelAdvancedPanel()"
+ >
+ Cancel
+ </button>
+ @if (hasAdvancedFilter(openFilterColumn)) {
+ <button
+ type="button"
+ class="advanced-clear-btn"
+ (click)="
+ clearAdvancedFilter(
+ openFilterColumn
+ )
+ "
+ >
+ Clear
+ </button>
+ }
+ </div>
+ </div>
+ }
+ </div>
+ }
+ <div [class.ts-mask-wrapper]="openFilterColumn === 'time'">
+ @if (openFilterColumn === 'time') {
+ <div class="ts-overlay" aria-hidden="true">
+ <span>{{
+ getTimestampTyped(
+ columnSearchTerms[openFilterColumn] || ''
+ )
+ }}</span
+ ><span class="ts-faded">{{
+ getTimestampTemplate(
+ columnSearchTerms[openFilterColumn] || ''
+ )
+ }}</span>
+ </div>
+ }
+ <input
+ type="text"
+ class="column-filter-search"
+ [class.ts-input]="openFilterColumn === 'time'"
+ title="Search"
+ [placeholder]="
+ openFilterColumn === 'time' ? '' : 'Search...'
+ "
+ [ngModel]="columnSearchTerms[openFilterColumn]"
+ (ngModelChange)="
+ openFilterColumn !== 'time'
+ ? onColumnSearchChange(openFilterColumn, $event)
+ : null
+ "
+ (input)="
+ openFilterColumn === 'time'
+ ? onTimestampSearchInput($event)
+ : null
+ "
+ (mousedown)="
+ openFilterColumn === 'time'
+ ? repositionToEnd($event)
+ : null
+ "
+ (click)="$event.stopPropagation()"
+ (keydown)="onSearchKeydown($event)"
+ />
+ </div>
+ <div class="column-filter-controls">
+ <mat-checkbox
+ [checked]="areAllValuesSelected(openFilterColumn)"
+ [indeterminate]="
+ !areAllValuesSelected(openFilterColumn) &&
+ columnFilters[openFilterColumn]?.size > 0
+ "
+ (change)="toggleAllValues(openFilterColumn)"
+ >
+ (Select All)
+ </mat-checkbox>
+ @if (hasSearchOrAdvanced(openFilterColumn)) {
+ <mat-checkbox
+
[checked]="areDisplayedValuesSelected(openFilterColumn)"
+ (change)="toggleDisplayedValues(openFilterColumn)"
+ >
+ (Select All Displayed)
+ </mat-checkbox>
+ }
+ </div>
+ <div
+ class="filter-list-wrapper"
+ [class.no-overflow-hint]="filterListScrollEnd"
+ >
+ <div
+ class="column-filter-list"
+ (scroll)="onFilterListScroll($event)"
+ >
+ @for (
+ value of getFilteredUniqueValues(openFilterColumn);
+ track value
+ ) {
+ <mat-checkbox
+ class="filter-checkbox"
+ [title]="value"
+ [checked]="isValueChecked(openFilterColumn, value)"
+ (change)="toggleValue(openFilterColumn, value)"
+ >
+ {{ value }}
+ </mat-checkbox>
+ }
+ </div>
+ </div>
+ </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 3d9e2ee4f0..f26d8b33bb 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
@@ -47,11 +47,10 @@
}
.analytics-table th {
- position: sticky;
- top: 0;
- z-index: 2;
+ position: relative;
padding: 0;
background: var(--color-bg-0);
+ overflow: visible;
}
.analytics-table .time-column {
@@ -123,3 +122,329 @@
text-align: center;
color: var(--color-secondary-text);
}
+
+.th-inner {
+ display: flex;
+ align-items: center;
+ width: 100%;
+}
+
+.th-inner .sort-trigger {
+ flex: 1;
+ min-width: 0;
+}
+
+.column-filter-trigger {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ background: transparent;
+ color: var(--color-secondary-text);
+ cursor: pointer;
+ padding: 2px;
+ border-radius: 2px;
+}
+
+.column-filter-trigger:hover {
+ color: var(--color-primary);
+}
+
+.column-filter-trigger.filter-active {
+ color: #d32f2f;
+}
+
+.column-filter-icon {
+ width: 1rem;
+ height: 1rem;
+ font-size: 1rem;
+ line-height: 1rem;
+}
+
+.column-filter-dropdown {
+ min-width: 220px;
+ max-width: 300px;
+ background: var(--color-bg-0);
+ border: 1px solid var(--color-border-subtle, var(--color-bg-3));
+ border-radius: 4px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ padding: var(--space-xs);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ max-height: 70vh;
+ overflow-y: auto;
+}
+
+.clear-filter-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ padding: 6px 8px;
+ border: 0;
+ background: transparent;
+ color: var(--color-secondary-text);
+ font-size: var(--font-size-xs);
+ cursor: pointer;
+ border-radius: 3px;
+ border-bottom: 1px solid var(--color-border-subtle, var(--color-bg-3));
+}
+
+.clear-filter-btn:hover {
+ background: var(--color-bg-2, #f0f0f0);
+ color: #d32f2f;
+}
+
+.clear-filter-icon {
+ width: 1rem;
+ height: 1rem;
+ font-size: 1rem;
+ line-height: 1rem;
+}
+
+.advanced-filter-btn {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ padding: 6px 8px;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font-size: var(--font-size-xs);
+ cursor: pointer;
+ border-radius: 3px;
+ border-bottom: 1px solid var(--color-border-subtle, var(--color-bg-3));
+}
+
+.advanced-filter-btn:hover {
+ background: var(--color-bg-2, #f0f0f0);
+}
+
+.advanced-arrow-icon {
+ width: 1rem;
+ height: 1rem;
+ font-size: 1rem;
+ line-height: 1rem;
+}
+
+.column-filter-search {
+ width: 100%;
+ padding: 6px 8px;
+ border: 1px solid var(--color-border-subtle, var(--color-bg-3));
+ border-radius: 3px;
+ font-size: var(--font-size-xs);
+ background: var(--color-bg-1, var(--color-bg-0));
+ color: inherit;
+ outline: none;
+ box-sizing: border-box;
+}
+
+.column-filter-search:focus {
+ border-color: var(--color-primary);
+}
+
+.column-filter-controls {
+ padding: 2px 0;
+ border-bottom: 1px solid var(--color-border-subtle, var(--color-bg-3));
+}
+
+.column-filter-list {
+ max-height: 200px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+}
+
+.column-filter-list mat-checkbox,
+.column-filter-controls mat-checkbox {
+ display: flex;
+ align-items: center;
+ min-height: 28px;
+ padding: 2px 0;
+ font-size: var(--font-size-xs);
+}
+
+.column-filter-list mat-checkbox ::ng-deep .mdc-label,
+.column-filter-controls mat-checkbox ::ng-deep .mdc-label {
+ white-space: nowrap;
+ padding-right: 16px;
+}
+
+.advanced-filter-panel {
+ border-top: 1px solid var(--color-border-subtle, var(--color-bg-3));
+ padding-top: var(--space-xs);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+}
+
+.advanced-filter-options {
+ display: flex;
+ flex-direction: column;
+ max-height: 160px;
+ overflow-y: auto;
+}
+
+.advanced-option-btn {
+ width: 100%;
+ padding: 6px 8px;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font-size: var(--font-size-xs);
+ text-align: left;
+ cursor: pointer;
+ border-radius: 3px;
+}
+
+.advanced-option-btn:hover {
+ background: var(--color-bg-2, #f0f0f0);
+}
+
+.advanced-option-btn.advanced-option-selected {
+ background: color-mix(in srgb, var(--color-primary) 15%,
var(--color-bg-0));
+ font-weight: var(--font-weight-semibold, 600);
+}
+
+.advanced-filter-inputs {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding-top: var(--space-xs);
+ border-top: 1px solid var(--color-border-subtle, var(--color-bg-3));
+}
+
+.ts-mask-wrapper {
+ position: relative;
+ width: 100%;
+}
+
+.ts-overlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ padding: 6px 8px;
+ font-size: var(--font-size-xs);
+ font-family: inherit;
+ pointer-events: none;
+ user-select: none;
+ white-space: nowrap;
+ overflow: hidden;
+ z-index: 1;
+}
+
+.ts-faded {
+ color: var(--color-secondary-text, #aaa);
+ opacity: 0.6;
+}
+
+.ts-input {
+ color: transparent !important;
+ caret-color: var(--color-text, currentColor) !important;
+}
+
+.advanced-input {
+ width: 100%;
+ padding: 6px 8px;
+ border: 1px solid var(--color-border-subtle, var(--color-bg-3));
+ border-radius: 3px;
+ font-size: var(--font-size-xs);
+ background: var(--color-bg-1, var(--color-bg-0));
+ color: inherit;
+ outline: none;
+ box-sizing: border-box;
+}
+
+.advanced-input:focus {
+ border-color: var(--color-primary);
+}
+
+.advanced-and-label {
+ font-size: var(--font-size-xs);
+ color: var(--color-secondary-text);
+ text-align: center;
+}
+
+.advanced-filter-actions {
+ display: flex;
+ gap: 6px;
+ justify-content: flex-end;
+}
+
+.advanced-apply-btn,
+.advanced-cancel-btn,
+.advanced-clear-btn {
+ padding: 4px 12px;
+ border: 1px solid var(--color-border-subtle, var(--color-bg-3));
+ border-radius: 3px;
+ font-size: var(--font-size-xs);
+ cursor: pointer;
+ background: transparent;
+ color: inherit;
+}
+
+.advanced-apply-btn:hover {
+ background: var(--color-primary);
+ color: #fff;
+ border-color: var(--color-primary);
+}
+
+.advanced-cancel-btn:hover {
+ background: var(--color-secondary-text, #666);
+ color: #fff;
+ border-color: var(--color-secondary-text, #666);
+}
+
+.advanced-clear-btn:hover {
+ background: #d32f2f;
+ color: #fff;
+ border-color: #d32f2f;
+}
+
+.filter-list-wrapper {
+ position: relative;
+
+ &::after {
+ content: '';
+ position: absolute;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ width: 24px;
+ background: linear-gradient(
+ to right,
+ transparent,
+ var(--color-bg-0, #fff)
+ );
+ pointer-events: none;
+ z-index: 1;
+ transition: opacity 0.12s ease;
+ }
+
+ &.no-overflow-hint::after {
+ opacity: 0;
+ }
+}
+
+.filter-checkbox {
+ min-width: 100%;
+ width: max-content;
+ box-sizing: border-box;
+
+ ::ng-deep .mdc-form-field {
+ min-width: max-content;
+ align-items: center;
+ }
+
+ ::ng-deep .mdc-label {
+ white-space: nowrap;
+ overflow: visible;
+ flex: none;
+ margin-right: 0;
+ padding-right: 20px;
+ }
+}
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 46ad31dfac..850b9531e1 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
@@ -17,9 +17,11 @@
*/
import { DatePipe, NgClass, NgStyle } from '@angular/common';
-import { Component, ViewChild } from '@angular/core';
+import { Component, ElementRef, HostListener, ViewChild } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
+import { MatCheckbox } from '@angular/material/checkbox';
+import { FormsModule } from '@angular/forms';
import { BaseDataExplorerWidgetDirective } from
'../base/base-data-explorer-widget.directive';
import { TableWidgetModel } from './model/table-widget.model';
import {
@@ -43,6 +45,43 @@ interface NumericColumnStats {
max: number;
}
+interface AdvancedFilter {
+ type: string;
+ value: string;
+ value2?: string;
+}
+
+const BLANKS_LABEL = '(Blanks)';
+const DROPDOWN_MAX_WIDTH = 300;
+const DROPDOWN_EDGE_PADDING = 3;
+
+const NO_INPUT_TYPES = new Set(['Top 10', 'Above average', 'Below average']);
+
+const NUMERIC_FILTER_OPTIONS = [
+ 'Equals',
+ 'Does not equal',
+ 'Greater than',
+ 'Greater than or equal to',
+ 'Less than',
+ 'Less than or equal to',
+ 'Between',
+ 'Top 10',
+ 'Above average',
+ 'Below average',
+];
+
+const TEXT_FILTER_OPTIONS = [
+ 'Equals',
+ 'Does not equal',
+ 'Begins with',
+ 'Ends with',
+ 'Contains',
+ 'Does not contain',
+];
+
+const TIMESTAMP_FILTER_OPTIONS = ['Before', 'After', 'Between'];
+const TIMESTAMP_MASK = 'yyyy-mm-dd HH:mm:ss.SSS';
+
@Component({
selector: 'sp-data-explorer-table-widget',
templateUrl: './table-widget.component.html',
@@ -56,6 +95,8 @@ interface NumericColumnStats {
TooMuchDataComponent,
MatPaginator,
MatIcon,
+ MatCheckbox,
+ FormsModule,
DatePipe,
TranslatePipe,
],
@@ -77,38 +118,72 @@ export class TableWidgetComponent extends
BaseDataExplorerWidgetDirective<TableW
sortColumn = '';
sortDirection: SortDirection = '';
- private numericColumnStats: Record<string, NumericColumnStats> = {};
+ columnFilters: Record<string, Set<string>> = {};
+ columnSearchTerms: Record<string, string> = {};
+ openFilterColumn: string | null = null;
+ advancedFilters: Record<string, AdvancedFilter> = {};
+ showAdvancedPanel: string | null = null;
+ advancedInputValue = '';
+ advancedInputValue2 = '';
+ selectedAdvancedType = '';
+ dropdownStyle: Record<string, string> = {};
+ filterListScrollEnd = true;
+
+ constructor(private elRef: ElementRef) {
+ super();
+ }
- regenerateColumnNames(): void {
- this.groupByColumnNames = this.makeGroupByColumns(
- this.dataExplorerWidget.visualizationConfig.selectedColumns ?? [],
+ @HostListener('document:click', ['$event'])
+ onDocumentClick(event: MouseEvent): void {
+ if (!this.openFilterColumn) return;
+ const target = event.target as HTMLElement;
+ const dropdown = this.elRef.nativeElement.querySelector(
+ '.column-filter-dropdown',
+ );
+ const trigger = this.elRef.nativeElement.querySelector(
+ '.column-filter-trigger.filter-open',
);
+ if (
+ dropdown &&
+ !dropdown.contains(target) &&
+ (!trigger || !trigger.contains(target))
+ ) {
+ this.closeFilter();
+ }
+ }
+
+ closeFilter(): void {
+ this.openFilterColumn = null;
+ this.showAdvancedPanel = null;
+ }
+ private numericColumnStats: Record<string, NumericColumnStats> = {};
+
+ regenerateColumnNames(): void {
+ const selected =
+ this.dataExplorerWidget.visualizationConfig.selectedColumns ?? [];
+ this.groupByColumnNames = this.makeGroupByColumns(selected);
this.columnNames = Array.from(
new Set([
'time',
- ...(
- this.dataExplorerWidget.visualizationConfig
- .selectedColumns ?? []
- ).map(column => column.fullDbName),
+ ...selected.map(c => c.fullDbName),
...this.groupByColumnNames,
]),
);
}
makeGroupByColumns(selectedColumns: DataExplorerField[]): string[] {
- return this.dataExplorerWidget.dataConfig.sourceConfigs.flatMap(sc => {
- return (sc.queryConfig.groupBy ?? [])
- .filter(groupBy => groupBy.selected)
+ return this.dataExplorerWidget.dataConfig.sourceConfigs.flatMap(sc =>
+ (sc.queryConfig.groupBy ?? [])
+ .filter(g => g.selected)
.filter(
- groupBy =>
- selectedColumns.find(
- column =>
- column.runtimeName === groupBy.runtimeName,
- ) === undefined,
+ g =>
+ !selectedColumns.find(
+ c => c.runtimeName === g.runtimeName,
+ ),
)
- .map(groupBy => groupBy.runtimeName);
- });
+ .map(g => g.runtimeName),
+ );
}
transformData(spQueryResult: SpQueryResult, rowOffset: number): TableRow[]
{
@@ -139,11 +214,7 @@ export class TableWidgetComponent extends
BaseDataExplorerWidgetDirective<TableW
{ __rowIndex: rowIndex } as TableRow,
);
- if (tags) {
- Object.keys(tags).forEach(key => {
- row[key] = tags[key];
- });
- }
+ if (tags) Object.assign(row, tags);
return row;
}
@@ -161,14 +232,459 @@ export class TableWidgetComponent extends
BaseDataExplorerWidgetDirective<TableW
this.sortDirection = 'asc';
} else if (this.sortDirection === 'asc') {
this.sortDirection = 'desc';
- } else if (this.sortDirection === 'desc') {
+ } else {
this.sortDirection = '';
this.sortColumn = '';
+ }
+ this.applyTableState(false);
+ }
+
+ toggleColumnFilter(column: string, event: MouseEvent): void {
+ event.stopPropagation();
+ if (this.openFilterColumn === column) {
+ this.closeFilter();
+ return;
+ }
+ this.openFilterColumn = column;
+ this.showAdvancedPanel = null;
+ this.columnSearchTerms[column] =
+ column === 'time'
+ ? (this.columnSearchTerms[column] ?? TIMESTAMP_MASK)
+ : (this.columnSearchTerms[column] ?? '');
+ const rect = (
+ event.currentTarget as HTMLElement
+ ).getBoundingClientRect();
+ const root = this.elRef.nativeElement.getBoundingClientRect();
+ const leftRel = rect.left - root.left;
+ const finalLeft =
+ root.right - rect.left >= DROPDOWN_MAX_WIDTH +
DROPDOWN_EDGE_PADDING
+ ? leftRel
+ : Math.max(
+ 0,
+ leftRel -
+ (DROPDOWN_MAX_WIDTH - rect.width) -
+ DROPDOWN_EDGE_PADDING,
+ );
+ this.dropdownStyle = {
+ 'position': 'absolute',
+ 'top': `${rect.bottom - root.top}px`,
+ 'left': `${finalLeft}px`,
+ 'z-index': '9999',
+ };
+ setTimeout(() => {
+ const list = this.elRef.nativeElement.querySelector(
+ '.column-filter-list',
+ );
+ this.filterListScrollEnd =
+ !list || list.scrollWidth <= list.clientWidth;
+ });
+ }
+
+ isColumnFilterOpen = (column: string): boolean =>
+ this.openFilterColumn === column;
+
+ hasActiveFilter(column: string): boolean {
+ if (this.advancedFilters[column]) return true;
+ const f = this.columnFilters[column];
+ return !!f && f.size < this.getAllUniqueValues(column).length;
+ }
+
+ private uniqueValuesCache: Record<string, string[]> = {};
+
+ getAllUniqueValues = (column: string): string[] =>
+ (this.uniqueValuesCache[column] ??= this.extractUniqueValues(
+ this.rows,
+ column,
+ ));
+
+ getVisibleUniqueValues(column: string): string[] {
+ let baseRows = this.getRowsFilteredByOtherColumns(column);
+ const adv = this.advancedFilters[column];
+ if (adv)
+ baseRows = this.applyAdvancedFilterToRows(baseRows, column, adv);
+ return this.extractUniqueValues(baseRows, column);
+ }
+
+ getLivePreviewValues(column: string): string[] {
+ let baseRows = this.getRowsFilteredByOtherColumns(column);
+ const adv = this.advancedFilters[column];
+ if (adv)
+ baseRows = this.applyAdvancedFilterToRows(baseRows, column, adv);
+ if (
+ this.showAdvancedPanel === column &&
+ this.selectedAdvancedType &&
+ (this.advancedInputValue ||
+ !this.needsInput(this.selectedAdvancedType))
+ ) {
+ baseRows = this.applyAdvancedFilterToRows(baseRows, column, {
+ type: this.selectedAdvancedType,
+ value: this.advancedInputValue,
+ value2: this.advancedInputValue2,
+ });
+ }
+ return this.extractUniqueValues(baseRows, column);
+ }
+
+ getFilteredUniqueValues(column: string): string[] {
+ const raw = this.columnSearchTerms[column] ?? '';
+ const term = (column === 'time' ? this.getTimestampTyped(raw) : raw)
+ .trim()
+ .toLowerCase();
+ const values =
+ this.showAdvancedPanel === column
+ ? this.getLivePreviewValues(column)
+ : this.getVisibleUniqueValues(column);
+ return term
+ ? values.filter(v => v.toLowerCase().includes(term))
+ : values;
+ }
+
+ isValueChecked = (column: string, value: string): boolean =>
+ this.columnFilters[column]?.has(value) ?? true;
+
+ toggleValue(column: string, value: string): void {
+ this.ensureColumnFilter(column);
+ const f = this.columnFilters[column];
+ if (f.has(value)) {
+ f.delete(value);
} else {
- this.sortDirection = 'asc';
+ f.add(value);
}
+ this.applyTableState(true);
+ }
- this.applyTableState(false);
+ areAllValuesSelected(column: string): boolean {
+ const f = this.columnFilters[column];
+ if (!f) return true;
+ return this.getAllUniqueValues(column).every(v => f.has(v));
+ }
+
+ toggleAllValues(column: string): void {
+ this.ensureColumnFilter(column);
+ const f = this.columnFilters[column];
+ const all = this.getAllUniqueValues(column);
+ const allSelected = all.every(v => f.has(v));
+ all.forEach(v => (allSelected ? f.delete(v) : f.add(v)));
+ this.applyTableState(true);
+ }
+
+ hasSearchOrAdvanced = (column: string): boolean =>
+ !!(column === 'time'
+ ? this.getTimestampTyped(this.columnSearchTerms[column] ?? '')
+ : this.columnSearchTerms[column]?.trim()) ||
+ this.showAdvancedPanel === column;
+
+ areDisplayedValuesSelected(column: string): boolean {
+ const f = this.columnFilters[column];
+ if (!f) return true;
+ return this.getFilteredUniqueValues(column).every(v => f.has(v));
+ }
+
+ toggleDisplayedValues(column: string): void {
+ this.ensureColumnFilter(column);
+ const f = this.columnFilters[column];
+ const displayed = this.getFilteredUniqueValues(column);
+ const allDisplayedSelected = displayed.every(v => f.has(v));
+ displayed.forEach(v => (allDisplayedSelected ? f.delete(v) :
f.add(v)));
+ this.applyTableState(true);
+ }
+
+ onColumnSearchChange(column: string, term: string): void {
+ this.columnSearchTerms[column] = term;
+ }
+
+ clearColumnFilter(column: string): void {
+ delete this.columnFilters[column];
+ delete this.advancedFilters[column];
+ this.columnSearchTerms[column] = '';
+ this.showAdvancedPanel = null;
+ this.applyTableState(true);
+ }
+
+ getTimestampTyped(val: string): string {
+ let last = -1;
+ for (let i = 0; i < val.length; i++) {
+ if (val[i] !== TIMESTAMP_MASK[i]) last = i;
+ }
+ return val.slice(0, last + 1);
+ }
+
+ getTimestampTemplate(val: string): string {
+ let last = -1;
+ for (let i = 0; i < val.length; i++) {
+ if (val[i] !== TIMESTAMP_MASK[i]) last = i;
+ }
+ return val.slice(last + 1);
+ }
+
+ repositionToEnd(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ setTimeout(() =>
+ input.setSelectionRange(input.value.length, input.value.length),
+ );
+ }
+
+ onTimestampSearchInput(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ const digits = input.value.replace(/\D/g, '').slice(0, 17);
+ const formatted = this.formatTimestampMask(digits);
+ input.value = formatted;
+ this.columnSearchTerms[this.openFilterColumn!] = formatted;
+ }
+
+ onSearchKeydown(event: KeyboardEvent): void {
+ if (
+ this.openFilterColumn === 'time' &&
+ ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)
+ ) {
+ event.preventDefault();
+ return;
+ }
+ if (
+ this.openFilterColumn === 'time' &&
+ (event.key === 'Backspace' || event.key === 'Delete')
+ ) {
+ event.preventDefault();
+ const digits = (this.columnSearchTerms[this.openFilterColumn] ??
'')
+ .replace(/[^0-9]/g, '')
+ .slice(0, -1);
+ const formatted = this.formatTimestampMask(digits);
+ this.columnSearchTerms[this.openFilterColumn] = formatted;
+ (event.target as HTMLInputElement).value = formatted;
+ return;
+ }
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ this.closeFilter();
+ }
+ }
+
+ onAdvancedInputKeydown(
+ event: KeyboardEvent,
+ column: string,
+ inputIndex: number,
+ ): void {
+ if (
+ column === 'time' &&
+ ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)
+ ) {
+ event.preventDefault();
+ return;
+ }
+ if (
+ column === 'time' &&
+ (event.key === 'Backspace' || event.key === 'Delete')
+ ) {
+ event.preventDefault();
+ const current =
+ inputIndex === 1
+ ? this.advancedInputValue
+ : this.advancedInputValue2;
+ const digits = current.replace(/[^0-9]/g, '').slice(0, -1);
+ const formatted = this.formatTimestampMask(digits);
+ if (inputIndex === 1) this.advancedInputValue = formatted;
+ else this.advancedInputValue2 = formatted;
+ (event.target as HTMLInputElement).value = formatted;
+ return;
+ }
+ if (event.key !== 'Enter') return;
+ event.preventDefault();
+ if (
+ inputIndex === 1 &&
+ this.needsSecondInput(this.selectedAdvancedType)
+ ) {
+ this.elRef.nativeElement
+ .querySelectorAll('.advanced-input')?.[1]
+ ?.focus();
+ } else {
+ this.applyAdvancedFilter(column);
+ }
+ }
+
+ onFilterListScroll(event: Event): void {
+ const el = event.target as HTMLElement;
+ this.filterListScrollEnd =
+ el.scrollLeft + el.clientWidth >= el.scrollWidth - 2;
+ }
+
+ onFilterDropdownClick = (event: MouseEvent): void =>
+ event.stopPropagation();
+
+ onTimestampInput(field: 'value' | 'value2', event: Event): void {
+ const input = event.target as HTMLInputElement;
+ const digits = input.value.replace(/\D/g, '').slice(0, 17);
+ const formatted = this.formatTimestampMask(digits);
+ input.value = formatted;
+ if (field === 'value') this.advancedInputValue = formatted;
+ else this.advancedInputValue2 = formatted;
+ }
+
+ private formatTimestampMask(digits: string): string {
+ const positions = [
+ 0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 21, 22,
+ ];
+ const result = TIMESTAMP_MASK.split('');
+ positions.forEach((pos, i) => {
+ if (i < digits.length) result[pos] = digits[i];
+ });
+ return result.join('');
+ }
+
+ private parseTimestampInput(s: string): number | undefined {
+ const d = s.replace(/\D/g, '');
+ if (d.length < 14) return undefined;
+ const ms = d.length >= 17 ? d.slice(14, 17) : '000';
+ const dt = new Date(
+ `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}T${d.slice(8,
10)}:${d.slice(10, 12)}:${d.slice(12, 14)}.${ms}`,
+ );
+ return isNaN(dt.getTime()) ? undefined : dt.getTime();
+ }
+
+ getAdvancedFilterOptions = (column: string): string[] =>
+ column === 'time'
+ ? TIMESTAMP_FILTER_OPTIONS
+ : this.isNumericColumn(column)
+ ? NUMERIC_FILTER_OPTIONS
+ : TEXT_FILTER_OPTIONS;
+
+ getAdvancedFilterLabel = (column: string): string =>
+ column === 'time'
+ ? 'Timestamp Filters'
+ : this.isNumericColumn(column)
+ ? 'Number Filters'
+ : 'Text Filters';
+
+ toggleAdvancedPanel(column: string, event: MouseEvent): void {
+ event.stopPropagation();
+ if (this.showAdvancedPanel === column) {
+ this.showAdvancedPanel = null;
+ return;
+ }
+ this.showAdvancedPanel = column;
+ const existing = this.advancedFilters[column];
+ this.selectedAdvancedType = existing?.type ?? '';
+ this.advancedInputValue =
+ existing?.value ?? (column === 'time' ? TIMESTAMP_MASK : '');
+ this.advancedInputValue2 =
+ existing?.value2 ?? (column === 'time' ? TIMESTAMP_MASK : '');
+ }
+
+ selectAdvancedType(type: string): void {
+ this.selectedAdvancedType = type;
+ this.advancedInputValue =
+ this.openFilterColumn === 'time' ? TIMESTAMP_MASK : '';
+ this.advancedInputValue2 =
+ this.openFilterColumn === 'time' ? TIMESTAMP_MASK : '';
+ }
+
+ needsInput = (type: string): boolean => !NO_INPUT_TYPES.has(type);
+ needsSecondInput = (type: string): boolean => type === 'Between';
+
+ applyAdvancedFilter(column: string): void {
+ const isTimeCol = column === 'time';
+ const validInput = (v: string): boolean =>
+ isTimeCol ? this.parseTimestampInput(v) !== undefined : !!v.trim();
+ if (
+ this.needsInput(this.selectedAdvancedType) &&
+ !validInput(this.advancedInputValue)
+ ) {
+ this.showAdvancedPanel = null;
+ return;
+ }
+ if (
+ this.needsSecondInput(this.selectedAdvancedType) &&
+ !validInput(this.advancedInputValue2)
+ ) {
+ this.showAdvancedPanel = null;
+ return;
+ }
+ this.advancedFilters[column] = {
+ type: this.selectedAdvancedType,
+ value: this.advancedInputValue,
+ value2: this.advancedInputValue2,
+ };
+ this.showAdvancedPanel = null;
+ this.applyTableState(true);
+ }
+
+ cancelAdvancedPanel(): void {
+ this.showAdvancedPanel = null;
+ }
+
+ clearAdvancedFilter(column: string): void {
+ delete this.advancedFilters[column];
+ this.showAdvancedPanel = null;
+ this.selectedAdvancedType = '';
+ this.advancedInputValue = '';
+ this.advancedInputValue2 = '';
+ this.applyTableState(true);
+ }
+
+ hasAdvancedFilter = (column: string): boolean =>
+ !!this.advancedFilters[column];
+
+ private getRowsFilteredByOtherColumns(excludeColumn: string): TableRow[] {
+ let result = [...this.rows];
+ const search = (
+ this.dataExplorerWidget.visualizationConfig.searchValue ?? ''
+ )
+ .trim()
+ .toLowerCase();
+ if (search) {
+ result = result.filter(row =>
+ this.columnNames.some(c =>
+ String(this.formatCellValue(c, row[c]))
+ .toLowerCase()
+ .includes(search),
+ ),
+ );
+ }
+ for (const col of this.columnNames) {
+ if (col === excludeColumn) continue;
+ const f = this.columnFilters[col];
+ if (f && f.size < this.getAllUniqueValues(col).length) {
+ result = result.filter(row =>
+ f.has(this.formatForFilter(row, col)),
+ );
+ }
+ const adv = this.advancedFilters[col];
+ if (adv)
+ result = result.filter(row =>
+ this.passesAdvancedFilter(row, col, adv),
+ );
+ }
+ return result;
+ }
+
+ private ensureColumnFilter(column: string): void {
+ this.columnFilters[column] ??= new
Set(this.getAllUniqueValues(column));
+ }
+
+ private initColumnFilters(): void {
+ this.columnFilters = {};
+ this.columnSearchTerms = {};
+ this.advancedFilters = {};
+ this.uniqueValuesCache = {};
+ this.openFilterColumn = null;
+ this.showAdvancedPanel = null;
+ }
+
+ private extractUniqueValues(rows: TableRow[], column: string): string[] {
+ const seen = new Set(rows.map(r => this.formatForFilter(r, column)));
+ return Array.from(seen).sort((a, b) =>
+ a === BLANKS_LABEL
+ ? 1
+ : b === BLANKS_LABEL
+ ? -1
+ : a.localeCompare(b, undefined, { sensitivity: 'base' }),
+ );
+ }
+
+ private formatForFilter(row: TableRow, column: string): string {
+ const val = row[column];
+ return val === null || val === undefined || val === ''
+ ? BLANKS_LABEL
+ : String(this.formatCellValue(column, val));
}
sortIcon(column: string): string {
@@ -203,6 +719,7 @@ export class TableWidgetComponent extends
BaseDataExplorerWidgetDirective<TableW
return transformedRows;
});
+ this.initColumnFilters();
this.applyTableState(true);
this.setShownComponents(false, true, false, false);
}
@@ -227,35 +744,30 @@ export class TableWidgetComponent extends
BaseDataExplorerWidgetDirective<TableW
this.dataExplorerWidget.visualizationConfig.highlightedColumns = (
this.dataExplorerWidget.visualizationConfig.highlightedColumns ??
[]
- ).filter(
- field =>
- !removedFields.find(
- removedField =>
- removedField.fullDbName === field.fullDbName,
- ),
- );
+ ).filter(f => !removedFields.find(r => r.fullDbName === f.fullDbName));
this.refreshView();
}
- isNumericColumn(column: string): boolean {
- return !!this.fieldProvider.numericFields.find(
- field => field.fullDbName === column,
- );
- }
+ isNumericColumn = (column: string): boolean =>
+ !!this.fieldProvider.numericFields.find(f => f.fullDbName === column);
- isHighlightedColumn(column: string): boolean {
- return !!(
+ isHighlightedColumn = (column: string): boolean =>
+ !!(
this.dataExplorerWidget.visualizationConfig.highlightedColumns ??
[]
- ).find(field => field.fullDbName === column);
- }
+ ).find(f => f.fullDbName === column);
- headerLabel(column: string): string {
- return column === 'time' ? 'Time' : column;
- }
+ headerLabel = (column: string): string =>
+ column === 'time' ? 'Time' : column;
formatCellValue(column: string, value: unknown): unknown {
if (column === 'time') {
+ const d = new Date(value as string | number);
+ if (!isNaN(d.getTime())) {
+ const p = (n: number, l = 2): string =>
+ String(n).padStart(l, '0');
+ return `${d.getFullYear()}-${p(d.getMonth() +
1)}-${p(d.getDate())}
${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(),
3)}`;
+ }
return value;
}
@@ -271,19 +783,13 @@ export class TableWidgetComponent extends
BaseDataExplorerWidgetDirective<TableW
}
getCellStyle(row: TableRow, column: string): Record<string, string> {
- if (!this.isHighlightedColumn(column)) {
- return {};
- }
-
+ if (!this.isHighlightedColumn(column)) return {};
const highlightValue = this.getHighlightStrength(row[column], column);
- if (highlightValue === undefined) {
- return {};
- }
+ if (highlightValue === undefined) return {};
const intensity = Math.round(8 + highlightValue * 26);
- const highlightColor = this.getHighlightColor(column);
-
+ const color = this.getHighlightColor(column);
return {
- background: `color-mix(in srgb, ${highlightColor} ${intensity}%,
var(--color-bg-0))`,
+ background: `color-mix(in srgb, ${color} ${intensity}%,
var(--color-bg-0))`,
};
}
@@ -325,40 +831,151 @@ export class TableWidgetComponent extends
BaseDataExplorerWidgetDirective<TableW
}
private filterRows(rows: TableRow[]): TableRow[] {
- const searchTerm = (
+ let result = [...rows];
+ const search = (
this.dataExplorerWidget.visualizationConfig.searchValue ?? ''
)
.trim()
.toLowerCase();
-
- if (!searchTerm) {
- return [...rows];
+ if (search) {
+ result = result.filter(row =>
+ this.columnNames.some(c =>
+ String(this.formatCellValue(c, row[c]))
+ .toLowerCase()
+ .includes(search),
+ ),
+ );
}
-
- return rows.filter(row =>
- this.columnNames.some(column =>
- String(this.formatCellValue(column, row[column]))
- .toLowerCase()
- .includes(searchTerm),
- ),
- );
+ for (const col of this.columnNames) {
+ const f = this.columnFilters[col];
+ if (f && f.size < this.getAllUniqueValues(col).length) {
+ result = result.filter(row =>
+ f.has(this.formatForFilter(row, col)),
+ );
+ }
+ const adv = this.advancedFilters[col];
+ if (adv) result = this.applyAdvancedFilterToRows(result, col, adv);
+ }
+ return result;
}
- private sortRows(rows: TableRow[]): TableRow[] {
- if (!this.sortColumn || this.sortDirection === '') {
- return [...rows];
+ private applyAdvancedFilterToRows(
+ rows: TableRow[],
+ column: string,
+ adv: AdvancedFilter,
+ ): TableRow[] {
+ if (adv.type === 'Top 10') {
+ const top = rows
+ .map(r => ({ r, n: this.toNumber(r[column]) }))
+ .filter(
+ (e): e is { r: TableRow; n: number } => e.n !== undefined,
+ )
+ .sort((a, b) => b.n - a.n)
+ .slice(0, 10);
+ const set = new Set(top.map(e => e.r));
+ return rows.filter(r => set.has(r));
+ }
+ if (adv.type === 'Above average' || adv.type === 'Below average') {
+ const nums = rows
+ .map(r => this.toNumber(r[column]))
+ .filter((n): n is number => n !== undefined);
+ if (!nums.length) return rows;
+ const avg = nums.reduce((s, n) => s + n, 0) / nums.length;
+ return rows.filter(r => {
+ const n = this.toNumber(r[column]);
+ return (
+ n !== undefined &&
+ (adv.type === 'Above average' ? n > avg : n < avg)
+ );
+ });
}
+ return rows.filter(r => this.passesAdvancedFilter(r, column, adv));
+ }
- const directionMultiplier = this.sortDirection === 'asc' ? 1 : -1;
- return [...rows].sort((rowA, rowB) => {
- const comparison = this.compareValues(
- rowA[this.sortColumn],
- rowB[this.sortColumn],
- this.sortColumn,
- );
+ private passesAdvancedFilter(
+ row: TableRow,
+ column: string,
+ adv: AdvancedFilter,
+ ): boolean {
+ const raw = row[column];
+ if (column === 'time') {
+ const n = new Date(raw as string | number).getTime();
+ const t1 = this.parseTimestampInput(adv.value);
+ const t2 = this.parseTimestampInput(adv.value2 ?? '');
+ switch (adv.type) {
+ case 'Before':
+ return t1 !== undefined && n <= t1;
+ case 'After':
+ return t1 !== undefined && n >= t1;
+ case 'Between':
+ return (
+ t1 !== undefined &&
+ t2 !== undefined &&
+ n >= Math.min(t1, t2) &&
+ n <= Math.max(t1, t2)
+ );
+ default:
+ return true;
+ }
+ }
+ if (this.isNumericColumn(column)) {
+ const n = this.toNumber(raw);
+ const t1 = Number(adv.value);
+ const t2 = Number(adv.value2);
+ switch (adv.type) {
+ case 'Equals':
+ return n === t1;
+ case 'Does not equal':
+ return n !== t1;
+ case 'Greater than':
+ return n !== undefined && n > t1;
+ case 'Greater than or equal to':
+ return n !== undefined && n >= t1;
+ case 'Less than':
+ return n !== undefined && n < t1;
+ case 'Less than or equal to':
+ return n !== undefined && n <= t1;
+ case 'Between':
+ return (
+ n !== undefined &&
+ n >= Math.min(t1, t2) &&
+ n <= Math.max(t1, t2)
+ );
+ default:
+ return true;
+ }
+ }
+ const s = String(this.formatCellValue(column, raw)).toLowerCase();
+ const t = adv.value.toLowerCase();
+ switch (adv.type) {
+ case 'Equals':
+ return s === t;
+ case 'Does not equal':
+ return s !== t;
+ case 'Begins with':
+ return s.startsWith(t);
+ case 'Ends with':
+ return s.endsWith(t);
+ case 'Contains':
+ return s.includes(t);
+ case 'Does not contain':
+ return !s.includes(t);
+ default:
+ return true;
+ }
+ }
- return comparison * directionMultiplier;
- });
+ private sortRows(rows: TableRow[]): TableRow[] {
+ if (!this.sortColumn || this.sortDirection === '') return [...rows];
+ const dir = this.sortDirection === 'asc' ? 1 : -1;
+ return [...rows].sort(
+ (a, b) =>
+ this.compareValues(
+ a[this.sortColumn],
+ b[this.sortColumn],
+ this.sortColumn,
+ ) * dir,
+ );
}
private compareValues(
@@ -366,90 +983,60 @@ export class TableWidgetComponent extends
BaseDataExplorerWidgetDirective<TableW
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;
+ const a = this.normalizeSortValue(valueA, column);
+ const b = this.normalizeSortValue(valueB, column);
+ if (a === b) return 0;
+ if (a === null) return 1;
+ if (b === null) return -1;
+ return a > b ? 1 : -1;
}
private normalizeSortValue(
value: unknown,
column: string,
): number | string | null {
- if (value === null || value === undefined || value === '') {
- return 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 t = new Date(value as string | number).getTime();
+ return Number.isNaN(t) ? null : t;
}
-
- const numericValue = this.toNumber(value);
- if (numericValue !== undefined) {
- return numericValue;
- }
-
- if (typeof value === 'boolean') {
- return value ? 1 : 0;
- }
-
+ const n = this.toNumber(value);
+ if (n !== undefined) return n;
+ if (typeof value === 'boolean') return value ? 1 : 0;
return String(value).toLowerCase();
}
private computeNumericStats(
rows: TableRow[],
): Record<string, NumericColumnStats> {
- return (
+ const columns = (
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>,
- );
+ ).map(f => f.fullDbName);
+ return columns.reduce(
+ (stats, col) => {
+ const values = rows
+ .map(r => this.toNumber(r[col]))
+ .filter((v): v is number => v !== undefined);
+ if (values.length > 0) {
+ stats[col] = {
+ 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)';
- }
-
+ ).find(f => f.fullDbName === column);
return (
this.dataExplorerWidget.visualizationConfig
.highlightedColumnColors?.[
- `${field.fullDbName}:${field.sourceIndex}`
+ `${field?.fullDbName}:${field?.sourceIndex}`
] ?? 'var(--color-primary)'
);
}
@@ -458,50 +1045,31 @@ export class TableWidgetComponent extends
BaseDataExplorerWidgetDirective<TableW
value: unknown,
column: string,
): number | undefined {
- const booleanValue = this.toBoolean(value);
- if (booleanValue !== undefined) {
- return booleanValue ? 1 : 0;
- }
-
- const numericValue = this.toNumber(value);
+ const bool = this.toBoolean(value);
+ if (bool !== undefined) return bool ? 1 : 0;
+ const n = this.toNumber(value);
const stats = this.numericColumnStats[column];
- if (numericValue === undefined || !stats) {
- return undefined;
- }
-
+ if (n === undefined || !stats) return undefined;
return stats.max === stats.min
? 0.5
- : (numericValue - stats.min) / (stats.max - stats.min);
+ : (n - stats.min) / (stats.max - stats.min);
}
private toNumber(value: unknown): number | undefined {
- if (typeof value === 'number' && Number.isFinite(value)) {
- return value;
- }
-
+ 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;
+ const n = Number(value);
+ return Number.isFinite(n) ? n : undefined;
}
-
return undefined;
}
private toBoolean(value: unknown): boolean | undefined {
- if (typeof value === 'boolean') {
- return value;
- }
-
+ 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;
- }
+ const v = value.trim().toLowerCase();
+ return v === 'true' ? true : v === 'false' ? false : undefined;
}
-
return undefined;
}
@@ -514,10 +1082,7 @@ export class TableWidgetComponent extends
BaseDataExplorerWidgetDirective<TableW
}
private updatePagedRows(): void {
- const startIndex = this.pageIndex * this.pageSize;
- this.pagedRows = this.filteredRows.slice(
- startIndex,
- startIndex + this.pageSize,
- );
+ const start = this.pageIndex * this.pageSize;
+ this.pagedRows = this.filteredRows.slice(start, start + this.pageSize);
}
}