diff options
28 files changed, 1395 insertions, 295 deletions
diff --git a/it/it-tests/src/test/java/it/user/MyAccountPageTest.java b/it/it-tests/src/test/java/it/user/MyAccountPageTest.java index 01140a1b116..4fda2613b38 100644 --- a/it/it-tests/src/test/java/it/user/MyAccountPageTest.java +++ b/it/it-tests/src/test/java/it/user/MyAccountPageTest.java @@ -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") diff --git a/it/it-tests/src/test/java/pageobjects/Navigation.java b/it/it-tests/src/test/java/pageobjects/Navigation.java index e9c23d63f14..359cb5a8913 100644 --- a/it/it-tests/src/test/java/pageobjects/Navigation.java +++ b/it/it-tests/src/test/java/pageobjects/Navigation.java @@ -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 index 00000000000..a67e554e0e2 --- /dev/null +++ b/it/it-tests/src/test/java/pageobjects/NotificationsPage.java @@ -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(); + } +} 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 ); |