diff --git a/make-webpack-config.js b/make-webpack-config.js index d657450..8cc6228 100644 --- a/make-webpack-config.js +++ b/make-webpack-config.js @@ -50,8 +50,10 @@ const makeWebpackConfig = (opts_) => { 'tau/components/component.page.base', 'tau/core/class', 'tau/core/extension.base', + 'tau/api/internal/store/types', 'tau/core/bus.reg', 'tp3/mashups/storage', + 'tp3/api/featureToggling/v1', 'tau/core/templates-factory', 'tau/core/view-base', 'tau/services/service.customFields.cached', diff --git a/package-lock.json b/package-lock.json index b10e5ce..d56e0d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "CustomFieldConstraints", - "version": "1.4.12", + "version": "1.5.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 5b88c6c..0db0e9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CustomFieldConstraints", - "version": "1.4.13", + "version": "1.5.0", "description": "This mashup allows a custom field to be required when an entity is moved to a specific state or a specific value is selected in another custom field.", "author": "Aliaksei Shytkin ", "scripts": { diff --git a/src/screens/Form/components/TargetprocessFinder.js b/src/screens/Form/components/TargetprocessFinder.js index 97fa0da..32811e2 100644 --- a/src/screens/Form/components/TargetprocessFinder.js +++ b/src/screens/Form/components/TargetprocessFinder.js @@ -1,8 +1,41 @@ import React, {PropTypes as T} from 'react'; import {noop} from 'underscore'; +import {isEnabled} from 'tp3/api/featureToggling/v1'; +import tauTypes from 'tau/api/internal/store/types'; import TargetprocessComponent from 'components/TargetprocessComponent'; +const getDefaultEntityTypeNamesToFilterBy = () => { + + // Same as in mode.finder.entity.data processOptions. + const extendableGeneralNamesSorted = tauTypes.getAll() + .filter((t) => t.isExtendableDomainType && t.isGeneral) + .map((t) => t.name.toLowerCase()) + .sort(); + + return [ + 'project', + 'program', + 'release', + ...(isEnabled('hideProjectIterations') ? [] : ['iteration']), + 'teamiteration', + 'testcase', + 'testplan', + 'build', + 'impediment', + 'portfolioepic', + 'epic', + 'feature', + 'userstory', + 'task', + 'bug', + 'testplanrun', + 'request', + ...extendableGeneralNamesSorted + ]; + +}; + export default class TargetprocessFinder extends React.Component { static propTypes = { @@ -27,26 +60,7 @@ export default class TargetprocessFinder extends React.Component { static defaultProps = { filterDsl: void 0, - filterEntityTypeName: [ - 'project', - 'program', - 'release', - 'iteration', - 'teamiteration', - 'testcase', - 'testplan', - 'build', - 'impediment', - - 'portfolioepic', - 'epic', - 'feature', - 'userstory', - 'task', - 'bug', - 'testplanrun', - 'request' - ], + filterEntityTypeName: getDefaultEntityTypeNamesToFilterBy(), filterFields: {}, onAdjust: noop, onSelect: noop @@ -54,7 +68,7 @@ export default class TargetprocessFinder extends React.Component { render() { - const {entity, customField, filterEntityTypeName, filterDsl, filterFields} = this.props; + const {entity, filterEntityTypeName, filterDsl, filterFields} = this.props; const config = { entityType: null, @@ -71,12 +85,6 @@ export default class TargetprocessFinder extends React.Component { } - if (customField) { - - config.customField = customField; - - } - const context = entity ? {entity} : null; return ( diff --git a/src/shared/services/CustomFieldValue.js b/src/shared/services/CustomFieldValue.js index 8a5216b..f4efc39 100644 --- a/src/shared/services/CustomFieldValue.js +++ b/src/shared/services/CustomFieldValue.js @@ -120,6 +120,12 @@ export const fromInputValue = (customField, inputValue) => { }; -// TP can send null when we uncheck checkbox custom field, override to correct value false. +// TP can send null or false when we uncheck checkbox custom field, override to correct value false. +// Can't normalize multipleentities here as type for it is not send. export const getCustomFieldValue = (customField) => equalIgnoreCase(customField.type, 'checkbox') ? Boolean(customField.value) : customField.value; + +// When custom field is removed (false for checkbox, null for all except multipleentities - empty string) +// then should run rules both for it and its dependent custom fields. +export const checkDependentCustomFields = (targetValue) => targetValue === null || + isEmptyCheckboxValue(targetValue) || targetValue === ''; diff --git a/src/shared/services/__tests__/CustomFieldValue.js b/src/shared/services/__tests__/CustomFieldValue.js index 913e067..af9cae4 100644 --- a/src/shared/services/__tests__/CustomFieldValue.js +++ b/src/shared/services/__tests__/CustomFieldValue.js @@ -1,4 +1,5 @@ -import {isEmptyCheckboxValue, fromServerValue, getCustomFieldValue} from 'services/CustomFieldValue'; +import {isEmptyCheckboxValue, fromServerValue, getCustomFieldValue, + checkDependentCustomFields} from 'services/CustomFieldValue'; import dateUtils from 'tau/utils/utils.date'; describe('CustomFieldValue', () => { @@ -315,4 +316,32 @@ describe('CustomFieldValue', () => { }); + describe('checkDependentCustomFields()', () => { + + it('checks dependent custom fields rules are needed', () => { + + expect(checkDependentCustomFields(null), + 'should need dependents when custom field reset').to.be.true; + expect(checkDependentCustomFields(false), + 'should need dependents when checkbox custom field reset').to.be.true; + expect(checkDependentCustomFields(''), + 'should need dependents when multipleentities custom field reset').to.be.true; + + expect(checkDependentCustomFields(0), + 'should not need dependents when number custom field set to 0').to.be.false; + expect(checkDependentCustomFields(12), + 'should not need dependents when number custom field set to non-0').to.be.false; + expect(checkDependentCustomFields('some text'), + 'should not need dependents when text custom field set').to.be.false; + expect(checkDependentCustomFields(true), + 'should not need dependents when checkbox custom field checked').to.be.false; + expect(checkDependentCustomFields({id: 1234, kind: 'Epic', name: 'Epic #1'}), + 'should not need dependents when entity custom field set').to.be.false; + expect(checkDependentCustomFields('9811 epic, 5341 userstory'), + 'should not need dependents when multipleentities custom field set').to.be.false; + + }); + + }); + }); diff --git a/src/shared/services/__tests__/axes.js b/src/shared/services/__tests__/axes.js index 427a4eb..47d180b 100644 --- a/src/shared/services/__tests__/axes.js +++ b/src/shared/services/__tests__/axes.js @@ -3,9 +3,63 @@ import $, {when} from 'jquery'; $.whenList = (arr) => $.when(...arr); +const createSandbox = () => { + + return { + obj: null, + propName: null, + propValue: null, + hasOwnProp: false, + + stub(obj, prop) { + + if (obj !== null && obj !== void 0) { + + this.obj = obj; + this.propName = prop; + this.propValue = obj[prop]; + this.hasOwnProp = Object.getOwnPropertyNames(obj).includes(prop); + + const me = this; // eslint-disable-line consistent-this + + return { + + value(v) { + + me.obj[prop] = v; + + } + + }; + + } + + throw new Error('object is null or undefined'); + + }, + + restore() { + + if (this.hasOwnProp) { + + this.obj[this.propName] = this.propValue; + + } else { + + delete this.obj[this.propName]; + + } + + } + + }; + +}; + describe('axes', () => { let $ajax; + let windowSandbox; const entity = { id: 123, @@ -18,18 +72,19 @@ describe('axes', () => { $ajax = sinon.stub($, 'ajax'); + windowSandbox = createSandbox(); + windowSandbox.stub(window, 'tauFeatures').value({ + systemCustomFields: false, + hideProjectIterations: false + }); + }); afterEach(() => { $ajax.restore(); getCustomFieldsForAxes.resetCache(); - - if (window.tauFeatures) { - - delete window.tauFeatures; - - } + windowSandbox.restore(); }); @@ -676,7 +731,7 @@ describe('axes', () => { it('skips system custom fields if feature is enabled', () => { - window.tauFeatures = {systemCustomFields: true}; + window.tauFeatures.systemCustomFields = true; $ajax.onCall(0).returns(when({ items: [{ @@ -708,7 +763,7 @@ describe('axes', () => { it('returns system custom fields if feature is disabled', () => { - window.tauFeatures = {systemCustomFields: false}; + window.tauFeatures.systemCustomFields = false; $ajax.onCall(0).returns(when({ items: [{ diff --git a/src/shared/services/axes.js b/src/shared/services/axes.js index 94380d7..735d60f 100644 --- a/src/shared/services/axes.js +++ b/src/shared/services/axes.js @@ -16,7 +16,6 @@ import { getCustomFieldsNamesForChangedCustomFields, getCustomFieldsNamesForChangedCustomFieldsWithDependent } from 'services/customFieldsRequirements'; -import * as CustomFieldValue from 'services/CustomFieldValue'; const findInRealCustomFields = (customFieldsNames, realCustomFields) => customFieldsNames.reduce((res, v) => { @@ -230,8 +229,7 @@ const getCustomFieldsForAxis = (config, axis, processes, entity, values = {}, op if (axis.type === 'customfield') { - if (axis.checkDependent && (targetValue === null || - CustomFieldValue.isEmptyCheckboxValue(targetValue))) { + if (axis.checkDependent) { return getCustomFieldsNamesForChangedCustomFieldsWithDependent([realTargetValue.name], entity.entityState ? entity.entityState : null, config, process, entity.entityType.name, diff --git a/src/shared/services/interrupt/store.js b/src/shared/services/interrupt/store.js index 7ea533a..06f5559 100644 --- a/src/shared/services/interrupt/store.js +++ b/src/shared/services/interrupt/store.js @@ -7,7 +7,7 @@ import {equalIgnoreCase, isStateRelated, lc} from 'utils'; import {createInterrupter} from './base'; import {createRequirementsByTasks} from './requirementsByTasks'; -import {getCustomFieldValue} from 'services/CustomFieldValue'; +import {getCustomFieldValue, checkDependentCustomFields} from 'services/CustomFieldValue'; const getEntityFromChange = (sourceChange, changeValues) => { @@ -22,12 +22,18 @@ const getEntityFromChange = (sourceChange, changeValues) => { if (equalIgnoreCase(v.name, 'customfields')) { - return res.concat(v.value.map((vv) => ({ - type: 'customfield', - customFieldName: vv.name, - targetValue: getCustomFieldValue(vv), - checkDependent: true - }))); + return res.concat(v.value.map((vv) => { + + const targetValue = getCustomFieldValue(vv); + + return { + type: 'customfield', + customFieldName: vv.name, + targetValue, + checkDependent: checkDependentCustomFields(targetValue) + }; + + })); } diff --git a/src/shared/services/loaders.js b/src/shared/services/loaders.js index 56b474d..9469efa 100644 --- a/src/shared/services/loaders.js +++ b/src/shared/services/loaders.js @@ -1,10 +1,11 @@ import {filter, memoize, pluck, reject, isString} from 'underscore'; +import {isEnabled} from 'tp3/api/featureToggling/v1'; import {isGeneral} from 'utils'; import store from 'services/store'; import store2 from 'services/store2'; -const systemCustomFieldsEnabled = () => window.tauFeatures && window.tauFeatures.systemCustomFields; +const systemCustomFieldsEnabled = () => isEnabled('systemCustomFields'); export const getCustomFields = memoize((processId, entityType) => { @@ -100,7 +101,21 @@ export const preloadParentEntityStates = (processes) => { return cache; - }) : []; + }) : store2.get('EntityState', { + where: 'parentEntityState == null and process.id == null', + select: '{id,name,isInitial,isFinal,isDefaultFinal,isPlanned,' + + 'workflow:{workflow.id,process:{id:null}},' + + 'entityType:{entityType.name},subEntityStates:subEntityStates.Select(' + + '{id,name,entityType:{entityType.name},isInitial,isFinal,isDefaultFinal,isPlanned})}' + }).then((entityStates) => { + + const cache = preloadParentEntityStates.cache = preloadParentEntityStates.cache || []; + + cache.null = entityStates; + + return cache; + + }); }; @@ -121,12 +136,19 @@ preloadParentEntityStates.getStates = (processId) => { export const loadSingleParentEntityState = memoize(({filter: whereFilter, field}, processId, entityType) => { - return store2.get('EntityState', { - where: `${field} == ${isString(whereFilter) ? `'${whereFilter}'` : whereFilter} ` + - `and workflow.process.id in [${processId}] and entityType.name == '${entityType.name}' ` + - `and parentEntityState != null`, - select: `{parentEntityState.${field}}` - }).then(([parentEntityState]) => parentEntityState && parentEntityState[field] || null); + return (processId !== null ? + store2.get('EntityState', { + where: `${field} == ${isString(whereFilter) ? `'${whereFilter}'` : whereFilter} ` + + `and workflow.process.id in [${processId}] and entityType.name == '${entityType.name}' ` + + `and parentEntityState != null`, + select: `{parentEntityState.${field}}` + }) : store2.get('EntityState', { + where: `${field} == ${isString(whereFilter) ? `'${whereFilter}'` : whereFilter} ` + + `and entityType.name=='${entityType.name}' ` + + `and parentEntityState != null`, + select: `{parentEntityState.${field}}` + })) + .then(([parentEntityState]) => parentEntityState && parentEntityState[field] || null); }, ({filter: whereFilter, field}, processId, entityType) => `${isString(whereFilter) ? `'${whereFilter}'` : whereFilter}:${field}:${processId}:${entityType.name}`); diff --git a/src/shared/tau/api/internal/store/types/index.js b/src/shared/tau/api/internal/store/types/index.js new file mode 100644 index 0000000..421de0d --- /dev/null +++ b/src/shared/tau/api/internal/store/types/index.js @@ -0,0 +1,81 @@ +// 4 tests + +export default { + getAll() { + + return [{ + isGeneral: true, + isAssignable: false, + isExtendable: false, + name: 'General' + }, { + isGeneral: true, + isAssignable: true, + isExtendable: false, + name: 'Bug' + }, { + isGeneral: true, + isAssignable: true, + isExtendable: false, + name: 'PortfolioEpic' + }, { + isGeneral: false, + isAssignable: false, + isExtendable: false, + name: 'User' + }, { + isGeneral: true, + isAssignable: false, + isExtendable: false, + name: 'Project' + }, { + isGeneral: true, + isAssignable: true, + isExtendable: true, + name: 'KeyResult' + }, { + isGeneral: true, + isAssignable: false, + isExtendable: true, + name: 'Objective' + }]; + + }, + + getAllGenerals() { + + return [{ + isGeneral: true, + isAssignable: false, + isExtendable: false, + name: 'General' + }, { + isGeneral: true, + isAssignable: true, + isExtendable: false, + name: 'Bug' + }, { + isGeneral: true, + isAssignable: true, + isExtendable: false, + name: 'PortfolioEpic' + }, { + isGeneral: true, + isAssignable: false, + isExtendable: false, + name: 'Project' + }, { + isGeneral: true, + isAssignable: true, + isExtendable: true, + name: 'KeyResult' + }, { + isGeneral: true, + isAssignable: false, + isExtendable: true, + name: 'Objective' + }]; + + } + +}; diff --git a/src/shared/tp3/api/featureToggling/v1/index.js b/src/shared/tp3/api/featureToggling/v1/index.js new file mode 100644 index 0000000..fc88929 --- /dev/null +++ b/src/shared/tp3/api/featureToggling/v1/index.js @@ -0,0 +1,3 @@ +// 4 tests + +export const isEnabled = (featureName) => window.tauFeatures && window.tauFeatures[featureName]; diff --git a/src/shared/utils/__tests__/index.js b/src/shared/utils/__tests__/index.js index 2129511..235d5fb 100644 --- a/src/shared/utils/__tests__/index.js +++ b/src/shared/utils/__tests__/index.js @@ -57,7 +57,8 @@ describe('utils', () => { expect(isGeneral({entityType: {name: 'general'}})).to.be.true; expect(isGeneral({entityType: {name: 'bug'}})).to.be.true; - expect(isGeneral({entityType: {name: 'portfolioepic'}})).to.be.true; + expect(isGeneral({entityType: {name: 'portfolioEpic'}})).to.be.true; + expect(isGeneral({entityType: {name: 'keyResult'}})).to.be.true; expect(isGeneral({entityType: {name: 'user'}})).to.be.false; }); @@ -65,10 +66,13 @@ describe('utils', () => { it('isAssignable()', () => { expect(isAssignable({entityType: {name: 'general'}})).to.be.false; + expect(isAssignable({entityType: {name: 'assignable'}})).to.be.true; expect(isAssignable({entityType: {name: 'bug'}})).to.be.true; - expect(isAssignable({entityType: {name: 'portfolioepic'}})).to.be.true; - expect(isAssignable({entityType: {name: 'user'}})).to.be.false; + expect(isAssignable({entityType: {name: 'portfolioEpic'}})).to.be.true; + expect(isAssignable({entityType: {name: 'keyResult'}})).to.be.true; expect(isAssignable({entityType: {name: 'user'}})).to.be.false; + expect(isAssignable({entityType: {name: 'project'}})).to.be.false; + expect(isAssignable({entityType: {name: 'objective'}})).to.be.false; }); diff --git a/src/shared/utils/index.js b/src/shared/utils/index.js index 42500a4..dc64d4a 100644 --- a/src/shared/utils/index.js +++ b/src/shared/utils/index.js @@ -1,4 +1,5 @@ import {isArray, unique} from 'underscore'; +import tauTypes from 'tau/api/internal/store/types'; export const lc = (s) => s.toLowerCase(); @@ -26,49 +27,16 @@ const shortcutValues = ['_initial', '_final', '_planned']; export const isShortcut = (shortcut) => inValues(shortcutValues, String(shortcut)); -const generalValues = [ - 'General', - 'Assignable', - 'InboundAssignable', - 'OutboundAssignable', - 'PortfolioEpic', - 'Epic', - 'Feature', - 'UserStory', - 'Task', - 'Bug', - 'TestPlan', - 'TestPlanRun', - 'Request', - 'Project', - 'Program', - 'Release', - 'Iteration', - 'TeamIteration', - 'Team', - 'TestCase', - 'Build', - 'Impediment' -]; +const generalValues = tauTypes.getAllGenerals().map((type) => type.name.toLowerCase()); -export const isGeneral = (entity) => inValues(generalValues, entity.entityType.name); - -const assignableValues = [ - 'Assignable', - 'InboundAssignable', - 'OutboundAssignable', - 'PortfolioEpic', - 'Epic', - 'Feature', - 'UserStory', - 'Task', - 'Bug', - 'TestPlan', - 'TestPlanRun', - 'Request' -]; +export const isGeneral = (entity) => inValues(generalValues, entity.entityType.name.toLowerCase()); + +const assignableValues = tauTypes.getAll() + .filter((t) => t.isAssignable === true) + .map((t) => t.name.toLowerCase()) + .concat(['assignable']); -export const isAssignable = (entity) => inValues(assignableValues, entity.entityType.name); +export const isAssignable = (entity) => inValues(assignableValues, entity.entityType.name.toLowerCase()); const requesterValues = [ 'Requester'