Once I had a requirement for a project as follow :
- A web application
- Dynamic form builder, that can be build by business users instead of IT developers. The form builder must be extensible that business users can add any validation to an existing database ( let say, to validate if a company name key in to a field already exists in database), without IT dependency in the future
- Form created must be able to be attached to a workflow, and the flow could be defined by business users without dependency to IT in the future
- There must be a role-based access control where users can be grouped into role to assign fine-grained permissions
What I proposed for solution:
Using activiti workflow engine as based product, where it has the workflow engine and workflow designer, and also workflow runtime as a web application including the role-based access control.
What I needed to figure out :
- A way for business users to “attach” validation rule without having to write any code (the code already provided by IT as foundation)
In activiti, there’s a way to define form’s property renderer and form’s property type ( see this link )
Let’s take a look at form definition created using form designer :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
<?xml version="1.0" encoding="UTF-8"?> <definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:activiti="http://activiti.org/bpmn" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:omgdc="http://www.omg.org/spec/DD/20100524/DC" xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI" typeLanguage="http://www.w3.org/2001/XMLSchema" expressionLanguage="http://www.w3.org/1999/XPath" targetNamespace="http://www.activiti.org/test"> <process id="companyRegistration" name="Company Registration" isExecutable="true"> <startEvent id="startevent1" name="Start"> <extensionElements> <activiti:formProperty id="companyId" name="Company Id" type="string-with-custom-validation" readable="true" required="true"> <activiti:value id="methodName" name="mustNotExistEqual"></activiti:value> <activiti:value id="entityName" name="Company"></activiti:value> <activiti:value id="propertyName" name="id"></activiti:value> <activiti:value id="errorMessage" name="Id already exists"></activiti:value> </activiti:formProperty> <activiti:formProperty id="companyName" name="Company Name" type="string-with-custom-validation" required="true"> <activiti:value id="methodName" name="mustNotExistEqual"></activiti:value> <activiti:value id="entityName" name="Company"></activiti:value> <activiti:value id="propertyName" name="name"></activiti:value> <activiti:value id="errorMessage" name="Name already exists"></activiti:value> </activiti:formProperty> </extensionElements> </startEvent> <endEvent id="endevent1" name="End"></endEvent> <sequenceFlow id="flow1" sourceRef="startevent1" targetRef="endevent1"></sequenceFlow> </process> <bpmndi:BPMNDiagram id="BPMNDiagram_companyRegistration"> <bpmndi:BPMNPlane bpmnElement="companyRegistration" id="BPMNPlane_companyRegistration"> <bpmndi:BPMNShape bpmnElement="startevent1" id="BPMNShape_startevent1"> <omgdc:Bounds height="35.0" width="35.0" x="150.0" y="170.0"></omgdc:Bounds> </bpmndi:BPMNShape> <bpmndi:BPMNShape bpmnElement="endevent1" id="BPMNShape_endevent1"> <omgdc:Bounds height="35.0" width="35.0" x="390.0" y="170.0"></omgdc:Bounds> </bpmndi:BPMNShape> <bpmndi:BPMNEdge bpmnElement="flow1" id="BPMNEdge_flow1"> <omgdi:waypoint x="185.0" y="187.0"></omgdi:waypoint> <omgdi:waypoint x="390.0" y="187.0"></omgdi:waypoint> </bpmndi:BPMNEdge> </bpmndi:BPMNPlane> </bpmndi:BPMNDiagram> </definitions> |
Workflow definition can be deployed as activiti explorer’s plugin, along with java classes for custom form type. Remember our custom form type here will have “string-with-custom-validation” type.
To have custom form type, we need to extend org.activiti.engine.impl.form.FormTypes . Our example class will be CustomFormTypes below that extends FormTypes that renders custom properties on forms. There’s another class which extends activiti’s class, to handle String type property. see below
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
package sg.tenderhub.form; import java.util.HashMap; import java.util.Map; import org.activiti.bpmn.model.FormProperty; import org.activiti.bpmn.model.FormValue; import org.activiti.engine.form.AbstractFormType; import org.activiti.engine.impl.form.FormTypes; /** * A activiti engine extension to render custom properties on forms. * * */ public class CustomFormTypes extends FormTypes { /** * Parse a form property */ @Override public AbstractFormType parseFormPropertyType(FormProperty formProperty) { if (CustomValidationStringFormType.NAME.equals(formProperty.getType())) { return parseCustomValidationStringFormType(formProperty); } else { return super.parseFormPropertyType(formProperty); } } private AbstractFormType parseCustomValidationStringFormType(FormProperty formProperty) { Map<String, String> map = new HashMap<String, String>(); for (FormValue fv : formProperty.getFormValues()) { map.put(fv.getId(), fv.getName()); } CustomValidationStringFormType customValidationStringFormType = new CustomValidationStringFormType(); customValidationStringFormType.setMethodName(map.get("methodName")); customValidationStringFormType.setEntityName(map.get("entityName")); customValidationStringFormType.setPropertyName(map.get("propertyName")); customValidationStringFormType.setErrorMessage(map.get("errorMessage")); return customValidationStringFormType; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
package sg.tenderhub.form; import org.activiti.engine.impl.form.StringFormType; /** * A custom form type, that can be interpreted by the activiti engine * that holds the information needed by the custom validation services * * */ public class CustomValidationStringFormType extends StringFormType { public static final String NAME = "string-with-custom-validation"; private static final long serialVersionUID = -3756118346397329806L; private String methodName; private String entityName; private String propertyName; private String errorMessage; @Override public String getName() { return NAME; } public String getMethodName() { return methodName; } public void setMethodName(String methodName) { this.methodName = methodName; } public String getEntityName() { return entityName; } public void setEntityName(String entityName) { this.entityName = entityName; } public String getPropertyName() { return propertyName; } public void setPropertyName(String propertyName) { this.propertyName = propertyName; } public String getErrorMessage() { return errorMessage; } public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } } |
I added several properties on CustomValidationStringFormType class which purpose I will explain in later class.
Another class is needed to tell the engine how to render CustomValidationStringFormType
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
package sg.tenderhub.form; import org.activiti.engine.form.FormProperty; import org.activiti.explorer.Messages; import org.activiti.explorer.ui.form.AbstractFormPropertyRenderer; import com.vaadin.ui.Field; import com.vaadin.ui.TextField; import sg.tenderhub.dto.RPCValidationBean; /** * The renderer to the CustomValidationStringFormType. * * */ public class CustomValidationStringPropertyRenderer extends AbstractFormPropertyRenderer { private static final long serialVersionUID = 449784872993717113L; public CustomValidationStringPropertyRenderer() { super(CustomValidationStringFormType.class); } /** * Tell the engine how to render the CustomValidationStringFormType. * Also appends the CustomValidator to these fields. */ @Override public Field getPropertyField(FormProperty formProperty) { TextField textField = new TextField(getPropertyLabel(formProperty)); textField.setRequired(formProperty.isRequired()); textField.setEnabled(formProperty.isWritable()); textField.setRequiredError(getMessage(Messages.FORM_FIELD_REQUIRED, getPropertyLabel(formProperty))); if (formProperty.getValue() != null) { textField.setValue(formProperty.getValue()); } if (formProperty.getType() instanceof CustomValidationStringFormType) { CustomValidationStringFormType type = (CustomValidationStringFormType) formProperty.getType(); textField.addValidator(new CustomValidator(new RPCValidationBean(type.getMethodName(), type.getEntityName(), type.getPropertyName(), type.getErrorMessage()))); textField.setImmediate(true); } return textField; } } |
And finally, the action to validate, which basically call a JSON web service on a specified URL
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
package sg.tenderhub.form; import javax.ws.rs.client.Entity; import javax.ws.rs.client.Invocation; import javax.ws.rs.client.WebTarget; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import com.vaadin.data.validator.AbstractStringValidator; import sg.tenderhub.ClientSingleton; import sg.tenderhub.dto.RPCValidationBean; /** * A custom validator that consumes the custom-validations resources. * * */ public class CustomValidator extends AbstractStringValidator { private static final long serialVersionUID = -6285660831185739764L; private RPCValidationBean bean; public CustomValidator(RPCValidationBean bean) { super(bean.getErrorMessage()); this.bean = bean; } /** * Validate the content of a custom field by sending a POST request to the * '/validate' resource of custom-validations */ @Override protected boolean isValidString(String value) { bean.setComparisonValue(value); WebTarget target = ClientSingleton.getClientInstance().target("http://localhost:8080/custom-validations/api/v1/validate"); Invocation.Builder builder = target.request(MediaType.APPLICATION_JSON); Response response = builder.post(Entity.entity(bean, MediaType.APPLICATION_JSON)); boolean isValid = response.getStatus() >= 200 && response.getStatus() < 300; response.close(); return isValid; } } |
In my web service endpoint, here is how I handled the validation. Table name, and value to check would be taken from parameters that send by activiti explorer’s custom plugin
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
package sg.tenderhub.validator.impl; import sg.tenderhub.validator.AbstractRPCValidationResolver; import sg.tenderhub.validator.RPCName; /** * A RPCValidationResolver implementation that validate if there is * not an entity in the database with a specific value for a specific attribute * * */ @RPCName(name = "mustNotExistEqual") public class MustNotExistsEqualRPCValidationResolver extends AbstractRPCValidationResolver { @Override public boolean isValid(RPCValidationBean bean) { String query = "select count(*) from " + bean.getEntityName() + " where " + bean.getPropertyName() + " = " + treatComparisonValue(bean); return entityManager.createQuery(query, Long.class).getSingleResult() <= 0; } } |
The base class for this is responsible to define treatComparisonValue method as follow
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
package sg.tenderhub.validator; import javax.inject.Inject; import javax.persistence.EntityManager; import sg.tenderhub.util.ReflectionUtil; import sg.tenderhub.validator.impl.RPCValidationBean; /** * A abstract implementation of RPCValidationResolver that contains * code to be shared by it subclasses. * * */ public abstract class AbstractRPCValidationResolver implements RPCValidationResolver { @Inject protected EntityManager entityManager; @Inject protected ReflectionUtil reflection; /** * Concatenate single quotes to the value if it is a String subclass * @param bean The RPCValidationBean with the value inputed by the user. * @return The value after treatment */ protected String treatComparisonValue(RPCValidationBean bean) { if (reflection.isString(bean.getPropertyName(), bean.getEntityName())) { return "'" + bean.getComparisonValue() + "'"; } else { return bean.getComparisonValue(); } } } |