Browse Source

SONAR-18657 Add Filters for all, local and managed groups on groups list

tags/10.0.0.68432
guillaume-peoch-sonarsource 1 year ago
parent
commit
37add48161
40 changed files with 771 additions and 758 deletions
  1. 195
    0
      server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts
  2. 1
    0
      server/sonar-web/src/main/js/api/user_groups.ts
  3. 81
    52
      server/sonar-web/src/main/js/apps/groups/components/App.tsx
  4. 3
    3
      server/sonar-web/src/main/js/apps/groups/components/EditMembers.tsx
  5. 2
    2
      server/sonar-web/src/main/js/apps/groups/components/Header.tsx
  6. 6
    2
      server/sonar-web/src/main/js/apps/groups/components/List.tsx
  7. 42
    20
      server/sonar-web/src/main/js/apps/groups/components/ListItem.tsx
  8. 0
    191
      server/sonar-web/src/main/js/apps/groups/components/__tests__/App-test.tsx
  9. 0
    29
      server/sonar-web/src/main/js/apps/groups/components/__tests__/DeleteForm-test.tsx
  10. 2
    1
      server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembers-test.tsx
  11. 2
    1
      server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembersModal-test.tsx
  12. 0
    49
      server/sonar-web/src/main/js/apps/groups/components/__tests__/Form-test.tsx
  13. 255
    0
      server/sonar-web/src/main/js/apps/groups/components/__tests__/GroupsApp-it.tsx
  14. 0
    35
      server/sonar-web/src/main/js/apps/groups/components/__tests__/Header-test.tsx
  15. 5
    3
      server/sonar-web/src/main/js/apps/groups/components/__tests__/List-test.tsx
  16. 1
    0
      server/sonar-web/src/main/js/apps/groups/components/__tests__/ListItem-test.tsx
  17. 0
    94
      server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/App-test.tsx.snap
  18. 0
    45
      server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/DeleteForm-test.tsx.snap
  19. 7
    7
      server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembers-test.tsx.snap
  20. 0
    82
      server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/Form-test.tsx.snap
  21. 0
    69
      server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/Header-test.tsx.snap
  22. 3
    3
      server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/List-test.tsx.snap
  23. 4
    1
      server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/ListItem-test.tsx.snap
  24. 1
    4
      server/sonar-web/src/main/js/apps/users/Header.tsx
  25. 5
    4
      server/sonar-web/src/main/js/apps/users/UsersApp.tsx
  26. 3
    1
      server/sonar-web/src/main/js/apps/users/UsersList.tsx
  27. 1
    1
      server/sonar-web/src/main/js/apps/users/__tests__/Header-test.tsx
  28. 50
    9
      server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx
  29. 0
    3
      server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/Header-test.tsx.snap
  30. 37
    14
      server/sonar-web/src/main/js/apps/users/components/UserActions.tsx
  31. 17
    12
      server/sonar-web/src/main/js/apps/users/components/UserGroups.tsx
  32. 18
    4
      server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx
  33. 2
    2
      server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx
  34. 7
    1
      server/sonar-web/src/main/js/apps/users/components/__tests__/UserActions-test.tsx
  35. 9
    1
      server/sonar-web/src/main/js/apps/users/components/__tests__/UserGroups-test.tsx
  36. 3
    1
      server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserActions-test.tsx.snap
  37. 1
    0
      server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserGroups-test.tsx.snap
  38. 1
    0
      server/sonar-web/src/main/js/helpers/testMocks.ts
  39. 1
    0
      server/sonar-web/src/main/js/types/types.ts
  40. 6
    12
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 195
- 0
server/sonar-web/src/main/js/api/mocks/GroupsServiceMock.ts View File

@@ -0,0 +1,195 @@
/*
* 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 { cloneDeep } from 'lodash';
import {
mockClusterSysInfo,
mockGroup,
mockIdentityProvider,
mockPaging,
mockUser,
} from '../../helpers/testMocks';
import { Group, IdentityProvider, Paging, SysInfoCluster, UserSelected } from '../../types/types';
import { getSystemInfo } from '../system';
import { getIdentityProviders } from '../users';
import {
createGroup,
deleteGroup,
getUsersInGroup,
searchUsersGroups,
updateGroup,
} from '../user_groups';

export default class GroupsServiceMock {
isManaged = false;
paging: Paging;
groups: Group[];
readOnlyGroups = [
mockGroup({ name: 'managed-group', managed: true }),
mockGroup({ name: 'local-group', managed: false }),
];

constructor() {
this.groups = cloneDeep(this.readOnlyGroups);
this.paging = mockPaging({
pageIndex: 1,
pageSize: 2,
total: 200,
});

jest.mocked(getSystemInfo).mockImplementation(this.handleGetSystemInfo);
jest.mocked(getIdentityProviders).mockImplementation(this.handleGetIdentityProviders);
jest.mocked(searchUsersGroups).mockImplementation((p) => this.handleSearchUsersGroups(p));
jest.mocked(createGroup).mockImplementation((g) => this.handleCreateGroup(g));
jest.mocked(deleteGroup).mockImplementation((g) => this.handleDeleteGroup(g));
jest.mocked(updateGroup).mockImplementation((g) => this.handleUpdateGroup(g));
jest.mocked(getUsersInGroup).mockImplementation(this.handlegetUsersInGroup);
}

reset() {
this.groups = cloneDeep(this.readOnlyGroups);
}

setIsManaged(managed: boolean) {
this.isManaged = managed;
}

setPaging(paging: Partial<Paging>) {
this.paging = { ...this.paging, ...paging };
}

handleCreateGroup = (group: { name: string; description?: string }): Promise<Group> => {
const newGroup = mockGroup(group);
this.groups.push(newGroup);
return this.reply(newGroup);
};

handleDeleteGroup = (group: { name: string }): Promise<Record<string, never>> => {
if (!this.groups.some((g) => g.name === group.name)) {
return Promise.reject();
}

const groupToDelete = this.groups.find((g) => g.name === group.name);
if (groupToDelete?.managed) {
return Promise.reject();
}

this.groups = this.groups.filter((g) => g.name !== group.name);
return this.reply({});
};

handleUpdateGroup = (group: {
currentName: string;
name?: string;
description?: string;
}): Promise<Record<string, never>> => {
if (!this.groups.some((g) => group.currentName === g.name)) {
return Promise.reject();
}

this.groups.map((g) => {
if (g.name === group.currentName) {
if (group.name !== undefined) {
g.name = group.name;
}
if (group.description !== undefined) {
g.description = group.description;
}
}
});
return this.reply({});
};

handlegetUsersInGroup = (): Promise<Paging & { users: UserSelected[] }> => {
return this.reply({
...this.paging,
users: [
{
...mockUser({ name: 'alice' }),
selected: true,
} as UserSelected,
{
...mockUser({ name: 'bob' }),
selected: false,
} as UserSelected,
],
});
};

handleSearchUsersGroups = (data: {
f?: string;
p?: number;
ps?: number;
q?: string;
managed: boolean | undefined;
}): Promise<{ groups: Group[]; paging: Paging }> => {
const { paging } = this;
if (data.p !== undefined && data.p !== paging.pageIndex) {
this.setPaging({ pageIndex: paging.pageIndex++ });
const groups = [
mockGroup({ name: `local-group ${this.groups.length + 4}` }),
mockGroup({ name: `local-group ${this.groups.length + 5}` }),
];

return this.reply({ paging, groups });
}
if (this.isManaged) {
if (data.managed === undefined) {
return this.reply({
paging,
groups: this.groups.filter((g) => (data?.q ? g.name.includes(data.q) : true)),
});
}
const groups = this.groups.filter((group) => group.managed === data.managed);
return this.reply({
paging,
groups: groups.filter((g) => (data?.q ? g.name.includes(data.q) : true)),
});
}
return this.reply({
paging,
groups: this.groups.filter((g) => (data?.q ? g.name.includes(data.q) : true)),
});
};

handleGetIdentityProviders = (): Promise<{ identityProviders: IdentityProvider[] }> => {
return this.reply({ identityProviders: [mockIdentityProvider()] });
};

handleGetSystemInfo = (): Promise<SysInfoCluster> => {
return this.reply(
mockClusterSysInfo(
this.isManaged
? {
System: {
'High Availability': true,
'Server ID': 'asd564-asd54a-5dsfg45',
'External Users and Groups Provisioning': 'Okta',
},
}
: {}
)
);
};

reply<T>(response: T): Promise<T> {
return Promise.resolve(cloneDeep(response));
}
}

+ 1
- 0
server/sonar-web/src/main/js/api/user_groups.ts View File

@@ -26,6 +26,7 @@ export function searchUsersGroups(data: {
p?: number;
ps?: number;
q?: string;
managed: boolean | undefined;
}): Promise<{ groups: Group[]; paging: Paging }> {
return getJSON('/api/user_groups/search', data).catch(throwGlobalError);
}

+ 81
- 52
server/sonar-web/src/main/js/apps/groups/components/App.tsx View File

@@ -22,6 +22,7 @@ import * as React from 'react';
import { Helmet } from 'react-helmet-async';
import { getSystemInfo } from '../../../api/system';
import { createGroup, deleteGroup, searchUsersGroups, updateGroup } from '../../../api/user_groups';
import ButtonToggle from '../../../components/controls/ButtonToggle';
import ListFooter from '../../../components/controls/ListFooter';
import SearchBox from '../../../components/controls/SearchBox';
import Suggestions from '../../../components/embed-docs-modal/Suggestions';
@@ -41,11 +42,17 @@ interface State {
paging?: Paging;
query: string;
manageProvider?: string;
managed: boolean | undefined;
}

export default class App extends React.PureComponent<{}, State> {
mounted = false;
state: State = { loading: true, query: '' };
state: State = {
loading: true,
query: '',
managed: undefined,
paging: { pageIndex: 1, pageSize: 100, total: 1000 },
};

componentDidMount() {
this.mounted = true;
@@ -53,18 +60,19 @@ export default class App extends React.PureComponent<{}, State> {
this.fetchManageInstance();
}

componentDidUpdate(_prevProps: {}, prevState: State) {
if (prevState.query !== this.state.query || prevState.managed !== this.state.managed) {
this.fetchGroups();
}
if (prevState !== undefined && prevState.paging?.pageIndex !== this.state.paging?.pageIndex) {
this.fetchMoreGroups();
}
}

componentWillUnmount() {
this.mounted = false;
}

makeFetchGroupsRequest = (data?: { p?: number; q?: string }) => {
this.setState({ loading: true });
return searchUsersGroups({
q: this.state.query,
...data,
});
};

async fetchManageInstance() {
const info = (await getSystemInfo()) as SysInfoCluster;
if (this.mounted) {
@@ -80,9 +88,14 @@ export default class App extends React.PureComponent<{}, State> {
}
};

fetchGroups = async (data?: { p?: number; q?: string }) => {
fetchGroups = async () => {
const { query: q, managed } = this.state;
this.setState({ loading: true });
try {
const { groups, paging } = await this.makeFetchGroupsRequest(data);
const { groups, paging } = await searchUsersGroups({
q,
managed,
});
if (this.mounted) {
this.setState({ groups, loading: false, paging });
}
@@ -92,11 +105,13 @@ export default class App extends React.PureComponent<{}, State> {
};

fetchMoreGroups = async () => {
const { paging: currentPaging } = this.state;
const { query: q, managed, paging: currentPaging } = this.state;
if (currentPaging && currentPaging.total > currentPaging.pageIndex * currentPaging.pageSize) {
try {
const { groups, paging } = await this.makeFetchGroupsRequest({
p: currentPaging.pageIndex + 1,
const { groups, paging } = await searchUsersGroups({
p: currentPaging.pageIndex,
q,
managed,
});
if (this.mounted) {
this.setState(({ groups: existingGroups = [] }) => ({
@@ -111,15 +126,10 @@ export default class App extends React.PureComponent<{}, State> {
}
};

search = (query: string) => {
this.fetchGroups({ q: query });
this.setState({ query });
};

refresh = async () => {
const { paging, query } = this.state;
const { paging } = this.state;

await this.fetchGroups({ q: query });
await this.fetchGroups();

// reload all pages in order
if (paging && paging.pageIndex > 1) {
@@ -130,22 +140,6 @@ export default class App extends React.PureComponent<{}, State> {
}
};

closeDeleteForm = () => {
this.setState({ groupToBeDeleted: undefined });
};

closeEditForm = () => {
this.setState({ editedGroup: undefined });
};

openDeleteForm = (group: Group) => {
this.setState({ groupToBeDeleted: group });
};

openEditForm = (group: Group) => {
this.setState({ editedGroup: group });
};

handleCreate = async (data: { description: string; name: string }) => {
await createGroup({ ...data });

@@ -200,8 +194,16 @@ export default class App extends React.PureComponent<{}, State> {
};

render() {
const { editedGroup, groupToBeDeleted, groups, loading, paging, query, manageProvider } =
this.state;
const {
editedGroup,
groupToBeDeleted,
groups,
loading,
paging,
query,
manageProvider,
managed,
} = this.state;

const showAnyone = 'anyone'.includes(query.toLowerCase());

@@ -212,22 +214,45 @@ export default class App extends React.PureComponent<{}, State> {
<main className="page page-limited" id="groups-page">
<Header onCreate={this.handleCreate} manageProvider={manageProvider} />

<SearchBox
className="big-spacer-bottom"
id="groups-search"
minLength={2}
onChange={this.search}
placeholder={translate('search.search_by_name')}
value={query}
/>
<div className="display-flex-justify-start big-spacer-bottom big-spacer-top">
{manageProvider !== undefined && (
<div className="big-spacer-right">
<ButtonToggle
value={managed === undefined ? 'all' : managed}
disabled={loading}
options={[
{ label: translate('all'), value: 'all' },
{ label: translate('managed'), value: true },
{ label: translate('local'), value: false },
]}
onCheck={(filterOption) => {
if (filterOption === 'all') {
this.setState({ managed: undefined });
} else {
this.setState({ managed: filterOption as boolean });
}
}}
/>
</div>
)}
<SearchBox
className="big-spacer-bottom"
id="groups-search"
minLength={2}
onChange={(q) => this.setState({ query: q })}
placeholder={translate('search.search_by_name')}
value={query}
/>
</div>

{groups !== undefined && (
<List
groups={groups}
onDelete={this.openDeleteForm}
onEdit={this.openEditForm}
onDelete={(groupToBeDeleted) => this.setState({ groupToBeDeleted })}
onEdit={(editedGroup) => this.setState({ editedGroup })}
onEditMembers={this.refresh}
showAnyone={showAnyone}
manageProvider={manageProvider}
/>
)}

@@ -236,7 +261,11 @@ export default class App extends React.PureComponent<{}, State> {
<ListFooter
count={showAnyone ? groups.length + 1 : groups.length}
loading={loading}
loadMore={this.fetchMoreGroups}
loadMore={() => {
if (paging.total > paging.pageIndex * paging.pageSize) {
this.setState({ paging: { ...paging, pageIndex: paging.pageIndex + 1 } });
}
}}
ready={!loading}
total={showAnyone ? paging.total + 1 : paging.total}
/>
@@ -246,7 +275,7 @@ export default class App extends React.PureComponent<{}, State> {
{groupToBeDeleted && (
<DeleteForm
group={groupToBeDeleted}
onClose={this.closeDeleteForm}
onClose={() => this.setState({ groupToBeDeleted: undefined })}
onSubmit={this.handleDelete}
/>
)}
@@ -256,7 +285,7 @@ export default class App extends React.PureComponent<{}, State> {
confirmButtonText={translate('update_verb')}
group={editedGroup}
header={translate('groups.update_group')}
onClose={this.closeEditForm}
onClose={() => this.setState({ editedGroup: undefined })}
onSubmit={this.handleEdit}
/>
)}

+ 3
- 3
server/sonar-web/src/main/js/apps/groups/components/EditMembers.tsx View File

@@ -20,7 +20,7 @@
import * as React from 'react';
import { ButtonIcon } from '../../../components/controls/buttons';
import BulletListIcon from '../../../components/icons/BulletListIcon';
import { translate } from '../../../helpers/l10n';
import { translateWithParameters } from '../../../helpers/l10n';
import { Group } from '../../../types/types';
import EditMembersModal from './EditMembersModal';

@@ -61,10 +61,10 @@ export default class EditMembers extends React.PureComponent<Props, State> {
return (
<>
<ButtonIcon
aria-label={translate('groups.users.edit')}
aria-label={translateWithParameters('groups.users.edit', this.props.group.name)}
className="button-small"
onClick={this.handleMembersClick}
title={translate('groups.users.edit')}
title={translateWithParameters('groups.users.edit', this.props.group.name)}
>
<BulletListIcon />
</ButtonIcon>

+ 2
- 2
server/sonar-web/src/main/js/apps/groups/components/Header.tsx View File

@@ -25,12 +25,12 @@ import { Alert } from '../../../components/ui/Alert';
import { translate } from '../../../helpers/l10n';
import Form from './Form';

interface Props {
interface HeaderProps {
onCreate: (data: { description: string; name: string }) => Promise<void>;
manageProvider?: string;
}

export default function Header(props: Props) {
export default function Header(props: HeaderProps) {
const { manageProvider } = props;
const [createModal, setCreateModal] = React.useState(false);


+ 6
- 2
server/sonar-web/src/main/js/apps/groups/components/List.tsx View File

@@ -29,9 +29,12 @@ interface Props {
onEdit: (group: Group) => void;
onEditMembers: () => void;
showAnyone: boolean;
manageProvider: string | undefined;
}

export default function List(props: Props) {
const { groups, manageProvider, showAnyone } = props;

return (
<div className="boxed-group boxed-group-inner">
<table className="data zebra zebra-hover" id="groups-list">
@@ -46,7 +49,7 @@ export default function List(props: Props) {
</tr>
</thead>
<tbody>
{props.showAnyone && (
{showAnyone && (
<tr className="js-anyone" key="anyone">
<td className="width-20">
<strong className="js-group-name">{translate('groups.anyone')}</strong>
@@ -61,13 +64,14 @@ export default function List(props: Props) {
</tr>
)}

{sortBy(props.groups, (group) => group.name.toLowerCase()).map((group) => (
{sortBy(groups, (group) => group.name.toLowerCase()).map((group) => (
<ListItem
group={group}
key={group.name}
onDelete={props.onDelete}
onEdit={props.onEdit}
onEditMembers={props.onEditMembers}
manageProvider={manageProvider}
/>
))}
</tbody>

+ 42
- 20
server/sonar-web/src/main/js/apps/groups/components/ListItem.tsx View File

@@ -22,7 +22,7 @@ import ActionsDropdown, {
ActionsDropdownDivider,
ActionsDropdownItem,
} from '../../../components/controls/ActionsDropdown';
import { translate } from '../../../helpers/l10n';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { Group } from '../../../types/types';
import EditMembers from './EditMembers';

@@ -31,41 +31,63 @@ export interface ListItemProps {
onDelete: (group: Group) => void;
onEdit: (group: Group) => void;
onEditMembers: () => void;
manageProvider: string | undefined;
}

export default function ListItem(props: ListItemProps) {
const { group } = props;
const { manageProvider, group } = props;
const { name, managed, membersCount, description } = group;

const isManaged = () => {
return manageProvider !== undefined;
};

const isGroupLocal = () => {
return isManaged() && !managed;
};

return (
<tr data-id={group.name}>
<tr data-id={name}>
<td className="width-20">
<strong className="js-group-name">{group.name}</strong>
<strong className="js-group-name">{name}</strong>
{group.default && <span className="little-spacer-left">({translate('default')})</span>}
{isGroupLocal() && <span className="little-spacer-left badge">{translate('local')}</span>}
</td>

<td className="thin text-middle text-right little-padded-right">{group.membersCount}</td>
<td className="thin text-middle text-right little-padded-right">{membersCount}</td>
<td className="little-padded-left">
{!group.default && <EditMembers group={group} onEdit={props.onEditMembers} />}
{!group.default && !isManaged() && (
<EditMembers group={group} onEdit={props.onEditMembers} />
)}
</td>

<td className="width-40">
<span className="js-group-description">{group.description}</span>
<span className="js-group-description">{description}</span>
</td>

<td className="thin nowrap text-right">
{!group.default && (
<ActionsDropdown>
<ActionsDropdownItem className="js-group-update" onClick={() => props.onEdit(group)}>
{translate('update_details')}
</ActionsDropdownItem>
<ActionsDropdownDivider />
<ActionsDropdownItem
className="js-group-delete"
destructive={true}
onClick={() => props.onDelete(group)}
>
{translate('delete')}
</ActionsDropdownItem>
{!group.default && (!isManaged() || isGroupLocal()) && (
<ActionsDropdown label={translateWithParameters('groups.edit', group.name)}>
{!isManaged() && (
<>
<ActionsDropdownItem
className="js-group-update"
onClick={() => props.onEdit(group)}
>
{translate('update_details')}
</ActionsDropdownItem>
<ActionsDropdownDivider />
</>
)}
{(!isManaged() || isGroupLocal()) && (
<ActionsDropdownItem
className="js-group-delete"
destructive={true}
onClick={() => props.onDelete(group)}
>
{translate('delete')}
</ActionsDropdownItem>
)}
</ActionsDropdown>
)}
</td>

+ 0
- 191
server/sonar-web/src/main/js/apps/groups/components/__tests__/App-test.tsx View File

@@ -1,191 +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 {
createGroup,
deleteGroup,
searchUsersGroups,
updateGroup,
} from '../../../../api/user_groups';
import { mockGroup } from '../../../../helpers/testMocks';
import { waitAndUpdate } from '../../../../helpers/testUtils';
import App from '../App';

jest.mock('../../../../api/user_groups', () => ({
createGroup: jest.fn().mockResolvedValue({
default: false,
description: 'Desc foo',
membersCount: 0,
name: 'Foo',
}),
deleteGroup: jest.fn().mockResolvedValue({}),
searchUsersGroups: jest.fn().mockResolvedValue({
paging: { pageIndex: 1, pageSize: 2, total: 4 },
groups: [
{
default: false,
description: 'Owners of organization foo',
membersCount: 1,
name: 'Owners',
},
{
default: true,
description: 'Members of organization foo',
membersCount: 2,
name: 'Members',
},
],
}),
updateGroup: jest.fn().mockResolvedValue({}),
}));

jest.mock('../../../../api/system', () => ({
getSystemInfo: jest.fn().mockResolvedValue({ System: {} }),
}));

beforeEach(() => {
jest.clearAllMocks();
});

it('should render correctly', async () => {
const wrapper = shallowRender();
expect(wrapper).toMatchSnapshot();
await waitAndUpdate(wrapper);
expect(searchUsersGroups).toHaveBeenCalledWith({ q: '' });
expect(wrapper).toMatchSnapshot();
});

it('should correctly handle creation', async () => {
const wrapper = shallowRender();
await waitAndUpdate(wrapper);
expect(wrapper.state('groups')).toHaveLength(2);
wrapper.instance().handleCreate({ description: 'Desc foo', name: 'foo' });
await waitAndUpdate(wrapper);
expect(createGroup).toHaveBeenCalled();
});

it('should correctly handle deletion', async () => {
const wrapper = shallowRender();
await waitAndUpdate(wrapper);
expect(wrapper.state('groups')).toHaveLength(2);
wrapper.setState({ groupToBeDeleted: mockGroup({ name: 'Members' }) });
wrapper.instance().handleDelete();
await waitAndUpdate(wrapper);
expect(deleteGroup).toHaveBeenCalled();
expect(wrapper.state().groupToBeDeleted).toBeUndefined();
});

it('should ignore deletion', async () => {
const wrapper = shallowRender();
await waitAndUpdate(wrapper);
wrapper.setState({ groupToBeDeleted: undefined });
wrapper.instance().handleDelete();
expect(deleteGroup).not.toHaveBeenCalled();
});

it('should correctly handle edition', async () => {
const wrapper = shallowRender();
await waitAndUpdate(wrapper);
wrapper.setState({ editedGroup: mockGroup({ name: 'Owners' }) });
wrapper.instance().handleEdit({ description: 'foo', name: 'bar' });
await waitAndUpdate(wrapper);
expect(updateGroup).toHaveBeenCalled();
expect(wrapper.state('groups')).toContainEqual({
default: false,
description: 'foo',
membersCount: 1,
name: 'bar',
});
});

it('should ignore edition', async () => {
const wrapper = shallowRender();
await waitAndUpdate(wrapper);
wrapper.setState({ editedGroup: undefined });
wrapper.instance().handleEdit({ description: 'nope', name: 'nuhuh' });
expect(updateGroup).not.toHaveBeenCalled();
});

it('should fetch more groups', async () => {
const wrapper = shallowRender();
await waitAndUpdate(wrapper);
wrapper.find('ListFooter').prop<Function>('loadMore')();
await waitAndUpdate(wrapper);
expect(searchUsersGroups).toHaveBeenCalledWith({ p: 2, q: '' });
expect(wrapper.state('groups')).toHaveLength(4);
});

it('should search for groups', async () => {
const wrapper = shallowRender();
await waitAndUpdate(wrapper);
wrapper.find('SearchBox').prop<Function>('onChange')('foo');
expect(searchUsersGroups).toHaveBeenCalledWith({ q: 'foo' });
expect(wrapper.state('query')).toBe('foo');
});

it('should handle edit modal', async () => {
const editedGroup = mockGroup();

const wrapper = shallowRender();
await waitAndUpdate(wrapper);
expect(wrapper.state().editedGroup).toBeUndefined();

wrapper.instance().openEditForm(editedGroup);
expect(wrapper.state().editedGroup).toEqual(editedGroup);

wrapper.instance().closeEditForm();
expect(wrapper.state().editedGroup).toBeUndefined();
});

it('should handle delete modal', async () => {
const groupToBeDeleted = mockGroup();

const wrapper = shallowRender();
await waitAndUpdate(wrapper);
expect(wrapper.state().groupToBeDeleted).toBeUndefined();

wrapper.instance().openDeleteForm(groupToBeDeleted);
expect(wrapper.state().groupToBeDeleted).toEqual(groupToBeDeleted);

wrapper.instance().closeDeleteForm();
expect(wrapper.state().groupToBeDeleted).toBeUndefined();
});

it('should refresh correctly', async () => {
const wrapper = shallowRender();

await waitAndUpdate(wrapper);

const query = 'preserve me';
wrapper.setState({ paging: { pageIndex: 2, pageSize: 2, total: 5 }, query });

(searchUsersGroups as jest.Mock).mockClear();

wrapper.instance().refresh();
await waitAndUpdate(wrapper);

expect(searchUsersGroups).toHaveBeenNthCalledWith(1, { q: query });
expect(searchUsersGroups).toHaveBeenNthCalledWith(2, { q: query, p: 2 });
});

function shallowRender(props: Partial<App['props']> = {}) {
return shallow<App>(<App {...props} />);
}

+ 0
- 29
server/sonar-web/src/main/js/apps/groups/components/__tests__/DeleteForm-test.tsx View File

@@ -1,29 +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 DeleteForm from '../DeleteForm';

it('should render', () => {
const group = { id: 3, name: 'Foo', membersCount: 5 };
expect(
shallow(<DeleteForm group={group} onClose={jest.fn()} onSubmit={jest.fn()} />).dive()
).toMatchSnapshot();
});

+ 2
- 1
server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembers-test.tsx View File

@@ -19,11 +19,12 @@
*/
import { shallow } from 'enzyme';
import * as React from 'react';
import { mockGroup } from '../../../../helpers/testMocks';
import { click } from '../../../../helpers/testUtils';
import EditMembers from '../EditMembers';

it('should edit members', () => {
const group = { id: 3, name: 'Foo', membersCount: 5 };
const group = mockGroup({ name: 'Foo', membersCount: 5 });
const onEdit = jest.fn();

const wrapper = shallow(<EditMembers group={group} onEdit={onEdit} />);

+ 2
- 1
server/sonar-web/src/main/js/apps/groups/components/__tests__/EditMembersModal-test.tsx View File

@@ -21,10 +21,11 @@ import { shallow } from 'enzyme';
import * as React from 'react';
import { addUserToGroup, getUsersInGroup, removeUserFromGroup } from '../../../../api/user_groups';
import SelectList, { SelectListFilter } from '../../../../components/controls/SelectList';
import { mockGroup } from '../../../../helpers/testMocks';
import { waitAndUpdate } from '../../../../helpers/testUtils';
import EditMembersModal from '../EditMembersModal';

const group = { id: 1, name: 'foo', membersCount: 1 };
const group = mockGroup({ name: 'foo', membersCount: 1 });

jest.mock('../../../../api/user_groups', () => ({
getUsersInGroup: jest.fn().mockResolvedValue({

+ 0
- 49
server/sonar-web/src/main/js/apps/groups/components/__tests__/Form-test.tsx View File

@@ -1,49 +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 { change, click, submit } from '../../../../helpers/testUtils';
import Form from '../Form';

it('should render form', async () => {
const onClose = jest.fn();
const onSubmit = jest.fn(() => Promise.resolve());
const wrapper = shallow(
<Form
confirmButtonText="confirmButtonText"
header="header"
onClose={onClose}
onSubmit={onSubmit}
/>
).dive();
expect(wrapper).toMatchSnapshot();

change(wrapper.find('[name="name"]'), 'foo');
change(wrapper.find('[name="description"]'), 'bar');
submit(wrapper.find('form'));
expect(onSubmit).toHaveBeenCalledWith({ description: 'bar', name: 'foo' });

await new Promise(setImmediate);
expect(onClose).toHaveBeenCalled();

onClose.mockClear();
click(wrapper.find('ResetButtonLink'));
expect(onClose).toHaveBeenCalled();
});

+ 255
- 0
server/sonar-web/src/main/js/apps/groups/components/__tests__/GroupsApp-it.tsx View File

@@ -0,0 +1,255 @@
/*
* 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 * as React from 'react';
import { act } from 'react-dom/test-utils';
import { byRole, byText } from 'testing-library-selector';
import GroupsServiceMock from '../../../../api/mocks/GroupsServiceMock';
import { renderApp } from '../../../../helpers/testReactTestingUtils';
import App from '../App';

jest.mock('../../../../api/users');
jest.mock('../../../../api/system');
jest.mock('../../../../api/user_groups');

const handler = new GroupsServiceMock();

const ui = {
createGroupButton: byRole('button', { name: 'groups.create_group' }),
infoManageMode: byText(/groups\.page\.managed_description/),
description: byText('user_groups.page.description'),
allFilter: byRole('button', { name: 'all' }),
managedFilter: byRole('button', { name: 'managed' }),
localFilter: byRole('button', { name: 'local' }),
searchInput: byRole('searchbox', { name: 'search.search_by_name' }),
updateButton: byRole('button', { name: 'update_details' }),
updateDialog: byRole('dialog', { name: 'groups.update_group' }),
updateDialogButton: byRole('button', { name: 'update_verb' }),
deleteButton: byRole('button', { name: 'delete' }),
deleteDialog: byRole('dialog', { name: 'groups.delete_group' }),
deleteDialogButton: byRole('button', { name: 'delete' }),
showMore: byRole('button', { name: 'show_more' }),
nameInput: byRole('textbox', { name: 'name field_required' }),
descriptionInput: byRole('textbox', { name: 'description' }),
createGroupDialogButton: byRole('button', { name: 'create' }),
editGroupDialogButton: byRole('button', { name: 'groups.create_group' }),

createGroupDialog: byRole('dialog', { name: 'groups.create_group' }),
membersDialog: byRole('dialog', { name: 'users.update' }),

managedGroupRow: byRole('row', { name: 'managed-group 1' }),
managedGroupEditMembersButton: byRole('button', { name: 'groups.users.edit.managed-group' }),
managedEditButton: byRole('button', { name: 'groups.edit.managed-group' }),

localGroupRow: byRole('row', { name: 'local-group 1' }),
localGroupEditMembersButton: byRole('button', { name: 'groups.users.edit.local-group' }),
localGroupRow2: byRole('row', { name: 'local-group 2 1 group 2 is loco!' }),
editedLocalGroupRow: byRole('row', { name: 'local-group 3 1 group 3 rocks!' }),
localEditButton: byRole('button', { name: 'groups.edit.local-group' }),
localGroupRowWithLocalBadge: byRole('row', {
name: 'local-group local 1',
}),
};

describe('in non managed mode', () => {
beforeEach(() => {
handler.setIsManaged(false);
handler.reset();
});

it('should render all groups', async () => {
renderGroupsApp();

expect(await ui.localGroupRow.find()).toBeInTheDocument();
expect(ui.managedGroupRow.get()).toBeInTheDocument();
expect(ui.localGroupRowWithLocalBadge.query()).not.toBeInTheDocument();
});

it('should be able to create a group', async () => {
const user = userEvent.setup();
renderGroupsApp();

expect(await ui.description.find()).toBeInTheDocument();

await user.click(ui.createGroupButton.get());
expect(ui.createGroupDialog.get()).toBeInTheDocument();

await user.type(ui.nameInput.get(), 'local-group 2');
await user.type(ui.descriptionInput.get(), 'group 2 is loco!');

await act(async () => {
await user.click(ui.createGroupDialogButton.get());
});

expect(await ui.localGroupRow2.find()).toBeInTheDocument();
});

it('should be able to delete a group', async () => {
const user = userEvent.setup();
renderGroupsApp();

await user.click(await ui.localEditButton.find());
await user.click(await ui.deleteButton.find());

expect(await ui.deleteDialog.find()).toBeInTheDocument();
await act(async () => {
await user.click(ui.deleteDialogButton.get());
});

expect(await ui.managedGroupRow.find()).toBeInTheDocument();
expect(ui.localGroupRow.query()).not.toBeInTheDocument();
});

it('should be able to edit a group', async () => {
const user = userEvent.setup();
renderGroupsApp();

await user.click(await ui.localEditButton.find());
await user.click(await ui.updateButton.find());

expect(ui.updateDialog.get()).toBeInTheDocument();

await user.clear(ui.nameInput.get());
await user.type(ui.nameInput.get(), 'local-group 3');
await user.clear(ui.descriptionInput.get());
await user.type(ui.descriptionInput.get(), 'group 3 rocks!');

expect(ui.updateDialog.get()).toBeInTheDocument();

await act(async () => {
await user.click(ui.updateDialogButton.get());
});

expect(await ui.managedGroupRow.find()).toBeInTheDocument();
expect(await ui.editedLocalGroupRow.find()).toBeInTheDocument();
});

it('should be able to edit the members of a group', async () => {
const user = userEvent.setup();
renderGroupsApp();

expect(await ui.localGroupRow.find()).toBeInTheDocument();
expect(await ui.localGroupEditMembersButton.find()).toBeInTheDocument();

await user.click(ui.localGroupEditMembersButton.get());
expect(await ui.membersDialog.find()).toBeInTheDocument();
});

it('should be able search a group', async () => {
const user = userEvent.setup();
renderGroupsApp();

expect(await ui.localGroupRow.find()).toBeInTheDocument();
expect(ui.managedGroupRow.get()).toBeInTheDocument();

await user.type(await ui.searchInput.find(), 'local');

expect(await ui.localGroupRow.find()).toBeInTheDocument();
expect(ui.managedGroupRow.query()).not.toBeInTheDocument();
});

it('should be able load more group', async () => {
const user = userEvent.setup();
renderGroupsApp();

// including the anyone (deprecated) group
expect(await screen.findAllByRole('row')).toHaveLength(4);

await user.click(await ui.showMore.find());

expect(await screen.findAllByRole('row')).toHaveLength(6);
});
});

describe('in manage mode', () => {
beforeEach(() => {
handler.setIsManaged(true);
handler.reset();
});

it('should not be able to create a group', async () => {
renderGroupsApp();
expect(await ui.createGroupButton.find()).toBeDisabled();
expect(ui.infoManageMode.get()).toBeInTheDocument();
});

it('should ONLY be able to delete a local group', async () => {
const user = userEvent.setup();
renderGroupsApp();

expect(await ui.localGroupRowWithLocalBadge.find()).toBeInTheDocument();

await user.click(await ui.localFilter.find());
await user.click(await ui.localEditButton.find());
expect(ui.updateButton.query()).not.toBeInTheDocument();

await user.click(await ui.deleteButton.find());

expect(await ui.deleteDialog.find()).toBeInTheDocument();
await act(async () => {
await user.click(ui.deleteDialogButton.get());
});
expect(ui.localGroupRowWithLocalBadge.query()).not.toBeInTheDocument();
});

it('should not be able to delete or edit a managed group', async () => {
renderGroupsApp();

expect(await ui.managedGroupRow.find()).toBeInTheDocument();
expect(ui.managedEditButton.query()).not.toBeInTheDocument();

expect(ui.managedGroupEditMembersButton.query()).not.toBeInTheDocument();
});

it('should render list of all groups', async () => {
renderGroupsApp();

expect(await ui.allFilter.find()).toBeInTheDocument();

expect(ui.localGroupRowWithLocalBadge.get()).toBeInTheDocument();
expect(ui.managedGroupRow.get()).toBeInTheDocument();
});

it('should render list of managed groups', async () => {
const user = userEvent.setup();
renderGroupsApp();

await user.click(await ui.managedFilter.find());

expect(ui.localGroupRow.query()).not.toBeInTheDocument();
expect(ui.managedGroupRow.get()).toBeInTheDocument();
});

it('should render list of local groups', async () => {
const user = userEvent.setup();
renderGroupsApp();

await user.click(await ui.localFilter.find());

expect(ui.localGroupRowWithLocalBadge.get()).toBeInTheDocument();
expect(ui.managedGroupRow.query()).not.toBeInTheDocument();
});
});

function renderGroupsApp() {
return renderApp('admin/groups', <App />);
}

+ 0
- 35
server/sonar-web/src/main/js/apps/groups/components/__tests__/Header-test.tsx View File

@@ -1,35 +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 { click } from '../../../../helpers/testUtils';
import Header from '../Header';

it('should create new group', () => {
const onCreate = jest.fn(() => Promise.resolve());
const wrapper = shallow(<Header onCreate={onCreate} />);
expect(wrapper).toMatchSnapshot();

click(wrapper.find('[id="groups-create"]'));
expect(wrapper).toMatchSnapshot();

wrapper.find('Form').prop<Function>('onSubmit')({ name: 'foo', description: 'bar' });
expect(onCreate).toHaveBeenCalledWith({ name: 'foo', description: 'bar' });
});

+ 5
- 3
server/sonar-web/src/main/js/apps/groups/components/__tests__/List-test.tsx View File

@@ -19,6 +19,7 @@
*/
import { shallow } from 'enzyme';
import * as React from 'react';
import { mockGroup } from '../../../../helpers/testMocks';
import List from '../List';

it('should render', () => {
@@ -31,9 +32,9 @@ it('should not render "Anyone"', () => {

function shallowRender(showAnyone = true) {
const groups = [
{ id: 1, name: 'sonar-users', description: '', membersCount: 55, default: true },
{ id: 2, name: 'foo', description: 'foobar', membersCount: 0, default: false },
{ id: 3, name: 'bar', description: 'barbar', membersCount: 1, default: false },
mockGroup({ name: 'sonar-users', description: '', membersCount: 55, default: true }),
mockGroup({ name: 'foo', description: 'foobar', membersCount: 0, default: false }),
mockGroup({ name: 'bar', description: 'barbar', membersCount: 1, default: false }),
];
return shallow(
<List
@@ -42,6 +43,7 @@ function shallowRender(showAnyone = true) {
onEdit={jest.fn()}
onEditMembers={jest.fn()}
showAnyone={showAnyone}
manageProvider={undefined}
/>
);
}

+ 1
- 0
server/sonar-web/src/main/js/apps/groups/components/__tests__/ListItem-test.tsx View File

@@ -34,6 +34,7 @@ function shallowRender(overrides: Partial<ListItemProps> = {}) {
onDelete={jest.fn()}
onEdit={jest.fn()}
onEditMembers={jest.fn()}
manageProvider={undefined}
{...overrides}
/>
);

+ 0
- 94
server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/App-test.tsx.snap View File

@@ -1,94 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render correctly 1`] = `
<Fragment>
<Suggestions
suggestions="user_groups"
/>
<Helmet
defer={false}
encodeSpecialCharacters={true}
prioritizeSeoTags={false}
title="user_groups.page"
/>
<main
className="page page-limited"
id="groups-page"
>
<Header
onCreate={[Function]}
/>
<SearchBox
className="big-spacer-bottom"
id="groups-search"
minLength={2}
onChange={[Function]}
placeholder="search.search_by_name"
value=""
/>
</main>
</Fragment>
`;

exports[`should render correctly 2`] = `
<Fragment>
<Suggestions
suggestions="user_groups"
/>
<Helmet
defer={false}
encodeSpecialCharacters={true}
prioritizeSeoTags={false}
title="user_groups.page"
/>
<main
className="page page-limited"
id="groups-page"
>
<Header
onCreate={[Function]}
/>
<SearchBox
className="big-spacer-bottom"
id="groups-search"
minLength={2}
onChange={[Function]}
placeholder="search.search_by_name"
value=""
/>
<List
groups={
[
{
"default": false,
"description": "Owners of organization foo",
"membersCount": 1,
"name": "Owners",
},
{
"default": true,
"description": "Members of organization foo",
"membersCount": 2,
"name": "Members",
},
]
}
onDelete={[Function]}
onEdit={[Function]}
onEditMembers={[Function]}
showAnyone={true}
/>
<div
id="groups-list-footer"
>
<ListFooter
count={3}
loadMore={[Function]}
loading={false}
ready={true}
total={5}
/>
</div>
</main>
</Fragment>
`;

+ 0
- 45
server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/DeleteForm-test.tsx.snap View File

@@ -1,45 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render 1`] = `
<Modal
contentLabel="groups.delete_group"
onRequestClose={[MockFunction]}
>
<form
onSubmit={[Function]}
>
<header
className="modal-head"
>
<h2>
groups.delete_group
</h2>
</header>
<div
className="modal-body"
>
groups.delete_group.confirmation.Foo
</div>
<footer
className="modal-foot"
>
<DeferredSpinner
className="spacer-right"
loading={false}
/>
<SubmitButton
className="button-red"
disabled={false}
>
delete
</SubmitButton>
<ResetButtonLink
disabled={false}
onClick={[Function]}
>
cancel
</ResetButtonLink>
</footer>
</form>
</Modal>
`;

+ 7
- 7
server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembers-test.tsx.snap View File

@@ -3,10 +3,10 @@
exports[`should edit members 1`] = `
<Fragment>
<ButtonIcon
aria-label="groups.users.edit"
aria-label="groups.users.edit.Foo"
className="button-small"
onClick={[Function]}
title="groups.users.edit"
title="groups.users.edit.Foo"
>
<BulletListIcon />
</ButtonIcon>
@@ -16,17 +16,17 @@ exports[`should edit members 1`] = `
exports[`should edit members 2`] = `
<Fragment>
<ButtonIcon
aria-label="groups.users.edit"
aria-label="groups.users.edit.Foo"
className="button-small"
onClick={[Function]}
title="groups.users.edit"
title="groups.users.edit.Foo"
>
<BulletListIcon />
</ButtonIcon>
<EditMembersModal
group={
{
"id": 3,
"managed": false,
"membersCount": 5,
"name": "Foo",
}
@@ -39,10 +39,10 @@ exports[`should edit members 2`] = `
exports[`should edit members 3`] = `
<Fragment>
<ButtonIcon
aria-label="groups.users.edit"
aria-label="groups.users.edit.Foo"
className="button-small"
onClick={[Function]}
title="groups.users.edit"
title="groups.users.edit.Foo"
>
<BulletListIcon />
</ButtonIcon>

+ 0
- 82
server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/Form-test.tsx.snap View File

@@ -1,82 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render form 1`] = `
<Modal
contentLabel="header"
onRequestClose={[MockFunction]}
size="small"
>
<form
onSubmit={[Function]}
>
<header
className="modal-head"
>
<h2>
header
</h2>
</header>
<div
className="modal-body"
>
<MandatoryFieldsExplanation
className="modal-field"
/>
<div
className="modal-field"
>
<label
htmlFor="create-group-name"
>
name
<MandatoryFieldMarker />
</label>
<input
autoFocus={true}
id="create-group-name"
maxLength={255}
name="name"
onChange={[Function]}
required={true}
size={50}
type="text"
value=""
/>
</div>
<div
className="modal-field"
>
<label
htmlFor="create-group-description"
>
description
</label>
<textarea
id="create-group-description"
name="description"
onChange={[Function]}
value=""
/>
</div>
</div>
<footer
className="modal-foot"
>
<DeferredSpinner
className="spacer-right"
loading={false}
/>
<SubmitButton
disabled={false}
>
confirmButtonText
</SubmitButton>
<ResetButtonLink
onClick={[Function]}
>
cancel
</ResetButtonLink>
</footer>
</form>
</Modal>
`;

+ 0
- 69
server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/Header-test.tsx.snap View File

@@ -1,69 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should create new group 1`] = `
<Fragment>
<div
className="page-header"
id="groups-header"
>
<h2
className="page-title"
>
user_groups.page
</h2>
<div
className="page-actions"
>
<Button
disabled={false}
id="groups-create"
onClick={[Function]}
>
groups.create_group
</Button>
</div>
<p
className="page-description"
>
user_groups.page.description
</p>
</div>
</Fragment>
`;

exports[`should create new group 2`] = `
<Fragment>
<div
className="page-header"
id="groups-header"
>
<h2
className="page-title"
>
user_groups.page
</h2>
<div
className="page-actions"
>
<Button
disabled={false}
id="groups-create"
onClick={[Function]}
>
groups.create_group
</Button>
</div>
<p
className="page-description"
>
user_groups.page.description
</p>
</div>
<Form
confirmButtonText="create"
header="groups.create_group"
onClose={[Function]}
onSubmit={[MockFunction]}
/>
</Fragment>
`;

+ 3
- 3
server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/List-test.tsx.snap View File

@@ -64,7 +64,7 @@ exports[`should render 1`] = `
{
"default": false,
"description": "barbar",
"id": 3,
"managed": false,
"membersCount": 1,
"name": "bar",
}
@@ -79,7 +79,7 @@ exports[`should render 1`] = `
{
"default": false,
"description": "foobar",
"id": 2,
"managed": false,
"membersCount": 0,
"name": "foo",
}
@@ -94,7 +94,7 @@ exports[`should render 1`] = `
{
"default": true,
"description": "",
"id": 1,
"managed": false,
"membersCount": 55,
"name": "sonar-users",
}

+ 4
- 1
server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/ListItem-test.tsx.snap View File

@@ -24,6 +24,7 @@ exports[`should render correctly 1`] = `
<EditMembers
group={
{
"managed": false,
"membersCount": 1,
"name": "Foo",
}
@@ -41,7 +42,9 @@ exports[`should render correctly 1`] = `
<td
className="thin nowrap text-right"
>
<ActionsDropdown>
<ActionsDropdown
label="groups.edit.Foo"
>
<ActionsDropdownItem
className="js-group-update"
onClick={[Function]}

+ 1
- 4
server/sonar-web/src/main/js/apps/users/Header.tsx View File

@@ -22,12 +22,10 @@ import { FormattedMessage } from 'react-intl';
import DocLink from '../../components/common/DocLink';
import { Button } from '../../components/controls/buttons';
import { Alert } from '../../components/ui/Alert';
import DeferredSpinner from '../../components/ui/DeferredSpinner';
import { translate } from '../../helpers/l10n';
import UserForm from './components/UserForm';

interface Props {
loading: boolean;
onUpdateUsers: () => void;
manageProvider?: string;
}
@@ -35,11 +33,10 @@ interface Props {
export default function Header(props: Props) {
const [openUserForm, setOpenUserForm] = React.useState(false);

const { manageProvider, loading } = props;
const { manageProvider } = props;
return (
<div className="page-header null-spacer-bottom">
<h2 className="page-title">{translate('users.page')}</h2>
<DeferredSpinner loading={loading} />

<div className="page-actions">
<Button

+ 5
- 4
server/sonar-web/src/main/js/apps/users/UsersApp.tsx View File

@@ -138,21 +138,22 @@ export class UsersApp extends React.PureComponent<Props, State> {
render() {
const { search, managed } = parseQuery(this.props.location.query);
const { loading, paging, users, manageProvider } = this.state;
// What if we have ONLY managed users? Should we not display the filter toggle?
return (
<main className="page page-limited" id="users-page">
<Suggestions suggestions="users" />
<Helmet defer={false} title={translate('users.page')} />
<Header loading={loading} onUpdateUsers={this.fetchUsers} manageProvider={manageProvider} />
<Header onUpdateUsers={this.fetchUsers} manageProvider={manageProvider} />
<div className="display-flex-justify-start big-spacer-bottom big-spacer-top">
{manageProvider !== undefined && (
<div className="big-spacer-right">
<ButtonToggle
value={managed === undefined ? 'all' : managed}
disabled={loading}
options={[
{ label: translate('all'), value: 'all' },
{ label: translate('users.managed'), value: true },
{ label: translate('users.local'), value: false },
{ label: translate('managed'), value: true },
{ label: translate('local'), value: false },
]}
onCheck={(filterOption) => {
if (filterOption === 'all') {

+ 3
- 1
server/sonar-web/src/main/js/apps/users/UsersList.tsx View File

@@ -51,7 +51,9 @@ export default function UsersList({
<th className="nowrap">{translate('users.last_connection')}</th>
<th className="nowrap">{translate('my_profile.groups')}</th>
<th className="nowrap">{translate('users.tokens')}</th>
<th className="nowrap">&nbsp;</th>
{(manageProvider === undefined || users.some((u) => !u.managed)) && (
<th className="nowrap">&nbsp;</th>
)}
</tr>
</thead>
<tbody>

+ 1
- 1
server/sonar-web/src/main/js/apps/users/__tests__/Header-test.tsx View File

@@ -33,5 +33,5 @@ it('should open the user creation form', () => {
});

function getWrapper(props = {}) {
return shallow(<Header loading={true} onUpdateUsers={jest.fn()} {...props} />);
return shallow(<Header onUpdateUsers={jest.fn()} {...props} />);
}

+ 50
- 9
server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx View File

@@ -35,12 +35,17 @@ const ui = {
infoManageMode: byText(/users\.page\.managed_description/),
description: byText('users.page.description'),
allFilter: byRole('button', { name: 'all' }),
managedFilter: byRole('button', { name: 'users.managed' }),
localFilter: byRole('button', { name: 'users.local' }),
managedFilter: byRole('button', { name: 'managed' }),
localFilter: byRole('button', { name: 'local' }),
aliceRow: byRole('row', { name: 'AM Alice Merveille alice.merveille never' }),
aliceRowWithLocalBadge: byRole('row', {
name: 'AM Alice Merveille alice.merveille users.local never',
name: 'AM Alice Merveille alice.merveille local never',
}),
aliceUpdateGroupButton: byRole('button', { name: 'users.update_users_groups.alice.merveille' }),
aliceUpdateButton: byRole('button', { name: 'users.manage_user.alice.merveille' }),
alicedDeactivateButton: byRole('button', { name: 'users.deactivate' }),
bobUpdateGroupButton: byRole('button', { name: 'users.update_users_groups.bob.marley' }),
bobUpdateButton: byRole('button', { name: 'users.manage_user.bob.marley' }),
bobRow: byRole('row', { name: 'BM Bob Marley bob.marley never' }),
};

@@ -56,6 +61,20 @@ describe('in non managed mode', () => {
expect(ui.createUserButton.get()).toBeEnabled();
});

it("should be able to add/remove user's group", async () => {
renderUsersApp();

expect(await ui.aliceUpdateGroupButton.find()).toBeInTheDocument();
expect(await ui.bobUpdateGroupButton.find()).toBeInTheDocument();
});

it('should be able to update / change password / deactivate a user', async () => {
renderUsersApp();

expect(await ui.aliceUpdateButton.find()).toBeInTheDocument();
expect(await ui.bobUpdateButton.find()).toBeInTheDocument();
});

it('should render all users', async () => {
renderUsersApp();

@@ -76,6 +95,32 @@ describe('in manage mode', () => {
expect(await ui.infoManageMode.find()).toBeInTheDocument();
});

it("should not be able to add/remove a user's group", async () => {
renderUsersApp();

expect(await ui.aliceRowWithLocalBadge.find()).toBeInTheDocument();
expect(ui.aliceUpdateGroupButton.query()).not.toBeInTheDocument();

expect(await ui.bobRow.find()).toBeInTheDocument();
expect(ui.bobUpdateGroupButton.query()).not.toBeInTheDocument();
});

it('should not be able to update / change password / deactivate a managed user', async () => {
renderUsersApp();

expect(await ui.bobRow.find()).toBeInTheDocument();
expect(ui.bobUpdateButton.query()).not.toBeInTheDocument();
});

it('should ONLY be able to deactivate a local user', async () => {
const user = userEvent.setup();
renderUsersApp();

expect(await ui.aliceRowWithLocalBadge.find()).toBeInTheDocument();
await user.click(ui.aliceUpdateButton.get());
expect(await ui.alicedDeactivateButton.get()).toBeInTheDocument();
});

it('should render list of all users', async () => {
renderUsersApp();

@@ -89,9 +134,7 @@ describe('in manage mode', () => {
const user = userEvent.setup();
renderUsersApp();

// The click downs't work without this line
expect(await ui.managedFilter.find()).toBeInTheDocument();
await user.click(await ui.managedFilter.get());
await user.click(await ui.managedFilter.find());

expect(ui.aliceRowWithLocalBadge.query()).not.toBeInTheDocument();
expect(ui.bobRow.get()).toBeInTheDocument();
@@ -101,9 +144,7 @@ describe('in manage mode', () => {
const user = userEvent.setup();
renderUsersApp();

// The click downs't work without this line
expect(await ui.localFilter.find()).toBeInTheDocument();
await user.click(await ui.localFilter.get());
await user.click(await ui.localFilter.find());

expect(ui.aliceRowWithLocalBadge.get()).toBeInTheDocument();
expect(ui.bobRow.query()).not.toBeInTheDocument();

+ 0
- 3
server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/Header-test.tsx.snap View File

@@ -9,9 +9,6 @@ exports[`should render correctly 1`] = `
>
users.page
</h2>
<DeferredSpinner
loading={true}
/>
<div
className="page-actions"
>

+ 37
- 14
server/sonar-web/src/main/js/apps/users/components/UserActions.tsx View File

@@ -22,7 +22,7 @@ import ActionsDropdown, {
ActionsDropdownDivider,
ActionsDropdownItem,
} from '../../../components/controls/ActionsDropdown';
import { translate } from '../../../helpers/l10n';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { isUserActive, User } from '../../../types/users';
import DeactivateForm from './DeactivateForm';
import PasswordForm from './PasswordForm';
@@ -32,6 +32,7 @@ interface Props {
isCurrentUser: boolean;
onUpdateUsers: () => void;
user: User;
manageProvider: string | undefined;
}

interface State {
@@ -57,23 +58,41 @@ export default class UserActions extends React.PureComponent<Props, State> {
this.setState({ openForm: undefined });
};

isInstanceManaged = () => {
return this.props.manageProvider !== undefined;
};

isUserLocal = () => {
return this.isInstanceManaged() && !this.props.user.managed;
};

isUserManaged = () => {
return this.isInstanceManaged() && this.props.user.managed;
};

renderActions = () => {
const { user } = this.props;

return (
<ActionsDropdown>
<ActionsDropdownItem className="js-user-update" onClick={this.handleOpenUpdateForm}>
{translate('update_details')}
</ActionsDropdownItem>
{user.local && (
<ActionsDropdownItem
className="js-user-change-password"
onClick={this.handleOpenPasswordForm}
>
{translate('my_profile.password.title')}
</ActionsDropdownItem>
<ActionsDropdown label={translateWithParameters('users.manage_user', user.login)}>
{!this.isInstanceManaged() && (
<>
<ActionsDropdownItem className="js-user-update" onClick={this.handleOpenUpdateForm}>
{translate('update_details')}
</ActionsDropdownItem>
{user.local && (
<ActionsDropdownItem
className="js-user-change-password"
onClick={this.handleOpenPasswordForm}
>
{translate('my_profile.password.title')}
</ActionsDropdownItem>
)}
</>
)}
<ActionsDropdownDivider />
{isUserActive(user) && (

{isUserActive(user) && !this.isInstanceManaged() && <ActionsDropdownDivider />}
{isUserActive(user) && (!this.isInstanceManaged() || this.isUserLocal()) && (
<ActionsDropdownItem
className="js-user-deactivate"
destructive={true}
@@ -90,6 +109,10 @@ export default class UserActions extends React.PureComponent<Props, State> {
const { openForm } = this.state;
const { isCurrentUser, onUpdateUsers, user } = this.props;

if (this.isUserManaged()) {
return null;
}

return (
<>
{this.renderActions()}

+ 17
- 12
server/sonar-web/src/main/js/apps/users/components/UserGroups.tsx View File

@@ -28,6 +28,7 @@ interface Props {
groups: string[];
onUpdateUsers: () => void;
user: User;
manageProvider: string | undefined;
}

interface State {
@@ -49,7 +50,8 @@ export default class UserGroups extends React.PureComponent<Props, State> {
};

render() {
const { groups } = this.props;
const { groups, user, manageProvider } = this.props;
const { showMore, openForm } = this.state;
const limit = groups.length > GROUPS_LIMIT ? GROUPS_LIMIT - 1 : GROUPS_LIMIT;
return (
<ul>
@@ -59,31 +61,34 @@ export default class UserGroups extends React.PureComponent<Props, State> {
</li>
))}
{groups.length > GROUPS_LIMIT &&
this.state.showMore &&
showMore &&
groups.slice(limit).map((group) => (
<li className="little-spacer-bottom" key={group}>
{group}
</li>
))}
<li className="little-spacer-bottom">
{groups.length > GROUPS_LIMIT && !this.state.showMore && (
{groups.length > GROUPS_LIMIT && !showMore && (
<a className="js-user-more-groups spacer-right" href="#" onClick={this.toggleShowMore}>
{translateWithParameters('more_x', groups.length - limit)}
</a>
)}
<ButtonIcon
className="js-user-groups button-small"
onClick={this.handleOpenForm}
tooltip={translate('users.update_groups')}
>
<BulletListIcon />
</ButtonIcon>
{manageProvider === undefined && (
<ButtonIcon
aria-label={translateWithParameters('users.update_users_groups', user.login)}
className="js-user-groups button-small"
onClick={this.handleOpenForm}
tooltip={translate('users.update_groups')}
>
<BulletListIcon />
</ButtonIcon>
)}
</li>
{this.state.openForm && (
{openForm && (
<GroupsForm
onClose={this.handleCloseForm}
onUpdateUsers={this.props.onUpdateUsers}
user={this.props.user}
user={user}
/>
)}
</ul>

+ 18
- 4
server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx View File

@@ -69,7 +69,12 @@ export default function UserListItem(props: UserListItemProps) {
<DateFromNow date={user.lastConnectionDate} hourPrecision={true} />
</td>
<td className="thin nowrap text-middle">
<UserGroups groups={user.groups || []} onUpdateUsers={onUpdateUsers} user={user} />
<UserGroups
groups={user.groups || []}
manageProvider={manageProvider}
onUpdateUsers={onUpdateUsers}
user={user}
/>
</td>
<td className="thin nowrap text-middle">
{user.tokensCount}
@@ -81,9 +86,18 @@ export default function UserListItem(props: UserListItemProps) {
<BulletListIcon />
</ButtonIcon>
</td>
<td className="thin nowrap text-right text-middle">
<UserActions isCurrentUser={isCurrentUser} onUpdateUsers={onUpdateUsers} user={user} />
</td>

{(manageProvider === undefined || !user.managed) && (
<td className="thin nowrap text-right text-middle">
<UserActions
isCurrentUser={isCurrentUser}
onUpdateUsers={onUpdateUsers}
user={user}
manageProvider={manageProvider}
/>
</td>
)}

{openTokenForm && (
<TokensFormModal
onClose={() => setOpenTokenForm(false)}

+ 2
- 2
server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx View File

@@ -43,8 +43,8 @@ export default function UserListItemIdentity({ identityProvider, user, managePro
{!user.local && user.externalProvider !== 'sonarqube' && (
<ExternalProvider identityProvider={identityProvider} user={user} />
)}
{user.managed === false && manageProvider !== undefined && (
<span className="badge">{translate('users.local')}</span>
{!user.managed && manageProvider !== undefined && (
<span className="badge">{translate('local')}</span>
)}
</td>
);

+ 7
- 1
server/sonar-web/src/main/js/apps/users/components/__tests__/UserActions-test.tsx View File

@@ -55,6 +55,12 @@ it('should open the deactivate form', () => {

function getWrapper(props = {}) {
return shallow(
<UserActions isCurrentUser={false} onUpdateUsers={jest.fn()} user={user} {...props} />
<UserActions
isCurrentUser={false}
onUpdateUsers={jest.fn()}
user={user}
manageProvider={undefined}
{...props}
/>
);
}

+ 9
- 1
server/sonar-web/src/main/js/apps/users/components/__tests__/UserGroups-test.tsx View File

@@ -51,5 +51,13 @@ it('should open the groups form', () => {
});

function getWrapper(props = {}) {
return shallow(<UserGroups groups={groups} onUpdateUsers={jest.fn()} user={user} {...props} />);
return shallow(
<UserGroups
groups={groups}
onUpdateUsers={jest.fn()}
user={user}
manageProvider={undefined}
{...props}
/>
);
}

+ 3
- 1
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserActions-test.tsx.snap View File

@@ -2,7 +2,9 @@

exports[`should render correctly 1`] = `
<Fragment>
<ActionsDropdown>
<ActionsDropdown
label="users.manage_user.obi"
>
<ActionsDropdownItem
className="js-user-update"
onClick={[Function]}

+ 1
- 0
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserGroups-test.tsx.snap View File

@@ -25,6 +25,7 @@ exports[`should render correctly 1`] = `
more_x.2
</a>
<ButtonIcon
aria-label="users.update_users_groups.obi"
className="js-user-groups button-small"
onClick={[Function]}
tooltip="users.update_groups"

+ 1
- 0
server/sonar-web/src/main/js/helpers/testMocks.ts View File

@@ -276,6 +276,7 @@ export function mockGroup(overrides: Partial<Group> = {}): Group {
return {
membersCount: 1,
name: 'Foo',
managed: false,
...overrides,
};
}

+ 1
- 0
server/sonar-web/src/main/js/types/types.ts View File

@@ -219,6 +219,7 @@ export interface Group {
description?: string;
membersCount: number;
name: string;
managed: boolean;
}

export type HealthType = 'RED' | 'YELLOW' | 'GREEN';

+ 6
- 12
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -271,8 +271,10 @@ false_positive=False positive
go_back_to_homepage=Go back to the homepage
last_analysis_before=Last analysis before
less_than_1_hour_ago=< 1 hour ago
local=Local
logging_out=You're logging out, please wait...
manage=Manage
managed=Managed
management=Management
more_information=More information
new_violations=New violations
@@ -2149,15 +2151,6 @@ unauthorized.message=You're not authorized to access this page. Please contact t
unauthorized.reason=Reason:



#------------------------------------------------------------------------------
#
# USERS & GROUPS PAGE
#
#------------------------------------------------------------------------------

groups.users.edit=Change group members

#------------------------------------------------------------------------------
#
# MY PROFILE & MY ACCOUNT
@@ -4340,12 +4333,13 @@ users.cannot_update_delegated_user=You cannot update the name and email of this
users.minimum_x_characters=Minimum {0} characters
users.email=Email
users.last_connection=Last connection
users.update_users_groups=Update {0}'s group membership
users.update_groups=Update Groups
users.manage_user=Update {0}
users.update_tokens=Update Tokens
users.add=Add user
users.remove=Remove user
users.search_description=Search users by login or name
users.update=Update users
users.tokens=Tokens
users.user_X_tokens=Tokens of {user}
users.tokens.sure=Sure?
@@ -4384,8 +4378,6 @@ users.change_admin_password.form.confirm=Confirm password for user 'admin'
users.change_admin_password.form.cannot_use_default_password=You must choose a password that is different from the default password.
users.change_admin_password.form.success=The admin user's password was successfully changed.
users.change_admin_password.form.continue_to_app=Continue to SonarQube
users.local=Local
users.managed=Managed

#------------------------------------------------------------------------------
#
@@ -4400,7 +4392,9 @@ groups.delete_group=Delete Group
groups.delete_group.confirmation=Are you sure you want to delete "{0}"?
groups.create_group=Create Group
groups.update_group=Update Group
groups.users.edit=Change {0} members
groups.anyone=Anyone
groups.edit=Edit {0}


#------------------------------------------------------------------------------

Loading…
Cancel
Save