});
expect(ui.dialog.get()).toBeInTheDocument();
await selectEvent.select(ui.selectUserOrGroup.get(), 'Buzz');
- await user.click(ui.addButton.get());
+ await act(async () => {
+ await user.click(ui.addButton.get());
+ });
expect(ui.permissionSection.byText('Buzz').get()).toBeInTheDocument();
// Remove User
.get(),
);
expect(ui.dialog.get()).toBeInTheDocument();
- await user.click(ui.removeButton.get());
+ await act(async () => {
+ await user.click(ui.removeButton.get());
+ });
expect(ui.permissionSection.byText('buzz').query()).not.toBeInTheDocument();
});
});
expect(ui.dialog.get()).toBeInTheDocument();
await selectEvent.select(ui.selectUserOrGroup.get(), 'ACDC');
- await user.click(ui.addButton.get());
+ await act(async () => {
+ await user.click(ui.addButton.get());
+ });
expect(ui.permissionSection.byText('ACDC').get()).toBeInTheDocument();
// Remove group
.get(),
);
expect(ui.dialog.get()).toBeInTheDocument();
- await user.click(ui.removeButton.get());
+ await act(async () => {
+ await user.click(ui.removeButton.get());
+ });
expect(ui.permissionSection.byText('ACDC').query()).not.toBeInTheDocument();
});
isOverflowVisible
onClose={this.props.onClose}
body={
- <div className="sw-mt-1">
+ <div className="sw-mt-1" id="profile-projects">
<SelectList
allowBulkSelection
elements={this.state.projects.map((project) => project.key)}
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+import { ButtonPrimary, FormField, Modal } from 'design-system';
import * as React from 'react';
-import { addGroup, addUser } from '../../../api/quality-profiles';
-import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
-import Modal from '../../../components/controls/Modal';
import { translate } from '../../../helpers/l10n';
+import { useAddGroupMutation, useAddUserMutation } from '../../../queries/quality-profiles';
import { UserSelected } from '../../../types/types';
import { Group } from './ProfilePermissions';
import ProfilePermissionsFormSelect from './ProfilePermissionsFormSelect';
profile: { language: string; name: string };
}
-interface State {
- selected?: UserSelected | Group;
- submitting: boolean;
-}
-
-export default class ProfilePermissionsForm extends React.PureComponent<Props, State> {
- mounted = false;
- state: State = { submitting: false };
+export default function ProfilePermissionForm(props: Readonly<Props>) {
+ const { profile } = props;
+ const [selected, setSelected] = React.useState<UserSelected | Group>();
- componentDidMount() {
- this.mounted = true;
- }
+ const { mutate: addUser, isLoading: addingUser } = useAddUserMutation(() =>
+ props.onUserAdd(selected as UserSelected),
+ );
+ const { mutate: addGroup, isLoading: addingGroup } = useAddGroupMutation(() =>
+ props.onGroupAdd(selected as Group),
+ );
- componentWillUnmount() {
- this.mounted = false;
- }
-
- stopSubmitting = () => {
- if (this.mounted) {
- this.setState({ submitting: false });
- }
- };
-
- handleUserAdd = (user: UserSelected) => {
- const {
- profile: { language, name },
- } = this.props;
- addUser({
- language,
- login: user.login,
- qualityProfile: name,
- }).then(() => this.props.onUserAdd(user), this.stopSubmitting);
- };
+ const loading = addingUser || addingGroup;
- handleGroupAdd = (group: Group) => {
- const {
- profile: { language, name },
- } = this.props;
- addGroup({
- group: group.name,
- language,
- qualityProfile: name,
- }).then(() => this.props.onGroupAdd(group), this.stopSubmitting);
- };
+ const header = translate('quality_profiles.grant_permissions_to_user_or_group');
+ const submitDisabled = !selected || loading;
- handleFormSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
+ const handleFormSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();
- const { selected } = this.state;
if (selected) {
- this.setState({ submitting: true });
- if ((selected as UserSelected).login !== undefined) {
- this.handleUserAdd(selected as UserSelected);
+ if (isSelectedUser(selected)) {
+ addUser({
+ language: profile.language,
+ login: selected.login,
+ qualityProfile: profile.name,
+ });
} else {
- this.handleGroupAdd(selected as Group);
+ addGroup({
+ language: profile.language,
+ group: selected.name,
+ qualityProfile: profile.name,
+ });
}
}
};
- handleValueChange = (selected: UserSelected | Group) => {
- this.setState({ selected });
- };
-
- render() {
- const { profile } = this.props;
- const header = translate('quality_profiles.grant_permissions_to_user_or_group');
- const submitDisabled = !this.state.selected || this.state.submitting;
- return (
- <Modal contentLabel={header} onRequestClose={this.props.onClose}>
- <header className="modal-head">
- <h2>{header}</h2>
- </header>
- <form onSubmit={this.handleFormSubmit}>
- <div className="modal-body">
- <div className="modal-field">
- <label htmlFor="change-profile-permission-input">
- {translate('quality_profiles.search_description')}
- </label>
- <ProfilePermissionsFormSelect onChange={this.handleValueChange} profile={profile} />
- </div>
- </div>
- <footer className="modal-foot">
- {this.state.submitting && <i className="spinner spacer-right" />}
- <SubmitButton disabled={submitDisabled}>{translate('add_verb')}</SubmitButton>
- <ResetButtonLink onClick={this.props.onClose}>{translate('cancel')}</ResetButtonLink>
- </footer>
+ return (
+ <Modal
+ isOverflowVisible
+ headerTitle={header}
+ onClose={props.onClose}
+ loading={loading}
+ primaryButton={
+ <ButtonPrimary type="submit" form="grant_permissions_form" disabled={submitDisabled}>
+ {translate('add_verb')}
+ </ButtonPrimary>
+ }
+ secondaryButtonLabel={translate('cancel')}
+ body={
+ <form onSubmit={handleFormSubmit} id="grant_permissions_form">
+ <FormField label={translate('quality_profiles.search_description')}>
+ <ProfilePermissionsFormSelect
+ onChange={(option) => setSelected(option)}
+ selected={selected}
+ profile={profile}
+ />
+ </FormField>
</form>
- </Modal>
- );
- }
+ }
+ />
+ );
+}
+
+function isSelectedUser(selected: UserSelected | Group): selected is UserSelected {
+ return (selected as UserSelected).login !== undefined;
}
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { debounce, omit } from 'lodash';
+import {
+ Avatar,
+ GenericAvatar,
+ LabelValueSelectOption,
+ SearchSelectDropdown,
+ UserGroupIcon,
+} from 'design-system';
+import { omit } from 'lodash';
import * as React from 'react';
-import { ControlProps, OptionProps, SingleValueProps, components } from 'react-select';
+import { useIntl } from 'react-intl';
import {
SearchUsersGroupsParameters,
searchGroups,
searchUsers,
} from '../../../api/quality-profiles';
-import { SearchSelect } from '../../../components/controls/Select';
-import GroupIcon from '../../../components/icons/GroupIcon';
-import LegacyAvatar from '../../../components/ui/LegacyAvatar';
-import { translate } from '../../../helpers/l10n';
import { UserSelected } from '../../../types/types';
import { Group } from './ProfilePermissions';
type Option = UserSelected | Group;
-type OptionWithValue = Option & { value: string };
interface Props {
- onChange: (option: OptionWithValue) => void;
+ onChange: (option: Option) => void;
profile: { language: string; name: string };
+ selected?: Option;
}
-const DEBOUNCE_DELAY = 250;
+export default function ProfilePermissionsFormSelect(props: Readonly<Props>) {
+ const { profile, selected } = props;
+ const [defaultOptions, setDefaultOptions] = React.useState<LabelValueSelectOption<string>[]>([]);
+ const intl = useIntl();
-export default class ProfilePermissionsFormSelect extends React.PureComponent<Props> {
- constructor(props: Props) {
- super(props);
- this.handleSearch = debounce(this.handleSearch, DEBOUNCE_DELAY);
- }
+ const value = selected ? getOption(selected) : null;
+ const controlLabel = selected ? (
+ <>
+ {isUser(selected) ? (
+ <Avatar hash={selected.avatar} name={selected.name} size="xs" />
+ ) : (
+ <GenericAvatar Icon={UserGroupIcon} name={selected.name} size="xs" />
+ )}{' '}
+ {selected.name}
+ </>
+ ) : undefined;
- optionRenderer(props: OptionProps<OptionWithValue, false>) {
- const { data } = props;
- return <components.Option {...props}>{customOptions(data)}</components.Option>;
- }
+ const loadOptions = React.useCallback(
+ async (q = '') => {
+ const parameters: SearchUsersGroupsParameters = {
+ language: profile.language,
+ q,
+ qualityProfile: profile.name,
+ selected: 'deselected',
+ };
+ const [{ users }, { groups }] = await Promise.all([
+ searchUsers(parameters),
+ searchGroups(parameters),
+ ]);
- singleValueRenderer = (props: SingleValueProps<OptionWithValue, false>) => (
- <components.SingleValue {...props}>{customOptions(props.data)}</components.SingleValue>
+ return { users, groups };
+ },
+ [profile.language, profile.name],
);
- controlRenderer = (props: ControlProps<OptionWithValue, false>) => (
- <components.Control {...omit(props, ['children'])} className="abs-height-100">
- {props.children}
- </components.Control>
- );
+ const loadInitial = React.useCallback(async () => {
+ try {
+ const { users, groups } = await loadOptions();
+ setDefaultOptions([...users, ...groups].map(getOption));
+ } catch {
+ // noop
+ }
+ }, [loadOptions]);
- handleSearch = (q: string, resolve: (options: OptionWithValue[]) => void) => {
- const { profile } = this.props;
- const parameters: SearchUsersGroupsParameters = {
- language: profile.language,
- q,
- qualityProfile: profile.name,
- selected: 'deselected',
- };
- Promise.all([searchUsers(parameters), searchGroups(parameters)])
- .then(([usersResponse, groupsResponse]) => [...usersResponse.users, ...groupsResponse.groups])
- .then((options: Option[]) => options.map((opt) => ({ ...opt, value: getStringValue(opt) })))
- .then(resolve)
- .catch(() => resolve([]));
+ const handleSearch = (q: string, cb: (options: LabelValueSelectOption<string>[]) => void) => {
+ loadOptions(q)
+ .then(({ users, groups }) => [...users, ...groups].map(getOption))
+ // eslint-disable-next-line promise/no-callback-in-promise
+ .then(cb)
+ // eslint-disable-next-line promise/no-callback-in-promise
+ .catch(() => cb([]));
};
- render() {
- const noResultsText = translate('no_results');
+ const handleChange = (option: LabelValueSelectOption<string>) => {
+ props.onChange(omit(option, ['Icon', 'label', 'value']) as Option);
+ };
- return (
- <SearchSelect
- className="width-100"
- autoFocus
- isClearable={false}
- id="change-profile-permission"
- inputId="change-profile-permission-input"
- onChange={this.props.onChange}
- defaultOptions
- loadOptions={this.handleSearch}
- placeholder=""
- noOptionsMessage={() => noResultsText}
- large
- components={{
- Option: this.optionRenderer,
- SingleValue: this.singleValueRenderer,
- Control: this.controlRenderer,
- }}
- />
- );
- }
+ React.useEffect(() => {
+ loadInitial();
+ }, [loadInitial]);
+
+ return (
+ <SearchSelectDropdown
+ id="change-profile-permission"
+ inputId="change-profile-permission-input"
+ controlAriaLabel={intl.formatMessage({ id: 'quality_profiles.search_description' })}
+ size="full"
+ controlLabel={controlLabel}
+ onChange={handleChange}
+ defaultOptions={defaultOptions}
+ loadOptions={handleSearch}
+ value={value}
+ />
+ );
}
+const getOption = (option: Option): LabelValueSelectOption<string> => {
+ return {
+ ...option,
+ value: getStringValue(option),
+ label: option.name,
+ Icon: isUser(option) ? (
+ <Avatar hash={option.avatar} name={option.name} size="xs" />
+ ) : (
+ <GenericAvatar Icon={UserGroupIcon} name={option.name} size="xs" />
+ ),
+ };
+};
+
function isUser(option: Option): option is UserSelected {
return (option as UserSelected).login !== undefined;
}
function getStringValue(option: Option) {
return isUser(option) ? `user:${option.login}` : `group:${option.name}`;
}
-
-function customOptions(option: OptionWithValue) {
- return isUser(option) ? (
- <span className="display-flex-center">
- <LegacyAvatar hash={option.avatar} name={option.name} size={16} />
- <strong className="spacer-left">{option.name}</strong>
- <span className="note little-spacer-left">{option.login}</span>
- </span>
- ) : (
- <span className="display-flex-center">
- <GroupIcon size={16} />
- <strong className="spacer-left">{option.name}</strong>
- </span>
- );
-}
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { DestructiveIcon, GenericAvatar, TrashIcon, UserGroupIcon } from 'design-system';
+import {
+ DangerButtonPrimary,
+ DestructiveIcon,
+ GenericAvatar,
+ Modal,
+ TrashIcon,
+ UserGroupIcon,
+} from 'design-system';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { removeGroup } from '../../../api/quality-profiles';
-import SimpleModal, { ChildrenProps } from '../../../components/controls/SimpleModal';
-import { Button, ResetButtonLink } from '../../../components/controls/buttons';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { Group } from './ProfilePermissions';
profile: { language: string; name: string };
}
-interface State {
- deleteModal: boolean;
-}
-
-export default class ProfilePermissionsGroup extends React.PureComponent<Props, State> {
- mounted = false;
- state: State = { deleteModal: false };
-
- componentDidMount() {
- this.mounted = true;
- }
-
- componentWillUnmount() {
- this.mounted = false;
- }
-
- handleDeleteClick = () => {
- this.setState({ deleteModal: true });
- };
-
- handleDeleteModalClose = () => {
- if (this.mounted) {
- this.setState({ deleteModal: false });
- }
- };
-
- handleDelete = () => {
- const { group, profile } = this.props;
+export default function ProfilePermissionsGroup(props: Readonly<Props>) {
+ const { group, profile } = props;
+ const [deleteDialogOpened, setDeleteDialogOpened] = React.useState(false);
+ const handleDelete = () => {
return removeGroup({
group: group.name,
language: profile.language,
qualityProfile: profile.name,
}).then(() => {
- this.handleDeleteModalClose();
- this.props.onDelete(group);
+ setDeleteDialogOpened(false);
+ props.onDelete(group);
});
};
- renderDeleteModal = (props: ChildrenProps) => (
- <div>
- <header className="modal-head">
- <h2>{translate('quality_profiles.permissions.remove.group')}</h2>
- </header>
-
- <div className="modal-body">
- <FormattedMessage
- defaultMessage={translate('quality_profiles.permissions.remove.group.confirmation')}
- id="quality_profiles.permissions.remove.group.confirmation"
- values={{
- user: <strong>{this.props.group.name}</strong>,
- }}
+ return (
+ <div className="sw-flex sw-items-center sw-justify-between">
+ <div className="sw-flex sw-truncate">
+ <GenericAvatar
+ Icon={UserGroupIcon}
+ className="sw-mt-1/2 sw-mr-3 sw-grow-0 sw-shrink-0"
+ name={group.name}
+ size="xs"
/>
+ <strong className="sw-body-sm-highlight sw-truncate fs-mask">{group.name}</strong>
</div>
+ <DestructiveIcon
+ Icon={TrashIcon}
+ aria-label={translateWithParameters(
+ 'quality_profiles.permissions.remove.group_x',
+ group.name,
+ )}
+ onClick={() => setDeleteDialogOpened(true)}
+ />
- <footer className="modal-foot">
- {props.submitting && <i className="spinner spacer-right" />}
- <Button className="button-red" disabled={props.submitting} onClick={props.onSubmitClick}>
- {translate('remove')}
- </Button>
- <ResetButtonLink onClick={props.onCloseClick}>{translate('cancel')}</ResetButtonLink>
- </footer>
+ {deleteDialogOpened && (
+ <Modal
+ headerTitle={translate('quality_profiles.permissions.remove.group')}
+ onClose={() => setDeleteDialogOpened(false)}
+ body={
+ <FormattedMessage
+ defaultMessage={translate('quality_profiles.permissions.remove.group.confirmation')}
+ id="quality_profiles.permissions.remove.group.confirmation"
+ values={{
+ user: <strong>{group.name}</strong>,
+ }}
+ />
+ }
+ primaryButton={
+ <DangerButtonPrimary autoFocus onClick={handleDelete}>
+ {translate('remove')}
+ </DangerButtonPrimary>
+ }
+ secondaryButtonLabel={translate('cancel')}
+ />
+ )}
</div>
);
-
- render() {
- const { group } = this.props;
-
- return (
- <div className="sw-flex sw-items-center sw-justify-between">
- <div className="sw-flex sw-truncate">
- <GenericAvatar
- Icon={UserGroupIcon}
- className="sw-mt-1/2 sw-mr-3 sw-grow-0 sw-shrink-0"
- name={group.name}
- size="xs"
- />
- <strong className="sw-body-sm-highlight sw-truncate fs-mask">{group.name}</strong>
- </div>
- <DestructiveIcon
- Icon={TrashIcon}
- aria-label={translateWithParameters(
- 'quality_profiles.permissions.remove.group_x',
- group.name,
- )}
- onClick={this.handleDeleteClick}
- />
-
- {this.state.deleteModal && (
- <SimpleModal
- header={translate('quality_profiles.permissions.remove.group')}
- onClose={this.handleDeleteModalClose}
- onSubmit={this.handleDelete}
- >
- {this.renderDeleteModal}
- </SimpleModal>
- )}
- </div>
- );
- }
}
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { Avatar, DestructiveIcon, Note, TrashIcon } from 'design-system';
+import {
+ Avatar,
+ DangerButtonPrimary,
+ DestructiveIcon,
+ Modal,
+ Note,
+ TrashIcon,
+} from 'design-system';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { removeUser } from '../../../api/quality-profiles';
-import SimpleModal, { ChildrenProps } from '../../../components/controls/SimpleModal';
-import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { UserSelected } from '../../../types/types';
user: UserSelected;
}
-interface State {
- deleteModal: boolean;
-}
-
-export default class ProfilePermissionsUser extends React.PureComponent<Props, State> {
- mounted = false;
- state: State = { deleteModal: false };
-
- componentDidMount() {
- this.mounted = true;
- }
-
- componentWillUnmount() {
- this.mounted = false;
- }
-
- handleDeleteClick = () => {
- this.setState({ deleteModal: true });
- };
-
- handleDeleteModalClose = () => {
- if (this.mounted) {
- this.setState({ deleteModal: false });
- }
- };
-
- handleDelete = () => {
- const { profile, user } = this.props;
+export default function ProfilePermissionsGroup(props: Readonly<Props>) {
+ const { user, profile } = props;
+ const [deleteDialogOpened, setDeleteDialogOpened] = React.useState(false);
+ const handleDelete = () => {
return removeUser({
language: profile.language,
login: user.login,
qualityProfile: profile.name,
}).then(() => {
- this.handleDeleteModalClose();
- this.props.onDelete(user);
+ setDeleteDialogOpened(false);
+ props.onDelete(user);
});
};
- renderDeleteModal = (props: ChildrenProps) => (
- <div>
- <header className="modal-head">
- <h2>{translate('quality_profiles.permissions.remove.user')}</h2>
- </header>
-
- <div className="modal-body">
- <FormattedMessage
- defaultMessage={translate('quality_profiles.permissions.remove.user.confirmation')}
- id="quality_profiles.permissions.remove.user.confirmation"
- values={{
- user: <strong>{this.props.user.name}</strong>,
- }}
+ return (
+ <div className="sw-flex sw-items-center sw-justify-between">
+ <div className="sw-flex sw-truncate">
+ <Avatar
+ className="sw-mt-1/2 sw-mr-3 sw-grow-0 sw-shrink-0"
+ hash={user.avatar}
+ name={user.name}
+ size="xs"
/>
+ <div className="sw-truncate fs-mask">
+ <strong className="sw-body-sm-highlight">{user.name}</strong>
+ <Note className="sw-block">{user.login}</Note>
+ </div>
</div>
+ <DestructiveIcon
+ Icon={TrashIcon}
+ aria-label={translateWithParameters(
+ 'quality_profiles.permissions.remove.user_x',
+ user.name,
+ )}
+ onClick={() => setDeleteDialogOpened(true)}
+ />
- <footer className="modal-foot">
- {props.submitting && <i className="spinner spacer-right" />}
- <SubmitButton
- className="button-red"
- disabled={props.submitting}
- onClick={props.onSubmitClick}
- >
- {translate('remove')}
- </SubmitButton>
- <ResetButtonLink onClick={props.onCloseClick}>{translate('cancel')}</ResetButtonLink>
- </footer>
+ {deleteDialogOpened && (
+ <Modal
+ headerTitle={translate('quality_profiles.permissions.remove.user')}
+ onClose={() => setDeleteDialogOpened(false)}
+ body={
+ <FormattedMessage
+ defaultMessage={translate('quality_profiles.permissions.remove.user.confirmation')}
+ id="quality_profiles.permissions.remove.user.confirmation"
+ values={{
+ user: <strong>{user.name}</strong>,
+ }}
+ />
+ }
+ primaryButton={
+ <DangerButtonPrimary autoFocus onClick={handleDelete}>
+ {translate('remove')}
+ </DangerButtonPrimary>
+ }
+ secondaryButtonLabel={translate('cancel')}
+ />
+ )}
</div>
);
-
- render() {
- const { user } = this.props;
-
- return (
- <div className="sw-flex sw-items-center sw-justify-between">
- <div className="sw-flex sw-truncate">
- <Avatar
- className="sw-mt-1/2 sw-mr-3 sw-grow-0 sw-shrink-0"
- hash={user.avatar}
- name={user.name}
- size="xs"
- />
- <div className="sw-truncate fs-mask">
- <strong className="sw-body-sm-highlight">{user.name}</strong>
- <Note className="sw-block">{user.login}</Note>
- </div>
- </div>
- <DestructiveIcon
- Icon={TrashIcon}
- aria-label={translateWithParameters(
- 'quality_profiles.permissions.remove.user_x',
- user.name,
- )}
- onClick={this.handleDeleteClick}
- />
-
- {this.state.deleteModal && (
- <SimpleModal
- header={translate('quality_profiles.permissions.remove.user')}
- onClose={this.handleDeleteModalClose}
- onSubmit={this.handleDelete}
- >
- {this.renderDeleteModal}
- </SimpleModal>
- )}
- </div>
- );
- }
}
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { UseQueryResult, useQuery } from '@tanstack/react-query';
-import { Profile, getProfileInheritance } from '../api/quality-profiles';
+import { UseQueryResult, useMutation, useQuery } from '@tanstack/react-query';
+import {
+ AddRemoveGroupParameters,
+ AddRemoveUserParameters,
+ Profile,
+ addGroup,
+ addUser,
+ getProfileInheritance,
+} from '../api/quality-profiles';
import { ProfileInheritanceDetails } from '../types/types';
export function useProfileInheritanceQuery(
},
});
}
+
+export function useAddUserMutation(onSuccess: () => unknown) {
+ return useMutation({
+ mutationFn: (data: AddRemoveUserParameters) => addUser(data),
+ onSuccess,
+ });
+}
+
+export function useAddGroupMutation(onSuccess: () => unknown) {
+ return useMutation({
+ mutationFn: (data: AddRemoveGroupParameters) => addGroup(data),
+ onSuccess,
+ });
+}
quality_profiles.grant_permissions_to_more_users=Grant permissions to more users
quality_profiles.grant_permissions_to_user_or_group=Grant permissions to a user or a group
quality_profiles.additional_user_groups=Additional users / groups:
-quality_profiles.search_description=Search users by login or name, and groups by name:
+quality_profiles.search_description=Search users by login or name, and groups by name:
quality_profiles.permissions.remove.user=Remove permission from user
quality_profiles.permissions.remove.user_x=Remove permission from user {0}
quality_profiles.permissions.remove.user.confirmation=Are you sure you want to remove permission on this quality profile from user {user}?