diff --git a/.eslintrc b/.eslintrc index 988b7f66fb..3a39e55c74 100644 --- a/.eslintrc +++ b/.eslintrc @@ -8,16 +8,17 @@ "parser": "@babel/eslint-parser", "rules": { "react/jsx-boolean-value": ["error","always"], + "react/jsx-no-bind": "off", + "react/no-multi-comp": 0, "react/prefer-es6-class": 0, "react/prefer-stateless-function": 0, - "react/no-multi-comp": 0, + "react/react-in-jsx-scope": 0, "array-callback-return": "off", "arrow-body-style": ["warn", "as-needed"], "comma-dangle": ["warn", "never"], "import/extensions": "off", "import/no-unresolved": "off", "indent": ["warn", 2, {"SwitchCase": 1}], - "react/jsx-no-bind": "off", "max-len": [1, 120, 2, { "ignoreUrls": true, "ignoreComments": true diff --git a/app/pages/notifications/index.jsx b/app/pages/notifications/index.jsx index 282ef0cc8c..546576708a 100644 --- a/app/pages/notifications/index.jsx +++ b/app/pages/notifications/index.jsx @@ -1,12 +1,13 @@ -import PropTypes from 'prop-types'; -import React from 'react'; import counterpart from 'counterpart'; +import { shape, string } from 'prop-types'; +import { Component } from 'react'; +import { Helmet } from 'react-helmet'; import Translate from 'react-translate-component'; import talkClient from 'panoptes-client/lib/talk-client'; -import { Helmet } from 'react-helmet'; -import Loading from '../../components/loading-indicator'; -import NotificationSection from '../notifications/notification-section'; + +import NotificationSection from './notification-section'; import CollapsableSection from '../../components/collapsable-section'; +import Loading from '../../components/loading-indicator'; counterpart.registerTranslations('en', { notifications: { @@ -18,148 +19,212 @@ counterpart.registerTranslations('en', { } }); -export default class NotificationsPage extends React.Component { +export default class NotificationsPage extends Component { constructor(props) { super(props); - this.onChildChanged = this.onChildChanged.bind(this); + this.state = { - projNotifications: [], - expanded: false + error: null, + expanded: false, + loading: false, + notifications: [] }; + + this.handleExpand = this.handleExpand.bind(this); } componentDidMount() { - if (this.props.user) { - this.getProjectNotifications(); + const { project, user } = this.props; + + if (user) { + this.fetchNotifications(); } - } - componentWillReceiveProps(nextProps) { - if (nextProps.user !== null && nextProps.user !== this.props.user) { - this.getProjectNotifications(); + if (project) { + this.setState({ expanded: project.id }); } } - onChildChanged(section) { - this.setState({ expanded: section }); + componentDidUpdate(prevProps) { + const { project, user } = this.props; + + if (user && !prevProps.user) { + this.fetchNotifications(); + } + + if (project !== prevProps.project) { + this.setState({ expanded: project.id }); + } } - getProjectNotifications() { - talkClient.type('notifications').get({ page: 1, page_size: 50 }) - .then((projNotifications) => { - this.groupNotifications(projNotifications); - }) - .then(() => { - if (this.props.project) this.setState({ expanded: `project-${this.props.project.id}` }); - }) - .catch((e) => { - console.error('Unable to load notifications', e); - }); + handleExpand(sectionID) { + this.setState({ expanded: sectionID }); } - groupNotifications(notifications) { - const projectSections = []; - const projectNotifications = []; - notifications.forEach((notification) => { - if (projectSections.indexOf(notification.section) < 0) { - if (notification.section === 'zooniverse') { - projectSections.unshift(notification.section); - projectNotifications.unshift(notification); - } else { - projectSections.push(notification.section); - projectNotifications.push(notification); - } + async fetchNotifications() { + this.setState({ loading: true }); + + function requestAllNotifications() { + let notifications = []; + + function getNotifications(page) { + return talkClient + .type('notifications') + .get({ page, page_size: 50 }) + .then((response) => { + notifications = notifications.concat(response); + const meta = response[0] ? response[0].getMeta() : null; + if (meta && meta.next_page) { + return getNotifications(meta.next_page); + } + return Promise.resolve(notifications); + }) + .catch((error) => { + throw error; + }); } - }); - if (this.props.project && projectSections.indexOf(`project-${this.props.project.id}`) < 0) { - talkClient.type('notifications').get({ page: 1, page_size: 1, section: `project-${this.props.project.id}` }) - .then(([notification]) => { - if (notification) { - projectNotifications.push(notification); - this.setState({ projNotifications: projectNotifications }); - this.setState({ expanded: `project-${this.props.project.id}` }); - } - }); + return getNotifications(1); } - this.setState({ projNotifications: projectNotifications }); - } - renderNotifications() { - let notificationView; - - if (this.state.projNotifications.length > 0) { - notificationView = ( -
-
- {this.state.projNotifications.map((notification, i) => { - const opened = notification.section === this.state.expanded || this.state.projNotifications.length === 1; - return ( - - - - ); - })} -
-
- ); - } else if (this.state.projNotifications.length === 0) { - notificationView = ( -
- {' '} - -
- ); - } else { - notificationView = ; + try { + const notifications = await requestAllNotifications(); + this.setState({ notifications, loading: false }); + } catch (error) { + this.setState({ error, loading: false }); } + } - return notificationView; + groupNotifications(allNotifications) { + const notificationsMap = {}; + allNotifications.forEach((notification) => { + const { project_id: projectID, section } = notification; + const notificationSectionID = projectID || section; + if (!notificationsMap[notificationSectionID]) { + notificationsMap[notificationSectionID] = [notification]; + } + notificationsMap[notificationSectionID].push(notification); + }); + return notificationsMap; } render() { - let signedIn; - const headerStyle = this.props.project ? 'notifications-title talk-module' : 'notifications-title'; - - if (this.props.user) { - signedIn = this.renderNotifications(); - } else { - signedIn = ( -
- + const { + location, project, user + } = this.props; + const { + error, expanded, loading, notifications + } = this.state; + + let groupedNotifications = {}; + let groupedNotificationsIDs = []; + if (notifications?.length > 0) { + groupedNotifications = this.groupNotifications(notifications); + groupedNotificationsIDs = Object.keys(groupedNotifications); + if (groupedNotificationsIDs.includes('zooniverse')) { + groupedNotificationsIDs.splice(groupedNotificationsIDs.indexOf('zooniverse'), 1); + groupedNotificationsIDs.unshift('zooniverse'); + } + } + + const headerStyle = project ? 'notifications-title talk-module' : 'notifications-title'; + + let content = ''; + if (!user) { + content = ( + + ); + } else if (error) { + content = ( + {error.message} + ); + } else if (loading) { + content = ( + + ); + } else if (notifications?.length === 0) { + content = ( +
+ + {' '} +
); } return (
- +

+ {content ? ( +
+ {content} +
+ ) : ( +
+
+ {groupedNotificationsIDs.map((notificationSectionID) => { + const opened = notificationSectionID === expanded || groupedNotificationsIDs.length === 1; - {signedIn} + const slug = groupedNotifications[notificationSectionID][0].project_slug; + + const sectionNotifications = groupedNotifications[notificationSectionID] || []; + const uniqueNotifications = sectionNotifications.filter((notification, index, self) => ( + index === self.findIndex((t) => t.id === notification.id) + )); + + return ( + { + const section = expanded === notificationSectionID ? false : notificationSectionID; + this.handleExpand(section); + }} + expanded={opened} + section={notificationSectionID} + > + + + ); + })} +
+
+ )}
); } } +NotificationsPage.defaultProps = { + location: { + query: { + page: '1' + } + }, + project: null, + user: null +}; + NotificationsPage.propTypes = { - location: PropTypes.shape({ - query: PropTypes.object + location: shape({ + query: shape({ + page: string + }) }), - project: PropTypes.shape({ - id: PropTypes.string + project: shape({ + id: string }), - user: PropTypes.shape({ - display_name: PropTypes.string, - login: PropTypes.string + user: shape({ + display_name: string, + login: string }) -}; \ No newline at end of file +}; diff --git a/app/pages/notifications/index.spec.js b/app/pages/notifications/index.spec.js index 5882c4fdd0..2e742360c8 100644 --- a/app/pages/notifications/index.spec.js +++ b/app/pages/notifications/index.spec.js @@ -1,82 +1,145 @@ -// These tests are skipped until a solution can be found for cjsx imports with the coffee-script test compiler +/* eslint-disable func-names, prefer-arrow-callback, react/jsx-filename-extension */ import React from 'react'; import assert from 'assert'; -import Notifications from './index'; import { mount, shallow } from 'enzyme'; import sinon from 'sinon'; +import talkClient from 'panoptes-client/lib/talk-client'; + +import Loading from '../../components/loading-indicator'; +import NotificationsPage from './index'; + const testNotifications = [ - { id: '123', - section: 'project-4321' + { + id: '123', + project_id: '4321', + section: 'project-4321', + getMeta: () => ({ next_page: null }) }, - { id: '124', - section: 'project-1234' + { + id: '124', + project_id: '1234', + section: 'project-1234', + getMeta: () => ({ next_page: null }) }, - { id: '125', - section: 'zooniverse' + { + id: '125', + project_id: '', + section: 'zooniverse', + getMeta: () => ({ next_page: null }) }, - { id: '126', - section: 'project-4321' + { + id: '126', + project_id: '4321', + section: 'project-4321', + getMeta: () => ({ next_page: null }) } ]; -describe('Notifications', function() { - let wrapper; - let notifications; +describe('NotificationsPage', function () { + describe('without a user', function () { + let wrapper; - describe('it will display according to user', function() { - it('will ask user to sign in', function() { - wrapper = mount(); - assert.equal(wrapper.find('.talk-module').text(), 'You\'re not signed in.'); + before(function () { + wrapper = mount(); }); - it('will notify when no notifications present', function() { - const stub = sinon.stub(Notifications.prototype, 'componentDidMount'); - wrapper = mount(); - assert(wrapper.contains(You have no notifications.)); - stub.restore(); + it('should render not signed in message', function () { + assert.equal(wrapper.find('.centering').text(), 'You\'re not signed in.'); }); }); - describe('it correctly display projects', function() { - beforeEach(function () { - wrapper = shallow( - , - { disableLifecycleMethods: true } - ); - wrapper.instance().groupNotifications(testNotifications); - notifications = shallow(wrapper.instance().renderNotifications()); - }); + describe('with a user', function () { + describe('with an error', function () { + let wrapper; + const testError = new Error('Test error'); - it('will place zooniverse section first', function() { - assert.equal(notifications.find('.list').childAt(0).prop('section'), 'zooniverse'); - }); + before(function () { + sinon.stub(talkClient, 'request').callsFake(() => Promise.reject(testError)); + + wrapper = mount(); + }); - it('will display correct number of sections', function() { - assert.equal(notifications.find('.list').children().length, 3); + after(function () { + talkClient.request.restore(); + }); + + it('should render an error message', function () { + assert.equal(wrapper.find('.centering').text(), 'Test error'); + }); }); - }); - describe('will open sections correctly', function() { - beforeEach(function () { - wrapper = shallow( - , - { disableLifecycleMethods: true } - ); - wrapper.setState({ expanded: 'project-1234' }); - wrapper.instance().groupNotifications(testNotifications); - notifications = shallow(wrapper.instance().renderNotifications()); + describe('while loading', function () { + let wrapper; + + before(function () { + sinon.stub(talkClient, 'request').callsFake(() => Promise.resolve(null)); + + wrapper = mount(); + }); + + after(function () { + talkClient.request.restore(); + }); + + it('should render a loading message', function () { + assert.equal(wrapper.find(Loading).length, 1); + }); }); - it('will open the active project', function() { - const activeProject = notifications.find('CollapsableSection').filterWhere(n => n.prop('section') === 'project-1234'); - assert.equal(activeProject.prop('expanded'), true); + describe('without notifications', function () { + let wrapper; + + before(function () { + sinon.stub(talkClient, 'request').callsFake(() => Promise.resolve([])); + + wrapper = mount(); + }); + + after(function () { + talkClient.request.restore(); + }); + + it('should render a no notifications message', function () { + assert.equal(wrapper.find('.centering').text(), 'You have no notifications. You can receive notifications by participating in Talk, following discussions, and receiving messages.'); + }); }); - it('will keep other projects closed', function() { - const activeProject = notifications.find('CollapsableSection').filterWhere(n => n.prop('section') === 'project-4321'); - assert.equal(activeProject.prop('expanded'), false); + describe('with notifications', function () { + let wrapper; + + before(function () { + wrapper = shallow( + , + { disableLifecycleMethods: true } + ); + wrapper.setState({ + expanded: '1234', + notifications: testNotifications + }); + }); + + it('will display correct number of sections', function () { + assert.equal(wrapper.find('CollapsableSection').length, 3); + }); + + it('will place Zooniverse section first', function () { + assert.equal(wrapper.find('CollapsableSection').at(0).prop('section'), 'zooniverse'); + }); + + it('will open the active project', function () { + assert.equal(wrapper.find('CollapsableSection').at(1).prop('expanded'), true); + assert.equal(wrapper.find('CollapsableSection').at(1).prop('section'), '1234'); + }); + + it('will keep other projects closed', function () { + assert.equal(wrapper.find('CollapsableSection').at(0).prop('expanded'), false); + assert.equal(wrapper.find('CollapsableSection').at(2).prop('expanded'), false); + }); }); }); }); diff --git a/app/pages/notifications/notification-section.jsx b/app/pages/notifications/notification-section.jsx index d5b320c54b..11d06174b5 100644 --- a/app/pages/notifications/notification-section.jsx +++ b/app/pages/notifications/notification-section.jsx @@ -1,223 +1,222 @@ import PropTypes from 'prop-types'; -import React, { Component } from 'react'; +import { Component } from 'react'; import apiClient from 'panoptes-client/lib/api-client'; import talkClient from 'panoptes-client/lib/talk-client'; import { Link } from 'react-router'; + import Notification from './notification.cjsx'; -import Paginator from '../../talk/lib/paginator.cjsx'; -import ZooniverseLogo from '../../partials/zooniverse-logo.cjsx'; -import getNotificationData from '../../lib/get-notification-data'; import Loading from '../../components/loading-indicator'; +import getNotificationData from '../../lib/get-notification-data'; +import ZooniverseLogo from '../../partials/zooniverse-logo.cjsx'; +import Paginator from '../../talk/lib/paginator.cjsx'; export default class NotificationSection extends Component { constructor(props) { super(props); this.state = { - currentMeta: { }, error: null, - firstMeta: { }, - lastMeta: { }, loading: false, + name: '', notificationData: [], - notificationsMap: { }, - page: 1, project: null }; this.markAllRead = this.markAllRead.bind(this); this.markAsRead = this.markAsRead.bind(this); } - componentWillMount() { - if (this.props.section === 'zooniverse') { - this.setState({ - name: 'Zooniverse' - }); + componentDidMount() { + const { + expanded, + location, + notifications, + section + } = this.props; + + if (section === 'zooniverse') { + this.setState({ name: 'Zooniverse' }); } else { - apiClient.type('projects').get(this.props.projectID, { include: 'avatar' }) - .catch((error) => { - this.setState({ error }); - }) - .then((project) => { - this.setState({ project }) - if (project.links.avatar) { - apiClient.type('avatars').get(project.links.avatar.id) - .then((avatar) => { - this.setState({ - name: project.display_name, - avatar: avatar.src - }); - }) - .catch(() => { - this.setState({ - name: project.display_name - }); - }); - } else { - this.setState({ - name: project.display_name - }); - } - }); + this.fetchProjectData(section); } - } - componentDidMount() { - this.getUnreadCount(); + const page = parseInt(location.query.page, 10) || 1; + + if (expanded) { + this.fetchNotificationData(notifications, page); + } } - componentWillReceiveProps(nextProps) { - const pageChanged = nextProps.location.query.page !== this.state.page; - const userChanged = nextProps.user && nextProps.user !== this.props.user; - if ((pageChanged || userChanged) && nextProps.expanded) { - this.getNotifications(nextProps.location.query.page); + componentDidUpdate(prevProps) { + const { + expanded, + location, + notifications, + section + } = this.props; + + if (section !== prevProps.section) { + if (section === 'zooniverse') { + this.setState({ name: 'Zooniverse' }); + } else { + this.fetchProjectData(section); + } + } + + const page = parseInt(location.query.page, 10) || 1; + const prevPage = parseInt(prevProps.location.query.page, 10) || 1; + + if (expanded && !prevProps.expanded) { + this.fetchNotificationData(notifications, page); + } + + if (page !== prevPage) { + this.fetchNotificationData(notifications, page); } } - getNotifications(page) { + fetchNotificationData(notifications, page = 1) { this.setState({ loading: true }); - let firstMeta; - let lastMeta; - return talkClient.type('notifications').get({ page, page_size: 5, section: this.props.section }) - .then((newNotifications) => { - const meta = newNotifications[0] ? newNotifications[0].getMeta() : { }; - const notificationsMap = this.state.notificationsMap; - firstMeta = this.state.firstMeta; - lastMeta = this.state.lastMeta; - - meta.notificationIds = []; - newNotifications.forEach((notification, i) => { - notificationsMap[notification.id] = notification; - meta.notificationIds.push(newNotifications[i].id); - }); - - if (meta.page > this.state.lastMeta.page) { - lastMeta = meta; - } else if (meta.page < this.state.firstMeta.page) { - firstMeta = meta; - } else { - firstMeta = lastMeta = meta; - } - getNotificationData(newNotifications).then((notificationData) => { - this.setState({ notificationData, loading: false }); - }); - - this.setState({ - notifications: newNotifications, - currentMeta: meta, - firstMeta, - lastMeta, - notificationsMap, - page - }); - this.getUnreadCount(); + const activeNotifications = notifications.slice((page - 1) * 5, page * 5); + + getNotificationData(activeNotifications) + .then((notificationData) => { + this.setState({ notificationData, loading: false }); }); } - getUnreadCount() { - return talkClient.type('notifications').get({ page: 1, page_size: 1, delivered: false, section: this.props.section }) - .catch((error) => { - this.setState({ error }); - }) - .then(([project]) => { - if (project) { - const count = project.getMeta().count || 0; - this.setState({ unread: count }); - } else { - this.setState({ unread: 0 }); - } - }); + fetchProjectData(projectID) { + apiClient.type('projects') + .get(projectID, { include: 'avatar' }) + .catch((error) => { + this.setState({ error }); + }) + .then((project) => { + this.setState({ project }); + if (project.links.avatar) { + apiClient.type('avatars') + .get(project.links.avatar.id) + .then((avatar) => { + this.setState({ + name: project.display_name, + avatar: avatar.src + }); + }) + .catch(() => { + this.setState({ + name: project.display_name + }); + }); + } else { + this.setState({ + name: project.display_name + }); + } + }); } markAllRead() { - this.state.notificationData.forEach((data) => { + const { notificationsCounter } = this.context; + const { section, toggleSection } = this.props; + const { notificationData } = this.state; + + const requestSection = section === 'zooniverse' ? 'zooniverse' : `project-${section}`; + + notificationData.forEach((data) => { data.notification.update({ delivered: true }); }); - this.setState({ unread: 0 }); - return talkClient.put('/notifications/read', { section: this.props.section }).then(() => { - return talkClient.type('notifications').get({ page_size: 1, delivered: false }).then(([notification]) => { - const count = notification ? notification.getMeta().count : 0; - if (count === 0) this.context.notificationsCounter.setUnread(0); - }); - }); + return talkClient + .put('/notifications/read', { section: requestSection }) + .then(() => talkClient + .type('notifications') + .get({ page_size: 1, delivered: false }) + .then(([notification]) => { + const count = notification ? notification.getMeta().count : 0; + if (count === 0) notificationsCounter.setUnread(0); + toggleSection(false); + })); } markAsRead(readNotification) { const { notificationsCounter } = this.context; - const { user } = this.props; - const { notifications } = this.state; - const relatedNotifications = notifications.filter(notification => ( + const { notifications, user } = this.props; + const relatedNotifications = notifications.filter((notification) => ( notification.delivered === false && (notification.id === readNotification.id || notification.source.discussion_id === readNotification.source.discussion_id) )); const notificationPromises = relatedNotifications - .map((relatedNotification) => { - return relatedNotification - .update({ delivered: true }) - .save() - .catch((error) => { - console.warn(error); - return {}; - }); - }); + .map((relatedNotification) => relatedNotification + .update({ delivered: true }) + .save() + .catch((error) => { + console.warn(error); + return {}; + })); Promise.all(notificationPromises).then(() => notificationsCounter.update(user)); } - avatarFor() { - const src = this.state.avatar || '/assets/simple-avatar.jpg'; - let avatar; + avatarFor(unread) { + const { slug } = this.props; + const { avatar, name } = this.state; - if (this.state.unread > 0) return this.unreadCircle(); + const src = avatar || '/assets/simple-avatar.jpg'; + let sectionAvatar; - if (this.state.name === 'Zooniverse') { - avatar = ; + if (unread > 0) return this.unreadCircle(unread); + + if (name === 'Zooniverse') { + sectionAvatar = ; } else { - avatar = Project Avatar; + sectionAvatar = Project Avatar; } return ( - - {avatar} + + {sectionAvatar} ); } - unreadCircle() { + unreadCircle(unread) { let centerNum = '40%'; - if (this.state.unread > 99) { + if (unread > 99) { centerNum = '20%'; - } else if (this.state.unread > 9) { + } else if (unread > 9) { centerNum = '30%'; } - const unreadNotifications = this.state.unread > 99 ? '99+' : this.state.unread; + const unreadNotifications = unread > 99 ? '99+' : unread; return ( - {`${this.state.unread} Unread Notification(s)`} + + {`${unread} Unread Notification(s)`} + {unreadNotifications} ); } - renderHeader() { - const buttonType = this.props.expanded ? 'fa fa-chevron-up fa-lg' : 'fa fa-chevron-down fa-lg'; + renderHeader(unread) { + const { expanded, toggleSection } = this.props; + const { name } = this.state; + + const buttonType = expanded ? 'fa fa-chevron-up fa-lg' : 'fa fa-chevron-down fa-lg'; return ( -
+
- {this.avatarFor()} + {this.avatarFor(unread)}
-

{this.state.name}

+

{name}

-
@@ -226,71 +225,95 @@ export default class NotificationSection extends Component { ); } - renderError(item) { - return ( -
- {item.error} -
- ); - } - render() { - const l = this.state.currentMeta; - const firstNotification = (l.page * l.page_size) - (l.page_size - 1) || 0; - const lastNotification = Math.min(l.page_size * l.page, l.count) || 0; + const { + expanded, location, notifications, user + } = this.props; + const { + error, loading, notificationData, project + } = this.state; + + const unread = notifications.filter((notification) => notification.delivered === false).length; + + const page = parseInt(location.query.page, 10) || 1; + const pageCount = Math.ceil(notifications.length / 5); + const firstNotification = (page - 1) * 5 + 1; + const lastNotification = Math.min(page * 5, notifications.length); return (
- {this.renderHeader()} + {this.renderHeader(unread)} - {!!this.state.error && ( + {!!error && (
- {this.state.error.toString()} + {error.toString()}
)} - {this.state.loading && ( + {loading && ( )} - {(this.props.expanded && this.state.unread > 0) && ( - )} - {(this.props.expanded && !this.state.loading) && ( - this.state.notificationData.map((item) => { + {(expanded && !loading) && ( + notificationData.map((item) => { if (item.notification) { return ( ); + project={project} + user={user} + /> + ); } - return this.renderError(item); + return ( +
+ {item.error} +
+ ); }) )} - {(this.props.expanded) && ( + {(expanded) && (
older } - page={+this.state.currentMeta.page} - pageCount={this.state.lastMeta.page_count} + itemCount={true} + nextLabel={( + + {' older'} + + + )} + page={page} + pageCount={pageCount} pageSelector={false} - previousLabel={ previous} - totalItems={{firstNotification} - {lastNotification} of {l.count}} + previousLabel={( + + + {' previous'} + + )} + totalItems={( + + {`${firstNotification} - ${lastNotification} of ${notifications.length}`} + + )} />
)} @@ -302,21 +325,38 @@ export default class NotificationSection extends Component { NotificationSection.propTypes = { expanded: PropTypes.bool, - projectID: PropTypes.string, + location: PropTypes.shape({ + query: PropTypes.shape({ + page: PropTypes.string + }) + }), + notifications: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string + })), section: PropTypes.string, slug: PropTypes.string, toggleSection: PropTypes.func, user: PropTypes.shape({ - display_name: PropTypes.string, - login: PropTypes.string + id: PropTypes.string }) }; NotificationSection.contextTypes = { - notificationsCounter: PropTypes.object + notificationsCounter: PropTypes.shape({ + setUnread: PropTypes.func + }) }; NotificationSection.defaultProps = { expanded: false, - toggleSection: () => {} + location: { + query: { + page: '1' + } + }, + notifications: [], + section: '', + slug: '', + toggleSection: () => {}, + user: null }; diff --git a/app/pages/notifications/notification-section.spec.js b/app/pages/notifications/notification-section.spec.js index ad815ccbb6..8e934fe37a 100644 --- a/app/pages/notifications/notification-section.spec.js +++ b/app/pages/notifications/notification-section.spec.js @@ -1,45 +1,98 @@ +/* eslint-disable func-names, prefer-arrow-callback, react/jsx-filename-extension */ + import React from 'react'; import assert from 'assert'; import sinon from 'sinon'; import { shallow } from 'enzyme'; +import talkClient from 'panoptes-client/lib/talk-client'; + import NotificationSection from './notification-section'; -const notifications = [ - { notification: { - id: '123', - source_type: 'DataRequest', - url: '/', - message: 'test message', - created_at: '2016-12-09T16:09:50.641Z', - }, - data: { projectName: 'TestingProject' }, +const notificationComment = { + id: '123', + source_type: 'Comment', + delivered: true, + source: { + discussion_id: '789' }, - { notification: { - id: '124', - source_type: 'DataRequest', - url: '/', - message: 'test message', - created_at: '2016-12-10T16:09:50.641Z', + update: sinon.stub().returnsThis(), + save: sinon.stub().resolves({}) +}; + +const notificationDataRequest = { + id: '124', + source_type: 'DataRequest', + delivered: true, + update: sinon.stub().returnsThis(), + save: sinon.stub().resolves({}) +}; + +const notificationModeration = { + id: '126', + source_type: 'Moderation', + delivered: true, + update: sinon.stub().returnsThis(), + save: sinon.stub().resolves({}) +}; + +const notificationCommentUnread1 = { + id: '127', + source_type: 'Comment', + delivered: false, + source: { + discussion_id: '790' }, - data: { projectName: 'TestingProject' }, + update: sinon.stub().returnsThis(), + save: sinon.stub().resolves({}) +}; + +const notificationCommentUnread2 = { + id: '128', + source_type: 'Comment', + delivered: false, + source: { + discussion_id: '790' }, + update: sinon.stub().returnsThis(), + save: sinon.stub().resolves({}) +}; + +const notifications = [ + notificationComment, + notificationDataRequest, + notificationModeration ]; -describe('Notification Section', function() { - let wrapper; +const notificationsWithUnread = notifications.concat([notificationCommentUnread1, notificationCommentUnread2]); - before(function () { - sinon.stub(NotificationSection.prototype, 'getUnreadCount').callsFake(() => null); - }); +function notificationDataItem(notification) { + const dataType = notification.source_type.toLowerCase(); + return { + notification, + data: { + [dataType]: { + id: notification.id.split('').reverse().join('') + } + } + }; +} - after(function () { - NotificationSection.prototype.getUnreadCount.restore(); - }); +const notificationData = notifications.map((notification) => notificationDataItem(notification)); + +const notificationDataUnread = notificationsWithUnread.map((notification) => notificationDataItem(notification)); +describe('NotificationSection', function () { describe('it can display a Zooniverse section', function () { - beforeEach(function () { + let wrapper; + + before(function () { wrapper = shallow( - , + ); }); @@ -47,7 +100,7 @@ describe('Notification Section', function() { assert.equal(wrapper.find('.notification-section__title').text(), 'Zooniverse'); }); - it('should link to the home page', function () { + it('should link to the Zooniverse home page', function () { assert.equal(wrapper.find('Link').prop('to'), '/'); }); @@ -56,37 +109,76 @@ describe('Notification Section', function() { }); }); - describe('it correctly displays a project', function () { + describe('it can display a project section', function () { + let wrapper; + before(function () { - sinon.stub(NotificationSection.prototype, 'componentWillMount').callsFake(() => null); - wrapper = shallow(); - wrapper.setState({ name: 'Testing' }); + wrapper = shallow( + , + { disableLifecycleMethods: true } + ); + wrapper.setState({ + avatar: '/project/avatar/url', + name: 'Test Project' + }); }); - after(function () { - NotificationSection.prototype.componentWillMount.restore(); + it('should display the correct title', function () { + assert.equal(wrapper.find('.notification-section__title').text(), 'Test Project'); }); + }); - it('should display the correct title', function () { - assert.equal(wrapper.find('.notification-section__title').text(), 'Testing'); + describe('it can display a section with unread notifications', function () { + let wrapper; + + before(function () { + wrapper = shallow( + + ); + + wrapper.setState({ + loading: false, + name: 'Zooniverse' + }); + }); + + it('should show an unread notification circle in place of an avatar', function () { + assert.equal(wrapper.find('circle').length, 1); }); }); describe('will render appropriately when open', function () { - beforeEach(function () { + let wrapper; + + before(function () { wrapper = shallow( - , + , + { disableLifecycleMethods: true } ); - wrapper.setState({ notificationData: notifications }); + wrapper.setState({ + loading: false, + name: 'Zooniverse', + notificationData + }); }); it('should display the correct number of notifications', function () { - assert.equal(wrapper.find('Notification').length, 2); - }); - - it('should show an unread notification in place of an avatar', function () { - wrapper.setState({ unread: 1 }); - assert.equal(wrapper.find('circle').length, 1); + assert.equal(wrapper.find('Notification').length, 3); }); it('should show close icon', function () { @@ -94,76 +186,110 @@ describe('Notification Section', function() { }); }); - describe('will update notifications as read', function () { - const newNotifications = [ - { - id: '123', - delivered: false, - source: { - discussion_id: '456' - }, - source_type: 'Comment', - update: sinon.stub().returnsThis(), - save: sinon.stub().resolves({}) - }, - { - id: '124', - delivered: false, - source: { - discussion_id: '456' - }, - source_type: 'Comment', - update: sinon.stub().returnsThis(), - save: sinon.stub().rejects() - }, - { - id: '125', - delivered: false, - source_type: 'Moderation', - source: {}, - update: sinon.stub().returnsThis(), - save: sinon.stub().resolves({}) - }, - { - id: '126', - delivered: false, - source: { - discussion_id: '789' - }, - source_type: 'Comment', - update: sinon.stub().returnsThis(), - save: sinon.stub().resolves({}) - } - ]; - const notificationsCounter = { + describe('will update notification as read', function () { + let wrapper; + + const notificationsCounterStub = { update: sinon.stub() }; - wrapper = shallow( - , - { context: { notificationsCounter }, disableLifeCycleMethods: true } - ); - wrapper.setState({ notifications: newNotifications }); - wrapper.instance().markAsRead(newNotifications[0]); - it('should update read notification as read (delivered)', function () { - assert.equal(newNotifications[0].update.calledWith({ delivered: true }), true); - assert.equal(newNotifications[0].save.called, true); + before(function () { + wrapper = shallow( + , + { + context: { + notificationsCounter: notificationsCounterStub + }, + disableLifecycleMethods: true + } + ); + wrapper.setState({ + loading: false, + name: 'Zooniverse', + notificationData: notificationDataUnread + }); + wrapper.instance().markAsRead(notificationDataUnread[4].notification); + }); + + it('should update unread notification as read (delivered)', function () { + assert.equal(notificationCommentUnread2.update.calledWith({ delivered: true }), true); + assert.equal(notificationCommentUnread2.save.called, true); }); - it('should update related notifications as read (delivered)', function () { - assert.equal(newNotifications[1].update.calledWith({ delivered: true }), true); - assert.equal(newNotifications[1].save.called, true); + it('should update related unread notifications as read (delivered)', function () { + assert.equal(notificationCommentUnread1.update.calledWith({ delivered: true }), true); + assert.equal(notificationCommentUnread1.save.called, true); }); it('should not update unrelated notifications', function () { - assert.equal(newNotifications[2].update.called, false); - assert.equal(newNotifications[2].save.called, false); - assert.equal(newNotifications[3].update.called, false); - assert.equal(newNotifications[3].save.called, false); + assert.equal(notificationComment.update.called, false); + assert.equal(notificationDataRequest.update.called, false); + assert.equal(notificationModeration.update.called, false); + }); + + it('should update the notifications counter', function () { + assert.equal(notificationsCounterStub.update.called, true); + }); + }); + + describe('will update all notifications as read', function () { + let wrapper; + let toggleSectionSpy; + + const notificationsCounterStub = { + setUnread: sinon.stub() + }; + + before(function () { + sinon.stub(talkClient, 'request').callsFake(() => Promise.resolve([])); + toggleSectionSpy = sinon.spy(); + + wrapper = shallow( + , + { + context: { + notificationsCounter: notificationsCounterStub + }, + disableLifecycleMethods: true + } + ); + wrapper.setState({ + loading: false, + name: 'Zooniverse', + notificationData: notificationDataUnread + }); + wrapper.instance().markAllRead(); + }); + + after(function () { + talkClient.request.restore(); + }); + + it('should update all notifications as read (delivered)', function () { + assert.equal(notificationComment.update.calledWith({ delivered: true }), true); + assert.equal(notificationDataRequest.update.calledWith({ delivered: true }), true); + assert.equal(notificationModeration.update.calledWith({ delivered: true }), true); + assert.equal(notificationCommentUnread1.update.calledWith({ delivered: true }), true); + assert.equal(notificationCommentUnread2.update.calledWith({ delivered: true }), true); + }); + + it('should call toggleSection', function () { + assert.equal(toggleSectionSpy.calledWith(false), true); }); it('should update the notifications counter', function () { - assert.equal(notificationsCounter.update.called, true); + assert.equal(notificationsCounterStub.setUnread.calledWith(0), true); }); }); });