-
-
DropDownBox with search and embedded DataGrid
-
-
-
-
-
-
-
+
+
DropDownBox with search and embedded DataGrid
+
+
+
+
);
}
-interface DataGridComponentProps {
- data: { value: number[] | null; component: any };
-}
-
-function DataGridComponent({ data }: DataGridComponentProps): JSX.Element {
- const { value, component } = data;
-
- const [isDataReady, setIsDataReady] = useState
(false);
- const [pendingSelection, setPendingSelection] = useState(null);
-
- const ctx = useContext(DropDownBoxDispatch);
- if (!ctx) return ;
- const {
- dispatch, dataSource, focusedRowKey, focusedRowIndex,
- } = ctx;
-
- const effectiveSelection = useMemo((): number[] => {
- if (isDataReady && (pendingSelection || value)) {
- return pendingSelection ?? value ?? [];
- }
- return [];
- }, [isDataReady, pendingSelection, value]);
-
- const selectionChanged = useCallback((e: DataGridTypes.SelectionChangedEvent): void => {
- if (!isDataReady) return;
-
- const newSelection = e.selectedRowKeys;
- const currentSelection = effectiveSelection;
-
- if (JSON.stringify(newSelection) !== JSON.stringify(currentSelection)) {
- dispatch({ value: newSelection, opened: false, type: 'all' });
- setPendingSelection(null);
- }
- }, [dispatch, isDataReady, effectiveSelection]);
-
- const contentReady = useCallback((e: DataGridTypes.ContentReadyEvent): void => {
- const inst = e.component;
-
- if (!(inst as unknown as { isNotFirstLoad?: boolean }).isNotFirstLoad) {
- (inst as unknown as { isNotFirstLoad?: boolean }).isNotFirstLoad = true;
- component.focus();
- const refObj: DataGridRef = { instance: () => inst };
- dispatch({ ref: refObj, type: 'dataGridRef' });
- }
- setIsDataReady(true);
- if (value && !isDataReady) {
- setPendingSelection(value);
- }
- }, [component, dispatch, value, isDataReady]);
-
- const keyDown = useCallback((e: DataGridTypes.KeyDownEvent): void => {
- if (e.event && e.event.keyCode === 13 && focusedRowKey != null) {
- dispatch({ value: [focusedRowKey], opened: false, type: 'all' });
- }
- }, [dispatch, focusedRowKey]);
-
- const focusedRowChanged = useCallback((e: DataGridTypes.FocusedRowChangedEvent): void => {
- const rowIndex = e.rowIndex;
- const key = e.component.getKeyByRowIndex(rowIndex);
- dispatch({ focusedRowIndex: rowIndex, focusedRowKey: key ?? null, type: 'focusedRowKey' });
- }, [dispatch, focusedRowKey]);
-
- useEffect(() => {
- setIsDataReady(false);
- setPendingSelection(value);
- }, [dataSource, value]);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
export default App;
diff --git a/React/src/components/drop-down-grid/DropDownGrid.tsx b/React/src/components/drop-down-grid/DropDownGrid.tsx
new file mode 100644
index 0000000..321a8d9
--- /dev/null
+++ b/React/src/components/drop-down-grid/DropDownGrid.tsx
@@ -0,0 +1,221 @@
+import {
+ useState, useCallback, useRef, useReducer,
+} from 'react';
+import DropDownBox, { type DropDownBoxTypes, type DropDownBoxRef } from 'devextreme-react/drop-down-box';
+import DataGrid, {
+ Column, Format, Selection, Paging, Scrolling, type DataGridTypes, type DataGridRef,
+} from 'devextreme-react/data-grid';
+import { DataSource } from 'devextreme-react/common/data';
+import { isSearchIncomplete, type OrderItem } from '../../service';
+import { selectionReducer } from './selectionReducer';
+
+interface DropDownGridProps {
+ selectedRowKey: number;
+ dataSource: DataSource;
+ dropDownBoxDataSource: DataSource;
+ searchTimeout: number;
+ // eslint-disable-next-line no-unused-vars
+ displayExpr: (item: OrderItem | null) => string;
+}
+
+const dropDownOptions = { height: 400 };
+
+export function DropDownGrid({
+ selectedRowKey,
+ dataSource,
+ dropDownBoxDataSource,
+ searchTimeout,
+ displayExpr,
+}: DropDownGridProps): JSX.Element {
+ const [selection, dispatch] = useReducer(selectionReducer, {
+ dropDownValue: selectedRowKey,
+ selectedRowKeys: [selectedRowKey],
+ focusedRowKey: selectedRowKey,
+ });
+
+ const [gridBoxOpened, setGridBoxOpened] = useState(false);
+ const [focusAfterLoading, setFocusAfterLoading] = useState(false);
+
+ const gridFirstLoadCompleted = useRef(false);
+ const searchTimer = useRef | null>(null);
+
+ const dropDownBoxRef = useRef(null);
+ const dataGridRef = useRef(null);
+
+ const onDropDownValueChanged = useCallback((args: DropDownBoxTypes.ValueChangedEvent) => {
+ if (searchTimer.current) clearTimeout(searchTimer.current);
+ dispatch({ type: 'SELECT_VALUE', value: args.value ?? null });
+ if (args.value) {
+ setGridBoxOpened(false);
+ }
+ }, []);
+
+ const onFocusedRowChanged = useCallback((e: DataGridTypes.FocusedRowChangedEvent) => {
+ dispatch({ type: 'SET_FOCUSED_KEY', key: e.row?.key || null });
+ }, []);
+
+ const onSelectionChanged = useCallback((args: DataGridTypes.SelectionChangedEvent) => {
+ if (!gridFirstLoadCompleted.current || !args.selectedRowKeys.length) return;
+ dispatch({ type: 'SELECT_ROW', keys: args.selectedRowKeys });
+ dropDownBoxRef.current?.instance().focus();
+ }, []);
+
+ const onInput = useCallback((e: DropDownBoxTypes.InputEvent) => {
+ if (searchTimer.current) clearTimeout(searchTimer.current);
+ searchTimer.current = setTimeout(() => {
+ if (!gridBoxOpened) setGridBoxOpened(true);
+ const text = e.component.option('text');
+ dataSource.searchValue(text ?? null);
+ if (isSearchIncomplete(e.component)) {
+ setFocusAfterLoading(true);
+ dataSource.load().then((items) => {
+ if (items.length > 0) {
+ dispatch({ type: 'SET_FOCUSED_KEY', key: items[0].OrderNumber });
+ }
+ setTimeout(() => {
+ dropDownBoxRef.current?.instance().focus();
+ });
+ }).catch(() => {});
+ }
+ }, searchTimeout);
+ }, [dataSource, gridBoxOpened, searchTimeout, focusAfterLoading]);
+
+ const onOpened = useCallback((e: DropDownBoxTypes.OpenedEvent) => {
+ if (!gridFirstLoadCompleted.current) {
+ gridFirstLoadCompleted.current = true;
+ }
+ const _gridFirstLoadCompleted = gridFirstLoadCompleted.current;
+ const dropDownBox = e.component;
+ function handleOptionChanged(args: DataGridTypes.OptionChangedEvent): void {
+ const grid = args.component;
+ const triggerCondition = _gridFirstLoadCompleted
+ ? args.name === 'opened'
+ : args.name === 'focusedRowKey' || args.name === 'focusedRowIndex';
+ if (triggerCondition) {
+ grid.off('optionChanged', handleOptionChanged);
+ requestAnimationFrame(() => {
+ grid.focus();
+ grid.option('opened', false);
+ });
+ }
+ }
+
+ dataGridRef.current?.instance().on('optionChanged', handleOptionChanged);
+
+ if (gridFirstLoadCompleted.current) {
+ dataGridRef.current?.instance().option('opened', true);
+ }
+
+ const displayValue = dropDownBox.option('displayValue') as string[];
+ const isTextEqualToDisplayValue = dropDownBox.option('text') === displayValue[0];
+ const shouldClearSelection = (dropDownBox.option('value') && !dropDownBox.option('text')) || !isTextEqualToDisplayValue;
+
+ if (shouldClearSelection && selection.selectedRowKeys?.length) {
+ dispatch({ type: 'RESET' });
+ }
+ }, []);
+
+ const onClosed = useCallback((e: DropDownBoxTypes.ClosedEvent) => {
+ const dropDownBox = e.component;
+ const hasLoadedItems = dataGridRef.current?.instance().getVisibleRows().length;
+ const text = dropDownBox.option('text');
+ const displayValue = dropDownBox.option('displayValue') as string[];
+ const resetValue = text && text !== displayValue[0];
+
+ if (!hasLoadedItems) {
+ dropDownBox.reset('');
+ dataSource.searchValue('');
+ dataSource.load()
+ .then(() => {})
+ .catch(() => {});
+ return;
+ }
+
+ if (resetValue) {
+ const firstKey = dataGridRef.current?.instance().getKeyByRowIndex(0);
+ dispatch({ type: 'SELECT_VALUE', value: firstKey });
+ }
+ }, [dataSource]);
+
+ const onOptionChanged = useCallback((args: DropDownBoxTypes.OptionChangedEvent) => {
+ if (args.name === 'text' && !args.value && gridFirstLoadCompleted.current) {
+ setTimeout(() => {
+ dataGridRef.current?.instance().pageIndex(0).then(() => {
+ dataGridRef.current?.instance().option('focusedRowIndex', 0);
+ }).catch(() => {});
+ }, 1500);
+ }
+ }, []);
+
+ const dataGridKeyDown = useCallback((e: DataGridTypes.KeyDownEvent) => {
+ if (e.event?.key === 'Enter' && selection.focusedRowKey) {
+ dispatch({ type: 'SELECT_VALUE', value: selection.focusedRowKey });
+ }
+ }, [selection.focusedRowKey]);
+
+ const onDropDownBoxKeyDown = useCallback((e: DropDownBoxTypes.KeyDownEvent) => {
+ if (e.event?.key !== 'ArrowDown') return;
+ if (!gridBoxOpened) {
+ setGridBoxOpened(true);
+ } else if (dataGridRef.current?.instance()) {
+ dataGridRef.current.instance().focus();
+ }
+ }, [gridBoxOpened]);
+
+ const onOpenedChange = useCallback((isOpened: boolean) => {
+ setGridBoxOpened(isOpened);
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/React/src/components/drop-down-grid/selectionReducer.ts b/React/src/components/drop-down-grid/selectionReducer.ts
new file mode 100644
index 0000000..aef3cc1
--- /dev/null
+++ b/React/src/components/drop-down-grid/selectionReducer.ts
@@ -0,0 +1,34 @@
+interface SelectionState {
+ dropDownValue: number | null;
+ selectedRowKeys: number[];
+ focusedRowKey: number | null;
+}
+
+type SelectionAction =
+ | { type: 'SELECT_VALUE'; value: number | null }
+ | { type: 'SELECT_ROW'; keys: number[] }
+ | { type: 'SET_FOCUSED_KEY'; key: number | null }
+ | { type: 'RESET' };
+
+export function selectionReducer(state: SelectionState, action: SelectionAction): SelectionState {
+ switch (action.type) {
+ case 'SELECT_VALUE':
+ return {
+ dropDownValue: action.value,
+ selectedRowKeys: action.value ? [action.value] : [],
+ focusedRowKey: action.value,
+ };
+ case 'SELECT_ROW':
+ return {
+ ...state,
+ dropDownValue: action.keys.length ? action.keys[0] : null,
+ selectedRowKeys: action.keys,
+ };
+ case 'SET_FOCUSED_KEY':
+ return { ...state, focusedRowKey: action.key };
+ case 'RESET':
+ return { ...state, selectedRowKeys: [] };
+ default:
+ return state;
+ }
+}
diff --git a/React/src/service.ts b/React/src/service.ts
new file mode 100644
index 0000000..8767c7f
--- /dev/null
+++ b/React/src/service.ts
@@ -0,0 +1,40 @@
+import * as AspNetData from 'devextreme-aspnet-data-nojquery';
+import DropDownBox from 'devextreme/ui/drop_down_box';
+
+export function isSearchIncomplete(dropDownBox: DropDownBox): boolean {
+ let displayValue: any = dropDownBox.option('displayValue');
+ const text = dropDownBox.option('text');
+ const textValue = text?.length ? text : undefined;
+ displayValue = displayValue?.length && displayValue[0];
+ return textValue !== displayValue;
+}
+
+export interface OrderItem {
+ OrderNumber: number;
+ Employee: string;
+ StoreState: string;
+ StoreCity: string;
+ OrderDate: string;
+ SaleAmount: number;
+}
+
+const API_URL = 'https://js.devexpress.com/Demos/WidgetsGalleryDataService/api/orders';
+
+export function formatDisplayExpr(item: OrderItem | null): string {
+ if (!item || typeof item !== 'object') return '';
+ return `${item.Employee}: ${item.StoreState} - ${item.StoreCity} <${item.OrderNumber}>`;
+}
+
+export function makeAsyncDataSource(): AspNetData.CustomStore {
+ return AspNetData.createStore({
+ key: 'OrderNumber',
+ loadUrl: API_URL,
+ });
+}
+
+export const searchExprOptions = [
+ { name: '\'Employee\'', value: 'Employee' },
+ { name: '[\'OrderNumber\', \'Employee\']', value: ['OrderNumber', 'Employee'] },
+ { name: '[\'StoreCity\', \'Employee\']', value: ['StoreCity', 'Employee'] },
+ { name: '[\'OrderNumber\',\'StoreCity\', \'StoreState\', \'Employee\']', value: ['OrderNumber', 'StoreCity', 'StoreState', 'Employee'] },
+];