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"
/>