diff --git a/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/MapOptionsField.groovy b/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/MapOptionsField.groovy index 1a26e8db41..f0acc8e978 100644 --- a/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/MapOptionsField.groovy +++ b/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/MapOptionsField.groovy @@ -40,4 +40,8 @@ abstract class MapOptionsField extends Field { boolean isDynamic() { return this.optionsExpression != null } + + T getOption(String key) { + return this.options.get(key) + } } diff --git a/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy b/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy index 26791594b3..3944696783 100644 --- a/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy +++ b/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy @@ -54,6 +54,7 @@ import com.netgrif.application.engine.startup.FilterRunner import com.netgrif.application.engine.startup.ImportHelper import com.netgrif.application.engine.utils.FullPageRequest import com.netgrif.application.engine.workflow.domain.Case +import com.netgrif.application.engine.workflow.domain.DataField import com.netgrif.application.engine.workflow.domain.QCase import com.netgrif.application.engine.workflow.domain.QTask import com.netgrif.application.engine.workflow.domain.Task @@ -62,6 +63,8 @@ import com.netgrif.application.engine.workflow.domain.eventoutcomes.caseoutcomes import com.netgrif.application.engine.workflow.domain.eventoutcomes.dataoutcomes.GetDataEventOutcome import com.netgrif.application.engine.workflow.domain.eventoutcomes.dataoutcomes.SetDataEventOutcome import com.netgrif.application.engine.workflow.domain.eventoutcomes.taskoutcomes.AssignTaskEventOutcome +import com.netgrif.application.engine.workflow.domain.eventoutcomes.taskoutcomes.CancelTaskEventOutcome +import com.netgrif.application.engine.workflow.domain.eventoutcomes.taskoutcomes.FinishTaskEventOutcome import com.netgrif.application.engine.workflow.domain.eventoutcomes.taskoutcomes.TaskEventOutcome import com.netgrif.application.engine.workflow.service.FileFieldInputStream import com.netgrif.application.engine.workflow.service.TaskService @@ -915,11 +918,194 @@ class ActionDelegate { return result.content } + /** + * Finds cases referenced by a field in its value. + * + * Use this overload when working on a case from the current action context. For working with fields from out of the + * current action context see other overloads of this action. + * + *

If the field value is {@code null}, this method returns an empty list.

+ *

If the value cannot be converted to case IDs, this method returns an empty list.

+ * + * @param caseRef field whose value contains case IDs, may be of types + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#CASE_REF}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#MULTICHOICE}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#MULTICHOICE_MAP}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#ENUMERATION_MAP}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#STRING_COLLECTION}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#TEXT}, + * @return list of matching cases, or an empty list when the field value is {@code null} + * @see ActionDelegate#findCases(DataField) + * @see ActionDelegate#findCases(List) + * @see ActionDelegate#findCases(Closure) + * @see ActionDelegate#findCases(Closure, Pageable) + */ + List findCases(Field caseRef) { + if(caseRef.value == null) { + log.error("Value of field with id [${caseRef.importId}] is null, returning empty list.") + return [] + } + try { + return this.findCases([caseRef.value].flatten() as List) + } catch (ClassCastException e) { + log.error("Method cannot be used with field with id [${caseRef.importId}].", e) + return [] + } + } + + /** + * Finds cases referenced by a dataField in its value. + * + * Use this overload when working on a case not from the current action context. For working with fields from the current + * action context see other overloads of this action. + * + *

If the field value is {@code null}, this method returns an empty list.

+ *

If the value cannot be converted to case IDs, this method returns an empty list.

+ * + * @param caseRef field whose value contains case IDs, may be of types + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#CASE_REF}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#MULTICHOICE}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#MULTICHOICE_MAP}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#ENUMERATION_MAP}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#STRING_COLLECTION}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#TEXT}, + * @return list of matching cases, or an empty list when the field value is {@code null} + * @see ActionDelegate#findCases(Field) + * @see ActionDelegate#findCases(List) + * @see ActionDelegate#findCases(Closure) + * @see ActionDelegate#findCases(Closure, Pageable) + */ + List findCases(DataField caseRef) { + if(caseRef.value == null) { + log.error("Value of field is null, returning empty list.") + return [] + } + try { + return this.findCases([caseRef.value].flatten() as List) + } catch (ClassCastException e) { + log.error("Method cannot be used with field.", e) + return [] + } + } + + + /** + * Finds cases by their MongoDB IDs. + * + * @param mongoIds list of case IDs + * @return list of matching cases, or an empty list when the input is {@code null} or {@code empty} + * @see ActionDelegate#findCases(Field) + * @see ActionDelegate#findCases(DataField) + * @see ActionDelegate#findCases(Closure) + * @see ActionDelegate#findCases(Closure, Pageable) + */ + List findCases(List mongoIds) { + if(mongoIds == null || mongoIds.empty) { + log.warn("Null value detected, returning empty list.") + return [] + } + return workflowService.findAllById(mongoIds) + } + Case findCase(Closure predicate) { QCase qCase = new QCase("case") return workflowService.searchOne(predicate(qCase)) } + + + /** + * Finds the first case referenced by a field in its value. + * + * Use this overload when working on a case from current action context. For working with fields from out of the + * current action context see other overloads of this action. + * + *

If the field value is {@code null}, this method returns {@code null}.

+ *

If the value cannot be converted to case IDs, this method returns {@code null}.

+ * + * @param caseRef field whose value contains case IDs, may be of types + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#CASE_REF}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#MULTICHOICE}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#MULTICHOICE_MAP}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#ENUMERATION_MAP}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#STRING_COLLECTION}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#TEXT}, + * @return referenced case, or {@code null} when the field value is invalid + * @see ActionDelegate#findCase(DataField) + * @see ActionDelegate#findCase(String) + * @see ActionDelegate#findCase(Closure) + */ + Case findCase(Field caseRef) { + if(caseRef.value == null) { + log.error("Value of field with id [${caseRef.importId}] is null, returning null.") + return null + } + try { + List castValue = [caseRef.value].flatten() as List + if(castValue.size() == 0) { + log.error("Value of field with id [${caseRef.importId}] does not contain at least one element, returning null.") + return null + } + return this.findCase(castValue[0]) + } catch (ClassCastException e) { + log.error("Method cannot be used with field with id [${caseRef.importId}].", e) + return null + } + } + + + /** + * Finds the first case referenced by a dataField in its value. + * + * Use this overload when working on a case from out of current action context. For working with fields from the current + * action context see other overloads of this action. + * + *

If the field value is {@code null}, this method returns {@code null}.

+ *

If the value cannot be converted to case IDs, this method returns {@code null}.

+ * + * @param caseRef field whose value contains case IDs, may be of types + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#CASE_REF}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#MULTICHOICE}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#MULTICHOICE_MAP}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#ENUMERATION_MAP}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#STRING_COLLECTION}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#TEXT}, + * @return referenced case, or {@code null} when the dataField value is invalid + * @see ActionDelegate#findCase(Field) + * @see ActionDelegate#findCase(String) + * @see ActionDelegate#findCase(Closure) + */ + Case findCase(DataField caseRef) { + if(caseRef.value == null) { + log.error("Value of field is null, returning null.") + return null + } + try { + List castValue = [caseRef.value].flatten() as List + if(castValue.size() == 0) { + log.error("Value of field does not contain at least one element, returning null.") + return null + } + return this.findCase(castValue[0]) + } catch (ClassCastException e) { + log.error("Method cannot be used with field.", e) + return null + } + } + + /** + * Finds case by its MongoDB ID. + * + * @param mongoId case IDs + * @return resulting case + * @see ActionDelegate#findCase(Field) + * @see ActionDelegate#findCase(DataField) + * @see ActionDelegate#findCase(Closure) + */ + Case findCase(String mongoId) { + return workflowService.findOne(mongoId) + } + Case createCase(String identifier, String title = null, String color = "", IUser author = userService.loggedOrSystem, Locale locale = LocaleContextHolder.getLocale(), Map params = [:]) { return workflowService.createCaseByIdentifier(identifier, title, color, author.transformToLoggedUser(), locale, params).getCase() } @@ -930,19 +1116,70 @@ class ActionDelegate { return outcome.getCase() } + /** + * Deletes a case by its MongoDB ID. + * + * @param mongoId case identifier + * @return deleted case, or {@code null} when the input is {@code null} + */ + Case deleteCase(String mongoId) { + if(mongoId == null){ + log.warn("Null value detected, returning null.") + return null + } + return this.deleteCase(workflowService.findOne(mongoId)) + } + + /** + * Deletes the provided case. + * + * @param toDelete case to delete + * @return deleted case, or {@code null} when the input is {@code null} + */ + Case deleteCase(Case toDelete) { + if(toDelete == null){ + log.warn("Null value detected, returning null.") + return null + } + return workflowService.deleteCase(toDelete).case + } + Task assignTask(String transitionId, Case aCase = useCase, IUser user = userService.loggedOrSystem, Map params = [:]) { String taskId = getTaskId(transitionId, aCase) - AssignTaskEventOutcome outcome = taskService.assignTask(user.transformToLoggedUser(), taskId, params) - this.outcomes.add(outcome) - return outcome.getTask() + return addTaskOutcomeAndReturnTask(taskService.assignTask(user.transformToLoggedUser(), taskId, params)) } Task assignTask(Task task, IUser user = userService.loggedOrSystem, Map params = [:]) { return addTaskOutcomeAndReturnTask(taskService.assignTask(task, user, params)) } - void assignTasks(List tasks, IUser assignee = userService.loggedOrSystem, Map params = [:]) { - this.outcomes.addAll(taskService.assignTasks(tasks, assignee, params)) + /** + * Assigns tasks for all transitions in the provided list and returns the assigned tasks. + * + * @param transitionIds transition identifiers whose tasks should be assigned + * @param aCase case used to resolve the tasks, defaults to the current case + * @param user user to assign the tasks to, defaults to the logged or system user + * @param params additional parameters + * @return assigned tasks + */ + List assignTasksByTransitions(List transitionIds, Case aCase = useCase, IUser user = userService.loggedOrSystem, Map params = [:]) { + List taskIds = getTaskIds(transitionIds, aCase) + List tasks = taskService.findAllById(taskIds) + return assignTasks(tasks, user, params) + } + + /** + * Assigns the provided tasks and returns the assigned tasks. + * + * @param tasks tasks to assign + * @param assignee user to assign the tasks to, defaults to the logged or system user + * @param params additional parameters + * @return assigned tasks + */ + List assignTasks(List tasks, IUser assignee = userService.loggedOrSystem, Map params = [:]) { + List outcomes = taskService.assignTasks(tasks, assignee, params) + this.outcomes.addAll(outcomes) + return outcomes.collect { it.task } } Task cancelTask(String transitionId, Case aCase = useCase, IUser user = userService.loggedOrSystem, Map params = [:]) { @@ -954,8 +1191,34 @@ class ActionDelegate { return addTaskOutcomeAndReturnTask(taskService.cancelTask(task, user, params)) } - void cancelTasks(List tasks, IUser user = userService.loggedOrSystem, Map params = [:]) { - this.outcomes.addAll(taskService.cancelTasks(tasks, user, params)) + + /** + * Cancels tasks for all transitions in the provided list and returns the canceled tasks. + * + * @param transitionIds transition identifiers whose tasks should be canceled + * @param aCase case used to resolve the tasks, defaults to the current case + * @param user user performing the cancellation, defaults to the logged or system user + * @param params additional parameters + * @return canceled tasks + */ + List cancelTasksByTransitions(List transitionIds, Case aCase = useCase, IUser user = userService.loggedOrSystem, Map params = [:]) { + List taskIds = getTaskIds(transitionIds, aCase) + List tasks = taskService.findAllById(taskIds) + return cancelTasks(tasks, user, params) + } + + /** + * Cancels the provided tasks and returns the canceled tasks. + * + * @param tasks tasks to cancel + * @param user user performing the cancellation, defaults to the logged or system user + * @param params additional parameters + * @return canceled tasks + */ + List cancelTasks(List tasks, IUser user = userService.loggedOrSystem, Map params = [:]) { + List outcomes = taskService.cancelTasks(tasks, user, params) + this.outcomes.addAll(outcomes) + return outcomes.collect { it.task } } private Task addTaskOutcomeAndReturnTask(TaskEventOutcome outcome) { @@ -963,17 +1226,42 @@ class ActionDelegate { return outcome.getTask() } - void finishTask(String transitionId, Case aCase = useCase, IUser user = userService.loggedOrSystem, Map params = [:]) { + Task finishTask(String transitionId, Case aCase = useCase, IUser user = userService.loggedOrSystem, Map params = [:]) { String taskId = getTaskId(transitionId, aCase) - addTaskOutcomeAndReturnTask(taskService.finishTask(user.transformToLoggedUser(), taskId, params)) + return addTaskOutcomeAndReturnTask(taskService.finishTask(user.transformToLoggedUser(), taskId, params)) + } + + Task finishTask(Task task, IUser user = userService.loggedOrSystem, Map params = [:]) { + return addTaskOutcomeAndReturnTask(taskService.finishTask(task, user, params)) } - void finishTask(Task task, IUser user = userService.loggedOrSystem, Map params = [:]) { - addTaskOutcomeAndReturnTask(taskService.finishTask(task, user, params)) + /** + * Finishes tasks for all transitions in the provided list and returns the finished tasks. + * + * @param transitionIds transition identifiers whose tasks should be finished + * @param aCase case used to resolve the tasks, defaults to the current case + * @param user user performing the finish operation, defaults to the logged or system user + * @param params additional parameters + * @return finished tasks + */ + List finishTasksByTransitions(List transitionIds, Case aCase = useCase, IUser user = userService.loggedOrSystem, Map params = [:]) { + List taskIds = getTaskIds(transitionIds, aCase) + List tasks = taskService.findAllById(taskIds) + return finishTasks(tasks, user, params) } - void finishTasks(List tasks, IUser finisher = userService.loggedOrSystem, Map params = [:]) { - this.outcomes.addAll(taskService.finishTasks(tasks, finisher, params)) + /** + * Finishes the provided tasks and returns the finished tasks. + * + * @param tasks tasks to finish + * @param finisher user performing the finish operation, defaults to the logged or system user + * @param params additional parameters + * @return finished tasks + */ + List finishTasks(List tasks, IUser finisher = userService.loggedOrSystem, Map params = [:]) { + List outcomes = taskService.finishTasks(tasks, finisher, params) + this.outcomes.addAll(outcomes) + return outcomes.collect { it.task } } List findTasks(Closure predicate) { @@ -988,13 +1276,262 @@ class ActionDelegate { return result.content } + /** + * Finds tasks referenced by a field in its value. + * + * Use this overload when working on a case from current action context. For working with fields from out of the + * current action context see other overloads of this action. + * + *

If the field value is {@code null}, this method returns an empty list.

+ *

If the value cannot be converted to task IDs, this method returns {@code null}.

+ * + * @param taskRef field whose value contains task IDs + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#TASK_REF}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#MULTICHOICE}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#MULTICHOICE_MAP}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#ENUMERATION_MAP}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#STRING_COLLECTION}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#TEXT}, + * @return list of matching tasks, or an empty list when the field value is {@code null} + * @see ActionDelegate#findTasks(DataField) + * @see ActionDelegate#findTasks(List) + */ + List findTasks(Field taskRef) { + if(taskRef.value == null) { + log.error("Value of field with id [${taskRef.importId}] is null, returning empty list.") + return [] + } + try { + return this.findTasks([taskRef.value].flatten() as List) + } catch (ClassCastException e) { + log.error("Method cannot be used with field with id [${taskRef.importId}].", e) + return null + } + } + + /** + * Finds tasks referenced by a dataField in its value. + * + * Use this overload when working on a case not from the current action context. For working with fields from out of the + * current action context see other overloads of this action. + * + *

If the field value is {@code null}, this method returns an empty list.

+ *

If the value cannot be converted to task IDs, this method returns {@code null}.

+ * + * @param taskRef field whose value contains task IDs + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#TASK_REF}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#MULTICHOICE}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#MULTICHOICE_MAP}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#ENUMERATION_MAP}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#STRING_COLLECTION}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#TEXT}, + * @return list of matching tasks, or an empty list when the field value is {@code null} + * @see ActionDelegate#findTasks(Field) + * @see ActionDelegate#findTasks(List) + */ + List findTasks(DataField taskRef) { + if(taskRef.value == null) { + log.error("Value of field is null, returning empty list.") + return [] + } + try { + return this.findTasks([taskRef.value].flatten() as List) + } catch (ClassCastException e) { + log.error("Method cannot be used with field.", e) + return null + } + } + + /** + * Finds tasks by their MongoDB IDs. + * + * @param mongoIds task identifiers + * @return list of matching tasks, or an empty list when the input is {@code null} or {@code empty} + * @see ActionDelegate#findTasks(Field) + * @see ActionDelegate#findTasks(DataField) + */ + List findTasks(List mongoIds) { + if(mongoIds == null || mongoIds.empty) { + log.warn("Null value detected, returning empty list.") + return [] + } + return taskService.findAllById(mongoIds) + } + Task findTask(Closure predicate) { QTask qTask = new QTask("task") return taskService.searchOne(predicate(qTask)) } Task findTask(String mongoId) { - return taskService.searchOne(QTask.task._id.eq(new ObjectId(mongoId))) + return taskService.findOne(mongoId) + } + + /** + * Finds the first task referenced by a field in its value. + * + * Use this overload when working on a case from the current action context. For working with fields from out of the + * current action context see other overloads of this action. + * + *

If the field value is {@code null}, this method returns {@code null}.

+ *

If the field contains no value or the value cannot be converted to a task ID, this method returns + * {@code null}.

+ * + * @param taskRef field whose value contains a task ID + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#TASK_REF}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#MULTICHOICE}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#MULTICHOICE_MAP}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#ENUMERATION_MAP}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#STRING_COLLECTION}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#TEXT}, + * @return referenced task, or {@code null} when the field value is invalid + * @see ActionDelegate#findTask(DataField) + * @see ActionDelegate#findTask(String) + * @see ActionDelegate#findTask(Closure) + */ + Task findTask(Field taskRef) { + if(taskRef.value == null) { + log.error("Value of field with id [${taskRef.importId}] is null, returning null") + return null + } + try { + List castValue = [taskRef.value].flatten() as List + if(castValue.size() == 0) { + log.error("Value of field with id [${taskRef.importId}] does not contain at least one element, returning null.") + return null + } + return this.findTask(castValue[0]) + } catch (ClassCastException e) { + log.error("Method cannot be used with field with id [${taskRef.importId}].", e) + return null + } + } + + /** + * Finds the first task referenced by a dataField in its value. + * + * Use this overload when working on a case not from the current action context. For working with fields from out of the + * current action context see other overloads of this action. + * + *

If the field value is {@code null}, this method returns {@code null}.

+ *

If the field contains no value or the value cannot be converted to a task ID, this method returns + * {@code null}.

+ * + * @param taskRef field whose value contains a task ID + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#TASK_REF}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#MULTICHOICE}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#MULTICHOICE_MAP}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#ENUMERATION_MAP}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#STRING_COLLECTION}, + * {@link com.netgrif.application.engine.petrinet.domain.dataset.FieldType#TEXT}, + * @return referenced task, or {@code null} when the field value is invalid + * @see ActionDelegate#findTask(Field) + * @see ActionDelegate#findTask(String) + * @see ActionDelegate#findTask(Closure) + */ + Task findTask(DataField taskRef) { + if(taskRef.value == null) { + log.error("Value of field is null, returning null") + return null + } + try { + List castValue = [taskRef.value].flatten() as List + if(castValue.size() == 0) { + log.error("Value of field does not contain at least one element, returning null.") + return null + } + return this.findTask(castValue[0]) + } catch (ClassCastException e) { + log.error("Method cannot be used with field.", e) + return null + } + } + + /** + * Finds a Petri net by its MongoDB ID. + * + * @param mongoId Petri net identifier + * @return matching Petri net, or {@code null} when the input is {@code null} + */ + PetriNet findPetriNet(String mongoId) { + if(mongoId == null){ + log.warn("Null value detected, returning null.") + return null + } + return petriNetService.getPetriNet(mongoId) + } + + /** + * Finds a Petri net by its {@link ObjectId}. + * + * @param objectId Petri net object identifier + * @return matching Petri net, or {@code null} when the input is {@code null} + */ + PetriNet findPetriNet(ObjectId objectId) { + if(objectId == null){ + log.warn("Null value detected, returning null.") + return null + } + return petriNetService.get(objectId) + } + + /** + * Finds Petri nets by their MongoDB IDs. + * + * @param mongoIds list of Petri net identifiers + * @return matching Petri nets, or an empty list when the input is {@code null} or {@code empty} + */ + List findPetriNets(List mongoIds) { + if(mongoIds == null || mongoIds.empty){ + log.warn("Null value detected, returning empty list.") + return [] + } + return petriNetService.findAllById(mongoIds) + } + + /** + * Finds Petri nets by their {@link ObjectId} values. + * + * @param objectIds list of Petri net object identifiers + * @return matching Petri nets, or an empty list when the input is {@code null} or {@code empty} + */ + List findPetriNetsByObjectIds(List objectIds) { + if(objectIds == null || objectIds.empty){ + log.warn("Null value detected, returning empty list.") + return [] + } + return petriNetService.get(objectIds as Collection) + } + + /** + * Finds a Petri net by its identifier and optional version. + * + * If the version is not provided, the newest available version is returned. + * + * @param identifier Petri net identifier + * @param version requested version, or {@code null} for the newest version + * @return matching Petri net, or {@code null} when the identifier is {@code null} + */ + PetriNet findPetriNetByIdentifier(String identifier, Version version = null) { + if(identifier == null) { + log.warn("Null identifier value detected, returning null.") + return null + } + return version == null ? petriNetService.getNewestVersionByIdentifier(identifier) : petriNetService.getPetriNet(identifier, version) + } + + /** + * Converts cases to a map of option keys and translated option values. + * + * @param casesToTransform cases to convert + * @param valueTransformation transformation used to derive the option label from a case, case title is used if not specified otherwise + * @param keyTransformation transformation used to derive the option key from a case, case stringId is used if not specified otherwise + * @return map of option keys and translated values + */ + Map casesToOptions(List casesToTransform, Closure valueTransformation = { return it.title }, Closure keyTransformation = { return it.stringId }) { + return casesToTransform.collectEntries { + [(keyTransformation(it)): new I18nString(valueTransformation(it))] + } } String getTaskId(String transitionId, Case aCase = useCase) { @@ -1002,6 +1539,18 @@ class ActionDelegate { refs.find { it.transitionId == transitionId }.stringId } + /** + * Returns task identifiers for tasks belonging to the provided transitions in the given case. + * + * @param transitionIds transition identifiers + * @param aCase case whose tasks should be inspected, defaults to the current case + * @return list of matching task identifiers + */ + List getTaskIds(List transitionIds, Case aCase = useCase) { + List refs = taskService.findAllByCase(aCase.stringId, null) + return refs.findAll { transitionIds.contains(it.transitionId) }.collect { it.stringId} + } + IUser assignRole(String roleMongoId, IUser user = userService.loggedUser) { IUser actualUser = userService.addRole(user, roleMongoId) return actualUser @@ -1526,8 +2075,8 @@ class ActionDelegate { } /** - * Action API case search function using Elasticsearch database - * @param requests the CaseSearchRequest list + * Action API task search function using Elasticsearch database + * @param requests the @link{ElasticTaskSearchRequest} list * @param loggedUser the user who is searching for the requests * @param page the order of page to return. by default it returns the first page * @param pageable the page configuration that will contain the requests @@ -1535,13 +2084,13 @@ class ActionDelegate { * @param isIntersection to decide null query handling * @return page of cases * */ - Page findTasks(List requests, LoggedUser loggedUser = userService.loggedOrSystem.transformToLoggedUser(), + Page findTasksElastic(List requests, LoggedUser loggedUser = userService.loggedOrSystem.transformToLoggedUser(), int page = 1, int pageSize = 25, Locale locale = Locale.default, boolean isIntersection = false) { return elasticTaskService.search(requests, loggedUser, PageRequest.of(page, pageSize), locale, isIntersection) } /** - * Action API case search function using Elasticsearch database + * Action API task search function using Elasticsearch database * @param request case search request * @param loggedUser the user who is searching for the requests * @param page the order of page to return. by default it returns the first page @@ -1550,10 +2099,10 @@ class ActionDelegate { * @param isIntersection to decide null query handling * @return page of cases * */ - Page findTasks(Map request, LoggedUser loggedUser = userService.loggedOrSystem.transformToLoggedUser(), + Page findTasksElastic(Map request, LoggedUser loggedUser = userService.loggedOrSystem.transformToLoggedUser(), int page = 1, int pageSize = 25, Locale locale = Locale.default, boolean isIntersection = false) { List requests = Collections.singletonList(new ElasticTaskSearchRequest(request)) - return findTasks(requests, loggedUser, page, pageSize, locale, isIntersection) + return findTasksElastic(requests, loggedUser, page, pageSize, locale, isIntersection) } List findDefaultFilters() { diff --git a/src/test/groovy/com/netgrif/application/engine/action/ActionDelegateTest.groovy b/src/test/groovy/com/netgrif/application/engine/action/ActionDelegateTest.groovy index 4900ac8595..dc10532dbd 100644 --- a/src/test/groovy/com/netgrif/application/engine/action/ActionDelegateTest.groovy +++ b/src/test/groovy/com/netgrif/application/engine/action/ActionDelegateTest.groovy @@ -7,9 +7,20 @@ import com.netgrif.application.engine.auth.domain.IUser import com.netgrif.application.engine.auth.service.interfaces.IUserService import com.netgrif.application.engine.auth.web.requestbodies.NewUserRequest import com.netgrif.application.engine.configuration.PublicViewProperties +import com.netgrif.application.engine.petrinet.domain.I18nString +import com.netgrif.application.engine.petrinet.domain.PetriNet +import com.netgrif.application.engine.petrinet.domain.VersionType +import com.netgrif.application.engine.petrinet.domain.dataset.EnumerationMapField +import com.netgrif.application.engine.petrinet.domain.dataset.Field import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.ActionDelegate +import com.netgrif.application.engine.petrinet.domain.version.Version +import com.netgrif.application.engine.petrinet.service.interfaces.IPetriNetService +import com.netgrif.application.engine.workflow.domain.Case +import com.netgrif.application.engine.workflow.domain.DataField +import com.netgrif.application.engine.workflow.domain.Task import com.netgrif.application.engine.workflow.service.interfaces.IFilterImportExportService import com.netgrif.application.engine.workflow.web.responsebodies.MessageResource +import org.bson.types.ObjectId import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test @@ -22,6 +33,8 @@ import org.springframework.test.context.junit.jupiter.SpringExtension import javax.mail.internet.MimeMessage import static java.util.Base64.* +import static org.junit.jupiter.api.Assertions.assertThrows +import static org.junit.jupiter.api.Assertions.assertEquals; @SpringBootTest @ActiveProfiles(["test"]) @@ -43,21 +56,30 @@ class ActionDelegateTest { @Autowired private PublicViewProperties publicViewProperties + @Autowired + private IPetriNetService petriNetService + + private static final ACTION_API_NET_IDENTIFIER = "action_api_improvements" + private static final ERROR_MESSAGE_TEMPLATE = "field [|fieldId|] in [|testedMethod|] method returned null" + private static final FIELD_ID_TEMPLATE = "|fieldId|" + private static final TESTED_METHOD_TEMPLATE = "|testedMethod|" + @BeforeEach void before() { testHelper.truncateDbs() + actionDelegate.outcomes = [] } @Test @Disabled("Context user") - void importFiltersTest(){ + void importFiltersTest() { List actionDelegateList = actionDelegate.importFilters() List importedTasksIds = importExportService.importFilters() assert actionDelegateList.size() == importedTasksIds.size() } @Test - void inviteUser(){ + void inviteUser() { GreenMail smtpServer = new GreenMail(new ServerSetup(2525, null, "smtp")) smtpServer.start() @@ -70,7 +92,7 @@ class ActionDelegateTest { } @Test - void deleteUser(){ + void deleteUser() { GreenMail smtpServer = new GreenMail(new ServerSetup(2525, null, "smtp")) smtpServer.start() String mail = "test@netgrif.com"; @@ -88,7 +110,7 @@ class ActionDelegateTest { @Test - void inviteUserNewUserRequest(){ + void inviteUserNewUserRequest() { GreenMail smtpServer = new GreenMail(new ServerSetup(2525, null, "smtp")) smtpServer.start() @@ -113,4 +135,338 @@ class ActionDelegateTest { assert actionDelegate.makeUrl(publicViewProperties.url, identifier) == url assert actionDelegate.makeUrl("test.netgrif.com/public", "identifier") == "test.netgrif.com/public/${getEncoder().encodeToString(identifier.bytes)}" } + + @Test + void testTaskActions() { + importTestPetriNet() + Case testCase = actionDelegate.createCase(ACTION_API_NET_IDENTIFIER) + List taskIds = testCase.tasks.collect { it.task } + List tasks = actionDelegate.findTasks(taskIds) + + assert tasks != null + assert !tasks.empty + assert tasks.size() == taskIds.size() + + testCase = actionDelegate.setData("t1", testCase, [ + "enumeration_map" : [ + "type" : "enumeration_map", + "value": taskIds[0] + ], + "multichoice_map" : [ + "type" : "multichoice_map", + "value": taskIds + ], + "stringCollection": [ + "type" : "stringCollection", + "value": taskIds + ], + "taskRef" : [ + "type" : "taskRef", + "value": taskIds + ], + "text" : [ + "type" : "text", + "value": taskIds[0] + ] + ]).case + actionDelegate.useCase = testCase + +// fields with single value + ["enumeration_map", "text"].forEach { + assertTaskSearchResults(it, testCase, 1, true) + } + +// fields with collection value + ["multichoice_map", "stringCollection", "taskRef"].forEach { + assertTaskSearchResults(it, testCase, taskIds.size(), false) + } + } + + @Test + void testCaseActions() { + importTestPetriNet() + Case testCase1 = actionDelegate.createCase(ACTION_API_NET_IDENTIFIER) + Case testCase2 = actionDelegate.createCase(ACTION_API_NET_IDENTIFIER) + Case testCase3 = actionDelegate.createCase(ACTION_API_NET_IDENTIFIER) + Case testCase4 = actionDelegate.createCase(ACTION_API_NET_IDENTIFIER) + + List caseIds = [testCase1.stringId, testCase2.stringId, testCase3.stringId, testCase4.stringId] + + Case searchedCase = actionDelegate.findCase(caseIds[0]) + assert searchedCase != null + assert searchedCase.stringId == testCase1.stringId + + List searchedCases = actionDelegate.findCases(caseIds) + assert searchedCases != null + assert !searchedCases.empty + assert searchedCases.size() == caseIds.size() + + + testCase1 = actionDelegate.setData("t1", testCase1, [ + "enumeration_map" : [ + "type" : "enumeration_map", + "value": caseIds[0] + ], + "multichoice_map" : [ + "type" : "multichoice_map", + "value": caseIds + ], + "stringCollection": [ + "type" : "stringCollection", + "value": caseIds + ], + "caseRef" : [ + "type" : "caseRef", + "value": caseIds + ], + "text" : [ + "type" : "text", + "value": caseIds[0] + ] + ]).case + + actionDelegate.useCase = testCase1 +// fields with single value + ["enumeration_map", "text"].forEach { + assertCaseSearchResults(it, testCase1, 1, true) + } + +// fields with collection value + ["multichoice_map", "stringCollection", "caseRef"].forEach { + assertCaseSearchResults(it, testCase1, caseIds.size(), false) + } + } + + @Test + void testCaseDeletionActions() { + importTestPetriNet() + Case testCase1 = actionDelegate.createCase(ACTION_API_NET_IDENTIFIER) + Case testCase2 = actionDelegate.createCase(ACTION_API_NET_IDENTIFIER) + + testCase1 = actionDelegate.deleteCase(testCase1) + assertCaseDeletion(testCase1.stringId) + + testCase2 = actionDelegate.deleteCase(testCase2.stringId) + assertCaseDeletion(testCase2.stringId) + } + + @Test + void testPetriNetActions() { + PetriNet importedNet = importTestPetriNet() + String mongoId = importedNet.stringId + String netIdentifier = importedNet.getIdentifier() + ObjectId objectId = importedNet.objectId + + PetriNet foundNet = actionDelegate.findPetriNet(mongoId) + assert foundNet != null + assert foundNet.objectId == objectId + assert foundNet.identifier == netIdentifier + foundNet = null + + foundNet = actionDelegate.findPetriNet(objectId) + assert foundNet != null + assert foundNet.stringId == mongoId + assert foundNet.identifier == netIdentifier + foundNet = null + + PetriNet filterNet = petriNetService.getByIdentifier("filter")[0] + String mongoId2 = filterNet.stringId + String netIdentifier2 = filterNet.getIdentifier() + ObjectId objectId3 = filterNet.objectId + + def searchTargets = [mongoId, mongoId2] + List searchResults = actionDelegate.findPetriNets(searchTargets as List) + assert searchResults != null + assert !searchResults.empty + assert searchResults.collect { it.stringId }.containsAll(searchTargets) + searchResults = null + + searchTargets = [objectId, objectId] + searchResults = actionDelegate.findPetriNetsByObjectIds(searchTargets as List) + assert searchResults != null + assert !searchResults.empty + assert searchResults.collect { it.objectId }.containsAll(searchTargets) + + PetriNet importedNet2 = importTestPetriNet() + assert importedNet2 != null + assert importedNet2.identifier == netIdentifier + assert importedNet2.stringId != mongoId + assert importedNet2.version != importedNet.version + + foundNet = actionDelegate.findPetriNetByIdentifier(importedNet2.identifier, new Version(1, 0, 0)) + assert foundNet != null + assert foundNet.identifier == netIdentifier + assert foundNet.stringId == mongoId + assert foundNet.version == importedNet.version + assert foundNet.version != importedNet2.version + foundNet = null + +// find newest + foundNet = actionDelegate.findPetriNetByIdentifier(importedNet2.identifier) + assert foundNet != null + assert foundNet.identifier == netIdentifier + assert foundNet.stringId != mongoId + assert foundNet.version != importedNet.version + assert foundNet.version == importedNet2.version + } + + @Test + void testOptionsActions() { + PetriNet net = importTestPetriNet() + Case case1 = actionDelegate.createCase(net, "Test title 1") + Case case2 = actionDelegate.createCase(net, "Test title 2") + + List cases = [case1, case2] + Map options = actionDelegate.casesToOptions(cases) + assert options != null + assert options.size() == cases.size() + assert options.keySet().containsAll(cases.collect { it.stringId }) + assert options.get(case1.stringId).defaultValue == case1.title + assert options.get(case2.stringId).defaultValue == case2.title + options = null + + String keyTransformationTestString = "Key transformation test " + String valueTransformationTestString = "Value transformation test " + options = actionDelegate.casesToOptions(cases, { return "Value transformation test ".concat(it.title) }, { return "Key transformation test ".concat(it.stringId) }) + assert options != null + assert options.size() == cases.size() + assert options.keySet().containsAll(cases.collect { keyTransformationTestString.concat(it.stringId) }) + assert options.get(keyTransformationTestString.concat(case1.stringId)).defaultValue == valueTransformationTestString.concat(case1.title) + assert options.get(keyTransformationTestString.concat(case2.stringId)).defaultValue == valueTransformationTestString.concat(case2.title) + + case1.dataSet["enumeration_map"].options = options + actionDelegate.useCase = case1 + actionDelegate.initFieldsMap(["enumeration_map": "enumeration_map"]) + EnumerationMapField field = (EnumerationMapField) actionDelegate.map.get("enumeration_map") + assert field != null + assert field.options != null + I18nString option = field.getOption(keyTransformationTestString.concat(case1.stringId)) + assert option != null + assert option.defaultValue == valueTransformationTestString.concat(case1.title) + } + + @Test + void testTaskEventActions() { + importTestPetriNet() + Case testCase = actionDelegate.createCase(ACTION_API_NET_IDENTIFIER) + List transitionIds = ["t1", "t2"] + IUser user = userService.getLoggedOrSystem() + +// testing "byTransition" variants should be enough, as they call methods, that take List instead of List + List tasks = actionDelegate.assignTasksByTransitions(transitionIds, testCase) + assert tasks != null + assert tasks.size() == transitionIds.size() + assert tasks.stream().allMatch { it.user.email == user.email } + assert tasks.stream().allMatch { it.userId == user.stringId } + assert tasks.stream().allMatch { it.startDate != null } + tasks = null + + tasks = actionDelegate.cancelTasksByTransitions(transitionIds, testCase) + assert tasks != null + assert tasks.size() == transitionIds.size() + assert tasks.stream().allMatch { it.userId == null } + assert tasks.stream().allMatch { it.startDate == null } + tasks = null + + actionDelegate.assignTasksByTransitions(transitionIds, testCase) + tasks = actionDelegate.finishTasksByTransitions(transitionIds, testCase) + assert tasks != null + assert tasks.size() == transitionIds.size() + assert tasks.stream().allMatch { it.finishedBy == user.stringId } + assert tasks.stream().allMatch { it.finishDate != null } + assert tasks.stream().allMatch { it.userId == null } + tasks = actionDelegate.findTasks(tasks.collect { it.stringId }) + assert tasks.size() == 1 + assert tasks[0].transitionId == "t2" + } + + private void assertCaseDeletion(String deletedCaseId) { + Exception e = assertThrows(IllegalArgumentException.class, () -> { + actionDelegate.findCase(deletedCaseId) + }) + + String expectedMessage = "Could not find Case with id [${deletedCaseId}]" + assertEquals(expectedMessage, e.getMessage()) + } + + private void assertTaskSearchResults(String fieldId, Case testCase, int sizeToCheck, boolean singleValueField) { + actionDelegate.initFieldsMap([(fieldId): fieldId]) + String errorMessageWithFieldId = ERROR_MESSAGE_TEMPLATE.replace(FIELD_ID_TEMPLATE, fieldId) + Field field = actionDelegate.map.get(fieldId) + String firstTaskId = ([field.value].flatten() as List)[0] + + Task task = actionDelegate.findTask(field) + String errorMessage = errorMessageWithFieldId.replace(TESTED_METHOD_TEMPLATE, "findTask(Field)") + assert task != null : errorMessage + assert task.stringId == firstTaskId : errorMessage + task = null + + List tasks = actionDelegate.findTasks(field) + errorMessage = errorMessageWithFieldId.replace(TESTED_METHOD_TEMPLATE, "findTasks(Field)") + assert tasks != null : errorMessage + assert !tasks.empty : errorMessage + assert tasks.size() == sizeToCheck : errorMessage + if (singleValueField) { + assert tasks[0].stringId == firstTaskId : errorMessage + } + tasks = null + + DataField dataField = testCase.getDataField(fieldId) + task = actionDelegate.findTask(dataField) + errorMessage = errorMessageWithFieldId.replace(TESTED_METHOD_TEMPLATE, "findTask(DataField)") + assert task != null : errorMessage + assert task.stringId == firstTaskId : errorMessage + + tasks = actionDelegate.findTasks(dataField) + errorMessage = errorMessageWithFieldId.replace(TESTED_METHOD_TEMPLATE, "findTasks(DataField)") + assert tasks != null : errorMessage + assert !tasks.empty : errorMessage + assert tasks.size() == sizeToCheck : errorMessage + if (singleValueField) { + assert tasks[0].stringId == firstTaskId : errorMessage + } + } + + private void assertCaseSearchResults(String fieldId, Case testCase, int sizeToCheck, boolean singleValueField) { + actionDelegate.initFieldsMap([(fieldId): fieldId]) + String errorMessageWithFieldId = ERROR_MESSAGE_TEMPLATE.replace(FIELD_ID_TEMPLATE, fieldId) + Field field = actionDelegate.map.get(fieldId) + String firstCaseId = ([field.value].flatten() as List)[0] + + Case searchedCase = actionDelegate.findCase(field) + String errorMessage = errorMessageWithFieldId.replace(TESTED_METHOD_TEMPLATE, "findCase(Field)") + assert searchedCase != null : errorMessage + assert searchedCase.stringId == firstCaseId : errorMessage + searchedCase = null + + List searchedCases = actionDelegate.findCases(field) + errorMessage = errorMessageWithFieldId.replace(TESTED_METHOD_TEMPLATE, "findCases(Field)") + assert searchedCases != null : errorMessage + assert !searchedCases.empty : errorMessage + assert searchedCases.size() == sizeToCheck : errorMessage + if (singleValueField) { + assert searchedCases[0].stringId == firstCaseId : errorMessage + } + searchedCases = null + + + DataField dataField = testCase.getDataField(fieldId) + searchedCase = actionDelegate.findCase(dataField) + errorMessage = errorMessageWithFieldId.replace(TESTED_METHOD_TEMPLATE, "findCase(DataField)") + assert searchedCase != null: errorMessage + assert searchedCase.stringId == firstCaseId: errorMessage + + searchedCases = actionDelegate.findCases(dataField) + errorMessage = errorMessageWithFieldId.replace(TESTED_METHOD_TEMPLATE, "findCases(DataField)") + assert searchedCases != null: errorMessage + assert !searchedCases.empty: errorMessage + assert searchedCases.size() == sizeToCheck: errorMessage + if (singleValueField) { + assert searchedCases[0].stringId == firstCaseId: errorMessage + } + } + + private PetriNet importTestPetriNet() { + return petriNetService.importPetriNet(new FileInputStream("src/test/resources/petriNets/NAE-2390_action_api_improvements.xml"), VersionType.MAJOR, userService.getLoggedOrSystem().transformToLoggedUser()).getNet() + } } diff --git a/src/test/resources/petriNets/NAE-2390_action_api_improvements.xml b/src/test/resources/petriNets/NAE-2390_action_api_improvements.xml new file mode 100644 index 0000000000..4cd61d922b --- /dev/null +++ b/src/test/resources/petriNets/NAE-2390_action_api_improvements.xml @@ -0,0 +1,214 @@ + + action_api_improvements + 1.0.0 + NEW + Action API Improvements Test + device_hub + true + true + false + + caseRef + + <allowedNets> + <allowedNet>action_api_improvements</allowedNet> + </allowedNets> + </data> + <data type="enumeration"> + <id>enumeration</id> + <title/> + </data> + <data type="enumeration_map"> + <id>enumeration_map</id> + <title/> + </data> + <data type="multichoice"> + <id>multichoice</id> + <title/> + </data> + <data type="multichoice_map"> + <id>multichoice_map</id> + <title/> + </data> + <data type="stringCollection"> + <id>stringCollection</id> + <title/> + </data> + <data type="taskRef"> + <id>taskRef</id> + <title/> + </data> + <data type="text"> + <id>text</id> + <title/> + </data> + <transition> + <id>t1</id> + <x>336</x> + <y>112</y> + <label/> + <dataGroup> + <id>t1_0</id> + <cols>4</cols> + <layout>grid</layout> + <dataRef> + <id>enumeration_map</id> + <logic> + <behavior>editable</behavior> + </logic> + <layout> + <x>0</x> + <y>0</y> + <rows>1</rows> + <cols>2</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + </dataRef> + <dataRef> + <id>caseRef</id> + <logic> + <behavior>editable</behavior> + </logic> + <layout> + <x>2</x> + <y>0</y> + <rows>1</rows> + <cols>2</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + </dataRef> + <dataRef> + <id>taskRef</id> + <logic> + <behavior>editable</behavior> + </logic> + <layout> + <x>0</x> + <y>1</y> + <rows>1</rows> + <cols>4</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + </dataRef> + <dataRef> + <id>stringCollection</id> + <logic> + <behavior>editable</behavior> + </logic> + <layout> + <x>0</x> + <y>2</y> + <rows>1</rows> + <cols>2</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + </dataRef> + <dataRef> + <id>enumeration</id> + <logic> + <behavior>editable</behavior> + </logic> + <layout> + <x>2</x> + <y>2</y> + <rows>1</rows> + <cols>2</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + </dataRef> + <dataRef> + <id>multichoice</id> + <logic> + <behavior>editable</behavior> + </logic> + <layout> + <x>0</x> + <y>3</y> + <rows>1</rows> + <cols>2</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + </dataRef> + <dataRef> + <id>text</id> + <logic> + <behavior>editable</behavior> + </logic> + <layout> + <x>2</x> + <y>3</y> + <rows>1</rows> + <cols>2</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + </dataRef> + <dataRef> + <id>multichoice_map</id> + <logic> + <behavior>editable</behavior> + </logic> + <layout> + <x>2</x> + <y>4</y> + <rows>1</rows> + <cols>2</cols> + <template>material</template> + <appearance>outline</appearance> + </layout> + </dataRef> + </dataGroup> + </transition> + <transition> + <id>t2</id> + <x>208</x> + <y>240</y> + <label/> + </transition> + <transition> + <id>t3</id> + <x>336</x> + <y>240</y> + <label/> + </transition> + <transition> + <id>t4</id> + <x>464</x> + <y>240</y> + <label/> + </transition> + <place> + <id>p1</id> + <x>208</x> + <y>112</y> + <tokens>1</tokens> + <static>false</static> + </place> + <place> + <id>p2</id> + <x>464</x> + <y>112</y> + <tokens>0</tokens> + <static>false</static> + </place> + <arc> + <id>a1</id> + <type>regular</type> + <sourceId>p1</sourceId> + <destinationId>t1</destinationId> + <multiplicity>1</multiplicity> + </arc> + <arc> + <id>a2</id> + <type>regular</type> + <sourceId>t1</sourceId> + <destinationId>p2</destinationId> + <multiplicity>1</multiplicity> + </arc> +</document> \ No newline at end of file