Play framework conditional form mapping with field-level errors.
Play Framework will not let you assign an error message to a specific form field if you need to validate one field based on the value of another. This micro-library provides a small collection of conditional mappings that solve this problem.
Consider the following model which represents fields on a form:
case class Model(nonUkResident: Boolean, country: Option[String], email: String)
Assuming this model has the validation rules:
- an
email
address is always mandatory - A value for
country
is mandatory IFnonUkResident
istrue
A typical solution following the official play form guidelines would look like the following:
Form(mapping(
"nonUkResident" -> boolean,
"country" -> optional(nonEmptyText),
"email" -> nonEmptyText
)(Model.apply)(Model.unapply).verifying("Error.countryRequired", x => x.nonUkResident && x.country.isDefined))
There are two problems with this approach that have seriously negative implications on user experience:
-
Users will have to endure two rounds of validation. The conditional validation on
country
is only applied if there are no field level errors. So if there is anemail
validation error, the user will first fix that, and then be told they need to supply a country. -
Conditional validations are always global errors. It is not possible for the conditional error to be assigned to the
country
field, so it is not easily (without overly complex tinkering) to direct users to the field where they need to take action
The following test cases demonstrate these problems:
behavior of "vanilla play conditional mapping"
it should "not contain an error for the conditional validation when there is a field-level error" in {
val data = Map("nonUkResident" -> "true")
val res = form.bind(data)
assert(res.errors.length == 1)
assert(res.errors.head.key === "email")
}
it should "not allow a field-level error message for a conditional validation" in {
val data = Map("nonUkResident" -> "true", "email" -> "abc@gov.uk")
val res = form.bind(data)
assert(res.errors.length == 1)
assert(res.errors.head.key === "")
}
The conditional mappings in this library remedy both of the above problems. You can validate a field based on the value of another field, and the error will be assigned to the field that requires the attention of the user.
This library solves these problems with a mini-dsl shown in the examples below. You can see many more examples in this repository's unit tests.
behavior of "conditional mappings"
it should "contain field level errors for fields using conditional mappings" in {
val data = Map("nonUkResident" -> "true")
val res = form.bind(data)
assert(res.errors.length == 2)
assert(res.errors.head.key === "country")
assert(res.errors.tail.head.key === "email")
}
lazy val form = Form(mapping(
"nonUkResident" -> boolean,
"country" -> mandatoryIfTrue("nonUkResident", nonEmptyText),
"email" -> nonEmptyText
)(Model.apply)(Model.unapply))
import ConditionalMappings._
behavior of "mandatory if equal"
it should "mandate the target field if the source has the required value" in {
val data = Map("country" -> "England")
val res = form.bind(data)
assert(res.errors.head.key === "town")
}
it should "not mandate the target field if the source field does not have the required value" in {
val data = Map("country" -> "Scotland")
val res = form.bind(data)
assert(res.errors.isEmpty)
}
lazy val form = Form(mapping(
"country" -> nonEmptyText,
"town" -> mandatoryIfEqual("country", "England", nonEmptyText)
)(Model.apply)(Model.unapply))
case class Model(country: String, town: Option[String])
import ConditionalMappings._
behavior of "chained mappings"
it should "apply mappings if all of the chained criteria are satisfied" in {
val data = Map("name" -> "Francoise", "age" -> "21")
val res = form.bind(data)
assert(res.errors.head.key === "favouriteColour")
}
it should "not apply mappings if any part of the chained critieria is not satisfied" in {
val data = Map("name" -> "Francoise", "age" -> "20")
val res = form.bind(data)
assert(res.errors.isEmpty)
}
lazy val form = Form(mapping(
"name" -> nonEmptyText,
"age" -> number,
"favouriteColour" -> mandatoryIf(
isEqual("name", "Francoise") and isEqual("age", "21"),
nonEmptyText
)
)(Model.apply)(Model.unapply))
case class Model(name: String, age: Int, favouriteColour: Option[String])
If you want a mapping to apply based on the value of another field, without the target
field becoming mandatory i.e you want the form value to be ignored, you can use
the onlyIf
mappings:
import ConditionalMappings._
behavior of "only if true"
it should "apply the mapping to the target field if the source field is true" in {
val data = Map("source" -> "true", "target" -> "Bonjour")
val res = form.bind(data)
assert(res.value.value === Model(true, Some("Bonjour")))
}
it should "ignore the mapping and set the default value if the source field is not true" in {
val data = Map("source" -> "false", "target" -> "Bonjour")
val res = form.bind(data)
assert(res.value.value === Model(false, None))
}
it should "not mandate the target field even if the source field is true" in {
val data = Map("source" -> "true")
val res = form.bind(data)
assert(res.errors.isEmpty)
}
lazy val form = Form(mapping(
"source" -> boolean,
"target" -> onlyIfTrue("source", optional(nonEmptyText))
)(Model.apply)(Model.unapply))
case class Model(source: Boolean, target: Option[String])
Include the following dependency in your SBT build before v2.0.0
resolvers += "HMRC-open-artefacts-maven" at "https://open.artefacts.tax.service.gov.uk/maven2"
libraryDependencies += "uk.gov.hmrc" %% "play-conditional-form-mapping" % "[INSERT_VERSION]"
Include one of the following in your SBT build for v2.0.0 or after depending on whether you're using Play 2.8, Play 2.9 or Play 3.0
libraryDependencies += "uk.gov.hmrc" %% "play-conditional-form-mapping-play-28" % "[INSERT_VERSION]"
OR
libraryDependencies += "uk.gov.hmrc" %% "play-conditional-form-mapping-play-29" % "[INSERT_VERSION]"
OR
libraryDependencies += "uk.gov.hmrc" %% "play-conditional-form-mapping-play-30" % "[INSERT_VERSION]"
This code is open source software licensed under the Apache 2.0 License.