Skip to content

feat: DH-18281 Input Filters UI Component #1165

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
Draft
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
2 changes: 2 additions & 0 deletions plugins/ui/src/deephaven/ui/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from .illustrated_message import illustrated_message
from .image import image
from .inline_alert import inline_alert
from .input_filters import input_filters
from .item import item
from .item_table_source import item_table_source
from .labeled_value import labeled_value
Expand Down Expand Up @@ -131,6 +132,7 @@
"image",
"labeled_value",
"inline_alert",
"input_filters",
"link",
"list_view",
"list_action_group",
Expand Down
35 changes: 35 additions & 0 deletions plugins/ui/src/deephaven/ui/components/input_filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from __future__ import annotations
from typing import Any, Callable

from .types import FilterChangeEventCallable
from ..types import ColumnName
from .basic import component_element
from ..elements import Element
from .._internal.utils import create_props
from deephaven.table import Table


def input_filters(
table: Table,
on_change: FilterChangeEventCallable | None = None,
on_filters: Callable[[list[str]], None] | None = None,
column_names: list[ColumnName] | None = None,
key: str | None = None,
) -> Element:
"""
This will call on_input_filters_changes when the filters change on the client.

Args:
on_change: Called with list of all FilterChangeEvents when the input filters change.
on_filters: Called with list of applicable filter strings when the input filters change.
columns: The list of columns to filter on.
key: A unique identifier used by React to render elements in a list.

Returns:
The rendered button component.
"""

# _, props = create_props(locals())
children, props = create_props(locals())

return component_element("InputFilters", *children, **props)
33 changes: 33 additions & 0 deletions plugins/ui/src/deephaven/ui/components/types/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,37 @@ class SliderChange(TypedDict):
"""


class FilterChangeEvent(TypedDict):
"""
Data for a filter change event.
"""

name: str
"""
The column name
"""

type: str
"""
The column type
"""

value: str
"""
The filter value
"""

timestamp: int
"""
The timestamp of the filter change
"""

exclude_panel_ids: list[str] | None
"""
The list of panel ids to exclude from the filter change
"""


SliderChangeCallable = Callable[[SliderChange], None]

PointerType = Literal["mouse", "touch", "pen", "keyboard", "virtual"]
Expand All @@ -127,3 +158,5 @@ class SliderChange(TypedDict):
FocusEventCallable = Callable[[FocusEvent], None]
KeyboardEventCallable = Callable[[KeyboardEvent], None]
PressEventCallable = Callable[[PressEvent], None]

FilterChangeEventCallable = Callable[[list[FilterChangeEvent]], None]
2 changes: 2 additions & 0 deletions plugins/ui/src/deephaven/ui/hooks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .use_execution_context import use_execution_context
from .use_liveness_scope import use_liveness_scope
from .use_boolean import use_boolean
from .use_input_filters import use_input_filters


__all__ = [
Expand All @@ -33,4 +34,5 @@
"use_execution_context",
"use_liveness_scope",
"use_boolean",
"use_input_filters",
]
20 changes: 20 additions & 0 deletions plugins/ui/src/deephaven/ui/hooks/use_input_filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from __future__ import annotations
from typing import Callable
from . import use_state, use_memo

from deephaven.table import Table


def use_input_filters(table: Table) -> tuple[Table, Callable[[list[str]], None]]:
"""
Hook to add input filters to a table.

Args:
table: The table to add input filters to.

Returns:
A tuple containing the filtered table and a function to set the input filters.
"""
filters, set_filters = use_state([])
filtered_table = use_memo(lambda: table.where(filters), [filters])
return filtered_table, set_filters
2 changes: 2 additions & 0 deletions plugins/ui/src/deephaven/ui/types/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,8 @@ class SliderChange(TypedDict):
SliderChangeCallable = Callable[[SliderChange], None]

ColumnName = str
ColumnType = str
Column = Dict[ColumnName, ColumnType]
RowDataMap = Dict[ColumnName, RowDataValue]
RowPressCallback = Callable[[RowDataMap], None]
CellPressCallback = Callable[[CellData], None]
Expand Down
14 changes: 14 additions & 0 deletions plugins/ui/src/js/src/DashboardContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { createContext, useContext } from 'react';

/**
* Context that holds the ID of the dashboard that we are currently in.
*/
export const DashboardContext = createContext<string>('');

/**
* Gets the panel ID from the nearest panel context.
* @returns The panel ID
*/
export function useDashboardId(): string {
return useContext(DashboardContext);
}
11 changes: 7 additions & 4 deletions plugins/ui/src/js/src/DashboardPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
DASHBOARD_ELEMENT,
WIDGET_ELEMENT,
} from './widget/WidgetUtils';
import { DashboardContext } from './DashboardContext';

const PLUGIN_NAME = '@deephaven/js-plugin-ui.DashboardPlugin';

Expand Down Expand Up @@ -289,10 +290,12 @@ export function DashboardPlugin(
);

return (
<LayoutManagerContext.Provider value={layout}>
<style>{styles}</style>
<PortalPanelManager>{widgetHandlers}</PortalPanelManager>
</LayoutManagerContext.Provider>
<DashboardContext.Provider value={id}>
<LayoutManagerContext.Provider value={layout}>
<style>{styles}</style>
<PortalPanelManager>{widgetHandlers}</PortalPanelManager>
</LayoutManagerContext.Provider>
</DashboardContext.Provider>
);
}

Expand Down
133 changes: 133 additions & 0 deletions plugins/ui/src/js/src/elements/InputFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '@deephaven/redux';
import {
getInputFiltersForDashboard,
InputFilterEvent,
} from '@deephaven/dashboard-core-plugins';
import { FilterChangeEvent } from '@deephaven/dashboard-core-plugins/dist/FilterPlugin';
import { useLayoutManager } from '@deephaven/dashboard';
import type { dh as DhType } from '@deephaven/jsapi-types';
import { IrisGridUtils } from '@deephaven/iris-grid';
import { nanoid } from 'nanoid'; // TODO get rid of this
import { useDashboardId } from '../DashboardContext';

export interface InputFiltersProps {
onChange?: (event: FilterChangeEvent[]) => void;
onFilters?: (filters: string[]) => void;
table: DhType.WidgetExportedObject;
columnNames?: string[];
}

// TODO from UITable, make common
function useThrowError(): [
throwError: (error: unknown) => void,
clearError: () => void,
] {
const [error, setError] = useState<unknown>(null);
const clearError = useCallback(() => {
setError(null);
}, []);
if (error != null) {
// Re-throw the error so that the error boundary can catch it
if (typeof error === 'string') {
throw new Error(error);
}
throw error;
}

return [setError, clearError];
}

function useTableColumns(
exportedTable: DhType.WidgetExportedObject
): DhType.Column[] | undefined {
const [columns, setColumns] = useState<DhType.Column[]>();
const [throwError, clearError] = useThrowError();

// Just load the object on mount
useEffect(() => {
let isCancelled = false;
async function loadColumns() {
try {
const reexportedTable = await exportedTable.reexport();
const table = (await reexportedTable.fetch()) as DhType.Table;
setColumns(table.columns);
if (!isCancelled) {
clearError();
setColumns(table.columns);
}
} catch (e) {
if (!isCancelled) {
// Errors thrown from an async useEffect are not caught
// by the component's error boundary
throwError(e);
}
}
}
loadColumns();
return () => {
isCancelled = true;
};
}, [exportedTable, clearError, throwError]);

return columns;
}

export function InputFilters(props: InputFiltersProps): JSX.Element {
const { onChange, onFilters, table: exportedTable, columnNames } = props;
const dashboardId = useDashboardId();
const { eventHub } = useLayoutManager();
const inputFilters = useSelector((state: RootState) =>
getInputFiltersForDashboard(state, dashboardId)
);

const tableColumns = useTableColumns(exportedTable);
const columnsString = JSON.stringify(columnNames); // TODO workaround for changing columnNames reference
Copy link
Member

Choose a reason for hiding this comment

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

The columnNames shouldn't be changing, that's something I'll need to look into.

const columns = useMemo(
() =>
columnNames
? tableColumns?.filter(column => columnNames.includes(column.name))
: tableColumns,
[tableColumns, columnsString]

Check warning on line 92 in plugins/ui/src/js/src/elements/InputFilters.tsx

View workflow job for this annotation

GitHub Actions / test-js / unit

React Hook useMemo has a missing dependency: 'columnNames'. Either include it or remove the dependency array

Check warning on line 92 in plugins/ui/src/js/src/elements/InputFilters.tsx

View workflow job for this annotation

GitHub Actions / test-js / unit

React Hook useMemo has a missing dependency: 'columnNames'. Either include it or remove the dependency array
);

useEffect(() => {
const id = nanoid(); // TODO use widget id
Copy link
Member

Choose a reason for hiding this comment

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

So the widgetId is used in DashboardWidgetHandler, but isn't passed down. That could be passed down in a context - it would probably make sense to just pass it down to WidgetHandler and then WidgetHandler adds it to the WidgetStatus (which can then be accessed with useWidgetStatus).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, the issue wasn't getting widget id. Originally, I was trying to get a ref to the panel. Then I started a refactor to have the event use panelId and you suggested widgetId:

https://deephaven.atlassian.net/browse/DH-19267

eventHub.emit(InputFilterEvent.COLUMNS_CHANGED, id, columns);
return () => {
eventHub.emit(InputFilterEvent.COLUMNS_CHANGED, id, []);
};
}, [columns, eventHub]);

// If onChange is provided, call it with all of the input filters
useEffect(() => {
if (onChange) {
onChange(inputFilters);
}
}, [inputFilters, onChange]);

// If onFilters is provided, call it with the filters for the columns
useEffect(() => {
if (onFilters && columns != null) {
const inputFiltersForColumns = IrisGridUtils.getInputFiltersForColumns(
columns,
// They may have picked a column, but not actually entered a value yet. In that case, don't need to update.
inputFilters.filter(
({ value, excludePanelIds }) =>
value != null &&
(excludePanelIds == null ||
(dashboardId != null && !excludePanelIds.includes(dashboardId)))
)
);
const filters = inputFiltersForColumns.map(
filter => `${filter.name}=\`${filter.value}\``
); // TODO use some util to do this?
onFilters(filters);
}
}, [inputFilters, onFilters, columns, dashboardId]);

return <div>{JSON.stringify(inputFilters)}</div>;
}

export default InputFilters;
1 change: 1 addition & 0 deletions plugins/ui/src/js/src/elements/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export * from './HTMLElementView';
export * from './IconElementView';
export * from './IllustratedMessage';
export * from './Image';
export * from './InputFilters';
export * from './LabeledValue';
export * from './InlineAlert';
export * from './ListView';
Expand Down
1 change: 1 addition & 0 deletions plugins/ui/src/js/src/elements/model/ElementConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export const ELEMENT_NAME = {
illustratedMessage: uiComponentName('IllustratedMessage'),
image: uiComponentName('Image'),
inlineAlert: uiComponentName('InlineAlert'),
inputFilters: uiComponentName('InputFilters'),
item: uiComponentName('Item'),
labeledValue: uiComponentName('LabeledValue'),
listActionGroup: uiComponentName('ListActionGroup'),
Expand Down
2 changes: 2 additions & 0 deletions plugins/ui/src/js/src/widget/WidgetUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
Image,
LabeledValue,
InlineAlert,
InputFilters,
ListView,
LogicButton,
Markdown,
Expand Down Expand Up @@ -168,6 +169,7 @@ export const elementComponentMap: Record<ValueOf<ElementName>, unknown> = {
[ELEMENT_NAME.illustratedMessage]: IllustratedMessage,
[ELEMENT_NAME.image]: Image,
[ELEMENT_NAME.inlineAlert]: InlineAlert,
[ELEMENT_NAME.inputFilters]: InputFilters,
[ELEMENT_NAME.item]: Item,
[ELEMENT_NAME.labeledValue]: LabeledValue,
[ELEMENT_NAME.link]: Link,
Expand Down
Loading