aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main/js
diff options
context:
space:
mode:
Diffstat (limited to 'server/sonar-web/src/main/js')
-rw-r--r--server/sonar-web/src/main/js/api/components.js18
-rw-r--r--server/sonar-web/src/main/js/api/notifications.js53
-rw-r--r--server/sonar-web/src/main/js/apps/account/components/Nav.js5
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/GlobalNotifications.js90
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/Notifications.js63
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.js82
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/NotificationsList.js65
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/ProjectNotification.js72
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.js133
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/Projects.js123
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/GlobalNotifications-test.js41
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.js28
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/NotificationsList-test.js67
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.js75
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.js45
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/GlobalNotifications-test.js.snap60
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.js.snap16
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/NotificationsList-test.js.snap46
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.js.snap59
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.js.snap163
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/actions.js56
-rw-r--r--server/sonar-web/src/main/js/apps/account/routes.js2
-rw-r--r--server/sonar-web/src/main/js/components/controls/Checkbox.js5
-rw-r--r--server/sonar-web/src/main/js/store/notifications/duck.js163
-rw-r--r--server/sonar-web/src/main/js/store/rootReducer.js26
25 files changed, 1261 insertions, 295 deletions
diff --git a/server/sonar-web/src/main/js/api/components.js b/server/sonar-web/src/main/js/api/components.js
index fa6ce518734..aaccb414a4d 100644
--- a/server/sonar-web/src/main/js/api/components.js
+++ b/server/sonar-web/src/main/js/api/components.js
@@ -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
index 00000000000..4ca5b24dd65
--- /dev/null
+++ b/server/sonar-web/src/main/js/api/notifications.js
@@ -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);
+};
diff --git a/server/sonar-web/src/main/js/apps/account/components/Nav.js b/server/sonar-web/src/main/js/apps/account/components/Nav.js
index 1f9363f5f1a..f02b4f2db89 100644
--- a/server/sonar-web/src/main/js/apps/account/components/Nav.js
+++ b/server/sonar-web/src/main/js/apps/account/components/Nav.js
@@ -35,6 +35,11 @@ const Nav = () => (
</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')}
</Link>
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
index 469dfe26312..e6394c3ecf1 100644
--- a/server/sonar-web/src/main/js/apps/account/notifications/GlobalNotifications.js
+++ b/server/sonar-web/src/main/js/apps/account/notifications/GlobalNotifications.js
@@ -18,33 +18,71 @@
* 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;
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
index 27b96e5b2a0..c5c1c887d6e 100644
--- a/server/sonar-web/src/main/js/apps/account/notifications/Notifications.js
+++ b/server/sonar-web/src/main/js/apps/account/notifications/Notifications.js
@@ -17,42 +17,47 @@
* 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
index 47933f2ebb0..00000000000
--- a/server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.js
+++ /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>
- );
- }
-}
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
index cc7476e9686..2206194aadf 100644
--- a/server/sonar-web/src/main/js/apps/account/notifications/NotificationsList.js
+++ b/server/sonar-web/src/main/js/apps/account/notifications/NotificationsList.js
@@ -18,24 +18,53 @@
* 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
index 325cca54e1c..00000000000
--- a/server/sonar-web/src/main/js/apps/account/notifications/ProjectNotification.js
+++ /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>
- );
- }
-}
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
index cddb8f9149b..828e4ec3eba 100644
--- a/server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.js
+++ b/server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.js
@@ -18,66 +18,89 @@
* 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
index 00000000000..53a6670be39
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/notifications/Projects.js
@@ -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
index 00000000000..69f1281c027
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/GlobalNotifications-test.js
@@ -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
index 00000000000..a4d96a7e33a
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.js
@@ -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
index 00000000000..11300e0914a
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/NotificationsList-test.js
@@ -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
index 00000000000..373a8c47756
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.js
@@ -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
index 00000000000..eec057d46d6
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.js
@@ -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
index 00000000000..7e8149ba9fd
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/GlobalNotifications-test.js.snap
@@ -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
index 00000000000..0221433d9fd
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.js.snap
@@ -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
index 00000000000..23b0f13ab0c
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/NotificationsList-test.js.snap
@@ -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
index 00000000000..44df6e92f1a
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.js.snap
@@ -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
index 00000000000..3c6d90e870b
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.js.snap
@@ -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
index 00000000000..32d10641df4
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/notifications/actions.js
@@ -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)
+ )
+);
diff --git a/server/sonar-web/src/main/js/apps/account/routes.js b/server/sonar-web/src/main/js/apps/account/routes.js
index 7314daf94ba..2cf1a59c8b4 100644
--- a/server/sonar-web/src/main/js/apps/account/routes.js
+++ b/server/sonar-web/src/main/js/apps/account/routes.js
@@ -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';
diff --git a/server/sonar-web/src/main/js/components/controls/Checkbox.js b/server/sonar-web/src/main/js/components/controls/Checkbox.js
index f417c875bf7..1ad7d4b0f34 100644
--- a/server/sonar-web/src/main/js/components/controls/Checkbox.js
+++ b/server/sonar-web/src/main/js/components/controls/Checkbox.js
@@ -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
index 00000000000..b53948850f9
--- /dev/null
+++ b/server/sonar-web/src/main/js/store/notifications/duck.js
@@ -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;
diff --git a/server/sonar-web/src/main/js/store/rootReducer.js b/server/sonar-web/src/main/js/store/rootReducer.js
index 0077b62ce5e..05174e1f9be 100644
--- a/server/sonar-web/src/main/js/store/rootReducer.js
+++ b/server/sonar-web/src/main/js/store/rootReducer.js
@@ -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
);