]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-8563 Rewrite notifications page
authorStas Vilchik <vilchiks@gmail.com>
Thu, 29 Dec 2016 11:02:55 +0000 (12:02 +0100)
committerStas Vilchik <vilchiks@gmail.com>
Thu, 29 Dec 2016 16:26:40 +0000 (17:26 +0100)
28 files changed:
it/it-tests/src/test/java/it/user/MyAccountPageTest.java
it/it-tests/src/test/java/pageobjects/Navigation.java
it/it-tests/src/test/java/pageobjects/NotificationsPage.java [new file with mode: 0644]
server/sonar-web/src/main/js/api/components.js
server/sonar-web/src/main/js/api/notifications.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/components/Nav.js
server/sonar-web/src/main/js/apps/account/notifications/GlobalNotifications.js
server/sonar-web/src/main/js/apps/account/notifications/Notifications.js
server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.js [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/NotificationsList.js
server/sonar-web/src/main/js/apps/account/notifications/ProjectNotification.js [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.js
server/sonar-web/src/main/js/apps/account/notifications/Projects.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/GlobalNotifications-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/NotificationsList-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/GlobalNotifications-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/NotificationsList-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/actions.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/routes.js
server/sonar-web/src/main/js/components/controls/Checkbox.js
server/sonar-web/src/main/js/store/notifications/duck.js [new file with mode: 0644]
server/sonar-web/src/main/js/store/rootReducer.js

index 01140a1b11685910c7a5e7effb75754d77567103..4fda2613b38d4105f85cf6a281a22884af16ab05 100644 (file)
@@ -83,6 +83,18 @@ public class MyAccountPageTest {
     runSelenese(orchestrator, "/user/MyAccountPageTest/should_display_projects.html");
   }
 
+  @Test
+  public void notifications() {
+    nav.logIn().asAdmin().openNotifications()
+      .addGlobalNotification("ChangesOnMyIssue")
+      .addGlobalNotification("NewIssues")
+      .removeGlobalNotification("ChangesOnMyIssue");
+
+    nav.openNotifications()
+      .shouldHaveGlobalNotification("NewIssues")
+      .shouldNotHaveGlobalNotification("ChangesOnMyIssue");
+  }
+
   private static void createUser(String login, String name, String email) {
     adminWsClient.wsConnector().call(
       new PostRequest("api/users/create")
index e9c23d63f14b913d3a70ea9e34b8c458a13d1c07..359cb5a89136305f2566aa3be19982144e2954cf 100644 (file)
@@ -112,6 +112,10 @@ public class Navigation extends ExternalResource {
     return open("/settings/server_id", ServerIdPage.class);
   }
 
+  public NotificationsPage openNotifications() {
+    return open("/account/notifications", NotificationsPage.class);
+  }
+
   public LoginPage openLogin() {
     return open("/sessions/login", LoginPage.class);
   }
diff --git a/it/it-tests/src/test/java/pageobjects/NotificationsPage.java b/it/it-tests/src/test/java/pageobjects/NotificationsPage.java
new file mode 100644 (file)
index 0000000..a67e554
--- /dev/null
@@ -0,0 +1,118 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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.
+ */
+package pageobjects;
+
+import static com.codeborne.selenide.Condition.cssClass;
+import static com.codeborne.selenide.Condition.visible;
+import static com.codeborne.selenide.Selenide.$;
+
+public class NotificationsPage {
+
+  private final String EMAIL = "EmailNotificationChannel";
+
+  public NotificationsPage() {
+  }
+
+  public NotificationsPage shouldHaveGlobalNotification(String type) {
+    return shouldHaveGlobalNotification(type, EMAIL);
+  }
+
+  public NotificationsPage shouldHaveGlobalNotification(String type, String channel) {
+    return shouldBeChecked(globalCheckboxSelector(type, channel));
+  }
+
+  public NotificationsPage shouldNotHaveGlobalNotification(String type) {
+    return shouldNotHaveGlobalNotification(type, EMAIL);
+  }
+
+  public NotificationsPage shouldNotHaveGlobalNotification(String type, String channel) {
+    return shouldNotBeChecked(globalCheckboxSelector(type, channel));
+  }
+
+  public NotificationsPage shouldHaveProjectNotification(String project, String type, String channel) {
+    return shouldBeChecked(projectCheckboxSelector(project, type, channel));
+  }
+
+  public NotificationsPage shouldNotHaveProjectNotification(String project, String type, String channel) {
+    return shouldNotBeChecked(projectCheckboxSelector(project, type, channel));
+  }
+
+  public NotificationsPage addGlobalNotification(String type) {
+    return addGlobalNotification(type, EMAIL);
+  }
+
+  public NotificationsPage addGlobalNotification(String type, String channel) {
+    shouldNotHaveGlobalNotification(type, channel);
+    toggleCheckbox(globalCheckboxSelector(type, channel));
+    shouldHaveGlobalNotification(type, channel);
+    return this;
+  }
+
+  public NotificationsPage removeGlobalNotification(String type) {
+    return removeGlobalNotification(type, EMAIL);
+  }
+
+  public NotificationsPage removeGlobalNotification(String type, String channel) {
+    shouldHaveGlobalNotification(type, channel);
+    toggleCheckbox(globalCheckboxSelector(type, channel));
+    shouldNotHaveGlobalNotification(type, channel);
+    return this;
+  }
+
+  public NotificationsPage addProjectNotification(String project, String type, String channel) {
+    shouldNotHaveProjectNotification(project, type, channel);
+    toggleCheckbox(projectCheckboxSelector(project, type, channel));
+    shouldHaveProjectNotification(project, type, channel);
+    return this;
+  }
+
+  public NotificationsPage removeProjectNotification(String project, String type, String channel) {
+    shouldHaveProjectNotification(project, type, channel);
+    toggleCheckbox(projectCheckboxSelector(project, type, channel));
+    shouldNotHaveProjectNotification(project, type, channel);
+    return this;
+  }
+
+  private String globalCheckboxSelector(String type, String channel) {
+    return "#global-notification-" + type + "-" + channel;
+  }
+
+  private String projectCheckboxSelector(String project, String type, String channel) {
+    return "#project-notification-" + project + "-" + type + "-" + channel;
+  }
+
+  private NotificationsPage shouldBeChecked(String selector) {
+    $(selector)
+      .shouldBe(visible)
+      .shouldHave(cssClass("icon-checkbox-checked"));
+    return this;
+  }
+
+  private NotificationsPage shouldNotBeChecked(String selector) {
+    $(selector)
+      .shouldBe(visible)
+      .shouldNotHave(cssClass("icon-checkbox-checked"));
+    return this;
+  }
+
+  private void toggleCheckbox(String selector) {
+    $(selector).click();
+  }
+}
index fa6ce518734c3eb508ff5fa587030225bb153758..aaccb414a4d1d1c1de4932b470695e69abc98da7 100644 (file)
@@ -101,16 +101,6 @@ export function getBreadcrumbs ({ id, key }: { id: string, key: string }) {
   });
 }
 
-export function getProjectsWithInternalId (query: string) {
-  const url = '/api/resources/search';
-  const data = {
-    f: 's2',
-    q: 'TRK',
-    s: query
-  };
-  return getJSON(url, data).then(r => r.results);
-}
-
 export function getMyProjects (data?: Object) {
   const url = '/api/projects/search_my_projects';
   return getJSON(url, data);
@@ -121,6 +111,14 @@ export function searchProjects (data?: Object) {
   return getJSON(url, data);
 }
 
+export function simpleSearchProjects (data?: Object) {
+  const url = '/api/projects/index';
+  return getJSON(url, data).then(projects => projects.map(project => ({
+    key: project.k,
+    name: project.nm
+  })));
+}
+
 /**
  * Change component's key
  * @param {string} key
diff --git a/server/sonar-web/src/main/js/api/notifications.js b/server/sonar-web/src/main/js/api/notifications.js
new file mode 100644 (file)
index 0000000..4ca5b24
--- /dev/null
@@ -0,0 +1,53 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 { getJSON, post } from '../helpers/request';
+
+export type GetNotificationsResponse = {
+  notifications: Array<{
+    channel: string,
+    type: string,
+    project: string | null,
+    projectName: string | null
+  }>,
+  channels: Array<string>,
+  globalTypes: Array<string>,
+  perProjectTypes: Array<string>
+};
+
+export const getNotifications = (): Promise<GetNotificationsResponse> => (
+    getJSON('/api/notifications/list')
+);
+
+export const addNotification = (channel: string, type: string, project: string | null): Promise<*> => {
+  const data: Object = { channel, type };
+  if (project) {
+    Object.assign(data, { project });
+  }
+  return post('/api/notifications/add', data);
+};
+
+export const removeNotification = (channel: string, type: string, project: string | null): Promise<*> => {
+  const data: Object = { channel, type };
+  if (project) {
+    Object.assign(data, { project });
+  }
+  return post('/api/notifications/remove', data);
+};
index 1f9363f5f1ac376da6728677ad3c004a2aec93f9..f02b4f2db8981446f7fa8e6efa201319d9eedee9 100644 (file)
@@ -34,6 +34,11 @@ const Nav = () => (
             {translate('my_account.security')}
           </Link>
         </li>
+        <li>
+          <Link to="/account/notifications" activeClassName="active">
+            {translate('my_account.notifications')}
+          </Link>
+        </li>
         <li>
           <Link to="/account/projects/" activeClassName="active">
             {translate('my_account.projects')}
index 469dfe26312159a7a13e290de8d1cbb1ce35ed3f..e6394c3ecf11f8bdfd6ff7564f229b5cae9d3ee1 100644 (file)
  * 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 { translate } from '../../../helpers/l10n';
+import {
+  getGlobalNotifications,
+  getNotificationChannels,
+  getNotificationGlobalTypes
+} from '../../../store/rootReducer';
+import type {
+  Notification,
+  NotificationsState,
+  ChannelsState,
+  TypesState
+} from '../../../store/notifications/duck';
+import { addNotification, removeNotification } from './actions';
 
-export default function GlobalNotifications ({ notifications, channels }) {
-  return (
-      <section>
-        <h2 className="spacer-bottom">
-          {translate('my_profile.overall_notifications.title')}
-        </h2>
-
-        <table className="form">
-          <thead>
-            <tr>
-              <th/>
-              {channels.map(channel => (
-                  <th key={channel} className="text-center">
-                    <h4>{translate('notification.channel', channel)}</h4>
-                  </th>
-              ))}
-            </tr>
-          </thead>
-
-          <NotificationsList
-              notifications={notifications}
-              checkboxId={(d, c) => `global_notifs_${d}_${c}`}
-              checkboxName={(d, c) => `global_notifs[${d}.${c}]`}/>
-        </table>
-      </section>
-  );
+class GlobalNotifications extends React.Component {
+  props: {
+    notifications: NotificationsState,
+    channels: ChannelsState,
+    types: TypesState,
+    addNotification: (n: Notification) => void,
+    removeNotification: (n: Notification) => void
+  };
+
+  render () {
+    return (
+        <section>
+          <h2 className="spacer-bottom">
+            {translate('my_profile.overall_notifications.title')}
+          </h2>
+
+          <table className="form">
+            <thead>
+              <tr>
+                <th/>
+                {this.props.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) => `global-notification-${d}-${c}`}
+                onAdd={this.props.addNotification}
+                onRemove={this.props.removeNotification}/>
+          </table>
+        </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;
index 27b96e5b2a0fd0c65c365f4bdc8278a73e8cf4ec..c5c1c887d6e3257082cbc81ed62c5945e915ad8b 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.
  */
+// @flow
 import React from 'react';
+import Helmet from 'react-helmet';
+import { connect } from 'react-redux';
 import GlobalNotifications from './GlobalNotifications';
-import ProjectNotifications from './ProjectNotifications';
+import Projects from './Projects';
 import { translate } from '../../../helpers/l10n';
+import { fetchNotifications } from './actions';
 
-const Notifications = ({ globalNotifications, projectNotifications, onAddProject, onRemoveProject }) => {
-  const channels = globalNotifications[0].channels.map(c => c.id);
+class Notifications extends React.Component {
+  props: {
+    fetchNotifications: () => void
+  };
 
-  return (
-      <div>
-        <p className="big-spacer-bottom">
-          {translate('notification.dispatcher.information')}
-        </p>
-        <form id="notif_form" method="post" action={`${window.baseUrl}/account/update_notifications`}>
-          <GlobalNotifications
-              notifications={globalNotifications}
-              channels={channels}/>
+  componentDidMount () {
+    this.props.fetchNotifications();
+  }
 
-          <hr className="account-separator"/>
+  render () {
+    const title = translate('my_account.page') + ' - ' + translate('my_account.notifications');
+
+    return (
+        <div className="account-body account-container">
+          <Helmet title={title} titleTemplate="SonarQube - %s"/>
+
+          <p className="big-spacer-bottom">
+            {translate('notification.dispatcher.information')}
+          </p>
 
-          <ProjectNotifications
-              notifications={projectNotifications}
-              channels={channels}
-              onAddProject={onAddProject}
-              onRemoveProject={onRemoveProject}/>
+          <GlobalNotifications/>
 
           <hr className="account-separator"/>
 
-          <div className="text-center">
-            <button id="submit-notifications" type="submit">
-              {translate('my_profile.notifications.submit')}
-            </button>
-          </div>
-        </form>
-      </div>
-  );
-};
-
-export default Notifications;
+          <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/NotificationsContainer.js b/server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.js
deleted file mode 100644 (file)
index 47933f2..0000000
+++ /dev/null
@@ -1,82 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2016 SonarSource SA
- * mailto:contact 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 Helmet from 'react-helmet';
-import Notifications from './Notifications';
-import { translate } from '../../../helpers/l10n';
-
-export default class NotificationsContainer extends React.Component {
-  state = {
-    globalNotifications: window.sonarqube.notifications.global,
-    projectNotifications: window.sonarqube.notifications.project
-  };
-
-  componentWillMount () {
-    this.handleAddProject = this.handleAddProject.bind(this);
-    this.handleRemoveProject = this.handleRemoveProject.bind(this);
-  }
-
-  handleAddProject (project) {
-    const { projectNotifications } = this.state;
-    const found = projectNotifications
-        .find(notification => notification.project.internalId === project.internalId);
-
-    if (!found) {
-      const newProjectNotification = {
-        project,
-        notifications: window.sonarqube.notifications.projectDispatchers.map(dispatcher => {
-          const channels = window.sonarqube.notifications.channels.map(channel => {
-            return { id: channel, checked: false };
-          });
-          return { dispatcher, channels };
-        })
-      };
-
-      this.setState({
-        projectNotifications: [...projectNotifications, newProjectNotification]
-      });
-    }
-  }
-
-  handleRemoveProject (project) {
-    const projectNotifications = this.state.projectNotifications
-        .filter(notification => notification.project.internalId !== project.internalId);
-    this.setState({ projectNotifications });
-  }
-
-  render () {
-    const title = translate('my_account.page') + ' - ' +
-        translate('my_account.notifications');
-
-    return (
-        <div className="account-body account-container">
-          <Helmet
-              title={title}
-              titleTemplate="SonarQube - %s"/>
-
-          <Notifications
-              globalNotifications={this.state.globalNotifications}
-              projectNotifications={this.state.projectNotifications}
-              onAddProject={this.handleAddProject}
-              onRemoveProject={this.handleRemoveProject}/>
-        </div>
-    );
-  }
-}
index cc7476e96865d6a1c45a8ba1853b2e75ce808202..2206194aadfd634dbf3364d1be993ae4b5f541af 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import React from 'react';
+import Checkbox from '../../../components/controls/Checkbox';
 import { translate } from '../../../helpers/l10n';
+import { Notification, NotificationsState, ChannelsState, TypesState } from '../../../store/notifications/duck';
 
-export default function NotificationsList ({ notifications, checkboxName, checkboxId }) {
-  return (
-      <tbody>
-        {notifications.map(notification => (
-            <tr key={notification.dispatcher}>
-              <td>{translate('notification.dispatcher', notification.dispatcher)}</td>
-              {notification.channels.map(channel => (
-                  <td key={channel.id} className="text-center">
-                    <input defaultChecked={channel.checked}
-                           id={checkboxId(notification.dispatcher, channel.id)}
-                           name={checkboxName(notification.dispatcher, channel.id)}
-                           type="checkbox"/>
-                  </td>
-              ))}
-            </tr>
-        ))}
-      </tbody>
-  );
+export default class NotificationsList extends React.Component {
+  props: {
+    onAdd: (n: Notification) => void,
+    onRemove: (n: Notification) => void,
+    channels: ChannelsState,
+    checkboxId: (string, string) => string,
+    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 });
+    }
+  }
+
+  render () {
+    const { channels, checkboxId, types } = this.props;
+
+    return (
+        <tbody>
+          {types.map(type => (
+              <tr key={type}>
+                <td>{translate('notification.dispatcher', 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/ProjectNotification.js b/server/sonar-web/src/main/js/apps/account/notifications/ProjectNotification.js
deleted file mode 100644 (file)
index 325cca5..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2016 SonarSource SA
- * mailto:contact 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 classNames from 'classnames';
-import React, { Component } from 'react';
-import NotificationsList from './NotificationsList';
-import { translate } from '../../../helpers/l10n';
-
-export default class ProjectNotification extends Component {
-  state = {
-    toDelete: false
-  };
-
-  handleRemoveProject (e) {
-    e.preventDefault();
-    if (this.state.toDelete) {
-      const { data, onRemoveProject } = this.props;
-      onRemoveProject(data.project);
-    } else {
-      this.setState({ toDelete: true });
-    }
-  }
-
-  render () {
-    const { data, channels } = this.props;
-    const buttonClassName = classNames('big-spacer-left', 'button-red', {
-      'active': this.state.toDelete
-    });
-
-    return (
-        <table key={data.project.internalId} className="form big-spacer-bottom">
-          <thead>
-            <tr>
-              <th>
-                <h4 className="display-inline-block">{data.project.name}</h4>
-                <button
-                    onClick={this.handleRemoveProject.bind(this)}
-                    className={buttonClassName}>
-                  {this.state.toDelete ? 'Sure?' : translate('delete')}
-                </button>
-              </th>
-              {channels.map(channel => (
-                  <th key={channel} className="text-center">
-                    <h4>{translate('notification.channel', channel)}</h4>
-                  </th>
-              ))}
-            </tr>
-          </thead>
-          <NotificationsList
-              notifications={data.notifications}
-              checkboxId={(d, c) => `project_notifs_${data.project.internalId}_${d}_${c}`}
-              checkboxName={(d, c) => `project_notifs[${data.project.internalId}][${d}][${c}]`}/>
-        </table>
-    );
-  }
-}
index cddb8f9149b89ba886cde4c8620a1ce6da172b67..828e4ec3ebad64e8e648b9246a7550afda9d323d 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import React from 'react';
-import Select from 'react-select';
-import ProjectNotification from './ProjectNotification';
+import { connect } from 'react-redux';
+import NotificationsList from './NotificationsList';
 import { translate } from '../../../helpers/l10n';
-import { getProjectsWithInternalId } from '../../../api/components';
+import {
+  getProjectNotifications,
+  getNotificationChannels,
+  getNotificationPerProjectTypes
+} from '../../../store/rootReducer';
+import type {
+  Notification,
+  NotificationsState,
+  ChannelsState,
+  TypesState
+} from '../../../store/notifications/duck';
+import { addNotification, removeNotification } from './actions';
 
-export default function ProjectNotifications ({ notifications, channels, onAddProject, onRemoveProject }) {
-  const loadOptions = query => {
-    return getProjectsWithInternalId(query)
-        .then(results => results.map(r => {
-          return {
-            value: r.id,
-            label: r.text
-          };
-        }))
-        .then(options => {
-          return { options };
-        });
+class ProjectNotifications extends React.Component {
+  props: {
+    project: {
+      key: string,
+      name: string
+    },
+    notifications: NotificationsState,
+    channels: ChannelsState,
+    types: TypesState,
+    addNotification: (n: Notification) => void,
+    removeNotification: (n: Notification) => void
   };
 
-  const handleAddProject = selected => {
-    const project = {
-      internalId: selected.value,
-      name: selected.label
-    };
-    onAddProject(project);
-  };
-
-  return (
-      <section>
-        <h2 className="spacer-bottom">
-          {translate('my_profile.per_project_notifications.title')}
-        </h2>
+  handleAddNotification ({ channel, type }) {
+    this.props.addNotification({
+      channel,
+      type,
+      project: this.props.project.key,
+      projectName: this.props.project.name
+    });
+  }
 
-        {!notifications.length && (
-            <div className="note">
-              {translate('my_account.no_project_notifications')}
-            </div>
-        )}
+  handleRemoveNotification ({ channel, type }) {
+    this.props.removeNotification({
+      channel,
+      type,
+      project: this.props.project.key
+    });
+  }
 
-        {notifications.map(p => (
-            <ProjectNotification
-                key={p.project.internalId}
-                data={p}
-                channels={channels}
-                onRemoveProject={onRemoveProject}/>
-        ))}
+  render () {
+    const { project, channels } = this.props;
 
-        <div className="spacer-top panel bg-muted">
-          <span className="text-middle spacer-right">
-            Set notifications for:
-          </span>
-          <Select.Async
-              name="new_project"
-              style={{ width: '300px' }}
-              loadOptions={loadOptions}
-              minimumInput={2}
-              onChange={handleAddProject}
-              placeholder="Search Project"
-              searchPromptText="Type at least 2 characters to search"/>
-        </div>
-      </section>
-  );
+    return (
+        <table key={project.key} className="form big-spacer-bottom">
+          <thead>
+            <tr>
+              <th>
+                <h4 className="display-inline-block">{project.name}</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)}/>
+        </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/Projects.js b/server/sonar-web/src/main/js/apps/account/notifications/Projects.js
new file mode 100644 (file)
index 0000000..53a6670
--- /dev/null
@@ -0,0 +1,123 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 Select from 'react-select';
+import { connect } from 'react-redux';
+import differenceBy from 'lodash/differenceBy';
+import ProjectNotifications from './ProjectNotifications';
+import { translate } from '../../../helpers/l10n';
+import { simpleSearchProjects } 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.Component {
+  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 => {
+    // TODO filter existing out
+    return simpleSearchProjects({ search: query })
+        .then(projects => projects.map(project => ({
+          value: project.key,
+          label: project.name
+        })))
+        .then(options => ({ options }));
+  };
+
+  handleAddProject = selected => {
+    const project = { key: selected.value, name: selected.label };
+    this.setState({
+      addedProjects: [...this.state.addedProjects, project]
+    });
+  };
+
+  render () {
+    const allProjects = [...this.props.projects, ...this.state.addedProjects];
+
+    return (
+        <section>
+          <h2 className="spacer-bottom">
+            {translate('my_profile.per_project_notifications.title')}
+          </h2>
+
+          {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">
+              Set notifications for:
+            </span>
+            <Select.Async
+                name="new_project"
+                style={{ width: '300px' }}
+                loadOptions={this.loadOptions}
+                minimumInput={2}
+                onChange={this.handleAddProject}
+                placeholder="Search Project"
+                searchPromptText="Type at least 2 characters to search"/>
+          </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/__tests__/GlobalNotifications-test.js b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/GlobalNotifications-test.js
new file mode 100644 (file)
index 0000000..69f1281
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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__/Notifications-test.js b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.js
new file mode 100644 (file)
index 0000000..a4d96a7
--- /dev/null
@@ -0,0 +1,28 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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__/NotificationsList-test.js b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/NotificationsList-test.js
new file mode 100644 (file)
index 0000000..11300e0
--- /dev/null
@@ -0,0 +1,67 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 NotificationsList from '../NotificationsList';
+import Checkbox from '../../../../components/controls/Checkbox';
+
+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}`;
+
+it('should match snapshot', () => {
+  expect(shallow(
+      <NotificationsList
+          onAdd={jest.fn()}
+          onRemove={jest.fn()}
+          channels={channels}
+          checkboxId={checkboxId}
+          types={types}
+          notifications={notifications}/>
+  )).toMatchSnapshot();
+});
+
+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__/ProjectNotifications-test.js b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.js
new file mode 100644 (file)
index 0000000..373a8c4
--- /dev/null
@@ -0,0 +1,75 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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__/Projects-test.js b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.js
new file mode 100644 (file)
index 0000000..eec057d
--- /dev/null
@@ -0,0 +1,45 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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__/__snapshots__/GlobalNotifications-test.js.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/GlobalNotifications-test.js.snap
new file mode 100644 (file)
index 0000000..7e8149b
--- /dev/null
@@ -0,0 +1,60 @@
+exports[`test should match snapshot 1`] = `
+<section>
+  <h2
+    className="spacer-bottom">
+    my_profile.overall_notifications.title
+  </h2>
+  <table
+    className="form">
+    <thead>
+      <tr>
+        <th />
+        <th
+          className="text-center">
+          <h4>
+            notification.channel.channel1
+          </h4>
+        </th>
+        <th
+          className="text-center">
+          <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]}
+      types={
+        Array [
+          "type1",
+          "type2",
+        ]
+      } />
+  </table>
+</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
new file mode 100644 (file)
index 0000000..0221433
--- /dev/null
@@ -0,0 +1,16 @@
+exports[`test should match snapshot 1`] = `
+<div
+  className="account-body account-container">
+  <HelmetWrapper
+    title="my_account.page - my_account.notifications"
+    titleTemplate="SonarQube - %s" />
+  <p
+    className="big-spacer-bottom">
+    notification.dispatcher.information
+  </p>
+  <Connect(GlobalNotifications) />
+  <hr
+    className="account-separator" />
+  <Connect(Projects) />
+</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
new file mode 100644 (file)
index 0000000..23b0f13
--- /dev/null
@@ -0,0 +1,46 @@
+exports[`test should match snapshot 1`] = `
+<tbody>
+  <tr>
+    <td>
+      notification.dispatcher.type1
+    </td>
+    <td
+      className="text-center">
+      <Checkbox
+        checked={true}
+        id="checkbox-io-type1-channel1"
+        onCheck={[Function]}
+        thirdState={false} />
+    </td>
+    <td
+      className="text-center">
+      <Checkbox
+        checked={false}
+        id="checkbox-io-type1-channel2"
+        onCheck={[Function]}
+        thirdState={false} />
+    </td>
+  </tr>
+  <tr>
+    <td>
+      notification.dispatcher.type2
+    </td>
+    <td
+      className="text-center">
+      <Checkbox
+        checked={true}
+        id="checkbox-io-type2-channel1"
+        onCheck={[Function]}
+        thirdState={false} />
+    </td>
+    <td
+      className="text-center">
+      <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
new file mode 100644 (file)
index 0000000..44df6e9
--- /dev/null
@@ -0,0 +1,59 @@
+exports[`test should match snapshot 1`] = `
+<table
+  className="form big-spacer-bottom">
+  <thead>
+    <tr>
+      <th>
+        <h4
+          className="display-inline-block">
+          Foo
+        </h4>
+      </th>
+      <th
+        className="text-center">
+        <h4>
+          notification.channel.channel1
+        </h4>
+      </th>
+      <th
+        className="text-center">
+        <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]}
+    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
new file mode 100644 (file)
index 0000000..3c6d90e
--- /dev/null
@@ -0,0 +1,163 @@
+exports[`test should render projects 1`] = `
+<section>
+  <h2
+    className="spacer-bottom">
+    my_profile.per_project_notifications.title
+  </h2>
+  <Connect(ProjectNotifications)
+    project={
+      Object {
+        "key": "foo",
+        "name": "Foo",
+      }
+    } />
+  <Connect(ProjectNotifications)
+    project={
+      Object {
+        "key": "bar",
+        "name": "Bar",
+      }
+    } />
+  <div
+    className="spacer-top panel bg-muted">
+    <span
+      className="text-middle spacer-right">
+      Set notifications for:
+    </span>
+    <Async
+      autoload={true}
+      cache={Object {}}
+      ignoreAccents={true}
+      ignoreCase={true}
+      loadOptions={[Function]}
+      loadingPlaceholder="Loading..."
+      minimumInput={2}
+      name="new_project"
+      onChange={[Function]}
+      options={Array []}
+      placeholder="Search Project"
+      searchPromptText="Type at least 2 characters to search"
+      style={
+        Object {
+          "width": "300px",
+        }
+      } />
+  </div>
+</section>
+`;
+
+exports[`test should render projects 2`] = `
+<section>
+  <h2
+    className="spacer-bottom">
+    my_profile.per_project_notifications.title
+  </h2>
+  <Connect(ProjectNotifications)
+    project={
+      Object {
+        "key": "foo",
+        "name": "Foo",
+      }
+    } />
+  <Connect(ProjectNotifications)
+    project={
+      Object {
+        "key": "bar",
+        "name": "Bar",
+      }
+    } />
+  <Connect(ProjectNotifications)
+    project={
+      Object {
+        "key": "qux",
+        "name": "Qux",
+      }
+    } />
+  <div
+    className="spacer-top panel bg-muted">
+    <span
+      className="text-middle spacer-right">
+      Set notifications for:
+    </span>
+    <Async
+      autoload={true}
+      cache={Object {}}
+      ignoreAccents={true}
+      ignoreCase={true}
+      loadOptions={[Function]}
+      loadingPlaceholder="Loading..."
+      minimumInput={2}
+      name="new_project"
+      onChange={[Function]}
+      options={Array []}
+      placeholder="Search Project"
+      searchPromptText="Type at least 2 characters to search"
+      style={
+        Object {
+          "width": "300px",
+        }
+      } />
+  </div>
+</section>
+`;
+
+exports[`test should render projects 3`] = `
+<section>
+  <h2
+    className="spacer-bottom">
+    my_profile.per_project_notifications.title
+  </h2>
+  <Connect(ProjectNotifications)
+    project={
+      Object {
+        "key": "foo",
+        "name": "Foo",
+      }
+    } />
+  <Connect(ProjectNotifications)
+    project={
+      Object {
+        "key": "bar",
+        "name": "Bar",
+      }
+    } />
+  <Connect(ProjectNotifications)
+    project={
+      Object {
+        "key": "qux",
+        "name": "Qux",
+      }
+    } />
+  <div
+    className="spacer-top panel bg-muted">
+    <span
+      className="text-middle spacer-right">
+      Set notifications for:
+    </span>
+    <Async
+      autoload={true}
+      cache={Object {}}
+      ignoreAccents={true}
+      ignoreCase={true}
+      loadOptions={[Function]}
+      loadingPlaceholder="Loading..."
+      minimumInput={2}
+      name="new_project"
+      onChange={[Function]}
+      options={Array []}
+      placeholder="Search Project"
+      searchPromptText="Type at least 2 characters to search"
+      style={
+        Object {
+          "width": "300px",
+        }
+      } />
+  </div>
+</section>
+`;
+
+exports[`test 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
new file mode 100644 (file)
index 0000000..32d1064
--- /dev/null
@@ -0,0 +1,56 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 } 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) => {
+    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)
+    )
+);
index 7314daf94ba97a6d9cb2d7700d6baa754354757f..2cf1a59c8b41dedbfcba011c72b37ac2e293a6b4 100644 (file)
@@ -23,12 +23,14 @@ import Account from './components/Account';
 import ProjectsContainer from './projects/ProjectsContainer';
 import Security from './components/Security';
 import Profile from './profile/Profile';
+import Notifications from './notifications/Notifications';
 
 export default (
     <Route component={Account}>
       <IndexRoute component={Profile}/>
       <Route path="security" component={Security}/>
       <Route path="projects" component={ProjectsContainer}/>
+      <Route path="notifications" component={Notifications}/>
 
       <Route path="issues" onEnter={() => {
         window.location = window.baseUrl + '/issues' + window.location.hash + '|assigned_to_me=true';
index f417c875bf746deeecd8d54b3f874e825a9ec7f4..1ad7d4b0f34a28d80ababe00feaa616143ed1295 100644 (file)
@@ -22,6 +22,7 @@ import classNames from 'classnames';
 
 export default class Checkbox extends React.Component {
   static propTypes = {
+    id: React.PropTypes.string,
     onCheck: React.PropTypes.func.isRequired,
     checked: React.PropTypes.bool.isRequired,
     thirdState: React.PropTypes.bool
@@ -48,9 +49,7 @@ export default class Checkbox extends React.Component {
     });
 
     return (
-        <a className={className}
-           href="#"
-           onClick={this.handleClick}/>
+        <a id={this.props.id} className={className} href="#" onClick={this.handleClick}/>
     );
   }
 }
diff --git a/server/sonar-web/src/main/js/store/notifications/duck.js b/server/sonar-web/src/main/js/store/notifications/duck.js
new file mode 100644 (file)
index 0000000..b539488
--- /dev/null
@@ -0,0 +1,163 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 from 'lodash/uniqBy';
+import uniqWith from 'lodash/uniqWith';
+
+export type Notification = {
+  channel: string,
+  type: string,
+  project: string | null,
+  projectName: string | null
+};
+
+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 const addNotification = (notification: Notification): AddNotificationAction => ({
+  type: 'ADD_NOTIFICATION',
+  notification
+});
+
+export const removeNotification = (notification: Notification): RemoveNotificationAction => ({
+  type: 'REMOVE_NOTIFICATION',
+  notification
+});
+
+export const receiveNotifications = (
+    notifications: NotificationsState,
+    channels: ChannelsState,
+    globalTypes: TypesState,
+    perProjectTypes: TypesState
+): ReceiveNotificationsAction => ({
+  type: 'RECEIVE_NOTIFICATIONS',
+  notifications,
+  channels,
+  globalTypes,
+  perProjectTypes
+});
+
+const onAddNotification = (state: NotificationsState, notification: Notification) => {
+  const isNotificationsEqual = (a: Notification, b: Notification) => (
+      a.channel === b.channel && a.type === b.type && a.project === b.project
+  );
+  return uniqWith([...state, notification], isNotificationsEqual);
+};
+
+const onRemoveNotification = (state: NotificationsState, notification: Notification) => {
+  return state.filter(n =>
+      n.channel !== notification.channel ||
+      n.type !== notification.type ||
+      n.project !== notification.project
+  );
+};
+
+const onReceiveNotifications = (state: NotificationsState, notifications: NotificationsState) => {
+  return [...notifications];
+};
+
+const 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;
+  }
+};
+
+const channels = (state: ChannelsState = [], action: Action) => {
+  if (action.type === 'RECEIVE_NOTIFICATIONS') {
+    return action.channels;
+  } else {
+    return state;
+  }
+};
+
+const globalTypes = (state: TypesState = [], action: Action) => {
+  if (action.type === 'RECEIVE_NOTIFICATIONS') {
+    return action.globalTypes;
+  } else {
+    return state;
+  }
+};
+
+const 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 const getGlobal = (state: State): NotificationsState => (
+    state.notifications.filter(n => !n.project)
+);
+
+export const getProjects = (state: State): Array<string> => (
+    uniqBy(
+        state.notifications.filter(n => n.project).map(n => ({ key: n.project, name: n.projectName })),
+        project => project.key
+    )
+);
+
+export const getForProject = (state: State, project: string): NotificationsState => (
+    state.notifications.filter(n => n.project === project)
+);
+
+export const getChannels = (state: State): ChannelsState => state.channels;
+
+export const getGlobalTypes = (state: State): TypesState => state.globalTypes;
+
+export const getPerProjectTypes = (state: State): TypesState => state.perProjectTypes;
index 0077b62ce5e607abf5e30bb6a51001a51344b274..05174e1f9be7dd1ae9be85b2f861587a501ccc3a 100644 (file)
@@ -24,6 +24,7 @@ import users, * as fromUsers from './users/reducer';
 import favorites, * as fromFavorites from './favorites/duck';
 import languages, * as fromLanguages from './languages/reducer';
 import measures, * as fromMeasures from './measures/reducer';
+import notifications, * as fromNotifications from './notifications/duck';
 import globalMessages, * as fromGlobalMessages from './globalMessages/duck';
 import projectActivity from './projectActivity/duck';
 import measuresApp, * as fromMeasuresApp from '../apps/component-measures/store/rootReducer';
@@ -40,6 +41,7 @@ export default combineReducers({
   favorites,
   languages,
   measures,
+  notifications,
   projectActivity,
   users,
 
@@ -84,6 +86,30 @@ export const getComponentMeasures = (state, componentKey) => (
     fromMeasures.getComponentMeasures(state.measures, componentKey)
 );
 
+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 getProjectActivity = state => (
     state.projectActivity
 );