fix(roles): convert permissions/groups dropdowns to AsyncSelect with server-side search (#38387)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Joe Li
2026-03-05 16:54:16 -08:00
committed by GitHub
parent 664c465d80
commit 17df85b5ed
10 changed files with 1256 additions and 190 deletions

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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>
);

View File

@@ -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: 'john@example.com',
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,
},
],
});
});
});

View File

@@ -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 (!loadingRoleUsers && formRef.current && roleUsers.length >= 0) {
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 (!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}>

View File

@@ -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 {

View File

@@ -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();
});

View File

@@ -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 };
}
};

View File

@@ -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);
});
});

View File

@@ -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 && (