From 5ff62fa68f1c6d9693aeb4c27116b77c43e1a309 Mon Sep 17 00:00:00 2001 From: Robert Jaros Date: Thu, 29 Mar 2018 01:05:02 +0200 Subject: Components for file upload. --- src/main/kotlin/pl/treksoft/kvision/KVManager.kt | 18 +- src/main/kotlin/pl/treksoft/kvision/form/Form.kt | 19 ++ .../kotlin/pl/treksoft/kvision/form/FormControl.kt | 18 ++ .../kotlin/pl/treksoft/kvision/form/FormPanel.kt | 19 ++ .../pl/treksoft/kvision/form/upload/Upload.kt | 304 +++++++++++++++++++++ .../pl/treksoft/kvision/form/upload/UploadInput.kt | 270 ++++++++++++++++++ src/main/kotlin/pl/treksoft/kvision/utils/Utils.kt | 22 +- 7 files changed, 668 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/pl/treksoft/kvision/form/upload/Upload.kt create mode 100644 src/main/kotlin/pl/treksoft/kvision/form/upload/UploadInput.kt (limited to 'src/main/kotlin') diff --git a/src/main/kotlin/pl/treksoft/kvision/KVManager.kt b/src/main/kotlin/pl/treksoft/kvision/KVManager.kt index 121f0ab7..ee7b2d43 100644 --- a/src/main/kotlin/pl/treksoft/kvision/KVManager.kt +++ b/src/main/kotlin/pl/treksoft/kvision/KVManager.kt @@ -44,7 +44,7 @@ external fun require(name: String): dynamic /** * Internal singleton object which initializes and configures KVision framework. */ -@Suppress("EmptyCatchBlock") +@Suppress("EmptyCatchBlock", "TooGenericExceptionCaught") internal object KVManager { internal const val AJAX_REQUEST_DELAY = 300 internal const val KVNULL = "#kvnull" @@ -118,6 +118,22 @@ internal object KVManager { require("element-resize-event") } catch (e: Throwable) { } + private val bootstrapFileinputCss = try { + require("bootstrap-fileinput/css/fileinput.min.css") + } catch (e: Throwable) { + } + private val bootstrapFileinputCssFa = try { + require("bootstrap-fileinput/themes/explorer-fa/theme.min.css") + } catch (e: Throwable) { + } + private val bootstrapFileinput = try { + require("bootstrap-fileinput") + } catch (e: Throwable) { + } + private val bootstrapFileinputFa = try { + require("bootstrap-fileinput/themes/explorer-fa/theme.min.js") + } catch (e: Throwable) { + } private val resizable = require("jquery-resizable-dom") internal val fecha = require("fecha") diff --git a/src/main/kotlin/pl/treksoft/kvision/form/Form.kt b/src/main/kotlin/pl/treksoft/kvision/form/Form.kt index 4ce21ec6..65161303 100644 --- a/src/main/kotlin/pl/treksoft/kvision/form/Form.kt +++ b/src/main/kotlin/pl/treksoft/kvision/form/Form.kt @@ -21,6 +21,7 @@ */ package pl.treksoft.kvision.form +import org.w3c.files.File import kotlin.js.Date import kotlin.js.Json import kotlin.reflect.KProperty1 @@ -42,6 +43,7 @@ internal data class FieldParams( * @param panel optional instance of [FormPanel] * @param modelFactory function transforming a Map to a data model of class K */ +@Suppress("TooManyFunctions") class Form(private val panel: FormPanel? = null, private val modelFactory: (Map) -> K) { internal val fields: MutableMap = mutableMapOf() @@ -127,6 +129,23 @@ class Form(private val panel: FormPanel? = null, private val modelFactory: return addInternal(key, control, required, validatorMessage, validator) } + /** + * Adds a files control to the form. + * @param key key identifier of the control + * @param control the files form control + * @param required determines if the control is required + * @param validatorMessage optional function returning validation message + * @param validator optional validation function + * @return current form + */ + fun add( + key: KProperty1?>, control: C, required: Boolean = false, + validatorMessage: ((C) -> String?)? = null, + validator: ((C) -> Boolean?)? = null + ): Form { + return addInternal(key, control, required, validatorMessage, validator) + } + /** * Removes a control from the form. * @param key key identifier of the control diff --git a/src/main/kotlin/pl/treksoft/kvision/form/FormControl.kt b/src/main/kotlin/pl/treksoft/kvision/form/FormControl.kt index 091f4162..be27ac7a 100644 --- a/src/main/kotlin/pl/treksoft/kvision/form/FormControl.kt +++ b/src/main/kotlin/pl/treksoft/kvision/form/FormControl.kt @@ -21,6 +21,7 @@ */ package pl.treksoft.kvision.form +import org.w3c.files.File import pl.treksoft.kvision.core.Component import kotlin.js.Date @@ -202,3 +203,20 @@ interface DateFormControl : FormControl { override fun getValueAsString(): String? = value?.toString() } + +/** + * Base interface of a form control with a list of files value. + */ +interface FilesFormControl : FormControl { + /** + * List of files value. + */ + var value: List? + + override fun getValue(): List? = value + override fun setValue(v: Any?) { + if (v == null) value = null + } + + override fun getValueAsString(): String? = value?.joinToString { it.name } +} diff --git a/src/main/kotlin/pl/treksoft/kvision/form/FormPanel.kt b/src/main/kotlin/pl/treksoft/kvision/form/FormPanel.kt index 2a274684..3eb2a0ca 100644 --- a/src/main/kotlin/pl/treksoft/kvision/form/FormPanel.kt +++ b/src/main/kotlin/pl/treksoft/kvision/form/FormPanel.kt @@ -22,6 +22,7 @@ package pl.treksoft.kvision.form import com.github.snabbdom.VNode +import org.w3c.files.File import pl.treksoft.kvision.core.Container import pl.treksoft.kvision.core.StringBoolPair import pl.treksoft.kvision.core.StringPair @@ -81,6 +82,7 @@ enum class FormTarget(internal val target: String) { * @param classes set of CSS class names * @param modelFactory function transforming a Map to a data model of class K */ +@Suppress("TooManyFunctions") open class FormPanel( method: FormMethod? = null, action: String? = null, enctype: FormEnctype? = null, private val type: FormType? = null, classes: Set = setOf(), @@ -287,6 +289,23 @@ open class FormPanel( return addInternal(key, control, required, validatorMessage, validator) } + /** + * Adds a files control to the form panel. + * @param key key identifier of the control + * @param control the files form control + * @param required determines if the control is required + * @param validatorMessage optional function returning validation message + * @param validator optional validation function + * @return current form panel + */ + open fun add( + key: KProperty1?>, control: C, required: Boolean = false, + validatorMessage: ((C) -> String?)? = null, + validator: ((C) -> Boolean?)? = null + ): FormPanel { + return addInternal(key, control, required, validatorMessage, validator) + } + /** * Removes a control from the form panel. * @param key key identifier of the control diff --git a/src/main/kotlin/pl/treksoft/kvision/form/upload/Upload.kt b/src/main/kotlin/pl/treksoft/kvision/form/upload/Upload.kt new file mode 100644 index 00000000..e6b397eb --- /dev/null +++ b/src/main/kotlin/pl/treksoft/kvision/form/upload/Upload.kt @@ -0,0 +1,304 @@ +/* + * Copyright (c) 2018. Robert Jaros + */ +package pl.treksoft.kvision.form.upload + +import pl.treksoft.kvision.core.Container +import pl.treksoft.kvision.core.StringBoolPair +import pl.treksoft.kvision.core.Widget +import pl.treksoft.kvision.form.FieldLabel +import pl.treksoft.kvision.form.FilesFormControl +import pl.treksoft.kvision.form.HelpBlock +import pl.treksoft.kvision.panel.SimplePanel +import pl.treksoft.kvision.utils.SnOn + +/** + * The form field file upload component. + * + * @constructor + * @param uploadUrl the optional URL for the upload processing action + * @param multiple determines if multiple file upload is supported + * @param label label text bound to the input element + * @param rich determines if [label] can contain HTML code + */ +@Suppress("TooManyFunctions") +open class Upload( + uploadUrl: String? = null, multiple: Boolean = false, label: String? = null, + rich: Boolean = false +) : SimplePanel(setOf("form-group")), FilesFormControl { + + /** + * File input value. + */ + override var value + get() = input.value + set(value) { + input.value = value + } + /** + * The optional URL for the upload processing action. + * If not set the upload button action will default to form submission. + */ + var uploadUrl + get() = input.uploadUrl + set(value) { + input.uploadUrl = value + } + /** + * Determines if multiple file upload is supported. + */ + var multiple + get() = input.multiple + set(value) { + input.multiple = value + } + /** + * The extra data that will be passed as data to the AJAX server call via POST. + */ + var uploadExtraData + get() = input.uploadExtraData + set(value) { + input.uploadExtraData = value + } + /** + * Determines if the explorer theme is used. + */ + var explorerTheme + get() = input.explorerTheme + set(value) { + input.explorerTheme = value + } + /** + * Determines if the input selection is required. + */ + var required + get() = input.required + set(value) { + input.required = value + } + /** + * Determines if the caption is shown. + */ + var showCaption + get() = input.showCaption + set(value) { + input.showCaption = value + } + /** + * Determines if the preview is shown. + */ + var showPreview + get() = input.showPreview + set(value) { + input.showPreview = value + } + /** + * Determines if the remove button is shown. + */ + var showRemove + get() = input.showRemove + set(value) { + input.showRemove = value + } + /** + * Determines if the upload button is shown. + */ + var showUpload + get() = input.showUpload + set(value) { + input.showUpload = value + } + /** + * Determines if the cancel button is shown. + */ + var showCancel + get() = input.showCancel + set(value) { + input.showCancel = value + } + /** + * Determines if the file browse button is shown. + */ + var showBrowse + get() = input.showBrowse + set(value) { + input.showBrowse = value + } + /** + * Determines if the click on the preview zone opens file browse window. + */ + var browseOnZoneClick + get() = input.browseOnZoneClick + set(value) { + input.browseOnZoneClick = value + } + /** + * Determines if the iconic preview is prefered. + */ + var preferIconicPreview + get() = input.preferIconicPreview + set(value) { + input.preferIconicPreview = value + } + /** + * Allowed file types. + */ + var allowedFileTypes + get() = input.allowedFileTypes + set(value) { + input.allowedFileTypes = value + } + /** + * Allowed file extensions. + */ + var allowedFileExtensions + get() = input.allowedFileExtensions + set(value) { + input.allowedFileExtensions = value + } + /** + * Determines if Drag&Drop zone is enabled. + */ + var dropZoneEnabled + get() = input.dropZoneEnabled + set(value) { + input.dropZoneEnabled = value + } + /** + * The label text bound to the spinner input element. + */ + var label + get() = flabel.content + set(value) { + flabel.content = value + } + /** + * Determines if [label] can contain HTML code. + */ + var rich + get() = flabel.rich + set(value) { + flabel.rich = value + } + + protected val idc = "kv_form_upload_$counter" + final override val input: UploadInput = UploadInput(uploadUrl, multiple) + .apply { + this.id = idc + this.name = name + } + final override val flabel: FieldLabel = FieldLabel(idc, label, rich) + final override val validationInfo: HelpBlock = HelpBlock().apply { visible = false } + + init { + @Suppress("LeakingThis") + input.eventTarget = this + this.addInternal(flabel) + this.addInternal(input) + this.addInternal(validationInfo) + counter++ + } + + override fun getSnClass(): List { + val cl = super.getSnClass().toMutableList() + if (validatorError != null) { + cl.add("has-error" to true) + } + return cl + } + + @Suppress("UNCHECKED_CAST") + override fun setEventListener(block: SnOn.() -> Unit): Widget { + input.setEventListener(block) + return this + } + + override fun setEventListener(block: SnOn.() -> Unit): Widget { + input.setEventListener(block) + return this + } + + override fun removeEventListeners(): Widget { + input.removeEventListeners() + return this + } + + override fun getValueAsString(): String? { + return input.getValueAsString() + } + + /** + * Resets the file input control. + */ + open fun resetInput() { + input.resetInput() + } + + /** + * Clears the file input control (including the native input). + */ + open fun clearInput() { + input.clearInput() + } + + /** + * Trigger ajax upload (only for ajax mode). + */ + open fun upload() { + input.upload() + } + + /** + * Cancel an ongoing ajax upload (only for ajax mode). + */ + open fun cancel() { + input.cancel() + } + + /** + * Locks the file input (disabling all buttons except a cancel button). + */ + open fun lock() { + input.lock() + } + + /** + * Unlocks the file input. + */ + open fun unlock() { + input.unlock() + } + + override fun focus() { + input.focus() + } + + override fun blur() { + input.blur() + } + + companion object { + internal var counter = 0 + + /** + * DSL builder extension function. + * + * It takes the same parameters as the constructor of the built component. + */ + fun Container.upload( + uploadUrl: String? = null, + multiple: Boolean = false, + label: String? = null, + rich: Boolean = false, + init: (Upload.() -> Unit)? = null + ): Upload { + val upload = Upload(uploadUrl, multiple, label, rich).apply { + init?.invoke( + this + ) + } + this.add(upload) + return upload + } + } +} diff --git a/src/main/kotlin/pl/treksoft/kvision/form/upload/UploadInput.kt b/src/main/kotlin/pl/treksoft/kvision/form/upload/UploadInput.kt new file mode 100644 index 00000000..b0c07c3e --- /dev/null +++ b/src/main/kotlin/pl/treksoft/kvision/form/upload/UploadInput.kt @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2018. Robert Jaros + */ +package pl.treksoft.kvision.form.upload + +import com.github.snabbdom.VNode +import org.w3c.files.File +import pl.treksoft.kvision.core.Container +import pl.treksoft.kvision.core.StringBoolPair +import pl.treksoft.kvision.core.StringPair +import pl.treksoft.kvision.core.Widget +import pl.treksoft.kvision.form.FormInput +import pl.treksoft.kvision.form.InputSize +import pl.treksoft.kvision.utils.obj + +/** + * The file upload component. + * + * @constructor + * @param uploadUrl the optional URL for the upload processing action + * @param multiple determines if multiple file upload is supported + * @param classes a set of CSS class names + */ +@Suppress("TooManyFunctions") +open class UploadInput(uploadUrl: String? = null, multiple: Boolean = false, classes: Set = setOf()) : + Widget(classes + "form-control"), FormInput { + + /** + * File input value. + */ + var value: List? + get() = getValue() + set(value) { + if (value == null) resetInput() + } + + /** + * The optional URL for the upload processing action. + * If not set the upload button action will default to form submission. + */ + var uploadUrl: String? by refreshOnUpdate(uploadUrl, { refreshUploadInput() }) + /** + * Determines if multiple file upload is supported. + */ + var multiple: Boolean by refreshOnUpdate(multiple, { refresh(); refreshUploadInput() }) + /** + * The extra data that will be passed as data to the AJAX server call via POST. + */ + var uploadExtraData: ((String, Int) -> dynamic)? by refreshOnUpdate({ refreshUploadInput() }) + /** + * Determines if the explorer theme is used. + */ + var explorerTheme: Boolean by refreshOnUpdate(false, { refreshUploadInput() }) + /** + * Determines if the input selection is required. + */ + var required: Boolean by refreshOnUpdate(false, { refreshUploadInput() }) + /** + * Determines if the caption is shown. + */ + var showCaption: Boolean by refreshOnUpdate(true, { refreshUploadInput() }) + /** + * Determines if the preview is shown. + */ + var showPreview: Boolean by refreshOnUpdate(true, { refreshUploadInput() }) + /** + * Determines if the remove button is shown. + */ + var showRemove: Boolean by refreshOnUpdate(true, { refreshUploadInput() }) + /** + * Determines if the upload button is shown. + */ + var showUpload: Boolean by refreshOnUpdate(true, { refreshUploadInput() }) + /** + * Determines if the cancel button is shown. + */ + var showCancel: Boolean by refreshOnUpdate(true, { refreshUploadInput() }) + /** + * Determines if the file browse button is shown. + */ + var showBrowse: Boolean by refreshOnUpdate(true, { refreshUploadInput() }) + /** + * Determines if the click on the preview zone opens file browse window. + */ + var browseOnZoneClick: Boolean by refreshOnUpdate(true, { refreshUploadInput() }) + /** + * Determines if the iconic preview is prefered. + */ + var preferIconicPreview: Boolean by refreshOnUpdate(false, { refreshUploadInput() }) + /** + * Allowed file types. + */ + var allowedFileTypes: Set? by refreshOnUpdate({ refreshUploadInput() }) + /** + * Allowed file extensions. + */ + var allowedFileExtensions: Set? by refreshOnUpdate({ refreshUploadInput() }) + /** + * Determines if Drag&Drop zone is enabled. + */ + var dropZoneEnabled: Boolean by refreshOnUpdate(true, { refreshUploadInput() }) + /** + * The name attribute of the generated HTML input element. + */ + override var name: String? by refreshOnUpdate() + /** + * Determines if the field is disabled. + */ + override var disabled by refreshOnUpdate(false, { refresh(); refreshUploadInput() }) + /** + * The size of the input (currently not working) + */ + override var size: InputSize? by refreshOnUpdate() + + override fun render(): VNode { + return render("input") + } + + override fun getSnClass(): List { + val cl = super.getSnClass().toMutableList() + size?.let { + cl.add(it.className to true) + } + return cl + } + + override fun getSnAttrs(): List { + val sn = super.getSnAttrs().toMutableList() + sn.add("type" to "file") + name?.let { + sn.add("name" to it) + } + if (multiple) { + sn.add("multiple" to "true") + } + if (disabled) { + sn.add("disabled" to "disabled") + } + return sn + } + + private fun getValue(): List? { + val v = getFiles() + return if (v.isNotEmpty()) v else null + } + + override fun afterInsert(node: VNode) { + getElementJQueryD()?.fileinput(getSettingsObj()) + } + + override fun afterDestroy() { + getElementJQueryD()?.fileinput("destroy") + } + + private fun refreshUploadInput() { + getElementJQueryD()?.fileinput("refresh", getSettingsObj()) + } + + /** + * Resets the file input control. + */ + open fun resetInput() { + getElementJQueryD()?.fileinput("reset") + } + + /** + * Clears the file input control (including the native input). + */ + open fun clearInput() { + getElementJQueryD()?.fileinput("clear") + } + + /** + * Trigger ajax upload (only for ajax mode). + */ + open fun upload() { + getElementJQueryD()?.fileinput("upload") + } + + /** + * Cancel an ongoing ajax upload (only for ajax mode). + */ + open fun cancel() { + getElementJQueryD()?.fileinput("cancel") + } + + /** + * Locks the file input (disabling all buttons except a cancel button). + */ + open fun lock() { + getElementJQueryD()?.fileinput("lock") + } + + /** + * Unlocks the file input. + */ + open fun unlock() { + getElementJQueryD()?.fileinput("unlock") + } + + private fun getFiles(): List { + return (getElementJQueryD()?.fileinput("getFileStack") as Array).toList() + } + + /** + * Returns the value of the file input control as a String. + * @return value as a String + */ + fun getValueAsString(): String? { + return value?.joinToString { it.name } + } + + /** + * Makes the input element focused. + */ + open fun focus() { + getElementJQuery()?.focus() + } + + /** + * Makes the input element blur. + */ + open fun blur() { + getElementJQuery()?.blur() + } + + private fun getSettingsObj(): dynamic { + return obj { + this.uploadUrl = uploadUrl + this.uploadExtraData = uploadExtraData ?: undefined + this.theme = if (explorerTheme) "explorer-fa" else null + this.required = required + this.showCaption = showCaption + this.showPreview = showPreview + this.showRemove = showRemove + this.showUpload = showUpload + this.showCancel = showCancel + this.showBrowse = showBrowse + this.browseOnZoneClick = browseOnZoneClick + this.preferIconicPreview = preferIconicPreview + this.allowedFileTypes = allowedFileTypes?.toTypedArray() + this.allowedFileExtensions = allowedFileExtensions?.toTypedArray() + this.dropZoneEnabled = dropZoneEnabled + } + } + + companion object { + internal var counter = 0 + + /** + * DSL builder extension function. + * + * It takes the same parameters as the constructor of the built component. + */ + fun Container.uploadInput( + uploadUrl: String? = null, + multiple: Boolean = false, + classes: Set = setOf(), + init: (UploadInput.() -> Unit)? = null + ): UploadInput { + val uploadInput = UploadInput(uploadUrl, multiple, classes).apply { + init?.invoke( + this + ) + } + this.add(uploadInput) + return uploadInput + } + } +} diff --git a/src/main/kotlin/pl/treksoft/kvision/utils/Utils.kt b/src/main/kotlin/pl/treksoft/kvision/utils/Utils.kt index d638f79e..ba9467c3 100644 --- a/src/main/kotlin/pl/treksoft/kvision/utils/Utils.kt +++ b/src/main/kotlin/pl/treksoft/kvision/utils/Utils.kt @@ -23,6 +23,9 @@ package pl.treksoft.kvision.utils +import kotlinx.coroutines.experimental.suspendCancellableCoroutine +import org.w3c.files.File +import org.w3c.files.FileReader import pl.treksoft.kvision.KVManager import pl.treksoft.kvision.core.CssSize import pl.treksoft.kvision.core.UNIT @@ -96,7 +99,7 @@ val Int.mm: CssSize /** * Extension property to convert Int to CSS in units. */ -@Suppress("FunctionNaming") +@Suppress("TopLevelPropertyNaming") val Int.`in`: CssSize get() { return Pair(this, UNIT.`in`) @@ -204,3 +207,20 @@ fun Date.toStringF(format: String = "YYYY-MM-DD HH:mm:ss"): String { * @return true if the current browser is IE11 */ fun isIE11(): Boolean = window.navigator.userAgent.matches("Trident\\/7\\.") + +/** + * Suspending extension function to get file content. + * @return file content + */ +@Suppress("EXPERIMENTAL_FEATURE_WARNING") +suspend fun File.getContent() = suspendCancellableCoroutine { cont -> + val reader = FileReader() + reader.onload = { + @Suppress("UnsafeCastFromDynamic") + cont.resume(reader.result) + } + reader.onerror = { e -> + cont.resumeWithException(Exception(e.type)) + } + reader.readAsDataURL(this@getContent) +} -- cgit