From 108b7a267c3a61ffe20c118cc11dc12e852ccedf Mon Sep 17 00:00:00 2001 From: Wouter Admiraal Date: Tue, 21 Feb 2023 08:17:04 +0100 Subject: [PATCH] SONAR-18430 Migrate permissions app tests to RTL --- .../js/api/mocks/PermissionsServiceMock.ts | 122 ++++++----- .../components/Template.tsx | 16 +- .../__tests__/PermissionTemplatesApp-it.tsx | 2 +- .../{App.tsx => PermissionsGlobalApp.tsx} | 195 ++++++++--------- .../global/components/__tests__/App-test.tsx | 135 ------------ .../__tests__/PermissionsGlobal-it.tsx | 184 ++++++++++++++++ .../__tests__/__snapshots__/App-test.tsx.snap | 202 ------------------ .../project/components/PageHeader.tsx | 2 +- .../components/PermissionsProjectApp.tsx | 180 ++++++++-------- .../__tests__/PermissionsProject-it.tsx | 182 ++++++---------- .../src/main/js/apps/permissions/routes.tsx | 2 +- .../main/js/apps/permissions/test-utils.ts | 142 ++++++++++++ .../permissions}/AllHoldersList.tsx | 24 +-- .../permissions}/GroupHolder.tsx | 10 +- .../permissions}/HoldersList.tsx | 11 +- .../permissions}/PermissionCell.tsx | 8 +- .../permissions}/PermissionHeader.tsx | 12 +- .../permissions}/SearchForm.tsx | 6 +- .../permissions}/UserHolder.tsx | 8 +- .../__tests__/permissions-test.ts} | 4 +- .../utils.ts => helpers/permissions.ts} | 6 +- .../main/js/helpers/testReactTestingUtils.tsx | 16 +- 22 files changed, 699 insertions(+), 770 deletions(-) rename server/sonar-web/src/main/js/apps/permissions/global/components/{App.tsx => PermissionsGlobalApp.tsx} (64%) delete mode 100644 server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/App-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/PermissionsGlobal-it.tsx delete mode 100644 server/sonar-web/src/main/js/apps/permissions/global/components/__tests__/__snapshots__/App-test.tsx.snap create mode 100644 server/sonar-web/src/main/js/apps/permissions/test-utils.ts rename server/sonar-web/src/main/js/{apps/permissions/shared/components => components/permissions}/AllHoldersList.tsx (79%) rename server/sonar-web/src/main/js/{apps/permissions/shared/components => components/permissions}/GroupHolder.tsx (91%) rename server/sonar-web/src/main/js/{apps/permissions/shared/components => components/permissions}/HoldersList.tsx (96%) rename server/sonar-web/src/main/js/{apps/permissions/shared/components => components/permissions}/PermissionCell.tsx (93%) rename server/sonar-web/src/main/js/{apps/permissions/shared/components => components/permissions}/PermissionHeader.tsx (89%) rename server/sonar-web/src/main/js/{apps/permissions/shared/components => components/permissions}/SearchForm.tsx (89%) rename server/sonar-web/src/main/js/{apps/permissions/shared/components => components/permissions}/UserHolder.tsx (93%) rename server/sonar-web/src/main/js/{apps/permissions/__tests__/utils-test.ts => helpers/__tests__/permissions-test.ts} (92%) rename server/sonar-web/src/main/js/{apps/permissions/utils.ts => helpers/permissions.ts} (96%) 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/global/components/App.tsx b/server/sonar-web/src/main/js/apps/permissions/global/components/PermissionsGlobalApp.tsx similarity index 64% rename from server/sonar-web/src/main/js/apps/permissions/global/components/App.tsx rename to server/sonar-web/src/main/js/apps/permissions/global/components/PermissionsGlobalApp.tsx index 5e0c9a0c8a6..e6b1563d185 100644 --- a/server/sonar-web/src/main/js/apps/permissions/global/components/App.tsx +++ b/server/sonar-web/src/main/js/apps/permissions/global/components/PermissionsGlobalApp.tsx @@ -21,25 +21,25 @@ 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 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 { 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'; +} from '../../../../helpers/permissions'; +import { ComponentQualifier } from '../../../../types/component'; +import { Paging, PermissionGroup, PermissionUser } from '../../../../types/types'; +import '../../styles.css'; import PageHeader from './PageHeader'; -interface Props { - appState: AppState; -} +type Props = WithAppStateContextProps; + interface State { filter: FilterOption; groups: PermissionGroup[]; @@ -49,7 +49,8 @@ interface State { users: PermissionUser[]; usersPaging?: Paging; } -export class App extends React.PureComponent { + +class PermissionsGlobalApp extends React.PureComponent { mounted = false; constructor(props: Props) { @@ -109,7 +110,7 @@ export class App extends React.PureComponent { }, this.stopLoading); }; - onLoadMore = () => { + handleLoadMore = () => { const { usersPaging, groupsPaging } = this.state; this.setState({ loading: true }); return this.loadUsersAndGroups( @@ -128,11 +129,11 @@ export class App extends React.PureComponent { }, this.stopLoading); }; - onFilter = (filter: FilterOption) => { + handleFilter = (filter: FilterOption) => { this.setState({ filter }, this.loadHolders); }; - onSearch = (query: string) => { + handleSearch = (query: string) => { this.setState({ query }, this.loadHolders); }; @@ -168,100 +169,72 @@ export class App extends React.PureComponent { ); }; - 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(); + 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); }; - 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(); + 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); }; - 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(); + 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); }; - 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(); + 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 = () => { @@ -288,17 +261,17 @@ export class App extends React.PureComponent { @@ -307,4 +280,4 @@ export class App extends React.PureComponent { } } -export default withAppStateContext(App); +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/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/shared/components/AllHoldersList.tsx b/server/sonar-web/src/main/js/components/permissions/AllHoldersList.tsx similarity index 79% rename from server/sonar-web/src/main/js/apps/permissions/shared/components/AllHoldersList.tsx rename to server/sonar-web/src/main/js/components/permissions/AllHoldersList.tsx index 9e54ec1fc8c..0d60b2fa438 100644 --- a/server/sonar-web/src/main/js/apps/permissions/shared/components/AllHoldersList.tsx +++ b/server/sonar-web/src/main/js/components/permissions/AllHoldersList.tsx @@ -18,16 +18,16 @@ * 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'; +} from '../../types/types'; +import ListFooter from '../controls/ListFooter'; +import HoldersList from './HoldersList'; +import SearchForm, { FilterOption } from './SearchForm'; interface Props { filter: FilterOption; @@ -36,12 +36,12 @@ interface Props { onQuery: (query: string) => void; groups: PermissionGroup[]; groupsPaging?: Paging; - revokePermissionFromGroup: (group: string, permission: string) => Promise; - grantPermissionToGroup: (group: string, permission: string) => Promise; + onRevokePermissionFromGroup: (group: string, permission: string) => Promise; + onGrantPermissionToGroup: (group: string, permission: string) => Promise; users: PermissionUser[]; usersPaging?: Paging; - revokePermissionFromUser: (user: string, permission: string) => Promise; - grantPermissionToUser: (user: string, permission: string) => Promise; + onRevokePermissionFromUser: (user: string, permission: string) => Promise; + onGrantPermissionToUser: (user: string, permission: string) => Promise; permissions: Array; onLoadMore: () => void; selectedPermission?: string; @@ -54,19 +54,19 @@ export default class AllHoldersList extends React.PureComponent { const hasPermission = user.permissions.includes(permission); if (hasPermission) { - return this.props.revokePermissionFromUser(user.login, permission); + return this.props.onRevokePermissionFromUser(user.login, permission); } - return this.props.grantPermissionToUser(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.revokePermissionFromGroup(group.name, permission); + return this.props.onRevokePermissionFromGroup(group.name, permission); } - return this.props.grantPermissionToGroup(group.name, permission); + return this.props.onGrantPermissionToGroup(group.name, permission); }; getPaging = () => { diff --git a/server/sonar-web/src/main/js/apps/permissions/shared/components/GroupHolder.tsx b/server/sonar-web/src/main/js/components/permissions/GroupHolder.tsx similarity index 91% rename from server/sonar-web/src/main/js/apps/permissions/shared/components/GroupHolder.tsx rename to server/sonar-web/src/main/js/components/permissions/GroupHolder.tsx index 91d89a431f0..3cf869814d6 100644 --- a/server/sonar-web/src/main/js/apps/permissions/shared/components/GroupHolder.tsx +++ b/server/sonar-web/src/main/js/components/permissions/GroupHolder.tsx @@ -19,11 +19,11 @@ */ 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 { 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 { diff --git a/server/sonar-web/src/main/js/apps/permissions/shared/components/HoldersList.tsx b/server/sonar-web/src/main/js/components/permissions/HoldersList.tsx similarity index 96% rename from server/sonar-web/src/main/js/apps/permissions/shared/components/HoldersList.tsx rename to server/sonar-web/src/main/js/components/permissions/HoldersList.tsx index 3b6d1f407c0..da9250f4c9e 100644 --- a/server/sonar-web/src/main/js/apps/permissions/shared/components/HoldersList.tsx +++ b/server/sonar-web/src/main/js/components/permissions/HoldersList.tsx @@ -19,14 +19,9 @@ */ 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 { 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'; diff --git a/server/sonar-web/src/main/js/apps/permissions/shared/components/PermissionCell.tsx b/server/sonar-web/src/main/js/components/permissions/PermissionCell.tsx similarity index 93% rename from server/sonar-web/src/main/js/apps/permissions/shared/components/PermissionCell.tsx rename to server/sonar-web/src/main/js/components/permissions/PermissionCell.tsx index e8657cd2b05..9e227ff87ef 100644 --- a/server/sonar-web/src/main/js/apps/permissions/shared/components/PermissionCell.tsx +++ b/server/sonar-web/src/main/js/components/permissions/PermissionCell.tsx @@ -19,15 +19,15 @@ */ import classNames from 'classnames'; import * as React from 'react'; -import Checkbox from '../../../../components/controls/Checkbox'; -import { translateWithParameters } from '../../../../helpers/l10n'; +import { translateWithParameters } from '../../helpers/l10n'; +import { isPermissionDefinitionGroup } from '../../helpers/permissions'; import { PermissionDefinition, PermissionDefinitionGroup, PermissionGroup, PermissionUser, -} from '../../../../types/types'; -import { isPermissionDefinitionGroup } from '../../utils'; +} from '../../types/types'; +import Checkbox from '../controls/Checkbox'; export interface PermissionCellProps { disabled?: boolean; diff --git a/server/sonar-web/src/main/js/apps/permissions/shared/components/PermissionHeader.tsx b/server/sonar-web/src/main/js/components/permissions/PermissionHeader.tsx similarity index 89% rename from server/sonar-web/src/main/js/apps/permissions/shared/components/PermissionHeader.tsx rename to server/sonar-web/src/main/js/components/permissions/PermissionHeader.tsx index 36371d68ec8..fcea8718533 100644 --- a/server/sonar-web/src/main/js/apps/permissions/shared/components/PermissionHeader.tsx +++ b/server/sonar-web/src/main/js/components/permissions/PermissionHeader.tsx @@ -19,12 +19,12 @@ */ 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'; +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; diff --git a/server/sonar-web/src/main/js/apps/permissions/shared/components/SearchForm.tsx b/server/sonar-web/src/main/js/components/permissions/SearchForm.tsx similarity index 89% rename from server/sonar-web/src/main/js/apps/permissions/shared/components/SearchForm.tsx rename to server/sonar-web/src/main/js/components/permissions/SearchForm.tsx index fb5bc5b0bf3..3c5ef9e346b 100644 --- a/server/sonar-web/src/main/js/apps/permissions/shared/components/SearchForm.tsx +++ b/server/sonar-web/src/main/js/components/permissions/SearchForm.tsx @@ -18,9 +18,9 @@ * 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'; +import { translate } from '../../helpers/l10n'; +import ButtonToggle from '../controls/ButtonToggle'; +import SearchBox from '../controls/SearchBox'; export type FilterOption = 'all' | 'users' | 'groups'; interface Props { diff --git a/server/sonar-web/src/main/js/apps/permissions/shared/components/UserHolder.tsx b/server/sonar-web/src/main/js/components/permissions/UserHolder.tsx similarity index 93% rename from server/sonar-web/src/main/js/apps/permissions/shared/components/UserHolder.tsx rename to server/sonar-web/src/main/js/components/permissions/UserHolder.tsx index 8c6596deb69..7336a716a63 100644 --- a/server/sonar-web/src/main/js/apps/permissions/shared/components/UserHolder.tsx +++ b/server/sonar-web/src/main/js/components/permissions/UserHolder.tsx @@ -19,10 +19,10 @@ */ 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 { 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 { diff --git a/server/sonar-web/src/main/js/apps/permissions/__tests__/utils-test.ts b/server/sonar-web/src/main/js/helpers/__tests__/permissions-test.ts similarity index 92% rename from server/sonar-web/src/main/js/apps/permissions/__tests__/utils-test.ts rename to server/sonar-web/src/main/js/helpers/__tests__/permissions-test.ts index b4b6e369d27..041ede53c42 100644 --- a/server/sonar-web/src/main/js/apps/permissions/__tests__/utils-test.ts +++ b/server/sonar-web/src/main/js/helpers/__tests__/permissions-test.ts @@ -17,9 +17,9 @@ * 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'; +import { convertToPermissionDefinitions } from '../permissions'; -jest.mock('../../../helpers/l10nBundle', () => ({ +jest.mock('../l10nBundle', () => ({ getMessages: jest.fn().mockReturnValue({}), })); diff --git a/server/sonar-web/src/main/js/apps/permissions/utils.ts b/server/sonar-web/src/main/js/helpers/permissions.ts similarity index 96% rename from server/sonar-web/src/main/js/apps/permissions/utils.ts rename to server/sonar-web/src/main/js/helpers/permissions.ts index ea2e8220f3c..c1ef07bf3f3 100644 --- a/server/sonar-web/src/main/js/apps/permissions/utils.ts +++ b/server/sonar-web/src/main/js/helpers/permissions.ts @@ -17,9 +17,9 @@ * 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'; +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, 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'), }} > -- 2.39.5