From 8bc279921ab376256cc110cf6b9a42a458fc44fb Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Mon, 24 Jun 2024 00:42:25 -0500 Subject: [PATCH] Add support for dynamic context menu items --- plugins/ui/docs/README.md | 26 +++++- .../ui/src/deephaven/ui/components/table.py | 14 ++-- plugins/ui/src/deephaven/ui/types/types.py | 11 ++- plugins/ui/src/js/src/elements/UITable.tsx | 2 +- .../{ => utils}/UITableContextMenuHandler.ts | 4 +- .../js/src/elements/utils/UITableUtils.tsx | 84 ++++++++++++++----- 6 files changed, 109 insertions(+), 32 deletions(-) rename plugins/ui/src/js/src/elements/{ => utils}/UITableContextMenuHandler.ts (93%) diff --git a/plugins/ui/docs/README.md b/plugins/ui/docs/README.md index 6d49702bb..b7ef67fa9 100644 --- a/plugins/ui/docs/README.md +++ b/plugins/ui/docs/README.md @@ -1291,7 +1291,7 @@ te = ui.table( ### ui.table Context Menu -Items can be added to the bottom of the `ui.table` context menu (right-click menu) by using the `context_menu` or `context_header_menu` props. The `context_menu` prop adds items for each cell, while the `context_header_menu` prop adds items for the header cells. +Items can be added to the bottom of the `ui.table` context menu (right-click menu) by using the `context_menu` or `context_header_menu` props. The `context_menu` prop adds items to the cell context menu, while the `context_header_menu` prop adds items to the column header context menu. Menu items must have a `title` and either an `action` or `actions` prop. They may have an `icon` which is the name of the icon that will be passed to `ui.icon`. @@ -1299,6 +1299,8 @@ The `action` prop is a callback that is called when the item is clicked and rece The `actions` prop is an array of menu items that will be displayed in a sub-menu. Sub-menus can contain other sub-menus for a nested menu. +Menu items can be dynamically created by instead passing a function as the context item. The function will be called with the data of the cell that was clicked when the menu was opened, and must return the menu items or None. + ```py from deephaven import ui import deephaven.plot.express as dx @@ -1335,6 +1337,28 @@ t = ui.table( ) ``` +The following example shows creating context menu items dynamically so that the item only appears on the `sym` column. Note the function is still passed as part of a list. If multiple functions are passed, each will be called and any items they return will be added to the context menu. + +```py +from deephaven import ui +import deephaven.plot.express as dx + +def create_context_menu(data): + if data["column_name"] == "sym": + return [ + { + "title": f"Print {data['value']}", + "action": lambda d: print(d['value']) + }, + ] + return None + +t = ui.table( + dx.data.stocks(), + context_menu=[create_context_menu] +) +``` + ## Re-using components In a previous example, we created a text_filter_table component. We can re-use that component, and display two tables with an input filter side-by-side: diff --git a/plugins/ui/src/deephaven/ui/components/table.py b/plugins/ui/src/deephaven/ui/components/table.py index dda33f49a..6d227b644 100644 --- a/plugins/ui/src/deephaven/ui/components/table.py +++ b/plugins/ui/src/deephaven/ui/components/table.py @@ -8,7 +8,7 @@ ColumnPressCallback, QuickFilterExpression, RowPressCallback, - ContextMenuItem, + ResolvableContextMenuItem, ) @@ -24,8 +24,8 @@ def table( quick_filters: dict[ColumnName, QuickFilterExpression] | None = None, show_quick_filters: bool = False, show_search: bool = False, - context_menu: list[ContextMenuItem] | None = None, - context_header_menu: list[ContextMenuItem] | None = None, + context_menu: list[ResolvableContextMenuItem] | None = None, + context_header_menu: list[ResolvableContextMenuItem] | None = None, ) -> UITable: """ Customization to how a table is displayed, how it behaves, and listen to UI events. @@ -51,8 +51,12 @@ def table( quick_filters: The quick filters to apply to the table. Dictionary of column name to filter value. show_quick_filters: Whether to show the quick filter bar by default. show_search: Whether to show the search bar by default. - context_menu: The context menu items to show when a cell is right clicked. May contain action items or submenu items. - context_header_menu: The context menu items to show when a column header is right clicked. May contain action items or submenu items. + context_menu: The context menu items to show when a cell is right clicked. + May contain action items or submenu items or None. + May also be a function that receives the cell data and returns the context menu items. + context_header_menu: The context menu items to show when a column header is right clicked. + May contain action items or submenu items. + May also be a function that receives the column header data and returns the context menu items or None. """ props = locals() del props["table"] diff --git a/plugins/ui/src/deephaven/ui/types/types.py b/plugins/ui/src/deephaven/ui/types/types.py index 8a3c8dae9..6d9c52abb 100644 --- a/plugins/ui/src/deephaven/ui/types/types.py +++ b/plugins/ui/src/deephaven/ui/types/types.py @@ -136,7 +136,7 @@ class ContextMenuSubmenuItem(ContextMenuItemBase): An item that contains a submenu for a context menu. """ - actions: List[Union[ContextMenuActionItem, "ContextMenuSubmenuItem"]] + actions: List["ResolvableContextMenuItem"] """ A list of actions that will form the submenu for the item. """ @@ -148,6 +148,15 @@ class ContextMenuSubmenuItem(ContextMenuItemBase): May contain an action item or a submenu item. """ +ResolvableContextMenuItem = Union[ + ContextMenuItem, + Callable[[ContextMenuActionParams], Union[List[ContextMenuItem], None]], +] +""" +A context menu item or a function that returns a list of context menu items or None. +This can be used to dynamically generate context menu items based on the cell the menu is opened on. +""" + class SliderChange(TypedDict): """ diff --git a/plugins/ui/src/js/src/elements/UITable.tsx b/plugins/ui/src/js/src/elements/UITable.tsx index 2fc992cf0..89f0e397a 100644 --- a/plugins/ui/src/js/src/elements/UITable.tsx +++ b/plugins/ui/src/js/src/elements/UITable.tsx @@ -17,7 +17,7 @@ import { getSettings, RootState } from '@deephaven/redux'; import { GridMouseHandler } from '@deephaven/grid'; import { UITableProps, wrapContextActions } from './utils/UITableUtils'; import UITableMouseHandler from './utils/UITableMouseHandler'; -import UITableContextMenuHandler from './UITableContextMenuHandler'; +import UITableContextMenuHandler from './utils/UITableContextMenuHandler'; const log = Log.module('@deephaven/js-plugin-ui/UITable'); diff --git a/plugins/ui/src/js/src/elements/UITableContextMenuHandler.ts b/plugins/ui/src/js/src/elements/utils/UITableContextMenuHandler.ts similarity index 93% rename from plugins/ui/src/js/src/elements/UITableContextMenuHandler.ts rename to plugins/ui/src/js/src/elements/utils/UITableContextMenuHandler.ts index 16de07ba7..8eadc84bc 100644 --- a/plugins/ui/src/js/src/elements/UITableContextMenuHandler.ts +++ b/plugins/ui/src/js/src/elements/utils/UITableContextMenuHandler.ts @@ -20,14 +20,14 @@ class UITableContextMenuHandler extends IrisGridContextMenuHandler { private contextMenuItems: UITableProps['contextMenu']; - private contextColumnHeaderItems: UITableProps['contextColumnMenu']; + private contextColumnHeaderItems: UITableProps['contextHeaderMenu']; constructor( dh: typeof DhType, irisGrid: IrisGridType, model: IrisGridModel, contextMenuItems: UITableProps['contextMenu'], - contextColumnHeaderItems: UITableProps['contextColumnMenu'] + contextColumnHeaderItems: UITableProps['contextHeaderMenu'] ) { super(irisGrid, dh); this.irisGrid = irisGrid; diff --git a/plugins/ui/src/js/src/elements/utils/UITableUtils.tsx b/plugins/ui/src/js/src/elements/utils/UITableUtils.tsx index fb0b6ff11..8a3572b64 100644 --- a/plugins/ui/src/js/src/elements/utils/UITableUtils.tsx +++ b/plugins/ui/src/js/src/elements/utils/UITableUtils.tsx @@ -5,7 +5,10 @@ import type { IrisGridContextMenuData, RowIndex, } from '@deephaven/iris-grid'; -import type { ContextAction } from '@deephaven/components'; +import type { + ContextAction, + ResolvableContextAction, +} from '@deephaven/components'; import { ELEMENT_KEY, ElementNode, isElementNode } from './ElementUtils'; import { getIcon } from './IconElementUtils'; import { @@ -30,17 +33,24 @@ export type ColumnIndex = number; export type RowDataMap = Record; +export interface UIContextItemParams { + value: unknown; + text_value: string | null; + column_name: string; + is_column_header: boolean; + is_row_header: boolean; +} + export type UIContextItem = Omit & { - action?: (params: { - value: unknown; - text_value: string | null; - column_name: string; - is_column_header: boolean; - is_row_header: boolean; - }) => void; + action?: (params: UIContextItemParams) => void; - actions?: UIContextItem[]; + actions?: ResolvableUIContextItem[]; }; + +type ResolvableUIContextItem = + | UIContextItem + | ((params: UIContextItemParams) => Promise); + export interface UITableProps { table: dh.WidgetExportedObject; onCellPress?: (cellIndex: [ColumnIndex, RowIndex], data: CellData) => void; @@ -57,8 +67,8 @@ export interface UITableProps { sorts?: DehydratedSort[]; showSearch: boolean; showQuickFilters: boolean; - contextMenu?: UIContextItem[]; - contextHeaderMenu?: UIContextItem[]; + contextMenu?: ResolvableUIContextItem[]; + contextHeaderMenu?: ResolvableUIContextItem[]; [key: string]: unknown; } @@ -73,17 +83,11 @@ export function isUITable(obj: unknown): obj is UITableNode { ); } -/** - * Wraps context item actions from the server so they are called with the cell info. - * @param items The context items from the server - * @param data The context menu data to use for the context items - * @returns Context items with the UI actions wrapped so they receive the cell info - */ -export function wrapContextActions( - items: UIContextItem[], +function wrapUIContextItem( + item: UIContextItem, data: Omit -): ContextAction[] { - return items.map(item => ({ +): ContextAction { + return { group: 999999, // Default to the end of the menu ...item, icon: item.icon @@ -101,5 +105,41 @@ export function wrapContextActions( } : undefined, actions: item.actions ? wrapContextActions(item.actions, data) : undefined, - })); + } satisfies ContextAction; +} + +function wrapUIContextItems( + items: UIContextItem[], + data: Omit +): ContextAction[] { + return items.map(item => wrapUIContextItem(item, data)); +} + +/** + * Wraps context item actions from the server so they are called with the cell info. + * @param items The context items from the server + * @param data The context menu data to use for the context items + * @returns Context items with the UI actions wrapped so they receive the cell info + */ +export function wrapContextActions( + items: ResolvableUIContextItem[], + data: Omit +): ResolvableContextAction[] { + return items.map(item => { + if (typeof item === 'function') { + return async () => + wrapUIContextItems( + (await item({ + value: data.value, + text_value: data.valueText, + column_name: data.column.name, + is_column_header: data.rowIndex == null, + is_row_header: data.columnIndex == null, + })) ?? [], + data + ); + } + + return wrapUIContextItem(item, data); + }); }