Skip to content

Commit 115e057

Browse files
authored
feat: ComboBox - @deephaven/jsapi-components (#2077)
Jsapi support for ComboBox. Includes some splitting out of existing Picker logic to make code re-usable. Should be testable with plugins PR deephaven/deephaven-plugins#588 I have also deployed an alpha [0.83.1-alpha-combobox.8](https://www.npmjs.com/package/@deephaven/components/v/0.83.1-alpha-combobox.8) if you need it, although should only matter for types I think. ```python from deephaven import empty_table, ui, time_table import datetime # Change this to test different data types key_column="Timestamp" initial_row_count=5 * 8760 # 5 years in hours # Tick every 6 hours (makes it easier to test Timestamp filters for a whole day like `2024-01-02`) _items = time_table("PT6H", start_time=datetime.datetime.now() - datetime.timedelta(hours=initial_row_count)).update([ # Timestamp column also implicitly included in `time_table` "Int=new Integer(i)", "Long=new Long(i)", "BigInt=new java.math.BigInteger(``+i)", "String=new String(`a`+i * 1000)", ]) @ui.component def ui_combo_box(items): value, set_value = ui.use_state("") combo = ui.combo_box( ui.item_table_source( items, key_column=key_column, label_column=key_column, ), label=key_column, on_selection_change=set_value, menu_trigger="focus", selected_key=value, ) # Show current selection in a ui.text component text = ui.text("Selection: " + str(value)) return combo, text my_combo_box = ui_combo_box(_items) ``` There is a known issue with inconsistent open as you type for table data sources. #2115 resolves #2074
1 parent e3aa392 commit 115e057

10 files changed

Lines changed: 285 additions & 152 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
export {
22
ActionBar,
33
type SpectrumActionBarProps as ActionBarProps,
4+
// ComboBox is exported from ComboBox.tsx as a custom DH component. Re-exporting
5+
// the Spectrum props type for upstream consumers that need to compose prop types.
6+
type SpectrumComboBoxProps,
47
// ListBox - we aren't planning to support this component
58
MenuTrigger,
69
type SpectrumMenuTriggerProps as MenuTriggerProps,
710
// TableView - we aren't planning to support this component
11+
// Picker is exported from Picker.tsx as a custom DH component. Re-exporting
12+
// the Spectrum props type for upstream consumers that need to compose prop types.
13+
type SpectrumPickerProps,
814
TagGroup,
915
type SpectrumTagGroupProps as TagGroupProps,
1016
} from '@adobe/react-spectrum';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {
2+
ComboBoxNormalized,
3+
NormalizedItem,
4+
SpectrumComboBoxProps,
5+
} from '@deephaven/components';
6+
import { useCallback } from 'react';
7+
import { PickerWithTableProps } from './PickerProps';
8+
import { usePickerProps } from './utils';
9+
10+
export type ComboBoxProps = PickerWithTableProps<
11+
SpectrumComboBoxProps<NormalizedItem>
12+
>;
13+
14+
export function ComboBox(props: ComboBoxProps): JSX.Element {
15+
const {
16+
onInputChange: onInputChangeInternal,
17+
onSearchTextChange,
18+
...pickerProps
19+
} = usePickerProps<ComboBoxProps>(props);
20+
21+
const onInputChange = useCallback(
22+
(value: string) => {
23+
onInputChangeInternal?.(value);
24+
onSearchTextChange(value);
25+
},
26+
[onInputChangeInternal, onSearchTextChange]
27+
);
28+
29+
return (
30+
<ComboBoxNormalized
31+
// eslint-disable-next-line react/jsx-props-no-spreading
32+
{...pickerProps}
33+
onInputChange={onInputChange}
34+
/>
35+
);
36+
}
37+
38+
export default ComboBox;

packages/jsapi-components/src/spectrum/Picker.tsx

Lines changed: 6 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -1,152 +1,14 @@
1-
import { useCallback, useEffect, useMemo, useState } from 'react';
2-
import {
3-
ItemKey,
4-
NormalizedItem,
5-
NormalizedItemData,
6-
NormalizedSection,
7-
NormalizedSectionData,
8-
PickerNormalized,
9-
PickerProps as PickerBaseProps,
10-
useSpectrumThemeProvider,
11-
} from '@deephaven/components';
12-
import { dh as DhType } from '@deephaven/jsapi-types';
13-
import { Settings } from '@deephaven/jsapi-utils';
14-
import Log from '@deephaven/log';
15-
import { PICKER_ITEM_HEIGHTS, PICKER_TOP_OFFSET } from '@deephaven/utils';
16-
import useFormatter from '../useFormatter';
17-
import useGetItemIndexByValue from '../useGetItemIndexByValue';
18-
import { useViewportData } from '../useViewportData';
19-
import { getItemKeyColumn } from './utils/itemUtils';
20-
import { useItemRowDeserializer } from './utils/useItemRowDeserializer';
1+
import { PickerNormalized } from '@deephaven/components';
2+
import { PickerProps } from './PickerProps';
3+
import { usePickerProps } from './utils';
214

22-
const log = Log.module('jsapi-components.Picker');
23-
24-
export interface PickerProps extends Omit<PickerBaseProps, 'children'> {
25-
table: DhType.Table;
26-
/* The column of values to use as item keys. Defaults to the first column. */
27-
keyColumn?: string;
28-
/* The column of values to display as primary text. Defaults to the `keyColumn` value. */
29-
labelColumn?: string;
30-
31-
/* The column of values to map to icons. */
32-
iconColumn?: string;
33-
34-
settings?: Settings;
35-
}
36-
37-
export function Picker({
38-
table,
39-
keyColumn: keyColumnName,
40-
labelColumn: labelColumnName,
41-
iconColumn: iconColumnName,
42-
settings,
43-
onChange,
44-
onSelectionChange,
45-
...props
46-
}: PickerProps): JSX.Element {
47-
const { scale } = useSpectrumThemeProvider();
48-
const itemHeight = PICKER_ITEM_HEIGHTS[scale];
49-
50-
const { getFormattedString: formatValue } = useFormatter(settings);
51-
52-
// `null` is a valid value for `selectedKey` in controlled mode, so we check
53-
// for explicit `undefined` to identify uncontrolled mode.
54-
const isUncontrolled = props.selectedKey === undefined;
55-
const [uncontrolledSelectedKey, setUncontrolledSelectedKey] = useState<
56-
ItemKey | null | undefined
57-
>(props.defaultSelectedKey);
58-
59-
const keyColumn = useMemo(
60-
() => getItemKeyColumn(table, keyColumnName),
61-
[keyColumnName, table]
62-
);
63-
64-
const deserializeRow = useItemRowDeserializer({
65-
table,
66-
iconColumnName,
67-
keyColumnName,
68-
labelColumnName,
69-
formatValue,
70-
});
71-
72-
const getItemIndexByValue = useGetItemIndexByValue({
73-
table,
74-
columnName: keyColumn.name,
75-
value: isUncontrolled ? uncontrolledSelectedKey : props.selectedKey,
76-
});
77-
78-
const getInitialScrollPosition = useCallback(async () => {
79-
const index = await getItemIndexByValue();
80-
81-
if (index == null) {
82-
return null;
83-
}
84-
85-
return index * itemHeight + PICKER_TOP_OFFSET;
86-
}, [getItemIndexByValue, itemHeight]);
87-
88-
const { viewportData, onScroll, setViewport } = useViewportData<
89-
NormalizedItemData | NormalizedSectionData,
90-
DhType.Table
91-
>({
92-
reuseItemsOnTableResize: true,
93-
table,
94-
itemHeight,
95-
deserializeRow,
96-
});
97-
98-
const normalizedItems = viewportData.items as (
99-
| NormalizedItem
100-
| NormalizedSection
101-
)[];
102-
103-
useEffect(
104-
// Set viewport to include the selected item so that its data will load and
105-
// the real `key` will be available to show the selection in the UI.
106-
function setViewportFromSelectedKey() {
107-
let isCanceled = false;
108-
109-
getItemIndexByValue()
110-
.then(index => {
111-
if (index == null || isCanceled) {
112-
return;
113-
}
114-
115-
setViewport(index);
116-
})
117-
.catch(err => {
118-
log.error('Error setting viewport from selected key', err);
119-
});
120-
121-
return () => {
122-
isCanceled = true;
123-
};
124-
},
125-
[getItemIndexByValue, settings, setViewport]
126-
);
127-
128-
const onSelectionChangeInternal = useCallback(
129-
(key: ItemKey | null): void => {
130-
// If our component is uncontrolled, track the selected key internally
131-
// so that we can scroll to the selected item if the user re-opens
132-
if (isUncontrolled) {
133-
setUncontrolledSelectedKey(key);
134-
}
135-
136-
(onChange ?? onSelectionChange)?.(key);
137-
},
138-
[isUncontrolled, onChange, onSelectionChange]
139-
);
5+
export function Picker(props: PickerProps): JSX.Element {
6+
const pickerProps = usePickerProps<PickerProps>(props);
1407

1418
return (
1429
<PickerNormalized
14310
// eslint-disable-next-line react/jsx-props-no-spreading
144-
{...props}
145-
normalizedItems={normalizedItems}
146-
showItemIcons={iconColumnName != null}
147-
getInitialScrollPosition={getInitialScrollPosition}
148-
onChange={onSelectionChangeInternal}
149-
onScroll={onScroll}
11+
{...pickerProps}
15012
/>
15113
);
15214
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {
2+
NormalizedItem,
3+
PickerPropsT,
4+
SpectrumPickerProps,
5+
} from '@deephaven/components';
6+
import { dh as DhType } from '@deephaven/jsapi-types';
7+
import { Settings } from '@deephaven/jsapi-utils';
8+
9+
export type PickerWithTableProps<TProps> = Omit<
10+
PickerPropsT<TProps>,
11+
'children'
12+
> & {
13+
table: DhType.Table;
14+
/* The column of values to use as item keys. Defaults to the first column. */
15+
keyColumn?: string;
16+
/* The column of values to display as primary text. Defaults to the `keyColumn` value. */
17+
labelColumn?: string;
18+
19+
/* The column of values to map to icons. */
20+
iconColumn?: string;
21+
22+
settings?: Settings;
23+
};
24+
25+
export type PickerProps = PickerWithTableProps<
26+
SpectrumPickerProps<NormalizedItem>
27+
>;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
1+
export * from './ComboBox';
12
export * from './ListView';
23
export * from './Picker';
4+
export * from './PickerProps';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './itemUtils';
22
export * from './useItemRowDeserializer';
3+
export * from './usePickerProps';

packages/jsapi-components/src/spectrum/utils/useItemRowDeserializer.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useCallback, useMemo } from 'react';
22
import { NormalizedItemData } from '@deephaven/components';
33
import { dh } from '@deephaven/jsapi-types';
4+
import { assertNotNull } from '@deephaven/utils';
45
import { getItemKeyColumn, getItemLabelColumn } from './itemUtils';
56

67
function defaultFormatKey(value: unknown): string | number | boolean {
@@ -37,38 +38,52 @@ export function useItemRowDeserializer({
3738
labelColumnName,
3839
formatValue = defaultFormatValue,
3940
}: {
40-
table: dh.Table;
41+
table?: dh.Table | null;
4142
descriptionColumnName?: string;
4243
iconColumnName?: string;
4344
keyColumnName?: string;
4445
labelColumnName?: string;
4546
formatValue?: (value: unknown, columnType: string) => string;
4647
}): (row: dh.Row) => NormalizedItemData {
4748
const keyColumn = useMemo(
48-
() => getItemKeyColumn(table, keyColumnName),
49+
() => (table == null ? null : getItemKeyColumn(table, keyColumnName)),
4950
[keyColumnName, table]
5051
);
5152

5253
const labelColumn = useMemo(
53-
() => getItemLabelColumn(table, keyColumn, labelColumnName),
54+
() =>
55+
table == null || keyColumn == null
56+
? null
57+
: getItemLabelColumn(table, keyColumn, labelColumnName),
5458
[keyColumn, labelColumnName, table]
5559
);
5660

5761
const descriptionColumn = useMemo(
5862
() =>
59-
descriptionColumnName == null
63+
table == null || descriptionColumnName == null
6064
? null
6165
: table.findColumn(descriptionColumnName),
6266
[descriptionColumnName, table]
6367
);
6468

6569
const iconColumn = useMemo(
66-
() => (iconColumnName == null ? null : table.findColumn(iconColumnName)),
70+
() =>
71+
table == null || iconColumnName == null
72+
? null
73+
: table.findColumn(iconColumnName),
6774
[iconColumnName, table]
6875
);
6976

7077
const deserializeRow = useCallback(
7178
(row: dh.Row): NormalizedItemData => {
79+
// `deserializeRow` can be created on a null `table` which results in null
80+
// `keyColumn` + `labelColumn`, but it should never actually be called.
81+
// The assumption is that the `table` will eventually be non-null,
82+
// `deserializeRow` will be recreated, and then applied to the non-null
83+
// table.
84+
assertNotNull(keyColumn, 'keyColumn cannot be null.');
85+
assertNotNull(labelColumn, 'labelColumn cannot be null.');
86+
7287
const key = defaultFormatKey(row.get(keyColumn));
7388
const content = formatValue(row.get(labelColumn), labelColumn.type);
7489

0 commit comments

Comments
 (0)