diff options
Diffstat (limited to 'server/sonar-web/src/main/js/apps')
41 files changed, 1398 insertions, 156 deletions
diff --git a/server/sonar-web/src/main/js/apps/account/profile/Profile.tsx b/server/sonar-web/src/main/js/apps/account/profile/Profile.tsx index b752f8fa8b3..941371b710e 100644 --- a/server/sonar-web/src/main/js/apps/account/profile/Profile.tsx +++ b/server/sonar-web/src/main/js/apps/account/profile/Profile.tsx @@ -20,6 +20,7 @@ import * as React from 'react'; import { translate } from 'sonar-ui-common/helpers/l10n'; import UserExternalIdentity from './UserExternalIdentity'; +import UserDeleteAccount from './UserDeleteAccount'; import UserGroups from './UserGroups'; import UserScmAccounts from './UserScmAccounts'; import { isSonarCloud } from '../../../helpers/system'; @@ -59,6 +60,14 @@ export function Profile({ currentUser }: Props) { <hr /> <UserScmAccounts scmAccounts={currentUser.scmAccounts} user={currentUser} /> + + {isSonarCloud() && ( + <> + <hr /> + + <UserDeleteAccount user={currentUser} /> + </> + )} </div> </div> ); diff --git a/server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccount.tsx b/server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccount.tsx new file mode 100644 index 00000000000..38351f002b4 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccount.tsx @@ -0,0 +1,125 @@ +/* + * 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 { translate } from 'sonar-ui-common/helpers/l10n'; +import { Button } from 'sonar-ui-common/components/controls/buttons'; +import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner'; +import UserDeleteAccountModal from './UserDeleteAccountModal'; +import UserDeleteAccountContent from './UserDeleteAccountContent'; +import { whenLoggedIn } from '../../../components/hoc/whenLoggedIn'; +import { withUserOrganizations } from '../../../components/hoc/withUserOrganizations'; +import { getOrganizationsThatPreventDeletion } from '../../../api/organizations'; + +interface Props { + user: T.LoggedInUser; + userOrganizations: T.Organization[]; +} + +interface State { + loading: boolean; + organizationsToTransferOrDelete: T.Organization[]; + showModal: boolean; +} + +export class UserDeleteAccount extends React.PureComponent<Props, State> { + mounted = false; + + state: State = { + loading: true, + organizationsToTransferOrDelete: [], + showModal: false + }; + + componentDidMount() { + this.mounted = true; + this.fetchOrganizationsThatPreventDeletion(); + } + + componentWillUnmount() { + this.mounted = false; + } + + fetchOrganizationsThatPreventDeletion = () => { + getOrganizationsThatPreventDeletion().then( + ({ organizations }) => { + if (this.mounted) { + this.setState({ + loading: false, + organizationsToTransferOrDelete: organizations + }); + } + }, + () => {} + ); + }; + + toggleModal = () => { + if (this.mounted) { + this.setState(state => ({ + showModal: !state.showModal + })); + } + }; + + render() { + const { user, userOrganizations } = this.props; + const { organizationsToTransferOrDelete, loading, showModal } = this.state; + + const label = translate('my_profile.delete_account'); + + return ( + <div> + <h2 className="spacer-bottom">{label}</h2> + + <DeferredSpinner loading={loading} /> + + {!loading && ( + <> + <UserDeleteAccountContent + className="list-styled no-padding big-spacer-top big-spacer-bottom" + organizationsSafeToDelete={userOrganizations} + organizationsToTransferOrDelete={organizationsToTransferOrDelete} + /> + + <Button + className="button-red" + disabled={organizationsToTransferOrDelete.length > 0} + onClick={this.toggleModal} + type="button"> + {translate('delete')} + </Button> + + {showModal && ( + <UserDeleteAccountModal + label={label} + organizationsSafeToDelete={userOrganizations} + organizationsToTransferOrDelete={organizationsToTransferOrDelete} + toggleModal={this.toggleModal} + user={user} + /> + )} + </> + )} + </div> + ); + } +} + +export default whenLoggedIn(withUserOrganizations(UserDeleteAccount)); diff --git a/server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccountContent.tsx b/server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccountContent.tsx new file mode 100644 index 00000000000..aaad598307b --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccountContent.tsx @@ -0,0 +1,148 @@ +/* + * 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 { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router'; +import { Alert } from 'sonar-ui-common/components/ui/Alert'; +import { translate } from 'sonar-ui-common/helpers/l10n'; +import { getOrganizationUrl } from '../../../helpers/urls'; + +function getOrganizationLink(org: T.Organization, i: number, organizations: T.Organization[]) { + return ( + <span key={org.key}> + <Link to={getOrganizationUrl(org.key)}>{org.name}</Link> + {i < organizations.length - 1 && ', '} + </span> + ); +} + +export function ShowOrganizationsToTransferOrDelete({ + organizations +}: { + organizations: T.Organization[]; +}) { + return ( + <> + <p className="big-spacer-bottom"> + <FormattedMessage + defaultMessage={translate('my_profile.delete_account.info.orgs_to_transfer_or_delete')} + id="my_profile.delete_account.info.orgs_to_transfer_or_delete" + values={{ + organizations: <>{organizations.map(getOrganizationLink)}</> + }} + /> + </p> + + <Alert className="big-spacer-bottom" variant="warning"> + <FormattedMessage + defaultMessage={translate( + 'my_profile.delete_account.info.orgs_to_transfer_or_delete.info' + )} + id="my_profile.delete_account.info.orgs_to_transfer_or_delete.info" + values={{ + link: ( + <a + href="https://sieg.eu.ngrok.io/documentation/organizations/overview/#how-to-transfer-ownership-of-an-organization" + rel="noopener noreferrer" + target="_blank"> + {translate('my_profile.delete_account.info.orgs_to_transfer_or_delete.info.link')} + </a> + ) + }} + /> + </Alert> + </> + ); +} + +export function ShowOrganizations({ + className, + organizations +}: { + className?: string; + organizations: T.Organization[]; +}) { + const organizationsIAdministrate = organizations.filter(o => o.actions && o.actions.admin); + + return ( + <ul className={className}> + <li className="spacer-bottom">{translate('my_profile.delete_account.info')}</li> + + <li className="spacer-bottom"> + <FormattedMessage + defaultMessage={translate('my_profile.delete_account.data.info')} + id="my_profile.delete_account.data.info" + values={{ + help: ( + <a + href="/documentation/user-guide/user-account/#delete-your-user-account" + rel="noopener noreferrer" + target="_blank"> + {translate('learn_more')} + </a> + ) + }} + /> + </li> + + {organizations.length > 0 && ( + <li className="spacer-bottom"> + <FormattedMessage + defaultMessage={translate('my_profile.delete_account.info.orgs.members')} + id="my_profile.delete_account.info.orgs.members" + values={{ + organizations: <>{organizations.map(getOrganizationLink)}</> + }} + /> + </li> + )} + + {organizationsIAdministrate.length > 0 && ( + <li className="spacer-bottom"> + <FormattedMessage + defaultMessage={translate('my_profile.delete_account.info.orgs.administrators')} + id="my_profile.delete_account.info.orgs.administrators" + values={{ + organizations: <>{organizationsIAdministrate.map(getOrganizationLink)}</> + }} + /> + </li> + )} + </ul> + ); +} + +interface UserDeleteAccountContentProps { + className?: string; + organizationsSafeToDelete: T.Organization[]; + organizationsToTransferOrDelete: T.Organization[]; +} + +export default function UserDeleteAccountContent({ + className, + organizationsSafeToDelete, + organizationsToTransferOrDelete +}: UserDeleteAccountContentProps) { + if (organizationsToTransferOrDelete.length > 0) { + return <ShowOrganizationsToTransferOrDelete organizations={organizationsToTransferOrDelete} />; + } + + return <ShowOrganizations className={className} organizations={organizationsSafeToDelete} />; +} diff --git a/server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccountModal.tsx b/server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccountModal.tsx new file mode 100644 index 00000000000..566fad469f6 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccountModal.tsx @@ -0,0 +1,150 @@ +/* + * 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 { FormikProps } from 'formik'; +import { connect } from 'react-redux'; +import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; +import { Alert } from 'sonar-ui-common/components/ui/Alert'; +import InputValidationField from 'sonar-ui-common/components/controls/InputValidationField'; +import UserDeleteAccountContent from './UserDeleteAccountContent'; +import RecentHistory from '../../../app/components/RecentHistory'; +import ValidationModal from '../../../components/controls/ValidationModal'; +import { deactivateUser } from '../../../api/users'; +import { Router, withRouter } from '../../../components/hoc/withRouter'; +import { doLogout } from '../../../store/rootActions'; + +interface Values { + login: string; +} + +interface DeleteModalProps { + doLogout: () => Promise<void>; + label: string; + organizationsSafeToDelete: T.Organization[]; + organizationsToTransferOrDelete: T.Organization[]; + router: Pick<Router, 'push'>; + toggleModal: VoidFunction; + user: T.LoggedInUser; +} + +export class UserDeleteAccountModal extends React.PureComponent<DeleteModalProps> { + handleSubmit = () => { + const { user } = this.props; + + return deactivateUser({ login: user.login }) + .then(this.props.doLogout) + .then(() => { + RecentHistory.clear(); + window.location.replace('/account-deleted'); + }); + }; + + handleValidate = ({ login }: Values) => { + const { user } = this.props; + const errors: { login?: string } = {}; + const trimmedLogin = login.trim(); + + if (!trimmedLogin) { + errors.login = translate('my_profile.delete_account.login.required'); + } else if (user.externalIdentity && trimmedLogin !== user.externalIdentity.trim()) { + errors.login = translate('my_profile.delete_account.login.wrong_value'); + } + + return errors; + }; + + render() { + const { + label, + organizationsSafeToDelete, + organizationsToTransferOrDelete, + toggleModal, + user + } = this.props; + + return ( + <ValidationModal + confirmButtonText={translate('delete')} + header={translateWithParameters( + 'my_profile.delete_account.modal.header', + label, + user.externalIdentity || '' + )} + initialValues={{ + login: '' + }} + isDestructive={true} + onClose={toggleModal} + onSubmit={this.handleSubmit} + validate={this.handleValidate}> + {({ + dirty, + errors, + handleBlur, + handleChange, + isSubmitting, + touched, + values + }: FormikProps<Values>) => ( + <> + <Alert className="big-spacer-bottom" variant="error"> + {translate('my_profile.warning_message')} + </Alert> + + <UserDeleteAccountContent + className="list-styled no-padding big-spacer-bottom" + organizationsSafeToDelete={organizationsSafeToDelete} + organizationsToTransferOrDelete={organizationsToTransferOrDelete} + /> + + <InputValidationField + autoFocus={true} + dirty={dirty} + disabled={isSubmitting} + error={errors.login} + id="user-login" + label={ + <label htmlFor="user-login"> + {translate('my_profile.delete_account.verify')} + <em className="mandatory">*</em> + </label> + } + name="login" + onBlur={handleBlur} + onChange={handleChange} + touched={touched.login} + type="text" + value={values.login} + /> + </> + )} + </ValidationModal> + ); + } +} + +const mapStateToProps = () => ({}); + +const mapDispatchToProps = { doLogout: doLogout as any }; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(withRouter(UserDeleteAccountModal)); diff --git a/server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccount-test.tsx b/server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccount-test.tsx new file mode 100644 index 00000000000..1e4d4b217e3 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccount-test.tsx @@ -0,0 +1,87 @@ +/* + * 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 { shallow } from 'enzyme'; +import * as React from 'react'; +import { click, waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; +import { mockLoggedInUser, mockOrganization } from '../../../../helpers/testMocks'; +import { UserDeleteAccount } from '../UserDeleteAccount'; +import { getOrganizationsThatPreventDeletion } from '../../../../api/organizations'; + +jest.mock('../../../../api/organizations', () => ({ + getOrganizationsThatPreventDeletion: jest.fn().mockResolvedValue({ organizations: [] }) +})); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +const organizationToTransferOrDelete = { + key: 'luke-leia', + name: 'Luke and Leia' +}; + +it('should render correctly', async () => { + const wrapper = shallowRender(); + expect(wrapper).toMatchSnapshot(); + + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot(); + + click(wrapper.find('Button')); + expect(wrapper).toMatchSnapshot(); +}); + +it('should get some organizations', async () => { + (getOrganizationsThatPreventDeletion as jest.Mock).mockResolvedValue({ + organizations: [organizationToTransferOrDelete] + }); + + const wrapper = shallowRender(); + + await waitAndUpdate(wrapper); + + expect(wrapper.state('loading')).toBeFalsy(); + expect(wrapper.state('organizationsToTransferOrDelete')).toEqual([ + organizationToTransferOrDelete + ]); + expect(getOrganizationsThatPreventDeletion).toBeCalled(); + expect(wrapper.find('Button').prop('disabled')).toBe(true); +}); + +it('should toggle modal', () => { + const wrapper = shallowRender(); + wrapper.setState({ loading: false }); + expect(wrapper.find('Connect(withRouter(UserDeleteAccountModal))').exists()).toBe(false); + click(wrapper.find('Button')); + expect(wrapper.find('Connect(withRouter(UserDeleteAccountModal))').exists()).toBe(true); +}); + +function shallowRender(props: Partial<UserDeleteAccount['props']> = {}) { + const user = mockLoggedInUser({ externalIdentity: 'luke' }); + + const userOrganizations = [ + mockOrganization({ key: 'luke-leia', name: 'Luke and Leia' }), + mockOrganization({ key: 'luke', name: 'Luke Skywalker' }) + ]; + + return shallow<UserDeleteAccount>( + <UserDeleteAccount user={user} userOrganizations={userOrganizations} {...props} /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccountContent-test.tsx b/server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccountContent-test.tsx new file mode 100644 index 00000000000..c9e5e334d5e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccountContent-test.tsx @@ -0,0 +1,71 @@ +/* + * 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 UserDeleteAccountContent, { + ShowOrganizations, + ShowOrganizationsToTransferOrDelete +} from '../UserDeleteAccountContent'; + +const organizationSafeToDelete = { + key: 'luke', + name: 'Luke Skywalker' +}; + +const organizationToTransferOrDelete = { + key: 'luke-leia', + name: 'Luke and Leia' +}; + +it('should render content correctly', () => { + expect( + shallow( + <UserDeleteAccountContent + className="my-class" + organizationsSafeToDelete={[organizationSafeToDelete]} + organizationsToTransferOrDelete={[organizationToTransferOrDelete]} + /> + ) + ).toMatchSnapshot(); + + expect( + shallow( + <UserDeleteAccountContent + className="my-class" + organizationsSafeToDelete={[organizationSafeToDelete]} + organizationsToTransferOrDelete={[]} + /> + ) + ).toMatchSnapshot(); +}); + +it('should render correctly ShowOrganizationsToTransferOrDelete', () => { + expect( + shallow( + <ShowOrganizationsToTransferOrDelete organizations={[organizationToTransferOrDelete]} /> + ) + ).toMatchSnapshot(); +}); + +it('should render correctly ShowOrganizations', () => { + expect( + shallow(<ShowOrganizations organizations={[organizationSafeToDelete]} />) + ).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccountModal-test.tsx b/server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccountModal-test.tsx new file mode 100644 index 00000000000..e820b6c0284 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccountModal-test.tsx @@ -0,0 +1,86 @@ +/* + * 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 { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; +import { mockLoggedInUser, mockRouter } from '../../../../helpers/testMocks'; +import { UserDeleteAccountModal } from '../UserDeleteAccountModal'; +import { deactivateUser } from '../../../../api/users'; + +jest.mock('../../../../api/users', () => ({ + deactivateUser: jest.fn() +})); + +const organizationSafeToDelete = { + key: 'luke', + name: 'Luke Skywalker' +}; + +const organizationToTransferOrDelete = { + key: 'luke-leia', + name: 'Luke and Leia' +}; + +it('should render modal correctly', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +it('should handle submit', async () => { + (deactivateUser as jest.Mock).mockResolvedValue(true); + window.location.replace = jest.fn(); + + const wrapper = shallowRender(); + const instance = wrapper.instance(); + + instance.handleSubmit(); + await waitAndUpdate(wrapper); + + expect(deactivateUser).toBeCalled(); + expect(window.location.replace).toHaveBeenCalledWith('/account-deleted'); +}); + +it('should validate user input', () => { + const wrapper = shallowRender(); + const instance = wrapper.instance(); + const { handleValidate } = instance; + + expect(handleValidate({ login: '' }).login).toBe('my_profile.delete_account.login.required'); + expect(handleValidate({ login: 'abc' }).login).toBe( + 'my_profile.delete_account.login.wrong_value' + ); + expect(handleValidate({ login: 'luke' }).login).toBeUndefined(); +}); + +function shallowRender(props: Partial<UserDeleteAccountModal['props']> = {}) { + const user = mockLoggedInUser({ externalIdentity: 'luke' }); + + return shallow<UserDeleteAccountModal>( + <UserDeleteAccountModal + doLogout={jest.fn().mockResolvedValue(true)} + label="label" + organizationsSafeToDelete={[organizationSafeToDelete]} + organizationsToTransferOrDelete={[organizationToTransferOrDelete]} + router={mockRouter()} + toggleModal={jest.fn()} + user={user} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccount-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccount-test.tsx.snap new file mode 100644 index 00000000000..05a4397a565 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccount-test.tsx.snap @@ -0,0 +1,118 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<div> + <h2 + className="spacer-bottom" + > + my_profile.delete_account + </h2> + <DeferredSpinner + loading={true} + timeout={100} + /> +</div> +`; + +exports[`should render correctly 2`] = ` +<div> + <h2 + className="spacer-bottom" + > + my_profile.delete_account + </h2> + <DeferredSpinner + loading={false} + timeout={100} + /> + <UserDeleteAccountContent + className="list-styled no-padding big-spacer-top big-spacer-bottom" + organizationsSafeToDelete={ + Array [ + Object { + "key": "luke-leia", + "name": "Luke and Leia", + }, + Object { + "key": "luke", + "name": "Luke Skywalker", + }, + ] + } + organizationsToTransferOrDelete={Array []} + /> + <Button + className="button-red" + disabled={false} + onClick={[Function]} + type="button" + > + delete + </Button> +</div> +`; + +exports[`should render correctly 3`] = ` +<div> + <h2 + className="spacer-bottom" + > + my_profile.delete_account + </h2> + <DeferredSpinner + loading={false} + timeout={100} + /> + <UserDeleteAccountContent + className="list-styled no-padding big-spacer-top big-spacer-bottom" + organizationsSafeToDelete={ + Array [ + Object { + "key": "luke-leia", + "name": "Luke and Leia", + }, + Object { + "key": "luke", + "name": "Luke Skywalker", + }, + ] + } + organizationsToTransferOrDelete={Array []} + /> + <Button + className="button-red" + disabled={false} + onClick={[Function]} + type="button" + > + delete + </Button> + <Connect(withRouter(UserDeleteAccountModal)) + label="my_profile.delete_account" + organizationsSafeToDelete={ + Array [ + Object { + "key": "luke-leia", + "name": "Luke and Leia", + }, + Object { + "key": "luke", + "name": "Luke Skywalker", + }, + ] + } + organizationsToTransferOrDelete={Array []} + toggleModal={[Function]} + user={ + Object { + "externalIdentity": "luke", + "groups": Array [], + "isLoggedIn": true, + "login": "luke", + "name": "Skywalker", + "scmAccounts": Array [], + } + } + /> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccountContent-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccountContent-test.tsx.snap new file mode 100644 index 00000000000..56ac708fb53 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccountContent-test.tsx.snap @@ -0,0 +1,128 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render content correctly 1`] = ` +<ShowOrganizationsToTransferOrDelete + organizations={ + Array [ + Object { + "key": "luke-leia", + "name": "Luke and Leia", + }, + ] + } +/> +`; + +exports[`should render content correctly 2`] = ` +<ShowOrganizations + className="my-class" + organizations={ + Array [ + Object { + "key": "luke", + "name": "Luke Skywalker", + }, + ] + } +/> +`; + +exports[`should render correctly ShowOrganizations 1`] = ` +<ul> + <li + className="spacer-bottom" + > + my_profile.delete_account.info + </li> + <li + className="spacer-bottom" + > + <FormattedMessage + defaultMessage="my_profile.delete_account.data.info" + id="my_profile.delete_account.data.info" + values={ + Object { + "help": <a + href="/documentation/user-guide/user-account/#delete-your-user-account" + rel="noopener noreferrer" + target="_blank" + > + learn_more + </a>, + } + } + /> + </li> + <li + className="spacer-bottom" + > + <FormattedMessage + defaultMessage="my_profile.delete_account.info.orgs.members" + id="my_profile.delete_account.info.orgs.members" + values={ + Object { + "organizations": <React.Fragment> + <span> + <Link + onlyActiveOnIndex={false} + style={Object {}} + to="/organizations/luke" + > + Luke Skywalker + </Link> + </span> + </React.Fragment>, + } + } + /> + </li> +</ul> +`; + +exports[`should render correctly ShowOrganizationsToTransferOrDelete 1`] = ` +<Fragment> + <p + className="big-spacer-bottom" + > + <FormattedMessage + defaultMessage="my_profile.delete_account.info.orgs_to_transfer_or_delete" + id="my_profile.delete_account.info.orgs_to_transfer_or_delete" + values={ + Object { + "organizations": <React.Fragment> + <span> + <Link + onlyActiveOnIndex={false} + style={Object {}} + to="/organizations/luke-leia" + > + Luke and Leia + </Link> + </span> + </React.Fragment>, + } + } + /> + </p> + <Alert + className="big-spacer-bottom" + variant="warning" + > + <FormattedMessage + defaultMessage="my_profile.delete_account.info.orgs_to_transfer_or_delete.info" + id="my_profile.delete_account.info.orgs_to_transfer_or_delete.info" + values={ + Object { + "link": <a + href="https://sieg.eu.ngrok.io/documentation/organizations/overview/#how-to-transfer-ownership-of-an-organization" + rel="noopener noreferrer" + target="_blank" + > + my_profile.delete_account.info.orgs_to_transfer_or_delete.info.link + </a>, + } + } + /> + </Alert> +</Fragment> +`; diff --git a/server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccountModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccountModal-test.tsx.snap new file mode 100644 index 00000000000..5ba1d10e4ad --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccountModal-test.tsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render modal correctly 1`] = ` +<ValidationModal + confirmButtonText="delete" + header="my_profile.delete_account.modal.header.label.luke" + initialValues={ + Object { + "login": "", + } + } + isDestructive={true} + onClose={[MockFunction]} + onSubmit={[Function]} + validate={[Function]} +> + <Component /> +</ValidationModal> +`; diff --git a/server/sonar-web/src/main/js/apps/custom-measures/components/Item.tsx b/server/sonar-web/src/main/js/apps/custom-measures/components/Item.tsx index b7ceeab374d..92ec1433518 100644 --- a/server/sonar-web/src/main/js/apps/custom-measures/components/Item.tsx +++ b/server/sonar-web/src/main/js/apps/custom-measures/components/Item.tsx @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { translate } from 'sonar-ui-common/helpers/l10n'; +import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; import Tooltip from 'sonar-ui-common/components/controls/Tooltip'; import { formatMeasure } from 'sonar-ui-common/helpers/measures'; import ActionsDropdown, { @@ -28,6 +28,7 @@ import ActionsDropdown, { import DeleteForm from './DeleteForm'; import Form from './Form'; import MeasureDate from './MeasureDate'; +import { isUserActive } from '../../../helpers/users'; interface Props { measure: T.CustomMeasure; @@ -114,7 +115,11 @@ export default class Item extends React.PureComponent<Props, State> { <td> <MeasureDate measure={measure} /> {translate('by_')}{' '} - <span className="js-custom-measure-user">{measure.user.name}</span> + <span className="js-custom-measure-user"> + {isUserActive(measure.user) + ? measure.user.name || measure.user.login + : translateWithParameters('user.x_deleted', measure.user.login)} + </span> </td> <td className="thin nowrap"> diff --git a/server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/Item-test.tsx b/server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/Item-test.tsx index 90fb5c29edc..667d1f38a88 100644 --- a/server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/Item-test.tsx +++ b/server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/Item-test.tsx @@ -33,14 +33,12 @@ const measure = { }; it('should render', () => { - expect( - shallow(<Item measure={measure} onDelete={jest.fn()} onEdit={jest.fn()} />) - ).toMatchSnapshot(); + expect(shallowRender()).toMatchSnapshot(); }); it('should edit metric', () => { const onEdit = jest.fn(); - const wrapper = shallow(<Item measure={measure} onDelete={jest.fn()} onEdit={onEdit} />); + const wrapper = shallowRender({ onEdit }); click(wrapper.find('.js-custom-measure-update')); wrapper.update(); @@ -55,7 +53,7 @@ it('should edit metric', () => { it('should delete custom measure', () => { const onDelete = jest.fn(); - const wrapper = shallow(<Item measure={measure} onDelete={onDelete} onEdit={jest.fn()} />); + const wrapper = shallowRender({ onDelete }); click(wrapper.find('.js-custom-measure-delete')); wrapper.update(); @@ -63,3 +61,13 @@ it('should delete custom measure', () => { wrapper.find('DeleteForm').prop<Function>('onSubmit')(); expect(onDelete).toBeCalledWith('1'); }); + +it('should render correctly for deleted user', () => { + expect( + shallowRender({ measure: { ...measure, user: { active: false, login: 'user' } } }) + ).toMatchSnapshot(); +}); + +function shallowRender(props: Partial<Item['props']> = {}) { + return shallow(<Item measure={measure} onDelete={jest.fn()} onEdit={jest.fn()} {...props} />); +} diff --git a/server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/__snapshots__/Item-test.tsx.snap b/server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/__snapshots__/Item-test.tsx.snap index 225ca0c587c..a763f649723 100644 --- a/server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/__snapshots__/Item-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/__snapshots__/Item-test.tsx.snap @@ -87,3 +87,90 @@ exports[`should render 1`] = ` </td> </tr> `; + +exports[`should render correctly for deleted user 1`] = ` +<tr + data-metric="custom" +> + <td + className="nowrap" + > + <div> + <span + className="js-custom-measure-metric-name" + > + custom-metric + </span> + </div> + <span + className="js-custom-measure-domain note" + /> + </td> + <td + className="nowrap" + > + <strong + className="js-custom-measure-value" + > + custom-value + </strong> + </td> + <td> + <span + className="js-custom-measure-description" + > + my custom measure + </span> + </td> + <td> + <MeasureDate + measure={ + Object { + "createdAt": "2017-01-01", + "description": "my custom measure", + "id": "1", + "metric": Object { + "key": "custom", + "name": "custom-metric", + "type": "STRING", + }, + "projectKey": "foo", + "user": Object { + "active": false, + "login": "user", + }, + "value": "custom-value", + } + } + /> + + by_ + + <span + className="js-custom-measure-user" + > + user.x_deleted.user + </span> + </td> + <td + className="thin nowrap" + > + <ActionsDropdown> + <ActionsDropdownItem + className="js-custom-measure-update" + onClick={[Function]} + > + update_verb + </ActionsDropdownItem> + <ActionsDropdownDivider /> + <ActionsDropdownItem + className="js-custom-measure-delete" + destructive={true} + onClick={[Function]} + > + delete + </ActionsDropdownItem> + </ActionsDropdown> + </td> +</tr> +`; diff --git a/server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx b/server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx index a0f00cf6d6e..712206e016f 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx +++ b/server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx @@ -24,12 +24,7 @@ import { ResetButtonLink } from 'sonar-ui-common/components/controls/buttons'; import Modal from 'sonar-ui-common/components/controls/Modal'; import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner'; import SelectList, { Filter } from '../../../components/SelectList/SelectList'; -import { - addUserToGroup, - getUsersInGroup, - GroupUser, - removeUserFromGroup -} from '../../../api/user_groups'; +import { addUserToGroup, getUsersInGroup, removeUserFromGroup } from '../../../api/user_groups'; interface Props { group: T.Group; @@ -50,7 +45,7 @@ interface State { lastSearchParams: SearchParams; listHasBeenTouched: boolean; loading: boolean; - users: GroupUser[]; + users: T.UserSelected[]; usersTotalCount?: number; selectedUsers: string[]; } diff --git a/server/sonar-web/src/main/js/apps/issues/components/App.tsx b/server/sonar-web/src/main/js/apps/issues/components/App.tsx index d4482a05dbf..4d3262d3fec 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/App.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/App.tsx @@ -66,7 +66,6 @@ import { ReferencedComponent, ReferencedLanguage, ReferencedRule, - ReferencedUser, saveMyIssues, serializeQuery, STANDARDS, @@ -96,7 +95,7 @@ interface FetchIssuesPromise { languages: ReferencedLanguage[]; paging: T.Paging; rules: ReferencedRule[]; - users: ReferencedUser[]; + users: T.UserBase[]; } interface Props { @@ -136,7 +135,7 @@ export interface State { referencedComponentsByKey: T.Dict<ReferencedComponent>; referencedLanguages: T.Dict<ReferencedLanguage>; referencedRules: T.Dict<ReferencedRule>; - referencedUsers: T.Dict<ReferencedUser>; + referencedUsers: T.Dict<T.UserBase>; selected?: string; selectedFlowIndex?: number; selectedLocationIndex?: number; diff --git a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx index 9599069623a..7a8fb72a717 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx @@ -36,7 +36,7 @@ import Select from '../../../components/controls/Select'; import SeverityHelper from '../../../components/shared/SeverityHelper'; import throwGlobalError from '../../../app/utils/throwGlobalError'; import { searchIssueTags, bulkChangeIssues } from '../../../api/issues'; -import { isLoggedIn } from '../../../helpers/users'; +import { isLoggedIn, isUserActive } from '../../../helpers/users'; interface AssigneeOption { avatar?: string; @@ -161,7 +161,11 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> { handleAssigneeSearch = (query: string) => { return searchAssignees(query, this.state.organization).then(({ results }) => - results.map(r => ({ avatar: r.avatar, label: r.name, value: r.login })) + results.map(r => ({ + avatar: r.avatar, + label: isUserActive(r) ? r.name : translateWithParameters('user.x_deleted', r.login), + value: r.login + })) ); }; diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx index caf10424969..95c4446ff8d 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx @@ -20,12 +20,13 @@ import * as React from 'react'; import { omit, sortBy, without } from 'lodash'; import { highlightTerm } from 'sonar-ui-common/helpers/search'; -import { translate } from 'sonar-ui-common/helpers/l10n'; -import { searchAssignees, Query, ReferencedUser, SearchedAssignee, Facet } from '../utils'; +import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; +import { searchAssignees, Query, Facet } from '../utils'; import Avatar from '../../../components/ui/Avatar'; import ListStyleFacet from '../../../components/facet/ListStyleFacet'; +import { isUserActive } from '../../../helpers/users'; -export interface Props { +interface Props { assigned: boolean; assignees: string[]; fetching: boolean; @@ -36,7 +37,7 @@ export interface Props { organization: string | undefined; query: Query; stats: T.Dict<number> | undefined; - referencedUsers: T.Dict<ReferencedUser>; + referencedUsers: T.Dict<T.UserBase>; } export default class AssigneeFacet extends React.PureComponent<Props> { @@ -71,11 +72,14 @@ export default class AssigneeFacet extends React.PureComponent<Props> { return translate('unassigned'); } else { const user = this.props.referencedUsers[assignee]; - return user ? user.name : assignee; + if (!user) { + return assignee; + } + return isUserActive(user) ? user.name : translateWithParameters('user.x_deleted', user.login); } }; - loadSearchResultCount = (assignees: SearchedAssignee[]) => { + loadSearchResultCount = (assignees: T.UserBase[]) => { return this.props.loadSearchResultCount('assignees', { assigned: undefined, assignees: assignees.map(assignee => assignee.login) @@ -99,28 +103,35 @@ export default class AssigneeFacet extends React.PureComponent<Props> { } const user = this.props.referencedUsers[assignee]; + return user ? ( <> - <Avatar className="little-spacer-right" hash={user.avatar} name={user.name} size={16} /> - {user.name} + <Avatar + className="little-spacer-right" + hash={user.avatar} + name={user.name || user.login} + size={16} + /> + {isUserActive(user) ? user.name : translateWithParameters('user.x_deleted', user.login)} </> ) : ( assignee ); }; - renderSearchResult = (result: SearchedAssignee, query: string) => { + renderSearchResult = (result: T.UserBase, query: string) => { + const displayName = isUserActive(result) + ? result.name + : translateWithParameters('user.x_deleted', result.login); return ( <> - {result.avatar !== undefined && ( - <Avatar - className="little-spacer-right" - hash={result.avatar} - name={result.name} - size={16} - /> - )} - {highlightTerm(result.name, query)} + <Avatar + className="little-spacer-right" + hash={result.avatar} + name={result.name || result.login} + size={16} + /> + {highlightTerm(displayName, query)} </> ); }; @@ -132,12 +143,12 @@ export default class AssigneeFacet extends React.PureComponent<Props> { } return ( - <ListStyleFacet<SearchedAssignee> + <ListStyleFacet<T.UserBase> facetHeader={translate('issues.facet.assignees')} fetching={this.props.fetching} getFacetItemText={this.getAssigneeName} getSearchResultKey={user => user.login} - getSearchResultText={user => user.name} + getSearchResultText={user => user.name || user.login} // put "not assigned" item first getSortedItems={this.getSortedItems} loadSearchResultCount={this.loadSearchResultCount} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx index 95bcfd24158..ade6f7080b8 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx @@ -32,14 +32,7 @@ import StandardFacet from './StandardFacet'; import StatusFacet from './StatusFacet'; import TagFacet from './TagFacet'; import TypeFacet from './TypeFacet'; -import { - Query, - Facet, - ReferencedComponent, - ReferencedUser, - ReferencedLanguage, - ReferencedRule -} from '../utils'; +import { Query, Facet, ReferencedComponent, ReferencedLanguage, ReferencedRule } from '../utils'; export interface Props { component: T.Component | undefined; @@ -57,7 +50,7 @@ export interface Props { referencedComponentsByKey: T.Dict<ReferencedComponent>; referencedLanguages: T.Dict<ReferencedLanguage>; referencedRules: T.Dict<ReferencedRule>; - referencedUsers: T.Dict<ReferencedUser>; + referencedUsers: T.Dict<T.UserBase>; } export default class Sidebar extends React.PureComponent<Props> { diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx index 6bab64ec2a1..4ce377cd1be 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx @@ -19,18 +19,18 @@ */ import * as React from 'react'; import { shallow } from 'enzyme'; -import AssigneeFacet, { Props } from '../AssigneeFacet'; +import AssigneeFacet from '../AssigneeFacet'; import { Query } from '../../utils'; jest.mock('../../../../store/rootReducer', () => ({})); it('should render', () => { - expect(renderAssigneeFacet({ assignees: ['foo'] })).toMatchSnapshot(); + expect(shallowRender({ assignees: ['foo'] })).toMatchSnapshot(); }); it('should select unassigned', () => { expect( - renderAssigneeFacet({ assigned: false }) + shallowRender({ assigned: false }) .find('ListStyleFacet') .prop('values') ).toEqual(['']); @@ -38,7 +38,7 @@ it('should select unassigned', () => { it('should call onChange', () => { const onChange = jest.fn(); - const wrapper = renderAssigneeFacet({ assignees: ['foo'], onChange }); + const wrapper = shallowRender({ assignees: ['foo'], onChange }); const itemOnClick = wrapper.find('ListStyleFacet').prop<Function>('onItemClick'); itemOnClick(''); @@ -51,8 +51,39 @@ it('should call onChange', () => { expect(onChange).lastCalledWith({ assigned: true, assignees: ['baz', 'foo'] }); }); -function renderAssigneeFacet(props?: Partial<Props>) { - return shallow( +describe('test behavior', () => { + const instance = shallowRender({ + assignees: ['foo', 'baz'], + referencedUsers: { + foo: { active: false, login: 'foo' }, + baz: { active: true, login: 'baz', name: 'Name Baz' } + } + }).instance(); + + it('should correctly render assignee name', () => { + expect(instance.getAssigneeName('')).toBe('unassigned'); + expect(instance.getAssigneeName('bar')).toBe('bar'); + expect(instance.getAssigneeName('baz')).toBe('Name Baz'); + expect(instance.getAssigneeName('foo')).toBe('user.x_deleted.foo'); + }); + + it('should correctly render facet item', () => { + expect(instance.renderFacetItem('')).toBe('unassigned'); + expect(instance.renderFacetItem('bar')).toBe('bar'); + expect(instance.renderFacetItem('baz')).toMatchSnapshot(); + expect(instance.renderFacetItem('foo')).toMatchSnapshot(); + }); + + it('should correctly render search result correctly', () => { + expect( + instance.renderSearchResult({ active: true, login: 'bar', name: 'Name Bar' }, 'ba') + ).toMatchSnapshot(); + expect(instance.renderSearchResult({ active: false, login: 'foo' }, 'fo')).toMatchSnapshot(); + }); +}); + +function shallowRender(props?: Partial<AssigneeFacet['props']>) { + return shallow<AssigneeFacet>( <AssigneeFacet assigned={true} assignees={[]} @@ -63,7 +94,7 @@ function renderAssigneeFacet(props?: Partial<Props>) { open={true} organization={undefined} query={{} as Query} - referencedUsers={{ foo: { avatar: 'avatart-foo', name: 'name-foo' } }} + referencedUsers={{ foo: { avatar: 'avatart-foo', login: 'name-foo', name: 'Name Foo' } }} stats={{ '': 5, foo: 13, bar: 7, baz: 6 }} {...props} /> diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap index 7dea30a849c..6e5f27e4e47 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap @@ -38,3 +38,59 @@ exports[`should render 1`] = ` } /> `; + +exports[`test behavior should correctly render facet item 1`] = ` +<React.Fragment> + <Connect(Avatar) + className="little-spacer-right" + name="Name Baz" + size={16} + /> + Name Baz +</React.Fragment> +`; + +exports[`test behavior should correctly render facet item 2`] = ` +<React.Fragment> + <Connect(Avatar) + className="little-spacer-right" + name="foo" + size={16} + /> + user.x_deleted.foo +</React.Fragment> +`; + +exports[`test behavior should correctly render search result correctly 1`] = ` +<React.Fragment> + <Connect(Avatar) + className="little-spacer-right" + name="Name Bar" + size={16} + /> + <React.Fragment> + Name + <mark> + Ba + </mark> + r + </React.Fragment> +</React.Fragment> +`; + +exports[`test behavior should correctly render search result correctly 2`] = ` +<React.Fragment> + <Connect(Avatar) + className="little-spacer-right" + name="foo" + size={16} + /> + <React.Fragment> + user.x_deleted. + <mark> + fo + </mark> + o + </React.Fragment> +</React.Fragment> +`; diff --git a/server/sonar-web/src/main/js/apps/issues/utils.ts b/server/sonar-web/src/main/js/apps/issues/utils.ts index a6c644f645e..8e98b7919cb 100644 --- a/server/sonar-web/src/main/js/apps/issues/utils.ts +++ b/server/sonar-web/src/main/js/apps/issues/utils.ts @@ -199,11 +199,6 @@ export interface ReferencedComponent { uuid: string; } -export interface ReferencedUser { - avatar: string; - name: string; -} - export interface ReferencedLanguage { name: string; } @@ -213,17 +208,11 @@ export interface ReferencedRule { name: string; } -export interface SearchedAssignee { - avatar?: string; - login: string; - name: string; -} - export const searchAssignees = ( query: string, organization: string | undefined, page = 1 -): Promise<{ paging: T.Paging; results: SearchedAssignee[] }> => { +): Promise<{ paging: T.Paging; results: T.UserBase[] }> => { return organization ? searchMembers({ organization, p: page, ps: 50, q: query }).then(({ paging, users }) => ({ paging, diff --git a/server/sonar-web/src/main/js/apps/projects/components/NoFavoriteProjects.tsx b/server/sonar-web/src/main/js/apps/projects/components/NoFavoriteProjects.tsx index 7132c0bd98b..02a75616768 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/NoFavoriteProjects.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/NoFavoriteProjects.tsx @@ -50,20 +50,25 @@ export function NoFavoriteProjects(props: StateProps & OwnProps) { {translate('provisioning.analyze_new_project')} </Button> - <Dropdown - className="display-inline-block big-spacer-left" - overlay={ - <ul className="menu"> - {sortBy(props.organizations, org => org.name.toLowerCase()).map(organization => ( - <OrganizationListItem key={organization.key} organization={organization} /> - ))} - </ul> - }> - <a className="button" href="#"> - {translate('projects.no_favorite_projects.favorite_projects_from_orgs')} - <DropdownIcon className="little-spacer-left" /> - </a> - </Dropdown> + {props.organizations.length > 0 && ( + <Dropdown + className="display-inline-block big-spacer-left" + overlay={ + <ul className="menu"> + {sortBy(props.organizations, org => org.name.toLowerCase()).map( + organization => ( + <OrganizationListItem key={organization.key} organization={organization} /> + ) + )} + </ul> + }> + <a className="button" href="#"> + {translate('projects.no_favorite_projects.favorite_projects_from_orgs')} + <DropdownIcon className="little-spacer-left" /> + </a> + </Dropdown> + )} + <Link className="button big-spacer-left" to="/explore/projects"> {translate('projects.no_favorite_projects.favorite_public_projects')} </Link> diff --git a/server/sonar-web/src/main/js/apps/projects/components/__tests__/NoFavoriteProjects-test.tsx b/server/sonar-web/src/main/js/apps/projects/components/__tests__/NoFavoriteProjects-test.tsx index 8cdc9b4a4e7..0f8947329a5 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/__tests__/NoFavoriteProjects-test.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/__tests__/NoFavoriteProjects-test.tsx @@ -31,7 +31,14 @@ it('renders', () => { ).toMatchSnapshot(); }); -it('renders for SonarCloud', () => { +it('renders for SonarCloud without organizations', () => { + (isSonarCloud as jest.Mock).mockImplementation(() => true); + expect( + shallow(<NoFavoriteProjects openProjectOnboarding={jest.fn()} organizations={[]} />) + ).toMatchSnapshot(); +}); + +it('renders for SonarCloud with organizations', () => { (isSonarCloud as jest.Mock).mockImplementation(() => true); const organizations: T.Organization[] = [ { actions: { admin: true }, key: 'org1', name: 'org1', projectVisibility: 'public' }, diff --git a/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/NoFavoriteProjects-test.tsx.snap b/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/NoFavoriteProjects-test.tsx.snap index d1b0a82b9c6..0aa5d44907e 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/NoFavoriteProjects-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/NoFavoriteProjects-test.tsx.snap @@ -29,7 +29,7 @@ exports[`renders 1`] = ` </div> `; -exports[`renders for SonarCloud 1`] = ` +exports[`renders for SonarCloud with organizations 1`] = ` <div className="projects-empty-list" > @@ -105,3 +105,37 @@ exports[`renders for SonarCloud 1`] = ` </div> </div> `; + +exports[`renders for SonarCloud without organizations 1`] = ` +<div + className="projects-empty-list" +> + <h3> + projects.no_favorite_projects + </h3> + <div + className="spacer-top" + > + <p> + projects.no_favorite_projects.how_to_add_projects + </p> + <div + className="huge-spacer-top" + > + <Button + onClick={[MockFunction]} + > + provisioning.analyze_new_project + </Button> + <Link + className="button big-spacer-left" + onlyActiveOnIndex={false} + style={Object {}} + to="/explore/projects" + > + projects.no_favorite_projects.favorite_public_projects + </Link> + </div> + </div> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/App.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/App.tsx index 29b60956fca..7de9677ea73 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/App.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/App.tsx @@ -31,7 +31,7 @@ import Suggestions from '../../app/components/embed-docs-modal/Suggestions'; import { getComponents, Project } from '../../api/components'; export interface Props { - currentUser: { login: string }; + currentUser: Pick<T.LoggedInUser, 'login'>; hasProvisionPermission?: boolean; onOrganizationUpgrade: () => void; onVisibilityChange: (visibility: T.Visibility) => void; diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx index 1273e30ffd3..4e99b7f715b 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx @@ -27,7 +27,7 @@ import DateTooltipFormatter from '../../components/intl/DateTooltipFormatter'; import { Project } from '../../api/components'; interface Props { - currentUser: { login: string }; + currentUser: Pick<T.LoggedInUser, 'login'>; onProjectCheck: (project: Project, checked: boolean) => void; organization: string | undefined; project: Project; diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRowActions.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRowActions.tsx index abff099d1ee..a2578797b24 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRowActions.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRowActions.tsx @@ -29,7 +29,7 @@ import { getComponentNavigation } from '../../api/nav'; import { getComponentPermissionsUrl } from '../../helpers/urls'; export interface Props { - currentUser: { login: string }; + currentUser: Pick<T.LoggedInUser, 'login'>; organization: string | undefined; project: Project; } diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx index d644c5410d2..5c89aaac9b1 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx @@ -24,7 +24,7 @@ import ProjectRow from './ProjectRow'; import { Project } from '../../api/components'; interface Props { - currentUser: { login: string }; + currentUser: Pick<T.LoggedInUser, 'login'>; onProjectDeselected: (project: string) => void; onProjectSelected: (project: string) => void; organization: T.Organization; diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/RestoreAccessModal.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/RestoreAccessModal.tsx index 89fc396db7d..ad11a0f3ded 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/RestoreAccessModal.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/RestoreAccessModal.tsx @@ -26,7 +26,7 @@ import { grantPermissionToUser } from '../../api/permissions'; import { Project } from '../../api/components'; interface Props { - currentUser: { login: string }; + currentUser: Pick<T.LoggedInUser, 'login'>; onClose: () => void; onRestoreAccess: () => void; project: Project; diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissions.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissions.tsx index a9816e9d4be..dc919c071df 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissions.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissions.tsx @@ -31,12 +31,6 @@ import { } from '../../../api/quality-profiles'; import { Profile } from '../types'; -export interface User { - avatar?: string; - login: string; - name: string; -} - export interface Group { name: string; } @@ -50,7 +44,7 @@ interface State { addUserForm: boolean; groups?: Group[]; loading: boolean; - users?: User[]; + users?: T.UserSelected[]; } export default class ProfilePermissions extends React.PureComponent<Props, State> { @@ -112,7 +106,7 @@ export default class ProfilePermissions extends React.PureComponent<Props, State } }; - handleUserAdd = (addedUser: User) => { + handleUserAdd = (addedUser: T.UserSelected) => { if (this.mounted) { this.setState((state: State) => ({ addUserForm: false, @@ -121,7 +115,7 @@ export default class ProfilePermissions extends React.PureComponent<Props, State } }; - handleUserDelete = (removedUser: User) => { + handleUserDelete = (removedUser: T.UserSelected) => { if (this.mounted) { this.setState((state: State) => ({ users: state.users && state.users.filter(user => user !== removedUser) diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsForm.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsForm.tsx index 574063aaa4a..4e93e4f12d4 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsForm.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsForm.tsx @@ -21,8 +21,8 @@ import * as React from 'react'; import { translate } from 'sonar-ui-common/helpers/l10n'; import { SubmitButton, ResetButtonLink } from 'sonar-ui-common/components/controls/buttons'; import Modal from 'sonar-ui-common/components/controls/Modal'; -import { User, Group } from './ProfilePermissions'; import ProfilePermissionsFormSelect from './ProfilePermissionsFormSelect'; +import { Group } from './ProfilePermissions'; import { searchUsers, searchGroups, @@ -34,13 +34,13 @@ import { interface Props { onClose: () => void; onGroupAdd: (group: Group) => void; - onUserAdd: (user: User) => void; + onUserAdd: (user: T.UserSelected) => void; organization?: string; profile: { language: string; name: string }; } interface State { - selected?: User | Group; + selected?: T.UserSelected | Group; submitting: boolean; } @@ -62,7 +62,7 @@ export default class ProfilePermissionsForm extends React.PureComponent<Props, S } }; - handleUserAdd = (user: User) => + handleUserAdd = (user: T.UserSelected) => addUser({ language: this.props.profile.language, login: user.login, @@ -83,8 +83,8 @@ export default class ProfilePermissionsForm extends React.PureComponent<Props, S const { selected } = this.state; if (selected) { this.setState({ submitting: true }); - if ((selected as User).login !== undefined) { - this.handleUserAdd(selected as User); + if ((selected as T.UserSelected).login !== undefined) { + this.handleUserAdd(selected as T.UserSelected); } else { this.handleGroupAdd(selected as Group); } @@ -105,7 +105,7 @@ export default class ProfilePermissionsForm extends React.PureComponent<Props, S ); }; - handleValueChange = (selected: User | Group) => { + handleValueChange = (selected: T.UserSelected | Group) => { this.setState({ selected }); }; diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsFormSelect.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsFormSelect.tsx index 3599277ea52..7d3ef0b6a1d 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsFormSelect.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsFormSelect.tsx @@ -21,11 +21,11 @@ import * as React from 'react'; import GroupIcon from 'sonar-ui-common/components/icons/GroupIcon'; import { debounce, identity } from 'lodash'; import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; -import { User, Group } from './ProfilePermissions'; +import { Group } from './ProfilePermissions'; import Select from '../../../components/controls/Select'; import Avatar from '../../../components/ui/Avatar'; -type Option = User | Group; +type Option = T.UserSelected | Group; type OptionWithValue = Option & { value: string }; interface Props { @@ -112,8 +112,8 @@ export default class ProfilePermissionsFormSelect extends React.PureComponent<Pr } } -function isUser(option: Option): option is User { - return (option as User).login !== undefined; +function isUser(option: Option): option is T.UserSelected { + return (option as T.UserSelected).login !== undefined; } function getStringValue(option: Option) { diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsUser.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsUser.tsx index 196ba45d7e9..5498efdc9b2 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsUser.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsUser.tsx @@ -26,15 +26,14 @@ import { ResetButtonLink } from 'sonar-ui-common/components/controls/buttons'; import SimpleModal, { ChildrenProps } from 'sonar-ui-common/components/controls/SimpleModal'; -import { User } from './ProfilePermissions'; import { removeUser } from '../../../api/quality-profiles'; import Avatar from '../../../components/ui/Avatar'; interface Props { - onDelete: (user: User) => void; + onDelete: (user: T.UserSelected) => void; organization?: string; profile: { language: string; name: string }; - user: User; + user: T.UserSelected; } interface State { diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsUser-test.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsUser-test.tsx index 027390687f1..e70e777f7d6 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsUser-test.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsUser-test.tsx @@ -17,23 +17,21 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -/* eslint-disable import/first, import/order */ -jest.mock('../../../../api/quality-profiles', () => ({ - removeUser: jest.fn(() => Promise.resolve()) -})); - import * as React from 'react'; import { shallow } from 'enzyme'; -import ProfilePermissionsUser from '../ProfilePermissionsUser'; import { click } from 'sonar-ui-common/helpers/testUtils'; +import ProfilePermissionsUser from '../ProfilePermissionsUser'; +import { removeUser } from '../../../../api/quality-profiles'; -const removeUser = require('../../../../api/quality-profiles').removeUser as jest.Mock<any>; +jest.mock('../../../../api/quality-profiles', () => ({ + removeUser: jest.fn(() => Promise.resolve()) +})); const profile = { language: 'js', name: 'Sonar way' }; -const user = { login: 'luke', name: 'Luke Skywalker' }; +const user: T.UserSelected = { login: 'luke', name: 'Luke Skywalker', selected: true }; beforeEach(() => { - removeUser.mockClear(); + jest.clearAllMocks(); }); it('renders', () => { diff --git a/server/sonar-web/src/main/js/apps/sessions/components/Logout.tsx b/server/sonar-web/src/main/js/apps/sessions/components/Logout.tsx index 0ebfab02599..0bba08201fe 100644 --- a/server/sonar-web/src/main/js/apps/sessions/components/Logout.tsx +++ b/server/sonar-web/src/main/js/apps/sessions/components/Logout.tsx @@ -29,12 +29,12 @@ interface Props { doLogout: () => Promise<void>; } -class Logout extends React.PureComponent<Props> { +export class Logout extends React.PureComponent<Props> { componentDidMount() { this.props.doLogout().then( () => { RecentHistory.clear(); - window.location.href = getBaseUrl() + '/'; + window.location.replace(getBaseUrl() + '/'); }, () => {} ); diff --git a/server/sonar-web/src/main/js/apps/sessions/components/__tests__/Logout-test.tsx b/server/sonar-web/src/main/js/apps/sessions/components/__tests__/Logout-test.tsx new file mode 100644 index 00000000000..dcd7df7c9e4 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/sessions/components/__tests__/Logout-test.tsx @@ -0,0 +1,50 @@ +/* + * 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 { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; +import { Logout } from '../Logout'; + +it('should logout correctly', async () => { + const doLogout = jest.fn().mockResolvedValue(true); + window.location.replace = jest.fn(); + + const wrapper = shallowRender({ doLogout }); + await waitAndUpdate(wrapper); + + expect(doLogout).toHaveBeenCalled(); + expect(window.location.replace).toHaveBeenCalledWith('/'); +}); + +it('should not redirect if logout fails', async () => { + const doLogout = jest.fn().mockRejectedValue(false); + window.location.replace = jest.fn(); + + const wrapper = shallowRender({ doLogout }); + await waitAndUpdate(wrapper); + + expect(doLogout).toHaveBeenCalled(); + expect(window.location.replace).not.toHaveBeenCalled(); + expect(wrapper).toMatchSnapshot(); +}); + +function shallowRender(props: Partial<Logout['props']> = {}) { + return shallow(<Logout doLogout={jest.fn()} {...props} />); +} diff --git a/server/sonar-web/src/main/js/apps/sessions/components/__tests__/__snapshots__/Logout-test.tsx.snap b/server/sonar-web/src/main/js/apps/sessions/components/__tests__/__snapshots__/Logout-test.tsx.snap new file mode 100644 index 00000000000..f510e29fcf7 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/sessions/components/__tests__/__snapshots__/Logout-test.tsx.snap @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should not redirect if logout fails 1`] = ` +<div + className="page page-limited" +> + <Connect(GlobalMessages) /> + <div + className="text-center" + > + logging_out + </div> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx b/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx index bf7ea8f38d1..a193e71bcce 100644 --- a/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx @@ -26,7 +26,7 @@ import { deactivateUser } from '../../../api/users'; export interface Props { onClose: () => void; onUpdateUsers: () => void; - user: T.User; + user: T.UserActive; } interface State { diff --git a/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx b/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx index 43b4273c75c..d4248014214 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx @@ -26,6 +26,7 @@ import ActionsDropdown, { import DeactivateForm from './DeactivateForm'; import PasswordForm from './PasswordForm'; import UserForm from './UserForm'; +import { isUserActive } from '../../../helpers/users'; interface Props { isCurrentUser: boolean; @@ -40,10 +41,21 @@ interface State { export default class UserActions extends React.PureComponent<Props, State> { state: State = {}; - handleOpenDeactivateForm = () => this.setState({ openForm: 'deactivate' }); - handleOpenPasswordForm = () => this.setState({ openForm: 'password' }); - handleOpenUpdateForm = () => this.setState({ openForm: 'update' }); - handleCloseForm = () => this.setState({ openForm: undefined }); + handleOpenDeactivateForm = () => { + this.setState({ openForm: 'deactivate' }); + }; + + handleOpenPasswordForm = () => { + this.setState({ openForm: 'password' }); + }; + + handleOpenUpdateForm = () => { + this.setState({ openForm: 'update' }); + }; + + handleCloseForm = () => { + this.setState({ openForm: undefined }); + }; renderActions = () => { const { user } = this.props; @@ -60,12 +72,14 @@ export default class UserActions extends React.PureComponent<Props, State> { </ActionsDropdownItem> )} <ActionsDropdownDivider /> - <ActionsDropdownItem - className="js-user-deactivate" - destructive={true} - onClick={this.handleOpenDeactivateForm}> - {translate('users.deactivate')} - </ActionsDropdownItem> + {isUserActive(user) && ( + <ActionsDropdownItem + className="js-user-deactivate" + destructive={true} + onClick={this.handleOpenDeactivateForm}> + {translate('users.deactivate')} + </ActionsDropdownItem> + )} </ActionsDropdown> ); }; @@ -77,7 +91,7 @@ export default class UserActions extends React.PureComponent<Props, State> { return ( <> {this.renderActions()} - {openForm === 'deactivate' && ( + {openForm === 'deactivate' && isUserActive(user) && ( <DeactivateForm onClose={this.handleCloseForm} onUpdateUsers={onUpdateUsers} diff --git a/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx b/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx index 06e487685e6..584095c3779 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx @@ -22,11 +22,11 @@ import { uniq } from 'lodash'; import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; import { parseError } from 'sonar-ui-common/helpers/request'; import { Button, ResetButtonLink, SubmitButton } from 'sonar-ui-common/components/controls/buttons'; -import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal'; import { Alert } from 'sonar-ui-common/components/ui/Alert'; +import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal'; import UserScmAccountInput from './UserScmAccountInput'; -import { createUser, updateUser } from '../../../api/users'; import throwGlobalError from '../../../app/utils/throwGlobalError'; +import { createUser, updateUser } from '../../../api/users'; export interface Props { onClose: () => void; @@ -41,7 +41,6 @@ interface State { name: string; password: string; scmAccounts: string[]; - submitting: boolean; } export default class UserForm extends React.PureComponent<Props, State> { @@ -54,10 +53,9 @@ export default class UserForm extends React.PureComponent<Props, State> { this.state = { email: user.email || '', login: user.login, - name: user.name, + name: user.name || '', password: '', - scmAccounts: user.scmAccounts || [], - submitting: false + scmAccounts: user.scmAccounts || [] }; } else { this.state = { @@ -65,8 +63,7 @@ export default class UserForm extends React.PureComponent<Props, State> { login: '', name: '', password: '', - scmAccounts: [], - submitting: false + scmAccounts: [] }; } } @@ -84,7 +81,7 @@ export default class UserForm extends React.PureComponent<Props, State> { return throwGlobalError(error); } else { return parseError(error).then( - errorMsg => this.setState({ error: errorMsg, submitting: false }), + errorMsg => this.setState({ error: errorMsg }), throwGlobalError ); } @@ -103,8 +100,7 @@ export default class UserForm extends React.PureComponent<Props, State> { this.setState({ password: event.currentTarget.value }); handleCreateUser = () => { - this.setState({ submitting: true }); - createUser({ + return createUser({ email: this.state.email || undefined, login: this.state.login, name: this.state.name, @@ -118,9 +114,7 @@ export default class UserForm extends React.PureComponent<Props, State> { handleUpdateUser = () => { const { user } = this.props; - - this.setState({ submitting: true }); - updateUser({ + return updateUser({ email: user!.local ? this.state.email : undefined, login: this.state.login, name: user!.local ? this.state.name : undefined, diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UserActions-test.tsx b/server/sonar-web/src/main/js/apps/users/components/__tests__/UserActions-test.tsx index 6002475bbbe..58d79168075 100644 --- a/server/sonar-web/src/main/js/apps/users/components/__tests__/UserActions-test.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/UserActions-test.tsx @@ -34,23 +34,37 @@ it('should render correctly', () => { expect(getWrapper()).toMatchSnapshot(); }); -it('should display change password action', () => { +it('should open the update form', () => { + const wrapper = getWrapper(); + click(wrapper.find('.js-user-update')); expect( - getWrapper({ user: { ...user, local: true } }) - .find('.js-user-change-password') + wrapper + .first() + .find('UserForm') .exists() - ).toBeTruthy(); + ).toBe(true); }); -it('should open the update form', () => { +it('should open the password form', () => { + const wrapper = getWrapper({ user: { ...user, local: true } }); + click(wrapper.find('.js-user-change-password')); + expect( + wrapper + .first() + .find('PasswordForm') + .exists() + ).toBe(true); +}); + +it('should open the deactivate form', () => { const wrapper = getWrapper(); - click(wrapper.find('.js-user-update')); + click(wrapper.find('.js-user-deactivate')); expect( wrapper .first() - .find('UserForm') + .find('DeactivateForm') .exists() - ).toBeTruthy(); + ).toBe(true); }); function getWrapper(props = {}) { |