Compare commits

..

No commits in common. "c877fcbce37cc6a4f1049a5a8ed7a2762445d739" and "469655092e8c7f871dfb610f1c34555d82da0d81" have entirely different histories.

7 changed files with 79 additions and 246 deletions

View File

@ -2,8 +2,10 @@ package space.kscience.visionforge
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import space.kscience.dataforge.context.Plugin import space.kscience.dataforge.context.Plugin
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.MetaRepr import space.kscience.dataforge.meta.MetaRepr
import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.parseAsName
/** /**
* A feedback client that communicates with a server and provides ability to propagate events and changes back to the model * A feedback client that communicates with a server and provides ability to propagate events and changes back to the model
@ -13,25 +15,25 @@ public interface VisionClient: Plugin {
public suspend fun sendEvent(targetName: Name, event: VisionEvent) public suspend fun sendEvent(targetName: Name, event: VisionEvent)
// public fun notifyPropertyChanged(visionName: Name, propertyName: Name, item: Meta?) public fun notifyPropertyChanged(visionName: Name, propertyName: Name, item: Meta?)
} }
//public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Meta?) { public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Meta?) {
// notifyPropertyChanged(visionName, propertyName.parseAsName(true), item) notifyPropertyChanged(visionName, propertyName.parseAsName(true), item)
//} }
//
//public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Number) { public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Number) {
// notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item)) notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item))
//} }
//
//public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: String) { public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: String) {
// notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item)) notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item))
//} }
//
//public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Boolean) { public fun VisionClient.notifyPropertyChanged(visionName: Name, propertyName: String, item: Boolean) {
// notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item)) notifyPropertyChanged(visionName, propertyName.parseAsName(true), Meta(item))
//} }
public fun VisionClient.sendEvent(targetName: Name, payload: MetaRepr): Unit { public fun VisionClient.sendEvent(targetName: Name, payload: MetaRepr): Unit {
context.launch { context.launch {

View File

@ -13,7 +13,10 @@ import space.kscience.dataforge.meta.descriptors.MetaDescriptor
import space.kscience.dataforge.meta.toJson import space.kscience.dataforge.meta.toJson
import space.kscience.dataforge.meta.toMeta import space.kscience.dataforge.meta.toMeta
import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.Name
import space.kscience.visionforge.html.* import space.kscience.visionforge.html.VisionOfCheckbox
import space.kscience.visionforge.html.VisionOfHtmlForm
import space.kscience.visionforge.html.VisionOfNumberField
import space.kscience.visionforge.html.VisionOfTextField
public class VisionManager(meta: Meta) : AbstractPlugin(meta), MutableVisionContainer<Vision> { public class VisionManager(meta: Meta) : AbstractPlugin(meta), MutableVisionContainer<Vision> {
override val tag: PluginTag get() = Companion.tag override val tag: PluginTag get() = Companion.tag
@ -69,11 +72,9 @@ public class VisionManager(meta: Meta) : AbstractPlugin(meta), MutableVisionCont
defaultDeserializer { SimpleVisionGroup.serializer() } defaultDeserializer { SimpleVisionGroup.serializer() }
subclass(NullVision.serializer()) subclass(NullVision.serializer())
subclass(SimpleVisionGroup.serializer()) subclass(SimpleVisionGroup.serializer())
subclass(VisionOfHtmlInput.serializer())
subclass(VisionOfNumberField.serializer()) subclass(VisionOfNumberField.serializer())
subclass(VisionOfTextField.serializer()) subclass(VisionOfTextField.serializer())
subclass(VisionOfCheckbox.serializer()) subclass(VisionOfCheckbox.serializer())
subclass(VisionOfRangeField.serializer())
subclass(VisionOfHtmlForm.serializer()) subclass(VisionOfHtmlForm.serializer())
} }
} }

View File

@ -1,8 +1,6 @@
package space.kscience.visionforge.html package space.kscience.visionforge.html
import kotlinx.html.InputType import kotlinx.html.InputType
import kotlinx.html.TagConsumer
import kotlinx.html.stream.createHTML
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import space.kscience.dataforge.meta.* import space.kscience.dataforge.meta.*
@ -15,39 +13,10 @@ public abstract class VisionOfHtml : AbstractVision() {
public var classes: List<String> by properties.stringList(*emptyArray()) public var classes: List<String> by properties.stringList(*emptyArray())
} }
@Serializable
@SerialName("html.plain")
public class VisionOfPlainHtml : VisionOfHtml() {
public var content: String? by properties.string()
}
public inline fun VisionOfPlainHtml.content(block: TagConsumer<*>.() -> Unit) {
content = createHTML().apply(block).finalize()
}
@Serializable
public enum class InputFeedbackMode{
/**
* Fire feedback event on `onchange` event
*/
ONCHANGE,
/**
* Fire feedback event on `oninput` event
*/
ONINPUT,
/**
* provide only manual feedback
*/
NONE
}
@Serializable @Serializable
@SerialName("html.input") @SerialName("html.input")
public open class VisionOfHtmlInput( public open class VisionOfHtmlInput(
public val inputType: String, public val inputType: String,
public val feedbackMode: InputFeedbackMode = InputFeedbackMode.ONCHANGE
) : VisionOfHtml() { ) : VisionOfHtml() {
public var value : Value? by properties.value() public var value : Value? by properties.value()
public var disabled: Boolean by properties.boolean { false } public var disabled: Boolean by properties.boolean { false }

View File

@ -39,22 +39,9 @@ public fun Vision.useProperty(
callback: (Meta) -> Unit, callback: (Meta) -> Unit,
): Job = useProperty(propertyName.parseAsName(), inherit, includeStyles, scope, callback) ): Job = useProperty(propertyName.parseAsName(), inherit, includeStyles, scope, callback)
/**
* Observe changes to the specific property without passing the initial value.
*/
public fun <V : Vision, T> V.onPropertyChange(
property: KProperty1<V, T>,
scope: CoroutineScope = manager?.context ?: error("Orphan Vision can't observe properties"),
callback: suspend V.(T) -> Unit,
): Job = properties.changes.onEach { name ->
if (name.startsWith(property.name.asName())) {
callback(property.get(this))
}
}.launchIn(scope)
public fun <V : Vision, T> V.useProperty( public fun <V : Vision, T> V.useProperty(
property: KProperty1<V, T>, property: KProperty1<V, T>,
scope: CoroutineScope = manager?.context ?: error("Orphan Vision can't observe properties"), scope: CoroutineScope? = manager?.context,
callback: V.(T) -> Unit, callback: V.(T) -> Unit,
): Job { ): Job {
//Pass initial value. //Pass initial value.
@ -63,5 +50,5 @@ public fun <V : Vision, T> V.useProperty(
if (name.startsWith(property.name.asName())) { if (name.startsWith(property.name.asName())) {
callback(property.get(this@useProperty)) callback(property.get(this@useProperty))
} }
}.launchIn(scope) }.launchIn(scope ?: error("Orphan Vision can't observe properties"))
} }

View File

@ -26,7 +26,7 @@ public interface ElementVisionRenderer : Named {
/** /**
* Give a [vision] integer rating based on this renderer capabilities. [ZERO_RATING] or negative values means that this renderer * Give a [vision] integer rating based on this renderer capabilities. [ZERO_RATING] or negative values means that this renderer
* can't process a vision. The value of [DEFAULT_RATING] used for default renderer. Specialized renderers could specify * can't process a vision. The value of [DEFAULT_RATING] used for default renderer. Specialized renderers could specify
* higher value to "steal" rendering job * higher value in order to "steal" rendering job
*/ */
public fun rateVision(vision: Vision): Int public fun rateVision(vision: Vision): Int

View File

@ -65,20 +65,20 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
private fun Element.getFlag(attribute: String): Boolean = attributes[attribute]?.value != null private fun Element.getFlag(attribute: String): Boolean = attributes[attribute]?.value != null
// private val mutex = Mutex() private val mutex = Mutex()
private val changeCollector = VisionChangeBuilder()
/**
// /** * Communicate vision property changed from rendering engine to model
// * Communicate vision property changed from rendering engine to model */
// */ override fun notifyPropertyChanged(visionName: Name, propertyName: Name, item: Meta?) {
// private fun notifyPropertyChanged(visionName: Name, propertyName: Name, item: Meta?) { context.launch {
// context.launch { mutex.withLock {
// mutex.withLock { changeCollector.propertyChanged(visionName, propertyName, item)
// changeCollector.propertyChanged(visionName, propertyName, item) }
// } }
// } }
// }
private val eventCollector by lazy { private val eventCollector by lazy {
MutableSharedFlow<Pair<Name, VisionEvent>>(meta["feedback.eventCache"].int ?: 100) MutableSharedFlow<Pair<Name, VisionEvent>>(meta["feedback.eventCache"].int ?: 100)
@ -97,7 +97,7 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
renderer.render(element, name, vision, outputMeta) renderer.render(element, name, vision, outputMeta)
} }
private fun startVisionUpdate(element: Element, visionName: Name, vision: Vision, outputMeta: Meta) { private fun startVisionUpdate(element: Element, name: Name, vision: Vision?, outputMeta: Meta) {
element.attributes[OUTPUT_CONNECT_ATTRIBUTE]?.let { attr -> element.attributes[OUTPUT_CONNECT_ATTRIBUTE]?.let { attr ->
val wsUrl = if (attr.value.isBlank() || attr.value == VisionTagConsumer.AUTO_DATA_ATTRIBUTE) { val wsUrl = if (attr.value.isBlank() || attr.value == VisionTagConsumer.AUTO_DATA_ATTRIBUTE) {
val endpoint = resolveEndpoint(element) val endpoint = resolveEndpoint(element)
@ -109,10 +109,9 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
URL(attr.value) URL(attr.value)
}.apply { }.apply {
protocol = "ws" protocol = "ws"
searchParams.append("name", visionName.toString()) searchParams.append("name", name.toString())
} }
logger.info { "Updating vision data from $wsUrl" } logger.info { "Updating vision data from $wsUrl" }
//Individual websocket for this vision //Individual websocket for this vision
@ -128,11 +127,12 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
// If change contains root vision replacement, do it // If change contains root vision replacement, do it
if(event is VisionChange) { if(event is VisionChange) {
event.vision?.let { vision -> event.vision?.let { vision ->
renderVision(element, visionName, vision, outputMeta) renderVision(element, name, vision, outputMeta)
} }
} }
logger.debug { "Got $event for output with name $visionName" } logger.debug { "Got $event for output with name $name" }
if (vision == null) error("Can't update vision because it is not loaded.")
vision.receiveEvent(event) vision.receiveEvent(event)
} else { } else {
logger.error { "WebSocket message data is not a string" } logger.error { "WebSocket message data is not a string" }
@ -147,44 +147,32 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
val feedbackAggregationTime = meta["feedback.aggregationTime"]?.int ?: 300 val feedbackAggregationTime = meta["feedback.aggregationTime"]?.int ?: 300
onopen = { onopen = {
val mutex = Mutex()
val changeCollector = VisionChangeBuilder()
feedbackJob = visionManager.context.launch { feedbackJob = visionManager.context.launch {
//launch a separate coroutine to send events to the backend eventCollector.filter { it.first == name }.onEach {
eventCollector.filter { it.first == visionName }.onEach {
send(visionManager.jsonFormat.encodeToString(VisionEvent.serializer(), it.second)) send(visionManager.jsonFormat.encodeToString(VisionEvent.serializer(), it.second))
}.launchIn(this) }.launchIn(this)
//launch backward property propagation
vision.properties.changes.onEach { propertyName: Name ->
changeCollector.propertyChanged(visionName, propertyName, vision.properties.getMeta(propertyName))
}.launchIn(this)
//aggregate atomic changes
while (isActive) { while (isActive) {
delay(feedbackAggregationTime.milliseconds) delay(feedbackAggregationTime.milliseconds)
if (!changeCollector.isEmpty()) { val change = changeCollector[name] ?: continue
if (!change.isEmpty()) {
mutex.withLock { mutex.withLock {
eventCollector.emit(visionName to changeCollector.deepCopy(visionManager)) eventCollector.emit(name to change.deepCopy(visionManager))
changeCollector.reset() change.reset()
} }
} }
} }
} }
logger.info { "WebSocket feedback channel established for output '$visionName'" } logger.info { "WebSocket feedback channel established for output '$name'" }
} }
onclose = { onclose = {
feedbackJob?.cancel() feedbackJob?.cancel()
logger.info { "WebSocket feedback channel closed for output '$visionName'" } logger.info { "WebSocket feedback channel closed for output '$name'" }
} }
onerror = { onerror = {
feedbackJob?.cancel() feedbackJob?.cancel()
logger.error { "WebSocket feedback channel error for output '$visionName'" } logger.error { "WebSocket feedback channel error for output '$name'" }
} }
} }
} }
@ -253,9 +241,9 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
} }
//Try to load vision via websocket //Try to load vision via websocket
// element.attributes[OUTPUT_CONNECT_ATTRIBUTE] != null -> { element.attributes[OUTPUT_CONNECT_ATTRIBUTE] != null -> {
// startVisionUpdate(element, name, null, outputMeta) startVisionUpdate(element, name, null, outputMeta)
// } }
else -> error("No embedded vision data / fetch url for $name") else -> error("No embedded vision data / fetch url for $name")
} }
@ -264,13 +252,9 @@ public class JsVisionClient : AbstractPlugin(), VisionClient {
override fun content(target: String): Map<Name, Any> = if (target == ElementVisionRenderer.TYPE) { override fun content(target: String): Map<Name, Any> = if (target == ElementVisionRenderer.TYPE) {
listOf( listOf(
htmlVisionRenderer, numberVisionRenderer(),
inputVisionRenderer, textVisionRenderer(),
checkboxVisionRenderer, formVisionRenderer()
numberVisionRenderer,
textVisionRenderer,
rangeVisionRenderer,
formVisionRenderer
).associateByName() ).associateByName()
} else super<AbstractPlugin>.content(target) } else super<AbstractPlugin>.content(target)

View File

@ -2,12 +2,11 @@ package space.kscience.visionforge
import kotlinx.browser.document import kotlinx.browser.document
import kotlinx.html.InputType import kotlinx.html.InputType
import kotlinx.html.div
import kotlinx.html.js.input import kotlinx.html.js.input
import kotlinx.html.js.onChangeFunction
import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLFormElement import org.w3c.dom.HTMLFormElement
import org.w3c.dom.HTMLInputElement import org.w3c.dom.HTMLInputElement
import org.w3c.dom.events.Event
import org.w3c.dom.get import org.w3c.dom.get
import org.w3c.xhr.FormData import org.w3c.xhr.FormData
import space.kscience.dataforge.context.debug import space.kscience.dataforge.context.debug
@ -15,11 +14,7 @@ import space.kscience.dataforge.context.logger
import space.kscience.dataforge.meta.* import space.kscience.dataforge.meta.*
import space.kscience.visionforge.html.* import space.kscience.visionforge.html.*
/**
* Subscribes the HTML element to a given vision.
*
* @param vision The vision to subscribe to.
*/
private fun HTMLElement.subscribeToVision(vision: VisionOfHtml) { private fun HTMLElement.subscribeToVision(vision: VisionOfHtml) {
vision.useProperty(VisionOfHtml::classes) { vision.useProperty(VisionOfHtml::classes) {
classList.value = classes.joinToString(separator = " ") classList.value = classes.joinToString(separator = " ")
@ -27,11 +22,6 @@ private fun HTMLElement.subscribeToVision(vision: VisionOfHtml) {
} }
/**
* Subscribes the HTML input element to a given vision.
*
* @param inputVision The input vision to subscribe to.
*/
private fun HTMLInputElement.subscribeToInput(inputVision: VisionOfHtmlInput) { private fun HTMLInputElement.subscribeToInput(inputVision: VisionOfHtmlInput) {
subscribeToVision(inputVision) subscribeToVision(inputVision)
inputVision.useProperty(VisionOfHtmlInput::disabled) { inputVision.useProperty(VisionOfHtmlInput::disabled) {
@ -39,133 +29,33 @@ private fun HTMLInputElement.subscribeToInput(inputVision: VisionOfHtmlInput) {
} }
} }
internal val htmlVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfPlainHtml> { _, vision, _ ->
div {}.also { div ->
div.subscribeToVision(vision)
vision.useProperty(VisionOfPlainHtml::content) {
div.textContent = it
}
}
}
internal val inputVisionRenderer: ElementVisionRenderer = internal fun JsVisionClient.textVisionRenderer(): ElementVisionRenderer =
ElementVisionRenderer<VisionOfHtmlInput>(acceptRating = ElementVisionRenderer.DEFAULT_RATING - 1) { _, vision, _ -> ElementVisionRenderer<VisionOfTextField> { visionName, vision, _ ->
input { input {
type = InputType.text type = InputType.text
}.also { htmlInputElement -> onChangeFunction = {
val onEvent: (Event) -> Unit = { notifyPropertyChanged(visionName, VisionOfTextField::text.name, value)
vision.value = htmlInputElement.value.asValue()
} }
}.apply {
subscribeToInput(vision)
when (vision.feedbackMode) {
InputFeedbackMode.ONCHANGE -> htmlInputElement.onchange = onEvent
InputFeedbackMode.ONINPUT -> htmlInputElement.oninput = onEvent
InputFeedbackMode.NONE -> {}
}
htmlInputElement.subscribeToInput(vision)
vision.useProperty(VisionOfHtmlInput::value) {
htmlInputElement.value = it?.string ?: ""
}
}
}
internal val checkboxVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfCheckbox> { _, vision, _ ->
input {
type = InputType.checkBox
}.also { htmlInputElement ->
val onEvent: (Event) -> Unit = {
vision.checked = htmlInputElement.checked
}
when (vision.feedbackMode) {
InputFeedbackMode.ONCHANGE -> htmlInputElement.onchange = onEvent
InputFeedbackMode.ONINPUT -> htmlInputElement.oninput = onEvent
InputFeedbackMode.NONE -> {}
}
htmlInputElement.subscribeToInput(vision)
vision.useProperty(VisionOfCheckbox::checked) {
htmlInputElement.checked = it ?: false
}
}
}
internal val textVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfTextField> { _, vision, _ ->
input {
type = InputType.text
}.also { htmlInputElement ->
val onEvent: (Event) -> Unit = {
vision.text = htmlInputElement.value
}
when (vision.feedbackMode) {
InputFeedbackMode.ONCHANGE -> htmlInputElement.onchange = onEvent
InputFeedbackMode.ONINPUT -> htmlInputElement.oninput = onEvent
InputFeedbackMode.NONE -> {}
}
htmlInputElement.subscribeToInput(vision)
vision.useProperty(VisionOfTextField::text) { vision.useProperty(VisionOfTextField::text) {
htmlInputElement.value = it ?: "" value = (it ?: "").asValue()
} }
} }
} }
internal val numberVisionRenderer: ElementVisionRenderer = internal fun JsVisionClient.numberVisionRenderer(): ElementVisionRenderer =
ElementVisionRenderer<VisionOfNumberField> { _, vision, _ -> ElementVisionRenderer<VisionOfNumberField> { visionName, vision, _ ->
input { input {
type = InputType.text type = InputType.text
}.also { htmlInputElement -> onChangeFunction = {
notifyPropertyChanged(visionName, VisionOfNumberField::value.name, value)
val onEvent: (Event) -> Unit = {
htmlInputElement.value.toDoubleOrNull()?.let { vision.number = it }
} }
}.apply {
when (vision.feedbackMode) { subscribeToInput(vision)
InputFeedbackMode.ONCHANGE -> htmlInputElement.onchange = onEvent
InputFeedbackMode.ONINPUT -> htmlInputElement.oninput = onEvent
InputFeedbackMode.NONE -> {}
}
htmlInputElement.subscribeToInput(vision)
vision.useProperty(VisionOfNumberField::value) { vision.useProperty(VisionOfNumberField::value) {
htmlInputElement.valueAsNumber = it?.double ?: 0.0 value = (it?.double ?: 0.0).asValue()
}
}
}
internal val rangeVisionRenderer: ElementVisionRenderer =
ElementVisionRenderer<VisionOfRangeField> { _, vision, _ ->
input {
type = InputType.text
min = vision.min.toString()
max = vision.max.toString()
step = vision.step.toString()
}.also { htmlInputElement ->
val onEvent: (Event) -> Unit = {
htmlInputElement.value.toDoubleOrNull()?.let { vision.number = it }
}
when (vision.feedbackMode) {
InputFeedbackMode.ONCHANGE -> htmlInputElement.onchange = onEvent
InputFeedbackMode.ONINPUT -> htmlInputElement.oninput = onEvent
InputFeedbackMode.NONE -> {}
}
htmlInputElement.subscribeToInput(vision)
vision.useProperty(VisionOfRangeField::value) {
htmlInputElement.valueAsNumber = it?.double ?: 0.0
} }
} }
} }
@ -193,7 +83,7 @@ internal fun FormData.toMeta(): Meta {
return DynamicMeta(`object`) return DynamicMeta(`object`)
} }
internal val formVisionRenderer: ElementVisionRenderer = internal fun JsVisionClient.formVisionRenderer(): ElementVisionRenderer =
ElementVisionRenderer<VisionOfHtmlForm> { visionName, vision, _ -> ElementVisionRenderer<VisionOfHtmlForm> { visionName, vision, _ ->
val form = document.getElementById(vision.formId) as? HTMLFormElement val form = document.getElementById(vision.formId) as? HTMLFormElement
@ -201,10 +91,10 @@ internal val formVisionRenderer: ElementVisionRenderer =
form.subscribeToVision(vision) form.subscribeToVision(vision)
vision.manager?.logger?.debug { "Adding hooks to form with id = '$vision.formId'" } logger.debug { "Adding hooks to form with id = '$vision.formId'" }
vision.useProperty(VisionOfHtmlForm::values) { values -> vision.useProperty(VisionOfHtmlForm::values) { values ->
vision.manager?.logger?.debug { "Updating form '${vision.formId}' with values $values" } logger.debug { "Updating form '${vision.formId}' with values $values" }
val inputs = form.getElementsByTagName("input") val inputs = form.getElementsByTagName("input")
values?.valueSequence()?.forEach { (token, value) -> values?.valueSequence()?.forEach { (token, value) ->
(inputs[token.toString()] as? HTMLInputElement)?.value = value.toString() (inputs[token.toString()] as? HTMLInputElement)?.value = value.toString()
@ -214,7 +104,7 @@ internal val formVisionRenderer: ElementVisionRenderer =
form.onsubmit = { event -> form.onsubmit = { event ->
event.preventDefault() event.preventDefault()
val formData = FormData(form).toMeta() val formData = FormData(form).toMeta()
vision.values = formData notifyPropertyChanged(visionName, VisionOfHtmlForm::values.name, formData)
console.info("Sent: ${formData.toMap()}") console.info("Sent: ${formData.toMap()}")
false false
} }