mockPaging,
mockUser,
} from '../../helpers/testMocks';
-import { Group, IdentityProvider, Paging, SysInfoCluster, UserSelected } from '../../types/types';
+import {
+ Group,
+ IdentityProvider,
+ Paging,
+ SysInfoCluster,
+ UserGroupMember,
+} from '../../types/types';
import { getSystemInfo } from '../system';
import { getIdentityProviders } from '../users';
import {
return this.reply({});
};
- handlegetUsersInGroup = (): Promise<Paging & { users: UserSelected[] }> => {
+ handlegetUsersInGroup = (data: {
+ name?: string;
+ p?: number;
+ ps?: number;
+ q?: string;
+ selected?: string;
+ }): Promise<Paging & { users: UserGroupMember[] }> => {
return this.reply({
...this.paging,
users: [
{
...mockUser({ name: 'alice' }),
selected: true,
- } as UserSelected,
+ } as UserGroupMember,
{
...mockUser({ name: 'bob' }),
selected: false,
- } as UserSelected,
- ],
+ } as UserGroupMember,
+ ].filter((u) => u.name.includes(data.q ?? '')),
});
};
*/
import { throwGlobalError } from '../helpers/error';
import { getJSON, post, postJSON } from '../helpers/request';
-import { Group, Paging, UserSelected } from '../types/types';
+import { Group, Paging, UserGroupMember } from '../types/types';
export function searchUsersGroups(data: {
f?: string;
ps?: number;
q?: string;
selected?: string;
-}): Promise<Paging & { users: UserSelected[] }> {
+}): Promise<
+ Paging & {
+ users: UserGroupMember[];
+ }
+> {
return getJSON('/api/user_groups/users', data).catch(throwGlobalError);
}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import * as React from 'react';
-import { ButtonIcon } from '../../../components/controls/buttons';
-import BulletListIcon from '../../../components/icons/BulletListIcon';
-import { translateWithParameters } from '../../../helpers/l10n';
-import { Group } from '../../../types/types';
-import EditMembersModal from './EditMembersModal';
-
-interface Props {
- group: Group;
- onEdit: () => void;
-}
-
-interface State {
- modal: boolean;
-}
-
-export default class EditMembers extends React.PureComponent<Props, State> {
- container?: HTMLElement | null;
- mounted = false;
- state: State = { modal: false };
-
- componentDidMount() {
- this.mounted = true;
- }
-
- componentWillUnmount() {
- this.mounted = false;
- }
-
- handleMembersClick = () => {
- this.setState({ modal: true });
- };
-
- handleModalClose = () => {
- if (this.mounted) {
- this.setState({ modal: false });
- this.props.onEdit();
- }
- };
-
- render() {
- return (
- <>
- <ButtonIcon
- aria-label={translateWithParameters('groups.users.edit', this.props.group.name)}
- className="button-small little-spacer-left little-padded"
- onClick={this.handleMembersClick}
- title={translateWithParameters('groups.users.edit', this.props.group.name)}
- >
- <BulletListIcon />
- </ButtonIcon>
- {this.state.modal && (
- <EditMembersModal group={this.props.group} onClose={this.handleModalClose} />
- )}
- </>
- );
- }
-}
render() {
const modalHeader = translate('users.update');
return (
- <Modal contentLabel={modalHeader} onRequestClose={this.props.onClose}>
+ <Modal
+ className="group-menbers-modal"
+ contentLabel={modalHeader}
+ onRequestClose={this.props.onClose}
+ >
<header className="modal-head">
<h2>{modalHeader}</h2>
</header>
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import classNames from 'classnames';
import * as React from 'react';
import { useState } from 'react';
import ActionsDropdown, {
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { Group } from '../../../types/types';
import DeleteGroupForm from './DeleteGroupForm';
-import EditMembers from './EditMembers';
import GroupForm from './GroupForm';
+import Members from './Members';
export interface ListItemProps {
group: Group;
</td>
<td className="group-members display-flex-justify-end" headers="list-group-member">
- <span
- className={classNames({ 'big-padded-right spacer-right': group.default && !isManaged() })}
- >
- {membersCount}
- </span>
- {!group.default && !isManaged() && <EditMembers group={group} onEdit={props.reload} />}
+ <span>{membersCount}</span>
+ <Members group={group} onEdit={props.reload} isManaged={isManaged()} />
</td>
<td className="width-40" headers="list-group-description">
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import * as React from 'react';
+import { ButtonIcon } from '../../../components/controls/buttons';
+import BulletListIcon from '../../../components/icons/BulletListIcon';
+import { translateWithParameters } from '../../../helpers/l10n';
+import { Group } from '../../../types/types';
+import EditMembersModal from './EditMembersModal';
+import ViewMembersModal from './ViewMembersModal';
+
+interface Props {
+ isManaged: boolean;
+ group: Group;
+ onEdit: () => void;
+}
+
+interface State {
+ modal: boolean;
+}
+
+export default class Members extends React.PureComponent<Props, State> {
+ mounted = false;
+ state: State = { modal: false };
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ handleMembersClick = () => {
+ this.setState({ modal: true });
+ };
+
+ handleModalClose = () => {
+ const { isManaged, group } = this.props;
+ if (this.mounted) {
+ this.setState({ modal: false });
+ if (!isManaged && !group.default) {
+ this.props.onEdit();
+ }
+ }
+ };
+
+ render() {
+ const { isManaged, group } = this.props;
+ return (
+ <>
+ <ButtonIcon
+ aria-label={translateWithParameters(
+ isManaged || group.default ? 'groups.users.view' : 'groups.users.edit',
+ group.name
+ )}
+ className="button-small little-spacer-left little-padded"
+ onClick={this.handleMembersClick}
+ title={translateWithParameters('groups.users.edit', group.name)}
+ >
+ <BulletListIcon />
+ </ButtonIcon>
+ {this.state.modal &&
+ (isManaged || group.default ? (
+ <ViewMembersModal isManaged={isManaged} group={group} onClose={this.handleModalClose} />
+ ) : (
+ <EditMembersModal group={group} onClose={this.handleModalClose} />
+ ))}
+ </>
+ );
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { DeferredSpinner } from 'design-system/lib';
+import * as React from 'react';
+import { getUsersInGroup } from '../../../api/user_groups';
+import { ResetButtonLink } from '../../../components/controls/buttons';
+import ListFooter from '../../../components/controls/ListFooter';
+import Modal from '../../../components/controls/Modal';
+import SearchBox from '../../../components/controls/SearchBox';
+import { SelectListFilter } from '../../../components/controls/SelectList';
+import { translate } from '../../../helpers/l10n';
+import { Group, UserGroupMember } from '../../../types/types';
+
+interface Props {
+ isManaged: boolean;
+ group: Group;
+ onClose: () => void;
+}
+
+export default function ViewMembersModal(props: Props) {
+ const { isManaged, group } = props;
+
+ const [loading, setLoading] = React.useState(false);
+ const [page, setPage] = React.useState(1);
+ const [query, setQuery] = React.useState<string>();
+ const [total, setTotal] = React.useState<number>();
+ const [users, setUsers] = React.useState<UserGroupMember[]>([]);
+
+ React.useEffect(() => {
+ (async () => {
+ setLoading(true);
+ const data = await getUsersInGroup({
+ name: group.name,
+ p: page,
+ q: query,
+ selected: SelectListFilter.Selected,
+ });
+ if (page > 1) {
+ setUsers([...users, ...data.users]);
+ } else {
+ setUsers(data.users);
+ }
+ setTotal(data.total);
+ setLoading(false);
+ })();
+ }, [query, page]);
+
+ const modalHeader = translate('users.list');
+ return (
+ <Modal
+ className="group-menbers-modal"
+ contentLabel={modalHeader}
+ onRequestClose={props.onClose}
+ >
+ <header className="modal-head">
+ <h2>{modalHeader}</h2>
+ </header>
+
+ <div className="modal-body modal-container">
+ <SearchBox
+ className="view-search-box"
+ loading={loading}
+ onChange={(q) => {
+ setQuery(q);
+ setPage(1);
+ }}
+ placeholder={translate('search_verb')}
+ value={query}
+ />
+ <div className="select-list-list-container spacer-top">
+ <DeferredSpinner loading={loading}>
+ <ul className="menu">
+ {users.map((user) => (
+ <li key={user.login} className="display-flex-center">
+ <span className="little-spacer-left width-100">
+ <span className="select-list-list-item display-flex-center display-flex-space-between">
+ <span className="spacer-right">
+ {user.name}
+ <br />
+ <span className="note">{user.login}</span>
+ </span>
+ {!user.managed && isManaged && (
+ <span className="badge">{translate('local')}</span>
+ )}
+ </span>
+ </span>
+ </li>
+ ))}
+ </ul>
+ </DeferredSpinner>
+ </div>
+ {total !== undefined && (
+ <ListFooter count={users.length} loadMore={() => setPage((p) => p + 1)} total={total} />
+ )}
+ </div>
+
+ <footer className="modal-foot">
+ <ResetButtonLink onClick={props.onClose}>{translate('done')}</ResetButtonLink>
+ </footer>
+ </Modal>
+ );
+}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import { shallow } from 'enzyme';
-import * as React from 'react';
-import { mockGroup } from '../../../../helpers/testMocks';
-import { click } from '../../../../helpers/testUtils';
-import EditMembers from '../EditMembers';
-
-it('should edit members', () => {
- const group = mockGroup({ name: 'Foo', membersCount: 5 });
- const onEdit = jest.fn();
-
- const wrapper = shallow(<EditMembers group={group} onEdit={onEdit} />);
- expect(wrapper).toMatchSnapshot();
-
- click(wrapper.find('ButtonIcon'));
- expect(wrapper).toMatchSnapshot();
-
- wrapper.find('EditMembersModal').prop<Function>('onClose')();
- expect(onEdit).toHaveBeenCalled();
- expect(wrapper).toMatchSnapshot();
-});
editGroupDialogButton: byRole('button', { name: 'groups.create_group' }),
createGroupDialog: byRole('dialog', { name: 'groups.create_group' }),
+ membersViewDialog: byRole('dialog', { name: 'users.list' }),
membersDialog: byRole('dialog', { name: 'users.update' }),
managedGroupRow: byRole('row', { name: 'managed-group 1' }),
managedGroupEditMembersButton: byRole('button', { name: 'groups.users.edit.managed-group' }),
+ managedGroupViewMembersButton: byRole('button', { name: 'groups.users.view.managed-group' }),
+
+ memberAliceUser: byText('alice'),
+ memberBobUser: byText('bob'),
+ memberSearchInput: byRole('searchbox', { name: 'search_verb' }),
+
managedEditButton: byRole('button', { name: 'groups.edit.managed-group' }),
localGroupRow: byRole('row', { name: 'local-group 1' }),
expect(ui.managedEditButton.query()).not.toBeInTheDocument();
expect(ui.managedGroupEditMembersButton.query()).not.toBeInTheDocument();
+
+ await userEvent.click(ui.managedGroupViewMembersButton.get());
+ expect(await ui.membersViewDialog.find()).toBeInTheDocument();
+
+ expect(ui.memberAliceUser.get()).toBeInTheDocument();
+ expect(ui.memberBobUser.get()).toBeInTheDocument();
+
+ await userEvent.type(ui.memberSearchInput.get(), 'b');
+
+ expect(await ui.memberBobUser.find()).toBeInTheDocument();
+ expect(ui.memberAliceUser.query()).not.toBeInTheDocument();
});
it('should render list of all groups', async () => {
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import { shallow } from 'enzyme';
-import * as React from 'react';
-import { mockGroup } from '../../../../helpers/testMocks';
-import List from '../List';
-
-it('should render', () => {
- expect(shallowRender()).toMatchSnapshot();
-});
-
-function shallowRender() {
- const groups = [
- mockGroup({ name: 'sonar-users', description: '', membersCount: 55, default: true }),
- mockGroup({ name: 'foo', description: 'foobar', membersCount: 0, default: false }),
- mockGroup({ name: 'bar', description: 'barbar', membersCount: 1, default: false }),
- ];
- return shallow(<List groups={groups} manageProvider={undefined} reload={jest.fn()} />);
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import { shallow } from 'enzyme';
-import * as React from 'react';
-import { mockGroup } from '../../../../helpers/testMocks';
-import ListItem, { ListItemProps } from '../ListItem';
-
-it('should render correctly', () => {
- expect(shallowRender()).toMatchSnapshot();
- expect(shallowRender({ group: mockGroup({ default: true }) })).toMatchSnapshot('default group');
-});
-
-function shallowRender(overrides: Partial<ListItemProps> = {}) {
- return shallow(
- <ListItem group={mockGroup()} reload={jest.fn()} manageProvider={undefined} {...overrides} />
- );
-}
+++ /dev/null
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should edit members 1`] = `
-<Fragment>
- <ButtonIcon
- aria-label="groups.users.edit.Foo"
- className="button-small little-spacer-left little-padded"
- onClick={[Function]}
- title="groups.users.edit.Foo"
- >
- <BulletListIcon />
- </ButtonIcon>
-</Fragment>
-`;
-
-exports[`should edit members 2`] = `
-<Fragment>
- <ButtonIcon
- aria-label="groups.users.edit.Foo"
- className="button-small little-spacer-left little-padded"
- onClick={[Function]}
- title="groups.users.edit.Foo"
- >
- <BulletListIcon />
- </ButtonIcon>
- <EditMembersModal
- group={
- {
- "managed": false,
- "membersCount": 5,
- "name": "Foo",
- }
- }
- onClose={[Function]}
- />
-</Fragment>
-`;
-
-exports[`should edit members 3`] = `
-<Fragment>
- <ButtonIcon
- aria-label="groups.users.edit.Foo"
- className="button-small little-spacer-left little-padded"
- onClick={[Function]}
- title="groups.users.edit.Foo"
- >
- <BulletListIcon />
- </ButtonIcon>
-</Fragment>
-`;
exports[`should render modal properly 1`] = `
<Modal
+ className="group-menbers-modal"
contentLabel="users.update"
onRequestClose={[MockFunction]}
>
+++ /dev/null
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render 1`] = `
-<div
- className="boxed-group boxed-group-inner"
->
- <table
- className="data zebra zebra-hover"
- id="groups-list"
- >
- <thead>
- <tr>
- <th
- id="list-group-name"
- >
- user_groups.page.group_header
- </th>
- <th
- className="nowrap width-10"
- id="list-group-member"
- >
- members
- </th>
- <th
- className="nowrap"
- id="list-group-description"
- >
- description
- </th>
- <th
- id="list-group-actions"
- >
- actions
- </th>
- </tr>
- </thead>
- <tbody>
- <ListItem
- group={
- {
- "default": false,
- "description": "barbar",
- "managed": false,
- "membersCount": 1,
- "name": "bar",
- }
- }
- key="bar"
- reload={[MockFunction]}
- />
- <ListItem
- group={
- {
- "default": false,
- "description": "foobar",
- "managed": false,
- "membersCount": 0,
- "name": "foo",
- }
- }
- key="foo"
- reload={[MockFunction]}
- />
- <ListItem
- group={
- {
- "default": true,
- "description": "",
- "managed": false,
- "membersCount": 55,
- "name": "sonar-users",
- }
- }
- key="sonar-users"
- reload={[MockFunction]}
- />
- </tbody>
- </table>
-</div>
-`;
+++ /dev/null
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly 1`] = `
-<tr
- data-id="Foo"
->
- <td
- className="width-20"
- headers="list-group-name"
- >
- <strong>
- Foo
- </strong>
- </td>
- <td
- className="group-members display-flex-justify-end"
- headers="list-group-member"
- >
- <span>
- 1
- </span>
- <EditMembers
- group={
- {
- "managed": false,
- "membersCount": 1,
- "name": "Foo",
- }
- }
- onEdit={[MockFunction]}
- />
- </td>
- <td
- className="width-40"
- headers="list-group-description"
- >
- <span
- className="js-group-description"
- />
- </td>
- <td
- className="thin nowrap text-right"
- headers="list-group-actions"
- >
- <ActionsDropdown
- label="groups.edit.Foo"
- >
- <ActionsDropdownItem
- className="js-group-update"
- onClick={[Function]}
- >
- update_details
- </ActionsDropdownItem>
- <ActionsDropdownDivider />
- <ActionsDropdownItem
- className="js-group-delete"
- destructive={true}
- onClick={[Function]}
- >
- delete
- </ActionsDropdownItem>
- </ActionsDropdown>
- </td>
-</tr>
-`;
-
-exports[`should render correctly: default group 1`] = `
-<tr
- data-id="Foo"
->
- <td
- className="width-20"
- headers="list-group-name"
- >
- <strong>
- Foo
- </strong>
- <span
- className="little-spacer-left"
- >
- (
- default
- )
- </span>
- </td>
- <td
- className="group-members display-flex-justify-end"
- headers="list-group-member"
- >
- <span
- className="big-padded-right spacer-right"
- >
- 1
- </span>
- </td>
- <td
- className="width-40"
- headers="list-group-description"
- >
- <span
- className="js-group-description"
- />
- </td>
- <td
- className="thin nowrap text-right"
- headers="list-group-actions"
- />
-</tr>
-`;
#groups-page .group-members {
padding-right: 50%;
}
+
+.group-menbers-modal .modal-container > :last-child {
+ margin-bottom: 0;
+}
+
+.group-menbers-modal .select-list-list-container {
+ height: 350px;
+}
+
+.group-menbers-modal .modal-body {
+ padding: 12px 32px;
+}
+
+.group-members-modal .view-search-box.search-box {
+ max-width: 100%;
+}
}
.button-icon:hover,
-.button-icon:focus {
+.button-icon:focus-visible {
background-color: currentColor;
}
.button-icon:not(.disabled):hover svg,
-.button-icon:not(.disabled):focus svg {
+.button-icon:not(.disabled):focus-visible svg {
color: var(--white);
}
selected: boolean;
}
+export interface UserGroupMember {
+ selected: boolean;
+ login: string;
+ name: string;
+ managed: boolean;
+}
+
export namespace WebApi {
export interface Action {
key: string;
users.create_user=Create User
users.create_user.scm_account_new=New SCM account
users.create_user.scm_account_x=SCM account '{0}'
+users.update=Update users
+users.list=Users list
users.update_user=Update User
users.cannot_update_delegated_user=You cannot update the name and email of this user, as it is controlled by an external identity provider.
users.minimum_x_characters=Minimum {0} characters
groups.create_group=Create Group
groups.update_group=Update Group
groups.users.edit=Change {0} members
+groups.users.view=View {0} members
groups.edit=Edit {0}