diff options
author | Benoit <benoit.gianinetti@sonarsource.com> | 2019-07-12 14:06:47 +0200 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2019-07-12 20:21:16 +0200 |
commit | 7f1afd8ce4723dad04762837efec3b4b2525dff5 (patch) | |
tree | 868fce46d90be48623e237c195a64e9aff091696 /server/sonar-web/src | |
parent | c2d9ced3637a5aa08422427e6435aaecaf659feb (diff) | |
download | sonarqube-7f1afd8ce4723dad04762837efec3b4b2525dff5.tar.gz sonarqube-7f1afd8ce4723dad04762837efec3b4b2525dff5.zip |
MMF-769 User can close their account (#1861)
Diffstat (limited to 'server/sonar-web/src')
70 files changed, 2225 insertions, 364 deletions
diff --git a/server/sonar-web/src/main/js/api/issues.ts b/server/sonar-web/src/main/js/api/issues.ts index b2a5c560825..c8ce98cf84d 100644 --- a/server/sonar-web/src/main/js/api/issues.ts +++ b/server/sonar-web/src/main/js/api/issues.ts @@ -26,7 +26,7 @@ export interface IssueResponse { components?: Array<{ key: string; name: string }>; issue: RawIssue; rules?: Array<{}>; - users?: Array<{ login: string }>; + users?: Array<T.UserBase>; } interface IssuesResponse { @@ -37,13 +37,9 @@ interface IssuesResponse { values: { count: number; val: string }[]; }>; issues: RawIssue[]; - paging: { - pageIndex: number; - pageSize: number; - total: number; - }; + paging: T.Paging; rules?: Array<{}>; - users?: { login: string }[]; + users?: Array<T.UserBase>; } type FacetName = @@ -109,8 +105,8 @@ export function searchIssueTags(data: { .catch(throwGlobalError); } -export function getIssueChangelog(issue: string): Promise<any> { - return getJSON('/api/issues/changelog', { issue }).then(r => r.changelog, throwGlobalError); +export function getIssueChangelog(issue: string): Promise<{ changelog: T.IssueChangelog[] }> { + return getJSON('/api/issues/changelog', { issue }).catch(throwGlobalError); } export function getIssueFilters() { diff --git a/server/sonar-web/src/main/js/api/organizations.ts b/server/sonar-web/src/main/js/api/organizations.ts index 1ce52b9344a..191d100af54 100644 --- a/server/sonar-web/src/main/js/api/organizations.ts +++ b/server/sonar-web/src/main/js/api/organizations.ts @@ -52,6 +52,12 @@ export function getOrganizationNavigation(key: string): Promise<GetOrganizationN ); } +export function getOrganizationsThatPreventDeletion(): Promise<{ + organizations: T.Organization[]; +}> { + return getJSON('/api/organizations/prevent_user_deletion').catch(throwGlobalError); +} + export function createOrganization( data: T.OrganizationBase & { installationId?: string } ): Promise<T.Organization> { diff --git a/server/sonar-web/src/main/js/api/quality-profiles.ts b/server/sonar-web/src/main/js/api/quality-profiles.ts index e0f1c948aa9..150276ecb21 100644 --- a/server/sonar-web/src/main/js/api/quality-profiles.ts +++ b/server/sonar-web/src/main/js/api/quality-profiles.ts @@ -194,13 +194,8 @@ export interface SearchUsersGroupsParameters { selected?: 'all' | 'selected' | 'deselected'; } -export interface SearchUsersResponse { - users: Array<{ - avatar?: string; - login: string; - name: string; - selected?: boolean; - }>; +interface SearchUsersResponse { + users: T.UserSelected[]; paging: T.Paging; } diff --git a/server/sonar-web/src/main/js/api/user_groups.ts b/server/sonar-web/src/main/js/api/user_groups.ts index 14d3900f942..7a3c88b0d33 100644 --- a/server/sonar-web/src/main/js/api/user_groups.ts +++ b/server/sonar-web/src/main/js/api/user_groups.ts @@ -30,12 +30,6 @@ export function searchUsersGroups(data: { return getJSON('/api/user_groups/search', data).catch(throwGlobalError); } -export interface GroupUser { - login: string; - name: string; - selected: boolean; -} - export function getUsersInGroup(data: { id?: number; name?: string; @@ -44,7 +38,7 @@ export function getUsersInGroup(data: { ps?: number; q?: string; selected?: string; -}): Promise<T.Paging & { users: GroupUser[] }> { +}): Promise<T.Paging & { users: T.UserSelected[] }> { return getJSON('/api/user_groups/users', data).catch(throwGlobalError); } diff --git a/server/sonar-web/src/main/js/app/components/AccountDeleted.tsx b/server/sonar-web/src/main/js/app/components/AccountDeleted.tsx new file mode 100644 index 00000000000..d91059bf630 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/AccountDeleted.tsx @@ -0,0 +1,53 @@ +/* + * 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'; + +export default function AccountDeleted() { + return ( + <div className="page-wrapper-simple display-flex-column"> + <Alert className="huge-spacer-bottom" variant="success"> + {translate('my_profile.delete_account.success')} + </Alert> + + <div className="page-simple text-center"> + <p className="big-spacer-bottom"> + <h1>{translate('my_profile.delete_account.feedback.reason.explanation')}</h1> + </p> + <p className="spacer-bottom"> + <FormattedMessage + defaultMessage={translate('my_profile.delete_account.feedback.call_to_action')} + id="my_profile.delete_account.feedback.call_to_action" + values={{ + link: <Link to="/about/contact">{translate('footer.contact_us')}</Link> + }} + /> + </p> + <p> + <Link to="/">{translate('go_back_to_homepage')}</Link> + </p> + </div> + </div> + ); +} diff --git a/server/sonar-web/src/main/js/app/components/__tests__/AccountDeleted-test.tsx b/server/sonar-web/src/main/js/app/components/__tests__/AccountDeleted-test.tsx new file mode 100644 index 00000000000..911ce7ae432 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/__tests__/AccountDeleted-test.tsx @@ -0,0 +1,27 @@ +/* + * 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 AccountDeleted from '../AccountDeleted'; + +it('should render correctly', () => { + expect(shallow(<AccountDeleted />)).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/AccountDeleted-test.tsx.snap b/server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/AccountDeleted-test.tsx.snap new file mode 100644 index 00000000000..8bffcc4ae6e --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/AccountDeleted-test.tsx.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<div + className="page-wrapper-simple display-flex-column" +> + <Alert + className="huge-spacer-bottom" + variant="success" + > + my_profile.delete_account.success + </Alert> + <div + className="page-simple text-center" + > + <p + className="big-spacer-bottom" + > + <h1> + my_profile.delete_account.feedback.reason.explanation + </h1> + </p> + <p + className="spacer-bottom" + > + <FormattedMessage + defaultMessage="my_profile.delete_account.feedback.call_to_action" + id="my_profile.delete_account.feedback.call_to_action" + values={ + Object { + "link": <Link + onlyActiveOnIndex={false} + style={Object {}} + to="/about/contact" + > + footer.contact_us + </Link>, + } + } + /> + </p> + <p> + <Link + onlyActiveOnIndex={false} + style={Object {}} + to="/" + > + go_back_to_homepage + </Link> + </p> + </div> +</div> +`; diff --git a/server/sonar-web/src/main/js/app/styles/init/lists.css b/server/sonar-web/src/main/js/app/styles/init/lists.css index 42d19d67d32..fe2e6c9cd52 100644 --- a/server/sonar-web/src/main/js/app/styles/init/lists.css +++ b/server/sonar-web/src/main/js/app/styles/init/lists.css @@ -28,6 +28,10 @@ ul { padding-left: 40px; } +.list-styled.no-padding { + padding-left: calc(var(--gridSize) * 2); +} + ul.list-styled { list-style: disc; } diff --git a/server/sonar-web/src/main/js/app/types.d.ts b/server/sonar-web/src/main/js/app/types.d.ts index f1f0bc2e88b..415a5f2e276 100644 --- a/server/sonar-web/src/main/js/app/types.d.ts +++ b/server/sonar-web/src/main/js/app/types.d.ts @@ -239,12 +239,7 @@ declare namespace T { }; projectKey: string; pending?: boolean; - user: { - active?: boolean; - email?: string; - login: string; - name: string; - }; + user: T.UserBase; value: string; updatedAt?: string; } @@ -333,7 +328,7 @@ declare namespace T { export interface Issue { actions: string[]; assignee?: string; - assigneeActive?: string; + assigneeActive?: boolean; assigneeAvatar?: string; assigneeLogin?: string; assigneeName?: string; @@ -374,6 +369,21 @@ declare namespace T { type: T.IssueType; } + export interface IssueChangelog { + avatar?: string; + creationDate: string; + diffs: IssueChangelogDiff[]; + user: string; + isUserActive: boolean; + userName: string; + } + + export interface IssueChangelogDiff { + key: string; + newValue?: string; + oldValue?: string; + } + export interface IssueComment { author?: string; authorActive?: boolean; @@ -423,17 +433,14 @@ declare namespace T { open?: boolean; } - export interface LoggedInUser extends CurrentUser { - avatar?: string; - email?: string; + export interface LoggedInUser extends CurrentUser, UserActive { externalIdentity?: string; externalProvider?: string; groups: string[]; homepage?: HomePage; isLoggedIn: true; local?: boolean; - login: string; - name: string; + personalOrganization?: string; scmAccounts: string[]; settings?: CurrentUserSetting[]; } @@ -527,10 +534,7 @@ declare namespace T { url?: string; } - export interface OrganizationMember { - login: string; - name: string; - avatar?: string; + export interface OrganizationMember extends UserActive { groupCount?: number; } @@ -590,11 +594,7 @@ declare namespace T { permissions: string[]; } - export interface PermissionUser { - avatar?: string; - email?: string; - login: string; - name: string; + export interface PermissionUser extends UserActive { permissions: string[]; } @@ -987,21 +987,33 @@ declare namespace T { endOffset: number; } - export interface User { - active: boolean; - avatar?: string; - email?: string; + export interface User extends UserBase { externalIdentity?: string; externalProvider?: string; groups?: string[]; lastConnectionDate?: string; local: boolean; - login: string; - name: string; scmAccounts?: string[]; tokensCount?: number; } + export interface UserActive extends UserBase { + active?: true; + name: string; + } + + export interface UserBase { + active?: boolean; + avatar?: string; + email?: string; + login: string; + name?: string; + } + + export interface UserSelected extends UserActive { + selected: boolean; + } + export interface UserToken { name: string; createdAt: string; diff --git a/server/sonar-web/src/main/js/app/utils/startReactApp.tsx b/server/sonar-web/src/main/js/app/utils/startReactApp.tsx index 714cba17a37..6560bb4bef2 100644 --- a/server/sonar-web/src/main/js/app/utils/startReactApp.tsx +++ b/server/sonar-web/src/main/js/app/utils/startReactApp.tsx @@ -20,7 +20,7 @@ /* eslint-disable react/jsx-sort-props */ import * as React from 'react'; import { render } from 'react-dom'; -import { Router, Route, IndexRoute, Redirect, RouteProps, RouteConfig } from 'react-router'; +import { IndexRoute, Redirect, Route, RouteConfig, RouteProps, Router } from 'react-router'; import { Provider } from 'react-redux'; import { IntlProvider } from 'react-intl'; import { Location } from 'history'; @@ -202,6 +202,10 @@ export default function startReactApp( import('../../apps/feedback/downgrade/DowngradeFeedback') )} /> + <Route + path="account-deleted" + component={lazyLoad(() => import('../components/AccountDeleted'))} + /> </> )} <RouteWithChildRoutes path="organizations" childRoutes={organizationsRoutes} /> 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 = {}) { diff --git a/server/sonar-web/src/main/js/components/controls/ValidationModal.tsx b/server/sonar-web/src/main/js/components/controls/ValidationModal.tsx index 110bbc84237..841b3592be1 100644 --- a/server/sonar-web/src/main/js/components/controls/ValidationModal.tsx +++ b/server/sonar-web/src/main/js/components/controls/ValidationModal.tsx @@ -29,6 +29,7 @@ interface Props<V> extends ModalProps { confirmButtonText: string; header: string; initialValues: V; + isDestructive?: boolean; isInitialValid?: boolean; onClose: () => void; onSubmit: (data: V) => Promise<void>; @@ -64,7 +65,9 @@ export default class ValidationModal<V> extends React.PureComponent<Props<V>> { <footer className="modal-foot"> <DeferredSpinner className="spacer-right" loading={props.isSubmitting} /> - <SubmitButton disabled={props.isSubmitting || !props.isValid || !props.dirty}> + <SubmitButton + className={this.props.isDestructive ? 'button-red' : undefined} + disabled={props.isSubmitting || !props.isValid || !props.dirty}> {this.props.confirmButtonText} </SubmitButton> <ResetButtonLink disabled={props.isSubmitting} onClick={this.props.onClose}> diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/ValidationModal-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/ValidationModal-test.tsx index c09219ef13b..f4af7f14294 100644 --- a/server/sonar-web/src/main/js/components/controls/__tests__/ValidationModal-test.tsx +++ b/server/sonar-web/src/main/js/components/controls/__tests__/ValidationModal-test.tsx @@ -27,6 +27,7 @@ it('should render correctly', () => { confirmButtonText="confirm" header="title" initialValues={{ field: 'foo' }} + isDestructive={true} isInitialValid={true} onClose={jest.fn()} onSubmit={jest.fn()} diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueAssign.tsx b/server/sonar-web/src/main/js/components/issue/components/IssueAssign.tsx index c13bf63ea1b..544456a265c 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueAssign.tsx +++ b/server/sonar-web/src/main/js/components/issue/components/IssueAssign.tsx @@ -19,7 +19,7 @@ */ import * as React from 'react'; import DropdownIcon from 'sonar-ui-common/components/icons/DropdownIcon'; -import { translate } from 'sonar-ui-common/helpers/l10n'; +import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; import { ButtonLink } from 'sonar-ui-common/components/controls/buttons'; import Toggler from 'sonar-ui-common/components/controls/Toggler'; import Avatar from '../../ui/Avatar'; @@ -27,7 +27,10 @@ import SetAssigneePopup from '../popups/SetAssigneePopup'; interface Props { isOpen: boolean; - issue: Pick<T.Issue, 'assignee' | 'assigneeAvatar' | 'assigneeName' | 'projectOrganization'>; + issue: Pick< + T.Issue, + 'assignee' | 'assigneeActive' | 'assigneeAvatar' | 'assigneeName' | 'projectOrganization' + >; canAssign: boolean; onAssign: (login: string) => void; togglePopup: (popup: string, show?: boolean) => void; @@ -44,9 +47,12 @@ export default class IssueAssign extends React.PureComponent<Props> { renderAssignee() { const { issue } = this.props; - return ( - <span> - {issue.assignee && ( + const assignee = + issue.assigneeActive !== false ? issue.assigneeName || issue.assignee : issue.assignee; + + if (assignee) { + return ( + <> <span className="text-top"> <Avatar className="little-spacer-right" @@ -55,12 +61,16 @@ export default class IssueAssign extends React.PureComponent<Props> { size={16} /> </span> - )} - <span className="issue-meta-label"> - {issue.assignee ? issue.assigneeName || issue.assignee : translate('unassigned')} - </span> - </span> - ); + <span className="issue-meta-label"> + {issue.assigneeActive === false + ? translateWithParameters('user.x_deleted', assignee) + : assignee} + </span> + </> + ); + } + + return <span className="issue-meta-label">{translate('unassigned')}</span>; } render() { diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueChangelogDiff.tsx b/server/sonar-web/src/main/js/components/issue/components/IssueChangelogDiff.tsx index a4e84cbf7a9..1f1cccfd6b8 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueChangelogDiff.tsx +++ b/server/sonar-web/src/main/js/components/issue/components/IssueChangelogDiff.tsx @@ -21,14 +21,8 @@ import * as React from 'react'; import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; import { formatMeasure } from 'sonar-ui-common/helpers/measures'; -export interface ChangelogDiff { - key: string; - newValue?: string; - oldValue?: string; -} - interface Props { - diff: ChangelogDiff; + diff: T.IssueChangelogDiff; } export default function IssueChangelogDiff({ diff }: Props) { diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.tsx b/server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.tsx index 117697c19a7..5d351a94347 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.tsx +++ b/server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.tsx @@ -20,8 +20,9 @@ import * as React from 'react'; import { sanitize } from 'dompurify'; import { EditButton, DeleteButton } from 'sonar-ui-common/components/controls/buttons'; -import Toggler from 'sonar-ui-common/components/controls/Toggler'; import { PopupPlacement } from 'sonar-ui-common/components/ui/popups'; +import { translateWithParameters } from 'sonar-ui-common/helpers/l10n'; +import Toggler from 'sonar-ui-common/components/controls/Toggler'; import Avatar from '../../ui/Avatar'; import CommentDeletePopup from '../popups/CommentDeletePopup'; import CommentPopup from '../popups/CommentPopup'; @@ -77,16 +78,21 @@ export default class IssueCommentLine extends React.PureComponent<Props, State> render() { const { comment } = this.props; + const author = comment.authorName || comment.author; + const displayName = + comment.authorActive === false && author + ? translateWithParameters('user.x_deleted', author) + : author; return ( <div className="issue-comment"> - <div className="issue-comment-author" title={comment.authorName}> + <div className="issue-comment-author" title={displayName}> <Avatar className="little-spacer-right" hash={comment.authorAvatar} - name={comment.authorName || comment.author} + name={author} size={16} /> - {comment.authorName || comment.author} + {displayName} </div> <div className="issue-comment-text markdown" diff --git a/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueCommentLine-test.tsx b/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueCommentLine-test.tsx index 14f487b2950..b2c3ac6eddb 100644 --- a/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueCommentLine-test.tsx +++ b/server/sonar-web/src/main/js/components/issue/components/__tests__/IssueCommentLine-test.tsx @@ -23,6 +23,8 @@ import { click } from 'sonar-ui-common/helpers/testUtils'; import IssueCommentLine from '../IssueCommentLine'; const comment: T.IssueComment = { + author: 'john.doe', + authorActive: true, authorAvatar: 'gravatarhash', authorName: 'John Doe', createdAt: '2017-03-01T09:36:01+0100', @@ -32,32 +34,34 @@ const comment: T.IssueComment = { updatable: true }; -it('should render correctly a comment that is not updatable', () => { - const element = shallow( - <IssueCommentLine - comment={{ ...comment, updatable: false }} - onDelete={jest.fn()} - onEdit={jest.fn()} - /> - ); - expect(element).toMatchSnapshot(); +it('should render correctly a comment that is updatable', () => { + expect(shallowRender()).toMatchSnapshot(); }); -it('should render correctly a comment that is updatable', () => { - const element = shallow( - <IssueCommentLine comment={comment} onDelete={jest.fn()} onEdit={jest.fn()} /> - ); - expect(element).toMatchSnapshot(); +it('should render correctly a comment that is not updatable', () => { + expect(shallowRender({ comment: { ...comment, updatable: false } })).toMatchSnapshot(); }); it('should open the right popups when the buttons are clicked', () => { - const element = shallow( - <IssueCommentLine comment={comment} onDelete={jest.fn()} onEdit={jest.fn()} /> - ); - click(element.find('.js-issue-comment-edit')); - expect(element.state()).toMatchSnapshot(); - click(element.find('.js-issue-comment-delete')); - expect(element.state()).toMatchSnapshot(); - element.update(); - expect(element).toMatchSnapshot(); + const wrapper = shallowRender(); + click(wrapper.find('.js-issue-comment-edit')); + expect(wrapper.state()).toMatchSnapshot(); + click(wrapper.find('.js-issue-comment-delete')); + expect(wrapper.state()).toMatchSnapshot(); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); +}); + +it('should render correctly a comment with a deleted author', () => { + expect( + shallowRender({ + comment: { ...comment, authorActive: false, authorName: undefined } + }).find('.issue-comment-author') + ).toMatchSnapshot(); }); + +function shallowRender(props: Partial<IssueCommentLine['props']> = {}) { + return shallow( + <IssueCommentLine comment={comment} onDelete={jest.fn()} onEdit={jest.fn()} {...props} /> + ); +} diff --git a/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueAssign-test.tsx.snap b/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueAssign-test.tsx.snap index 0b8921f3cd4..2e2ccd87499 100644 --- a/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueAssign-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueAssign-test.tsx.snap @@ -18,7 +18,7 @@ exports[`should open the popup when the button is clicked 2`] = ` onRequestClose={[Function]} open={true} overlay={ - <Connect(SetAssigneePopup) + <Connect(withCurrentUser(SetAssigneePopup)) issue={ Object { "assignee": "john", @@ -35,22 +35,20 @@ exports[`should open the popup when the button is clicked 2`] = ` className="issue-action issue-action-with-options js-issue-assign" onClick={[Function]} > - <span> - <span - className="text-top" - > - <Connect(Avatar) - className="little-spacer-right" - hash="gravatarhash" - name="John Doe" - size={16} - /> - </span> - <span - className="issue-meta-label" - > - John Doe - </span> + <span + className="text-top" + > + <Connect(Avatar) + className="little-spacer-right" + hash="gravatarhash" + name="John Doe" + size={16} + /> + </span> + <span + className="issue-meta-label" + > + John Doe </span> <DropdownIcon className="little-spacer-left" @@ -69,7 +67,7 @@ exports[`should render with the action 1`] = ` onRequestClose={[Function]} open={false} overlay={ - <Connect(SetAssigneePopup) + <Connect(withCurrentUser(SetAssigneePopup)) issue={ Object { "assignee": "john", @@ -86,22 +84,20 @@ exports[`should render with the action 1`] = ` className="issue-action issue-action-with-options js-issue-assign" onClick={[Function]} > - <span> - <span - className="text-top" - > - <Connect(Avatar) - className="little-spacer-right" - hash="gravatarhash" - name="John Doe" - size={16} - /> - </span> - <span - className="issue-meta-label" - > - John Doe - </span> + <span + className="text-top" + > + <Connect(Avatar) + className="little-spacer-right" + hash="gravatarhash" + name="John Doe" + size={16} + /> + </span> + <span + className="issue-meta-label" + > + John Doe </span> <DropdownIcon className="little-spacer-left" @@ -112,7 +108,7 @@ exports[`should render with the action 1`] = ` `; exports[`should render without the action when the correct rights are missing 1`] = ` -<span> +<Fragment> <span className="text-top" > @@ -128,5 +124,5 @@ exports[`should render without the action when the correct rights are missing 1` > John Doe </span> -</span> +</Fragment> `; diff --git a/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.tsx.snap b/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.tsx.snap index 2b347f6d742..1f5b5388ac9 100644 --- a/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.tsx.snap @@ -57,6 +57,8 @@ exports[`should open the right popups when the buttons are clicked 3`] = ` <CommentPopup comment={ Object { + "author": "john.doe", + "authorActive": true, "authorAvatar": "gravatarhash", "authorName": "John Doe", "createdAt": "2017-03-01T09:36:01+0100", @@ -183,6 +185,8 @@ exports[`should render correctly a comment that is updatable 1`] = ` <CommentPopup comment={ Object { + "author": "john.doe", + "authorActive": true, "authorAvatar": "gravatarhash", "authorName": "John Doe", "createdAt": "2017-03-01T09:36:01+0100", @@ -226,3 +230,18 @@ exports[`should render correctly a comment that is updatable 1`] = ` </div> </div> `; + +exports[`should render correctly a comment with a deleted author 1`] = ` +<div + className="issue-comment-author" + title="user.x_deleted.john.doe" +> + <Connect(Avatar) + className="little-spacer-right" + hash="gravatarhash" + name="john.doe" + size={16} + /> + user.x_deleted.john.doe +</div> +`; diff --git a/server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.tsx b/server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.tsx index 2a023866e2a..2393284a645 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.tsx +++ b/server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.tsx @@ -18,35 +18,25 @@ * 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 { DropdownOverlay } from 'sonar-ui-common/components/controls/Dropdown'; import { PopupPlacement } from 'sonar-ui-common/components/ui/popups'; import { getIssueChangelog } from '../../../api/issues'; import Avatar from '../../ui/Avatar'; import DateTimeFormatter from '../../intl/DateTimeFormatter'; -import IssueChangelogDiff, { ChangelogDiff } from '../components/IssueChangelogDiff'; - -interface Changelog { - avatar?: string; - creationDate: string; - diffs: ChangelogDiff[]; - user: string; - userName: string; -} +import IssueChangelogDiff from '../components/IssueChangelogDiff'; interface Props { issue: Pick<T.Issue, 'author' | 'creationDate' | 'key'>; } interface State { - changelogs: Changelog[]; + changelog: T.IssueChangelog[]; } export default class ChangelogPopup extends React.PureComponent<Props, State> { mounted = false; - state: State = { - changelogs: [] - }; + state: State = { changelog: [] }; componentDidMount() { this.mounted = true; @@ -59,9 +49,9 @@ export default class ChangelogPopup extends React.PureComponent<Props, State> { loadChangelog() { getIssueChangelog(this.props.issue.key).then( - changelogs => { + ({ changelog }) => { if (this.mounted) { - this.setState({ changelogs }); + this.setState({ changelog }); } }, () => {} @@ -85,23 +75,23 @@ export default class ChangelogPopup extends React.PureComponent<Props, State> { </td> </tr> - {this.state.changelogs.map((item, idx) => ( + {this.state.changelog.map((item, idx) => ( <tr key={idx}> <td className="thin text-left text-top nowrap"> <DateTimeFormatter date={item.creationDate} /> </td> <td className="text-left text-top"> - {item.userName && ( - <p> - <Avatar - className="little-spacer-right" - hash={item.avatar} - name={item.userName} - size={16} - /> - {item.userName} - </p> - )} + <p> + <Avatar + className="little-spacer-right" + hash={item.avatar} + name={(item.isUserActive && item.userName) || item.user} + size={16} + /> + {item.isUserActive + ? item.userName || item.user + : translateWithParameters('user.x_deleted', item.user)} + </p> {item.diffs.map(diff => ( <IssueChangelogDiff diff={diff} key={diff.key} /> ))} diff --git a/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx b/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx index 52ed2d9d674..f019d02f673 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx +++ b/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx @@ -19,25 +19,17 @@ */ import * as React from 'react'; import { map } from 'lodash'; -import { connect } from 'react-redux'; import { translate } from 'sonar-ui-common/helpers/l10n'; import SearchBox from 'sonar-ui-common/components/controls/SearchBox'; import { DropdownOverlay } from 'sonar-ui-common/components/controls/Dropdown'; import Avatar from '../../ui/Avatar'; import SelectList from '../../common/SelectList'; import SelectListItem from '../../common/SelectListItem'; +import { withCurrentUser } from '../../hoc/withCurrentUser'; import { searchMembers } from '../../../api/organizations'; import { searchUsers } from '../../../api/users'; -import { getCurrentUser, Store } from '../../../store/rootReducer'; +import { isLoggedIn, isUserActive } from '../../../helpers/users'; import { isSonarCloud } from '../../../helpers/system'; -import { isLoggedIn } from '../../../helpers/users'; - -interface User { - avatar?: string; - email?: string; - login: string; - name: string; -} interface Props { currentUser: T.CurrentUser; @@ -48,13 +40,13 @@ interface Props { interface State { currentUser: string; query: string; - users: User[]; + users: T.UserActive[]; } const LIST_SIZE = 10; -class SetAssigneePopup extends React.PureComponent<Props, State> { - defaultUsersArray: User[]; +export class SetAssigneePopup extends React.PureComponent<Props, State> { + defaultUsersArray: T.UserActive[]; constructor(props: Props) { super(props); @@ -83,10 +75,11 @@ class SetAssigneePopup extends React.PureComponent<Props, State> { searchUsers({ q: query, ps: LIST_SIZE }).then(this.handleSearchResult, () => {}); }; - handleSearchResult = (response: { users: T.OrganizationMember[] }) => { + handleSearchResult = ({ users }: { users: T.UserBase[] }) => { + const activeUsers = users.filter(isUserActive); this.setState({ - users: response.users, - currentUser: response.users.length > 0 ? response.users[0].login : '' + users: activeUsers, + currentUser: activeUsers.length > 0 ? activeUsers[0].login : '' }); }; @@ -130,7 +123,7 @@ class SetAssigneePopup extends React.PureComponent<Props, State> { {!!user.login && ( <Avatar className="spacer-right" hash={user.avatar} name={user.name} size={16} /> )} - <span className="text-middle" style={{ marginLeft: !user.login ? 24 : undefined }}> + <span className="text-middle" style={{ marginLeft: user.login ? 24 : undefined }}> {user.name} </span> </SelectListItem> @@ -142,8 +135,4 @@ class SetAssigneePopup extends React.PureComponent<Props, State> { } } -const mapStateToProps = (state: Store) => ({ - currentUser: getCurrentUser(state) -}); - -export default connect(mapStateToProps)(SetAssigneePopup); +export default withCurrentUser(SetAssigneePopup); diff --git a/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.tsx b/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.tsx index 2b4c067d78f..baa824dedcf 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.tsx +++ b/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.tsx @@ -22,7 +22,7 @@ import IssueTypeIcon from 'sonar-ui-common/components/icons/IssueTypeIcon'; import QualifierIcon from 'sonar-ui-common/components/icons/QualifierIcon'; import TagsIcon from 'sonar-ui-common/components/icons/TagsIcon'; import { fileFromPath, limitComponentName } from 'sonar-ui-common/helpers/path'; -import { translate } from 'sonar-ui-common/helpers/l10n'; +import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; import { DropdownOverlay } from 'sonar-ui-common/components/controls/Dropdown'; import Avatar from '../../ui/Avatar'; import SelectList from '../../common/SelectList'; @@ -56,6 +56,8 @@ export default class SimilarIssuesPopup extends React.PureComponent<Props> { 'file' ].filter(item => item) as string[]; + const assignee = issue.assigneeName || issue.assignee; + return ( <DropdownOverlay noPadding={true}> <header className="menu-search"> @@ -87,16 +89,18 @@ export default class SimilarIssuesPopup extends React.PureComponent<Props> { </SelectListItem> <SelectListItem item="assignee"> - {issue.assignee != null ? ( + {assignee ? ( <span> {translate('assigned_to')} <Avatar className="little-spacer-left little-spacer-right" hash={issue.assigneeAvatar} - name={issue.assigneeName || issue.assignee} + name={assignee} size={16} /> - {issue.assigneeName || issue.assignee} + {issue.assigneeActive === false + ? translateWithParameters('user.x_deleted', assignee) + : assignee} </span> ) : ( translate('unassigned') diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/ChangelogPopup-test.tsx b/server/sonar-web/src/main/js/components/issue/popups/__tests__/ChangelogPopup-test.tsx index 358b5c3d1a3..8045eef9d53 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/__tests__/ChangelogPopup-test.tsx +++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/ChangelogPopup-test.tsx @@ -19,27 +19,61 @@ */ import * as React from 'react'; import { shallow } from 'enzyme'; +import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; import ChangelogPopup from '../ChangelogPopup'; +import { getIssueChangelog } from '../../../../api/issues'; -it('should render the changelog popup correctly', () => { - const element = shallow( - <ChangelogPopup - issue={{ - key: 'issuekey', - author: 'john.david.dalton@gmail.com', - creationDate: '2017-03-01T09:36:01+0100' - }} - /> - ); - element.setState({ - changelogs: [ +jest.mock('../../../../api/issues', () => ({ + getIssueChangelog: jest.fn().mockResolvedValue({ + changelog: [ { creationDate: '2017-03-01T09:36:01+0100', - userName: 'john.doe', + user: 'john.doe', + isUserActive: true, + userName: 'John Doe', avatar: 'gravatarhash', diffs: [{ key: 'severity', newValue: 'MINOR', oldValue: 'CRITICAL' }] } ] + }) +})); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('should render the changelog popup correctly', async () => { + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + expect(getIssueChangelog).toBeCalledWith('issuekey'); + expect(wrapper).toMatchSnapshot(); +}); + +it('should render the changelog popup when we have a deleted user', async () => { + (getIssueChangelog as jest.Mock).mockResolvedValueOnce({ + changelog: [ + { + creationDate: '2017-03-01T09:36:01+0100', + user: 'john.doe', + isUserActive: false, + diffs: [{ key: 'severity', newValue: 'MINOR', oldValue: 'CRITICAL' }] + } + ] }); - expect(element).toMatchSnapshot(); + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot(); }); + +function shallowRender(props: Partial<ChangelogPopup['props']> = {}) { + return shallow( + <ChangelogPopup + issue={{ + key: 'issuekey', + author: 'john.david.dalton@gmail.com', + creationDate: '2017-03-01T09:36:01+0100' + }} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/SetAssigneePopup-test.tsx b/server/sonar-web/src/main/js/components/issue/popups/__tests__/SetAssigneePopup-test.tsx new file mode 100644 index 00000000000..6eb8153484f --- /dev/null +++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/SetAssigneePopup-test.tsx @@ -0,0 +1,81 @@ +/* + * 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 { SetAssigneePopup } from '../SetAssigneePopup'; +import { mockLoggedInUser, mockUser } from '../../../../helpers/testMocks'; +import { searchMembers } from '../../../../api/organizations'; +import { searchUsers } from '../../../../api/users'; +import { isSonarCloud } from '../../../../helpers/system'; + +jest.mock('../../../../helpers/system', () => ({ + isSonarCloud: jest.fn().mockReturnValue(false) +})); + +jest.mock('../../../../api/organizations', () => { + const { mockUser } = jest.requireActual('../../../../helpers/testMocks'); + return { + searchMembers: jest.fn().mockResolvedValue({ + users: [mockUser(), mockUser({ active: false, login: 'foo', name: undefined })] + }) + }; +}); + +jest.mock('../../../../api/users', () => { + const { mockUser } = jest.requireActual('../../../../helpers/testMocks'); + return { searchUsers: jest.fn().mockResolvedValue({ users: [mockUser()] }) }; +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +it('should allow to search for a user on SQ', async () => { + const wrapper = shallowRender(); + wrapper.find('SearchBox').prop<Function>('onChange')('o'); + await waitAndUpdate(wrapper); + expect(searchUsers).toBeCalledWith({ q: 'o', ps: 10 }); + expect(wrapper.state('users')).toEqual([mockUser()]); +}); + +it('should allow to search for a user on SC', async () => { + (isSonarCloud as jest.Mock).mockReturnValueOnce(true); + const wrapper = shallowRender(); + wrapper.find('SearchBox').prop<Function>('onChange')('o'); + await waitAndUpdate(wrapper); + expect(searchMembers).toBeCalledWith({ organization: 'foo', q: 'o', ps: 10 }); + expect(wrapper.state('users')).toEqual([mockUser()]); +}); + +function shallowRender(props: Partial<SetAssigneePopup['props']> = {}) { + return shallow( + <SetAssigneePopup + currentUser={mockLoggedInUser()} + issue={{ projectOrganization: 'foo' }} + onSelect={jest.fn()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/SimilarIssuesPopup-test.tsx b/server/sonar-web/src/main/js/components/issue/popups/__tests__/SimilarIssuesPopup-test.tsx new file mode 100644 index 00000000000..962e2d0ca3a --- /dev/null +++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/SimilarIssuesPopup-test.tsx @@ -0,0 +1,59 @@ +/* + * 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 SimilarIssuesPopup from '../SimilarIssuesPopup'; +import { mockIssue } from '../../../../helpers/testMocks'; + +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +it('should render correctly when assigned', () => { + expect( + shallowRender({ + issue: mockIssue(false, { assignee: 'luke', assigneeName: 'Luke Skywalker' }) + }).find('SelectListItem[item="assignee"]') + ).toMatchSnapshot(); + + expect( + shallowRender({ issue: mockIssue(false, { assignee: 'luke', assigneeActive: false }) }).find( + 'SelectListItem[item="assignee"]' + ) + ).toMatchSnapshot(); +}); + +it('should filter properly', () => { + const issue = mockIssue(); + const onFilter = jest.fn(); + const wrapper = shallowRender({ issue, onFilter }); + wrapper.find('SelectList').prop<Function>('onSelect')('assignee'); + expect(onFilter).toBeCalledWith('assignee', issue); +}); + +function shallowRender(props: Partial<SimilarIssuesPopup['props']> = {}) { + return shallow( + <SimilarIssuesPopup + issue={mockIssue(false, { subProject: 'foo', subProjectName: 'Foo', tags: ['test-tag'] })} + onFilter={jest.fn()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.tsx.snap b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.tsx.snap index eb4ed4d2aed..2bf516e280b 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.tsx.snap @@ -42,10 +42,74 @@ exports[`should render the changelog popup correctly 1`] = ` <Connect(Avatar) className="little-spacer-right" hash="gravatarhash" + name="John Doe" + size={16} + /> + John Doe + </p> + <IssueChangelogDiff + diff={ + Object { + "key": "severity", + "newValue": "MINOR", + "oldValue": "CRITICAL", + } + } + key="severity" + /> + </td> + </tr> + </tbody> + </table> + </div> +</DropdownOverlay> +`; + +exports[`should render the changelog popup when we have a deleted user 1`] = ` +<DropdownOverlay + placement="bottom-right" +> + <div + className="menu is-container issue-changelog" + > + <table + className="spaced" + > + <tbody> + <tr> + <td + className="thin text-left text-top nowrap" + > + <DateTimeFormatter + date="2017-03-01T09:36:01+0100" + /> + </td> + <td + className="text-left text-top" + > + created_by john.david.dalton@gmail.com + </td> + </tr> + <tr + key="0" + > + <td + className="thin text-left text-top nowrap" + > + <DateTimeFormatter + date="2017-03-01T09:36:01+0100" + /> + </td> + <td + className="text-left text-top" + > + <p> + <Connect(Avatar) + className="little-spacer-right" name="john.doe" size={16} /> - john.doe + user.x_deleted.john.doe </p> <IssueChangelogDiff diff={ diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetAssigneePopup-test.tsx.snap b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetAssigneePopup-test.tsx.snap new file mode 100644 index 00000000000..b539ed1acf0 --- /dev/null +++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetAssigneePopup-test.tsx.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<DropdownOverlay + noPadding={true} +> + <div + className="multi-select" + > + <div + className="menu-search" + > + <SearchBox + autoFocus={true} + className="little-spacer-top" + minLength={2} + onChange={[Function]} + placeholder="search.search_for_users" + value="" + /> + </div> + <SelectList + currentItem="luke" + items={ + Array [ + "luke", + "", + ] + } + onSelect={[MockFunction]} + > + <SelectListItem + item="luke" + key="luke" + > + <Connect(Avatar) + className="spacer-right" + name="Skywalker" + size={16} + /> + <span + className="text-middle" + style={ + Object { + "marginLeft": 24, + } + } + > + Skywalker + </span> + </SelectListItem> + <SelectListItem + item="" + key="" + > + <span + className="text-middle" + style={ + Object { + "marginLeft": undefined, + } + } + > + unassigned + </span> + </SelectListItem> + </SelectList> + </div> +</DropdownOverlay> +`; diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SimilarIssuesPopup-test.tsx.snap b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SimilarIssuesPopup-test.tsx.snap new file mode 100644 index 00000000000..59070654247 --- /dev/null +++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SimilarIssuesPopup-test.tsx.snap @@ -0,0 +1,151 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<DropdownOverlay + noPadding={true} +> + <header + className="menu-search" + > + <h6> + issue.filter_similar_issues + </h6> + </header> + <SelectList + className="issues-similar-issues-menu" + currentItem="type" + items={ + Array [ + "type", + "severity", + "status", + "resolution", + "assignee", + "rule", + "tag###test-tag", + "project", + "module", + "file", + ] + } + onSelect={[Function]} + > + <SelectListItem + item="type" + > + <IssueTypeIcon + className="little-spacer-right" + query="BUG" + /> + issue.type.BUG + </SelectListItem> + <SelectListItem + item="severity" + > + <SeverityHelper + severity="MAJOR" + /> + </SelectListItem> + <SelectListItem + item="status" + > + <StatusHelper + status="OPEN" + /> + </SelectListItem> + <SelectListItem + item="resolution" + > + unresolved + </SelectListItem> + <SelectListItem + item="assignee" + > + unassigned + </SelectListItem> + <li + className="divider" + /> + <SelectListItem + item="rule" + > + foo + </SelectListItem> + <SelectListItem + item="tag###test-tag" + key="tag###test-tag" + > + <TagsIcon + className="icon-half-transparent little-spacer-right text-middle" + /> + <span + className="text-middle" + > + test-tag + </span> + </SelectListItem> + <li + className="divider" + /> + <SelectListItem + item="project" + > + <QualifierIcon + className="little-spacer-right" + qualifier="TRK" + /> + Foo + </SelectListItem> + <SelectListItem + item="module" + > + <QualifierIcon + className="little-spacer-right" + qualifier="BRC" + /> + Foo + </SelectListItem> + <SelectListItem + item="file" + > + <QualifierIcon + className="little-spacer-right" + qualifier="FIL" + /> + main.js + </SelectListItem> + </SelectList> +</DropdownOverlay> +`; + +exports[`should render correctly when assigned 1`] = ` +<SelectListItem + item="assignee" +> + <span> + assigned_to + <Connect(Avatar) + className="little-spacer-left little-spacer-right" + name="Luke Skywalker" + size={16} + /> + Luke Skywalker + </span> +</SelectListItem> +`; + +exports[`should render correctly when assigned 2`] = ` +<SelectListItem + item="assignee" +> + <span> + assigned_to + <Connect(Avatar) + className="little-spacer-left little-spacer-right" + name="luke" + size={16} + /> + user.x_deleted.luke + </span> +</SelectListItem> +`; diff --git a/server/sonar-web/src/main/js/helpers/issues.ts b/server/sonar-web/src/main/js/helpers/issues.ts index d0f90ebeb03..e7757004281 100644 --- a/server/sonar-web/src/main/js/helpers/issues.ts +++ b/server/sonar-web/src/main/js/helpers/issues.ts @@ -25,10 +25,6 @@ interface Comment { [x: string]: any; } -interface User { - login: string; -} - interface Rule {} interface Component { @@ -83,7 +79,7 @@ function injectRelational( return newFields; } -function injectCommentsRelational(issue: RawIssue, users?: User[]) { +function injectCommentsRelational(issue: RawIssue, users?: T.UserBase[]) { if (!issue.comments) { return {}; } @@ -158,7 +154,7 @@ function orderLocations(locations: T.FlowLocation[]) { export function parseIssueFromResponse( issue: RawIssue, components?: Component[], - users?: User[], + users?: T.UserBase[], rules?: Rule[] ): T.Issue { const { secondaryLocations, flows } = splitFlows(issue, components); diff --git a/server/sonar-web/src/main/js/helpers/users.ts b/server/sonar-web/src/main/js/helpers/users.ts index a12fe60f13a..cf2cc974c18 100644 --- a/server/sonar-web/src/main/js/helpers/users.ts +++ b/server/sonar-web/src/main/js/helpers/users.ts @@ -27,3 +27,7 @@ export function hasGlobalPermission(user: T.CurrentUser, permission: string): bo export function isLoggedIn(user: T.CurrentUser): user is T.LoggedInUser { return user.isLoggedIn; } + +export function isUserActive(user: T.UserBase): user is T.UserActive { + return user.active !== false && Boolean(user.name); +} |