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',