Przeglądaj źródła

SONAR-10037 Manage project notifications from project dashboard

Co-authored-by: Wouter Admiraal <wouter.admiraal@sonarsource.com>
tags/8.1.0.31237
Siegfried Ehret 4 lat temu
rodzic
commit
cd2296809f
17 zmienionych plików z 996 dodań i 244 usunięć
  1. 45
    139
      server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx
  2. 2
    3
      server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx
  3. 65
    66
      server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx
  4. 0
    1
      server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx
  5. 139
    6
      server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap
  6. 1
    1
      server/sonar-web/src/main/js/apps/overview/badges/ProjectBadges.tsx
  7. 3
    3
      server/sonar-web/src/main/js/apps/overview/badges/__tests__/ProjectBadges-test.tsx
  8. 0
    0
      server/sonar-web/src/main/js/apps/overview/badges/__tests__/__snapshots__/ProjectBadges-test.tsx.snap
  9. 26
    10
      server/sonar-web/src/main/js/apps/overview/meta/MetaContainer.tsx
  10. 105
    15
      server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaContainer-test.tsx.snap
  11. 116
    0
      server/sonar-web/src/main/js/apps/overview/notifications/ProjectNotifications.tsx
  12. 90
    0
      server/sonar-web/src/main/js/apps/overview/notifications/__tests__/ProjectNotifications.tsx
  13. 108
    0
      server/sonar-web/src/main/js/apps/overview/notifications/__tests__/__snapshots__/ProjectNotifications.tsx.snap
  14. 51
    0
      server/sonar-web/src/main/js/components/hoc/__tests__/__snapshots__/withNotifications-test.tsx.snap
  15. 96
    0
      server/sonar-web/src/main/js/components/hoc/__tests__/withNotifications-test.tsx
  16. 148
    0
      server/sonar-web/src/main/js/components/hoc/withNotifications.tsx
  17. 1
    0
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 45
- 139
server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx Wyświetl plik

@@ -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);

+ 2
- 3
server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx Wyświetl plik

@@ -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">

+ 65
- 66
server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx Wyświetl plik

@@ -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}
/>
);
}

+ 0
- 1
server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx Wyświetl plik

@@ -110,7 +110,6 @@ function shallowRender(props?: Partial<Projects['props']>) {
<Projects
addNotification={jest.fn()}
channels={channels}
initialProjectNotificationsCount={0}
notifications={[]}
removeNotification={jest.fn()}
types={types}

+ 139
- 6
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap Wyświetl plik

@@ -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",

server/sonar-web/src/main/js/apps/overview/badges/BadgesModal.tsx → server/sonar-web/src/main/js/apps/overview/badges/ProjectBadges.tsx Wyświetl plik

@@ -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,

server/sonar-web/src/main/js/apps/overview/badges/__tests__/BadgesModal-test.tsx → server/sonar-web/src/main/js/apps/overview/badges/__tests__/ProjectBadges-test.tsx Wyświetl plik

@@ -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'));

server/sonar-web/src/main/js/apps/overview/badges/__tests__/__snapshots__/BadgesModal-test.tsx.snap → server/sonar-web/src/main/js/apps/overview/badges/__tests__/__snapshots__/ProjectBadges-test.tsx.snap Wyświetl plik


+ 26
- 10
server/sonar-web/src/main/js/apps/overview/meta/MetaContainer.tsx Wyświetl plik

@@ -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>
);

+ 105
- 15
server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaContainer-test.tsx.snap Wyświetl plik

@@ -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>
`;

+ 116
- 0
server/sonar-web/src/main/js/apps/overview/notifications/ProjectNotifications.tsx Wyświetl plik

@@ -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);

+ 90
- 0
server/sonar-web/src/main/js/apps/overview/notifications/__tests__/ProjectNotifications.tsx Wyświetl plik

@@ -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()
})
);
}

+ 108
- 0
server/sonar-web/src/main/js/apps/overview/notifications/__tests__/__snapshots__/ProjectNotifications.tsx.snap Wyświetl plik

@@ -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>
`;

+ 51
- 0
server/sonar-web/src/main/js/components/hoc/__tests__/__snapshots__/withNotifications-test.tsx.snap Wyświetl plik

@@ -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]}
/>
`;

+ 96
- 0
server/sonar-web/src/main/js/components/hoc/__tests__/withNotifications-test.tsx Wyświetl plik

@@ -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 />);
}

+ 148
- 0
server/sonar-web/src/main/js/components/hoc/withNotifications.tsx Wyświetl plik

@@ -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;
}

+ 1
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties Wyświetl plik

@@ -1568,6 +1568,7 @@ my_profile.sonarcloud_feature_notifications.title=SonarCloud new feature notific
my_profile.sonarcloud_feature_notifications.description=Display a notification in the header when new features are deployed
my_profile.per_project_notifications.title=Notifications per project
my_profile.per_project_notifications.add=Add a project
my_profile.per_project_notifications.edit=Set notifications

my_account.page=My Account
my_account.notifications=Notifications

Ładowanie…
Anuluj
Zapisz