From: Wouter Admiraal Date: Tue, 21 Feb 2023 07:17:04 +0000 (+0100) Subject: SONAR-18430 Migrate permissions app tests to RTL X-Git-Tag: 10.0.0.68432~202 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=108b7a267c3a61ffe20c118cc11dc12e852ccedf;p=sonarqube.git SONAR-18430 Migrate permissions app tests to RTL --- diff --git a/server/sonar-web/src/main/js/api/mocks/PermissionsServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/PermissionsServiceMock.ts index 96531f55e5c..c8edbe64903 100644 --- a/server/sonar-web/src/main/js/api/mocks/PermissionsServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/PermissionsServiceMock.ts @@ -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(response: T): Promise { diff --git a/server/sonar-web/src/main/js/apps/permission-templates/components/Template.tsx b/server/sonar-web/src/main/js/apps/permission-templates/components/Template.tsx index 18a55478f7b..3a12d1dd84c 100644 --- a/server/sonar-web/src/main/js/apps/permission-templates/components/Template.tsx +++ b/server/sonar-web/src/main/js/apps/permission-templates/components/Template.tsx @@ -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 { { 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} diff --git a/server/sonar-web/src/main/js/apps/permission-templates/components/__tests__/PermissionTemplatesApp-it.tsx b/server/sonar-web/src/main/js/apps/permission-templates/components/__tests__/PermissionTemplatesApp-it.tsx index 52e7da56855..799cb16ee55 100644 --- a/server/sonar-web/src/main/js/apps/permission-templates/components/__tests__/PermissionTemplatesApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/permission-templates/components/__tests__/PermissionTemplatesApp-it.tsx @@ -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 index b4b6e369d27..00000000000 --- a/server/sonar-web/src/main/js/apps/permissions/__tests__/utils-test.ts +++ /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 index 5e0c9a0c8a6..00000000000 --- a/server/sonar-web/src/main/js/apps/permissions/global/components/App.tsx +++ /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 { - 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 ( -
- - - - -
- ); - } -} - -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 index 00000000000..e6b1563d185 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/permissions/global/components/PermissionsGlobalApp.tsx @@ -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 { + 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 ( +
+ + + + +
+ ); + } +} + +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 index f76d0c04c57..00000000000 --- a/server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/App-test.tsx +++ /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 = {}) { - return shallow(); -} 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 index 00000000000..126671aec9f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/PermissionsGlobal-it.tsx @@ -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 index 55679b704a0..00000000000 --- a/server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/__snapshots__/App-test.tsx.snap +++ /dev/null @@ -1,202 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render correctly 1`] = ` -
- - - - -
-`; - -exports[`should render correctly 2`] = ` -
- - - - -
-`; diff --git a/server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx b/server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx index a1d7c39f50c..e6f27deb12f 100644 --- a/server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx +++ b/server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx @@ -79,7 +79,7 @@ export default class PageHeader extends React.PureComponent {

{translate('permissions.page')}

- + {canApplyPermissionTemplate && (
diff --git a/server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectApp.tsx b/server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectApp.tsx index acefdf94fb6..8850f8c033b 100644 --- a/server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectApp.tsx +++ b/server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectApp.tsx @@ -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) => void; } interface State { @@ -51,7 +53,7 @@ interface State { usersPaging?: Paging; } -export class PermissionsProjectApp extends React.PureComponent { +class PermissionsProjectApp extends React.PureComponent { mounted = false; constructor(props: Props) { @@ -117,7 +119,7 @@ export class PermissionsProjectApp extends React.PureComponent { }, 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 { ); }; - 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 { } }; - 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 { } }; - closeDisclaimer = () => { + handleCloseDisclaimer = () => { if (this.mounted) { this.setState({ disclaimer: false }); } @@ -341,7 +331,7 @@ export class PermissionsProjectApp extends React.PureComponent { const permissions = convertToPermissionDefinitions(order, 'projects_role'); return ( -
+
@@ -356,30 +346,30 @@ export class PermissionsProjectApp extends React.PureComponent { {disclaimer && ( )}
-
+ ); } } diff --git a/server/sonar-web/src/main/js/apps/permissions/project/components/__tests__/PermissionsProject-it.tsx b/server/sonar-web/src/main/js/apps/permissions/project/components/__tests__/PermissionsProject-it.tsx index 0e8d81a0d0f..f73dbcac56a 100644 --- a/server/sonar-web/src/main/js/apps/permissions/project/components/__tests__/PermissionsProject-it.tsx +++ b/server/sonar-web/src/main/js/apps/permissions/project/components/__tests__/PermissionsProject-it.tsx @@ -18,20 +18,21 @@ * 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) { - function App({ component }: { component: Component }) { - const [realComponent, setRealComponent] = React.useState(component); - return ( - ) => { - setRealComponent({ ...realComponent, ...changes }); - }} - /> - ); - } - - return renderApp( - '/', - + }), + } ); } diff --git a/server/sonar-web/src/main/js/apps/permissions/routes.tsx b/server/sonar-web/src/main/js/apps/permissions/routes.tsx index 9670b118c68..0e1e8721c63 100644 --- a/server/sonar-web/src/main/js/apps/permissions/routes.tsx +++ b/server/sonar-web/src/main/js/apps/permissions/routes.tsx @@ -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 index 9e54ec1fc8c..00000000000 --- a/server/sonar-web/src/main/js/apps/permissions/shared/components/AllHoldersList.tsx +++ /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; - grantPermissionToGroup: (group: string, permission: string) => Promise; - users: PermissionUser[]; - usersPaging?: Paging; - revokePermissionFromUser: (user: string, permission: string) => Promise; - grantPermissionToUser: (user: string, permission: string) => Promise; - permissions: Array; - onLoadMore: () => void; - selectedPermission?: string; - onSelectPermission?: (permissions?: string) => void; - loading?: boolean; -} - -export default class AllHoldersList extends React.PureComponent { - 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 ( - <> - - - - - - ); - } -} 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 index 91d89a431f0..00000000000 --- a/server/sonar-web/src/main/js/apps/permissions/shared/components/GroupHolder.tsx +++ /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; - permissions: PermissionDefinitions; - selectedPermission?: string; -} - -interface State { - loading: string[]; -} - -export const ANYONE = 'Anyone'; - -export default class GroupHolder extends React.PureComponent { - 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 ( - - -
- -
-
- {group.name} - {group.name === ANYONE && ( - {translate('deprecated')} - )} -
-
- {group.name === ANYONE - ? translate('user_groups.anyone.description') - : group.description} -
-
-
- - {permissions.map((permission) => { - const isPermissionGroup = isPermissionDefinitionGroup(permission); - const permissionKey = isPermissionGroup ? permission.category : permission.key; - const isAdminPermission = !isPermissionGroup && permissionKey === Permissions.Admin; - - return ( - - ); - })} - - ); - } -} 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 index 3b6d1f407c0..00000000000 --- a/server/sonar-web/src/main/js/apps/permissions/shared/components/HoldersList.tsx +++ /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; - onToggleUser: (user: PermissionUser, permission: string) => Promise; - permissions: PermissionDefinitions; - query?: string; - selectedPermission?: string; - users: PermissionUser[]; -} - -interface State { - initialPermissionsCount: Dict; -} -export default class HoldersList extends React.PureComponent { - 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 ( - - {translate('no_results_search')} - - ); - } - - renderItem(item: PermissionUser | PermissionGroup, permissions: PermissionDefinitions) { - return this.isPermissionUser(item) ? ( - - ) : ( - - ); - } - - render() { - const { permissions, users, groups, loading, children, selectedPermission } = this.props; - const items = [...groups, ...users]; - const [itemWithPermissions, itemWithoutPermissions] = partition(items, (item) => - this.getItemInitialPermissionsCount(item) - ); - - return ( -
- - - - - {permissions.map((permission) => ( - - ))} - - - - {items.length === 0 && !loading && this.renderEmpty()} - {itemWithPermissions.map((item) => this.renderItem(item, permissions))} - {itemWithPermissions.length > 0 && itemWithoutPermissions.length > 0 && ( - <> - - - - {/* Keep correct zebra colors in the table */} - - )} - {itemWithoutPermissions.map((item) => this.renderItem(item, permissions))} - -
{children}
-
-
- ); - } -} 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 index e8657cd2b05..00000000000 --- a/server/sonar-web/src/main/js/apps/permissions/shared/components/PermissionCell.tsx +++ /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 ( - - {permission.permissions.map((permissionDefinition) => { - const isChecked = permissionItem.permissions.includes(permissionDefinition.key); - const isDisabled = disabled || loading.includes(permissionDefinition.key); - - return ( -
- - {permissionDefinition.name} - -
- ); - })} - - ); - } - - const isChecked = permissionItem.permissions.includes(permission.key); - const isDisabled = disabled || loading.includes(permission.key); - - return ( - - - - ); -} 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 index 36371d68ec8..00000000000 --- a/server/sonar-web/src/main/js/apps/permissions/shared/components/PermissionHeader.tsx +++ /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 { - handlePermissionClick = (event: React.SyntheticEvent) => { - 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) => ( - - {permission.name}: - -
-
- )); - } - - return ; - }; - - render() { - const { onSelectPermission, permission } = this.props; - let name; - if (isPermissionDefinitionGroup(permission)) { - name = translate('global_permissions', permission.category); - } else { - name = onSelectPermission ? ( - - - {permission.name} - - - ) : ( - permission.name - ); - } - return ( - -
- {name} - -
- - ); - } -} 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 index fb5bc5b0bf3..00000000000 --- a/server/sonar-web/src/main/js/apps/permissions/shared/components/SearchForm.tsx +++ /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 ( -
- - -
- -
-
- ); -} 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 index 8c6596deb69..00000000000 --- a/server/sonar-web/src/main/js/apps/permissions/shared/components/UserHolder.tsx +++ /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; - permissions: PermissionDefinitions; - selectedPermission?: string; - user: PermissionUser; -} - -interface State { - loading: string[]; -} - -export default class UserHolder extends React.PureComponent { - 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) => ( - - )); - - if (user.login === '') { - return ( - - -
- {user.name} -
-
- {translate('permission_templates.project_creators.explanation')} -
- - {permissionCells} - - ); - } - - return ( - - -
- -
-
- {user.name} - {user.login} -
-
{user.email}
-
-
- - {permissionCells} - - ); - } -} 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 index 00000000000..eab1858e81f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/permissions/test-utils.ts @@ -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 index ea2e8220f3c..00000000000 --- a/server/sonar-web/src/main/js/apps/permissions/utils.ts +++ /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 = { - 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, - 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, - l10nPrefix: string -): Array { - 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 index 00000000000..0d60b2fa438 --- /dev/null +++ b/server/sonar-web/src/main/js/components/permissions/AllHoldersList.tsx @@ -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; + onGrantPermissionToGroup: (group: string, permission: string) => Promise; + users: PermissionUser[]; + usersPaging?: Paging; + onRevokePermissionFromUser: (user: string, permission: string) => Promise; + onGrantPermissionToUser: (user: string, permission: string) => Promise; + permissions: Array; + onLoadMore: () => void; + selectedPermission?: string; + onSelectPermission?: (permissions?: string) => void; + loading?: boolean; +} + +export default class AllHoldersList extends React.PureComponent { + 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 ( + <> + + + + + + ); + } +} 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 index 00000000000..3cf869814d6 --- /dev/null +++ b/server/sonar-web/src/main/js/components/permissions/GroupHolder.tsx @@ -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; + permissions: PermissionDefinitions; + selectedPermission?: string; +} + +interface State { + loading: string[]; +} + +export const ANYONE = 'Anyone'; + +export default class GroupHolder extends React.PureComponent { + 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 ( + + +
+ +
+
+ {group.name} + {group.name === ANYONE && ( + {translate('deprecated')} + )} +
+
+ {group.name === ANYONE + ? translate('user_groups.anyone.description') + : group.description} +
+
+
+ + {permissions.map((permission) => { + const isPermissionGroup = isPermissionDefinitionGroup(permission); + const permissionKey = isPermissionGroup ? permission.category : permission.key; + const isAdminPermission = !isPermissionGroup && permissionKey === Permissions.Admin; + + return ( + + ); + })} + + ); + } +} 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 index 00000000000..da9250f4c9e --- /dev/null +++ b/server/sonar-web/src/main/js/components/permissions/HoldersList.tsx @@ -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; + onToggleUser: (user: PermissionUser, permission: string) => Promise; + permissions: PermissionDefinitions; + query?: string; + selectedPermission?: string; + users: PermissionUser[]; +} + +interface State { + initialPermissionsCount: Dict; +} +export default class HoldersList extends React.PureComponent { + 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 ( + + {translate('no_results_search')} + + ); + } + + renderItem(item: PermissionUser | PermissionGroup, permissions: PermissionDefinitions) { + return this.isPermissionUser(item) ? ( + + ) : ( + + ); + } + + render() { + const { permissions, users, groups, loading, children, selectedPermission } = this.props; + const items = [...groups, ...users]; + const [itemWithPermissions, itemWithoutPermissions] = partition(items, (item) => + this.getItemInitialPermissionsCount(item) + ); + + return ( +
+ + + + + {permissions.map((permission) => ( + + ))} + + + + {items.length === 0 && !loading && this.renderEmpty()} + {itemWithPermissions.map((item) => this.renderItem(item, permissions))} + {itemWithPermissions.length > 0 && itemWithoutPermissions.length > 0 && ( + <> + + + + {/* Keep correct zebra colors in the table */} + + )} + {itemWithoutPermissions.map((item) => this.renderItem(item, permissions))} + +
{children}
+
+
+ ); + } +} 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 index 00000000000..9e227ff87ef --- /dev/null +++ b/server/sonar-web/src/main/js/components/permissions/PermissionCell.tsx @@ -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 ( + + {permission.permissions.map((permissionDefinition) => { + const isChecked = permissionItem.permissions.includes(permissionDefinition.key); + const isDisabled = disabled || loading.includes(permissionDefinition.key); + + return ( +
+ + {permissionDefinition.name} + +
+ ); + })} + + ); + } + + const isChecked = permissionItem.permissions.includes(permission.key); + const isDisabled = disabled || loading.includes(permission.key); + + return ( + + + + ); +} 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 index 00000000000..fcea8718533 --- /dev/null +++ b/server/sonar-web/src/main/js/components/permissions/PermissionHeader.tsx @@ -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 { + handlePermissionClick = (event: React.SyntheticEvent) => { + 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) => ( + + {permission.name}: + +
+
+ )); + } + + return ; + }; + + render() { + const { onSelectPermission, permission } = this.props; + let name; + if (isPermissionDefinitionGroup(permission)) { + name = translate('global_permissions', permission.category); + } else { + name = onSelectPermission ? ( + + + {permission.name} + + + ) : ( + permission.name + ); + } + return ( + +
+ {name} + +
+ + ); + } +} 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 index 00000000000..3c5ef9e346b --- /dev/null +++ b/server/sonar-web/src/main/js/components/permissions/SearchForm.tsx @@ -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 ( +
+ + +
+ +
+
+ ); +} 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 index 00000000000..7336a716a63 --- /dev/null +++ b/server/sonar-web/src/main/js/components/permissions/UserHolder.tsx @@ -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; + permissions: PermissionDefinitions; + selectedPermission?: string; + user: PermissionUser; +} + +interface State { + loading: string[]; +} + +export default class UserHolder extends React.PureComponent { + 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) => ( + + )); + + if (user.login === '') { + return ( + + +
+ {user.name} +
+
+ {translate('permission_templates.project_creators.explanation')} +
+ + {permissionCells} + + ); + } + + return ( + + +
+ +
+
+ {user.name} + {user.login} +
+
{user.email}
+
+
+ + {permissionCells} + + ); + } +} 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 index 00000000000..041ede53c42 --- /dev/null +++ b/server/sonar-web/src/main/js/helpers/__tests__/permissions-test.ts @@ -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 index 00000000000..c1ef07bf3f3 --- /dev/null +++ b/server/sonar-web/src/main/js/helpers/permissions.ts @@ -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 = { + 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, + 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, + l10nPrefix: string +): Array { + 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/helpers/testReactTestingUtils.tsx b/server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx index ad8b57a062d..d3a32551d7b 100644 --- a/server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx +++ b/server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx @@ -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 + componentContext: Partial = {} ) { function MockComponentContainer() { + const [realComponent, setRealComponent] = React.useState( + componentContext?.component ?? mockComponent() + ); return ( ) => { + setRealComponent({ ...realComponent, ...changes }); + }, + component: realComponent, + ...omit(componentContext, 'component'), }} >