feat(List Groups): Migrate List Groups FAB to React (#33301)

This commit is contained in:
Enzo Martellucci
2025-06-03 16:18:15 +02:00
committed by GitHub
parent fc13a0fde5
commit fa0c5891bf
23 changed files with 1403 additions and 219 deletions

View File

@@ -204,3 +204,8 @@ export enum FilterPlugins {
TimeColumn = 'filter_timecolumn',
TimeGrain = 'filter_timegrain',
}
export enum Actions {
CREATE = 'create',
UPDATE = 'update',
}

View File

@@ -0,0 +1,164 @@
/**
* 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 { t } from '@superset-ui/core';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import FormModal from 'src/components/Modal/FormModal';
import { FormItem } from 'src/components/Form';
import { Input } from 'src/components/Input';
import Select from 'src/components/Select/Select';
import { GroupObject, Role } from 'src/pages/GroupsList';
import { Actions } from 'src/constants';
import AsyncSelect from 'src/components/Select/AsyncSelect';
import { BaseGroupListModalProps, FormValues } from './types';
import { createGroup, fetchUserOptions, updateGroup } from './utils';
export interface GroupModalProps extends BaseGroupListModalProps {
roles: Role[];
isEditMode?: boolean;
group?: GroupObject;
}
function GroupListModal({
show,
onHide,
onSave,
roles,
isEditMode = false,
group,
}: GroupModalProps) {
const { addDangerToast, addSuccessToast } = useToasts();
const handleFormSubmit = async (values: FormValues) => {
const handleError = async (
err: Response,
action: Actions.CREATE | Actions.UPDATE,
) => {
let errorMessage =
action === Actions.CREATE
? t('There was an error creating the group. Please, try again.')
: t('There was an error updating the group. Please, try again.');
if (err.status === 422) {
const errorData = await err.json();
const detail = errorData?.message || '';
if (detail.includes('duplicate key value')) {
if (detail.includes('ab_group_name_key')) {
errorMessage = t(
'This name is already taken. Please choose another one.',
);
}
}
}
addDangerToast(errorMessage);
throw err;
};
if (isEditMode) {
if (!group) {
throw new Error('Group is required in edit mode');
}
try {
await updateGroup(group.id, values);
addSuccessToast(t('The group has been updated successfully.'));
} catch (err) {
await handleError(err, Actions.UPDATE);
}
} else {
try {
await createGroup(values);
addSuccessToast(t('The group has been created successfully.'));
} catch (err) {
await handleError(err, Actions.CREATE);
}
}
};
const requiredFields = ['name'];
const initialValues = {
...group,
roles: group?.roles?.map(role => role.id) || [],
users:
group?.users?.map(user => ({
value: user.id,
label: user.username,
})) || [],
};
return (
<FormModal
show={show}
onHide={onHide}
title={isEditMode ? t('Edit Group') : t('Add Group')}
onSave={onSave}
formSubmitHandler={handleFormSubmit}
requiredFields={requiredFields}
initialValues={initialValues}
>
<FormItem
name="name"
label={t('Name')}
rules={[{ required: true, message: t('Name is required') }]}
>
<Input name="name" placeholder={t("Enter the group's name")} />
</FormItem>
<FormItem name="label" label={t('Label')}>
<Input name="label" placeholder={t("Enter the group's label")} />
</FormItem>
<FormItem name="description" label={t('Description')}>
<Input
name="description"
placeholder={t("Enter the group's description")}
/>
</FormItem>
<FormItem name="roles" label={t('Roles')}>
<Select
name="roles"
mode="multiple"
placeholder={t('Select roles')}
options={roles.map(role => ({
value: role.id,
label: role.name,
}))}
getPopupContainer={trigger => trigger.closest('.antd5-modal-content')}
/>
</FormItem>
<FormItem name="users" label={t('Users')}>
<AsyncSelect
name="users"
mode="multiple"
placeholder={t('Select users')}
options={(filterValue, page, pageSize) =>
fetchUserOptions(filterValue, page, pageSize, addDangerToast)
}
/>
</FormItem>
</FormModal>
);
}
export const GroupListAddModal = (
props: Omit<GroupModalProps, 'isEditMode' | 'initialValues'>,
) => <GroupListModal {...props} isEditMode={false} />;
export const GroupListEditModal = (
props: Omit<GroupModalProps, 'isEditMode'> & { group: GroupObject },
) => <GroupListModal {...props} isEditMode />;
export default GroupListModal;

View File

@@ -21,14 +21,22 @@ import Select from 'src/components/Select/Select';
import { Input } from 'src/components/Input';
import { t } from '@superset-ui/core';
import { FC } from 'react';
import { FormattedPermission, UserObject } from './types';
import { GroupObject } from 'src/pages/GroupsList';
import AsyncSelect from 'src/components/Select/AsyncSelect';
import { FormattedPermission } from './types';
import { fetchUserOptions } from '../groups/utils';
interface PermissionsFieldProps {
permissions: FormattedPermission[];
}
interface GroupsFieldProps {
groups: GroupObject[];
}
interface UsersFieldProps {
users: UserObject[];
addDangerToast: (msg: string) => void;
loading: boolean;
}
export const RoleNameField = () => (
@@ -58,13 +66,28 @@ export const PermissionsField: FC<PermissionsFieldProps> = ({
</FormItem>
);
export const UsersField: FC<UsersFieldProps> = ({ users }) => (
export const UsersField = ({ addDangerToast, loading }: UsersFieldProps) => (
<FormItem name="roleUsers" label={t('Users')}>
<Select
mode="multiple"
<AsyncSelect
name="roleUsers"
options={users.map(user => ({ label: user.username, value: user.id }))}
data-test="users-select"
mode="multiple"
placeholder={t('Select users')}
options={(filterValue, page, pageSize) =>
fetchUserOptions(filterValue, page, pageSize, addDangerToast)
}
loading={loading}
data-test="roles-select"
/>
</FormItem>
);
export const GroupsField: FC<GroupsFieldProps> = ({ groups }) => (
<FormItem name="roleGroups" label={t('Groups')}>
<Select
mode="multiple"
name="roleGroups"
options={groups.map(group => ({ label: group.name, value: group.id }))}
data-test="groups-select"
/>
</FormItem>
);

View File

@@ -43,9 +43,11 @@ function RoleListAddModal({
await updateRolePermissions(roleResponse.id, values.rolePermissions);
}
addSuccessToast(t('Role was successfully created!'));
addSuccessToast(t('The role has been created successfully.'));
} catch (err) {
addDangerToast(t('Error while adding role!'));
addDangerToast(
t('There was an error creating the role. Please, try again.'),
);
throw err;
}
};

View File

@@ -45,6 +45,7 @@ describe('RoleListDuplicateModal', () => {
name: 'Admin',
permission_ids: [10, 20],
user_ids: [1],
group_ids: [],
};
const mockProps = {

View File

@@ -45,9 +45,11 @@ function RoleListDuplicateModal({
if (permission_ids.length > 0) {
await updateRolePermissions(roleResponse.id, permission_ids);
}
addSuccessToast(t('Role was successfully duplicated!'));
addSuccessToast(t('The role has been duplicated successfully.'));
} catch (err) {
addDangerToast(t('Error while duplicating role!'));
addDangerToast(
t('There was an error duplicating the role. Please, try again.'),
);
throw err;
}
};

View File

@@ -23,6 +23,7 @@ import {
fireEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { SupersetClient } from '@superset-ui/core';
import RoleListEditModal from './RoleListEditModal';
import {
updateRoleName,
@@ -44,12 +45,24 @@ jest.mock('src/components/MessageToasts/withToasts', () => ({
useToasts: () => mockToasts,
}));
jest.mock('@superset-ui/core', () => {
const original = jest.requireActual('@superset-ui/core');
return {
...original,
SupersetClient: {
get: jest.fn(),
},
t: (str: string) => str,
};
});
describe('RoleListEditModal', () => {
const mockRole = {
id: 1,
name: 'Admin',
permission_ids: [10, 20],
user_ids: [5, 7],
group_ids: [1, 2],
};
const mockPermissions = [
@@ -74,6 +87,17 @@ describe('RoleListEditModal', () => {
},
];
const mockGroups = [
{
id: 1,
name: 'Group A',
label: 'Group A',
description: 'Description A',
roles: [],
users: [],
},
];
const mockProps = {
role: mockRole,
show: true,
@@ -81,6 +105,7 @@ describe('RoleListEditModal', () => {
onSave: jest.fn(),
permissions: mockPermissions,
users: mockUsers,
groups: mockGroups,
};
it('renders modal with correct title and fields', () => {
@@ -114,6 +139,36 @@ describe('RoleListEditModal', () => {
});
it('calls update functions when save button is clicked', async () => {
(SupersetClient.get as jest.Mock).mockImplementation(({ endpoint }) => {
if (endpoint?.includes('/api/v1/security/users/')) {
return Promise.resolve({
json: {
count: 2,
result: [
{
id: 5,
first_name: 'John',
last_name: 'Doe',
username: 'johndoe',
email: 'john@example.com',
is_active: true,
},
{
id: 7,
first_name: 'Jane',
last_name: 'Smith',
username: 'janesmith',
email: 'jane@example.com',
is_active: true,
},
],
},
});
}
return Promise.resolve({ json: { count: 0, result: [] } });
});
render(<RoleListEditModal {...mockProps} />);
fireEvent.change(screen.getByTestId('role-name-input'), {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { t } from '@superset-ui/core';
import Tabs from 'src/components/Tabs';
import { RoleObject } from 'src/pages/RolesList';
@@ -25,13 +25,22 @@ import {
BaseModalProps,
FormattedPermission,
RoleForm,
UserObject,
} from 'src/features/roles/types';
import { CellProps } from 'react-table';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import FormModal from 'src/components/Modal/FormModal';
import { PermissionsField, RoleNameField, UsersField } from './RoleFormItems';
import { GroupObject } from 'src/pages/GroupsList';
import { FormInstance } from 'src/components';
import { fetchPaginatedData } from 'src/utils/fetchOptions';
import { UserObject } from 'src/pages/UsersList';
import {
GroupsField,
PermissionsField,
RoleNameField,
UsersField,
} from './RoleFormItems';
import {
updateRoleGroups,
updateRoleName,
updateRolePermissions,
updateRoleUsers,
@@ -40,7 +49,7 @@ import {
export interface RoleListEditModalProps extends BaseModalProps {
role: RoleObject;
permissions: FormattedPermission[];
users: UserObject[];
groups: GroupObject[];
}
const roleTabs = {
@@ -85,23 +94,67 @@ function RoleListEditModal({
role,
onSave,
permissions,
users,
groups,
}: RoleListEditModalProps) {
const { id, name, permission_ids, user_ids } = role;
const { id, name, permission_ids, user_ids, group_ids } = role;
const [activeTabKey, setActiveTabKey] = useState(roleTabs.edit.key);
const { addDangerToast, addSuccessToast } = useToasts();
const filteredUsers = users.filter(user =>
user?.roles.some(role => role.id === id),
);
const [roleUsers, setRoleUsers] = useState<UserObject[]>([]);
const [loadingRoleUsers, setLoadingRoleUsers] = useState(true);
const formRef = useRef<FormInstance | null>(null);
useEffect(() => {
if (!user_ids.length) {
setRoleUsers([]);
setLoadingRoleUsers(false);
return;
}
const filters = [{ col: 'id', opr: 'in', value: user_ids }];
fetchPaginatedData({
endpoint: `/api/v1/security/users/`,
pageSize: 100,
setData: setRoleUsers,
filters,
setLoadingState: (loading: boolean) => setLoadingRoleUsers(loading),
loadingKey: 'roleUsers',
addDangerToast,
errorMessage: t('There was an error loading users.'),
mapResult: (user: UserObject) => ({
id: user.id,
username: user.username,
}),
});
}, [user_ids]);
useEffect(() => {
if (!loadingRoleUsers && formRef.current && roleUsers.length >= 0) {
const userOptions = roleUsers.map(user => ({
value: user.id,
label: user.username,
}));
formRef.current.setFieldsValue({
roleUsers: userOptions,
});
}
}, [loadingRoleUsers, roleUsers]);
const handleFormSubmit = async (values: RoleForm) => {
try {
await updateRoleName(id, values.roleName);
await updateRolePermissions(id, values.rolePermissions);
await updateRoleUsers(id, values.roleUsers);
addSuccessToast(t('Role successfully updated!'));
const userIds = values.roleUsers?.map(user => user.value) || [];
await Promise.all([
updateRoleName(id, values.roleName),
updateRolePermissions(id, values.rolePermissions),
updateRoleUsers(id, userIds),
updateRoleGroups(id, values.roleGroups),
]);
addSuccessToast(t('The role has been updated successfully.'));
} catch (err) {
addDangerToast(t('Error while updating role!'));
addDangerToast(
t('There was an error updating the role. Please, try again.'),
);
throw err;
}
};
@@ -109,7 +162,12 @@ function RoleListEditModal({
const initialValues = {
roleName: name,
rolePermissions: permission_ids,
roleUsers: user_ids,
roleUsers:
roleUsers?.map(user => ({
value: user.id,
label: user.username,
})) || [],
roleGroups: group_ids,
};
return (
@@ -123,29 +181,39 @@ function RoleListEditModal({
bodyStyle={{ height: '400px' }}
requiredFields={['roleName']}
>
<Tabs
activeKey={activeTabKey}
onChange={activeKey => setActiveTabKey(activeKey)}
>
<Tabs.TabPane
tab={roleTabs.edit.name}
key={roleTabs.edit.key}
forceRender
>
<>
<RoleNameField />
<PermissionsField permissions={permissions} />
<UsersField users={users} />
</>
</Tabs.TabPane>
<Tabs.TabPane tab={roleTabs.users.name} key={roleTabs.users.key}>
<TableView
columns={userColumns}
data={filteredUsers}
emptyWrapperType={EmptyWrapperType.Small}
/>
</Tabs.TabPane>
</Tabs>
{(form: FormInstance) => {
formRef.current = form;
return (
<Tabs
activeKey={activeTabKey}
onChange={activeKey => setActiveTabKey(activeKey)}
>
<Tabs.TabPane
tab={roleTabs.edit.name}
key={roleTabs.edit.key}
forceRender
>
<>
<RoleNameField />
<PermissionsField permissions={permissions} />
<UsersField
addDangerToast={addDangerToast}
loading={loadingRoleUsers}
/>
<GroupsField groups={groups} />
</>
</Tabs.TabPane>
<Tabs.TabPane tab={roleTabs.users.name} key={roleTabs.users.key}>
<TableView
columns={userColumns}
data={roleUsers}
emptyWrapperType={EmptyWrapperType.Small}
/>
</Tabs.TabPane>
</Tabs>
);
}}
</FormModal>
);
}

View File

@@ -48,6 +48,11 @@ export type UserObject = {
roles: Array<RoleInfo>;
};
export type SelectOption = {
value: number;
label: string;
};
export type RoleInfo = {
id: number;
name: string;
@@ -56,7 +61,8 @@ export type RoleInfo = {
export type RoleForm = {
roleName: string;
rolePermissions: number[];
roleUsers: number[];
roleUsers: SelectOption[];
roleGroups: number[];
};
export interface BaseModalProps {

View File

@@ -40,6 +40,12 @@ export const updateRoleUsers = async (roleId: number, userIds: number[]) =>
jsonPayload: { user_ids: userIds },
});
export const updateRoleGroups = async (roleId: number, groupIds: number[]) =>
SupersetClient.put({
endpoint: `/api/v1/security/roles/${roleId}/groups`,
jsonPayload: { group_ids: groupIds },
});
export const updateRoleName = async (roleId: number, name: string) =>
SupersetClient.put({
endpoint: `/api/v1/security/roles/${roleId}`,

View File

@@ -23,15 +23,17 @@ import { FormItem } from 'src/components/Form';
import { Input } from 'src/components/Input';
import Checkbox from 'src/components/Checkbox';
import Select from 'src/components/Select/Select';
import { Role, UserObject } from 'src/pages/UsersList';
import { Group, Role, UserObject } from 'src/pages/UsersList';
import { FormInstance } from 'src/components';
import { Actions } from 'src/constants';
import { BaseUserListModalProps, FormValues } from './types';
import { createUser, updateUser } from './utils';
import { createUser, updateUser, atLeastOneRoleOrGroup } from './utils';
export interface UserModalProps extends BaseUserListModalProps {
roles: Role[];
isEditMode?: boolean;
user?: UserObject;
groups: Group[];
}
function UserListModal({
@@ -41,14 +43,18 @@ function UserListModal({
roles,
isEditMode = false,
user,
groups,
}: UserModalProps) {
const { addDangerToast, addSuccessToast } = useToasts();
const handleFormSubmit = async (values: FormValues) => {
const handleError = async (err: any, action: 'create' | 'update') => {
const handleError = async (
err: any,
action: Actions.CREATE | Actions.UPDATE,
) => {
let errorMessage =
action === 'create'
? t('Error while adding user!')
: t('Error while updating user!');
action === Actions.CREATE
? t('There was an error creating the user. Please, try again.')
: t('There was an error updating the user. Please, try again.');
if (err.status === 422) {
const errorData = await err.json();
@@ -61,7 +67,7 @@ function UserListModal({
);
} else if (detail.includes('ab_user_email_key')) {
errorMessage = t(
'This email is already associated with an account.',
'This email is already associated with an account. Please choose another one.',
);
}
}
@@ -77,35 +83,35 @@ function UserListModal({
}
try {
await updateUser(user.id, values);
addSuccessToast(t('User was successfully updated!'));
addSuccessToast(t('The user has been updated successfully.'));
} catch (err) {
await handleError(err, 'update');
await handleError(err, Actions.UPDATE);
}
} else {
try {
await createUser(values);
addSuccessToast(t('User was successfully created!'));
addSuccessToast(t('The group has been created successfully.'));
} catch (err) {
await handleError(err, 'create');
await handleError(err, Actions.CREATE);
}
}
};
const requiredFields = isEditMode
? ['first_name', 'last_name', 'username', 'email', 'roles']
? ['first_name', 'last_name', 'username', 'email']
: [
'first_name',
'last_name',
'username',
'email',
'password',
'roles',
'confirmPassword',
];
const initialValues = {
...user,
roles: user?.roles.map(role => role.id) || [],
roles: user?.roles?.map(role => role.id) || [],
groups: user?.groups?.map(group => group.id) || [],
};
return (
@@ -177,7 +183,8 @@ function UserListModal({
<FormItem
name="roles"
label={t('Roles')}
rules={[{ required: true, message: t('Role is required') }]}
dependencies={['groups']}
rules={[atLeastOneRoleOrGroup('groups')]}
>
<Select
name="roles"
@@ -192,7 +199,25 @@ function UserListModal({
}
/>
</FormItem>
<FormItem
name="groups"
label={t('Groups')}
dependencies={['roles']}
rules={[atLeastOneRoleOrGroup('roles')]}
>
<Select
name="groups"
mode="multiple"
placeholder={t('Select groups')}
options={groups.map(group => ({
value: group.id,
label: group.name,
}))}
getPopupContainer={trigger =>
trigger.closest('.antd5-modal-content')
}
/>
</FormItem>
{!isEditMode && (
<>
<FormItem

View File

@@ -16,7 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { SupersetClient } from '@superset-ui/core';
import { SupersetClient, t } from '@superset-ui/core';
import { SelectOption } from 'src/components/ListView';
import { FormValues } from './types';
export const createUser = async (values: FormValues) => {
@@ -41,3 +42,22 @@ export const deleteUser = async (userId: number) =>
SupersetClient.delete({
endpoint: `/api/v1/security/users/${userId}`,
});
export const atLeastOneRoleOrGroup =
(fieldToCheck: 'roles' | 'groups') =>
({
getFieldValue,
}: {
getFieldValue: (field: string) => Array<SelectOption>;
}) => ({
validator(_: Object, value: Array<SelectOption>) {
const current = value || [];
const other = getFieldValue(fieldToCheck) || [];
if (current.length === 0 && other.length === 0) {
return Promise.reject(
new Error(t('Please select at least one role or group')),
);
}
return Promise.resolve();
},
});

View File

@@ -0,0 +1,165 @@
/**
* 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 fetchMock from 'fetch-mock';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import {
render,
screen,
fireEvent,
waitFor,
act,
within,
} from 'spec/helpers/testing-library';
import { MemoryRouter } from 'react-router-dom';
import { QueryParamProvider } from 'use-query-params';
import GroupsList from './index';
const mockStore = configureStore([thunk]);
const store = mockStore({});
const mockUser = {
userId: 1,
firstName: 'Admin',
lastName: 'User',
roles: [{ id: 1, name: 'Admin' }],
};
const rolesEndpoint = 'glob:*/security/roles/?*';
const usersEndpoint = 'glob:*/security/users/?*';
const mockRoles = Array.from({ length: 3 }, (_, i) => ({
id: i,
name: `role ${i}`,
}));
const mockUsers = Array.from({ length: 3 }, (_, i) => ({
id: i,
username: `user${i}`,
}));
fetchMock.get(usersEndpoint, {
result: mockUsers,
count: 3,
});
fetchMock.get(rolesEndpoint, {
result: mockRoles,
count: 3,
});
jest.mock('src/dashboard/util/permissionUtils', () => ({
...jest.requireActual('src/dashboard/util/permissionUtils'),
isUserAdmin: jest.fn(() => true),
}));
describe('GroupsList', () => {
const renderComponent = async () => {
await act(async () => {
render(
<MemoryRouter>
<QueryParamProvider>
<GroupsList user={mockUser} />
</QueryParamProvider>
</MemoryRouter>,
{ useRedux: true, store },
);
});
};
beforeEach(() => {
fetchMock.resetHistory();
});
it('renders the page', async () => {
await renderComponent();
expect(await screen.findByText('List Groups')).toBeInTheDocument();
});
it('fetches roles on load', async () => {
await renderComponent();
await waitFor(() => {
expect(fetchMock.calls(rolesEndpoint).length).toBeGreaterThan(0);
});
});
it('renders add group button and triggers modal', async () => {
await renderComponent();
const addButton = screen.getByTestId('add-group-button');
fireEvent.click(addButton);
expect(await screen.findByTestId('Add Group-modal')).toBeInTheDocument();
});
it('renders actions column for admin', async () => {
await renderComponent();
expect(screen.getByText('Actions')).toBeInTheDocument();
});
it('renders the filters correctly', async () => {
await renderComponent();
const filtersSelect = screen.getAllByTestId('filters-select')[0];
expect(within(filtersSelect).getByText(/name/i)).toBeInTheDocument();
expect(within(filtersSelect).getByText(/label/i)).toBeInTheDocument();
expect(within(filtersSelect).getByText(/description/i)).toBeInTheDocument();
expect(within(filtersSelect).getByText(/roles/i)).toBeInTheDocument();
expect(within(filtersSelect).getByText(/users/i)).toBeInTheDocument();
});
it('renders correct columns in the table', async () => {
await renderComponent();
const table = screen.getByRole('table');
expect(await within(table).findByText('Name')).toBeInTheDocument();
expect(await within(table).findByText('Label')).toBeInTheDocument();
expect(await within(table).findByText('Roles')).toBeInTheDocument();
});
it('opens add group modal on button click', async () => {
await renderComponent();
const addButton = screen.getByTestId('add-group-button');
fireEvent.click(addButton);
expect(await screen.findByTestId('Add Group-modal')).toBeInTheDocument();
});
it('opens edit modal on edit button click', async () => {
fetchMock.get('glob:*/security/groups/?*', {
result: [
{
id: 1,
name: 'Editors',
label: 'editors',
description: 'Group for editors',
roles: mockRoles,
users: mockUsers,
},
],
count: 1,
});
await renderComponent();
const editButtons = await screen.findAllByTestId('group-list-edit-action');
fireEvent.click(editButtons[0]);
expect(await screen.findByTestId('Edit Group-modal')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,459 @@
/**
* 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 { useCallback, useEffect, useMemo, useState } from 'react';
import { css, t, useTheme } from '@superset-ui/core';
import { useListViewResource } from 'src/views/CRUD/hooks';
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar';
import ListView, {
ListViewProps,
Filters,
FilterOperator,
} from 'src/components/ListView';
import DeleteModal from 'src/components/DeleteModal';
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
import { Icons } from 'src/components/Icons';
import {
GroupListAddModal,
GroupListEditModal,
} from 'src/features/groups/GroupListModal';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { deleteGroup, fetchUserOptions } from 'src/features/groups/utils';
import { fetchPaginatedData } from 'src/utils/fetchOptions';
import { Tooltip } from 'src/components/Tooltip';
const PAGE_SIZE = 25;
interface GroupsListProps {
user: {
userId: string | number;
firstName: string;
lastName: string;
roles: object;
};
}
export type Role = {
id: number;
name: string;
};
export type User = {
id: number;
username: string;
};
export type GroupObject = {
id: number;
name: string;
label: string;
description: string;
roles: Role[];
users: User[];
};
enum ModalType {
ADD = 'add',
EDIT = 'edit',
}
function GroupsList({ user }: GroupsListProps) {
const theme = useTheme();
const { addDangerToast, addSuccessToast } = useToasts();
const {
state: {
loading,
resourceCount: groupsCount,
resourceCollection: groups,
bulkSelectEnabled,
},
fetchData,
refreshData,
toggleBulkSelect,
} = useListViewResource<GroupObject>(
'security/groups',
t('Group'),
addDangerToast,
);
const [modalState, setModalState] = useState({
edit: false,
add: false,
});
const openModal = (type: ModalType) =>
setModalState(prev => ({ ...prev, [type]: true }));
const closeModal = (type: ModalType) =>
setModalState(prev => ({ ...prev, [type]: false }));
const [currentGroup, setCurrentGroup] = useState<GroupObject | null>(null);
const [groupCurrentlyDeleting, setGroupCurrentlyDeleting] =
useState<GroupObject | null>(null);
const [loadingState, setLoadingState] = useState({
roles: true,
});
const [roles, setRoles] = useState<Role[]>([]);
const isAdmin = useMemo(() => isUserAdmin(user), [user]);
const fetchRoles = useCallback(() => {
fetchPaginatedData({
endpoint: '/api/v1/security/roles/',
setData: setRoles,
setLoadingState,
loadingKey: 'roles',
addDangerToast,
errorMessage: t('Error while fetching roles'),
});
}, [addDangerToast]);
useEffect(() => {
fetchRoles();
}, [fetchRoles]);
const handleGroupDelete = async ({ id, name }: GroupObject) => {
try {
await deleteGroup(id);
refreshData();
setGroupCurrentlyDeleting(null);
addSuccessToast(t('Deleted group: %s', name));
} catch (error) {
addDangerToast(t('There was an issue deleting %s', name));
}
};
const handleBulkGroupsDelete = (groupsToDelete: GroupObject[]) => {
const deletedGroupsNames: string[] = [];
Promise.all(
groupsToDelete.map(group =>
deleteGroup(group.id)
.then(() => {
deletedGroupsNames.push(group.name);
})
.catch(err => {
addDangerToast(t('Error deleting %s', group.name));
}),
),
)
.then(() => {
if (deletedGroupsNames.length > 0) {
addSuccessToast(
t('Deleted groups: %s', deletedGroupsNames.join(', ')),
);
}
})
.finally(() => {
refreshData();
});
};
const initialSort = [{ id: 'name', desc: true }];
const columns = useMemo(
() => [
{
accessor: 'name',
id: 'name',
Header: t('Name'),
Cell: ({
row: {
original: { name },
},
}: any) => <span>{name}</span>,
},
{
accessor: 'label',
id: 'label',
Header: t('Label'),
Cell: ({
row: {
original: { label },
},
}: any) => <span>{label}</span>,
},
{
accessor: 'description',
id: 'description',
Header: t('Description'),
Cell: ({
row: {
original: { description },
},
}: any) => <span>{description}</span>,
hidden: true,
},
{
accessor: 'roles',
id: 'roles',
Header: t('Roles'),
Cell: ({
row: {
original: { roles },
},
}: any) => (
<Tooltip
title={
roles?.map((role: Role) => role.name).join(', ') || t('No roles')
}
>
<span>{roles?.map((role: Role) => role.name).join(', ')}</span>
</Tooltip>
),
disableSortBy: true,
},
{
accessor: 'users',
id: 'users',
Header: t('Users'),
Cell: ({
row: {
original: { users },
},
}: any) => (
<span>{users?.map((user: User) => user.username).join(', ')}</span>
),
disableSortBy: true,
hidden: true,
},
{
Cell: ({ row: { original } }: any) => {
const handleEdit = () => {
setCurrentGroup(original);
openModal(ModalType.EDIT);
};
const handleDelete = () => setGroupCurrentlyDeleting(original);
const actions = isAdmin
? [
{
label: 'group-list-edit-action',
tooltip: t('Edit group'),
placement: 'bottom',
icon: 'EditOutlined',
onClick: handleEdit,
},
{
label: 'group-list-delete-action',
tooltip: t('Delete group'),
placement: 'bottom',
icon: 'DeleteOutlined',
onClick: handleDelete,
},
]
: [];
return <ActionsBar actions={actions as ActionProps[]} />;
},
Header: t('Actions'),
id: 'actions',
disableSortBy: true,
hidden: !isAdmin,
size: 'xl',
},
],
[isAdmin],
);
const subMenuButtons: SubMenuProps['buttons'] = [];
if (isAdmin) {
subMenuButtons.push(
{
name: (
<>
<Icons.PlusOutlined
iconColor={theme.colors.primary.light5}
iconSize="m"
css={css`
margin: auto ${theme.gridUnit * 2}px auto 0;
vertical-align: text-top;
`}
/>
{t('Group')}
</>
),
buttonStyle: 'primary',
onClick: () => {
openModal(ModalType.ADD);
},
loading: loadingState.roles,
'data-test': 'add-group-button',
},
{
name: t('Bulk select'),
onClick: toggleBulkSelect,
buttonStyle: 'secondary',
},
);
}
const filters: Filters = useMemo(
() => [
{
Header: t('Name'),
key: 'name',
id: 'name',
input: 'search',
operator: FilterOperator.Contains,
},
{
Header: t('Label'),
key: 'label',
id: 'label',
input: 'search',
operator: FilterOperator.Contains,
},
{
Header: t('Description'),
key: 'description',
id: 'description',
input: 'search',
operator: FilterOperator.Contains,
},
{
Header: t('Roles'),
key: 'roles',
id: 'roles',
input: 'select',
operator: FilterOperator.RelationManyMany,
unfilteredLabel: t('All'),
selects: roles?.map(role => ({
label: role.name,
value: role.id,
})),
loading: loadingState.roles,
},
{
Header: t('Users'),
key: 'users',
id: 'users',
input: 'select',
operator: FilterOperator.RelationManyMany,
unfilteredLabel: t('All'),
fetchSelects: async (filterValue, page, pageSize) =>
fetchUserOptions(filterValue, page, pageSize, addDangerToast),
},
],
[loadingState.roles, roles],
);
const emptyState = {
title: t('No groups yet'),
image: 'filter-results.svg',
...(isAdmin && {
buttonAction: () => {
openModal(ModalType.ADD);
},
buttonText: (
<>
<Icons.PlusOutlined
iconColor={theme.colors.primary.light5}
iconSize="m"
css={css`
margin: auto ${theme.gridUnit * 2}px auto 0;
vertical-align: text-top;
`}
/>
{t('Group')}
</>
),
}),
};
return (
<>
<SubMenu name={t('List Groups')} buttons={subMenuButtons} />
<GroupListAddModal
onHide={() => closeModal(ModalType.ADD)}
show={modalState.add}
onSave={() => {
refreshData();
closeModal(ModalType.ADD);
}}
roles={roles}
/>
{modalState.edit && currentGroup && (
<GroupListEditModal
group={currentGroup}
show={modalState.edit}
onHide={() => closeModal(ModalType.EDIT)}
onSave={() => {
refreshData();
closeModal(ModalType.EDIT);
}}
roles={roles}
/>
)}
{groupCurrentlyDeleting && (
<DeleteModal
description={t('This action will permanently delete the group.')}
onConfirm={() => {
if (groupCurrentlyDeleting) {
handleGroupDelete(groupCurrentlyDeleting);
}
}}
onHide={() => setGroupCurrentlyDeleting(null)}
open
title={t('Delete Group?')}
/>
)}
<ConfirmStatusChange
title={t('Please confirm')}
description={t('Are you sure you want to delete the selected groups?')}
onConfirm={handleBulkGroupsDelete}
>
{confirmDelete => {
const bulkActions: ListViewProps['bulkActions'] = isAdmin
? [
{
key: 'delete',
name: t('Delete'),
onSelect: confirmDelete,
type: 'danger',
},
]
: [];
return (
<ListView<GroupObject>
className="group-list-view"
columns={columns}
count={groupsCount}
data={groups}
fetchData={fetchData}
filters={filters}
initialSort={initialSort}
loading={loading}
pageSize={PAGE_SIZE}
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
disableBulkSelect={toggleBulkSelect}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
emptyState={emptyState}
refreshData={refreshData}
/>
);
}}
</ConfirmStatusChange>
</>
);
}
export default GroupsList;

View File

@@ -137,13 +137,11 @@ describe('RolesList', () => {
});
});
it('fetches permissions and users on load', async () => {
it('fetches permissions on load', async () => {
await renderAndWait();
await waitFor(() => {
const permissionCalls = fetchMock.calls(permissionsEndpoint);
const userCalls = fetchMock.calls(usersEndpoint);
expect(permissionCalls.length).toBeGreaterThan(0);
expect(userCalls.length).toBeGreaterThan(0);
});
});
@@ -151,7 +149,7 @@ describe('RolesList', () => {
await renderAndWait();
const typeFilter = screen.queryAllByTestId('filters-select');
expect(typeFilter).toHaveLength(3);
expect(typeFilter).toHaveLength(4);
});
it('renders correct list columns', async () => {

View File

@@ -33,13 +33,12 @@ import ListView, {
} from 'src/components/ListView';
import DeleteModal from 'src/components/DeleteModal';
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
import {
FormattedPermission,
PermissionResource,
UserObject,
} from 'src/features/roles/types';
import { FormattedPermission, UserObject } from 'src/features/roles/types';
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
import { Icons } from 'src/components/Icons';
import { fetchPaginatedData } from 'src/utils/fetchOptions';
import { fetchUserOptions } from 'src/features/groups/utils';
import { GroupObject } from '../GroupsList';
const PAGE_SIZE = 25;
@@ -60,6 +59,7 @@ export type RoleObject = {
permission_ids: number[];
users?: Array<UserObject>;
user_ids: number[];
group_ids: number[];
};
enum ModalType {
@@ -100,108 +100,48 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
const [roleCurrentlyDeleting, setRoleCurrentlyDeleting] =
useState<RoleObject | null>(null);
const [permissions, setPermissions] = useState<FormattedPermission[]>([]);
const [users, setUsers] = useState<UserObject[]>([]);
const [groups, setGroups] = useState<GroupObject[]>([]);
const [loadingState, setLoadingState] = useState({
permissions: true,
users: true,
groups: true,
});
const isAdmin = useMemo(() => isUserAdmin(user), [user]);
const fetchPermissions = useCallback(async () => {
try {
const pageSize = 100;
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 fetchPage = async (pageIndex: number) => {
const response = await SupersetClient.get({
endpoint: `api/v1/security/permissions-resources/?q=(page_size:${pageSize},page:${pageIndex})`,
});
return {
count: response.json.count,
results: response.json.result.map(
({ permission, view_menu, id }: PermissionResource) => ({
label: `${permission.name.replace(/_/g, ' ')} ${view_menu.name.replace(/_/g, ' ')}`,
value: `${permission.name}__${view_menu.name}`,
id,
}),
),
};
};
const initialResponse = await fetchPage(0);
const totalPermissions = initialResponse.count;
const firstPageResults = initialResponse.results;
if (firstPageResults.length >= totalPermissions) {
setPermissions(firstPageResults);
return;
}
const totalPages = Math.ceil(totalPermissions / pageSize);
const permissionRequests = Array.from(
{ length: totalPages - 1 },
(_, i) => fetchPage(i + 1),
);
const remainingResults = await Promise.all(permissionRequests);
setPermissions([
...firstPageResults,
...remainingResults.flatMap(res => res.results),
]);
} catch (err) {
addDangerToast(t('Error while fetching permissions'));
} finally {
setLoadingState(prev => ({ ...prev, permissions: false }));
}
}, []);
const fetchUsers = useCallback(async () => {
try {
const pageSize = 100;
const fetchPage = async (pageIndex: number) => {
const response = await SupersetClient.get({
endpoint: `api/v1/security/users/?q=(page_size:${pageSize},page:${pageIndex})`,
});
return response.json;
};
const initialResponse = await fetchPage(0);
const totalUsers = initialResponse.count;
const firstPageResults = initialResponse.result;
if (pageSize >= totalUsers) {
setUsers(firstPageResults);
return;
}
const totalPages = Math.ceil(totalUsers / pageSize);
const userRequests = Array.from({ length: totalPages - 1 }, (_, i) =>
fetchPage(i + 1),
);
const remainingResults = await Promise.all(userRequests);
setUsers([
...firstPageResults,
...remainingResults.flatMap(res => res.result),
]);
} catch (err) {
addDangerToast(t('Error while fetching users'));
} finally {
setLoadingState(prev => ({ ...prev, users: false }));
}
}, []);
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(() => {
fetchUsers();
}, [fetchUsers]);
fetchGroups();
}, [fetchGroups]);
const handleRoleDelete = async ({ id, name }: RoleObject) => {
try {
@@ -246,6 +186,7 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
() => [
{
accessor: 'name',
id: 'name',
Header: t('Name'),
Cell: ({
row: {
@@ -255,12 +196,21 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
},
{
accessor: 'user_ids',
id: 'user_ids',
Header: t('Users'),
hidden: true,
Cell: ({ row: { original } }: any) => original.user_ids.join(', '),
},
{
accessor: 'group_ids',
id: 'group_ids',
Header: t('Groups'),
hidden: true,
Cell: ({ row: { original } }: any) => original.groups_ids.join(', '),
},
{
accessor: 'permission_ids',
id: 'permission_ids',
Header: t('Permissions'),
hidden: true,
Cell: ({ row: { original } }: any) =>
@@ -365,11 +315,8 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
input: 'select',
operator: FilterOperator.RelationOneMany,
unfilteredLabel: t('All'),
selects: users?.map(user => ({
label: user.username,
value: user.id,
})),
loading: loadingState.users,
fetchSelects: async (filterValue, page, pageSize) =>
fetchUserOptions(filterValue, page, pageSize, addDangerToast),
},
{
Header: t('Permissions'),
@@ -384,8 +331,21 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
})),
loading: loadingState.permissions,
},
{
Header: t('Groups'),
key: 'group_ids',
id: 'group_ids',
input: 'select',
operator: FilterOperator.RelationOneMany,
unfilteredLabel: t('All'),
selects: groups?.map(group => ({
label: group.name,
value: group.id,
})),
loading: loadingState.groups,
},
],
[permissions, users, loadingState.users, loadingState.permissions],
[permissions, groups, loadingState.groups, loadingState.permissions],
);
const emptyState = {
@@ -431,10 +391,9 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) {
onSave={() => {
refreshData();
closeModal(ModalType.EDIT);
fetchUsers();
}}
permissions={permissions}
users={users}
groups={groups}
/>
)}
{modalState.duplicate && currentRole && (

View File

@@ -18,7 +18,7 @@
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { css, t, SupersetClient, useTheme } from '@superset-ui/core';
import { css, t, useTheme } from '@superset-ui/core';
import { useListViewResource } from 'src/views/CRUD/hooks';
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar';
@@ -37,6 +37,8 @@ import {
} from 'src/features/users/UserListModal';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { deleteUser } from 'src/features/users/utils';
import { fetchPaginatedData } from 'src/utils/fetchOptions';
import { Tooltip } from 'src/components/Tooltip';
const PAGE_SIZE = 25;
@@ -54,6 +56,11 @@ export type Role = {
name: string;
};
export type Group = {
id: number;
name: string;
};
export type UserObject = {
active: boolean;
changed_by: string | null;
@@ -69,6 +76,7 @@ export type UserObject = {
login_count: number;
roles: Role[];
username: string;
groups: Group[];
};
enum ModalType {
@@ -108,7 +116,6 @@ function UsersList({ user }: UsersListProps) {
const [modalState, setModalState] = useState({
edit: false,
add: false,
duplicate: false,
});
const openModal = (type: ModalType) =>
setModalState(prev => ({ ...prev, [type]: true }));
@@ -118,8 +125,12 @@ function UsersList({ user }: UsersListProps) {
const [currentUser, setCurrentUser] = useState<UserObject | null>(null);
const [userCurrentlyDeleting, setUserCurrentlyDeleting] =
useState<UserObject | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [loadingState, setLoadingState] = useState({
roles: true,
groups: true,
});
const [roles, setRoles] = useState<Role[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const loginCountStats = useMemo(() => {
if (!users || users.length === 0) return { min: 0, max: 0 };
@@ -141,48 +152,36 @@ function UsersList({ user }: UsersListProps) {
const isAdmin = useMemo(() => isUserAdmin(user), [user]);
const fetchRoles = useCallback(async () => {
try {
const pageSize = 100;
const fetchRoles = useCallback(() => {
fetchPaginatedData({
endpoint: '/api/v1/security/roles/',
setData: setRoles,
setLoadingState,
loadingKey: 'roles',
addDangerToast,
errorMessage: t('Error while fetching roles'),
});
}, [addDangerToast]);
const fetchPage = async (pageIndex: number) => {
const response = await SupersetClient.get({
endpoint: `api/v1/security/roles/?q=(page_size:${pageSize},page:${pageIndex})`,
});
return response.json;
};
const initialResponse = await fetchPage(0);
const totalRoles = initialResponse.count;
const firstPageResults = initialResponse.result;
if (pageSize >= totalRoles) {
setRoles(firstPageResults);
return;
}
const totalPages = Math.ceil(totalRoles / pageSize);
const roleRequests = Array.from({ length: totalPages - 1 }, (_, i) =>
fetchPage(i + 1),
);
const remainingResults = await Promise.all(roleRequests);
setRoles([
...firstPageResults,
...remainingResults.flatMap(res => res.result),
]);
} catch (err) {
addDangerToast(t('Error while fetching roles'));
} finally {
setIsLoading(false);
}
}, []);
const fetchGroups = useCallback(() => {
fetchPaginatedData({
endpoint: '/api/v1/security/groups/',
setData: setGroups,
setLoadingState,
loadingKey: 'groups',
addDangerToast,
errorMessage: t('Error while fetching groups'),
});
}, [addDangerToast]);
useEffect(() => {
fetchRoles();
}, [fetchRoles]);
useEffect(() => {
fetchGroups();
}, [fetchGroups]);
const handleUserDelete = async ({ id, username }: UserObject) => {
try {
await deleteUser(id);
@@ -224,6 +223,7 @@ function UsersList({ user }: UsersListProps) {
{
accessor: 'first_name',
Header: t('First name'),
id: 'first_name',
Cell: ({
row: {
original: { first_name },
@@ -233,6 +233,7 @@ function UsersList({ user }: UsersListProps) {
{
accessor: 'last_name',
Header: t('Last name'),
id: 'last_name',
Cell: ({
row: {
original: { last_name },
@@ -242,6 +243,7 @@ function UsersList({ user }: UsersListProps) {
{
accessor: 'username',
Header: t('Username'),
id: 'username',
Cell: ({
row: {
original: { username },
@@ -251,6 +253,7 @@ function UsersList({ user }: UsersListProps) {
{
accessor: 'email',
Header: t('Email'),
id: 'email',
Cell: ({
row: {
original: { email },
@@ -260,6 +263,7 @@ function UsersList({ user }: UsersListProps) {
{
accessor: 'active',
Header: t('Is active?'),
id: 'active',
Cell: ({
row: {
original: { active },
@@ -269,30 +273,60 @@ function UsersList({ user }: UsersListProps) {
{
accessor: 'roles',
Header: t('Roles'),
id: 'roles',
Cell: ({
row: {
original: { roles },
},
}: any) => (
<span>{roles.map((role: Role) => role.name).join(', ')}</span>
<Tooltip
title={
roles?.map((role: Role) => role.name).join(', ') || t('No roles')
}
>
<span>{roles?.map((role: Role) => role.name).join(', ')}</span>
</Tooltip>
),
disableSortBy: true,
},
{
accessor: 'groups',
Header: t('Groups'),
id: 'groups',
Cell: ({
row: {
original: { groups },
},
}: any) => (
<Tooltip
title={
groups?.map((group: Group) => group.name).join(', ') ||
t('No groups')
}
>
<span>{groups?.map((group: Group) => group.name).join(', ')}</span>
</Tooltip>
),
disableSortBy: true,
},
{
accessor: 'login_count',
Header: t('Login count'),
id: 'login_count',
hidden: true,
Cell: ({ row: { original } }: any) => original.login_count,
},
{
accessor: 'fail_login_count',
Header: t('Fail login count'),
id: 'fail_login_count',
hidden: true,
Cell: ({ row: { original } }: any) => original.fail_login_count,
},
{
accessor: 'created_on',
Header: t('Created on'),
id: 'created_on',
hidden: true,
Cell: ({
row: {
@@ -302,6 +336,7 @@ function UsersList({ user }: UsersListProps) {
},
{
accessor: 'changed_on',
id: 'changed_on',
Header: t('Changed on'),
hidden: true,
Cell: ({
@@ -313,6 +348,7 @@ function UsersList({ user }: UsersListProps) {
{
accessor: 'last_login',
Header: t('Last login'),
id: 'last_login',
hidden: true,
Cell: ({
row: {
@@ -380,7 +416,7 @@ function UsersList({ user }: UsersListProps) {
onClick: () => {
openModal(ModalType.ADD);
},
loading: isLoading,
loading: loadingState.roles || loadingState.groups,
'data-test': 'add-user-button',
},
{
@@ -432,7 +468,6 @@ function UsersList({ user }: UsersListProps) {
label: option.label,
value: option.value,
})),
loading: isLoading,
},
{
Header: t('Roles'),
@@ -445,7 +480,20 @@ function UsersList({ user }: UsersListProps) {
label: role.name,
value: role.id,
})),
loading: isLoading,
loading: loadingState.roles,
},
{
Header: t('Groups'),
key: 'groups',
id: 'groups',
input: 'select',
operator: FilterOperator.RelationManyMany,
unfilteredLabel: t('All'),
selects: groups?.map(group => ({
label: group.name,
value: group.id,
})),
loading: loadingState.groups,
},
{
Header: t('Created on'),
@@ -488,7 +536,14 @@ function UsersList({ user }: UsersListProps) {
operator: FilterOperator.Between,
},
],
[isLoading, roles, loginCountStats, failLoginCountStats],
[
loadingState.roles,
roles,
groups,
loadingState.groups,
loginCountStats,
failLoginCountStats,
],
);
const emptyState = {
@@ -525,6 +580,7 @@ function UsersList({ user }: UsersListProps) {
closeModal(ModalType.ADD);
}}
roles={roles}
groups={groups}
/>
{modalState.edit && currentUser && (
<UserListEditModal
@@ -536,6 +592,7 @@ function UsersList({ user }: UsersListProps) {
closeModal(ModalType.EDIT);
}}
roles={roles}
groups={groups}
/>
)}

View File

@@ -0,0 +1,113 @@
/**
* 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, t } from '@superset-ui/core';
import rison from 'rison';
import { Dispatch, SetStateAction } from 'react';
interface FetchPaginatedOptions {
endpoint: string;
pageSize?: number;
setData: (data: any[]) => void;
setLoadingState: Dispatch<SetStateAction<any>>;
filters?: SupersetFilter[];
loadingKey: string;
addDangerToast: (message: string) => void;
errorMessage?: string;
mapResult?: (item: any) => any;
}
interface QueryObj {
page_size: number;
page: number;
filters?: SupersetFilter[];
}
interface SupersetFilter {
col: string;
opr: string;
value: string | number | (string | number)[];
}
export const fetchPaginatedData = async ({
endpoint,
pageSize = 100,
setData,
filters,
setLoadingState,
loadingKey,
addDangerToast,
errorMessage = 'Error while fetching data',
mapResult = (item: any) => item,
}: FetchPaginatedOptions) => {
try {
const fetchPage = async (pageIndex: number) => {
const queryObj: QueryObj = {
page_size: pageSize,
page: pageIndex,
};
if (filters) {
queryObj.filters = filters;
}
const encodedQuery = rison.encode(queryObj);
const response = await SupersetClient.get({
endpoint: `${endpoint}?q=${encodedQuery}`,
});
return {
count: response.json.count,
results: response.json.result.map(mapResult),
};
};
const initialResponse = await fetchPage(0);
const totalItems = initialResponse.count;
const firstPageResults = initialResponse.results;
if (pageSize >= totalItems) {
setData(firstPageResults);
return;
}
const totalPages = Math.ceil(totalItems / pageSize);
const requests = Array.from({ length: totalPages - 1 }, (_, i) =>
fetchPage(i + 1),
);
const remainingResults = await Promise.all(requests);
setData([
...firstPageResults,
...remainingResults.flatMap(res => res.results),
]);
} catch (err) {
addDangerToast(t(errorMessage));
} finally {
setLoadingState((prev: boolean | Record<string, boolean>) => {
if (typeof prev === 'boolean') {
return false;
}
return {
...prev,
[loadingKey]: false,
};
});
}
};

View File

@@ -141,6 +141,9 @@ const ActionLogList: LazyExoticComponent<any> = lazy(
() => import(/* webpackChunkName: "ActionLogList" */ 'src/pages/ActionLog'),
);
const GroupsList: LazyExoticComponent<any> = lazy(
() => import(/* webpackChunkName: "GroupsList" */ 'src/pages/GroupsList'),
);
type Routes = {
path: string;
Component: ComponentType;
@@ -273,6 +276,10 @@ if (isAdmin) {
path: '/users/',
Component: UsersList,
},
{
path: '/list_groups/',
Component: GroupsList,
},
);
}

View File

@@ -174,6 +174,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
from superset.views.dynamic_plugins import DynamicPluginsView
from superset.views.error_handling import set_app_error_handlers
from superset.views.explore import ExplorePermalinkView, ExploreView
from superset.views.groups import GroupsListView
from superset.views.log.api import LogRestApi
from superset.views.logs import ActionLogView
from superset.views.roles import RolesListView
@@ -298,6 +299,14 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
category_label=__("Security"),
)
appbuilder.add_view(
GroupsListView,
"List Groups",
label=__("List Groups"),
category="Security",
category_label=__("Security"),
)
appbuilder.add_view(
DynamicPluginsView,
"Plugins",

View File

@@ -308,6 +308,9 @@ class RoleRestAPI(BaseSupersetApi):
Role.permissions.any(id=filter_dict["permission_ids"])
)
if "group_ids" in filter_dict:
query = query.filter(Role.groups.any(id=filter_dict["group_ids"]))
if "name" in filter_dict:
query = query.filter(Role.name.ilike(f"%{filter_dict['name']}%"))
@@ -323,6 +326,7 @@ class RoleRestAPI(BaseSupersetApi):
"name": role.name,
"user_ids": [user.id for user in role.user],
"permission_ids": [perm.id for perm in role.permissions],
"group_ids": [group.id for group in role.groups],
}
for role in roles
],

View File

@@ -145,6 +145,7 @@ class SupersetUserApi(UserApi):
search_columns = [
"id",
"roles",
"groups",
"first_name",
"last_name",
"username",
@@ -281,6 +282,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
"User's Statistics",
# Guarding all AB_ADD_SECURITY_API = True REST APIs
"RoleRestAPI",
"Group",
"Role",
"Permission",
"PermissionViewMenu",
@@ -2790,7 +2792,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
for view in list(self.appbuilder.baseviews):
if isinstance(view, self.rolemodelview.__class__) and getattr(
view, "route_base", None
) in ["/roles", "/users"]:
) in ["/roles", "/users", "/groups"]:
self.appbuilder.baseviews.remove(view)
security_menu = next(
@@ -2798,5 +2800,5 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
)
if security_menu:
for item in list(security_menu.childs):
if item.name in ["List Roles", "List Users"]:
if item.name in ["List Roles", "List Users", "List Groups"]:
security_menu.childs.remove(item)

34
superset/views/groups.py Normal file
View File

@@ -0,0 +1,34 @@
# 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.
from flask_appbuilder import permission_name
from flask_appbuilder.api import expose
from flask_appbuilder.security.decorators import has_access
from superset.superset_typing import FlaskResponse
from .base import BaseSupersetView
class GroupsListView(BaseSupersetView):
route_base = "/"
class_permission_name = "security"
@expose("/list_groups/")
@has_access
@permission_name("read")
def list(self) -> FlaskResponse:
return super().render_app_template()