aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--server/sonar-web/src/main/js/api/organizations.js2
-rw-r--r--server/sonar-web/src/main/js/api/users.js5
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/actions.js11
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/components/MembersList.js8
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembers.js23
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembersContainer.js15
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationMembers-test.js.snap11
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/components/forms/AddMemberForm.js110
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/AddMemberForm-test.js39
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/__snapshots__/AddMemberForm-test.js.snap66
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/UsersSearch.js2
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.css13
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.js102
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js74
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js48
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.js54
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchOption-test.js48
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchValue-test.js53
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.js.snap131
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap45
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap47
-rw-r--r--server/sonar-web/src/main/js/store/organizationsMembers/actions.js14
-rw-r--r--server/sonar-web/src/main/js/store/organizationsMembers/reducer.js12
-rw-r--r--server/sonar-web/src/main/js/store/users/reducer.js5
-rw-r--r--server/sonar-web/src/main/less/components/modals.less59
-rw-r--r--sonar-core/src/main/resources/org/sonar/l10n/core.properties5
26 files changed, 966 insertions, 36 deletions
diff --git a/server/sonar-web/src/main/js/api/organizations.js b/server/sonar-web/src/main/js/api/organizations.js
index 0723fb0c258..41f3c014b9c 100644
--- a/server/sonar-web/src/main/js/api/organizations.js
+++ b/server/sonar-web/src/main/js/api/organizations.js
@@ -56,7 +56,7 @@ export const updateOrganization = (key: string, changes: {}) =>
export const deleteOrganization = (key: string) => post('/api/organizations/delete', { key });
export const searchMembers = (
- data: { organizations?: string, p?: number, ps?: number, q?: string, selected?: string }
+ data: { organization?: string, p?: number, ps?: number, q?: string, selected?: string }
) => getJSON('/api/organizations/search_members', data);
export const addMember = (data: { login: string, organization: string }) =>
diff --git a/server/sonar-web/src/main/js/api/users.js b/server/sonar-web/src/main/js/api/users.js
index a661bcace4e..2014d391454 100644
--- a/server/sonar-web/src/main/js/api/users.js
+++ b/server/sonar-web/src/main/js/api/users.js
@@ -40,8 +40,11 @@ export function getIdentityProviders() {
return getJSON(url);
}
-export function searchUsers(query) {
+export function searchUsers(query, pageSize) {
const url = '/api/users/search';
const data = { q: query };
+ if (pageSize != null) {
+ data.ps = pageSize;
+ }
return getJSON(url, data);
}
diff --git a/server/sonar-web/src/main/js/apps/organizations/actions.js b/server/sonar-web/src/main/js/apps/organizations/actions.js
index 916a9d7e17a..f21d6bb775f 100644
--- a/server/sonar-web/src/main/js/apps/organizations/actions.js
+++ b/server/sonar-web/src/main/js/apps/organizations/actions.js
@@ -26,6 +26,7 @@ import { getOrganizationMembersState } from '../../store/rootReducer';
import { addGlobalSuccessMessage } from '../../store/globalMessages/duck';
import { translate, translateWithParameters } from '../../helpers/l10n';
import type { Organization } from '../../store/organizations/duck';
+import type { Member } from '../../store/organizationsMembers/actions';
const PAGE_SIZE = 50;
@@ -97,7 +98,7 @@ const fetchMembers = (
) => {
dispatch(membersActions.updateState(key, { loading: true }));
const data: Object = {
- organizations: key,
+ organization: key,
ps: PAGE_SIZE
};
if (page != null) {
@@ -131,3 +132,11 @@ export const fetchMoreOrganizationMembers = (key: string, query?: string) =>
query,
getOrganizationMembersState(getState(), key).pageIndex + 1
);
+
+export const addOrganizationMember = (key: string, member: Member) => (dispatch: Function) => {
+ dispatch(membersActions.addMember(key, member));
+ return api.addMember({ login: member.login, organization: key }).catch((error: Object) => {
+ onFail(dispatch)(error);
+ dispatch(membersActions.removeMember(key, member));
+ });
+};
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/MembersList.js b/server/sonar-web/src/main/js/apps/organizations/components/MembersList.js
index 156f8262e27..a5744dfd253 100644
--- a/server/sonar-web/src/main/js/apps/organizations/components/MembersList.js
+++ b/server/sonar-web/src/main/js/apps/organizations/components/MembersList.js
@@ -25,7 +25,7 @@ import type { Organization } from '../../../store/organizations/duck';
type Props = {
members: Array<Member>,
- organization?: Organization
+ organization?: Organization,
};
export default class MembersList extends React.PureComponent {
@@ -36,7 +36,11 @@ export default class MembersList extends React.PureComponent {
<table className="data zebra">
<tbody>
{this.props.members.map(member => (
- <MembersListItem key={member.login} member={member} organization={this.props.organization} />
+ <MembersListItem
+ key={member.login}
+ member={member}
+ organization={this.props.organization}
+ />
))}
</tbody>
</table>
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembers.js b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembers.js
index b3e252c0669..cf87c5cc85e 100644
--- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembers.js
+++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembers.js
@@ -21,6 +21,7 @@
import React from 'react';
import PageHeader from './PageHeader';
import MembersList from './MembersList';
+import AddMemberForm from './forms/AddMemberForm';
import UsersSearch from '../../users/components/UsersSearch';
import ListFooter from '../../../components/controls/ListFooter';
import type { Organization } from '../../../store/organizations/duck';
@@ -28,10 +29,12 @@ import type { Member } from '../../../store/organizationsMembers/actions';
type Props = {
members: Array<Member>,
+ memberLogins: Array<string>,
status: { loading?: boolean, total?: number, pageIndex?: number, query?: string },
organization: Organization,
fetchOrganizationMembers: (organizationKey: string, query?: string) => void,
fetchMoreOrganizationMembers: (organizationKey: string, query?: string) => void,
+ addOrganizationMember: (organizationKey: string, login: Member) => void
};
export default class OrganizationMembers extends React.PureComponent {
@@ -52,17 +55,27 @@ export default class OrganizationMembers extends React.PureComponent {
this.props.fetchMoreOrganizationMembers(this.props.organization.key, this.props.status.query);
};
- addMember() {
- // TODO Not yet implemented
- }
+ addMember = (member: Member) => {
+ this.props.addOrganizationMember(this.props.organization.key, member);
+ };
render() {
const { organization, status, members } = this.props;
return (
<div className="page page-limited">
- <PageHeader loading={status.loading} total={status.total} />
+ <PageHeader loading={status.loading} total={status.total}>
+ {organization.canAdmin &&
+ <div className="page-actions">
+ <div className="button-group">
+ <AddMemberForm memberLogins={this.props.memberLogins} addMember={this.addMember} />
+ </div>
+ </div>}
+ </PageHeader>
<UsersSearch onSearch={this.handleSearchMembers} />
- <MembersList members={members} organization={organization} />
+ <MembersList
+ members={members}
+ organization={organization}
+ />
{status.total != null &&
<ListFooter
count={members.length}
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembersContainer.js b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembersContainer.js
index 27dd084b063..abea8ce8552 100644
--- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembersContainer.js
+++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembersContainer.js
@@ -25,18 +25,25 @@ import {
getUsersByLogins,
getOrganizationMembersState
} from '../../../store/rootReducer';
-import { fetchOrganizationMembers, fetchMoreOrganizationMembers } from '../actions';
+import {
+ fetchOrganizationMembers,
+ fetchMoreOrganizationMembers,
+ addOrganizationMember
+} from '../actions';
const mapStateToProps = (state, ownProps) => {
const { organizationKey } = ownProps.params;
const memberLogins = getOrganizationMembersLogins(state, organizationKey);
return {
+ memberLogins,
members: getUsersByLogins(state, memberLogins),
organization: getOrganizationByKey(state, organizationKey),
status: getOrganizationMembersState(state, organizationKey)
};
};
-export default connect(mapStateToProps, { fetchOrganizationMembers, fetchMoreOrganizationMembers })(
- OrganizationMembers
-);
+export default connect(mapStateToProps, {
+ fetchOrganizationMembers,
+ fetchMoreOrganizationMembers,
+ addOrganizationMember
+})(OrganizationMembers);
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationMembers-test.js.snap b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationMembers-test.js.snap
index 3af82c13471..87ab6b15a84 100644
--- a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationMembers-test.js.snap
+++ b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationMembers-test.js.snap
@@ -41,7 +41,16 @@ exports[`test should render actions for admin 1`] = `
className="page page-limited">
<PageHeader
loading={true}
- total={2} />
+ total={2}>
+ <div
+ className="page-actions">
+ <div
+ className="button-group">
+ <AddMemberForm
+ addMember={[Function]} />
+ </div>
+ </div>
+ </PageHeader>
<UsersSearch
onSearch={[Function]} />
<MembersList
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/forms/AddMemberForm.js b/server/sonar-web/src/main/js/apps/organizations/components/forms/AddMemberForm.js
new file mode 100644
index 00000000000..3ed2825cde5
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/organizations/components/forms/AddMemberForm.js
@@ -0,0 +1,110 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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.
+ */
+// @flow
+import React from 'react';
+import Modal from 'react-modal';
+import UsersSelectSearch from '../../../users/components/UsersSelectSearch';
+import { searchUsers } from '../../../../api/users';
+import { translate } from '../../../../helpers/l10n';
+import type { Member } from '../../../../store/organizationsMembers/actions';
+
+type Props = {
+ memberLogins: Array<string>,
+ addMember: (member: Member) => void
+};
+
+type State = {
+ open: boolean,
+ selectedMember?: Member
+};
+
+export default class AddMemberForm extends React.PureComponent {
+ props: Props;
+
+ state: State = {
+ open: false
+ };
+
+ openForm = () => {
+ this.setState({ open: true });
+ };
+
+ closeForm = () => {
+ this.setState({ open: false, selectedMember: undefined });
+ };
+
+ handleSubmit = (e: Object) => {
+ e.preventDefault();
+ if (this.state.selectedMember) {
+ this.props.addMember(this.state.selectedMember);
+ this.closeForm();
+ }
+ };
+
+ selectedMemberChange = (member: Member) => {
+ this.setState({ selectedMember: member });
+ };
+
+ renderModal() {
+ return (
+ <Modal
+ isOpen={true}
+ contentLabel="modal form"
+ className="modal"
+ overlayClassName="modal-overlay"
+ onRequestClose={this.closeForm}
+ >
+ <header className="modal-head">
+ <h2>{translate('users.add')}</h2>
+ </header>
+ <form onSubmit={this.handleSubmit}>
+ <div className="modal-body">
+ <div className="modal-large-field">
+ <label>{translate('users.search_description')}</label>
+ <UsersSelectSearch
+ selectedUser={this.state.selectedMember}
+ excludedUsers={this.props.memberLogins}
+ searchUsers={searchUsers}
+ handleValueChange={this.selectedMemberChange}
+ />
+ </div>
+ </div>
+ <footer className="modal-foot">
+ <div>
+ <button type="submit">{translate('organization.members.add_to_members')}</button>
+ <button type="reset" className="button-link" onClick={this.closeForm}>
+ {translate('cancel')}
+ </button>
+ </div>
+ </footer>
+ </form>
+ </Modal>
+ );
+ }
+
+ render() {
+ return (
+ <button onClick={this.openForm}>
+ {translate('organization.members.add')}
+ {this.state.open && this.renderModal()}
+ </button>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/AddMemberForm-test.js b/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/AddMemberForm-test.js
new file mode 100644
index 00000000000..7b8246f7b6a
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/AddMemberForm-test.js
@@ -0,0 +1,39 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 React from 'react';
+import { shallow, mount } from 'enzyme';
+import { click } from '../../../../../helpers/testUtils';
+import AddMemberForm from '../AddMemberForm';
+
+const memberLogins = ['admin'];
+const addMember = jest.fn();
+
+it('should render and open the modal', () => {
+ const wrapper = shallow(<AddMemberForm memberLogins={memberLogins} addMember={addMember} />);
+ expect(wrapper).toMatchSnapshot();
+ wrapper.setState({ open: true });
+ expect(wrapper).toMatchSnapshot();
+});
+
+it('should correctly handle user interactions', () => {
+ const wrapper = mount(<AddMemberForm memberLogins={memberLogins} addMember={addMember} />);
+ click(wrapper.find('button'));
+ expect(wrapper.state('open')).toBeTruthy();
+});
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/__snapshots__/AddMemberForm-test.js.snap b/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/__snapshots__/AddMemberForm-test.js.snap
new file mode 100644
index 00000000000..8cae5645817
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/__snapshots__/AddMemberForm-test.js.snap
@@ -0,0 +1,66 @@
+exports[`test should render and open the modal 1`] = `
+<button
+ onClick={[Function]}>
+ organization.members.add
+</button>
+`;
+
+exports[`test should render and open the modal 2`] = `
+<button
+ onClick={[Function]}>
+ organization.members.add
+ <Modal
+ ariaHideApp={true}
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="modal form"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}>
+ <header
+ className="modal-head">
+ <h2>
+ users.add
+ </h2>
+ </header>
+ <form
+ onSubmit={[Function]}>
+ <div
+ className="modal-body">
+ <div
+ className="modal-large-field">
+ <label>
+ users.search_description
+ </label>
+ <UsersSelectSearch
+ excludedUsers={
+ Array [
+ "admin",
+ ]
+ }
+ handleValueChange={[Function]}
+ searchUsers={[Function]} />
+ </div>
+ </div>
+ <footer
+ className="modal-foot">
+ <div>
+ <button
+ type="submit">
+ organization.members.add_to_members
+ </button>
+ <button
+ className="button-link"
+ onClick={[Function]}
+ type="reset">
+ cancel
+ </button>
+ </div>
+ </footer>
+ </form>
+ </Modal>
+</button>
+`;
diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSearch.js b/server/sonar-web/src/main/js/apps/users/components/UsersSearch.js
index 5c17f90f8cf..1b8290cd35d 100644
--- a/server/sonar-web/src/main/js/apps/users/components/UsersSearch.js
+++ b/server/sonar-web/src/main/js/apps/users/components/UsersSearch.js
@@ -56,7 +56,7 @@ export default class UsersSearch extends React.PureComponent {
render() {
const { query } = this.state;
const inputClassName = classNames('search-box-input', {
- touched: query != null && query !== '' && query.length < 2
+ touched: query != null && query.length === 1
});
return (
<div className="panel panel-vertical bordered-bottom spacer-bottom">
diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.css b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.css
new file mode 100644
index 00000000000..c8780833809
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.css
@@ -0,0 +1,13 @@
+.Select-big .Select-control {
+ padding-top: 4px;
+ padding-bottom: 4px;
+}
+
+.Select-big .Select-placeholder {
+ margin-top: 4px;
+ margin-bottom: 4px;
+}
+
+.Select-big .Select-value-label {
+ margin-top: 5px;
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.js b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.js
new file mode 100644
index 00000000000..5026720f2b2
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.js
@@ -0,0 +1,102 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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.
+ */
+//@flow
+import React from 'react';
+import Select from 'react-select';
+import { debounce } from 'lodash';
+import UsersSelectSearchOption from './UsersSelectSearchOption';
+import UsersSelectSearchValue from './UsersSelectSearchValue';
+import './UsersSelectSearch.css';
+
+export type Option = {
+ login: string,
+ name: string,
+ email?: string,
+ avatar?: string,
+ groupCount?: number
+};
+
+type Props = {
+ selectedUser?: Option,
+ excludedUsers: Array<string>,
+ searchUsers: (string, number) => Promise<*>,
+ handleValueChange: (Option) => void
+};
+
+type State = {
+ searchResult: Array<Option>,
+ isLoading: boolean,
+ search: string
+};
+
+const LIST_SIZE = 10;
+
+export default class UsersSelectSearch extends React.PureComponent {
+ props: Props;
+ state: State;
+
+ constructor(props: Props) {
+ super(props);
+ this.handleSearch = debounce(this.handleSearch);
+ this.state = { searchResult: [], isLoading: false, search: '' };
+ }
+
+ componentDidMount() {
+ this.handleSearch(this.state.search);
+ }
+
+ componentWillReceiveProps(nextProps: Props) {
+ if (this.props.excludedUsers !== nextProps.excludedUsers) {
+ this.handleSearch(this.state.search);
+ }
+ }
+
+ filterSearchResult = ({ users }: { users: Array<Option> }) =>
+ users.filter(user => !this.props.excludedUsers.includes(user.login)).slice(0, LIST_SIZE);
+
+ handleSearch = (search: string) => {
+ this.setState({ isLoading: true, search });
+ this.props.searchUsers(search, Math.min(this.props.excludedUsers.length + LIST_SIZE, 500))
+ .then(this.filterSearchResult)
+ .then(searchResult => {
+ this.setState({ isLoading: false, searchResult });
+ });
+ };
+
+ render() {
+ return (
+ <Select
+ className="Select-big"
+ options={this.state.searchResult}
+ isLoading={this.state.isLoading}
+ optionComponent={UsersSelectSearchOption}
+ valueComponent={UsersSelectSearchValue}
+ onChange={this.props.handleValueChange}
+ onInputChange={this.handleSearch}
+ value={this.props.selectedUser}
+ placeholder=""
+ labelKey="name"
+ valueKey="login"
+ clearable={false}
+ searchable={true}
+ />
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js
new file mode 100644
index 00000000000..1861cb83d0c
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js
@@ -0,0 +1,74 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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.
+ */
+//@flow
+import React from 'react';
+import Avatar from '../../../components/ui/Avatar';
+import type { Option } from './UsersSelectSearch';
+
+type Props = {
+ option: Option,
+ children?: Element | Text,
+ className?: string,
+ isFocused?: boolean,
+ onFocus: (Option, MouseEvent) => void,
+ onSelect: (Option, MouseEvent) => void
+};
+
+const AVATAR_SIZE: number = 20;
+
+export default class UsersSelectSearchOption extends React.PureComponent {
+ props: Props;
+
+ handleMouseDown = (event: MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ this.props.onSelect(this.props.option, event);
+ };
+
+ handleMouseEnter = (event: MouseEvent) => {
+ this.props.onFocus(this.props.option, event);
+ };
+
+ handleMouseMove = (event: MouseEvent) => {
+ if (this.props.isFocused) {
+ return;
+ }
+ this.props.onFocus(this.props.option, event);
+ };
+
+ render() {
+ const user = this.props.option;
+ return (
+ <div
+ className={this.props.className}
+ onMouseDown={this.handleMouseDown}
+ onMouseEnter={this.handleMouseEnter}
+ onMouseMove={this.handleMouseMove}
+ title={user.name}
+ >
+ <div className="little-spacer-bottom little-spacer-top">
+ <Avatar hash={user.avatar} email={user.email} size={AVATAR_SIZE} />
+ <strong className="spacer-left">{user.login}</strong>
+ <span className="note little-spacer-left">{this.props.children}</span>
+ </div>
+ </div>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js
new file mode 100644
index 00000000000..b0939f127a9
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js
@@ -0,0 +1,48 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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.
+ */
+//@flow
+import React from 'react';
+import Avatar from '../../../components/ui/Avatar';
+import type { Option } from './UsersSelectSearch';
+
+type Props = {
+ value: Option,
+ children?: Element | Text
+};
+
+const AVATAR_SIZE: number = 20;
+
+export default class UsersSelectSearchValue extends React.PureComponent {
+ props: Props;
+
+ render() {
+ const user = this.props.value;
+ return (
+ <div className="Select-value" title={user ? user.name : ''}>
+ {user && user.login &&
+ <div className="Select-value-label">
+ <Avatar hash={user.avatar} email={user.email} size={AVATAR_SIZE} />
+ <strong className="spacer-left">{user.login}</strong>
+ <span className="note little-spacer-left">{this.props.children}</span>
+ </div>}
+ </div>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.js b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.js
new file mode 100644
index 00000000000..cfc0481145c
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.js
@@ -0,0 +1,54 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 React from 'react';
+import { shallow } from 'enzyme';
+import UsersSelectSearch from '../UsersSelectSearch';
+
+const selectedUser = {
+ login: 'admin',
+ name: 'Administrator',
+ avatar: '7daf6c79d4802916d83f6266e24850af'
+};
+const users = [
+ { login: 'admin', name: 'Administrator', email: 'admin@admin.ch' },
+ { login: 'test', name: 'Tester', email: 'tester@testing.ch' },
+ { login: 'foo', name: 'Foo Bar', email: 'foo@bar.ch' }
+];
+const excludedUsers = ['admin'];
+const onSearch = jest.fn(() => {
+ return Promise.resolve(users);
+});
+const onChange = jest.fn();
+
+it('should render correctly', () => {
+ const wrapper = shallow(
+ <UsersSelectSearch
+ selectedUser={selectedUser}
+ excludedUsers={excludedUsers}
+ isLoading={false}
+ handleValueChange={onChange}
+ searchUsers={onSearch}
+ />
+ );
+ expect(wrapper).toMatchSnapshot();
+ const searchResult = wrapper.instance().filterSearchResult({ users });
+ expect(searchResult).toMatchSnapshot();
+ expect(wrapper.setState({ searchResult })).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchOption-test.js b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchOption-test.js
new file mode 100644
index 00000000000..3023f73918c
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchOption-test.js
@@ -0,0 +1,48 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 React from 'react';
+import { shallow } from 'enzyme';
+import UsersSelectSearchOption from '../UsersSelectSearchOption';
+
+const user = {
+ login: 'admin',
+ name: 'Administrator',
+ avatar: '7daf6c79d4802916d83f6266e24850af'
+};
+
+const user2 = {
+ login: 'admin',
+ name: 'Administrator',
+ email: 'admin@admin.ch'
+};
+
+it('should render correctly without all parameters', () => {
+ const wrapper = shallow(
+ <UsersSelectSearchOption option={user}>{user.name}</UsersSelectSearchOption>
+ );
+ expect(wrapper).toMatchSnapshot();
+});
+
+it('should render correctly with email instead of hash', () => {
+ const wrapper = shallow(
+ <UsersSelectSearchOption option={user2}>{user.name}</UsersSelectSearchOption>
+ );
+ expect(wrapper).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchValue-test.js b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchValue-test.js
new file mode 100644
index 00000000000..357365a0826
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchValue-test.js
@@ -0,0 +1,53 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 React from 'react';
+import { shallow } from 'enzyme';
+import UsersSelectSearchValue from '../UsersSelectSearchValue';
+
+const user = {
+ login: 'admin',
+ name: 'Administrator',
+ avatar: '7daf6c79d4802916d83f6266e24850af'
+};
+
+const user2 = {
+ login: 'admin',
+ name: 'Administrator',
+ email: 'admin@admin.ch'
+};
+
+it('should render correctly with a user', () => {
+ const wrapper = shallow(
+ <UsersSelectSearchValue value={user}>{user.name}</UsersSelectSearchValue>
+ );
+ expect(wrapper).toMatchSnapshot();
+});
+
+it('should render correctly with email instead of hash', () => {
+ const wrapper = shallow(
+ <UsersSelectSearchValue value={user2}>{user2.name}</UsersSelectSearchValue>
+ );
+ expect(wrapper).toMatchSnapshot();
+});
+
+it('should render correctly without value', () => {
+ const wrapper = shallow(<UsersSelectSearchValue />);
+ expect(wrapper).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.js.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.js.snap
new file mode 100644
index 00000000000..e4f6d80595e
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.js.snap
@@ -0,0 +1,131 @@
+exports[`test should render correctly 1`] = `
+<Select
+ addLabelText="Add \"{label}\"?"
+ arrowRenderer={[Function]}
+ autosize={true}
+ backspaceRemoves={true}
+ backspaceToRemoveMessage="Press backspace to remove {label}"
+ className="Select-big"
+ clearAllText="Clear all"
+ clearValueText="Clear value"
+ clearable={false}
+ delimiter=","
+ disabled={false}
+ escapeClearsValue={true}
+ filterOptions={[Function]}
+ ignoreAccents={true}
+ ignoreCase={true}
+ inputProps={Object {}}
+ isLoading={false}
+ joinValues={false}
+ labelKey="name"
+ matchPos="any"
+ matchProp="any"
+ menuBuffer={0}
+ menuRenderer={[Function]}
+ multi={false}
+ noResultsText="No results found"
+ onBlurResetsInput={true}
+ onChange={[Function]}
+ onCloseResetsInput={true}
+ onInputChange={[Function]}
+ openAfterFocus={false}
+ optionComponent={[Function]}
+ options={Array []}
+ pageSize={5}
+ placeholder=""
+ required={false}
+ scrollMenuIntoView={true}
+ searchable={true}
+ simpleValue={false}
+ tabSelectsValue={true}
+ value={
+ Object {
+ "avatar": "7daf6c79d4802916d83f6266e24850af",
+ "login": "admin",
+ "name": "Administrator",
+ }
+ }
+ valueComponent={[Function]}
+ valueKey="login" />
+`;
+
+exports[`test should render correctly 2`] = `
+Array [
+ Object {
+ "email": "tester@testing.ch",
+ "login": "test",
+ "name": "Tester",
+ },
+ Object {
+ "email": "foo@bar.ch",
+ "login": "foo",
+ "name": "Foo Bar",
+ },
+]
+`;
+
+exports[`test should render correctly 3`] = `
+<Select
+ addLabelText="Add \"{label}\"?"
+ arrowRenderer={[Function]}
+ autosize={true}
+ backspaceRemoves={true}
+ backspaceToRemoveMessage="Press backspace to remove {label}"
+ className="Select-big"
+ clearAllText="Clear all"
+ clearValueText="Clear value"
+ clearable={false}
+ delimiter=","
+ disabled={false}
+ escapeClearsValue={true}
+ filterOptions={[Function]}
+ ignoreAccents={true}
+ ignoreCase={true}
+ inputProps={Object {}}
+ isLoading={false}
+ joinValues={false}
+ labelKey="name"
+ matchPos="any"
+ matchProp="any"
+ menuBuffer={0}
+ menuRenderer={[Function]}
+ multi={false}
+ noResultsText="No results found"
+ onBlurResetsInput={true}
+ onChange={[Function]}
+ onCloseResetsInput={true}
+ onInputChange={[Function]}
+ openAfterFocus={false}
+ optionComponent={[Function]}
+ options={
+ Array [
+ Object {
+ "email": "tester@testing.ch",
+ "login": "test",
+ "name": "Tester",
+ },
+ Object {
+ "email": "foo@bar.ch",
+ "login": "foo",
+ "name": "Foo Bar",
+ },
+ ]
+ }
+ pageSize={5}
+ placeholder=""
+ required={false}
+ scrollMenuIntoView={true}
+ searchable={true}
+ simpleValue={false}
+ tabSelectsValue={true}
+ value={
+ Object {
+ "avatar": "7daf6c79d4802916d83f6266e24850af",
+ "login": "admin",
+ "name": "Administrator",
+ }
+ }
+ valueComponent={[Function]}
+ valueKey="login" />
+`;
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap
new file mode 100644
index 00000000000..92c64c9030f
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap
@@ -0,0 +1,45 @@
+exports[`test should render correctly with email instead of hash 1`] = `
+<div
+ onMouseDown={[Function]}
+ onMouseEnter={[Function]}
+ onMouseMove={[Function]}
+ title="Administrator">
+ <div
+ className="little-spacer-bottom little-spacer-top">
+ <Connect(Avatar)
+ email="admin@admin.ch"
+ size={20} />
+ <strong
+ className="spacer-left">
+ admin
+ </strong>
+ <span
+ className="note little-spacer-left">
+ Administrator
+ </span>
+ </div>
+</div>
+`;
+
+exports[`test should render correctly without all parameters 1`] = `
+<div
+ onMouseDown={[Function]}
+ onMouseEnter={[Function]}
+ onMouseMove={[Function]}
+ title="Administrator">
+ <div
+ className="little-spacer-bottom little-spacer-top">
+ <Connect(Avatar)
+ hash="7daf6c79d4802916d83f6266e24850af"
+ size={20} />
+ <strong
+ className="spacer-left">
+ admin
+ </strong>
+ <span
+ className="note little-spacer-left">
+ Administrator
+ </span>
+ </div>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap
new file mode 100644
index 00000000000..2e5a2ab9cd6
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap
@@ -0,0 +1,47 @@
+exports[`test should render correctly with a user 1`] = `
+<div
+ className="Select-value"
+ title="Administrator">
+ <div
+ className="Select-value-label">
+ <Connect(Avatar)
+ hash="7daf6c79d4802916d83f6266e24850af"
+ size={20} />
+ <strong
+ className="spacer-left">
+ admin
+ </strong>
+ <span
+ className="note little-spacer-left">
+ Administrator
+ </span>
+ </div>
+</div>
+`;
+
+exports[`test should render correctly with email instead of hash 1`] = `
+<div
+ className="Select-value"
+ title="Administrator">
+ <div
+ className="Select-value-label">
+ <Connect(Avatar)
+ email="admin@admin.ch"
+ size={20} />
+ <strong
+ className="spacer-left">
+ admin
+ </strong>
+ <span
+ className="note little-spacer-left">
+ Administrator
+ </span>
+ </div>
+</div>
+`;
+
+exports[`test should render correctly without value 1`] = `
+<div
+ className="Select-value"
+ title="" />
+`;
diff --git a/server/sonar-web/src/main/js/store/organizationsMembers/actions.js b/server/sonar-web/src/main/js/store/organizationsMembers/actions.js
index 44de107c3f8..08425dafc76 100644
--- a/server/sonar-web/src/main/js/store/organizationsMembers/actions.js
+++ b/server/sonar-web/src/main/js/store/organizationsMembers/actions.js
@@ -21,8 +21,9 @@
export type Member = {
login: string,
name: string,
- avatar: string,
- groupCount: number
+ avatar?: string,
+ email?: string,
+ groupCount?: number
};
type MembersState = {
@@ -35,7 +36,8 @@ type MembersState = {
export const actions = {
UPDATE_STATE: 'organizations/UPDATE_STATE',
RECEIVE_MEMBERS: 'organizations/RECEIVE_MEMBERS',
- RECEIVE_MORE_MEMBERS: 'organizations/RECEIVE_MORE_MEMBERS'
+ RECEIVE_MORE_MEMBERS: 'organizations/RECEIVE_MORE_MEMBERS',
+ ADD_MEMBER: 'organizations/ADD_MEMBER',
};
export const receiveMembers = (organizationKey: string, members: Array<Member>, stateChanges: MembersState) => ({
@@ -52,6 +54,12 @@ export const receiveMoreMembers = (organizationKey: string, members: Array<Membe
stateChanges
});
+export const addMember = (organizationKey: string, member: Member) => ({
+ type: actions.ADD_MEMBER,
+ organization: organizationKey,
+ member
+});
+
export const updateState = (
organizationKey: string,
stateChanges: MembersState
diff --git a/server/sonar-web/src/main/js/store/organizationsMembers/reducer.js b/server/sonar-web/src/main/js/store/organizationsMembers/reducer.js
index 1e7e5094945..3e635f21dbc 100644
--- a/server/sonar-web/src/main/js/store/organizationsMembers/reducer.js
+++ b/server/sonar-web/src/main/js/store/organizationsMembers/reducer.js
@@ -31,6 +31,7 @@ export const getOrganizationMembersState = (state, organization) =>
organization && state[organization] ? state[organization] : {};
const organizationMembers = (state = {}, action = {}) => {
+ const members = state.members || [];
switch (action.type) {
case actions.UPDATE_STATE:
return { ...state, ...action.stateChanges };
@@ -40,8 +41,16 @@ const organizationMembers = (state = {}, action = {}) => {
return {
...state,
...action.stateChanges,
- members: uniq((state.members || []).concat(action.members.map(member => member.login)))
+ members: uniq(members.concat(action.members.map(member => member.login)))
};
+ case actions.ADD_MEMBER: {
+ const withNew = [...members, action.member.login].sort();
+ return {
+ ...state,
+ total: withNew.length,
+ members: withNew
+ };
+ }
default:
return state;
}
@@ -53,6 +62,7 @@ const organizationsMembers = (state = {}, action = {}) => {
case actions.UPDATE_STATE:
case actions.RECEIVE_MEMBERS:
case actions.RECEIVE_MORE_MEMBERS:
+ case actions.ADD_MEMBER:
return {
...state,
[action.organization]: organizationMembers(organization, action)
diff --git a/server/sonar-web/src/main/js/store/users/reducer.js b/server/sonar-web/src/main/js/store/users/reducer.js
index a6206ffca58..5a943db98f7 100644
--- a/server/sonar-web/src/main/js/store/users/reducer.js
+++ b/server/sonar-web/src/main/js/store/users/reducer.js
@@ -29,6 +29,8 @@ const usersByLogin = (state = {}, action = {}) => {
case membersActions.RECEIVE_MEMBERS:
case membersActions.RECEIVE_MORE_MEMBERS:
return { ...state, ...keyBy(action.members, 'login') };
+ case membersActions.ADD_MEMBER:
+ return { ...state, [action.member.login]: action.member };
default:
return state;
}
@@ -41,6 +43,9 @@ const userLogins = (state = [], action = {}) => {
case membersActions.RECEIVE_MEMBERS:
case membersActions.RECEIVE_MORE_MEMBERS:
return uniq([...state, action.members.map(member => member.login)]);
+ case membersActions.ADD_MEMBER: {
+ return uniq([...state, action.member.login]).sort();
+ }
default:
return state;
}
diff --git a/server/sonar-web/src/main/less/components/modals.less b/server/sonar-web/src/main/less/components/modals.less
index 65a70f094e7..291d61c7fc8 100644
--- a/server/sonar-web/src/main/less/components/modals.less
+++ b/server/sonar-web/src/main/less/components/modals.less
@@ -116,6 +116,12 @@ ul.modal-head-metadata li {
padding: 5px 0 5px 130px;
}
+.modal-large-field {
+ clear: both;
+ display: block;
+ padding: 20px 40px;
+}
+
.modal-field label {
position: relative;
left: -140px;
@@ -142,31 +148,54 @@ ul.modal-head-metadata li {
}
}
+.modal-large-field label {
+ display: inline-block;
+ padding-bottom: 15px;
+ font-weight: bold;
+}
+
.readonly-field {
padding-top: 5px;
margin-left: -5px;
line-height: 1;
}
-.modal-field input,
-.modal-field select,
-.modal-field textarea {
- margin-right: 5px;
- margin-bottom: 10px;
+.modal-field, .modal-large-field {
+ input,
+ select,
+ textarea,
+ .Select {
+ margin-right: 5px;
+ margin-bottom: 10px;
+ }
+
+ input[type="radio"],
+ input[type="checkbox"] {
+ margin-top: 5px;
+ margin-bottom: 4px;
+ }
}
-.modal-field input[type="radio"],
-.modal-field input[type="checkbox"] {
- margin-top: 5px;
- margin-bottom: 4px;
+.modal-field {
+ input[type=text],
+ input[type=email],
+ input[type=password],
+ textarea,
+ select,
+ .Select {
+ width: 250px;
+ }
}
-.modal-field input[type=text],
-.modal-field input[type=email],
-.modal-field input[type=password],
-.modal-field textarea,
-.modal-field select {
- width: 250px;
+.modal-large-field {
+ input[type=text],
+ input[type=email],
+ input[type=password],
+ textarea,
+ select,
+ .Select {
+ width: 100%;
+ }
}
.modal-field-description {
diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties
index 0d4555c94ba..9bc2e6ac2aa 100644
--- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties
+++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties
@@ -1771,6 +1771,9 @@ user.password_cant_be_changed_on_external_auth=Password cannot be changed when e
#
#------------------------------------------------------------------------------
users.create=Create User
+users.add=Add user
+users.remove=Remove user
+users.search_description=Search by username, full name, or email address
#------------------------------------------------------------------------------
#
@@ -2817,6 +2820,6 @@ organization.updated=Organization details have been updated.
organization.url=Url
organization.url.description=Url of the homepage of the organization.
organization.members.page=Members
-organization.members.add=Add member
+organization.members.add=Add a member
organization.members.x_group(s)={0} group(s)
organization.members.member(s)=member(s)