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('sm');
+ });
+
+ it('should apply custom className', () => {
+ 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 2', { user });
+
+ expect(handleChange).toHaveBeenCalledWith(
+ mockOptions[1],
+ expect.objectContaining({ action: 'select-option' })
+ );
+ });
+
+ it('should handle multi-select', 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 input = screen.getByRole('combobox');
+ await selectEvent.openMenu(input, { user });
+
+ expect(screen.getByText('Group 1')).toBeInTheDocument();
+ expect(screen.getByText('Group 2')).toBeInTheDocument();
+ expect(screen.getByText('Group 1 Option 1')).toBeInTheDocument();
+ });
+
+ it('should handle disabled state', () => {
+ const { container } = render(
+
+ );
+
+ const selectContainer = container.querySelector(
+ '.portainer-selector-root'
+ );
+ expect(selectContainer).toHaveClass('portainer-selector--is-disabled');
+ });
+
+ it('should handle loading state', () => {
+ const { container } = render(
+
+ );
+
+ const loadingIndicator = container.querySelector(
+ '.portainer-selector__loading-indicator'
+ );
+ expect(loadingIndicator).toBeInTheDocument();
+ });
+
+ it('should clear selection', async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ 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();
+ });
+
+ it('should handle undefined options', () => {
+ 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(
+
+ );
+
+ let input = screen.getByRole('combobox');
+ await user.click(input);
+
+ // Regular select should render all 3 options immediately
+ expect(screen.getByText('Option 1')).toBeInTheDocument();
+ expect(screen.getByText('Option 2')).toBeInTheDocument();
+ expect(screen.getByText('Option 3')).toBeInTheDocument();
+
+ // Close menu
+ await user.keyboard('{Escape}');
+
+ // Now rerender with many options - should switch to TooManyResultsSelector
+ const manyOptions = Array.from({ length: 1001 }, (_, i) => ({
+ value: `option${i}`,
+ label: `Option ${i}`,
+ }));
+
+ rerender(
+
+ );
+
+ 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(
+
+ );
+
+ const input = screen.getByRole('combobox');
+ // Type a new value that doesn't exist in options
+ await user.type(input, 'Brand New Option');
+
+ // Should show create option (may appear in multiple places)
+ await waitFor(() => {
+ const createOptions = screen.getAllByText(/Create "Brand New Option"/);
+ expect(createOptions.length).toBeGreaterThan(0);
+ });
+ });
+
+ it('should preserve props when switching to TooManyResultsSelector', () => {
+ const handleChange = vi.fn();
+ const manyOptions = Array.from({ length: 1001 }, (_, i) => ({
+ value: `option${i}`,
+ label: `Option ${i}`,
+ }));
+
+ const { container } = 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