From 96f4b95ac2c6bedb1c1e09ac9f280f8cd617c811 Mon Sep 17 00:00:00 2001 From: Andrey Zhuchkov Date: Sat, 20 Dec 2014 21:11:19 +0300 Subject: [PATCH 1/2] Fixed compilation errors according to Kotlin M10 updates --- src/HTMLBuilder/src/kotlin/html/CssDSL.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/HTMLBuilder/src/kotlin/html/CssDSL.kt b/src/HTMLBuilder/src/kotlin/html/CssDSL.kt index c0343380..99c2c35e 100644 --- a/src/HTMLBuilder/src/kotlin/html/CssDSL.kt +++ b/src/HTMLBuilder/src/kotlin/html/CssDSL.kt @@ -213,7 +213,7 @@ open class CssElement() { return UnionSelector(selectors) } - class UnionSelector(val selectors: Array) : Selector { + class UnionSelector(val selectors: Array) : Selector { override fun toExternalForm(): String { return "(${selectors.map ({ it.toExternalForm() }).makeString(",")})" } From 654753bfe28f766c6fa1b8fca047675477350e71 Mon Sep 17 00:00:00 2001 From: Andrey Zhuchkov Date: Sun, 21 Dec 2014 20:56:23 +0300 Subject: [PATCH 2/2] Forms data binding framework --- .../src/kara/demo/routes/forms/FormsDemo.kt | 68 +++ .../KaraDemo/src/kara/demo/views/FormsDemo.kt | 115 +++++ src/KaraLib/src/kara/forms/Forms.kt | 398 ++++++++++++++++++ 3 files changed, 581 insertions(+) create mode 100644 samples/KaraDemo/src/kara/demo/routes/forms/FormsDemo.kt create mode 100644 samples/KaraDemo/src/kara/demo/views/FormsDemo.kt create mode 100644 src/KaraLib/src/kara/forms/Forms.kt diff --git a/samples/KaraDemo/src/kara/demo/routes/forms/FormsDemo.kt b/samples/KaraDemo/src/kara/demo/routes/forms/FormsDemo.kt new file mode 100644 index 00000000..be334d08 --- /dev/null +++ b/samples/KaraDemo/src/kara/demo/routes/forms/FormsDemo.kt @@ -0,0 +1,68 @@ +package kara.demo.routes.forms + +import kara.Get +import kara.Request +import kara.Post +import kara.TextResult +import kara.forms.* +import kara.demo.views + +enum class Gender { MAIL FEMAIL } + +data class Address( + var city: String? = null, + var street: String? = null +) + +data class User( + var firstName: String? = null, + var lastName: String? = null, + var age: Int? = null, + var gender: Gender? = null, + var addr: Address? = null, + var registered: Boolean? = null +) + +object FormsDemo { + val UserForm = Form( + mapping( + User::firstName mapTo notEmptyText, + User::lastName mapTo notEmptyText, + User::age mapTo number(min = 18).verifying("21 is restricted age") { it != 21 }, + User::gender mapTo enum(), + User::registered mapTo boolean, + User::addr toNested mapping ( + Address::city mapTo text(min = 5), + Address::street mapTo text(min = 5, max = 10).verifying("Wrong format") { + it!!.endsWith("st.") + } + ) { Address() } + .verifying("Street address must be prefixed by city") { it.street!!.startsWith(it.city!!) } + ) { User() } + .verifying("first name and last name MUST be different") { + it.firstName != it.lastName + } + ) + + Get("/user") + class UserFormAction : Request({ + val form = UserForm fill User(firstName = "John", age = 21) + + views.ShowDemoForm(form) + }) + + Get("/user/done") + class UserSaveCompleteAction : Request({ + TextResult("The user has been saved successfully") + }) + + Post("/user") + class SaveUserAction : Request({ + val form = UserForm bind params._map + + when { + form.hasErrors -> views.ShowDemoForm(form) + else -> redirect(UserSaveCompleteAction()) + } + }) +} \ No newline at end of file diff --git a/samples/KaraDemo/src/kara/demo/views/FormsDemo.kt b/samples/KaraDemo/src/kara/demo/views/FormsDemo.kt new file mode 100644 index 00000000..03dc0479 --- /dev/null +++ b/samples/KaraDemo/src/kara/demo/views/FormsDemo.kt @@ -0,0 +1,115 @@ +package kara.demo.views + +import kara.HtmlTemplateView +import kara.Template +import kotlin.html.* +import kara.forms.Form +import kara.forms.FormField +import kara.demo.routes.forms.User +import kara.demo.routes.forms.FormsDemo +import kara.demo.routes.forms.Address +import kara.demo.routes.forms.Gender + +fun ShowDemoForm(form: Form) = HtmlTemplateView(FormTemplate(form), {}) + +private fun FORM.inputTmpl(field: FormField, label: String, input: HtmlBodyTag.() -> Unit) { + div { + style = "margin: 10px;" + + label { + +label + + input() + } + + if (field.hasErrors()) { + ul { + field.errors.forEach { + li { + +it.message + } + } + } + } + } +} + +private fun FORM.inputText(field: FormField, label: String) = inputTmpl(field, label) { + input { + inputType = InputType.text + name = field.fieldName + value = field.value.orEmpty() + } +} + +private fun FORM.checkbox(field: FormField, label: String) = inputTmpl(field, label) { + input { + inputType = InputType.checkbox + name = field.fieldName + value = field.value.orEmpty() + + if (field.value != null) + checked = true + } +} + +private fun FORM.radio>(field: FormField, values: Array, label: String) = inputTmpl(field, label) { + values.forEach {(radioValue) -> + label { + input { + inputType = InputType.radio + name = field.fieldName + value = radioValue.toString() + + if (radioValue.name() == field.value) + checked = true + } + +radioValue.name() + } + } +} + +private class FormTemplate(val form: Form) : Template() { + override fun HTML.render() { + head { + title { + +"Kara forms binding demo" + } + } + + body { + if (form.hasGlobalErrors) { + ul { + form.globalErrors.forEach { + li { + +it.message + } + } + } + } + + form { + action = FormsDemo.SaveUserAction() + method = FormMethod.post + + inputText(form[User::firstName], "First Name: ") + inputText(form[User::lastName], "Last Name: ") + + inputText(form[User::age], "Age: ") + + inputText(form[User::addr][Address::city], "City: ") + inputText(form[User::addr][Address::street], "Street: ") + + radio(form[User::gender], Gender.values(), "Gender: ") + + checkbox(form[User::registered], "Registered: ") + + br() + + input { + inputType = InputType.submit + } + } + } + } +} diff --git a/src/KaraLib/src/kara/forms/Forms.kt b/src/KaraLib/src/kara/forms/Forms.kt new file mode 100644 index 00000000..eda1e8c6 --- /dev/null +++ b/src/KaraLib/src/kara/forms/Forms.kt @@ -0,0 +1,398 @@ +package kara.forms + +import kotlin.reflect.KMutableMemberProperty +import java.util.ArrayList +import java.util.Date +import java.text.SimpleDateFormat +import java.text.ParseException +import kotlin.properties.Delegates +import java.util.regex.Pattern + +/* Tagged Error */ + +data class FormError(val context: String, val message: String) + +/* Binding Result */ + +trait BindingResult + +data class SuccessBinding(val value: T) : BindingResult + +data class FailedBinding(val errors: List) : BindingResult + +/* Form Binders */ + +trait FormBinder { + fun bind(data: T, context: String): BindingResult + + fun unbind(data: V): T +} + +trait ValidationBinder : FormBinder { + fun validating(validator: (V) -> List) = object : FormBinder { + override fun bind(data: T, context: String): BindingResult { + val result = this@ValidationBinder.bind(data, context) + + return when (result) { + is SuccessBinding -> { + val errors = validator(result.value) + if (errors.isNotEmpty()) FailedBinding(errors.map { FormError(context, it) }) else result + } + else -> result + } + } + + override fun unbind(data: V): T = this@ValidationBinder.unbind(data) + } + + fun verifying(message: String, predicate: (V) -> Boolean) = + validating { if (predicate(it)) listOf() else listOf(message) } +} + +[suppress("BASE_WITH_NULLABLE_UPPER_BOUND", "UNCHECKED_CAST")] +fun FormBinder.not(): FormBinder { + val callee = this + + return object : FormBinder { + override fun bind(data: T, context: String): BindingResult { + val result = callee.bind(data, context) + + return when (result) { + is SuccessBinding -> SuccessBinding(result.value!!) + else -> result as BindingResult + } + } + + override fun unbind(data: V): T = callee.unbind(data) + } +} + +private fun formError(context: String, message: String) = FailedBinding(listOf(FormError(context, message))) + +fun optional(delegate: FormBinder) = object : FormBinder { + override fun bind(data: String?, context: String): BindingResult { + if (data == null || data.isEmpty()) + return SuccessBinding(null) + + return delegate.bind(data, context) + } + + override fun unbind(data: T): String? = delegate.unbind(data) +} + +fun required(delegate: FormBinder) = object : FormBinder { + override fun bind(data: String?, context: String): BindingResult { + if (data == null || data.isEmpty()) + return formError(context, "Field is required") + + return delegate.bind(data, context) + } + + override fun unbind(data: T): String? = delegate.unbind(data) +} + +fun number(min: Int = Integer.MIN_VALUE, max: Int = Integer.MAX_VALUE) = object : ValidationBinder { + { + if (min > max) throw IllegalArgumentException("max cannot be less than min") + } + + override fun bind(data: String?, context: String): BindingResult { + return try { + val number = Integer.parseInt(data) + when { + number < min -> formError(context, "Minimum value is $min") + number > max -> formError(context, "Maximum value is $max") + else -> SuccessBinding(number) + } + } catch (e: NumberFormatException) { + formError(context, "Invalid number format") + } + } + + override fun unbind(data: Int?): String? = data?.toString() +} + +val number = number() + +fun text(min: Int = 0, max: Int = Integer.MAX_VALUE, trim: Boolean = false) = object : ValidationBinder { + { + if (min > max) throw IllegalArgumentException("max cannot be less than min") + } + + override fun bind(data: String?, context: String): BindingResult { + val text = if (trim) data?.trim() else data + + return when { + text?.length() ?: 0 < min -> formError(context, "Minimum length is $min") + text?.length() ?: 0 > max -> formError(context, "Maximum length is $max") + else -> SuccessBinding(data) + } + } + + override fun unbind(data: String?): String? = data +} + +val text = text() + +val notEmptyText = text(min = 1, trim = true) + +fun date(format: String) = object : ValidationBinder { + private val sdf = SimpleDateFormat(format) + + override fun bind(data: String?, context: String): BindingResult { + return try { + SuccessBinding(sdf.parse(data)) + } catch (e: ParseException) { + formError(context, "Invalid number format") + } + } + + override fun unbind(data: Date?): String? = when { + data != null -> sdf.format(data) + else -> null + } +} + +val email = text.verifying("invalid email address", { + it?.matches("""^(?!\.)("([^"\r\\]|\\["\r\\])*"|([-a-zA-Z0-9!#$%&'*+/=?^_`{|}~]|(?>() = object : ValidationBinder { + override fun bind(data: String?, context: String): BindingResult { + val value = javaClass().getEnumConstants().firstOrNull { it.name() == data } + + return when { + value == null -> formError(context, "Invalid enum value") + else -> SuccessBinding(value) + } + } + + override fun unbind(data: T?): String? = data?.name() +} + +val checked = object : ValidationBinder { + override fun bind(data: String?, context: String): BindingResult = + SuccessBinding(data != null) + + override fun unbind(data: Boolean?): String? = if (data != null) "TRUE" else null +} + +val boolean = checked + +fun ignored(value: T) = object : FormBinder { + override fun bind(data: String?, context: String) = SuccessBinding(value) + + override fun unbind(data: T) = value.toString() +} + +fun binding(bind: (String?) -> T, unbind: (T) -> String = { it.toString() }, errorMessage: String = "Invalid value") = + object : ValidationBinder { + override fun bind(data: String?, context: String): BindingResult = try { + SuccessBinding(bind(data)) + } catch (e: Exception) { + formError(context, errorMessage) + } + + override fun unbind(data: T) = unbind(data) + } + +/* Form Handling */ + +trait FormField { + val fieldName: String + val value: String? + val errors: List + + fun hasErrors() = errors.isNotEmpty() + + fun get(field: KMutableMemberProperty): FormField +} + +data class Form(val mapping: FormBinder, T>, + val data: Map = mapOf(), + val errors: List = listOf(), + val value: T = null) { + private val unbound: Map by Delegates.blockingLazy { + if (value != null) + mapping.unbind(value) + else + throw IllegalStateException("form doesn't have a value") + } + + val hasErrors = errors.isNotEmpty() + val globalErrors = errors.filter { it.context == "" } + val hasGlobalErrors = globalErrors.isNotEmpty() + + fun errors(code: String) = errors.filter { it.context == code } + + fun hasErrors(code: String) = errors(code).isNotEmpty() + + fun withError(code: String, message: String) = + Form(mapping, data, errors + FormError(code, message), value) + + fun discardingErrors() = Form(mapping, data, value = value) + + fun bind(data: Map): Form { + val result = mapping.bind(data, "") + + return when (result) { + is SuccessBinding -> Form(mapping, data, value = result.value) + is FailedBinding -> Form(mapping, data, result.errors) + else -> throw IllegalArgumentException("unsupported binding: " + result) + } + } + + /* todo fun bind(request: HttpServletRequest): Form = + bind(request.getParameterMap().flatMap { + val param = it.key + val values = it.value + + when { + values == null -> listOf(param to null) + values.size() == 1 -> listOf(param to values[0]) + else -> + values.mapIndexed {(i, value) -> + "$param[$i]" to value + } + } + }.toMap())*/ + + fun fill(value: T) = Form(mapping, data, errors, value) + + fun validate() = Form(mapping).bind(unbound) + + inner class FormFieldImpl(override val fieldName: String) : FormField { + override val value: String? = if (this@Form.value != null) unbound[fieldName] else data[fieldName] + override val errors: List = errors(fieldName) + override fun get(field: KMutableMemberProperty) = + FormFieldImpl("$fieldName.${field.name}") + + override fun toString() = + "FormField{name=$fieldName; value=$value; errors=$errors}" + } + + fun get(field: KMutableMemberProperty): FormField = + FormFieldImpl(field.name) +} + +class BeanFieldsMapping(private val mapping: Collection>, + private val factory: () -> T) : ValidationBinder, T> { + + override fun unbind(data: T): Map { + val result = hashMapOf() + + mapping.forEach { + result.putAll(it.unbind(data)) + } + + return result + } + + override fun bind(data: Map, context: String): BindingResult { + val result = factory() + + val errors = ArrayList() + + mapping.forEach { + it.bind(data, result, context(context, it.field.name), errors) + } + + return when { + errors.isEmpty() -> SuccessBinding(result) + else -> FailedBinding(errors) + } + } + + private fun context(parent: String, child: String) = + if (parent.isNotEmpty()) "$parent.$child" else child +} + +fun mapping(vararg mappings: FieldMapping, factory: () -> T) = + BeanFieldsMapping(listOf(*mappings), factory) + +trait FieldMapping { + val field: KMutableMemberProperty + val binder: FormBinder<*, Field> + + fun bind(data: Map, context: String): BindingResult + + fun bind(data: Map, obj: Bean, context: String, errors: MutableCollection) { + val bindingResult = bind(data, context) + + when (bindingResult) { + is SuccessBinding -> + field.set(obj, bindingResult.value) + is FailedBinding -> + errors.addAll(bindingResult.errors) + } + } + + fun unbind(obj: Bean): Map +} + +class SimpleMapping(override val field: KMutableMemberProperty, + override val binder: FormBinder) : FieldMapping { + override fun unbind(obj: Bean): Map = + mapOf(field.name to binder.unbind(field.get(obj))) + + override fun bind(data: Map, context: String) = binder.bind(data[context], context) +} + +class ComplexMapping(override val field: KMutableMemberProperty, + override val binder: FormBinder, Field>) : FieldMapping { + override fun unbind(obj: Bean): Map = + binder.unbind(field.get(obj)) mapKeys { "${field.name}.${it.key}" } + + override fun bind(data: Map, context: String) = binder.bind(data, context) +} + +fun KMutableMemberProperty.mapTo(binder: FormBinder) = + SimpleMapping(this, binder) + +fun KMutableMemberProperty?>.toList(binder: FormBinder) = + ComplexMapping(this, object : FormBinder, List?> { + override fun bind(data: Map, context: String): BindingResult?> { + val pattern = Pattern.compile(Pattern.quote(context) + """\[(?\d+)\]""") + + val matched = sortedMapOf>() + + data.forEach { + val matcher = pattern.matcher(it.key) + + if (matcher.matches()) + matched[matcher.group("index").toInt()] = binder.bind(it.value, it.key) + } + + val bindings = matched.values() + + if (bindings.all { it is SuccessBinding }) + return SuccessBinding(bindings.map { (it as SuccessBinding).value }) + + return FailedBinding( + bindings.filter { it is FailedBinding } + .flatMap { (it as FailedBinding).errors } + ) + } + + override fun unbind(data: List?): Map = + data?.mapIndexed {(index, value) -> + "[$index]" to binder.unbind(value) + }?.toMap() ?: mapOf() + }) + +[suppress("BASE_WITH_NULLABLE_UPPER_BOUND")] +fun KMutableMemberProperty.toNested(binder: FormBinder, Field>) = + ComplexMapping(this, nullableFieldBinderWrapper(binder, mapOf())) + +[suppress("BASE_WITH_NULLABLE_UPPER_BOUND")] +private fun nullableFieldBinderWrapper(binder: FormBinder, nullValue: T): FormBinder = + object : FormBinder { + override fun unbind(data: V?): T = when { + data == null -> nullValue + else -> binder.unbind(data) + } + + [suppress("UNCHECKED_CAST")] + override fun bind(data: T, context: String): BindingResult = + binder.bind(data, context) as BindingResult + } \ No newline at end of file