aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main/js
diff options
context:
space:
mode:
authorBenoit <benoit.gianinetti@sonarsource.com>2019-07-12 14:06:47 +0200
committerSonarTech <sonartech@sonarsource.com>2019-07-12 20:21:16 +0200
commit7f1afd8ce4723dad04762837efec3b4b2525dff5 (patch)
tree868fce46d90be48623e237c195a64e9aff091696 /server/sonar-web/src/main/js
parentc2d9ced3637a5aa08422427e6435aaecaf659feb (diff)
downloadsonarqube-7f1afd8ce4723dad04762837efec3b4b2525dff5.tar.gz
sonarqube-7f1afd8ce4723dad04762837efec3b4b2525dff5.zip
MMF-769 User can close their account (#1861)
Diffstat (limited to 'server/sonar-web/src/main/js')
-rw-r--r--server/sonar-web/src/main/js/api/issues.ts14
-rw-r--r--server/sonar-web/src/main/js/api/organizations.ts6
-rw-r--r--server/sonar-web/src/main/js/api/quality-profiles.ts9
-rw-r--r--server/sonar-web/src/main/js/api/user_groups.ts8
-rw-r--r--server/sonar-web/src/main/js/app/components/AccountDeleted.tsx53
-rw-r--r--server/sonar-web/src/main/js/app/components/__tests__/AccountDeleted-test.tsx27
-rw-r--r--server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/AccountDeleted-test.tsx.snap53
-rw-r--r--server/sonar-web/src/main/js/app/styles/init/lists.css4
-rw-r--r--server/sonar-web/src/main/js/app/types.d.ts66
-rw-r--r--server/sonar-web/src/main/js/app/utils/startReactApp.tsx6
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/Profile.tsx9
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccount.tsx125
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccountContent.tsx148
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/UserDeleteAccountModal.tsx150
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccount-test.tsx87
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccountContent-test.tsx71
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/__tests__/UserDeleteAccountModal-test.tsx86
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccount-test.tsx.snap118
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccountContent-test.tsx.snap128
-rw-r--r--server/sonar-web/src/main/js/apps/account/profile/__tests__/__snapshots__/UserDeleteAccountModal-test.tsx.snap19
-rw-r--r--server/sonar-web/src/main/js/apps/custom-measures/components/Item.tsx9
-rw-r--r--server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/Item-test.tsx18
-rw-r--r--server/sonar-web/src/main/js/apps/custom-measures/components/__tests__/__snapshots__/Item-test.tsx.snap87
-rw-r--r--server/sonar-web/src/main/js/apps/groups/components/EditMembersModal.tsx9
-rw-r--r--server/sonar-web/src/main/js/apps/issues/components/App.tsx5
-rw-r--r--server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx8
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx51
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx11
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx45
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap56
-rw-r--r--server/sonar-web/src/main/js/apps/issues/utils.ts13
-rw-r--r--server/sonar-web/src/main/js/apps/projects/components/NoFavoriteProjects.tsx33
-rw-r--r--server/sonar-web/src/main/js/apps/projects/components/__tests__/NoFavoriteProjects-test.tsx9
-rw-r--r--server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/NoFavoriteProjects-test.tsx.snap36
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/App.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/ProjectRowActions.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/RestoreAccessModal.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissions.tsx12
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsForm.tsx14
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsFormSelect.tsx8
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsUser.tsx5
-rw-r--r--server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsUser-test.tsx16
-rw-r--r--server/sonar-web/src/main/js/apps/sessions/components/Logout.tsx4
-rw-r--r--server/sonar-web/src/main/js/apps/sessions/components/__tests__/Logout-test.tsx50
-rw-r--r--server/sonar-web/src/main/js/apps/sessions/components/__tests__/__snapshots__/Logout-test.tsx.snap14
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/UserActions.tsx36
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/UserForm.tsx22
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/__tests__/UserActions-test.tsx30
-rw-r--r--server/sonar-web/src/main/js/components/controls/ValidationModal.tsx5
-rw-r--r--server/sonar-web/src/main/js/components/controls/__tests__/ValidationModal-test.tsx1
-rw-r--r--server/sonar-web/src/main/js/components/issue/components/IssueAssign.tsx32
-rw-r--r--server/sonar-web/src/main/js/components/issue/components/IssueChangelogDiff.tsx8
-rw-r--r--server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.tsx14
-rw-r--r--server/sonar-web/src/main/js/components/issue/components/__tests__/IssueCommentLine-test.tsx50
-rw-r--r--server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueAssign-test.tsx.snap68
-rw-r--r--server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.tsx.snap19
-rw-r--r--server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.tsx46
-rw-r--r--server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx33
-rw-r--r--server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.tsx12
-rw-r--r--server/sonar-web/src/main/js/components/issue/popups/__tests__/ChangelogPopup-test.tsx62
-rw-r--r--server/sonar-web/src/main/js/components/issue/popups/__tests__/SetAssigneePopup-test.tsx81
-rw-r--r--server/sonar-web/src/main/js/components/issue/popups/__tests__/SimilarIssuesPopup-test.tsx59
-rw-r--r--server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.tsx.snap66
-rw-r--r--server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetAssigneePopup-test.tsx.snap70
-rw-r--r--server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SimilarIssuesPopup-test.tsx.snap151
-rw-r--r--server/sonar-web/src/main/js/helpers/issues.ts8
-rw-r--r--server/sonar-web/src/main/js/helpers/users.ts4
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);
+}