]> source.dussan.org Git - sonarqube.git/commitdiff
rewrite notifications app in ts and drop from redux store (#233)
authorStas Vilchik <stas.vilchik@sonarsource.com>
Tue, 15 May 2018 09:09:13 +0000 (11:09 +0200)
committerSonarTech <sonartech@sonarsource.com>
Tue, 15 May 2018 18:20:50 +0000 (20:20 +0200)
40 files changed:
server/sonar-web/src/main/js/api/components.ts
server/sonar-web/src/main/js/api/notifications.ts
server/sonar-web/src/main/js/app/types.ts
server/sonar-web/src/main/js/apps/account/notifications/GlobalNotifications.js [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/GlobalNotifications.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/Notifications.js [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/NotificationsList.js [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/NotificationsList.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.js [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/Projects.js [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/GlobalNotifications-test.js [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/GlobalNotifications-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.js [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/NotificationsList-test.js [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/NotificationsList-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.js [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.js [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/GlobalNotifications-test.js.snap [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/GlobalNotifications-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.js.snap [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/NotificationsList-test.js.snap [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/NotificationsList-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.js.snap [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.js.snap [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/actions.js [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/types.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/routes.ts
server/sonar-web/src/main/js/components/controls/Select.tsx
server/sonar-web/src/main/js/store/notifications/duck.js [deleted file]
server/sonar-web/src/main/js/store/rootReducer.js

index 4505e8e928243555047f58250d7bbcadaa535dc3..78e2b72d96a16c017e480ab28afad969763b370d 100644 (file)
@@ -239,7 +239,7 @@ export function getSuggestions(
   if (more) {
     data.more = more;
   }
-  return getJSON('/api/components/suggestions', data);
+  return getJSON('/api/components/suggestions', data).catch(throwGlobalError);
 }
 
 export function getComponentForSourceViewer(
index db846af96fc36ac2506d3b0d19e42f10fd97ff4b..d1f0db0e09cdf2fd01af4f48ee994c9938cb5cb1 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { getJSON, post, RequestData } from '../helpers/request';
+import { Notification } from '../app/types';
+import throwGlobalError from '../app/utils/throwGlobalError';
+import { getJSON, post } from '../helpers/request';
 
-export interface GetNotificationsResponse {
-  notifications: Array<{
-    channel: string;
-    type: string;
-    organization?: string;
-    project?: string;
-    projectName?: string;
-  }>;
-  channels: Array<string>;
-  globalTypes: Array<string>;
-  perProjectTypes: Array<string>;
+export function getNotifications(): Promise<{
+  channels: string[];
+  globalTypes: string[];
+  notifications: Notification[];
+  perProjectTypes: string[];
+}> {
+  return getJSON('/api/notifications/list').catch(throwGlobalError);
 }
 
-export function getNotifications(): Promise<GetNotificationsResponse> {
-  return getJSON('/api/notifications/list');
+export function addNotification(data: { channel: string; type: string; project?: string }) {
+  return post('/api/notifications/add', data).catch(throwGlobalError);
 }
 
-export function addNotification(channel: string, type: string, project?: string): Promise<void> {
-  const data: RequestData = { channel, type };
-  if (project) {
-    Object.assign(data, { project });
-  }
-  return post('/api/notifications/add', data);
-}
-
-export function removeNotification(channel: string, type: string, project?: string): Promise<void> {
-  const data: RequestData = { channel, type };
-  if (project) {
-    Object.assign(data, { project });
-  }
-  return post('/api/notifications/remove', data);
+export function removeNotification(data: { channel: string; type: string; project?: string }) {
+  return post('/api/notifications/remove', data).catch(throwGlobalError);
 }
index ffaf58534f1d684e5cbd17bee58328f4961db785..8fa11ed7fb140bf960d2069263849eb23aefb637 100644 (file)
@@ -312,6 +312,14 @@ export interface Metric {
   type: string;
 }
 
+export interface Notification {
+  channel: string;
+  organization?: string;
+  project?: string;
+  projectName?: string;
+  type: string;
+}
+
 export interface Organization {
   adminPages?: { key: string; name: string }[];
   avatar?: string;
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/GlobalNotifications.js b/server/sonar-web/src/main/js/apps/account/notifications/GlobalNotifications.js
deleted file mode 100644 (file)
index 21b7c28..0000000
+++ /dev/null
@@ -1,89 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2018 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 React from 'react';
-import { connect } from 'react-redux';
-import NotificationsList from './NotificationsList';
-import { addNotification, removeNotification } from './actions';
-import { translate } from '../../../helpers/l10n';
-import {
-  getGlobalNotifications,
-  getNotificationChannels,
-  getNotificationGlobalTypes
-} from '../../../store/rootReducer';
-/*:: import type {
-  Notification,
-  NotificationsState,
-  ChannelsState,
-  TypesState
-} from '../../../store/notifications/duck'; */
-
-/*::
-type Props = {
-  notifications: NotificationsState,
-  channels: ChannelsState,
-  types: TypesState,
-  addNotification: (n: Notification) => void,
-  removeNotification: (n: Notification) => void
-};
-*/
-
-function GlobalNotifications(props /*: Props */) {
-  return (
-    <section className="boxed-group">
-      <h2>{translate('my_profile.overall_notifications.title')}</h2>
-
-      <div className="boxed-group-inner">
-        <table className="form">
-          <thead>
-            <tr>
-              <th />
-              {props.channels.map(channel => (
-                <th key={channel} className="text-center">
-                  <h4>{translate('notification.channel', channel)}</h4>
-                </th>
-              ))}
-            </tr>
-          </thead>
-
-          <NotificationsList
-            notifications={props.notifications}
-            channels={props.channels}
-            types={props.types}
-            checkboxId={(d, c) => `global-notification-${d}-${c}`}
-            onAdd={props.addNotification}
-            onRemove={props.removeNotification}
-          />
-        </table>
-      </div>
-    </section>
-  );
-}
-
-const mapStateToProps = state => ({
-  notifications: getGlobalNotifications(state),
-  channels: getNotificationChannels(state),
-  types: getNotificationGlobalTypes(state)
-});
-
-const mapDispatchToProps = { addNotification, removeNotification };
-
-export default connect(mapStateToProps, mapDispatchToProps)(GlobalNotifications);
-
-export const UnconnectedGlobalNotifications = GlobalNotifications;
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/GlobalNotifications.tsx b/server/sonar-web/src/main/js/apps/account/notifications/GlobalNotifications.tsx
new file mode 100644 (file)
index 0000000..0ac75ce
--- /dev/null
@@ -0,0 +1,67 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 NotificationsList from './NotificationsList';
+import { Notification } from '../../../app/types';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+  addNotification: (n: Notification) => void;
+  channels: string[];
+  notifications: Notification[];
+  removeNotification: (n: Notification) => void;
+  types: string[];
+}
+
+export default function GlobalNotifications(props: Props) {
+  return (
+    <section className="boxed-group">
+      <h2>{translate('my_profile.overall_notifications.title')}</h2>
+
+      <div className="boxed-group-inner">
+        <table className="form">
+          <thead>
+            <tr>
+              <th />
+              {props.channels.map(channel => (
+                <th className="text-center" key={channel}>
+                  <h4>{translate('notification.channel', channel)}</h4>
+                </th>
+              ))}
+            </tr>
+          </thead>
+
+          <NotificationsList
+            channels={props.channels}
+            checkboxId={getCheckboxId}
+            notifications={props.notifications}
+            onAdd={props.addNotification}
+            onRemove={props.removeNotification}
+            types={props.types}
+          />
+        </table>
+      </div>
+    </section>
+  );
+}
+
+function getCheckboxId(type: string, channel: string) {
+  return `global-notification-${type}-${channel}`;
+}
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/Notifications.js b/server/sonar-web/src/main/js/apps/account/notifications/Notifications.js
deleted file mode 100644 (file)
index 5614870..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2018 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.
- */
-// @flow
-import React from 'react';
-import Helmet from 'react-helmet';
-import { connect } from 'react-redux';
-import GlobalNotifications from './GlobalNotifications';
-import Projects from './Projects';
-import { fetchNotifications } from './actions';
-import { translate } from '../../../helpers/l10n';
-
-class Notifications extends React.PureComponent {
-  /*:: props: {
-    fetchNotifications: () => void
-  };
-*/
-
-  componentDidMount() {
-    this.props.fetchNotifications();
-  }
-
-  render() {
-    return (
-      <div className="account-body account-container">
-        <Helmet title={translate('my_account.notifications')} />
-        <p className="alert alert-info">{translate('notification.dispatcher.information')}</p>
-        <GlobalNotifications />
-        <Projects />
-      </div>
-    );
-  }
-}
-
-const mapDispatchToProps = { fetchNotifications };
-
-export default connect(null, mapDispatchToProps)(Notifications);
-
-export const UnconnectedNotifications = Notifications;
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx b/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx
new file mode 100644 (file)
index 0000000..a6c2c76
--- /dev/null
@@ -0,0 +1,179 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 Helmet from 'react-helmet';
+import { groupBy, partition, uniq, uniqBy, uniqWith } from 'lodash';
+import * as PropTypes from 'prop-types';
+import GlobalNotifications from './GlobalNotifications';
+import Projects from './Projects';
+import { NotificationProject } from './types';
+import * as api from '../../../api/notifications';
+import { Notification } from '../../../app/types';
+import DeferredSpinner from '../../../components/common/DeferredSpinner';
+import { translate } from '../../../helpers/l10n';
+
+export interface Props {
+  fetchOrganizations: (organizations: string[]) => void;
+}
+
+interface State {
+  channels: string[];
+  globalTypes: string[];
+  loading: boolean;
+  notifications: Notification[];
+  perProjectTypes: string[];
+}
+
+export default class Notifications extends React.PureComponent<Props, State> {
+  mounted = false;
+
+  static contextTypes = {
+    organizationsEnabled: PropTypes.bool
+  };
+
+  state: State = {
+    channels: [],
+    globalTypes: [],
+    loading: true,
+    notifications: [],
+    perProjectTypes: []
+  };
+
+  componentDidMount() {
+    this.mounted = true;
+    this.fetchNotifications();
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  fetchNotifications = () => {
+    api.getNotifications().then(
+      response => {
+        if (this.mounted) {
+          if (this.context.organizationsEnabled) {
+            const organizations = uniq(response.notifications
+              .filter(n => n.organization)
+              .map(n => n.organization) as string[]);
+            this.props.fetchOrganizations(organizations);
+          }
+
+          this.setState({
+            channels: response.channels,
+            globalTypes: response.globalTypes,
+            loading: false,
+            notifications: response.notifications,
+            perProjectTypes: response.perProjectTypes
+          });
+        }
+      },
+      () => {
+        if (this.mounted) {
+          this.setState({ loading: false });
+        }
+      }
+    );
+  };
+
+  addNotificationToState = (added: Notification) => {
+    this.setState(state => ({
+      notifications: uniqWith([...state.notifications, added], areNotificationsEqual)
+    }));
+  };
+
+  removeNotificationFromState = (removed: Notification) => {
+    this.setState(state => ({
+      notifications: state.notifications.filter(
+        notification => !areNotificationsEqual(notification, removed)
+      )
+    }));
+  };
+
+  addNotification = (added: Notification) => {
+    // optimistic update
+    this.addNotificationToState(added);
+
+    // recreate `data` to omit `projectName` and `organization` from `Notification`
+    const data = { channel: added.channel, project: added.project, type: added.type };
+    api.addNotification(data).catch(() => {
+      this.removeNotificationFromState(added);
+    });
+  };
+
+  removeNotification = (removed: Notification) => {
+    // optimistic update
+    this.removeNotificationFromState(removed);
+
+    // recreate `data` to omit `projectName` and `organization` from `Notification`
+    const data = { channel: removed.channel, project: removed.project, type: removed.type };
+    api.removeNotification(data).catch(() => {
+      this.addNotificationToState(removed);
+    });
+  };
+
+  render() {
+    const [globalNotifications, projectNotifications] = partition(
+      this.state.notifications,
+      n => !n.project
+    );
+    const projects = uniqBy(
+      projectNotifications.map(n => ({
+        key: n.project,
+        name: n.projectName,
+        organization: n.organization
+      })) as NotificationProject[],
+      project => project.key
+    );
+    const notificationsByProject = groupBy(projectNotifications, n => n.project);
+
+    return (
+      <div className="account-body account-container">
+        <Helmet title={translate('my_account.notifications')} />
+        <p className="alert alert-info">{translate('notification.dispatcher.information')}</p>
+        <DeferredSpinner loading={this.state.loading}>
+          {this.state.notifications && (
+            <>
+              <GlobalNotifications
+                addNotification={this.addNotification}
+                channels={this.state.channels}
+                notifications={globalNotifications}
+                removeNotification={this.removeNotification}
+                types={this.state.globalTypes}
+              />
+              <Projects
+                addNotification={this.addNotification}
+                channels={this.state.channels}
+                notificationsByProject={notificationsByProject}
+                projects={projects}
+                removeNotification={this.removeNotification}
+                types={this.state.perProjectTypes}
+              />
+            </>
+          )}
+        </DeferredSpinner>
+      </div>
+    );
+  }
+}
+
+function areNotificationsEqual(a: Notification, b: Notification) {
+  return a.channel === b.channel && a.type === b.type && a.project === b.project;
+}
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.tsx b/server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.tsx
new file mode 100644 (file)
index 0000000..7c05e0f
--- /dev/null
@@ -0,0 +1,26 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 { connect } from 'react-redux';
+import Notifications, { Props } from './Notifications';
+import { fetchOrganizations } from '../../../store/rootActions';
+
+const mapDispatchToProps = { fetchOrganizations } as Pick<Props, 'fetchOrganizations'>;
+
+export default connect(null, mapDispatchToProps)(Notifications);
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/NotificationsList.js b/server/sonar-web/src/main/js/apps/account/notifications/NotificationsList.js
deleted file mode 100644 (file)
index 6ca7f02..0000000
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2018 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 React from 'react';
-import Checkbox from '../../../components/controls/Checkbox';
-import { translate, hasMessage } from '../../../helpers/l10n';
-/*:: import type {
-  Notification,
-  NotificationsState,
-  ChannelsState,
-  TypesState
-} from '../../../store/notifications/duck'; */
-
-export default class NotificationsList extends React.PureComponent {
-  /*:: props: {
-    onAdd: (n: Notification) => void,
-    onRemove: (n: Notification) => void,
-    channels: ChannelsState,
-    checkboxId: (string, string) => string,
-    project?: boolean,
-    types: TypesState,
-    notifications: NotificationsState
-  };
-*/
-
-  isEnabled(type /*: string */, channel /*: string */) /*: boolean */ {
-    return !!this.props.notifications.find(
-      notification => notification.type === type && notification.channel === channel
-    );
-  }
-
-  handleCheck(type /*: string */, channel /*: string */, checked /*: boolean */) {
-    if (checked) {
-      this.props.onAdd({ type, channel });
-    } else {
-      this.props.onRemove({ type, channel });
-    }
-  }
-
-  getDispatcherLabel(dispatcher /*: string */) {
-    const globalMessageKey = ['notification.dispatcher', dispatcher];
-    const projectMessageKey = [...globalMessageKey, 'project'];
-    const shouldUseProjectMessage = this.props.project && hasMessage(...projectMessageKey);
-    return shouldUseProjectMessage
-      ? translate(...projectMessageKey)
-      : translate(...globalMessageKey);
-  }
-
-  render() {
-    const { channels, checkboxId, types } = this.props;
-
-    return (
-      <tbody>
-        {types.map(type => (
-          <tr key={type}>
-            <td>{this.getDispatcherLabel(type)}</td>
-            {channels.map(channel => (
-              <td key={channel} className="text-center">
-                <Checkbox
-                  checked={this.isEnabled(type, channel)}
-                  id={checkboxId(type, channel)}
-                  onCheck={checked => this.handleCheck(type, channel, checked)}
-                />
-              </td>
-            ))}
-          </tr>
-        ))}
-      </tbody>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/NotificationsList.tsx b/server/sonar-web/src/main/js/apps/account/notifications/NotificationsList.tsx
new file mode 100644 (file)
index 0000000..35915a0
--- /dev/null
@@ -0,0 +1,81 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 { Notification } from '../../../app/types';
+import Checkbox from '../../../components/controls/Checkbox';
+import { translate, hasMessage } from '../../../helpers/l10n';
+
+interface Props {
+  onAdd: (n: Notification) => void;
+  onRemove: (n: Notification) => void;
+  channels: string[];
+  checkboxId: (type: string, channel: string) => string;
+  project?: boolean;
+  types: string[];
+  notifications: Notification[];
+}
+
+export default class NotificationsList extends React.PureComponent<Props> {
+  isEnabled(type: string, channel: string) {
+    return !!this.props.notifications.find(
+      notification => notification.type === type && notification.channel === channel
+    );
+  }
+
+  handleCheck(type: string, channel: string, checked: boolean) {
+    if (checked) {
+      this.props.onAdd({ type, channel });
+    } else {
+      this.props.onRemove({ type, channel });
+    }
+  }
+
+  getDispatcherLabel(dispatcher: string) {
+    const globalMessageKey = ['notification.dispatcher', dispatcher];
+    const projectMessageKey = [...globalMessageKey, 'project'];
+    const shouldUseProjectMessage = this.props.project && hasMessage(...projectMessageKey);
+    return shouldUseProjectMessage
+      ? translate(...projectMessageKey)
+      : translate(...globalMessageKey);
+  }
+
+  render() {
+    const { channels, checkboxId, types } = this.props;
+
+    return (
+      <tbody>
+        {types.map(type => (
+          <tr key={type}>
+            <td>{this.getDispatcherLabel(type)}</td>
+            {channels.map(channel => (
+              <td className="text-center" key={channel}>
+                <Checkbox
+                  checked={this.isEnabled(type, channel)}
+                  id={checkboxId(type, channel)}
+                  onCheck={checked => this.handleCheck(type, channel, checked)}
+                />
+              </td>
+            ))}
+          </tr>
+        ))}
+      </tbody>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.js b/server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.js
deleted file mode 100644 (file)
index 8fbf66c..0000000
+++ /dev/null
@@ -1,119 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2018 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 React from 'react';
-import { connect } from 'react-redux';
-import { Link } from 'react-router';
-import NotificationsList from './NotificationsList';
-import { addNotification, removeNotification } from './actions';
-import Organization from '../../../components/shared/Organization';
-import { translate } from '../../../helpers/l10n';
-import {
-  getProjectNotifications,
-  getNotificationChannels,
-  getNotificationPerProjectTypes
-} from '../../../store/rootReducer';
-/*:: import type {
-  Notification,
-  NotificationsState,
-  ChannelsState,
-  TypesState
-} from '../../../store/notifications/duck'; */
-import { getProjectUrl } from '../../../helpers/urls';
-
-class ProjectNotifications extends React.PureComponent {
-  /*:: props: {
-    project: {
-      key: string,
-      name: string,
-      organization: string
-    },
-    notifications: NotificationsState,
-    channels: ChannelsState,
-    types: TypesState,
-    addNotification: (n: Notification) => void,
-    removeNotification: (n: Notification) => void
-  };
-*/
-
-  handleAddNotification({ channel, type }) {
-    this.props.addNotification({
-      channel,
-      type,
-      project: this.props.project.key,
-      projectName: this.props.project.name,
-      organization: this.props.project.organization
-    });
-  }
-
-  handleRemoveNotification({ channel, type }) {
-    this.props.removeNotification({
-      channel,
-      type,
-      project: this.props.project.key
-    });
-  }
-
-  render() {
-    const { project, channels } = this.props;
-
-    return (
-      <table key={project.key} className="form big-spacer-bottom">
-        <thead>
-          <tr>
-            <th>
-              <span className="text-normal">
-                <Organization organizationKey={project.organization} />
-              </span>
-              <h4 className="display-inline-block">
-                <Link to={getProjectUrl(project.key)}>{project.name}</Link>
-              </h4>
-            </th>
-            {channels.map(channel => (
-              <th key={channel} className="text-center">
-                <h4>{translate('notification.channel', channel)}</h4>
-              </th>
-            ))}
-          </tr>
-        </thead>
-        <NotificationsList
-          notifications={this.props.notifications}
-          channels={this.props.channels}
-          types={this.props.types}
-          checkboxId={(d, c) => `project-notification-${project.key}-${d}-${c}`}
-          onAdd={n => this.handleAddNotification(n)}
-          onRemove={n => this.handleRemoveNotification(n)}
-          project={true}
-        />
-      </table>
-    );
-  }
-}
-
-const mapStateToProps = (state, ownProps) => ({
-  notifications: getProjectNotifications(state, ownProps.project.key),
-  channels: getNotificationChannels(state),
-  types: getNotificationPerProjectTypes(state)
-});
-
-const mapDispatchToProps = { addNotification, removeNotification };
-
-export default connect(mapStateToProps, mapDispatchToProps)(ProjectNotifications);
-
-export const UnconnectedProjectNotifications = ProjectNotifications;
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.tsx b/server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.tsx
new file mode 100644 (file)
index 0000000..f5e29f7
--- /dev/null
@@ -0,0 +1,95 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 { Link } from 'react-router';
+import NotificationsList from './NotificationsList';
+import { NotificationProject } from './types';
+import { Notification } from '../../../app/types';
+import Organization from '../../../components/shared/Organization';
+import { translate } from '../../../helpers/l10n';
+import { getProjectUrl } from '../../../helpers/urls';
+
+interface Props {
+  addNotification: (n: Notification) => void;
+  channels: string[];
+  notifications: Notification[];
+  project: NotificationProject;
+  removeNotification: (n: Notification) => void;
+  types: string[];
+}
+
+export default class ProjectNotifications extends React.PureComponent<Props> {
+  getCheckboxId = (type: string, channel: string) => {
+    return `project-notification-${this.props.project.key}-${type}-${channel}`;
+  };
+
+  handleAddNotification = ({ channel, type }: { channel: string; type: string }) => {
+    this.props.addNotification({
+      channel,
+      type,
+      project: this.props.project.key,
+      projectName: this.props.project.name,
+      organization: this.props.project.organization
+    });
+  };
+
+  handleRemoveNotification = ({ channel, type }: { channel: string; type: string }) => {
+    this.props.removeNotification({
+      channel,
+      type,
+      project: this.props.project.key
+    });
+  };
+
+  render() {
+    const { project, channels } = this.props;
+
+    return (
+      <table className="form big-spacer-bottom" key={project.key}>
+        <thead>
+          <tr>
+            <th>
+              <span className="text-normal">
+                <Organization organizationKey={project.organization} />
+              </span>
+              <h4 className="display-inline-block">
+                <Link to={getProjectUrl(project.key)}>{project.name}</Link>
+              </h4>
+            </th>
+            {channels.map(channel => (
+              <th className="text-center" key={channel}>
+                <h4>{translate('notification.channel', channel)}</h4>
+              </th>
+            ))}
+          </tr>
+        </thead>
+        <NotificationsList
+          channels={this.props.channels}
+          checkboxId={this.getCheckboxId}
+          notifications={this.props.notifications}
+          onAdd={this.handleAddNotification}
+          onRemove={this.handleRemoveNotification}
+          project={true}
+          types={this.props.types}
+        />
+      </table>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/Projects.js b/server/sonar-web/src/main/js/apps/account/notifications/Projects.js
deleted file mode 100644 (file)
index 5799f59..0000000
+++ /dev/null
@@ -1,154 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2018 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 React from 'react';
-import { connect } from 'react-redux';
-import { differenceBy } from 'lodash';
-import ProjectNotifications from './ProjectNotifications';
-import { AsyncSelect } from '../../../components/controls/Select';
-import Organization from '../../../components/shared/Organization';
-import { translate } from '../../../helpers/l10n';
-import { getSuggestions } from '../../../api/components';
-import { getProjectsWithNotifications } from '../../../store/rootReducer';
-
-/*::
-type Props = {
-  projects: Array<{
-    key: string,
-    name: string
-  }>
-};
-*/
-
-/*::
-type State = {
-  addedProjects: Array<{
-    key: string,
-    name: string
-  }>
-};
-*/
-
-class Projects extends React.PureComponent {
-  /*:: props: Props; */
-
-  state /*: State */ = {
-    addedProjects: []
-  };
-
-  componentWillReceiveProps(nextProps /*: Props */) {
-    // remove all projects from `this.state.addedProjects`
-    // that already exist in `nextProps.projects`
-    const nextAddedProjects = differenceBy(
-      this.state.addedProjects,
-      nextProps.projects,
-      project => project.key
-    );
-
-    if (nextAddedProjects.length !== this.state.addedProjects) {
-      this.setState({ addedProjects: nextAddedProjects });
-    }
-  }
-
-  loadOptions = (query, cb) => {
-    if (query.length < 2) {
-      cb(null, { options: [] });
-      return;
-    }
-
-    getSuggestions(query)
-      .then(r => {
-        const projects = r.results.find(domain => domain.q === 'TRK');
-        return projects ? projects.items : [];
-      })
-      .then(projects =>
-        projects.map(project => ({
-          value: project.key,
-          label: project.name,
-          organization: project.organization
-        }))
-      )
-      .then(options => {
-        cb(null, { options });
-      });
-  };
-
-  handleAddProject = selected => {
-    const project = {
-      key: selected.value,
-      name: selected.label,
-      organization: selected.organization
-    };
-    this.setState({
-      addedProjects: [...this.state.addedProjects, project]
-    });
-  };
-
-  renderOption = option => {
-    return (
-      <span>
-        <Organization organizationKey={option.organization} link={false} />
-        <strong>{option.label}</strong>
-      </span>
-    );
-  };
-
-  render() {
-    const allProjects = [...this.props.projects, ...this.state.addedProjects];
-
-    return (
-      <section className="boxed-group">
-        <h2>{translate('my_profile.per_project_notifications.title')}</h2>
-
-        <div className="boxed-group-inner">
-          {allProjects.length === 0 && (
-            <div className="note">{translate('my_account.no_project_notifications')}</div>
-          )}
-
-          {allProjects.map(project => <ProjectNotifications key={project.key} project={project} />)}
-
-          <div className="spacer-top panel bg-muted">
-            <span className="text-middle spacer-right">
-              {translate('my_account.set_notifications_for')}:
-            </span>
-            <AsyncSelect
-              autoload={false}
-              cache={false}
-              name="new_project"
-              style={{ width: '300px' }}
-              loadOptions={this.loadOptions}
-              minimumInput={2}
-              optionRenderer={this.renderOption}
-              onChange={this.handleAddProject}
-              placeholder={translate('my_account.search_project')}
-            />
-          </div>
-        </div>
-      </section>
-    );
-  }
-}
-
-const mapStateToProps = state => ({
-  projects: getProjectsWithNotifications(state)
-});
-
-export default connect(mapStateToProps)(Projects);
-
-export const UnconnectedProjects = Projects;
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx b/server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx
new file mode 100644 (file)
index 0000000..77f7876
--- /dev/null
@@ -0,0 +1,150 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 { differenceWith } from 'lodash';
+import ProjectNotifications from './ProjectNotifications';
+import { NotificationProject } from './types';
+import { getSuggestions } from '../../../api/components';
+import { Notification } from '../../../app/types';
+import { AsyncSelect } from '../../../components/controls/Select';
+import Organization from '../../../components/shared/Organization';
+import { translate } from '../../../helpers/l10n';
+
+export interface Props {
+  addNotification: (n: Notification) => void;
+  channels: string[];
+  notificationsByProject: { [project: string]: Notification[] };
+  projects: NotificationProject[];
+  removeNotification: (n: Notification) => void;
+  types: string[];
+}
+
+interface State {
+  addedProjects: NotificationProject[];
+}
+
+export default class Projects extends React.PureComponent<Props, State> {
+  state: State = { addedProjects: [] };
+
+  componentWillReceiveProps(nextProps: Props) {
+    // remove all projects from `this.state.addedProjects`
+    // that already exist in `nextProps.projects`
+    this.setState(state => ({
+      addedProjects: differenceWith(
+        state.addedProjects,
+        Object.keys(nextProps.projects),
+        (stateProject, propsProjectKey) => stateProject.key !== propsProjectKey
+      )
+    }));
+  }
+
+  loadOptions = (query: string) => {
+    if (query.length < 2) {
+      return Promise.resolve({ options: [] });
+    }
+
+    return getSuggestions(query)
+      .then(r => {
+        const projects = r.results.find(domain => domain.q === 'TRK');
+        return projects ? projects.items : [];
+      })
+      .then(projects => {
+        return projects
+          .filter(
+            project =>
+              !this.props.projects.find(p => p.key === project.key) &&
+              !this.state.addedProjects.find(p => p.key === project.key)
+          )
+          .map(project => ({
+            value: project.key,
+            label: project.name,
+            organization: project.organization
+          }));
+      })
+      .then(options => {
+        return { options };
+      });
+  };
+
+  handleAddProject = (selected: { label: string; organization: string; value: string }) => {
+    const project = {
+      key: selected.value,
+      name: selected.label,
+      organization: selected.organization
+    };
+    this.setState(state => ({
+      addedProjects: [...state.addedProjects, project]
+    }));
+  };
+
+  renderOption = (option: { label: string; organization: string; value: string }) => {
+    return (
+      <span>
+        <Organization link={false} organizationKey={option.organization} />
+        <strong>{option.label}</strong>
+      </span>
+    );
+  };
+
+  render() {
+    const allProjects = [...this.props.projects, ...this.state.addedProjects];
+
+    return (
+      <section className="boxed-group">
+        <h2>{translate('my_profile.per_project_notifications.title')}</h2>
+
+        <div className="boxed-group-inner">
+          {allProjects.length === 0 && (
+            <div className="note">{translate('my_account.no_project_notifications')}</div>
+          )}
+
+          {allProjects.map(project => (
+            <ProjectNotifications
+              addNotification={this.props.addNotification}
+              channels={this.props.channels}
+              key={project.key}
+              notifications={this.props.notificationsByProject[project.key] || []}
+              project={project}
+              removeNotification={this.props.removeNotification}
+              types={this.props.types}
+            />
+          ))}
+
+          <div className="spacer-top panel bg-muted">
+            <span className="text-middle spacer-right">
+              {translate('my_account.set_notifications_for')}:
+            </span>
+            <AsyncSelect
+              autoload={false}
+              cache={false}
+              className="input-super-large"
+              loadOptions={this.loadOptions}
+              minimumInput={2}
+              name="new_project"
+              onChange={this.handleAddProject}
+              optionRenderer={this.renderOption}
+              placeholder={translate('my_account.search_project')}
+            />
+          </div>
+        </div>
+      </section>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/GlobalNotifications-test.js b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/GlobalNotifications-test.js
deleted file mode 100644 (file)
index 8201a1b..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2018 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 React from 'react';
-import { shallow } from 'enzyme';
-import { UnconnectedGlobalNotifications } from '../GlobalNotifications';
-
-it('should match snapshot', () => {
-  const channels = ['channel1', 'channel2'];
-  const types = ['type1', 'type2'];
-  const notifications = [
-    { channel: 'channel1', type: 'type1' },
-    { channel: 'channel1', type: 'type2' },
-    { channel: 'channel2', type: 'type2' }
-  ];
-
-  expect(
-    shallow(
-      <UnconnectedGlobalNotifications
-        notifications={notifications}
-        channels={channels}
-        types={types}
-        addNotification={jest.fn()}
-        removeNotification={jest.fn()}
-      />
-    )
-  ).toMatchSnapshot();
-});
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/GlobalNotifications-test.tsx b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/GlobalNotifications-test.tsx
new file mode 100644 (file)
index 0000000..7f5cb72
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 GlobalNotifications from '../GlobalNotifications';
+
+it('should match snapshot', () => {
+  const channels = ['channel1', 'channel2'];
+  const types = ['type1', 'type2'];
+  const notifications = [
+    { channel: 'channel1', type: 'type1' },
+    { channel: 'channel1', type: 'type2' },
+    { channel: 'channel2', type: 'type2' }
+  ];
+
+  expect(
+    shallow(
+      <GlobalNotifications
+        addNotification={jest.fn()}
+        channels={channels}
+        notifications={notifications}
+        removeNotification={jest.fn()}
+        types={types}
+      />
+    )
+  ).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.js b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.js
deleted file mode 100644 (file)
index ca99b42..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2018 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 React from 'react';
-import { shallow } from 'enzyme';
-import { UnconnectedNotifications } from '../Notifications';
-
-it('should match snapshot', () => {
-  expect(shallow(<UnconnectedNotifications fetchNotifications={jest.fn()} />)).toMatchSnapshot();
-});
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx
new file mode 100644 (file)
index 0000000..e0124a0
--- /dev/null
@@ -0,0 +1,108 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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.
+ */
+/* eslint-disable import/order */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import Notifications, { Props } from '../Notifications';
+import { waitAndUpdate } from '../../../../helpers/testUtils';
+
+jest.mock('../../../../api/notifications', () => ({
+  addNotification: jest.fn(() => Promise.resolve()),
+  getNotifications: jest.fn(() =>
+    Promise.resolve({
+      channels: ['channel1', 'channel2'],
+      globalTypes: ['type-global', 'type-common'],
+      notifications: [
+        { channel: 'channel1', type: 'type-global' },
+        { channel: 'channel1', type: 'type-common' },
+        {
+          channel: 'channel2',
+          type: 'type-common',
+          project: 'foo',
+          projectName: 'Foo',
+          organization: 'org'
+        }
+      ],
+      perProjectTypes: ['type-common']
+    })
+  ),
+  removeNotification: jest.fn(() => Promise.resolve())
+}));
+
+const api = require('../../../../api/notifications');
+
+const addNotification = api.addNotification as jest.Mock<any>;
+const getNotifications = api.getNotifications as jest.Mock<any>;
+const removeNotification = api.removeNotification as jest.Mock<any>;
+
+beforeEach(() => {
+  addNotification.mockClear();
+  getNotifications.mockClear();
+  removeNotification.mockClear();
+});
+
+it('should fetch notifications and render', async () => {
+  const wrapper = await shallowRender();
+  expect(wrapper).toMatchSnapshot();
+  expect(getNotifications).toBeCalled();
+});
+
+it('should add global notification', async () => {
+  const notification = { channel: 'channel2', type: 'type-global' };
+  const wrapper = await shallowRender();
+  wrapper.find('GlobalNotifications').prop<Function>('addNotification')(notification);
+  // `state` must be immediately updated
+  expect(wrapper.state('notifications')).toContainEqual(notification);
+  expect(addNotification).toBeCalledWith(notification);
+});
+
+it('should remove project notification', async () => {
+  const notification = { channel: 'channel2', project: 'foo', type: 'type-common' };
+  const wrapper = await shallowRender();
+  expect(wrapper.state('notifications')).toContainEqual({
+    ...notification,
+    organization: 'org',
+    projectName: 'Foo'
+  });
+  wrapper.find('Projects').prop<Function>('removeNotification')(notification);
+  // `state` must be immediately updated
+  expect(wrapper.state('notifications')).not.toContainEqual(notification);
+  expect(removeNotification).toBeCalledWith(notification);
+});
+
+it('should NOT fetch organizations', async () => {
+  const fetchOrganizations = jest.fn();
+  await shallowRender({ fetchOrganizations });
+  expect(getNotifications).toBeCalled();
+  expect(fetchOrganizations).not.toBeCalled();
+});
+
+it('should fetch organizations', async () => {
+  const fetchOrganizations = jest.fn();
+  await shallowRender({ fetchOrganizations }, { organizationsEnabled: true });
+  expect(getNotifications).toBeCalled();
+  expect(fetchOrganizations).toBeCalledWith(['org']);
+});
+
+async function shallowRender(props?: Partial<Props>, context?: any) {
+  const wrapper = shallow(<Notifications fetchOrganizations={jest.fn()} {...props} />, { context });
+  await waitAndUpdate(wrapper);
+  return wrapper;
+}
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/NotificationsList-test.js b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/NotificationsList-test.js
deleted file mode 100644 (file)
index 5e38954..0000000
+++ /dev/null
@@ -1,102 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2018 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.
- */
-/* eslint-disable import/first */
-jest.mock('../../../../helpers/l10n', () => {
-  const l10n = require.requireActual('../../../../helpers/l10n');
-  l10n.hasMessage = jest.fn();
-  return l10n;
-});
-
-import React from 'react';
-import { shallow } from 'enzyme';
-import NotificationsList from '../NotificationsList';
-import Checkbox from '../../../../components/controls/Checkbox';
-import { hasMessage } from '../../../../helpers/l10n';
-
-const channels = ['channel1', 'channel2'];
-const types = ['type1', 'type2'];
-const notifications = [
-  { channel: 'channel1', type: 'type1' },
-  { channel: 'channel1', type: 'type2' },
-  { channel: 'channel2', type: 'type2' }
-];
-const checkboxId = (t, c) => `checkbox-io-${t}-${c}`;
-
-beforeEach(() => {
-  hasMessage.mockImplementation(() => false).mockClear();
-});
-
-it('should match snapshot', () => {
-  expect(
-    shallow(
-      <NotificationsList
-        onAdd={jest.fn()}
-        onRemove={jest.fn()}
-        channels={channels}
-        checkboxId={checkboxId}
-        types={types}
-        notifications={notifications}
-      />
-    )
-  ).toMatchSnapshot();
-});
-
-it('renders project-specific labels', () => {
-  hasMessage.mockImplementation(() => true);
-  expect(
-    shallow(
-      <NotificationsList
-        onAdd={jest.fn()}
-        onRemove={jest.fn()}
-        channels={channels}
-        checkboxId={checkboxId}
-        project={true}
-        types={types}
-        notifications={notifications}
-      />
-    )
-  ).toMatchSnapshot();
-  expect(hasMessage).toBeCalledWith('notification.dispatcher', 'type1', 'project');
-  expect(hasMessage).toBeCalledWith('notification.dispatcher', 'type2', 'project');
-});
-
-it('should call `onAdd` and `onRemove`', () => {
-  const onAdd = jest.fn();
-  const onRemove = jest.fn();
-  const wrapper = shallow(
-    <NotificationsList
-      onAdd={onAdd}
-      onRemove={onRemove}
-      channels={channels}
-      checkboxId={checkboxId}
-      types={types}
-      notifications={notifications}
-    />
-  );
-  const checkbox = wrapper.find(Checkbox).first();
-
-  checkbox.prop('onCheck')(true);
-  expect(onAdd).toHaveBeenCalledWith({ channel: 'channel1', type: 'type1' });
-
-  jest.resetAllMocks();
-
-  checkbox.prop('onCheck')(false);
-  expect(onRemove).toHaveBeenCalledWith({ channel: 'channel1', type: 'type1' });
-});
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/NotificationsList-test.tsx b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/NotificationsList-test.tsx
new file mode 100644 (file)
index 0000000..25044db
--- /dev/null
@@ -0,0 +1,102 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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.
+ */
+/* eslint-disable import/first */
+jest.mock('../../../../helpers/l10n', () => {
+  const l10n = require.requireActual('../../../../helpers/l10n');
+  l10n.hasMessage = jest.fn();
+  return l10n;
+});
+
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import NotificationsList from '../NotificationsList';
+import Checkbox from '../../../../components/controls/Checkbox';
+import { hasMessage } from '../../../../helpers/l10n';
+
+const channels = ['channel1', 'channel2'];
+const types = ['type1', 'type2'];
+const notifications = [
+  { channel: 'channel1', type: 'type1' },
+  { channel: 'channel1', type: 'type2' },
+  { channel: 'channel2', type: 'type2' }
+];
+const checkboxId = (t: string, c: string) => `checkbox-io-${t}-${c}`;
+
+beforeEach(() => {
+  (hasMessage as jest.Mock<any>).mockImplementation(() => false).mockClear();
+});
+
+it('should match snapshot', () => {
+  expect(
+    shallow(
+      <NotificationsList
+        channels={channels}
+        checkboxId={checkboxId}
+        notifications={notifications}
+        onAdd={jest.fn()}
+        onRemove={jest.fn()}
+        types={types}
+      />
+    )
+  ).toMatchSnapshot();
+});
+
+it('renders project-specific labels', () => {
+  (hasMessage as jest.Mock<any>).mockImplementation(() => true);
+  expect(
+    shallow(
+      <NotificationsList
+        channels={channels}
+        checkboxId={checkboxId}
+        notifications={notifications}
+        onAdd={jest.fn()}
+        onRemove={jest.fn()}
+        project={true}
+        types={types}
+      />
+    )
+  ).toMatchSnapshot();
+  expect(hasMessage).toBeCalledWith('notification.dispatcher', 'type1', 'project');
+  expect(hasMessage).toBeCalledWith('notification.dispatcher', 'type2', 'project');
+});
+
+it('should call `onAdd` and `onRemove`', () => {
+  const onAdd = jest.fn();
+  const onRemove = jest.fn();
+  const wrapper = shallow(
+    <NotificationsList
+      channels={channels}
+      checkboxId={checkboxId}
+      notifications={notifications}
+      onAdd={onAdd}
+      onRemove={onRemove}
+      types={types}
+    />
+  );
+  const checkbox = wrapper.find(Checkbox).first();
+
+  checkbox.prop('onCheck')(true);
+  expect(onAdd).toHaveBeenCalledWith({ channel: 'channel1', type: 'type1' });
+
+  jest.resetAllMocks();
+
+  checkbox.prop('onCheck')(false);
+  expect(onRemove).toHaveBeenCalledWith({ channel: 'channel1', type: 'type1' });
+});
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.js b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.js
deleted file mode 100644 (file)
index d05fe08..0000000
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2018 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 React from 'react';
-import { shallow } from 'enzyme';
-import { UnconnectedProjectNotifications } from '../ProjectNotifications';
-import NotificationsList from '../NotificationsList';
-
-const channels = ['channel1', 'channel2'];
-const types = ['type1', 'type2'];
-const notifications = [
-  { channel: 'channel1', type: 'type1' },
-  { channel: 'channel1', type: 'type2' },
-  { channel: 'channel2', type: 'type2' }
-];
-
-it('should match snapshot', () => {
-  expect(
-    shallow(
-      <UnconnectedProjectNotifications
-        project={{ key: 'foo', name: 'Foo' }}
-        notifications={notifications}
-        channels={channels}
-        types={types}
-        addNotification={jest.fn()}
-        removeNotification={jest.fn()}
-      />
-    )
-  ).toMatchSnapshot();
-});
-
-it('should call `addNotification` and `removeNotification`', () => {
-  const addNotification = jest.fn();
-  const removeNotification = jest.fn();
-  const wrapper = shallow(
-    <UnconnectedProjectNotifications
-      project={{ key: 'foo', name: 'Foo' }}
-      notifications={notifications}
-      channels={channels}
-      types={types}
-      addNotification={addNotification}
-      removeNotification={removeNotification}
-    />
-  );
-  const notificationsList = wrapper.find(NotificationsList);
-
-  notificationsList.prop('onAdd')({ channel: 'channel2', type: 'type1' });
-  expect(addNotification).toHaveBeenCalledWith({
-    channel: 'channel2',
-    type: 'type1',
-    project: 'foo',
-    projectName: 'Foo'
-  });
-
-  jest.resetAllMocks();
-
-  notificationsList.prop('onRemove')({ channel: 'channel1', type: 'type1' });
-  expect(removeNotification).toHaveBeenCalledWith({
-    channel: 'channel1',
-    type: 'type1',
-    project: 'foo'
-  });
-});
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.tsx b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.tsx
new file mode 100644 (file)
index 0000000..2ebd284
--- /dev/null
@@ -0,0 +1,79 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 ProjectNotifications from '../ProjectNotifications';
+
+const channels = ['channel1', 'channel2'];
+const types = ['type1', 'type2'];
+const notifications = [
+  { channel: 'channel1', type: 'type1' },
+  { channel: 'channel1', type: 'type2' },
+  { channel: 'channel2', type: 'type2' }
+];
+
+it('should match snapshot', () => {
+  expect(
+    shallow(
+      <ProjectNotifications
+        addNotification={jest.fn()}
+        channels={channels}
+        notifications={notifications}
+        project={{ key: 'foo', name: 'Foo', organization: 'org' }}
+        removeNotification={jest.fn()}
+        types={types}
+      />
+    )
+  ).toMatchSnapshot();
+});
+
+it('should call `addNotification` and `removeNotification`', () => {
+  const addNotification = jest.fn();
+  const removeNotification = jest.fn();
+  const wrapper = shallow(
+    <ProjectNotifications
+      addNotification={addNotification}
+      channels={channels}
+      notifications={notifications}
+      project={{ key: 'foo', name: 'Foo', organization: 'org' }}
+      removeNotification={removeNotification}
+      types={types}
+    />
+  );
+  const notificationsList = wrapper.find('NotificationsList');
+
+  notificationsList.prop<Function>('onAdd')({ channel: 'channel2', type: 'type1' });
+  expect(addNotification).toHaveBeenCalledWith({
+    channel: 'channel2',
+    organization: 'org',
+    project: 'foo',
+    projectName: 'Foo',
+    type: 'type1'
+  });
+
+  jest.resetAllMocks();
+
+  notificationsList.prop<Function>('onRemove')({ channel: 'channel1', type: 'type1' });
+  expect(removeNotification).toHaveBeenCalledWith({
+    channel: 'channel1',
+    type: 'type1',
+    project: 'foo'
+  });
+});
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.js b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.js
deleted file mode 100644 (file)
index 5f6598b..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2018 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 React from 'react';
-import { shallow } from 'enzyme';
-import { UnconnectedProjects } from '../Projects';
-
-const projects = [{ key: 'foo', name: 'Foo' }, { key: 'bar', name: 'Bar' }];
-
-const newProject = { key: 'qux', name: 'Qux' };
-
-it('should render projects', () => {
-  const wrapper = shallow(<UnconnectedProjects projects={projects} />);
-  expect(wrapper).toMatchSnapshot();
-
-  // let's add a new project
-  wrapper.setState({ addedProjects: [newProject] });
-  expect(wrapper).toMatchSnapshot();
-
-  // let's say we saved it, so it's passed back in `props`
-  wrapper.setProps({ projects: [...projects, newProject] });
-  expect(wrapper).toMatchSnapshot();
-  expect(wrapper.state()).toMatchSnapshot();
-});
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx
new file mode 100644 (file)
index 0000000..dc02812
--- /dev/null
@@ -0,0 +1,134 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 Projects, { Props } from '../Projects';
+
+jest.mock('../../../../api/components', () => ({
+  getSuggestions: jest.fn(() =>
+    Promise.resolve({
+      results: [
+        {
+          q: 'TRK',
+          items: [
+            { key: 'foo', name: 'Foo', organization: 'org' },
+            { key: 'bar', name: 'Bar', organization: 'org' }
+          ]
+        },
+        // this file should be ignored
+        { q: 'FIL', items: [{ key: 'foo:file.js', name: 'file.js', organization: 'org' }] }
+      ]
+    })
+  )
+}));
+
+const channels = ['channel1', 'channel2'];
+const types = ['type1', 'type2'];
+
+const projectFoo = { key: 'foo', name: 'Foo', organization: 'org' };
+const projectBar = { key: 'bar', name: 'Bar', organization: 'org' };
+const projects = [projectFoo, projectBar];
+
+const newProject = { key: 'qux', name: 'Qux', organization: 'org' };
+
+it('should render projects', () => {
+  const wrapper = shallowRender({
+    notificationsByProject: {
+      foo: [
+        {
+          channel: 'channel1',
+          organization: 'org',
+          project: 'foo',
+          projectName: 'Foo',
+          type: 'type1'
+        },
+        {
+          channel: 'channel1',
+          organization: 'org',
+          project: 'foo',
+          projectName: 'Foo',
+          type: 'type2'
+        }
+      ]
+    },
+    projects
+  });
+  expect(wrapper).toMatchSnapshot();
+
+  // let's add a new project
+  wrapper.setState({ addedProjects: [newProject] });
+  expect(wrapper).toMatchSnapshot();
+
+  // let's say we saved it, so it's passed back in `props`
+  wrapper.setProps({ projects: [...projects, newProject] });
+  expect(wrapper).toMatchSnapshot();
+  expect(wrapper.state()).toMatchSnapshot();
+});
+
+it('should search projects', () => {
+  const wrapper = shallowRender({ projects: [projectBar] });
+  const loadOptions = wrapper.find('AsyncSelect').prop<Function>('loadOptions');
+  expect(loadOptions('')).resolves.toEqual({ options: [] });
+  // should not contain `projectBar`
+  expect(loadOptions('more than two symbols')).resolves.toEqual({
+    options: [{ label: 'Foo', organization: 'org', value: 'foo' }]
+  });
+});
+
+it('should add project', () => {
+  const wrapper = shallowRender();
+  expect(wrapper.state('addedProjects')).toEqual([]);
+  wrapper.find('AsyncSelect').prop<Function>('onChange')({
+    label: 'Qwe',
+    organization: 'org',
+    value: 'qwe'
+  });
+  expect(wrapper.state('addedProjects')).toEqual([
+    { key: 'qwe', name: 'Qwe', organization: 'org' }
+  ]);
+});
+
+it('should render option', () => {
+  const wrapper = shallowRender();
+  const optionRenderer = wrapper.find('AsyncSelect').prop<Function>('optionRenderer');
+  expect(
+    shallow(
+      optionRenderer({
+        label: 'Qwe',
+        organization: 'org',
+        value: 'qwe'
+      })
+    )
+  ).toMatchSnapshot();
+});
+
+function shallowRender(props?: Partial<Props>) {
+  return shallow(
+    <Projects
+      addNotification={jest.fn()}
+      channels={channels}
+      notificationsByProject={{}}
+      projects={[]}
+      removeNotification={jest.fn()}
+      types={types}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/GlobalNotifications-test.js.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/GlobalNotifications-test.js.snap
deleted file mode 100644 (file)
index 3d21dbb..0000000
+++ /dev/null
@@ -1,73 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should match snapshot 1`] = `
-<section
-  className="boxed-group"
->
-  <h2>
-    my_profile.overall_notifications.title
-  </h2>
-  <div
-    className="boxed-group-inner"
-  >
-    <table
-      className="form"
-    >
-      <thead>
-        <tr>
-          <th />
-          <th
-            className="text-center"
-            key="channel1"
-          >
-            <h4>
-              notification.channel.channel1
-            </h4>
-          </th>
-          <th
-            className="text-center"
-            key="channel2"
-          >
-            <h4>
-              notification.channel.channel2
-            </h4>
-          </th>
-        </tr>
-      </thead>
-      <NotificationsList
-        channels={
-          Array [
-            "channel1",
-            "channel2",
-          ]
-        }
-        checkboxId={[Function]}
-        notifications={
-          Array [
-            Object {
-              "channel": "channel1",
-              "type": "type1",
-            },
-            Object {
-              "channel": "channel1",
-              "type": "type2",
-            },
-            Object {
-              "channel": "channel2",
-              "type": "type2",
-            },
-          ]
-        }
-        onAdd={[MockFunction]}
-        onRemove={[MockFunction]}
-        types={
-          Array [
-            "type1",
-            "type2",
-          ]
-        }
-      />
-    </table>
-  </div>
-</section>
-`;
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/GlobalNotifications-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/GlobalNotifications-test.tsx.snap
new file mode 100644 (file)
index 0000000..3d21dbb
--- /dev/null
@@ -0,0 +1,73 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should match snapshot 1`] = `
+<section
+  className="boxed-group"
+>
+  <h2>
+    my_profile.overall_notifications.title
+  </h2>
+  <div
+    className="boxed-group-inner"
+  >
+    <table
+      className="form"
+    >
+      <thead>
+        <tr>
+          <th />
+          <th
+            className="text-center"
+            key="channel1"
+          >
+            <h4>
+              notification.channel.channel1
+            </h4>
+          </th>
+          <th
+            className="text-center"
+            key="channel2"
+          >
+            <h4>
+              notification.channel.channel2
+            </h4>
+          </th>
+        </tr>
+      </thead>
+      <NotificationsList
+        channels={
+          Array [
+            "channel1",
+            "channel2",
+          ]
+        }
+        checkboxId={[Function]}
+        notifications={
+          Array [
+            Object {
+              "channel": "channel1",
+              "type": "type1",
+            },
+            Object {
+              "channel": "channel1",
+              "type": "type2",
+            },
+            Object {
+              "channel": "channel2",
+              "type": "type2",
+            },
+          ]
+        }
+        onAdd={[MockFunction]}
+        onRemove={[MockFunction]}
+        types={
+          Array [
+            "type1",
+            "type2",
+          ]
+        }
+      />
+    </table>
+  </div>
+</section>
+`;
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.js.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.js.snap
deleted file mode 100644 (file)
index 855ccec..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should match snapshot 1`] = `
-<div
-  className="account-body account-container"
->
-  <HelmetWrapper
-    defer={true}
-    encodeSpecialCharacters={true}
-    title="my_account.notifications"
-  />
-  <p
-    className="alert alert-info"
-  >
-    notification.dispatcher.information
-  </p>
-  <Connect(GlobalNotifications) />
-  <Connect(Projects) />
-</div>
-`;
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap
new file mode 100644 (file)
index 0000000..4fd7bdf
--- /dev/null
@@ -0,0 +1,90 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should fetch notifications and render 1`] = `
+<div
+  className="account-body account-container"
+>
+  <HelmetWrapper
+    defer={true}
+    encodeSpecialCharacters={true}
+    title="my_account.notifications"
+  />
+  <p
+    className="alert alert-info"
+  >
+    notification.dispatcher.information
+  </p>
+  <DeferredSpinner
+    loading={false}
+    timeout={100}
+  >
+    <React.Fragment>
+      <GlobalNotifications
+        addNotification={[Function]}
+        channels={
+          Array [
+            "channel1",
+            "channel2",
+          ]
+        }
+        notifications={
+          Array [
+            Object {
+              "channel": "channel1",
+              "type": "type-global",
+            },
+            Object {
+              "channel": "channel1",
+              "type": "type-common",
+            },
+          ]
+        }
+        removeNotification={[Function]}
+        types={
+          Array [
+            "type-global",
+            "type-common",
+          ]
+        }
+      />
+      <Projects
+        addNotification={[Function]}
+        channels={
+          Array [
+            "channel1",
+            "channel2",
+          ]
+        }
+        notificationsByProject={
+          Object {
+            "foo": Array [
+              Object {
+                "channel": "channel2",
+                "organization": "org",
+                "project": "foo",
+                "projectName": "Foo",
+                "type": "type-common",
+              },
+            ],
+          }
+        }
+        projects={
+          Array [
+            Object {
+              "key": "foo",
+              "name": "Foo",
+              "organization": "org",
+            },
+          ]
+        }
+        removeNotification={[Function]}
+        types={
+          Array [
+            "type-common",
+          ]
+        }
+      />
+    </React.Fragment>
+  </DeferredSpinner>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/NotificationsList-test.js.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/NotificationsList-test.js.snap
deleted file mode 100644 (file)
index 13a18bf..0000000
+++ /dev/null
@@ -1,127 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders project-specific labels 1`] = `
-<tbody>
-  <tr
-    key="type1"
-  >
-    <td>
-      notification.dispatcher.type1.project
-    </td>
-    <td
-      className="text-center"
-      key="channel1"
-    >
-      <Checkbox
-        checked={true}
-        id="checkbox-io-type1-channel1"
-        onCheck={[Function]}
-        thirdState={false}
-      />
-    </td>
-    <td
-      className="text-center"
-      key="channel2"
-    >
-      <Checkbox
-        checked={false}
-        id="checkbox-io-type1-channel2"
-        onCheck={[Function]}
-        thirdState={false}
-      />
-    </td>
-  </tr>
-  <tr
-    key="type2"
-  >
-    <td>
-      notification.dispatcher.type2.project
-    </td>
-    <td
-      className="text-center"
-      key="channel1"
-    >
-      <Checkbox
-        checked={true}
-        id="checkbox-io-type2-channel1"
-        onCheck={[Function]}
-        thirdState={false}
-      />
-    </td>
-    <td
-      className="text-center"
-      key="channel2"
-    >
-      <Checkbox
-        checked={true}
-        id="checkbox-io-type2-channel2"
-        onCheck={[Function]}
-        thirdState={false}
-      />
-    </td>
-  </tr>
-</tbody>
-`;
-
-exports[`should match snapshot 1`] = `
-<tbody>
-  <tr
-    key="type1"
-  >
-    <td>
-      notification.dispatcher.type1
-    </td>
-    <td
-      className="text-center"
-      key="channel1"
-    >
-      <Checkbox
-        checked={true}
-        id="checkbox-io-type1-channel1"
-        onCheck={[Function]}
-        thirdState={false}
-      />
-    </td>
-    <td
-      className="text-center"
-      key="channel2"
-    >
-      <Checkbox
-        checked={false}
-        id="checkbox-io-type1-channel2"
-        onCheck={[Function]}
-        thirdState={false}
-      />
-    </td>
-  </tr>
-  <tr
-    key="type2"
-  >
-    <td>
-      notification.dispatcher.type2
-    </td>
-    <td
-      className="text-center"
-      key="channel1"
-    >
-      <Checkbox
-        checked={true}
-        id="checkbox-io-type2-channel1"
-        onCheck={[Function]}
-        thirdState={false}
-      />
-    </td>
-    <td
-      className="text-center"
-      key="channel2"
-    >
-      <Checkbox
-        checked={true}
-        id="checkbox-io-type2-channel2"
-        onCheck={[Function]}
-        thirdState={false}
-      />
-    </td>
-  </tr>
-</tbody>
-`;
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/NotificationsList-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/NotificationsList-test.tsx.snap
new file mode 100644 (file)
index 0000000..13a18bf
--- /dev/null
@@ -0,0 +1,127 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders project-specific labels 1`] = `
+<tbody>
+  <tr
+    key="type1"
+  >
+    <td>
+      notification.dispatcher.type1.project
+    </td>
+    <td
+      className="text-center"
+      key="channel1"
+    >
+      <Checkbox
+        checked={true}
+        id="checkbox-io-type1-channel1"
+        onCheck={[Function]}
+        thirdState={false}
+      />
+    </td>
+    <td
+      className="text-center"
+      key="channel2"
+    >
+      <Checkbox
+        checked={false}
+        id="checkbox-io-type1-channel2"
+        onCheck={[Function]}
+        thirdState={false}
+      />
+    </td>
+  </tr>
+  <tr
+    key="type2"
+  >
+    <td>
+      notification.dispatcher.type2.project
+    </td>
+    <td
+      className="text-center"
+      key="channel1"
+    >
+      <Checkbox
+        checked={true}
+        id="checkbox-io-type2-channel1"
+        onCheck={[Function]}
+        thirdState={false}
+      />
+    </td>
+    <td
+      className="text-center"
+      key="channel2"
+    >
+      <Checkbox
+        checked={true}
+        id="checkbox-io-type2-channel2"
+        onCheck={[Function]}
+        thirdState={false}
+      />
+    </td>
+  </tr>
+</tbody>
+`;
+
+exports[`should match snapshot 1`] = `
+<tbody>
+  <tr
+    key="type1"
+  >
+    <td>
+      notification.dispatcher.type1
+    </td>
+    <td
+      className="text-center"
+      key="channel1"
+    >
+      <Checkbox
+        checked={true}
+        id="checkbox-io-type1-channel1"
+        onCheck={[Function]}
+        thirdState={false}
+      />
+    </td>
+    <td
+      className="text-center"
+      key="channel2"
+    >
+      <Checkbox
+        checked={false}
+        id="checkbox-io-type1-channel2"
+        onCheck={[Function]}
+        thirdState={false}
+      />
+    </td>
+  </tr>
+  <tr
+    key="type2"
+  >
+    <td>
+      notification.dispatcher.type2
+    </td>
+    <td
+      className="text-center"
+      key="channel1"
+    >
+      <Checkbox
+        checked={true}
+        id="checkbox-io-type2-channel1"
+        onCheck={[Function]}
+        thirdState={false}
+      />
+    </td>
+    <td
+      className="text-center"
+      key="channel2"
+    >
+      <Checkbox
+        checked={true}
+        id="checkbox-io-type2-channel2"
+        onCheck={[Function]}
+        thirdState={false}
+      />
+    </td>
+  </tr>
+</tbody>
+`;
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.js.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.js.snap
deleted file mode 100644 (file)
index 33c5542..0000000
+++ /dev/null
@@ -1,88 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should match snapshot 1`] = `
-<table
-  className="form big-spacer-bottom"
-  key="foo"
->
-  <thead>
-    <tr>
-      <th>
-        <span
-          className="text-normal"
-        >
-          <Connect(Organization) />
-        </span>
-        <h4
-          className="display-inline-block"
-        >
-          <Link
-            onlyActiveOnIndex={false}
-            style={Object {}}
-            to={
-              Object {
-                "pathname": "/dashboard",
-                "query": Object {
-                  "id": "foo",
-                },
-              }
-            }
-          >
-            Foo
-          </Link>
-        </h4>
-      </th>
-      <th
-        className="text-center"
-        key="channel1"
-      >
-        <h4>
-          notification.channel.channel1
-        </h4>
-      </th>
-      <th
-        className="text-center"
-        key="channel2"
-      >
-        <h4>
-          notification.channel.channel2
-        </h4>
-      </th>
-    </tr>
-  </thead>
-  <NotificationsList
-    channels={
-      Array [
-        "channel1",
-        "channel2",
-      ]
-    }
-    checkboxId={[Function]}
-    notifications={
-      Array [
-        Object {
-          "channel": "channel1",
-          "type": "type1",
-        },
-        Object {
-          "channel": "channel1",
-          "type": "type2",
-        },
-        Object {
-          "channel": "channel2",
-          "type": "type2",
-        },
-      ]
-    }
-    onAdd={[Function]}
-    onRemove={[Function]}
-    project={true}
-    types={
-      Array [
-        "type1",
-        "type2",
-      ]
-    }
-  />
-</table>
-`;
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.tsx.snap
new file mode 100644 (file)
index 0000000..90b25b1
--- /dev/null
@@ -0,0 +1,90 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should match snapshot 1`] = `
+<table
+  className="form big-spacer-bottom"
+  key="foo"
+>
+  <thead>
+    <tr>
+      <th>
+        <span
+          className="text-normal"
+        >
+          <Connect(Organization)
+            organizationKey="org"
+          />
+        </span>
+        <h4
+          className="display-inline-block"
+        >
+          <Link
+            onlyActiveOnIndex={false}
+            style={Object {}}
+            to={
+              Object {
+                "pathname": "/dashboard",
+                "query": Object {
+                  "id": "foo",
+                },
+              }
+            }
+          >
+            Foo
+          </Link>
+        </h4>
+      </th>
+      <th
+        className="text-center"
+        key="channel1"
+      >
+        <h4>
+          notification.channel.channel1
+        </h4>
+      </th>
+      <th
+        className="text-center"
+        key="channel2"
+      >
+        <h4>
+          notification.channel.channel2
+        </h4>
+      </th>
+    </tr>
+  </thead>
+  <NotificationsList
+    channels={
+      Array [
+        "channel1",
+        "channel2",
+      ]
+    }
+    checkboxId={[Function]}
+    notifications={
+      Array [
+        Object {
+          "channel": "channel1",
+          "type": "type1",
+        },
+        Object {
+          "channel": "channel1",
+          "type": "type2",
+        },
+        Object {
+          "channel": "channel2",
+          "type": "type2",
+        },
+      ]
+    }
+    onAdd={[Function]}
+    onRemove={[Function]}
+    project={true}
+    types={
+      Array [
+        "type1",
+        "type2",
+      ]
+    }
+  />
+</table>
+`;
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.js.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.js.snap
deleted file mode 100644 (file)
index 7f2fb25..0000000
+++ /dev/null
@@ -1,196 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render projects 1`] = `
-<section
-  className="boxed-group"
->
-  <h2>
-    my_profile.per_project_notifications.title
-  </h2>
-  <div
-    className="boxed-group-inner"
-  >
-    <Connect(ProjectNotifications)
-      key="foo"
-      project={
-        Object {
-          "key": "foo",
-          "name": "Foo",
-        }
-      }
-    />
-    <Connect(ProjectNotifications)
-      key="bar"
-      project={
-        Object {
-          "key": "bar",
-          "name": "Bar",
-        }
-      }
-    />
-    <div
-      className="spacer-top panel bg-muted"
-    >
-      <span
-        className="text-middle spacer-right"
-      >
-        my_account.set_notifications_for
-        :
-      </span>
-      <AsyncSelect
-        autoload={false}
-        cache={false}
-        loadOptions={[Function]}
-        minimumInput={2}
-        name="new_project"
-        onChange={[Function]}
-        optionRenderer={[Function]}
-        placeholder="my_account.search_project"
-        style={
-          Object {
-            "width": "300px",
-          }
-        }
-      />
-    </div>
-  </div>
-</section>
-`;
-
-exports[`should render projects 2`] = `
-<section
-  className="boxed-group"
->
-  <h2>
-    my_profile.per_project_notifications.title
-  </h2>
-  <div
-    className="boxed-group-inner"
-  >
-    <Connect(ProjectNotifications)
-      key="foo"
-      project={
-        Object {
-          "key": "foo",
-          "name": "Foo",
-        }
-      }
-    />
-    <Connect(ProjectNotifications)
-      key="bar"
-      project={
-        Object {
-          "key": "bar",
-          "name": "Bar",
-        }
-      }
-    />
-    <Connect(ProjectNotifications)
-      key="qux"
-      project={
-        Object {
-          "key": "qux",
-          "name": "Qux",
-        }
-      }
-    />
-    <div
-      className="spacer-top panel bg-muted"
-    >
-      <span
-        className="text-middle spacer-right"
-      >
-        my_account.set_notifications_for
-        :
-      </span>
-      <AsyncSelect
-        autoload={false}
-        cache={false}
-        loadOptions={[Function]}
-        minimumInput={2}
-        name="new_project"
-        onChange={[Function]}
-        optionRenderer={[Function]}
-        placeholder="my_account.search_project"
-        style={
-          Object {
-            "width": "300px",
-          }
-        }
-      />
-    </div>
-  </div>
-</section>
-`;
-
-exports[`should render projects 3`] = `
-<section
-  className="boxed-group"
->
-  <h2>
-    my_profile.per_project_notifications.title
-  </h2>
-  <div
-    className="boxed-group-inner"
-  >
-    <Connect(ProjectNotifications)
-      key="foo"
-      project={
-        Object {
-          "key": "foo",
-          "name": "Foo",
-        }
-      }
-    />
-    <Connect(ProjectNotifications)
-      key="bar"
-      project={
-        Object {
-          "key": "bar",
-          "name": "Bar",
-        }
-      }
-    />
-    <Connect(ProjectNotifications)
-      key="qux"
-      project={
-        Object {
-          "key": "qux",
-          "name": "Qux",
-        }
-      }
-    />
-    <div
-      className="spacer-top panel bg-muted"
-    >
-      <span
-        className="text-middle spacer-right"
-      >
-        my_account.set_notifications_for
-        :
-      </span>
-      <AsyncSelect
-        autoload={false}
-        cache={false}
-        loadOptions={[Function]}
-        minimumInput={2}
-        name="new_project"
-        onChange={[Function]}
-        optionRenderer={[Function]}
-        placeholder="my_account.search_project"
-        style={
-          Object {
-            "width": "300px",
-          }
-        }
-      />
-    </div>
-  </div>
-</section>
-`;
-
-exports[`should render projects 4`] = `
-Object {
-  "addedProjects": Array [],
-}
-`;
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.tsx.snap
new file mode 100644 (file)
index 0000000..74aceb0
--- /dev/null
@@ -0,0 +1,375 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render option 1`] = `
+<span>
+  <Connect(Organization)
+    link={false}
+    organizationKey="org"
+  />
+  <strong>
+    Qwe
+  </strong>
+</span>
+`;
+
+exports[`should render projects 1`] = `
+<section
+  className="boxed-group"
+>
+  <h2>
+    my_profile.per_project_notifications.title
+  </h2>
+  <div
+    className="boxed-group-inner"
+  >
+    <ProjectNotifications
+      addNotification={[MockFunction]}
+      channels={
+        Array [
+          "channel1",
+          "channel2",
+        ]
+      }
+      key="foo"
+      notifications={
+        Array [
+          Object {
+            "channel": "channel1",
+            "organization": "org",
+            "project": "foo",
+            "projectName": "Foo",
+            "type": "type1",
+          },
+          Object {
+            "channel": "channel1",
+            "organization": "org",
+            "project": "foo",
+            "projectName": "Foo",
+            "type": "type2",
+          },
+        ]
+      }
+      project={
+        Object {
+          "key": "foo",
+          "name": "Foo",
+          "organization": "org",
+        }
+      }
+      removeNotification={[MockFunction]}
+      types={
+        Array [
+          "type1",
+          "type2",
+        ]
+      }
+    />
+    <ProjectNotifications
+      addNotification={[MockFunction]}
+      channels={
+        Array [
+          "channel1",
+          "channel2",
+        ]
+      }
+      key="bar"
+      notifications={Array []}
+      project={
+        Object {
+          "key": "bar",
+          "name": "Bar",
+          "organization": "org",
+        }
+      }
+      removeNotification={[MockFunction]}
+      types={
+        Array [
+          "type1",
+          "type2",
+        ]
+      }
+    />
+    <div
+      className="spacer-top panel bg-muted"
+    >
+      <span
+        className="text-middle spacer-right"
+      >
+        my_account.set_notifications_for
+        :
+      </span>
+      <AsyncSelect
+        autoload={false}
+        cache={false}
+        className="input-super-large"
+        loadOptions={[Function]}
+        minimumInput={2}
+        name="new_project"
+        onChange={[Function]}
+        optionRenderer={[Function]}
+        placeholder="my_account.search_project"
+      />
+    </div>
+  </div>
+</section>
+`;
+
+exports[`should render projects 2`] = `
+<section
+  className="boxed-group"
+>
+  <h2>
+    my_profile.per_project_notifications.title
+  </h2>
+  <div
+    className="boxed-group-inner"
+  >
+    <ProjectNotifications
+      addNotification={[MockFunction]}
+      channels={
+        Array [
+          "channel1",
+          "channel2",
+        ]
+      }
+      key="foo"
+      notifications={
+        Array [
+          Object {
+            "channel": "channel1",
+            "organization": "org",
+            "project": "foo",
+            "projectName": "Foo",
+            "type": "type1",
+          },
+          Object {
+            "channel": "channel1",
+            "organization": "org",
+            "project": "foo",
+            "projectName": "Foo",
+            "type": "type2",
+          },
+        ]
+      }
+      project={
+        Object {
+          "key": "foo",
+          "name": "Foo",
+          "organization": "org",
+        }
+      }
+      removeNotification={[MockFunction]}
+      types={
+        Array [
+          "type1",
+          "type2",
+        ]
+      }
+    />
+    <ProjectNotifications
+      addNotification={[MockFunction]}
+      channels={
+        Array [
+          "channel1",
+          "channel2",
+        ]
+      }
+      key="bar"
+      notifications={Array []}
+      project={
+        Object {
+          "key": "bar",
+          "name": "Bar",
+          "organization": "org",
+        }
+      }
+      removeNotification={[MockFunction]}
+      types={
+        Array [
+          "type1",
+          "type2",
+        ]
+      }
+    />
+    <ProjectNotifications
+      addNotification={[MockFunction]}
+      channels={
+        Array [
+          "channel1",
+          "channel2",
+        ]
+      }
+      key="qux"
+      notifications={Array []}
+      project={
+        Object {
+          "key": "qux",
+          "name": "Qux",
+          "organization": "org",
+        }
+      }
+      removeNotification={[MockFunction]}
+      types={
+        Array [
+          "type1",
+          "type2",
+        ]
+      }
+    />
+    <div
+      className="spacer-top panel bg-muted"
+    >
+      <span
+        className="text-middle spacer-right"
+      >
+        my_account.set_notifications_for
+        :
+      </span>
+      <AsyncSelect
+        autoload={false}
+        cache={false}
+        className="input-super-large"
+        loadOptions={[Function]}
+        minimumInput={2}
+        name="new_project"
+        onChange={[Function]}
+        optionRenderer={[Function]}
+        placeholder="my_account.search_project"
+      />
+    </div>
+  </div>
+</section>
+`;
+
+exports[`should render projects 3`] = `
+<section
+  className="boxed-group"
+>
+  <h2>
+    my_profile.per_project_notifications.title
+  </h2>
+  <div
+    className="boxed-group-inner"
+  >
+    <ProjectNotifications
+      addNotification={[MockFunction]}
+      channels={
+        Array [
+          "channel1",
+          "channel2",
+        ]
+      }
+      key="foo"
+      notifications={
+        Array [
+          Object {
+            "channel": "channel1",
+            "organization": "org",
+            "project": "foo",
+            "projectName": "Foo",
+            "type": "type1",
+          },
+          Object {
+            "channel": "channel1",
+            "organization": "org",
+            "project": "foo",
+            "projectName": "Foo",
+            "type": "type2",
+          },
+        ]
+      }
+      project={
+        Object {
+          "key": "foo",
+          "name": "Foo",
+          "organization": "org",
+        }
+      }
+      removeNotification={[MockFunction]}
+      types={
+        Array [
+          "type1",
+          "type2",
+        ]
+      }
+    />
+    <ProjectNotifications
+      addNotification={[MockFunction]}
+      channels={
+        Array [
+          "channel1",
+          "channel2",
+        ]
+      }
+      key="bar"
+      notifications={Array []}
+      project={
+        Object {
+          "key": "bar",
+          "name": "Bar",
+          "organization": "org",
+        }
+      }
+      removeNotification={[MockFunction]}
+      types={
+        Array [
+          "type1",
+          "type2",
+        ]
+      }
+    />
+    <ProjectNotifications
+      addNotification={[MockFunction]}
+      channels={
+        Array [
+          "channel1",
+          "channel2",
+        ]
+      }
+      key="qux"
+      notifications={Array []}
+      project={
+        Object {
+          "key": "qux",
+          "name": "Qux",
+          "organization": "org",
+        }
+      }
+      removeNotification={[MockFunction]}
+      types={
+        Array [
+          "type1",
+          "type2",
+        ]
+      }
+    />
+    <div
+      className="spacer-top panel bg-muted"
+    >
+      <span
+        className="text-middle spacer-right"
+      >
+        my_account.set_notifications_for
+        :
+      </span>
+      <AsyncSelect
+        autoload={false}
+        cache={false}
+        className="input-super-large"
+        loadOptions={[Function]}
+        minimumInput={2}
+        name="new_project"
+        onChange={[Function]}
+        optionRenderer={[Function]}
+        placeholder="my_account.search_project"
+      />
+    </div>
+  </div>
+</section>
+`;
+
+exports[`should render projects 4`] = `
+Object {
+  "addedProjects": Array [],
+}
+`;
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/actions.js b/server/sonar-web/src/main/js/apps/account/notifications/actions.js
deleted file mode 100644 (file)
index 29a2c1d..0000000
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2018 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.
- */
-// @flow
-import * as api from '../../../api/notifications';
-/*:: import type { GetNotificationsResponse } from '../../../api/notifications'; */
-import { onFail, fetchOrganizations } from '../../../store/rootActions';
-import {
-  receiveNotifications,
-  addNotification as addNotificationAction,
-  removeNotification as removeNotificationAction
-} from '../../../store/notifications/duck';
-/*:: import type { Notification } from '../../../store/notifications/duck'; */
-
-export const fetchNotifications = () => (dispatch /*: Function */) => {
-  const onFulfil = (response /*: GetNotificationsResponse */) => {
-    const organizations = response.notifications
-      .filter(n => n.organization)
-      .map(n => n.organization);
-
-    dispatch(fetchOrganizations(organizations)).then(() => {
-      dispatch(
-        receiveNotifications(
-          response.notifications,
-          response.channels,
-          response.globalTypes,
-          response.perProjectTypes
-        )
-      );
-    });
-  };
-
-  return api.getNotifications().then(onFulfil, onFail(dispatch));
-};
-
-export const addNotification = (n /*: Notification */) => (dispatch /*: Function */) =>
-  api
-    .addNotification(n.channel, n.type, n.project)
-    .then(() => dispatch(addNotificationAction(n)), onFail(dispatch));
-
-export const removeNotification = (n /*: Notification */) => (dispatch /*: Function */) =>
-  api
-    .removeNotification(n.channel, n.type, n.project)
-    .then(() => dispatch(removeNotificationAction(n)), onFail(dispatch));
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/types.ts b/server/sonar-web/src/main/js/apps/account/notifications/types.ts
new file mode 100644 (file)
index 0000000..58d5dc5
--- /dev/null
@@ -0,0 +1,24 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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.
+ */
+export interface NotificationProject {
+  key: string;
+  name: string;
+  organization: string;
+}
index 2683c0988f69526cdaf3e98f0a9b9c71bf48b766..b65220d777e8bebff83b3e2b40e4b4f25de17c13 100644 (file)
@@ -36,7 +36,7 @@ const routes = [
       },
       {
         path: 'notifications',
-        component: lazyLoad(() => import('./notifications/Notifications'))
+        component: lazyLoad(() => import('./notifications/NotificationsContainer'))
       },
       {
         path: 'organizations',
index 85fa556edc913130aa194ed17d7b496bf8852868..8aa569de29bb585189ed3e69c290e47c80679ae6 100644 (file)
@@ -60,6 +60,6 @@ export function Creatable(props: ReactCreatableSelectProps) {
 }
 
 // TODO figure out why `ref` prop is incompatible
-export function AsyncSelect(props: ReactAsyncSelectProps & { ref: any }) {
+export function AsyncSelect(props: ReactAsyncSelectProps & { ref?: any }) {
   return <Async {...props} />;
 }
diff --git a/server/sonar-web/src/main/js/store/notifications/duck.js b/server/sonar-web/src/main/js/store/notifications/duck.js
deleted file mode 100644 (file)
index 0861944..0000000
+++ /dev/null
@@ -1,204 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2018 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.
- */
-// @flow
-import { combineReducers } from 'redux';
-import { uniqBy, uniqWith } from 'lodash';
-
-/*::
-export type Notification = {
-  channel: string,
-  type: string,
-  project?: string,
-  projectName?: string,
-  organization?: string
-};
-*/
-
-/*::
-export type NotificationsState = Array<Notification>;
-*/
-/*::
-export type ChannelsState = Array<string>;
-*/
-/*::
-export type TypesState = Array<string>;
-*/
-
-/*::
-type AddNotificationAction = {
-  type: 'ADD_NOTIFICATION',
-  notification: Notification
-};
-*/
-
-/*::
-type RemoveNotificationAction = {
-  type: 'REMOVE_NOTIFICATION',
-  notification: Notification
-};
-*/
-
-/*::
-type ReceiveNotificationsAction = {
-  type: 'RECEIVE_NOTIFICATIONS',
-  notifications: NotificationsState,
-  channels: ChannelsState,
-  globalTypes: TypesState,
-  perProjectTypes: TypesState
-};
-*/
-
-/*::
-type Action = AddNotificationAction | RemoveNotificationAction | ReceiveNotificationsAction;
-*/
-
-export function addNotification(notification /*: Notification */) /*: AddNotificationAction */ {
-  return {
-    type: 'ADD_NOTIFICATION',
-    notification
-  };
-}
-
-export function removeNotification(
-  notification /*: Notification */
-) /*: RemoveNotificationAction */ {
-  return {
-    type: 'REMOVE_NOTIFICATION',
-    notification
-  };
-}
-
-export function receiveNotifications(
-  notifications /*: NotificationsState */,
-  channels /*: ChannelsState */,
-  globalTypes /*: TypesState */,
-  perProjectTypes /*: TypesState */
-) /*: ReceiveNotificationsAction */ {
-  return {
-    type: 'RECEIVE_NOTIFICATIONS',
-    notifications,
-    channels,
-    globalTypes,
-    perProjectTypes
-  };
-}
-
-function onAddNotification(state /*: NotificationsState */, notification /*: Notification */) {
-  function isNotificationsEqual(a /*: Notification */, b /*: Notification */) {
-    return a.channel === b.channel && a.type === b.type && a.project === b.project;
-  }
-
-  return uniqWith([...state, notification], isNotificationsEqual);
-}
-
-function onRemoveNotification(state /*: NotificationsState */, notification /*: Notification */) {
-  return state.filter(
-    n =>
-      n.channel !== notification.channel ||
-      n.type !== notification.type ||
-      n.project !== notification.project
-  );
-}
-
-function onReceiveNotifications(
-  state /*: NotificationsState */,
-  notifications /*: NotificationsState */
-) {
-  return [...notifications];
-}
-
-function notifications(state /*: NotificationsState */ = [], action /*: Action */) {
-  switch (action.type) {
-    case 'ADD_NOTIFICATION':
-      return onAddNotification(state, action.notification);
-    case 'REMOVE_NOTIFICATION':
-      return onRemoveNotification(state, action.notification);
-    case 'RECEIVE_NOTIFICATIONS':
-      return onReceiveNotifications(state, action.notifications);
-    default:
-      return state;
-  }
-}
-
-function channels(state /*: ChannelsState */ = [], action /*: Action */) {
-  if (action.type === 'RECEIVE_NOTIFICATIONS') {
-    return action.channels;
-  } else {
-    return state;
-  }
-}
-
-function globalTypes(state /*: TypesState */ = [], action /*: Action */) {
-  if (action.type === 'RECEIVE_NOTIFICATIONS') {
-    return action.globalTypes;
-  } else {
-    return state;
-  }
-}
-
-function perProjectTypes(state /*: TypesState */ = [], action /*: Action */) {
-  if (action.type === 'RECEIVE_NOTIFICATIONS') {
-    return action.perProjectTypes;
-  } else {
-    return state;
-  }
-}
-
-/*::
-type State = {
-  notifications: NotificationsState,
-  channels: ChannelsState,
-  globalTypes: TypesState,
-  perProjectTypes: TypesState
-};
-*/
-
-export default combineReducers({ notifications, channels, globalTypes, perProjectTypes });
-
-export function getGlobal(state /*: State */) /*: NotificationsState */ {
-  return state.notifications.filter(n => !n.project);
-}
-
-export function getProjects(state /*: State */) /*: Array<{ key: string, name: string }> */ {
-  // $FlowFixMe
-  const allProjects = state.notifications.filter(n => n.project != null).map(n => ({
-    key: n.project,
-    name: n.projectName,
-    organization: n.organization
-  }));
-
-  return uniqBy(allProjects, project => project.key);
-}
-
-export function getForProject(state /*: State */, project /*: string */) /*: NotificationsState */ {
-  return state.notifications.filter(n => n.project === project);
-}
-
-export function getChannels(state /*: State */) /*: ChannelsState */ {
-  return state.channels;
-}
-
-export function getGlobalTypes(state /*: State */) /*: TypesState */ {
-  return state.globalTypes;
-}
-
-export function getPerProjectTypes(state /*: State */) /*: TypesState */ {
-  return state.perProjectTypes;
-}
index 15c9d1ac30b877f215885eceec25efa51d3af993..0ca7aab07c43d23e267fb76844baa9dcd9edc2a0 100644 (file)
@@ -24,7 +24,6 @@ import users, * as fromUsers from './users/reducer';
 import favorites, * as fromFavorites from './favorites/duck';
 import languages, * as fromLanguages from './languages/reducer';
 import metrics, * as fromMetrics from './metrics/reducer';
-import notifications, * as fromNotifications from './notifications/duck';
 import organizations, * as fromOrganizations from './organizations/duck';
 import organizationsMembers, * as fromOrganizationsMembers from './organizationsMembers/reducer';
 import globalMessages, * as fromGlobalMessages from './globalMessages/duck';
@@ -39,7 +38,6 @@ export default combineReducers({
   languages,
   marketplace,
   metrics,
-  notifications,
   organizations,
   organizationsMembers,
   users,
@@ -89,22 +87,6 @@ export const getMetricByKey = (state, key) => fromMetrics.getMetricByKey(state.m
 
 export const getMetricsKey = state => fromMetrics.getMetricsKey(state.metrics);
 
-export const getGlobalNotifications = state => fromNotifications.getGlobal(state.notifications);
-
-export const getProjectsWithNotifications = state =>
-  fromNotifications.getProjects(state.notifications);
-
-export const getProjectNotifications = (state, project) =>
-  fromNotifications.getForProject(state.notifications, project);
-
-export const getNotificationChannels = state => fromNotifications.getChannels(state.notifications);
-
-export const getNotificationGlobalTypes = state =>
-  fromNotifications.getGlobalTypes(state.notifications);
-
-export const getNotificationPerProjectTypes = state =>
-  fromNotifications.getPerProjectTypes(state.notifications);
-
 export const getOrganizationByKey = (state, key) =>
   fromOrganizations.getOrganizationByKey(state.organizations, key);