From 5c3b34d4f98889d361f6f2556481bc6854cb3e0f Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Mon, 4 May 2020 14:49:30 +0300 Subject: [PATCH 01/20] feat: SelectDropdown support placeholder --- .../SelectDropdown/SelectDropdownBase.jsx | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/components/SelectDropdown/SelectDropdownBase.jsx b/src/components/SelectDropdown/SelectDropdownBase.jsx index 3c49225da..2c7243f91 100644 --- a/src/components/SelectDropdown/SelectDropdownBase.jsx +++ b/src/components/SelectDropdown/SelectDropdownBase.jsx @@ -24,16 +24,15 @@ class SelectDropdown extends PureComponent { } componentWillMount() { - this.updateSelectedOptionValue(_.toString(this.props.value)) + this.updateSelectedOptionValue(this.props.value) } updateSelectedOptionValue(value) { const { options } = this.props - let selectedOption = _.find(options, (o) => o.value === value) - if (!selectedOption) { - selectedOption = options[0] - } + + const selectedOption = _.find(options, { value }) || null + this.setState({ selectedOption }/*, function() { @@ -89,9 +88,9 @@ class SelectDropdown extends PureComponent { } render() { - const { options, theme, disabled } = this.props + const { options, theme, disabled, placeholder } = this.props const { selectedOption, confirmOption } = this.state - const selectedValue = selectedOption.title + const selectedValue = selectedOption ? selectedOption.title : placeholder const renderOption = (option, optIdx) => { if (option.hide) { return null @@ -160,18 +159,28 @@ class SelectDropdown extends PureComponent { } } +const valuePropType = PT.oneOfType([PT.string, PT.number]) + +SelectDropdown.defaultProps = { + placeholder: ' - Select - ' +} + SelectDropdown.propTypes = { onSelect : PT.func, options : PT.arrayOf(PT.shape({ title: PT.string.isRequired, - value: PT.string.isRequired, + value: valuePropType.isRequired, disabled: PT.bool, hide: PT.bool, confirm: PT.oneOfType([PT.string, PT.bool]), toolTipMessage: PT.string, })).isRequired, theme : PT.string, - selectedOption : PT.object + value : valuePropType, + /** + * Placeholder to show when there is no selected option + */ + placeholder: PT.string, } export default SelectDropdown From 42a36458a27a5de4a821f0cddfcbf6ac0aa8b5de Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Mon, 4 May 2020 14:55:37 +0300 Subject: [PATCH 02/20] fix: metadata default dropdown option - Don't show first option in dropdown when there is no value --- .../metadata/components/MetaDataPanel.jsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/routes/metadata/components/MetaDataPanel.jsx b/src/routes/metadata/components/MetaDataPanel.jsx index a27760215..b4e6fce1e 100644 --- a/src/routes/metadata/components/MetaDataPanel.jsx +++ b/src/routes/metadata/components/MetaDataPanel.jsx @@ -335,7 +335,7 @@ class MetaDataPanel extends React.Component { getVersionOptions(versionOptions) { return _.map(versionOptions, (versionOption) => { return { - value: _.toString(versionOption.version), + value: versionOption.version, title: _.toString(versionOption.version) } }) @@ -350,8 +350,8 @@ class MetaDataPanel extends React.Component { const metadata = this.getMetadata(props) if (metadataType === 'productTemplate') { const prodCatOptions = this.getProductCategoryOptions(templates.productCategories) - const categoryValue = metadata && metadata.category ? metadata.category : prodCatOptions[0].value - const subCategoryValue = metadata && metadata.subCategory ? metadata.subCategory : prodCatOptions[0].value + const categoryValue = metadata && metadata.category + const subCategoryValue = metadata && metadata.subCategory fields = fields.concat([ { key: 'id', type: 'number' }, { key: 'name', type: 'text' }, @@ -367,16 +367,16 @@ class MetaDataPanel extends React.Component { { key: 'isAddOn', type: 'checkbox' }, ]) } else if (metadataType === 'projectTemplate') { - const projectTypeOptions = this.getProductCategoryOptions(templates.projectTypes) - const value = metadata && metadata.category ? metadata.category : projectTypeOptions[0].value - const subCategoryVal = metadata && metadata.subCategory ? metadata.subCategory : '' + const projectTypeOptions = [{title: '--', value: null}, ...this.getProductCategoryOptions(templates.projectTypes)] + const value = metadata && metadata.category + const subCategoryVal = metadata && metadata.subCategory const metadataVal = metadata && metadata.metadataVal ? metadata.metadataVal : {} fields = fields.concat([ { key: 'id', type: 'number' }, { key: 'name', type: 'text' }, { key: 'key', type: 'text' }, { key: 'category', type: 'dropdown', options: projectTypeOptions, value }, - { key: 'subCategory', type: 'dropdown', options: projectTypeOptions.concat({title: '--', value: ''}), value: subCategoryVal }, + { key: 'subCategory', type: 'dropdown', options: projectTypeOptions, value: subCategoryVal }, { key: 'icon', type: 'text' }, { key: 'question', type: 'text' }, { key: 'info', type: 'text' }, @@ -406,7 +406,7 @@ class MetaDataPanel extends React.Component { ]) } else { const projectVersionOptions = this.getVersionOptions(templates.versionOptions) - const value = metadata && metadata.version ? metadata.version : '' + const value = metadata && metadata.version fields = fields.concat([ { key: 'key', type: 'text' }, { key: 'version', type: 'dropdown', options: projectVersionOptions, value }, @@ -433,6 +433,8 @@ class MetaDataPanel extends React.Component { } }) + const productTemplateValue = metadata && metadata.referenceId + fields = fields.concat([ { key: 'name', type: 'text' }, { key: 'description', type: 'textarea' }, @@ -444,7 +446,7 @@ class MetaDataPanel extends React.Component { { key: 'completedText', type: 'textarea' }, { key: 'blockedText', type: 'textarea' }, { key: 'reference', type: 'text', readonly: true, value: 'productTemplate' }, - { key: 'referenceId', type: 'dropdown', options: productTemplateOptions, value: String(productTemplateOptions[0].value) }, + { key: 'referenceId', type: 'dropdown', options: productTemplateOptions, value: productTemplateValue }, { key: 'metadata', type: 'json' }, { key: 'hidden', type: 'checkbox' }, ]) From 6596f6f19aa8ed7c2394d8d17bc9d7a9f68b772b Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Mon, 4 May 2020 19:08:23 +0300 Subject: [PATCH 03/20] feat: add column to Milestone Template table Added column "Product Template" --- .../components/MilestoneTemplatesGridView.jsx | 17 ++++++++++++++++- .../containers/MilestoneTemplatesContainer.jsx | 3 +++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/routes/metadata/components/MilestoneTemplatesGridView.jsx b/src/routes/metadata/components/MilestoneTemplatesGridView.jsx index 979903c0a..43766aafd 100644 --- a/src/routes/metadata/components/MilestoneTemplatesGridView.jsx +++ b/src/routes/metadata/components/MilestoneTemplatesGridView.jsx @@ -10,7 +10,7 @@ import './MetaDataProjectTemplatesGridView.scss' const MilestoneTemplatesGridView = props => { const { totalCount, criteria, pageNum, pageSize, sortHandler, error, isLoading, infiniteAutoload, setInfiniteAutoload, - applyFilters, milestoneTemplates } = props + applyFilters, milestoneTemplates, productTemplates } = props const currentSortField = _.get(criteria, 'sort', '') // This 'little' array is the heart of the list component. @@ -61,6 +61,21 @@ const MilestoneTemplatesGridView = props => { ) } + }, { + id: 'referenceId', + headerLabel: 'Product Template', + classes: 'item-key', + sortable: true, + renderText: item => { + const productTemplate = _.find(productTemplates, { id: item.referenceId }) + return ( +
+
+ {productTemplate ? `(${productTemplate.id}) ${productTemplate.name}` : '--'} +
+
+ ) + } }, { id: 'updatedAt', headerLabel: 'Updated At', diff --git a/src/routes/metadata/containers/MilestoneTemplatesContainer.jsx b/src/routes/metadata/containers/MilestoneTemplatesContainer.jsx index 9bfd5b076..1f8b904dc 100644 --- a/src/routes/metadata/containers/MilestoneTemplatesContainer.jsx +++ b/src/routes/metadata/containers/MilestoneTemplatesContainer.jsx @@ -49,6 +49,7 @@ class MilestoneTemplatesContainer extends React.Component { render() { const { milestoneTemplates, + productTemplates, isLoading, isAdmin, currentUser, @@ -78,6 +79,7 @@ class MilestoneTemplatesContainer extends React.Component { pageNum={1} pageSize={milestoneTemplates ? milestoneTemplates.length : 0} milestoneTemplates={milestoneTemplates} + productTemplates={productTemplates} criteria={criteria} sortHandler={this.sortHandler} error={error} @@ -99,6 +101,7 @@ const mapStateToProps = ({ templates, loadUser }) => { const powerUserRoles = [ROLE_ADMINISTRATOR, ROLE_CONNECT_ADMIN] return { + productTemplates: templates.productTemplates, milestoneTemplates: templates.milestoneTemplates, isLoading: templates.isLoading, error: templates.error, From 671c5851d8a8728ede932017d6ddebe728f0b559 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 5 May 2020 12:35:40 +0300 Subject: [PATCH 04/20] 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 05/20] 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 8446070a5433d72b7ec6dced79167333847a46d4 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 12 May 2020 13:55:40 +0300 Subject: [PATCH 06/20] chore: use new CDN URLs --- config/constants/dev.js | 2 +- config/constants/master.js | 2 +- config/constants/qa.js | 2 +- src/config/constants.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/constants/dev.js b/config/constants/dev.js index 0ab9baa0a..deaa09883 100644 --- a/config/constants/dev.js +++ b/config/constants/dev.js @@ -49,5 +49,5 @@ module.exports = { TC_SYSTEM_USERID: process.env.DEV_TC_SYSTEM_USERID, MAINTENANCE_MODE: process.env.DEV_MAINTENANCE_MODE, - TC_CDN_URL: process.env.TC_CDN_URL || 'https://d1aahxkjiobka8.cloudfront.net' + TC_CDN_URL: process.env.TC_CDN_URL } diff --git a/config/constants/master.js b/config/constants/master.js index e0aa0b448..8eaef1198 100644 --- a/config/constants/master.js +++ b/config/constants/master.js @@ -49,5 +49,5 @@ module.exports = { TC_SYSTEM_USERID: process.env.PROD_TC_SYSTEM_USERID, MAINTENANCE_MODE: process.env.PROD_MAINTENANCE_MODE, - TC_CDN_URL: process.env.TC_CDN_URL || 'https://dlxczxztayxv6.cloudfront.net' + TC_CDN_URL: process.env.TC_CDN_URL } diff --git a/config/constants/qa.js b/config/constants/qa.js index e9a131a3b..3f2f7910c 100644 --- a/config/constants/qa.js +++ b/config/constants/qa.js @@ -48,5 +48,5 @@ module.exports = { TC_SYSTEM_USERID: process.env.QA_TC_SYSTEM_USERID, MAINTENANCE_MODE: process.env.QA_MAINTENANCE_MODE, - TC_CDN_URL: process.env.TC_CDN_URL || 'https://d1aahxkjiobka8.cloudfront.net' + TC_CDN_URL: process.env.TC_CDN_URL } diff --git a/src/config/constants.js b/src/config/constants.js index 49a2f2889..470a2ed05 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -709,7 +709,7 @@ export const DIRECT_PROJECT_URL = `https://www.${DOMAIN}/direct/projectOverview? 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_CDN_URL = process.env.TC_CDN_URL +export const TC_CDN_URL = process.env.TC_CDN_URL || `https://community-app.${DOMAIN}/api/cdn/public` export const RESET_PASSWORD_URL = `https://accounts.${DOMAIN}/connect/reset-password` export const VERIFY_EMAIL_URL = `http://www.${DOMAIN}/settings/account/changeEmail` From 621406101b0353b46b60e34178a47b245e93f479 Mon Sep 17 00:00:00 2001 From: nqv Date: Tue, 12 May 2020 19:32:00 +0700 Subject: [PATCH 07/20] 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 08/20] 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 09/20] 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 10/20] 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 11/20] 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 From 929c60ce42688cf655029cdb755ed3548a0e34fe Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Fri, 15 May 2020 09:04:24 +0300 Subject: [PATCH 12/20] chore: letter case "Company Name" --- .../settings/routes/profile/components/ProfileSettingsForm.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/settings/routes/profile/components/ProfileSettingsForm.jsx b/src/routes/settings/routes/profile/components/ProfileSettingsForm.jsx index 82f66f078..dfdc31764 100644 --- a/src/routes/settings/routes/profile/components/ProfileSettingsForm.jsx +++ b/src/routes/settings/routes/profile/components/ProfileSettingsForm.jsx @@ -277,7 +277,7 @@ class ProfileSettingsForm extends Component { )} {showCompanyName && this.getField( - 'Company name', + 'Company Name', 'companyName', true, disableCompanyInput From 37aa0a459be5a407fcebc646fc6bb6f8688fd810 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Fri, 15 May 2020 13:04:25 +0300 Subject: [PATCH 13/20] chore: updated axios "0.13.1" to "0.19.2" Fixing high severity vulnerability. CVE-2019-10742 Vulnerable versions: <= 0.18.0 Patched version: 0.18.1 Axios up to and including 0.18.0 allows attackers to cause a denial of service (application crash) by continuing to accepting content after maxContentLength is exceeded. --- package-lock.json | 26 ++++++++++++++++++++++---- package.json | 2 +- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index eba1f584a..a862fde30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -851,11 +851,29 @@ "dev": true }, "axios": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.13.1.tgz", - "integrity": "sha1-Pmer/kMzvJ0tX+b70TtGlOr8jfg=", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", + "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", "requires": { - "follow-redirects": "0.0.7" + "follow-redirects": "1.5.10" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "requires": { + "debug": "=3.1.0" + } + } } }, "axobject-query": { diff --git a/package.json b/package.json index 814ba7c08..54a176771 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ }, "dependencies": { "appirio-tech-react-components": "git+https://github.com/appirio-tech/react-components.git#feature/connectv2", - "axios": "^0.13.1", + "axios": "^0.19.2", "brace": "^0.11.1", "classnames": "^2.2.3", "coffeescript": "^1.12.7", From c05ed0cd1123c136c5f837f3577f05045f8b0422 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Fri, 15 May 2020 13:08:45 +0300 Subject: [PATCH 14/20] chore: removed digitalocean URLs as API --- src/config/constants.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/config/constants.js b/src/config/constants.js index 00ecf02e1..c48568c9d 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -712,8 +712,9 @@ 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 = 'https://dev.maxceem.com/v5/notifications' +export const TC_NOTIFICATION_URL = process.env.TC_NOTIFICATION_URL || `${TC_API_URL}/v5/notifications` +// Uncomment if you run TC Notifications locally +// export const TC_NOTIFICATION_URL = 'http://localstho:4000/v5/notifications' export const TC_CDN_URL = process.env.TC_CDN_URL || `https://community-app.${DOMAIN}/api/cdn/public` @@ -758,9 +759,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 = 'https://dev.maxceem.com' +// export const PROJECTS_API_URL = 'http://localhost:8001' export const CONNECT_MESSAGE_API_URL = process.env.CONNECT_MESSAGE_API_URL || TC_API_URL export const NEW_PROJECT_PATH = '/new-project' From cf1b5a03bfe346b575f8073529c982ec3ca052a5 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Fri, 15 May 2020 13:15:14 +0300 Subject: [PATCH 15/20] chore: disable loggin webpack config --- scripts/permissions-doc/webpack.config.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/permissions-doc/webpack.config.js b/scripts/permissions-doc/webpack.config.js index 3b9eeb65d..bf20a177d 100644 --- a/scripts/permissions-doc/webpack.config.js +++ b/scripts/permissions-doc/webpack.config.js @@ -21,6 +21,7 @@ webpackConfig.module.rules.push({ webpackConfig.plugins = [] -console.log('webpackConfig', JSON.stringify(webpackConfig, null, 2)) +// Uncomment for debugging the final webpack config +// console.log('webpackConfig', JSON.stringify(webpackConfig, null, 2)) module.exports = webpackConfig From 31e4a3bda7d4e0e6ddad960a25d634544e7b30bb Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Fri, 15 May 2020 15:31:54 +0300 Subject: [PATCH 16/20] fix: limit number of avatars to fit card ref issue #3992 --- src/projects/components/projectsCard/ProjectCard.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/projects/components/projectsCard/ProjectCard.jsx b/src/projects/components/projectsCard/ProjectCard.jsx index a2c4e9a53..4d3ad2866 100644 --- a/src/projects/components/projectsCard/ProjectCard.jsx +++ b/src/projects/components/projectsCard/ProjectCard.jsx @@ -42,7 +42,7 @@ function ProjectCard({ project, disabled, currentUser, history, onChangeStatus, />
- + {(!isMember && isInvited) &&
Date: Fri, 15 May 2020 18:05:28 +0530 Subject: [PATCH 17/20] - Changes for displaying project type icon on project's left hand navigation - Made feature branch deployable to dev --- .circleci/config.yml | 2 +- src/components/ProjectInfo/ProjectInfo.jsx | 59 ++++++++++++------- src/components/ProjectInfo/ProjectInfo.scss | 24 +++++++- .../detail/containers/ProjectInfoContainer.js | 3 +- 4 files changed, 64 insertions(+), 24 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 443b21887..551aee79f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -128,7 +128,7 @@ workflows: - build-dev filters: branches: - only: ['dev', 'dev-msinteg'] + only: ['dev', 'dev-msinteg', 'feature/Add_project_type_icon_in_leftNavigation_for_project'] - deployTest01: context : org-global diff --git a/src/components/ProjectInfo/ProjectInfo.jsx b/src/components/ProjectInfo/ProjectInfo.jsx index 365feac7c..f7e27186b 100644 --- a/src/components/ProjectInfo/ProjectInfo.jsx +++ b/src/components/ProjectInfo/ProjectInfo.jsx @@ -12,6 +12,7 @@ import ReviewProjectButton from '../../projects/detail/components/ReviewProjectB import Tooltip from 'appirio-tech-react-components/components/Tooltip/Tooltip' import { TOOLTIP_DEFAULT_DELAY } from '../../config/constants' +import ProjectTypeIcon from '../../components/ProjectTypeIcon' import './ProjectInfo.scss' class ProjectInfo extends Component { @@ -23,7 +24,7 @@ class ProjectInfo extends Component { render() { const { project, currentMemberRole, onChangeStatus, isSuperUser, phases, onSubmitForReview, isProjectProcessing, - showDeleteConfirm, toggleProjectDelete, onConfirmDelete } = this.props + showDeleteConfirm, toggleProjectDelete, onConfirmDelete, projectTemplates } = this.props const code = _.get(project, 'details.utm.code', '') @@ -50,30 +51,46 @@ class ProjectInfo extends Component {
) + const url = `/projects/${project.id}` + const projectTemplateId = project.templateId + const projectTemplateKey = _.get(project, 'details.products[0]') + const projectTemplate = projectTemplateId + ? _.find(projectTemplates, pt => pt.id === projectTemplateId) + : getProjectTemplateByKey(projectTemplates, projectTemplateKey) + // icon for the product, use default generic work project icon for categories which no longer exist now + const productIcon = _.get(projectTemplate, 'icon', 'tech-32px-outline-work-project') return (
-
-
{_.unescape(project.name)}
- -
-
- Created {moment(project.createdAt).format('MMM DD, YYYY')} -
- {!!code && -
- -
-
{_.unescape(code)}
-
-
-
{_.unescape(code)}
-
-
-
- - } +
+
+ +
+ +
+ +
+
+
{_.unescape(project.name)}
+
+
+ Created {moment(project.createdAt).format('MMM DD, YYYY')} +
+ {!!code && +
+ +
+
{_.unescape(code)}
+
+
+
{_.unescape(code)}
+
+
+
+ + } +
{showDeleteConfirm && ( svg > path { + fill: $tc-dark-blue-110; + } + } + } .project-info-review { > p { @@ -42,7 +62,7 @@ .project-status { margin-bottom: 10px; - padding-left: 4 * $base_unit; + padding-left: 2 * $base_unit; padding-right: 4 * $base_unit; display: flex; flex-direction: column; @@ -124,6 +144,8 @@ .project-status-bottom { display: flex; justify-content: space-between; + padding-left: 4 * $base_unit; + padding-right: 4 * $base_unit; } .tooltip-target-container { diff --git a/src/projects/detail/containers/ProjectInfoContainer.js b/src/projects/detail/containers/ProjectInfoContainer.js index af5a8386d..cc86cd0fc 100644 --- a/src/projects/detail/containers/ProjectInfoContainer.js +++ b/src/projects/detail/containers/ProjectInfoContainer.js @@ -423,7 +423,7 @@ class ProjectInfoContainer extends React.Component { render() { const { showDeleteConfirm } = this.state const { project, currentMemberRole, isSuperUser, phases, hideInfo, hideMembers, - productsTimelines, isProjectProcessing, notifications } = this.props + productsTimelines, isProjectProcessing, notifications, projectTemplates } = this.props let directLinks = null // check if direct links need to be added const isMemberOrCopilot = _.indexOf([ @@ -519,6 +519,7 @@ class ProjectInfoContainer extends React.Component { toggleProjectDelete={this.toggleProjectDelete} onConfirmDelete={this.onConfirmDelete} showDeleteConfirm={showDeleteConfirm} + projectTemplates={projectTemplates} /> } From f173398a97d721213eab46187dbf388609f0125b Mon Sep 17 00:00:00 2001 From: RishiRaj Date: Fri, 15 May 2020 18:12:54 +0530 Subject: [PATCH 18/20] lint fix --- src/components/ProjectInfo/ProjectInfo.jsx | 27 +++++++++++----------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/components/ProjectInfo/ProjectInfo.jsx b/src/components/ProjectInfo/ProjectInfo.jsx index f7e27186b..41f7da050 100644 --- a/src/components/ProjectInfo/ProjectInfo.jsx +++ b/src/components/ProjectInfo/ProjectInfo.jsx @@ -11,6 +11,7 @@ import { SCREEN_BREAKPOINT_MD, PROJECT_STATUS_ACTIVE, PHASE_STATUS_ACTIVE, PHASE import ReviewProjectButton from '../../projects/detail/components/ReviewProjectButton' import Tooltip from 'appirio-tech-react-components/components/Tooltip/Tooltip' import { TOOLTIP_DEFAULT_DELAY } from '../../config/constants' +import { getProjectTemplateByKey } from '../../helpers/templates' import ProjectTypeIcon from '../../components/ProjectTypeIcon' import './ProjectInfo.scss' @@ -74,22 +75,22 @@ class ProjectInfo extends Component {
-
- Created {moment(project.createdAt).format('MMM DD, YYYY')} +
+ Created {moment(project.createdAt).format('MMM DD, YYYY')} +
+ {!!code && +
+ +
+
{_.unescape(code)}
- {!!code && -
- -
-
{_.unescape(code)}
-
-
-
{_.unescape(code)}
-
-
+
+
{_.unescape(code)}
+ +
- } + }
{showDeleteConfirm && ( Date: Thu, 21 May 2020 20:47:57 +0530 Subject: [PATCH 19/20] Using templateName for tooltip title, for V2 projects still using projectType. --- src/components/ProjectInfo/ProjectInfo.jsx | 4 +++- src/projects/components/projectsCard/ProjectCardHeader.jsx | 4 +++- src/projects/list/components/Projects/ProjectsGridView.jsx | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/ProjectInfo/ProjectInfo.jsx b/src/components/ProjectInfo/ProjectInfo.jsx index 41f7da050..7786145a9 100644 --- a/src/components/ProjectInfo/ProjectInfo.jsx +++ b/src/components/ProjectInfo/ProjectInfo.jsx @@ -60,12 +60,14 @@ class ProjectInfo extends Component { : getProjectTemplateByKey(projectTemplates, projectTemplateKey) // icon for the product, use default generic work project icon for categories which no longer exist now const productIcon = _.get(projectTemplate, 'icon', 'tech-32px-outline-work-project') + const templateName = _.get(projectTemplate, 'name', null) + const projectType = project.type !== undefined ? project.type[0].toUpperCase() + project.type.substr(1).replace(/_/g, ' ') : null return (
-
+
diff --git a/src/projects/components/projectsCard/ProjectCardHeader.jsx b/src/projects/components/projectsCard/ProjectCardHeader.jsx index c76a53893..97b97fa1b 100644 --- a/src/projects/components/projectsCard/ProjectCardHeader.jsx +++ b/src/projects/components/projectsCard/ProjectCardHeader.jsx @@ -18,13 +18,15 @@ function ProjectCardHeader({ project, onClick, projectTemplates, unreadMentionsC : getProjectTemplateByKey(projectTemplates, projectTemplateKey) // icon for the product, use default generic work project icon for categories which no longer exist now const productIcon = _.get(projectTemplate, 'icon', 'tech-32px-outline-work-project') + const templateName = _.get(projectTemplate, 'name', null) + const projectType = project.type !== undefined ? project.type[0].toUpperCase() + project.type.substr(1).replace(/_/g, ' ') : null return (
{ unreadMentionsCount > 0 && } -
+
diff --git a/src/projects/list/components/Projects/ProjectsGridView.jsx b/src/projects/list/components/Projects/ProjectsGridView.jsx index 1babc5a7a..18d22978a 100644 --- a/src/projects/list/components/Projects/ProjectsGridView.jsx +++ b/src/projects/list/components/Projects/ProjectsGridView.jsx @@ -74,9 +74,11 @@ const ProjectsGridView = props => { : getProjectTemplateByKey(projectTemplates, projectTemplateKey) // icon for the product, use default generic work project icon for categories which no longer exist now const productIcon = _.get(projectTemplate, 'icon', 'tech-32px-outline-work-project') + const templateName = _.get(projectTemplate, 'name', null) + const projectType = item.type !== undefined ? item.type[0].toUpperCase() + item.type.substr(1).replace(/_/g, ' ') : null return ( -
+
From 70cad97310a012ec8fa1f2fe5e3c882c7e9236b5 Mon Sep 17 00:00:00 2001 From: RishiRaj Date: Tue, 26 May 2020 11:44:29 +0530 Subject: [PATCH 20/20] Removed feature from deployable --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 551aee79f..443b21887 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -128,7 +128,7 @@ workflows: - build-dev filters: branches: - only: ['dev', 'dev-msinteg', 'feature/Add_project_type_icon_in_leftNavigation_for_project'] + only: ['dev', 'dev-msinteg'] - deployTest01: context : org-global