From 06f6bcc34057134779f9f310852dd368fa2784c8 Mon Sep 17 00:00:00 2001 From: James Player Date: Tue, 19 Aug 2025 09:35:00 +1200 Subject: [PATCH] fix(ui): Fixed react-select TooManyResultsSelector filter and improved scrolling (#1024) --- .../form-components/ReactSelect.test.tsx | 330 ++++++++++++++++++ .../form-components/ReactSelect.tsx | 59 ++-- package.json | 1 + yarn.lock | 49 +++ 4 files changed, 417 insertions(+), 22 deletions(-) create mode 100644 app/react/components/form-components/ReactSelect.test.tsx diff --git a/app/react/components/form-components/ReactSelect.test.tsx b/app/react/components/form-components/ReactSelect.test.tsx new file mode 100644 index 000000000..d5368b53e --- /dev/null +++ b/app/react/components/form-components/ReactSelect.test.tsx @@ -0,0 +1,330 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi } from 'vitest'; + +import selectEvent from '@/react/test-utils/react-select'; + +import { Select } from './ReactSelect'; + +describe('ReactSelect', () => { + const mockOptions = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3' }, + ]; + + const mockGroupedOptions = [ + { + label: 'Group 1', + options: [ + { value: 'g1-option1', label: 'Group 1 Option 1' }, + { value: 'g1-option2', label: 'Group 1 Option 2' }, + ], + }, + { + label: 'Group 2', + options: [ + { value: 'g2-option1', label: 'Group 2 Option 1' }, + { value: 'g2-option2', label: 'Group 2 Option 2' }, + ], + }, + ]; + + describe('Select component', () => { + it('should apply the correct size class', () => { + const { container } = render( + + ); + + const selectContainer = container.querySelector( + '.portainer-selector-root' + ); + expect(selectContainer).toHaveClass('custom-class'); + }); + + it('should handle onChange event', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + + render( + + ); + + const input = screen.getByRole('combobox'); + await selectEvent.select(input, 'Option 1', { user }); + await selectEvent.select(input, 'Option 2', { user }); + + expect(handleChange).toHaveBeenCalledTimes(2); + expect(handleChange).toHaveBeenLastCalledWith( + [mockOptions[0], mockOptions[1]], + expect.objectContaining({ action: 'select-option' }) + ); + }); + + it('should render with grouped options', async () => { + const user = userEvent.setup(); + + render( + + ); + + const selectContainer = container.querySelector( + '.portainer-selector-root' + ); + expect(selectContainer).toHaveClass('portainer-selector--is-disabled'); + }); + + it('should handle loading state', () => { + const { container } = render( + + ); + + const input = screen.getByRole('combobox'); + await selectEvent.clearFirst(input, { user }); + + expect(handleChange).toHaveBeenCalledWith( + null, + expect.objectContaining({ action: 'clear' }) + ); + }); + + it('should handle empty options array', () => { + render( + ); + + const input = screen.getByRole('combobox'); + expect(input).toBeInTheDocument(); + }); + }); + + describe('Component integration', () => { + it('should switch between regular and paginated select based on options count', async () => { + const user = userEvent.setup(); + + // First render with few options - should use regular Select + const { rerender } = render( + + ); + + input = screen.getByRole('combobox'); + await user.click(input); + + // Paginated select should only render first page (100 items max) + // Check that first few options are present + await waitFor(() => { + expect(screen.getByText('Option 0')).toBeInTheDocument(); + }); + + // Count total rendered options - should be limited to PAGE_SIZE (100) + // React-select uses divs with class portainer-selector__option for options + const renderedOptions = document.querySelectorAll( + '.portainer-selector__option' + ); + expect(renderedOptions.length).toBeLessThanOrEqual(100); + expect(renderedOptions.length).toBeGreaterThan(0); + + // Verify that options beyond page size are NOT rendered + expect(screen.queryByText('Option 999')).not.toBeInTheDocument(); + }); + + it('should render creatable mode when isCreatable prop is true', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + + render( + + ); + + // Should use TooManyResultsSelector for large datasets + const selectContainer = container.querySelector( + '.portainer-selector-root' + ); + expect(selectContainer).toBeInTheDocument(); + + // Should preserve data-cy attribute + const input = screen.getByRole('combobox'); + expect(input).toHaveAttribute('data-cy', 'test-select'); + + // Should preserve id + expect(input).toHaveAttribute('id', 'test-input'); + }); + }); +}); diff --git a/app/react/components/form-components/ReactSelect.tsx b/app/react/components/form-components/ReactSelect.tsx index c7ea47366..df9bd3beb 100644 --- a/app/react/components/form-components/ReactSelect.tsx +++ b/app/react/components/form-components/ReactSelect.tsx @@ -1,9 +1,10 @@ import ReactSelectCreatable, { CreatableProps as ReactSelectCreatableProps, } from 'react-select/creatable'; -import ReactSelectAsync, { - AsyncProps as ReactSelectAsyncProps, -} from 'react-select/async'; +import { + AsyncPaginate as ReactSelectAsyncPaginate, + AsyncPaginateProps as ReactSelectAsyncPaginateProps, +} from 'react-select-async-paginate'; import ReactSelect, { components, GroupBase, @@ -18,6 +19,9 @@ import ReactSelectType from 'react-select/dist/declarations/src/Select'; import './ReactSelect.css'; import { AutomationTestingProps } from '@/types'; +const PAGE_SIZE = 100; +const MAX_OPTIONS_WITHOUT_PAGINATION = 1000; + interface DefaultOption { value: string; label: string; @@ -86,7 +90,7 @@ export function Select< Group >(dataCy, componentsProp); - if ((options?.length || 0) > 1000) { + if ((options?.length || 0) > MAX_OPTIONS_WITHOUT_PAGINATION) { return ( & { +}: ReactSelectAsyncPaginateProps & { size?: 'sm' | 'md'; } & AutomationTestingProps) { const { 'data-cy': dataCy, components: componentsProp, ...rest } = props; @@ -155,7 +159,7 @@ export function Async< >(dataCy, componentsProp); return ( - - !!getOptionValue?.(item).toLowerCase().includes(search.toLowerCase()), + search.trim() === '' || + !!getOptionLabel?.(item).toLowerCase().includes(search.toLowerCase()), ...props }: RegularProps & { isItemVisible?: (item: Option, search: string) => boolean; }) { - const defaultOptions = useMemo(() => options?.slice(0, 100), [options]); - return ( - filterOptions(options, isItemVisible, search) + loadOptions={( + search: string, + loadedOptions: OptionsOrGroups | undefined + ) => + filterOptions( + options, + isItemVisible, + search, + loadedOptions + ) } - defaultOptions={defaultOptions} // eslint-disable-next-line react/jsx-props-no-spreading {...props} /> @@ -201,17 +212,21 @@ function filterOptions< >( options: OptionsOrGroups | undefined, isItemVisible: (item: Option, search: string) => boolean, - search: string -): Promise | undefined> { - return Promise.resolve | undefined>( - options - ?.filter((item) => - isGroup(item) - ? item.options.some((ni) => isItemVisible(ni, search)) - : isItemVisible(item, search) - ) - .slice(0, 100) + search: string, + loadedOptions?: OptionsOrGroups +) { + const filteredOptions = options?.filter((item) => + isGroup(item) + ? item.options.some((ni) => isItemVisible(ni, search)) + : isItemVisible(item, search) ); + + const offset = loadedOptions?.length ?? 0; + + return { + options: filteredOptions?.slice(offset, offset + PAGE_SIZE) ?? [], + hasMore: (filteredOptions?.length ?? 0) > offset + PAGE_SIZE, + }; } function isGroup< diff --git a/package.json b/package.json index dbd162bb6..41698d038 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "react-is": "^17.0.2", "react-json-view-lite": "^1.2.1", "react-select": "^5.2.1", + "react-select-async-paginate": "^0.7.11", "sanitize-html": "^2.8.1", "spinkit": "^2.0.1", "strip-ansi": "^6.0.0", diff --git a/yarn.lock b/yarn.lock index 3e826163f..15cfe1462 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4808,6 +4808,11 @@ "@sagold/json-pointer" "^5.1.2" ebnf "^1.9.1" +"@seznam/compose-react-refs@^1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@seznam/compose-react-refs/-/compose-react-refs-1.0.6.tgz#6ec4e70bdd6e32f8e70b4100f27267cf306bd8df" + integrity sha512-izzOXQfeQLonzrIQb8u6LQ8dk+ymz3WXTIXjvOlTXHq6sbzROg3NWU+9TTAOpEoK9Bth24/6F/XrfHJ5yR5n6Q== + "@shikijs/core@1.29.2": version "1.29.2" resolved "https://registry.yarnpkg.com/@shikijs/core/-/core-1.29.2.tgz#9c051d3ac99dd06ae46bd96536380c916e552bf3" @@ -6992,6 +6997,11 @@ loupe "^3.1.2" tinyrainbow "^1.2.0" +"@vtaits/use-lazy-ref@^0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@vtaits/use-lazy-ref/-/use-lazy-ref-0.1.4.tgz#6befc141f4b29f97022259b00c4a5b6c482fe953" + integrity sha512-pdHe8k2WLIm8ccVfNw3HzeTCkifKKjVQ3hpiM7/rMynCp8nev715wrY2RCYnbeowNvekWqpGdHtrWKfCDocC6g== + "@webassemblyjs/ast@1.11.5", "@webassemblyjs/ast@^1.11.5": version "1.11.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.5.tgz#6e818036b94548c1fb53b754b5cae3c9b208281c" @@ -12868,6 +12878,11 @@ klona@^2.0.6: resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.6.tgz#85bffbf819c03b2f53270412420a4555ef882e22" integrity sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA== +krustykrab@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/krustykrab/-/krustykrab-1.1.0.tgz#2b77faf06da9a43fe740799ac73fc2e8b6b515b0" + integrity sha512-xpX9MPbw+nJseewe6who9Oq46RQwrBfps+dO/N4fSjJhsf2+y4XWC2kz46oBGX8yzMHyYJj35ug0X5s5yxB6tA== + kubernetes-types@^1.30.0: version "1.30.0" resolved "https://registry.yarnpkg.com/kubernetes-types/-/kubernetes-types-1.30.0.tgz#f686cacb08ffc5f7e89254899c2153c723420116" @@ -15514,6 +15529,18 @@ react-remove-scroll@^2.6.3: use-callback-ref "^1.3.3" use-sidecar "^1.1.3" +react-select-async-paginate@^0.7.11: + version "0.7.11" + resolved "https://registry.yarnpkg.com/react-select-async-paginate/-/react-select-async-paginate-0.7.11.tgz#737b3fef1beb23dab82c7d2b90059c7b823aae1d" + integrity sha512-AjtCLPMk5DLNgygwQprEPC0gfVIjkou+QYvXM+2gm/LeRpY1Gv5KNT79EYB37H1uMCrwA+HL9BY7OtlaNWtYNg== + dependencies: + "@seznam/compose-react-refs" "^1.0.6" + "@vtaits/use-lazy-ref" "^0.1.4" + krustykrab "^1.1.0" + sleep-promise "^9.1.0" + use-is-mounted-ref "^1.5.0" + use-latest "^1.3.0" + react-select@^5.2.1: version "5.2.1" resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.2.1.tgz#416c25c6b79b94687702374e019c4f2ed9d159d6" @@ -16554,6 +16581,11 @@ slash@^4.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7" integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== +sleep-promise@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/sleep-promise/-/sleep-promise-9.1.0.tgz#101ebe65700bcd184709da95d960967b02b79d03" + integrity sha512-UHYzVpz9Xn8b+jikYSD6bqvf754xL2uBUzDFwiU6NcdZeifPr6UfgU43xpkPu67VMS88+TI2PSI7Eohgqf2fKA== + slice-ansi@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-5.0.0.tgz#b73063c57aa96f9cd881654b15294d95d285c42a" @@ -17908,6 +17940,23 @@ use-callback-ref@^1.3.3: dependencies: tslib "^2.0.0" +use-is-mounted-ref@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/use-is-mounted-ref/-/use-is-mounted-ref-1.5.0.tgz#d737e7b30f1bbbaca594f21cdd2621dc52ae8180" + integrity sha512-p5FksHf/ospZUr5KU9ese6u3jp9fzvZ3wuSb50i0y6fdONaHWgmOqQtxR/PUcwi6hnhQDbNxWSg3eTK3N6m+dg== + +use-isomorphic-layout-effect@^1.1.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz#2f11a525628f56424521c748feabc2ffcc962fce" + integrity sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA== + +use-latest@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.3.0.tgz#549b9b0d4c1761862072f0899c6f096eb379137a" + integrity sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ== + dependencies: + use-isomorphic-layout-effect "^1.1.1" + use-resize-observer@^9.1.0: version "9.1.0" resolved "https://registry.yarnpkg.com/use-resize-observer/-/use-resize-observer-9.1.0.tgz#14735235cf3268569c1ea468f8a90c5789fc5c6c"