fix(select): permission label search matches displayed label (#42041) (#42592)

Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
Evan Rusackas
2026-08-12 08:58:22 -07:00
committed by GitHub
co-authored by Claude Code
parent a501fed560
commit a0d7ec9faf
3 changed files with 100 additions and 1 deletions
@@ -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(
<AsyncSelect
{...defaultProps}
options={loadOptions}
filterOption={false}
/>,
);
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}`,
@@ -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(<PermissionsField addDangerToast={addDangerToast} />);
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(<UsersField addDangerToast={addDangerToast} loading={false} />);
expect(screen.getByText('Users')).toBeInTheDocument();
@@ -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"
/>