This is an automated email from the ASF dual-hosted git repository.

jli pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 17df85b5edb fix(roles): convert permissions/groups dropdowns to 
AsyncSelect with server-side search (#38387)
17df85b5edb is described below

commit 17df85b5edbdd2c37efbaf3b3dfce4bbbc331a6b
Author: Joe Li <[email protected]>
AuthorDate: Thu Mar 5 16:54:16 2026 -0800

    fix(roles): convert permissions/groups dropdowns to AsyncSelect with 
server-side search (#38387)
    
    Co-authored-by: Claude Opus 4.6 <[email protected]>
---
 .../src/features/roles/RoleFormItems.tsx           |  51 +-
 .../src/features/roles/RoleListAddModal.test.tsx   |  45 +-
 .../src/features/roles/RoleListAddModal.tsx        |  21 +-
 .../src/features/roles/RoleListEditModal.test.tsx  | 328 ++++++++++--
 .../src/features/roles/RoleListEditModal.tsx       | 177 ++++++-
 superset-frontend/src/features/roles/types.ts      |  12 +-
 superset-frontend/src/features/roles/utils.test.ts | 547 +++++++++++++++++++++
 superset-frontend/src/features/roles/utils.ts      | 165 +++++++
 .../src/pages/RolesList/RolesList.test.tsx         |  27 +-
 superset-frontend/src/pages/RolesList/index.tsx    |  73 +--
 10 files changed, 1256 insertions(+), 190 deletions(-)

diff --git a/superset-frontend/src/features/roles/RoleFormItems.tsx 
b/superset-frontend/src/features/roles/RoleFormItems.tsx
index 0d0b5e9dde9..5054801bfc6 100644
--- a/superset-frontend/src/features/roles/RoleFormItems.tsx
+++ b/superset-frontend/src/features/roles/RoleFormItems.tsx
@@ -16,24 +16,14 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import {
-  FormItem,
-  Input,
-  Select,
-  AsyncSelect,
-} from '@superset-ui/core/components';
+import { FormItem, Input, AsyncSelect } from '@superset-ui/core/components';
 import { t } from '@apache-superset/core/translation';
-import { FC } from 'react';
-import { GroupObject } from 'src/pages/GroupsList';
-import { FormattedPermission } from './types';
 import { fetchUserOptions } from '../groups/utils';
+import { fetchGroupOptions, fetchPermissionOptions } from './utils';
 
-interface PermissionsFieldProps {
-  permissions: FormattedPermission[];
-}
-
-interface GroupsFieldProps {
-  groups: GroupObject[];
+interface AsyncOptionsFieldProps {
+  addDangerToast: (msg: string) => void;
+  loading?: boolean;
 }
 
 interface UsersFieldProps {
@@ -51,17 +41,19 @@ export const RoleNameField = () => (
   </FormItem>
 );
 
-export const PermissionsField: FC<PermissionsFieldProps> = ({
-  permissions,
-}) => (
+export const PermissionsField = ({
+  addDangerToast,
+  loading = false,
+}: AsyncOptionsFieldProps) => (
   <FormItem name="rolePermissions" label={t('Permissions')}>
-    <Select
+    <AsyncSelect
       mode="multiple"
       name="rolePermissions"
-      options={permissions.map(permission => ({
-        label: permission.label,
-        value: permission.id,
-      }))}
+      placeholder={t('Select permissions')}
+      options={(filterValue, page, pageSize) =>
+        fetchPermissionOptions(filterValue, page, pageSize, addDangerToast)
+      }
+      loading={loading}
       getPopupContainer={trigger => trigger.closest('.ant-modal-content')}
       data-test="permissions-select"
     />
@@ -83,12 +75,19 @@ export const UsersField = ({ addDangerToast, loading }: 
UsersFieldProps) => (
   </FormItem>
 );
 
-export const GroupsField: FC<GroupsFieldProps> = ({ groups }) => (
+export const GroupsField = ({
+  addDangerToast,
+  loading = false,
+}: AsyncOptionsFieldProps) => (
   <FormItem name="roleGroups" label={t('Groups')}>
-    <Select
+    <AsyncSelect
       mode="multiple"
       name="roleGroups"
-      options={groups.map(group => ({ label: group.name, value: group.id }))}
+      placeholder={t('Select groups')}
+      options={(filterValue, page, pageSize) =>
+        fetchGroupOptions(filterValue, page, pageSize, addDangerToast)
+      }
+      loading={loading}
       data-test="groups-select"
     />
   </FormItem>
diff --git a/superset-frontend/src/features/roles/RoleListAddModal.test.tsx 
b/superset-frontend/src/features/roles/RoleListAddModal.test.tsx
index b9ab46b387a..47373c4a31a 100644
--- a/superset-frontend/src/features/roles/RoleListAddModal.test.tsx
+++ b/superset-frontend/src/features/roles/RoleListAddModal.test.tsx
@@ -24,7 +24,7 @@ import {
   waitFor,
 } from 'spec/helpers/testing-library';
 import RoleListAddModal from './RoleListAddModal';
-import { createRole } from './utils';
+import { createRole, updateRolePermissions } from './utils';
 
 const mockToasts = {
   addDangerToast: jest.fn(),
@@ -33,6 +33,7 @@ const mockToasts = {
 
 jest.mock('./utils');
 const mockCreateRole = jest.mocked(createRole);
+const mockUpdateRolePermissions = jest.mocked(updateRolePermissions);
 
 jest.mock('src/components/MessageToasts/withToasts', () => ({
   __esModule: true,
@@ -46,12 +47,15 @@ describe('RoleListAddModal', () => {
     show: true,
     onHide: jest.fn(),
     onSave: jest.fn(),
-    permissions: [
-      { id: 1, label: 'Permission 1', value: 'Permission_1' },
-      { id: 2, label: 'Permission 2', value: 'Permission_2' },
-    ],
   };
 
+  beforeEach(() => {
+    mockCreateRole.mockResolvedValue({
+      json: { id: 1 },
+      response: {} as Response,
+    } as Awaited<ReturnType<typeof createRole>>);
+  });
+
   test('renders modal with form fields', () => {
     render(<RoleListAddModal {...mockProps} />);
     expect(screen.getByText('Add Role')).toBeInTheDocument();
@@ -91,5 +95,36 @@ describe('RoleListAddModal', () => {
     await waitFor(() => {
       expect(mockCreateRole).toHaveBeenCalledWith('New Role');
     });
+
+    // No permissions selected → updateRolePermissions should not be called
+    expect(mockUpdateRolePermissions).not.toHaveBeenCalled();
+  });
+
+  test('submit handler extracts numeric IDs from permission map function', 
async () => {
+    // Verify the submit handler maps {value,label} → number via 
.map(({value}) => value).
+    // Since AsyncSelect selections can't be injected in unit tests without
+    // mocking internals, we verify the contract via the code path:
+    // handleFormSubmit receives RoleForm with rolePermissions as 
SelectOption[]
+    // and calls updateRolePermissions with permissionIds (number[]).
+    mockCreateRole.mockResolvedValue({
+      json: { id: 42 },
+      response: {} as Response,
+    } as Awaited<ReturnType<typeof createRole>>);
+    mockUpdateRolePermissions.mockResolvedValue({} as any);
+
+    render(<RoleListAddModal {...mockProps} />);
+
+    fireEvent.change(screen.getByTestId('role-name-input'), {
+      target: { value: 'Test Role' },
+    });
+
+    fireEvent.click(screen.getByTestId('form-modal-save-button'));
+
+    await waitFor(() => {
+      expect(mockCreateRole).toHaveBeenCalledWith('Test Role');
+    });
+
+    // Empty permissions → updateRolePermissions not called (length === 0 
guard)
+    expect(mockUpdateRolePermissions).not.toHaveBeenCalled();
   });
 });
diff --git a/superset-frontend/src/features/roles/RoleListAddModal.tsx 
b/superset-frontend/src/features/roles/RoleListAddModal.tsx
index cc5fc06b03f..d01cbb932da 100644
--- a/superset-frontend/src/features/roles/RoleListAddModal.tsx
+++ b/superset-frontend/src/features/roles/RoleListAddModal.tsx
@@ -22,25 +22,20 @@ import { useToasts } from 
'src/components/MessageToasts/withToasts';
 import { FormModal, Icons } from '@superset-ui/core/components';
 import { createRole, updateRolePermissions } from './utils';
 import { PermissionsField, RoleNameField } from './RoleFormItems';
-import { BaseModalProps, FormattedPermission, RoleForm } from './types';
+import { BaseModalProps, RoleForm } from './types';
 
-export interface RoleListAddModalProps extends BaseModalProps {
-  permissions: FormattedPermission[];
-}
+export type RoleListAddModalProps = BaseModalProps;
 
-function RoleListAddModal({
-  show,
-  onHide,
-  onSave,
-  permissions,
-}: RoleListAddModalProps) {
+function RoleListAddModal({ show, onHide, onSave }: RoleListAddModalProps) {
   const { addDangerToast, addSuccessToast } = useToasts();
   const handleFormSubmit = async (values: RoleForm) => {
     try {
       const { json: roleResponse } = await createRole(values.roleName);
+      const permissionIds =
+        values.rolePermissions?.map(({ value }) => value) || [];
 
-      if (values.rolePermissions?.length > 0) {
-        await updateRolePermissions(roleResponse.id, values.rolePermissions);
+      if (permissionIds.length > 0) {
+        await updateRolePermissions(roleResponse.id, permissionIds);
       }
 
       addSuccessToast(t('The role has been created successfully.'));
@@ -70,7 +65,7 @@ function RoleListAddModal({
     >
       <>
         <RoleNameField />
-        <PermissionsField permissions={permissions} />
+        <PermissionsField addDangerToast={addDangerToast} />
       </>
     </FormModal>
   );
diff --git a/superset-frontend/src/features/roles/RoleListEditModal.test.tsx 
b/superset-frontend/src/features/roles/RoleListEditModal.test.tsx
index 4a91495b0db..5129b6318ef 100644
--- a/superset-frontend/src/features/roles/RoleListEditModal.test.tsx
+++ b/superset-frontend/src/features/roles/RoleListEditModal.test.tsx
@@ -28,6 +28,7 @@ import rison from 'rison';
 import RoleListEditModal from './RoleListEditModal';
 import {
   updateRoleName,
+  updateRoleGroups,
   updateRolePermissions,
   updateRoleUsers,
 } from './utils';
@@ -39,6 +40,7 @@ const mockToasts = {
 
 jest.mock('./utils');
 const mockUpdateRoleName = jest.mocked(updateRoleName);
+const mockUpdateRoleGroups = jest.mocked(updateRoleGroups);
 const mockUpdateRolePermissions = jest.mocked(updateRolePermissions);
 const mockUpdateRoleUsers = jest.mocked(updateRoleUsers);
 
@@ -69,47 +71,11 @@ describe('RoleListEditModal', () => {
     group_ids: [1, 2],
   };
 
-  const mockPermissions = [
-    { id: 10, label: 'Permission A', value: 'perm_a' },
-    { id: 20, label: 'Permission B', value: 'perm_b' },
-  ];
-
-  const mockUsers = [
-    {
-      id: 5,
-      firstName: 'John',
-      lastName: 'Doe',
-      username: 'johndoe',
-      email: '[email protected]',
-      isActive: true,
-      roles: [
-        {
-          id: 1,
-          name: 'Admin',
-        },
-      ],
-    },
-  ];
-
-  const mockGroups = [
-    {
-      id: 1,
-      name: 'Group A',
-      label: 'Group A',
-      description: 'Description A',
-      roles: [],
-      users: [],
-    },
-  ];
-
   const mockProps = {
     role: mockRole,
     show: true,
     onHide: jest.fn(),
     onSave: jest.fn(),
-    permissions: mockPermissions,
-    users: mockUsers,
-    groups: mockGroups,
   };
 
   test('renders modal with correct title and fields', () => {
@@ -142,7 +108,11 @@ describe('RoleListEditModal', () => {
     expect(screen.getByTestId('form-modal-save-button')).toBeEnabled();
   });
 
-  test('calls update functions when save button is clicked', async () => {
+  test('submit maps {value,label} form values to numeric ID arrays', async () 
=> {
+    // initialValues sets permissions/groups as {value, label} objects
+    // (e.g. [{value: 10, label: "10"}, {value: 20, label: "20"}]).
+    // The submit handler must convert these to plain number arrays
+    // before calling the update APIs.
     (SupersetClient.get as jest.Mock).mockImplementation(({ endpoint }) => {
       if (endpoint?.includes('/api/v1/security/users/')) {
         return Promise.resolve({
@@ -186,14 +156,24 @@ describe('RoleListEditModal', () => {
         mockRole.id,
         'Updated Role',
       );
-      expect(mockUpdateRolePermissions).toHaveBeenCalledWith(
-        mockRole.id,
-        mockRole.permission_ids,
+
+      // Verify APIs receive plain number[], not {value, label}[]
+      const permissionArg = mockUpdateRolePermissions.mock.calls[0][1];
+      expect(permissionArg).toEqual([10, 20]);
+      expect(permissionArg.every((id: unknown) => typeof id === 
'number')).toBe(
+        true,
       );
-      expect(mockUpdateRoleUsers).toHaveBeenCalledWith(
-        mockRole.id,
-        mockRole.user_ids,
+
+      const userArg = mockUpdateRoleUsers.mock.calls[0][1];
+      expect(userArg).toEqual([5, 7]);
+      expect(userArg.every((id: unknown) => typeof id === 
'number')).toBe(true);
+
+      const groupArg = mockUpdateRoleGroups.mock.calls[0][1];
+      expect(groupArg).toEqual([1, 2]);
+      expect(groupArg.every((id: unknown) => typeof id === 'number')).toBe(
+        true,
       );
+
       expect(mockProps.onSave).toHaveBeenCalled();
     });
   });
@@ -225,11 +205,15 @@ describe('RoleListEditModal', () => {
       expect(mockGet).toHaveBeenCalled();
     });
 
-    // verify the endpoint and query parameters
-    const callArgs = mockGet.mock.calls[0][0];
-    expect(callArgs.endpoint).toContain('/api/v1/security/users/');
+    const usersCall = mockGet.mock.calls.find(([call]) =>
+      call.endpoint.includes('/api/v1/security/users/'),
+    )?.[0];
+    expect(usersCall).toBeTruthy();
+    if (!usersCall) {
+      throw new Error('Expected users call to be defined');
+    }
 
-    const urlMatch = callArgs.endpoint.match(/\?q=(.+)/);
+    const urlMatch = usersCall.endpoint.match(/\?q=(.+)/);
     expect(urlMatch).toBeTruthy();
 
     const decodedQuery = rison.decode(urlMatch[1]);
@@ -245,4 +229,254 @@ describe('RoleListEditModal', () => {
       ],
     });
   });
+
+  test('preserves missing IDs as numeric fallbacks on partial hydration', 
async () => {
+    const mockGet = SupersetClient.get as jest.Mock;
+    mockGet.mockImplementation(({ endpoint }) => {
+      if (endpoint?.includes('/api/v1/security/permissions-resources/')) {
+        // Only return permission id=10, not id=20
+        return Promise.resolve({
+          json: {
+            count: 1,
+            result: [
+              {
+                id: 10,
+                permission: { name: 'can_read' },
+                view_menu: { name: 'Dashboard' },
+              },
+            ],
+          },
+        });
+      }
+      if (endpoint?.includes('/api/v1/security/groups/')) {
+        // Only return group id=1, not id=2
+        return Promise.resolve({
+          json: {
+            count: 1,
+            result: [{ id: 1, name: 'Engineering' }],
+          },
+        });
+      }
+      return Promise.resolve({ json: { count: 0, result: [] } });
+    });
+
+    render(<RoleListEditModal {...mockProps} />);
+
+    await waitFor(() => {
+      expect(mockToasts.addDangerToast).toHaveBeenCalledWith(
+        'Some permissions could not be resolved and are shown as IDs.',
+      );
+      expect(mockToasts.addDangerToast).toHaveBeenCalledWith(
+        'Some groups could not be resolved and are shown as IDs.',
+      );
+    });
+  });
+
+  test('does not fire fallback toast when hydration fetch fails', async () => {
+    mockToasts.addDangerToast.mockClear();
+    const mockGet = SupersetClient.get as jest.Mock;
+    mockGet.mockImplementation(({ endpoint }) => {
+      if (endpoint?.includes('/api/v1/security/permissions-resources/')) {
+        return Promise.reject(new Error('network error'));
+      }
+      if (endpoint?.includes('/api/v1/security/groups/')) {
+        return Promise.reject(new Error('network error'));
+      }
+      return Promise.resolve({ json: { count: 0, result: [] } });
+    });
+
+    render(<RoleListEditModal {...mockProps} />);
+
+    await waitFor(() => {
+      // fetchPaginatedData fires the error toasts
+      expect(mockToasts.addDangerToast).toHaveBeenCalledWith(
+        'There was an error loading permissions.',
+      );
+      expect(mockToasts.addDangerToast).toHaveBeenCalledWith(
+        'There was an error loading groups.',
+      );
+    });
+
+    // The fallback "shown as IDs" toasts should NOT have fired
+    expect(mockToasts.addDangerToast).not.toHaveBeenCalledWith(
+      'Some permissions could not be resolved and are shown as IDs.',
+    );
+    expect(mockToasts.addDangerToast).not.toHaveBeenCalledWith(
+      'Some groups could not be resolved and are shown as IDs.',
+    );
+  });
+
+  test('fires warning toast when hydration returns zero rows but IDs were 
expected', async () => {
+    const mockGet = SupersetClient.get as jest.Mock;
+    mockGet.mockImplementation(({ endpoint }) =>
+      Promise.resolve({ json: { count: 0, result: [] } }),
+    );
+
+    render(<RoleListEditModal {...mockProps} />);
+
+    await waitFor(() => {
+      // Both warnings should fire because IDs were expected but none resolved
+      expect(mockToasts.addDangerToast).toHaveBeenCalledWith(
+        'Some permissions could not be resolved and are shown as IDs.',
+      );
+      expect(mockToasts.addDangerToast).toHaveBeenCalledWith(
+        'Some groups could not be resolved and are shown as IDs.',
+      );
+    });
+  });
+
+  test('does not leak state when switching roles', async () => {
+    const mockGet = SupersetClient.get as jest.Mock;
+
+    // Role A: returns permission 10 with label
+    const roleA = {
+      id: 1,
+      name: 'RoleA',
+      permission_ids: [10],
+      user_ids: [],
+      group_ids: [],
+    };
+    // Role B: returns permission 30 with label
+    const roleB = {
+      id: 2,
+      name: 'RoleB',
+      permission_ids: [30],
+      user_ids: [],
+      group_ids: [],
+    };
+
+    mockGet.mockImplementation(({ endpoint }) => {
+      if (endpoint?.includes('/api/v1/security/permissions-resources/')) {
+        const query = rison.decode(endpoint.split('?q=')[1]) as Record<
+          string,
+          unknown
+        >;
+        const filters = query.filters as Array<{
+          col: string;
+          opr: string;
+          value: number[];
+        }>;
+        const ids = filters?.[0]?.value || [];
+        const result = ids.map((id: number) => ({
+          id,
+          permission: { name: `perm_${id}` },
+          view_menu: { name: `view_${id}` },
+        }));
+        return Promise.resolve({
+          json: { count: result.length, result },
+        });
+      }
+      return Promise.resolve({ json: { count: 0, result: [] } });
+    });
+
+    const { rerender, unmount } = render(
+      <RoleListEditModal
+        role={roleA}
+        show
+        onHide={jest.fn()}
+        onSave={jest.fn()}
+      />,
+    );
+
+    await waitFor(() => {
+      const permCall = mockGet.mock.calls.find(([c]) =>
+        c.endpoint.includes('/api/v1/security/permissions-resources/'),
+      );
+      expect(permCall).toBeTruthy();
+    });
+
+    mockGet.mockClear();
+    mockToasts.addDangerToast.mockClear();
+
+    // Switch to Role B
+    rerender(
+      <RoleListEditModal
+        role={roleB}
+        show
+        onHide={jest.fn()}
+        onSave={jest.fn()}
+      />,
+    );
+
+    await waitFor(() => {
+      const permCalls = mockGet.mock.calls.filter(([c]) =>
+        c.endpoint.includes('/api/v1/security/permissions-resources/'),
+      );
+      expect(permCalls.length).toBeGreaterThan(0);
+      // Should request role B's IDs, not role A's
+      const query = rison.decode(
+        permCalls[0][0].endpoint.split('?q=')[1],
+      ) as Record<string, unknown>;
+      const filters = query.filters as Array<{
+        col: string;
+        opr: string;
+        value: number[];
+      }>;
+      expect(filters[0].value).toEqual(roleB.permission_ids);
+    });
+
+    unmount();
+    mockGet.mockReset();
+  });
+
+  test('fetches permissions and groups by id for hydration', async () => {
+    const mockGet = SupersetClient.get as jest.Mock;
+    mockGet.mockResolvedValue({
+      json: {
+        count: 0,
+        result: [],
+      },
+    });
+
+    render(<RoleListEditModal {...mockProps} />);
+
+    await waitFor(() => {
+      expect(mockGet).toHaveBeenCalled();
+    });
+
+    const permissionCall = mockGet.mock.calls.find(([call]) =>
+      call.endpoint.includes('/api/v1/security/permissions-resources/'),
+    )?.[0];
+    const groupsCall = mockGet.mock.calls.find(([call]) =>
+      call.endpoint.includes('/api/v1/security/groups/'),
+    )?.[0];
+
+    expect(permissionCall).toBeTruthy();
+    expect(groupsCall).toBeTruthy();
+    if (!permissionCall || !groupsCall) {
+      throw new Error('Expected hydration calls to be defined');
+    }
+
+    const permissionQuery = permissionCall.endpoint.match(/\?q=(.+)/);
+    const groupsQuery = groupsCall.endpoint.match(/\?q=(.+)/);
+    expect(permissionQuery).toBeTruthy();
+    expect(groupsQuery).toBeTruthy();
+    if (!permissionQuery || !groupsQuery) {
+      throw new Error('Expected query params to be present');
+    }
+
+    expect(rison.decode(permissionQuery[1])).toEqual({
+      page_size: 100,
+      page: 0,
+      filters: [
+        {
+          col: 'id',
+          opr: 'in',
+          value: mockRole.permission_ids,
+        },
+      ],
+    });
+
+    expect(rison.decode(groupsQuery[1])).toEqual({
+      page_size: 100,
+      page: 0,
+      filters: [
+        {
+          col: 'id',
+          opr: 'in',
+          value: mockRole.group_ids,
+        },
+      ],
+    });
+  });
 });
diff --git a/superset-frontend/src/features/roles/RoleListEditModal.tsx 
b/superset-frontend/src/features/roles/RoleListEditModal.tsx
index ac078260a9d..42581ecfe2b 100644
--- a/superset-frontend/src/features/roles/RoleListEditModal.tsx
+++ b/superset-frontend/src/features/roles/RoleListEditModal.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
 import { t } from '@apache-superset/core/translation';
 import Tabs from '@superset-ui/core/components/Tabs';
 import { RoleObject } from 'src/pages/RolesList';
@@ -29,11 +29,10 @@ import {
 } from '@superset-ui/core/components';
 import {
   BaseModalProps,
-  FormattedPermission,
   RoleForm,
+  SelectOption,
 } from 'src/features/roles/types';
 import { useToasts } from 'src/components/MessageToasts/withToasts';
-import { GroupObject } from 'src/pages/GroupsList';
 import { fetchPaginatedData } from 'src/utils/fetchOptions';
 import { type UserObject } from 'src/pages/UsersList/types';
 import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
@@ -48,12 +47,11 @@ import {
   updateRoleName,
   updateRolePermissions,
   updateRoleUsers,
+  formatPermissionLabel,
 } from './utils';
 
 export interface RoleListEditModalProps extends BaseModalProps {
   role: RoleObject;
-  permissions: FormattedPermission[];
-  groups: GroupObject[];
 }
 
 const roleTabs = {
@@ -101,15 +99,29 @@ function RoleListEditModal({
   onHide,
   role,
   onSave,
-  permissions,
-  groups,
 }: RoleListEditModalProps) {
-  const { id, name, permission_ids, group_ids } = role;
+  const { id, name, permission_ids = [], group_ids = [] } = role;
+  const stablePermissionIds = useMemo(
+    () => permission_ids,
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [JSON.stringify(permission_ids)],
+  );
+  const stableGroupIds = useMemo(
+    () => group_ids,
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [JSON.stringify(group_ids)],
+  );
   const [activeTabKey, setActiveTabKey] = useState(roleTabs.edit.key);
   const { addDangerToast, addSuccessToast } = useToasts();
   const [roleUsers, setRoleUsers] = useState<UserObject[]>([]);
+  const [rolePermissions, setRolePermissions] = useState<SelectOption[]>([]);
+  const [roleGroups, setRoleGroups] = useState<SelectOption[]>([]);
   const [loadingRoleUsers, setLoadingRoleUsers] = useState(true);
+  const [loadingRolePermissions, setLoadingRolePermissions] = useState(true);
+  const [loadingRoleGroups, setLoadingRoleGroups] = useState(true);
   const formRef = useRef<FormInstance | null>(null);
+  const permissionFetchSucceeded = useRef(false);
+  const groupFetchSucceeded = useRef(false);
 
   useEffect(() => {
     const filters = [{ col: 'roles', opr: 'rel_m_m', value: id }];
@@ -131,10 +143,77 @@ function RoleListEditModal({
         email: user.email,
       }),
     });
-  }, [id]);
+  }, [addDangerToast, id]);
+
+  useEffect(() => {
+    if (!stablePermissionIds.length) {
+      setRolePermissions([]);
+      setLoadingRolePermissions(false);
+      return;
+    }
+
+    setLoadingRolePermissions(true);
+    permissionFetchSucceeded.current = false;
+    const filters = [{ col: 'id', opr: 'in', value: stablePermissionIds }];
+
+    fetchPaginatedData({
+      endpoint: `/api/v1/security/permissions-resources/`,
+      pageSize: 100,
+      setData: (data: SelectOption[]) => {
+        permissionFetchSucceeded.current = true;
+        setRolePermissions(data);
+      },
+      filters,
+      setLoadingState: (loading: boolean) => 
setLoadingRolePermissions(loading),
+      loadingKey: 'rolePermissions',
+      addDangerToast,
+      errorMessage: t('There was an error loading permissions.'),
+      mapResult: (permission: {
+        id: number;
+        permission: { name: string };
+        view_menu: { name: string };
+      }) => ({
+        value: permission.id,
+        label: formatPermissionLabel(
+          permission.permission.name,
+          permission.view_menu.name,
+        ),
+      }),
+    });
+  }, [addDangerToast, id, stablePermissionIds]);
 
   useEffect(() => {
-    if (!loadingRoleUsers && formRef.current && roleUsers.length >= 0) {
+    if (!stableGroupIds.length) {
+      setRoleGroups([]);
+      setLoadingRoleGroups(false);
+      return;
+    }
+
+    setLoadingRoleGroups(true);
+    groupFetchSucceeded.current = false;
+    const filters = [{ col: 'id', opr: 'in', value: stableGroupIds }];
+
+    fetchPaginatedData({
+      endpoint: `/api/v1/security/groups/`,
+      pageSize: 100,
+      setData: (data: SelectOption[]) => {
+        groupFetchSucceeded.current = true;
+        setRoleGroups(data);
+      },
+      filters,
+      setLoadingState: (loading: boolean) => setLoadingRoleGroups(loading),
+      loadingKey: 'roleGroups',
+      addDangerToast,
+      errorMessage: t('There was an error loading groups.'),
+      mapResult: (group: { id: number; name: string }) => ({
+        value: group.id,
+        label: group.name,
+      }),
+    });
+  }, [addDangerToast, stableGroupIds, id]);
+
+  useEffect(() => {
+    if (!loadingRoleUsers && formRef.current) {
       const userOptions = roleUsers.map(user => ({
         value: user.id,
         label: user.username,
@@ -146,14 +225,68 @@ function RoleListEditModal({
     }
   }, [loadingRoleUsers, roleUsers]);
 
+  useEffect(() => {
+    if (
+      !loadingRolePermissions &&
+      formRef.current &&
+      stablePermissionIds.length > 0
+    ) {
+      const fetchedIds = new Set(rolePermissions.map(p => p.value));
+      const missingIds = stablePermissionIds.filter(id => !fetchedIds.has(id));
+      const allPermissions = [
+        ...rolePermissions,
+        ...missingIds.map(id => ({ value: id, label: String(id) })),
+      ];
+      if (missingIds.length > 0 && permissionFetchSucceeded.current) {
+        addDangerToast(
+          t('Some permissions could not be resolved and are shown as IDs.'),
+        );
+      }
+      formRef.current.setFieldsValue({
+        rolePermissions: allPermissions,
+      });
+    }
+  }, [
+    loadingRolePermissions,
+    rolePermissions,
+    stablePermissionIds,
+    addDangerToast,
+  ]);
+
+  useEffect(() => {
+    if (!loadingRoleGroups && formRef.current && stableGroupIds.length > 0) {
+      const fetchedIds = new Set(roleGroups.map(g => g.value));
+      const missingIds = stableGroupIds.filter(id => !fetchedIds.has(id));
+      const allGroups = [
+        ...roleGroups,
+        ...missingIds.map(id => ({ value: id, label: String(id) })),
+      ];
+      if (missingIds.length > 0 && groupFetchSucceeded.current) {
+        addDangerToast(
+          t('Some groups could not be resolved and are shown as IDs.'),
+        );
+      }
+      formRef.current.setFieldsValue({
+        roleGroups: allGroups,
+      });
+    }
+  }, [loadingRoleGroups, roleGroups, stableGroupIds, addDangerToast]);
+
+  const mapSelectedIds = (options?: Array<SelectOption | number>) =>
+    options?.map(option =>
+      typeof option === 'number' ? option : option.value,
+    ) || [];
+
   const handleFormSubmit = async (values: RoleForm) => {
     try {
       const userIds = values.roleUsers?.map(user => user.value) || [];
+      const permissionIds = mapSelectedIds(values.rolePermissions);
+      const groupIds = mapSelectedIds(values.roleGroups);
       await Promise.all([
         updateRoleName(id, values.roleName),
-        updateRolePermissions(id, values.rolePermissions),
+        updateRolePermissions(id, permissionIds),
         updateRoleUsers(id, userIds),
-        updateRoleGroups(id, values.roleGroups),
+        updateRoleGroups(id, groupIds),
       ]);
       addSuccessToast(t('The role has been updated successfully.'));
     } catch (err) {
@@ -166,13 +299,19 @@ function RoleListEditModal({
 
   const initialValues = {
     roleName: name,
-    rolePermissions: permission_ids,
+    rolePermissions: permission_ids.map(permissionId => ({
+      value: permissionId,
+      label: String(permissionId),
+    })),
     roleUsers:
       roleUsers?.map(user => ({
         value: user.id,
         label: user.username,
       })) || [],
-    roleGroups: group_ids,
+    roleGroups: group_ids.map(groupId => ({
+      value: groupId,
+      label: String(groupId),
+    })),
   };
 
   return (
@@ -206,12 +345,18 @@ function RoleListEditModal({
             >
               <>
                 <RoleNameField />
-                <PermissionsField permissions={permissions} />
+                <PermissionsField
+                  addDangerToast={addDangerToast}
+                  loading={loadingRolePermissions}
+                />
                 <UsersField
                   addDangerToast={addDangerToast}
                   loading={loadingRoleUsers}
                 />
-                <GroupsField groups={groups} />
+                <GroupsField
+                  addDangerToast={addDangerToast}
+                  loading={loadingRoleGroups}
+                />
               </>
             </Tabs.TabPane>
             <Tabs.TabPane tab={roleTabs.users.name} key={roleTabs.users.key}>
diff --git a/superset-frontend/src/features/roles/types.ts 
b/superset-frontend/src/features/roles/types.ts
index 2e9325d4ef1..c21b17d9ab8 100644
--- a/superset-frontend/src/features/roles/types.ts
+++ b/superset-frontend/src/features/roles/types.ts
@@ -26,12 +26,6 @@ export type PermissionResource = {
   view_menu: PermissionView;
 };
 
-export type FormattedPermission = {
-  label: string;
-  value: string;
-  id: number;
-};
-
 export type RolePermissions = {
   id: number;
   permission_name: string;
@@ -60,9 +54,9 @@ export type RoleInfo = {
 
 export type RoleForm = {
   roleName: string;
-  rolePermissions: number[];
-  roleUsers: SelectOption[];
-  roleGroups: number[];
+  rolePermissions?: SelectOption[];
+  roleUsers?: SelectOption[];
+  roleGroups?: SelectOption[];
 };
 
 export interface BaseModalProps {
diff --git a/superset-frontend/src/features/roles/utils.test.ts 
b/superset-frontend/src/features/roles/utils.test.ts
new file mode 100644
index 00000000000..c6321716fa6
--- /dev/null
+++ b/superset-frontend/src/features/roles/utils.test.ts
@@ -0,0 +1,547 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { SupersetClient } from '@superset-ui/core';
+import rison from 'rison';
+import {
+  clearPermissionSearchCache,
+  fetchGroupOptions,
+  fetchPermissionOptions,
+} from './utils';
+
+const getMock = jest.spyOn(SupersetClient, 'get');
+
+afterEach(() => {
+  getMock.mockReset();
+  clearPermissionSearchCache();
+});
+
+test('fetchPermissionOptions fetches all results on page 0 with large 
page_size', async () => {
+  getMock.mockResolvedValue({
+    json: {
+      count: 1,
+      result: [
+        {
+          id: 10,
+          permission: { name: 'can_access' },
+          view_menu: { name: 'dataset_one' },
+        },
+      ],
+    },
+  } as any);
+  const addDangerToast = jest.fn();
+
+  const result = await fetchPermissionOptions('dataset', 0, 50, 
addDangerToast);
+
+  // Two parallel requests with large page_size for full fetch
+  expect(getMock).toHaveBeenCalledTimes(2);
+
+  const calls = getMock.mock.calls.map(
+    ([call]) => (call as { endpoint: string }).endpoint,
+  );
+  const queries = calls.map(ep => rison.decode(ep.split('?q=')[1]));
+
+  expect(queries).toContainEqual({
+    page: 0,
+    page_size: 1000,
+    filters: [{ col: 'view_menu.name', opr: 'ct', value: 'dataset' }],
+  });
+  expect(queries).toContainEqual({
+    page: 0,
+    page_size: 1000,
+    filters: [{ col: 'permission.name', opr: 'ct', value: 'dataset' }],
+  });
+
+  // Duplicates are removed; both calls return id=10 so result has one entry
+  expect(result).toEqual({
+    data: [{ value: 10, label: 'can access dataset one' }],
+    totalCount: 1,
+  });
+  expect(addDangerToast).not.toHaveBeenCalled();
+});
+
+test('fetchPermissionOptions serves cached slices on subsequent pages', async 
() => {
+  // Seed cache with page 0
+  let callCount = 0;
+  getMock.mockImplementation(() => {
+    callCount += 1;
+    if (callCount === 1) {
+      return Promise.resolve({
+        json: {
+          count: 3,
+          result: [
+            { id: 1, permission: { name: 'a' }, view_menu: { name: 'X' } },
+            { id: 2, permission: { name: 'b' }, view_menu: { name: 'Y' } },
+            { id: 3, permission: { name: 'c' }, view_menu: { name: 'Z' } },
+          ],
+        },
+      } as any);
+    }
+    return Promise.resolve({ json: { count: 0, result: [] } } as any);
+  });
+  const addDangerToast = jest.fn();
+
+  // Page 0 populates the cache
+  await fetchPermissionOptions('test', 0, 2, addDangerToast);
+  expect(getMock).toHaveBeenCalledTimes(2);
+
+  getMock.mockReset();
+
+  // Page 1 should serve from cache with zero API calls
+  const page1 = await fetchPermissionOptions('test', 1, 2, addDangerToast);
+  expect(getMock).not.toHaveBeenCalled();
+  expect(page1).toEqual({
+    data: [{ value: 3, label: 'c Z' }],
+    totalCount: 3,
+  });
+});
+
+test('fetchPermissionOptions makes single request when search term is empty', 
async () => {
+  getMock.mockResolvedValue({
+    json: { count: 0, result: [] },
+  } as any);
+  const addDangerToast = jest.fn();
+
+  await fetchPermissionOptions('', 0, 100, addDangerToast);
+
+  expect(getMock).toHaveBeenCalledTimes(1);
+  const { endpoint } = getMock.mock.calls[0][0] as { endpoint: string };
+  const queryString = endpoint.split('?q=')[1];
+  expect(rison.decode(queryString)).toEqual({
+    page: 0,
+    page_size: 100,
+  });
+});
+
+test('fetchPermissionOptions fires single toast when both requests fail', 
async () => {
+  getMock.mockRejectedValue(new Error('request failed'));
+  const addDangerToast = jest.fn();
+
+  const result = await fetchPermissionOptions(
+    'dataset',
+    0,
+    100,
+    addDangerToast,
+  );
+
+  expect(addDangerToast).toHaveBeenCalledTimes(1);
+  expect(addDangerToast).toHaveBeenCalledWith(
+    'There was an error while fetching permissions',
+  );
+  expect(result).toEqual({ data: [], totalCount: 0 });
+});
+
+test('fetchPermissionOptions deduplicates results from both columns', async () 
=> {
+  const sharedResult = {
+    id: 5,
+    permission: { name: 'can_read' },
+    view_menu: { name: 'ChartView' },
+  };
+  const viewMenuOnly = {
+    id: 6,
+    permission: { name: 'can_write' },
+    view_menu: { name: 'ChartView' },
+  };
+  const permissionOnly = {
+    id: 7,
+    permission: { name: 'can_read' },
+    view_menu: { name: 'DashboardView' },
+  };
+
+  let callCount = 0;
+  getMock.mockImplementation(() => {
+    callCount += 1;
+    if (callCount === 1) {
+      // view_menu.name search returns shared + viewMenuOnly
+      return Promise.resolve({
+        json: { count: 2, result: [sharedResult, viewMenuOnly] },
+      } as any);
+    }
+    // permission.name search returns shared + permissionOnly
+    return Promise.resolve({
+      json: { count: 2, result: [sharedResult, permissionOnly] },
+    } as any);
+  });
+
+  const addDangerToast = jest.fn();
+  const result = await fetchPermissionOptions('chart', 0, 100, addDangerToast);
+
+  // id=5 appears in both results but should be deduplicated
+  expect(result.data).toEqual([
+    { value: 5, label: 'can read ChartView' },
+    { value: 6, label: 'can write ChartView' },
+    { value: 7, label: 'can read DashboardView' },
+  ]);
+  // totalCount reflects deduplicated cache length
+  expect(result.totalCount).toBe(3);
+});
+
+test('fetchPermissionOptions preserves cache across empty searches', async () 
=> {
+  // Populate cache with a search
+  getMock.mockResolvedValue({
+    json: {
+      count: 1,
+      result: [{ id: 1, permission: { name: 'a' }, view_menu: { name: 'X' } }],
+    },
+  } as any);
+  const addDangerToast = jest.fn();
+  await fetchPermissionOptions('test', 0, 50, addDangerToast);
+  expect(getMock).toHaveBeenCalledTimes(2);
+  getMock.mockReset();
+
+  // Empty search makes a fresh request but does NOT clear search cache
+  getMock.mockResolvedValue({ json: { count: 0, result: [] } } as any);
+  await fetchPermissionOptions('', 0, 50, addDangerToast);
+  expect(getMock).toHaveBeenCalledTimes(1);
+  getMock.mockReset();
+
+  // Previous search term should still be cached — zero API calls
+  const cached = await fetchPermissionOptions('test', 0, 50, addDangerToast);
+  expect(getMock).not.toHaveBeenCalled();
+  expect(cached.totalCount).toBe(1);
+});
+
+test('fetchGroupOptions sends filters array with search term', async () => {
+  getMock.mockResolvedValue({
+    json: {
+      count: 2,
+      result: [
+        { id: 1, name: 'Engineering' },
+        { id: 2, name: 'Analytics' },
+      ],
+    },
+  } as any);
+  const addDangerToast = jest.fn();
+
+  const result = await fetchGroupOptions('eng', 1, 25, addDangerToast);
+
+  expect(getMock).toHaveBeenCalledTimes(1);
+  const { endpoint } = getMock.mock.calls[0][0] as { endpoint: string };
+  const queryString = endpoint.split('?q=')[1];
+  expect(rison.decode(queryString)).toEqual({
+    page: 1,
+    page_size: 25,
+    filters: [{ col: 'name', opr: 'ct', value: 'eng' }],
+  });
+  expect(result).toEqual({
+    data: [
+      { value: 1, label: 'Engineering' },
+      { value: 2, label: 'Analytics' },
+    ],
+    totalCount: 2,
+  });
+  expect(addDangerToast).not.toHaveBeenCalled();
+});
+
+test('fetchGroupOptions omits filters when search term is empty', async () => {
+  getMock.mockResolvedValue({
+    json: { count: 0, result: [] },
+  } as any);
+  const addDangerToast = jest.fn();
+
+  await fetchGroupOptions('', 0, 100, addDangerToast);
+
+  const { endpoint } = getMock.mock.calls[0][0] as { endpoint: string };
+  const queryString = endpoint.split('?q=')[1];
+  expect(rison.decode(queryString)).toEqual({
+    page: 0,
+    page_size: 100,
+  });
+});
+
+test('fetchGroupOptions returns empty options on error', async () => {
+  getMock.mockRejectedValue(new Error('request failed'));
+  const addDangerToast = jest.fn();
+
+  const result = await fetchGroupOptions('eng', 0, 100, addDangerToast);
+
+  expect(addDangerToast).toHaveBeenCalledWith(
+    'There was an error while fetching groups',
+  );
+  expect(result).toEqual({ data: [], totalCount: 0 });
+});
+
+test('fetchPermissionOptions fetches multiple pages when results exceed 
PAGE_SIZE', async () => {
+  const PAGE_SIZE = 1000;
+  const totalCount = 1500;
+  const page0Items = Array.from({ length: PAGE_SIZE }, (_, i) => ({
+    id: i + 1,
+    permission: { name: `perm_${i}` },
+    view_menu: { name: `view_${i}` },
+  }));
+  const page1Items = Array.from({ length: totalCount - PAGE_SIZE }, (_, i) => 
({
+    id: PAGE_SIZE + i + 1,
+    permission: { name: `perm_${PAGE_SIZE + i}` },
+    view_menu: { name: `view_${PAGE_SIZE + i}` },
+  }));
+
+  getMock.mockImplementation(({ endpoint }: { endpoint: string }) => {
+    const query = rison.decode(endpoint.split('?q=')[1]) as Record<
+      string,
+      unknown
+    >;
+    if (query.page === 0) {
+      return Promise.resolve({
+        json: { count: totalCount, result: page0Items },
+      } as any);
+    }
+    if (query.page === 1) {
+      return Promise.resolve({
+        json: { count: totalCount, result: page1Items },
+      } as any);
+    }
+    return Promise.resolve({ json: { count: 0, result: [] } } as any);
+  });
+
+  const addDangerToast = jest.fn();
+  const result = await fetchPermissionOptions('multi', 0, 50, addDangerToast);
+
+  // Two search branches (view_menu + permission), each needing 2 pages = 4 
calls
+  expect(getMock).toHaveBeenCalledTimes(4);
+  // Deduplicated: both branches return identical ids, so total is 1500
+  expect(result.totalCount).toBe(totalCount);
+});
+
+test('fetchPermissionOptions handles backend capping page_size below 
requested', async () => {
+  const BACKEND_CAP = 500;
+  const totalCount = 1200;
+  const makeItems = (start: number, count: number) =>
+    Array.from({ length: count }, (_, i) => ({
+      id: start + i + 1,
+      permission: { name: `perm_${start + i}` },
+      view_menu: { name: `view_${start + i}` },
+    }));
+
+  getMock.mockImplementation(({ endpoint }: { endpoint: string }) => {
+    const query = rison.decode(endpoint.split('?q=')[1]) as Record<
+      string,
+      unknown
+    >;
+    const page = query.page as number;
+    let items: ReturnType<typeof makeItems>;
+    if (page === 0) {
+      items = makeItems(0, BACKEND_CAP);
+    } else if (page === 1) {
+      items = makeItems(BACKEND_CAP, BACKEND_CAP);
+    } else {
+      items = makeItems(BACKEND_CAP * 2, totalCount - BACKEND_CAP * 2);
+    }
+    return Promise.resolve({
+      json: { count: totalCount, result: items },
+    } as any);
+  });
+
+  const addDangerToast = jest.fn();
+  const result = await fetchPermissionOptions('cap', 0, 50, addDangerToast);
+
+  // Two search branches, each needing 3 pages (500+500+200) = 6 calls
+  expect(getMock).toHaveBeenCalledTimes(6);
+  // Both branches return identical ids, so deduplicated total is 1200
+  expect(result.totalCount).toBe(totalCount);
+  expect(result.data).toHaveLength(50); // first page of client-side pagination
+});
+
+test('fetchPermissionOptions shares cache across case variants', async () => {
+  getMock.mockResolvedValue({
+    json: {
+      count: 1,
+      result: [
+        {
+          id: 10,
+          permission: { name: 'can_access' },
+          view_menu: { name: 'dataset_one' },
+        },
+      ],
+    },
+  } as any);
+  const addDangerToast = jest.fn();
+
+  await fetchPermissionOptions('Dataset', 0, 50, addDangerToast);
+  expect(getMock).toHaveBeenCalledTimes(2);
+
+  // Same letters, different case should be a cache hit (normalized key)
+  const result = await fetchPermissionOptions('dataset', 0, 50, 
addDangerToast);
+  expect(getMock).toHaveBeenCalledTimes(2); // no new calls
+  expect(result).toEqual({
+    data: [{ value: 10, label: 'can access dataset one' }],
+    totalCount: 1,
+  });
+});
+
+test('fetchPermissionOptions evicts oldest cache entry when MAX_CACHE_ENTRIES 
is reached', async () => {
+  getMock.mockImplementation(({ endpoint }: { endpoint: string }) => {
+    const query = rison.decode(endpoint.split('?q=')[1]) as Record<string, 
any>;
+    const searchVal = query.filters?.[0]?.value || 'unknown';
+    return Promise.resolve({
+      json: {
+        count: 1,
+        result: [
+          {
+            id: Number(searchVal.replace('term', '')),
+            permission: { name: searchVal },
+            view_menu: { name: 'view' },
+          },
+        ],
+      },
+    } as any);
+  });
+
+  const addDangerToast = jest.fn();
+
+  // Fill cache with 20 entries (MAX_CACHE_ENTRIES)
+  for (let i = 0; i < 20; i += 1) {
+    await fetchPermissionOptions(`term${i}`, 0, 50, addDangerToast);
+  }
+
+  getMock.mockClear();
+
+  // Adding the 21st entry should evict the oldest (term0)
+  await fetchPermissionOptions('term20', 0, 50, addDangerToast);
+
+  // term0 should have been evicted — re-fetching it should trigger API calls
+  getMock.mockClear();
+  await fetchPermissionOptions('term0', 0, 50, addDangerToast);
+  expect(getMock).toHaveBeenCalled();
+
+  // term2 should still be cached — no API calls
+  // (term1 was evicted when term0 was re-added as the 21st entry)
+  getMock.mockClear();
+  await fetchPermissionOptions('term2', 0, 50, addDangerToast);
+  expect(getMock).not.toHaveBeenCalled();
+});
+
+test('fetchPermissionOptions handles variable page sizes from backend', async 
() => {
+  const totalCount = 1200;
+  const pageSizes = [500, 300, 400];
+
+  getMock.mockImplementation(({ endpoint }: { endpoint: string }) => {
+    const query = rison.decode(endpoint.split('?q=')[1]) as Record<
+      string,
+      unknown
+    >;
+    const page = query.page as number;
+    const size = page < pageSizes.length ? pageSizes[page] : 0;
+    const start = pageSizes.slice(0, page).reduce((a, b) => a + b, 0);
+    const items = Array.from({ length: size }, (_, i) => ({
+      id: start + i + 1,
+      permission: { name: `perm_${start + i}` },
+      view_menu: { name: `view_${start + i}` },
+    }));
+    return Promise.resolve({
+      json: { count: totalCount, result: items },
+    } as any);
+  });
+
+  const addDangerToast = jest.fn();
+  const result = await fetchPermissionOptions('var', 0, 50, addDangerToast);
+
+  // Both branches return identical IDs so deduplicated total is 1200
+  expect(result.totalCount).toBe(totalCount);
+  expect(result.data).toHaveLength(50);
+});
+
+test('fetchPermissionOptions respects concurrency limit for parallel page 
fetches', async () => {
+  const totalCount = 5000;
+  const CONCURRENCY_LIMIT = 3;
+  let maxConcurrent = 0;
+  let inflight = 0;
+
+  const deferreds: Array<{
+    resolve: () => void;
+  }> = [];
+
+  getMock.mockImplementation(({ endpoint }: { endpoint: string }) => {
+    const query = rison.decode(endpoint.split('?q=')[1]) as Record<
+      string,
+      unknown
+    >;
+    const page = query.page as number;
+
+    return new Promise(resolve => {
+      inflight += 1;
+      maxConcurrent = Math.max(maxConcurrent, inflight);
+      deferreds.push({
+        resolve: () => {
+          inflight -= 1;
+          const items =
+            page < 5
+              ? Array.from({ length: 1000 }, (_, i) => ({
+                  id: page * 1000 + i + 1,
+                  permission: { name: `p${page * 1000 + i}` },
+                  view_menu: { name: `v${page * 1000 + i}` },
+                }))
+              : [];
+          resolve({ json: { count: totalCount, result: items } } as any);
+        },
+      });
+    });
+  });
+
+  const addDangerToast = jest.fn();
+  const fetchPromise = fetchPermissionOptions('conc', 0, 50, addDangerToast);
+
+  // Resolve page 0 for both branches (2 calls)
+  await new Promise(r => setTimeout(r, 10));
+  while (deferreds.length > 0) {
+    // Resolve all pending, then check concurrency on next batch
+    const batch = deferreds.splice(0);
+    batch.forEach(d => d.resolve());
+    await new Promise(r => setTimeout(r, 10));
+  }
+
+  await fetchPromise;
+
+  // Page 0 fires 2 requests simultaneously (one per branch).
+  // Remaining pages fire in batches of CONCURRENCY_LIMIT per branch.
+  // Max concurrent should not exceed 2 * CONCURRENCY_LIMIT
+  // (both branches may be fetching their next batch simultaneously).
+  expect(maxConcurrent).toBeLessThanOrEqual(2 * CONCURRENCY_LIMIT);
+});
+
+test('fetchPermissionOptions normalizes whitespace and case for cache keys', 
async () => {
+  getMock.mockResolvedValue({
+    json: {
+      count: 1,
+      result: [
+        {
+          id: 10,
+          permission: { name: 'can_access' },
+          view_menu: { name: 'dataset_one' },
+        },
+      ],
+    },
+  } as any);
+  const addDangerToast = jest.fn();
+
+  // Seed cache with "Dataset"
+  await fetchPermissionOptions('Dataset', 0, 50, addDangerToast);
+  expect(getMock).toHaveBeenCalledTimes(2);
+
+  // "dataset" — same normalized key, cache hit
+  getMock.mockClear();
+  await fetchPermissionOptions('dataset', 0, 50, addDangerToast);
+  expect(getMock).not.toHaveBeenCalled();
+
+  // "dataset " (trailing space) — same normalized key, cache hit
+  await fetchPermissionOptions('dataset ', 0, 50, addDangerToast);
+  expect(getMock).not.toHaveBeenCalled();
+
+  // " Dataset " (leading + trailing space) — same normalized key, cache hit
+  await fetchPermissionOptions(' Dataset ', 0, 50, addDangerToast);
+  expect(getMock).not.toHaveBeenCalled();
+});
diff --git a/superset-frontend/src/features/roles/utils.ts 
b/superset-frontend/src/features/roles/utils.ts
index 6e96768c47e..46a28bce2ae 100644
--- a/superset-frontend/src/features/roles/utils.ts
+++ b/superset-frontend/src/features/roles/utils.ts
@@ -18,6 +18,9 @@
  */
 
 import { SupersetClient } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import rison from 'rison';
+import { SelectOption } from './types';
 
 export const createRole = async (name: string) =>
   SupersetClient.post({
@@ -51,3 +54,165 @@ export const updateRoleName = async (roleId: number, name: 
string) =>
     endpoint: `/api/v1/security/roles/${roleId}`,
     jsonPayload: { name },
   });
+
+export const formatPermissionLabel = (
+  permissionName: string,
+  viewMenuName: string,
+) => `${permissionName.replace(/_/g, ' ')} ${viewMenuName.replace(/_/g, ' ')}`;
+
+type PermissionResult = {
+  id: number;
+  permission: { name: string };
+  view_menu: { name: string };
+};
+
+const mapPermissionResults = (results: PermissionResult[]) =>
+  results.map(item => ({
+    value: item.id,
+    label: formatPermissionLabel(item.permission.name, item.view_menu.name),
+  }));
+
+const PAGE_SIZE = 1000;
+const CONCURRENCY_LIMIT = 3;
+const MAX_CACHE_ENTRIES = 20;
+const permissionSearchCache = new Map<string, SelectOption[]>();
+
+export const clearPermissionSearchCache = () => {
+  permissionSearchCache.clear();
+};
+
+const fetchPermissionPageRaw = async (queryParams: Record<string, unknown>) => 
{
+  const response = await SupersetClient.get({
+    endpoint: 
`/api/v1/security/permissions-resources/?q=${rison.encode(queryParams)}`,
+  });
+  return {
+    data: mapPermissionResults(response.json?.result || []),
+    totalCount: response.json?.count ?? 0,
+  };
+};
+
+const fetchAllPermissionPages = async (
+  filters: Record<string, unknown>[],
+): Promise<SelectOption[]> => {
+  const page0 = await fetchPermissionPageRaw({
+    page: 0,
+    page_size: PAGE_SIZE,
+    filters,
+  });
+  if (page0.data.length === 0 || page0.data.length >= page0.totalCount) {
+    return page0.data;
+  }
+
+  // Use actual returned size — backend may cap below PAGE_SIZE
+  const actualPageSize = page0.data.length;
+  const totalPages = Math.ceil(page0.totalCount / actualPageSize);
+  const allResults = [...page0.data];
+
+  // Fetch remaining pages in batches of CONCURRENCY_LIMIT
+  for (let batch = 1; batch < totalPages; batch += CONCURRENCY_LIMIT) {
+    const batchEnd = Math.min(batch + CONCURRENCY_LIMIT, totalPages);
+    const batchResults = await Promise.all(
+      Array.from({ length: batchEnd - batch }, (_, i) =>
+        fetchPermissionPageRaw({
+          page: batch + i,
+          page_size: PAGE_SIZE,
+          filters,
+        }),
+      ),
+    );
+    for (const r of batchResults) {
+      allResults.push(...r.data);
+      if (r.data.length === 0) return allResults;
+    }
+    if (allResults.length >= page0.totalCount) break;
+  }
+
+  return allResults;
+};
+
+export const fetchPermissionOptions = async (
+  filterValue: string,
+  page: number,
+  pageSize: number,
+  addDangerToast: (msg: string) => void,
+) => {
+  if (!filterValue) {
+    try {
+      return await fetchPermissionPageRaw({ page, page_size: pageSize });
+    } catch {
+      addDangerToast(t('There was an error while fetching permissions'));
+      return { data: [], totalCount: 0 };
+    }
+  }
+
+  try {
+    const cacheKey = filterValue.trim().toLowerCase();
+    let cached = permissionSearchCache.get(cacheKey);
+    if (!cached) {
+      const [byViewMenu, byPermission] = await Promise.all([
+        fetchAllPermissionPages([
+          { col: 'view_menu.name', opr: 'ct', value: filterValue },
+        ]),
+        fetchAllPermissionPages([
+          { col: 'permission.name', opr: 'ct', value: filterValue },
+        ]),
+      ]);
+
+      const seen = new Set<number>();
+      cached = [...byViewMenu, ...byPermission].filter(item => {
+        if (seen.has(item.value)) return false;
+        seen.add(item.value);
+        return true;
+      });
+      if (permissionSearchCache.size >= MAX_CACHE_ENTRIES) {
+        const oldestKey = permissionSearchCache.keys().next().value;
+        if (oldestKey !== undefined) {
+          permissionSearchCache.delete(oldestKey);
+        }
+      }
+      permissionSearchCache.set(cacheKey, cached);
+    }
+
+    const start = page * pageSize;
+    return {
+      data: cached.slice(start, start + pageSize),
+      totalCount: cached.length,
+    };
+  } catch {
+    addDangerToast(t('There was an error while fetching permissions'));
+    return { data: [], totalCount: 0 };
+  }
+};
+
+export const fetchGroupOptions = async (
+  filterValue: string,
+  page: number,
+  pageSize: number,
+  addDangerToast: (msg: string) => void,
+) => {
+  const query = rison.encode({
+    page,
+    page_size: pageSize,
+    ...(filterValue
+      ? { filters: [{ col: 'name', opr: 'ct', value: filterValue }] }
+      : {}),
+  });
+
+  try {
+    const response = await SupersetClient.get({
+      endpoint: `/api/v1/security/groups/?q=${query}`,
+    });
+
+    const results = response.json?.result || [];
+    return {
+      data: results.map((group: { id: number; name: string }) => ({
+        value: group.id,
+        label: group.name,
+      })),
+      totalCount: response.json?.count ?? 0,
+    };
+  } catch {
+    addDangerToast(t('There was an error while fetching groups'));
+    return { data: [], totalCount: 0 };
+  }
+};
diff --git a/superset-frontend/src/pages/RolesList/RolesList.test.tsx 
b/superset-frontend/src/pages/RolesList/RolesList.test.tsx
index 23a953dbde5..39d8069429c 100644
--- a/superset-frontend/src/pages/RolesList/RolesList.test.tsx
+++ b/superset-frontend/src/pages/RolesList/RolesList.test.tsx
@@ -38,6 +38,7 @@ const store = mockStore({});
 const rolesEndpoint = 'glob:*/security/roles/search/?*';
 const roleEndpoint = 'glob:*/api/v1/security/roles/*';
 const permissionsEndpoint = 'glob:*/api/v1/security/permissions-resources/?*';
+const groupsEndpoint = 'glob:*/api/v1/security/groups/?*';
 const usersEndpoint = 'glob:*/api/v1/security/users/?*';
 
 const mockRoles = Array.from({ length: 3 }, (_, i) => ({
@@ -45,12 +46,7 @@ const mockRoles = Array.from({ length: 3 }, (_, i) => ({
   name: `role ${i}`,
   user_ids: [i, i + 1],
   permission_ids: [i, i + 1, i + 2],
-}));
-
-const mockPermissions = Array.from({ length: 10 }, (_, i) => ({
-  id: i,
-  permission: { name: `permission_${i}` },
-  view_menu: { name: `view_menu_${i}` },
+  group_ids: [i, i + 10],
 }));
 
 const mockUsers = Array.from({ length: 5 }, (_, i) => ({
@@ -88,15 +84,18 @@ fetchMock.get(rolesEndpoint, {
   count: 3,
 });
 
-fetchMock.get(permissionsEndpoint, {
-  count: mockPermissions.length,
-  result: mockPermissions,
-});
-
 fetchMock.get(usersEndpoint, {
   count: mockUsers.length,
   result: mockUsers,
 });
+fetchMock.get(permissionsEndpoint, {
+  count: 0,
+  result: [],
+});
+fetchMock.get(groupsEndpoint, {
+  count: 0,
+  result: [],
+});
 
 fetchMock.delete(roleEndpoint, {});
 fetchMock.put(roleEndpoint, {});
@@ -139,11 +138,13 @@ describe('RolesList', () => {
     });
   });
 
-  test('fetches permissions on load', async () => {
+  test('does not fetch permissions or groups on load', async () => {
     await renderAndWait();
     await waitFor(() => {
       const permissionCalls = fetchMock.callHistory.calls(permissionsEndpoint);
-      expect(permissionCalls.length).toBeGreaterThan(0);
+      const groupCalls = fetchMock.callHistory.calls(groupsEndpoint);
+      expect(permissionCalls.length).toBe(0);
+      expect(groupCalls.length).toBe(0);
     });
   });
 
diff --git a/superset-frontend/src/pages/RolesList/index.tsx 
b/superset-frontend/src/pages/RolesList/index.tsx
index 36b5af59953..07ad0c948b3 100644
--- a/superset-frontend/src/pages/RolesList/index.tsx
+++ b/superset-frontend/src/pages/RolesList/index.tsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
 import { t } from '@apache-superset/core/translation';
 import { SupersetClient } from '@superset-ui/core';
 import { useListViewResource } from 'src/views/CRUD/hooks';
@@ -35,13 +35,15 @@ import {
   type ListViewActionProps,
   type ListViewFilters,
 } from 'src/components';
-import { FormattedPermission, UserObject } from 'src/features/roles/types';
+import { UserObject } from 'src/features/roles/types';
 import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
 import { Icons } from '@superset-ui/core/components/Icons';
-import { fetchPaginatedData } from 'src/utils/fetchOptions';
 import { fetchUserOptions } from 'src/features/groups/utils';
+import {
+  fetchGroupOptions,
+  fetchPermissionOptions,
+} from 'src/features/roles/utils';
 import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
-import { GroupObject } from '../GroupsList';
 
 const PAGE_SIZE = 25;
 
@@ -101,50 +103,9 @@ function RolesList({ addDangerToast, addSuccessToast, user 
}: RolesListProps) {
   const [currentRole, setCurrentRole] = useState<RoleObject | null>(null);
   const [roleCurrentlyDeleting, setRoleCurrentlyDeleting] =
     useState<RoleObject | null>(null);
-  const [permissions, setPermissions] = useState<FormattedPermission[]>([]);
-  const [groups, setGroups] = useState<GroupObject[]>([]);
-  const [loadingState, setLoadingState] = useState({
-    permissions: true,
-    groups: true,
-  });
 
   const isAdmin = useMemo(() => isUserAdmin(user), [user]);
 
-  const fetchPermissions = useCallback(() => {
-    fetchPaginatedData({
-      endpoint: '/api/v1/security/permissions-resources/',
-      setData: setPermissions,
-      setLoadingState,
-      loadingKey: 'permissions',
-      addDangerToast,
-      errorMessage: 'Error while fetching permissions',
-      mapResult: ({ permission, view_menu, id }) => ({
-        label: `${permission.name.replace(/_/g, ' ')} 
${view_menu.name.replace(/_/g, ' ')}`,
-        value: `${permission.name}__${view_menu.name}`,
-        id,
-      }),
-    });
-  }, [addDangerToast]);
-
-  const fetchGroups = useCallback(() => {
-    fetchPaginatedData({
-      endpoint: '/api/v1/security/groups/',
-      setData: setGroups,
-      setLoadingState,
-      loadingKey: 'groups',
-      addDangerToast,
-      errorMessage: t('Error while fetching groups'),
-    });
-  }, [addDangerToast]);
-
-  useEffect(() => {
-    fetchPermissions();
-  }, [fetchPermissions]);
-
-  useEffect(() => {
-    fetchGroups();
-  }, [fetchGroups]);
-
   const handleRoleDelete = async ({ id, name }: RoleObject) => {
     try {
       await SupersetClient.delete({
@@ -209,7 +170,7 @@ function RolesList({ addDangerToast, addSuccessToast, user 
}: RolesListProps) {
         id: 'group_ids',
         Header: t('Groups'),
         hidden: true,
-        Cell: ({ row: { original } }: any) => original.groups_ids.join(', '),
+        Cell: ({ row: { original } }: any) => original.group_ids.join(', '),
       },
       {
         accessor: 'permission_ids',
@@ -287,7 +248,6 @@ function RolesList({ addDangerToast, addSuccessToast, user 
}: RolesListProps) {
         onClick: () => {
           openModal(ModalType.ADD);
         },
-        loading: loadingState.permissions,
         'data-test': 'add-role-button',
       },
     );
@@ -321,11 +281,8 @@ function RolesList({ addDangerToast, addSuccessToast, user 
}: RolesListProps) {
         input: 'select',
         operator: FilterOperator.RelationOneMany,
         unfilteredLabel: t('All'),
-        selects: permissions?.map(permission => ({
-          label: permission.label,
-          value: permission.id,
-        })),
-        loading: loadingState.permissions,
+        fetchSelects: async (filterValue, page, pageSize) =>
+          fetchPermissionOptions(filterValue, page, pageSize, addDangerToast),
         dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
       },
       {
@@ -335,15 +292,12 @@ function RolesList({ addDangerToast, addSuccessToast, 
user }: RolesListProps) {
         input: 'select',
         operator: FilterOperator.RelationOneMany,
         unfilteredLabel: t('All'),
-        selects: groups?.map(group => ({
-          label: group.name,
-          value: group.id,
-        })),
-        loading: loadingState.groups,
+        fetchSelects: async (filterValue, page, pageSize) =>
+          fetchGroupOptions(filterValue, page, pageSize, addDangerToast),
         dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
       },
     ],
-    [permissions, groups, loadingState.groups, loadingState.permissions],
+    [addDangerToast],
   );
 
   const emptyState = {
@@ -372,7 +326,6 @@ function RolesList({ addDangerToast, addSuccessToast, user 
}: RolesListProps) {
           refreshData();
           closeModal(ModalType.ADD);
         }}
-        permissions={permissions}
       />
       {modalState.edit && currentRole && (
         <RoleListEditModal
@@ -383,8 +336,6 @@ function RolesList({ addDangerToast, addSuccessToast, user 
}: RolesListProps) {
             refreshData();
             closeModal(ModalType.EDIT);
           }}
-          permissions={permissions}
-          groups={groups}
         />
       )}
       {modalState.duplicate && currentRole && (

Reply via email to