Quellcode durchsuchen

SONAR-15441 QG permission management handles groups

tags/9.2.0.49834
Jeremy Davis vor 2 Jahren
Ursprung
Commit
6770559c5d
19 geänderte Dateien mit 588 neuen und 167 gelöschten Zeilen
  1. 14
    0
      server/sonar-web/src/main/js/api/quality-gates.ts
  2. 15
    9
      server/sonar-web/src/main/js/apps/quality-gates/components/PermissionItem.tsx
  3. 63
    26
      server/sonar-web/src/main/js/apps/quality-gates/components/QualityGatePermissions.tsx
  4. 26
    17
      server/sonar-web/src/main/js/apps/quality-gates/components/QualityGatePermissionsAddModal.tsx
  5. 13
    7
      server/sonar-web/src/main/js/apps/quality-gates/components/QualityGatePermissionsAddModalRenderer.tsx
  6. 34
    15
      server/sonar-web/src/main/js/apps/quality-gates/components/QualityGatePermissionsRenderer.tsx
  7. 5
    4
      server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/PermissionItem-test.tsx
  8. 97
    20
      server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGatePermissions-test.tsx
  9. 13
    7
      server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGatePermissionsAddModal-test.tsx
  10. 5
    4
      server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGatePermissionsAddModalRenderer-test.tsx
  11. 10
    3
      server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGatePermissionsRenderer-test.tsx
  12. 22
    1
      server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/PermissionItem-test.tsx.snap
  13. 1
    0
      server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/QualityGatePermissions-test.tsx.snap
  14. 1
    14
      server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/QualityGatePermissionsAddModal-test.tsx.snap
  15. 19
    2
      server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/QualityGatePermissionsAddModalRenderer-test.tsx.snap
  16. 231
    32
      server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/QualityGatePermissionsRenderer-test.tsx.snap
  17. 1
    1
      server/sonar-web/src/main/js/components/icons/GroupIcon.tsx
  18. 16
    3
      server/sonar-web/src/main/js/types/quality-gates.ts
  19. 2
    2
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 14
- 0
server/sonar-web/src/main/js/api/quality-gates.ts Datei anzeigen

@@ -21,7 +21,9 @@ import throwGlobalError from '../app/utils/throwGlobalError';
import { getJSON, post, postJSON } from '../helpers/request';
import { BranchParameters } from '../types/branch-like';
import {
AddDeleteGroupPermissionsParameters,
AddDeleteUserPermissionsParameters,
Group,
QualityGateApplicationStatus,
QualityGateProjectStatus,
SearchPermissionsParameters
@@ -141,3 +143,15 @@ export function removeUser(data: AddDeleteUserPermissionsParameters) {
export function searchUsers(data: SearchPermissionsParameters): Promise<{ users: T.UserBase[] }> {
return getJSON('/api/qualitygates/search_users', data).catch(throwGlobalError);
}

export function addGroup(data: AddDeleteGroupPermissionsParameters) {
return post('/api/qualitygates/add_group', data).catch(throwGlobalError);
}

export function removeGroup(data: AddDeleteGroupPermissionsParameters) {
return post('/api/qualitygates/remove_group', data).catch(throwGlobalError);
}

export function searchGroups(data: SearchPermissionsParameters): Promise<{ groups: Group[] }> {
return getJSON('/api/qualitygates/search_groups', data).catch(throwGlobalError);
}

+ 15
- 9
server/sonar-web/src/main/js/apps/quality-gates/components/PermissionItem.tsx Datei anzeigen

@@ -19,26 +19,32 @@
*/
import * as React from 'react';
import { DeleteButton } from '../../../components/controls/buttons';
import GroupIcon from '../../../components/icons/GroupIcon';
import Avatar from '../../../components/ui/Avatar';
import { Group, isUser } from '../../../types/quality-gates';

interface Props {
onClickDelete: (user: T.UserBase) => void;
user: T.UserBase;
export interface PermissionItemProps {
onClickDelete: (item: T.UserBase | Group) => void;
item: T.UserBase | Group;
}

export default function PermissionItem(props: Props) {
const { user } = props;
export default function PermissionItem(props: PermissionItemProps) {
const { item } = props;

return (
<div className="display-flex-center permission-list-item padded">
<Avatar className="spacer-right" hash={user.avatar} name={user.name} size={32} />
{isUser(item) ? (
<Avatar className="spacer-right" hash={item.avatar} name={item.name} size={32} />
) : (
<GroupIcon className="pull-left spacer-right" size={32} />
)}

<div className="overflow-hidden flex-1">
<strong>{user.name}</strong>
<div className="note">{user.login}</div>
<strong>{item.name}</strong>
{isUser(item) && <div className="note">{item.login}</div>}
</div>

<DeleteButton onClick={() => props.onClickDelete(user)} />
<DeleteButton onClick={() => props.onClickDelete(item)} />
</div>
);
}

+ 63
- 26
server/sonar-web/src/main/js/apps/quality-gates/components/QualityGatePermissions.tsx Datei anzeigen

@@ -19,7 +19,15 @@
*/
import { sortBy } from 'lodash';
import * as React from 'react';
import { addUser, removeUser, searchUsers } from '../../../api/quality-gates';
import {
addGroup,
addUser,
removeGroup,
removeUser,
searchGroups,
searchUsers
} from '../../../api/quality-gates';
import { Group, isUser, SearchPermissionsParameters } from '../../../types/quality-gates';
import QualityGatePermissionsRenderer from './QualityGatePermissionsRenderer';

interface Props {
@@ -27,16 +35,18 @@ interface Props {
}

interface State {
groups: Group[];
submitting: boolean;
loading: boolean;
showAddModal: boolean;
userPermissionToDelete?: T.UserBase;
permissionToDelete?: T.UserBase | Group;
users: T.UserBase[];
}

export default class QualityGatePermissions extends React.Component<Props, State> {
mounted = false;
state: State = {
groups: [],
submitting: false,
loading: true,
showAddModal: false,
@@ -63,15 +73,17 @@ export default class QualityGatePermissions extends React.Component<Props, State
const { qualityGate } = this.props;
this.setState({ loading: true });

const { users } = await searchUsers({
qualityGate: qualityGate.id,
const params: SearchPermissionsParameters = {
gateName: qualityGate.name,
selected: 'selected'
}).catch(() => ({
users: []
}));
};
const [{ users }, { groups }] = await Promise.all([
searchUsers(params).catch(() => ({ users: [] })),
searchGroups(params).catch(() => ({ groups: [] }))
]);

if (this.mounted) {
this.setState({ loading: false, users });
this.setState({ groups, loading: false, users });
}
};

@@ -83,58 +95,83 @@ export default class QualityGatePermissions extends React.Component<Props, State
this.setState({ showAddModal: true });
};

handleSubmitAddPermission = async (user: T.UserBase) => {
handleSubmitAddPermission = async (item: T.UserBase | Group) => {
const { qualityGate } = this.props;
this.setState({ submitting: true });

let error = false;
try {
await addUser({ qualityGate: qualityGate.id, userLogin: user.login });
if (isUser(item)) {
await addUser({ gateName: qualityGate.name, login: item.login });
} else {
await addGroup({ gateName: qualityGate.name, groupName: item.name });
}
} catch {
error = true;
}

if (this.mounted && !error) {
if (isUser(item)) {
this.setState(({ users }) => ({
showAddModal: false,
users: sortBy(users.concat(item), u => u.name)
}));
} else {
this.setState(({ groups }) => ({
showAddModal: false,
groups: sortBy(groups.concat(item), g => g.name)
}));
}
}

if (this.mounted) {
this.setState(({ users }) => {
return {
submitting: false,
showAddModal: error,
users: sortBy(users.concat(user), u => u.name)
};
this.setState({
submitting: false
});
}
};

handleCloseDeletePermission = () => {
this.setState({ userPermissionToDelete: undefined });
this.setState({ permissionToDelete: undefined });
};

handleClickDeletePermission = (userPermissionToDelete?: T.UserBase) => {
this.setState({ userPermissionToDelete });
handleClickDeletePermission = (permissionToDelete?: T.UserBase | Group) => {
this.setState({ permissionToDelete });
};

handleConfirmDeletePermission = async (user: T.UserBase) => {
handleConfirmDeletePermission = async (item: T.UserBase | Group) => {
const { qualityGate } = this.props;

let error = false;
try {
await removeUser({ qualityGate: qualityGate.id, userLogin: user.login });
if (isUser(item)) {
await removeUser({ gateName: qualityGate.name, login: item.login });
} else {
await removeGroup({ gateName: qualityGate.name, groupName: item.name });
}
} catch {
error = true;
}

if (this.mounted && !error) {
this.setState(({ users }) => ({
users: users.filter(u => u.login !== user.login)
}));
if (isUser(item)) {
this.setState(({ users }) => ({
users: users.filter(u => u.login !== item.login)
}));
} else {
this.setState(({ groups }) => ({
groups: groups.filter(g => g.name !== item.name)
}));
}
}
};

render() {
const { qualityGate } = this.props;
const { submitting, loading, showAddModal, userPermissionToDelete, users } = this.state;
const { groups, submitting, loading, showAddModal, permissionToDelete, users } = this.state;
return (
<QualityGatePermissionsRenderer
groups={groups}
loading={loading}
onClickAddPermission={this.handleClickAddPermission}
onCloseAddPermission={this.handleCloseAddPermission}
@@ -142,10 +179,10 @@ export default class QualityGatePermissions extends React.Component<Props, State
onCloseDeletePermission={this.handleCloseDeletePermission}
onClickDeletePermission={this.handleClickDeletePermission}
onConfirmDeletePermission={this.handleConfirmDeletePermission}
permissionToDelete={permissionToDelete}
qualityGate={qualityGate}
showAddModal={showAddModal}
submitting={submitting}
userPermissionToDelete={userPermissionToDelete}
users={users}
/>
);

+ 26
- 17
server/sonar-web/src/main/js/apps/quality-gates/components/QualityGatePermissionsAddModal.tsx Datei anzeigen

@@ -19,12 +19,13 @@
*/
import { debounce } from 'lodash';
import * as React from 'react';
import { searchUsers } from '../../../api/quality-gates';
import { searchGroups, searchUsers } from '../../../api/quality-gates';
import { Group, SearchPermissionsParameters } from '../../../types/quality-gates';
import QualityGatePermissionsAddModalRenderer from './QualityGatePermissionsAddModalRenderer';

interface Props {
onClose: () => void;
onSubmit: (selectedUser: T.UserBase) => void;
onSubmit: (selection: T.UserBase | Group) => void;
qualityGate: T.QualityGate;
submitting: boolean;
}
@@ -32,8 +33,8 @@ interface Props {
interface State {
loading: boolean;
query?: string;
searchResults: T.UserBase[];
selection?: T.UserBase;
searchResults: Array<T.UserBase | Group>;
selection?: T.UserBase | Group;
}

const DEBOUNCE_DELAY = 250;
@@ -58,21 +59,29 @@ export default class QualityGatePermissionsAddModal extends React.Component<Prop
this.mounted = false;
}

handleSearch = (query: string) => {
handleSearch = async (query: string) => {
const { qualityGate } = this.props;
this.setState({ loading: true });
searchUsers({ qualityGate: qualityGate.id, q: query, selected: 'deselected' }).then(
result => {
if (this.mounted) {
this.setState({ loading: false, searchResults: result.users });
}
},
() => {
if (this.mounted) {
this.setState({ loading: false });
}

const queryParams: SearchPermissionsParameters = {
gateName: qualityGate.name,
q: query,
selected: 'deselected'
};

try {
const [{ users }, { groups }] = await Promise.all([
searchUsers(queryParams),
searchGroups(queryParams)
]);
if (this.mounted) {
this.setState({ loading: false, searchResults: [...users, ...groups] });
}
);
} catch {
if (this.mounted) {
this.setState({ loading: false });
}
}
};

handleInputChange = (query: string) => {
@@ -82,7 +91,7 @@ export default class QualityGatePermissionsAddModal extends React.Component<Prop
}
};

handleSelection = (selection: T.UserBase) => {
handleSelection = (selection: T.UserBase | Group) => {
this.setState({ selection });
};


+ 13
- 7
server/sonar-web/src/main/js/apps/quality-gates/components/QualityGatePermissionsAddModalRenderer.tsx Datei anzeigen

@@ -21,22 +21,24 @@ import * as React from 'react';
import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
import Modal from '../../../components/controls/Modal';
import Select from '../../../components/controls/Select';
import GroupIcon from '../../../components/icons/GroupIcon';
import Avatar from '../../../components/ui/Avatar';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { Group, isUser } from '../../../types/quality-gates';

export interface QualityGatePermissionsAddModalRendererProps {
onClose: () => void;
onInputChange: (query: string) => void;
onSubmit: (event: React.SyntheticEvent<HTMLFormElement>) => void;
onSelection: (selection: T.UserBase) => void;
onSelection: (selection: T.UserBase | Group) => void;
submitting: boolean;
loading: boolean;
query: string;
searchResults: T.UserBase[];
selection?: T.UserBase;
searchResults: Array<T.UserBase | Group>;
selection?: T.UserBase | Group;
}

type Option = T.UserBase & { value: string };
type Option = (T.UserBase | Group) & { value: string };

export default function QualityGatePermissionsAddModalRenderer(
props: QualityGatePermissionsAddModalRendererProps
@@ -68,7 +70,7 @@ export default function QualityGatePermissionsAddModalRenderer(
onChange={props.onSelection}
onInputChange={props.onInputChange}
optionRenderer={optionRenderer}
options={searchResults.map(r => ({ ...r, value: r.login }))}
options={searchResults.map(r => ({ ...r, value: isUser(r) ? r.login : r.name }))}
placeholder=""
searchable={true}
value={selection}
@@ -89,9 +91,13 @@ export default function QualityGatePermissionsAddModalRenderer(
function optionRenderer(option: Option) {
return (
<>
<Avatar hash={option.avatar} name={option.name} size={16} />
{isUser(option) ? (
<Avatar hash={option.avatar} name={option.name} size={16} />
) : (
<GroupIcon size={16} />
)}
<strong className="spacer-left">{option.name}</strong>
<span className="note little-spacer-left">{option.login}</span>
{isUser(option) && <span className="note little-spacer-left">{option.login}</span>}
</>
);
}

+ 34
- 15
server/sonar-web/src/main/js/apps/quality-gates/components/QualityGatePermissionsRenderer.tsx Datei anzeigen

@@ -23,39 +23,52 @@ import { Button } from '../../../components/controls/buttons';
import ConfirmModal from '../../../components/controls/ConfirmModal';
import DeferredSpinner from '../../../components/ui/DeferredSpinner';
import { translate } from '../../../helpers/l10n';
import { Group, isUser } from '../../../types/quality-gates';
import PermissionItem from './PermissionItem';
import QualityGatePermissionsAddModal from './QualityGatePermissionsAddModal';

export interface QualityGatePermissionsRendererProps {
groups: Group[];
loading: boolean;
onClickAddPermission: () => void;
onCloseAddPermission: () => void;
onSubmitAddPermission: (user: T.UserBase) => void;
onSubmitAddPermission: (item: T.UserBase | Group) => void;
onCloseDeletePermission: () => void;
onConfirmDeletePermission: (user: T.UserBase) => void;
onClickDeletePermission: (user: T.UserBase) => void;
onConfirmDeletePermission: (item: T.UserBase | Group) => void;
onClickDeletePermission: (item: T.UserBase | Group) => void;
permissionToDelete?: T.UserBase | Group;
qualityGate: T.QualityGate;
showAddModal: boolean;
submitting: boolean;
userPermissionToDelete?: T.UserBase;
users: T.UserBase[];
}

export default function QualityGatePermissionsRenderer(props: QualityGatePermissionsRendererProps) {
const { loading, qualityGate, showAddModal, submitting, userPermissionToDelete, users } = props;
const {
groups,
loading,
permissionToDelete,
qualityGate,
showAddModal,
submitting,
users
} = props;

return (
<div className="quality-gate-permissions">
<header className="display-flex-center spacer-bottom">
<h3>{translate('quality_gates.permissions')}</h3>
</header>
<h3 className="spacer-bottom">{translate('quality_gates.permissions')}</h3>
<p className="spacer-bottom">{translate('quality_gates.permissions.help')}</p>
<div>
<DeferredSpinner loading={loading}>
<ul>
{users.map(user => (
<li key={user.login}>
<PermissionItem onClickDelete={props.onClickDeletePermission} user={user} />
<PermissionItem onClickDelete={props.onClickDeletePermission} item={user} />
</li>
))}
{groups.map(group => (
<li key={group.name}>
<PermissionItem onClickDelete={props.onClickDeletePermission} item={group} />
</li>
))}
</ul>
@@ -75,19 +88,25 @@ export default function QualityGatePermissionsRenderer(props: QualityGatePermiss
/>
)}

{userPermissionToDelete && (
{permissionToDelete && (
<ConfirmModal
header={translate('users.remove')}
header={
isUser(permissionToDelete) ? translate('users.remove') : translate('groups.remove')
}
confirmButtonText={translate('remove')}
isDestructive={true}
confirmData={userPermissionToDelete}
confirmData={permissionToDelete}
onClose={props.onCloseDeletePermission}
onConfirm={props.onConfirmDeletePermission}>
<FormattedMessage
defaultMessage={translate('users.remove.confirmation')}
id="users.remove.confirmation"
defaultMessage={
isUser(permissionToDelete)
? translate('users.remove.confirmation')
: translate('groups.remove.confirmation')
}
id="remove.confirmation"
values={{
user: <strong>{userPermissionToDelete.name}</strong>
user: <strong>{permissionToDelete.name}</strong>
}}
/>
</ConfirmModal>

+ 5
- 4
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/PermissionItem-test.tsx Datei anzeigen

@@ -20,12 +20,13 @@
import { shallow } from 'enzyme';
import * as React from 'react';
import { mockUser } from '../../../../helpers/testMocks';
import PermissionItem from '../PermissionItem';
import PermissionItem, { PermissionItemProps } from '../PermissionItem';

it('should render correctly', () => {
expect(shallowRender()).toMatchSnapshot();
expect(shallowRender()).toMatchSnapshot('user');
expect(shallowRender({ item: { name: 'groupname' } })).toMatchSnapshot('group');
});

function shallowRender() {
return shallow(<PermissionItem onClickDelete={jest.fn()} user={mockUser()} />);
function shallowRender(overrides: Partial<PermissionItemProps> = {}) {
return shallow(<PermissionItem onClickDelete={jest.fn()} item={mockUser()} {...overrides} />);
}

+ 97
- 20
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGatePermissions-test.tsx Datei anzeigen

@@ -19,7 +19,14 @@
*/
import { shallow } from 'enzyme';
import * as React from 'react';
import { addUser, removeUser, searchUsers } from '../../../../api/quality-gates';
import {
addGroup,
addUser,
removeGroup,
removeUser,
searchGroups,
searchUsers
} from '../../../../api/quality-gates';
import { mockQualityGate } from '../../../../helpers/mocks/quality-gates';
import { mockUserBase } from '../../../../helpers/mocks/users';
import { waitAndUpdate } from '../../../../helpers/testUtils';
@@ -28,7 +35,10 @@ import QualityGatePermissions from '../QualityGatePermissions';
jest.mock('../../../../api/quality-gates', () => ({
addUser: jest.fn().mockResolvedValue(undefined),
removeUser: jest.fn().mockResolvedValue(undefined),
searchUsers: jest.fn().mockResolvedValue({ users: [] })
searchUsers: jest.fn().mockResolvedValue({ users: [] }),
addGroup: jest.fn().mockResolvedValue(undefined),
removeGroup: jest.fn().mockResolvedValue(undefined),
searchGroups: jest.fn().mockResolvedValue({ groups: [] })
}));

beforeEach(() => {
@@ -39,20 +49,32 @@ it('should render correctly', () => {
expect(shallowRender()).toMatchSnapshot();
});

it('should fetch users', async () => {
it('should fetch users and groups', async () => {
const wrapper = shallowRender();
await waitAndUpdate(wrapper);
expect(searchUsers).toBeCalledWith({ qualityGate: '1', selected: 'selected' });
expect(searchUsers).toBeCalledWith({ gateName: 'qualitygate', selected: 'selected' });
expect(searchGroups).toBeCalledWith({ gateName: 'qualitygate', selected: 'selected' });
});

it('should fetch users on update', async () => {
it('should handle errors when fetching users and groups', async () => {
(searchUsers as jest.Mock).mockRejectedValueOnce('nope');
(searchGroups as jest.Mock).mockRejectedValueOnce('nope');

const wrapper = shallowRender();
await waitAndUpdate(wrapper);

expect(wrapper.state().groups).toHaveLength(0);
expect(wrapper.state().users).toHaveLength(0);
});

it('should fetch users and groups on update', async () => {
const wrapper = shallowRender();
await waitAndUpdate(wrapper);

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

wrapper.setProps({ qualityGate: mockQualityGate({ id: '2' }) });
expect(searchUsers).toBeCalledWith({ qualityGate: '2', selected: 'selected' });
wrapper.setProps({ qualityGate: mockQualityGate({ id: '2', name: 'qg2' }) });
expect(searchUsers).toBeCalledWith({ gateName: 'qg2', selected: 'selected' });
});

it('should handleCloseAddPermission', () => {
@@ -69,23 +91,48 @@ it('should handleClickAddPermission', () => {
expect(wrapper.state().showAddModal).toBe(true);
});

it('should handleSubmitAddPermission', async () => {
it('should handleSubmitAddPermission for a user', async () => {
const wrapper = shallowRender();

await waitAndUpdate(wrapper);

expect(wrapper.state().users).toHaveLength(0);
expect(wrapper.state().groups).toHaveLength(0);

wrapper.instance().handleSubmitAddPermission(mockUserBase({ login: 'user1', name: 'User One' }));
expect(wrapper.state().submitting).toBe(true);

expect(addUser).toBeCalledWith({ qualityGate: '1', userLogin: 'user1' });
expect(addUser).toBeCalledWith({ gateName: 'qualitygate', login: 'user1' });
expect(addGroup).not.toBeCalled();

await waitAndUpdate(wrapper);

expect(wrapper.state().submitting).toBe(false);
expect(wrapper.state().showAddModal).toBe(false);
expect(wrapper.state().users).toHaveLength(1);
expect(wrapper.state().groups).toHaveLength(0);
});

it('should handleSubmitAddPermission for a group', async () => {
const wrapper = shallowRender();

await waitAndUpdate(wrapper);

expect(wrapper.state().users).toHaveLength(0);
expect(wrapper.state().groups).toHaveLength(0);

wrapper.instance().handleSubmitAddPermission({ name: 'group' });
expect(wrapper.state().submitting).toBe(true);

expect(addUser).not.toBeCalled();
expect(addGroup).toBeCalledWith({ gateName: 'qualitygate', groupName: 'group' });

await waitAndUpdate(wrapper);

expect(wrapper.state().submitting).toBe(false);
expect(wrapper.state().showAddModal).toBe(false);
expect(wrapper.state().users).toHaveLength(0);
expect(wrapper.state().groups).toHaveLength(1);
});

it('should handleSubmitAddPermission if it returns an error', async () => {
@@ -96,51 +143,81 @@ it('should handleSubmitAddPermission if it returns an error', async () => {

expect(wrapper.state().users).toHaveLength(0);

wrapper.setState({ showAddModal: true });
wrapper.instance().handleSubmitAddPermission(mockUserBase({ login: 'user1', name: 'User One' }));
expect(wrapper.state().submitting).toBe(true);

expect(addUser).toBeCalledWith({ qualityGate: '1', userLogin: 'user1' });
expect(addUser).toBeCalledWith({ gateName: 'qualitygate', login: 'user1' });

await waitAndUpdate(wrapper);

expect(wrapper.state().submitting).toBe(false);
expect(wrapper.state().showAddModal).toBe(true);
expect(wrapper.state().users).toHaveLength(1);
expect(wrapper.state().users).toHaveLength(0);
});

it('should handleCloseDeletePermission', () => {
const wrapper = shallowRender();
wrapper.setState({ userPermissionToDelete: mockUserBase() });
wrapper.setState({ permissionToDelete: mockUserBase() });
wrapper.instance().handleCloseDeletePermission();
expect(wrapper.state().userPermissionToDelete).toBeUndefined();
expect(wrapper.state().permissionToDelete).toBeUndefined();
});

it('should handleClickDeletePermission', () => {
const user = mockUserBase();

const wrapper = shallowRender();
wrapper.setState({ userPermissionToDelete: undefined });
wrapper.setState({ permissionToDelete: undefined });
wrapper.instance().handleClickDeletePermission(user);
expect(wrapper.state().userPermissionToDelete).toBe(user);
expect(wrapper.state().permissionToDelete).toBe(user);
});

it('should handleConfirmDeletePermission', async () => {
it('should handleConfirmDeletePermission for a user', async () => {
const deleteThisUser = mockUserBase();
const deleteThisGroup = { name: 'deletableGroup' };
(searchUsers as jest.Mock).mockResolvedValueOnce({ users: [deleteThisUser] });
(searchGroups as jest.Mock).mockResolvedValueOnce({ groups: [deleteThisGroup] });
const wrapper = shallowRender();

await waitAndUpdate(wrapper);

expect(wrapper.state().users).toHaveLength(1);
expect(wrapper.state().groups).toHaveLength(1);

wrapper.instance().handleConfirmDeletePermission(deleteThisUser);

expect(removeUser).toBeCalledWith({ qualityGate: '1', userLogin: deleteThisUser.login });
expect(removeUser).toBeCalledWith({ gateName: 'qualitygate', login: deleteThisUser.login });
expect(removeGroup).not.toBeCalled();

await waitAndUpdate(wrapper);

expect(wrapper.state().userPermissionToDelete).toBeUndefined();
expect(wrapper.state().permissionToDelete).toBeUndefined();
expect(wrapper.state().users).toHaveLength(0);
expect(wrapper.state().groups).toHaveLength(1);
});

it('should handleConfirmDeletePermission for a group', async () => {
const deleteThisUser = mockUserBase();
const deleteThisGroup = { name: 'deletableGroup' };
(searchUsers as jest.Mock).mockResolvedValueOnce({ users: [deleteThisUser] });
(searchGroups as jest.Mock).mockResolvedValueOnce({ groups: [deleteThisGroup] });
const wrapper = shallowRender();

await waitAndUpdate(wrapper);

expect(wrapper.state().users).toHaveLength(1);
expect(wrapper.state().groups).toHaveLength(1);

wrapper.instance().handleConfirmDeletePermission(deleteThisGroup);

expect(removeUser).not.toBeCalled();
expect(removeGroup).toBeCalledWith({ gateName: 'qualitygate', groupName: deleteThisGroup.name });

await waitAndUpdate(wrapper);

expect(wrapper.state().permissionToDelete).toBeUndefined();
expect(wrapper.state().users).toHaveLength(1);
expect(wrapper.state().groups).toHaveLength(0);
});

it('should handleConfirmDeletePermission if it returns an error', async () => {
@@ -155,11 +232,11 @@ it('should handleConfirmDeletePermission if it returns an error', async () => {

wrapper.instance().handleConfirmDeletePermission(deleteThisUser);

expect(removeUser).toBeCalledWith({ qualityGate: '1', userLogin: deleteThisUser.login });
expect(removeUser).toBeCalledWith({ gateName: 'qualitygate', login: deleteThisUser.login });

await waitAndUpdate(wrapper);

expect(wrapper.state().userPermissionToDelete).toBeUndefined();
expect(wrapper.state().permissionToDelete).toBeUndefined();
expect(wrapper.state().users).toHaveLength(1);
});


+ 13
- 7
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGatePermissionsAddModal-test.tsx Datei anzeigen

@@ -19,14 +19,15 @@
*/
import { shallow } from 'enzyme';
import * as React from 'react';
import { searchUsers } from '../../../../api/quality-gates';
import { searchGroups, searchUsers } from '../../../../api/quality-gates';
import { mockQualityGate } from '../../../../helpers/mocks/quality-gates';
import { mockUserBase } from '../../../../helpers/mocks/users';
import { mockEvent, waitAndUpdate } from '../../../../helpers/testUtils';
import QualityGatePermissionsAddModal from '../QualityGatePermissionsAddModal';

jest.mock('../../../../api/quality-gates', () => ({
searchUsers: jest.fn().mockResolvedValue({ users: [] })
searchUsers: jest.fn().mockResolvedValue({ users: [] }),
searchGroups: jest.fn().mockResolvedValue({ groups: [] })
}));

beforeEach(() => {
@@ -34,12 +35,12 @@ beforeEach(() => {
});

it('should render correctly', () => {
expect(shallowRender()).toMatchSnapshot('default');
expect(shallowRender({ submitting: true })).toMatchSnapshot('submitting');
expect(shallowRender()).toMatchSnapshot();
});

it('should fetch users', async () => {
it('should fetch users and groups', async () => {
(searchUsers as jest.Mock).mockResolvedValueOnce({ users: [mockUserBase()] });
(searchGroups as jest.Mock).mockResolvedValueOnce({ groups: [{ name: 'group' }] });

const wrapper = shallowRender();

@@ -48,12 +49,17 @@ it('should fetch users', async () => {
wrapper.instance().handleSearch(query);

expect(wrapper.state().loading).toBe(true);
expect(searchUsers).toBeCalledWith({ qualityGate: '1', q: query, selected: 'deselected' });
expect(searchUsers).toBeCalledWith({ gateName: 'qualitygate', q: query, selected: 'deselected' });
expect(searchGroups).toBeCalledWith({
gateName: 'qualitygate',
q: query,
selected: 'deselected'
});

await waitAndUpdate(wrapper);

expect(wrapper.state().loading).toBe(false);
expect(wrapper.state().searchResults).toHaveLength(1);
expect(wrapper.state().searchResults).toHaveLength(2);
});

it('should handle input change', () => {

+ 5
- 4
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGatePermissionsAddModalRenderer-test.tsx Datei anzeigen

@@ -32,9 +32,9 @@ it('should render correctly', () => {
expect(shallowRender({ selection: mockUserBase(), submitting: true })).toMatchSnapshot(
'submitting'
);
expect(shallowRender({ query: 'ab', searchResults: [mockUserBase()] })).toMatchSnapshot(
'query and results'
);
expect(
shallowRender({ query: 'ab', searchResults: [mockUserBase(), { name: 'group name' }] })
).toMatchSnapshot('query and results');
});

it('should render options correctly', () => {
@@ -42,7 +42,8 @@ it('should render options correctly', () => {

const { optionRenderer = () => null } = wrapper.find(Select).props();

expect(optionRenderer({ avatar: 'avatar', name: 'name', login: 'login' })).toMatchSnapshot();
expect(optionRenderer({ avatar: 'A', name: 'name', login: 'login' })).toMatchSnapshot('user');
expect(optionRenderer({ name: 'group name' })).toMatchSnapshot('group');
});

function shallowRender(overrides: Partial<QualityGatePermissionsAddModalRendererProps> = {}) {

+ 10
- 3
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/QualityGatePermissionsRenderer-test.tsx Datei anzeigen

@@ -27,17 +27,24 @@ import QualityGatePermissionsRenderer, {
} from '../QualityGatePermissionsRenderer';

it('should render correctly', () => {
expect(shallowRender()).toMatchSnapshot('with users');
expect(shallowRender()).toMatchSnapshot('with users and groups');
expect(shallowRender({ users: [] })).toMatchSnapshot('with no users');
expect(shallowRender({ groups: [] })).toMatchSnapshot('with no groups');
expect(shallowRender({ groups: [], users: [] })).toMatchSnapshot('with no users or groups');
expect(shallowRender({ showAddModal: true })).toMatchSnapshot('show add modal');
expect(shallowRender({ userPermissionToDelete: mockUserBase() })).toMatchSnapshot(
'show remove modal'
expect(shallowRender({ permissionToDelete: mockUserBase() })).toMatchSnapshot(
'show remove modal for user'
);

expect(shallowRender({ permissionToDelete: { name: 'deletable group' } })).toMatchSnapshot(
'show remove modal for group'
);
});

function shallowRender(overrides: Partial<QualityGatePermissionsRendererProps> = {}) {
return shallow(
<QualityGatePermissionsRenderer
groups={[{ name: 'group' }]}
loading={false}
onClickAddPermission={jest.fn()}
onCloseAddPermission={jest.fn()}

+ 22
- 1
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/PermissionItem-test.tsx.snap Datei anzeigen

@@ -1,6 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render correctly 1`] = `
exports[`should render correctly: group 1`] = `
<div
className="display-flex-center permission-list-item padded"
>
<GroupIcon
className="pull-left spacer-right"
size={32}
/>
<div
className="overflow-hidden flex-1"
>
<strong>
groupname
</strong>
</div>
<DeleteButton
onClick={[Function]}
/>
</div>
`;

exports[`should render correctly: user 1`] = `
<div
className="display-flex-center permission-list-item padded"
>

+ 1
- 0
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/QualityGatePermissions-test.tsx.snap Datei anzeigen

@@ -2,6 +2,7 @@

exports[`should render correctly 1`] = `
<QualityGatePermissionsRenderer
groups={Array []}
loading={true}
onClickAddPermission={[Function]}
onClickDeletePermission={[Function]}

+ 1
- 14
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/QualityGatePermissionsAddModal-test.tsx.snap Datei anzeigen

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

exports[`should render correctly: default 1`] = `
exports[`should render correctly 1`] = `
<QualityGatePermissionsAddModalRenderer
loading={false}
onClose={[MockFunction]}
@@ -12,16 +12,3 @@ exports[`should render correctly: default 1`] = `
submitting={false}
/>
`;

exports[`should render correctly: submitting 1`] = `
<QualityGatePermissionsAddModalRenderer
loading={false}
onClose={[MockFunction]}
onInputChange={[Function]}
onSelection={[Function]}
onSubmit={[Function]}
query=""
searchResults={Array []}
submitting={true}
/>
`;

+ 19
- 2
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/QualityGatePermissionsAddModalRenderer-test.tsx.snap Datei anzeigen

@@ -99,6 +99,10 @@ exports[`should render correctly: query and results 1`] = `
"login": "userlogin",
"value": "userlogin",
},
Object {
"name": "group name",
"value": "group name",
},
]
}
placeholder=""
@@ -315,10 +319,23 @@ exports[`should render correctly: submitting 1`] = `
</Modal>
`;

exports[`should render options correctly 1`] = `
exports[`should render options correctly: group 1`] = `
<React.Fragment>
<GroupIcon
size={16}
/>
<strong
className="spacer-left"
>
group name
</strong>
</React.Fragment>
`;

exports[`should render options correctly: user 1`] = `
<React.Fragment>
<Connect(Avatar)
hash="avatar"
hash="A"
name="name"
size={16}
/>

+ 231
- 32
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/QualityGatePermissionsRenderer-test.tsx.snap Datei anzeigen

@@ -4,13 +4,11 @@ exports[`should render correctly: show add modal 1`] = `
<div
className="quality-gate-permissions"
>
<header
className="display-flex-center spacer-bottom"
<h3
className="spacer-bottom"
>
<h3>
quality_gates.permissions
</h3>
</header>
quality_gates.permissions
</h3>
<p
className="spacer-bottom"
>
@@ -25,8 +23,7 @@ exports[`should render correctly: show add modal 1`] = `
key="john.doe"
>
<PermissionItem
onClickDelete={[MockFunction]}
user={
item={
Object {
"active": true,
"local": true,
@@ -34,6 +31,19 @@ exports[`should render correctly: show add modal 1`] = `
"name": "John Doe",
}
}
onClickDelete={[MockFunction]}
/>
</li>
<li
key="group"
>
<PermissionItem
item={
Object {
"name": "group",
}
}
onClickDelete={[MockFunction]}
/>
</li>
</ul>
@@ -59,17 +69,15 @@ exports[`should render correctly: show add modal 1`] = `
</div>
`;

exports[`should render correctly: show remove modal 1`] = `
exports[`should render correctly: show remove modal for group 1`] = `
<div
className="quality-gate-permissions"
>
<header
className="display-flex-center spacer-bottom"
<h3
className="spacer-bottom"
>
<h3>
quality_gates.permissions
</h3>
</header>
quality_gates.permissions
</h3>
<p
className="spacer-bottom"
>
@@ -84,8 +92,89 @@ exports[`should render correctly: show remove modal 1`] = `
key="john.doe"
>
<PermissionItem
item={
Object {
"active": true,
"local": true,
"login": "john.doe",
"name": "John Doe",
}
}
onClickDelete={[MockFunction]}
user={
/>
</li>
<li
key="group"
>
<PermissionItem
item={
Object {
"name": "group",
}
}
onClickDelete={[MockFunction]}
/>
</li>
</ul>
</DeferredSpinner>
</div>
<Button
className="big-spacer-top"
onClick={[MockFunction]}
>
quality_gates.permissions.grant
</Button>
<ConfirmModal
confirmButtonText="remove"
confirmData={
Object {
"name": "deletable group",
}
}
header="groups.remove"
isDestructive={true}
onClose={[MockFunction]}
onConfirm={[MockFunction]}
>
<FormattedMessage
defaultMessage="groups.remove.confirmation"
id="remove.confirmation"
values={
Object {
"user": <strong>
deletable group
</strong>,
}
}
/>
</ConfirmModal>
</div>
`;

exports[`should render correctly: show remove modal for user 1`] = `
<div
className="quality-gate-permissions"
>
<h3
className="spacer-bottom"
>
quality_gates.permissions
</h3>
<p
className="spacer-bottom"
>
quality_gates.permissions.help
</p>
<div>
<DeferredSpinner
loading={false}
>
<ul>
<li
key="john.doe"
>
<PermissionItem
item={
Object {
"active": true,
"local": true,
@@ -93,6 +182,19 @@ exports[`should render correctly: show remove modal 1`] = `
"name": "John Doe",
}
}
onClickDelete={[MockFunction]}
/>
</li>
<li
key="group"
>
<PermissionItem
item={
Object {
"name": "group",
}
}
onClickDelete={[MockFunction]}
/>
</li>
</ul>
@@ -118,7 +220,7 @@ exports[`should render correctly: show remove modal 1`] = `
>
<FormattedMessage
defaultMessage="users.remove.confirmation"
id="users.remove.confirmation"
id="remove.confirmation"
values={
Object {
"user": <strong />,
@@ -129,17 +231,104 @@ exports[`should render correctly: show remove modal 1`] = `
</div>
`;

exports[`should render correctly: with no groups 1`] = `
<div
className="quality-gate-permissions"
>
<h3
className="spacer-bottom"
>
quality_gates.permissions
</h3>
<p
className="spacer-bottom"
>
quality_gates.permissions.help
</p>
<div>
<DeferredSpinner
loading={false}
>
<ul>
<li
key="john.doe"
>
<PermissionItem
item={
Object {
"active": true,
"local": true,
"login": "john.doe",
"name": "John Doe",
}
}
onClickDelete={[MockFunction]}
/>
</li>
</ul>
</DeferredSpinner>
</div>
<Button
className="big-spacer-top"
onClick={[MockFunction]}
>
quality_gates.permissions.grant
</Button>
</div>
`;

exports[`should render correctly: with no users 1`] = `
<div
className="quality-gate-permissions"
>
<header
className="display-flex-center spacer-bottom"
<h3
className="spacer-bottom"
>
<h3>
quality_gates.permissions
</h3>
</header>
quality_gates.permissions
</h3>
<p
className="spacer-bottom"
>
quality_gates.permissions.help
</p>
<div>
<DeferredSpinner
loading={false}
>
<ul>
<li
key="group"
>
<PermissionItem
item={
Object {
"name": "group",
}
}
onClickDelete={[MockFunction]}
/>
</li>
</ul>
</DeferredSpinner>
</div>
<Button
className="big-spacer-top"
onClick={[MockFunction]}
>
quality_gates.permissions.grant
</Button>
</div>
`;

exports[`should render correctly: with no users or groups 1`] = `
<div
className="quality-gate-permissions"
>
<h3
className="spacer-bottom"
>
quality_gates.permissions
</h3>
<p
className="spacer-bottom"
>
@@ -161,17 +350,15 @@ exports[`should render correctly: with no users 1`] = `
</div>
`;

exports[`should render correctly: with users 1`] = `
exports[`should render correctly: with users and groups 1`] = `
<div
className="quality-gate-permissions"
>
<header
className="display-flex-center spacer-bottom"
<h3
className="spacer-bottom"
>
<h3>
quality_gates.permissions
</h3>
</header>
quality_gates.permissions
</h3>
<p
className="spacer-bottom"
>
@@ -186,8 +373,7 @@ exports[`should render correctly: with users 1`] = `
key="john.doe"
>
<PermissionItem
onClickDelete={[MockFunction]}
user={
item={
Object {
"active": true,
"local": true,
@@ -195,6 +381,19 @@ exports[`should render correctly: with users 1`] = `
"name": "John Doe",
}
}
onClickDelete={[MockFunction]}
/>
</li>
<li
key="group"
>
<PermissionItem
item={
Object {
"name": "group",
}
}
onClickDelete={[MockFunction]}
/>
</li>
</ul>

+ 1
- 1
server/sonar-web/src/main/js/components/icons/GroupIcon.tsx Datei anzeigen

@@ -23,7 +23,7 @@ import Icon, { IconProps } from './Icon';

export default function GroupIcon({ fill, size = 36, ...iconProps }: IconProps) {
return (
<Icon viewBox="0 0 36 36" {...iconProps}>
<Icon viewBox="0 0 36 36" size={size} {...iconProps}>
<g transform="matrix(0.0625,0,0,0.0625,3,4)">
<path
d="M148.25,224C121.25,224.833 99.167,235.5 82,256L48.5,256C34.833,256 23.333,252.625 14,245.875C4.667,239.125 0,229.25 0,216.25C0,157.417 10.333,128 31,128C32,128 35.625,129.75 41.875,133.25C48.125,136.75 56.25,140.292 66.25,143.875C76.25,147.458 86.167,149.25 96,149.25C107.167,149.25 118.25,147.333 129.25,143.5C128.417,149.667 128,155.167 128,160C128,183.167 134.75,204.5 148.25,224ZM416,383.25C416,403.25 409.917,419.042 397.75,430.625C385.583,442.208 369.417,448 349.25,448L130.75,448C110.583,448 94.417,442.208 82.25,430.625C70.083,419.042 64,403.25 64,383.25C64,374.417 64.292,365.792 64.875,357.375C65.458,348.958 66.625,339.875 68.375,330.125C70.125,320.375 72.333,311.333 75,303C77.667,294.667 81.25,286.542 85.75,278.625C90.25,270.708 95.417,263.958 101.25,258.375C107.083,252.792 114.208,248.333 122.625,245C131.042,241.667 140.333,240 150.5,240C152.167,240 155.75,241.792 161.25,245.375C166.75,248.958 172.833,252.958 179.5,257.375C186.167,261.792 195.083,265.792 206.25,269.375C217.417,272.958 228.667,274.75 240,274.75C251.333,274.75 262.583,272.958 273.75,269.375C284.917,265.792 293.833,261.792 300.5,257.375C307.167,252.958 313.25,248.958 318.75,245.375C324.25,241.792 327.833,240 329.5,240C339.667,240 348.958,241.667 357.375,245C365.792,248.333 372.917,252.792 378.75,258.375C384.583,263.958 389.75,270.708 394.25,278.625C398.75,286.542 402.333,294.667 405,303C407.667,311.333 409.875,320.375 411.625,330.125C413.375,339.875 414.542,348.958 415.125,357.375C415.708,365.792 416,374.417 416,383.25ZM160,64C160,81.667 153.75,96.75 141.25,109.25C128.75,121.75 113.667,128 96,128C78.333,128 63.25,121.75 50.75,109.25C38.25,96.75 32,81.667 32,64C32,46.333 38.25,31.25 50.75,18.75C63.25,6.25 78.333,0 96,0C113.667,0 128.75,6.25 141.25,18.75C153.75,31.25 160,46.333 160,64ZM336,160C336,186.5 326.625,209.125 307.875,227.875C289.125,246.625 266.5,256 240,256C213.5,256 190.875,246.625 172.125,227.875C153.375,209.125 144,186.5 144,160C144,133.5 153.375,110.875 172.125,92.125C190.875,73.375 213.5,64 240,64C266.5,64 289.125,73.375 307.875,92.125C326.625,110.875 336,133.5 336,160ZM480,216.25C480,229.25 475.333,239.125 466,245.875C456.667,252.625 445.167,256 431.5,256L398,256C380.833,235.5 358.75,224.833 331.75,224C345.25,204.5 352,183.167 352,160C352,155.167 351.583,149.667 350.75,143.5C361.75,147.333 372.833,149.25 384,149.25C393.833,149.25 403.75,147.458 413.75,143.875C423.75,140.292 431.875,136.75 438.125,133.25C444.375,129.75 448,128 449,128C469.667,128 480,157.417 480,216.25ZM448,64C448,81.667 441.75,96.75 429.25,109.25C416.75,121.75 401.667,128 384,128C366.333,128 351.25,121.75 338.75,109.25C326.25,96.75 320,81.667 320,64C320,46.333 326.25,31.25 338.75,18.75C351.25,6.25 366.333,0 384,0C401.667,0 416.75,6.25 429.25,18.75C441.75,31.25 448,46.333 448,64Z"

+ 16
- 3
server/sonar-web/src/main/js/types/quality-gates.ts Datei anzeigen

@@ -82,12 +82,25 @@ export interface QualityGateStatusConditionEnhanced extends QualityGateStatusCon
}

export interface SearchPermissionsParameters {
qualityGate: string;
gateName: string;
q?: string;
selected?: 'all' | 'selected' | 'deselected';
}

export interface AddDeleteUserPermissionsParameters {
qualityGate: string;
userLogin: string;
gateName: string;
login: string;
}

export interface AddDeleteGroupPermissionsParameters {
gateName: string;
groupName: string;
}

export interface Group {
name: string;
}

export function isUser(item: T.UserBase | Group): item is T.UserBase {
return item && (item as T.UserBase).login !== undefined;
}

+ 2
- 2
sonar-core/src/main/resources/org/sonar/l10n/core.properties Datei anzeigen

@@ -1735,8 +1735,8 @@ quality_gates.built_in.description.2=It will automatically be updated with the l
quality_gates.status=Quality Gate status
quality_gates.help=A Quality Gate is a set of measure-based, Boolean conditions. It helps you know immediately whether your projects are production-ready. Ideally, all projects will use the same quality gate. Each project's Quality Gate status is displayed prominently on its homepage.
quality_gates.permissions=Permissions
quality_gates.permissions.help=Users with the global "Manage Quality Gates" permission can manage this Quality Gate.
quality_gates.permissions.grant=Grant permissions to a user
quality_gates.permissions.help=Users with the global "Administer Quality Gates" permission and those listed below can manage this Quality Gate.
quality_gates.permissions.grant=Grant permissions to more users
quality_gates.permissions.search=Search users by login or name:

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

Laden…
Abbrechen
Speichern