diff --git a/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.test.tsx index 91d4ca1c689..4800c0595b1 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.test.tsx @@ -945,7 +945,33 @@ test('shows all options when filterOption is false', async () => { await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2)); const options = await findAllSelectOptions(); - expect(options.length).toBeGreaterThan(0); + expect(options).toHaveLength(10); +}); + +test('preserves new option entry across search fetch when allowNewOptions is on', async () => { + const page0Data = Array.from({ length: 10 }, (_, i) => ({ + label: `Option ${i}`, + value: i, + })); + const loadOptions = jest.fn(async (search: string) => { + if (search === '') { + return { data: page0Data, totalCount: 100 }; + } + return { data: [], totalCount: 0 }; + }); + + render( + , + ); + await open(); + await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1)); + + await type('newval'); + await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2)); + + const options = await findAllSelectOptions(); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('newval'); }); test('restores base options when search is cleared', async () => { diff --git a/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.tsx b/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.tsx index 11d9be7dbec..f120b52fc9a 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Select/AsyncSelect.tsx @@ -337,10 +337,23 @@ const AsyncSelect = forwardRef( const fetchOptions = options as SelectOptionsPagePromise; fetchOptions(search, page, pageSize) .then(({ data, totalCount }: SelectOptionsTypePage) => { - let resultData: SelectOptionsType; + let resultData: SelectOptionsType = data; if (search && page === 0) { - resultData = data.slice().sort(sortComparatorForNoSearch); - setSelectOptions(resultData); + // Preserve optimistic isNewOption entries inserted by + // handleOnSearch so allowNewOptions users can still click + // the value they typed when the server returns no match. + setSelectOptions((prevOptions: SelectOptionsType) => { + const dataValues = new Set( + data.map((opt: SelectOptionsType[number]) => opt.value), + ); + const preservedNew = prevOptions.filter( + (opt: SelectOptionsType[number]) => + opt.isNewOption && !dataValues.has(opt.value), + ); + return preservedNew + .concat(data) + .sort(sortComparatorForNoSearch); + }); } else { resultData = mergeData(data); if (!search) { @@ -512,6 +525,8 @@ const AsyncSelect = forwardRef( fetchedQueries.current.clear(); setAllValuesLoaded(false); setSelectOptions(EMPTY_OPTIONS); + initialOptionsRef.current = EMPTY_OPTIONS; + wasSearchingRef.current = false; }, [options]); useEffect(() => {