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 be5ada50d55..380fefecb2b 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 @@ -989,6 +989,52 @@ test('shows all options when filterOption is false', async () => { expect(options[0]).toHaveTextContent('Server 0'); }); +test('renders a server-matched option whose label diverges from the search term when filterOption is false (regression for #42041)', async () => { + // Mirrors the real permissions-search bug: the remote fetch legitimately + // matches the raw, underscore-containing value (e.g. a schema name like + // "stg_silver"), but the returned option's displayed label has had + // underscores replaced with spaces (see formatPermissionLabel in + // features/roles/utils.ts). filterOption defaults to true, which + // re-filters already-matched options against that same relabeled text + // client-side, so the underscore search term never matches and the + // legitimately fetched option gets hidden -- this is why + // PermissionsField (features/roles/RoleFormItems.tsx) sets + // filterOption={false}: the loader is already the authoritative filter, + // and its match doesn't depend on the label used to render the option. + const searchData = [{ label: 'stg silver', value: 100 }]; + const loadOptions = jest.fn(async (search: string) => + // totalCount must exceed the empty initial page here, otherwise + // AsyncSelect marks allValuesLoaded and short-circuits every later + // fetch, including the search request this test depends on. + search === '' + ? { data: [], totalCount: 1 } + : { data: searchData, totalCount: 1 }, + ); + + render( + , + ); + await open(); + + await type('stg_silver'); + await waitFor(() => + expect(loadOptions).toHaveBeenCalledWith( + 'stg_silver', + expect.anything(), + expect.anything(), + ), + ); + + // The backend legitimately matched and returned this option (asserted + // above); it should render in the dropdown despite the search term using + // underscores while the label uses spaces. + expect(await findSelectOption('stg silver')).toBeInTheDocument(); +}); + test('preserves new option entry across search fetch when allowNewOptions is on', async () => { const page0Data = Array.from({ length: 10 }, (_, i) => ({ label: `Option ${i}`, diff --git a/superset-frontend/src/features/roles/RoleFormItems.test.tsx b/superset-frontend/src/features/roles/RoleFormItems.test.tsx index 84402ee82f4..18c28e837a1 100644 --- a/superset-frontend/src/features/roles/RoleFormItems.test.tsx +++ b/superset-frontend/src/features/roles/RoleFormItems.test.tsx @@ -16,13 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -import { render, screen } from 'spec/helpers/testing-library'; +import { render, screen, waitFor, within } from 'spec/helpers/testing-library'; +import userEvent from '@testing-library/user-event'; import { RoleNameField, PermissionsField, UsersField, GroupsField, } from './RoleFormItems'; +import { fetchPermissionOptions } from './utils'; jest.mock('./utils', () => ({ fetchPermissionOptions: jest.fn(), @@ -53,6 +55,45 @@ test('PermissionsField renders loading state', () => { expect(screen.getByTestId('permissions-select')).toBeInTheDocument(); }); +test('PermissionsField shows a permission matched by its raw name even though the label uses spaces (regression for #42041)', async () => { + // fetchPermissionOptions matches the raw, underscore-containing name + // server-side; the returned label has already gone through + // formatPermissionLabel (underscores replaced with spaces for display). + // PermissionsField's normalizing filterOption must match the raw search + // term against that space-formatted label, or the option the server + // legitimately returned gets hidden by client-side re-filtering. + jest + .mocked(fetchPermissionOptions) + .mockImplementation(async (filterValue: string) => + filterValue === 'stg_silver' + ? { data: [{ value: 1, label: 'stg silver' }], totalCount: 1 } + : // totalCount must exceed the empty initial page here, otherwise + // AsyncSelect marks allValuesLoaded and short-circuits every + // later fetch, including the search request this test depends on. + { data: [], totalCount: 1 }, + ); + + render(); + const combobox = screen.getByRole('combobox'); + await waitFor(() => userEvent.click(combobox)); + await userEvent.clear(combobox); + await userEvent.type(combobox, 'stg_silver', { delay: 10 }); + + await waitFor(() => + expect(fetchPermissionOptions).toHaveBeenCalledWith( + 'stg_silver', + expect.anything(), + expect.anything(), + addDangerToast, + ), + ); + expect( + await within(document.querySelector('.rc-virtual-list')!).findByText( + 'stg silver', + ), + ).toBeInTheDocument(); +}); + test('UsersField renders label and select', () => { render(); expect(screen.getByText('Users')).toBeInTheDocument(); diff --git a/superset-frontend/src/features/roles/RoleFormItems.tsx b/superset-frontend/src/features/roles/RoleFormItems.tsx index 0c6dd866a77..8980a982a12 100644 --- a/superset-frontend/src/features/roles/RoleFormItems.tsx +++ b/superset-frontend/src/features/roles/RoleFormItems.tsx @@ -60,6 +60,18 @@ export const PermissionsField = ({ placeholder={t('Select permissions')} options={options} loading={loading} + // formatPermissionLabel renders the raw permission/view_menu name with + // underscores replaced by spaces, so AsyncSelect's default client-side + // re-filter never matches a raw-name search term (e.g. "stg_silver") + // against the displayed label ("stg silver") and hides the + // server-matched option. Normalize both sides so client-side narrowing + // still works without hiding valid matches. See #42041. + filterOption={(input, option) => + String(option?.label ?? '') + .toLowerCase() + .replace(/_/g, ' ') + .includes(input.toLowerCase().replace(/_/g, ' ')) + } getPopupContainer={trigger => trigger.closest('.ant-modal-container')} data-test="permissions-select" />