diff --git a/config/constants/development.js b/config/constants/development.js index 06fc24b3..487ef24b 100644 --- a/config/constants/development.js +++ b/config/constants/development.js @@ -13,8 +13,13 @@ module.exports = { ACCOUNTS_APP_CONNECTOR_URL: `https://accounts-auth0.${DOMAIN}`, ACCOUNTS_APP_LOGIN_URL: `https://accounts-auth0.${DOMAIN}`, COMMUNITY_APP_URL: `https://www.${DOMAIN}`, + ENGAGEMENTS_APP_URL: 'https://engagements.topcoder-dev.com', MEMBER_API_URL: `${DEV_API_HOSTNAME}/v6/members`, CHALLENGE_API_URL: `${DEV_API_HOSTNAME}/v6/challenges`, + ENGAGEMENTS_API_URL: `${DEV_API_HOSTNAME}/v6/engagements/engagements`, + ENGAGEMENTS_ROOT_API_URL: `${DEV_API_HOSTNAME}/v6/engagements`, + APPLICATIONS_API_URL: `${DEV_API_HOSTNAME}/v6/engagements/applications`, + TC_FINANCE_API_URL: process.env.TC_FINANCE_API_URL || `${API_V6}/finance`, CHALLENGE_DEFAULT_REVIEWERS_URL: `${DEV_API_HOSTNAME}/v6/challenge/default-reviewers`, CHALLENGE_API_VERSION: '1.1.0', CHALLENGE_TIMELINE_TEMPLATES_URL: `${DEV_API_HOSTNAME}/v6/timeline-templates`, @@ -62,6 +67,7 @@ module.exports = { HEADER_AUTH_URLS_HREF: `https://accounts-auth0.${DOMAIN}?utm_source=community-app-main`, HEADER_AUTH_URLS_LOCATION: `https://accounts-auth0.${DOMAIN}?retUrl=%S&utm_source=community-app-main`, SKILLS_V5_API_URL: `${API_V5}/standardized-skills/skills/autocomplete`, + SKILLS_V5_SKILLS_URL: `${API_V5}/standardized-skills/skills`, UPDATE_SKILLS_V5_API_URL: `${API_V5}/standardized-skills/challenge-skills`, SALESFORCE_BILLING_ACCOUNT_LINK: 'https://c.cs18.visual.force.com/apex/baredirect?id=', PROFILE_URL: 'https://profiles.topcoder-dev.com/' diff --git a/config/constants/local.js b/config/constants/local.js index 38146249..ad2ae1da 100644 --- a/config/constants/local.js +++ b/config/constants/local.js @@ -26,10 +26,15 @@ module.exports = { ACCOUNTS_APP_CONNECTOR_URL: `https://accounts-auth0.${DOMAIN}`, ACCOUNTS_APP_LOGIN_URL: `https://accounts-auth0.${DOMAIN}`, COMMUNITY_APP_URL: `https://www.${DOMAIN}`, + ENGAGEMENTS_APP_URL: 'https://engagements.topcoder-dev.com', // Local service URLs MEMBER_API_URL: `${LOCAL_MEMBER_API}/members`, CHALLENGE_API_URL: `${LOCAL_CHALLENGE_API}/challenges`, + ENGAGEMENTS_API_URL: `${LOCAL_CHALLENGE_API}/engagements/engagements`, + ENGAGEMENTS_ROOT_API_URL: `${LOCAL_CHALLENGE_API}/engagements`, + APPLICATIONS_API_URL: `${LOCAL_CHALLENGE_API}/engagements/applications`, + TC_FINANCE_API_URL: process.env.TC_FINANCE_API_URL || 'http://localhost:3009/v6/finance', CHALLENGE_DEFAULT_REVIEWERS_URL: `${LOCAL_CHALLENGE_API.replace(/\/v6$/, '')}/v6/challenge/default-reviewers`, CHALLENGE_API_VERSION: '1.1.0', CHALLENGE_TIMELINE_TEMPLATES_URL: `${LOCAL_CHALLENGE_API}/timeline-templates`, @@ -90,6 +95,7 @@ module.exports = { // Standardized skills API on local SKILLS_V5_API_URL: `${LOCAL_SKILLS_API_V5}/skills/autocomplete`, + SKILLS_V5_SKILLS_URL: `${LOCAL_SKILLS_API_V5}/skills`, UPDATE_SKILLS_V5_API_URL: `${LOCAL_SKILLS_API_V5}/challenge-skills`, SALESFORCE_BILLING_ACCOUNT_LINK: 'https://c.cs18.visual.force.com/apex/baredirect?id=', diff --git a/config/constants/production.js b/config/constants/production.js index 9631100a..425d9b46 100644 --- a/config/constants/production.js +++ b/config/constants/production.js @@ -12,8 +12,13 @@ module.exports = { ACCOUNTS_APP_CONNECTOR_URL: process.env.ACCOUNTS_APP_CONNECTOR_URL || `https://accounts-auth0.${DOMAIN}`, ACCOUNTS_APP_LOGIN_URL: `https://accounts-auth0.${DOMAIN}`, COMMUNITY_APP_URL: `https://www.${DOMAIN}`, + ENGAGEMENTS_APP_URL: 'https://engagements.topcoder.com', MEMBER_API_URL: `${PROD_API_HOSTNAME}/v6/members`, CHALLENGE_API_URL: `${PROD_API_HOSTNAME}/v6/challenges`, + ENGAGEMENTS_API_URL: `${PROD_API_HOSTNAME}/v6/engagements/engagements`, + ENGAGEMENTS_ROOT_API_URL: `${PROD_API_HOSTNAME}/v6/engagements`, + APPLICATIONS_API_URL: `${PROD_API_HOSTNAME}/v6/engagements/applications`, + TC_FINANCE_API_URL: process.env.TC_FINANCE_API_URL || `${API_V6}/finance`, CHALLENGE_DEFAULT_REVIEWERS_URL: `${PROD_API_HOSTNAME}/v6/challenge/default-reviewers`, CHALLENGE_API_VERSION: '1.1.0', CHALLENGE_TIMELINE_TEMPLATES_URL: `${PROD_API_HOSTNAME}/v6/timeline-templates`, @@ -62,6 +67,7 @@ module.exports = { HEADER_AUTH_URLS_HREF: `https://accounts-auth0.${DOMAIN}?utm_source=community-app-main`, HEADER_AUTH_URLS_LOCATION: `https://accounts-auth0.${DOMAIN}?retUrl=%S&utm_source=community-app-main`, SKILLS_V5_API_URL: `${API_V5}/standardized-skills/skills/autocomplete`, + SKILLS_V5_SKILLS_URL: `${API_V5}/standardized-skills/skills`, UPDATE_SKILLS_V5_API_URL: `${API_V5}/standardized-skills/challenge-skills`, SALESFORCE_BILLING_ACCOUNT_LINK: 'https://topcoder.my.salesforce.com/apex/baredirect?id=', PROFILE_URL: 'https://profiles.topcoder.com/' diff --git a/config/formatWebpackMessages.js b/config/formatWebpackMessages.js new file mode 100644 index 00000000..af5ddf51 --- /dev/null +++ b/config/formatWebpackMessages.js @@ -0,0 +1,138 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const chalk = require('chalk'); +const friendlySyntaxErrorLabel = 'Syntax error:'; + +function isLikelyASyntaxError(message) { + return message.indexOf(friendlySyntaxErrorLabel) !== -1; +} + +// Cleans up webpack error messages. +// eslint-disable-next-line no-unused-vars +function formatMessage(message, isError) { + // Webpack 5 can emit warning/error objects; normalize to strings. + if (typeof message !== 'string') { + if (message && typeof message.message === 'string') { + message = message.message; + } else if (message && typeof message.stack === 'string') { + message = message.stack; + } else { + message = String(message); + } + } + + let lines = message.split('\n'); + + // Strip Webpack-added headers off errors/warnings + // https://github.com/webpack/webpack/blob/master/lib/ModuleError.js + lines = lines.filter(line => !/Module [A-z ]+\(from/.test(line)); + + // Transform parsing error into syntax error + // TODO: move this to our ESLint formatter? + lines = lines.map(line => { + const parsingError = /Line (\d+):(?:(\d+):)?\s*Parsing error: (.+)$/.exec( + line + ); + if (!parsingError) { + return line; + } + const [, errorLine, errorColumn, errorMessage] = parsingError; + return `${friendlySyntaxErrorLabel} ${errorMessage} (${errorLine}:${errorColumn})`; + }); + + message = lines.join('\n'); + // Smoosh syntax errors (commonly found in CSS) + message = message.replace( + /SyntaxError\s+\((\d+):(\d+)\)\s*(.+?)\n/g, + `${friendlySyntaxErrorLabel} $3 ($1:$2)\n` + ); + // Remove columns from ESLint formatter output (we added these for more + // accurate syntax errors) + message = message.replace(/Line (\d+):\d+:/g, 'Line $1:'); + // Clean up export errors + message = message.replace( + /^.*export '(.+?)' was not found in '(.+?)'.*$/gm, + `Attempted import error: '$1' is not exported from '$2'.` + ); + message = message.replace( + /^.*export 'default' \(imported as '(.+?)'\) was not found in '(.+?)'.*$/gm, + `Attempted import error: '$2' does not contain a default export (imported as '$1').` + ); + message = message.replace( + /^.*export '(.+?)' \(imported as '(.+?)'\) was not found in '(.+?)'.*$/gm, + `Attempted import error: '$1' is not exported from '$3' (imported as '$2').` + ); + lines = message.split('\n'); + + // Remove leading newline + if (lines.length > 2 && lines[1].trim() === '') { + lines.splice(1, 1); + } + // Clean up file name + lines[0] = lines[0].replace(/^(.*) \d+:\d+-\d+$/, '$1'); + + // Cleans up verbose "module not found" messages for files and packages. + if (lines[1] && lines[1].indexOf('Module not found: ') === 0) { + lines = [ + lines[0], + lines[1] + .replace('Error: ', '') + .replace('Module not found: Cannot find file:', 'Cannot find file:'), + ]; + } + + // Add helpful message for users trying to use Sass for the first time + if (lines[1] && lines[1].match(/Cannot find module.+node-sass/)) { + lines[1] = 'To import Sass files, you first need to install node-sass.\n'; + lines[1] += + 'Run `npm install node-sass` or `yarn add node-sass` inside your workspace.'; + } + + lines[0] = chalk.inverse(lines[0]); + + message = lines.join('\n'); + // Internal stacks are generally useless so we strip them... with the + // exception of stacks containing `webpack:` because they're normally + // from user code generated by Webpack. For more information see + // https://github.com/facebook/create-react-app/pull/1050 + message = message.replace( + /^\s*at\s((?!webpack:).)*:\d+:\d+[\s)]*(\n|$)/gm, + '' + ); // at ... ...:x:y + message = message.replace(/^\s*at\s(\n|$)/gm, ''); // at + lines = message.split('\n'); + + // Remove duplicated newlines + lines = lines.filter( + (line, index, arr) => + index === 0 || line.trim() !== '' || line.trim() !== arr[index - 1].trim() + ); + + // Reassemble the message + message = lines.join('\n'); + return message.trim(); +} + +function formatWebpackMessages(json) { + const formattedErrors = json.errors.map(function(message) { + return formatMessage(message, true); + }); + const formattedWarnings = json.warnings.map(function(message) { + return formatMessage(message, false); + }); + const result = { errors: formattedErrors, warnings: formattedWarnings }; + if (result.errors.some(isLikelyASyntaxError)) { + // If there are any syntax errors, show just them. + result.errors = result.errors.filter(isLikelyASyntaxError); + } + return result; +} + +module.exports = formatWebpackMessages; diff --git a/config/webpack.config.js b/config/webpack.config.js index a9568a21..d47d193f 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -37,6 +37,7 @@ module.exports = function (webpackEnv) { const isEnvDevelopment = webpackEnv === 'development' const isEnvProduction = webpackEnv === 'production' const WM_DEBUG = /^(1|true|on|yes)$/i.test(String(process.env.WM_DEBUG || '')) + const reactDevUtilsContextRegExp = /[\\/]react-dev-utils[\\/]/ // Webpack uses `publicPath` to determine where the app is being served from. // It requires a trailing slash, or the file assets will get an incorrect path. @@ -60,6 +61,14 @@ module.exports = function (webpackEnv) { // common function to get style loaders const getStyleLoaders = (cssOptions, preProcessor) => { + const resolvedCssOptions = Object.assign( + { + url: { + filter: url => !url.toLowerCase().startsWith('data:') + } + }, + cssOptions + ) const loaders = [ isEnvDevelopment && require.resolve('style-loader'), isEnvProduction && { @@ -71,7 +80,7 @@ module.exports = function (webpackEnv) { }, { loader: require.resolve('css-loader'), - options: cssOptions + options: resolvedCssOptions }, { // Options for PostCSS as we reference these options twice @@ -476,6 +485,13 @@ module.exports = function (webpackEnv) { // This gives some necessary context to module not found errors, such as // the requesting resource. new ModuleNotFoundPlugin(paths.appPath), + // Ensure the dev client tolerates webpack 5 warning/error objects. + isEnvDevelopment && + new webpack.NormalModuleReplacementPlugin(/\.\/formatWebpackMessages$/, (resource) => { + if (reactDevUtilsContextRegExp.test(resource.context || '')) { + resource.request = path.resolve(__dirname, 'formatWebpackMessages') + } + }), // (DefinePlugin already added above with merged env) // This is necessary to emit hot updates (currently CSS only): isEnvDevelopment && new webpack.HotModuleReplacementPlugin(), diff --git a/scripts/start.js b/scripts/start.js index 9e54dd4d..5dd346ab 100644 --- a/scripts/start.js +++ b/scripts/start.js @@ -21,6 +21,25 @@ const webpack = require('webpack') const WebpackDevServer = require('webpack-dev-server') const clearConsole = require('react-dev-utils/clearConsole') const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles') +const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages') +// Webpack 5 returns error objects; normalize to strings for react-dev-utils@7. +const normalizeWebpackMessages = (json = {}) => { + const normalizeMessage = (message) => { + if (typeof message === 'string') return message + if (message && typeof message.message === 'string') return message.message + if (message && typeof message.stack === 'string') return message.stack + return String(message) + } + return { + ...json, + errors: Array.isArray(json.errors) ? json.errors.map(normalizeMessage) : [], + warnings: Array.isArray(json.warnings) ? json.warnings.map(normalizeMessage) : [] + } +} +const formatWebpackMessagesPatched = (json) => + formatWebpackMessages(normalizeWebpackMessages(json)) +require.cache[require.resolve('react-dev-utils/formatWebpackMessages')].exports = + formatWebpackMessagesPatched const { choosePort, createCompiler, diff --git a/src/actions/applications.js b/src/actions/applications.js new file mode 100644 index 00000000..f5fc035d --- /dev/null +++ b/src/actions/applications.js @@ -0,0 +1,109 @@ +import _ from 'lodash' +import { + fetchApplications, + fetchApplication, + updateApplicationStatus as updateApplicationStatusAPI +} from '../services/engagements' +import { + LOAD_APPLICATIONS_PENDING, + LOAD_APPLICATIONS_SUCCESS, + LOAD_APPLICATIONS_FAILURE, + LOAD_APPLICATION_DETAILS_PENDING, + LOAD_APPLICATION_DETAILS_SUCCESS, + LOAD_APPLICATION_DETAILS_FAILURE, + UPDATE_APPLICATION_STATUS_PENDING, + UPDATE_APPLICATION_STATUS_SUCCESS, + UPDATE_APPLICATION_STATUS_FAILURE +} from '../config/constants' + +/** + * Loads applications for an engagement + * @param {String|Number} engagementId + * @param {String} statusFilter + */ +export function loadApplications (engagementId, statusFilter = 'all') { + return async (dispatch) => { + dispatch({ + type: LOAD_APPLICATIONS_PENDING + }) + + const filters = {} + if (statusFilter && statusFilter !== 'all') { + filters.status = statusFilter + } + + try { + const response = await fetchApplications(engagementId, filters) + return dispatch({ + type: LOAD_APPLICATIONS_SUCCESS, + applications: _.get(response, 'data', []) + }) + } catch (error) { + dispatch({ + type: LOAD_APPLICATIONS_FAILURE, + error + }) + return Promise.reject(error) + } + } +} + +/** + * Loads application details + * @param {String|Number} applicationId + */ +export function loadApplicationDetails (applicationId) { + return async (dispatch) => { + if (!applicationId) { + return dispatch({ + type: LOAD_APPLICATION_DETAILS_SUCCESS, + applicationDetails: {} + }) + } + + dispatch({ + type: LOAD_APPLICATION_DETAILS_PENDING + }) + + try { + const response = await fetchApplication(applicationId) + return dispatch({ + type: LOAD_APPLICATION_DETAILS_SUCCESS, + applicationDetails: _.get(response, 'data', {}) + }) + } catch (error) { + dispatch({ + type: LOAD_APPLICATION_DETAILS_FAILURE, + error + }) + return Promise.reject(error) + } + } +} + +/** + * Updates application status + * @param {String|Number} applicationId + * @param {String} status + */ +export function updateApplicationStatus (applicationId, status) { + return async (dispatch) => { + dispatch({ + type: UPDATE_APPLICATION_STATUS_PENDING + }) + + try { + const response = await updateApplicationStatusAPI(applicationId, status) + return dispatch({ + type: UPDATE_APPLICATION_STATUS_SUCCESS, + application: _.get(response, 'data', {}) + }) + } catch (error) { + dispatch({ + type: UPDATE_APPLICATION_STATUS_FAILURE, + error + }) + return Promise.reject(error) + } + } +} diff --git a/src/actions/auth.js b/src/actions/auth.js index 6ed70d57..abb68595 100644 --- a/src/actions/auth.js +++ b/src/actions/auth.js @@ -2,7 +2,10 @@ import { configureConnector, decodeToken } from 'tc-auth-lib' import { fetchProfile } from '../services/user' import { LOAD_USER_SUCCESS, - SAVE_AUTH_TOKEN + SAVE_AUTH_TOKEN, + ADMIN_ROLES, + MANAGER_ROLES, + TASK_MANAGER_ROLES } from '../config/constants' const { ACCOUNTS_APP_CONNECTOR_URL } = process.env @@ -17,6 +20,21 @@ configureConnector({ frameId: 'tc-accounts-iframe' }) +const normalizeRoles = (roles) => (Array.isArray(roles) ? roles : []) + +const getRoleFlags = (roles) => { + const normalizedRoles = normalizeRoles(roles) + const normalizedRolesLower = normalizedRoles.map((role) => `${role}`.toLowerCase()) + const hasRole = (roleList) => normalizedRolesLower.some((role) => roleList.includes(role)) + + return { + roles: normalizedRoles, + isAdmin: hasRole(ADMIN_ROLES), + isManager: hasRole(MANAGER_ROLES), + isTaskManager: hasRole(TASK_MANAGER_ROLES) + } +} + /** * Load user profile * @returns {Function} @@ -24,12 +42,16 @@ configureConnector({ export function loadUser () { return async (dispatch, getState) => { if (!getState().auth.user) { - if (getState().auth.token) { - const { handle } = decodeToken(getState().auth.token) + const token = getState().auth.token + if (token) { + const tokenData = decodeToken(token) + const { handle } = tokenData + const roleFlags = getRoleFlags(tokenData.roles) fetchProfile(handle).then(user => { dispatch({ type: LOAD_USER_SUCCESS, - user + user, + ...roleFlags }) }) } @@ -44,11 +66,13 @@ export function loadUser () { */ export function saveToken (token) { return (dispatch) => { + const tokenData = decodeToken(token) + const roleFlags = getRoleFlags(tokenData.roles) dispatch({ type: SAVE_AUTH_TOKEN, - token + token, + ...roleFlags }) - const { handle } = decodeToken(token) - dispatch(loadUser(handle)) + dispatch(loadUser()) } } diff --git a/src/actions/engagements.js b/src/actions/engagements.js new file mode 100644 index 00000000..1b31d427 --- /dev/null +++ b/src/actions/engagements.js @@ -0,0 +1,345 @@ +import _ from 'lodash' +import { + fetchEngagements, + fetchEngagement, + createEngagement as createEngagementAPI, + updateEngagement as updateEngagementAPI, + patchEngagement, + deleteEngagement as deleteEngagementAPI +} from '../services/engagements' +import { fetchSkillsByIds } from '../services/skills' +import { + normalizeEngagement, + normalizeEngagements, + toEngagementStatusApi +} from '../util/engagements' +import { + LOAD_ENGAGEMENTS_PENDING, + LOAD_ENGAGEMENTS_SUCCESS, + LOAD_ENGAGEMENTS_FAILURE, + LOAD_ENGAGEMENT_DETAILS_PENDING, + LOAD_ENGAGEMENT_DETAILS_SUCCESS, + LOAD_ENGAGEMENT_DETAILS_FAILURE, + CREATE_ENGAGEMENT_PENDING, + CREATE_ENGAGEMENT_SUCCESS, + CREATE_ENGAGEMENT_FAILURE, + UPDATE_ENGAGEMENT_DETAILS_PENDING, + UPDATE_ENGAGEMENT_DETAILS_SUCCESS, + UPDATE_ENGAGEMENT_DETAILS_FAILURE, + DELETE_ENGAGEMENT_PENDING, + DELETE_ENGAGEMENT_SUCCESS, + DELETE_ENGAGEMENT_FAILURE +} from '../config/constants' + +const getSkillId = (skill) => { + if (!skill) { + return null + } + if (typeof skill === 'string') { + return skill + } + return skill.id || skill.value || null +} + +const getEngagementSkills = (engagement) => { + if (!engagement) { + return [] + } + const skills = Array.isArray(engagement.skills) ? engagement.skills : [] + if (skills.length) { + return skills + } + return Array.isArray(engagement.requiredSkills) ? engagement.requiredSkills : [] +} + +const buildSkillsMap = (skills = []) => ( + skills.reduce((acc, skill) => { + if (skill && skill.id) { + acc[skill.id] = skill + } + return acc + }, {}) +) + +const withSkillDetails = (engagement, skillsMap) => { + if (!engagement) { + return engagement + } + const skills = getEngagementSkills(engagement) + .map((skill) => { + const id = getSkillId(skill) + if (!id) { + return null + } + const mapped = skillsMap[id] + const rawName = skill && typeof skill === 'object' ? (skill.name || skill.label) : null + const name = rawName && rawName !== id + ? rawName + : (mapped && mapped.name) || 'Unknown skill' + return { + ...(mapped || {}), + ...(skill && typeof skill === 'object' ? skill : {}), + id, + name + } + }) + .filter(Boolean) + + return { + ...engagement, + skills + } +} + +const hydrateEngagementSkills = async (engagements = []) => { + if (!Array.isArray(engagements) || !engagements.length) { + return [] + } + + const skillIds = new Set() + engagements.forEach((engagement) => { + getEngagementSkills(engagement).forEach((skill) => { + const id = getSkillId(skill) + if (id) { + skillIds.add(id) + } + }) + }) + + const uniqueIds = Array.from(skillIds) + if (!uniqueIds.length) { + return engagements.map((engagement) => withSkillDetails(engagement, {})) + } + + try { + const skills = await fetchSkillsByIds(uniqueIds) + const skillsMap = buildSkillsMap(skills) + return engagements.map((engagement) => withSkillDetails(engagement, skillsMap)) + } catch (error) { + return engagements.map((engagement) => withSkillDetails(engagement, {})) + } +} + +/** + * Loads engagements for a project + * @param {String|Number} projectId + * @param {String} status + * @param {String} filterName + * @param {Boolean} includePrivate + */ +export function loadEngagements (projectId, status = 'all', filterName = '', includePrivate = false) { + return async (dispatch) => { + dispatch({ + type: LOAD_ENGAGEMENTS_PENDING + }) + + const filters = {} + if (projectId) { + filters.projectId = projectId + } + if (status && status !== 'all') { + filters.status = toEngagementStatusApi(status) + } + if (!_.isEmpty(filterName)) { + filters.title = filterName + } + if (includePrivate) { + filters.includePrivate = true + } + + try { + const response = await fetchEngagements(filters) + const responseData = _.get(response, 'data', []) + const nestedData = _.get(responseData, 'data', []) + const engagements = Array.isArray(responseData) + ? responseData + : Array.isArray(nestedData) + ? nestedData + : [] + const hydratedEngagements = await hydrateEngagementSkills(engagements) + const normalizedEngagements = normalizeEngagements(hydratedEngagements) + dispatch({ + type: LOAD_ENGAGEMENTS_SUCCESS, + engagements: normalizedEngagements + }) + } catch (error) { + dispatch({ + type: LOAD_ENGAGEMENTS_FAILURE, + error + }) + } + } +} + +/** + * Loads engagement details + * @param {String|Number} projectId + * @param {String|Number} engagementId + */ +export function loadEngagementDetails (projectId, engagementId) { + return async (dispatch) => { + void projectId + if (!engagementId) { + return dispatch({ + type: LOAD_ENGAGEMENT_DETAILS_SUCCESS, + engagementDetails: {} + }) + } + + dispatch({ + type: LOAD_ENGAGEMENT_DETAILS_PENDING + }) + + try { + const response = await fetchEngagement(engagementId) + const [hydratedEngagement] = await hydrateEngagementSkills([_.get(response, 'data', {})]) + const engagementDetails = normalizeEngagement(hydratedEngagement || {}) + return dispatch({ + type: LOAD_ENGAGEMENT_DETAILS_SUCCESS, + engagementDetails + }) + } catch (error) { + dispatch({ + type: LOAD_ENGAGEMENT_DETAILS_FAILURE, + error + }) + return Promise.reject(error) + } + } +} + +/** + * Creates engagement + * @param {Object} engagementDetails + * @param {String|Number} projectId + */ +export function createEngagement (engagementDetails, projectId) { + return async (dispatch) => { + dispatch({ + type: CREATE_ENGAGEMENT_PENDING + }) + + if (!projectId) { + const error = new Error('Project ID is required to create engagement.') + dispatch({ + type: CREATE_ENGAGEMENT_FAILURE, + error + }) + return Promise.reject(error) + } + + const payload = { + ...engagementDetails, + projectId: String(projectId) + } + + try { + const response = await createEngagementAPI(payload) + const [hydratedEngagement] = await hydrateEngagementSkills([_.get(response, 'data', {})]) + const engagementDetails = normalizeEngagement(hydratedEngagement || {}) + return dispatch({ + type: CREATE_ENGAGEMENT_SUCCESS, + engagementDetails + }) + } catch (error) { + dispatch({ + type: CREATE_ENGAGEMENT_FAILURE, + error + }) + return Promise.reject(error) + } + } +} + +/** + * Updates engagement details + * @param {String|Number} engagementId + * @param {Object} engagementDetails + * @param {String|Number} projectId + */ +export function updateEngagementDetails (engagementId, engagementDetails, projectId) { + return async (dispatch) => { + void projectId + dispatch({ + type: UPDATE_ENGAGEMENT_DETAILS_PENDING + }) + + try { + const response = await updateEngagementAPI(engagementId, engagementDetails) + const [hydratedEngagement] = await hydrateEngagementSkills([_.get(response, 'data', {})]) + const updatedDetails = normalizeEngagement(hydratedEngagement || {}) + return dispatch({ + type: UPDATE_ENGAGEMENT_DETAILS_SUCCESS, + engagementDetails: updatedDetails + }) + } catch (error) { + dispatch({ + type: UPDATE_ENGAGEMENT_DETAILS_FAILURE, + error + }) + return Promise.reject(error) + } + } +} + +/** + * Partially updates engagement details + * @param {String|Number} engagementId + * @param {Object} partialDetails + * @param {String|Number} projectId + */ +export function partiallyUpdateEngagementDetails (engagementId, partialDetails, projectId) { + return async (dispatch) => { + void projectId + dispatch({ + type: UPDATE_ENGAGEMENT_DETAILS_PENDING + }) + + try { + const response = await patchEngagement(engagementId, partialDetails) + const [hydratedEngagement] = await hydrateEngagementSkills([_.get(response, 'data', {})]) + const updatedDetails = normalizeEngagement(hydratedEngagement || {}) + return dispatch({ + type: UPDATE_ENGAGEMENT_DETAILS_SUCCESS, + engagementDetails: updatedDetails + }) + } catch (error) { + dispatch({ + type: UPDATE_ENGAGEMENT_DETAILS_FAILURE, + error + }) + return Promise.reject(error) + } + } +} + +/** + * Deletes engagement + * @param {String|Number} engagementId + * @param {String|Number} projectId + */ +export function deleteEngagement (engagementId, projectId) { + return async (dispatch) => { + void projectId + dispatch({ + type: DELETE_ENGAGEMENT_PENDING + }) + + try { + const response = await deleteEngagementAPI(engagementId) + const [hydratedEngagement] = await hydrateEngagementSkills([_.get(response, 'data', {})]) + const engagementDetails = normalizeEngagement(hydratedEngagement || {}) + return dispatch({ + type: DELETE_ENGAGEMENT_SUCCESS, + engagementDetails, + engagementId + }) + } catch (error) { + dispatch({ + type: DELETE_ENGAGEMENT_FAILURE, + error + }) + return Promise.reject(error) + } + } +} diff --git a/src/actions/payments.js b/src/actions/payments.js new file mode 100644 index 00000000..5a18b9f9 --- /dev/null +++ b/src/actions/payments.js @@ -0,0 +1,107 @@ +import { createPayment, getPaymentsByAssignmentId } from '../services/payments' +import { + CREATE_PAYMENT_PENDING, + CREATE_PAYMENT_SUCCESS, + CREATE_PAYMENT_FAILURE, + FETCH_ASSIGNMENT_PAYMENTS_PENDING, + FETCH_ASSIGNMENT_PAYMENTS_SUCCESS, + FETCH_ASSIGNMENT_PAYMENTS_FAILURE +} from '../config/constants' + +/** + * Creates a payment for a member + * @param {String|Number} assignmentId + * @param {String|Number} memberId + * @param {String} memberHandle + * @param {String} paymentTitle + * @param {String|Number} amount + * @param {String|Number} billingAccountId + */ +export function createMemberPayment ( + assignmentId, + memberId, + memberHandle, + paymentTitle, + amount, + billingAccountId +) { + return async (dispatch) => { + dispatch({ + type: CREATE_PAYMENT_PENDING + }) + + const parsedAmount = Number(amount) + const payload = { + winnerId: String(memberId), + type: 'ENGAGEMENT', + origin: 'Topcoder', + category: 'ENGAGEMENT_PAYMENT', + title: paymentTitle, + description: paymentTitle, + externalId: String(assignmentId), + attributes: { + memberHandle, + assignmentId + }, + details: [ + { + totalAmount: parsedAmount, + grossAmount: parsedAmount, + installmentNumber: 1, + currency: 'USD', + billingAccount: String(billingAccountId), + challengeFee: 0 + } + ] + } + + try { + const response = await createPayment(payload) + dispatch({ + type: CREATE_PAYMENT_SUCCESS, + payment: response.data, + assignmentId + }) + return response + } catch (error) { + dispatch({ + type: CREATE_PAYMENT_FAILURE, + error + }) + return Promise.reject(error) + } + } +} + +/** + * Fetch payments for a specific assignment. + * @param {String|Number} assignmentId + */ +export function fetchAssignmentPayments (assignmentId) { + return async (dispatch) => { + dispatch({ + type: FETCH_ASSIGNMENT_PAYMENTS_PENDING, + assignmentId + }) + + try { + const response = await getPaymentsByAssignmentId(assignmentId) + const payments = response && response.data && response.data.data + ? response.data.data + : response.data + dispatch({ + type: FETCH_ASSIGNMENT_PAYMENTS_SUCCESS, + assignmentId, + payments + }) + return response + } catch (error) { + dispatch({ + type: FETCH_ASSIGNMENT_PAYMENTS_FAILURE, + assignmentId, + error + }) + return Promise.reject(error) + } + } +} diff --git a/src/components/ApplicationDetail/ApplicationDetail.module.scss b/src/components/ApplicationDetail/ApplicationDetail.module.scss new file mode 100644 index 00000000..9b02c0cc --- /dev/null +++ b/src/components/ApplicationDetail/ApplicationDetail.module.scss @@ -0,0 +1,127 @@ +@use '../../styles/includes' as *; + +.container { + width: 100%; + max-width: 900px; +} + +.header { + margin-bottom: 20px; +} + +.title { + font-size: 24px; + font-weight: 700; + color: $challenges-title; +} + +.subtitle { + margin-top: 6px; + color: $tc-gray-70; + font-size: 14px; +} + +.section { + margin-bottom: 24px; +} + +.sectionTitle { + font-size: 16px; + font-weight: 600; + color: $tc-gray-80; + margin-bottom: 12px; +} + +.detailGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px 20px; +} + +.detailItem { + font-size: 14px; +} + +.label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.4px; + color: $tc-gray-60; + margin-bottom: 4px; +} + +.value { + color: $tc-gray-80; +} + +.status { + padding: 4px 8px; + border-radius: 12px; + font-size: 12px; + display: inline-block; + text-transform: capitalize; +} + +.statusSubmitted { + background: $tc-gray-20; + color: $tc-gray-80; +} + +.statusUnderReview { + background: $tc-yellow; + color: $tc-gray-90; +} + +.statusAccepted { + background: $tc-green-20; + color: $tc-gray-90; +} + +.statusRejected { + background: $tc-red; + color: $white; +} + +.statusControl { + min-width: 200px; +} + +.links { + display: flex; + flex-direction: column; + gap: 12px; + font-size: 14px; +} + +.linkRow { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.linkLabel { + min-width: 80px; + font-weight: 600; + color: $tc-gray-70; +} + +.linkList { + display: flex; + flex-direction: column; + gap: 6px; +} + +.coverLetter { + background: $tc-gray-10; + border-radius: 6px; + padding: 12px; + font-size: 14px; + color: $tc-gray-80; + white-space: pre-wrap; +} + +.footer { + display: flex; + justify-content: flex-end; + margin-top: 24px; +} diff --git a/src/components/ApplicationDetail/index.js b/src/components/ApplicationDetail/index.js new file mode 100644 index 00000000..3c9988d3 --- /dev/null +++ b/src/components/ApplicationDetail/index.js @@ -0,0 +1,242 @@ +import React from 'react' +import PropTypes from 'prop-types' +import moment from 'moment-timezone' +import Modal from '../Modal' +import Select from '../Select' +import { OutlineButton } from '../Buttons' +import styles from './ApplicationDetail.module.scss' + +const STATUS_OPTIONS = [ + { label: 'Submitted', value: 'SUBMITTED' }, + { label: 'Under Review', value: 'UNDER_REVIEW' }, + { label: 'Accepted', value: 'ACCEPTED' }, + { label: 'Rejected', value: 'REJECTED' } +] + +const formatDateTime = (value) => { + if (!value) { + return '-' + } + return moment(value).format('MMM DD, YYYY HH:mm') +} + +const formatMobileNumber = (value) => { + if (!value) { + return '-' + } + + const rawValue = value.toString().trim() + if (!rawValue) { + return '-' + } + + let digits = rawValue.replace(/\D/g, '') + if (!digits) { + return '-' + } + + if (digits.length === 11 && digits.startsWith('1')) { + digits = digits.slice(1) + } + + if (digits.length === 10) { + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}` + } + + if (digits.length > 10) { + const countryCode = digits.slice(0, digits.length - 10) + const nationalNumber = digits.slice(-10) + return `+${countryCode} (${nationalNumber.slice(0, 3)}) ${nationalNumber.slice(3, 6)}-${nationalNumber.slice(6)}` + } + + if (digits.length === 7) { + return `${digits.slice(0, 3)}-${digits.slice(3)}` + } + + return digits +} + +const getStatusClass = (status) => { + const normalized = (status || '').toString().toLowerCase().replace(/\s+/g, '_') + if (normalized === 'submitted') { + return styles.statusSubmitted + } + if (normalized === 'under_review') { + return styles.statusUnderReview + } + if (normalized === 'accepted') { + return styles.statusAccepted + } + if (normalized === 'rejected') { + return styles.statusRejected + } + return styles.statusSubmitted +} + +const getStatusLabel = (status) => { + const match = STATUS_OPTIONS.find(option => option.value === status) + if (match) { + return match.label + } + if (!status) { + return '-' + } + return status.toString().replace(/_/g, ' ') +} + +const ApplicationDetail = ({ application, engagement, canManage, onUpdateStatus, onClose }) => { + if (!application) { + return null + } + + const statusLabel = getStatusLabel(application.status) + const statusClass = getStatusClass(application.status) + const statusOption = STATUS_OPTIONS.find(option => option.value === application.status) || null + const portfolioUrls = Array.isArray(application.portfolioUrls) ? application.portfolioUrls : [] + + return ( + +
+
+
Application Details
+ {engagement && engagement.title && ( +
{engagement.title}
+ )} +
+ +
+
Applicant
+
+
+
Name
+
{application.name || '-'}
+
+
+
Email
+
{application.email || '-'}
+
+
+
Mobile Number
+
{formatMobileNumber(application.mobileNumber)}
+
+
+
Address
+
{application.address || '-'}
+
+
+
+ +
+
Application
+
+
+
Applied Date
+
{formatDateTime(application.createdAt)}
+
+
+
Years of Experience
+
+ {application.yearsOfExperience != null ? application.yearsOfExperience : '-'} +
+
+
+
Availability
+
{application.availability || '-'}
+
+
+
Status
+
+ {statusLabel} +
+
+ {canManage && ( +
+
Update Status
+
+ setStatusFilter(option || STATUS_OPTIONS[0])} + isClearable={false} + /> +
+ + {filteredApplications.length === 0 ? ( +
No applications found.
+ ) : ( + + + + + + + + + + + + + + {filteredApplications.map((application) => { + const statusLabel = getStatusLabel(application.status) + const statusClass = getStatusClass(application.status) + const statusOption = STATUS_UPDATE_OPTIONS.find(option => option.value === application.status) || null + + return ( + + + + + + + + + + ) + })} + +
Applicant NameEmailApplied DateYears of ExperienceAvailabilityStatusActions
{application.name || '-'}{application.email || '-'}{formatDateTime(application.createdAt)}{application.yearsOfExperience != null ? application.yearsOfExperience : '-'}{application.availability || '-'} + + {statusLabel} + + +
+ setSelectedApplication(application)} + /> + {canManage && ( +
+
+ )} +
+ ) +} + +ApplicationsList.defaultProps = { + applications: [], + engagement: null, + isLoading: false, + canManage: false, + onUpdateStatus: () => {} +} + +ApplicationsList.propTypes = { + applications: PropTypes.arrayOf(PropTypes.shape()), + engagement: PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + title: PropTypes.string, + description: PropTypes.string, + applicationDeadline: PropTypes.any + }), + isLoading: PropTypes.bool, + canManage: PropTypes.bool, + onUpdateStatus: PropTypes.func +} + +export default ApplicationsList diff --git a/src/components/Buttons/OutlineButton/index.js b/src/components/Buttons/OutlineButton/index.js index 5bc8140e..e5e270d8 100644 --- a/src/components/Buttons/OutlineButton/index.js +++ b/src/components/Buttons/OutlineButton/index.js @@ -6,12 +6,35 @@ import cn from 'classnames' import styles from './Outline.module.scss' import _ from 'lodash' -const OutlineButton = ({ type, text, link, onClick, url, className, submit, disabled, target = 'self' }) => { +const OutlineButton = ({ type, text, link, onClick, url, className, submit, disabled, target = 'self', rel }) => { + const containerClassName = cn(styles.container, styles[type], className) + + const handleUrlClick = (event) => { + if (disabled) { + event.preventDefault() + event.stopPropagation() + return + } + if (onClick) { + onClick(event) + } + } + + const resolvedRel = (() => { + if (target !== '_blank') { + return rel + } + const relTokens = new Set((rel || '').split(/\s+/).filter(Boolean)) + relTokens.add('noopener') + relTokens.add('noreferrer') + return Array.from(relTokens).join(' ') + })() + if (_.isEmpty(link) && _.isEmpty(url)) { return (