Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ui/src/assets/components/data_grid.scss
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,8 @@
text-overflow: ellipsis;
}
}

.pf-distinct-values-menu {
max-height: 300px;
overflow-y: auto;
}
45 changes: 45 additions & 0 deletions ui/src/components/widgets/data_grid/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@ export interface DataGridDataSource {
* Returns a promise that resolves to all filtered and sorted rows.
*/
exportData(): Promise<readonly RowDef[]>;

/**
* Get distinct values for a column with current filters applied.
* Returns up to maxValues distinct values, or 'too_many' if there are more.
* Returns 'error' if the query fails.
* Results are cached internally by the data source and invalidated when
* filters change.
*/
getDistinctValues?(
column: string,
maxValues: number,
): Promise<SqlValue[] | 'too_many' | 'error'>;
}

/**
Expand Down Expand Up @@ -157,3 +169,36 @@ export function areAggregatesEqual(
): boolean {
return a.col === b.col && a.func === b.func;
}

/**
* Comparator function for SQL values.
* Handles strings (locale comparison), numbers, bigints, Uint8Arrays, and nulls.
*/
export function compareSqlValues(a: SqlValue, b: SqlValue): number {
// Handle null values - nulls come first
if (a === null && b === null) return 0;
if (a === null) return -1;
if (b === null) return 1;

if (typeof a === 'number' && typeof b === 'number') {
return a - b;
}

if (typeof a === 'bigint' && typeof b === 'bigint') {
return Number(a - b);
}

if (typeof a === 'string' && typeof b === 'string') {
return a.localeCompare(b);
}

if (a instanceof Uint8Array && b instanceof Uint8Array) {
// Compare by length for Uint8Arrays
return a.length - b.length;
}

// Default comparison using string conversion
const strA = String(a);
const strB = String(b);
return strA.localeCompare(strB);
}
208 changes: 207 additions & 1 deletion ui/src/components/widgets/data_grid/data_grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,15 @@ import {Box} from '../../../widgets/box';
import {Button} from '../../../widgets/button';
import {Chip} from '../../../widgets/chip';
import {LinearProgress} from '../../../widgets/linear_progress';
import {MenuDivider, MenuItem, MenuTitle} from '../../../widgets/menu';
import {
MenuDivider,
MenuItem,
MenuTitle,
PopupMenu,
} from '../../../widgets/menu';
import {PopupPosition} from '../../../widgets/popup';
import {TextInput} from '../../../widgets/text_input';
import {Spinner} from '../../../widgets/spinner';
import {Stack, StackAuto} from '../../../widgets/stack';
import {
renderSortMenuItems,
Expand Down Expand Up @@ -307,6 +315,182 @@ export interface DataGridApi {
): Promise<string>;
}

// Configuration constants
const MAX_DISTINCT_VALUES = 100;
const MAX_DISPLAY_STRING_LENGTH = 50;
const SEARCH_BOX_THRESHOLD = 10;
const DISTINCT_VALUES_MENU_MAX_HEIGHT = 300;

type DistinctValuesResult = SqlValue[] | 'too_many' | 'error';

interface DistinctValuesMenuState {
values?: DistinctValuesResult;
loading: boolean;
searchText: string;
}

interface DistinctValuesFilterMenuAttrs {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is subjective but this component feels a bit too interested in its parent state. Maybe it would be better off as a render() function inside the main DataGrid component so you're having to do a lot of prop drilling.

readonly column: ColumnDefinition;
readonly dataSource: DataGridDataSource;
readonly onFilterAdd: OnFilterAdd;
readonly operator: '=' | '!=';
readonly label: string;
}

class DistinctValuesFilterMenu
implements m.ClassComponent<DistinctValuesFilterMenuAttrs>
{
private state: DistinctValuesMenuState = {
values: undefined,
loading: false,
searchText: '',
};

async oninit({attrs}: m.Vnode<DistinctValuesFilterMenuAttrs>) {
const {dataSource, column} = attrs;

if (!dataSource.getDistinctValues) {
this.state.values = 'error';
return;
}

this.state.loading = true;

try {
const values = await dataSource.getDistinctValues(
column.name,
MAX_DISTINCT_VALUES,
);
this.state.values = values;
} finally {
this.state.loading = false;
}
}

view({attrs}: m.Vnode<DistinctValuesFilterMenuAttrs>): m.Children {
const {column, onFilterAdd, operator, label} = attrs;

return m(
PopupMenu,
{
position: PopupPosition.RightStart,
trigger: m(MenuItem, {
label,
rightIcon: 'chevron_right',
closePopupOnClick: false,
}),
showArrow: false,
createNewGroup: false,
edgeOffset: 5,
},
this.renderMenuContent(column, onFilterAdd, operator),
);
}

private renderMenuContent(
column: ColumnDefinition,
onFilterAdd: OnFilterAdd,
operator: '=' | '!=',
): m.Children {
const {values, loading, searchText} = this.state;

// Show loading state
if (values === undefined || loading) {
return m(Spinner);
}

// Handle error state
if (values === 'error') {
return m(MenuItem, {
label: 'Failed to fetch values',
disabled: true,
});
}

// Handle too many values
if (values === 'too_many') {
return m(MenuItem, {
label: `Too many distinct values (>${MAX_DISTINCT_VALUES})`,
disabled: true,
});
}

// Handle empty values
if (values.length === 0) {
return m(MenuItem, {
label: 'No non-null values',
disabled: true,
});
}

// Filter values based on search text
const filteredValues = values.filter((value) => {
const valueStr = formatDisplayValue(value);
return valueStr.toLowerCase().includes(searchText.toLowerCase());
});

const showSearchBox = values.length > SEARCH_BOX_THRESHOLD;

return m(
'.pf-distinct-values-menu',
{
style: {
maxHeight: `${DISTINCT_VALUES_MENU_MAX_HEIGHT}px`,
overflowY: 'auto',
},
},
[
// Search box for filtering values
showSearchBox &&
m(TextInput, {
autofocus: true,
placeholder: 'Search values...',
value: searchText,
oninput: (e: InputEvent) => {
const target = e.target as HTMLInputElement;
this.state.searchText = target.value;
},
}),
showSearchBox && m(MenuDivider),
// Value list
filteredValues.length > 0
? filteredValues.map((value) =>
m(MenuItem, {
label: formatDisplayValue(value),
onclick: () => {
onFilterAdd({
column: column.name,
op: operator,
value,
});
},
}),
)
: m(MenuItem, {
label: 'No matching values',
disabled: true,
}),
],
);
}
}

function formatDisplayValue(value: SqlValue): string {
if (value === null) {
return 'NULL';
}
if (typeof value === 'string') {
if (value.length > MAX_DISPLAY_STRING_LENGTH) {
return value.substring(0, MAX_DISPLAY_STRING_LENGTH - 3) + '...';
}
return value;
}
if (typeof value === 'bigint') {
return value.toString();
}
return String(value);
}

export class DataGrid implements m.ClassComponent<DataGridAttrs> {
// Internal state
private sorting: Sorting = {direction: 'UNSORTED'};
Expand Down Expand Up @@ -498,6 +682,28 @@ export class DataGrid implements m.ClassComponent<DataGridAttrs> {
},
}),
);

// Add distinct values menu if datasource supports it
if (dataSource.getDistinctValues) {
menuItems.push(
m(DistinctValuesFilterMenu, {
column,
dataSource,
onFilterAdd,
operator: '=',
label: 'Filter equal to...',
}),
);
menuItems.push(
m(DistinctValuesFilterMenu, {
column,
dataSource,
onFilterAdd,
operator: '!=',
label: 'Filter not equal to...',
}),
);
}
}

if (Boolean(column.headerMenuItems)) {
Expand Down
Loading