diff --git a/.circleci/config.yml b/.circleci/config.yml index 897301638..6b4361462 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -128,7 +128,7 @@ workflows: - build-dev filters: branches: - only: ['dev'] + only: ['dev', 'feature/generic-phases-with-new-milestone-types'] - deployTest01: context : org-global diff --git a/config/constants/dev.js b/config/constants/dev.js index e4ca55cc7..20515dd35 100644 --- a/config/constants/dev.js +++ b/config/constants/dev.js @@ -43,6 +43,7 @@ module.exports = { PREDIX_PROGRAM_ID : 3448, IBM_COGNITIVE_PROGRAM_ID : 3449, HEAP_ANALYTICS_APP_ID : '4153837120', + PHASE_PRODUCT_TEMPLATE_ID : 166, TC_NOTIFICATION_URL: 'https://api.topcoder-dev.com/v5/notifications', CONNECT_MESSAGE_API_URL: 'https://api.topcoder-dev.com/v5', diff --git a/config/constants/master.js b/config/constants/master.js index 1854f149e..745029fb2 100644 --- a/config/constants/master.js +++ b/config/constants/master.js @@ -43,6 +43,7 @@ module.exports = { PREDIX_PROGRAM_ID : 3448, IBM_COGNITIVE_PROGRAM_ID : 3449, HEAP_ANALYTICS_APP_ID : '638908330', + PHASE_PRODUCT_TEMPLATE_ID : 33, TC_NOTIFICATION_URL: 'https://api.topcoder.com/v5/notifications', CONNECT_MESSAGE_API_URL: 'https://api.topcoder.com/v5', diff --git a/config/constants/qa.js b/config/constants/qa.js index 3f2f7910c..c101bd640 100644 --- a/config/constants/qa.js +++ b/config/constants/qa.js @@ -42,6 +42,7 @@ module.exports = { PREDIX_PROGRAM_ID : 3448, IBM_COGNITIVE_PROGRAM_ID : 3449, HEAP_ANALYTICS_APP_ID : '4153837120', + PHASE_PRODUCT_TEMPLATE_ID : 166, TC_NOTIFICATION_URL: 'https://api.topcoder-dev.com/v5/notifications', CONNECT_MESSAGE_API_URL: 'https://api.topcoder-qa.com/v5', diff --git a/docs/permissions.html b/docs/permissions.html index 36b67dac9..259910af3 100644 --- a/docs/permissions.html +++ b/docs/permissions.html @@ -996,6 +996,30 @@

+
+
+
+ Edit project status to special +
+
EDIT_PROJECT_STATUS_TO_SPECIAL
+
Special values are any values except of "Active" and "Completed".
+
+
+
+ manager + account_manager + account_executive + project_manager + program_manager + solution_architect +
+ +
+ administrator + Connect Admin +
+
+
@@ -1167,6 +1191,12 @@

customer + manager + account_manager + account_executive + project_manager + program_manager + solution_architect
diff --git a/src/api/projects.js b/src/api/projects.js index 63563c4a1..41b3366d2 100644 --- a/src/api/projects.js +++ b/src/api/projects.js @@ -184,26 +184,6 @@ export function createPhaseProduct(projectId, phaseId, product) { .then( resp => resp.data) } -export function createProjectWithStatus(projectProps, status) { - // Phase out discussions - // TODO: Remove this once none of the active projects - // have the discussions tab enabled - projectProps.details.hideDiscussions = true - - return axios.post(`${PROJECTS_API_URL}/v5/projects/`, projectProps) - .then( resp => resp.data) - .then(project => { - const updatedProps = { status } - const projectId = project.id - return axios.patch(`${PROJECTS_API_URL}/v5/projects/${projectId}/`, updatedProps) - .then(resp => resp.data) - .catch(error => { // eslint-disable-line no-unused-vars - // return created project even if status update fails to prevent error page - return project - }) - }) -} - export function deleteProject(projectId) { return axios.delete(`${PROJECTS_API_URL}/v5/projects/${projectId}/`) .then(() => { diff --git a/src/components/Layout/Layout.scss b/src/components/Layout/Layout.scss index d7361ebb5..6922dc988 100644 --- a/src/components/Layout/Layout.scss +++ b/src/components/Layout/Layout.scss @@ -22,8 +22,5 @@ &[data-route*='new-project'] { background-color: $tc-gray-neutral-light; } - &[data-route*='add-phase'] { - background-color: $tc-gray-neutral-light; - } } } diff --git a/src/components/ProjectStatus/ProjectStatus.scss b/src/components/ProjectStatus/ProjectStatus.scss index 7c54c3dbb..dfe187ef2 100644 --- a/src/components/ProjectStatus/ProjectStatus.scss +++ b/src/components/ProjectStatus/ProjectStatus.scss @@ -8,7 +8,7 @@ .ProjectStatus { position: relative; height: $status-height; - + .status-icon { position: relative; display: inline-block; @@ -16,14 +16,14 @@ width: 4 * $base-unit; height: 4 * $base-unit; // top: 3px; - + i { display: block; width: 4 * $base-unit; height: 4 * $base-unit; } } - + .status-label { position: relative; @include tc-label-xs; @@ -33,7 +33,7 @@ margin-left: 10px; } } - + .EditableProjectStatus { &.modal-active { .modal-overlay { @@ -59,7 +59,7 @@ .status-label { vertical-align: top; } - + .status-header { display: flex; // position: absolute; @@ -70,19 +70,19 @@ user-select: none; border-radius: $base-unit*4; cursor: default; - + .caret { display: none; } - + &.editable { cursor: pointer; - + &:hover { &::after { } } - + .caret { // content: ''; // z-index: 3; @@ -97,7 +97,7 @@ path { stroke: $tc-gray-70; } - + .Icon { width: 100%; height: 100%; @@ -105,17 +105,17 @@ } } } - - + + &.unified-header { background-color: $tc-gray-40; - + .status-label { color: $tc-white; } } } - + .status-label { position: relative; @include tc-label-xs; @@ -123,7 +123,7 @@ padding-right: $base-unit; margin-left: 10px; } - + // Stylize the dropdown elements .status-dropdown { min-width: 150px; @@ -138,12 +138,12 @@ right: -10px; z-index: 20; transition: 250ms all; - + &.dropdown-up { top: auto; bottom: 0; } - + .status-header { @include roboto-medium; font-size: $tc-label-md; @@ -154,7 +154,7 @@ margin-bottom: 2px; white-space: nowrap; } - + .status-option { display: flex; @include roboto-medium; @@ -163,19 +163,37 @@ line-height: 30px; padding: 0 20px; white-space: nowrap; - + &:hover { background: $tc-dark-blue-10; } - + &.active { background: $tc-gray-10; } - + + &.disabled { + &, + &:hover, + &:active, + &:focus { + background: transparent; + cursor: default; + + .status-label { + color: $tc-gray-30; + } + + svg { + opacity: 0.5; + } + } + } + svg { margin-top: 7px; } - + .status-label { color: $tc-gray-80; line-height: 30px; @@ -185,4 +203,4 @@ } } } - + diff --git a/src/components/ProjectStatus/editableProjectStatus.js b/src/components/ProjectStatus/editableProjectStatus.js index f98964d21..49465eae4 100644 --- a/src/components/ProjectStatus/editableProjectStatus.js +++ b/src/components/ProjectStatus/editableProjectStatus.js @@ -4,13 +4,17 @@ import ProjectStatusChangeConfirmation from './ProjectStatusChangeConfirmation' import cn from 'classnames' import _ from 'lodash' import enhanceDropdown from 'appirio-tech-react-components/components/Dropdown/enhanceDropdown' +import Tooltip from 'appirio-tech-react-components/components/Tooltip/Tooltip' import { PROJECT_STATUS, PROJECT_STATUS_COMPLETED, PROJECT_STATUS_CANCELLED, - PROJECT_STATUS_DRAFT + PROJECT_STATUS_DRAFT, + TOOLTIP_DEFAULT_DELAY } from '../../config/constants' import CarretDownNormal9px from '../../assets/icons/arrow-9px-carret-down-normal.svg' +import { hasPermission } from '../../helpers/permissions' +import { PERMISSIONS } from '../../config/permissions' const hocStatusDropdown = (CompositeComponent, statusList) => { @@ -55,24 +59,33 @@ const hocStatusDropdown = (CompositeComponent, statusList) => {
Project Status
@@ -132,10 +145,29 @@ const editableProjectStatus = (CompositeComponent) => class extends Component { } getProjectStatusDropdownValues(status) { - if (status === PROJECT_STATUS_DRAFT) { - return [{color: 'gray', name: 'Draft', fullName: 'Project is in draft', value: PROJECT_STATUS_DRAFT, order: 2, dropDownOrder: 1 }].concat(PROJECT_STATUS) + const statusList = status === PROJECT_STATUS_DRAFT + // if current status, is "Draft" which is deprecated, then show it on the list + ? [{color: 'gray', name: 'Draft', fullName: 'Project is in draft', value: PROJECT_STATUS_DRAFT, order: 2, dropDownOrder: 1 }].concat(PROJECT_STATUS) + // otherwise don't show deprecated status + : PROJECT_STATUS + + if (hasPermission(PERMISSIONS.EDIT_PROJECT_STATUS_TO_SPECIAL)) { + return statusList + } else { + return statusList.map((statusOption) => { + // if option is not special anyone can choose it + // also, don't disable special option, if it's the current value + if (!statusOption.isSpecial || statusOption.value === status) { + return statusOption + } + + return { + ...statusOption, + disabled: true, + toolTipMessage: 'Only managers and admins can change to this status' + } + }) } - return PROJECT_STATUS } render() { diff --git a/src/components/SelectDropdown/SelectDropdown.scss b/src/components/SelectDropdown/SelectDropdown.scss index c21e57f41..2388b77aa 100644 --- a/src/components/SelectDropdown/SelectDropdown.scss +++ b/src/components/SelectDropdown/SelectDropdown.scss @@ -43,8 +43,10 @@ z-index: 10; } -.dropdown-disabled { +:global(.SelectDropdown).dropdown-disabled { margin-left: 0; + margin-bottom: 2 * $base_unit; + padding: 2 * $base_unit + 2px 0; // +2px instead of border width: 100%; :global(.tc-link) { diff --git a/src/components/TopBar/TopBarContainer.js b/src/components/TopBar/TopBarContainer.js index be38a5dcf..3c34174e4 100644 --- a/src/components/TopBar/TopBarContainer.js +++ b/src/components/TopBar/TopBarContainer.js @@ -49,12 +49,6 @@ class TopBarContainer extends React.Component { render() { - const location = this.props.location.pathname - if (location && (location.substr(location.lastIndexOf('/') + 1) === 'add-phase')) { - return ( -
- ) - } const { user, toolbar, userRoles } = this.props const userHandle = _.get(user, 'handle') const bigPhotoURL = _.get(user, 'photoURL') diff --git a/src/config/constants.js b/src/config/constants.js index c862f92f3..ed6ae46c9 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -84,6 +84,18 @@ export const SET_PROJECTS_SEARCH_CRITERIA = 'SET_PROJECTS_SEARCH_CRITERIA' export const SET_PROJECTS_INFINITE_AUTOLOAD = 'SET_PROJECTS_INFINITE_AUTOLOAD' export const SET_PROJECTS_LIST_VIEW = 'SET_PROJECTS_LIST_VIEW' +// milestones +export const CREATE_TIMELINE_MILESTONE = 'CREATE_TIMELINE_MILESTONE' +export const CREATE_TIMELINE_MILESTONE_SUCCESS = 'CREATE_TIMELINE_MILESTONE_SUCCESS' +export const CREATE_TIMELINE_MILESTONE_FAILURE = 'CREATE_TIMELINE_MILESTONE_FAILURE' +export const CREATE_TIMELINE_MILESTONE_PENDING = 'CREATE_TIMELINE_MILESTONE_PENDING' + +// project phases and timeline and milestones +export const CREATE_PROJECT_PHASE_TIMELINE_MILESTONES = 'CREATE_PROJECT_PHASE_TIMELINE_MILESTONES' +export const CREATE_PROJECT_PHASE_TIMELINE_MILESTONES_FAILURE = 'CREATE_PROJECT_PHASE_TIMELINE_MILESTONES_FAILURE' +export const CREATE_PROJECT_PHASE_TIMELINE_MILESTONES_SUCCESS = 'CREATE_PROJECT_PHASE_TIMELINE_MILESTONES_SUCCESS' +export const CREATE_PROJECT_PHASE_TIMELINE_MILESTONES_PENDING = 'CREATE_PROJECT_PHASE_TIMELINE_MILESTONES_PENDING' + // Delete project export const DELETE_PROJECT = 'DELETE_PROJECT' @@ -224,11 +236,6 @@ export const CREATE_PROJECT_PENDING = 'CREATE_PROJECT_PENDING' export const CREATE_PROJECT_SUCCESS = 'CREATE_PROJECT_SUCCESS' export const CREATE_PROJECT_FAILURE = 'CREATE_PROJECT_FAILURE' -export const CREATE_PROJECT_STAGE = 'CREATE_PROJECT_STAGE' -export const CREATE_PROJECT_STAGE_PENDING = 'CREATE_PROJECT_STAGE_PENDING' -export const CREATE_PROJECT_STAGE_SUCCESS = 'CREATE_PROJECT_STAGE_SUCCESS' -export const CREATE_PROJECT_STAGE_FAILURE = 'CREATE_PROJECT_STAGE_FAILURE' - export const UPDATE_PROJECT = 'UPDATE_PROJECT' export const UPDATE_PROJECT_PENDING = 'UPDATE_PROJECT_PENDING' export const UPDATE_PROJECT_SUCCESS = 'UPDATE_PROJECT_SUCCESS' @@ -588,20 +595,20 @@ export const PHASE_STATUS_PAUSED = 'paused' export const PROJECT_STATUS = [ // {color: 'gray', name: 'Draft', fullName: 'Project is in draft', value: PROJECT_STATUS_DRAFT, order: 2, dropDownOrder: 1 }, - {color: 'gray', name: 'In review', fullName: 'Project is in review', value: PROJECT_STATUS_IN_REVIEW, order: 3, dropDownOrder: 2 }, - {color: 'gray', name: 'Reviewed', fullName: 'Project is reviewed', value: PROJECT_STATUS_REVIEWED, order: 4, dropDownOrder: 3 }, - {color: 'green', name: 'Active', fullName: 'Project is active', value: PROJECT_STATUS_ACTIVE, order: 1, dropDownOrder: 4 }, - {color: 'black', name: 'Completed', fullName: 'Project is completed', value: PROJECT_STATUS_COMPLETED, order: 5, dropDownOrder: 5 }, - {color: 'black', name: 'Cancelled', fullName: 'Project is canceled', value: PROJECT_STATUS_CANCELLED, order: 6, dropDownOrder: 6 }, - {color: 'red', name: 'Paused', fullName: 'Project is paused', value: PROJECT_STATUS_PAUSED, order: 7, dropDownOrder: 7 } + {color: 'gray', name: 'In review', fullName: 'Project is in review', value: PROJECT_STATUS_IN_REVIEW, order: 3, dropDownOrder: 2, isSpecial: true }, + {color: 'gray', name: 'Reviewed', fullName: 'Project is reviewed', value: PROJECT_STATUS_REVIEWED, order: 4, dropDownOrder: 3, isSpecial: true }, + {color: 'green', name: 'Active', fullName: 'Project is active', value: PROJECT_STATUS_ACTIVE, order: 1, dropDownOrder: 4, isSpecial: false }, + {color: 'black', name: 'Completed', fullName: 'Project is completed', value: PROJECT_STATUS_COMPLETED, order: 5, dropDownOrder: 5, isSpecial: false }, + {color: 'black', name: 'Cancelled', fullName: 'Project is canceled', value: PROJECT_STATUS_CANCELLED, order: 6, dropDownOrder: 6, isSpecial: true }, + {color: 'red', name: 'Paused', fullName: 'Project is paused', value: PROJECT_STATUS_PAUSED, order: 7, dropDownOrder: 7, isSpecial: true } ] export const PHASE_STATUS = [ {color: 'gray', name: 'Draft', fullName: 'Phase is in draft', value: PHASE_STATUS_DRAFT, order: 2, dropDownOrder: 1 }, // {color: 'gray', name: 'In review', fullName: 'Phase is in review', value: PHASE_STATUS_IN_REVIEW, order: 3, dropDownOrder: 2 }, {color: 'gray', name: 'Planned', fullName: 'Phase is reviewed', value: PHASE_STATUS_REVIEWED, order: 4, dropDownOrder: 3 }, - {color: 'green', name: 'In Progress', fullName: 'Phase is active', value: PHASE_STATUS_ACTIVE, order: 1, dropDownOrder: 4 }, - {color: 'black', name: 'Delivered', fullName: 'Phase is completed', value: PHASE_STATUS_COMPLETED, order: 5, dropDownOrder: 5 }, + {color: 'green', name: 'Active', fullName: 'Phase is active', value: PHASE_STATUS_ACTIVE, order: 1, dropDownOrder: 4 }, + {color: 'black', name: 'Completed', fullName: 'Phase is completed', value: PHASE_STATUS_COMPLETED, order: 5, dropDownOrder: 5 }, // {color: 'black', name: 'Cancelled', fullName: 'Phase is canceled', value: PHASE_STATUS_CANCELLED, order: 6, dropDownOrder: 6 }, // {color: 'red', name: 'Paused', fullName: 'Phase is paused', value: PHASE_STATUS_PAUSED, order: 7, dropDownOrder: 7 } ] @@ -1093,3 +1100,103 @@ export const PROFILE_FIELDS_CONFIG = { // businessPhone: false, } } + +/** + * The type of the project for talent as a service app. + */ +export const PROJECT_TYPE_TALENT_AS_A_SERVICE = 'talent-as-a-service' + +/** + * URL to the Topcoder TaaS App + */ +export const TAAS_APP_URL = process.env.TAAS_APP_URL || 'https://mfe.topcoder-dev.com/taas' + +/** + * Milestone Types + */ +export const MILESTONE_TYPE = { + REPORTING: 'reporting', + DELIVERABLE_REVIEW: 'deliverable-review', + FINAL_DELIVERABLE_REVIEW: 'final-deliverable-review', + DELIVERABLE_FINAL_FIXES: 'deliverable-final-fixes', + PHASE_SPECIFICATION:'phase-specification', + COMMUNITY_WORK: 'community-work', + COMMUNITY_REVIEW: 'community-review', + GENERIC_WORK: 'generic-work', + CHECKPOINT_REVIEW: 'checkpoint-review', + ADD_LINKS: 'add-links', + FINAL_DESIGNS: 'final-designs', + FINAL_FIX: 'final-fix', + DELIVERY_DEV: 'delivery-dev', + DELIVERY_DESIGN: 'delivery-design', + DELIVERY: 'delivery' +} + +/** + * Milestone Type Options + */ +export const MILESTONE_TYPE_OPTIONS = [ + { + title: 'Reporting', + value: MILESTONE_TYPE.REPORTING, + }, + { + title: 'Deliverable Review', + value: MILESTONE_TYPE.DELIVERABLE_REVIEW, + }, + { + title: 'Final Deliverable Review', + value: MILESTONE_TYPE.FINAL_DELIVERABLE_REVIEW, + }, +] + +/** + * Default values for newly created milestones. + */ +export const MILESTONE_DEFAULT_VALUES = { + [MILESTONE_TYPE.REPORTING]: { + description: 'description', + plannedText: 'The delivery team will provide an update on your progress.', + activeText: 'The delivery team will provide an update on your progress.', + blockedText: 'The delivery team will provide an update on your progress.', + completedText: 'The delivery team will provide an update on your progress.', + details: {}, + hidden: false, + status: MILESTONE_STATUS.PLANNED + }, + [MILESTONE_TYPE.DELIVERABLE_REVIEW]: { + description: 'description', + plannedText: 'The delivery team plans to share in-progress deliverables on this date for your review.', + activeText: 'Your review and feedback on in-progress deliverables is required.', + blockedText: 'Your in-progress deliverables have been blocked. Work with your delivery team to understand the next steps.', + completedText: 'Thank you for your review of the in-progress deliverables. Your feedback will inform your final deliverables.', + details: {}, + hidden: false, + status: MILESTONE_STATUS.PLANNED + }, + [MILESTONE_TYPE.FINAL_DELIVERABLE_REVIEW]: { + description: 'description', + plannedText: 'The delivery team plans to share the final deliverables on this date for your review.', + activeText: 'Your review and feedback on the final deliverables is required.', + blockedText: 'Your final deliverables have been blocked. Work with your delivery team to understand the next steps.', + completedText: 'Your final deliverables are complete.', + details: {}, + hidden: false, + status: MILESTONE_STATUS.PLANNED + }, + [MILESTONE_TYPE.DELIVERABLE_FINAL_FIXES]: { + description: 'description', + plannedText: 'The delivery team plans to share the final deliverables on this date for your review.', + activeText: 'Your review and feedback on the final deliverables is required.', + blockedText: 'Your final deliverables have been blocked. Work with your delivery team to understand the next steps.', + completedText: 'Your final deliverables are complete.', + details: {}, + hidden: false, + status: MILESTONE_STATUS.PLANNED + }, +} + +/** + * project template id + */ +export const PHASE_PRODUCT_TEMPLATE_ID = process.env.PHASE_PRODUCT_TEMPLATE_ID diff --git a/src/config/permissions.js b/src/config/permissions.js index ee0391173..49f38d235 100644 --- a/src/config/permissions.js +++ b/src/config/permissions.js @@ -620,6 +620,20 @@ export const PERMISSIONS = { ] }, + EDIT_PROJECT_STATUS_TO_SPECIAL: { + meta: { + group: 'Project Details', + title: 'Edit project status to special', + description: 'Special values are any values except of "Active" and "Completed".' + }, + projectRoles: [ + ...PROJECT_MANAGERS, + ], + topcoderRoles: [ + ...TOPCODER_ADMINS, + ] + }, + VIEW_PROJECT_SPECIAL_LINKS: { meta: { group: 'Project Details', @@ -718,7 +732,7 @@ export const PERMISSIONS = { title: 'Accept final delivery', }, projectRoles: [ - PROJECT_ROLE_CUSTOMER, + ..._.difference(PROJECT_ALL, [PROJECT_ROLE_COPILOT]) ], topcoderRoles: [ ...TOPCODER_ADMINS, diff --git a/src/helpers/milestoneHelper.js b/src/helpers/milestoneHelper.js index 1ae729cbb..cd5351cd9 100644 --- a/src/helpers/milestoneHelper.js +++ b/src/helpers/milestoneHelper.js @@ -55,7 +55,7 @@ function mergeJsonObjects(targetObj, sourceObj) { 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) @@ -64,14 +64,11 @@ function updateMilestone(milestone, updatedProps) { // 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) { @@ -79,25 +76,11 @@ function updateMilestone(milestone, updatedProps) { // 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 } @@ -141,7 +124,7 @@ function updateComingMilestones(origMilestone, updMilestone, timelineMilestones) } // Calculate the endDate, and update it if different - const endDate = moment.utc(updateProps.startDate || milestone.startDate).add(milestone.duration - 1, 'days').toDate().toISOString() + const endDate = moment.utc(updateProps.endDate || milestone.endDate).toDate().toISOString() if (!_.isEqual(milestone.endDate, endDate)) { updateProps.endDate = endDate updateProps.updatedBy = updMilestone.updatedBy @@ -176,7 +159,6 @@ function cascadeMilestones(originalMilestone, updatedMilestone, timelineMileston // 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) { @@ -199,3 +181,8 @@ export const processUpdateMilestone = (milestone, updatedProps, timelineMileston return { updatedMilestone, updatedTimelineMilestones } } + + +export const processDeleteMilestone = (index, timelineMilestones) => { + return update(timelineMilestones, {$splice:[ [index, 1]]}) +} diff --git a/src/helpers/utils.js b/src/helpers/utils.js index 60ca43e7a..5991db238 100644 --- a/src/helpers/utils.js +++ b/src/helpers/utils.js @@ -2,6 +2,7 @@ Helper util functions */ import _ from 'lodash' +import moment from 'moment' /** * Finds the difference between two objects. @@ -148,3 +149,27 @@ export const formatPhone = (phone) => { return '+' + phone } } + +/** + * Validates if object has valid start and end dates + * + * @param {object} values + * @param {string} values.startDate start date + * @param {string} values.endDate end date + * + * @returns {boolean} is valid + */ +export const isValidStartEndDates = (values) => { + const { startDate, endDate } = values + // if no dates, don't validate + if (!startDate || !endDate) { + return false + } + + const momentStartDate = moment(startDate) + const momentEndDate = moment(endDate) + + const isValid = momentStartDate.isSameOrBefore(momentEndDate, 'days') + + return isValid +} diff --git a/src/projects/actions/productsTimelines.js b/src/projects/actions/productsTimelines.js index 25e44d253..1691a3cdd 100644 --- a/src/projects/actions/productsTimelines.js +++ b/src/projects/actions/productsTimelines.js @@ -23,12 +23,20 @@ import { SUBMIT_FINAL_FIXES_REQUEST_PENDING, SUBMIT_FINAL_FIXES_REQUEST_SUCCESS, SUBMIT_FINAL_FIXES_REQUEST_FAILURE, + CREATE_TIMELINE_MILESTONE, + MILESTONE_TYPE, MILESTONE_STATUS, UPDATE_PRODUCT_TIMELINE, PHASE_STATUS_COMPLETED, BULK_UPDATE_PRODUCT_MILESTONES, + MILESTONE_DEFAULT_VALUES, } from '../../config/constants' -import { processUpdateMilestone } from '../../helpers/milestoneHelper' +import { + processUpdateMilestone, + processDeleteMilestone +} from '../../helpers/milestoneHelper' +import moment from 'moment' + /** * Get the next milestone in the list, which is not hidden @@ -48,17 +56,24 @@ function getNextNotHiddenMilestone(milestones, currentMilestoneIndex) { return milestones[index] } - /** - * Check if the milestone is last non-hidden milestone in the timeline or no + * bulk create proudct milestones * - * @param {Object} milestones timeline's milestones - * @param {Number} milestoneIdx milestone index - * - * @returns {Boolean} true if milestone is last non-hidden + * @param {Object} timeline timeline object + * @param {Array} milestones milestones */ -function checkIfLastMilestone(milestones, milestoneIdx) { - return _.slice(milestones, milestoneIdx + 1).filter(m => !m.hidden).length === 0 +export function createProductMilestone(timeline, milestones) { + return (dispatch) => { + + milestones = milestones.map(item => _.omit(item, ['timelineId', 'error', 'isUpdating', 'statusHistory'])) + return dispatch({ + type: CREATE_TIMELINE_MILESTONE, + payload: updateMilestones(timeline.id, milestones), + meta: { + timeline + } + }) + } } /** @@ -110,7 +125,12 @@ export function updateProductMilestone(productId, timelineId, milestoneId, updat 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 + let updatedTimelineMilestones + if (!updatedProps) { + updatedTimelineMilestones = processDeleteMilestone(milestoneIdx, timeline.milestones) + } else { + updatedTimelineMilestones = processUpdateMilestone(milestone, updatedProps, timeline.milestones).updatedTimelineMilestones + } dispatch({ type: UPDATE_PRODUCT_MILESTONE_PENDING, @@ -194,10 +214,10 @@ export function completeProductMilestone(productId, timelineId, milestoneId, upd 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' ) ) { + if ( ((nextMilestone.type === MILESTONE_TYPE.CHECKPOINT_REVIEW || nextMilestone.type === MILESTONE_TYPE.FINAL_DESIGNS) // case # 2 + && completedMilestone.type === MILESTONE_TYPE.ADD_LINKS ) || + ((nextMilestone.type === MILESTONE_TYPE.DELIVERY_DESIGN || nextMilestone.type === MILESTONE_TYPE.DELIVERY_DEV) // case # 4 + && completedMilestone.type !== MILESTONE_TYPE.FINAL_FIX) ) { details.metadata = { ..._.get(nextMilestone.details, 'metadata', {}), waitingForCustomer: true @@ -218,14 +238,6 @@ export function completeProductMilestone(productId, timelineId, milestoneId, upd payload: updateMilestones(timelineId, milestones), meta: { productId } }).then(() => { - 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 } @@ -311,7 +323,7 @@ export function submitFinalFixesRequest(productId, timelineId, milestoneId, fina let finalFixesMilestone = timeline.milestones[milestoneIdx - 1] - if (!finalFixesMilestone || finalFixesMilestone.type !== 'final-fix') { + if (!finalFixesMilestone || finalFixesMilestone.type !== MILESTONE_TYPE.FINAL_FIX) { throw new Error('Cannot find final-fix milestone.') } @@ -448,3 +460,74 @@ export function completeFinalFixesMilestone(productId, timelineId, milestoneId, }) } } + +/** + * Mark the milestone as 'completed' and append a 'deliverable-final-fixes' milestone after it + * @param {Number} productId product id + * @param {Number} timelineId timeline id + * @param {Number} milestoneId milestone id + * @param {String} finalFixRequest final fixes request text + */ +export function submitDeliverableFinalFixesRequest(productId, timelineId, milestoneId, finalFixRequest) { + return (dispatch, getState) => { + const state = getState() + const timeline = state.productsTimelines[productId].timeline + const milestoneIdx = _.findIndex(timeline.milestones, { id: milestoneId }) + const milestone = timeline.milestones[milestoneIdx] + + let updatedTimelineMilestones = [ + ...timeline.milestones, + ] + + updatedTimelineMilestones = processUpdateMilestone( + milestone, { + status: MILESTONE_STATUS.COMPLETED, + }, updatedTimelineMilestones + ).updatedTimelineMilestones + + const finalFixesMilestone = { + ...MILESTONE_DEFAULT_VALUES[MILESTONE_TYPE.DELIVERABLE_FINAL_FIXES], + type: MILESTONE_TYPE.DELIVERABLE_FINAL_FIXES, + startDate: milestone.endDate, + endDate: moment(milestone.endDate).add(3, 'day').toISOString(), + status: MILESTONE_STATUS.ACTIVE, + details: { + content: { + finalFixesRequest: finalFixRequest, + } + }, + name: 'Final Fixes', + duration: 3, + order: timeline.milestones.length + 1, + } + + updatedTimelineMilestones.splice(milestoneIdx + 1, 0, finalFixesMilestone) + + 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, + } + }).then(() => { + dispatch({ + type: SUBMIT_FINAL_FIXES_REQUEST_SUCCESS, + meta: { productId, milestoneId } + }) + }).catch((error) => { + dispatch({ + type: SUBMIT_FINAL_FIXES_REQUEST_FAILURE, + payload: error, + meta: { productId, milestoneId } + }) + throw error + }) + } +} diff --git a/src/projects/actions/project.js b/src/projects/actions/project.js index 8558583fd..1b2c7d821 100644 --- a/src/projects/actions/project.js +++ b/src/projects/actions/project.js @@ -3,7 +3,6 @@ import moment from 'moment' import { flatten, unflatten } from 'flat' import { getProjectById, createProject as createProjectAPI, - createProjectWithStatus as createProjectWithStatusAPI, updateProject as updateProjectAPI, deleteProject as deleteProjectAPI, deleteProjectPhase as deleteProjectPhaseAPI, @@ -19,6 +18,7 @@ import { getProjectMemberInvites, } from '../../api/projectMemberInvites' import { + updateMilestones, createTimeline, } from '../../api/timelines' import { @@ -29,7 +29,6 @@ import { LOAD_PROJECT, LOAD_PROJECT_MEMBER_INVITE, CREATE_PROJECT, - CREATE_PROJECT_STAGE, CLEAR_LOADED_PROJECT, UPDATE_PROJECT, DELETE_PROJECT, @@ -47,7 +46,6 @@ import { PHASE_DIRTY, PHASE_DIRTY_UNDO, PROJECT_STATUS_IN_REVIEW, - PHASE_STATUS_REVIEWED, PROJECT_STATUS_REVIEWED, PROJECT_STATUS_ACTIVE, EXPAND_PROJECT_PHASE, @@ -65,15 +63,17 @@ import { PHASE_STATUS_DRAFT, LOAD_PROJECT_MEMBERS, LOAD_PROJECT_MEMBER_INVITES, - LOAD_PROJECT_MEMBER + CREATE_PROJECT_PHASE_TIMELINE_MILESTONES, + LOAD_PROJECT_MEMBER, + ES_REINDEX_DELAY } from '../../config/constants' import { updateProductMilestone, updateProductTimeline } from './productsTimelines' -import { - getPhaseActualData, -} from '../../helpers/projectHelper' +import { delay } from '../../helpers/utils' +import { hasPermission } from '../../helpers/permissions' +import { PERMISSIONS } from '../../config/permissions' /** * Expand phase and optionaly expand particular tab @@ -270,45 +270,20 @@ function createProductsTimelineAndMilestone(project) { } } -export function createProduct(project, productTemplate, phases, timelines) { - // get endDates + 1 day for all the phases if there are any phases - const phaseEndDatesPlusOne = (phases || []).map((phase) => { - const productId = _.get(phase, 'products[0].id', -1) - const timeline = _.get(timelines, `${productId}.timeline`, null) - - const phaseActualData = getPhaseActualData(phase, timeline) - - return phaseActualData.endDate.add(1, 'day') - }) - - const today = moment().hours(0).minutes(0).seconds(0).milliseconds(0) - const startDate = _.max([...phaseEndDatesPlusOne, today]) - - // assumes 10 days as default duration, ideally we could store it at template level - const endDate = moment(startDate).add((10 - 1), 'days') - - return (dispatch) => { - return dispatch({ - type: CREATE_PROJECT_STAGE, - payload: createProjectPhaseAndProduct(project, productTemplate, PHASE_STATUS_DRAFT, startDate, endDate) - }) - } -} - /** * Create phase and product for the project * * @param {Object} project project - * @param {Object} projectTemplate project template + * @param {Object} productTemplate product template * @param {String} status (optional) project/phase status * * @return {Promise} project */ -export function createProjectPhaseAndProduct(project, projectTemplate, status = PHASE_STATUS_DRAFT, startDate, endDate) { +export function createProjectPhaseAndProduct(project, productTemplate, status = PHASE_STATUS_DRAFT, startDate, endDate) { const param = { status, - name: projectTemplate.name, - productTemplateId: projectTemplate.id + name: productTemplate.name, + productTemplateId: productTemplate.id } if (startDate) { param['startDate'] = startDate.format('YYYY-MM-DD') @@ -328,6 +303,62 @@ export function createProjectPhaseAndProduct(project, projectTemplate, status = }) } + +/** + * Create phase and product and milestones for the project + * + * @param {Object} project project + * @param {Object} productTemplate product template + * @param {String} status (optional) project/phase status + * @param {Object} startDate phase startDate + * @param {Object} endDate phase endDate + * @param {Array} milestones milestones + * + * @return {Promise} project + */ +function createPhaseAndMilestonesRequest(project, productTemplate, status = PHASE_STATUS_DRAFT, startDate, endDate, milestones) { + return createProjectPhaseAndProduct(project, productTemplate, status, startDate, endDate).then(({timeline, phase, project, product}) => { + // we have to add delay before creating milestones in newly created timeline + // to make sure timeline is created in ES, otherwise it may happen that we would try to add milestones + // into timeline before timeline existent in ES + return delay(ES_REINDEX_DELAY).then(() => updateMilestones(timeline.id, milestones).then((data) => ({ + phase, + project, + product, + timeline, + milestones: data + }))) + }) +} + +export function createPhaseAndMilestones(project, productTemplate, status, startDate, endDate, milestones) { + return (dispatch, getState) => { + return dispatch({ + type: CREATE_PROJECT_PHASE_TIMELINE_MILESTONES, + payload: createPhaseAndMilestonesRequest(project, productTemplate, status, startDate, endDate, milestones) + }).then(() => { + const state = getState() + const project = state.projectState.project + + console.log('project.status', project.status) + console.log('status', status) + + // if phase is created as ACTIVE, move project to ACTIVE too + if ( + _.includes([PROJECT_STATUS_DRAFT, PROJECT_STATUS_IN_REVIEW, PROJECT_STATUS_REVIEWED], project.status) && + status === PHASE_STATUS_ACTIVE && + hasPermission(PERMISSIONS.EDIT_PROJECT_STATUS) + ) { + dispatch( + updateProject(project.id, { + status: PROJECT_STATUS_ACTIVE + }, true) + ) + } + }) + } +} + export function deleteProjectPhase(projectId, phaseId) { return (dispatch) => { return dispatch({ @@ -433,10 +464,12 @@ export function updatePhase(projectId, phaseId, updatedProps, phaseIndex) { const phaseStartDate = timeline ? timeline.startDate : phase.startDate const startDateChanged = updatedProps.startDate ? updatedProps.startDate.diff(phaseStartDate) : null const phaseActivated = phaseStatusChanged && updatedProps.status === PHASE_STATUS_ACTIVE - if (phaseActivated) { - const duration = updatedProps.duration ? updatedProps.duration : phase.duration - updatedProps.startDate = moment().utc().hours(0).minutes(0).seconds(0).milliseconds(0).format('YYYY-MM-DD') - updatedProps.endDate = moment(updatedProps.startDate).add(duration - 1, 'days').format('YYYY-MM-DD') + + if (updatedProps.startDate) { + updatedProps.startDate = moment(updatedProps.startDate).format('YYYY-MM-DD') + } + if (updatedProps.endDate) { + updatedProps.endDate = moment(updatedProps.endDate).format('YYYY-MM-DD') } return dispatch({ @@ -494,24 +527,12 @@ export function updatePhase(projectId, phaseId, updatedProps, phaseIndex) { }).then(() => { const project = state.projectState.project - // if one phase moved to REVIEWED status, make project IN_REVIEW too - if ( - _.includes([PROJECT_STATUS_DRAFT], project.status) && - phase.status !== PHASE_STATUS_REVIEWED && - updatedProps.status === PHASE_STATUS_REVIEWED - ) { - dispatch( - updateProject(projectId, { - status: PHASE_STATUS_REVIEWED - }, true) - ) - } - // if one phase moved to ACTIVE status, make project ACTIVE too if ( _.includes([PROJECT_STATUS_DRAFT, PROJECT_STATUS_IN_REVIEW, PROJECT_STATUS_REVIEWED], project.status) && phase.status !== PHASE_STATUS_ACTIVE && - updatedProps.status === PHASE_STATUS_ACTIVE + updatedProps.status === PHASE_STATUS_ACTIVE && + hasPermission(PERMISSIONS.EDIT_PROJECT_STATUS) ) { dispatch( updateProject(projectId, { @@ -532,19 +553,6 @@ export function updateProduct(projectId, phaseId, productId, updatedProps) { } } -export function createProjectWithStatus(newProject, status, projectTemplate) { - return (dispatch) => { - return dispatch({ - type: CREATE_PROJECT, - payload: createProjectWithStatusAPI(newProject, status) - .then((project) => { - return createProjectPhaseAndProduct(project, projectTemplate, status) - .then(() => project) - }) - }) - } -} - export function deleteProject(newProject) { return (dispatch) => { return dispatch({ @@ -638,4 +646,4 @@ export function loadProjectMember(projectId, memberId) { payload: getProjectMember(projectId, memberId) }) } -} \ No newline at end of file +} diff --git a/src/projects/detail/components/CreatePhaseForm/CreatePhaseForm.jsx b/src/projects/detail/components/CreatePhaseForm/CreatePhaseForm.jsx new file mode 100644 index 000000000..352e1d994 --- /dev/null +++ b/src/projects/detail/components/CreatePhaseForm/CreatePhaseForm.jsx @@ -0,0 +1,418 @@ +/** + * create phase and milestone + */ +import React from 'react' +import PT from 'prop-types' +import moment from 'moment' +import FormsyForm from 'appirio-tech-react-components/components/Formsy' +import FormsySelect from '../../../../components/Select/FormsySelect' +import { MILESTONE_TYPE, MILESTONE_TYPE_OPTIONS, MILESTONE_STATUS, MILESTONE_DEFAULT_VALUES } from '../../../../config/constants' +import GenericMenu from '../../../../components/GenericMenu' +import TrashIcon from '../../../../assets/icons/icon-trash.svg' +import styles from './CreatePhaseForm.scss' +import { isValidStartEndDates } from '../../../../helpers/utils' + +const Formsy = FormsyForm.Formsy +const TCFormFields = FormsyForm.Fields + +const phaseOptions = _.map(MILESTONE_TYPE_OPTIONS, o => ({label: o.title, value: o.value})) + +/** + * Get milestone data from the formzy model by index + * + * @param {Object} model formzy flat model + * @param {Number} index milestone index + */ +const getMilestoneModelByIndex = (model, index) => { + let milestoneModel + + // omit phase fields + _.forEach(_.keys(_.omit(model, ['title', 'startDate', 'endDate'])), (key) => { + const keyMatches = key.match(/(\w+)_(\d+)/) + + if (keyMatches.length !== 3) { + return + } + + const arrIndex = Number(keyMatches[2]) + const objKey = keyMatches[1] + + if (arrIndex === index) { + if (!milestoneModel) { + milestoneModel = {} + } + + milestoneModel[objKey] = model[key] + } + }) + + return milestoneModel +} + +const defaultState = { + publishClicked: false, + isAddButtonClicked: false, + canSubmit: false, + milestones: [{ + pseudoId: _.uniqueId('milestone_'), // we need this to use for unique React `key` (using `index` might lead to issues) + type: MILESTONE_TYPE.REPORTING, + name: 'Reporting', + startDate: moment.utc().format('YYYY-MM-DD'), + endDate: moment.utc().add(3, 'days').format('YYYY-MM-DD') + }] +} + +class CreatePhaseForm extends React.Component { + constructor(props) { + super(props) + + this.state = _.cloneDeep(defaultState) + + this.onAddClick = this.onAddClick.bind(this) + this.onCancelClick = this.onCancelClick.bind(this) + this.onPublishClick = this.onPublishClick.bind(this) + this.onFormSubmit = this.onFormSubmit.bind(this) + this.enableButton = this.enableButton.bind(this) + this.disableButton = this.disableButton.bind(this) + this.handleChange = this.handleChange.bind(this) + + this.onDeleteMilestoneClick = this.onDeleteMilestoneClick.bind(this) + this.onAddMilestoneClick = this.onAddMilestoneClick.bind(this) + } + + onPublishClick() { + this.setState({ + publishClicked: true, + }) + } + + resetStatus() { + this.setState(_.cloneDeep(defaultState)) + } + + onCancelClick() { + this.resetStatus() + } + + onAddClick() { + this.setState( { + isAddButtonClicked: true + }) + } + + enableButton() { + this.setState( { canSubmit: true }) + } + + disableButton() { + this.setState({ canSubmit: false }) + } + + isChanged() { + // We check if this.refs.form exists because this may be called before the + // first render, in which case it will be undefined. + return (this.refs.form && this.refs.form.isChanged()) + } + + onFormSubmit(model) { + const {onSubmit} = this.props + const { publishClicked, milestones } = this.state + + const phaseData = { + title: model.title, + startDate: moment(model.startDate), + endDate: moment(model.endDate), + } + + const apiMilestones = milestones.map((milestone, index) => ({ + // default values + ...MILESTONE_DEFAULT_VALUES[milestone.type], + + // values from the form + ...getMilestoneModelByIndex(model, index), + + // auto-generated values + order: index + 1, + duration: moment(milestone.endDate).diff(moment(milestone.startDate), 'days') + 1 + })) + + if (publishClicked) { + apiMilestones[0].status = MILESTONE_STATUS.ACTIVE + apiMilestones[0].actualStartDate = moment().toISOString() + onSubmit('active', phaseData, apiMilestones) + } else { + onSubmit('draft', phaseData, apiMilestones) + } + this.resetStatus() + } + + /** + * Handles the change event of the form. + * + * @param change changed form model in flattened form + */ + handleChange(change) { + const { + milestones + } = this.state + + // update all milestones in state from the Formzy model + const newMilestones = milestones.map((milestone, index) => { + const milestoneModel = getMilestoneModelByIndex(change, index) + + const updatedMilestone = { + ...milestone, // keep all additional values we might keep in state + ...milestoneModel + } + + // if milestone has empty `name` then set it equal to the type textual value + if (milestoneModel.type && !milestoneModel.name) { + updatedMilestone.name = this.getOptionType(milestoneModel.type) + } + + return updatedMilestone + }) + + this.setState({ milestones: newMilestones }) + } + + getOptionType(val) { + return _.find(phaseOptions, (v) => v.value === val).label + } + + onDeleteMilestoneClick(index) { + const { + milestones + } = this.state + + this.setState({ + // delete without mutation + milestones: _.filter(milestones, (m, i) => i !== index) + }) + } + + onAddMilestoneClick() { + const { + milestones + } = this.state + + const newMilestone = { + pseudoId: _.uniqueId('milestone_'), // we need this to use for unique React `key` (using `index` might lead to issues) + name: '', + startDate: moment(_.last(milestones).endDate).format('YYYY-MM-DD'), + endDate: moment(_.last(milestones).endDate).add(3, 'days').format('YYYY-MM-DD') + } + + this.setState({ + // add without mutation + milestones: [ + ...milestones, + newMilestone + ] + }) + } + + renderMilestones() { + const { + milestones + } = this.state + + const ms = _.map(milestones, (m, index) => { + return ( +
+
+
+ + +
+
+
+ +
+
+ isValidStartEndDates(getMilestoneModelByIndex(values, index)) + }} + validationError={'Please, enter start date'} + validationErrors={{ + isValidStartEndDates: 'Start date cannot be after end date' + }} + required + label="Start Date" + type="date" + name={`startDate_${index}`} + value={milestones[index].startDate} + /> +
+
+ isValidStartEndDates(getMilestoneModelByIndex(values, index)) + }} + validationError={'Please, enter end date'} + validationErrors={{ + isValidStartEndDates: 'End date cannot be before start date' + }} + required + label="End Date" + type="date" + name={`endDate_${index}`} + value={milestones[index].endDate} + /> +
+ {index !== 0 && ( + this.onDeleteMilestoneClick(index)} title="trash"> + )} +
+ ) + }) + + return ( +
+ {ms} +
+ +
+
+ ) + } + + renderTab() { + const tabs = [ + { + onClick: () => {}, + label: 'Timeline', + isActive: true, + hasNotifications: false, + }] + return ( +
+ +
+ ) + } + + render() { + const { isAddButtonClicked } = this.state + + if (!isAddButtonClicked) { + return ( +
+ +
+ ) + } + + return ( +
+ +
+
+ +
+
+ + +
+ {this.renderTab()} + {this.renderMilestones()} +
+ + + +
+
+
+
+ ) + } +} + +CreatePhaseForm.propTypes = { + onSubmit: PT.func.isRequired, +} + +export default CreatePhaseForm diff --git a/src/projects/detail/components/CreatePhaseForm/CreatePhaseForm.scss b/src/projects/detail/components/CreatePhaseForm/CreatePhaseForm.scss new file mode 100644 index 000000000..b92c86416 --- /dev/null +++ b/src/projects/detail/components/CreatePhaseForm/CreatePhaseForm.scss @@ -0,0 +1,316 @@ +@import '~tc-ui/src/styles/tc-includes'; + +.add-button-container { + text-align: center; + margin-top: 10px; +} + +.form { + > .title-label-layer { + padding: 0 10px; + + + div { + padding: 0 10px; + } + + } +} + +.label-layer:nth-child(4) > :global(.Tooltip) { + width: 50%; + float: left; + .input-row { + width: 100%; + } +} + + +.input-row { + display: inline-block; + width: 50%; + + :global { + + .dropdown-wrap { + width: auto; + margin-left: 0px; + } + + .error-message { + width:calc(100% - 170px)!important; + margin: -5px 0 10px; + float: right; + } + + } + input { + display: inline-block; + width:calc(100% - 170px)!important; + } + + + label { + float: left; + margin: 0px 10px 10px; + text-align: right; + height: 40px; + line-height: 40px; + white-space: nowrap; + display: block; + width: 150px; + + } + + input:not([type="checkbox"]):disabled { + padding: 0; + } + :global(.SelectDropdown:not(.dropdown-wrap)) { + position: relative; + top: 12px; + } + + .input { + &::-webkit-input-placeholder { + text-transform: initial; + color:$tc-gray-30; + font-size: 15px; + font-weight: 400; + } + &:-moz-placeholder { + text-transform: initial; + color:$tc-gray-30; + font-size: 15px; + font-weight: 400; + } + &::-moz-placeholder { + text-transform: initial; + color:$tc-gray-30; + font-size: 15px; + font-weight: 400; + } + &:-ms-input-placeholder { + text-transform: initial; + color:$tc-gray-30; + font-size: 15px; + font-weight: 400; + } + } +} + +.title-label-layer { + .input-row { + width: 100%; + input { + display: inline-block; + width:calc(100% - 170px); + } + label { + display: inline-block; + width: 150px; + } + } +} + +.group-bottom { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + align-items: center; + margin-top: 20px; + padding-bottom: 20px; + + button { + margin: 0 5px 0 5px; + &.disable { + opacity: 0.5; + } + } +} + +.message { + background-color: $tc-red-10; + padding: 4 * $base-unit 4 * $base-unit 0 4 * $base-unit; + text-align: center; + + .message-title { + @include roboto-bold; + color: $tc-red-100; + font-size: 15px; + } + + .message-text { + @include roboto; + font-size: 13px; + line-height: 20px; + margin-top: 2 * $base-unit; + } +} + +// screen < 1024px +@media screen and (max-width: 1023px) { + .label-layer:nth-child(4) > :global(.Tooltip) { + width: 100%; + float: none; + .input-row { + width: 100%; + + } + } + .label-layer, + .input-row { + display: block; + width: 100%; + input:not([type="checkbox"]):disabled { + width: 100%; + } + :global(.SelectDropdown:not(.dropdown-wrap)) { + width: 100%; + position: relative; + top: 0; + } + :global { + .dropdown-wrap { + width: 100%; + margin-left: 0px; + height: 40px; + div { + padding: 0 5px!important; + } + } + + .error-message { + width: 100% !important; + float: initial; + } + } + input { + width: 100%!important; + display: block; + } + label { + width: 100%; + display: block; + text-align: left; + margin: 0; + float: none; + } + } + .title-label-layer { + display: block; + .input-row { + width: 100%; + display: block; + input { + width: 100%!important; + display: block; + } + label { + float: none; + width: 100%; + display: block; + margin: 0; + text-align: left; + } + } + } +} + + +.tab-container { + border-top: 1px solid $tc-gray-10; + border-bottom: 1px solid $tc-gray-10; + + + div > div:first-child { + &::before { + visibility: hidden; + } + } +} + +.milestone-item { + position: relative; + margin: 0 10px; + + &::before { + content: ''; + height: 1px; + background-color: $tc-gray-10; + display: block; + margin: 0px -10px 10px; + } + + > div { + width: 90%; + } + + > i { + position: absolute; + top: 20px; + right: 17px; + cursor: pointer; + } +} + +.add-milestone-wrapper { + text-align: center; + padding: 10px 0 30px; + border-bottom: 1px solid #aaaaab; +} + +.add-phase-form { + margin-top: 10px; + padding-top: 10px; + background-color: $tc-white; + border-radius: 6px; + box-shadow: 0 1px 1px 0 $tc-gray-10; + + @media screen and (max-width: $screen-md - 1px) { + border-radius: 0; + } +} + +.add-milestone-form { + margin-top: 10px; + + .input-row { + width: 100%; + } + + > div > div:first-child { + margin-bottom: 10px; + > div { + display: initial; + >div:first-child { + float: left; + margin: 0px 10px 10px; + text-align: right; + height: 40px; + line-height: 40px; + white-space: nowrap; + display: block; + width: 150px; + + @media screen and (max-width: 1023px) { + margin: initial; + > div { + text-align:left; + } + } + } + >div:last-child { + display: inline-block; + width: calc(100% - 170px) !important; + + @media screen and (max-width: 1023px) { + width: 100% !important; + } + } + + } + + + } +} + + diff --git a/src/projects/detail/components/CreatePhaseForm/index.js b/src/projects/detail/components/CreatePhaseForm/index.js new file mode 100644 index 000000000..26902484b --- /dev/null +++ b/src/projects/detail/components/CreatePhaseForm/index.js @@ -0,0 +1,2 @@ +import CreatePhaseForm from './CreatePhaseForm' +export default CreatePhaseForm diff --git a/src/projects/detail/components/PhaseCard/EditStageForm.jsx b/src/projects/detail/components/PhaseCard/EditStageForm.jsx index 523dd90b2..2849afc69 100644 --- a/src/projects/detail/components/PhaseCard/EditStageForm.jsx +++ b/src/projects/detail/components/PhaseCard/EditStageForm.jsx @@ -14,34 +14,25 @@ const TCFormFields = FormsyForm.Fields // import enhanceDropdown from 'appirio-tech-react-components/components/Dropdown/enhanceDropdown' import { updatePhase as updatePhaseAction, firePhaseDirty, firePhaseDirtyUndo } from '../../../actions/project' import LoadingIndicator from '../../../../components/LoadingIndicator/LoadingIndicator' -import SelectDropdown from '../../../../components/SelectDropdown/SelectDropdown' -import { PHASE_STATUS_COMPLETED, PHASE_STATUS, PHASE_STATUS_ACTIVE, PHASE_STATUS_DRAFT } from '../../../../config/constants' -import Tooltip from 'appirio-tech-react-components/components/Tooltip/Tooltip' -import { TOOLTIP_DEFAULT_DELAY } from '../../../../config/constants' -import { getPhaseActualData } from '../../../../helpers/projectHelper' +import { PHASE_STATUS_COMPLETED, PHASE_STATUS_ACTIVE, PHASE_STATUS_DRAFT } from '../../../../config/constants' import DeletePhase from './DeletePhase' import { hasPermission } from '../../../../helpers/permissions' import { PERMISSIONS } from '../../../../config/permissions' +import { isValidStartEndDates } from '../../../../helpers/utils' const moment = extendMoment(Moment) -const phaseStatuses = PHASE_STATUS.map(ps => ({ - title: ps.name, - value: ps.value, -})) class EditStageForm extends React.Component { constructor(props) { super(props) this.state = { + publishClicked: false, isUpdating: false, isEdittable: (_.get(props, 'phase.status') !== PHASE_STATUS_COMPLETED) || (_.get(props, 'canEditCompletedPhase')), - disableActiveStatusFields: _.get(props, 'phase.status') !== PHASE_STATUS_ACTIVE, showPhaseOverlapWarning: false, phaseIsdirty: false, showActivatingWarning: false, - // we have to control phase status separately, so we can restore its when we need - selectedPhaseStatus: _.get(props, 'phase.status') } this.submitValue = this.submitValue.bind(this) this.enableButton = this.enableButton.bind(this) @@ -52,7 +43,7 @@ class EditStageForm extends React.Component { this.showActivatingWarning = this.showActivatingWarning.bind(this) this.cancelActivatingPhase = this.cancelActivatingPhase.bind(this) this.onFormSubmit = this.onFormSubmit.bind(this) - this.updateSelectedPhaseStatus = this.updateSelectedPhaseStatus.bind(this) + this.onPublishClick = this.onPublishClick.bind(this) } showActivatingWarning() { @@ -62,23 +53,9 @@ class EditStageForm extends React.Component { } cancelActivatingPhase() { - const phaseStatus = _.get(this.props, 'phase.status') this.setState({ + publishClicked: false, showActivatingWarning: false, - // to restore phase status first we change selected value to nothing - // and after will again put initial value, this will force SelectDropdown to change - // to initial value - selectedPhaseStatus: '', - }, () => { - this.setState({ - selectedPhaseStatus: phaseStatus, - }) - }) - } - - updateSelectedPhaseStatus(selectedOption) { - this.setState({ - selectedPhaseStatus: selectedOption.value, }) } @@ -86,15 +63,7 @@ class EditStageForm extends React.Component { this.setState({ isUpdating: nextProps.isUpdating, isEdittable: nextProps.phase.status !== PHASE_STATUS_COMPLETED || nextProps.canEditCompletedPhase, - disableActiveStatusFields: nextProps.phase.status !== PHASE_STATUS_ACTIVE, }) - - // update selected phase status if it was updated at the props - const prevPhaseStatus = _.get(this.props, 'phase.status') - const nextPhaseStatus = _.get(nextProps, 'phase.status') - if (nextPhaseStatus && prevPhaseStatus !== nextPhaseStatus) { - this.setState({ selectedPhaseStatus: nextPhaseStatus }) - } } componentDidMount() { @@ -108,26 +77,35 @@ class EditStageForm extends React.Component { submitValue(model) { const { phase, phaseIndex, updatePhaseAction } = this.props + const { + publishClicked + } = this.state const updatedStartDate = moment.utc(new Date(model.startDate)) - const duration = model.duration ? model.duration : 1 - const endDate = model.status === PHASE_STATUS_COMPLETED ? moment.utc(new Date()) : moment.utc(updatedStartDate).add(duration - 1, 'days') + const updatedEndDate = model.status === PHASE_STATUS_COMPLETED ? moment.utc(new Date()) : moment.utc(model.endDate) + let newStatus = phase.status + if (publishClicked && phase.status === PHASE_STATUS_DRAFT) { + newStatus = PHASE_STATUS_ACTIVE + } const updateParam = _.assign({}, model, { startDate: updatedStartDate, - endDate: endDate || '', - duration + endDate: updatedEndDate || '', + status: newStatus + }) + this.setState({ + isUpdating: true, + publishClicked: false }) - this.setState({isUpdating: true}) updatePhaseAction(phase.projectId, phase.id, updateParam, phaseIndex) } onFormSubmit(model) { const { phase } = this.props - const { showActivatingWarning } = this.state + const { showActivatingWarning, publishClicked } = this.state if ( !showActivatingWarning && - phase.status !== PHASE_STATUS_ACTIVE && - model.status === PHASE_STATUS_ACTIVE + publishClicked && + phase.status === PHASE_STATUS_DRAFT ) { this.showActivatingWarning() } else { @@ -161,11 +139,19 @@ class EditStageForm extends React.Component { this.props.firePhaseDirtyUndo() this.setState({ showPhaseOverlapWarning: false, - phaseIsdirty: false + phaseIsdirty: false, }) this.props.cancel() } + /** + * when in draft status, click publish button + */ + onPublishClick() { + this.setState({ + publishClicked: true, + }) + } /** * Handles the change event of the form. * @@ -175,10 +161,12 @@ class EditStageForm extends React.Component { handleChange(change) { const { phases, phase, phaseIndex } = this.props let showPhaseOverlapWarning = false - // if start date's day or duration is updated for a phase, we need to update other phases dates accordingly - const phaseDay = moment.utc(new Date(phase.startDate)).format('DD') - const changedDay = moment.utc(new Date(change.startDate)).format('DD') - if (phaseDay !== changedDay || phase.duration !== change.duration) { + // if start date's start day or end day is updated for a phase, we need to update other phases dates accordingly + const phaseStartDay = moment.utc(new Date(phase.startDate)).format('YYYY-MM-DD') + const changedStartDay = moment.utc(new Date(change.startDate)).format('YYYY-MM-DD') + const phaseEndDay = moment.utc(new Date(phase.endDate)).format('YYYY-MM-DD') + const changedEndDay = moment.utc(new Date(change.endDate)).format('YYYY-MM-DD') + if (phaseStartDay !== changedStartDay || phaseEndDay !== changedEndDay) { // console.log('Need to sync phases') const reqChanges = this.checkOverlappingPhases(phases, phase, phaseIndex, change) //console.log('reqChanges : ', reqChanges) @@ -191,7 +179,6 @@ class EditStageForm extends React.Component { // console.log('No need to sync phases') } this.setState({ - disableActiveStatusFields: change.status !== PHASE_STATUS_ACTIVE, showPhaseOverlapWarning }) if (this.isChanged()) { @@ -206,8 +193,8 @@ class EditStageForm extends React.Component { } checkOverlappingPhases(phases, refPhase, refPhaseIndex, updatedPhase) { - // if startDate or duration is not set in the current update, we don't need to do anything - if (!updatedPhase.startDate || !updatedPhase.duration) return false + // if startDate or endDate is not set in the current update, we don't need to do anything + if (!updatedPhase.startDate || !updatedPhase.endDate) return false //Possible mutations //!date,!duration //both changed //!date,duration //date changed @@ -215,13 +202,13 @@ class EditStageForm extends React.Component { const phasesToBeUpdated = [] let overLapping = false const updatedPhaseStartDate = updatedPhase ? moment(new Date(updatedPhase.startDate)) : null - const updatedPhaseEndDate = updatedPhase ? moment(new Date(updatedPhase.startDate)).add(updatedPhase.duration - 1, 'days') : null + const updatedPhaseEndDate = updatedPhase ? moment(new Date(updatedPhase.endDate)) : null const updatedPhaseRange = moment().range(updatedPhaseStartDate, updatedPhaseEndDate) for(let i =0; i < phases.length; i++) { overLapping = false if(i !== refPhaseIndex) { const currentStartDate = moment(new Date(phases[i].startDate)) - const currentEndDate = moment(new Date(phases[i].startDate)).add(phases[i].duration - 1, 'days') + const currentEndDate = moment(new Date(phases[i].endDate)) const currentPhaseRange = moment().range(currentStartDate, currentEndDate) if(currentPhaseRange.contains(updatedPhaseStartDate)) { overLapping = true @@ -246,20 +233,14 @@ class EditStageForm extends React.Component { } render() { - const { phase, isUpdating, timeline, deleteProjectPhase } = this.props - const { isEdittable, showPhaseOverlapWarning, showActivatingWarning, selectedPhaseStatus } = this.state + const { phase, isUpdating, deleteProjectPhase } = this.props + const { isEdittable, showPhaseOverlapWarning, showActivatingWarning } = this.state let startDate = phase.startDate ? new Date(phase.startDate) : new Date() startDate = moment.utc(startDate).format('YYYY-MM-DD') - const hasTimeline = !!timeline + let endDate = phase.endDate ? new Date(phase.endDate) : new Date() + endDate = moment.utc(endDate).format('YYYY-MM-DD') const canDelete = phase.status !== PHASE_STATUS_ACTIVE && phase.status !== PHASE_STATUS_COMPLETED - // don't allow to selected completed status if product has timeline - const activePhaseStatuses = phaseStatuses.map((status) => ({ - ...status, - disabled: hasTimeline && status.value === PHASE_STATUS_COMPLETED, - toolTipMessage: (hasTimeline && status.value === PHASE_STATUS_COMPLETED) ? 'Once activated, phase delivery is controlled by the milestones.': null, - })) - - const { progress, duration } = getPhaseActualData(phase, timeline) + const isDraft = phase.status === PHASE_STATUS_DRAFT return (
@@ -282,71 +263,51 @@ class EditStageForm extends React.Component { Warning: You are about to manually change the start/end date of this phase, Please ensure the start and end dates of all subsequent phases (where applicable) are updated in line with this change.
}
- -
-
-
- - -
-
- {hasTimeline && phase.status === PHASE_STATUS_ACTIVE ? ( - -
-
- - -
-
-
- Phase status is controlled by statuses of individual milestones -
-
- ) : ( -
- - -
- )} - +
{!showActivatingWarning ? ( @@ -354,12 +315,19 @@ class EditStageForm extends React.Component { + >Save Changes + {isDraft ? ( + + ) : null}
) : (
-

You are about to activate the phase

-

This action will permanently change the status of your phase to Active and cannot be undone.

+

You are about to publish the phase

+

This action will permanently publish your phase and cannot be undone.

) } diff --git a/src/projects/detail/components/ProjectStage.jsx b/src/projects/detail/components/ProjectStage.jsx index cf225da6d..26a611d77 100644 --- a/src/projects/detail/components/ProjectStage.jsx +++ b/src/projects/detail/components/ProjectStage.jsx @@ -87,7 +87,9 @@ function formatPhaseCardAttr(phase, phaseIndex, productTemplates, feed, timeline posts, phaseIndex, phase, - progressInPercent + progressInPercent, + actualStartDate: startDate, + actualEndDate: endDate, } } diff --git a/src/projects/detail/components/timeline/CreateMilestoneForm/CreateMilestoneForm.jsx b/src/projects/detail/components/timeline/CreateMilestoneForm/CreateMilestoneForm.jsx new file mode 100644 index 000000000..c5e59db9e --- /dev/null +++ b/src/projects/detail/components/timeline/CreateMilestoneForm/CreateMilestoneForm.jsx @@ -0,0 +1,190 @@ +/** + * add milestone form for timeline + */ +import React from 'react' +import PT from 'prop-types' +import moment from 'moment' + +import { MILESTONE_DEFAULT_VALUES, MILESTONE_TYPE_OPTIONS } from '../../../../../config/constants' +import LoadingIndicator from '../../../../../components/LoadingIndicator/LoadingIndicator' +import Form from '../Form' +import { isValidStartEndDates } from '../../../../../helpers/utils' +import './CreateMilestoneForm.scss' + +class CreateMilestoneForm extends React.Component { + constructor(props) { + super(props) + + const { previousMilestone } = props + const startDate = previousMilestone ? moment.utc(previousMilestone.completionDate || previousMilestone.endDate) : moment.utc() + this.state = { + isEditing: false, + type: '', + name: '', + startDate: startDate.format('YYYY-MM-DD'), + endDate: startDate.add(3, 'days').format('YYYY-MM-DD') + } + + this.submitForm = this.submitForm.bind(this) + this.cancelEdit = this.cancelEdit.bind(this) + this.onAddClick = this.onAddClick.bind(this) + this.changeForm = this.changeForm.bind(this) + } + + cancelEdit() { + const { previousMilestone } = this.props + const startDate = previousMilestone ? moment.utc(previousMilestone.completionDate || previousMilestone.endDate) : moment.utc() + this.state = { + isEditing: false, + type: '', + name: '', + startDate: startDate.format('YYYY-MM-DD'), + endDate: startDate.add(3, 'days').format('YYYY-MM-DD') + } + } + + onAddClick() { + this.setState({ + isEditing: true + }) + } + + submitForm(values) { + const { onSubmit, previousMilestone } = this.props + + const apiValues = { + // default values + ...MILESTONE_DEFAULT_VALUES[values.type], + + // values from the form + ...values, + + // auto-generated values + order: previousMilestone ? previousMilestone.order + 1 : 1, + duration: moment(values.endDate).diff(moment(values.startDate), 'days') + 1, + } + + onSubmit(apiValues) + } + + getOptionType(val) { + return _.find(MILESTONE_TYPE_OPTIONS, (v) => v.value === val).title + } + + changeForm(values) { + const { type, name, startDate, endDate } = this.state + if (values['name'] !== name) { + this.setState({ + name: values['name'] + }) + } + if (values['startDate'] !== startDate) { + this.setState({ + startDate: values['startDate'] + }) + } + if (values['endDate'] !== endDate) { + this.setState({ + endDate: values['endDate'] + }) + } + // set name when select type and name is empty + if (values['type'] !== type) { + this.setState({ + type: values['type'] + }) + if (!name) { + this.setState({ + name: this.getOptionType(values['type']) + }) + } + } + } + + render() { + const { isAdding, isEditing, type, name, startDate, endDate } = this.state + if(isAdding) { + return ( + + ) + } + if (!isEditing) { + return ( +
+ +
+ ) + } + const editForm = ( +
+ ) + + return ( +
+ {editForm} +
+ ) + } +} + +CreateMilestoneForm.propTypes = { + onSubmit: PT.func.isRequired, + previousMilestone: PT.object +} + +export default CreateMilestoneForm + diff --git a/src/projects/detail/components/timeline/CreateMilestoneForm/CreateMilestoneForm.scss b/src/projects/detail/components/timeline/CreateMilestoneForm/CreateMilestoneForm.scss new file mode 100644 index 000000000..6c86f3763 --- /dev/null +++ b/src/projects/detail/components/timeline/CreateMilestoneForm/CreateMilestoneForm.scss @@ -0,0 +1,17 @@ +@import '~tc-ui/src/styles/tc-includes'; + +.button-container { + text-align: center; + padding: 20px 0; +} + +form { + > div:nth-child(2) > div:first-child { + :global { + .dropdown-wrap { + margin-bottom: 10px; + } + } + } +} + diff --git a/src/projects/detail/components/timeline/CreateMilestoneForm/index.js b/src/projects/detail/components/timeline/CreateMilestoneForm/index.js new file mode 100644 index 000000000..e3a1cf1c9 --- /dev/null +++ b/src/projects/detail/components/timeline/CreateMilestoneForm/index.js @@ -0,0 +1,2 @@ +import CreateMilestoneForm from './CreateMilestoneForm' +export default CreateMilestoneForm diff --git a/src/projects/detail/components/timeline/FormFieldDate/FormFieldDate.jsx b/src/projects/detail/components/timeline/FormFieldDate/FormFieldDate.jsx index c62b5030d..b9a4fb4e6 100644 --- a/src/projects/detail/components/timeline/FormFieldDate/FormFieldDate.jsx +++ b/src/projects/detail/components/timeline/FormFieldDate/FormFieldDate.jsx @@ -30,16 +30,6 @@ FormFieldDate.defaultProps = { } FormFieldDate.propTypes = { - endDate: PT.shape({ - label: PT.string.isRequired, - name: PT.string.isRequired, - value: PT.string, - }), - startDate: PT.shape({ - label: PT.string.isRequired, - name: PT.string.isRequired, - value: PT.string, - }), theme: PT.string, } diff --git a/src/projects/detail/components/timeline/FormFieldDate/FormFieldDate.scss b/src/projects/detail/components/timeline/FormFieldDate/FormFieldDate.scss index cda278e1d..b59d3deb4 100644 --- a/src/projects/detail/components/timeline/FormFieldDate/FormFieldDate.scss +++ b/src/projects/detail/components/timeline/FormFieldDate/FormFieldDate.scss @@ -13,22 +13,11 @@ margin-bottom: 12 * $base-unit; } - input:not([type="checkbox"]).tc-file-field__inputs.error:focus { - /* have to use !important because it is defined initially like that */ - border-color: $tc-dark-blue-70 !important; - margin-bottom: 2 * $base-unit; - } - .tc-file-field__inputs.error + p { - position: absolute; margin-bottom: 2 * $base-unit; margin-top: - 10 * $base-unit; min-width: 100%; } - - .tc-file-field__inputs.error:focus + p { - display: none; - } } .col-left { diff --git a/src/projects/detail/components/timeline/FormFieldDropdown/FormFieldDropdown.jsx b/src/projects/detail/components/timeline/FormFieldDropdown/FormFieldDropdown.jsx index 6e886aaab..0662f698a 100644 --- a/src/projects/detail/components/timeline/FormFieldDropdown/FormFieldDropdown.jsx +++ b/src/projects/detail/components/timeline/FormFieldDropdown/FormFieldDropdown.jsx @@ -9,7 +9,7 @@ import SelectDropdown from '../../../../../components/SelectDropdown/SelectDropd import './FormFieldDropdown.scss' -const FormFieldDropdown = ({ name, value, options, label, theme }) => ( +const FormFieldDropdown = ({ name, value, options, label, theme, disabled }) => (
{label}
@@ -20,6 +20,7 @@ const FormFieldDropdown = ({ name, value, options, label, theme }) => ( value={value} theme="default" options={options} + disabled={disabled} />
@@ -28,6 +29,7 @@ const FormFieldDropdown = ({ name, value, options, label, theme }) => ( FormFieldDropdown.defaultProps = { value: '', theme: '', + disabled: false, } FormFieldDropdown.propTypes = { @@ -39,6 +41,7 @@ FormFieldDropdown.propTypes = { value: PT.string.isRequired, })), theme: PT.string, + disabled: PT.bool, } export default FormFieldDropdown diff --git a/src/projects/detail/components/timeline/FormFieldNumber/FormFieldNumber.scss b/src/projects/detail/components/timeline/FormFieldNumber/FormFieldNumber.scss index 857c111ac..6f806e07c 100644 --- a/src/projects/detail/components/timeline/FormFieldNumber/FormFieldNumber.scss +++ b/src/projects/detail/components/timeline/FormFieldNumber/FormFieldNumber.scss @@ -24,15 +24,6 @@ .tc-file-field__inputs.error + p { margin-bottom: 2 * $base-unit; } - - input:not([type="checkbox"]).tc-file-field__inputs.error:focus { - /* have to use !important because it is defined initially like that */ - border-color: $tc-dark-blue-70 !important; - } - - .tc-file-field__inputs.error:focus + p { - display: none; - } } .col-left { diff --git a/src/projects/detail/components/timeline/FormFieldText/FormFieldText.scss b/src/projects/detail/components/timeline/FormFieldText/FormFieldText.scss index a3c54dc7b..c22431842 100644 --- a/src/projects/detail/components/timeline/FormFieldText/FormFieldText.scss +++ b/src/projects/detail/components/timeline/FormFieldText/FormFieldText.scss @@ -12,15 +12,6 @@ .tc-file-field__inputs.error + p { margin-bottom: 2 * $base-unit; } - - input:not([type="checkbox"]).tc-file-field__inputs.error:focus { - /* have to use !important because it is defined initially like that */ - border-color: $tc-dark-blue-70 !important; - } - - .tc-file-field__inputs.error:focus + p { - display: none; - } } .col-left { diff --git a/src/projects/detail/components/timeline/LinkItem/LinkItem.jsx b/src/projects/detail/components/timeline/LinkItem/LinkItem.jsx index bab9c9b9c..968d5fe15 100644 --- a/src/projects/detail/components/timeline/LinkItem/LinkItem.jsx +++ b/src/projects/detail/components/timeline/LinkItem/LinkItem.jsx @@ -88,6 +88,9 @@ class LinkItem extends React.Component { // fallback to no-type if type is not supported to avoid errors due to lack of styles const type = _.includes(supportedTypes, link.type) ? link.type : '' + // if URL doesn't have protocol, then add `https` + const url = link.url.match(/^[a-zA-Z]{3,}:\/\/.+/) ? link.url : `https://${link.url}` + return (
o.value === milestone.type) + const options = _.clone(MILESTONE_TYPE_OPTIONS) + if (!option) { + options.unshift( + { + title: `Deprecated type <${milestone.type}>`, + value: milestone.type + } + ) + + } + return options + } + render() { const { milestone, + index, currentUser, previousMilestone, } = this.props const { isEditing, isMobileEditing } = this.state - const isPlanned = milestone.status === MILESTONE_STATUS.PLANNED const isActive = milestone.status === MILESTONE_STATUS.ACTIVE const isCompleted = milestone.status === MILESTONE_STATUS.COMPLETED @@ -234,94 +282,133 @@ class Milestone extends React.Component { const isUpdating = milestone.isUpdating const isActualDateEditable = this.isActualStartDateEditable() const isCompletionDateEditable = this.isCompletionDateEditable() + + const disableDelete = index === 0 && milestone.type === MILESTONE_TYPE.REPORTING || milestone.status !== MILESTONE_STATUS.PLANNED + const disableType = index === 0 && milestone.type === MILESTONE_TYPE.REPORTING || milestone.status !== MILESTONE_STATUS.PLANNED + const editForm = ( ) + return (
{(
)} @@ -380,7 +468,8 @@ class Milestone extends React.Component { } {isEditing && !isUpdating && ( -
+
+ {disableDelete ? null: } {editForm}
)} @@ -408,7 +497,7 @@ class Milestone extends React.Component { {isUpdating && } - {!isEditing && !isUpdating && milestone.type === 'phase-specification' && ( + {!isEditing && !isUpdating && milestone.type === MILESTONE_TYPE.PHASE_SPECIFICATION && ( )} - {!isEditing && !isUpdating && (milestone.type === 'community-work' || milestone.type === 'community-review' || milestone.type === 'generic-work') && ( + {!isEditing && !isUpdating && (milestone.type === MILESTONE_TYPE.COMMUNITY_WORK || milestone.type === MILESTONE_TYPE.COMMUNITY_REVIEW || milestone.type === MILESTONE_TYPE.GENERIC_WORK) && ( )} - {!isEditing && !isUpdating && milestone.type === 'checkpoint-review' && ( + {!isEditing && !isUpdating && milestone.type === MILESTONE_TYPE.CHECKPOINT_REVIEW && ( )} - {!isEditing && !isUpdating && milestone.type === 'add-links' && ( + {!isEditing && !isUpdating && milestone.type === MILESTONE_TYPE.ADD_LINKS && ( )} - {!isEditing && !isUpdating && milestone.type === 'final-designs' && ( + {!isEditing && !isUpdating && milestone.type === MILESTONE_TYPE.FINAL_DESIGNS && ( )} - {!isEditing && !isUpdating && milestone.type === 'final-fix' && ( + {!isEditing && !isUpdating && milestone.type === MILESTONE_TYPE.FINAL_FIX && ( ) } + + {!isEditing && !isUpdating && milestone.type === MILESTONE_TYPE.REPORTING && ( + + )} + + {!isEditing && !isUpdating && (milestone.type === MILESTONE_TYPE.DELIVERABLE_REVIEW || milestone.type === MILESTONE_TYPE.FINAL_DELIVERABLE_REVIEW || milestone.type === MILESTONE_TYPE.DELIVERABLE_FINAL_FIXES) && ( + + )}
) @@ -502,8 +609,10 @@ Milestone.propTypes = { currentUser: PT.object.isRequired, extendMilestone: PT.func.isRequired, milestone: PT.object.isRequired, + index: PT.number.isRequired, submitFinalFixesRequest: PT.func.isRequired, updateMilestone: PT.func.isRequired, + submitDeliverableFinalFixesRequest: PT.func.isRequired, } export default Milestone diff --git a/src/projects/detail/components/timeline/Milestone/Milestone.scss b/src/projects/detail/components/timeline/Milestone/Milestone.scss index 620a3e33d..d02fe6089 100644 --- a/src/projects/detail/components/timeline/Milestone/Milestone.scss +++ b/src/projects/detail/components/timeline/Milestone/Milestone.scss @@ -10,7 +10,13 @@ height: auto; } + :global(.error + p) { + position: relative !important; + } + :global(.dropdown-wrap) { + margin-bottom: 10px; + } @include roboto; a { @@ -306,4 +312,15 @@ form { flex: 1; } -} \ No newline at end of file +} + +.edit-form { + position: relative; + >i { + position: absolute; + right: 25px; + top: 20px; + cursor: pointer; + + } +} diff --git a/src/projects/detail/components/timeline/Timeline/Timeline.jsx b/src/projects/detail/components/timeline/Timeline/Timeline.jsx index 12ed9cbb8..d3ae6f297 100644 --- a/src/projects/detail/components/timeline/Timeline/Timeline.jsx +++ b/src/projects/detail/components/timeline/Timeline/Timeline.jsx @@ -8,8 +8,12 @@ import _ from 'lodash' import Milestone from '../Milestone' import LoadingIndicator from '../../../../../components/LoadingIndicator/LoadingIndicator' import NotificationsReader from '../../../../../components/NotificationsReader' +import CreateMilestoneForm from '../CreateMilestoneForm' import { buildPhaseTimelineNotificationsCriteria } from '../../../../../routes/notifications/constants/notifications' +import './Timeline.scss' +import { hasPermission } from '../../../../../helpers/permissions' +import { PERMISSIONS } from '../../../../../config/permissions' class Timeline extends React.Component { constructor(props) { @@ -17,14 +21,18 @@ class Timeline extends React.Component { this.state = { height: 0, + isEditing: false } this.updateHeight = this.updateHeight.bind(this) + this.onAddClick = this.onAddClick.bind(this) this.updateMilestone = this.updateMilestone.bind(this) + this.createMilestone = this.createMilestone.bind(this) this.completeMilestone = this.completeMilestone.bind(this) this.completeFinalFixesMilestone = this.completeFinalFixesMilestone.bind(this) this.extendMilestone = this.extendMilestone.bind(this) this.submitFinalFixesRequest = this.submitFinalFixesRequest.bind(this) + this.submitDeliverableFinalFixesRequest = this.submitDeliverableFinalFixesRequest.bind(this) } componentWillReceiveProps() { @@ -41,6 +49,23 @@ class Timeline extends React.Component { } } + onCancelClick() { + this.setState({ + isEditing: true + }) + } + onSubmitClick() { + this.setState({ + isEditing: false + }) + } + onAddClick() { + this.setState({ + isEditing: true + }) + } + + updateMilestone(milestoneId, values) { const { product, @@ -51,6 +76,17 @@ class Timeline extends React.Component { updateProductMilestone(product.id, timeline.id, milestoneId, values) } + createMilestone(milestone) { + const { + createProductMilestone, + timeline, + } = this.props + + const orderedMilestones = timeline.milestones ? _.orderBy(timeline.milestones, ['order']) : [] + milestone.order = orderedMilestones.length ? _.last(orderedMilestones).order + 1 : 1 + + createProductMilestone(timeline, [...timeline.milestones, milestone]) + } completeMilestone(milestoneId, updatedProps = {}) { const { product, @@ -91,6 +127,16 @@ class Timeline extends React.Component { submitFinalFixesRequest(product.id, timeline.id, milestoneId, finalFixRequests) } + submitDeliverableFinalFixesRequest(milestoneId, finalFixesRequest) { + const { + product, + submitDeliverableFinalFixesRequest, + timeline, + } = this.props + + submitDeliverableFinalFixesRequest(product.id, timeline.id, milestoneId, finalFixesRequest) + } + render() { const { currentUser, @@ -100,35 +146,46 @@ class Timeline extends React.Component { project, } = this.props + const canAddeMilestone = hasPermission(PERMISSIONS.MANAGE_PROJECT_PLAN) + if (isLoading || _.some(timeline.milestones, 'isUpdating')) { const divHeight = `${this.state.height}px` return (
) } else { //Ordering milestones wrt "order" before rendering const orderedMilestones = timeline.milestones ? _.orderBy(timeline.milestones, ['order']) : [] + const allShowMilestones = _.reject(orderedMilestones, { hidden: true }) return (
{ this.div = div } }> - - {_.reject(orderedMilestones, { hidden: true }).map((milestone) => ( + {allShowMilestones.map((milestone, index) => ( m.order === milestone.order-1) && _.find(orderedMilestones, m => m.order === milestone.order-1).type} project={project} /> ))} + {canAddeMilestone && ( + + )}
) } @@ -143,9 +200,12 @@ Timeline.propType = { isLoading: PT.bool, product: PT.object.isRequired, timeline: PT.object.isRequired, + createProductMilestone: PT.func.isRequired, updateProductMilestone: PT.func.isRequired, + createTimelineMilestone: PT.func.isRequired, completeProductMilestone: PT.func.isRequired, extendProductMilestone: PT.func.isRequired, + submitDeliverableFinalFixesRequest: PT.func.isRequired, } export default Timeline diff --git a/src/projects/detail/components/timeline/createMilestoneForm/CreateMilestoneForm.jsx b/src/projects/detail/components/timeline/createMilestoneForm/CreateMilestoneForm.jsx new file mode 100644 index 000000000..c5e59db9e --- /dev/null +++ b/src/projects/detail/components/timeline/createMilestoneForm/CreateMilestoneForm.jsx @@ -0,0 +1,190 @@ +/** + * add milestone form for timeline + */ +import React from 'react' +import PT from 'prop-types' +import moment from 'moment' + +import { MILESTONE_DEFAULT_VALUES, MILESTONE_TYPE_OPTIONS } from '../../../../../config/constants' +import LoadingIndicator from '../../../../../components/LoadingIndicator/LoadingIndicator' +import Form from '../Form' +import { isValidStartEndDates } from '../../../../../helpers/utils' +import './CreateMilestoneForm.scss' + +class CreateMilestoneForm extends React.Component { + constructor(props) { + super(props) + + const { previousMilestone } = props + const startDate = previousMilestone ? moment.utc(previousMilestone.completionDate || previousMilestone.endDate) : moment.utc() + this.state = { + isEditing: false, + type: '', + name: '', + startDate: startDate.format('YYYY-MM-DD'), + endDate: startDate.add(3, 'days').format('YYYY-MM-DD') + } + + this.submitForm = this.submitForm.bind(this) + this.cancelEdit = this.cancelEdit.bind(this) + this.onAddClick = this.onAddClick.bind(this) + this.changeForm = this.changeForm.bind(this) + } + + cancelEdit() { + const { previousMilestone } = this.props + const startDate = previousMilestone ? moment.utc(previousMilestone.completionDate || previousMilestone.endDate) : moment.utc() + this.state = { + isEditing: false, + type: '', + name: '', + startDate: startDate.format('YYYY-MM-DD'), + endDate: startDate.add(3, 'days').format('YYYY-MM-DD') + } + } + + onAddClick() { + this.setState({ + isEditing: true + }) + } + + submitForm(values) { + const { onSubmit, previousMilestone } = this.props + + const apiValues = { + // default values + ...MILESTONE_DEFAULT_VALUES[values.type], + + // values from the form + ...values, + + // auto-generated values + order: previousMilestone ? previousMilestone.order + 1 : 1, + duration: moment(values.endDate).diff(moment(values.startDate), 'days') + 1, + } + + onSubmit(apiValues) + } + + getOptionType(val) { + return _.find(MILESTONE_TYPE_OPTIONS, (v) => v.value === val).title + } + + changeForm(values) { + const { type, name, startDate, endDate } = this.state + if (values['name'] !== name) { + this.setState({ + name: values['name'] + }) + } + if (values['startDate'] !== startDate) { + this.setState({ + startDate: values['startDate'] + }) + } + if (values['endDate'] !== endDate) { + this.setState({ + endDate: values['endDate'] + }) + } + // set name when select type and name is empty + if (values['type'] !== type) { + this.setState({ + type: values['type'] + }) + if (!name) { + this.setState({ + name: this.getOptionType(values['type']) + }) + } + } + } + + render() { + const { isAdding, isEditing, type, name, startDate, endDate } = this.state + if(isAdding) { + return ( + + ) + } + if (!isEditing) { + return ( +
+ +
+ ) + } + const editForm = ( + + ) + + return ( +
+ {editForm} +
+ ) + } +} + +CreateMilestoneForm.propTypes = { + onSubmit: PT.func.isRequired, + previousMilestone: PT.object +} + +export default CreateMilestoneForm + diff --git a/src/projects/detail/components/timeline/createMilestoneForm/CreateMilestoneForm.scss b/src/projects/detail/components/timeline/createMilestoneForm/CreateMilestoneForm.scss new file mode 100644 index 000000000..6c86f3763 --- /dev/null +++ b/src/projects/detail/components/timeline/createMilestoneForm/CreateMilestoneForm.scss @@ -0,0 +1,17 @@ +@import '~tc-ui/src/styles/tc-includes'; + +.button-container { + text-align: center; + padding: 20px 0; +} + +form { + > div:nth-child(2) > div:first-child { + :global { + .dropdown-wrap { + margin-bottom: 10px; + } + } + } +} + diff --git a/src/projects/detail/components/timeline/createMilestoneForm/index.js b/src/projects/detail/components/timeline/createMilestoneForm/index.js new file mode 100644 index 000000000..e3a1cf1c9 --- /dev/null +++ b/src/projects/detail/components/timeline/createMilestoneForm/index.js @@ -0,0 +1,2 @@ +import CreateMilestoneForm from './CreateMilestoneForm' +export default CreateMilestoneForm diff --git a/src/projects/detail/components/timeline/milestones/MilestoneTypeCheckpointReview/MilestoneTypeCheckpointReview.jsx b/src/projects/detail/components/timeline/milestones/MilestoneTypeCheckpointReview/MilestoneTypeCheckpointReview.jsx index 2042bdd31..e88d83415 100644 --- a/src/projects/detail/components/timeline/milestones/MilestoneTypeCheckpointReview/MilestoneTypeCheckpointReview.jsx +++ b/src/projects/detail/components/timeline/milestones/MilestoneTypeCheckpointReview/MilestoneTypeCheckpointReview.jsx @@ -17,6 +17,7 @@ import { getMilestoneStatusText, getDaysLeft, getTotalDays, getProgressPercent, import { MILESTONE_STATUS, MIN_CHECKPOINT_REVIEW_DESIGNS, + MILESTONE_TYPE, } from '../../../../../../config/constants' import './MilestoneTypeCheckpointReview.scss' @@ -29,7 +30,7 @@ class MilestoneTypeCheckpointReview extends React.Component { this.state = { selectedLinks: [], - isLinksProvided: _.get(props.milestone, 'details.prevMilestoneType') === 'add-links', + isLinksProvided: _.get(props.milestone, 'details.prevMilestoneType') === MILESTONE_TYPE.ADD_LINKS, isSelectWarningVisible: false, isShowCompleteConfirmMessage: false, } diff --git a/src/projects/detail/components/timeline/milestones/MilestoneTypeDeliverableReview/MilestoneTypeDeliverableReview.jsx b/src/projects/detail/components/timeline/milestones/MilestoneTypeDeliverableReview/MilestoneTypeDeliverableReview.jsx new file mode 100644 index 000000000..9cf67cb86 --- /dev/null +++ b/src/projects/detail/components/timeline/milestones/MilestoneTypeDeliverableReview/MilestoneTypeDeliverableReview.jsx @@ -0,0 +1,368 @@ +/** + * Milestone type `deliverable-review`, `final-deliverable-review`, `deliverable-final-fixes` + */ +import React from 'react' +import PT from 'prop-types' +import _ from 'lodash' +import cn from 'classnames' + +import DotIndicator from '../../DotIndicator' +import LinkList from '../../LinkList' +import LinkItem from '../../LinkItem' +import LinkItemForm from '../../LinkItemForm' +import MilestoneDescription from '../../MilestoneDescription' +import { withMilestoneExtensionRequest } from '../../MilestoneExtensionRequest' +import { getMilestoneStatusText } from '../../../../../../helpers/milestoneHelper' + +import { + MILESTONE_TYPE, + MILESTONE_STATUS +} from '../../../../../../config/constants' + +import './MilestoneTypeDeliverableReview.scss' +import { hasPermission } from '../../../../../../helpers/permissions' +import { PERMISSIONS } from '../../../../../../config/permissions' + +class MilestoneTypeDeliverableReview extends React.Component { + constructor(props) { + super(props) + + this.state = { + isAddingDeliverableUpdate: false, + isAddingFeedbackLink: false, + deliverableUpdateText: '', + feedbackLink: '', + isAdddingFinalFixesRequest: false, + finalFixesRequest: '', + } + + this.onClickAddDeliverableReview = this.onClickAddDeliverableReview.bind(this) + this.onClickAddFeedbackLink = this.onClickAddFeedbackLink.bind(this) + this.onDeliverableUpdateTextChange = this.onDeliverableUpdateTextChange.bind(this) + this.onClickSaveDeliverableUpdate = this.onClickSaveDeliverableUpdate.bind(this) + this.onClickCancelAddDeliverableUpdate = this.onClickCancelAddDeliverableUpdate.bind(this) + this.updatedSubmissionUrl = this.updatedSubmissionUrl.bind(this) + this.removeSubmissionUrl = this.removeSubmissionUrl.bind(this) + this.onSubmitFeedbackLink = this.onSubmitFeedbackLink.bind(this) + this.onAddFeedbackLinkCancel = this.onAddFeedbackLinkCancel.bind(this) + this.onDeleteFeedbackLink = this.onDeleteFeedbackLink.bind(this) + this.onClickReviewComplete = this.onClickReviewComplete.bind(this) + + this.onClickRequestFixes = this.onClickRequestFixes.bind(this) + this.onClickCancelFinalFixesRequest = this.onClickCancelFinalFixesRequest.bind(this) + this.onClickSubmitRequest = this.onClickSubmitRequest.bind(this) + this.onFinalFixesRequestTextChange = this.onFinalFixesRequestTextChange.bind(this) + } + + onClickAddDeliverableReview() { + const { milestone } = this.props + + this.setState({ + isAddingDeliverableUpdate: true, + deliverableUpdateText: _.get(milestone, 'details.content.deliverableUpdate'), + }) + } + + onClickAddFeedbackLink() { + const { milestone } = this.props + + this.setState({ + isAddingFeedbackLink: true, + feedbackLink: _.get(milestone, 'details.content.feedbackLink'), + }) + } + + onAddFeedbackLinkCancel() { + this.setState({ + isAddingFeedbackLink: false, + }) + } + + onClickCancelAddDeliverableUpdate() { + this.setState({ + isAddingDeliverableUpdate: false, + }) + } + + onDeliverableUpdateTextChange(event) { + this.setState({ + deliverableUpdateText: event.target.value, + }) + } + + onClickSaveDeliverableUpdate() { + const { deliverableUpdateText } = this.state + const { updateMilestoneContent } = this.props + + updateMilestoneContent({ deliverableUpdate: deliverableUpdateText }) + } + + updatedSubmissionUrl(values, linkIndex) { + const { milestone, updateMilestoneContent } = this.props + + const submissionLinks = [..._.get(milestone, 'details.content.submissionLinks', [])] + + values.type = '' + + if (typeof linkIndex === 'number') { + submissionLinks.splice(linkIndex, 1, values) + } else { + submissionLinks.push(values) + } + + updateMilestoneContent({ + submissionLinks + }) + } + + removeSubmissionUrl(linkIndex) { + if (!window.confirm('Are you sure you want to remove this link?')) { + return + } + + const { milestone, updateMilestoneContent } = this.props + const submissionLinks = [..._.get(milestone, 'details.content.submissionLinks', [])] + + submissionLinks.splice(linkIndex, 1) + + updateMilestoneContent({ + submissionLinks + }) + } + + onDeleteFeedbackLink() { + if (!window.confirm('Are you sure you want to remove this link?')) { + return + } + + const { updateMilestoneContent } = this.props + + updateMilestoneContent({ + feedbackLink: '', + }) + } + + onSubmitFeedbackLink({ url }) { + const { updateMilestoneContent } = this.props + + updateMilestoneContent({ + feedbackLink: url, + }) + } + + onClickReviewComplete() { + const { completeMilestone } = this.props + + completeMilestone() + } + + onClickRequestFixes() { + this.setState({ + isAdddingFinalFixesRequest: true, + }) + } + + onClickCancelFinalFixesRequest() { + this.setState({ + isAdddingFinalFixesRequest: false, + }) + } + + onClickSubmitRequest() { + const { submitDeliverableFinalFixesRequest } = this.props + const { finalFixesRequest } = this.state + + submitDeliverableFinalFixesRequest(finalFixesRequest) + } + + onFinalFixesRequestTextChange(event) { + this.setState({ + finalFixesRequest: event.target.value, + }) + } + + render() { + const { + milestone, + theme, + } = this.props + const { + isAddingDeliverableUpdate, + isAddingFeedbackLink, + deliverableUpdateText, + feedbackLink, + + isAdddingFinalFixesRequest, + finalFixesRequest, + } = this.state + + const isActive = milestone.status === MILESTONE_STATUS.ACTIVE + + const canManage = hasPermission(PERMISSIONS.MANAGE_MILESTONE) + const canAcceptFinalDelivery = hasPermission(PERMISSIONS.ACCEPT_MILESTONE_FINAL_DELIVERY) + + const milestoneDeliverableFinalFixesRequest = _.get(milestone, 'details.content.finalFixesRequest', '') + const milestoneDeliverableUpdate = _.get(milestone, 'details.content.deliverableUpdate', '') + const milestoneFeedbackLink = _.get(milestone, 'details.content.feedbackLink', '') + const milestoneSubmissionLinks = _.get(milestone, 'details.content.submissionLinks', []) + + return ( +
+ + + + + +
+ + {/* For milestone type of 'deliverable-final-fixes' specifically */} + {milestone.type === MILESTONE_TYPE.DELIVERABLE_FINAL_FIXES && ( +
+
{milestoneDeliverableFinalFixesRequest}
+
+ )} + + {/* Deliverable update */} + {isAddingDeliverableUpdate ? ( +
+