Co-authored-by: Wouter Admiraal <wouter.admiraal@sonarsource.com>tags/8.1.0.31237
@@ -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); |
@@ -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"> |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -110,7 +110,6 @@ function shallowRender(props?: Partial<Projects['props']>) { | |||
<Projects | |||
addNotification={jest.fn()} | |||
channels={channels} | |||
initialProjectNotificationsCount={0} | |||
notifications={[]} | |||
removeNotification={jest.fn()} | |||
types={types} |
@@ -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", |
@@ -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, |
@@ -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')); |
@@ -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> | |||
); |
@@ -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> | |||
`; |
@@ -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); |
@@ -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() | |||
}) | |||
); | |||
} |
@@ -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> | |||
`; |
@@ -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]} | |||
/> | |||
`; |
@@ -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 />); | |||
} |
@@ -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; | |||
} |
@@ -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 |