]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-18430 Migrate permissions app tests to RTL
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Tue, 21 Feb 2023 07:17:04 +0000 (08:17 +0100)
committersonartech <sonartech@sonarsource.com>
Thu, 23 Feb 2023 20:03:01 +0000 (20:03 +0000)
32 files changed:
server/sonar-web/src/main/js/api/mocks/PermissionsServiceMock.ts
server/sonar-web/src/main/js/apps/permission-templates/components/Template.tsx
server/sonar-web/src/main/js/apps/permission-templates/components/__tests__/PermissionTemplatesApp-it.tsx
server/sonar-web/src/main/js/apps/permissions/__tests__/utils-test.ts [deleted file]
server/sonar-web/src/main/js/apps/permissions/global/components/App.tsx [deleted file]
server/sonar-web/src/main/js/apps/permissions/global/components/PermissionsGlobalApp.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/App-test.tsx [deleted file]
server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/PermissionsGlobal-it.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/__snapshots__/App-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx
server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectApp.tsx
server/sonar-web/src/main/js/apps/permissions/project/components/__tests__/PermissionsProject-it.tsx
server/sonar-web/src/main/js/apps/permissions/routes.tsx
server/sonar-web/src/main/js/apps/permissions/shared/components/AllHoldersList.tsx [deleted file]
server/sonar-web/src/main/js/apps/permissions/shared/components/GroupHolder.tsx [deleted file]
server/sonar-web/src/main/js/apps/permissions/shared/components/HoldersList.tsx [deleted file]
server/sonar-web/src/main/js/apps/permissions/shared/components/PermissionCell.tsx [deleted file]
server/sonar-web/src/main/js/apps/permissions/shared/components/PermissionHeader.tsx [deleted file]
server/sonar-web/src/main/js/apps/permissions/shared/components/SearchForm.tsx [deleted file]
server/sonar-web/src/main/js/apps/permissions/shared/components/UserHolder.tsx [deleted file]
server/sonar-web/src/main/js/apps/permissions/test-utils.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/permissions/utils.ts [deleted file]
server/sonar-web/src/main/js/components/permissions/AllHoldersList.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/permissions/GroupHolder.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/permissions/HoldersList.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/permissions/PermissionCell.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/permissions/PermissionHeader.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/permissions/SearchForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/permissions/UserHolder.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/__tests__/permissions-test.ts [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/permissions.ts [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx

index 96531f55e5c910b84e9faa86fc95ddf53c723ba7..c8edbe64903a1c6bc276048b12af5a36e3958891 100644 (file)
@@ -35,6 +35,8 @@ import {
   applyTemplateToProject,
   bulkApplyTemplate,
   changeProjectVisibility,
+  getGlobalPermissionsGroups,
+  getGlobalPermissionsUsers,
   getPermissionsGroupsForComponent,
   getPermissionsUsersForComponent,
   getPermissionTemplateGroups,
@@ -115,21 +117,21 @@ const DEFAULT_PAGE = 1;
 jest.mock('../permissions');
 
 export default class PermissionsServiceMock {
-  permissionTemplates: PermissionTemplate[] = [];
-  permissions: Permission[];
-  defaultTemplates: Array<{ templateId: string; qualifier: string }>;
-  groups: PermissionGroup[];
-  users: PermissionUser[];
-  isAllowedPermissionChange = true;
+  #permissionTemplates: PermissionTemplate[] = [];
+  #permissions: Permission[];
+  #defaultTemplates: Array<{ templateId: string; qualifier: string }>;
+  #groups: PermissionGroup[];
+  #users: PermissionUser[];
+  #isAllowedToChangePermissions = true;
 
   constructor() {
-    this.permissionTemplates = cloneDeep(defaultPermissionTemplates);
-    this.defaultTemplates = [
+    this.#permissionTemplates = cloneDeep(defaultPermissionTemplates);
+    this.#defaultTemplates = [
       ComponentQualifier.Project,
       ComponentQualifier.Application,
       ComponentQualifier.Portfolio,
-    ].map((qualifier) => ({ templateId: this.permissionTemplates[0].id, qualifier }));
-    this.permissions = [
+    ].map((qualifier) => ({ templateId: this.#permissionTemplates[0].id, qualifier }));
+    this.#permissions = [
       Permissions.Admin,
       Permissions.CodeViewer,
       Permissions.IssueAdmin,
@@ -137,8 +139,8 @@ export default class PermissionsServiceMock {
       Permissions.Scan,
       Permissions.Browse,
     ].map((key) => mockPermission({ key, name: key }));
-    this.groups = cloneDeep(defaultGroups);
-    this.users = cloneDeep(defaultUsers);
+    this.#groups = cloneDeep(defaultGroups);
+    this.#users = cloneDeep(defaultUsers);
 
     jest.mocked(getPermissionTemplates).mockImplementation(this.handleGetPermissionTemplates);
     jest.mocked(bulkApplyTemplate).mockImplementation(this.handleBulkApplyTemplate);
@@ -156,6 +158,8 @@ export default class PermissionsServiceMock {
     jest.mocked(grantTemplatePermissionToUser).mockImplementation(this.handlePermissionChange);
     jest.mocked(revokeTemplatePermissionFromUser).mockImplementation(this.handlePermissionChange);
     jest.mocked(changeProjectVisibility).mockImplementation(this.handleChangeProjectVisibility);
+    jest.mocked(getGlobalPermissionsUsers).mockImplementation(this.handleGetPermissionUsers);
+    jest.mocked(getGlobalPermissionsGroups).mockImplementation(this.handleGetPermissionGroups);
     jest
       .mocked(getPermissionsGroupsForComponent)
       .mockImplementation(this.handleGetPermissionGroupsForComponent);
@@ -170,9 +174,9 @@ export default class PermissionsServiceMock {
 
   handleGetPermissionTemplates = () => {
     return this.reply({
-      permissionTemplates: this.permissionTemplates,
-      defaultTemplates: this.defaultTemplates,
-      permissions: this.permissions,
+      permissionTemplates: this.#permissionTemplates,
+      defaultTemplates: this.#defaultTemplates,
+      permissions: this.#permissions,
     });
   };
 
@@ -238,8 +242,7 @@ export default class PermissionsServiceMock {
     return this.reply(undefined);
   };
 
-  handleGetPermissionGroupsForComponent = (data: {
-    projectKey: string;
+  handleGetPermissionUsers = (data: {
     q?: string;
     permission?: string;
     p?: number;
@@ -247,24 +250,23 @@ export default class PermissionsServiceMock {
   }) => {
     const { ps = PAGE_SIZE, p = DEFAULT_PAGE, q, permission } = data;
 
-    const groups =
+    const users =
       q && q.length >= MIN_QUERY_LENGTH
-        ? this.groups.filter((group) => group.name.toLowerCase().includes(q.toLowerCase()))
-        : this.groups;
+        ? this.#users.filter((user) => user.name.toLowerCase().includes(q.toLowerCase()))
+        : this.#users;
 
-    const groupsChunked = chunk(
-      permission ? groups.filter((g) => g.permissions.includes(permission)) : groups,
+    const usersChunked = chunk(
+      permission ? users.filter((u) => u.permissions.includes(permission)) : users,
       ps
     );
 
     return this.reply({
-      paging: { pageSize: ps, total: groups.length, pageIndex: p },
-      groups: groupsChunked[p - 1] ?? [],
+      paging: { pageSize: ps, total: users.length, pageIndex: p },
+      users: usersChunked[p - 1] ?? [],
     });
   };
 
-  handleGetPermissionUsersForComponent = (data: {
-    projectKey: string;
+  handleGetPermissionGroups = (data: {
     q?: string;
     permission?: string;
     p?: number;
@@ -272,33 +274,53 @@ export default class PermissionsServiceMock {
   }) => {
     const { ps = PAGE_SIZE, p = DEFAULT_PAGE, q, permission } = data;
 
-    const users =
+    const groups =
       q && q.length >= MIN_QUERY_LENGTH
-        ? this.users.filter((user) => user.name.toLowerCase().includes(q.toLowerCase()))
-        : this.users;
+        ? this.#groups.filter((group) => group.name.toLowerCase().includes(q.toLowerCase()))
+        : this.#groups;
 
-    const usersChunked = chunk(
-      permission ? users.filter((u) => u.permissions.includes(permission)) : users,
+    const groupsChunked = chunk(
+      permission ? groups.filter((g) => g.permissions.includes(permission)) : groups,
       ps
     );
 
     return this.reply({
-      paging: { pageSize: ps, total: users.length, pageIndex: p },
-      users: usersChunked[p - 1] ?? [],
+      paging: { pageSize: ps, total: groups.length, pageIndex: p },
+      groups: groupsChunked[p - 1] ?? [],
     });
   };
 
+  handleGetPermissionGroupsForComponent = (data: {
+    projectKey: string;
+    q?: string;
+    permission?: string;
+    p?: number;
+    ps?: number;
+  }) => {
+    return this.handleGetPermissionGroups(data);
+  };
+
+  handleGetPermissionUsersForComponent = (data: {
+    projectKey: string;
+    q?: string;
+    permission?: string;
+    p?: number;
+    ps?: number;
+  }) => {
+    return this.handleGetPermissionUsers(data);
+  };
+
   handleGrantPermissionToGroup = (data: {
     projectKey?: string;
     groupName: string;
     permission: string;
   }) => {
-    if (!this.isAllowedPermissionChange) {
+    if (!this.#isAllowedToChangePermissions) {
       return Promise.reject();
     }
 
     const { groupName, permission } = data;
-    const group = this.groups.find((g) => g.name === groupName);
+    const group = this.#groups.find((g) => g.name === groupName);
     if (group === undefined) {
       throw new Error(`Could not find group with name ${groupName}`);
     }
@@ -311,12 +333,12 @@ export default class PermissionsServiceMock {
     groupName: string;
     permission: string;
   }) => {
-    if (!this.isAllowedPermissionChange) {
+    if (!this.#isAllowedToChangePermissions) {
       return Promise.reject();
     }
 
     const { groupName, permission } = data;
-    const group = this.groups.find((g) => g.name === groupName);
+    const group = this.#groups.find((g) => g.name === groupName);
     if (group === undefined) {
       throw new Error(`Could not find group with name ${groupName}`);
     }
@@ -329,12 +351,12 @@ export default class PermissionsServiceMock {
     login: string;
     permission: string;
   }) => {
-    if (!this.isAllowedPermissionChange) {
+    if (!this.#isAllowedToChangePermissions) {
       return Promise.reject();
     }
 
     const { login, permission } = data;
-    const user = this.users.find((u) => u.login === login);
+    const user = this.#users.find((u) => u.login === login);
     if (user === undefined) {
       throw new Error(`Could not find user with login ${login}`);
     }
@@ -347,12 +369,12 @@ export default class PermissionsServiceMock {
     login: string;
     permission: string;
   }) => {
-    if (!this.isAllowedPermissionChange) {
+    if (!this.#isAllowedToChangePermissions) {
       return Promise.reject();
     }
 
     const { login, permission } = data;
-    const user = this.users.find((u) => u.login === login);
+    const user = this.#users.find((u) => u.login === login);
     if (user === undefined) {
       throw new Error(`Could not find user with name ${login}`);
     }
@@ -361,26 +383,26 @@ export default class PermissionsServiceMock {
   };
 
   handlePermissionChange = () => {
-    return this.isAllowedPermissionChange ? Promise.resolve() : Promise.reject();
+    return this.#isAllowedToChangePermissions ? Promise.resolve() : Promise.reject();
   };
 
-  updatePermissionChangeAllowance = (val: boolean) => {
-    this.isAllowedPermissionChange = val;
+  setIsAllowedToChangePermissions = (val: boolean) => {
+    this.#isAllowedToChangePermissions = val;
   };
 
   setGroups = (groups: PermissionGroup[]) => {
-    this.groups = groups;
+    this.#groups = groups;
   };
 
   setUsers = (users: PermissionUser[]) => {
-    this.users = users;
+    this.#users = users;
   };
 
   reset = () => {
-    this.permissionTemplates = cloneDeep(defaultPermissionTemplates);
-    this.groups = cloneDeep(defaultGroups);
-    this.users = cloneDeep(defaultUsers);
-    this.updatePermissionChangeAllowance(true);
+    this.#permissionTemplates = cloneDeep(defaultPermissionTemplates);
+    this.#groups = cloneDeep(defaultGroups);
+    this.#users = cloneDeep(defaultUsers);
+    this.setIsAllowedToChangePermissions(true);
   };
 
   reply<T>(response: T): Promise<T> {
index 18a55478f7b2d2626eb9a1106ae4d4becf0772f6..3a12d1dd84c7c994fb9a2169c9e8ab30dcfa2515 100644 (file)
@@ -21,14 +21,14 @@ import { without } from 'lodash';
 import * as React from 'react';
 import { Helmet } from 'react-helmet-async';
 import * as api from '../../../api/permissions';
+import AllHoldersList from '../../../components/permissions/AllHoldersList';
+import { FilterOption } from '../../../components/permissions/SearchForm';
 import { translate } from '../../../helpers/l10n';
-import { Paging, PermissionGroup, PermissionTemplate, PermissionUser } from '../../../types/types';
-import AllHoldersList from '../../permissions/shared/components/AllHoldersList';
-import { FilterOption } from '../../permissions/shared/components/SearchForm';
 import {
   convertToPermissionDefinitions,
   PERMISSIONS_ORDER_FOR_PROJECT_TEMPLATE,
-} from '../../permissions/utils';
+} from '../../../helpers/permissions';
+import { Paging, PermissionGroup, PermissionTemplate, PermissionUser } from '../../../types/types';
 import TemplateDetails from './TemplateDetails';
 import TemplateHeader from './TemplateHeader';
 
@@ -316,8 +316,8 @@ export default class Template extends React.PureComponent<Props, State> {
 
         <AllHoldersList
           filter={filter}
-          grantPermissionToGroup={this.grantPermissionToGroup}
-          grantPermissionToUser={this.grantPermissionToUser}
+          onGrantPermissionToGroup={this.grantPermissionToGroup}
+          onGrantPermissionToUser={this.grantPermissionToUser}
           groups={groups}
           groupsPaging={groupsPaging}
           loading={loading}
@@ -325,8 +325,8 @@ export default class Template extends React.PureComponent<Props, State> {
           onLoadMore={this.onLoadMore}
           onQuery={this.handleSearch}
           query={query}
-          revokePermissionFromGroup={this.revokePermissionFromGroup}
-          revokePermissionFromUser={this.revokePermissionFromUser}
+          onRevokePermissionFromGroup={this.revokePermissionFromGroup}
+          onRevokePermissionFromUser={this.revokePermissionFromUser}
           users={allUsers}
           usersPaging={usersPagingWithCreator}
           permissions={permissions}
index 52e7da56855d62e571d3418b163a88a085ec4e4a..799cb16ee55c209cbf3a6ccfeac2df02b5b6adb5 100644 (file)
@@ -67,7 +67,7 @@ it('grants/revokes permission from users or groups', async () => {
   expect(ui.permissionCheckbox('Anyone', Permissions.CodeViewer).get()).not.toBeChecked();
 
   // Handles error on permission change
-  serviceMock.updatePermissionChangeAllowance(false);
+  serviceMock.setIsAllowedToChangePermissions(false);
   await user.click(ui.permissionCheckbox('Admin Admin', Permissions.Browse).get());
   expect(ui.permissionCheckbox('Admin Admin', Permissions.Browse).get()).toBeChecked();
 
diff --git a/server/sonar-web/src/main/js/apps/permissions/__tests__/utils-test.ts b/server/sonar-web/src/main/js/apps/permissions/__tests__/utils-test.ts
deleted file mode 100644 (file)
index b4b6e36..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import { convertToPermissionDefinitions } from '../utils';
-
-jest.mock('../../../helpers/l10nBundle', () => ({
-  getMessages: jest.fn().mockReturnValue({}),
-}));
-
-describe('convertToPermissionDefinitions', () => {
-  it('should convert and translate a permission definition', () => {
-    const data = convertToPermissionDefinitions(['admin'], 'global_permissions');
-    const expected = [
-      {
-        description: 'global_permissions.admin.desc',
-        key: 'admin',
-        name: 'global_permissions.admin',
-      },
-    ];
-
-    expect(data).toEqual(expected);
-  });
-});
diff --git a/server/sonar-web/src/main/js/apps/permissions/global/components/App.tsx b/server/sonar-web/src/main/js/apps/permissions/global/components/App.tsx
deleted file mode 100644 (file)
index 5e0c9a0..0000000
+++ /dev/null
@@ -1,310 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import { without } from 'lodash';
-import * as React from 'react';
-import { Helmet } from 'react-helmet-async';
-import * as api from '../../../../api/permissions';
-import withAppStateContext from '../../../../app/components/app-state/withAppStateContext';
-import Suggestions from '../../../../components/embed-docs-modal/Suggestions';
-import { translate } from '../../../../helpers/l10n';
-import { AppState } from '../../../../types/appstate';
-import { ComponentQualifier } from '../../../../types/component';
-import { Paging, PermissionGroup, PermissionUser } from '../../../../types/types';
-import AllHoldersList from '../../shared/components/AllHoldersList';
-import { FilterOption } from '../../shared/components/SearchForm';
-import '../../styles.css';
-import {
-  convertToPermissionDefinitions,
-  filterPermissions,
-  PERMISSIONS_ORDER_GLOBAL,
-} from '../../utils';
-import PageHeader from './PageHeader';
-
-interface Props {
-  appState: AppState;
-}
-interface State {
-  filter: FilterOption;
-  groups: PermissionGroup[];
-  groupsPaging?: Paging;
-  loading: boolean;
-  query: string;
-  users: PermissionUser[];
-  usersPaging?: Paging;
-}
-export class App extends React.PureComponent<Props, State> {
-  mounted = false;
-
-  constructor(props: Props) {
-    super(props);
-    this.state = {
-      filter: 'all',
-      groups: [],
-      loading: true,
-      query: '',
-      users: [],
-    };
-  }
-
-  componentDidMount() {
-    this.mounted = true;
-    this.loadHolders();
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  loadUsersAndGroups = (userPage?: number, groupsPage?: number) => {
-    const { filter, query } = this.state;
-
-    const getUsers: Promise<{ paging?: Paging; users: PermissionUser[] }> =
-      filter !== 'groups'
-        ? api.getGlobalPermissionsUsers({
-            q: query || undefined,
-            p: userPage,
-          })
-        : Promise.resolve({ paging: undefined, users: [] });
-
-    const getGroups: Promise<{ paging?: Paging; groups: PermissionGroup[] }> =
-      filter !== 'users'
-        ? api.getGlobalPermissionsGroups({
-            q: query || undefined,
-            p: groupsPage,
-          })
-        : Promise.resolve({ paging: undefined, groups: [] });
-
-    return Promise.all([getUsers, getGroups]);
-  };
-
-  loadHolders = () => {
-    this.setState({ loading: true });
-    return this.loadUsersAndGroups().then(([usersResponse, groupsResponse]) => {
-      if (this.mounted) {
-        this.setState({
-          groups: groupsResponse.groups,
-          groupsPaging: groupsResponse.paging,
-          loading: false,
-          users: usersResponse.users,
-          usersPaging: usersResponse.paging,
-        });
-      }
-    }, this.stopLoading);
-  };
-
-  onLoadMore = () => {
-    const { usersPaging, groupsPaging } = this.state;
-    this.setState({ loading: true });
-    return this.loadUsersAndGroups(
-      usersPaging ? usersPaging.pageIndex + 1 : 1,
-      groupsPaging ? groupsPaging.pageIndex + 1 : 1
-    ).then(([usersResponse, groupsResponse]) => {
-      if (this.mounted) {
-        this.setState(({ groups, users }) => ({
-          groups: [...groups, ...groupsResponse.groups],
-          groupsPaging: groupsResponse.paging,
-          loading: false,
-          users: [...users, ...usersResponse.users],
-          usersPaging: usersResponse.paging,
-        }));
-      }
-    }, this.stopLoading);
-  };
-
-  onFilter = (filter: FilterOption) => {
-    this.setState({ filter }, this.loadHolders);
-  };
-
-  onSearch = (query: string) => {
-    this.setState({ query }, this.loadHolders);
-  };
-
-  addPermissionToGroup = (groups: PermissionGroup[], group: string, permission: string) => {
-    return groups.map((candidate) =>
-      candidate.name === group
-        ? { ...candidate, permissions: [...candidate.permissions, permission] }
-        : candidate
-    );
-  };
-
-  addPermissionToUser = (users: PermissionUser[], user: string, permission: string) => {
-    return users.map((candidate) =>
-      candidate.login === user
-        ? { ...candidate, permissions: [...candidate.permissions, permission] }
-        : candidate
-    );
-  };
-
-  removePermissionFromGroup = (groups: PermissionGroup[], group: string, permission: string) => {
-    return groups.map((candidate) =>
-      candidate.name === group
-        ? { ...candidate, permissions: without(candidate.permissions, permission) }
-        : candidate
-    );
-  };
-
-  removePermissionFromUser = (users: PermissionUser[], user: string, permission: string) => {
-    return users.map((candidate) =>
-      candidate.login === user
-        ? { ...candidate, permissions: without(candidate.permissions, permission) }
-        : candidate
-    );
-  };
-
-  grantPermissionToGroup = (group: string, permission: string) => {
-    if (this.mounted) {
-      this.setState(({ groups }) => ({
-        groups: this.addPermissionToGroup(groups, group, permission),
-      }));
-      return api
-        .grantPermissionToGroup({
-          groupName: group,
-          permission,
-        })
-        .then(
-          () => {},
-          () => {
-            if (this.mounted) {
-              this.setState(({ groups }) => ({
-                groups: this.removePermissionFromGroup(groups, group, permission),
-              }));
-            }
-          }
-        );
-    }
-    return Promise.resolve();
-  };
-
-  grantPermissionToUser = (user: string, permission: string) => {
-    if (this.mounted) {
-      this.setState(({ users }) => ({
-        users: this.addPermissionToUser(users, user, permission),
-      }));
-      return api
-        .grantPermissionToUser({
-          login: user,
-          permission,
-        })
-        .then(
-          () => {},
-          () => {
-            if (this.mounted) {
-              this.setState(({ users }) => ({
-                users: this.removePermissionFromUser(users, user, permission),
-              }));
-            }
-          }
-        );
-    }
-    return Promise.resolve();
-  };
-
-  revokePermissionFromGroup = (group: string, permission: string) => {
-    if (this.mounted) {
-      this.setState(({ groups }) => ({
-        groups: this.removePermissionFromGroup(groups, group, permission),
-      }));
-      return api
-        .revokePermissionFromGroup({
-          groupName: group,
-          permission,
-        })
-        .then(
-          () => {},
-          () => {
-            if (this.mounted) {
-              this.setState(({ groups }) => ({
-                groups: this.addPermissionToGroup(groups, group, permission),
-              }));
-            }
-          }
-        );
-    }
-    return Promise.resolve();
-  };
-
-  revokePermissionFromUser = (user: string, permission: string) => {
-    if (this.mounted) {
-      this.setState(({ users }) => ({
-        users: this.removePermissionFromUser(users, user, permission),
-      }));
-      return api
-        .revokePermissionFromUser({
-          login: user,
-          permission,
-        })
-        .then(
-          () => {},
-          () => {
-            if (this.mounted) {
-              this.setState(({ users }) => ({
-                users: this.addPermissionToUser(users, user, permission),
-              }));
-            }
-          }
-        );
-    }
-    return Promise.resolve();
-  };
-
-  stopLoading = () => {
-    if (this.mounted) {
-      this.setState({ loading: false });
-    }
-  };
-
-  render() {
-    const { appState } = this.props;
-    const { filter, groups, groupsPaging, users, usersPaging, loading, query } = this.state;
-
-    const hasPortfoliosEnabled = appState.qualifiers.includes(ComponentQualifier.Portfolio);
-    const hasApplicationsEnabled = appState.qualifiers.includes(ComponentQualifier.Application);
-    const permissions = convertToPermissionDefinitions(
-      filterPermissions(PERMISSIONS_ORDER_GLOBAL, hasApplicationsEnabled, hasPortfoliosEnabled),
-      'global_permissions'
-    );
-    return (
-      <main className="page page-limited">
-        <Suggestions suggestions="global_permissions" />
-        <Helmet defer={false} title={translate('global_permissions.permission')} />
-        <PageHeader loading={loading} />
-        <AllHoldersList
-          permissions={permissions}
-          filter={filter}
-          grantPermissionToGroup={this.grantPermissionToGroup}
-          grantPermissionToUser={this.grantPermissionToUser}
-          groups={groups}
-          groupsPaging={groupsPaging}
-          loading={loading}
-          onFilter={this.onFilter}
-          onLoadMore={this.onLoadMore}
-          onQuery={this.onSearch}
-          query={query}
-          revokePermissionFromGroup={this.revokePermissionFromGroup}
-          revokePermissionFromUser={this.revokePermissionFromUser}
-          users={users}
-          usersPaging={usersPaging}
-        />
-      </main>
-    );
-  }
-}
-
-export default withAppStateContext(App);
diff --git a/server/sonar-web/src/main/js/apps/permissions/global/components/PermissionsGlobalApp.tsx b/server/sonar-web/src/main/js/apps/permissions/global/components/PermissionsGlobalApp.tsx
new file mode 100644 (file)
index 0000000..e6b1563
--- /dev/null
@@ -0,0 +1,283 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { without } from 'lodash';
+import * as React from 'react';
+import { Helmet } from 'react-helmet-async';
+import * as api from '../../../../api/permissions';
+import withAppStateContext, {
+  WithAppStateContextProps,
+} from '../../../../app/components/app-state/withAppStateContext';
+import Suggestions from '../../../../components/embed-docs-modal/Suggestions';
+import AllHoldersList from '../../../../components/permissions/AllHoldersList';
+import { FilterOption } from '../../../../components/permissions/SearchForm';
+import { translate } from '../../../../helpers/l10n';
+import {
+  convertToPermissionDefinitions,
+  filterPermissions,
+  PERMISSIONS_ORDER_GLOBAL,
+} from '../../../../helpers/permissions';
+import { ComponentQualifier } from '../../../../types/component';
+import { Paging, PermissionGroup, PermissionUser } from '../../../../types/types';
+import '../../styles.css';
+import PageHeader from './PageHeader';
+
+type Props = WithAppStateContextProps;
+
+interface State {
+  filter: FilterOption;
+  groups: PermissionGroup[];
+  groupsPaging?: Paging;
+  loading: boolean;
+  query: string;
+  users: PermissionUser[];
+  usersPaging?: Paging;
+}
+
+class PermissionsGlobalApp extends React.PureComponent<Props, State> {
+  mounted = false;
+
+  constructor(props: Props) {
+    super(props);
+    this.state = {
+      filter: 'all',
+      groups: [],
+      loading: true,
+      query: '',
+      users: [],
+    };
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+    this.loadHolders();
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  loadUsersAndGroups = (userPage?: number, groupsPage?: number) => {
+    const { filter, query } = this.state;
+
+    const getUsers: Promise<{ paging?: Paging; users: PermissionUser[] }> =
+      filter !== 'groups'
+        ? api.getGlobalPermissionsUsers({
+            q: query || undefined,
+            p: userPage,
+          })
+        : Promise.resolve({ paging: undefined, users: [] });
+
+    const getGroups: Promise<{ paging?: Paging; groups: PermissionGroup[] }> =
+      filter !== 'users'
+        ? api.getGlobalPermissionsGroups({
+            q: query || undefined,
+            p: groupsPage,
+          })
+        : Promise.resolve({ paging: undefined, groups: [] });
+
+    return Promise.all([getUsers, getGroups]);
+  };
+
+  loadHolders = () => {
+    this.setState({ loading: true });
+    return this.loadUsersAndGroups().then(([usersResponse, groupsResponse]) => {
+      if (this.mounted) {
+        this.setState({
+          groups: groupsResponse.groups,
+          groupsPaging: groupsResponse.paging,
+          loading: false,
+          users: usersResponse.users,
+          usersPaging: usersResponse.paging,
+        });
+      }
+    }, this.stopLoading);
+  };
+
+  handleLoadMore = () => {
+    const { usersPaging, groupsPaging } = this.state;
+    this.setState({ loading: true });
+    return this.loadUsersAndGroups(
+      usersPaging ? usersPaging.pageIndex + 1 : 1,
+      groupsPaging ? groupsPaging.pageIndex + 1 : 1
+    ).then(([usersResponse, groupsResponse]) => {
+      if (this.mounted) {
+        this.setState(({ groups, users }) => ({
+          groups: [...groups, ...groupsResponse.groups],
+          groupsPaging: groupsResponse.paging,
+          loading: false,
+          users: [...users, ...usersResponse.users],
+          usersPaging: usersResponse.paging,
+        }));
+      }
+    }, this.stopLoading);
+  };
+
+  handleFilter = (filter: FilterOption) => {
+    this.setState({ filter }, this.loadHolders);
+  };
+
+  handleSearch = (query: string) => {
+    this.setState({ query }, this.loadHolders);
+  };
+
+  addPermissionToGroup = (groups: PermissionGroup[], group: string, permission: string) => {
+    return groups.map((candidate) =>
+      candidate.name === group
+        ? { ...candidate, permissions: [...candidate.permissions, permission] }
+        : candidate
+    );
+  };
+
+  addPermissionToUser = (users: PermissionUser[], user: string, permission: string) => {
+    return users.map((candidate) =>
+      candidate.login === user
+        ? { ...candidate, permissions: [...candidate.permissions, permission] }
+        : candidate
+    );
+  };
+
+  removePermissionFromGroup = (groups: PermissionGroup[], group: string, permission: string) => {
+    return groups.map((candidate) =>
+      candidate.name === group
+        ? { ...candidate, permissions: without(candidate.permissions, permission) }
+        : candidate
+    );
+  };
+
+  removePermissionFromUser = (users: PermissionUser[], user: string, permission: string) => {
+    return users.map((candidate) =>
+      candidate.login === user
+        ? { ...candidate, permissions: without(candidate.permissions, permission) }
+        : candidate
+    );
+  };
+
+  handleGrantPermissionToGroup = (group: string, permission: string) => {
+    this.setState({ loading: true });
+    return api
+      .grantPermissionToGroup({
+        groupName: group,
+        permission,
+      })
+      .then(() => {
+        if (this.mounted) {
+          this.setState(({ groups }) => ({
+            loading: false,
+            groups: this.addPermissionToGroup(groups, group, permission),
+          }));
+        }
+      }, this.stopLoading);
+  };
+
+  handleGrantPermissionToUser = (user: string, permission: string) => {
+    this.setState({ loading: true });
+    return api
+      .grantPermissionToUser({
+        login: user,
+        permission,
+      })
+      .then(() => {
+        if (this.mounted) {
+          this.setState(({ users }) => ({
+            loading: false,
+            users: this.addPermissionToUser(users, user, permission),
+          }));
+        }
+      }, this.stopLoading);
+  };
+
+  handleRevokePermissionFromGroup = (group: string, permission: string) => {
+    this.setState({ loading: true });
+    return api
+      .revokePermissionFromGroup({
+        groupName: group,
+        permission,
+      })
+      .then(() => {
+        if (this.mounted) {
+          this.setState(({ groups }) => ({
+            loading: false,
+            groups: this.removePermissionFromGroup(groups, group, permission),
+          }));
+        }
+      }, this.stopLoading);
+  };
+
+  handleRevokePermissionFromUser = (user: string, permission: string) => {
+    this.setState({ loading: true });
+    return api
+      .revokePermissionFromUser({
+        login: user,
+        permission,
+      })
+      .then(() => {
+        if (this.mounted) {
+          this.setState(({ users }) => ({
+            loading: false,
+            users: this.removePermissionFromUser(users, user, permission),
+          }));
+        }
+      }, this.stopLoading);
+  };
+
+  stopLoading = () => {
+    if (this.mounted) {
+      this.setState({ loading: false });
+    }
+  };
+
+  render() {
+    const { appState } = this.props;
+    const { filter, groups, groupsPaging, users, usersPaging, loading, query } = this.state;
+
+    const hasPortfoliosEnabled = appState.qualifiers.includes(ComponentQualifier.Portfolio);
+    const hasApplicationsEnabled = appState.qualifiers.includes(ComponentQualifier.Application);
+    const permissions = convertToPermissionDefinitions(
+      filterPermissions(PERMISSIONS_ORDER_GLOBAL, hasApplicationsEnabled, hasPortfoliosEnabled),
+      'global_permissions'
+    );
+    return (
+      <main className="page page-limited">
+        <Suggestions suggestions="global_permissions" />
+        <Helmet defer={false} title={translate('global_permissions.permission')} />
+        <PageHeader loading={loading} />
+        <AllHoldersList
+          permissions={permissions}
+          filter={filter}
+          onGrantPermissionToGroup={this.handleGrantPermissionToGroup}
+          onGrantPermissionToUser={this.handleGrantPermissionToUser}
+          groups={groups}
+          groupsPaging={groupsPaging}
+          loading={loading}
+          onFilter={this.handleFilter}
+          onLoadMore={this.handleLoadMore}
+          onQuery={this.handleSearch}
+          query={query}
+          onRevokePermissionFromGroup={this.handleRevokePermissionFromGroup}
+          onRevokePermissionFromUser={this.handleRevokePermissionFromUser}
+          users={users}
+          usersPaging={usersPaging}
+        />
+      </main>
+    );
+  }
+}
+
+export default withAppStateContext(PermissionsGlobalApp);
diff --git a/server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/App-test.tsx
deleted file mode 100644 (file)
index f76d0c0..0000000
+++ /dev/null
@@ -1,135 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import { shallow } from 'enzyme';
-import * as React from 'react';
-import {
-  grantPermissionToGroup,
-  grantPermissionToUser,
-  revokePermissionFromGroup,
-  revokePermissionFromUser,
-} from '../../../../../api/permissions';
-import { mockAppState } from '../../../../../helpers/testMocks';
-import { waitAndUpdate } from '../../../../../helpers/testUtils';
-import { ANYONE } from '../../../shared/components/GroupHolder';
-import { App } from '../App';
-
-jest.mock('../../../../../api/permissions', () => ({
-  getGlobalPermissionsGroups: jest.fn().mockResolvedValue({
-    paging: { pageIndex: 1, pageSize: 100, total: 2 },
-    groups: [
-      { name: 'Anyone', permissions: ['admin', 'codeviewer', 'issueadmin'] },
-      { id: '1', name: 'SonarSource', description: 'SonarSource team', permissions: [] },
-    ],
-  }),
-  getGlobalPermissionsUsers: jest.fn().mockResolvedValue({
-    paging: { pageIndex: 1, pageSize: 100, total: 3 },
-    users: [
-      {
-        avatar: 'admin-avatar',
-        email: 'admin@gmail.com',
-        login: 'admin',
-        name: 'Admin Admin',
-        permissions: ['admin'],
-      },
-      {
-        avatar: 'user-avatar-1',
-        email: 'user1@gmail.com',
-        login: 'user1',
-        name: 'User Number 1',
-        permissions: [],
-      },
-      {
-        avatar: 'user-avatar-2',
-        email: 'user2@gmail.com',
-        login: 'user2',
-        name: 'User Number 2',
-        permissions: [],
-      },
-    ],
-  }),
-  grantPermissionToGroup: jest.fn().mockResolvedValue({}),
-  grantPermissionToUser: jest.fn().mockResolvedValue({}),
-  revokePermissionFromGroup: jest.fn().mockResolvedValue({}),
-  revokePermissionFromUser: jest.fn().mockResolvedValue({}),
-}));
-
-beforeEach(() => {
-  jest.clearAllMocks();
-});
-
-it('should render correctly', async () => {
-  const wrapper = shallowRender();
-  expect(wrapper).toMatchSnapshot();
-  await waitAndUpdate(wrapper);
-  expect(wrapper).toMatchSnapshot();
-});
-
-describe('should manage state correctly', () => {
-  it('should add and remove permission to a group', async () => {
-    const wrapper = shallowRender();
-    await waitAndUpdate(wrapper);
-    const instance = wrapper.instance();
-    const apiPayload = { groupName: ANYONE, permission: 'foo' };
-
-    instance.grantPermissionToGroup(ANYONE, 'foo');
-    const groupState = wrapper.state('groups');
-    expect(groupState[0].permissions).toHaveLength(4);
-    expect(groupState[0].permissions).toContain('foo');
-    await waitAndUpdate(wrapper);
-    expect(grantPermissionToGroup).toHaveBeenCalledWith(apiPayload);
-    expect(wrapper.state('groups')).toBe(groupState);
-
-    (grantPermissionToGroup as jest.Mock).mockRejectedValueOnce({});
-    instance.grantPermissionToGroup(ANYONE, 'bar');
-    expect(wrapper.state('groups')[0].permissions).toHaveLength(5);
-    expect(wrapper.state('groups')[0].permissions).toContain('bar');
-    await waitAndUpdate(wrapper);
-    expect(wrapper.state('groups')[0].permissions).toHaveLength(4);
-    expect(wrapper.state('groups')[0].permissions).not.toContain('bar');
-
-    instance.revokePermissionFromGroup(ANYONE, 'foo');
-    expect(wrapper.state('groups')[0].permissions).toHaveLength(3);
-    expect(wrapper.state('groups')[0].permissions).not.toContain('foo');
-    await waitAndUpdate(wrapper);
-    expect(revokePermissionFromGroup).toHaveBeenCalledWith(apiPayload);
-  });
-
-  it('should add and remove permission to a user', async () => {
-    const wrapper = shallowRender();
-    await waitAndUpdate(wrapper);
-    const instance = wrapper.instance();
-    const apiPayload = { login: 'user1', permission: 'foo' };
-
-    instance.grantPermissionToUser('user1', 'foo');
-    expect(wrapper.state('users')[1].permissions).toHaveLength(1);
-    expect(wrapper.state('users')[1].permissions).toContain('foo');
-    await waitAndUpdate(wrapper);
-    expect(grantPermissionToUser).toHaveBeenCalledWith(apiPayload);
-
-    instance.revokePermissionFromUser('user1', 'foo');
-    expect(wrapper.state('users')[1].permissions).toHaveLength(0);
-    await waitAndUpdate(wrapper);
-    expect(revokePermissionFromUser).toHaveBeenCalledWith(apiPayload);
-  });
-});
-
-function shallowRender(props: Partial<App['props']> = {}) {
-  return shallow<App>(<App appState={mockAppState()} {...props} />);
-}
diff --git a/server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/PermissionsGlobal-it.tsx b/server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/PermissionsGlobal-it.tsx
new file mode 100644 (file)
index 0000000..126671a
--- /dev/null
@@ -0,0 +1,184 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { without } from 'lodash';
+import PermissionsServiceMock from '../../../../../api/mocks/PermissionsServiceMock';
+import { mockPermissionGroup, mockPermissionUser } from '../../../../../helpers/mocks/permissions';
+import { PERMISSIONS_ORDER_GLOBAL } from '../../../../../helpers/permissions';
+import { mockAppState } from '../../../../../helpers/testMocks';
+import { renderAppRoutes } from '../../../../../helpers/testReactTestingUtils';
+import { AppState } from '../../../../../types/appstate';
+import { ComponentQualifier } from '../../../../../types/component';
+import { Permissions } from '../../../../../types/permissions';
+import { PermissionGroup, PermissionUser } from '../../../../../types/types';
+import { globalPermissionsRoutes } from '../../../routes';
+import { flattenPermissionsList, getPageObject } from '../../../test-utils';
+
+let serviceMock: PermissionsServiceMock;
+beforeAll(() => {
+  serviceMock = new PermissionsServiceMock();
+});
+
+afterEach(() => {
+  serviceMock.reset();
+});
+
+describe('rendering', () => {
+  it('should render correctly without applications and portfolios', async () => {
+    const user = userEvent.setup();
+    const ui = getPageObject(user);
+    renderPermissionsGlobalApp();
+    await ui.appLoaded();
+
+    without(
+      flattenPermissionsList(PERMISSIONS_ORDER_GLOBAL),
+      Permissions.ApplicationCreation,
+      Permissions.PortfolioCreation
+    ).forEach((permission) => {
+      expect(ui.globalPermissionCheckbox('johndoe', permission).get()).toBeInTheDocument();
+    });
+  });
+
+  it.each([
+    [
+      ComponentQualifier.Portfolio,
+      without(flattenPermissionsList(PERMISSIONS_ORDER_GLOBAL), Permissions.ApplicationCreation),
+    ],
+    [
+      ComponentQualifier.Application,
+      without(flattenPermissionsList(PERMISSIONS_ORDER_GLOBAL), Permissions.PortfolioCreation),
+    ],
+  ])('should render correctly when %s are enabled', async (qualifier, permissions) => {
+    const user = userEvent.setup();
+    const ui = getPageObject(user);
+    renderPermissionsGlobalApp(mockAppState({ qualifiers: [qualifier] }));
+    await ui.appLoaded();
+
+    permissions.forEach((permission) => {
+      expect(ui.globalPermissionCheckbox('johndoe', permission).get()).toBeInTheDocument();
+    });
+  });
+});
+
+describe('assigning/revoking permissions', () => {
+  it('should add and remove permissions to/from a group', async () => {
+    const user = userEvent.setup();
+    const ui = getPageObject(user);
+    renderPermissionsGlobalApp();
+    await ui.appLoaded();
+
+    expect(ui.globalPermissionCheckbox('sonar-users', Permissions.Admin).get()).not.toBeChecked();
+
+    await ui.toggleGlobalPermission('sonar-users', Permissions.Admin);
+    await ui.appLoaded();
+    expect(ui.globalPermissionCheckbox('sonar-users', Permissions.Admin).get()).toBeChecked();
+
+    await ui.toggleGlobalPermission('sonar-users', Permissions.Admin);
+    await ui.appLoaded();
+    expect(ui.globalPermissionCheckbox('sonar-users', Permissions.Admin).get()).not.toBeChecked();
+  });
+
+  it('should add and remove permissions to/from a user', async () => {
+    const user = userEvent.setup();
+    const ui = getPageObject(user);
+    renderPermissionsGlobalApp();
+    await ui.appLoaded();
+
+    expect(ui.globalPermissionCheckbox('johndoe', Permissions.Scan).get()).not.toBeChecked();
+
+    await ui.toggleGlobalPermission('johndoe', Permissions.Scan);
+    await ui.appLoaded();
+    expect(ui.globalPermissionCheckbox('johndoe', Permissions.Scan).get()).toBeChecked();
+
+    await ui.toggleGlobalPermission('johndoe', Permissions.Scan);
+    await ui.appLoaded();
+    expect(ui.globalPermissionCheckbox('johndoe', Permissions.Scan).get()).not.toBeChecked();
+  });
+
+  it('should handle errors correctly', async () => {
+    serviceMock.setIsAllowedToChangePermissions(false);
+    const user = userEvent.setup();
+    const ui = getPageObject(user);
+    renderPermissionsGlobalApp();
+    await ui.appLoaded();
+
+    expect(ui.globalPermissionCheckbox('johndoe', Permissions.Scan).get()).not.toBeChecked();
+    await ui.toggleGlobalPermission('johndoe', Permissions.Scan);
+    await ui.appLoaded();
+    expect(ui.globalPermissionCheckbox('johndoe', Permissions.Scan).get()).not.toBeChecked();
+  });
+});
+
+it('should allow to filter permission holders', async () => {
+  const user = userEvent.setup();
+  const ui = getPageObject(user);
+  renderPermissionsGlobalApp();
+  await ui.appLoaded();
+
+  expect(screen.getByText('sonar-users')).toBeInTheDocument();
+  expect(screen.getByText('johndoe')).toBeInTheDocument();
+
+  await ui.showOnlyUsers();
+  expect(screen.queryByText('sonar-users')).not.toBeInTheDocument();
+  expect(screen.getByText('johndoe')).toBeInTheDocument();
+
+  await ui.showOnlyGroups();
+  expect(screen.getByText('sonar-users')).toBeInTheDocument();
+  expect(screen.queryByText('johndoe')).not.toBeInTheDocument();
+
+  await ui.showAll();
+  expect(screen.getByText('sonar-users')).toBeInTheDocument();
+  expect(screen.getByText('johndoe')).toBeInTheDocument();
+
+  await ui.searchFor('sonar-adm');
+  expect(screen.getByText('sonar-admins')).toBeInTheDocument();
+  expect(screen.queryByText('sonar-users')).not.toBeInTheDocument();
+  expect(screen.queryByText('johndoe')).not.toBeInTheDocument();
+
+  await ui.clearSearch();
+  expect(screen.getByText('sonar-users')).toBeInTheDocument();
+  expect(screen.getByText('johndoe')).toBeInTheDocument();
+});
+
+it('should correctly handle pagination', async () => {
+  const groups: PermissionGroup[] = [];
+  const users: PermissionUser[] = [];
+  Array.from(Array(20).keys()).forEach((i) => {
+    groups.push(mockPermissionGroup({ name: `Group ${i}` }));
+    users.push(mockPermissionUser({ login: `user-${i}` }));
+  });
+  serviceMock.setGroups(groups);
+  serviceMock.setUsers(users);
+
+  const user = userEvent.setup();
+  const ui = getPageObject(user);
+  renderPermissionsGlobalApp();
+  await ui.appLoaded();
+
+  expect(screen.getAllByRole('row').length).toBe(11);
+  await ui.clickLoadMore();
+  expect(screen.getAllByRole('row').length).toBe(21);
+});
+
+function renderPermissionsGlobalApp(appState?: AppState) {
+  return renderAppRoutes('permissions', globalPermissionsRoutes, { appState });
+}
diff --git a/server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/__snapshots__/App-test.tsx.snap b/server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/__snapshots__/App-test.tsx.snap
deleted file mode 100644 (file)
index 55679b7..0000000
+++ /dev/null
@@ -1,202 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly 1`] = `
-<main
-  className="page page-limited"
->
-  <Suggestions
-    suggestions="global_permissions"
-  />
-  <Helmet
-    defer={false}
-    encodeSpecialCharacters={true}
-    prioritizeSeoTags={false}
-    title="global_permissions.permission"
-  />
-  <PageHeader
-    loading={true}
-  />
-  <AllHoldersList
-    filter="all"
-    grantPermissionToGroup={[Function]}
-    grantPermissionToUser={[Function]}
-    groups={[]}
-    loading={true}
-    onFilter={[Function]}
-    onLoadMore={[Function]}
-    onQuery={[Function]}
-    permissions={
-      [
-        {
-          "description": "global_permissions.admin.desc",
-          "key": "admin",
-          "name": "global_permissions.admin",
-        },
-        {
-          "category": "administer",
-          "permissions": [
-            {
-              "description": "global_permissions.gateadmin.desc",
-              "key": "gateadmin",
-              "name": "global_permissions.gateadmin",
-            },
-            {
-              "description": "global_permissions.profileadmin.desc",
-              "key": "profileadmin",
-              "name": "global_permissions.profileadmin",
-            },
-          ],
-        },
-        {
-          "description": "global_permissions.scan.desc",
-          "key": "scan",
-          "name": "global_permissions.scan",
-        },
-        {
-          "category": "creator",
-          "permissions": [
-            {
-              "description": "global_permissions.provisioning.desc",
-              "key": "provisioning",
-              "name": "global_permissions.provisioning",
-            },
-          ],
-        },
-      ]
-    }
-    query=""
-    revokePermissionFromGroup={[Function]}
-    revokePermissionFromUser={[Function]}
-    users={[]}
-  />
-</main>
-`;
-
-exports[`should render correctly 2`] = `
-<main
-  className="page page-limited"
->
-  <Suggestions
-    suggestions="global_permissions"
-  />
-  <Helmet
-    defer={false}
-    encodeSpecialCharacters={true}
-    prioritizeSeoTags={false}
-    title="global_permissions.permission"
-  />
-  <PageHeader
-    loading={false}
-  />
-  <AllHoldersList
-    filter="all"
-    grantPermissionToGroup={[Function]}
-    grantPermissionToUser={[Function]}
-    groups={
-      [
-        {
-          "name": "Anyone",
-          "permissions": [
-            "admin",
-            "codeviewer",
-            "issueadmin",
-          ],
-        },
-        {
-          "description": "SonarSource team",
-          "id": "1",
-          "name": "SonarSource",
-          "permissions": [],
-        },
-      ]
-    }
-    groupsPaging={
-      {
-        "pageIndex": 1,
-        "pageSize": 100,
-        "total": 2,
-      }
-    }
-    loading={false}
-    onFilter={[Function]}
-    onLoadMore={[Function]}
-    onQuery={[Function]}
-    permissions={
-      [
-        {
-          "description": "global_permissions.admin.desc",
-          "key": "admin",
-          "name": "global_permissions.admin",
-        },
-        {
-          "category": "administer",
-          "permissions": [
-            {
-              "description": "global_permissions.gateadmin.desc",
-              "key": "gateadmin",
-              "name": "global_permissions.gateadmin",
-            },
-            {
-              "description": "global_permissions.profileadmin.desc",
-              "key": "profileadmin",
-              "name": "global_permissions.profileadmin",
-            },
-          ],
-        },
-        {
-          "description": "global_permissions.scan.desc",
-          "key": "scan",
-          "name": "global_permissions.scan",
-        },
-        {
-          "category": "creator",
-          "permissions": [
-            {
-              "description": "global_permissions.provisioning.desc",
-              "key": "provisioning",
-              "name": "global_permissions.provisioning",
-            },
-          ],
-        },
-      ]
-    }
-    query=""
-    revokePermissionFromGroup={[Function]}
-    revokePermissionFromUser={[Function]}
-    users={
-      [
-        {
-          "avatar": "admin-avatar",
-          "email": "admin@gmail.com",
-          "login": "admin",
-          "name": "Admin Admin",
-          "permissions": [
-            "admin",
-          ],
-        },
-        {
-          "avatar": "user-avatar-1",
-          "email": "user1@gmail.com",
-          "login": "user1",
-          "name": "User Number 1",
-          "permissions": [],
-        },
-        {
-          "avatar": "user-avatar-2",
-          "email": "user2@gmail.com",
-          "login": "user2",
-          "name": "User Number 2",
-          "permissions": [],
-        },
-      ]
-    }
-    usersPaging={
-      {
-        "pageIndex": 1,
-        "pageSize": 100,
-        "total": 3,
-      }
-    }
-  />
-</main>
-`;
index a1d7c39f50c1a0d6ac5b9ef8cfcd966ff0cdd208..e6f27deb12f42f3fabe00b06560037e65d65296a 100644 (file)
@@ -79,7 +79,7 @@ export default class PageHeader extends React.PureComponent<Props, State> {
       <header className="page-header">
         <h1 className="page-title">{translate('permissions.page')}</h1>
 
-        <DeferredSpinner loading={this.props.loading} />
+        <DeferredSpinner className="spacer-left" loading={this.props.loading} />
 
         {canApplyPermissionTemplate && (
           <div className="page-actions">
index acefdf94fb61af06bdb4c49fc1f63e0437d5ac1c..8850f8c033b3efb53f2f2cb0f7a25c53ddfc7804 100644 (file)
@@ -23,20 +23,22 @@ import { Helmet } from 'react-helmet-async';
 import * as api from '../../../../api/permissions';
 import withComponentContext from '../../../../app/components/componentContext/withComponentContext';
 import VisibilitySelector from '../../../../components/common/VisibilitySelector';
+import AllHoldersList from '../../../../components/permissions/AllHoldersList';
+import { FilterOption } from '../../../../components/permissions/SearchForm';
 import { translate } from '../../../../helpers/l10n';
-import { Visibility } from '../../../../types/component';
+import {
+  convertToPermissionDefinitions,
+  PERMISSIONS_ORDER_BY_QUALIFIER,
+} from '../../../../helpers/permissions';
+import { ComponentContextShape, Visibility } from '../../../../types/component';
 import { Permissions } from '../../../../types/permissions';
 import { Component, Paging, PermissionGroup, PermissionUser } from '../../../../types/types';
-import AllHoldersList from '../../shared/components/AllHoldersList';
-import { FilterOption } from '../../shared/components/SearchForm';
 import '../../styles.css';
-import { convertToPermissionDefinitions, PERMISSIONS_ORDER_BY_QUALIFIER } from '../../utils';
 import PageHeader from './PageHeader';
 import PublicProjectDisclaimer from './PublicProjectDisclaimer';
 
-interface Props {
+interface Props extends ComponentContextShape {
   component: Component;
-  onComponentChange: (changes: Partial<Component>) => void;
 }
 
 interface State {
@@ -51,7 +53,7 @@ interface State {
   usersPaging?: Paging;
 }
 
-export class PermissionsProjectApp extends React.PureComponent<Props, State> {
+class PermissionsProjectApp extends React.PureComponent<Props, State> {
   mounted = false;
 
   constructor(props: Props) {
@@ -117,7 +119,7 @@ export class PermissionsProjectApp extends React.PureComponent<Props, State> {
     }, this.stopLoading);
   };
 
-  onLoadMore = () => {
+  handleLoadMore = () => {
     const { usersPaging, groupsPaging } = this.state;
     this.setState({ loading: true });
     return this.loadUsersAndGroups(
@@ -192,88 +194,76 @@ export class PermissionsProjectApp extends React.PureComponent<Props, State> {
     );
   };
 
-  grantPermissionToGroup = (group: string, permission: string) => {
-    if (this.mounted) {
-      this.setState({ loading: true });
-      return api
-        .grantPermissionToGroup({
-          projectKey: this.props.component.key,
-          groupName: group,
-          permission,
-        })
-        .then(() => {
-          if (this.mounted) {
-            this.setState({
-              loading: false,
-              groups: this.addPermissionToGroup(group, permission),
-            });
-          }
-        }, this.stopLoading);
-    }
-    return Promise.resolve();
+  handleGrantPermissionToGroup = (group: string, permission: string) => {
+    this.setState({ loading: true });
+    return api
+      .grantPermissionToGroup({
+        projectKey: this.props.component.key,
+        groupName: group,
+        permission,
+      })
+      .then(() => {
+        if (this.mounted) {
+          this.setState({
+            loading: false,
+            groups: this.addPermissionToGroup(group, permission),
+          });
+        }
+      }, this.stopLoading);
   };
 
-  grantPermissionToUser = (user: string, permission: string) => {
-    if (this.mounted) {
-      this.setState({ loading: true });
-      return api
-        .grantPermissionToUser({
-          projectKey: this.props.component.key,
-          login: user,
-          permission,
-        })
-        .then(() => {
-          if (this.mounted) {
-            this.setState({
-              loading: false,
-              users: this.addPermissionToUser(user, permission),
-            });
-          }
-        }, this.stopLoading);
-    }
-    return Promise.resolve();
+  handleGrantPermissionToUser = (user: string, permission: string) => {
+    this.setState({ loading: true });
+    return api
+      .grantPermissionToUser({
+        projectKey: this.props.component.key,
+        login: user,
+        permission,
+      })
+      .then(() => {
+        if (this.mounted) {
+          this.setState({
+            loading: false,
+            users: this.addPermissionToUser(user, permission),
+          });
+        }
+      }, this.stopLoading);
   };
 
-  revokePermissionFromGroup = (group: string, permission: string) => {
-    if (this.mounted) {
-      this.setState({ loading: true });
-      return api
-        .revokePermissionFromGroup({
-          projectKey: this.props.component.key,
-          groupName: group,
-          permission,
-        })
-        .then(() => {
-          if (this.mounted) {
-            this.setState({
-              loading: false,
-              groups: this.removePermissionFromGroup(group, permission),
-            });
-          }
-        }, this.stopLoading);
-    }
-    return Promise.resolve();
+  handleRevokePermissionFromGroup = (group: string, permission: string) => {
+    this.setState({ loading: true });
+    return api
+      .revokePermissionFromGroup({
+        projectKey: this.props.component.key,
+        groupName: group,
+        permission,
+      })
+      .then(() => {
+        if (this.mounted) {
+          this.setState({
+            loading: false,
+            groups: this.removePermissionFromGroup(group, permission),
+          });
+        }
+      }, this.stopLoading);
   };
 
-  revokePermissionFromUser = (user: string, permission: string) => {
-    if (this.mounted) {
-      this.setState({ loading: true });
-      return api
-        .revokePermissionFromUser({
-          projectKey: this.props.component.key,
-          login: user,
-          permission,
-        })
-        .then(() => {
-          if (this.mounted) {
-            this.setState({
-              loading: false,
-              users: this.removePermissionFromUser(user, permission),
-            });
-          }
-        }, this.stopLoading);
-    }
-    return Promise.resolve();
+  handleRevokePermissionFromUser = (user: string, permission: string) => {
+    this.setState({ loading: true });
+    return api
+      .revokePermissionFromUser({
+        projectKey: this.props.component.key,
+        login: user,
+        permission,
+      })
+      .then(() => {
+        if (this.mounted) {
+          this.setState({
+            loading: false,
+            users: this.removePermissionFromUser(user, permission),
+          });
+        }
+      }, this.stopLoading);
   };
 
   handleVisibilityChange = (visibility: string) => {
@@ -284,7 +274,7 @@ export class PermissionsProjectApp extends React.PureComponent<Props, State> {
     }
   };
 
-  turnProjectToPublic = () => {
+  handleTurnProjectToPublic = () => {
     this.setState({ loading: true });
     return api.changeProjectVisibility(this.props.component.key, Visibility.Public).then(() => {
       this.props.onComponentChange({ visibility: Visibility.Public });
@@ -306,7 +296,7 @@ export class PermissionsProjectApp extends React.PureComponent<Props, State> {
     }
   };
 
-  closeDisclaimer = () => {
+  handleCloseDisclaimer = () => {
     if (this.mounted) {
       this.setState({ disclaimer: false });
     }
@@ -341,7 +331,7 @@ export class PermissionsProjectApp extends React.PureComponent<Props, State> {
     const permissions = convertToPermissionDefinitions(order, 'projects_role');
 
     return (
-      <div className="page page-limited" id="project-permissions-page">
+      <main className="page page-limited" id="project-permissions-page">
         <Helmet defer={false} title={translate('permissions.page')} />
 
         <PageHeader component={component} loadHolders={this.loadHolders} loading={loading} />
@@ -356,30 +346,30 @@ export class PermissionsProjectApp extends React.PureComponent<Props, State> {
           {disclaimer && (
             <PublicProjectDisclaimer
               component={component}
-              onClose={this.closeDisclaimer}
-              onConfirm={this.turnProjectToPublic}
+              onClose={this.handleCloseDisclaimer}
+              onConfirm={this.handleTurnProjectToPublic}
             />
           )}
         </div>
         <AllHoldersList
           filter={filter}
-          grantPermissionToGroup={this.grantPermissionToGroup}
-          grantPermissionToUser={this.grantPermissionToUser}
+          onGrantPermissionToGroup={this.handleGrantPermissionToGroup}
+          onGrantPermissionToUser={this.handleGrantPermissionToUser}
           groups={groups}
           groupsPaging={groupsPaging}
           onFilter={this.handleFilterChange}
-          onLoadMore={this.onLoadMore}
+          onLoadMore={this.handleLoadMore}
           onSelectPermission={this.handlePermissionSelect}
           onQuery={this.handleQueryChange}
           query={query}
-          revokePermissionFromGroup={this.revokePermissionFromGroup}
-          revokePermissionFromUser={this.revokePermissionFromUser}
+          onRevokePermissionFromGroup={this.handleRevokePermissionFromGroup}
+          onRevokePermissionFromUser={this.handleRevokePermissionFromUser}
           selectedPermission={selectedPermission}
           users={users}
           usersPaging={usersPaging}
           permissions={permissions}
         />
-      </div>
+      </main>
     );
   }
 }
index 0e8d81a0d0fca2861d9130df6c8a9e9d8c33b5d9..f73dbcac56a0c19ef4403ea8ec17ec5e45fbb9d1 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
-import { act, screen, waitFor } from '@testing-library/react';
+import { act, screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
-import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup';
-import * as React from 'react';
-import selectEvent from 'react-select-event';
-import { byLabelText, byRole, byText } from 'testing-library-selector';
 import PermissionsServiceMock from '../../../../../api/mocks/PermissionsServiceMock';
 import { mockComponent } from '../../../../../helpers/mocks/component';
-import { renderApp } from '../../../../../helpers/testReactTestingUtils';
+import { mockPermissionGroup, mockPermissionUser } from '../../../../../helpers/mocks/permissions';
+import {
+  PERMISSIONS_ORDER_FOR_PROJECT_TEMPLATE,
+  PERMISSIONS_ORDER_FOR_VIEW,
+} from '../../../../../helpers/permissions';
+import { renderAppWithComponentContext } from '../../../../../helpers/testReactTestingUtils';
 import { ComponentQualifier, Visibility } from '../../../../../types/component';
 import { Permissions } from '../../../../../types/permissions';
-import { Component } from '../../../../../types/types';
-import { PERMISSIONS_ORDER_FOR_PROJECT_TEMPLATE, PERMISSIONS_ORDER_FOR_VIEW } from '../../../utils';
-import { PermissionsProjectApp } from '../PermissionsProjectApp';
+import { Component, PermissionGroup, PermissionUser } from '../../../../../types/types';
+import { projectPermissionsRoutes } from '../../../routes';
+import { getPageObject } from '../../../test-utils';
 
 let serviceMock: PermissionsServiceMock;
 beforeAll(() => {
@@ -59,7 +60,7 @@ describe('rendering', () => {
 
     expect(screen.getByText(description)).toBeInTheDocument();
     permissions.forEach((permission) => {
-      expect(ui.permissionCheckbox('johndoe', permission).get()).toBeInTheDocument();
+      expect(ui.projectPermissionCheckbox('johndoe', permission).get()).toBeInTheDocument();
     });
   });
 });
@@ -105,6 +106,8 @@ describe('filtering', () => {
     expect(screen.getAllByRole('row').length).toBe(7);
     await ui.toggleFilterByPermission(Permissions.Admin);
     expect(screen.getAllByRole('row').length).toBe(2);
+    await ui.toggleFilterByPermission(Permissions.Admin);
+    expect(screen.getAllByRole('row').length).toBe(7);
   });
 });
 
@@ -131,13 +134,15 @@ describe('assigning/revoking permissions', () => {
 
     expect(ui.visibilityRadio(Visibility.Public).get()).toBeChecked();
     expect(
-      ui.permissionCheckbox('sonar-users', Permissions.Browse).query()
+      ui.projectPermissionCheckbox('sonar-users', Permissions.Browse).query()
     ).not.toBeInTheDocument();
     await act(async () => {
       await ui.turnProjectPrivate();
     });
     expect(ui.visibilityRadio(Visibility.Private).get()).toBeChecked();
-    expect(ui.permissionCheckbox('sonar-users', Permissions.Browse).get()).toBeInTheDocument();
+    expect(
+      ui.projectPermissionCheckbox('sonar-users', Permissions.Browse).get()
+    ).toBeInTheDocument();
 
     await ui.turnProjectPublic();
     expect(ui.makePublicDisclaimer.get()).toBeInTheDocument();
@@ -153,15 +158,15 @@ describe('assigning/revoking permissions', () => {
     renderPermissionsProjectApp();
     await ui.appLoaded();
 
-    expect(ui.permissionCheckbox('sonar-users', Permissions.Admin).get()).not.toBeChecked();
+    expect(ui.projectPermissionCheckbox('sonar-users', Permissions.Admin).get()).not.toBeChecked();
 
-    await ui.togglePermission('sonar-users', Permissions.Admin);
+    await ui.toggleProjectPermission('sonar-users', Permissions.Admin);
     await ui.appLoaded();
-    expect(ui.permissionCheckbox('sonar-users', Permissions.Admin).get()).toBeChecked();
+    expect(ui.projectPermissionCheckbox('sonar-users', Permissions.Admin).get()).toBeChecked();
 
-    await ui.togglePermission('sonar-users', Permissions.Admin);
+    await ui.toggleProjectPermission('sonar-users', Permissions.Admin);
     await ui.appLoaded();
-    expect(ui.permissionCheckbox('sonar-users', Permissions.Admin).get()).not.toBeChecked();
+    expect(ui.projectPermissionCheckbox('sonar-users', Permissions.Admin).get()).not.toBeChecked();
   });
 
   it('should add and remove permissions to/from a user', async () => {
@@ -170,118 +175,65 @@ describe('assigning/revoking permissions', () => {
     renderPermissionsProjectApp();
     await ui.appLoaded();
 
-    expect(ui.permissionCheckbox('johndoe', Permissions.Scan).get()).not.toBeChecked();
+    expect(ui.projectPermissionCheckbox('johndoe', Permissions.Scan).get()).not.toBeChecked();
+
+    await ui.toggleProjectPermission('johndoe', Permissions.Scan);
+    await ui.appLoaded();
+    expect(ui.projectPermissionCheckbox('johndoe', Permissions.Scan).get()).toBeChecked();
 
-    await ui.togglePermission('johndoe', Permissions.Scan);
+    await ui.toggleProjectPermission('johndoe', Permissions.Scan);
+    await ui.appLoaded();
+    expect(ui.projectPermissionCheckbox('johndoe', Permissions.Scan).get()).not.toBeChecked();
+  });
+
+  it('should handle errors correctly', async () => {
+    serviceMock.setIsAllowedToChangePermissions(false);
+    const user = userEvent.setup();
+    const ui = getPageObject(user);
+    renderPermissionsProjectApp();
     await ui.appLoaded();
-    expect(ui.permissionCheckbox('johndoe', Permissions.Scan).get()).toBeChecked();
 
-    await ui.togglePermission('johndoe', Permissions.Scan);
+    expect(ui.projectPermissionCheckbox('johndoe', Permissions.Scan).get()).not.toBeChecked();
+    await ui.toggleProjectPermission('johndoe', Permissions.Scan);
     await ui.appLoaded();
-    expect(ui.permissionCheckbox('johndoe', Permissions.Scan).get()).not.toBeChecked();
+    expect(ui.projectPermissionCheckbox('johndoe', Permissions.Scan).get()).not.toBeChecked();
   });
 });
 
-function getPageObject(user: UserEvent) {
-  const ui = {
-    loading: byLabelText('loading'),
-    permissionCheckbox: (target: string, permission: Permissions) =>
-      byRole('checkbox', {
-        name: `permission.assign_x_to_y.projects_role.${permission}.${target}`,
-      }),
-    visibilityRadio: (visibility: Visibility) =>
-      byRole('radio', { name: `visibility.${visibility}` }),
-    makePublicDisclaimer: byText(
-      'projects_role.are_you_sure_to_turn_project_to_public.warning.TRK'
-    ),
-    confirmPublicBtn: byRole('button', { name: 'projects_role.turn_project_to_public.TRK' }),
-    openModalBtn: byRole('button', { name: 'projects_role.apply_template' }),
-    closeModalBtn: byRole('button', { name: 'close' }),
-    templateSelect: byRole('combobox', { name: /template/ }),
-    templateSuccessfullyApplied: byText('projects_role.apply_template.success'),
-    confirmApplyTemplateBtn: byRole('button', { name: 'apply' }),
-    tableHeaderFilter: (permission: Permissions) =>
-      byRole('link', { name: `projects_role.${permission}` }),
-    onlyUsersBtn: byRole('button', { name: 'users.page' }),
-    onlyGroupsBtn: byRole('button', { name: 'user_groups.page' }),
-    showAllBtn: byRole('button', { name: 'all' }),
-    searchInput: byRole('searchbox', { name: 'search.search_for_users_or_groups' }),
-  };
-
-  return {
-    ...ui,
-    async appLoaded() {
-      await waitFor(() => {
-        expect(ui.loading.query()).not.toBeInTheDocument();
-      });
-    },
-    async togglePermission(target: string, permission: Permissions) {
-      await user.click(ui.permissionCheckbox(target, permission).get());
-    },
-    async turnProjectPrivate() {
-      await user.click(ui.visibilityRadio(Visibility.Private).get());
-    },
-    async turnProjectPublic() {
-      await user.click(ui.visibilityRadio(Visibility.Public).get());
-    },
-    async confirmTurnProjectPublic() {
-      await user.click(ui.confirmPublicBtn.get());
-    },
-    async openTemplateModal() {
-      await user.click(ui.openModalBtn.get());
-    },
-    async closeTemplateModal() {
-      await user.click(ui.closeModalBtn.get());
-    },
-    async chooseTemplate(name: string) {
-      await selectEvent.select(ui.templateSelect.get(), [name]);
-      await user.click(ui.confirmApplyTemplateBtn.get());
-    },
-    async toggleFilterByPermission(permission: Permissions) {
-      await user.click(ui.tableHeaderFilter(permission).get());
-    },
-    async showOnlyUsers() {
-      await user.click(ui.onlyUsersBtn.get());
-    },
-    async showOnlyGroups() {
-      await user.click(ui.onlyGroupsBtn.get());
-    },
-    async showAll() {
-      await user.click(ui.showAllBtn.get());
-    },
-    async searchFor(name: string) {
-      await user.type(ui.searchInput.get(), name);
-    },
-    async clearSearch() {
-      await user.clear(ui.searchInput.get());
-    },
-  };
-}
+it('should correctly handle pagination', async () => {
+  const groups: PermissionGroup[] = [];
+  const users: PermissionUser[] = [];
+  Array.from(Array(20).keys()).forEach((i) => {
+    groups.push(mockPermissionGroup({ name: `Group ${i}` }));
+    users.push(mockPermissionUser({ login: `user-${i}` }));
+  });
+  serviceMock.setGroups(groups);
+  serviceMock.setUsers(users);
+
+  const user = userEvent.setup();
+  const ui = getPageObject(user);
+  renderPermissionsProjectApp();
+  await ui.appLoaded();
+
+  expect(screen.getAllByRole('row').length).toBe(11);
+  await ui.clickLoadMore();
+  expect(screen.getAllByRole('row').length).toBe(21);
+});
 
 function renderPermissionsProjectApp(override?: Partial<Component>) {
-  function App({ component }: { component: Component }) {
-    const [realComponent, setRealComponent] = React.useState(component);
-    return (
-      <PermissionsProjectApp
-        component={realComponent}
-        onComponentChange={(changes: Partial<Component>) => {
-          setRealComponent({ ...realComponent, ...changes });
-        }}
-      />
-    );
-  }
-
-  return renderApp(
-    '/',
-    <App
-      component={mockComponent({
+  return renderAppWithComponentContext(
+    'project_roles',
+    projectPermissionsRoutes,
+    {},
+    {
+      component: mockComponent({
         visibility: Visibility.Public,
         configuration: {
           canUpdateProjectVisibilityToPrivate: true,
           canApplyPermissionTemplate: true,
         },
         ...override,
-      })}
-    />
+      }),
+    }
   );
 }
index 9670b118c6840b8d7240b496273c35cf6779d591..0e1e8721c639fe54cb41976bb54e302b5468b789 100644 (file)
@@ -19,7 +19,7 @@
  */
 import React from 'react';
 import { Route } from 'react-router-dom';
-import GlobalPermissionsApp from './global/components/App';
+import GlobalPermissionsApp from './global/components/PermissionsGlobalApp';
 import PermissionsProjectApp from './project/components/PermissionsProjectApp';
 
 export const globalPermissionsRoutes = () => (
diff --git a/server/sonar-web/src/main/js/apps/permissions/shared/components/AllHoldersList.tsx b/server/sonar-web/src/main/js/apps/permissions/shared/components/AllHoldersList.tsx
deleted file mode 100644 (file)
index 9e54ec1..0000000
+++ /dev/null
@@ -1,118 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import ListFooter from '../../../../components/controls/ListFooter';
-import {
-  Paging,
-  PermissionDefinition,
-  PermissionDefinitionGroup,
-  PermissionGroup,
-  PermissionUser,
-} from '../../../../types/types';
-import HoldersList from '../../shared/components/HoldersList';
-import SearchForm, { FilterOption } from '../../shared/components/SearchForm';
-
-interface Props {
-  filter: FilterOption;
-  query: string;
-  onFilter: (filter: string) => void;
-  onQuery: (query: string) => void;
-  groups: PermissionGroup[];
-  groupsPaging?: Paging;
-  revokePermissionFromGroup: (group: string, permission: string) => Promise<void>;
-  grantPermissionToGroup: (group: string, permission: string) => Promise<void>;
-  users: PermissionUser[];
-  usersPaging?: Paging;
-  revokePermissionFromUser: (user: string, permission: string) => Promise<void>;
-  grantPermissionToUser: (user: string, permission: string) => Promise<void>;
-  permissions: Array<PermissionDefinition | PermissionDefinitionGroup>;
-  onLoadMore: () => void;
-  selectedPermission?: string;
-  onSelectPermission?: (permissions?: string) => void;
-  loading?: boolean;
-}
-
-export default class AllHoldersList extends React.PureComponent<Props> {
-  handleToggleUser = (user: PermissionUser, permission: string) => {
-    const hasPermission = user.permissions.includes(permission);
-
-    if (hasPermission) {
-      return this.props.revokePermissionFromUser(user.login, permission);
-    }
-    return this.props.grantPermissionToUser(user.login, permission);
-  };
-
-  handleToggleGroup = (group: PermissionGroup, permission: string) => {
-    const hasPermission = group.permissions.includes(permission);
-
-    if (hasPermission) {
-      return this.props.revokePermissionFromGroup(group.name, permission);
-    }
-
-    return this.props.grantPermissionToGroup(group.name, permission);
-  };
-
-  getPaging = () => {
-    const { filter, groups, groupsPaging, users, usersPaging } = this.props;
-
-    let count = 0;
-    let total = 0;
-    if (filter !== 'users') {
-      count += groups.length;
-      total += groupsPaging ? groupsPaging.total : groups.length;
-    }
-    if (filter !== 'groups') {
-      count += users.length;
-      total += usersPaging ? usersPaging.total : users.length;
-    }
-
-    return { count, total };
-  };
-
-  render() {
-    const { filter, query, groups, users, permissions, selectedPermission, loading } = this.props;
-    const { count, total } = this.getPaging();
-
-    return (
-      <>
-        <HoldersList
-          loading={loading}
-          filter={filter}
-          groups={groups}
-          onSelectPermission={this.props.onSelectPermission}
-          onToggleGroup={this.handleToggleGroup}
-          onToggleUser={this.handleToggleUser}
-          permissions={permissions}
-          query={query}
-          selectedPermission={selectedPermission}
-          users={users}
-        >
-          <SearchForm
-            filter={filter}
-            onFilter={this.props.onFilter}
-            onSearch={this.props.onQuery}
-            query={query}
-          />
-        </HoldersList>
-        <ListFooter count={count} loadMore={this.props.onLoadMore} total={total} />
-      </>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/permissions/shared/components/GroupHolder.tsx b/server/sonar-web/src/main/js/apps/permissions/shared/components/GroupHolder.tsx
deleted file mode 100644 (file)
index 91d89a4..0000000
+++ /dev/null
@@ -1,114 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import { without } from 'lodash';
-import * as React from 'react';
-import GroupIcon from '../../../../components/icons/GroupIcon';
-import { translate } from '../../../../helpers/l10n';
-import { Permissions } from '../../../../types/permissions';
-import { PermissionDefinitions, PermissionGroup } from '../../../../types/types';
-import { isPermissionDefinitionGroup } from '../../utils';
-import PermissionCell from './PermissionCell';
-
-interface Props {
-  group: PermissionGroup;
-  isComponentPrivate?: boolean;
-  onToggle: (group: PermissionGroup, permission: string) => Promise<void>;
-  permissions: PermissionDefinitions;
-  selectedPermission?: string;
-}
-
-interface State {
-  loading: string[];
-}
-
-export const ANYONE = 'Anyone';
-
-export default class GroupHolder extends React.PureComponent<Props, State> {
-  mounted = false;
-  state: State = { loading: [] };
-
-  componentDidMount() {
-    this.mounted = true;
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  stopLoading = (permission: string) => {
-    if (this.mounted) {
-      this.setState((state) => ({ loading: without(state.loading, permission) }));
-    }
-  };
-
-  handleCheck = (_checked: boolean, permission?: string) => {
-    if (permission !== undefined) {
-      this.setState((state) => ({ loading: [...state.loading, permission] }));
-      this.props.onToggle(this.props.group, permission).then(
-        () => this.stopLoading(permission),
-        () => this.stopLoading(permission)
-      );
-    }
-  };
-
-  render() {
-    const { group, isComponentPrivate, permissions, selectedPermission } = this.props;
-
-    return (
-      <tr>
-        <td className="nowrap text-middle">
-          <div className="display-flex-center">
-            <GroupIcon className="big-spacer-right" />
-            <div className="max-width-100">
-              <div className="max-width-100 text-ellipsis">
-                <strong>{group.name}</strong>
-                {group.name === ANYONE && (
-                  <span className="spacer-left badge badge-error">{translate('deprecated')}</span>
-                )}
-              </div>
-              <div className="little-spacer-top" style={{ whiteSpace: 'normal' }}>
-                {group.name === ANYONE
-                  ? translate('user_groups.anyone.description')
-                  : group.description}
-              </div>
-            </div>
-          </div>
-        </td>
-        {permissions.map((permission) => {
-          const isPermissionGroup = isPermissionDefinitionGroup(permission);
-          const permissionKey = isPermissionGroup ? permission.category : permission.key;
-          const isAdminPermission = !isPermissionGroup && permissionKey === Permissions.Admin;
-
-          return (
-            <PermissionCell
-              disabled={group.name === ANYONE && (isComponentPrivate || isAdminPermission)}
-              key={permissionKey}
-              loading={this.state.loading}
-              onCheck={this.handleCheck}
-              permission={permission}
-              permissionItem={group}
-              selectedPermission={selectedPermission}
-            />
-          );
-        })}
-      </tr>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/permissions/shared/components/HoldersList.tsx b/server/sonar-web/src/main/js/apps/permissions/shared/components/HoldersList.tsx
deleted file mode 100644 (file)
index 3b6d1f4..0000000
+++ /dev/null
@@ -1,168 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import { partition } from 'lodash';
-import * as React from 'react';
-import { translate } from '../../../../helpers/l10n';
-import {
-  Dict,
-  PermissionDefinitions,
-  PermissionGroup,
-  PermissionUser,
-} from '../../../../types/types';
-import { isPermissionDefinitionGroup } from '../../utils';
-import GroupHolder from './GroupHolder';
-import PermissionHeader from './PermissionHeader';
-import UserHolder from './UserHolder';
-
-interface Props {
-  filter?: string;
-  groups: PermissionGroup[];
-  isComponentPrivate?: boolean;
-  loading?: boolean;
-  onSelectPermission?: (permission: string) => void;
-  onToggleGroup: (group: PermissionGroup, permission: string) => Promise<void>;
-  onToggleUser: (user: PermissionUser, permission: string) => Promise<void>;
-  permissions: PermissionDefinitions;
-  query?: string;
-  selectedPermission?: string;
-  users: PermissionUser[];
-}
-
-interface State {
-  initialPermissionsCount: Dict<number>;
-}
-export default class HoldersList extends React.PureComponent<Props, State> {
-  state: State = { initialPermissionsCount: {} };
-  componentDidUpdate(prevProps: Props) {
-    if (this.props.filter !== prevProps.filter || this.props.query !== prevProps.query) {
-      this.setState({ initialPermissionsCount: {} });
-    }
-  }
-
-  isPermissionUser(item: PermissionGroup | PermissionUser): item is PermissionUser {
-    return (item as PermissionUser).login !== undefined;
-  }
-
-  handleGroupToggle = (group: PermissionGroup, permission: string) => {
-    const key = group.id || group.name;
-    if (this.state.initialPermissionsCount[key] === undefined) {
-      this.setState((state) => ({
-        initialPermissionsCount: {
-          ...state.initialPermissionsCount,
-          [key]: group.permissions.length,
-        },
-      }));
-    }
-    return this.props.onToggleGroup(group, permission);
-  };
-
-  handleUserToggle = (user: PermissionUser, permission: string) => {
-    if (this.state.initialPermissionsCount[user.login] === undefined) {
-      this.setState((state) => ({
-        initialPermissionsCount: {
-          ...state.initialPermissionsCount,
-          [user.login]: user.permissions.length,
-        },
-      }));
-    }
-    return this.props.onToggleUser(user, permission);
-  };
-
-  getItemInitialPermissionsCount = (item: PermissionGroup | PermissionUser) => {
-    const key = this.isPermissionUser(item) ? item.login : item.id || item.name;
-    return this.state.initialPermissionsCount[key] !== undefined
-      ? this.state.initialPermissionsCount[key]
-      : item.permissions.length;
-  };
-
-  renderEmpty() {
-    const columns = this.props.permissions.length + 1;
-    return (
-      <tr>
-        <td colSpan={columns}>{translate('no_results_search')}</td>
-      </tr>
-    );
-  }
-
-  renderItem(item: PermissionUser | PermissionGroup, permissions: PermissionDefinitions) {
-    return this.isPermissionUser(item) ? (
-      <UserHolder
-        key={`user-${item.login}`}
-        onToggle={this.handleUserToggle}
-        permissions={permissions}
-        selectedPermission={this.props.selectedPermission}
-        user={item}
-      />
-    ) : (
-      <GroupHolder
-        group={item}
-        isComponentPrivate={this.props.isComponentPrivate}
-        key={`group-${item.id || item.name}`}
-        onToggle={this.handleGroupToggle}
-        permissions={permissions}
-        selectedPermission={this.props.selectedPermission}
-      />
-    );
-  }
-
-  render() {
-    const { permissions, users, groups, loading, children, selectedPermission } = this.props;
-    const items = [...groups, ...users];
-    const [itemWithPermissions, itemWithoutPermissions] = partition(items, (item) =>
-      this.getItemInitialPermissionsCount(item)
-    );
-
-    return (
-      <div className="boxed-group boxed-group-inner">
-        <table className="data zebra permissions-table">
-          <thead>
-            <tr>
-              <td className="nowrap bordered-bottom">{children}</td>
-              {permissions.map((permission) => (
-                <PermissionHeader
-                  key={
-                    isPermissionDefinitionGroup(permission) ? permission.category : permission.key
-                  }
-                  onSelectPermission={this.props.onSelectPermission}
-                  permission={permission}
-                  selectedPermission={selectedPermission}
-                />
-              ))}
-            </tr>
-          </thead>
-          <tbody>
-            {items.length === 0 && !loading && this.renderEmpty()}
-            {itemWithPermissions.map((item) => this.renderItem(item, permissions))}
-            {itemWithPermissions.length > 0 && itemWithoutPermissions.length > 0 && (
-              <>
-                <tr>
-                  <td className="divider" colSpan={20} />
-                </tr>
-                <tr />
-                {/* Keep correct zebra colors in the table */}
-              </>
-            )}
-            {itemWithoutPermissions.map((item) => this.renderItem(item, permissions))}
-          </tbody>
-        </table>
-      </div>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/permissions/shared/components/PermissionCell.tsx b/server/sonar-web/src/main/js/apps/permissions/shared/components/PermissionCell.tsx
deleted file mode 100644 (file)
index e8657cd..0000000
+++ /dev/null
@@ -1,95 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import classNames from 'classnames';
-import * as React from 'react';
-import Checkbox from '../../../../components/controls/Checkbox';
-import { translateWithParameters } from '../../../../helpers/l10n';
-import {
-  PermissionDefinition,
-  PermissionDefinitionGroup,
-  PermissionGroup,
-  PermissionUser,
-} from '../../../../types/types';
-import { isPermissionDefinitionGroup } from '../../utils';
-
-export interface PermissionCellProps {
-  disabled?: boolean;
-  loading: string[];
-  onCheck: (checked: boolean, permission?: string) => void;
-  permission: PermissionDefinition | PermissionDefinitionGroup;
-  permissionItem: PermissionGroup | PermissionUser;
-  selectedPermission?: string;
-}
-
-export default function PermissionCell(props: PermissionCellProps) {
-  const { disabled, loading, onCheck, permission, permissionItem, selectedPermission } = props;
-
-  if (isPermissionDefinitionGroup(permission)) {
-    return (
-      <td className="text-middle">
-        {permission.permissions.map((permissionDefinition) => {
-          const isChecked = permissionItem.permissions.includes(permissionDefinition.key);
-          const isDisabled = disabled || loading.includes(permissionDefinition.key);
-
-          return (
-            <div key={permissionDefinition.key}>
-              <Checkbox
-                checked={isChecked}
-                disabled={isDisabled}
-                id={permissionDefinition.key}
-                label={translateWithParameters(
-                  'permission.assign_x_to_y',
-                  permissionDefinition.name,
-                  permissionItem.name
-                )}
-                onCheck={onCheck}
-              >
-                <span className="little-spacer-left">{permissionDefinition.name}</span>
-              </Checkbox>
-            </div>
-          );
-        })}
-      </td>
-    );
-  }
-
-  const isChecked = permissionItem.permissions.includes(permission.key);
-  const isDisabled = disabled || loading.includes(permission.key);
-
-  return (
-    <td
-      className={classNames('permission-column text-center text-middle', {
-        selected: permission.key === selectedPermission,
-      })}
-    >
-      <Checkbox
-        checked={isChecked}
-        disabled={isDisabled}
-        id={permission.key}
-        label={translateWithParameters(
-          'permission.assign_x_to_y',
-          permission.name,
-          permissionItem.name
-        )}
-        onCheck={onCheck}
-      />
-    </td>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/permissions/shared/components/PermissionHeader.tsx b/server/sonar-web/src/main/js/apps/permissions/shared/components/PermissionHeader.tsx
deleted file mode 100644 (file)
index 36371d6..0000000
+++ /dev/null
@@ -1,97 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import classNames from 'classnames';
-import * as React from 'react';
-import InstanceMessage from '../../../../components/common/InstanceMessage';
-import HelpTooltip from '../../../../components/controls/HelpTooltip';
-import Tooltip from '../../../../components/controls/Tooltip';
-import { translate, translateWithParameters } from '../../../../helpers/l10n';
-import { PermissionDefinition, PermissionDefinitionGroup } from '../../../../types/types';
-import { isPermissionDefinitionGroup } from '../../utils';
-
-interface Props {
-  onSelectPermission?: (permission: string) => void;
-  permission: PermissionDefinition | PermissionDefinitionGroup;
-  selectedPermission?: string;
-}
-
-export default class PermissionHeader extends React.PureComponent<Props> {
-  handlePermissionClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
-    event.preventDefault();
-    event.currentTarget.blur();
-    const { permission } = this.props;
-    if (this.props.onSelectPermission && !isPermissionDefinitionGroup(permission)) {
-      this.props.onSelectPermission(permission.key);
-    }
-  };
-
-  getTooltipOverlay = () => {
-    const { permission } = this.props;
-
-    if (isPermissionDefinitionGroup(permission)) {
-      return permission.permissions.map((permission) => (
-        <React.Fragment key={permission.key}>
-          <b className="little-spacer-right">{permission.name}:</b>
-          <InstanceMessage key={permission.key} message={permission.description} />
-          <br />
-        </React.Fragment>
-      ));
-    }
-
-    return <InstanceMessage message={permission.description} />;
-  };
-
-  render() {
-    const { onSelectPermission, permission } = this.props;
-    let name;
-    if (isPermissionDefinitionGroup(permission)) {
-      name = translate('global_permissions', permission.category);
-    } else {
-      name = onSelectPermission ? (
-        <Tooltip
-          overlay={translateWithParameters(
-            'global_permissions.filter_by_x_permission',
-            permission.name
-          )}
-        >
-          <a href="#" onClick={this.handlePermissionClick}>
-            {permission.name}
-          </a>
-        </Tooltip>
-      ) : (
-        permission.name
-      );
-    }
-    return (
-      <th
-        className={classNames('permission-column text-center text-middle', {
-          selected:
-            !isPermissionDefinitionGroup(permission) &&
-            permission.key === this.props.selectedPermission,
-        })}
-      >
-        <div className="permission-column-inner">
-          {name}
-          <HelpTooltip className="spacer-left" overlay={this.getTooltipOverlay()} />
-        </div>
-      </th>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/permissions/shared/components/SearchForm.tsx b/server/sonar-web/src/main/js/apps/permissions/shared/components/SearchForm.tsx
deleted file mode 100644 (file)
index fb5bc5b..0000000
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import ButtonToggle from '../../../../components/controls/ButtonToggle';
-import SearchBox from '../../../../components/controls/SearchBox';
-import { translate } from '../../../../helpers/l10n';
-
-export type FilterOption = 'all' | 'users' | 'groups';
-interface Props {
-  filter: FilterOption;
-  onFilter: (value: FilterOption) => void;
-  onSearch: (value: string) => void;
-  query: string;
-}
-
-export default function SearchForm(props: Props) {
-  const filterOptions = [
-    { value: 'all', label: translate('all') },
-    { value: 'users', label: translate('users.page') },
-    { value: 'groups', label: translate('user_groups.page') },
-  ];
-
-  return (
-    <div className="display-flex-row">
-      <ButtonToggle onCheck={props.onFilter} options={filterOptions} value={props.filter} />
-
-      <div className="flex-1 spacer-left">
-        <SearchBox
-          minLength={3}
-          onChange={props.onSearch}
-          placeholder={translate('search.search_for_users_or_groups')}
-          value={props.query}
-        />
-      </div>
-    </div>
-  );
-}
diff --git a/server/sonar-web/src/main/js/apps/permissions/shared/components/UserHolder.tsx b/server/sonar-web/src/main/js/apps/permissions/shared/components/UserHolder.tsx
deleted file mode 100644 (file)
index 8c6596d..0000000
+++ /dev/null
@@ -1,119 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import { without } from 'lodash';
-import * as React from 'react';
-import Avatar from '../../../../components/ui/Avatar';
-import { translate } from '../../../../helpers/l10n';
-import { PermissionDefinitions, PermissionUser } from '../../../../types/types';
-import { isPermissionDefinitionGroup } from '../../utils';
-import PermissionCell from './PermissionCell';
-
-interface Props {
-  onToggle: (user: PermissionUser, permission: string) => Promise<void>;
-  permissions: PermissionDefinitions;
-  selectedPermission?: string;
-  user: PermissionUser;
-}
-
-interface State {
-  loading: string[];
-}
-
-export default class UserHolder extends React.PureComponent<Props, State> {
-  mounted = false;
-  state: State = { loading: [] };
-
-  componentDidMount() {
-    this.mounted = true;
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  stopLoading = (permission: string) => {
-    if (this.mounted) {
-      this.setState((state) => ({ loading: without(state.loading, permission) }));
-    }
-  };
-
-  handleCheck = (_checked: boolean, permission?: string) => {
-    if (permission !== undefined) {
-      this.setState((state) => ({ loading: [...state.loading, permission] }));
-      this.props.onToggle(this.props.user, permission).then(
-        () => this.stopLoading(permission),
-        () => this.stopLoading(permission)
-      );
-    }
-  };
-
-  render() {
-    const { user } = this.props;
-    const permissionCells = this.props.permissions.map((permission) => (
-      <PermissionCell
-        key={isPermissionDefinitionGroup(permission) ? permission.category : permission.key}
-        loading={this.state.loading}
-        onCheck={this.handleCheck}
-        permission={permission}
-        permissionItem={user}
-        selectedPermission={this.props.selectedPermission}
-      />
-    ));
-
-    if (user.login === '<creator>') {
-      return (
-        <tr>
-          <td className="nowrap text-middle">
-            <div>
-              <strong>{user.name}</strong>
-            </div>
-            <div className="little-spacer-top" style={{ whiteSpace: 'normal' }}>
-              {translate('permission_templates.project_creators.explanation')}
-            </div>
-          </td>
-          {permissionCells}
-        </tr>
-      );
-    }
-
-    return (
-      <tr>
-        <td className="nowrap text-middle">
-          <div className="display-flex-center">
-            <Avatar
-              className="text-middle big-spacer-right flex-0"
-              hash={user.avatar}
-              name={user.name}
-              size={36}
-            />
-            <div className="max-width-100">
-              <div className="max-width-100 text-ellipsis">
-                <strong>{user.name}</strong>
-                <span className="note spacer-left">{user.login}</span>
-              </div>
-              <div className="little-spacer-top max-width-100 text-ellipsis">{user.email}</div>
-            </div>
-          </div>
-        </td>
-        {permissionCells}
-      </tr>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/permissions/test-utils.ts b/server/sonar-web/src/main/js/apps/permissions/test-utils.ts
new file mode 100644 (file)
index 0000000..eab1858
--- /dev/null
@@ -0,0 +1,142 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { waitFor } from '@testing-library/react';
+import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup';
+import selectEvent from 'react-select-event';
+import { byLabelText, byRole, byText } from 'testing-library-selector';
+import { Visibility } from '../../types/component';
+import { Permissions } from '../../types/permissions';
+
+export function getPageObject(user: UserEvent) {
+  const ui = {
+    loading: byLabelText('loading'),
+    projectPermissionCheckbox: (target: string, permission: Permissions) =>
+      byRole('checkbox', {
+        name: `permission.assign_x_to_y.projects_role.${permission}.${target}`,
+      }),
+    globalPermissionCheckbox: (target: string, permission: Permissions) =>
+      byRole('checkbox', {
+        name: `permission.assign_x_to_y.global_permissions.${permission}.${target}`,
+      }),
+    visibilityRadio: (visibility: Visibility) =>
+      byRole('radio', { name: `visibility.${visibility}` }),
+    makePublicDisclaimer: byText(
+      'projects_role.are_you_sure_to_turn_project_to_public.warning.TRK'
+    ),
+    confirmPublicBtn: byRole('button', { name: 'projects_role.turn_project_to_public.TRK' }),
+    openModalBtn: byRole('button', { name: 'projects_role.apply_template' }),
+    closeModalBtn: byRole('button', { name: 'close' }),
+    templateSelect: byRole('combobox', { name: /template/ }),
+    templateSuccessfullyApplied: byText('projects_role.apply_template.success'),
+    confirmApplyTemplateBtn: byRole('button', { name: 'apply' }),
+    tableHeaderFilter: (permission: Permissions) =>
+      byRole('link', { name: `projects_role.${permission}` }),
+    onlyUsersBtn: byRole('button', { name: 'users.page' }),
+    onlyGroupsBtn: byRole('button', { name: 'user_groups.page' }),
+    showAllBtn: byRole('button', { name: 'all' }),
+    searchInput: byRole('searchbox', { name: 'search.search_for_users_or_groups' }),
+    loadMoreBtn: byRole('button', { name: 'show_more' }),
+  };
+
+  return {
+    ...ui,
+    async appLoaded() {
+      await waitFor(() => {
+        expect(ui.loading.query()).not.toBeInTheDocument();
+      });
+    },
+    async toggleProjectPermission(target: string, permission: Permissions) {
+      await user.click(ui.projectPermissionCheckbox(target, permission).get());
+    },
+    async toggleGlobalPermission(target: string, permission: Permissions) {
+      await user.click(ui.globalPermissionCheckbox(target, permission).get());
+    },
+    async turnProjectPrivate() {
+      await user.click(ui.visibilityRadio(Visibility.Private).get());
+    },
+    async turnProjectPublic() {
+      await user.click(ui.visibilityRadio(Visibility.Public).get());
+    },
+    async confirmTurnProjectPublic() {
+      await user.click(ui.confirmPublicBtn.get());
+    },
+    async openTemplateModal() {
+      await user.click(ui.openModalBtn.get());
+    },
+    async closeTemplateModal() {
+      await user.click(ui.closeModalBtn.get());
+    },
+    async chooseTemplate(name: string) {
+      await selectEvent.select(ui.templateSelect.get(), [name]);
+      await user.click(ui.confirmApplyTemplateBtn.get());
+    },
+    async toggleFilterByPermission(permission: Permissions) {
+      await user.click(ui.tableHeaderFilter(permission).get());
+    },
+    async showOnlyUsers() {
+      await user.click(ui.onlyUsersBtn.get());
+    },
+    async showOnlyGroups() {
+      await user.click(ui.onlyGroupsBtn.get());
+    },
+    async showAll() {
+      await user.click(ui.showAllBtn.get());
+    },
+    async searchFor(name: string) {
+      await user.type(ui.searchInput.get(), name);
+    },
+    async clearSearch() {
+      await user.clear(ui.searchInput.get());
+    },
+    async clickLoadMore() {
+      await user.click(ui.loadMoreBtn.get());
+    },
+  };
+}
+
+export function flattenPermissionsList(
+  list: Array<
+    | Permissions
+    | {
+        category: string;
+        permissions: Permissions[];
+      }
+  >
+) {
+  function isPermissions(
+    p:
+      | Permissions
+      | {
+          category: string;
+          permissions: Permissions[];
+        }
+  ): p is Permissions {
+    return typeof p === 'string';
+  }
+
+  return list.reduce((acc, item) => {
+    if (isPermissions(item)) {
+      acc.push(item);
+    } else {
+      acc.push(...item.permissions);
+    }
+    return acc;
+  }, [] as Permissions[]);
+}
diff --git a/server/sonar-web/src/main/js/apps/permissions/utils.ts b/server/sonar-web/src/main/js/apps/permissions/utils.ts
deleted file mode 100644 (file)
index ea2e822..0000000
+++ /dev/null
@@ -1,113 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import { translate } from '../../helpers/l10n';
-import { Permissions } from '../../types/permissions';
-import { Dict, PermissionDefinition, PermissionDefinitionGroup } from '../../types/types';
-
-export const PERMISSIONS_ORDER_FOR_PROJECT_TEMPLATE = [
-  Permissions.Browse,
-  Permissions.CodeViewer,
-  Permissions.IssueAdmin,
-  Permissions.SecurityHotspotAdmin,
-  Permissions.Admin,
-  Permissions.Scan,
-];
-
-export const PERMISSIONS_ORDER_GLOBAL = [
-  Permissions.Admin,
-  {
-    category: 'administer',
-    permissions: [Permissions.QualityGateAdmin, Permissions.QualityProfileAdmin],
-  },
-  Permissions.Scan,
-  {
-    category: 'creator',
-    permissions: [
-      Permissions.ProjectCreation,
-      Permissions.ApplicationCreation,
-      Permissions.PortfolioCreation,
-    ],
-  },
-];
-
-export const PERMISSIONS_ORDER_FOR_VIEW = [Permissions.Browse, Permissions.Admin];
-
-export const PERMISSIONS_ORDER_BY_QUALIFIER: Dict<string[]> = {
-  TRK: PERMISSIONS_ORDER_FOR_PROJECT_TEMPLATE,
-  VW: PERMISSIONS_ORDER_FOR_VIEW,
-  SVW: PERMISSIONS_ORDER_FOR_VIEW,
-  APP: PERMISSIONS_ORDER_FOR_VIEW,
-};
-
-function convertToPermissionDefinition(permission: string, l10nPrefix: string) {
-  const name = translate(`${l10nPrefix}.${permission}`);
-  const description = translate(`${l10nPrefix}.${permission}.desc`);
-
-  return {
-    key: permission,
-    name,
-    description,
-  };
-}
-
-export function filterPermissions(
-  permissions: Array<Permissions | { category: string; permissions: Permissions[] }>,
-  hasApplicationsEnabled: boolean,
-  hasPortfoliosEnabled: boolean
-) {
-  return permissions.map((permission) => {
-    if (typeof permission === 'object' && permission.category === 'creator') {
-      return {
-        ...permission,
-        permissions: permission.permissions.filter((p) => {
-          return (
-            p === Permissions.ProjectCreation ||
-            (p === Permissions.PortfolioCreation && hasPortfoliosEnabled) ||
-            (p === Permissions.ApplicationCreation && hasApplicationsEnabled)
-          );
-        }),
-      };
-    }
-    return permission;
-  });
-}
-
-export function convertToPermissionDefinitions(
-  permissions: Array<string | { category: string; permissions: string[] }>,
-  l10nPrefix: string
-): Array<PermissionDefinition | PermissionDefinitionGroup> {
-  return permissions.map((permission) => {
-    if (typeof permission === 'object') {
-      return {
-        category: permission.category,
-        permissions: permission.permissions.map((permission) =>
-          convertToPermissionDefinition(permission, l10nPrefix)
-        ),
-      };
-    }
-    return convertToPermissionDefinition(permission, l10nPrefix);
-  });
-}
-
-export function isPermissionDefinitionGroup(
-  permission?: PermissionDefinition | PermissionDefinitionGroup
-): permission is PermissionDefinitionGroup {
-  return Boolean(permission && (permission as PermissionDefinitionGroup).category);
-}
diff --git a/server/sonar-web/src/main/js/components/permissions/AllHoldersList.tsx b/server/sonar-web/src/main/js/components/permissions/AllHoldersList.tsx
new file mode 100644 (file)
index 0000000..0d60b2f
--- /dev/null
@@ -0,0 +1,118 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import {
+  Paging,
+  PermissionDefinition,
+  PermissionDefinitionGroup,
+  PermissionGroup,
+  PermissionUser,
+} from '../../types/types';
+import ListFooter from '../controls/ListFooter';
+import HoldersList from './HoldersList';
+import SearchForm, { FilterOption } from './SearchForm';
+
+interface Props {
+  filter: FilterOption;
+  query: string;
+  onFilter: (filter: string) => void;
+  onQuery: (query: string) => void;
+  groups: PermissionGroup[];
+  groupsPaging?: Paging;
+  onRevokePermissionFromGroup: (group: string, permission: string) => Promise<void>;
+  onGrantPermissionToGroup: (group: string, permission: string) => Promise<void>;
+  users: PermissionUser[];
+  usersPaging?: Paging;
+  onRevokePermissionFromUser: (user: string, permission: string) => Promise<void>;
+  onGrantPermissionToUser: (user: string, permission: string) => Promise<void>;
+  permissions: Array<PermissionDefinition | PermissionDefinitionGroup>;
+  onLoadMore: () => void;
+  selectedPermission?: string;
+  onSelectPermission?: (permissions?: string) => void;
+  loading?: boolean;
+}
+
+export default class AllHoldersList extends React.PureComponent<Props> {
+  handleToggleUser = (user: PermissionUser, permission: string) => {
+    const hasPermission = user.permissions.includes(permission);
+
+    if (hasPermission) {
+      return this.props.onRevokePermissionFromUser(user.login, permission);
+    }
+    return this.props.onGrantPermissionToUser(user.login, permission);
+  };
+
+  handleToggleGroup = (group: PermissionGroup, permission: string) => {
+    const hasPermission = group.permissions.includes(permission);
+
+    if (hasPermission) {
+      return this.props.onRevokePermissionFromGroup(group.name, permission);
+    }
+
+    return this.props.onGrantPermissionToGroup(group.name, permission);
+  };
+
+  getPaging = () => {
+    const { filter, groups, groupsPaging, users, usersPaging } = this.props;
+
+    let count = 0;
+    let total = 0;
+    if (filter !== 'users') {
+      count += groups.length;
+      total += groupsPaging ? groupsPaging.total : groups.length;
+    }
+    if (filter !== 'groups') {
+      count += users.length;
+      total += usersPaging ? usersPaging.total : users.length;
+    }
+
+    return { count, total };
+  };
+
+  render() {
+    const { filter, query, groups, users, permissions, selectedPermission, loading } = this.props;
+    const { count, total } = this.getPaging();
+
+    return (
+      <>
+        <HoldersList
+          loading={loading}
+          filter={filter}
+          groups={groups}
+          onSelectPermission={this.props.onSelectPermission}
+          onToggleGroup={this.handleToggleGroup}
+          onToggleUser={this.handleToggleUser}
+          permissions={permissions}
+          query={query}
+          selectedPermission={selectedPermission}
+          users={users}
+        >
+          <SearchForm
+            filter={filter}
+            onFilter={this.props.onFilter}
+            onSearch={this.props.onQuery}
+            query={query}
+          />
+        </HoldersList>
+        <ListFooter count={count} loadMore={this.props.onLoadMore} total={total} />
+      </>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/permissions/GroupHolder.tsx b/server/sonar-web/src/main/js/components/permissions/GroupHolder.tsx
new file mode 100644 (file)
index 0000000..3cf8698
--- /dev/null
@@ -0,0 +1,114 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { without } from 'lodash';
+import * as React from 'react';
+import { translate } from '../../helpers/l10n';
+import { isPermissionDefinitionGroup } from '../../helpers/permissions';
+import { Permissions } from '../../types/permissions';
+import { PermissionDefinitions, PermissionGroup } from '../../types/types';
+import GroupIcon from '../icons/GroupIcon';
+import PermissionCell from './PermissionCell';
+
+interface Props {
+  group: PermissionGroup;
+  isComponentPrivate?: boolean;
+  onToggle: (group: PermissionGroup, permission: string) => Promise<void>;
+  permissions: PermissionDefinitions;
+  selectedPermission?: string;
+}
+
+interface State {
+  loading: string[];
+}
+
+export const ANYONE = 'Anyone';
+
+export default class GroupHolder extends React.PureComponent<Props, State> {
+  mounted = false;
+  state: State = { loading: [] };
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  stopLoading = (permission: string) => {
+    if (this.mounted) {
+      this.setState((state) => ({ loading: without(state.loading, permission) }));
+    }
+  };
+
+  handleCheck = (_checked: boolean, permission?: string) => {
+    if (permission !== undefined) {
+      this.setState((state) => ({ loading: [...state.loading, permission] }));
+      this.props.onToggle(this.props.group, permission).then(
+        () => this.stopLoading(permission),
+        () => this.stopLoading(permission)
+      );
+    }
+  };
+
+  render() {
+    const { group, isComponentPrivate, permissions, selectedPermission } = this.props;
+
+    return (
+      <tr>
+        <td className="nowrap text-middle">
+          <div className="display-flex-center">
+            <GroupIcon className="big-spacer-right" />
+            <div className="max-width-100">
+              <div className="max-width-100 text-ellipsis">
+                <strong>{group.name}</strong>
+                {group.name === ANYONE && (
+                  <span className="spacer-left badge badge-error">{translate('deprecated')}</span>
+                )}
+              </div>
+              <div className="little-spacer-top" style={{ whiteSpace: 'normal' }}>
+                {group.name === ANYONE
+                  ? translate('user_groups.anyone.description')
+                  : group.description}
+              </div>
+            </div>
+          </div>
+        </td>
+        {permissions.map((permission) => {
+          const isPermissionGroup = isPermissionDefinitionGroup(permission);
+          const permissionKey = isPermissionGroup ? permission.category : permission.key;
+          const isAdminPermission = !isPermissionGroup && permissionKey === Permissions.Admin;
+
+          return (
+            <PermissionCell
+              disabled={group.name === ANYONE && (isComponentPrivate || isAdminPermission)}
+              key={permissionKey}
+              loading={this.state.loading}
+              onCheck={this.handleCheck}
+              permission={permission}
+              permissionItem={group}
+              selectedPermission={selectedPermission}
+            />
+          );
+        })}
+      </tr>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/permissions/HoldersList.tsx b/server/sonar-web/src/main/js/components/permissions/HoldersList.tsx
new file mode 100644 (file)
index 0000000..da9250f
--- /dev/null
@@ -0,0 +1,163 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { partition } from 'lodash';
+import * as React from 'react';
+import { translate } from '../../helpers/l10n';
+import { isPermissionDefinitionGroup } from '../../helpers/permissions';
+import { Dict, PermissionDefinitions, PermissionGroup, PermissionUser } from '../../types/types';
+import GroupHolder from './GroupHolder';
+import PermissionHeader from './PermissionHeader';
+import UserHolder from './UserHolder';
+
+interface Props {
+  filter?: string;
+  groups: PermissionGroup[];
+  isComponentPrivate?: boolean;
+  loading?: boolean;
+  onSelectPermission?: (permission: string) => void;
+  onToggleGroup: (group: PermissionGroup, permission: string) => Promise<void>;
+  onToggleUser: (user: PermissionUser, permission: string) => Promise<void>;
+  permissions: PermissionDefinitions;
+  query?: string;
+  selectedPermission?: string;
+  users: PermissionUser[];
+}
+
+interface State {
+  initialPermissionsCount: Dict<number>;
+}
+export default class HoldersList extends React.PureComponent<Props, State> {
+  state: State = { initialPermissionsCount: {} };
+  componentDidUpdate(prevProps: Props) {
+    if (this.props.filter !== prevProps.filter || this.props.query !== prevProps.query) {
+      this.setState({ initialPermissionsCount: {} });
+    }
+  }
+
+  isPermissionUser(item: PermissionGroup | PermissionUser): item is PermissionUser {
+    return (item as PermissionUser).login !== undefined;
+  }
+
+  handleGroupToggle = (group: PermissionGroup, permission: string) => {
+    const key = group.id || group.name;
+    if (this.state.initialPermissionsCount[key] === undefined) {
+      this.setState((state) => ({
+        initialPermissionsCount: {
+          ...state.initialPermissionsCount,
+          [key]: group.permissions.length,
+        },
+      }));
+    }
+    return this.props.onToggleGroup(group, permission);
+  };
+
+  handleUserToggle = (user: PermissionUser, permission: string) => {
+    if (this.state.initialPermissionsCount[user.login] === undefined) {
+      this.setState((state) => ({
+        initialPermissionsCount: {
+          ...state.initialPermissionsCount,
+          [user.login]: user.permissions.length,
+        },
+      }));
+    }
+    return this.props.onToggleUser(user, permission);
+  };
+
+  getItemInitialPermissionsCount = (item: PermissionGroup | PermissionUser) => {
+    const key = this.isPermissionUser(item) ? item.login : item.id || item.name;
+    return this.state.initialPermissionsCount[key] !== undefined
+      ? this.state.initialPermissionsCount[key]
+      : item.permissions.length;
+  };
+
+  renderEmpty() {
+    const columns = this.props.permissions.length + 1;
+    return (
+      <tr>
+        <td colSpan={columns}>{translate('no_results_search')}</td>
+      </tr>
+    );
+  }
+
+  renderItem(item: PermissionUser | PermissionGroup, permissions: PermissionDefinitions) {
+    return this.isPermissionUser(item) ? (
+      <UserHolder
+        key={`user-${item.login}`}
+        onToggle={this.handleUserToggle}
+        permissions={permissions}
+        selectedPermission={this.props.selectedPermission}
+        user={item}
+      />
+    ) : (
+      <GroupHolder
+        group={item}
+        isComponentPrivate={this.props.isComponentPrivate}
+        key={`group-${item.id || item.name}`}
+        onToggle={this.handleGroupToggle}
+        permissions={permissions}
+        selectedPermission={this.props.selectedPermission}
+      />
+    );
+  }
+
+  render() {
+    const { permissions, users, groups, loading, children, selectedPermission } = this.props;
+    const items = [...groups, ...users];
+    const [itemWithPermissions, itemWithoutPermissions] = partition(items, (item) =>
+      this.getItemInitialPermissionsCount(item)
+    );
+
+    return (
+      <div className="boxed-group boxed-group-inner">
+        <table className="data zebra permissions-table">
+          <thead>
+            <tr>
+              <td className="nowrap bordered-bottom">{children}</td>
+              {permissions.map((permission) => (
+                <PermissionHeader
+                  key={
+                    isPermissionDefinitionGroup(permission) ? permission.category : permission.key
+                  }
+                  onSelectPermission={this.props.onSelectPermission}
+                  permission={permission}
+                  selectedPermission={selectedPermission}
+                />
+              ))}
+            </tr>
+          </thead>
+          <tbody>
+            {items.length === 0 && !loading && this.renderEmpty()}
+            {itemWithPermissions.map((item) => this.renderItem(item, permissions))}
+            {itemWithPermissions.length > 0 && itemWithoutPermissions.length > 0 && (
+              <>
+                <tr>
+                  <td className="divider" colSpan={20} />
+                </tr>
+                <tr />
+                {/* Keep correct zebra colors in the table */}
+              </>
+            )}
+            {itemWithoutPermissions.map((item) => this.renderItem(item, permissions))}
+          </tbody>
+        </table>
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/permissions/PermissionCell.tsx b/server/sonar-web/src/main/js/components/permissions/PermissionCell.tsx
new file mode 100644 (file)
index 0000000..9e227ff
--- /dev/null
@@ -0,0 +1,95 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import classNames from 'classnames';
+import * as React from 'react';
+import { translateWithParameters } from '../../helpers/l10n';
+import { isPermissionDefinitionGroup } from '../../helpers/permissions';
+import {
+  PermissionDefinition,
+  PermissionDefinitionGroup,
+  PermissionGroup,
+  PermissionUser,
+} from '../../types/types';
+import Checkbox from '../controls/Checkbox';
+
+export interface PermissionCellProps {
+  disabled?: boolean;
+  loading: string[];
+  onCheck: (checked: boolean, permission?: string) => void;
+  permission: PermissionDefinition | PermissionDefinitionGroup;
+  permissionItem: PermissionGroup | PermissionUser;
+  selectedPermission?: string;
+}
+
+export default function PermissionCell(props: PermissionCellProps) {
+  const { disabled, loading, onCheck, permission, permissionItem, selectedPermission } = props;
+
+  if (isPermissionDefinitionGroup(permission)) {
+    return (
+      <td className="text-middle">
+        {permission.permissions.map((permissionDefinition) => {
+          const isChecked = permissionItem.permissions.includes(permissionDefinition.key);
+          const isDisabled = disabled || loading.includes(permissionDefinition.key);
+
+          return (
+            <div key={permissionDefinition.key}>
+              <Checkbox
+                checked={isChecked}
+                disabled={isDisabled}
+                id={permissionDefinition.key}
+                label={translateWithParameters(
+                  'permission.assign_x_to_y',
+                  permissionDefinition.name,
+                  permissionItem.name
+                )}
+                onCheck={onCheck}
+              >
+                <span className="little-spacer-left">{permissionDefinition.name}</span>
+              </Checkbox>
+            </div>
+          );
+        })}
+      </td>
+    );
+  }
+
+  const isChecked = permissionItem.permissions.includes(permission.key);
+  const isDisabled = disabled || loading.includes(permission.key);
+
+  return (
+    <td
+      className={classNames('permission-column text-center text-middle', {
+        selected: permission.key === selectedPermission,
+      })}
+    >
+      <Checkbox
+        checked={isChecked}
+        disabled={isDisabled}
+        id={permission.key}
+        label={translateWithParameters(
+          'permission.assign_x_to_y',
+          permission.name,
+          permissionItem.name
+        )}
+        onCheck={onCheck}
+      />
+    </td>
+  );
+}
diff --git a/server/sonar-web/src/main/js/components/permissions/PermissionHeader.tsx b/server/sonar-web/src/main/js/components/permissions/PermissionHeader.tsx
new file mode 100644 (file)
index 0000000..fcea871
--- /dev/null
@@ -0,0 +1,97 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import classNames from 'classnames';
+import * as React from 'react';
+import { translate, translateWithParameters } from '../../helpers/l10n';
+import { isPermissionDefinitionGroup } from '../../helpers/permissions';
+import { PermissionDefinition, PermissionDefinitionGroup } from '../../types/types';
+import InstanceMessage from '../common/InstanceMessage';
+import HelpTooltip from '../controls/HelpTooltip';
+import Tooltip from '../controls/Tooltip';
+
+interface Props {
+  onSelectPermission?: (permission: string) => void;
+  permission: PermissionDefinition | PermissionDefinitionGroup;
+  selectedPermission?: string;
+}
+
+export default class PermissionHeader extends React.PureComponent<Props> {
+  handlePermissionClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+    event.preventDefault();
+    event.currentTarget.blur();
+    const { permission } = this.props;
+    if (this.props.onSelectPermission && !isPermissionDefinitionGroup(permission)) {
+      this.props.onSelectPermission(permission.key);
+    }
+  };
+
+  getTooltipOverlay = () => {
+    const { permission } = this.props;
+
+    if (isPermissionDefinitionGroup(permission)) {
+      return permission.permissions.map((permission) => (
+        <React.Fragment key={permission.key}>
+          <b className="little-spacer-right">{permission.name}:</b>
+          <InstanceMessage key={permission.key} message={permission.description} />
+          <br />
+        </React.Fragment>
+      ));
+    }
+
+    return <InstanceMessage message={permission.description} />;
+  };
+
+  render() {
+    const { onSelectPermission, permission } = this.props;
+    let name;
+    if (isPermissionDefinitionGroup(permission)) {
+      name = translate('global_permissions', permission.category);
+    } else {
+      name = onSelectPermission ? (
+        <Tooltip
+          overlay={translateWithParameters(
+            'global_permissions.filter_by_x_permission',
+            permission.name
+          )}
+        >
+          <a href="#" onClick={this.handlePermissionClick}>
+            {permission.name}
+          </a>
+        </Tooltip>
+      ) : (
+        permission.name
+      );
+    }
+    return (
+      <th
+        className={classNames('permission-column text-center text-middle', {
+          selected:
+            !isPermissionDefinitionGroup(permission) &&
+            permission.key === this.props.selectedPermission,
+        })}
+      >
+        <div className="permission-column-inner">
+          {name}
+          <HelpTooltip className="spacer-left" overlay={this.getTooltipOverlay()} />
+        </div>
+      </th>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/permissions/SearchForm.tsx b/server/sonar-web/src/main/js/components/permissions/SearchForm.tsx
new file mode 100644 (file)
index 0000000..3c5ef9e
--- /dev/null
@@ -0,0 +1,54 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { translate } from '../../helpers/l10n';
+import ButtonToggle from '../controls/ButtonToggle';
+import SearchBox from '../controls/SearchBox';
+
+export type FilterOption = 'all' | 'users' | 'groups';
+interface Props {
+  filter: FilterOption;
+  onFilter: (value: FilterOption) => void;
+  onSearch: (value: string) => void;
+  query: string;
+}
+
+export default function SearchForm(props: Props) {
+  const filterOptions = [
+    { value: 'all', label: translate('all') },
+    { value: 'users', label: translate('users.page') },
+    { value: 'groups', label: translate('user_groups.page') },
+  ];
+
+  return (
+    <div className="display-flex-row">
+      <ButtonToggle onCheck={props.onFilter} options={filterOptions} value={props.filter} />
+
+      <div className="flex-1 spacer-left">
+        <SearchBox
+          minLength={3}
+          onChange={props.onSearch}
+          placeholder={translate('search.search_for_users_or_groups')}
+          value={props.query}
+        />
+      </div>
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/components/permissions/UserHolder.tsx b/server/sonar-web/src/main/js/components/permissions/UserHolder.tsx
new file mode 100644 (file)
index 0000000..7336a71
--- /dev/null
@@ -0,0 +1,119 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { without } from 'lodash';
+import * as React from 'react';
+import { translate } from '../../helpers/l10n';
+import { isPermissionDefinitionGroup } from '../../helpers/permissions';
+import { PermissionDefinitions, PermissionUser } from '../../types/types';
+import Avatar from '../ui/Avatar';
+import PermissionCell from './PermissionCell';
+
+interface Props {
+  onToggle: (user: PermissionUser, permission: string) => Promise<void>;
+  permissions: PermissionDefinitions;
+  selectedPermission?: string;
+  user: PermissionUser;
+}
+
+interface State {
+  loading: string[];
+}
+
+export default class UserHolder extends React.PureComponent<Props, State> {
+  mounted = false;
+  state: State = { loading: [] };
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  stopLoading = (permission: string) => {
+    if (this.mounted) {
+      this.setState((state) => ({ loading: without(state.loading, permission) }));
+    }
+  };
+
+  handleCheck = (_checked: boolean, permission?: string) => {
+    if (permission !== undefined) {
+      this.setState((state) => ({ loading: [...state.loading, permission] }));
+      this.props.onToggle(this.props.user, permission).then(
+        () => this.stopLoading(permission),
+        () => this.stopLoading(permission)
+      );
+    }
+  };
+
+  render() {
+    const { user } = this.props;
+    const permissionCells = this.props.permissions.map((permission) => (
+      <PermissionCell
+        key={isPermissionDefinitionGroup(permission) ? permission.category : permission.key}
+        loading={this.state.loading}
+        onCheck={this.handleCheck}
+        permission={permission}
+        permissionItem={user}
+        selectedPermission={this.props.selectedPermission}
+      />
+    ));
+
+    if (user.login === '<creator>') {
+      return (
+        <tr>
+          <td className="nowrap text-middle">
+            <div>
+              <strong>{user.name}</strong>
+            </div>
+            <div className="little-spacer-top" style={{ whiteSpace: 'normal' }}>
+              {translate('permission_templates.project_creators.explanation')}
+            </div>
+          </td>
+          {permissionCells}
+        </tr>
+      );
+    }
+
+    return (
+      <tr>
+        <td className="nowrap text-middle">
+          <div className="display-flex-center">
+            <Avatar
+              className="text-middle big-spacer-right flex-0"
+              hash={user.avatar}
+              name={user.name}
+              size={36}
+            />
+            <div className="max-width-100">
+              <div className="max-width-100 text-ellipsis">
+                <strong>{user.name}</strong>
+                <span className="note spacer-left">{user.login}</span>
+              </div>
+              <div className="little-spacer-top max-width-100 text-ellipsis">{user.email}</div>
+            </div>
+          </div>
+        </td>
+        {permissionCells}
+      </tr>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/helpers/__tests__/permissions-test.ts b/server/sonar-web/src/main/js/helpers/__tests__/permissions-test.ts
new file mode 100644 (file)
index 0000000..041ede5
--- /dev/null
@@ -0,0 +1,39 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { convertToPermissionDefinitions } from '../permissions';
+
+jest.mock('../l10nBundle', () => ({
+  getMessages: jest.fn().mockReturnValue({}),
+}));
+
+describe('convertToPermissionDefinitions', () => {
+  it('should convert and translate a permission definition', () => {
+    const data = convertToPermissionDefinitions(['admin'], 'global_permissions');
+    const expected = [
+      {
+        description: 'global_permissions.admin.desc',
+        key: 'admin',
+        name: 'global_permissions.admin',
+      },
+    ];
+
+    expect(data).toEqual(expected);
+  });
+});
diff --git a/server/sonar-web/src/main/js/helpers/permissions.ts b/server/sonar-web/src/main/js/helpers/permissions.ts
new file mode 100644 (file)
index 0000000..c1ef07b
--- /dev/null
@@ -0,0 +1,113 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { Permissions } from '../types/permissions';
+import { Dict, PermissionDefinition, PermissionDefinitionGroup } from '../types/types';
+import { translate } from './l10n';
+
+export const PERMISSIONS_ORDER_FOR_PROJECT_TEMPLATE = [
+  Permissions.Browse,
+  Permissions.CodeViewer,
+  Permissions.IssueAdmin,
+  Permissions.SecurityHotspotAdmin,
+  Permissions.Admin,
+  Permissions.Scan,
+];
+
+export const PERMISSIONS_ORDER_GLOBAL = [
+  Permissions.Admin,
+  {
+    category: 'administer',
+    permissions: [Permissions.QualityGateAdmin, Permissions.QualityProfileAdmin],
+  },
+  Permissions.Scan,
+  {
+    category: 'creator',
+    permissions: [
+      Permissions.ProjectCreation,
+      Permissions.ApplicationCreation,
+      Permissions.PortfolioCreation,
+    ],
+  },
+];
+
+export const PERMISSIONS_ORDER_FOR_VIEW = [Permissions.Browse, Permissions.Admin];
+
+export const PERMISSIONS_ORDER_BY_QUALIFIER: Dict<string[]> = {
+  TRK: PERMISSIONS_ORDER_FOR_PROJECT_TEMPLATE,
+  VW: PERMISSIONS_ORDER_FOR_VIEW,
+  SVW: PERMISSIONS_ORDER_FOR_VIEW,
+  APP: PERMISSIONS_ORDER_FOR_VIEW,
+};
+
+function convertToPermissionDefinition(permission: string, l10nPrefix: string) {
+  const name = translate(`${l10nPrefix}.${permission}`);
+  const description = translate(`${l10nPrefix}.${permission}.desc`);
+
+  return {
+    key: permission,
+    name,
+    description,
+  };
+}
+
+export function filterPermissions(
+  permissions: Array<Permissions | { category: string; permissions: Permissions[] }>,
+  hasApplicationsEnabled: boolean,
+  hasPortfoliosEnabled: boolean
+) {
+  return permissions.map((permission) => {
+    if (typeof permission === 'object' && permission.category === 'creator') {
+      return {
+        ...permission,
+        permissions: permission.permissions.filter((p) => {
+          return (
+            p === Permissions.ProjectCreation ||
+            (p === Permissions.PortfolioCreation && hasPortfoliosEnabled) ||
+            (p === Permissions.ApplicationCreation && hasApplicationsEnabled)
+          );
+        }),
+      };
+    }
+    return permission;
+  });
+}
+
+export function convertToPermissionDefinitions(
+  permissions: Array<string | { category: string; permissions: string[] }>,
+  l10nPrefix: string
+): Array<PermissionDefinition | PermissionDefinitionGroup> {
+  return permissions.map((permission) => {
+    if (typeof permission === 'object') {
+      return {
+        category: permission.category,
+        permissions: permission.permissions.map((permission) =>
+          convertToPermissionDefinition(permission, l10nPrefix)
+        ),
+      };
+    }
+    return convertToPermissionDefinition(permission, l10nPrefix);
+  });
+}
+
+export function isPermissionDefinitionGroup(
+  permission?: PermissionDefinition | PermissionDefinitionGroup
+): permission is PermissionDefinitionGroup {
+  return Boolean(permission && (permission as PermissionDefinitionGroup).category);
+}
index ad8b57a062d768fe90138b64c24c24121a3eb9cc..d3a32551d7b7fce80a482bcecece0247d325f722 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { render, RenderResult } from '@testing-library/react';
+import { omit } from 'lodash';
 import * as React from 'react';
 import { HelmetProvider } from 'react-helmet-async';
 import { IntlProvider } from 'react-intl';
@@ -35,8 +36,9 @@ import { useLocation } from '../components/hoc/withRouter';
 import { AppState } from '../types/appstate';
 import { ComponentContextShape } from '../types/component';
 import { Feature } from '../types/features';
-import { Dict, Extension, Languages, Metric, SysStatus } from '../types/types';
+import { Component, Dict, Extension, Languages, Metric, SysStatus } from '../types/types';
 import { CurrentUser } from '../types/users';
+import { mockComponent } from './mocks/component';
 import { DEFAULT_METRICS } from './mocks/metrics';
 import { mockAppState, mockCurrentUser } from './testMocks';
 
@@ -107,16 +109,22 @@ export function renderAppWithComponentContext(
   indexPath: string,
   routes: () => JSX.Element,
   context: RenderContext = {},
-  componentContext?: Partial<ComponentContextShape>
+  componentContext: Partial<ComponentContextShape> = {}
 ) {
   function MockComponentContainer() {
+    const [realComponent, setRealComponent] = React.useState(
+      componentContext?.component ?? mockComponent()
+    );
     return (
       <ComponentContext.Provider
         value={{
           branchLikes: [],
           onBranchesChange: jest.fn(),
-          onComponentChange: jest.fn(),
-          ...componentContext,
+          onComponentChange: (changes: Partial<Component>) => {
+            setRealComponent({ ...realComponent, ...changes });
+          },
+          component: realComponent,
+          ...omit(componentContext, 'component'),
         }}
       >
         <Outlet />