aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main
diff options
context:
space:
mode:
authorSiegfried Ehret <49895321+siegfried-ehret-sonarsource@users.noreply.github.com>2019-10-17 11:32:10 +0200
committerSonarTech <sonartech@sonarsource.com>2019-10-28 20:21:09 +0100
commitcd2296809f5308f0cbf14100fcbff2846c3bf3da (patch)
tree9be140071398becdef60caf20b6555bba31e1ebd /server/sonar-web/src/main
parent3c93ddc45bbd9210d55a9d6b7a8802e330247640 (diff)
downloadsonarqube-cd2296809f5308f0cbf14100fcbff2846c3bf3da.tar.gz
sonarqube-cd2296809f5308f0cbf14100fcbff2846c3bf3da.zip
SONAR-10037 Manage project notifications from project dashboard
Co-authored-by: Wouter Admiraal <wouter.admiraal@sonarsource.com>
Diffstat (limited to 'server/sonar-web/src/main')
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx184
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx5
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx131
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx1
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap145
-rw-r--r--server/sonar-web/src/main/js/apps/overview/badges/ProjectBadges.tsx (renamed from server/sonar-web/src/main/js/apps/overview/badges/BadgesModal.tsx)2
-rw-r--r--server/sonar-web/src/main/js/apps/overview/badges/__tests__/ProjectBadges-test.tsx (renamed from server/sonar-web/src/main/js/apps/overview/badges/__tests__/BadgesModal-test.tsx)6
-rw-r--r--server/sonar-web/src/main/js/apps/overview/badges/__tests__/__snapshots__/ProjectBadges-test.tsx.snap (renamed from server/sonar-web/src/main/js/apps/overview/badges/__tests__/__snapshots__/BadgesModal-test.tsx.snap)0
-rw-r--r--server/sonar-web/src/main/js/apps/overview/meta/MetaContainer.tsx36
-rw-r--r--server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaContainer-test.tsx.snap120
-rw-r--r--server/sonar-web/src/main/js/apps/overview/notifications/ProjectNotifications.tsx116
-rw-r--r--server/sonar-web/src/main/js/apps/overview/notifications/__tests__/ProjectNotifications.tsx90
-rw-r--r--server/sonar-web/src/main/js/apps/overview/notifications/__tests__/__snapshots__/ProjectNotifications.tsx.snap108
-rw-r--r--server/sonar-web/src/main/js/components/hoc/__tests__/__snapshots__/withNotifications-test.tsx.snap51
-rw-r--r--server/sonar-web/src/main/js/components/hoc/__tests__/withNotifications-test.tsx96
-rw-r--r--server/sonar-web/src/main/js/components/hoc/withNotifications.tsx148
16 files changed, 995 insertions, 244 deletions
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
index a2d26135ada..e2f6cfce54d 100644
--- a/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx
+++ b/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx
@@ -17,152 +17,58 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { partition, uniqWith } from 'lodash';
+import { partition } from 'lodash';
import * as React from 'react';
import Helmet from 'react-helmet';
import { Alert } from 'sonar-ui-common/components/ui/Alert';
import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner';
import { translate } from 'sonar-ui-common/helpers/l10n';
-import * as api from '../../../api/notifications';
+import {
+ withNotifications,
+ WithNotificationsProps
+} from '../../../components/hoc/withNotifications';
import GlobalNotifications from './GlobalNotifications';
import Projects from './Projects';
-interface State {
- channels: string[];
- globalTypes: string[];
- initialProjectNotificationsCount: number;
- loading: boolean;
- notifications: T.Notification[];
- perProjectTypes: string[];
+export function Notifications(props: WithNotificationsProps) {
+ const {
+ addNotification,
+ channels,
+ globalTypes,
+ loading,
+ notifications,
+ perProjectTypes,
+ removeNotification
+ } = props;
+
+ const [globalNotifications, projectNotifications] = partition(notifications, n => !n.project);
+
+ return (
+ <div className="account-body account-container">
+ <Helmet title={translate('my_account.notifications')} />
+ <Alert variant="info">{translate('notification.dispatcher.information')}</Alert>
+ <DeferredSpinner loading={loading}>
+ {notifications && (
+ <>
+ <GlobalNotifications
+ addNotification={addNotification}
+ channels={channels}
+ notifications={globalNotifications}
+ removeNotification={removeNotification}
+ types={globalTypes}
+ />
+ <Projects
+ addNotification={addNotification}
+ channels={channels}
+ notifications={projectNotifications}
+ removeNotification={removeNotification}
+ types={perProjectTypes}
+ />
+ </>
+ )}
+ </DeferredSpinner>
+ </div>
+ );
}
-export default class Notifications extends React.PureComponent<{}, State> {
- mounted = false;
- state: State = {
- channels: [],
- globalTypes: [],
- initialProjectNotificationsCount: 0,
- loading: true,
- notifications: [],
- perProjectTypes: []
- };
-
- componentDidMount() {
- this.mounted = true;
- this.fetchNotifications();
- }
-
- componentWillUnmount() {
- this.mounted = false;
- }
-
- fetchNotifications = () => {
- api.getNotifications().then(
- response => {
- if (this.mounted) {
- const { notifications } = response;
- const { projectNotifications } = this.getNotificationUpdates(notifications);
-
- this.setState({
- channels: response.channels,
- globalTypes: response.globalTypes,
- initialProjectNotificationsCount: projectNotifications.length,
- loading: false,
- notifications: response.notifications,
- perProjectTypes: response.perProjectTypes
- });
- }
- },
- () => {
- if (this.mounted) {
- this.setState({ loading: false });
- }
- }
- );
- };
-
- addNotificationToState = (added: T.Notification) => {
- this.setState(state => {
- const notifications = uniqWith([...state.notifications, added], areNotificationsEqual);
- return { notifications };
- });
- };
-
- removeNotificationFromState = (removed: T.Notification) => {
- this.setState(state => ({
- notifications: state.notifications.filter(
- notification => !areNotificationsEqual(notification, removed)
- )
- }));
- };
-
- addNotification = (added: T.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: T.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);
- });
- };
-
- getNotificationUpdates = (notifications: T.Notification[]) => {
- const [globalNotifications, projectNotifications] = partition(notifications, n => !n.project);
-
- return {
- globalNotifications,
- projectNotifications
- };
- };
-
- render() {
- const { initialProjectNotificationsCount, notifications } = this.state;
- const { globalNotifications, projectNotifications } = this.getNotificationUpdates(
- notifications
- );
-
- return (
- <div className="account-body account-container">
- <Helmet title={translate('my_account.notifications')} />
- <Alert variant="info">{translate('notification.dispatcher.information')}</Alert>
- <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}
- initialProjectNotificationsCount={initialProjectNotificationsCount}
- notifications={projectNotifications}
- removeNotification={this.removeNotification}
- types={this.state.perProjectTypes}
- />
- </>
- )}
- </DeferredSpinner>
- </div>
- );
- }
-}
-
-function areNotificationsEqual(a: T.Notification, b: T.Notification) {
- return a.channel === b.channel && a.type === b.type && a.project === b.project;
-}
+export default withNotifications(Notifications);
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx b/server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx
index 57eebfa2a89..a9a020965b6 100644
--- a/server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx
+++ b/server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx
@@ -28,7 +28,6 @@ import ProjectNotifications from './ProjectNotifications';
export interface Props {
addNotification: (n: T.Notification) => void;
channels: string[];
- initialProjectNotificationsCount: number;
notifications: T.Notification[];
removeNotification: (n: T.Notification) => void;
types: string[];
@@ -98,7 +97,7 @@ export default class Projects extends React.PureComponent<Props, State> {
};
render() {
- const { initialProjectNotificationsCount, notifications } = this.props;
+ const { notifications } = this.props;
const { addedProjects, search } = this.state;
const projects = uniqBy(notifications, project => project.project).filter(
@@ -109,7 +108,7 @@ export default class Projects extends React.PureComponent<Props, State> {
const filteredProjects = sortBy(allProjects, 'projectName').filter(p =>
this.filterSearch(p, search)
);
- const shouldBeCollapsed = initialProjectNotificationsCount > THRESHOLD_COLLAPSED;
+ const shouldBeCollapsed = Object.keys(notificationsByProject).length > THRESHOLD_COLLAPSED;
return (
<section className="boxed-group" data-test="account__project-notifications">
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
index 82d02d028f6..34dabc9e830 100644
--- 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
@@ -17,19 +17,68 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-
import { shallow } from 'enzyme';
import * as React from 'react';
-import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
-import Notifications from '../Notifications';
+import GlobalNotifications from '../GlobalNotifications';
+import { Notifications } from '../Notifications';
+import Projects from '../Projects';
+
+it('should render correctly', () => {
+ expect(shallowRender({ loading: true })).toMatchSnapshot();
+ expect(shallowRender()).toMatchSnapshot();
+ expect(shallowRender({ notifications: [] })).toMatchSnapshot();
+});
+
+it('should add and remove global notifications', () => {
+ const addNotification = jest.fn();
+ const removeNotification = jest.fn();
+ const notification = { channel: 'channel2', type: 'type-global' };
+ const wrapper = shallowRender({ addNotification, removeNotification });
+
+ wrapper
+ .find(GlobalNotifications)
+ .props()
+ .addNotification(notification);
+ expect(addNotification).toBeCalledWith(notification);
+
+ wrapper
+ .find(GlobalNotifications)
+ .props()
+ .removeNotification(notification);
+ expect(removeNotification).toBeCalledWith(notification);
+});
+
+it('should add and remove project notification', () => {
+ const addNotification = jest.fn();
+ const removeNotification = jest.fn();
+ const notification = {
+ channel: 'channel2',
+ type: 'type-common',
+ project: 'qux'
+ };
+ const wrapper = shallowRender({ addNotification, removeNotification });
+
+ wrapper
+ .find(Projects)
+ .props()
+ .addNotification(notification);
+ expect(addNotification).toBeCalledWith(notification);
+
+ wrapper
+ .find(Projects)
+ .props()
+ .removeNotification(notification);
+ expect(removeNotification).toBeCalledWith(notification);
+});
-jest.mock('../../../../api/notifications', () => ({
- addNotification: jest.fn(() => Promise.resolve()),
- getNotifications: jest.fn(() =>
- Promise.resolve({
- channels: ['channel1', 'channel2'],
- globalTypes: ['type-global', 'type-common'],
- notifications: [
+function shallowRender(props = {}) {
+ return shallow(
+ <Notifications
+ addNotification={jest.fn()}
+ channels={['channel1', 'channel2']}
+ globalTypes={['type-global', 'type-common']}
+ loading={false}
+ notifications={[
{
channel: 'channel1',
type: 'type-global',
@@ -51,60 +100,10 @@ jest.mock('../../../../api/notifications', () => ({
projectName: 'Qux',
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',
- type: 'type-common',
- project: 'qux'
- };
- const wrapper = await shallowRender();
- expect(wrapper.state('notifications')).toContainEqual({
- ...notification,
- organization: 'org',
- projectName: 'Qux'
- });
- wrapper.find('Projects').prop<Function>('removeNotification')(notification);
- // `state` must be immediately updated
- expect(wrapper.state('notifications')).not.toContainEqual(notification);
- expect(removeNotification).toBeCalledWith(notification);
-});
-
-async function shallowRender(props: Partial<Notifications['props']> = {}) {
- const wrapper = shallow<Notifications>(<Notifications {...props} />);
- await waitAndUpdate(wrapper);
- return wrapper;
+ ]}
+ perProjectTypes={['type-common']}
+ removeNotification={jest.fn()}
+ {...props}
+ />
+ );
}
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
index e397e80f845..8f33ad36d40 100644
--- 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
@@ -110,7 +110,6 @@ function shallowRender(props?: Partial<Projects['props']>) {
<Projects
addNotification={jest.fn()}
channels={channels}
- initialProjectNotificationsCount={0}
notifications={[]}
removeNotification={jest.fn()}
types={types}
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
index 5b6472500bf..46053156fbf 100644
--- 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
@@ -1,6 +1,85 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`should fetch notifications and render 1`] = `
+exports[`should render correctly 1`] = `
+<div
+ className="account-body account-container"
+>
+ <HelmetWrapper
+ defer={true}
+ encodeSpecialCharacters={true}
+ title="my_account.notifications"
+ />
+ <Alert
+ variant="info"
+ >
+ notification.dispatcher.information
+ </Alert>
+ <DeferredSpinner
+ loading={true}
+ timeout={100}
+ >
+ <GlobalNotifications
+ addNotification={[MockFunction]}
+ channels={
+ Array [
+ "channel1",
+ "channel2",
+ ]
+ }
+ notifications={Array []}
+ removeNotification={[MockFunction]}
+ types={
+ Array [
+ "type-global",
+ "type-common",
+ ]
+ }
+ />
+ <Projects
+ addNotification={[MockFunction]}
+ channels={
+ Array [
+ "channel1",
+ "channel2",
+ ]
+ }
+ notifications={
+ Array [
+ Object {
+ "channel": "channel1",
+ "organization": "org",
+ "project": "foo",
+ "projectName": "Foo",
+ "type": "type-global",
+ },
+ Object {
+ "channel": "channel1",
+ "organization": "org",
+ "project": "bar",
+ "projectName": "Bar",
+ "type": "type-common",
+ },
+ Object {
+ "channel": "channel2",
+ "organization": "org",
+ "project": "qux",
+ "projectName": "Qux",
+ "type": "type-common",
+ },
+ ]
+ }
+ removeNotification={[MockFunction]}
+ types={
+ Array [
+ "type-common",
+ ]
+ }
+ />
+ </DeferredSpinner>
+</div>
+`;
+
+exports[`should render correctly 2`] = `
<div
className="account-body account-container"
>
@@ -19,7 +98,7 @@ exports[`should fetch notifications and render 1`] = `
timeout={100}
>
<GlobalNotifications
- addNotification={[Function]}
+ addNotification={[MockFunction]}
channels={
Array [
"channel1",
@@ -27,7 +106,7 @@ exports[`should fetch notifications and render 1`] = `
]
}
notifications={Array []}
- removeNotification={[Function]}
+ removeNotification={[MockFunction]}
types={
Array [
"type-global",
@@ -36,14 +115,13 @@ exports[`should fetch notifications and render 1`] = `
}
/>
<Projects
- addNotification={[Function]}
+ addNotification={[MockFunction]}
channels={
Array [
"channel1",
"channel2",
]
}
- initialProjectNotificationsCount={3}
notifications={
Array [
Object {
@@ -69,7 +147,62 @@ exports[`should fetch notifications and render 1`] = `
},
]
}
- removeNotification={[Function]}
+ removeNotification={[MockFunction]}
+ types={
+ Array [
+ "type-common",
+ ]
+ }
+ />
+ </DeferredSpinner>
+</div>
+`;
+
+exports[`should render correctly 3`] = `
+<div
+ className="account-body account-container"
+>
+ <HelmetWrapper
+ defer={true}
+ encodeSpecialCharacters={true}
+ title="my_account.notifications"
+ />
+ <Alert
+ variant="info"
+ >
+ notification.dispatcher.information
+ </Alert>
+ <DeferredSpinner
+ loading={false}
+ timeout={100}
+ >
+ <GlobalNotifications
+ addNotification={[MockFunction]}
+ channels={
+ Array [
+ "channel1",
+ "channel2",
+ ]
+ }
+ notifications={Array []}
+ removeNotification={[MockFunction]}
+ types={
+ Array [
+ "type-global",
+ "type-common",
+ ]
+ }
+ />
+ <Projects
+ addNotification={[MockFunction]}
+ channels={
+ Array [
+ "channel1",
+ "channel2",
+ ]
+ }
+ notifications={Array []}
+ removeNotification={[MockFunction]}
types={
Array [
"type-common",
diff --git a/server/sonar-web/src/main/js/apps/overview/badges/BadgesModal.tsx b/server/sonar-web/src/main/js/apps/overview/badges/ProjectBadges.tsx
index 8efeea8f9b2..145d2bdcc48 100644
--- a/server/sonar-web/src/main/js/apps/overview/badges/BadgesModal.tsx
+++ b/server/sonar-web/src/main/js/apps/overview/badges/ProjectBadges.tsx
@@ -42,7 +42,7 @@ interface State {
badgeOptions: BadgeOptions;
}
-export default class BadgesModal extends React.PureComponent<Props, State> {
+export default class ProjectBadges extends React.PureComponent<Props, State> {
state: State = {
open: false,
selectedType: BadgeType.measure,
diff --git a/server/sonar-web/src/main/js/apps/overview/badges/__tests__/BadgesModal-test.tsx b/server/sonar-web/src/main/js/apps/overview/badges/__tests__/ProjectBadges-test.tsx
index 5daad7315a2..b356b45a78d 100644
--- a/server/sonar-web/src/main/js/apps/overview/badges/__tests__/BadgesModal-test.tsx
+++ b/server/sonar-web/src/main/js/apps/overview/badges/__tests__/ProjectBadges-test.tsx
@@ -22,7 +22,7 @@ import * as React from 'react';
import { click } from 'sonar-ui-common/helpers/testUtils';
import { Location } from 'sonar-ui-common/helpers/urls';
import { isSonarCloud } from '../../../../helpers/system';
-import BadgesModal from '../BadgesModal';
+import ProjectBadges from '../ProjectBadges';
jest.mock('sonar-ui-common/helpers/urls', () => ({
getHostUrl: () => 'host',
@@ -45,7 +45,7 @@ const shortBranch: T.ShortLivingBranch = {
it('should display the modal after click on sonarcloud', () => {
(isSonarCloud as jest.Mock).mockImplementation(() => true);
const wrapper = shallow(
- <BadgesModal branchLike={shortBranch} metrics={{}} project="foo" qualifier="TRK" />
+ <ProjectBadges branchLike={shortBranch} metrics={{}} project="foo" qualifier="TRK" />
);
expect(wrapper).toMatchSnapshot();
click(wrapper.find('Button'));
@@ -55,7 +55,7 @@ it('should display the modal after click on sonarcloud', () => {
it('should display the modal after click on sonarqube', () => {
(isSonarCloud as jest.Mock).mockImplementation(() => false);
const wrapper = shallow(
- <BadgesModal branchLike={shortBranch} metrics={{}} project="foo" qualifier="TRK" />
+ <ProjectBadges branchLike={shortBranch} metrics={{}} project="foo" qualifier="TRK" />
);
expect(wrapper).toMatchSnapshot();
click(wrapper.find('Button'));
diff --git a/server/sonar-web/src/main/js/apps/overview/badges/__tests__/__snapshots__/BadgesModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/overview/badges/__tests__/__snapshots__/ProjectBadges-test.tsx.snap
index 43a0e7f7638..43a0e7f7638 100644
--- a/server/sonar-web/src/main/js/apps/overview/badges/__tests__/__snapshots__/BadgesModal-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/overview/badges/__tests__/__snapshots__/ProjectBadges-test.tsx.snap
diff --git a/server/sonar-web/src/main/js/apps/overview/meta/MetaContainer.tsx b/server/sonar-web/src/main/js/apps/overview/meta/MetaContainer.tsx
index 3f2c86595cf..c35ad5bec9a 100644
--- a/server/sonar-web/src/main/js/apps/overview/meta/MetaContainer.tsx
+++ b/server/sonar-web/src/main/js/apps/overview/meta/MetaContainer.tsx
@@ -19,10 +19,11 @@
*/
import * as React from 'react';
import { connect } from 'react-redux';
-import { lazyLoad } from 'sonar-ui-common/components/lazyLoad';
+import { lazyLoadComponent } from 'sonar-ui-common/components/lazyLoadComponent';
import { translate } from 'sonar-ui-common/helpers/l10n';
import PrivacyBadgeContainer from '../../../components/common/PrivacyBadgeContainer';
import { hasPrivateAccess } from '../../../helpers/organizations';
+import { isLoggedIn } from '../../../helpers/users';
import {
getAppState,
getCurrentUser,
@@ -39,7 +40,11 @@ import MetaQualityProfiles from './MetaQualityProfiles';
import MetaSize from './MetaSize';
import MetaTags from './MetaTags';
-const BadgesModal = lazyLoad(() => import('../badges/BadgesModal'), 'BadgesModal');
+const ProjectBadges = lazyLoadComponent(() => import('../badges/ProjectBadges'), 'ProjectBadges');
+const ProjectNotifications = lazyLoadComponent(
+ () => import('../notifications/ProjectNotifications'),
+ 'ProjectNotifications'
+);
interface StateToProps {
appState: T.AppState;
@@ -97,12 +102,15 @@ export class Meta extends React.PureComponent<Props> {
render() {
const { organizationsEnabled } = this.props.appState;
- const { branchLike, component, measures, metrics, organization } = this.props;
+ const { branchLike, component, currentUser, measures, metrics, organization } = this.props;
const { qualifier, description, visibility } = component;
const isProject = qualifier === 'TRK';
const isApp = qualifier === 'APP';
const isPrivate = visibility === 'private';
+ const canUseBadges = !isPrivate && (isProject || isApp);
+ const canConfigureNotifications = isLoggedIn(currentUser);
+
return (
<div className="overview-meta">
<div className="overview-meta-card">
@@ -146,13 +154,21 @@ export class Meta extends React.PureComponent<Props> {
{organizationsEnabled && <MetaOrganizationKey organization={component.organization} />}
</div>
- {!isPrivate && (isProject || isApp) && metrics && (
- <BadgesModal
- branchLike={branchLike}
- metrics={metrics}
- project={component.key}
- qualifier={component.qualifier}
- />
+ {(canUseBadges || canConfigureNotifications) && (
+ <div className="overview-meta-card">
+ {canUseBadges && metrics !== undefined && (
+ <ProjectBadges
+ branchLike={branchLike}
+ metrics={metrics}
+ project={component.key}
+ qualifier={component.qualifier}
+ />
+ )}
+
+ {canConfigureNotifications && (
+ <ProjectNotifications className="spacer-top spacer-bottom" component={component} />
+ )}
+ </div>
)}
</div>
);
diff --git a/server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaContainer-test.tsx.snap b/server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaContainer-test.tsx.snap
index d6c60fc0d8b..e7b7c3c489a 100644
--- a/server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaContainer-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaContainer-test.tsx.snap
@@ -102,11 +102,41 @@ exports[`should hide QG and QP links if the organization has a paid plan, and th
organization="foo"
/>
</div>
- <BadgesModal
- metrics={Object {}}
- project="my-project"
- qualifier="TRK"
- />
+ <div
+ className="overview-meta-card"
+ >
+ <ProjectBadges
+ metrics={Object {}}
+ project="my-project"
+ qualifier="TRK"
+ />
+ <ProjectNotifications
+ className="spacer-top spacer-bottom"
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "organization": "foo",
+ "qualifier": "TRK",
+ "qualityGate": Object {
+ "isDefault": true,
+ "key": "30",
+ "name": "Sonar way",
+ },
+ "qualityProfiles": Array [
+ Object {
+ "deleted": false,
+ "key": "my-qp",
+ "language": "ts",
+ "name": "Sonar way",
+ },
+ ],
+ "tags": Array [],
+ }
+ }
+ />
+ </div>
</div>
`;
@@ -241,11 +271,41 @@ exports[`should render correctly 1`] = `
organization="foo"
/>
</div>
- <BadgesModal
- metrics={Object {}}
- project="my-project"
- qualifier="TRK"
- />
+ <div
+ className="overview-meta-card"
+ >
+ <ProjectBadges
+ metrics={Object {}}
+ project="my-project"
+ qualifier="TRK"
+ />
+ <ProjectNotifications
+ className="spacer-top spacer-bottom"
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "organization": "foo",
+ "qualifier": "TRK",
+ "qualityGate": Object {
+ "isDefault": true,
+ "key": "30",
+ "name": "Sonar way",
+ },
+ "qualityProfiles": Array [
+ Object {
+ "deleted": false,
+ "key": "my-qp",
+ "language": "ts",
+ "name": "Sonar way",
+ },
+ ],
+ "tags": Array [],
+ }
+ }
+ />
+ </div>
</div>
`;
@@ -380,10 +440,40 @@ exports[`should show QG and QP links if the organization has a paid plan, and th
organization="foo"
/>
</div>
- <BadgesModal
- metrics={Object {}}
- project="my-project"
- qualifier="TRK"
- />
+ <div
+ className="overview-meta-card"
+ >
+ <ProjectBadges
+ metrics={Object {}}
+ project="my-project"
+ qualifier="TRK"
+ />
+ <ProjectNotifications
+ className="spacer-top spacer-bottom"
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "organization": "foo",
+ "qualifier": "TRK",
+ "qualityGate": Object {
+ "isDefault": true,
+ "key": "30",
+ "name": "Sonar way",
+ },
+ "qualityProfiles": Array [
+ Object {
+ "deleted": false,
+ "key": "my-qp",
+ "language": "ts",
+ "name": "Sonar way",
+ },
+ ],
+ "tags": Array [],
+ }
+ }
+ />
+ </div>
</div>
`;
diff --git a/server/sonar-web/src/main/js/apps/overview/notifications/ProjectNotifications.tsx b/server/sonar-web/src/main/js/apps/overview/notifications/ProjectNotifications.tsx
new file mode 100644
index 00000000000..15d7f7013be
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/overview/notifications/ProjectNotifications.tsx
@@ -0,0 +1,116 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { Button, ResetButtonLink } from 'sonar-ui-common/components/controls/buttons';
+import Modal from 'sonar-ui-common/components/controls/Modal';
+import ModalButton from 'sonar-ui-common/components/controls/ModalButton';
+import { Alert } from 'sonar-ui-common/components/ui/Alert';
+import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+import {
+ withNotifications,
+ WithNotificationsProps
+} from '../../../components/hoc/withNotifications';
+import NotificationsList from '../../account/notifications/NotificationsList';
+
+interface Props {
+ className?: string;
+ component: T.Component;
+}
+
+export function ProjectNotifications(props: WithNotificationsProps & Props) {
+ const { channels, className, component, loading, notifications, perProjectTypes } = props;
+
+ const header = translate('my_account.notifications');
+
+ const handleAddNotification = ({ channel, type }: { channel: string; type: string }) => {
+ props.addNotification({ project: component.key, channel, type });
+ };
+
+ const handleRemoveNotification = ({ channel, type }: { channel: string; type: string }) => {
+ props.removeNotification({
+ project: component.key,
+ channel,
+ type
+ });
+ };
+
+ const getCheckboxId = (type: string, channel: string) => {
+ return `project-notification-${component.key}-${type}-${channel}`;
+ };
+
+ const projectNotifications = notifications.filter(n => n.project && n.project === component.key);
+
+ return (
+ <div className={className}>
+ <ModalButton
+ modal={({ onClose }) => (
+ <Modal contentLabel={header} onRequestClose={onClose}>
+ <header className="modal-head">
+ <h2>{header}</h2>
+ </header>
+ <div className="modal-body">
+ <Alert variant="info">{translate('notification.dispatcher.information')}</Alert>
+
+ <DeferredSpinner loading={loading}>
+ <table className="data zebra notifications-table">
+ <thead>
+ <tr>
+ <th aria-label={translate('project')} />
+ {channels.map(channel => (
+ <th className="text-center" key={channel}>
+ <h4>{translate('notification.channel', channel)}</h4>
+ </th>
+ ))}
+ </tr>
+ </thead>
+
+ <NotificationsList
+ channels={channels}
+ checkboxId={getCheckboxId}
+ notifications={projectNotifications}
+ onAdd={handleAddNotification}
+ onRemove={handleRemoveNotification}
+ project={true}
+ types={perProjectTypes}
+ />
+ </table>
+ </DeferredSpinner>
+ </div>
+ <footer className="modal-foot">
+ <ResetButtonLink className="js-modal-close" onClick={onClose}>
+ {translate('close')}
+ </ResetButtonLink>
+ </footer>
+ </Modal>
+ )}>
+ {({ onClick }) => (
+ <Button onClick={onClick}>
+ <span data-test="overview__edit-notifications">
+ {translate('my_profile.per_project_notifications.edit')}
+ </span>
+ </Button>
+ )}
+ </ModalButton>
+ </div>
+ );
+}
+
+export default withNotifications(ProjectNotifications);
diff --git a/server/sonar-web/src/main/js/apps/overview/notifications/__tests__/ProjectNotifications.tsx b/server/sonar-web/src/main/js/apps/overview/notifications/__tests__/ProjectNotifications.tsx
new file mode 100644
index 00000000000..7d7b67c940f
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/overview/notifications/__tests__/ProjectNotifications.tsx
@@ -0,0 +1,90 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { mockComponent } from '../../../../helpers/testMocks';
+import { ProjectNotifications } from '../ProjectNotifications';
+
+it('should render correctly', () => {
+ expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should add and remove a notification for the project', () => {
+ const addNotification = jest.fn();
+ const removeNotification = jest.fn();
+ const wrapper = shallowRender({ addNotification, removeNotification });
+ const notification = {
+ channel: 'EmailNotificationChannel',
+ type: 'SQ-MyNewIssues'
+ };
+
+ wrapper.find('NotificationsList').prop<Function>('onAdd')(notification);
+ expect(addNotification).toHaveBeenCalledWith({ ...notification, project: 'foo' });
+
+ wrapper.find('NotificationsList').prop<Function>('onRemove')(notification);
+ expect(removeNotification).toHaveBeenCalledWith({ ...notification, project: 'foo' });
+});
+
+function shallowRender(props = {}) {
+ const wrapper = shallow(
+ <ProjectNotifications
+ addNotification={jest.fn()}
+ channels={['channel1', 'channel2']}
+ component={mockComponent({ key: 'foo' })}
+ globalTypes={['type-global', 'type-common']}
+ loading={false}
+ notifications={[
+ {
+ channel: 'channel1',
+ type: 'type-global',
+ project: 'foo',
+ projectName: 'Foo',
+ organization: 'org'
+ },
+ {
+ channel: 'channel1',
+ type: 'type-common',
+ project: 'bar',
+ projectName: 'Bar',
+ organization: 'org'
+ },
+ {
+ channel: 'channel2',
+ type: 'type-common',
+ project: 'qux',
+ projectName: 'Qux',
+ organization: 'org'
+ }
+ ]}
+ perProjectTypes={['type-common']}
+ removeNotification={jest.fn()}
+ {...props}
+ />
+ );
+
+ // Get the modal element. We need to trigger the ModalButton's `modal` prop,
+ // which is a function. It will return our Modal component.
+ return shallow(
+ wrapper.find('ModalButton').prop<Function>('modal')({
+ onClose: jest.fn()
+ })
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/overview/notifications/__tests__/__snapshots__/ProjectNotifications.tsx.snap b/server/sonar-web/src/main/js/apps/overview/notifications/__tests__/__snapshots__/ProjectNotifications.tsx.snap
new file mode 100644
index 00000000000..e542aa63bcb
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/overview/notifications/__tests__/__snapshots__/ProjectNotifications.tsx.snap
@@ -0,0 +1,108 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="my_account.notifications"
+ isOpen={true}
+ onRequestClose={[MockFunction]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ role="dialog"
+ shouldCloseOnEsc={true}
+ shouldCloseOnOverlayClick={true}
+ shouldFocusAfterRender={true}
+ shouldReturnFocusAfterClose={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ my_account.notifications
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <Alert
+ variant="info"
+ >
+ notification.dispatcher.information
+ </Alert>
+ <DeferredSpinner
+ loading={false}
+ timeout={100}
+ >
+ <table
+ className="data zebra notifications-table"
+ >
+ <thead>
+ <tr>
+ <th
+ aria-label="project"
+ />
+ <th
+ className="text-center"
+ key="channel1"
+ >
+ <h4>
+ notification.channel.channel1
+ </h4>
+ </th>
+ <th
+ className="text-center"
+ key="channel2"
+ >
+ <h4>
+ notification.channel.channel2
+ </h4>
+ </th>
+ </tr>
+ </thead>
+ <NotificationsList
+ channels={
+ Array [
+ "channel1",
+ "channel2",
+ ]
+ }
+ checkboxId={[Function]}
+ notifications={
+ Array [
+ Object {
+ "channel": "channel1",
+ "organization": "org",
+ "project": "foo",
+ "projectName": "Foo",
+ "type": "type-global",
+ },
+ ]
+ }
+ onAdd={[Function]}
+ onRemove={[Function]}
+ project={true}
+ types={
+ Array [
+ "type-common",
+ ]
+ }
+ />
+ </table>
+ </DeferredSpinner>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <ResetButtonLink
+ className="js-modal-close"
+ onClick={[MockFunction]}
+ >
+ close
+ </ResetButtonLink>
+ </footer>
+</Modal>
+`;
diff --git a/server/sonar-web/src/main/js/components/hoc/__tests__/__snapshots__/withNotifications-test.tsx.snap b/server/sonar-web/src/main/js/components/hoc/__tests__/__snapshots__/withNotifications-test.tsx.snap
new file mode 100644
index 00000000000..4af5332cd6c
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/hoc/__tests__/__snapshots__/withNotifications-test.tsx.snap
@@ -0,0 +1,51 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should fetch notifications and render 1`] = `
+<X
+ addNotification={[Function]}
+ channels={
+ Array [
+ "channel1",
+ "channel2",
+ ]
+ }
+ globalTypes={
+ Array [
+ "type-global",
+ "type-common",
+ ]
+ }
+ loading={false}
+ notifications={
+ Array [
+ Object {
+ "channel": "channel1",
+ "organization": "org",
+ "project": "foo",
+ "projectName": "Foo",
+ "type": "type-global",
+ },
+ Object {
+ "channel": "channel1",
+ "organization": "org",
+ "project": "bar",
+ "projectName": "Bar",
+ "type": "type-common",
+ },
+ Object {
+ "channel": "channel2",
+ "organization": "org",
+ "project": "qux",
+ "projectName": "Qux",
+ "type": "type-common",
+ },
+ ]
+ }
+ perProjectTypes={
+ Array [
+ "type-common",
+ ]
+ }
+ removeNotification={[Function]}
+/>
+`;
diff --git a/server/sonar-web/src/main/js/components/hoc/__tests__/withNotifications-test.tsx b/server/sonar-web/src/main/js/components/hoc/__tests__/withNotifications-test.tsx
new file mode 100644
index 00000000000..7d192fe72ef
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/hoc/__tests__/withNotifications-test.tsx
@@ -0,0 +1,96 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
+import { addNotification, getNotifications, removeNotification } from '../../../api/notifications';
+import { withNotifications, WithNotificationsProps } from '../withNotifications';
+
+jest.mock('../../../api/notifications', () => ({
+ addNotification: jest.fn().mockResolvedValue({}),
+ getNotifications: jest.fn(() =>
+ Promise.resolve({
+ channels: ['channel1', 'channel2'],
+ globalTypes: ['type-global', 'type-common'],
+ notifications: [
+ {
+ channel: 'channel1',
+ type: 'type-global',
+ project: 'foo',
+ projectName: 'Foo',
+ organization: 'org'
+ },
+ {
+ channel: 'channel1',
+ type: 'type-common',
+ project: 'bar',
+ projectName: 'Bar',
+ organization: 'org'
+ },
+ {
+ channel: 'channel2',
+ type: 'type-common',
+ project: 'qux',
+ projectName: 'Qux',
+ organization: 'org'
+ }
+ ],
+ perProjectTypes: ['type-common']
+ })
+ ),
+ removeNotification: jest.fn().mockResolvedValue({})
+}));
+
+class X extends React.Component<WithNotificationsProps> {
+ render() {
+ return <div />;
+ }
+}
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+it('should fetch notifications and render', async () => {
+ const wrapper = shallowRender();
+ await waitAndUpdate(wrapper);
+ expect(wrapper).toMatchSnapshot();
+ expect(getNotifications).toBeCalled();
+});
+
+it('should add and remove a notification', () => {
+ const wrapper = shallowRender();
+ const notification = {
+ channel: 'EmailNotificationChannel',
+ project: 'foo',
+ type: 'SQ-MyNewIssues'
+ };
+
+ wrapper.prop('addNotification')(notification);
+ expect(addNotification).toHaveBeenCalledWith(notification);
+
+ wrapper.prop('removeNotification')(notification);
+ expect(removeNotification).toHaveBeenCalledWith(notification);
+});
+
+function shallowRender() {
+ const UnderTest = withNotifications<{}>(X);
+ return shallow(<UnderTest />);
+}
diff --git a/server/sonar-web/src/main/js/components/hoc/withNotifications.tsx b/server/sonar-web/src/main/js/components/hoc/withNotifications.tsx
new file mode 100644
index 00000000000..f327c46f38e
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/hoc/withNotifications.tsx
@@ -0,0 +1,148 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { uniqWith } from 'lodash';
+import * as React from 'react';
+import { addNotification, getNotifications, removeNotification } from '../../api/notifications';
+import { getWrappedDisplayName } from './utils';
+
+interface State {
+ channels: string[];
+ globalTypes: string[];
+ loading: boolean;
+ notifications: T.Notification[];
+ perProjectTypes: string[];
+}
+
+export interface WithNotificationsProps {
+ addNotification: (added: T.Notification) => void;
+ channels: string[];
+ globalTypes: string[];
+ loading: boolean;
+ notifications: T.Notification[];
+ perProjectTypes: string[];
+ removeNotification: (removed: T.Notification) => void;
+}
+
+export function withNotifications<P>(
+ WrappedComponent: React.ComponentType<P & WithNotificationsProps>
+) {
+ class Wrapper extends React.Component<P, State> {
+ mounted = false;
+ static displayName = getWrappedDisplayName(WrappedComponent, 'withNotifications');
+
+ state: State = {
+ channels: [],
+ globalTypes: [],
+ loading: true,
+ notifications: [],
+ perProjectTypes: []
+ };
+
+ componentDidMount() {
+ this.mounted = true;
+ this.fetchNotifications();
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ fetchNotifications = () => {
+ getNotifications().then(
+ response => {
+ if (this.mounted) {
+ 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: T.Notification) => {
+ this.setState(state => {
+ const notifications = uniqWith([...state.notifications, added], this.areNotificationsEqual);
+ return { notifications };
+ });
+ };
+
+ removeNotificationFromState = (removed: T.Notification) => {
+ this.setState(state => {
+ const notifications = state.notifications.filter(
+ notification => !this.areNotificationsEqual(notification, removed)
+ );
+ return { notifications };
+ });
+ };
+
+ addNotification = (added: T.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 };
+ addNotification(data).catch(() => {
+ this.removeNotificationFromState(added);
+ });
+ };
+
+ removeNotification = (removed: T.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 };
+ removeNotification(data).catch(() => {
+ this.addNotificationToState(removed);
+ });
+ };
+
+ areNotificationsEqual = (a: T.Notification, b: T.Notification) => {
+ return a.channel === b.channel && a.type === b.type && a.project === b.project;
+ };
+
+ render() {
+ const { channels, globalTypes, loading, notifications, perProjectTypes } = this.state;
+ return (
+ <WrappedComponent
+ {...this.props}
+ addNotification={this.addNotification}
+ channels={channels}
+ globalTypes={globalTypes}
+ loading={loading}
+ notifications={notifications}
+ perProjectTypes={perProjectTypes}
+ removeNotification={this.removeNotification}
+ />
+ );
+ }
+ }
+
+ return Wrapper;
+}