diff options
author | Stas Vilchik <stas.vilchik@sonarsource.com> | 2018-05-15 11:09:13 +0200 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2018-05-15 20:20:50 +0200 |
commit | 34db3b05cf4dcdb11faa5b392837cc4d979d19ff (patch) | |
tree | d0532c404f09982e0c38405c4d0fd72804b76cb7 | |
parent | 254acb86a8bf05070940bbb94594bdf1c09707ee (diff) | |
download | sonarqube-34db3b05cf4dcdb11faa5b392837cc4d979d19ff.tar.gz sonarqube-34db3b05cf4dcdb11faa5b392837cc4d979d19ff.zip |
rewrite notifications app in ts and drop from redux store (#233)
28 files changed, 1083 insertions, 826 deletions
diff --git a/server/sonar-web/src/main/js/api/components.ts b/server/sonar-web/src/main/js/api/components.ts index 4505e8e9282..78e2b72d96a 100644 --- a/server/sonar-web/src/main/js/api/components.ts +++ b/server/sonar-web/src/main/js/api/components.ts @@ -239,7 +239,7 @@ export function getSuggestions( if (more) { data.more = more; } - return getJSON('/api/components/suggestions', data); + return getJSON('/api/components/suggestions', data).catch(throwGlobalError); } export function getComponentForSourceViewer( diff --git a/server/sonar-web/src/main/js/api/notifications.ts b/server/sonar-web/src/main/js/api/notifications.ts index db846af96fc..d1f0db0e09c 100644 --- a/server/sonar-web/src/main/js/api/notifications.ts +++ b/server/sonar-web/src/main/js/api/notifications.ts @@ -17,37 +17,23 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { getJSON, post, RequestData } from '../helpers/request'; +import { Notification } from '../app/types'; +import throwGlobalError from '../app/utils/throwGlobalError'; +import { getJSON, post } from '../helpers/request'; -export interface GetNotificationsResponse { - notifications: Array<{ - channel: string; - type: string; - organization?: string; - project?: string; - projectName?: string; - }>; - channels: Array<string>; - globalTypes: Array<string>; - perProjectTypes: Array<string>; +export function getNotifications(): Promise<{ + channels: string[]; + globalTypes: string[]; + notifications: Notification[]; + perProjectTypes: string[]; +}> { + return getJSON('/api/notifications/list').catch(throwGlobalError); } -export function getNotifications(): Promise<GetNotificationsResponse> { - return getJSON('/api/notifications/list'); +export function addNotification(data: { channel: string; type: string; project?: string }) { + return post('/api/notifications/add', data).catch(throwGlobalError); } -export function addNotification(channel: string, type: string, project?: string): Promise<void> { - const data: RequestData = { channel, type }; - if (project) { - Object.assign(data, { project }); - } - return post('/api/notifications/add', data); -} - -export function removeNotification(channel: string, type: string, project?: string): Promise<void> { - const data: RequestData = { channel, type }; - if (project) { - Object.assign(data, { project }); - } - return post('/api/notifications/remove', data); +export function removeNotification(data: { channel: string; type: string; project?: string }) { + return post('/api/notifications/remove', data).catch(throwGlobalError); } diff --git a/server/sonar-web/src/main/js/app/types.ts b/server/sonar-web/src/main/js/app/types.ts index ffaf58534f1..8fa11ed7fb1 100644 --- a/server/sonar-web/src/main/js/app/types.ts +++ b/server/sonar-web/src/main/js/app/types.ts @@ -312,6 +312,14 @@ export interface Metric { type: string; } +export interface Notification { + channel: string; + organization?: string; + project?: string; + projectName?: string; + type: string; +} + export interface Organization { adminPages?: { key: string; name: string }[]; avatar?: string; diff --git a/server/sonar-web/src/main/js/apps/account/notifications/GlobalNotifications.js b/server/sonar-web/src/main/js/apps/account/notifications/GlobalNotifications.tsx index 21b7c28f9d1..0ac75ce0151 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/GlobalNotifications.js +++ b/server/sonar-web/src/main/js/apps/account/notifications/GlobalNotifications.tsx @@ -17,34 +17,20 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import React from 'react'; -import { connect } from 'react-redux'; +import * as React from 'react'; import NotificationsList from './NotificationsList'; -import { addNotification, removeNotification } from './actions'; +import { Notification } from '../../../app/types'; import { translate } from '../../../helpers/l10n'; -import { - getGlobalNotifications, - getNotificationChannels, - getNotificationGlobalTypes -} from '../../../store/rootReducer'; -/*:: import type { - Notification, - NotificationsState, - ChannelsState, - TypesState -} from '../../../store/notifications/duck'; */ -/*:: -type Props = { - notifications: NotificationsState, - channels: ChannelsState, - types: TypesState, - addNotification: (n: Notification) => void, - removeNotification: (n: Notification) => void -}; -*/ +interface Props { + addNotification: (n: Notification) => void; + channels: string[]; + notifications: Notification[]; + removeNotification: (n: Notification) => void; + types: string[]; +} -function GlobalNotifications(props /*: Props */) { +export default function GlobalNotifications(props: Props) { return ( <section className="boxed-group"> <h2>{translate('my_profile.overall_notifications.title')}</h2> @@ -55,7 +41,7 @@ function GlobalNotifications(props /*: Props */) { <tr> <th /> {props.channels.map(channel => ( - <th key={channel} className="text-center"> + <th className="text-center" key={channel}> <h4>{translate('notification.channel', channel)}</h4> </th> ))} @@ -63,12 +49,12 @@ function GlobalNotifications(props /*: Props */) { </thead> <NotificationsList - notifications={props.notifications} channels={props.channels} - types={props.types} - checkboxId={(d, c) => `global-notification-${d}-${c}`} + checkboxId={getCheckboxId} + notifications={props.notifications} onAdd={props.addNotification} onRemove={props.removeNotification} + types={props.types} /> </table> </div> @@ -76,14 +62,6 @@ function GlobalNotifications(props /*: Props */) { ); } -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; +function getCheckboxId(type: string, channel: string) { + return `global-notification-${type}-${channel}`; +} diff --git a/server/sonar-web/src/main/js/apps/account/notifications/Notifications.js b/server/sonar-web/src/main/js/apps/account/notifications/Notifications.js deleted file mode 100644 index 56148702a1d..00000000000 --- a/server/sonar-web/src/main/js/apps/account/notifications/Notifications.js +++ /dev/null @@ -1,55 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2018 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -// @flow -import React from 'react'; -import Helmet from 'react-helmet'; -import { connect } from 'react-redux'; -import GlobalNotifications from './GlobalNotifications'; -import Projects from './Projects'; -import { fetchNotifications } from './actions'; -import { translate } from '../../../helpers/l10n'; - -class Notifications extends React.PureComponent { - /*:: props: { - fetchNotifications: () => void - }; -*/ - - componentDidMount() { - this.props.fetchNotifications(); - } - - render() { - return ( - <div className="account-body account-container"> - <Helmet title={translate('my_account.notifications')} /> - <p className="alert alert-info">{translate('notification.dispatcher.information')}</p> - <GlobalNotifications /> - <Projects /> - </div> - ); - } -} - -const mapDispatchToProps = { fetchNotifications }; - -export default connect(null, mapDispatchToProps)(Notifications); - -export const UnconnectedNotifications = Notifications; diff --git a/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx b/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx new file mode 100644 index 00000000000..a6c2c76b631 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx @@ -0,0 +1,179 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import Helmet from 'react-helmet'; +import { groupBy, partition, uniq, uniqBy, uniqWith } from 'lodash'; +import * as PropTypes from 'prop-types'; +import GlobalNotifications from './GlobalNotifications'; +import Projects from './Projects'; +import { NotificationProject } from './types'; +import * as api from '../../../api/notifications'; +import { Notification } from '../../../app/types'; +import DeferredSpinner from '../../../components/common/DeferredSpinner'; +import { translate } from '../../../helpers/l10n'; + +export interface Props { + fetchOrganizations: (organizations: string[]) => void; +} + +interface State { + channels: string[]; + globalTypes: string[]; + loading: boolean; + notifications: Notification[]; + perProjectTypes: string[]; +} + +export default class Notifications extends React.PureComponent<Props, State> { + mounted = false; + + static contextTypes = { + organizationsEnabled: PropTypes.bool + }; + + state: State = { + channels: [], + globalTypes: [], + loading: true, + notifications: [], + perProjectTypes: [] + }; + + componentDidMount() { + this.mounted = true; + this.fetchNotifications(); + } + + componentWillUnmount() { + this.mounted = false; + } + + fetchNotifications = () => { + api.getNotifications().then( + response => { + if (this.mounted) { + if (this.context.organizationsEnabled) { + const organizations = uniq(response.notifications + .filter(n => n.organization) + .map(n => n.organization) as string[]); + this.props.fetchOrganizations(organizations); + } + + this.setState({ + channels: response.channels, + globalTypes: response.globalTypes, + loading: false, + notifications: response.notifications, + perProjectTypes: response.perProjectTypes + }); + } + }, + () => { + if (this.mounted) { + this.setState({ loading: false }); + } + } + ); + }; + + addNotificationToState = (added: Notification) => { + this.setState(state => ({ + notifications: uniqWith([...state.notifications, added], areNotificationsEqual) + })); + }; + + removeNotificationFromState = (removed: Notification) => { + this.setState(state => ({ + notifications: state.notifications.filter( + notification => !areNotificationsEqual(notification, removed) + ) + })); + }; + + addNotification = (added: Notification) => { + // optimistic update + this.addNotificationToState(added); + + // recreate `data` to omit `projectName` and `organization` from `Notification` + const data = { channel: added.channel, project: added.project, type: added.type }; + api.addNotification(data).catch(() => { + this.removeNotificationFromState(added); + }); + }; + + removeNotification = (removed: Notification) => { + // optimistic update + this.removeNotificationFromState(removed); + + // recreate `data` to omit `projectName` and `organization` from `Notification` + const data = { channel: removed.channel, project: removed.project, type: removed.type }; + api.removeNotification(data).catch(() => { + this.addNotificationToState(removed); + }); + }; + + render() { + const [globalNotifications, projectNotifications] = partition( + this.state.notifications, + n => !n.project + ); + const projects = uniqBy( + projectNotifications.map(n => ({ + key: n.project, + name: n.projectName, + organization: n.organization + })) as NotificationProject[], + project => project.key + ); + const notificationsByProject = groupBy(projectNotifications, n => n.project); + + return ( + <div className="account-body account-container"> + <Helmet title={translate('my_account.notifications')} /> + <p className="alert alert-info">{translate('notification.dispatcher.information')}</p> + <DeferredSpinner loading={this.state.loading}> + {this.state.notifications && ( + <> + <GlobalNotifications + addNotification={this.addNotification} + channels={this.state.channels} + notifications={globalNotifications} + removeNotification={this.removeNotification} + types={this.state.globalTypes} + /> + <Projects + addNotification={this.addNotification} + channels={this.state.channels} + notificationsByProject={notificationsByProject} + projects={projects} + removeNotification={this.removeNotification} + types={this.state.perProjectTypes} + /> + </> + )} + </DeferredSpinner> + </div> + ); + } +} + +function areNotificationsEqual(a: Notification, b: Notification) { + return a.channel === b.channel && a.type === b.type && a.project === b.project; +} diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.js b/server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.tsx index 5f6598b5950..7c05e0f1616 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.js +++ b/server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.tsx @@ -17,24 +17,10 @@ * 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'; +import { connect } from 'react-redux'; +import Notifications, { Props } from './Notifications'; +import { fetchOrganizations } from '../../../store/rootActions'; -const projects = [{ key: 'foo', name: 'Foo' }, { key: 'bar', name: 'Bar' }]; +const mapDispatchToProps = { fetchOrganizations } as Pick<Props, 'fetchOrganizations'>; -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(); -}); +export default connect(null, mapDispatchToProps)(Notifications); diff --git a/server/sonar-web/src/main/js/apps/account/notifications/NotificationsList.js b/server/sonar-web/src/main/js/apps/account/notifications/NotificationsList.tsx index 6ca7f02bdfe..35915a0f4f1 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/NotificationsList.js +++ b/server/sonar-web/src/main/js/apps/account/notifications/NotificationsList.tsx @@ -17,35 +17,29 @@ * 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 * as React from 'react'; +import { Notification } from '../../../app/types'; import Checkbox from '../../../components/controls/Checkbox'; import { translate, hasMessage } from '../../../helpers/l10n'; -/*:: import type { - Notification, - NotificationsState, - ChannelsState, - TypesState -} from '../../../store/notifications/duck'; */ -export default class NotificationsList extends React.PureComponent { - /*:: props: { - onAdd: (n: Notification) => void, - onRemove: (n: Notification) => void, - channels: ChannelsState, - checkboxId: (string, string) => string, - project?: boolean, - types: TypesState, - notifications: NotificationsState - }; -*/ +interface Props { + onAdd: (n: Notification) => void; + onRemove: (n: Notification) => void; + channels: string[]; + checkboxId: (type: string, channel: string) => string; + project?: boolean; + types: string[]; + notifications: Notification[]; +} - isEnabled(type /*: string */, channel /*: string */) /*: boolean */ { +export default class NotificationsList extends React.PureComponent<Props> { + isEnabled(type: string, channel: string) { return !!this.props.notifications.find( notification => notification.type === type && notification.channel === channel ); } - handleCheck(type /*: string */, channel /*: string */, checked /*: boolean */) { + handleCheck(type: string, channel: string, checked: boolean) { if (checked) { this.props.onAdd({ type, channel }); } else { @@ -53,7 +47,7 @@ export default class NotificationsList extends React.PureComponent { } } - getDispatcherLabel(dispatcher /*: string */) { + getDispatcherLabel(dispatcher: string) { const globalMessageKey = ['notification.dispatcher', dispatcher]; const projectMessageKey = [...globalMessageKey, 'project']; const shouldUseProjectMessage = this.props.project && hasMessage(...projectMessageKey); @@ -71,7 +65,7 @@ export default class NotificationsList extends React.PureComponent { <tr key={type}> <td>{this.getDispatcherLabel(type)}</td> {channels.map(channel => ( - <td key={channel} className="text-center"> + <td className="text-center" key={channel}> <Checkbox checked={this.isEnabled(type, channel)} id={checkboxId(type, channel)} 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.tsx index 8fbf66c2d4c..f5e29f76ba1 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.js +++ b/server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.tsx @@ -17,42 +17,30 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import React from 'react'; -import { connect } from 'react-redux'; +import * as React from 'react'; import { Link } from 'react-router'; import NotificationsList from './NotificationsList'; -import { addNotification, removeNotification } from './actions'; +import { NotificationProject } from './types'; +import { Notification } from '../../../app/types'; import Organization from '../../../components/shared/Organization'; import { translate } from '../../../helpers/l10n'; -import { - getProjectNotifications, - getNotificationChannels, - getNotificationPerProjectTypes -} from '../../../store/rootReducer'; -/*:: import type { - Notification, - NotificationsState, - ChannelsState, - TypesState -} from '../../../store/notifications/duck'; */ import { getProjectUrl } from '../../../helpers/urls'; -class ProjectNotifications extends React.PureComponent { - /*:: props: { - project: { - key: string, - name: string, - organization: string - }, - notifications: NotificationsState, - channels: ChannelsState, - types: TypesState, - addNotification: (n: Notification) => void, - removeNotification: (n: Notification) => void +interface Props { + addNotification: (n: Notification) => void; + channels: string[]; + notifications: Notification[]; + project: NotificationProject; + removeNotification: (n: Notification) => void; + types: string[]; +} + +export default class ProjectNotifications extends React.PureComponent<Props> { + getCheckboxId = (type: string, channel: string) => { + return `project-notification-${this.props.project.key}-${type}-${channel}`; }; -*/ - handleAddNotification({ channel, type }) { + handleAddNotification = ({ channel, type }: { channel: string; type: string }) => { this.props.addNotification({ channel, type, @@ -60,21 +48,21 @@ class ProjectNotifications extends React.PureComponent { projectName: this.props.project.name, organization: this.props.project.organization }); - } + }; - handleRemoveNotification({ channel, type }) { + handleRemoveNotification = ({ channel, type }: { channel: string; type: string }) => { this.props.removeNotification({ channel, type, project: this.props.project.key }); - } + }; render() { const { project, channels } = this.props; return ( - <table key={project.key} className="form big-spacer-bottom"> + <table className="form big-spacer-bottom" key={project.key}> <thead> <tr> <th> @@ -86,34 +74,22 @@ class ProjectNotifications extends React.PureComponent { </h4> </th> {channels.map(channel => ( - <th key={channel} className="text-center"> + <th className="text-center" key={channel}> <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)} + checkboxId={this.getCheckboxId} + notifications={this.props.notifications} + onAdd={this.handleAddNotification} + onRemove={this.handleRemoveNotification} project={true} + types={this.props.types} /> </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.tsx index 5799f59f7d2..77f7876bb7d 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/Projects.js +++ b/server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx @@ -17,93 +17,87 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import React from 'react'; -import { connect } from 'react-redux'; -import { differenceBy } from 'lodash'; +import * as React from 'react'; +import { differenceWith } from 'lodash'; import ProjectNotifications from './ProjectNotifications'; +import { NotificationProject } from './types'; +import { getSuggestions } from '../../../api/components'; +import { Notification } from '../../../app/types'; import { AsyncSelect } from '../../../components/controls/Select'; import Organization from '../../../components/shared/Organization'; import { translate } from '../../../helpers/l10n'; -import { getSuggestions } from '../../../api/components'; -import { getProjectsWithNotifications } from '../../../store/rootReducer'; - -/*:: -type Props = { - projects: Array<{ - key: string, - name: string - }> -}; -*/ -/*:: -type State = { - addedProjects: Array<{ - key: string, - name: string - }> -}; -*/ +export interface Props { + addNotification: (n: Notification) => void; + channels: string[]; + notificationsByProject: { [project: string]: Notification[] }; + projects: NotificationProject[]; + removeNotification: (n: Notification) => void; + types: string[]; +} -class Projects extends React.PureComponent { - /*:: props: Props; */ +interface State { + addedProjects: NotificationProject[]; +} - state /*: State */ = { - addedProjects: [] - }; +export default class Projects extends React.PureComponent<Props, State> { + state: State = { addedProjects: [] }; - componentWillReceiveProps(nextProps /*: Props */) { + 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 }); - } + this.setState(state => ({ + addedProjects: differenceWith( + state.addedProjects, + Object.keys(nextProps.projects), + (stateProject, propsProjectKey) => stateProject.key !== propsProjectKey + ) + })); } - loadOptions = (query, cb) => { + loadOptions = (query: string) => { if (query.length < 2) { - cb(null, { options: [] }); - return; + return Promise.resolve({ options: [] }); } - getSuggestions(query) + return getSuggestions(query) .then(r => { const projects = r.results.find(domain => domain.q === 'TRK'); return projects ? projects.items : []; }) - .then(projects => - projects.map(project => ({ - value: project.key, - label: project.name, - organization: project.organization - })) - ) + .then(projects => { + return projects + .filter( + project => + !this.props.projects.find(p => p.key === project.key) && + !this.state.addedProjects.find(p => p.key === project.key) + ) + .map(project => ({ + value: project.key, + label: project.name, + organization: project.organization + })); + }) .then(options => { - cb(null, { options }); + return { options }; }); }; - handleAddProject = selected => { + handleAddProject = (selected: { label: string; organization: string; value: string }) => { const project = { key: selected.value, name: selected.label, organization: selected.organization }; - this.setState({ - addedProjects: [...this.state.addedProjects, project] - }); + this.setState(state => ({ + addedProjects: [...state.addedProjects, project] + })); }; - renderOption = option => { + renderOption = (option: { label: string; organization: string; value: string }) => { return ( <span> - <Organization organizationKey={option.organization} link={false} /> + <Organization link={false} organizationKey={option.organization} /> <strong>{option.label}</strong> </span> ); @@ -121,7 +115,17 @@ class Projects extends React.PureComponent { <div className="note">{translate('my_account.no_project_notifications')}</div> )} - {allProjects.map(project => <ProjectNotifications key={project.key} project={project} />)} + {allProjects.map(project => ( + <ProjectNotifications + addNotification={this.props.addNotification} + channels={this.props.channels} + key={project.key} + notifications={this.props.notificationsByProject[project.key] || []} + project={project} + removeNotification={this.props.removeNotification} + types={this.props.types} + /> + ))} <div className="spacer-top panel bg-muted"> <span className="text-middle spacer-right"> @@ -130,12 +134,12 @@ class Projects extends React.PureComponent { <AsyncSelect autoload={false} cache={false} - name="new_project" - style={{ width: '300px' }} + className="input-super-large" loadOptions={this.loadOptions} minimumInput={2} - optionRenderer={this.renderOption} + name="new_project" onChange={this.handleAddProject} + optionRenderer={this.renderOption} placeholder={translate('my_account.search_project')} /> </div> @@ -144,11 +148,3 @@ class Projects extends React.PureComponent { ); } } - -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.tsx index 8201a1bff14..7f5cb7287bd 100644 --- 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.tsx @@ -17,9 +17,9 @@ * 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 * as React from 'react'; import { shallow } from 'enzyme'; -import { UnconnectedGlobalNotifications } from '../GlobalNotifications'; +import GlobalNotifications from '../GlobalNotifications'; it('should match snapshot', () => { const channels = ['channel1', 'channel2']; @@ -32,12 +32,12 @@ it('should match snapshot', () => { expect( shallow( - <UnconnectedGlobalNotifications - notifications={notifications} - channels={channels} - types={types} + <GlobalNotifications addNotification={jest.fn()} + channels={channels} + notifications={notifications} removeNotification={jest.fn()} + types={types} /> ) ).toMatchSnapshot(); diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx new file mode 100644 index 00000000000..e0124a0806c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx @@ -0,0 +1,108 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +/* eslint-disable import/order */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import Notifications, { Props } from '../Notifications'; +import { waitAndUpdate } from '../../../../helpers/testUtils'; + +jest.mock('../../../../api/notifications', () => ({ + addNotification: jest.fn(() => Promise.resolve()), + getNotifications: jest.fn(() => + Promise.resolve({ + channels: ['channel1', 'channel2'], + globalTypes: ['type-global', 'type-common'], + notifications: [ + { channel: 'channel1', type: 'type-global' }, + { channel: 'channel1', type: 'type-common' }, + { + channel: 'channel2', + type: 'type-common', + project: 'foo', + projectName: 'Foo', + organization: 'org' + } + ], + perProjectTypes: ['type-common'] + }) + ), + removeNotification: jest.fn(() => Promise.resolve()) +})); + +const api = require('../../../../api/notifications'); + +const addNotification = api.addNotification as jest.Mock<any>; +const getNotifications = api.getNotifications as jest.Mock<any>; +const removeNotification = api.removeNotification as jest.Mock<any>; + +beforeEach(() => { + addNotification.mockClear(); + getNotifications.mockClear(); + removeNotification.mockClear(); +}); + +it('should fetch notifications and render', async () => { + const wrapper = await shallowRender(); + expect(wrapper).toMatchSnapshot(); + expect(getNotifications).toBeCalled(); +}); + +it('should add global notification', async () => { + const notification = { channel: 'channel2', type: 'type-global' }; + const wrapper = await shallowRender(); + wrapper.find('GlobalNotifications').prop<Function>('addNotification')(notification); + // `state` must be immediately updated + expect(wrapper.state('notifications')).toContainEqual(notification); + expect(addNotification).toBeCalledWith(notification); +}); + +it('should remove project notification', async () => { + const notification = { channel: 'channel2', project: 'foo', type: 'type-common' }; + const wrapper = await shallowRender(); + expect(wrapper.state('notifications')).toContainEqual({ + ...notification, + organization: 'org', + projectName: 'Foo' + }); + wrapper.find('Projects').prop<Function>('removeNotification')(notification); + // `state` must be immediately updated + expect(wrapper.state('notifications')).not.toContainEqual(notification); + expect(removeNotification).toBeCalledWith(notification); +}); + +it('should NOT fetch organizations', async () => { + const fetchOrganizations = jest.fn(); + await shallowRender({ fetchOrganizations }); + expect(getNotifications).toBeCalled(); + expect(fetchOrganizations).not.toBeCalled(); +}); + +it('should fetch organizations', async () => { + const fetchOrganizations = jest.fn(); + await shallowRender({ fetchOrganizations }, { organizationsEnabled: true }); + expect(getNotifications).toBeCalled(); + expect(fetchOrganizations).toBeCalledWith(['org']); +}); + +async function shallowRender(props?: Partial<Props>, context?: any) { + const wrapper = shallow(<Notifications fetchOrganizations={jest.fn()} {...props} />, { context }); + await waitAndUpdate(wrapper); + return wrapper; +} diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/NotificationsList-test.js b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/NotificationsList-test.tsx index 5e389545628..25044dbb717 100644 --- 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.tsx @@ -24,7 +24,7 @@ jest.mock('../../../../helpers/l10n', () => { return l10n; }); -import React from 'react'; +import * as React from 'react'; import { shallow } from 'enzyme'; import NotificationsList from '../NotificationsList'; import Checkbox from '../../../../components/controls/Checkbox'; @@ -37,39 +37,39 @@ const notifications = [ { channel: 'channel1', type: 'type2' }, { channel: 'channel2', type: 'type2' } ]; -const checkboxId = (t, c) => `checkbox-io-${t}-${c}`; +const checkboxId = (t: string, c: string) => `checkbox-io-${t}-${c}`; beforeEach(() => { - hasMessage.mockImplementation(() => false).mockClear(); + (hasMessage as jest.Mock<any>).mockImplementation(() => false).mockClear(); }); it('should match snapshot', () => { expect( shallow( <NotificationsList - onAdd={jest.fn()} - onRemove={jest.fn()} channels={channels} checkboxId={checkboxId} - types={types} notifications={notifications} + onAdd={jest.fn()} + onRemove={jest.fn()} + types={types} /> ) ).toMatchSnapshot(); }); it('renders project-specific labels', () => { - hasMessage.mockImplementation(() => true); + (hasMessage as jest.Mock<any>).mockImplementation(() => true); expect( shallow( <NotificationsList - onAdd={jest.fn()} - onRemove={jest.fn()} channels={channels} checkboxId={checkboxId} + notifications={notifications} + onAdd={jest.fn()} + onRemove={jest.fn()} project={true} types={types} - notifications={notifications} /> ) ).toMatchSnapshot(); @@ -82,12 +82,12 @@ it('should call `onAdd` and `onRemove`', () => { const onRemove = jest.fn(); const wrapper = shallow( <NotificationsList - onAdd={onAdd} - onRemove={onRemove} channels={channels} checkboxId={checkboxId} - types={types} notifications={notifications} + onAdd={onAdd} + onRemove={onRemove} + types={types} /> ); const checkbox = wrapper.find(Checkbox).first(); 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.tsx index d05fe0824c0..2ebd284b381 100644 --- 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.tsx @@ -17,10 +17,9 @@ * 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 * as React from 'react'; import { shallow } from 'enzyme'; -import { UnconnectedProjectNotifications } from '../ProjectNotifications'; -import NotificationsList from '../NotificationsList'; +import ProjectNotifications from '../ProjectNotifications'; const channels = ['channel1', 'channel2']; const types = ['type1', 'type2']; @@ -33,13 +32,13 @@ const notifications = [ it('should match snapshot', () => { expect( shallow( - <UnconnectedProjectNotifications - project={{ key: 'foo', name: 'Foo' }} - notifications={notifications} - channels={channels} - types={types} + <ProjectNotifications addNotification={jest.fn()} + channels={channels} + notifications={notifications} + project={{ key: 'foo', name: 'Foo', organization: 'org' }} removeNotification={jest.fn()} + types={types} /> ) ).toMatchSnapshot(); @@ -49,28 +48,29 @@ 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} + <ProjectNotifications addNotification={addNotification} + channels={channels} + notifications={notifications} + project={{ key: 'foo', name: 'Foo', organization: 'org' }} removeNotification={removeNotification} + types={types} /> ); - const notificationsList = wrapper.find(NotificationsList); + const notificationsList = wrapper.find('NotificationsList'); - notificationsList.prop('onAdd')({ channel: 'channel2', type: 'type1' }); + notificationsList.prop<Function>('onAdd')({ channel: 'channel2', type: 'type1' }); expect(addNotification).toHaveBeenCalledWith({ channel: 'channel2', - type: 'type1', + organization: 'org', project: 'foo', - projectName: 'Foo' + projectName: 'Foo', + type: 'type1' }); jest.resetAllMocks(); - notificationsList.prop('onRemove')({ channel: 'channel1', type: 'type1' }); + notificationsList.prop<Function>('onRemove')({ channel: 'channel1', type: 'type1' }); expect(removeNotification).toHaveBeenCalledWith({ channel: 'channel1', type: 'type1', diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx new file mode 100644 index 00000000000..dc028125b04 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx @@ -0,0 +1,134 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import Projects, { Props } from '../Projects'; + +jest.mock('../../../../api/components', () => ({ + getSuggestions: jest.fn(() => + Promise.resolve({ + results: [ + { + q: 'TRK', + items: [ + { key: 'foo', name: 'Foo', organization: 'org' }, + { key: 'bar', name: 'Bar', organization: 'org' } + ] + }, + // this file should be ignored + { q: 'FIL', items: [{ key: 'foo:file.js', name: 'file.js', organization: 'org' }] } + ] + }) + ) +})); + +const channels = ['channel1', 'channel2']; +const types = ['type1', 'type2']; + +const projectFoo = { key: 'foo', name: 'Foo', organization: 'org' }; +const projectBar = { key: 'bar', name: 'Bar', organization: 'org' }; +const projects = [projectFoo, projectBar]; + +const newProject = { key: 'qux', name: 'Qux', organization: 'org' }; + +it('should render projects', () => { + const wrapper = shallowRender({ + notificationsByProject: { + foo: [ + { + channel: 'channel1', + organization: 'org', + project: 'foo', + projectName: 'Foo', + type: 'type1' + }, + { + channel: 'channel1', + organization: 'org', + project: 'foo', + projectName: 'Foo', + type: 'type2' + } + ] + }, + projects + }); + expect(wrapper).toMatchSnapshot(); + + // let's add a new project + wrapper.setState({ addedProjects: [newProject] }); + expect(wrapper).toMatchSnapshot(); + + // let's say we saved it, so it's passed back in `props` + wrapper.setProps({ projects: [...projects, newProject] }); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.state()).toMatchSnapshot(); +}); + +it('should search projects', () => { + const wrapper = shallowRender({ projects: [projectBar] }); + const loadOptions = wrapper.find('AsyncSelect').prop<Function>('loadOptions'); + expect(loadOptions('')).resolves.toEqual({ options: [] }); + // should not contain `projectBar` + expect(loadOptions('more than two symbols')).resolves.toEqual({ + options: [{ label: 'Foo', organization: 'org', value: 'foo' }] + }); +}); + +it('should add project', () => { + const wrapper = shallowRender(); + expect(wrapper.state('addedProjects')).toEqual([]); + wrapper.find('AsyncSelect').prop<Function>('onChange')({ + label: 'Qwe', + organization: 'org', + value: 'qwe' + }); + expect(wrapper.state('addedProjects')).toEqual([ + { key: 'qwe', name: 'Qwe', organization: 'org' } + ]); +}); + +it('should render option', () => { + const wrapper = shallowRender(); + const optionRenderer = wrapper.find('AsyncSelect').prop<Function>('optionRenderer'); + expect( + shallow( + optionRenderer({ + label: 'Qwe', + organization: 'org', + value: 'qwe' + }) + ) + ).toMatchSnapshot(); +}); + +function shallowRender(props?: Partial<Props>) { + return shallow( + <Projects + addNotification={jest.fn()} + channels={channels} + notificationsByProject={{}} + projects={[]} + removeNotification={jest.fn()} + types={types} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/GlobalNotifications-test.js.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/GlobalNotifications-test.tsx.snap index 3d21dbbdf2a..3d21dbbdf2a 100644 --- 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.tsx.snap diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.js.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.js.snap deleted file mode 100644 index 855ccecb356..00000000000 --- a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.js.snap +++ /dev/null @@ -1,20 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should match snapshot 1`] = ` -<div - className="account-body account-container" -> - <HelmetWrapper - defer={true} - encodeSpecialCharacters={true} - title="my_account.notifications" - /> - <p - className="alert alert-info" - > - notification.dispatcher.information - </p> - <Connect(GlobalNotifications) /> - <Connect(Projects) /> -</div> -`; diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap new file mode 100644 index 00000000000..4fd7bdf0e79 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap @@ -0,0 +1,90 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should fetch notifications and render 1`] = ` +<div + className="account-body account-container" +> + <HelmetWrapper + defer={true} + encodeSpecialCharacters={true} + title="my_account.notifications" + /> + <p + className="alert alert-info" + > + notification.dispatcher.information + </p> + <DeferredSpinner + loading={false} + timeout={100} + > + <React.Fragment> + <GlobalNotifications + addNotification={[Function]} + channels={ + Array [ + "channel1", + "channel2", + ] + } + notifications={ + Array [ + Object { + "channel": "channel1", + "type": "type-global", + }, + Object { + "channel": "channel1", + "type": "type-common", + }, + ] + } + removeNotification={[Function]} + types={ + Array [ + "type-global", + "type-common", + ] + } + /> + <Projects + addNotification={[Function]} + channels={ + Array [ + "channel1", + "channel2", + ] + } + notificationsByProject={ + Object { + "foo": Array [ + Object { + "channel": "channel2", + "organization": "org", + "project": "foo", + "projectName": "Foo", + "type": "type-common", + }, + ], + } + } + projects={ + Array [ + Object { + "key": "foo", + "name": "Foo", + "organization": "org", + }, + ] + } + removeNotification={[Function]} + types={ + Array [ + "type-common", + ] + } + /> + </React.Fragment> + </DeferredSpinner> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/NotificationsList-test.js.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/NotificationsList-test.tsx.snap index 13a18bff3c7..13a18bff3c7 100644 --- 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.tsx.snap 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.tsx.snap index 33c554244f1..90b25b16cf1 100644 --- 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.tsx.snap @@ -11,7 +11,9 @@ exports[`should match snapshot 1`] = ` <span className="text-normal" > - <Connect(Organization) /> + <Connect(Organization) + organizationKey="org" + /> </span> <h4 className="display-inline-block" diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.js.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.js.snap deleted file mode 100644 index 7f2fb250868..00000000000 --- a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.js.snap +++ /dev/null @@ -1,196 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render projects 1`] = ` -<section - className="boxed-group" -> - <h2> - my_profile.per_project_notifications.title - </h2> - <div - className="boxed-group-inner" - > - <Connect(ProjectNotifications) - key="foo" - project={ - Object { - "key": "foo", - "name": "Foo", - } - } - /> - <Connect(ProjectNotifications) - key="bar" - project={ - Object { - "key": "bar", - "name": "Bar", - } - } - /> - <div - className="spacer-top panel bg-muted" - > - <span - className="text-middle spacer-right" - > - my_account.set_notifications_for - : - </span> - <AsyncSelect - autoload={false} - cache={false} - loadOptions={[Function]} - minimumInput={2} - name="new_project" - onChange={[Function]} - optionRenderer={[Function]} - placeholder="my_account.search_project" - style={ - Object { - "width": "300px", - } - } - /> - </div> - </div> -</section> -`; - -exports[`should render projects 2`] = ` -<section - className="boxed-group" -> - <h2> - my_profile.per_project_notifications.title - </h2> - <div - className="boxed-group-inner" - > - <Connect(ProjectNotifications) - key="foo" - project={ - Object { - "key": "foo", - "name": "Foo", - } - } - /> - <Connect(ProjectNotifications) - key="bar" - project={ - Object { - "key": "bar", - "name": "Bar", - } - } - /> - <Connect(ProjectNotifications) - key="qux" - project={ - Object { - "key": "qux", - "name": "Qux", - } - } - /> - <div - className="spacer-top panel bg-muted" - > - <span - className="text-middle spacer-right" - > - my_account.set_notifications_for - : - </span> - <AsyncSelect - autoload={false} - cache={false} - loadOptions={[Function]} - minimumInput={2} - name="new_project" - onChange={[Function]} - optionRenderer={[Function]} - placeholder="my_account.search_project" - style={ - Object { - "width": "300px", - } - } - /> - </div> - </div> -</section> -`; - -exports[`should render projects 3`] = ` -<section - className="boxed-group" -> - <h2> - my_profile.per_project_notifications.title - </h2> - <div - className="boxed-group-inner" - > - <Connect(ProjectNotifications) - key="foo" - project={ - Object { - "key": "foo", - "name": "Foo", - } - } - /> - <Connect(ProjectNotifications) - key="bar" - project={ - Object { - "key": "bar", - "name": "Bar", - } - } - /> - <Connect(ProjectNotifications) - key="qux" - project={ - Object { - "key": "qux", - "name": "Qux", - } - } - /> - <div - className="spacer-top panel bg-muted" - > - <span - className="text-middle spacer-right" - > - my_account.set_notifications_for - : - </span> - <AsyncSelect - autoload={false} - cache={false} - loadOptions={[Function]} - minimumInput={2} - name="new_project" - onChange={[Function]} - optionRenderer={[Function]} - placeholder="my_account.search_project" - style={ - Object { - "width": "300px", - } - } - /> - </div> - </div> -</section> -`; - -exports[`should render projects 4`] = ` -Object { - "addedProjects": Array [], -} -`; diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.tsx.snap new file mode 100644 index 00000000000..74aceb01f8f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.tsx.snap @@ -0,0 +1,375 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render option 1`] = ` +<span> + <Connect(Organization) + link={false} + organizationKey="org" + /> + <strong> + Qwe + </strong> +</span> +`; + +exports[`should render projects 1`] = ` +<section + className="boxed-group" +> + <h2> + my_profile.per_project_notifications.title + </h2> + <div + className="boxed-group-inner" + > + <ProjectNotifications + addNotification={[MockFunction]} + channels={ + Array [ + "channel1", + "channel2", + ] + } + key="foo" + notifications={ + Array [ + Object { + "channel": "channel1", + "organization": "org", + "project": "foo", + "projectName": "Foo", + "type": "type1", + }, + Object { + "channel": "channel1", + "organization": "org", + "project": "foo", + "projectName": "Foo", + "type": "type2", + }, + ] + } + project={ + Object { + "key": "foo", + "name": "Foo", + "organization": "org", + } + } + removeNotification={[MockFunction]} + types={ + Array [ + "type1", + "type2", + ] + } + /> + <ProjectNotifications + addNotification={[MockFunction]} + channels={ + Array [ + "channel1", + "channel2", + ] + } + key="bar" + notifications={Array []} + project={ + Object { + "key": "bar", + "name": "Bar", + "organization": "org", + } + } + removeNotification={[MockFunction]} + types={ + Array [ + "type1", + "type2", + ] + } + /> + <div + className="spacer-top panel bg-muted" + > + <span + className="text-middle spacer-right" + > + my_account.set_notifications_for + : + </span> + <AsyncSelect + autoload={false} + cache={false} + className="input-super-large" + loadOptions={[Function]} + minimumInput={2} + name="new_project" + onChange={[Function]} + optionRenderer={[Function]} + placeholder="my_account.search_project" + /> + </div> + </div> +</section> +`; + +exports[`should render projects 2`] = ` +<section + className="boxed-group" +> + <h2> + my_profile.per_project_notifications.title + </h2> + <div + className="boxed-group-inner" + > + <ProjectNotifications + addNotification={[MockFunction]} + channels={ + Array [ + "channel1", + "channel2", + ] + } + key="foo" + notifications={ + Array [ + Object { + "channel": "channel1", + "organization": "org", + "project": "foo", + "projectName": "Foo", + "type": "type1", + }, + Object { + "channel": "channel1", + "organization": "org", + "project": "foo", + "projectName": "Foo", + "type": "type2", + }, + ] + } + project={ + Object { + "key": "foo", + "name": "Foo", + "organization": "org", + } + } + removeNotification={[MockFunction]} + types={ + Array [ + "type1", + "type2", + ] + } + /> + <ProjectNotifications + addNotification={[MockFunction]} + channels={ + Array [ + "channel1", + "channel2", + ] + } + key="bar" + notifications={Array []} + project={ + Object { + "key": "bar", + "name": "Bar", + "organization": "org", + } + } + removeNotification={[MockFunction]} + types={ + Array [ + "type1", + "type2", + ] + } + /> + <ProjectNotifications + addNotification={[MockFunction]} + channels={ + Array [ + "channel1", + "channel2", + ] + } + key="qux" + notifications={Array []} + project={ + Object { + "key": "qux", + "name": "Qux", + "organization": "org", + } + } + removeNotification={[MockFunction]} + types={ + Array [ + "type1", + "type2", + ] + } + /> + <div + className="spacer-top panel bg-muted" + > + <span + className="text-middle spacer-right" + > + my_account.set_notifications_for + : + </span> + <AsyncSelect + autoload={false} + cache={false} + className="input-super-large" + loadOptions={[Function]} + minimumInput={2} + name="new_project" + onChange={[Function]} + optionRenderer={[Function]} + placeholder="my_account.search_project" + /> + </div> + </div> +</section> +`; + +exports[`should render projects 3`] = ` +<section + className="boxed-group" +> + <h2> + my_profile.per_project_notifications.title + </h2> + <div + className="boxed-group-inner" + > + <ProjectNotifications + addNotification={[MockFunction]} + channels={ + Array [ + "channel1", + "channel2", + ] + } + key="foo" + notifications={ + Array [ + Object { + "channel": "channel1", + "organization": "org", + "project": "foo", + "projectName": "Foo", + "type": "type1", + }, + Object { + "channel": "channel1", + "organization": "org", + "project": "foo", + "projectName": "Foo", + "type": "type2", + }, + ] + } + project={ + Object { + "key": "foo", + "name": "Foo", + "organization": "org", + } + } + removeNotification={[MockFunction]} + types={ + Array [ + "type1", + "type2", + ] + } + /> + <ProjectNotifications + addNotification={[MockFunction]} + channels={ + Array [ + "channel1", + "channel2", + ] + } + key="bar" + notifications={Array []} + project={ + Object { + "key": "bar", + "name": "Bar", + "organization": "org", + } + } + removeNotification={[MockFunction]} + types={ + Array [ + "type1", + "type2", + ] + } + /> + <ProjectNotifications + addNotification={[MockFunction]} + channels={ + Array [ + "channel1", + "channel2", + ] + } + key="qux" + notifications={Array []} + project={ + Object { + "key": "qux", + "name": "Qux", + "organization": "org", + } + } + removeNotification={[MockFunction]} + types={ + Array [ + "type1", + "type2", + ] + } + /> + <div + className="spacer-top panel bg-muted" + > + <span + className="text-middle spacer-right" + > + my_account.set_notifications_for + : + </span> + <AsyncSelect + autoload={false} + cache={false} + className="input-super-large" + loadOptions={[Function]} + minimumInput={2} + name="new_project" + onChange={[Function]} + optionRenderer={[Function]} + placeholder="my_account.search_project" + /> + </div> + </div> +</section> +`; + +exports[`should render projects 4`] = ` +Object { + "addedProjects": Array [], +} +`; diff --git a/server/sonar-web/src/main/js/apps/account/notifications/actions.js b/server/sonar-web/src/main/js/apps/account/notifications/actions.js deleted file mode 100644 index 29a2c1db3e6..00000000000 --- a/server/sonar-web/src/main/js/apps/account/notifications/actions.js +++ /dev/null @@ -1,60 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2018 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -// @flow -import * as api from '../../../api/notifications'; -/*:: import type { GetNotificationsResponse } from '../../../api/notifications'; */ -import { onFail, fetchOrganizations } from '../../../store/rootActions'; -import { - receiveNotifications, - addNotification as addNotificationAction, - removeNotification as removeNotificationAction -} from '../../../store/notifications/duck'; -/*:: import type { Notification } from '../../../store/notifications/duck'; */ - -export const fetchNotifications = () => (dispatch /*: Function */) => { - const onFulfil = (response /*: GetNotificationsResponse */) => { - const organizations = response.notifications - .filter(n => n.organization) - .map(n => n.organization); - - dispatch(fetchOrganizations(organizations)).then(() => { - dispatch( - receiveNotifications( - response.notifications, - response.channels, - response.globalTypes, - response.perProjectTypes - ) - ); - }); - }; - - return api.getNotifications().then(onFulfil, onFail(dispatch)); -}; - -export const addNotification = (n /*: Notification */) => (dispatch /*: Function */) => - api - .addNotification(n.channel, n.type, n.project) - .then(() => dispatch(addNotificationAction(n)), onFail(dispatch)); - -export const removeNotification = (n /*: Notification */) => (dispatch /*: Function */) => - api - .removeNotification(n.channel, n.type, n.project) - .then(() => dispatch(removeNotificationAction(n)), onFail(dispatch)); diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.js b/server/sonar-web/src/main/js/apps/account/notifications/types.ts index ca99b42e1c6..58d5dc5948d 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.js +++ b/server/sonar-web/src/main/js/apps/account/notifications/types.ts @@ -17,10 +17,8 @@ * 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(); -}); +export interface NotificationProject { + key: string; + name: string; + organization: string; +} diff --git a/server/sonar-web/src/main/js/apps/account/routes.ts b/server/sonar-web/src/main/js/apps/account/routes.ts index 2683c0988f6..b65220d777e 100644 --- a/server/sonar-web/src/main/js/apps/account/routes.ts +++ b/server/sonar-web/src/main/js/apps/account/routes.ts @@ -36,7 +36,7 @@ const routes = [ }, { path: 'notifications', - component: lazyLoad(() => import('./notifications/Notifications')) + component: lazyLoad(() => import('./notifications/NotificationsContainer')) }, { path: 'organizations', diff --git a/server/sonar-web/src/main/js/components/controls/Select.tsx b/server/sonar-web/src/main/js/components/controls/Select.tsx index 85fa556edc9..8aa569de29b 100644 --- a/server/sonar-web/src/main/js/components/controls/Select.tsx +++ b/server/sonar-web/src/main/js/components/controls/Select.tsx @@ -60,6 +60,6 @@ export function Creatable(props: ReactCreatableSelectProps) { } // TODO figure out why `ref` prop is incompatible -export function AsyncSelect(props: ReactAsyncSelectProps & { ref: any }) { +export function AsyncSelect(props: ReactAsyncSelectProps & { ref?: any }) { return <Async {...props} />; } diff --git a/server/sonar-web/src/main/js/store/notifications/duck.js b/server/sonar-web/src/main/js/store/notifications/duck.js deleted file mode 100644 index 0861944491c..00000000000 --- a/server/sonar-web/src/main/js/store/notifications/duck.js +++ /dev/null @@ -1,204 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2018 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -// @flow -import { combineReducers } from 'redux'; -import { uniqBy, uniqWith } from 'lodash'; - -/*:: -export type Notification = { - channel: string, - type: string, - project?: string, - projectName?: string, - organization?: string -}; -*/ - -/*:: -export type NotificationsState = Array<Notification>; -*/ -/*:: -export type ChannelsState = Array<string>; -*/ -/*:: -export type TypesState = Array<string>; -*/ - -/*:: -type AddNotificationAction = { - type: 'ADD_NOTIFICATION', - notification: Notification -}; -*/ - -/*:: -type RemoveNotificationAction = { - type: 'REMOVE_NOTIFICATION', - notification: Notification -}; -*/ - -/*:: -type ReceiveNotificationsAction = { - type: 'RECEIVE_NOTIFICATIONS', - notifications: NotificationsState, - channels: ChannelsState, - globalTypes: TypesState, - perProjectTypes: TypesState -}; -*/ - -/*:: -type Action = AddNotificationAction | RemoveNotificationAction | ReceiveNotificationsAction; -*/ - -export function addNotification(notification /*: Notification */) /*: AddNotificationAction */ { - return { - type: 'ADD_NOTIFICATION', - notification - }; -} - -export function removeNotification( - notification /*: Notification */ -) /*: RemoveNotificationAction */ { - return { - type: 'REMOVE_NOTIFICATION', - notification - }; -} - -export function receiveNotifications( - notifications /*: NotificationsState */, - channels /*: ChannelsState */, - globalTypes /*: TypesState */, - perProjectTypes /*: TypesState */ -) /*: ReceiveNotificationsAction */ { - return { - type: 'RECEIVE_NOTIFICATIONS', - notifications, - channels, - globalTypes, - perProjectTypes - }; -} - -function onAddNotification(state /*: NotificationsState */, notification /*: Notification */) { - function isNotificationsEqual(a /*: Notification */, b /*: Notification */) { - return a.channel === b.channel && a.type === b.type && a.project === b.project; - } - - return uniqWith([...state, notification], isNotificationsEqual); -} - -function onRemoveNotification(state /*: NotificationsState */, notification /*: Notification */) { - return state.filter( - n => - n.channel !== notification.channel || - n.type !== notification.type || - n.project !== notification.project - ); -} - -function onReceiveNotifications( - state /*: NotificationsState */, - notifications /*: NotificationsState */ -) { - return [...notifications]; -} - -function notifications(state /*: NotificationsState */ = [], action /*: Action */) { - switch (action.type) { - case 'ADD_NOTIFICATION': - return onAddNotification(state, action.notification); - case 'REMOVE_NOTIFICATION': - return onRemoveNotification(state, action.notification); - case 'RECEIVE_NOTIFICATIONS': - return onReceiveNotifications(state, action.notifications); - default: - return state; - } -} - -function channels(state /*: ChannelsState */ = [], action /*: Action */) { - if (action.type === 'RECEIVE_NOTIFICATIONS') { - return action.channels; - } else { - return state; - } -} - -function globalTypes(state /*: TypesState */ = [], action /*: Action */) { - if (action.type === 'RECEIVE_NOTIFICATIONS') { - return action.globalTypes; - } else { - return state; - } -} - -function perProjectTypes(state /*: TypesState */ = [], action /*: Action */) { - if (action.type === 'RECEIVE_NOTIFICATIONS') { - return action.perProjectTypes; - } else { - return state; - } -} - -/*:: -type State = { - notifications: NotificationsState, - channels: ChannelsState, - globalTypes: TypesState, - perProjectTypes: TypesState -}; -*/ - -export default combineReducers({ notifications, channels, globalTypes, perProjectTypes }); - -export function getGlobal(state /*: State */) /*: NotificationsState */ { - return state.notifications.filter(n => !n.project); -} - -export function getProjects(state /*: State */) /*: Array<{ key: string, name: string }> */ { - // $FlowFixMe - const allProjects = state.notifications.filter(n => n.project != null).map(n => ({ - key: n.project, - name: n.projectName, - organization: n.organization - })); - - return uniqBy(allProjects, project => project.key); -} - -export function getForProject(state /*: State */, project /*: string */) /*: NotificationsState */ { - return state.notifications.filter(n => n.project === project); -} - -export function getChannels(state /*: State */) /*: ChannelsState */ { - return state.channels; -} - -export function getGlobalTypes(state /*: State */) /*: TypesState */ { - return state.globalTypes; -} - -export function getPerProjectTypes(state /*: State */) /*: TypesState */ { - return state.perProjectTypes; -} diff --git a/server/sonar-web/src/main/js/store/rootReducer.js b/server/sonar-web/src/main/js/store/rootReducer.js index 15c9d1ac30b..0ca7aab07c4 100644 --- a/server/sonar-web/src/main/js/store/rootReducer.js +++ b/server/sonar-web/src/main/js/store/rootReducer.js @@ -24,7 +24,6 @@ import users, * as fromUsers from './users/reducer'; import favorites, * as fromFavorites from './favorites/duck'; import languages, * as fromLanguages from './languages/reducer'; import metrics, * as fromMetrics from './metrics/reducer'; -import notifications, * as fromNotifications from './notifications/duck'; import organizations, * as fromOrganizations from './organizations/duck'; import organizationsMembers, * as fromOrganizationsMembers from './organizationsMembers/reducer'; import globalMessages, * as fromGlobalMessages from './globalMessages/duck'; @@ -39,7 +38,6 @@ export default combineReducers({ languages, marketplace, metrics, - notifications, organizations, organizationsMembers, users, @@ -89,22 +87,6 @@ export const getMetricByKey = (state, key) => fromMetrics.getMetricByKey(state.m export const getMetricsKey = state => fromMetrics.getMetricsKey(state.metrics); -export const getGlobalNotifications = state => fromNotifications.getGlobal(state.notifications); - -export const getProjectsWithNotifications = state => - fromNotifications.getProjects(state.notifications); - -export const getProjectNotifications = (state, project) => - fromNotifications.getForProject(state.notifications, project); - -export const getNotificationChannels = state => fromNotifications.getChannels(state.notifications); - -export const getNotificationGlobalTypes = state => - fromNotifications.getGlobalTypes(state.notifications); - -export const getNotificationPerProjectTypes = state => - fromNotifications.getPerProjectTypes(state.notifications); - export const getOrganizationByKey = (state, key) => fromOrganizations.getOrganizationByKey(state.organizations, key); |