Business description
In our company, we need to improve our cost process. To make it easier and reduce the number of mistakes, we want to restrict the visibility of the fields and the available options. Moreover, in a few cases, more explanations are required, while in the other cases, they are optional.
Solution Brief
To resolve that situation, we will prepare a dynamic form using decision tables. Thanks to that, we could control the visibility or requirement of available fields on the form. Except for setting suitable configurations like screen schemes, we need to create three decision tables. The first one will relate existing Cost Types to available Cost Groups. The second will have similar functionality - it will connect chosen Cost Group to available Cost Categories. The last one will determine the obligatory set value. For each of them, we will use the Collect as Hit Policy to get all suitable entries.
Example Decision Table
Script
Note
In this script, we use some superclasses, which help us in code management. For more information, see Usage of base groovy classes and utilities page.
import com.atlassian.jira.issue.customfields.option.Option import com.onresolve.scriptrunner.runner.customisers.WithPlugin import eu.rivetgroup.atlas.jira.fields.api.ConcreteIssueFieldOperator import eu.rivetgroup.atlas.jira.fields.api.exceptions.ExceptionHandlingMode import global.fields.CustomFieldCode import global.fields.FieldUtils @WithPlugin([ "eu.rivetgroup.atlas.rvg-jira-app-base-plugin", "eu.rivetgroup.atlas.rvg-jira-decision-tables-plugin" ]) class TestCostFormBehaviors extends BaseDecisionTableBehaviors { private ConcreteIssueFieldOperator costTypeFieldOper private ConcreteIssueFieldOperator costGroupFieldOper private ConcreteIssueFieldOperator costCategoryFieldOper private ConcreteIssueFieldOperator amountFieldOper private ConcreteIssueFieldOperator customCostCategoryFieldOper private ConcreteIssueFieldOperator costReasonFieldOper @Override protected void initVariables() { super.initVariables() issueFieldOperator.config.exceptionHandlingStrategy.valueActiveCheckStrategy .setCategoryCheckOn(true) .setSourceHandlingMode(ExceptionHandlingMode.IGNORE) this.costTypeFieldOper = issueFieldOperator.forField(FieldUtils.getCustomField(CustomFieldCode.COST_TYPE)) this.costGroupFieldOper = issueFieldOperator.forField(FieldUtils.getCustomField(CustomFieldCode.COST_GROUP)) this.costCategoryFieldOper = issueFieldOperator.forField(FieldUtils.getCustomField(CustomFieldCode.COST_CATEGORY)) this.amountFieldOper = issueFieldOperator.forField(FieldUtils.getCustomField(CustomFieldCode.AMOUNT)) this.customCostCategoryFieldOper = issueFieldOperator.forField(FieldUtils.getCustomField(CustomFieldCode.CUSTOM_COST_CATEGORY)) this.costReasonFieldOper = issueFieldOperator.forField(FieldUtils.getCustomField(CustomFieldCode.COST_REASON)) setIgnoreInitialFieldBehaviorCallMode([ this.costTypeFieldOper.field.id, this.costGroupFieldOper.field.id, this.costCategoryFieldOper.field.id, this.amountFieldOper.field.id ]) } @Override Object runInternal() { if (formInit) { processCostGroupOptions() processCostCategoryOptions() processCustomCostCategoryAndReason() } else if (fieldChanged == costTypeFieldOper.field.id) { processCostGroupOptions() processCostCategoryOptions() processCustomCostCategoryAndReason() } else if (fieldChanged == costGroupFieldOper.field.id) { processCostCategoryOptions() processCustomCostCategoryAndReason() } else if (fieldChanged == costCategoryFieldOper.field.id) { processCustomCostCategoryAndReason() } else if (fieldChanged == amountFieldOper.field.id) { processCustomCostCategoryAndReason() } return null } private void processCostGroupOptions() { def costTypeVal = getOptionFromFormValue(getFieldById(costTypeFieldOper.field.id)) def allowedOptions = new LinkedHashSet<Option>() if (costTypeVal != null) { def dtCostCategoryResult = decisionTableManager.getDMNDecisionTableOperations( "costCategories", "costGroup" ).executeQuery([ "costType": costTypeFieldOper.getBusinessKeyFromObjectValue(costTypeVal).first() ]) allowedOptions.addAll( costGroupFieldOper.getValueByBusinessKey( dtCostCategoryResult.multiNonNull().collect({elem -> elem.get("costGroup")}) ).castElem(Option.class).multiNonNull() ) } new OptionFieldBehaviorOperator(this, costGroupFieldOper) .setRequired(true) .setSelectTheOnlyOption(true) .setHidden(costTypeVal == null) .setForceShowWhenHasValue(true) .setAllowedOptions(allowedOptions) .processField() } private void processCostCategoryOptions() { def costGroupVal = getOptionFromFormValue(getFieldById(costGroupFieldOper.field.id)) def allowedOptions = new LinkedHashSet<Option>() if (costGroupVal != null) { def dtCostCategoryResult = decisionTableManager.getDMNDecisionTableOperations( "costCategories", "costCategory" ).executeQuery([ "costGroup": costGroupFieldOper.getBusinessKeyFromObjectValue(costGroupVal).first() ]) allowedOptions.addAll( costCategoryFieldOper.getValueByBusinessKey( dtCostCategoryResult.multiNonNull().collect({it.get("costCategory")}) ).castElem(Option.class).multiNonNull() ) } new OptionFieldBehaviorOperator(this, costCategoryFieldOper) .setSelectTheOnlyOption(true) .setRequired(true) .setAllowNoneOption(true) .setHidden(costGroupVal == null) .setAllowedOptions(allowedOptions) .setForceShowWhenHasValue(true) .processField() } private void processCustomCostCategoryAndReason() { def costTypeVal = getOptionFromFormValue(getFieldById(costTypeFieldOper.field.id)) def costGroupVal = getOptionFromFormValue(getFieldById(costGroupFieldOper.field.id)) def costCategoryVal = getOptionFromFormValue(getFieldById(costCategoryFieldOper.field.id)) def amountVal = getFieldById(amountFieldOper.field.id).value def dtCostCategoryResult = decisionTableManager.getDMNDecisionTableOperations( "costCategories", "costCategoryFormRules" ).executeQuery([ "costType": costTypeFieldOper.getBusinessKeyFromObjectValue(costTypeVal).first(), "costGroup": costGroupFieldOper.getBusinessKeyFromObjectValue(costGroupVal).first(), "costCategory": costCategoryFieldOper.getBusinessKeyFromObjectValue(costCategoryVal).first(), "amount": amountVal ]) def isCustomCat = dtCostCategoryResult.multiNonNull().collect({it.get("customCategory")}) .find{(Boolean.TRUE == it)} != null def firstEntry = dtCostCategoryResult.first(); def requireCostReason = firstEntry != null && Boolean.TRUE == firstEntry.get("requiresCostReason") def customCostCategoryBehaviorOper = new BasicFieldBehaviorOperator(this, customCostCategoryFieldOper.field.id) .setRequired(isCustomCat) .setHidden(!isCustomCat) .setForceShowWhenHasValue(true) if (!formInit && !isCustomCat) { customCostCategoryBehaviorOper.setNewObjectValue(null) } customCostCategoryBehaviorOper.processField() getFieldById(costReasonFieldOper.field.id) .setRequired(requireCostReason) } }
Explanation
@WithPlugin([ "eu.rivetgroup.atlas.rvg-jira-app-base-plugin", "eu.rivetgroup.atlas.rvg-jira-decision-tables-plugin" ]) class TestCostFormBehaviors extends BaseDecisionTableBehaviors
Firstly, there is an import of required functionalities from add-ons and class definitions. Here we extend another base class - BaseDecisionTableBehaviors
Which supports behavior in real-time.
issueFieldOperator.config.exceptionHandlingStrategy.valueActiveCheckStrategy .setCategoryCheckOn(true) .setSourceHandlingMode(ExceptionHandlingMode.IGNORE)
Business requirements tell us, we need to reject some of the available options. To do that, we turn on the active check of values but disable throwing and reporting related to its exceptions because we want to remove unnecessary options silently - it is purposeful action.
this.costTypeFieldOper = issueFieldOperator.forField(FieldUtils.getCustomField(CustomFieldCode.COST_TYPE)) this.costGroupFieldOper = issueFieldOperator.forField(FieldUtils.getCustomField(CustomFieldCode.COST_GROUP)) this.costCategoryFieldOper = issueFieldOperator.forField(FieldUtils.getCustomField(CustomFieldCode.COST_CATEGORY)) this.amountFieldOper = issueFieldOperator.forField(FieldUtils.getCustomField(CustomFieldCode.AMOUNT)) this.customCostCategoryFieldOper = issueFieldOperator.forField(FieldUtils.getCustomField(CustomFieldCode.CUSTOM_COST_CATEGORY)) this.costReasonFieldOper = issueFieldOperator.forField(FieldUtils.getCustomField(CustomFieldCode.COST_REASON))
References to related fields. We strongly suggest excluding such relations to the external file, which will help you get a higher quality.
Thanks to them, we can get possible operations for all fields needed to call decision tables and process the result. Field operations allow taking no care about the particular field type in many scenarios - code is much less complex and easier to read.
setIgnoreInitialFieldBehaviorCallMode([ this.costTypeFieldOper.field.id, this.costGroupFieldOper.field.id, this.costCategoryFieldOper.field.id, this.amountFieldOper.field.id ])
Used Scriptrunner handles all changes of the fields. We don't need to do additional operations during initialize form, which are also handled by the tool. To resolve that problem, we ignore initial calls on purpose and concentrate on the next changes.
if (formInit) { processCostGroupOptions() processCostCategoryOptions() processCustomCostCategoryAndReason() } else if (fieldChanged == costTypeFieldOper.field.id) { processCostGroupOptions() processCostCategoryOptions() processCustomCostCategoryAndReason() } else if (fieldChanged == costGroupFieldOper.field.id) { processCostCategoryOptions() processCustomCostCategoryAndReason() } else if (fieldChanged == costCategoryFieldOper.field.id) { processCustomCostCategoryAndReason() } else if (fieldChanged == amountFieldOper.field.id) { processCustomCostCategoryAndReason() }
On any change, we need to react. As you could see above, we have a specific field structure with dependencies. Each function serves another field, so we have to execute another sequence depending on the change reason.
def allowedOptions = new LinkedHashSet<Option>() if (costTypeVal != null) { def dtCostCategoryResult = decisionTableManager.getDMNDecisionTableOperations( "costCategories", "costGroup" ).executeQuery([ "costType": costTypeFieldOper.getBusinessKeyFromObjectValue(costTypeVal).first() ]) allowedOptions.addAll( costGroupFieldOper.getValueByBusinessKey( dtCostCategoryResult.multiNonNull().collect({elem -> elem.get("costGroup")}) ).castElem(Option.class).multiNonNull() ) }
Due to getting a list of options, we need to receive not only one possibility. To allowed options, we add all of the found by the decision table engine.
new OptionFieldBehaviorOperator(this, costGroupFieldOper) .setRequired(true) .setSelectTheOnlyOption(true) .setHidden(costTypeVal == null) .setForceShowWhenHasValue(true) .setAllowedOptions(allowedOptions) .processField()
In the end, we need to make planned changes in form. To do that, we prepare a new operator and process it. We set a field as required during creation, block multi-choice possibility, add calculated options, or set visibility based on other filled values.
private void processCostCategoryOptions() { def costGroupVal = getOptionFromFormValue(getFieldById(costGroupFieldOper.field.id)) def allowedOptions = new LinkedHashSet<Option>() if (costGroupVal != null) { def dtCostCategoryResult = decisionTableManager.getDMNDecisionTableOperations( "costCategories", "costCategory" ).executeQuery([ "costGroup": costGroupFieldOper.getBusinessKeyFromObjectValue(costGroupVal).first() ]) allowedOptions.addAll( costCategoryFieldOper.getValueByBusinessKey(dtCostCategoryResult.multiNonNull().collect({it.get("costCategory")}) ).castElem(Option.class).multiNonNull() ) } new OptionFieldBehaviorOperator(this, costCategoryFieldOper) .setSelectTheOnlyOption(true) .setRequired(true) .setAllowNoneOption(true) .setHidden(costGroupVal == null) .setAllowedOptions(allowedOptions) .setForceShowWhenHasValue(true) .processField() }
Similarly, we process changes in another field. The only difference is a relation - to a suitable decision table or "parent" field.
def costTypeVal = getOptionFromFormValue(getFieldById(costTypeFieldOper.field.id)) def costGroupVal = getOptionFromFormValue(getFieldById(costGroupFieldOper.field.id)) def costCategoryVal = getOptionFromFormValue(getFieldById(costCategoryFieldOper.field.id)) def amountVal = getFieldById(amountFieldOper.field.id).value def dtCostCategoryResult = decisionTableManager.getDMNDecisionTableOperations( "costCategories", "costCategoryFormRules" ).executeQuery([ "costType": costTypeFieldOper.getBusinessKeyFromObjectValue(costTypeVal).first(), "costGroup": costGroupFieldOper.getBusinessKeyFromObjectValue(costGroupVal).first(), "costCategory": costCategoryFieldOper.getBusinessKeyFromObjectValue(costCategoryVal).first(), "amount": amountVal ])
Service the last decision table is more similar to the previous examples. We get chosen values from fields and use them in queries to decide about fields obligatory.
def isCustomCat = dtCostCategoryResult.multiNonNull().collect({it.get("customCategory")}) .find{(Boolean.TRUE == it)} != null def firstEntry = dtCostCategoryResult.first(); def requireCostReason = firstEntry != null && Boolean.TRUE == firstEntry.get("requiresCostReason") def customCostCategoryBehaviorOper = new BasicFieldBehaviorOperator(this, customCostCategoryFieldOper.field.id) .setRequired(isCustomCat) .setHidden(!isCustomCat) .setForceShowWhenHasValue(true) if (!formInit && !isCustomCat) { customCostCategoryBehaviorOper.setNewObjectValue(null) } customCostCategoryBehaviorOper.processField() getFieldById(costReasonFieldOper.field.id).setRequired(requireCostReason)
Setting requirements for the unmentioned fields is also easy. Base on the result decision tables job, we can know if the chosen fields should be required. As you see, we use a comparison between the logical value and Boolean.TRUE
- thanks to that, we handle null values. Meanwhile, we also set to operator null value - that is how to clear custom category when some parent fields were changed.
Attachments: