You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/09/04 20:33:38 UTC

(superset) branch master updated: fix(plugin-chart-table): defer search until IME composition ends (#43756)

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 0d025daaf7e fix(plugin-chart-table): defer search until IME composition ends (#43756)
0d025daaf7e is described below

commit 0d025daaf7e5e977b76a0df04af11bdfeeaa2370
Author: Endi Monan <65...@users.noreply.github.com>
AuthorDate: Fri Sep 4 22:33:30 2026 +0200

    fix(plugin-chart-table): defer search until IME composition ends (#43756)
---
 .../src/DataTable/components/GlobalFilter.tsx      |  33 ++-
 .../plugins/plugin-chart-table/src/TableChart.tsx  |  11 +-
 .../plugin-chart-table/test/TableChart.test.tsx    | 248 +++++++++++++++++++++
 3 files changed, 289 insertions(+), 3 deletions(-)

diff --git a/superset-frontend/plugins/plugin-chart-table/src/DataTable/components/GlobalFilter.tsx b/superset-frontend/plugins/plugin-chart-table/src/DataTable/components/GlobalFilter.tsx
index baf9fad9b2d..51256a51cf7 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/DataTable/components/GlobalFilter.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/src/DataTable/components/GlobalFilter.tsx
@@ -20,6 +20,10 @@ import {
   memo,
   ComponentType,
   ChangeEventHandler,
+  CompositionEvent,
+  CompositionEventHandler,
+  FocusEvent,
+  FocusEventHandler,
   useRef,
   useEffect,
   Ref,
@@ -33,7 +37,9 @@ export interface SearchInputProps {
   count: number;
   value: string;
   onChange: ChangeEventHandler<HTMLInputElement>;
-  onBlur?: () => void;
+  onBlur?: FocusEventHandler<HTMLInputElement>;
+  onCompositionStart?: CompositionEventHandler<HTMLInputElement>;
+  onCompositionEnd?: CompositionEventHandler<HTMLInputElement>;
   inputRef?: Ref<InputRef>;
 }
 
@@ -56,6 +62,8 @@ function DefaultSearchInput({
   value,
   onChange,
   onBlur,
+  onCompositionStart,
+  onCompositionEnd,
   inputRef,
 }: SearchInputProps) {
   return (
@@ -68,6 +76,8 @@ function DefaultSearchInput({
         value={value}
         onChange={onChange}
         onBlur={onBlur}
+        onCompositionStart={onCompositionStart}
+        onCompositionEnd={onCompositionEnd}
         className="form-control input-sm"
       />
     </Space>
@@ -87,10 +97,14 @@ export default (memo as <T>(fn: T) => T)(function GlobalFilter<
 }: GlobalFilterProps<D>) {
   const count = serverPagination ? rowCount : preGlobalFilteredRows.length;
   const inputRef = useRef<InputRef>(null);
+  const isComposingRef = useRef(false);
 
   const [value, setValue] = useAsyncState(
     filterValue,
     (newValue: string) => {
+      if (isComposingRef.current) {
+        return;
+      }
       setGlobalFilter(newValue || undefined);
     },
     200,
@@ -114,8 +128,21 @@ export default (memo as <T>(fn: T) => T)(function GlobalFilter<
     setValue(target.value);
   };
 
-  const handleBlur = () => {
+  const handleBlur = (e: FocusEvent<HTMLInputElement>) => {
     isSearchFocused.set(id, false);
+    if (isComposingRef.current) {
+      isComposingRef.current = false;
+      setValue(e.currentTarget.value);
+    }
+  };
+
+  const handleCompositionStart = () => {
+    isComposingRef.current = true;
+  };
+
+  const handleCompositionEnd = (e: CompositionEvent<HTMLInputElement>) => {
+    isComposingRef.current = false;
+    setValue(e.currentTarget.value);
   };
 
   const SearchInput = searchInput || DefaultSearchInput;
@@ -127,6 +154,8 @@ export default (memo as <T>(fn: T) => T)(function GlobalFilter<
       inputRef={inputRef}
       onChange={handleChange}
       onBlur={handleBlur}
+      onCompositionStart={handleCompositionStart}
+      onCompositionEnd={handleCompositionEnd}
     />
   );
 });
diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
index 81ba43f1c43..e048b95b91f 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
@@ -264,7 +264,14 @@ const VisuallyHidden = styled.label`
   border: 0;
 `;
 
-function SearchInput({ value, onChange, onBlur, inputRef }: SearchInputProps) {
+function SearchInput({
+  value,
+  onChange,
+  onBlur,
+  onCompositionStart,
+  onCompositionEnd,
+  inputRef,
+}: SearchInputProps) {
   return (
     <Space direction="vertical" size={4} className="dt-global-filter">
       <span aria-hidden="true">{t('Search')}</span>
@@ -275,6 +282,8 @@ function SearchInput({ value, onChange, onBlur, inputRef }: SearchInputProps) {
         size="small"
         onChange={onChange}
         onBlur={onBlur}
+        onCompositionStart={onCompositionStart}
+        onCompositionEnd={onCompositionEnd}
         ref={inputRef}
       />
     </Space>
diff --git a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx
index 832fd2e45df..a8e27a03644 100644
--- a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx
@@ -26,6 +26,7 @@ import {
 } from '@superset-ui/chart-controls';
 import { supersetTheme } from '@apache-superset/core/theme';
 import {
+  act,
   render,
   screen,
   fireEvent,
@@ -2687,6 +2688,253 @@ describe('plugin-chart-table', () => {
     expect(screen.queryByText('Search by')).toBeInTheDocument();
   });
 
+  test.each([
+    {
+      eventOrder: 'change before compositionend',
+      commitComposition: (searchInput: HTMLElement) => {
+        fireEvent.change(searchInput, { target: { value: '你好' } });
+        fireEvent.compositionEnd(searchInput);
+      },
+    },
+    {
+      eventOrder: 'compositionend carrying the committed value',
+      commitComposition: (searchInput: HTMLElement) => {
+        fireEvent.compositionEnd(searchInput, {
+          target: { value: '你好' },
+        });
+      },
+    },
+  ])(
+    'defers server-side search until IME composition ends ($eventOrder)',
+    async ({ commitComposition }) => {
+      jest.useFakeTimers();
+      try {
+        const setDataMask = jest.fn();
+        const props = transformProps({
+          ...testData.raw,
+          rawFormData: {
+            ...testData.raw.rawFormData,
+            server_pagination: true,
+            include_search: true,
+          },
+          hooks: { setDataMask },
+          queriesData: [
+            {
+              ...testData.raw.queriesData[0],
+              colnames: ['name'],
+              coltypes: [GenericDataType.String],
+              data: [{ name: 'Michael' }, { name: 'John' }],
+            },
+          ],
+        });
+        render(
+          ProviderWrapper({
+            children: (
+              <TableChart {...props} setDataMask={setDataMask} sticky={false} />
+            ),
+          }),
+        );
+
+        const searchInput = screen.getByRole('textbox');
+        const searchCalls = () =>
+          setDataMask.mock.calls.filter(([mask]) =>
+            Object.prototype.hasOwnProperty.call(
+              mask?.ownState ?? {},
+              'searchText',
+            ),
+          );
+
+        fireEvent.compositionStart(searchInput);
+        fireEvent.change(searchInput, { target: { value: 'nihao' } });
+
+        await act(async () => {
+          jest.advanceTimersByTime(300);
+        });
+        await act(async () => {
+          jest.advanceTimersByTime(900);
+        });
+        expect(searchInput).toHaveValue('nihao');
+        expect(searchCalls()).toHaveLength(0);
+
+        commitComposition(searchInput);
+
+        await act(async () => {
+          jest.advanceTimersByTime(300);
+        });
+        await act(async () => {
+          jest.advanceTimersByTime(900);
+        });
+
+        const calls = searchCalls();
+        expect(calls).toHaveLength(1);
+        expect(calls[0][0].ownState.searchText).toBe('你好');
+      } finally {
+        jest.useRealTimers();
+      }
+    },
+  );
+
+  test('restores server-side search after composition is interrupted by blur', async () => {
+    jest.useFakeTimers();
+    try {
+      const setDataMask = jest.fn();
+      const props = transformProps({
+        ...testData.raw,
+        rawFormData: {
+          ...testData.raw.rawFormData,
+          server_pagination: true,
+          include_search: true,
+        },
+        hooks: { setDataMask },
+        queriesData: [
+          {
+            ...testData.raw.queriesData[0],
+            colnames: ['name'],
+            coltypes: [GenericDataType.String],
+            data: [{ name: 'Michael' }, { name: 'John' }],
+          },
+        ],
+      });
+      render(
+        ProviderWrapper({
+          children: (
+            <TableChart {...props} setDataMask={setDataMask} sticky={false} />
+          ),
+        }),
+      );
+
+      const searchInput = screen.getByRole('textbox');
+      const searchCalls = () =>
+        setDataMask.mock.calls.filter(([mask]) =>
+          Object.prototype.hasOwnProperty.call(
+            mask?.ownState ?? {},
+            'searchText',
+          ),
+        );
+
+      fireEvent.compositionStart(searchInput);
+      fireEvent.change(searchInput, { target: { value: 'nihao' } });
+
+      await act(async () => {
+        jest.advanceTimersByTime(300);
+      });
+      await act(async () => {
+        jest.advanceTimersByTime(900);
+      });
+      expect(searchInput).toHaveValue('nihao');
+      expect(searchCalls()).toHaveLength(0);
+
+      fireEvent.blur(searchInput);
+      expect(searchCalls()).toHaveLength(0);
+
+      fireEvent.change(searchInput, { target: { value: 'hello' } });
+
+      await act(async () => {
+        jest.advanceTimersByTime(300);
+      });
+      await act(async () => {
+        jest.advanceTimersByTime(900);
+      });
+
+      const calls = searchCalls();
+      expect(calls).toHaveLength(1);
+      expect(calls[0][0].ownState.searchText).toBe('hello');
+    } finally {
+      jest.useRealTimers();
+    }
+  });
+
+  test.each([
+    {
+      eventOrder: 'compositionend before blur',
+      pauseBeforeLeaving: 50,
+      leaveInput: (searchInput: HTMLElement) => {
+        fireEvent.compositionEnd(searchInput, {
+          target: { value: 'nihao' },
+        });
+        fireEvent.blur(searchInput);
+      },
+    },
+    {
+      eventOrder: 'blur without compositionend',
+      pauseBeforeLeaving: 50,
+      leaveInput: (searchInput: HTMLElement) => {
+        fireEvent.blur(searchInput);
+      },
+    },
+    {
+      eventOrder: 'blur without compositionend after the debounce fired',
+      pauseBeforeLeaving: 300,
+      leaveInput: (searchInput: HTMLElement) => {
+        fireEvent.blur(searchInput);
+      },
+    },
+  ])(
+    'searches the input value after blur mid-composition ($eventOrder)',
+    async ({ pauseBeforeLeaving, leaveInput }) => {
+      jest.useFakeTimers();
+      try {
+        const setDataMask = jest.fn();
+        const props = transformProps({
+          ...testData.raw,
+          rawFormData: {
+            ...testData.raw.rawFormData,
+            server_pagination: true,
+            include_search: true,
+          },
+          hooks: { setDataMask },
+          queriesData: [
+            {
+              ...testData.raw.queriesData[0],
+              colnames: ['name'],
+              coltypes: [GenericDataType.String],
+              data: [{ name: 'Michael' }, { name: 'John' }],
+            },
+          ],
+        });
+        render(
+          ProviderWrapper({
+            children: (
+              <TableChart {...props} setDataMask={setDataMask} sticky={false} />
+            ),
+          }),
+        );
+
+        const searchInput = screen.getByRole('textbox');
+        const searchCalls = () =>
+          setDataMask.mock.calls.filter(([mask]) =>
+            Object.prototype.hasOwnProperty.call(
+              mask?.ownState ?? {},
+              'searchText',
+            ),
+          );
+
+        fireEvent.compositionStart(searchInput);
+        fireEvent.change(searchInput, { target: { value: 'nihao' } });
+
+        await act(async () => {
+          jest.advanceTimersByTime(pauseBeforeLeaving);
+        });
+        leaveInput(searchInput);
+        expect(searchCalls()).toHaveLength(0);
+
+        await act(async () => {
+          jest.advanceTimersByTime(300);
+        });
+        await act(async () => {
+          jest.advanceTimersByTime(900);
+        });
+
+        expect(searchInput).toHaveValue('nihao');
+        const calls = searchCalls();
+        expect(calls).toHaveLength(1);
+        expect(calls[0][0].ownState.searchText).toBe('nihao');
+      } finally {
+        jest.useRealTimers();
+      }
+    },
+  );
+
   test(
     'should read the totals row from the correct query when percent metrics ' +
       'use the "all records" calculation mode',