Page tree
Skip to end of metadata
Go to start of metadata

Business description

In our company we need to improve our cost process. To make it easier and reduce number of mistakes, we want to restrict visibility of the fields and the available options. Moreover, in a few cases there are more explains required, when 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 form. Except setting suitable configuration 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 determinate obligatory of set value . For each of them we will use 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 import of required functionalities from add-ons and class definition. Here we extend another base class - BaseDecisionTableBehaviors, which support behavior in real time.

issueFieldOperator.config.exceptionHandlingStrategy.valueActiveCheckStrategy
	.setCategoryCheckOn(true)
	.setSourceHandlingMode(ExceptionHandlingMode.IGNORE)

Business requirements tell us, we need to reject some of available options. To do that, we turn on the active check of values but disable throwing and reporting related to it exceptions, because we just want to remove unnecessary options silently - it is a purpose 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 to exclude such relations to external file, what 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 allows to 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 handle all changes of the fields. We don't need to do additional operations during initialize form, which are also handled by tool. To resolve that problem, we ignore initial calls on purpose and concentrate on 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 an 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 depends on change reason, we have to execute other sequence.

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 decision table engine.

new OptionFieldBehaviorOperator(this, costGroupFieldOper)
	.setRequired(true)
	.setSelectTheOnlyOption(true)
	.setHidden(costTypeVal == null)
	.setForceShowWhenHasValue(true)
	.setAllowedOptions(allowedOptions)
	.processField()

At the end we need to make planned changes in form. To do that, we prepare new operator and process it. During creation we set field as a required, block multi-choice possibility, add calculated options or set visibility based on other filed value.

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()
}

In similar way we process changes on other field. Only difference is a relations - to 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 query 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 requirement of unmentioned field is also easy. Base on result decision tables job we can know if the chosen fields should be required. As you see we use comparison between logical value and Boolean.TRUE - thanks to that we handle null values. Meanwhile we also set to operator null value - that is the way to clear custom category when some parent fields were changed.

Attachements:

TestCostFormBehaviors.groovy

costCategories.dmn

  • No labels