diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2019-02-05 09:38:19 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2019-03-06 11:30:41 +0100 |
commit | 9bd42df0365a3c64161ac9283c62b4a9f422402d (patch) | |
tree | 0bf09e9422342aa91c949c62481841481aec48db /server/sonar-web/src/main/js/apps/organizationMembers | |
parent | 2847ce4e648a167335d675a3ba6e71b7b1b0f248 (diff) | |
download | sonarqube-9bd42df0365a3c64161ac9283c62b4a9f422402d.tar.gz sonarqube-9bd42df0365a3c64161ac9283c62b4a9f422402d.zip |
SONARCLOUD-379 Enable users sync on existing ALM bound organizations
* Display org sync advertisement block
* Add membersSync prop to organization type and update mock functions
* Extract RadioCard from CardPlan
* Allow to customize Modal through ConfirmButton
* Add user sync configuration modal
* Show help tooltip when user sync is activated
Diffstat (limited to 'server/sonar-web/src/main/js/apps/organizationMembers')
13 files changed, 1069 insertions, 191 deletions
diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/MembersListHeader.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/MembersListHeader.tsx index 938e5a47d05..ba7cabf81a0 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/MembersListHeader.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/MembersListHeader.tsx @@ -18,16 +18,25 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import HelpTooltip from '../../components/controls/HelpTooltip'; import SearchBox from '../../components/controls/SearchBox'; +import { getAlmMembersUrl, sanitizeAlmId } from '../../helpers/almIntegrations'; +import { translate, translateWithParameters } from '../../helpers/l10n'; import { formatMeasure } from '../../helpers/measures'; -import { translate } from '../../helpers/l10n'; -interface Props { +export interface Props { + currentUser: T.LoggedInUser; handleSearch: (query?: string) => void; + organization: T.Organization; total?: number; } -export default function MembersListHeader({ handleSearch, total }: Props) { +export default function MembersListHeader({ + currentUser, + handleSearch, + organization, + total +}: Props) { return ( <div className="panel panel-vertical bordered-bottom spacer-bottom"> <SearchBox @@ -38,6 +47,38 @@ export default function MembersListHeader({ handleSearch, total }: Props) { {total !== undefined && ( <span className="pull-right little-spacer-top"> <strong>{formatMeasure(total, 'INT')}</strong> {translate('organization.members.members')} + {organization.alm && + organization.alm.membersSync && ( + <HelpTooltip + className="spacer-left" + overlay={ + <div className="abs-width-300 markdown cut-margins"> + <p> + {translate( + 'organization.members.auto_sync_total_help', + sanitizeAlmId(organization.alm.key) || '' + )} + </p> + {currentUser.personalOrganization !== organization.key && ( + <> + <hr /> + <p> + <a + href={getAlmMembersUrl(organization.alm)} + rel="noopener noreferrer" + target="_blank"> + {translateWithParameters( + 'organization.members.see_all_members_on_x', + translate(sanitizeAlmId(organization.alm.key) || '') + )} + </a> + </p> + </> + )} + </div> + } + /> + )} </span> )} </div> diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/MembersPageHeader.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/MembersPageHeader.tsx index ec540fed313..82c68eba3d6 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/MembersPageHeader.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/MembersPageHeader.tsx @@ -18,35 +18,110 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import { connect } from 'react-redux'; import { FormattedMessage } from 'react-intl'; import { Link } from 'react-router'; -import { translate } from '../../helpers/l10n'; +import AddMemberForm from './AddMemberForm'; +import SyncMemberForm from './SyncMemberForm'; import DeferredSpinner from '../../components/common/DeferredSpinner'; +import DocTooltip from '../../components/docs/DocTooltip'; +import NewInfoBox from '../../components/ui/NewInfoBox'; +import { sanitizeAlmId } from '../../helpers/almIntegrations'; +import { translate, translateWithParameters } from '../../helpers/l10n'; +import { getCurrentUserSetting, Store } from '../../store/rootReducer'; +import { setCurrentUserSetting } from '../../store/users'; interface Props { - children?: React.ReactNode; + dismissSyncNotifOrg: string[]; + handleAddMember: (member: T.OrganizationMember) => void; loading: boolean; + members?: T.OrganizationMember[]; + organization: T.Organization; + setCurrentUserSetting: (setting: T.CurrentUserSetting) => void; } -export default function MembersPageHeader(props: Props) { - return ( - <header className="page-header"> - <h1 className="page-title">{translate('organization.members.page')}</h1> - <DeferredSpinner loading={props.loading} /> - {props.children} - <p className="page-description"> - <FormattedMessage - defaultMessage={translate('organization.members.page.description')} - id="organization.members.page.description" - values={{ - link: ( - <Link to="/documentation/organizations/manage-team/"> - {translate('organization.members.manage_a_team')} - </Link> - ) - }} - /> - </p> - </header> - ); +export class MembersPageHeader extends React.PureComponent<Props> { + handleDismissSyncNotif = () => { + const { dismissSyncNotifOrg, organization } = this.props; + this.props.setCurrentUserSetting({ + key: 'organizations.members.dismissSyncNotif', + value: [...dismissSyncNotifOrg, organization.key].join(',') + }); + }; + + render() { + const { dismissSyncNotifOrg, members, organization } = this.props; + const memberLogins = members ? members.map(member => member.login) : []; + const isAdmin = organization.actions && organization.actions.admin; + const almKey = organization.alm && sanitizeAlmId(organization.alm.key); + const hasMemberSync = organization.alm && organization.alm.membersSync; + const showSyncNotif = + isAdmin && + organization.alm && + !hasMemberSync && + !dismissSyncNotifOrg.some(orgKey => orgKey === organization.key); + + return ( + <header className="page-header"> + <h1 className="page-title">{translate('organization.members.page')}</h1> + <DeferredSpinner loading={this.props.loading} /> + {isAdmin && ( + <div className="page-actions text-right"> + {almKey && !showSyncNotif && <SyncMemberForm organization={organization} />} + {!hasMemberSync && ( + <div className="display-inline-block spacer-left spacer-bottom"> + <AddMemberForm + addMember={this.props.handleAddMember} + memberLogins={memberLogins} + organization={organization} + /> + <DocTooltip + className="spacer-left" + doc={import(/* webpackMode: "eager" */ 'Docs/tooltips/organizations/add-organization-member.md')} + /> + </div> + )} + {almKey && + showSyncNotif && ( + <NewInfoBox + description={translate('organization.members.auto_sync_members_from_org', almKey)} + onClose={this.handleDismissSyncNotif} + title={translateWithParameters( + 'organization.members.auto_sync_with_x', + translate(almKey) + )}> + <SyncMemberForm organization={organization} /> + </NewInfoBox> + )} + </div> + )} + <div className="page-description"> + <FormattedMessage + defaultMessage={translate('organization.members.page.description')} + id="organization.members.page.description" + values={{ + link: ( + <Link to="/documentation/organizations/manage-team/"> + {translate('organization.members.manage_a_team')} + </Link> + ) + }} + /> + </div> + </header> + ); + } } + +const mapStateToProps = (state: Store) => ({ + dismissSyncNotifOrg: ( + getCurrentUserSetting(state, 'organizations.members.dismissSyncNotif') || '' + ).split(',') +}); + +const mapDispatchToProps = { setCurrentUserSetting }; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(MembersPageHeader); diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembers.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembers.tsx index 215415f6216..46e1162372b 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembers.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembers.tsx @@ -22,15 +22,14 @@ import Helmet from 'react-helmet'; import MembersPageHeader from './MembersPageHeader'; import MembersListHeader from './MembersListHeader'; import MembersList from './MembersList'; -import AddMemberForm from './AddMemberForm'; import Suggestions from '../../app/components/embed-docs-modal/Suggestions'; import ListFooter from '../../components/controls/ListFooter'; -import DocTooltip from '../../components/docs/DocTooltip'; import { translate } from '../../helpers/l10n'; import { searchMembers, addMember, removeMember } from '../../api/organizations'; import { searchUsersGroups, addUserToGroup, removeUserFromGroup } from '../../api/user_groups'; interface Props { + currentUser: T.LoggedInUser; organization: T.Organization; } @@ -187,31 +186,25 @@ export default class OrganizationMembers extends React.PureComponent<Props, Stat render() { const { organization } = this.props; const { groups, loading, members, paging } = this.state; - const memberLogins = members ? members.map(member => member.login) : []; return ( <div className="page page-limited"> <Helmet title={translate('organization.members.page')} /> <Suggestions suggestions="organization_members" /> - <MembersPageHeader loading={loading}> - {organization.actions && - organization.actions.admin && ( - <div className="page-actions"> - <AddMemberForm - addMember={this.handleAddMember} - memberLogins={memberLogins} - organization={organization} - /> - <DocTooltip - className="spacer-left" - doc={import(/* webpackMode: "eager" */ 'Docs/tooltips/organizations/add-organization-member.md')} - /> - </div> - )} - </MembersPageHeader> + <MembersPageHeader + handleAddMember={this.handleAddMember} + loading={loading} + members={members} + organization={organization} + /> {members !== undefined && paging !== undefined && ( <> - <MembersListHeader handleSearch={this.handleSearchMembers} total={paging.total} /> + <MembersListHeader + currentUser={this.props.currentUser} + handleSearch={this.handleSearchMembers} + organization={organization} + total={paging.total} + /> <MembersList members={members} organization={organization} diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembersContainer.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembersContainer.tsx index 0390d301ea3..05c709f3920 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembersContainer.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembersContainer.tsx @@ -20,6 +20,7 @@ import { connect } from 'react-redux'; import OrganizationMembers from './OrganizationMembers'; import { getOrganizationByKey, Store } from '../../store/rootReducer'; +import { withCurrentUser } from '../../components/hoc/withCurrentUser'; interface OwnProps { params: { organizationKey: string }; @@ -33,4 +34,4 @@ const mapStateToProps = (state: Store, ownProps: OwnProps): StateProps => { return { organization: getOrganizationByKey(state, ownProps.params.organizationKey) }; }; -export default connect(mapStateToProps)(OrganizationMembers); +export default withCurrentUser(connect(mapStateToProps)(OrganizationMembers)); diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/SyncMemberForm.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/SyncMemberForm.tsx new file mode 100644 index 00000000000..b04c679563f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/organizationMembers/SyncMemberForm.tsx @@ -0,0 +1,169 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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 { connect } from 'react-redux'; +import { Link } from 'react-router'; +import ConfirmButton from '../../components/controls/ConfirmButton'; +import RadioCard from '../../components/controls/RadioCard'; +import { Alert } from '../../components/ui/Alert'; +import { Button } from '../../components/ui/buttons'; +import { setOrganizationMemberSync, syncMembers } from '../../api/organizations'; +import { sanitizeAlmId, isGithub } from '../../helpers/almIntegrations'; +import { translate, translateWithParameters } from '../../helpers/l10n'; +import { fetchOrganization } from '../../store/rootActions'; + +interface Props { + fetchOrganization: (key: string) => void; + organization: T.Organization; +} + +interface State { + membersSync: boolean; +} + +export class SyncMemberForm extends React.PureComponent<Props, State> { + constructor(props: Props) { + super(props); + this.state = { + membersSync: Boolean(props.organization.alm && props.organization.alm.membersSync) + }; + } + + handleConfirm = () => { + const { organization } = this.props; + const { membersSync } = this.state; + return setOrganizationMemberSync({ + organization: organization.key, + enabled: membersSync + }).then(() => { + this.props.fetchOrganization(organization.key); + if (membersSync && isGithub(organization.alm && organization.alm.key)) { + return syncMembers(organization.key); + } + return Promise.resolve(); + }); + }; + + handleManualClick = () => { + this.setState({ membersSync: false }); + }; + + handleAutoClick = () => { + this.setState({ membersSync: true }); + }; + + renderModalBody = () => { + const { membersSync } = this.state; + const { organization } = this.props; + const almKey = organization.alm && sanitizeAlmId(organization.alm.key); + return ( + <> + {translate('organization.members.management.description')} + <Link + className="spacer-left" + target="_blank" + to={{ pathname: '/documentation/organizations/manage-team/' }}> + {translate('learn_more')} + </Link> + <div className="display-flex-stretch big-spacer-top"> + <RadioCard + onClick={this.handleManualClick} + selected={!membersSync} + title={translate('organization.members.management.manual')}> + <div className="spacer-left"> + <ul className="big-spacer-left note"> + <li className="spacer-bottom"> + {translate('organization.members.management.manual.add_members_manually')} + </li> + <li> + {translate('organization.members.management.manual.choose_members_permissions')} + </li> + </ul> + </div> + </RadioCard> + <RadioCard + onClick={this.handleAutoClick} + selected={membersSync} + title={translateWithParameters( + 'organization.members.management.automatic', + translate(almKey || '') + )}> + <div className="spacer-left"> + <ul className="big-spacer-left note"> + {almKey && ( + <> + <li className="spacer-bottom"> + {translate( + 'organization.members.management.automatic.synchronized_from', + almKey + )} + </li> + <li className="spacer-bottom"> + {translate( + 'organization.members.management.automatic.members_changes_reflected', + almKey + )} + </li> + </> + )} + <li> + {translate( + 'organization.members.management.automatic.still_choose_members_permissions' + )} + </li> + </ul> + </div> + {(!organization.alm || !organization.alm.membersSync) && ( + <Alert className="big-spacer-top" variant="warning"> + {translate('organization.members.management.automatic.warning')} + </Alert> + )} + </RadioCard> + </div> + </> + ); + }; + + render() { + const { organization } = this.props; + const orgMemberSync = Boolean(organization.alm && organization.alm.membersSync); + return ( + <ConfirmButton + cancelButtonText={translate('close')} + confirmButtonText={translate('save')} + confirmDisable={this.state.membersSync === orgMemberSync} + medium={true} + modalBody={this.renderModalBody()} + modalHeader={translate('organization.members.management.title')} + onConfirm={this.handleConfirm}> + {({ onClick }) => ( + <Button onClick={onClick}>{translate('organization.members.config_synchro')}</Button> + )} + </ConfirmButton> + ); + } +} + +const mapDispatchToProps = { fetchOrganization }; + +export default connect( + null, + mapDispatchToProps +)(SyncMemberForm); diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersListHeader-test.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersListHeader-test.tsx index 3105927acfa..7c97806d07e 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersListHeader-test.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersListHeader-test.tsx @@ -19,14 +19,54 @@ */ import * as React from 'react'; import { shallow } from 'enzyme'; -import MembersListHeader from '../MembersListHeader'; +import MembersListHeader, { Props } from '../MembersListHeader'; +import { + mockOrganization, + mockCurrentUser, + mockOrganizationWithAlm +} from '../../../helpers/testMocks'; it('should render without the total', () => { - const wrapper = shallow(<MembersListHeader handleSearch={jest.fn()} />); - expect(wrapper).toMatchSnapshot(); + expect(shallowRender({ total: undefined })).toMatchSnapshot(); }); it('should render with the total', () => { - const wrapper = shallow(<MembersListHeader handleSearch={jest.fn()} total={8} />); - expect(wrapper).toMatchSnapshot(); + expect(shallowRender()).toMatchSnapshot(); }); + +it('should render a help tooltip', () => { + expect( + shallowRender({ organization: mockOrganizationWithAlm({}, { membersSync: true }) }).find( + 'HelpTooltip' + ) + ).toMatchSnapshot(); + expect( + shallowRender({ + organization: mockOrganizationWithAlm( + {}, + { key: 'bitbucket', membersSync: true, url: 'https://bitbucket.com/foo' } + ) + }).find('HelpTooltip') + ).toMatchSnapshot(); +}); + +it('should not render link in help tooltip', () => { + expect( + shallowRender({ + currentUser: mockCurrentUser({ personalOrganization: 'foo' }), + organization: mockOrganizationWithAlm({}, { membersSync: true }) + }).find('HelpTooltip') + ).toMatchSnapshot(); +}); + +function shallowRender(props: Partial<Props> = {}) { + return shallow( + <MembersListHeader + currentUser={mockCurrentUser()} + handleSearch={jest.fn()} + organization={mockOrganization()} + total={8} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersPageHeader-test.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersPageHeader-test.tsx index 538d093be88..7f74a8eff3c 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersPageHeader-test.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersPageHeader-test.tsx @@ -19,13 +19,52 @@ */ import * as React from 'react'; import { shallow } from 'enzyme'; -import MembersPageHeader from '../MembersPageHeader'; +import { MembersPageHeader } from '../MembersPageHeader'; +import { + mockOrganization, + mockOrganizationWithAlm, + mockOrganizationWithAdminActions +} from '../../../helpers/testMocks'; -it('should render', () => { - const wrapper = shallow( - <MembersPageHeader loading={true}> - <span>children test</span> - </MembersPageHeader> - ); - expect(wrapper).toMatchSnapshot(); +it('should render correctly', () => { + expect(shallowRender({ loading: true })).toMatchSnapshot(); +}); + +it('should render for admin', () => { + expect( + shallowRender({ organization: mockOrganization({ actions: { admin: true } }) }) + ).toMatchSnapshot(); +}); + +it('should render for bound organization without sync', () => { + const organization = mockOrganizationWithAlm(mockOrganizationWithAdminActions()); + expect(shallowRender({ organization })).toMatchSnapshot(); + + const wrapper = shallowRender({ organization, dismissSyncNotifOrg: [organization.key] }); + expect(wrapper.find('Connect(SyncMemberForm)').exists()).toBe(true); + expect(wrapper.find('NewInfoBox').exists()).toBe(false); +}); + +it('should render for bound organization with sync', () => { + const organization = mockOrganizationWithAlm(mockOrganizationWithAdminActions(), { + membersSync: true + }); + const wrapper = shallowRender({ organization }); + expect(wrapper.find('Connect(SyncMemberForm)').exists()).toBe(true); + expect(wrapper.find('AddMemberForm').exists()).toBe(false); + expect(wrapper.find('NewInfoBox').exists()).toBe(false); }); + +function shallowRender(props: Partial<MembersPageHeader['props']> = {}) { + return shallow( + <MembersPageHeader + dismissSyncNotifOrg={[]} + handleAddMember={jest.fn()} + loading={false} + members={[]} + organization={mockOrganization()} + setCurrentUserSetting={jest.fn()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/OrganizationMembers-test.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/OrganizationMembers-test.tsx index 43c07605766..7861581aa2e 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/OrganizationMembers-test.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/OrganizationMembers-test.tsx @@ -20,9 +20,14 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import OrganizationMembers from '../OrganizationMembers'; -import { waitAndUpdate } from '../../../helpers/testUtils'; import { searchMembers, addMember, removeMember } from '../../../api/organizations'; import { searchUsersGroups, addUserToGroup, removeUserFromGroup } from '../../../api/user_groups'; +import { + mockOrganization, + mockCurrentUser, + mockOrganizationWithAdminActions +} from '../../../helpers/testMocks'; +import { waitAndUpdate } from '../../../helpers/testUtils'; jest.mock('../../../api/organizations', () => ({ addMember: jest.fn().mockResolvedValue({ login: 'bar', name: 'Bar', groupCount: 1 }), @@ -48,15 +53,13 @@ jest.mock('../../../api/user_groups', () => ({ }) })); -const organization = { key: 'foo', name: 'Foo' }; - beforeEach(() => { (searchMembers as jest.Mock).mockClear(); (searchUsersGroups as jest.Mock).mockClear(); }); it('should fetch members and render for non-admin', async () => { - const wrapper = shallow(<OrganizationMembers organization={organization} />); + const wrapper = shallowRender({ organization: mockOrganization() }); expect(wrapper).toMatchSnapshot(); await waitAndUpdate(wrapper); @@ -64,40 +67,31 @@ it('should fetch members and render for non-admin', async () => { expect(searchMembers).toBeCalledWith({ organization: 'foo', ps: 50, q: undefined }); }); -it('should fetch members and groups and render for admin', async () => { - const wrapper = shallow( - <OrganizationMembers organization={{ ...organization, actions: { admin: true } }} /> - ); +it('should fetch members and groups for admin', async () => { + const wrapper = shallowRender(); await waitAndUpdate(wrapper); - expect(wrapper).toMatchSnapshot(); expect(searchMembers).toBeCalledWith({ organization: 'foo', ps: 50, q: undefined }); expect(searchUsersGroups).toBeCalledWith({ organization: 'foo' }); }); it('should search users', async () => { - const wrapper = shallow( - <OrganizationMembers organization={{ ...organization, actions: { admin: true } }} /> - ); + const wrapper = shallowRender(); await waitAndUpdate(wrapper); wrapper.find('MembersListHeader').prop<Function>('handleSearch')('user'); expect(searchMembers).lastCalledWith({ organization: 'foo', ps: 50, q: 'user' }); }); it('should load more members', async () => { - const wrapper = shallow( - <OrganizationMembers organization={{ ...organization, actions: { admin: true } }} /> - ); + const wrapper = shallowRender(); await waitAndUpdate(wrapper); wrapper.find('ListFooter').prop<Function>('loadMore')(); expect(searchMembers).lastCalledWith({ organization: 'foo', p: 2, ps: 50, q: undefined }); }); it('should add new member', async () => { - const wrapper = shallow( - <OrganizationMembers organization={{ ...organization, actions: { admin: true } }} /> - ); + const wrapper = shallowRender(); await waitAndUpdate(wrapper); - wrapper.find('AddMemberForm').prop<Function>('addMember')({ login: 'bar' }); + wrapper.find('Connect(MembersPageHeader)').prop<Function>('handleAddMember')({ login: 'bar' }); await waitAndUpdate(wrapper); expect( wrapper @@ -110,9 +104,7 @@ it('should add new member', async () => { }); it('should remove member', async () => { - const wrapper = shallow( - <OrganizationMembers organization={{ ...organization, actions: { admin: true } }} /> - ); + const wrapper = shallowRender(); await waitAndUpdate(wrapper); wrapper.find('MembersList').prop<Function>('removeMember')({ login: 'john' }); await waitAndUpdate(wrapper); @@ -127,9 +119,7 @@ it('should remove member', async () => { }); it('should update groups', async () => { - const wrapper = shallow( - <OrganizationMembers organization={{ ...organization, actions: { admin: true } }} /> - ); + const wrapper = shallowRender(); await waitAndUpdate(wrapper); wrapper.find('MembersList').prop<Function>('updateMemberGroups')( { login: 'john' }, @@ -148,3 +138,13 @@ it('should update groups', async () => { expect(removeUserFromGroup).toHaveBeenCalledTimes(1); expect(removeUserFromGroup).toBeCalledWith({ login: 'john', name: 'birds', organization: 'foo' }); }); + +function shallowRender(props: Partial<OrganizationMembers['props']> = {}) { + return shallow( + <OrganizationMembers + currentUser={mockCurrentUser()} + organization={mockOrganizationWithAdminActions()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/SyncMemberForm-test.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/SyncMemberForm-test.tsx new file mode 100644 index 00000000000..4410d45c301 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/SyncMemberForm-test.tsx @@ -0,0 +1,93 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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 { shallow } from 'enzyme'; +import { SyncMemberForm } from '../SyncMemberForm'; +import { setOrganizationMemberSync, syncMembers } from '../../../api/organizations'; +import { mockOrganizationWithAlm } from '../../../helpers/testMocks'; +import { waitAndUpdate } from '../../../helpers/testUtils'; + +jest.mock('../../../api/organizations', () => ({ + setOrganizationMemberSync: jest.fn().mockResolvedValue(undefined), + syncMembers: jest.fn().mockResolvedValue(undefined) +})); + +beforeEach(() => { + (setOrganizationMemberSync as jest.Mock).mockClear(); + (syncMembers as jest.Mock).mockClear(); +}); + +it('should allow to switch to automatic mode with github', async () => { + const fetchOrganization = jest.fn(); + const wrapper = shallowRender({ fetchOrganization }); + expect(wrapper).toMatchSnapshot(); + + wrapper.setState({ membersSync: true }); + wrapper.find('ConfirmButton').prop<Function>('onConfirm')(); + expect(setOrganizationMemberSync).toHaveBeenCalledWith({ organization: 'foo', enabled: true }); + + await waitAndUpdate(wrapper); + expect(fetchOrganization).toHaveBeenCalledWith('foo'); + expect(syncMembers).toHaveBeenCalledWith('foo'); +}); + +it('should allow to switch to automatic mode with bitbucket', async () => { + const fetchOrganization = jest.fn(); + const wrapper = shallowRender({ + fetchOrganization, + organization: mockOrganizationWithAlm({}, { key: 'bitbucket' }) + }); + expect(wrapper).toMatchSnapshot(); + + wrapper.setState({ membersSync: true }); + wrapper.find('ConfirmButton').prop<Function>('onConfirm')(); + expect(setOrganizationMemberSync).toHaveBeenCalledWith({ organization: 'foo', enabled: true }); + + await waitAndUpdate(wrapper); + expect(fetchOrganization).toHaveBeenCalledWith('foo'); + expect(syncMembers).not.toHaveBeenCalled(); +}); + +it('should allow to switch to manual mode', async () => { + const fetchOrganization = jest.fn(); + const wrapper = shallowRender({ + fetchOrganization, + organization: mockOrganizationWithAlm({}, { membersSync: true }) + }); + expect(wrapper).toMatchSnapshot(); + + wrapper.setState({ membersSync: false }); + wrapper.find('ConfirmButton').prop<Function>('onConfirm')(); + expect(setOrganizationMemberSync).toHaveBeenCalledWith({ organization: 'foo', enabled: false }); + + await waitAndUpdate(wrapper); + expect(fetchOrganization).toHaveBeenCalledWith('foo'); + expect(syncMembers).not.toHaveBeenCalled(); +}); + +function shallowRender(props: Partial<SyncMemberForm['props']> = {}) { + return shallow<SyncMemberForm>( + <SyncMemberForm + fetchOrganization={jest.fn()} + organization={mockOrganizationWithAlm()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersListHeader-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersListHeader-test.tsx.snap index 7bfe2725e60..1d8ecfb92df 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersListHeader-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersListHeader-test.tsx.snap @@ -1,5 +1,74 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`should not render link in help tooltip 1`] = ` +<HelpTooltip + className="spacer-left" + overlay={ + <div + className="abs-width-300 markdown cut-margins" + > + <p> + organization.members.auto_sync_total_help.github + </p> + </div> + } +/> +`; + +exports[`should render a help tooltip 1`] = ` +<HelpTooltip + className="spacer-left" + overlay={ + <div + className="abs-width-300 markdown cut-margins" + > + <p> + organization.members.auto_sync_total_help.github + </p> + <React.Fragment> + <hr /> + <p> + <a + href="https://github.com/orgs/foo/people" + rel="noopener noreferrer" + target="_blank" + > + organization.members.see_all_members_on_x.github + </a> + </p> + </React.Fragment> + </div> + } +/> +`; + +exports[`should render a help tooltip 2`] = ` +<HelpTooltip + className="spacer-left" + overlay={ + <div + className="abs-width-300 markdown cut-margins" + > + <p> + organization.members.auto_sync_total_help.bitbucket + </p> + <React.Fragment> + <hr /> + <p> + <a + href="https://bitbucket.com/foo/profile/members" + rel="noopener noreferrer" + target="_blank" + > + organization.members.see_all_members_on_x.bitbucket + </a> + </p> + </React.Fragment> + </div> + } +/> +`; + exports[`should render with the total 1`] = ` <div className="panel panel-vertical bordered-bottom spacer-bottom" diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersPageHeader-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersPageHeader-test.tsx.snap index 95179079487..b0bc7e4a009 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersPageHeader-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersPageHeader-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should render 1`] = ` +exports[`should render correctly 1`] = ` <header className="page-header" > @@ -13,10 +13,7 @@ exports[`should render 1`] = ` loading={true} timeout={100} /> - <span> - children test - </span> - <p + <div className="page-description" > <FormattedMessage @@ -34,6 +31,153 @@ exports[`should render 1`] = ` } } /> - </p> + </div> +</header> +`; + +exports[`should render for admin 1`] = ` +<header + className="page-header" +> + <h1 + className="page-title" + > + organization.members.page + </h1> + <DeferredSpinner + loading={false} + timeout={100} + /> + <div + className="page-actions text-right" + > + <div + className="display-inline-block spacer-left spacer-bottom" + > + <AddMemberForm + addMember={[MockFunction]} + memberLogins={Array []} + organization={ + Object { + "actions": Object { + "admin": true, + }, + "key": "foo", + "name": "Foo", + } + } + /> + <DocTooltip + className="spacer-left" + doc={Promise {}} + /> + </div> + </div> + <div + className="page-description" + > + <FormattedMessage + defaultMessage="organization.members.page.description" + id="organization.members.page.description" + values={ + Object { + "link": <Link + onlyActiveOnIndex={false} + style={Object {}} + to="/documentation/organizations/manage-team/" + > + organization.members.manage_a_team + </Link>, + } + } + /> + </div> +</header> +`; + +exports[`should render for bound organization without sync 1`] = ` +<header + className="page-header" +> + <h1 + className="page-title" + > + organization.members.page + </h1> + <DeferredSpinner + loading={false} + timeout={100} + /> + <div + className="page-actions text-right" + > + <div + className="display-inline-block spacer-left spacer-bottom" + > + <AddMemberForm + addMember={[MockFunction]} + memberLogins={Array []} + organization={ + Object { + "actions": Object { + "admin": true, + }, + "alm": Object { + "key": "github", + "membersSync": false, + "url": "https://github.com/foo", + }, + "key": "foo", + "name": "Foo", + } + } + /> + <DocTooltip + className="spacer-left" + doc={Promise {}} + /> + </div> + <NewInfoBox + description="organization.members.auto_sync_members_from_org.github" + onClose={[Function]} + title="organization.members.auto_sync_with_x.github" + > + <Connect(SyncMemberForm) + organization={ + Object { + "actions": Object { + "admin": true, + }, + "alm": Object { + "key": "github", + "membersSync": false, + "url": "https://github.com/foo", + }, + "key": "foo", + "name": "Foo", + } + } + /> + </NewInfoBox> + </div> + <div + className="page-description" + > + <FormattedMessage + defaultMessage="organization.members.page.description" + id="organization.members.page.description" + values={ + Object { + "link": <Link + onlyActiveOnIndex={false} + style={Object {}} + to="/documentation/organizations/manage-team/" + > + organization.members.manage_a_team + </Link>, + } + } + /> + </div> </header> `; diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/OrganizationMembers-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/OrganizationMembers-test.tsx.snap index 90b44168efc..dd26a5928f4 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/OrganizationMembers-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/OrganizationMembers-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should fetch members and groups and render for admin 1`] = ` +exports[`should fetch members and render for non-admin 1`] = ` <div className="page page-limited" > @@ -12,110 +12,15 @@ exports[`should fetch members and groups and render for admin 1`] = ` <Suggestions suggestions="organization_members" /> - <MembersPageHeader - loading={false} - > - <div - className="page-actions" - > - <AddMemberForm - addMember={[Function]} - memberLogins={ - Array [ - "admin", - "john", - ] - } - organization={ - Object { - "actions": Object { - "admin": true, - }, - "key": "foo", - "name": "Foo", - } - } - /> - <DocTooltip - className="spacer-left" - doc={Promise {}} - /> - </div> - </MembersPageHeader> - <MembersListHeader - handleSearch={[Function]} - total={3} - /> - <MembersList - members={ - Array [ - Object { - "avatar": "", - "groupCount": 3, - "login": "admin", - "name": "Admin Istrator", - }, - Object { - "avatar": "7daf6c79d4802916d83f6266e24850af", - "groupCount": 1, - "login": "john", - "name": "John Doe", - }, - ] - } + <Connect(MembersPageHeader) + handleAddMember={[Function]} + loading={true} organization={ Object { - "actions": Object { - "admin": true, - }, "key": "foo", "name": "Foo", } } - organizationGroups={ - Array [ - Object { - "default": true, - "description": "", - "id": 1, - "membersCount": 2, - "name": "Members", - }, - Object { - "default": false, - "description": "", - "id": 2, - "membersCount": 0, - "name": "Watchers", - }, - ] - } - removeMember={[Function]} - updateMemberGroups={[Function]} - /> - <ListFooter - count={2} - loadMore={[Function]} - ready={true} - total={3} - /> -</div> -`; - -exports[`should fetch members and render for non-admin 1`] = ` -<div - className="page page-limited" -> - <HelmetWrapper - defer={true} - encodeSpecialCharacters={true} - title="organization.members.page" - /> - <Suggestions - suggestions="organization_members" - /> - <MembersPageHeader - loading={true} /> </div> `; @@ -132,11 +37,49 @@ exports[`should fetch members and render for non-admin 2`] = ` <Suggestions suggestions="organization_members" /> - <MembersPageHeader + <Connect(MembersPageHeader) + handleAddMember={[Function]} loading={false} + members={ + Array [ + Object { + "avatar": "", + "groupCount": 3, + "login": "admin", + "name": "Admin Istrator", + }, + Object { + "avatar": "7daf6c79d4802916d83f6266e24850af", + "groupCount": 1, + "login": "john", + "name": "John Doe", + }, + ] + } + organization={ + Object { + "key": "foo", + "name": "Foo", + } + } /> <MembersListHeader + currentUser={ + Object { + "groups": Array [], + "isLoggedIn": true, + "login": "luke", + "name": "Skywalker", + "scmAccounts": Array [], + } + } handleSearch={[Function]} + organization={ + Object { + "key": "foo", + "name": "Foo", + } + } total={3} /> <MembersList diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/SyncMemberForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/SyncMemberForm-test.tsx.snap new file mode 100644 index 00000000000..b8744db2ef6 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/SyncMemberForm-test.tsx.snap @@ -0,0 +1,271 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should allow to switch to automatic mode with bitbucket 1`] = ` +<ConfirmButton + cancelButtonText="close" + confirmButtonText="save" + confirmDisable={true} + medium={true} + modalBody={ + <React.Fragment> + organization.members.management.description + <Link + className="spacer-left" + onlyActiveOnIndex={false} + style={Object {}} + target="_blank" + to={ + Object { + "pathname": "/documentation/organizations/manage-team/", + } + } + > + learn_more + </Link> + <div + className="display-flex-stretch big-spacer-top" + > + <RadioCard + onClick={[Function]} + selected={true} + title="organization.members.management.manual" + > + <div + className="spacer-left" + > + <ul + className="big-spacer-left note" + > + <li + className="spacer-bottom" + > + organization.members.management.manual.add_members_manually + </li> + <li> + organization.members.management.manual.choose_members_permissions + </li> + </ul> + </div> + </RadioCard> + <RadioCard + onClick={[Function]} + selected={false} + title="organization.members.management.automatic.bitbucket" + > + <div + className="spacer-left" + > + <ul + className="big-spacer-left note" + > + <React.Fragment> + <li + className="spacer-bottom" + > + organization.members.management.automatic.synchronized_from.bitbucket + </li> + <li + className="spacer-bottom" + > + organization.members.management.automatic.members_changes_reflected.bitbucket + </li> + </React.Fragment> + <li> + organization.members.management.automatic.still_choose_members_permissions + </li> + </ul> + </div> + <Alert + className="big-spacer-top" + variant="warning" + > + organization.members.management.automatic.warning + </Alert> + </RadioCard> + </div> + </React.Fragment> + } + modalHeader="organization.members.management.title" + onConfirm={[Function]} +> + <Component /> +</ConfirmButton> +`; + +exports[`should allow to switch to automatic mode with github 1`] = ` +<ConfirmButton + cancelButtonText="close" + confirmButtonText="save" + confirmDisable={true} + medium={true} + modalBody={ + <React.Fragment> + organization.members.management.description + <Link + className="spacer-left" + onlyActiveOnIndex={false} + style={Object {}} + target="_blank" + to={ + Object { + "pathname": "/documentation/organizations/manage-team/", + } + } + > + learn_more + </Link> + <div + className="display-flex-stretch big-spacer-top" + > + <RadioCard + onClick={[Function]} + selected={true} + title="organization.members.management.manual" + > + <div + className="spacer-left" + > + <ul + className="big-spacer-left note" + > + <li + className="spacer-bottom" + > + organization.members.management.manual.add_members_manually + </li> + <li> + organization.members.management.manual.choose_members_permissions + </li> + </ul> + </div> + </RadioCard> + <RadioCard + onClick={[Function]} + selected={false} + title="organization.members.management.automatic.github" + > + <div + className="spacer-left" + > + <ul + className="big-spacer-left note" + > + <React.Fragment> + <li + className="spacer-bottom" + > + organization.members.management.automatic.synchronized_from.github + </li> + <li + className="spacer-bottom" + > + organization.members.management.automatic.members_changes_reflected.github + </li> + </React.Fragment> + <li> + organization.members.management.automatic.still_choose_members_permissions + </li> + </ul> + </div> + <Alert + className="big-spacer-top" + variant="warning" + > + organization.members.management.automatic.warning + </Alert> + </RadioCard> + </div> + </React.Fragment> + } + modalHeader="organization.members.management.title" + onConfirm={[Function]} +> + <Component /> +</ConfirmButton> +`; + +exports[`should allow to switch to manual mode 1`] = ` +<ConfirmButton + cancelButtonText="close" + confirmButtonText="save" + confirmDisable={true} + medium={true} + modalBody={ + <React.Fragment> + organization.members.management.description + <Link + className="spacer-left" + onlyActiveOnIndex={false} + style={Object {}} + target="_blank" + to={ + Object { + "pathname": "/documentation/organizations/manage-team/", + } + } + > + learn_more + </Link> + <div + className="display-flex-stretch big-spacer-top" + > + <RadioCard + onClick={[Function]} + selected={false} + title="organization.members.management.manual" + > + <div + className="spacer-left" + > + <ul + className="big-spacer-left note" + > + <li + className="spacer-bottom" + > + organization.members.management.manual.add_members_manually + </li> + <li> + organization.members.management.manual.choose_members_permissions + </li> + </ul> + </div> + </RadioCard> + <RadioCard + onClick={[Function]} + selected={true} + title="organization.members.management.automatic.github" + > + <div + className="spacer-left" + > + <ul + className="big-spacer-left note" + > + <React.Fragment> + <li + className="spacer-bottom" + > + organization.members.management.automatic.synchronized_from.github + </li> + <li + className="spacer-bottom" + > + organization.members.management.automatic.members_changes_reflected.github + </li> + </React.Fragment> + <li> + organization.members.management.automatic.still_choose_members_permissions + </li> + </ul> + </div> + </RadioCard> + </div> + </React.Fragment> + } + modalHeader="organization.members.management.title" + onConfirm={[Function]} +> + <Component /> +</ConfirmButton> +`; |