diff --git a/.circleci/config.yml b/.circleci/config.yml index e00374de94..e6867465b7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -175,13 +175,15 @@ workflows: branches: only: - develop + - feature-stats-history # This is alternate dev env for parallel testing - "build-test": context : org-global filters: branches: only: - - nav-hot-fix + - develop + - notifications # This is beta env for production soft releases - "build-prod-beta": context : org-global diff --git a/__tests__/shared/components/Header/__snapshots__/index.jsx.snap b/__tests__/shared/components/Header/__snapshots__/index.jsx.snap index bfbb29d909..16258ec770 100644 --- a/__tests__/shared/components/Header/__snapshots__/index.jsx.snap +++ b/__tests__/shared/components/Header/__snapshots__/index.jsx.snap @@ -155,6 +155,7 @@ exports[`Default render 1`] = ` }, ] } + auth={null} authURLs={ Object { "href": "https://accounts.topcoder-dev.com/member/registration?utm_source=community-app-main", @@ -162,7 +163,7 @@ exports[`Default render 1`] = ` } } loggedIn={true} - notificationButtonState="none" + notificationButtonState="new" notifications={Array []} onMenuOpen={[Function]} onSwitch={[Function]} @@ -174,7 +175,7 @@ exports[`Default render 1`] = ` "photoURL": "https://d1aahxkjiobka8.cloudfront.net/avatar/https%3A%2F%2Ftopcoder-dev-media.s3.amazonaws.com%2Fmember%2Fprofile%2Fhuanner-1552562543506.png?size=32", } } - showNotification={false} + showNotification={true} switchText={ Object { "href": "https://connect.topcoder-dev.com", diff --git a/__tests__/shared/containers/__snapshots__/TopcoderHeader.jsx.snap b/__tests__/shared/containers/__snapshots__/TopcoderHeader.jsx.snap index 1c2bdc7866..54b150a474 100644 --- a/__tests__/shared/containers/__snapshots__/TopcoderHeader.jsx.snap +++ b/__tests__/shared/containers/__snapshots__/TopcoderHeader.jsx.snap @@ -2,9 +2,20 @@ exports[`Matches shallow snapshot 1`] = `
+ + + + \ No newline at end of file diff --git a/src/shared/components/Header/index.jsx b/src/shared/components/Header/index.jsx index 4ea2d4855f..1e52ce9c90 100644 --- a/src/shared/components/Header/index.jsx +++ b/src/shared/components/Header/index.jsx @@ -16,7 +16,10 @@ try { // window is undefined } -const Header = ({ profile }) => { +const Header = ({ + profile, auth, notifications, loadNotifications, markNotificationAsRead, + markAllNotificationAsRead, markAllNotificationAsSeen, dismissChallengeNotifications, +}) => { const [activeLevel1Id, setActiveLevel1Id] = useState(); const [path, setPath] = useState(); const [openMore, setOpenMore] = useState(true); @@ -48,6 +51,17 @@ const Header = ({ profile }) => { useEffect(() => { setPath(window.location.pathname); }, []); + + /* + * Reload notificaitons if token was changed + * This prevent to use expired token in API call + */ + if (auth) { + useEffect(() => { + loadNotifications(auth.tokenV3); + }, [auth.tokenV3]); + } + if (TopNavRef) { return (
@@ -56,13 +70,19 @@ const Header = ({ profile }) => { rightMenu={( @@ -86,6 +106,7 @@ const Header = ({ profile }) => { Header.defaultProps = { profile: null, + auth: null, }; Header.propTypes = { @@ -93,6 +114,13 @@ Header.propTypes = { photoURL: PT.string, handle: PT.string, }), + auth: PT.shape(), + notifications: PT.arrayOf(PT.object).isRequired, + loadNotifications: PT.func.isRequired, + markNotificationAsRead: PT.func.isRequired, + markAllNotificationAsRead: PT.func.isRequired, + markAllNotificationAsSeen: PT.func.isRequired, + dismissChallengeNotifications: PT.func.isRequired, }; export default Header; diff --git a/src/shared/components/Notifications/TabsPanel/index.jsx b/src/shared/components/Notifications/TabsPanel/index.jsx new file mode 100644 index 0000000000..13977acada --- /dev/null +++ b/src/shared/components/Notifications/TabsPanel/index.jsx @@ -0,0 +1,102 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cn from 'classnames'; +import styles from './style.scss'; + + +const TABS = { + COMPLETED: 'completed', + BROADCAST: 'broadcast', + ACTIVE: 'active', +}; + +export default class TabsPanel extends React.Component { + constructor(props) { + super(props); + this.state = { + tab: TABS.ACTIVE, + }; + } + + + render() { + const { changeTab } = this.props; + const { tab } = this.state; + return ( +
+
+
{ + this.setState({ tab: TABS.ACTIVE }); + changeTab(TABS.ACTIVE); + } + } + onKeyPress={ + () => { + this.setState({ tab: TABS.ACTIVE }); + changeTab(TABS.ACTIVE); + } + } + >CHALLENGES +
+
{ + this.setState({ tab: TABS.BROADCAST }); + changeTab(TABS.BROADCAST); + } + } + onKeyPress={ + () => { + this.setState({ tab: TABS.BROADCAST }); + changeTab(TABS.BROADCAST); + } + } + >NOTIFICATIONS +
+ {/* + * Disabled until Backend updated (add flag completed in notifications) + * +
{ + this.setState({ tab: TABS.COMPLETED }); + changeTab(TABS.COMPLETED); + } + } + onKeyPress={ + () => { + this.setState({ tab: TABS.COMPLETED }); + changeTab(TABS.COMPLETED); + } + } + >COMPLETED CHALLENGES +
+ */} +
+ {/* + * Disabled until Settings page is ready + * +
+
Notification Settings
+
+ */} +
+ ); + } +} + + +TabsPanel.propTypes = { + changeTab: PropTypes.func.isRequired, +}; diff --git a/src/shared/components/Notifications/TabsPanel/style.scss b/src/shared/components/Notifications/TabsPanel/style.scss new file mode 100644 index 0000000000..c7cd384a5f --- /dev/null +++ b/src/shared/components/Notifications/TabsPanel/style.scss @@ -0,0 +1,62 @@ +@import "~styles/mixins"; + +.container { + display: flex; + justify-content: space-between; + width: 100%; + height: 30px; + margin-top: 50px; + margin-bottom: 20px; + + .lefts { + display: flex; + justify-content: space-between; + + .btn { + @include roboto-bold; + + color: #2a2a2a; + background-color: $tc-white; + font-size: 12px; + font-weight: 400; + text-transform: uppercase; + line-height: 30px; + text-align: center; + height: 30px; + padding: 0 15px; + cursor: pointer; + + &:not(:first-of-type) { + margin-left: 5px; + } + } + + .active { + color: #fff; + background-color: #7f7f7f; + box-shadow: inset 0 0 2px 0 rgba(0, 0, 0, 0.25); + border-radius: 15px; + } + } + + .rights { + .notification-setting { + color: #0d61bf; + + @include roboto-bold; + + font-size: 14px; + font-weight: 400; + line-height: 22px; + text-align: left; + text-decoration: underline; + cursor: pointer; + } + } +} + +@media (max-width: $screen-sm + 1px) { + .container { + margin: 15px 0; + } +} diff --git a/src/shared/components/Notifications/index.jsx b/src/shared/components/Notifications/index.jsx new file mode 100644 index 0000000000..ed0400a9ca --- /dev/null +++ b/src/shared/components/Notifications/index.jsx @@ -0,0 +1,280 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import cn from 'classnames'; +import _ from 'lodash'; +import moment from 'moment'; +import { Link } from 'topcoder-react-utils'; +import IconArrow from 'assets/images/notifications/arrow.svg'; +import styles from './style.scss'; +import TabsPanel from './TabsPanel'; + +// TODO: We change this later based on API event mapping +const eventTypes = { + PROJECT: { + ACTIVE: [ + 'challenge.notification.events', + 'notifications.autopilot.events', + ], + COMPLETED: 'challenge.notification.completed', + }, + BROADCAST: 'admin.notification.broadcast', +}; + +// Dynamic element, to select between Link and Div +const ConditionalWrapper = ({ + condition, renderLink, renderDiv, children, +}) => ( + condition ? renderLink(children) : renderDiv(children) +); + +const Item = ({ + item, auth, markNotificationAsRead, showPoint, isLink, +}) => ( + ( + !item.isRead && markNotificationAsRead(item, auth.tokenV3)} + > + {children} + + )} + renderDiv={children => ( +
+ {children} +
+ )} + > + +
+

+ {moment(item.date).fromNow()} +

+
+ { + !item.isRead + && showPoint + && ( +
{ + e.preventDefault(); + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + markNotificationAsRead(item, auth.tokenV3); + }} + onKeyPress={() => { + markNotificationAsRead(item, auth.tokenV3); + }} + tabIndex="0" + /> + )} +
+ + +); +Item.propTypes = { + item: PropTypes.shape().isRequired, + auth: PropTypes.shape().isRequired, + markNotificationAsRead: PropTypes.func.isRequired, + showPoint: PropTypes.bool.isRequired, + isLink: PropTypes.bool.isRequired, +}; + +const challenges = (listReceived) => { + const list = listReceived || []; + const challengeTitles = _.uniq( + list.map(noti => noti.sourceName).filter(x => x), + ); + const group = challengeTitles.map(title => ({ + challengeTitle: title, items: list.filter(t => t.sourceName === title), + })); + + return group; +}; + +export default class NotificationList extends React.Component { + constructor(props) { + super(props); + this.state = { + collapsedChallenges: {}, + activeTab: 'active', + }; + } + + componentWillUnmount() { + // mark all notifications as seen when go to another page + const { + markAllNotificationAsSeen, notifications, auth, + } = this.props; + const notificationsList = _.filter((notifications || []), t => !t.isSeen); + const result = _.map(notificationsList, 'id').join('-'); + if (result) { + markAllNotificationAsSeen(result, auth.tokenV3); + } + } + + changeTab = (tab) => { + this.setState({ + activeTab: tab, + }); + } + + toggleChallenge = (challengeIdx, collapsedChallenges) => { + const collapsed = collapsedChallenges || {}; + if (collapsed[challengeIdx]) { + collapsed[challengeIdx] = false; + } else { + collapsed[challengeIdx] = true; + } + this.setState({ collapsedChallenges: collapsed }); + } + + isLink = (item) => { + const ret = (eventTypes.PROJECT.ACTIVE.includes(item.eventType) + || eventTypes.PROJECT.COMPLETED.includes(item.eventType)) + && item.sourceId > 0; + return ret; + } + + render() { + const { + auth, notifications, loadNotifications, markNotificationAsRead, + dismissChallengeNotifications, + } = this.props; + const { collapsedChallenges, activeTab } = this.state; + let challengesList = []; + if (activeTab === 'active') { + challengesList = _.filter((notifications || []), + t => eventTypes.PROJECT.ACTIVE.includes(t.eventType)); + } else if (activeTab === 'completed') { + challengesList = _.filter((notifications || []), + t => eventTypes.PROJECT.COMPLETED.includes(t.eventType)); + } else { + challengesList = _.filter((notifications || []), + t => eventTypes.BROADCAST.includes(t.eventType)); + } + return ( +
+

Notifications

+
+ this.setState({ activeTab: tab })} + /> +
+ + { + challenges(challengesList).map((challenge) => { + const challegeId = challenge && challenge.items && challenge.items.length + && challenge.items[0].sourceId; + + return ( + +
+ {challenge.challengeTitle} +
+
{ + if (challegeId) { + dismissChallengeNotifications(challegeId, auth.tokenV3); + } + }} + onKeyPress={() => { + if (challegeId) { + dismissChallengeNotifications(challegeId, auth.tokenV3); + } + }} + >× +
+ + { + if (challegeId) { + this.toggleChallenge(challegeId, collapsedChallenges); + } + }} + /> +
+
+ { + (!collapsedChallenges[challegeId]) + && challenge.items.map(item => ( + + ))} +
+ ); + }) + } +
+
+
+
+ ); + } +} + +NotificationList.propTypes = { + auth: PropTypes.shape().isRequired, + /** + * Array of Notifications, each with properties: + * + * - id {number} message identifier + * - sourceId {number} identifies the associated challenge + * - sourceName {string} challenge title + * - eventType {string} indicates if challenge is active(connect.notification.project.active) + * or completed(connect.notification.project.completed) + * - date {date} when notification was raised + * - isRead {boolean} indicates if is read + * - isSeen {boolean} indicates if is seen + * - contents {string} message + * + */ + notifications: PropTypes.arrayOf(PropTypes.object).isRequired, + + loadNotifications: PropTypes.func.isRequired, + + /** + * Called with item to be marked as read. + * + * @param item {object} Item to be marked as read + */ + markNotificationAsRead: PropTypes.func.isRequired, + + /** + * Called with challenge id to be marked for dismiss. + * + * @param challengeId {number} challange to be marked for dismiss. + */ + dismissChallengeNotifications: PropTypes.func.isRequired, + + /** + * Called to be mark all notifications as seen. + * + */ + markAllNotificationAsSeen: PropTypes.func.isRequired, +}; diff --git a/src/shared/components/Notifications/style.scss b/src/shared/components/Notifications/style.scss new file mode 100644 index 0000000000..fecad3ffc1 --- /dev/null +++ b/src/shared/components/Notifications/style.scss @@ -0,0 +1,242 @@ +@import "~styles/mixins"; +$white: white; +$turquoise-dark: turquoise; +$turquoise-super-dark: turquoise; + +*:focus { + outline: none; +} + +.outer-container { + width: 1000px; + height: auto; + background-color: $tc-white; + margin: 70px auto 150px auto; + + .heading { + color: $tc-black; + + @include barlow-condensed; + + font-size: 60px; + font-weight: 500; + line-height: 58px; + text-align: left; + text-transform: uppercase; + } + + .notifications-panel { + border-radius: 10px; + display: flex; + flex-direction: column; + background-color: #fff; + min-height: 685px; + + [role="button"] { + cursor: pointer; + } + + .noti-body { + border: 1px solid #e0e0e0; + + &.center { + text-align: center; + } + + .txt { + margin: 0; + color: $tc-gray-90; + font-size: 14px; + + @include roboto-regular; + + line-height: 22px; + + &.center-txt { + text-align: center; + margin: 15px auto 25px auto; + } + + a { + color: #0d61bf; + text-decoration: underline; + cursor: pointer; + } + } + + .challenge-title { + background-color: #f4f4f4; + color: $tc-gray-90; + font-size: 14px; + + @include roboto-bold; + + font-weight: 500; + line-height: 22px; + margin-top: -2px; + padding: 5px 20px; + display: flex; + justify-content: space-between; + + .challenge-header-rights { + display: flex; + width: 45.4px; + justify-content: space-between; + + &.hide-challenge-header-rights { + display: none; + } + + .dismiss-challenge { + cursor: pointer; + color: #aaa; + + @include roboto-regular; + + font-size: 20px; + width: 10px; + height: 10px; + } + + .arrow { + margin: auto; + cursor: pointer; + + &.up { + transform: rotateZ(180deg); + } + + &.down { + transform: rotateZ(0deg); + } + } + } + } + + .right-remove { + position: absolute; + right: 1px; + top: 50%; + margin-top: -10px; + z-index: 666; + display: none; + + .btn-close { + display: block; + background-size: 10px; + width: 20px; + height: 20px; + } + + .black-txt { + position: absolute; + top: -28px; + left: -76px; + background-color: $tc-gray-90; + border-radius: 2px; + padding: 6px 0; + color: $tc-white; + font-size: 11px; + + @include roboto-regular; + + line-height: 13px; + text-align: center; + min-width: 110px; + display: none; + + &::after { + content: ""; + display: block; + position: absolute; + bottom: -5px; + left: calc(50% + 30px); + margin-left: -3px; + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid $tc-gray-90; + } + } + + &:hover { + .black-txt { + display: block; + } + } + } + + .noti-item { + background-color: $tc-white; + padding: 5px 20px; + display: flex; + justify-content: space-between; + border-bottom: 1px #e0e0e0 solid; + margin-left: 20px; + margin-right: 20px; + + .left { + display: flex; + flex-direction: column; + padding-left: 30px; + + .txt { + margin: 0; + color: $tc-gray-90; + font-size: 14px; + + @include roboto-regular; + + line-height: 22px; + } + + .time-txt { + display: inline-block; + vertical-align: middle; + color: #aaa; + font-size: 12px; + + @include roboto-regular; + + line-height: 20px; + } + } + + .right { + margin: auto 0; + + .point { + width: 10px; + height: 10px; + background-color: $tc-white; + border-radius: 100%; + display: inline-block; + vertical-align: middle; + cursor: pointer; + z-index: 10; + } + + .point-red { + background-color: $tc-red; + } + + .point-grey { + background-color: $tc-gray-10; + } + } + } + } + } +} + +@media (max-width: $screen-sm + 1px) { + .outer-container { + margin: 15px auto 50px auto; + padding: 0 10px; + + .heading { + font-size: 40px; + } + } +} diff --git a/src/shared/containers/Contentful/MenuLoader/index.jsx b/src/shared/containers/Contentful/MenuLoader/index.jsx index 8752973ff6..2132823f63 100644 --- a/src/shared/containers/Contentful/MenuLoader/index.jsx +++ b/src/shared/containers/Contentful/MenuLoader/index.jsx @@ -104,8 +104,8 @@ class MenuLoaderContainer extends React.Component { switchText={config.ACCOUNT_MENU_SWITCH_TEXT} onSwitch={this.handleSwitchMenu} onMenuOpen={this.handleCloseOpenMore} - showNotification={false} profile={normalizedProfile} + auth={auth} authURLs={config.HEADER_AUTH_URLS} /> )} diff --git a/src/shared/containers/Notifications/index.jsx b/src/shared/containers/Notifications/index.jsx new file mode 100644 index 0000000000..eb0127efc4 --- /dev/null +++ b/src/shared/containers/Notifications/index.jsx @@ -0,0 +1,47 @@ +/** + * Container for the notifications page. + */ + +import { actions } from 'topcoder-react-lib'; + +import Notifications from 'components/Notifications'; +import { connect } from 'react-redux'; + +function mapDispatchToProps(dispatch) { + return { + loadNotifications: (tokenV3) => { + dispatch(actions.notifications.getNotificationsInit()); + dispatch(actions.notifications.getNotificationsDone(tokenV3)); + }, + markNotificationAsRead: (item, tokenV3) => { + dispatch(actions.notifications.markNotificationAsReadInit()); + dispatch(actions.notifications.markNotificationAsReadDone(item, tokenV3)); + }, + markAllNotificationAsRead: (tokenV3) => { + dispatch(actions.notifications.markAllNotificationAsReadInit()); + dispatch(actions.notifications.markAllNotificationAsReadDone(tokenV3)); + }, + markAllNotificationAsSeen: (items, tokenV3) => { + dispatch(actions.notifications.markAllNotificationAsSeenInit()); + dispatch(actions.notifications.markAllNotificationAsSeenDone(items, tokenV3)); + }, + dismissChallengeNotifications: (challegeId, tokenV3) => { + dispatch(actions.notifications.dismissChallengeNotificationsInit()); + dispatch(actions.notifications.dismissChallengeNotificationsDone(challegeId, tokenV3)); + }, + }; +} + +function mapStateToProps(state) { + return { + notifications: (state.notifications + && state.notifications.items + && [...state.notifications.items]) || [], + auth: state.auth, + }; +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(Notifications); diff --git a/src/shared/containers/TopcoderHeader.js b/src/shared/containers/TopcoderHeader.js index 2f6b060f9f..537372ed51 100644 --- a/src/shared/containers/TopcoderHeader.js +++ b/src/shared/containers/TopcoderHeader.js @@ -1,20 +1,60 @@ /** * Container for the standard Topcoder header. */ +/* global location */ +/* eslint-disable no-restricted-globals */ import _ from 'lodash'; -import actions from 'actions/topcoder_header'; +import headerActions from 'actions/topcoder_header'; +import { actions } from 'topcoder-react-lib'; + import TopcoderHeader from 'components/Header'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -export default connect( - state => ({ +function mapDispatchToProps(dispatch) { + return { + ...bindActionCreators(headerActions.topcoderHeader, dispatch), + loadNotifications: (tokenV3) => { + dispatch(actions.notifications.getNotificationsInit()); + dispatch(actions.notifications.getNotificationsDone(tokenV3)); + }, + markNotificationAsRead: (item, tokenV3) => { + dispatch(actions.notifications.markNotificationAsReadInit()); + dispatch(actions.notifications.markNotificationAsReadDone(item, tokenV3)); + }, + markAllNotificationAsRead: (tokenV3) => { + dispatch(actions.notifications.markAllNotificationAsReadInit()); + dispatch(actions.notifications.markAllNotificationAsReadDone(tokenV3)); + }, + markAllNotificationAsSeen: (items, tokenV3) => { + dispatch(actions.notifications.markAllNotificationAsSeenInit()); + dispatch(actions.notifications.markAllNotificationAsSeenDone(items, tokenV3)); + }, + dismissChallengeNotifications: (challegeId, tokenV3) => { + dispatch(actions.notifications.dismissChallengeNotificationsInit()); + dispatch(actions.notifications.dismissChallengeNotificationsDone(challegeId, tokenV3)); + }, + }; +} + +function mapStateToProps(state) { + return { ...state.topcoderHeader, profile: { ...state.auth.profile, ..._.pickBy({ roles: state.auth.user ? state.auth.user.roles : undefined }), }, - }), - dispatch => bindActionCreators(actions.topcoderHeader, dispatch), + notifications: (state.notifications + && state.notifications.items + && [...state.notifications.items]) || [], + auth: { + ...state.auth, + }, + }; +} + +export default connect( + mapStateToProps, + mapDispatchToProps, )(TopcoderHeader); diff --git a/src/shared/routes/Topcoder/Notifications.jsx b/src/shared/routes/Topcoder/Notifications.jsx new file mode 100644 index 0000000000..be4b2de193 --- /dev/null +++ b/src/shared/routes/Topcoder/Notifications.jsx @@ -0,0 +1,19 @@ +import LoadingIndicator from 'components/LoadingIndicator'; +import React from 'react'; +import { AppChunk } from 'topcoder-react-utils'; + +export default function NotificationsRoute(props) { + return ( + import(/* webpackChunkName: "notifications/chunk" */'containers/Notifications') + .then(({ default: Notifications }) => ( + + )) + } + renderPlaceholder={() => } + /> + ); +} diff --git a/src/shared/routes/Topcoder/Routes.jsx b/src/shared/routes/Topcoder/Routes.jsx index 2f7da40d98..eca52ee957 100644 --- a/src/shared/routes/Topcoder/Routes.jsx +++ b/src/shared/routes/Topcoder/Routes.jsx @@ -27,6 +27,7 @@ import EDUTracks from 'containers/EDU/Tracks'; import EDUSearch from 'containers/EDU/Search'; import ChallengeListing from './ChallengeListing'; import Dashboard from './Dashboard'; +import Notifications from './Notifications'; import Settings from '../Settings'; import HallOfFame from '../HallOfFame'; import Profile from '../Profile'; @@ -61,6 +62,7 @@ export default function Topcoder() { path="/challenges/:challengeId(\d{8}|\d{5})" /> +