diff --git a/.circleci/config.yml b/.circleci/config.yml index 04ed6ec8a..e629370d9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -34,8 +34,8 @@ jobs: - persist_to_workspace: root: . paths: - - dist - + - dist + # Just tests commited code. deployDev: docker: @@ -88,7 +88,7 @@ jobs: - attach_workspace: at: ./workspace - run: ./deploy.sh DISCOURSE - + workflows: version: 2 @@ -100,7 +100,7 @@ workflows: - test filters: branches: - only: ['dev', 'feature/metadata-management'] + only: ['dev', 'dev-msinteg'] - deployTest02: requires: - test diff --git a/src/config/constants.js b/src/config/constants.js index 9f56e276c..4ea34a87d 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -604,6 +604,7 @@ export const AUTOCOMPLETE_TRIGGER_LENGTH = 3 export const MAINTENANCE_MODE = process.env.MAINTENANCE_MODE export const LS_INCOMPLETE_PROJECT = 'incompleteProject' +export const LS_INCOMPLETE_WIZARD = 'incompleteWizard' export const PROJECTS_API_URL = process.env.PROJECTS_API_URL || TC_API_URL diff --git a/src/config/projectWizard/index.js b/src/config/projectWizard/index.js index aefcb83a9..9849785c5 100644 --- a/src/config/projectWizard/index.js +++ b/src/config/projectWizard/index.js @@ -501,20 +501,16 @@ export function getProjectCreationTemplateField(product, sectionId, subSectionId * * @return {object} object containing price and time estimate */ -export function getProductEstimate(productId, productConfig) { - let specification = 'topcoder.v1' - let product = null +export function getProductEstimate(projectTemplate, productConfig) { let price = 0 let minTime = 0 let maxTime = 0 - if (productId) { - specification = typeToSpecification[productId] - product = findProduct(productId) - price = _.get(product, 'basePriceEstimate', 0) - minTime = _.get(product, 'baseTimeEstimateMin', 0) - maxTime = _.get(product, 'baseTimeEstimateMax', 0) + if (projectTemplate) { + price = _.get(projectTemplate, 'scope.basePriceEstimate', 0) + minTime = _.get(projectTemplate, 'scope.baseTimeEstimateMin', 0) + maxTime = _.get(projectTemplate, 'scope.baseTimeEstimateMax', 0) } - const sections = require(`../projectQuestions/${specification}`).default + const sections = projectTemplate.scope.sections if (sections) { sections.forEach((section) => { const subSections = section.subSections @@ -532,6 +528,19 @@ export function getProductEstimate(productId, productConfig) { minTime += _.get(qOption, 'minTimeUp', 0) maxTime += _.get(qOption, 'maxTimeUp', 0) } + // right now we are supporting only radio-group and tiled-radio-group type of questions + if(['checkbox-group'].indexOf(q.type) !== -1 && q.affectsQuickQuote) { + const answer = _.get(productConfig, q.fieldName) + if (answer) { + answer.forEach((a) => { + const qOption = _.find(q.options, (o) => o.value === a) + console.log(qOption) + price += _.get(qOption, 'quoteUp', 0) + minTime += _.get(qOption, 'minTimeUp', 0) + maxTime += _.get(qOption, 'maxTimeUp', 0) + }) + } + } }) } }) diff --git a/src/helpers/dependentQuestionsHelper.js b/src/helpers/dependentQuestionsHelper.js new file mode 100644 index 000000000..ee2910e41 --- /dev/null +++ b/src/helpers/dependentQuestionsHelper.js @@ -0,0 +1,226 @@ +import _ from 'lodash' + +/** + * Stack datastructure to perform required operations - + * push, pop, peek, empty to perform arithmatic/logical operaitons + * + * Adding new operator to be supported + * 1. Add the symbol/keyword of operation in allowedOps array + * 2. Add new 'case' in the switch present inside the method for newly added operator applyOp + * 3. If required, decide the precendence inside the method hasPrecedence + * + */ + +class Stack { + constructor() { + this.top = -1 + this.items = [] + } + + push(item) { + const idx = this.top + this.items[idx+1] = item + this.top = idx + 1 + } + + pop() { + const popIdx = this.top + const item = this.items[popIdx] + this.top = popIdx - 1 + return item + } + + peek() { + return this.items[this.top] + } + + empty() { + if(this.top === -1) + return true + return false + } +} + +/** + * ex - 4 * 5 - 2 + * @param op2 peeked from ops + * @param op1 found in the expression + * + * TODO implementation could be more precise and can be based on JS operator precedence values + * see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence + * + * @returns true if 'op2' has higher or same precedence as 'op1', otherwise returns false + */ +function hasPrecedence(op1, op2) { + if (op2 === '(' || op2 === ')') + return false + if ((op1 === '*' || op1 === '/' || op1 === '!') && (op2 === '+' || op2 === '-' || op2 === '==' || op2 === '>' || op2 === '<' || op2 === 'contains' || op2 === 'hasLength')) + return false + else + return true +} +/** + * A utility method to apply an operation 'op' on operands + * @param op operator + * @param b operand + * @param a operand + * + * @returns result of operation applied + */ +function applyOp(op, b, a) { + switch (op) { + case '+': + return a + b + case '-': + return a - b + case '*': + return a * b + case '/': + //should we handle the case of b == 0 ? + return a / b + case '==': + return a === b + case '!=': + return a !== b + case '&&': + return a && b + case '||': + return a || b + case '>': + return a > b + case '<': + return a < b + case '!': + return !b + case 'contains': + return (a || []).indexOf(b) > -1 + case 'hasLength': + return (a || []).length === b + } + return 0 +} + +// list of operations allowed by the parser +const allowedOps = ['+', '-', '*', '/', '==', '!=', '&&', '||', '>', '<', 'contains', 'hasLength', '!'] +// operators that work only with one param +const oneParamOps = ['!'] + +// will split expression into tokens mainly using spaces +// additionally we let "(", ")", "!" to be used without spaces around +const opsSplitters = ['\\s+', '\\(', '\\)', '!'] +const splitRegexp = new RegExp('(' + opsSplitters.join('|') + ')') + +/** + * Javascript parser to parse the logical/arithmetic opertaion provided + * this is based on javascript implementation of "Shunting Yard Algo" + * + * @param expression an expression to be evaluated + * @param data data json to fetch the operands value + * + * @returns true, if the expression evaluates to true otherwise false + * + */ +export function evaluate(expression, data) { + const tokens = expression + .split(splitRegexp) + // remove unnecessary spaces around tokens + .map((token) => token.trim()) + // remove empty tokens + .filter((token) => token !== '') + + // Stack for operands: 'values' + const values = new Stack() + + // Stack for Operators: 'ops' + const ops = new Stack() + + for (let i = 0; i < tokens.length; i++) { + if (tokens[i] === '(') { + ops.push(tokens[i]) + + // Closing brace encountered, solve expression since the last opening brace + } else if (tokens[i] === ')') { + while (ops.peek() !== '(') { + const op = ops.pop() + if (oneParamOps.indexOf(op) !== -1) { + values.push(applyOp(op, values.pop())) + } else { + values.push(applyOp(op, values.pop(), values.pop())) + } + } + ops.pop()//removing opening brace + + // Current token is an operator. + } else if (allowedOps.indexOf(tokens[i]) > -1) { + /** + * While top of 'ops' has same or greater precedence to current token, + * Apply operator on top of 'ops' to top two elements in values stack + */ + while (!ops.empty() && hasPrecedence(tokens[i], ops.peek())) { + const op = ops.pop() + if (oneParamOps.indexOf(op) !== -1) { + values.push(applyOp(op, values.pop())) + } else { + values.push(applyOp(op, values.pop(), values.pop())) + } + } + + // Push current token to ops + ops.push(tokens[i]) + + } else { + //console.log(tokens[i]) + if (tokens[i] in data) { + //console.log("val : ",data[tokens[i]]) + values.push(_.get(data, tokens[i])) + } else { + /*if(tokens[i] == "true") + values.push(true); + else if(tokens[i] == "false") + values.push(false); */ + if (!isNaN(tokens[i])) { + values.push(parseInt(tokens[i])) + } else { + //removing single quotes around the text values + values.push(tokens[i].replace(/'/g, '')) + } + } + } + } + //debugger + // Parsed expression tokens are pushed to values/ops respectively, + // Running while loop to evaluate the expression + while (!ops.empty()) { + const op = ops.pop() + if (oneParamOps.indexOf(op) !== -1) { + values.push(applyOp(op, values.pop())) + } else { + values.push(applyOp(op, values.pop(), values.pop())) + } + } + // Top contains result, return it + return values.pop() +} + +/** + * Parses expression to find variable names in format of domain name: + * string1.string2.string3 and so on. Minimum one dot "." is required. + * + * @param {String} expression expression + * + * @returns {Array} list of variable names + */ +export function getFieldNamesFromExpression(expression) { + const re = /([a-z]+[a-z0-9]*(?:\.[a-z]+[a-z0-9]*)+)/ig + let match + const fieldNames = [] + + do { + match = re.exec(expression) + if (match) { + fieldNames.push(match[1]) + } + } while (match) + + return fieldNames +} diff --git a/src/helpers/dependentQuestionsHelper.spec.js b/src/helpers/dependentQuestionsHelper.spec.js new file mode 100644 index 000000000..974e5abdf --- /dev/null +++ b/src/helpers/dependentQuestionsHelper.spec.js @@ -0,0 +1,78 @@ +/* eslint quotes: 0 */ +import chai from 'chai' +import { evaluate } from './dependentQuestionsHelper' + +chai.should() + +const testData = { + a: 1, + b: 2, + c: 1, + someArray: [1, 2, 3], + someArrayWithText: ['a', 'b', 'c'], + f: false, + t: true, +} + +describe('Evaluate', () => { + + describe('operator', () => { + it('hasLength (true)', () => { + const expression = 'someArray hasLength 3' + const result = evaluate(expression, testData) + + result.should.equal(true) + }) + + it('hasLength (false)', () => { + const expression = 'someArray hasLength 4' + const result = evaluate(expression, testData) + + result.should.equal(false) + }) + + it('!false => true', () => { + const expression = '!f' + const result = evaluate(expression, testData) + + result.should.equal(true) + }) + + it('!true => false', () => { + const expression = '!t' + const result = evaluate(expression, testData) + + result.should.equal(false) + }) + + it('! with conditions', () => { + const expression = '!t || !f' + const result = evaluate(expression, testData) + + result.should.equal(true) + }) + }) + + describe('expression format', () => { + it('no spaces near parenthesis', () => { + const expression = 'someArray contains (a + b) * 2' + const result = evaluate(expression, testData) + + result.should.equal(false) + }) + + it('no spaces near parenthesis with conditions', () => { + const expression = `(someArrayWithText contains 'a') || (someArrayWithText contains 'b') || (someArrayWithText contains 'c')` + const result = evaluate(expression, testData) + + result.should.equal(true) + }) + + it('multiple spaces', () => { + const expression = 'someArray contains ( b - a ) * 2' + const result = evaluate(expression, testData) + + result.should.equal(true) + }) + }) +}) diff --git a/src/helpers/wizardHelper.js b/src/helpers/wizardHelper.js new file mode 100644 index 000000000..e9f0e7217 --- /dev/null +++ b/src/helpers/wizardHelper.js @@ -0,0 +1,1286 @@ +/** + * Helper methods for project creation/edition wizard mode and conditional questions. + * + * The main idea behind wizard helper is that it's a set of methods which contain all the wizard logic, + * and these methods can update projectTemplate.scope which is used to render the form. + * The form rendering component doesn't contain any wizard logic, it just renders projectTemplate.scope which + * was processed by the methods in this helper. + * + * Glossary: + * - `step`: we call any section, subSection, question or option + * step is defined as an object with indexes: + * { + * sectionIndex: Number, + * subSectionIndex: Number, + * questionIndex: Number, + * optionIndex: Number, + * } + * If some index is not applicable it has be defined as -1. + * - `stepObject`: it's an actual section, subSection, question or option object + * - `read step`: is a step which has to be shown as one single step in wizard + */ +import _ from 'lodash' +import update from 'react-addons-update' +import { evaluate, getFieldNamesFromExpression } from './dependentQuestionsHelper' +import { flatten, unflatten } from 'flat' + +/** + * Defines how to display form steps which has been already filled + */ +export const PREVIOUS_STEP_VISIBILITY = { + NONE: 'none', + READ_ONLY: 'readOnly', + WRITE: 'write', +} + +/** + * Form template has many levels, and this constant define them + */ +export const LEVEL = { + SECTION: 'section', + SUB_SECTION: 'subSection', + QUESTION: 'question', + OPTION: 'option' +} + +/** + * Define relation between steps + * - the step is next to another one + * - the step previous to another one + * - it's a same step + */ +export const STEP_DIR = { + NEXT: +1, + PREV: -1, + SAME: 0, +} + +/** + * Determines if step has to be hidden during wizard initialization + * + * @param {String} previousStepVisibility previous step visibility in wizard + * @param {Object} currentStep the step which we iterate + * @param {Object} lastWizardStep the last step which was previously filled + * + * @returns {Boolean} true if step has to be hidden + */ +const shouldStepBeHidden = (previousStepVisibility, currentStep, lastWizardStep) => { + if (!lastWizardStep) { + const level = getStepLevel(currentStep) + return currentStep[`${level}Index`] !== 0 + } else if (previousStepVisibility === PREVIOUS_STEP_VISIBILITY.NONE) { + return !isSameStepAnyLevel(currentStep, lastWizardStep) + } else if (previousStepVisibility === PREVIOUS_STEP_VISIBILITY.READ_ONLY) { + return getDirForSteps(currentStep, lastWizardStep) === STEP_DIR.PREV + } else { + return true + } +} + +/** + * Determine if `step` is any level ancestor of `parentStep` + * + * @param {Object} parentStep parent step + * @param {Object} step step to check + * + * @returns {Boolean} true if `step` is any ancestor of `parentStep` + */ +const isSameStepAnyLevel = (parentStep, step) => { + let isParent = parentStep.sectionIndex !== -1 && parentStep.sectionIndex === step.sectionIndex + + if (parentStep.subSectionIndex !== -1) { + isParent = isParent && parentStep.subSectionIndex === step.subSectionIndex + } + + if (parentStep.questionIndex !== -1) { + isParent = isParent && parentStep.questionIndex === step.questionIndex + } + + if (parentStep.optionIndex !== -1) { + isParent = isParent && parentStep.optionIndex === step.optionIndex + } + + return isParent +} + +/** + * Check if wizard mode is enabled in template + * + * @param {Object} template template + * + * @returns {Boolean} true if wizard mode is enabled + */ +export const isWizardModeEnabled = (template) => ( + _.get(template, 'wizard.enabled') || template.wizard === true +) + +/** + * Get wizard previous step visibility + * + * @param {Object} template template + * + * @returns {String} previous step visibility + */ +export const getPreviousStepVisibility = (template) => ( + _.get(template, 'wizard.previousStepVisibility', PREVIOUS_STEP_VISIBILITY.WRITE) +) + +/** + * Iterates through all the steps of the template: sections, subSections, questions, options. + * + * If iteratee returns `false` iteration will be stopped. + * + * @param {Object} template template + * @param {Function} iteratee function which is called for each step with signature (stepObject, step) + * @param {Function} [iterateSublevelCondition] if returns false, we don't iterate through the steps of the child level + */ +export const forEachStep = (template, iteratee, iterateSublevelCondition) => { + let iterateeResult + + // iterate SECTIONS + _.forEach(template.sections, (section, sectionIndex) => { + const sectionStep = { + sectionIndex, + subSectionIndex: -1, + questionIndex: -1, + optionIndex: -1, + } + iterateeResult = iteratee(section, sectionStep) + + // iterate SUB_SECTIONS + if (iterateeResult !== false + && (!_.isFunction(iterateSublevelCondition) || iterateSublevelCondition(section, sectionStep)) + ) { + _.forEach(section.subSections, (subSection, subSectionIndex) => { + const subSectionStep = { + sectionIndex, + subSectionIndex, + questionIndex: -1, + optionIndex: -1, + } + iterateeResult = iteratee(subSection, subSectionStep) + + // iterate QUESTIONS + if (iterateeResult !== false + && (!_.isFunction(iterateSublevelCondition) || iterateSublevelCondition(subSection, subSectionStep)) + ) { + subSection.questions && _.forEach(subSection.questions, (question, questionIndex) => { + const questionStep = { + sectionIndex, + subSectionIndex, + questionIndex, + optionIndex: -1, + } + iterateeResult = iteratee(question, questionStep) + + // iterate OPTIONS + if (iterateeResult !== false + && (!_.isFunction(iterateSublevelCondition) || iterateSublevelCondition(question, questionStep)) + ) { + question.options && _.forEach(question.options, (option, optionIndex) => { + const optionsStep = { + sectionIndex, + subSectionIndex, + questionIndex, + optionIndex + } + iterateeResult = iteratee(option, optionsStep) + + return iterateeResult + }) + } + + return iterateeResult + }) + } + + return iterateeResult + }) + } + + return iterateeResult + }) +} + +/** + * Initialize template with wizard and dependant questions features. + * + * Add auxillary `__wizard` property for sections, subSections, questions and options + * if they have `wizard` property set to `true`. + * + * @param {Object} template raw template + * @param {Object} project project data (non-flat) + * @param {Object} incompleteWizard incomplete wizard props + * @param {Boolean} isReadOptimizedMode if true wizard is inited in read optimized mode + * + * @returns {Object} initialized template + */ +export const initWizard = (template, project, incompleteWizard, isReadOptimizedMode) => { + let wizardTemplate = _.cloneDeep(template) + const isWizardMode = isWizardModeEnabled(wizardTemplate) && !isReadOptimizedMode + const previousStepVisibility = getPreviousStepVisibility(wizardTemplate) + // try to get the step where we left the wizard + const lastWizardStep = incompleteWizard && incompleteWizard.currentWizardStep + // current step will define the first of the wizard in case we have to start the wizard from the beginning + let currentWizardStep = { + sectionIndex: -1, + subSectionIndex: -1, + questionIndex: -1, + optionIndex: -1, + } + let prevWizardStep = null + + // initialize wizard for the whole template + wizardTemplate.__wizard = { + // there will be the list of all fields which have dependencies in the template + dependantFields: [] + } + + // initialize wizard for each step + forEachStep(wizardTemplate, (stepObject, step) => { + // keep step indexes for each step inside template + stepObject.__wizard = { + step + } + + // add all found variables from condition to the list of dependant fields of the template + if (stepObject.condition) { + wizardTemplate.__wizard.dependantFields = _.uniq([ + ...wizardTemplate.__wizard.dependantFields, + ...getFieldNamesFromExpression(stepObject.condition) + ]) + } + }) + + const updateResult = updateStepsByConditions(wizardTemplate, project) + wizardTemplate = updateResult.updatedTemplate + + // in read optimized mode we display all the questions as readOnly if they are not hidden by conditions + forEachStep(wizardTemplate, (stepObject) => { + if (isReadOptimizedMode && !stepObject.__wizard.hiddenByCondition) { + stepObject.__wizard.readOnly = true + } + }) + + // initialize wizard mode + if (isWizardMode) { + currentWizardStep.sectionIndex = 0 + + forEachStep(wizardTemplate, (stepObject, step) => { + stepObject.__wizard.isStep = true + stepObject.__wizard.hidden = shouldStepBeHidden(previousStepVisibility, step, lastWizardStep) + + // if we reach subSection inside first section, then we will start from it + if (step.sectionIndex === 0 && currentWizardStep.subSectionIndex === -1 && getStepLevel(step) === LEVEL.SUB_SECTION) { + currentWizardStep.subSectionIndex = 0 + } + + // if we reach question inside first subSection of the first section, then we will start from it + if (step.sectionIndex === 0 && step.subSectionIndex === 0 && currentWizardStep.questionIndex === -1 && getStepLevel(step) === LEVEL.QUESTION) { + currentWizardStep.questionIndex = 0 + } + }, (stepObject) => (_.get(stepObject, 'wizard.enabled') || stepObject.wizard === true)) + + // if we are restoring previously unfinished wizard, we have finalize all steps before the current one + // in readOnly mode + if (lastWizardStep && previousStepVisibility === PREVIOUS_STEP_VISIBILITY.READ_ONLY) { + let tempStep = currentWizardStep + + while (tempStep && getDirForSteps(tempStep, lastWizardStep) === STEP_DIR.NEXT) { + wizardTemplate = finalizeStep(wizardTemplate, tempStep) + tempStep = getNextStepToShow(wizardTemplate, tempStep) + } + } + + if (lastWizardStep) { + prevWizardStep = getPrevStepToShow(wizardTemplate, lastWizardStep) + } + + currentWizardStep = lastWizardStep || currentWizardStep + } + + return { + template: wizardTemplate, + currentWizardStep, + prevWizardStep, + isWizardMode, + previousStepVisibility, + hasDependantFields: wizardTemplate.__wizard.dependantFields.length > 0 + } +} + +/** + * Gets sign of a number as Math.sign is not cross-browser + * + * @param {Number} x number + * + * @returns {Number} sign of a number + */ +const sign = (x) => ((x > 0) - (x < 0)) || +x + +/** + * Return direction between two steps + * + * @param {Object} step1 step + * @param {Object} step2 step + * + * @returns {String} direction between two steps + */ +const getDirForSteps = (step1, step2) => { + const optionSign = sign(step2.optionIndex - step1.optionIndex) + const questionSign = sign(step2.questionIndex - step1.questionIndex) + const subSectionSign = sign(step2.subSectionIndex - step1.subSectionIndex) + const sectionSign = sign(step2.sectionIndex - step1.sectionIndex) + + const dir = sectionSign || subSectionSign || questionSign || optionSign + + return dir +} + +/** + * Returns next step in desired direction inside template + * + * @param {Object} template template + * @param {Object} currentStep current step + * @param {String} dir direction + * + * @returns {Object} next step in direction + */ +const getStepByDir = (template, currentStep, dir) => { + // get the sibling of the current step if possible + let dirStep = getSiblingStepByDir(template, currentStep, dir) + + // if there is no sibling + // checking siblings of parent levels + let tempStep = currentStep + while (!dirStep && (tempStep = getParentStep(tempStep))) { + const parentStepObject = getStepObject(template, tempStep) + + if (_.get(parentStepObject, '__wizard.isStep')) { + dirStep = getSiblingStepByDir(template, tempStep, dir) + } + } + + // no matter where we got step: between the sibling of the current step + // or between siblings of the parent levels + // try to find the most inner step inside the possible step + if (dirStep) { + let tempStep = dirStep + + while (_.get(getStepObject(template, tempStep), 'wizard.enabled')) { + const childrenSteps = getStepChildren(template, tempStep) + + const childStepIndex = dir === STEP_DIR.NEXT ? 0 : childrenSteps.length - 1 + + if (childrenSteps[childStepIndex]) { + tempStep = childrenSteps[childStepIndex] + } + } + + return tempStep + } + + return null +} + +/** + * Returns next step which can be shown in desired direction inside template + * + * The difference from `getStepByDir()` is that this method skips steps which are hidden by conditions + * as such steps won't be visible. + * + * @param {Object} template template + * @param {Object} currentStep current step + * @param {String} dir direction + * + * @returns {Object} next step which can be shown in direction + */ +const getStepToShowByDir = (template, currentStep, dir) => { + let tempStep = currentStep + let tempStepObject + + do { + tempStep = getStepByDir(template, tempStep, dir) + tempStepObject = tempStep && getStepObject(template, tempStep) + } while (tempStepObject && _.get(tempStepObject, '__wizard.hiddenByCondition')) + + return tempStep +} + +/** + * Returns next step which can be shown inside template + * + * @param {Object} template template + * @param {Object} currentStep current step + * + * @returns {Object} next step which can be shown in direction + */ +export const getNextStepToShow = (template, currentStep) => ( + getStepToShowByDir(template, currentStep, STEP_DIR.NEXT) +) + +/** + * Returns previous step which can be shown inside template + * + * @param {Object} template template + * @param {Object} currentStep current step + * + * @returns {Object} next step which can be shown in direction + */ +export const getPrevStepToShow = (template, currentStep) => ( + getStepToShowByDir(template, currentStep, STEP_DIR.PREV) +) + + +/** + * Returns sibling step in desired direction inside template + * + * @param {Object} template template + * @param {Object} step current step + * @param {String} dir direction + * + * @returns {Object} sibling step in direction + */ +const getSiblingStepByDir = (template, step, dir) => { + const level = getStepLevel(step) + let siblingStep = null + + switch(level) { + case LEVEL.OPTION: + siblingStep = { + ...step, + optionIndex: step.optionIndex + dir + } + break + case LEVEL.QUESTION: + siblingStep = { + ...step, + questionIndex: step.questionIndex + dir + } + break + case LEVEL.SUB_SECTION: + siblingStep = { + ...step, + subSectionIndex: step.subSectionIndex + dir + } + break + case LEVEL.SECTION: + siblingStep = { + ...step, + sectionIndex: step.sectionIndex + dir + } + break + default: siblingStep = null + } + + if (siblingStep && getStepObject(template, siblingStep, level)) { + return siblingStep + } else { + return null + } +} + +/** + * Returns next sibling step inside template + * + * @param {Object} template template + * @param {Object} step current step + * + * @returns {Object} next sibling step + */ +const getNextSiblingStep = (template, step) => ( + getSiblingStepByDir(template, step, STEP_DIR.NEXT) +) + +/** + * Returns previous sibling step inside template + * + * @param {Object} template template + * @param {Object} step current step + * + * @returns {Object} previous sibling step + */ +const getPrevSiblingStep = (template, step) => ( + getSiblingStepByDir(template, step, STEP_DIR.PREV) +) + +/** + * Update option in template without template mutation + * + * @param {Object} template template + * @param {Number} sectionIndex section index + * @param {Number} subSectionIndex subSection index + * @param {Number} questionIndex question index + * @param {Number} optionIndex option index + * @param {Object} updateRule rule acceptable by update function + * + * @returns {Object} updated template + */ +const updateOption = (template, sectionIndex, subSectionIndex, questionIndex, optionIndex, updateRule) => { + const section = template.sections[sectionIndex] + const subSection = section.subSections[subSectionIndex] + const question = subSection.questions[questionIndex] + const option = question.options[optionIndex] + + const updatedOption = update(option, updateRule) + + return updateQuestion(template, sectionIndex, subSectionIndex, questionIndex, { + options: { + $splice: [[optionIndex, 1, updatedOption]] + } + }) +} + +/** + * Update question in template without template mutation + * + * @param {Object} template template + * @param {Number} sectionIndex section index + * @param {Number} subSectionIndex subSection index + * @param {Number} questionIndex question index + * @param {Object} updateRule rule acceptable by update function + * + * @returns {Object} updated template + */ +const updateQuestion = (template, sectionIndex, subSectionIndex, questionIndex, updateRule) => { + const section = template.sections[sectionIndex] + const subSection = section.subSections[subSectionIndex] + const question = subSection.questions[questionIndex] + + const updatedQuestion = update(question, updateRule) + + return updateSubSection(template, sectionIndex, subSectionIndex, { + questions: { + $splice: [[questionIndex, 1, updatedQuestion]] + } + }) +} + +/** + * Update sebSection in template without template mutation + * + * @param {Object} template template + * @param {Number} sectionIndex section index + * @param {Number} subSectionIndex subSection index + * @param {Object} updateRule rule acceptable by update function + * + * @returns {Object} updated template + */ +const updateSubSection = (template, sectionIndex, subSectionIndex, updateRule) => { + const section = template.sections[sectionIndex] + const subSection = section.subSections[subSectionIndex] + + const updatedSubSection = update(subSection, updateRule) + + return updateSection(template, sectionIndex, { + subSections: { + $splice: [[subSectionIndex, 1, updatedSubSection]] + } + }) +} + +/** + * Update section in template without template mutation + * + * @param {Object} template template + * @param {Number} sectionIndex section index + * @param {Object} updateRule rule acceptable by update function + * + * @returns {Object} updated template + */ +const updateSection = (template, sectionIndex, updateRule) => { + const section = template.sections[sectionIndex] + + const updatedSection = update(section, updateRule) + + const updatedTemplate = update(template, { + sections: { + $splice: [[sectionIndex, 1, updatedSection]] + } + }) + + return updatedTemplate +} + +/** + * Update any kind of step sections, subSection, question or option without template mutation. + * + * If level is not defined, it automatically detects the level of step we are updating. + * If level is defined, it forces to update step on that level. + * + * @param {Object} template template + * @param {Object} step section index + * @param {Object} updateRule rule acceptable by update function + * @param {String} [level] step level + * + * @returns {Object} updated template + */ +const updateStepObject = (template, step, updateRule, level) => { + const { sectionIndex, subSectionIndex, questionIndex, optionIndex } = step + let updatedTemplate = template + + switch (level) { + case LEVEL.OPTION: + updatedTemplate = updateOption(template, sectionIndex, subSectionIndex, questionIndex, optionIndex, updateRule) + break + case LEVEL.QUESTION: + updatedTemplate = updateQuestion(template, sectionIndex, subSectionIndex, questionIndex, updateRule) + break + case LEVEL.SUB_SECTION: + updatedTemplate = updateSubSection(template, sectionIndex, subSectionIndex, updateRule) + break + case LEVEL.SECTION: + updatedTemplate = updateSection(template, sectionIndex, updateRule) + break + default: + if (optionIndex !== -1) { + updatedTemplate = updateOption(template, sectionIndex, subSectionIndex, questionIndex, optionIndex, updateRule) + } else if (questionIndex !== -1) { + updatedTemplate = updateQuestion(template, sectionIndex, subSectionIndex, questionIndex, updateRule) + // if we are updating first question of a sub section, update the sub section as well + if (questionIndex === 0) { + updatedTemplate = updateSubSection(updatedTemplate, sectionIndex, subSectionIndex, updateRule) + } + } else if (subSectionIndex !== -1) { + updatedTemplate = updateSubSection(template, sectionIndex, subSectionIndex, updateRule) + } else if (sectionIndex !== -1) { + updatedTemplate = updateSection(template, sectionIndex, updateRule) + } + } + + return updatedTemplate +} + +/** + * Get step object from template using step (step indexes) + * + * If level is not defined, it automatically detects the level of step object to return. + * If level is defined, it forces to return step object on that level + * + * @param {Object} template template + * @param {Object} step step + * @param {String} [level] step level + */ +export const getStepObject = (template, step, level) => { + const { section, subSection, question, option } = getStepAllLevelsObjects(template, step) + + switch (level) { + case LEVEL.OPTION: return option + case LEVEL.QUESTION: return question + case LEVEL.SUB_SECTION: return subSection + case LEVEL.SECTION: return section + default: + return option || question || subSection || section + } +} + +/** + * Get step objects for all level of step. + * + * @param {Object} template template + * @param {Object} step step + * + * @returns {{section: Object, subSection: Object, question: Object, option: Object}} step objects for all levels of step + */ +const getStepAllLevelsObjects = (template, step) => { + const { sectionIndex, subSectionIndex, questionIndex, optionIndex } = step + const section = sectionIndex !== -1 ? template.sections[sectionIndex] : null + const subSection = section && subSectionIndex !== -1 ? section.subSections[subSectionIndex] : null + const question = subSection && subSection.questions && questionIndex !== -1 ? subSection.questions[questionIndex] : null + const option = question && question.options && optionIndex !== -1 ? question.options[optionIndex] : null + + return { + section, + subSection, + question, + option, + } +} + +/** + * Check if the step is a step on a certain level + * + * @param {Object} step step + * @param {String} level step level + * + * @returns {Boolean} true if step has a certain level + */ +const isStepLevel = (step, level) => { + if (!step) { + return false + } + + const { sectionIndex, subSectionIndex, questionIndex, optionIndex } = step + + switch (level) { + case LEVEL.OPTION: return optionIndex !== -1 && questionIndex !== -1 && subSectionIndex !== -1 && sectionIndex !== -1 + case LEVEL.QUESTION: return questionIndex !== -1 && subSectionIndex !== -1 && sectionIndex !== -1 + case LEVEL.SUB_SECTION: return subSectionIndex !== -1 && sectionIndex !== -1 + case LEVEL.SECTION: return sectionIndex !== -1 + default: return false + } +} + +/** + * Get the step level + * + * @param {Object} step step + * + * @returns {String} step level + */ +const getStepLevel = (step) => { + if (isStepLevel(step, LEVEL.OPTION)) { + return LEVEL.OPTION + } + + if (isStepLevel(step, LEVEL.QUESTION)) { + return LEVEL.QUESTION + } + + if (isStepLevel(step, LEVEL.SUB_SECTION)) { + return LEVEL.SUB_SECTION + } + + if (isStepLevel(step, LEVEL.SECTION)) { + return LEVEL.SECTION + } + + return null +} + +/** + * Get parent step + * + * @param {Object} step step + * + * @returns {Object} parent step + */ +const getParentStep = (step) => { + if (step.optionIndex !== -1) { + return { + ...step, + optionIndex: -1 + } + } else if (step.questionIndex !== -1) { + return { + ...step, + questionIndex: -1 + } + } else if (step.subSectionIndex !== -1) { + return { + ...step, + subSectionIndex: -1 + } + } else if (step.sectionIndex !== -1) { + return { + ...step, + sectionIndex: -1 + } + } else { + return null + } +} + +/** + * Get step children + * + * @param {Object} template template + * @param {Object} step step + * + * @returns {Array} list of children steps + */ +const getStepChildren = (template, step) => { + const stepObject = getStepObject(template, step) + + return (stepObject.options || stepObject.questions || stepObject.subSections || stepObject.sections || []).map((stepObject) => ( + _.get(stepObject, '__wizard.step') + )) +} + +/** + * Update questions in template using question conditions and data + * + * @param {Object} template template + * @param {Object} project data to evaluate question conditions + * + * @returns {Object} updated template + */ +export const updateStepsByConditions = (template, project) => { + let updatedTemplate = template + let hidedSomeSteps = false + let updatedSomeSteps = false + + let flatProjectData = flatten(removeValuesOfHiddenSteps(updatedTemplate, project), { safe: true }) + let { stepToUpdate, hiddenByCondition, disabledByCondition } = getStepWhichMustBeUpdatedByCondition(updatedTemplate, flatProjectData) + updatedSomeSteps = !!stepToUpdate + while (stepToUpdate) { + const updateRule = { + __wizard: {} + } + + if (!_.isUndefined(hiddenByCondition)) { + updateRule.__wizard.hiddenByCondition = { $set: hiddenByCondition } + } + + if (!_.isUndefined(disabledByCondition)) { + updateRule.__wizard.disabledByCondition = { $set: disabledByCondition } + } + + updatedTemplate = updateStepObject(updatedTemplate, stepToUpdate, updateRule) + hidedSomeSteps = hidedSomeSteps || hiddenByCondition + + // now get the next step + flatProjectData = flatten(removeValuesOfHiddenSteps(updatedTemplate, project), { safe: true }) + const prevStep = stepToUpdate + !({ stepToUpdate, hiddenByCondition, disabledByCondition } = getStepWhichMustBeUpdatedByCondition(updatedTemplate, flatProjectData)) + // as conditions in template or some errors in code could potentially lead to infinite loop at this point + // we check that we are not trying to update the same step again + // and in case of a loop we stop doing anything without any changes, as it's better than hang user's browser + if (stepToUpdate && getDirForSteps(prevStep, stepToUpdate) === STEP_DIR.SAME) { + console.error(`Infinite loop during updating step by condition ${JSON.stringify(stepToUpdate)}.`, updatedTemplate) + return { + template, + hidedSomeSteps: false, + updatedSomeSteps: false, + } + } + } + + return { + updatedTemplate, + hidedSomeSteps, + updatedSomeSteps, + } +} + +/** + * Removes values of the fields which are hidden by conditions from project data + * + * @param {Object} template template + * @param {Object} project project data (non-flat) + * + * @returns {Object} project data without data of hidden fields + */ +export const removeValuesOfHiddenSteps = (template, project) => { + let updatedProject = project + + forEachStep(template, (stepObject, step) => { + const level = getStepLevel(step) + + switch(level) { + // if some question is hidden, we remove it's value from the project data + case LEVEL.QUESTION: + if (stepObject.__wizard.hiddenByCondition && _.get(updatedProject, stepObject.fieldName)) { + updatedProject = update(updatedProject, unflatten({ + [stepObject.fieldName]: { $set: undefined } + })) + } + break + + // if some option is hidden, we remove it's value from the list of values of the parent question + case LEVEL.OPTION: { + if (stepObject.__wizard.hiddenByCondition) { + const questionStep = {...step, optionIndex: -1} + const questionStepObject = getStepObject(template, questionStep) + const questionValue = _.get(updatedProject, questionStepObject.fieldName) + + if (questionValue && _.isArray(questionValue)) { + const optionValueIndex = questionValue.indexOf(stepObject.value) + + if (optionValueIndex > - 1) { + updatedProject = update(updatedProject, unflatten({ + [questionStepObject.fieldName]: { $splice: [[optionValueIndex, 1]] } + })) + } + } + } + break + } + } + }) + + return updatedProject +} + +/** + * Returns first found step (only one) which has to be updated by condition + * + * @param {Object} template template + * @param {Object} flatProjectData project data (flat) + * + * @returns {Object} step + */ +const getStepWhichMustBeUpdatedByCondition = (template, flatProjectData) => { + const result = { + stepToUpdate: null + } + + forEachStep(template, (stepObject, step) => { + if (stepObject.condition) { + const hiddenByCondition = !evaluate(stepObject.condition, flatProjectData) + + // only update if the condition result has changed + if (hiddenByCondition !== stepObject.__wizard.hiddenByCondition) { + result.stepToUpdate = step + result.hiddenByCondition = hiddenByCondition + } + } + + if (stepObject.disableCondition) { + const disabledByCondition = evaluate(stepObject.disableCondition, flatProjectData) + + // only update if the condition result has changed + if (disabledByCondition !== stepObject.__wizard.disabledByCondition) { + result.stepToUpdate = step + result.disabledByCondition = disabledByCondition + } + } + + return !result.stepToUpdate + }) + + return result +} + +/** + * Finalize/unfinalize step + * + * When we've done with step we want to finalize it as per previousStepVisibility hide or make it read-only. + * This method does it. It also can the reverse operation if `value` is defined as `false` + * + * @param {Object} template template + * @param {Object} step step + * @param {Boolean} value + * + * @returns {Object} updated template + */ +const finalizeStep = (template, step, value = true) => { + let updatedTemplate = template + + const previousStepVisibility = getPreviousStepVisibility(template) + const stepObject = getStepObject(updatedTemplate, step) + + const updateRules = { + [PREVIOUS_STEP_VISIBILITY.READ_ONLY]: { + __wizard: { + readOnly: { $set: value } + } + }, + [PREVIOUS_STEP_VISIBILITY.NONE]: { + __wizard: { + hidden: { $set: value } + } + }, + } + + const updateRule = updateRules[previousStepVisibility] + + if (updateRule) { + updatedTemplate = updateStepObject(updatedTemplate, step, updateRule) + + // if the children of current step are not in wizard mode and we are making step read-only + // we also have make such children read-only + if (previousStepVisibility === PREVIOUS_STEP_VISIBILITY.READ_ONLY && !_.get(stepObject, 'wizard.enabled')) { + const stepChildren = getStepChildren(updatedTemplate, step) + + stepChildren.forEach((stepChild) => { + updatedTemplate = updateStepObject(updatedTemplate, stepChild, updateRule) + }) + } + } + + return updatedTemplate +} + +/** + * Update template so the next step in defined direction is shown + * + * @param {Object} template template + * @param {Object} currentStep current step + * @param {String} dir direction + * + * @returns {Object} updated template + */ +export const showStepByDir = (template, currentStep, dir) => { + let updatedTemplate = template + let tempStep + + // if we are moving to the next step, we have to finalize previous one + if (dir === STEP_DIR.NEXT) { + // finalize step on it's level all parent levels of the step + // as long as step is the last on the current level + tempStep = currentStep + do { + updatedTemplate = finalizeStep(updatedTemplate, tempStep) + + // if step is the last on the current level, we also finalize parent level step + if (!getNextSiblingStep(updatedTemplate, tempStep, dir)) { + tempStep = getParentStep(tempStep) + } else { + tempStep = null + } + } while (tempStep) + + // if we are moving to the previous step, we just have to hide current step + } else { + tempStep = currentStep + + do { + updatedTemplate = updateStepObject(updatedTemplate, tempStep, { + __wizard: { + hidden: { $set: true } + } + }) + + // if step is the first on the current level, we also hide parent level step + if (!getPrevSiblingStep(updatedTemplate, tempStep, dir)) { + tempStep = getParentStep(tempStep) + } else { + tempStep = null + } + } while (tempStep) + } + + const nextStep = getStepToShowByDir(updatedTemplate, currentStep, dir) + + if (!nextStep) { + console.warn('showNextStep method is called when there is no next step, probably something is wrong.') + } + + // make visible current step and all it's parents + tempStep = nextStep + do { + updatedTemplate = updateStepObject(updatedTemplate, tempStep, { + __wizard: { + hidden: { $set: false } + } + }) + tempStep = getParentStep(tempStep) + } while (tempStep) + + if (dir === STEP_DIR.PREV && _.get(updatedTemplate, 'wizard.previousStepVisibility') === PREVIOUS_STEP_VISIBILITY.READ_ONLY) { + updatedTemplate = finalizeStep(updatedTemplate, nextStep, false) + } + + return { + updatedTemplate, + nextStep, + } +} + +/** + * Update template so we show the `destinationStep` instead of `currentStep` + * + * @param {Object} template template + * @param {Object} currentStep current step + * @param {Object} destinationStep destinationStep + * + * @returns {Object} updated template + */ +export const rewindToStep = (template, currentStep, destinationStep) => { + const dir = getDirForSteps(currentStep, destinationStep) + let tempStep = currentStep + let tempDir = dir + let updatedTemplate = template + + if (dir === STEP_DIR.SAME) { + return updatedTemplate + } + + while (tempDir === dir) { + const nextStepData = showStepByDir(updatedTemplate, tempStep, dir) + + updatedTemplate = nextStepData.updatedTemplate + tempStep = nextStepData.nextStep + tempDir = getDirForSteps(tempStep, destinationStep) + } + + return updatedTemplate +} + +/** + * Determines if step has dependant steps + * + * @param {Object} template template + * @param {Object} step template + * + * @returns {Boolean} true if step has any dependant steps + */ +export const isStepHasDependencies = (template, step) => { + const stepObject = getStepObject(template, step) + + return _.includes(_.get(template, '__wizard.dependantFields', []), stepObject.fieldName) +} + +/** + * Check if step is defined as a step in wizard. + * + * @param {Object} template template + * @param {Object} step step + * + * @returns {Boolean} true if step is defined as a step in wizard + */ +export const findRealStep = (template, step) => { + let tempStep = step + let tempStepObject = getStepObject(template, tempStep) + + while (tempStep && !_.get(tempStepObject, '__wizard.isStep')) { + tempStep = getParentStep(tempStep) + tempStepObject = getStepObject(template, tempStep) + } + + return tempStep +} + +/** + * Update template so the `step` is showed as editable (non read-only) + * + * @param {Object} template template + * @param {Object} step step + * + * @returns {Object} updated template + */ +export const makeStepEditable = (template, step) => { + let updatedTemplate = template + + updatedTemplate = updateStepObject(updatedTemplate, step, { + __wizard: { + readOnly: { $set: false }, + editReadOnly: { $set: true } + } + }) + + return updatedTemplate +} + + +/** + * Update template so the `step` is showed as read-only + * + * @param {Object} template template + * @param {Object} step step + * + * @returns {Object} updated template + */ +export const makeStepReadonly = (template, step) => { + let updatedTemplate = template + + updatedTemplate = updateStepObject(updatedTemplate, step, { + __wizard: { + readOnly: { $set: true }, + editReadOnly: { $set: false } + } + }) + + return updatedTemplate +} + +/** + * Finds next either sibling or ancestor step + * + * @param {Object} template template + * @param {Object} step step + * + * @returns {Object} step + */ +const getNextSiblingOrAncestorStep = (template, step) => { + const sibling = getNextSiblingStep(template, step) + + if (sibling) { + return sibling + } + + const children = getStepChildren(template, step) + + if (children.length > 0) { + return children[0] + } + + return null +} + +/** + * Adds data which manged by the step to the snapshot + * + * @param {Object} snapshot snapshot + * @param {Object} template template + * @param {Object} step tep + * @param {Object} flatData flat data + */ +const saveStepDataToSnapshot = (snapshot, template, step, flatData) => { + const stepObject = getStepObject(template, step) + + // is some step is not a field, don't save anything + if (!stepObject.fieldName) { + return + } + + snapshot[stepObject.fieldName] = flatData[stepObject.fieldName] + + // as some types of subSections has multiple values we have to save them too + const refCodeFieldName = 'details.utm.code' + const businessUnitFieldName = 'details.businessUnit' + const costCentreFieldName = 'details.costCentre' + + switch(stepObject.type) { + case 'project-name': + snapshot[refCodeFieldName] = flatData[refCodeFieldName] + break + case 'project-name-advanced': + snapshot[refCodeFieldName] = flatData[refCodeFieldName] + snapshot[businessUnitFieldName] = flatData[businessUnitFieldName] + snapshot[costCentreFieldName] = flatData[costCentreFieldName] + break + default:break + } +} + +/** + * Adds snapshot of data of the provided "real step" + * + * @param {Array} snapshotsStorage array to store snapshots + * @param {Object} step step + * @param {Object} template template + * @param {Object} flatData flat data + */ +export const pushStepDataSnapshot = (snapshotsStorage, step, template, flatData) => { + const snapshot = {} + + saveStepDataToSnapshot(snapshot, template, step, flatData) + + const children = getStepChildren(template, step) + if (children.length > 0 && !isStepLevel(children[0], LEVEL.OPTION)) { + let tempStep = children[0] + + do { + saveStepDataToSnapshot(snapshot, template, tempStep, flatData) + + tempStep = getNextSiblingOrAncestorStep(template, tempStep) + } while (tempStep) + } + + snapshotsStorage.push({ + step, + snapshot, + }) +} + +/** + * Pop snapshot of data of the provided "real step" + * + * It removes data form `snapshotsStorage` and returns it + * + * @param {Array} snapshotsStorage array to store snapshots + * @param {Object} step step + * @param {Object} template template + * @param {Object} flatData flat data + * + * @returns {Object} snapshot + */ +export const popStepDataSnapshot = (snapshotsStorage, step) => { + const savedDataIndex = snapshotsStorage.findIndex((item) => _.isEqual(item.step, step)) + const savedData = savedDataIndex !== -1 ? snapshotsStorage[savedDataIndex] : null + snapshotsStorage.splice(savedDataIndex, 1) + + return savedData ? savedData.snapshot : null +} diff --git a/src/projects/create/components/FillProjectDetails.js b/src/projects/create/components/FillProjectDetails.js index e3d8c9c24..6a8393837 100644 --- a/src/projects/create/components/FillProjectDetails.js +++ b/src/projects/create/components/FillProjectDetails.js @@ -4,6 +4,7 @@ import PT from 'prop-types' import './FillProjectDetails.scss' import ProjectBasicDetailsForm from '../components/ProjectBasicDetailsForm' +import ProjectEstimationSection from '../../detail/components/ProjectEstimationSection' import ModalControl from '../../../components/ModalControl' import TailLeft from '../../../assets/icons/arrows-16px-1_tail-left.svg' @@ -25,6 +26,7 @@ class FillProjectDetails extends Component { shouldComponentUpdate(nextProps, nextState) { return !( _.isEqual(nextProps.project, this.props.project) + && _.isEqual(nextProps.dirtyProject, this.props.dirtyProject) && _.isEqual(nextState.project, this.state.project) && _.isEqual(nextProps.error, this.props.error) ) @@ -35,12 +37,12 @@ class FillProjectDetails extends Component { } render() { - const { project, processing, submitBtnText, onBackClick, projectTemplates } = this.props + const { project, processing, submitBtnText, onBackClick, projectTemplates, dirtyProject, templates, productTemplates } = this.props const projectTemplateId = _.get(project, 'templateId') const projectTemplate = _.find(projectTemplates, { id: projectTemplateId }) const formDisclaimer = _.get(projectTemplate, 'scope.formDisclaimer') - const sections = projectTemplate.scope.sections + const template = projectTemplate.scope return (
@@ -60,13 +62,16 @@ class FillProjectDetails extends Component {
+
{formDisclaimer && (
@@ -89,8 +94,10 @@ FillProjectDetails.propTypes = { onChangeProjectType: PT.func.isRequired, project: PT.object.isRequired, projectTemplates: PT.array.isRequired, + productTemplates: PT.array.isRequired, userRoles: PT.arrayOf(PT.string), processing: PT.bool, + templates: PT.array.isRequired, error: PT.oneOfType([ PT.bool, PT.object diff --git a/src/projects/create/components/FillProjectDetails.scss b/src/projects/create/components/FillProjectDetails.scss index 4edaf35d4..d0b8b94df 100644 --- a/src/projects/create/components/FillProjectDetails.scss +++ b/src/projects/create/components/FillProjectDetails.scss @@ -144,6 +144,9 @@ .sub-title { margin: 30px 0 10px; + &.read-optimized { + margin: 10px 0; + } .title { font-size: 15px; diff --git a/src/projects/create/components/ProjectBasicDetailsForm.js b/src/projects/create/components/ProjectBasicDetailsForm.js index 511bef36a..32e985e8a 100644 --- a/src/projects/create/components/ProjectBasicDetailsForm.js +++ b/src/projects/create/components/ProjectBasicDetailsForm.js @@ -3,6 +3,23 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import FormsyForm from 'appirio-tech-react-components/components/Formsy' const Formsy = FormsyForm.Formsy +import { + initWizard, + getNextStepToShow, + getPrevStepToShow, + isStepHasDependencies, + findRealStep, + rewindToStep, + updateStepsByConditions, + showStepByDir, + pushStepDataSnapshot, + popStepDataSnapshot, + removeValuesOfHiddenSteps, + STEP_DIR, + PREVIOUS_STEP_VISIBILITY, +} from '../../../helpers/wizardHelper' +import { LS_INCOMPLETE_WIZARD } from '../../../config/constants' +import Modal from 'react-modal' import './ProjectBasicDetailsForm.scss' import SpecSection from '../../detail/components/SpecSection' @@ -15,15 +32,137 @@ class ProjectBasicDetailsForm extends Component { this.disableButton = this.disableButton.bind(this) this.submit = this.submit.bind(this) this.handleChange = this.handleChange.bind(this) + this.showNextStep = this.showNextStep.bind(this) + this.showPrevStep = this.showPrevStep.bind(this) + this.startEditReadOnly = this.startEditReadOnly.bind(this) + this.declineEditReadOnly = this.declineEditReadOnly.bind(this) + this.cancelEditReadOnly = this.cancelEditReadOnly.bind(this) + this.confirmEditReadOnly = this.confirmEditReadOnly.bind(this) + this.updateEditReadOnly = this.updateEditReadOnly.bind(this) + + const incompleteWizardStr = window.localStorage.getItem(LS_INCOMPLETE_WIZARD) + let incompleteWizard = {} + if (incompleteWizardStr) { + try { + incompleteWizard = JSON.parse(incompleteWizardStr) + } catch (e) { + console.error('Cannot parse incomplete Wizard state.') + } + } + const { + template, + currentWizardStep, + prevWizardStep, + isWizardMode, + previousStepVisibility, + hasDependantFields, + } = initWizard(props.template, props.project, incompleteWizard) + + this.state = { + template, + nextWizardStep: getNextStepToShow(template, currentWizardStep), + showStartEditConfirmation: null, + prevWizardStep, + isWizardMode, + previousStepVisibility, + hasDependantFields, + editingReadonlyStep: null + } + + // we don't use for rendering, only for internal needs, so we don't need it in state + this.currentWizardStep = currentWizardStep + + // we will keep there form values before starting editing read-only values + // and we will use this data to restore previous values if user press Cancel + this.dataSnapshots = [] + } + + startEditReadOnly(step) { + const { template } = this.state + + if (isStepHasDependencies(template, step)) { + this.setState({ + showStartEditConfirmation: step, + }) + } else { + this._startEditReadOnly(step) + } + } + + declineEditReadOnly() { + this.setState({ + showStartEditConfirmation: null, + }) + } + + confirmEditReadOnly() { + this._startEditReadOnly(this.state.showStartEditConfirmation) + this.setState({ + showStartEditConfirmation: null, + }) + } + + _startEditReadOnly(step) { + const { template } = this.state + let updatedTemplate = template + + step = findRealStep(template, step) + pushStepDataSnapshot(this.dataSnapshots, step, template, this.refs.form.getCurrentValues()) + updatedTemplate = rewindToStep(updatedTemplate, this.currentWizardStep, step) + + this.setState({ + template: updatedTemplate, + editingReadonlyStep: step + }) + } + + cancelEditReadOnly() { + const { template, editingReadonlyStep } = this.state + let updatedTemplate = template + + const savedSnapshot = popStepDataSnapshot(this.dataSnapshots, editingReadonlyStep) + updatedTemplate = rewindToStep(updatedTemplate, editingReadonlyStep, this.currentWizardStep) + + this.setState({ + // first we show back form fields as it were before + template: updatedTemplate, + editingReadonlyStep: null + }, () => { + // only after we showed all the fields back we can restore their values + this.refs.form.inputs.forEach(component => { + const name = component.props.name + + if (!_.isUndefined(savedSnapshot[name])) { + component.setValue(savedSnapshot[name]) + } + }) + }) + } + + updateEditReadOnly(evt) { + // prevent default to avoid form being submitted + evt && evt.preventDefault() + + const { editingReadonlyStep } = this.state + + // removed saved snapshot + popStepDataSnapshot(this.dataSnapshots, editingReadonlyStep) + this.currentWizardStep = editingReadonlyStep + + this.showNextStep() } shouldComponentUpdate(nextProps, nextState) { return !( _.isEqual(nextProps.project, this.props.project) + && _.isEqual(nextProps.dirty, this.props.dirty) && _.isEqual(nextState.project, this.state.project) && _.isEqual(nextState.canSubmit, this.state.canSubmit) - && _.isEqual(nextProps.sections, this.props.sections) + && _.isEqual(nextState.template, this.state.template) && _.isEqual(nextState.isSaving, this.state.isSaving) + && _.isEqual(nextState.nextWizardStep, this.state.nextWizardStep) + && _.isEqual(nextState.prevWizardStep, this.state.prevWizardStep) + && _.isEqual(nextState.showStartEditConfirmation, this.state.showStartEditConfirmation) ) } @@ -35,20 +174,20 @@ class ProjectBasicDetailsForm extends Component { } componentWillReceiveProps(nextProps) { - // we receipt property updates from PROJECT_DIRTY REDUX state - if (nextProps.project.isDirty) return - const updatedProject = Object.assign({}, nextProps.project) - this.setState({ - project: updatedProject, - isSaving: false, - canSubmit: false - }) - } + if (this.state.hasDependantFields && !_.isEqual(nextProps.dirtyProject, this.props.dirtyProject)) { + const { + updatedTemplate, + hidedSomeSteps, + updatedSomeSteps, + } = updateStepsByConditions(this.state.template, nextProps.dirtyProject) - 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()) + if (updatedSomeSteps) { + this.setState({ + template: updatedTemplate, + project: hidedSomeSteps ? nextProps.dirtyProject : this.state.project, + }) + } + } } enableButton() { @@ -60,27 +199,64 @@ class ProjectBasicDetailsForm extends Component { } submit(model) { - console.log('submit', this.isChanged()) this.setState({isSaving: true }) - this.props.submitHandler(model) + const modelWithoutHiddenValues = removeValuesOfHiddenSteps(this.state.template, model) + this.props.submitHandler(modelWithoutHiddenValues) } /** * Handles the change event of the form. * * @param change changed form model in flattened form - * @param isChanged flag that indicates if form actually changed from initial model values */ handleChange(change) { - // removed check for isChanged argument to fire the PROJECT_DIRTY event for every change in the form - // this.props.fireProjectDirty(change) this.props.onProjectChange(change) } + showStepByDir(evt, dir) { + // prevent default to avoid form being submitted + evt && evt.preventDefault() + + const { template } = this.state + const { updatedTemplate, nextStep } = showStepByDir(template, this.currentWizardStep, dir) + + this.setState({ + template: updatedTemplate, + nextWizardStep: getNextStepToShow(updatedTemplate, nextStep), + prevWizardStep: dir === STEP_DIR.NEXT ? this.currentWizardStep : getPrevStepToShow(updatedTemplate, nextStep), + project: this.props.dirtyProject, + editingReadonlyStep: null + }) + + this.currentWizardStep = nextStep + + window.localStorage.setItem(LS_INCOMPLETE_WIZARD, JSON.stringify({ + currentWizardStep: this.currentWizardStep + })) + } + + showNextStep(evt) { + this.showStepByDir(evt, STEP_DIR.NEXT) + } + + showPrevStep(evt) { + this.showStepByDir(evt, STEP_DIR.PREV) + } render() { - const { isEditable, sections, submitBtnText } = this.props - const { project, canSubmit } = this.state + const { isEditable, submitBtnText, dirtyProject, productTemplates } = this.props + const { + project, + canSubmit, + template, + nextWizardStep, + prevWizardStep, + showStartEditConfirmation, + isWizardMode, + previousStepVisibility, + editingReadonlyStep, + } = this.state + const renderSection = (section, idx) => { return (
@@ -88,26 +264,48 @@ class ProjectBasicDetailsForm extends Component { {} }//dummy resetFeatures={ () => {} }//dummy - // TODO we shoudl not update the props (section is coming from props) + // TODO we should not update the props (section is coming from props) // further, it is not used for this component as we are not rendering spec screen section here validate={() => {}}//dummy isCreation + startEditReadOnly={this.startEditReadOnly} />
-
- -
) } return (
+ +
+ Confirmation +
+ +
+ You are about to change the response to question which may result in loss of data, do you want to continue? +
+ +
+ + +
+
+ - {sections.map(renderSection)} + {template.sections.filter(section => ( + // hide if we are in a wizard mode and section is hidden for now + !_.get(section, '__wizard.hidden') + )).map(renderSection)} +
+ {isWizardMode && previousStepVisibility === PREVIOUS_STEP_VISIBILITY.NONE && ( + + )} + {editingReadonlyStep && ( + + )} + {(nextWizardStep || editingReadonlyStep) ? ( + + ) : ( + + )} +
) @@ -126,8 +358,9 @@ class ProjectBasicDetailsForm extends Component { ProjectBasicDetailsForm.propTypes = { project: PropTypes.object.isRequired, saving: PropTypes.bool.isRequired, - sections: PropTypes.arrayOf(PropTypes.object).isRequired, + template: PropTypes.object.isRequired, isEditable: PropTypes.bool.isRequired, + productTemplates: PropTypes.array.isRequired, submitHandler: PropTypes.func.isRequired } diff --git a/src/projects/create/components/ProjectWizard.jsx b/src/projects/create/components/ProjectWizard.jsx index b28dcaef8..16c2e4b49 100644 --- a/src/projects/create/components/ProjectWizard.jsx +++ b/src/projects/create/components/ProjectWizard.jsx @@ -13,7 +13,7 @@ import FillProjectDetails from './FillProjectDetails' import ProjectSubmitted from './ProjectSubmitted' import update from 'react-addons-update' -import { LS_INCOMPLETE_PROJECT, PROJECT_REF_CODE_MAX_LENGTH } from '../../../config/constants' +import { LS_INCOMPLETE_PROJECT, PROJECT_REF_CODE_MAX_LENGTH, LS_INCOMPLETE_WIZARD } from '../../../config/constants' import './ProjectWizard.scss' const WZ_STEP_INCOMP_PROJ_CONF = 0 @@ -54,7 +54,7 @@ class ProjectWizard extends Component { // load incomplete project from local storage const incompleteProjectStr = window.localStorage.getItem(LS_INCOMPLETE_PROJECT) - + if ((params && params.project === 'submitted') || createdProject) { const wizardStep = WZ_STEP_PROJECT_SUBMITTED const updateQuery = {} @@ -74,7 +74,7 @@ class ProjectWizard extends Component { let updateQuery = {} if (incompleteProjectTemplate && params && params.project) { const projectTemplate = getProjectTemplateByAlias(projectTemplates, params.project) - + if (projectTemplate) { // load project details page directly if (projectTemplate.key === incompleteProjectTemplate.key) { @@ -87,7 +87,7 @@ class ProjectWizard extends Component { } } } - + this.setState({ project: update(this.state.project, updateQuery), dirtyProject: update(this.state.dirtyProject, updateQuery), @@ -234,6 +234,7 @@ class ProjectWizard extends Component { const { onStepChange } = this.props // remove incomplete project from local storage window.localStorage.removeItem(LS_INCOMPLETE_PROJECT) + window.localStorage.removeItem(LS_INCOMPLETE_WIZARD) // following code assumes that componentDidMount has already updated state with correct project const projectType = _.get(this.state.project, 'type') const projectTemplateId = _.get(this.state.project, 'templateId') @@ -394,8 +395,11 @@ class ProjectWizard extends Component { }) } - handleOnCreateProject() { - this.props.createProject(this.state.dirtyProject) + handleOnCreateProject(model) { + // add templateId and type to the saved project form + _.set(model, 'templateId', _.get(this.state.dirtyProject, 'templateId')) + _.set(model, 'type', _.get(this.state.dirtyProject, 'type')) + this.props.createProject(model) } handleStepChange(wizardStep) { @@ -426,7 +430,7 @@ class ProjectWizard extends Component { } render() { - const { processing, showModal, userRoles, projectTemplates, projectTypes, projectId, match } = this.props + const { processing, showModal, userRoles, projectTemplates, projectTypes, projectId, match, templates } = this.props const { project, dirtyProject, wizardStep } = this.state const params = match.params @@ -457,7 +461,9 @@ class ProjectWizard extends Component { /> { const { component: Component } = props const componentProps = { project: props.project, + projectNonDirty: props.projectNonDirty, currentMemberRole: currentMemberRole || '', isSuperUser: props.isSuperUser, isManageUser: props.isManageUser, @@ -180,6 +181,7 @@ const mapStateToProps = ({projectState, projectDashboard, loadUser, productsTime isProcessing: projectState.processing, error: projectState.error, project: projectState.project, + projectNonDirty: projectState.projectNonDirty, projectTemplate: (templateId && projectTemplates) ? ( getProjectTemplateById(projectTemplates, templateId) ) : null, diff --git a/src/projects/detail/components/AddonOptions/AddonOptions.jsx b/src/projects/detail/components/AddonOptions/AddonOptions.jsx new file mode 100644 index 000000000..cd6f85942 --- /dev/null +++ b/src/projects/detail/components/AddonOptions/AddonOptions.jsx @@ -0,0 +1,72 @@ +import React, { Component, PropTypes } from 'react' +import { HOC as hoc } from 'formsy-react' +import cn from 'classnames' + +class AddonOptions extends Component { + + constructor(props) { + super(props) + this.changeValue = this.changeValue.bind(this) + } + + changeValue() { + const value = [] + this.props.options.forEach((option, key) => { + if (this['element-' + key].checked) { + value.push(option.value) + } + }) + this.props.setValue(value) + this.props.onChange(this.props.name, value) + } + + render() { + const { label, name, options } = this.props + const hasError = !this.props.isPristine() && !this.props.isValid() + const errorMessage = this.props.getErrorMessage() || this.props.validationError + const getId = s => s.id + const renderOption = (cb, key) => { + const curValue = this.props.getValue() || [] + const checked = curValue.map(v => getId(v)).indexOf(getId(cb.value)) !== -1 + const disabled = this.props.isFormDisabled() || cb.disabled || this.props.disabled + const rClass = cn('checkbox-group-item', { disabled }) + const id = name+'-opt-'+key + const setRef = (c) => this['element-' + key] = c + return ( +
+
+ +
+ +
+ ) + } + + return ( +
+ +
{options.map(renderOption)}
+ { hasError ? (

{errorMessage}

) : null} +
+ ) + } +} + +AddonOptions.PropTypes = { + options: PropTypes.arrayOf(PropTypes.object).isRequired +} + +AddonOptions.defaultProps = { + onChange: () => {} +} + +export default hoc(AddonOptions) diff --git a/src/projects/detail/components/EditProjectForm/EditProjectForm.jsx b/src/projects/detail/components/EditProjectForm/EditProjectForm.jsx index 2104cfbc6..811e94535 100644 --- a/src/projects/detail/components/EditProjectForm/EditProjectForm.jsx +++ b/src/projects/detail/components/EditProjectForm/EditProjectForm.jsx @@ -15,6 +15,16 @@ const Formsy = FormsyForm.Formsy import XMarkIcon from '../../../../assets/icons/icon-x-mark.svg' import SpecSection from '../SpecSection' import { HOC as hoc } from 'formsy-react' +import { + initWizard, + updateStepsByConditions, + makeStepEditable, + makeStepReadonly, + isStepHasDependencies, + pushStepDataSnapshot, + popStepDataSnapshot, + removeValuesOfHiddenSteps, +} from '../../../../helpers/wizardHelper' import './EditProjectForm.scss' @@ -62,6 +72,26 @@ class EditProjectForm extends Component { this.onLeave = this.onLeave.bind(this) this.handleChange = this.handleChange.bind(this) this.makeDeliveredPhaseReadOnly = this.makeDeliveredPhaseReadOnly.bind(this) + this.startEditReadOnly = this.startEditReadOnly.bind(this) + this.declineEditReadOnly = this.declineEditReadOnly.bind(this) + this.confirmEditReadOnly = this.confirmEditReadOnly.bind(this) + this.stopEditReadOnly = this.stopEditReadOnly.bind(this) + this.cancelEditReadOnly = this.cancelEditReadOnly.bind(this) + + const { + template, + hasDependantFields, + } = initWizard(props.template, props.project, null, true) + + this.state = { + template, + hasDependantFields, + showStartEditConfirmation: false, + } + + // we will keep there form values before starting editing read-only values + // and we will use this data to restore previous values if user press Cancel + this.dataSnapshots = [] } componentWillMount() { @@ -83,33 +113,121 @@ class EditProjectForm extends Component { dirtyProject: Object.assign({}, nextProps.project), isProjectDirty: true }) - return - } - let updatedProject = Object.assign({}, nextProps.project) - if (this.state.isFeaturesDirty && !this.state.isSaving) { - updatedProject = update(updatedProject, { - details: { - appDefinition: { - features: { - $set: this.state.project.details.appDefinition.features + } else { + let updatedProject = Object.assign({}, nextProps.project) + if (this.state.isFeaturesDirty && !this.state.isSaving) { + updatedProject = update(updatedProject, { + details: { + appDefinition: { + features: { + $set: this.state.project.details.appDefinition.features + } } } - } + }) + } + this.setState({ + project: updatedProject, + isFeaturesDirty: false, // Since we just saved, features are not dirty anymore. + isProjectDirty: false, + canSubmit: false, + isSaving: false }) } - this.setState({ - project: updatedProject, - isFeaturesDirty: false, // Since we just saved, features are not dirty anymore. - isProjectDirty: false, - canSubmit: false, - isSaving: false - }) + + if (this.state.hasDependantFields && !_.isEqual(this.props.project, nextProps.project)) { + const { + updatedTemplate, + updatedSomeSteps, + hidedSomeSteps + } = updateStepsByConditions(this.state.template, nextProps.project) + + if (updatedSomeSteps) { + this.setState({ + template: updatedTemplate, + project: hidedSomeSteps ? nextProps.project : this.state.project, + }) + } + } } componentDidMount() { window.addEventListener('beforeunload', this.onLeave) } + startEditReadOnly(step) { + const { template } = this.state + + if (isStepHasDependencies(template, step)) { + this.setState({ + showStartEditConfirmation: step, + }) + } else { + this._startEditReadOnly(step) + } + } + + declineEditReadOnly() { + this.setState({ + showStartEditConfirmation: null, + }) + } + + confirmEditReadOnly() { + this._startEditReadOnly(this.state.showStartEditConfirmation) + this.setState({ + showStartEditConfirmation: null, + }) + } + + _startEditReadOnly(step) { + const { template } = this.state + let updatedTemplate = template + + pushStepDataSnapshot(this.dataSnapshots, step, template, this.refs.form.getCurrentValues()) + updatedTemplate = makeStepEditable(template, step) + + this.setState({ + template: updatedTemplate, + }) + } + + stopEditReadOnly(step) { + const { template } = this.state + let updatedTemplate = template + + // remove saved snapshot + popStepDataSnapshot(this.dataSnapshots, step) + + updatedTemplate = makeStepReadonly(template, step) + + this.setState({ + template: updatedTemplate, + }) + } + + cancelEditReadOnly(step) { + const { template } = this.state + let updatedTemplate = template + + const savedSnapshot = popStepDataSnapshot(this.dataSnapshots, step) + updatedTemplate = makeStepReadonly(template, step) + + this.setState({ + // first we show back form fields as it were before + template: updatedTemplate, + }, () => { + // only after we showed all the fields back we can restore their values + this.refs.form.inputs.forEach(component => { + const name = component.props.name + + if (!_.isUndefined(savedSnapshot[name])) { + component.setValue(savedSnapshot[name]) + } + }) + }) + } + autoResize() { if (self.autoResizeSet === true) { return } self.autoResizeSet = true @@ -142,9 +260,7 @@ class EditProjectForm extends Component { } 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()) || this.state.isFeaturesDirty + return !!this.props.project.isDirty } enableButton() { @@ -189,7 +305,8 @@ class EditProjectForm extends Component { // model.details.appDefinition.features = this.state.project.details.appDefinition.features // } this.setState({isSaving: true }) - this.props.submitHandler(model) + const modelWithoutHiddenValues = removeValuesOfHiddenSteps(this.state.template, model) + this.props.submitHandler(modelWithoutHiddenValues) } /** @@ -199,11 +316,7 @@ class EditProjectForm extends Component { * @param isChanged flag that indicates if form actually changed from initial model values */ handleChange(change) { - if (this.isChanged()) { - this.props.fireProjectDirty(unflatten(change)) - } else { - this.props.fireProjectDirtyUndo() - } + this.props.fireProjectDirty(unflatten(change)) } makeDeliveredPhaseReadOnly(projectStatus) { @@ -212,11 +325,11 @@ class EditProjectForm extends Component { render() { - const { isEdittable, sections, showHidden } = this.props - const { project, dirtyProject } = this.state + const { isEdittable, showHidden, productTemplates } = this.props + const { project, dirtyProject, template, showStartEditConfirmation } = this.state const onLeaveMessage = this.onLeave() || '' const renderSection = (section, idx) => { - const anySectionInvalid = _.some(this.props.sections, (s) => s.isInvalid) + const anySectionInvalid = _.some(template.sections, (s) => s.isInvalid) return (
@@ -248,6 +365,26 @@ class EditProjectForm extends Component { return (
+ +
+ Confirmation +
+ +
+ You are about to change the response to question which may result in loss of data, do you want to continue? +
+ +
+ + +
+
- {sections.map(renderSection)} + {template.sections.map(renderSection)} n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') -const ProjectEstimationSection = ({ project }) => { - const { products } = project.details - const productId = products ? products[0] : null - - const { priceEstimate, durationEstimate } = getProductEstimate(productId, project) +const ProjectEstimationSection = ({ project, templates }) => { + let projectTemplate = null + if (project.version === 'v2') { + const { products } = project.details + const productId = products ? products[0] : null + console.log(productId) + // TODO find template id + } else { + const templateId = project.templateId + projectTemplate = _.find(templates, t => t.id === templateId) + } + console.log('Rendering ProjectEstimationSection') + console.log(project) + if (!projectTemplate) { + return null + } + const { priceEstimate, durationEstimate } = getProductEstimate(projectTemplate, project) return (
@@ -35,6 +48,7 @@ const ProjectEstimationSection = ({ project }) => { ProjectEstimationSection.propTypes = { project: PropTypes.object.isRequired, + templates: PropTypes.array.isRequired, } export default ProjectEstimationSection diff --git a/src/projects/detail/components/ProjectStage.jsx b/src/projects/detail/components/ProjectStage.jsx index de7df222c..7209495d8 100644 --- a/src/projects/detail/components/ProjectStage.jsx +++ b/src/projects/detail/components/ProjectStage.jsx @@ -8,9 +8,9 @@ import uncontrollable from 'uncontrollable' import { formatNumberWithCommas } from '../../../helpers/format' import { getPhaseActualData } from '../../../helpers/projectHelper' -import { +import { PROJECT_ATTACHMENTS_FOLDER, - EVENT_TYPE, + EVENT_TYPE, } from '../../../config/constants' import { filterNotificationsByPosts, filterReadNotifications } from '../../../routes/notifications/helpers/notifications' @@ -152,6 +152,7 @@ class ProjectStage extends React.Component{ render() { const { phase, + phaseNonDirty, phaseIndex, project, productTemplates, @@ -184,7 +185,10 @@ class ProjectStage extends React.Component{ // so far we always have only one product per phase, so will display only one const productTemplate = _.find(productTemplates, { id: _.get(phase, 'products[0].templateId') }) const product = _.get(phase, 'products[0]') - const sections = _.get(productTemplate, 'template.questions', []) + const productNonDirty = _.get(phaseNonDirty, 'products[0]') + const template = { + sections: _.get(productTemplate, 'template.questions', []) + } const projectPhaseAnchor = `phase-${phase.id}-posts` const attachmentsStorePath = `${PROJECT_ATTACHMENTS_FOLDER}/${project.id}/phases/${phase.id}/products/${product.id}` @@ -239,7 +243,7 @@ class ProjectStage extends React.Component{ {currentActiveTab === 'specification' &&
- updateProduct(project.id, phase.id, product.id, model)} saving={isProcessing} diff --git a/src/projects/detail/components/ProjectStages.jsx b/src/projects/detail/components/ProjectStages.jsx index fcfc4701f..afd448e1a 100644 --- a/src/projects/detail/components/ProjectStages.jsx +++ b/src/projects/detail/components/ProjectStages.jsx @@ -73,6 +73,7 @@ function formatPhaseCardListHeaderProps(phases) { const ProjectStages = ({ project, phases, + phasesNonDirty, phasesStates, productTemplates, productsTimelines, @@ -107,6 +108,7 @@ const ProjectStages = ({ isManageUser={isManageUser} project={project} phase={phase} + phaseNonDirty={phasesNonDirty[index]} phaseIndex={index} updateProduct={updateProduct} fireProductDirty={fireProductDirty} diff --git a/src/projects/detail/components/SpecQuestionList/SpecQuestionList.jsx b/src/projects/detail/components/SpecQuestionList/SpecQuestionList.jsx index 51f617206..1674ce3ff 100644 --- a/src/projects/detail/components/SpecQuestionList/SpecQuestionList.jsx +++ b/src/projects/detail/components/SpecQuestionList/SpecQuestionList.jsx @@ -2,6 +2,9 @@ import React from 'react' import PropTypes from 'prop-types' import cn from 'classnames' import _ from 'lodash' + +import IconUIPencil from '../../../../assets/icons/ui-pencil.svg' + require('./SpecQuestionList.scss') const SpecQuestionList = ({ children, layout, additionalClass }) => { @@ -31,23 +34,69 @@ SpecQuestionList.defaultProps = { additionalClass: '' } -const SpecQuestionListItem = ({icon, title, type, additionalClass, description, children, required, hideDescription}) => { +const SpecQuestionListItem = ({ + icon, + title, + type, + additionalClass, + titleAside, + description, + children, + required, + hideDescription, + __wizard, + startEditReadOnly, + stopEditReadOnly, + cancelEditReadOnly, + readOptimized, +}) => { let shouldShowTitle = true let shouldShowRequire = false if (additionalClass.includes('spacing-gray-input') && (type === 'textinput')) { shouldShowTitle = false shouldShowRequire = true } - return (
- {icon &&
{icon}
} -
- {shouldShowTitle && (
{title}{required ? * : null}
)} - {children &&
{children}
} - {!hideDescription &&

{description}

} - {shouldShowRequire && -
{required ? 'Required' : 'Optional'}
} + return ( +
+ {icon &&
{icon}
} +
+ {_.get(__wizard, 'readOnly') && ( + + )} +
+ { shouldShowTitle && (
{title}{required ? * : null}
) } + {!!titleAside &&
{titleAside}
} +
+ {children &&
{children}
} + {!hideDescription &&

{description}

} + {shouldShowRequire && (
{required ? 'Required' : 'Optional'}
) } + {_.get(__wizard, 'editReadOnly') && ( +
+ + +
+ )} +
-
) + ) } SpecQuestionListItem.propTypes = { diff --git a/src/projects/detail/components/SpecQuestionList/SpecQuestionList.scss b/src/projects/detail/components/SpecQuestionList/SpecQuestionList.scss index acecb27e0..3acdc2d03 100644 --- a/src/projects/detail/components/SpecQuestionList/SpecQuestionList.scss +++ b/src/projects/detail/components/SpecQuestionList/SpecQuestionList.scss @@ -15,10 +15,37 @@ padding: 0; margin-bottom: 30px; + &.read-optimized { + margin-bottom: 10px; + } + @media screen and (max-width: $screen-md - 1px) { margin-top: 9 * $base-unit; } + .disabled-items-as-read-only { + .radio-group-options { + input[type="radio"][disabled] + label{ + padding-left: 0; + + &::before, + &::after { + display: none; + } + } + } + + .checkbox-group-item.disabled { + label { + margin-left: 0; + } + + .tc-checkbox { + display: none; + } + } + } + .require-desc { @include roboto; font-size: 12px; @@ -79,8 +106,11 @@ width: 100%; > h5 { + align-items: top; @include roboto-medium; font-size: 15px; + display: flex; + justify-content: space-between; line-height: 25px; color: $tc-gray-70; margin-bottom: 0; @@ -89,6 +119,13 @@ margin-bottom: 2 * $base-unit; } + .spec-section-title-aside { + color: $tc-gray-80; + margin-left: 4 * $base-unit; + font-size: $tc-body-md; + -webkit-text-fill-color: $tc-gray-50; + } + span { color: $tc-orange; padding-left: $base-unit; @@ -223,6 +260,12 @@ } } + .checkbox-group.horizontal { + .checkbox-group-options .checkbox-group-item { + margin: 15px 0px; + } + } + .SliderRadioGroup { height: auto; margin: 10px auto 28px auto; diff --git a/src/projects/detail/components/SpecQuestions.jsx b/src/projects/detail/components/SpecQuestions.jsx index 437d7171c..7ad233fcb 100644 --- a/src/projects/detail/components/SpecQuestions.jsx +++ b/src/projects/detail/components/SpecQuestions.jsx @@ -4,6 +4,7 @@ import seeAttachedWrapperField from './SeeAttachedWrapperField' import FormsyForm from 'appirio-tech-react-components/components/Formsy' const TCFormFields = FormsyForm.Fields import _ from 'lodash' +import AddonOptions from './AddonOptions/AddonOptions' import SpecQuestionList from './SpecQuestionList/SpecQuestionList' import SpecQuestionIcons from './SpecQuestionList/SpecQuestionIcons' @@ -29,10 +30,37 @@ const getIcon = icon => { } } +const filterAddonQuestions = (productTemplates, question) => + productTemplates.filter( + d => + d.category === question.category && + question.subCategories.includes(d.subCategory) + ) +const formatAddonOptions = options => options.map(o => ({ + label: o.name, + value: { id: o.id }, +})) + // { isRequired, represents the overall questions section's compulsion, is also available} -const SpecQuestions = ({questions, layout, additionalClass, project, dirtyProject, resetFeatures, showFeaturesDialog, showHidden }) => { +const SpecQuestions = ({ + questions, + layout, + additionalClass, + project, + dirtyProject, + resetFeatures, + showFeaturesDialog, + showHidden, + startEditReadOnly, + stopEditReadOnly, + cancelEditReadOnly, + isProjectDirty, + productTemplates, +}) => { + const currentProjectData = isProjectDirty ? dirtyProject : project const renderQ = (q, index) => { + const isReadOnly = _.get(q, '__wizard.readOnly') // let child = null // const value = const elemProps = { @@ -42,7 +70,29 @@ const SpecQuestions = ({questions, layout, additionalClass, project, dirtyProjec required: q.required, validations: q.required ? 'isRequired' : null, validationError: q.validationError, - validationErrors: q.validationErrors + validationErrors: q.validationErrors, + disabled: isReadOnly + } + if (q.options) { + // don't show options which are hidden by conditions + q.options = q.options.filter((option) => !_.get(option, '__wizard.hiddenByCondition')) + // disable options if they are disabled by conditions + q.options.forEach((option) => { + if (_.get(option, '__wizard.disabledByCondition', false)) { + option.disabled = true + } + }) + + if (elemProps.disabled) { + const fieldValue = _.get(currentProjectData, q.fieldName) + if (q.type === 'radio-group') { + q.options = _.filter(q.options, { value: fieldValue}) + } else if (q.type === 'checkbox-group') { + q.options = _.filter(q.options, (option) => ( + _.includes(fieldValue, option.value) + )) + } + } } // escape value of the question only when it is of string type if (typeof elemProps.value === 'string') { @@ -130,7 +180,7 @@ const SpecQuestions = ({questions, layout, additionalClass, project, dirtyProjec break case 'checkbox-group': ChildElem = TCFormFields.CheckboxGroup - _.assign(elemProps, {options: q.options}) + _.assign(elemProps, {options: q.options, layout: q.layout }) // child = break case 'checkbox': @@ -169,28 +219,91 @@ const SpecQuestions = ({questions, layout, additionalClass, project, dirtyProjec included: false }) break + case 'add-ons': + ChildElem = AddonOptions + _.assign(elemProps, { options: formatAddonOptions(filterAddonQuestions(productTemplates, q)) }) + break default: ChildElem =