diff options
author | Siegfried Ehret <49895321+siegfried-ehret-sonarsource@users.noreply.github.com> | 2019-10-17 11:32:10 +0200 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2019-10-28 20:21:09 +0100 |
commit | cd2296809f5308f0cbf14100fcbff2846c3bf3da (patch) | |
tree | 9be140071398becdef60caf20b6555bba31e1ebd /server/sonar-web/src/main | |
parent | 3c93ddc45bbd9210d55a9d6b7a8802e330247640 (diff) | |
download | sonarqube-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')
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; +} |