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}
+
+ Add Milestone
+
+
+ )
+ }
+
+ renderTab() {
+ const tabs = [
+ {
+ onClick: () => {},
+ label: 'Timeline',
+ isActive: true,
+ hasNotifications: false,
+ }]
+ return (
+
+
+
+ )
+ }
+
+ render() {
+ const { isAddButtonClicked } = this.state
+
+ if (!isAddButtonClicked) {
+ return (
+
+ Add New Phase
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {this.renderTab()}
+ {this.renderMilestones()}
+
+ {'Cancel'}
+ Save Draft
+ Publish
+
+
+
+
+ )
+ }
+}
+
+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
-
-
- ) : (
-
- Status
-
-
- )}
-
+
{!showActivatingWarning ? (
@@ -354,12 +315,19 @@ class EditStageForm extends React.Component {
{'Cancel'}
Update Phase
+ >Save Changes
+ {isDraft ? (
+
Publish
+ ) : null}
) : (