From 671c5851d8a8728ede932017d6ddebe728f0b559 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 5 May 2020 12:35:40 +0300 Subject: [PATCH 1/7] chore: use special Project Service instance --- src/config/constants.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/config/constants.js b/src/config/constants.js index cc7d303b6..b341bc220 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -707,7 +707,8 @@ export const ACCOUNTS_APP_REGISTER_URL = process.env.ACCOUNTS_APP_REGISTER_URL | export const TC_API_URL = `https://api.${DOMAIN}` export const DIRECT_PROJECT_URL = `https://www.${DOMAIN}/direct/projectOverview?formData.projectId=` export const SALESFORCE_PROJECT_LEAD_LINK = process.env.SALESFORCE_PROJECT_LEAD_LINK -export const TC_NOTIFICATION_URL = process.env.TC_NOTIFICATION_URL || `${TC_API_URL}/v5/notifications` +// export const TC_NOTIFICATION_URL = process.env.TC_NOTIFICATION_URL || `${TC_API_URL}/v5/notifications` +export const TC_NOTIFICATION_URL = 'http://167.71.90.133:4000/v5/notifications' export const TC_CDN_URL = process.env.NODE_ENV === 'development' ? 'https://d1aahxkjiobka8.cloudfront.net' : 'https://d2nl5eqipnb33q.cloudfront.net' @@ -752,9 +753,9 @@ export const LS_INCOMPLETE_PROJECT_QUERY_PARAMS = 'incompleteProjectQueryParams' */ export const SPECIAL_QUERY_PARAMS = ['returnUrl', 'refCode'] -export const PROJECTS_API_URL = process.env.PROJECTS_API_URL || TC_API_URL +// export const PROJECTS_API_URL = process.env.PROJECTS_API_URL || TC_API_URL // for local testing Connect App with Project Service, comment the previous line and uncomment the next one -// export const PROJECTS_API_URL = 'http://localhost:8001' +export const PROJECTS_API_URL = 'http://167.71.90.133:8001' export const CONNECT_MESSAGE_API_URL = process.env.CONNECT_MESSAGE_API_URL || TC_API_URL export const NEW_PROJECT_PATH = '/new-project' From 17de46640595338835472a1db720aeece6ac6d8d Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 5 May 2020 12:37:38 +0300 Subject: [PATCH 2/7] chore: hack to test as admin user --- src/actions/loadUser.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/actions/loadUser.js b/src/actions/loadUser.js index bc3b3c896..c96e383d5 100644 --- a/src/actions/loadUser.js +++ b/src/actions/loadUser.js @@ -54,6 +54,8 @@ export function loadUserSuccess(dispatch, token) { currentUser.id = currentUser.userId currentUser.token = token } + // uncomment to add admin role to the user for testing admin functionality locally + // currentUser.roles.push('administrator') if (currentUser) { getUserProfile(currentUser.handle).then((profile) => { currentUser = _.assign(currentUser, profile) From 621406101b0353b46b60e34178a47b245e93f479 Mon Sep 17 00:00:00 2001 From: nqv Date: Tue, 12 May 2020 19:32:00 +0700 Subject: [PATCH 3/7] Milestone Bulk Updates --- src/api/timelines.js | 13 + src/config/constants.js | 5 + src/helpers/milestoneHelper.js | 159 +++++++- src/projects/actions/productsTimelines.js | 372 ++++++++++-------- .../detail/components/timeline/Form/Form.jsx | 2 +- .../FormFieldNumber/FormFieldNumber.jsx | 8 +- .../timeline/Milestone/Milestone.jsx | 2 +- src/projects/reducers/productsTimelines.js | 35 +- 8 files changed, 421 insertions(+), 175 deletions(-) diff --git a/src/api/timelines.js b/src/api/timelines.js index e9270dc7c..f76392791 100644 --- a/src/api/timelines.js +++ b/src/api/timelines.js @@ -53,6 +53,19 @@ export function updateMilestone(timelineId, milestoneId, updatedProps) { .then(resp => resp.data) } +/** + * Bulk update milestones + * + * @param {Number} timelineId timeline id + * @param {Array<{}>} milestones the timeline's milestones + * + * @returns {Promise} milestones + */ +export function updateMilestones(timelineId, milestones) { + return axios.patch(`${PROJECTS_API_URL}/v5/timelines/${timelineId}/milestones`, milestones) + .then(resp => resp.data) +} + /** * Get milestone templates by product template id * diff --git a/src/config/constants.js b/src/config/constants.js index b341bc220..08a4d8d30 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -303,6 +303,11 @@ export const UPDATE_PRODUCT_MILESTONE_PENDING = 'UPDATE_PRODUCT_MILESTONE_PE export const UPDATE_PRODUCT_MILESTONE_FAILURE = 'UPDATE_PRODUCT_MILESTONE_FAILURE' export const UPDATE_PRODUCT_MILESTONE_SUCCESS = 'UPDATE_PRODUCT_MILESTONE_SUCCESS' +export const BULK_UPDATE_PRODUCT_MILESTONES = 'BULK_UPDATE_PRODUCT_MILESTONES' +export const BULK_UPDATE_PRODUCT_MILESTONES_PENDING = 'BULK_UPDATE_PRODUCT_MILESTONES_PENDING' +export const BULK_UPDATE_PRODUCT_MILESTONES_FAILURE = 'BULK_UPDATE_PRODUCT_MILESTONES_FAILURE' +export const BULK_UPDATE_PRODUCT_MILESTONES_SUCCESS = 'BULK_UPDATE_PRODUCT_MILESTONES_SUCCESS' + export const COMPLETE_PRODUCT_MILESTONE = 'COMPLETE_PRODUCT_MILESTONE' export const COMPLETE_PRODUCT_MILESTONE_PENDING = 'COMPLETE_PRODUCT_MILESTONE_PENDING' export const COMPLETE_PRODUCT_MILESTONE_FAILURE = 'COMPLETE_PRODUCT_MILESTONE_FAILURE' diff --git a/src/helpers/milestoneHelper.js b/src/helpers/milestoneHelper.js index 9b3e993e3..1ae729cbb 100644 --- a/src/helpers/milestoneHelper.js +++ b/src/helpers/milestoneHelper.js @@ -1,5 +1,6 @@ import _ from 'lodash' import moment from 'moment' +import update from 'react-addons-update' import { MILESTONE_STATUS } from '../config/constants' import { MILESTONE_STATUS_TEXT } from '../config/constants' @@ -41,4 +42,160 @@ export const getProgressPercent = (totalDays, daysLeft) => { : 100 return progressPercent -} \ No newline at end of file +} + +function mergeJsonObjects(targetObj, sourceObj) { + return _.mergeWith({}, targetObj, sourceObj, (target, source) => { + // Overwrite the array + if (_.isArray(source)) { + return source + } + }) +} + +function updateMilestone(milestone, updatedProps) { + const entityToUpdate = updatedProps + const durationChanged = entityToUpdate.duration && entityToUpdate.duration !== milestone.duration + const statusChanged = entityToUpdate.status && entityToUpdate.status !== milestone.status + const completionDateChanged = entityToUpdate.completionDate + && !_.isEqual(milestone.completionDate, entityToUpdate.completionDate) + const today = moment.utc().hours(0).minutes(0).seconds(0).milliseconds(0) + + // Merge JSON fields + entityToUpdate.details = mergeJsonObjects(milestone.details, entityToUpdate.details) + + let actualStartDateCanged = false + // if status has changed + if (statusChanged) { + // if status has changed to be completed, set the compeltionDate if not provided + if (entityToUpdate.status === MILESTONE_STATUS.COMPLETED) { + entityToUpdate.completionDate = entityToUpdate.completionDate ? entityToUpdate.completionDate : today.toISOString() + entityToUpdate.duration = moment.utc(entityToUpdate.completionDate) + .diff(entityToUpdate.actualStartDate, 'days') + 1 + } + // if status has changed to be active, set the startDate to today + if (entityToUpdate.status === MILESTONE_STATUS.ACTIVE) { + // NOTE: not updating startDate as activating a milestone should not update the scheduled start date + // entityToUpdate.startDate = today + // should update actual start date + entityToUpdate.actualStartDate = today.toISOString() + actualStartDateCanged = true + } + } + + // Updates the end date of the milestone if: + // 1. if duration of the milestone is udpated, update its end date + // OR + // 2. if actual start date is updated, updating the end date of the activated milestone because + // early or late start of milestone, we are essentially changing the end schedule of the milestone + if (durationChanged || actualStartDateCanged) { + const updatedStartDate = actualStartDateCanged ? entityToUpdate.actualStartDate : milestone.startDate + const updatedDuration = _.get(entityToUpdate, 'duration', milestone.duration) + entityToUpdate.endDate = moment.utc(updatedStartDate).add(updatedDuration - 1, 'days').toDate().toISOString() + } + + // if completionDate has changed + if (!statusChanged && completionDateChanged) { + entityToUpdate.duration = moment.utc(entityToUpdate.completionDate) + .diff(entityToUpdate.actualStartDate, 'days') + 1 + entityToUpdate.status = MILESTONE_STATUS.COMPLETED + } + + return update(milestone, {$merge: entityToUpdate}) +} + +/** + * Cascades endDate/completionDate changes to all milestones with a greater order than the given one. + * @param {Object} origMilestone the original milestone that was updated + * @param {Object} updMilestone the milestone that was updated + * @returns {Promise} a promise that resolves to the last found milestone. If no milestone exists with an + * order greater than the passed updMilestone, the promise will resolve to the passed + * updMilestone + */ +function updateComingMilestones(origMilestone, updMilestone, timelineMilestones) { + // flag to indicate if the milestone in picture, is updated for completionDate field or not + const completionDateChanged = !_.isEqual(origMilestone.completionDate, updMilestone.completionDate) + const today = moment.utc().hours(0).minutes(0).seconds(0).milliseconds(0).toISOString() + // updated milestone's start date, pefers actual start date over scheduled start date + const updMSStartDate = updMilestone.actualStartDate ? updMilestone.actualStartDate : updMilestone.startDate + // calculates schedule end date for the milestone based on start date and duration + let updMilestoneEndDate = moment.utc(updMSStartDate).add(updMilestone.duration - 1, 'days').toDate() + // if the milestone, in context, is completed, overrides the end date to the completion date + updMilestoneEndDate = updMilestone.completionDate ? updMilestone.completionDate : updMilestoneEndDate + + const affectedMilestones = timelineMilestones.filter(milestone => milestone.order > updMilestone.order) + const comingMilestones = _.sortBy(affectedMilestones, 'order') + // calculates the schedule start date for the next milestone + let startDate = moment.utc(updMilestoneEndDate).add(1, 'days').toDate().toISOString() + let firstMilestoneFound = false + + let updatedTimelineMilestones = timelineMilestones + for (let i = 0; i < comingMilestones.length; i += 1) { + const updateProps = {} + const milestone = comingMilestones[i] + + // Update the milestone startDate if different than the iterated startDate + if (!_.isEqual(milestone.startDate, startDate)) { + updateProps.startDate = startDate + updateProps.updatedBy = updMilestone.updatedBy + } + + // Calculate the endDate, and update it if different + const endDate = moment.utc(updateProps.startDate || milestone.startDate).add(milestone.duration - 1, 'days').toDate().toISOString() + if (!_.isEqual(milestone.endDate, endDate)) { + updateProps.endDate = endDate + updateProps.updatedBy = updMilestone.updatedBy + } + + // if completionDate is alerted, update status of the first non hidden milestone after the current one + if (!firstMilestoneFound && completionDateChanged && !milestone.hidden) { + // activate next milestone + updateProps.status = MILESTONE_STATUS.ACTIVE + updateProps.actualStartDate = today + firstMilestoneFound = true + } + + // if milestone is not hidden, update the startDate for the next milestone, otherwise keep the same startDate for next milestone + if (!milestone.hidden) { + // Set the next startDate value to the next day after completionDate if present or the endDate + startDate = moment.utc(milestone.completionDate + ? milestone.completionDate + : updateProps.endDate || milestone.endDate).add(1, 'days').toDate().toISOString() + } + + const milestoneIdx = updatedTimelineMilestones.findIndex(item => item.id === milestone.id) + updatedTimelineMilestones = update(updatedTimelineMilestones, {[milestoneIdx]: {$merge: updateProps}}) + } + + return updatedTimelineMilestones +} + +function cascadeMilestones(originalMilestone, updatedMilestone, timelineMilestones) { + const original = originalMilestone + const updated = updatedMilestone + + // we need to recalculate change in fields because we update some fields before making actual update + const needToCascade = !_.isEqual(original.completionDate, updated.completionDate) // completion date changed + || original.duration !== updated.duration // duration changed + || original.actualStartDate !== updated.actualStartDate // actual start date updated + + if (needToCascade) { + const updatedMilestones = updateComingMilestones(original, updated, timelineMilestones) + return updatedMilestones + } + + return timelineMilestones +} + +export const processUpdateMilestone = (milestone, updatedProps, timelineMilestones) => { + let updatedTimelineMilestones + + const updatedMilestone = updateMilestone(milestone, updatedProps) + + const milestoneIdx = timelineMilestones.findIndex(item => item.id === updatedMilestone.id) + updatedTimelineMilestones = update(timelineMilestones, { [milestoneIdx]: { $set: updatedMilestone } }) + + updatedTimelineMilestones = cascadeMilestones(milestone, updatedMilestone, updatedTimelineMilestones) + + return { updatedMilestone, updatedTimelineMilestones } +} diff --git a/src/projects/actions/productsTimelines.js b/src/projects/actions/productsTimelines.js index 2d39818e9..e88f3998c 100644 --- a/src/projects/actions/productsTimelines.js +++ b/src/projects/actions/productsTimelines.js @@ -5,20 +5,30 @@ import _ from 'lodash' import { getTimelinesByReference, getTimelineById, - updateMilestone, + updateMilestones, updateTimeline, } from '../../api/timelines' import {updatePhase} from './project' import { LOAD_PRODUCT_TIMELINE_WITH_MILESTONES, - UPDATE_PRODUCT_MILESTONE, - COMPLETE_PRODUCT_MILESTONE, - EXTEND_PRODUCT_MILESTONE, - SUBMIT_FINAL_FIXES_REQUEST, + UPDATE_PRODUCT_MILESTONE_PENDING, + UPDATE_PRODUCT_MILESTONE_SUCCESS, + UPDATE_PRODUCT_MILESTONE_FAILURE, + COMPLETE_PRODUCT_MILESTONE_PENDING, + COMPLETE_PRODUCT_MILESTONE_SUCCESS, + COMPLETE_PRODUCT_MILESTONE_FAILURE, + EXTEND_PRODUCT_MILESTONE_PENDING, + EXTEND_PRODUCT_MILESTONE_SUCCESS, + EXTEND_PRODUCT_MILESTONE_FAILURE, + SUBMIT_FINAL_FIXES_REQUEST_PENDING, + SUBMIT_FINAL_FIXES_REQUEST_SUCCESS, + SUBMIT_FINAL_FIXES_REQUEST_FAILURE, MILESTONE_STATUS, UPDATE_PRODUCT_TIMELINE, - PHASE_STATUS_COMPLETED + PHASE_STATUS_COMPLETED, + BULK_UPDATE_PRODUCT_MILESTONES, } from '../../config/constants' +import { processUpdateMilestone } from '../../helpers/milestoneHelper' /** * Get the next milestone in the list, which is not hidden @@ -42,13 +52,13 @@ function getNextNotHiddenMilestone(milestones, currentMilestoneIndex) { /** * Check if the milestone is last non-hidden milestone in the timeline or no * - * @param {Object} timeline timeline + * @param {Object} milestones timeline's milestones * @param {Number} milestoneIdx milestone index * * @returns {Boolean} true if milestone is last non-hidden */ -function checkIfLastMilestone(timeline, milestoneIdx) { - return _.slice(timeline.milestones, milestoneIdx + 1).filter(m => !m.hidden).length === 0 +function checkIfLastMilestone(milestones, milestoneIdx) { + return _.slice(milestones, milestoneIdx + 1).filter(m => !m.hidden).length === 0 } /** @@ -97,30 +107,35 @@ export function loadProductTimelineWithMilestonesById(timelineId, productId) { */ export function updateProductMilestone(productId, timelineId, milestoneId, updatedProps) { return (dispatch, getState) => { - const timeline = getState().productsTimelines[productId] - const milestoneIdx = _.findIndex(timeline.timeline.milestones, { id: milestoneId }) - const milestone = timeline.timeline.milestones[milestoneIdx] - const isDurationUpdated = ( - !_.isUndefined(updatedProps.duration) && - milestone.duration !== updatedProps.duration - ) + const timeline = getState().productsTimelines[productId].timeline + const milestoneIdx = _.findIndex(timeline.milestones, { id: milestoneId }) + const milestone = timeline.milestones[milestoneIdx] + const updatedTimelineMilestones = processUpdateMilestone(milestone, updatedProps, timeline.milestones).updatedTimelineMilestones - return dispatch({ - type: UPDATE_PRODUCT_MILESTONE, - payload: updateMilestone(timelineId, milestoneId, updatedProps), + dispatch({ + type: UPDATE_PRODUCT_MILESTONE_PENDING, meta: { productId, milestoneId, } + }) + + const milestones = updatedTimelineMilestones.map(item => _.omit(item, ['timelineId', 'error', 'isUpdating', 'statusHistory'])) + return dispatch({ + type: BULK_UPDATE_PRODUCT_MILESTONES, + payload: updateMilestones(timelineId, milestones), + meta: { productId } }).then(() => { - if (timeline) { - const isLastMilestone = checkIfLastMilestone(timeline.timeline, milestoneIdx) - // if milestone duration was updated and it's not the last milestone - // we have to refresh timeline as other milestone dates were updated by the server - if (isDurationUpdated && !isLastMilestone) { - dispatch(loadProductTimelineWithMilestonesById(timeline.timeline.id, productId)) - } - } + dispatch({ + type: UPDATE_PRODUCT_MILESTONE_SUCCESS, + meta: { productId, milestoneId } + }) + }).catch((error) => { + dispatch({ + type: UPDATE_PRODUCT_MILESTONE_FAILURE, + meta: { productId, milestoneId } + }) + throw error }) } } @@ -159,65 +174,69 @@ export function completeProductMilestone(productId, timelineId, milestoneId, upd const state = getState() const timeline = state.productsTimelines[productId].timeline const milestoneIdx = _.findIndex(timeline.milestones, { id: milestoneId }) - const nextMilestone = getNextNotHiddenMilestone(timeline.milestones, milestoneIdx) + const milestone = timeline.milestones[milestoneIdx] - const requests = [ - updateMilestone(timelineId, milestoneId, { + const result = processUpdateMilestone( + milestone, { ...updatedProps, // optional props to update - status: MILESTONE_STATUS.COMPLETED, - }).then((completedMilestone) => { - // TODO $TIMELINE_MILESTONE$ updating of the next milestone could be done in parallel - // but due to the backend issue https://github.com/topcoder-platform/tc-project-service/issues/162 - // we do in sequentially for now - if (nextMilestone) { - // NOTE we wait until the next milestone is also updated before fire COMPLETE_PRODUCT_MILESTONE - const details = { - ...nextMilestone.details, - prevMilestoneContent: completedMilestone.details.content, - prevMilestoneType: completedMilestone.type, - } - if ( ((nextMilestone.type === 'checkpoint-review' || nextMilestone.type === 'final-designs') // case # 2 - && completedMilestone.type === 'add-links' ) || - ((nextMilestone.type === 'delivery-design' || nextMilestone.type === 'delivery-dev') // case # 4 - && completedMilestone.type !== 'final-fix' ) ) { - details.metadata = { - ..._.get(nextMilestone.details, 'metadata', {}), - waitingForCustomer: true - } - } - return updateMilestone(timelineId, nextMilestone.id, { - details - // always return completedMilestone for COMPLETE_PRODUCT_MILESTONE - }).then(() => completedMilestone) - } else { - // always return completedMilestone for COMPLETE_PRODUCT_MILESTONE - return completedMilestone + status: MILESTONE_STATUS.COMPLETED + }, timeline.milestones + ) + + let updatedTimelineMilestones = result.updatedTimelineMilestones + const completedMilestone = result.updatedMilestone + const nextMilestone = getNextNotHiddenMilestone(updatedTimelineMilestones, milestoneIdx) + + if (nextMilestone) { + const details = { + ...nextMilestone.details, + prevMilestoneContent: completedMilestone.details.content, + prevMilestoneType: completedMilestone.type, + } + if ( ((nextMilestone.type === 'checkpoint-review' || nextMilestone.type === 'final-designs') // case # 2 + && completedMilestone.type === 'add-links' ) || + ((nextMilestone.type === 'delivery-design' || nextMilestone.type === 'delivery-dev') // case # 4 + && completedMilestone.type !== 'final-fix' ) ) { + details.metadata = { + ..._.get(nextMilestone.details, 'metadata', {}), + waitingForCustomer: true } - }) - ] + } + updatedTimelineMilestones = processUpdateMilestone(nextMilestone, { details }, updatedTimelineMilestones).updatedTimelineMilestones + } + + dispatch({ + type: COMPLETE_PRODUCT_MILESTONE_PENDING, + meta: { productId, milestoneId } + }) + + const milestones = updatedTimelineMilestones.map(milestone => _.omit(milestone, ['timelineId', 'error', 'isUpdating', 'statusHistory'])) return dispatch({ - type: COMPLETE_PRODUCT_MILESTONE, - payload: Promise.all(requests), - meta: { - productId, - milestoneId - } + type: BULK_UPDATE_PRODUCT_MILESTONES, + payload: updateMilestones(timelineId, milestones), + meta: { productId } }).then(() => { - if (timeline) { - const milestoneIdx = _.findIndex(timeline.milestones, { id: milestoneId }) - const isLastMilestone = checkIfLastMilestone(timeline, milestoneIdx) - if (isLastMilestone){ - const phaseIndex = _.findIndex(state.projectState.phases, p => p.products[0].id === productId) - const phase = state.projectState.phases[phaseIndex] - dispatch(updatePhase(state.projectState.project.id, phase.id, {status: PHASE_STATUS_COMPLETED}, phaseIndex)) - } else { - // if it's not the last milestone - // we have to refresh timeline as other milestone dates were updated by the server - dispatch(loadProductTimelineWithMilestonesById(timeline.id, productId)) - } + const milestoneIdx = _.findIndex(updatedTimelineMilestones, { id: milestoneId }) + const isLastMilestone = checkIfLastMilestone(updatedTimelineMilestones, milestoneIdx) + if (isLastMilestone){ + const phaseIndex = _.findIndex(state.projectState.phases, p => p.products[0].id === productId) + const phase = state.projectState.phases[phaseIndex] + dispatch(updatePhase(state.projectState.project.id, phase.id, {status: PHASE_STATUS_COMPLETED}, phaseIndex)) } + + dispatch({ + type: COMPLETE_PRODUCT_MILESTONE_SUCCESS, + meta: { productId, milestoneId } + }) + return true + }).catch((error) => { + dispatch({ + type: COMPLETE_PRODUCT_MILESTONE_FAILURE, + meta: { productId, milestoneId } + }) + throw error }) } } @@ -233,33 +252,40 @@ export function completeProductMilestone(productId, timelineId, milestoneId, upd */ export function extendProductMilestone(productId, timelineId, milestoneId, extendDuration, updatedProps = {}) { return (dispatch, getState) => { - const timeline = getState().productsTimelines[productId] - const milestoneIdx = _.findIndex(timeline.timeline.milestones, { id: milestoneId }) - const milestone = timeline.timeline.milestones[milestoneIdx] + const timeline = getState().productsTimelines[productId].timeline + const milestoneIdx = _.findIndex(timeline.milestones, { id: milestoneId }) + const milestone = timeline.milestones[milestoneIdx] - const requests = [ - updateMilestone(timelineId, milestoneId, { + const updatedTimelineMilestones = processUpdateMilestone( + milestone, + { ...updatedProps, // optional props to update - duration: milestone.duration + extendDuration, - }) - ] + duration: milestone.duration + extendDuration + }, + timeline.milestones + ).updatedTimelineMilestones + + dispatch({ + type: EXTEND_PRODUCT_MILESTONE_PENDING, + meta: { productId, milestoneId } + }) + const milestones = updatedTimelineMilestones.map(milestone => _.omit(milestone, ['timelineId', 'error', 'isUpdating', 'statusHistory'])) return dispatch({ - type: EXTEND_PRODUCT_MILESTONE, - payload: Promise.all(requests), - meta: { - productId, - milestoneId, - } + type: BULK_UPDATE_PRODUCT_MILESTONES, + payload: updateMilestones(timelineId, milestones), + meta: { productId } }).then(() => { - if (timeline) { - const isLastMilestone = checkIfLastMilestone(timeline.timeline, milestoneIdx) - // if it's not the last milestone - // we have to refresh timeline as other milestone dates were updated by the server - if (!isLastMilestone) { - dispatch(loadProductTimelineWithMilestonesById(timeline.timeline.id, productId)) - } - } + dispatch({ + type: EXTEND_PRODUCT_MILESTONE_SUCCESS, + meta: { productId, milestoneId } + }) + }).catch((error) => { + dispatch({ + type: EXTEND_PRODUCT_MILESTONE_FAILURE, + meta: { productId, milestoneId } + }) + throw error }) } } @@ -276,22 +302,18 @@ export function extendProductMilestone(productId, timelineId, milestoneId, exten */ export function submitFinalFixesRequest(productId, timelineId, milestoneId, finalFixRequests) { return (dispatch, getState) => { - const timeline = getState().productsTimelines[productId] - const milestoneIdx = _.findIndex(timeline.timeline.milestones, { id: milestoneId }) - const milestone = timeline.timeline.milestones[milestoneIdx] + const timeline = getState().productsTimelines[productId].timeline + const milestoneIdx = _.findIndex(timeline.milestones, { id: milestoneId }) + const milestone = timeline.milestones[milestoneIdx] - const finalFixesMilestone = timeline.timeline.milestones[milestoneIdx - 1] + let finalFixesMilestone = timeline.milestones[milestoneIdx - 1] if (!finalFixesMilestone || finalFixesMilestone.type !== 'final-fix') { throw new Error('Cannot find final-fix milestone.') } - // to update using reducer in redux store - const nextMilestone = finalFixesMilestone - - return dispatch({ - type: SUBMIT_FINAL_FIXES_REQUEST, - payload: updateMilestone(timelineId, milestoneId, { + let updatedTimelineMilestones = processUpdateMilestone( + milestone, { status: MILESTONE_STATUS.PLANNED, details: { ...milestone.details, @@ -304,28 +326,48 @@ export function submitFinalFixesRequest(productId, timelineId, milestoneId, fina isFinalFixesSubmitted: true } } - }).then((deliveryMilestone) => { - // show final fixes milestone - return updateMilestone(timelineId, finalFixesMilestone.id, { - status: MILESTONE_STATUS.ACTIVE, - hidden: false, - details: { - ...finalFixesMilestone.details, + }, timeline.milestones + ).updatedTimelineMilestones + + finalFixesMilestone = updatedTimelineMilestones[milestoneIdx - 1] - content: { - ..._.get(finalFixesMilestone, 'details.content', {}), - finalFixRequests, - } + updatedTimelineMilestones = processUpdateMilestone( + finalFixesMilestone, { + status: MILESTONE_STATUS.ACTIVE, + hidden: false, + details: { + ...finalFixesMilestone.details, + content: { + ..._.get(finalFixesMilestone, 'details.content', {}), + finalFixRequests, } - }).then((finalFixMilestone) => { - return [deliveryMilestone, finalFixMilestone] - }) - }), + } + }, updatedTimelineMilestones + ).updatedTimelineMilestones + + dispatch({ + type: SUBMIT_FINAL_FIXES_REQUEST_PENDING, + meta: { productId, milestoneId } + }) + + const milestones = updatedTimelineMilestones.map(milestone => _.omit(milestone, ['timelineId', 'error', 'isUpdating', 'statusHistory'])) + return dispatch({ + type: BULK_UPDATE_PRODUCT_MILESTONES, + payload: updateMilestones(timelineId, milestones), meta: { productId, - milestoneId, - nextMilestoneId: nextMilestone ? nextMilestone.id : null, } + }).then(() => { + dispatch({ + type: SUBMIT_FINAL_FIXES_REQUEST_SUCCESS, + meta: { productId, milestoneId } + }) + }).catch((error) => { + dispatch({ + type: SUBMIT_FINAL_FIXES_REQUEST_FAILURE, + meta: { productId, milestoneId } + }) + throw error }) } } @@ -343,47 +385,61 @@ export function completeFinalFixesMilestone(productId, timelineId, milestoneId, const state = getState() const timeline = state.productsTimelines[productId].timeline const milestoneIdx = _.findIndex(timeline.milestones, { id: milestoneId }) - const nextMilestone = getNextNotHiddenMilestone(timeline.milestones, milestoneIdx) + const milestone = timeline.milestones[milestoneIdx] - const requests = [ - updateMilestone(timelineId, milestoneId, { + const result = processUpdateMilestone( + milestone, { ...updatedProps, // optional props to update status: MILESTONE_STATUS.COMPLETED, - }).then((completedMilestone) => { - if (nextMilestone) { - // NOTE we wait until the delivery milestone is also updated before fire COMPLETE_PRODUCT_MILESTONE - return updateMilestone(timelineId, nextMilestone.id, { - details: { - ...nextMilestone.details, - prevMilestoneContent: completedMilestone.details.content, - prevMilestoneType: completedMilestone.type, - }, - status: MILESTONE_STATUS.COMPLETED, - // always return completedMilestone for COMPLETE_PRODUCT_MILESTONE - }).then(() => completedMilestone) - } else { - // always return completedMilestone for COMPLETE_PRODUCT_MILESTONE - return completedMilestone - } - }) - ] + }, + timeline.milestones + ) + let updatedTimelineMilestones = result.updatedTimelineMilestones + const completedMilestone = result.updatedMilestone + const nextMilestone = getNextNotHiddenMilestone(updatedTimelineMilestones, milestoneIdx) + + if (nextMilestone) { + updatedTimelineMilestones = processUpdateMilestone( + nextMilestone, { + details: { + ...nextMilestone.details, + prevMilestoneContent: completedMilestone.details.content, + prevMilestoneType: completedMilestone.type, + }, + status: MILESTONE_STATUS.COMPLETED, + }, updatedTimelineMilestones + ).updatedTimelineMilestones + } + dispatch({ + type: COMPLETE_PRODUCT_MILESTONE_PENDING, + meta: { productId, milestoneId } + }) + + const milestones = updatedTimelineMilestones.map(milestone => _.omit(milestone, ['timelineId', 'error', 'isUpdating', 'statusHistory'])) return dispatch({ - type: COMPLETE_PRODUCT_MILESTONE, - payload: Promise.all(requests), + type: BULK_UPDATE_PRODUCT_MILESTONES, + payload: updateMilestones(timelineId, milestones), meta: { productId, - milestoneId } }).then(() => { - if (timeline) { - const phaseIndex = _.findIndex(state.projectState.phases, p => p.products[0].id === productId) - const phase = state.projectState.phases[phaseIndex] - dispatch(updatePhase(state.projectState.project.id, phase.id, {status: PHASE_STATUS_COMPLETED}, phaseIndex)) - // we have to refresh timeline as other milestone dates were updated by the server - dispatch(loadProductTimelineWithMilestonesById(timeline.id, productId)) - } + const phaseIndex = _.findIndex(state.projectState.phases, p => p.products[0].id === productId) + const phase = state.projectState.phases[phaseIndex] + dispatch(updatePhase(state.projectState.project.id, phase.id, {status: PHASE_STATUS_COMPLETED}, phaseIndex)) + + dispatch({ + type: COMPLETE_PRODUCT_MILESTONE_SUCCESS, + meta: { productId, milestoneId } + }) + return true + }).catch((error) => { + dispatch({ + type: COMPLETE_PRODUCT_MILESTONE_FAILURE, + meta: { productId, milestoneId } + }) + throw error }) } } diff --git a/src/projects/detail/components/timeline/Form/Form.jsx b/src/projects/detail/components/timeline/Form/Form.jsx index 4e88cbd02..39953c2d4 100644 --- a/src/projects/detail/components/timeline/Form/Form.jsx +++ b/src/projects/detail/components/timeline/Form/Form.jsx @@ -128,7 +128,7 @@ Form.propTypes = { cancelButtonTitle: PT.string, fields: PT.arrayOf(PT.shape({ name: PT.string.isRequired, - value: PT.string, + value: PT.oneOfType([PT.string, PT.number]), })).isRequired, onCancelClick: PT.func.isRequired, onSubmit: PT.func.isRequired, diff --git a/src/projects/detail/components/timeline/FormFieldNumber/FormFieldNumber.jsx b/src/projects/detail/components/timeline/FormFieldNumber/FormFieldNumber.jsx index 44a1f68f3..10d868647 100644 --- a/src/projects/detail/components/timeline/FormFieldNumber/FormFieldNumber.jsx +++ b/src/projects/detail/components/timeline/FormFieldNumber/FormFieldNumber.jsx @@ -10,6 +10,10 @@ const TCFormFields = FormsyForm.Fields import './FormFieldNumber.scss' const FormFieldNumber = (props) => { + let ref + const setValue = (value) => { + ref && ref.setValue(+value) + } return (
@@ -17,10 +21,10 @@ const FormFieldNumber = (props) => {
- + { ref = textInput }} setValue={setValue} />
) } -export default FormFieldNumber \ No newline at end of file +export default FormFieldNumber diff --git a/src/projects/detail/components/timeline/Milestone/Milestone.jsx b/src/projects/detail/components/timeline/Milestone/Milestone.jsx index 52948fb0b..cc32e09bd 100644 --- a/src/projects/detail/components/timeline/Milestone/Milestone.jsx +++ b/src/projects/detail/components/timeline/Milestone/Milestone.jsx @@ -251,7 +251,7 @@ class Milestone extends React.Component { placeholder: 'Duration', label: 'Duration', name: 'duration', - value: String(milestone.duration || 0), + value: milestone.duration || 0, validations: { isRequired: true }, diff --git a/src/projects/reducers/productsTimelines.js b/src/projects/reducers/productsTimelines.js index 60476f054..53820f0fb 100644 --- a/src/projects/reducers/productsTimelines.js +++ b/src/projects/reducers/productsTimelines.js @@ -9,6 +9,7 @@ import { UPDATE_PRODUCT_MILESTONE_PENDING, UPDATE_PRODUCT_MILESTONE_SUCCESS, UPDATE_PRODUCT_MILESTONE_FAILURE, + BULK_UPDATE_PRODUCT_MILESTONES_SUCCESS, UPDATE_PRODUCT_TIMELINE_PENDING, UPDATE_PRODUCT_TIMELINE_SUCCESS, UPDATE_PRODUCT_TIMELINE_FAILURE, @@ -106,6 +107,24 @@ function updateTimeline(state, productId, updateTimeline, shouldReplace = false) }) } +/** + * Replace the timeline's milestones + * + * @param {Object} state The state + * @param {Number} productId The product id + * @param {Array<{}>} milestones The timeline's milestones + * + * @returns {Object} The state + */ +function updateMilestones(state, productId, milestones) { + milestones = _.sortBy(milestones, 'order') + return update(state, { + [productId]: { + timeline: {milestones: { $set: milestones }} + } + }) +} + export const productsTimelines = (state=initialState, action) => { const { type, payload, meta } = action @@ -191,14 +210,15 @@ export const productsTimelines = (state=initialState, action) => { }) case UPDATE_PRODUCT_MILESTONE_SUCCESS: - return updateMilestone(state, meta.productId, meta.milestoneId, payload, true) - case UPDATE_PRODUCT_MILESTONE_FAILURE: return updateMilestone(state, meta.productId, meta.milestoneId, { isUpdating: { $set: false }, error: { $set: false } }) + case BULK_UPDATE_PRODUCT_MILESTONES_SUCCESS: + return updateMilestones(state, meta.productId, payload) + case UPDATE_PRODUCT_TIMELINE_PENDING: return updateTimeline(state, meta.productId, { isUpdating: { $set: true }, @@ -224,16 +244,7 @@ export const productsTimelines = (state=initialState, action) => { case COMPLETE_PRODUCT_MILESTONE_SUCCESS: case EXTEND_PRODUCT_MILESTONE_SUCCESS: - case SUBMIT_FINAL_FIXES_REQUEST_SUCCESS: { - let updatedState = updateMilestone(state, meta.productId, meta.milestoneId, payload[0], true) - - if (meta.nextMilestoneId) { - updatedState = updateMilestone(updatedState, meta.productId, meta.nextMilestoneId, payload[1], true) - } - - return updatedState - } - + case SUBMIT_FINAL_FIXES_REQUEST_SUCCESS: case COMPLETE_PRODUCT_MILESTONE_FAILURE: case EXTEND_PRODUCT_MILESTONE_FAILURE: case SUBMIT_FINAL_FIXES_REQUEST_FAILURE: From 706e7090e81ccc1f1abbd992e16c2e7bdc4c7dc4 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 12 May 2020 15:59:29 +0300 Subject: [PATCH 4/7] chore: deploy branch to TEST env branch "feature/bulk-milestone-updates" --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index b062b4d44..443b21887 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -136,7 +136,7 @@ workflows: - build-dev filters: branches: - only: ['feature/permissions'] + only: ['feature/bulk-milestone-updates'] - deployProd: context : org-global From d40c8bb1bd0bcbc054392f1bd30389aa14c2784f Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 12 May 2020 17:01:06 +0300 Subject: [PATCH 5/7] chore: use https on demo backend --- src/config/constants.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/constants.js b/src/config/constants.js index 08a4d8d30..7d8c0bc9f 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -713,7 +713,7 @@ export const TC_API_URL = `https://api.${DOMAIN}` export const DIRECT_PROJECT_URL = `https://www.${DOMAIN}/direct/projectOverview?formData.projectId=` export const SALESFORCE_PROJECT_LEAD_LINK = process.env.SALESFORCE_PROJECT_LEAD_LINK // export const TC_NOTIFICATION_URL = process.env.TC_NOTIFICATION_URL || `${TC_API_URL}/v5/notifications` -export const TC_NOTIFICATION_URL = 'http://167.71.90.133:4000/v5/notifications' +export const TC_NOTIFICATION_URL = 'https://dev.maxceem.com/v5/notifications' export const TC_CDN_URL = process.env.NODE_ENV === 'development' ? 'https://d1aahxkjiobka8.cloudfront.net' : 'https://d2nl5eqipnb33q.cloudfront.net' @@ -760,7 +760,7 @@ export const SPECIAL_QUERY_PARAMS = ['returnUrl', 'refCode'] // export const PROJECTS_API_URL = process.env.PROJECTS_API_URL || TC_API_URL // for local testing Connect App with Project Service, comment the previous line and uncomment the next one -export const PROJECTS_API_URL = 'http://167.71.90.133:8001' +export const PROJECTS_API_URL = 'https://dev.maxceem.com:8001' export const CONNECT_MESSAGE_API_URL = process.env.CONNECT_MESSAGE_API_URL || TC_API_URL export const NEW_PROJECT_PATH = '/new-project' From f061325ae4e386ca26beec18e27ca5b06dd3df9a Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 12 May 2020 17:11:44 +0300 Subject: [PATCH 6/7] chore: fix demo port --- src/config/constants.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/constants.js b/src/config/constants.js index 7d8c0bc9f..147eaa4b8 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -760,7 +760,7 @@ export const SPECIAL_QUERY_PARAMS = ['returnUrl', 'refCode'] // export const PROJECTS_API_URL = process.env.PROJECTS_API_URL || TC_API_URL // for local testing Connect App with Project Service, comment the previous line and uncomment the next one -export const PROJECTS_API_URL = 'https://dev.maxceem.com:8001' +export const PROJECTS_API_URL = 'https://dev.maxceem.com' export const CONNECT_MESSAGE_API_URL = process.env.CONNECT_MESSAGE_API_URL || TC_API_URL export const NEW_PROJECT_PATH = '/new-project' From 7c60795c304de41cbfe92115a4be9e2bba2e53dc Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Fri, 15 May 2020 09:03:18 +0300 Subject: [PATCH 7/7] fix: show error on milestone actions --- src/projects/actions/productsTimelines.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/projects/actions/productsTimelines.js b/src/projects/actions/productsTimelines.js index e88f3998c..25e44d253 100644 --- a/src/projects/actions/productsTimelines.js +++ b/src/projects/actions/productsTimelines.js @@ -133,6 +133,7 @@ export function updateProductMilestone(productId, timelineId, milestoneId, updat }).catch((error) => { dispatch({ type: UPDATE_PRODUCT_MILESTONE_FAILURE, + payload: error, meta: { productId, milestoneId } }) throw error @@ -234,6 +235,7 @@ export function completeProductMilestone(productId, timelineId, milestoneId, upd }).catch((error) => { dispatch({ type: COMPLETE_PRODUCT_MILESTONE_FAILURE, + payload: error, meta: { productId, milestoneId } }) throw error @@ -283,6 +285,7 @@ export function extendProductMilestone(productId, timelineId, milestoneId, exten }).catch((error) => { dispatch({ type: EXTEND_PRODUCT_MILESTONE_FAILURE, + payload: error, meta: { productId, milestoneId } }) throw error @@ -365,6 +368,7 @@ export function submitFinalFixesRequest(productId, timelineId, milestoneId, fina }).catch((error) => { dispatch({ type: SUBMIT_FINAL_FIXES_REQUEST_FAILURE, + payload: error, meta: { productId, milestoneId } }) throw error @@ -437,6 +441,7 @@ export function completeFinalFixesMilestone(productId, timelineId, milestoneId, }).catch((error) => { dispatch({ type: COMPLETE_PRODUCT_MILESTONE_FAILURE, + payload: error, meta: { productId, milestoneId } }) throw error