diff --git a/config/dev.js b/config/dev.js index 68dd4ca..c0320e9 100644 --- a/config/dev.js +++ b/config/dev.js @@ -5,6 +5,7 @@ module.exports = { TC_NOTIFICATION_URL: "https://api.topcoder-dev.com/v5/notifications", CONNECT_DOMAIN: "https://connect.topcoder-dev.com", COMMUNITY_DOMAIN: "https://www.topcoder-dev.com", + TAAS_APP: "https://platform.topcoder-dev.com/taas/myteams", }, API: { V3: "https://api.topcoder-dev.com/v3", diff --git a/config/prod.js b/config/prod.js index 8054083..ee7b020 100644 --- a/config/prod.js +++ b/config/prod.js @@ -5,6 +5,7 @@ module.exports = { TC_NOTIFICATION_URL: "https://api.topcoder.com/v5/notifications", CONNECT_DOMAIN: "https://connect.topcoder.com", COMMUNITY_DOMAIN: "https://www.topcoder.com", + TAAS_APP: "https://platform.topcoder.com/taas/myteams", }, API: { V3: "https://api.topcoder.com/v3", diff --git a/jest.config.js b/jest.config.js index add373e..0b2e6e7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,7 +6,7 @@ module.exports = { transformIgnorePatterns: ["node_modules/?!(tc-auth-lib)"], moduleNameMapper: { "\\.(css|scss)$": "identity-obj-proxy", - "\\.svg$": "/__mocks__/fileMock.js", + "\\.(png|eot|otf|ttf|woff|woff2|svg)$": "/__mocks__/fileMock.js", }, setupFilesAfterEnv: [ "../node_modules/@testing-library/jest-dom/dist/index.js", diff --git a/server.js b/server.js index e3e2927..1e44b08 100644 --- a/server.js +++ b/server.js @@ -1,5 +1,5 @@ /* global process */ -const express = require('express'); +const express = require("express"); const app = express(); @@ -7,11 +7,11 @@ app.use( "/navbar", express.static("./dist", { setHeaders: function setHeaders(res) { - res.header('Access-Control-Allow-Origin', '*'); - res.header('Access-Control-Allow-Methods', 'GET'); + res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Methods", "GET"); res.header( - 'Access-Control-Allow-Headers', - 'Origin, X-Requested-With, Content-Type, Accept' + "Access-Control-Allow-Headers", + "Origin, X-Requested-With, Content-Type, Accept" ); }, }) diff --git a/src/actions/notifications.js b/src/actions/notifications.js index ca1bab3..073d4da 100644 --- a/src/actions/notifications.js +++ b/src/actions/notifications.js @@ -19,7 +19,6 @@ import { NOTIFICATIONS_PENDING, SET_NOTIFICATION_PLATFORM, RESET_NOTIFICATIONS, - RESET_COMMUNITY_NOTIFICATIONS, } from "../constants/notifications"; import notificationsService from "../services/notifications"; import { @@ -83,6 +82,25 @@ export const getNotifications = () => (dispatch) => { }); }; +export const getTaaSNotifications = () => (dispatch) => { + dispatch({ type: GET_NOTIFICATIONS_PENDING }); + notificationsService + .getTaaSNotifications() + .then((notifications) => { + dispatch({ + type: GET_NOTIFICATIONS_SUCCESS, + payload: notifications, + }); + }) + .catch((err) => { + dispatch({ + type: GET_NOTIFICATIONS_FAILURE, + payload: err, + }); + console.error(`Failed to load notifications. ${err.message}`); + }); +}; + export const getCommunityNotifications = () => (dispatch) => { dispatch({ type: GET_COMMUNITY_NOTIFICATIONS_PENDING }); notificationsService @@ -243,10 +261,6 @@ export const resetNotifications = () => (dispatch) => { dispatch({ type: RESET_NOTIFICATIONS }); }; -export const resetCommunityNotifications = () => (dispatch) => { - dispatch({ type: RESET_COMMUNITY_NOTIFICATIONS }); -}; - export default { getNotifications, getCommunityNotifications, @@ -262,5 +276,4 @@ export default { markNotificationsRead, setNotificationPlatform, resetNotifications, - resetCommunityNotifications, }; diff --git a/src/components/Menu/index.jsx b/src/components/Menu/index.jsx index 408758c..c941bb0 100644 --- a/src/components/Menu/index.jsx +++ b/src/components/Menu/index.jsx @@ -3,18 +3,18 @@ * * General component to show menu with submenu. */ -import React, { Fragment, useCallback, useState } from 'react'; -import { useLocation } from '@reach/router'; -import cn from 'classnames'; -import { includes, map } from 'lodash'; -import NavLink from '../NavLink'; -import './styles.css'; +import React, { Fragment, useCallback, useState } from "react"; +import { useLocation } from "@reach/router"; +import cn from "classnames"; +import { includes, map } from "lodash"; +import NavLink from "../NavLink"; +import "./styles.css"; const SubMenu = ({ option }) => { const location = useLocation(); const [isOpen, setIsOpen] = useState( - includes(map(option.children, 'path'), location.pathname) + includes(map(option.children, "path"), location.pathname) ); const toggleOpen = useCallback(() => { @@ -24,8 +24,8 @@ const SubMenu = ({ option }) => { return ( <> (
diff --git a/src/constants/apps.js b/src/constants/apps.js index a5b3c37..b941000 100644 --- a/src/constants/apps.js +++ b/src/constants/apps.js @@ -1,13 +1,13 @@ /** * Config for the All Apps menu. */ -import appDocumentationIcon from '../assets/images/learn.svg'; -import appTaasIcon from '../assets/images/integrations.svg'; -import appTaasAdminIcon from '../assets/images/taas-admin.png'; -import myteamsIcon from '../assets/images/my-teams.svg'; -import myteamsGreenIcon from '../assets/images/my-teams-green.svg'; -import createTeamIcon from '../assets/images/create-team.svg'; -import createTeamGreenIcon from '../assets/images/create-team-green.svg'; +import appDocumentationIcon from "../assets/images/learn.svg"; +import appTaasIcon from "../assets/images/integrations.svg"; +import appTaasAdminIcon from "../assets/images/taas-admin.png"; +import myteamsIcon from "../assets/images/my-teams.svg"; +import myteamsGreenIcon from "../assets/images/my-teams-green.svg"; +import createTeamIcon from "../assets/images/create-team.svg"; +import createTeamGreenIcon from "../assets/images/create-team-green.svg"; import earnIcon from "../assets/images/earn.svg"; /** @@ -15,23 +15,23 @@ import earnIcon from "../assets/images/earn.svg"; */ export const APP_CATEGORIES = [ { - category: 'Manage', + category: "Manage", apps: [ { - title: 'TaaS', + title: "TaaS", icon: appTaasIcon, - path: '/taas', + path: "/taas", menu: [ { - title: 'My Teams', - path: '/taas/myteams', + title: "My Teams", + path: "/taas/myteams", icon: myteamsIcon, activeIcon: myteamsGreenIcon, isExact: false, }, { - title: 'Create New Team', - path: '/taas/createnewteam', + title: "Create New Team", + path: "/taas/createnewteam", icon: createTeamIcon, activeIcon: createTeamGreenIcon, isExact: false, @@ -39,14 +39,14 @@ export const APP_CATEGORIES = [ ], }, { - title: 'TaaS Admin', + title: "TaaS Admin", icon: appTaasAdminIcon, - path: '/taas-admin', + path: "/taas-admin", menu: [], - roles: ["bookingmanager","administrator"], + roles: ["bookingmanager", "administrator"], }, { - title: 'Documentation', + title: "Documentation", icon: appDocumentationIcon, path: "/model", menu: [], @@ -57,7 +57,7 @@ export const APP_CATEGORIES = [ path: "/community-admin", menu: [], roles: ["Community Admin"], - } + }, ], }, { diff --git a/src/constants/notifications.js b/src/constants/notifications.js index 4b39ebd..ade9978 100644 --- a/src/constants/notifications.js +++ b/src/constants/notifications.js @@ -23,7 +23,6 @@ export const NOTIFICATIONS_PENDING = "NOTIFICATIONS_PENDING"; export const MARK_NOTIFICATIONS_READ = "MARK_NOTIFICATIONS_READ"; export const SET_NOTIFICATION_PLATFORM = "SET_NOTIFICATION_PLATFORM"; export const RESET_NOTIFICATIONS = "RESET_NOTIFICATIONS"; -export const RESET_COMMUNITY_NOTIFICATIONS = "RESET_COMMUNITY_NOTIFICATIONS"; /* * Project member role @@ -76,7 +75,7 @@ export const NOTIFICATIONS_LIMIT = 1000; export const PLATFORM = { CONNECT: "connect", COMMUNITY: "community", - BOTH: "connect+community", + TAAS: "taas", }; // Notifications event types @@ -141,6 +140,13 @@ export const EVENT_TYPE = { COMPLETED: "challenge.notification.completed", }, BROADCAST: "admin.notification.broadcast", + TAAS: { + POST_INTERVIEW_ACTION_REQUIRED: + "taas.notification.post-interview-action-required", + RESOURCE_BOOKING_EXPIRATION: + "taas.notification.resource-booking-expiration", + RESOURCE_BOOKING_PLACED: "taas.notification.resource-booking-placed", + }, }; export const NOTIFICATION_TYPE = { @@ -152,6 +158,7 @@ export const NOTIFICATION_TYPE = { MEMBER_ADDED: "member-added", CHALLENGE: "challenge", BROADCAST: "broadcast", + TAAS: "taas", }; /* @@ -169,6 +176,8 @@ export const GOTO = { PHASE: `${config.URL.CONNECT_DOMAIN}/projects/{{projectId}}/plan#phase-{{phaseId}}`, TOPCODER_TEAM: `${config.URL.CONNECT_DOMAIN}/projects/{{projectId}}#manageTopcoderTeam`, CHALLENGE: `${config.URL.COMMUNITY_DOMAIN}/challenges/{{id}}`, + TAAS_CANDIDATES_INTERVIEWS: `${config.URL.TAAS_APP}/{{projectId}}/positions/{{jobId}}/candidates/interviews`, + TAAS_PROJECT: `${config.URL.TAAS_APP}/{{projectId}}`, }; // each notification can be displayed differently depend on WHO see them @@ -1226,6 +1235,8 @@ export const NOTIFICATIONS = [ ], }, + /// Community notification rules + { eventType: EVENT_TYPE.CHALLENGE.ACTIVE, type: NOTIFICATION_TYPE.CHALLENGE, @@ -1258,6 +1269,47 @@ export const NOTIFICATIONS = [ }, ], }, + + /// TaaS notification rules + + { + version: 1, + eventType: EVENT_TYPE.TAAS.POST_INTERVIEW_ACTION_REQUIRED, + type: NOTIFICATION_TYPE.TAAS, + rules: [ + { + text: "Candidate action required for {{userHandle}} in job {{jobTitle}} of the team {{teamName}}", + shouldBundle: false, + goTo: GOTO.TAAS_CANDIDATES_INTERVIEWS, + }, + ], + }, + + { + version: 1, + eventType: EVENT_TYPE.TAAS.RESOURCE_BOOKING_EXPIRATION, + type: NOTIFICATION_TYPE.TAAS, + rules: [ + { + text: "{{numOfExpiringResourceBookings}} resource booking{{pluralize numOfExpiringResourceBookings '' 's'}} {{pluralize numOfExpiringResourceBookings 'is' 'are'}} expiring in the team {{teamName}}", + shouldBundle: false, + goTo: GOTO.TAAS_PROJECT, + }, + ], + }, + + { + version: 1, + eventType: EVENT_TYPE.TAAS.RESOURCE_BOOKING_PLACED, + type: NOTIFICATION_TYPE.TAAS, + rules: [ + { + text: "Resource {{userHandle}} is placed for the job {{jobTitle}} of the team {{teamName}}", + shouldBundle: false, + goTo: GOTO.TAAS_PROJECT, + }, + ], + }, ]; // list of ignored notifications diff --git a/src/containers/NotificationsContainer/index.jsx b/src/containers/NotificationsContainer/index.jsx index 93f2278..2e52611 100644 --- a/src/containers/NotificationsContainer/index.jsx +++ b/src/containers/NotificationsContainer/index.jsx @@ -279,7 +279,7 @@ class NotificationsContainer extends Component { render() { const { notifications, communityNotifications, ...restProps } = this.props; const preRenderedNotifications = preRenderNotifications(notifications); - const preRenderedNotifications2 = preRenderCommunityNotifications( + const preRenderedCommunityNotifications = preRenderCommunityNotifications( communityNotifications ); @@ -288,7 +288,7 @@ class NotificationsContainer extends Component { {...{ ...restProps, notifications: preRenderedNotifications, - communityNotifications: preRenderedNotifications2, + communityNotifications: preRenderedCommunityNotifications, }} /> ); diff --git a/src/containers/NotificationsDropdownContainer/index.jsx b/src/containers/NotificationsDropdownContainer/index.jsx index f580b5a..f41f607 100644 --- a/src/containers/NotificationsDropdownContainer/index.jsx +++ b/src/containers/NotificationsDropdownContainer/index.jsx @@ -11,6 +11,7 @@ import { TransitionGroup, Transition } from "react-transition-group"; import { getNotifications, getCommunityNotifications, + getTaaSNotifications, toggleNotificationSeen, markAllNotificationsRead, markAllNotificationsSeen, @@ -19,7 +20,6 @@ import { viewOlderNotifications, hideOlderNotifications, resetNotifications, - resetCommunityNotifications, } from "../../actions/notifications"; import { splitNotificationsBySources, @@ -70,6 +70,7 @@ const NotificationsDropdownContainerView = (props) => { toggleNotificationsDropdownMobile, toggleNotificationsDropdownWeb, markAllNotificationsSeen, + platform, } = props; if ( (!initialized && isLoading) || @@ -154,8 +155,11 @@ const NotificationsDropdownContainerView = (props) => { ); if ( - (!isLoading && !initialized) || - (!isCommunityLoading && !communityInitialized) + (!isLoading && !initialized && platform == PLATFORM.CONNECT) || + (!isLoading && !initialized && platform == PLATFORM.TAAS) || + (!isCommunityLoading && + !communityInitialized && + platform == PLATFORM.COMMUNITY) ) { notificationsEmpty = ( @@ -543,10 +547,11 @@ class NotificationsDropdownContainer extends React.Component { } componentWillReceiveProps(nextProps) { - const { platform: oldPlatform } = this.props; + const { platform: oldPlatform, resetNotifications } = this.props; const { platform } = nextProps; if (platform !== oldPlatform) { + resetNotifications(); this.getPlatformNotifications(platform); } } @@ -555,23 +560,17 @@ class NotificationsDropdownContainer extends React.Component { const { getNotifications, getCommunityNotifications, + getTaaSNotifications, platform, - resetNotifications, - resetCommunityNotifications, } = this.props; p = p || platform; - if (p === PLATFORM.BOTH) { - resetNotifications(); - resetCommunityNotifications(); - getNotifications(); - getCommunityNotifications(); - } else if (p === PLATFORM.CONNECT) { - resetCommunityNotifications(); + if (p === PLATFORM.CONNECT) { getNotifications(); + } else if (p === PLATFORM.TAAS) { + getTaaSNotifications(); } else { - resetNotifications(); getCommunityNotifications(); } } @@ -619,6 +618,7 @@ const mapStateToProps = ({ notifications }) => notifications; const mapDispatchToProps = { getNotifications, getCommunityNotifications, + getTaaSNotifications, toggleNotificationSeen, markAllNotificationsRead, markAllNotificationsSeen, @@ -627,7 +627,6 @@ const mapDispatchToProps = { viewOlderNotifications, hideOlderNotifications, resetNotifications, - resetCommunityNotifications, }; export default connect( diff --git a/src/reducers/notifications.js b/src/reducers/notifications.js index f638f3e..7033fa0 100644 --- a/src/reducers/notifications.js +++ b/src/reducers/notifications.js @@ -20,7 +20,6 @@ import { SET_NOTIFICATION_PLATFORM, PLATFORM, RESET_NOTIFICATIONS, - RESET_COMMUNITY_NOTIFICATIONS, } from "../constants/notifications"; import _ from "lodash"; import { getActiveAndBroadcastNotifications } from "../utils/notifications"; @@ -266,14 +265,10 @@ export default (state = initialState, action) => { case RESET_NOTIFICATIONS: return { ...state, + filterBy: "", notifications: [], - sources: [], - }; - - case RESET_COMMUNITY_NOTIFICATIONS: - return { - ...state, communityNotifications: [], + sources: [], communitySources: [], }; diff --git a/src/services/notifications.js b/src/services/notifications.js index 93e10d6..3cd0f84 100644 --- a/src/services/notifications.js +++ b/src/services/notifications.js @@ -5,6 +5,7 @@ import { URL } from "../../config"; import { prepareNotifications, prepareCommunityNotifications, + prepareTaaSNotifications, } from "../utils/notifications"; const logger = console; @@ -51,6 +52,14 @@ const getNotifications = () => { .then((resp) => prepareNotifications(resp.data.items)); }; +const getTaaSNotifications = () => { + return axiosInstance + .get( + `${URL.TC_NOTIFICATION_URL}/list?read=false&platform=taas&per_page=${NOTIFICATIONS_LIMIT}` + ) + .then((resp) => prepareTaaSNotifications(resp.data.items)); +}; + const getCommunityNotifications = () => { return axiosInstance .get( @@ -64,4 +73,5 @@ export default { getCommunityNotifications, markNotificationsRead, markNotificationsSeen, + getTaaSNotifications, }; diff --git a/src/utils/index.js b/src/utils/index.js index c827f83..30e94c6 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -20,9 +20,7 @@ export const getLoginUrl = () => * Generate Business Login URL */ export const getBusinessLoginUrl = () => - `${ - config.URL.AUTH - }?regSource=taasApp&mode=login&retUrl=${encodeURIComponent( + `${config.URL.AUTH}?regSource=taasApp&mode=login&retUrl=${encodeURIComponent( window.location.href.match(/[^?]*/)[0] )}`; diff --git a/src/utils/notifications.js b/src/utils/notifications.js index c382379..ff49cb7 100644 --- a/src/utils/notifications.js +++ b/src/utils/notifications.js @@ -59,9 +59,27 @@ const handlebarsFallbackHelper = (value, fallbackValue) => { return new Handlebars.SafeString(out); }; +/** + * Handlebars helper which displays single or plural noun + * + * Example: + * ``` + * {{pluralize count resource resources}} + * ``` + * Will output `resource` if `count` equals 0 or 1; otherwise `resources` + * + * @param {Number} count quantity + * @param {String} single noun + * @param {String} plural nouns + */ +const handlebarsPluralizeHelper = (number, single, plural) => { + return Math.abs(number) > 1 ? plural : single; +}; + // register handlebars helpers Handlebars.registerHelper("showMore", handlebarsShowMoreHelper); Handlebars.registerHelper("fallback", handlebarsFallbackHelper); +Handlebars.registerHelper("pluralize", handlebarsPluralizeHelper); export const renderGoTo = (goTo, contents) => { let goToHandlebars = ""; @@ -244,105 +262,6 @@ export const filterReadNotifications = (notifications) => export const filterSeenNotifications = (notifications) => _.filter(notifications, { seen: false }); -/** - * Filter notifications that belongs to project:projectId - * - * @param {Array} notifications list of notifications - * - * @param {Number} projectId - * - * @return {Array} notifications list filtered of notifications - */ -export const filterNotificationsByProjectId = (notifications, projectId) => - _.filter(notifications, (notification) => { - return notification.sourceId === `${projectId}`; - }); - -/** - * Filter notifications about Topic and Post changed - * - * @param {Array} notifications list of notifications - * @param {RegExp} [tagRegExp] regexp to filter notification by tags - * - * @return {Array} notifications list filtered of notifications - */ -export const filterTopicAndPostChangedNotifications = ( - notifications, - tagRegExp -) => { - let topicAndPostNotifications = _.filter( - notifications, - (notification) => - notification.eventType === EVENT_TYPE.TOPIC.CREATED || - notification.eventType === EVENT_TYPE.POST.CREATED || - notification.eventType === EVENT_TYPE.POST.UPDATED || - notification.eventType === EVENT_TYPE.POST.MENTION - ); - - // filter messages using `tags` - if (tagRegExp) { - topicAndPostNotifications = _.filter( - topicAndPostNotifications, - (notification) => { - const tags = _.get(notification, "contents.tags", []); - - return _.some(tags, (tag) => tagRegExp.test(tag)); - } - ); - } - - return topicAndPostNotifications; -}; - -/** - * Filter notifications about Link and File changed - * - * @param {Array} notifications list of notifications - * - * @return {Array} notifications list filtered of notifications - */ -export const filterFileAndLinkChangedNotifications = (notifications) => { - return _.filter( - notifications, - (notification) => - notification.eventType === EVENT_TYPE.PROJECT.FILE_UPLOADED || - notification.eventType === EVENT_TYPE.PROJECT.LINK_CREATED - ); -}; - -/** - * Filter notification about post mentions - * @param {Array} notifications list of notifications - * - * @return {Array} notifications list filtered by post mention event type - */ -export const filterPostsMentionNotifications = (notifications) => - _.filter(notifications, (notification) => { - return notification.eventType === EVENT_TYPE.POST.MENTION; - }); - -/** - * Filter notifications about the project - * - * @param {Array} notifications list of notifications - * - * @return {Array} notifications list filtered of notifications - */ -export const filterProjectNotifications = (notifications) => - _.filter(notifications, (notification) => { - return ( - notification.eventType === EVENT_TYPE.PROJECT.CREATED || - notification.eventType === EVENT_TYPE.PROJECT.APPROVED || - notification.eventType === EVENT_TYPE.PROJECT.PAUSED || - notification.eventType === EVENT_TYPE.PROJECT.COMPLETED || - notification.eventType === EVENT_TYPE.PROJECT.SPECIFICATION_MODIFIED || - notification.eventType === EVENT_TYPE.PROJECT.SUBMITTED_FOR_REVIEW || - notification.eventType === EVENT_TYPE.PROJECT.FILE_UPLOADED || - notification.eventType === EVENT_TYPE.PROJECT.CANCELED || - notification.eventType === EVENT_TYPE.PROJECT.LINK_CREATED - ); - }); - /** * Limits notifications quantity per source * @@ -669,7 +588,49 @@ export const preRenderNotifications = (notifications) => { return preRenderedNotifications; }; -//----- // +// --- TaaS --- // + +export const prepareTaaSNotifications = (rawNotifications) => { + const notifications = rawNotifications.map((rawNotification) => ({ + id: `${rawNotification.id}`, + sourceId: rawNotification.contents.projectId + ? `${rawNotification.contents.projectId}` + : "team", + sourceName: rawNotification.contents.projectId + ? rawNotification.contents.teamName || "Team" + : "Global", + eventType: rawNotification.type, + date: rawNotification.createdAt, + isRead: rawNotification.read, + seen: rawNotification.seen, + contents: rawNotification.contents, + version: rawNotification.version, + })); + + notifications.forEach((notification) => { + const notificationRule = getNotificationRule(notification); + + if (notificationRule) { + notification.type = notificationRule.type; + if (notificationRule.goTo) { + notification.goto = renderGoTo( + notificationRule.goTo, + notification.contents + ); + } + notification.rule = notificationRule; + } else { + console.warn( + `Cannot find notification rule for eventType '${notification.eventType}' version '${notification.version}'.` + ); + } + }); + + return notifications; +}; + +// --- Community --- // + const getCommunityNotificationRule = (notification) => { const notificationRule = _.find(NOTIFICATION_RULES, (_notificationRule) => { return _notificationRule.eventType === notification.eventType; diff --git a/webpack.config.js b/webpack.config.js index 4b31b48..fa827be 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -14,6 +14,9 @@ module.exports = (webpackConfigEnv, options) => { disableHtmlGeneration: true, }); + const unusedFilesWebpackPlugin = defaultConfig.plugins.find(p => p.constructor.name === "UnusedFilesWebpackPlugin"); + unusedFilesWebpackPlugin.globOptions.ignore.push("**/assets/icons/*.svg", "**/__mocks__/**"); + let cssLocalIdent; if (options.mode === "production") { cssLocalIdent = "[hash:base64:6]";