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);
     }
 }

Reply via email to