From 24c8c17b7e63da7acfa7dc5f930a1a36c20e5ab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20tom=20W=C3=B6rden?= <h.tomwoerden@indiscale.com> Date: Fri, 18 Dec 2020 12:43:14 +0000 Subject: [PATCH] Add documentation on the forms module --- .gitignore | 3 + README_SETUP.md | 1 + src/core/js/ext_bottom_line.js | 47 +- src/core/js/form_elements.js | 1760 +++++++++++++++++--------------- src/doc/Makefile | 10 +- src/doc/conf.py | 9 +- src/doc/extension.rst | 13 + src/doc/extension/forms.rst | 80 ++ src/doc/index.rst | 3 +- test/docker/Dockerfile | 1 + 10 files changed, 1047 insertions(+), 880 deletions(-) create mode 100644 src/doc/extension.rst create mode 100644 src/doc/extension/forms.rst diff --git a/.gitignore b/.gitignore index 859153e6..f69db87a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ /build __pycache__ +# auto-generated sources +/src/doc/api + # screen logs screenlog.* xerr.log diff --git a/README_SETUP.md b/README_SETUP.md index efad321c..485835fd 100644 --- a/README_SETUP.md +++ b/README_SETUP.md @@ -81,5 +81,6 @@ Build documentation in `build/` with `make doc`. - sphinx - sphinx-autoapi - jsdoc (`npm install jsdoc`) +- jsdoc-sphinx (`npm install jsdoc-sphinx`) - sphinx-js - recommonmark diff --git a/src/core/js/ext_bottom_line.js b/src/core/js/ext_bottom_line.js index bd7ec6a9..e09ee3d6 100644 --- a/src/core/js/ext_bottom_line.js +++ b/src/core/js/ext_bottom_line.js @@ -23,6 +23,28 @@ 'use strict'; +/** + * @typedef {BottomLineConfig} + * @property {string|HTMLElement} fallback - Fallback content if none of + * the creators are applicable. + * @property {string} version - the version of the configuration which must + * match this module's version. + * @property {CreatorConfig[]} creators - an array of creators. + */ + +/** + * @typedef {CreatorConfig} + * @property {string} [id] - a unique id for the creator. optional, for + * debuggin purposes. + * @property {function|string} is_applicable - If this is a string this has + * to be valid javascript! An asynchronous function which accepts one + * parameter, an entity in html representation, and which returns true + * iff this creator is applicable for the given entity. + * @property {string} create - This has to be valid javascript! An + * asynchronous function which accepts one parameter, an entity in html + * representation. It returns a HTMLElement or text node which will be + * shown in the bottom line container iff the creator is applicable. + */ /** * Add a special section to each entity one the current page where a thumbnail, @@ -45,6 +67,7 @@ * @requires UTIF (from utif.js library) * @requires ext_table_preview (module from ext_table_preview.js) */ + var ext_bottom_line = function($, logger, is_in_view_port, load_config, getEntityPath, connection, UTIF, ext_table_preview) { /** @@ -52,28 +75,7 @@ var ext_bottom_line = function($, logger, is_in_view_port, load_config, getEntit * (entity) Note: This property can as well be a * javascript string which evaluates to a function. */ - /** - * @type {BottomLineConfig} - * @property {string|HTMLElement} fallback - Fallback content if none of - * the creators are applicable. - * @property {string} version - the version of the configuration which must - * match this module's version. - * @property {CreatorConfig[]} creators - an array of creators. - */ - /** - * @type {CreatorConfig} - * @property {string} [id] - a unique id for the creator. optional, for - * debuggin purposes. - * @property {function|string} is_applicable - If this is a string this has - * to be valid javascript! An asynchronous function which accepts one - * parameter, an entity in html representation, and which returns true - * iff this creator is applicable for the given entity. - * @property {string} create - This has to be valid javascript! An - * asynchronous function which accepts one parameter, an entity in html - * representation. It returns a HTMLElement or text node which will be - * shown in the bottom line container iff the creator is applicable. - */ /** * Check if an entity has a path attribute and one of a set of extensions. @@ -547,6 +549,9 @@ var ext_bottom_line = function($, logger, is_in_view_port, load_config, getEntit } + /** + * @exports ext_bottom_line + */ return { previewShownEvent: previewShownEvent, previewReadyEvent: previewReadyEvent, diff --git a/src/core/js/form_elements.js b/src/core/js/form_elements.js index 47c8e0ce..d4cd4234 100644 --- a/src/core/js/form_elements.js +++ b/src/core/js/form_elements.js @@ -25,8 +25,6 @@ * form_elements module for reusable form elemenst which already have a basic * css styling. * - * @version 0.2 - * * IMPORTANCE CONCEPTS * * FIELD - an HTMLElement which wraps a LABEL element (the fields name) and the @@ -48,24 +46,53 @@ * SUBFORM - an HTMLElement which contains FIELDS and other SUBFORMS. SUBFORMS * can be used to nest FIELDS, which is not supported by HTML5 but allows only * for flat key-value pairs. - */ - -/** - * The configuration for double, integer, date input elements. - * - * @typedef {object} input_config - * @property {string} name - * @property {string} type - * @property {string} label - */ - -/** - * The configuration for reference_select input fields - * - * TODO * + * @version 0.2 + * @exports form_elements */ var form_elements = new function () { + /** + * Config for an alert + * + * @typedef {object} AlertConfig + * @property {string} [title] - an optional title for the alert. + * @property {string} [severity="danger"] - a bootstrap class suffix. Other + * examples: warning, info + * @property {string} message - informs the user what they are about to do. + * @property {function} proceed_callback - the function which is called + * then the user hits the "Proceed" button. + * @property {function} [cancel_callback] - a callback which is called then + * the cancel button is clicked. By default, only the alert is being + * closed an nothing happens. + * @property {string} [proceed_text="Proceed"] - the text on the proceed button. + * @property {string} [cancel_text="Cancel"] - the text on the cancel button. + * @property {string} [remember_my_decision_id] - if this parameter is + * present, a checkbox is appended to the alert ("Don't ask me + * again."). If the checkbox is checked the next time the make_alert + * function is called with the same remember_my_decision_id is created, + * the alert won't show up and the proceed_callback is called without + * any user interaction. + * @property {string} [remember_my_decision_text="Don't ask me again."] - + * label text for the checkbox. + * @property {HTMLElement} [proceed_button] - an optional custom proceed + * button. + * @property {HTMLElement} [cancel_button] - an optional custom cancel + * button. + */ + + + /** + * The configuration for double, integer, date input elements. + * + * There are specializations of this configuration object. See + * {@link ReferenceDropDownConfig} + * + * @typedef {object} FieldConfig + * @property {string} name + * @property {string} type + * @property {string} label + * @see {@link ReferenceDropDownConfig} + */ this.version = "0.1"; this.dependencies = ["log", "caosdb_utils", "markdown"]; @@ -171,33 +198,6 @@ var form_elements = new function () { localStorage["form_elements.alert_decision." + key] = val; } - /** - * @type {AlertConfig} - * @property {string} [title] - an optional title for the alert. - * @property {string} [severity="danger"] - a bootstrap class suffix. Other - * examples: warning, info - * @property {string} message - informs the user what they are about to do. - * @property {function} proceed_callback - the function which is called - * then the user hits the "Proceed" button. - * @property {function} [cancel_callback] - a callback which is called then - * the cancel button is clicked. By default, only the alert is being - * closed an nothing happens. - * @property {string} [proceed_text="Proceed"] - the text on the proceed button. - * @property {string} [cancel_text="Cancel"] - the text on the cancel button. - * @property {string} [remember_my_decision_id] - if this parameter is - * present, a checkbox is appended to the alert ("Don't ask me - * again."). If the checkbox is checked the next time the make_alert - * function is called with the same remember_my_decision_id is created, - * the alert won't show up and the proceed_callback is called without - * any user interaction. - * @property {string} [remember_my_decision_text="Don't ask me again."] - - * label text for the checkbox. - * @property {HTMLElement} [proceed_button] - an optional custom proceed - * button. - * @property {HTMLElement] [cancel_button] - an optional custom cancel - * button. - */ - /** * Make an alert, that is a dialog which can intercept a function call and * asks the user to proceed or cancel. @@ -270,37 +270,39 @@ var form_elements = new function () { return _alert[0]; } + + this.init = function () { + this.logger.trace("enter init"); + } + /** - * (Re-)set this module's functions to standard implementation. + * Return an OPTION element with entity reference. + * + * The OPTION element for a SELECT form input shows a short + * summary/description of an entity and has the entity's id as value. + * + * If the `desc` parameter is undefined, the entity_id is shown + * instead. + * + * @param {string} entity_id - the entity's id. + * @param {string} [desc] - the description for the entity. + * @returns {HTMLElement} OPTION element. */ - this._init_functions = function () { - - this.init = function () { - this.logger.trace("enter init"); + this.make_reference_option = function (entity_id, desc) { + caosdb_utils.assert_string(entity_id, "param `entity_id`"); + if (typeof desc == "undefined") { + desc = entity_id; } + var opt_str = '<option value="' + entity_id + '">' + desc + + "</option>"; + return $(opt_str)[0]; + } - /** - * Return an OPTION element with entity reference. - * - * The OPTION element for a SELECT form input shows a short - * summary/description of an entity and has the entity's id as value. - * - * If the `desc` parameter is undefined, the entity_id is shown - * instead. - * - * @param {string} entity_id - the entity's id. - * @param {string} [desc] - the description for the entity. - * @returns {HTMLElement} OPTION element. - */ - this.make_reference_option = function (entity_id, desc) { - caosdb_utils.assert_string(entity_id, "param `entity_id`"); - if (typeof desc == "undefined") { - desc = entity_id; - } - var opt_str = '<option value="' + entity_id + '">' + desc + - "</option>"; - return $(opt_str)[0]; - } + + /** + * (Re-)set this module's functions to standard implementation. + */ + this._init_functions = function () { /** * Return SELECT form element with entity references. @@ -350,8 +352,6 @@ var form_elements = new function () { } /** - * @typedef {option} ReferenceDropDownConfig - * * Configuration object for a drop down menu for selecting references. * `make_reference_drop_down` generates such a drop down menu using a * SELECT input with the references as its OPTION elements. @@ -369,6 +369,10 @@ var form_elements = new function () { * defined by `label`. If the `label` property is undefined, the `name` * is shown instead. * + * The ReferenceDropDownConfig is a specialisation of a + * {@link FieldConfig}. + * + * @typedef {option} ReferenceDropDownConfig * @property {string} name - The name of the select input. * @property {string} query - Query for entities. * @property {function} [make_value] - Call-back for the generation of @@ -383,129 +387,9 @@ var form_elements = new function () { * undefined. This property is used by `make_form_field` to decide * which type of field is to be generated. * + * @see {@link FieldConfig} */ - /** - * Search and retrieve entities and create a SELECT from element. - * - * @param {ReferenceDropDownConfig} config - all necessary parameters - * for the configuration. - * @returns {HTMLElement} SELECT element. - */ - this.make_reference_drop_down = function (config) { - let ret = $(this._make_field_wrapper(config.name)); - let label = this._make_input_label_str(config); - let loading = $('<div class="caosdb-f-field-not-ready">loading...</div>'); - let input_col = $('<div class="col-sm-9"/>'); - - input_col.append(loading); - this._query(config.query).then(async function (entities) { - let select = $(await form_elements.make_reference_select( - entities, config.make_desc, config.make_value, config.multiple, - config.value)); - select.attr("name", config.name); - loading.remove(); - input_col.append(select); - form_elements.init_select_picker(ret[0], config.value); - ret[0].dispatchEvent(form_elements.field_ready_event); - select.change(function () { - ret[0].dispatchEvent(form_elements.field_changed_event); - }); - }).catch(err => { - form_elements.logger.error(err); - loading.remove(); - input_col.append(err); - ret[0].dispatchEvent(form_elements.field_error_event); - }); - - return ret.append(label, input_col)[0]; - } - - - this.init_select_picker = function (field, value) { - caosdb_utils.assert_html_element(field, "parameter `field`"); - const select = $(field).find("select")[0]; - const select_picker_options = {}; - if ($(select).prop("multiple")) { - select_picker_options["actionsBox"] = true; - } - if ($(select).find("option").length > 8) { - select_picker_options["liveSearch"] = true; - select_picker_options["liveSearchNormalize"] = true; - select_picker_options["liveSearchPlaceholder"] = "search..."; - } - $(select).selectpicker(select_picker_options); - $(select).selectpicker("val", value); - this.init_actions_box(field); - } - - - this.init_actions_box = function (field) { - this.logger.trace("enter init_actions_box", field); - caosdb_utils.assert_html_element(field, "parameter `field`"); - const select = $(field).find("select"); - var actions_box = select.siblings().find(".bs-actionsbox"); - if (actions_box.length === 0) { - actions_box = $(`<div class="bs-actionsbox"> - <div class="btn-group btn-group-sm btn-block"> - <button type="button" class="actions-btn btn-default bs-deselect-all btn btn-light">None</button> - </div> - </div>`) - .hide(); - - select - .siblings(".dropdown-menu") - .prepend(actions_box); - - field.addEventListener( - form_elements.field_changed_event.type, - (e) => { - if (form_elements.is_set(field)) { - actions_box.show(); - } else { - actions_box.hide(); - } - }, true); - - actions_box - .find(".bs-deselect-all") - .click((e) => { - select.val(null) - .selectpicker("render") - .parent().toggleClass("open", false); - select[0].dispatchEvent(form_elements.field_changed_event); - }); - } - } - - /** - * Return a promise which resolves with the field when the field is ready. - * - * This function is especially useful if the caller can not be sure if - * the field_ready_event has been dispatched already and the field is - * ready or if the fields creation is still pending. - * - * @param {HTMLElement} field - * @return {Promise} the field-ready promise - */ - this.field_ready = function (field) { - // TODO add support for field name (string) as field parameter - // TODO check type of param field (not an array!) - caosdb_utils.assert_html_element(field, "parameter `field`"); - return new Promise(function (resolve, reject) { - try { - if (!$(field).hasClass("caosdb-f-field-not-ready") && $(field).find(".caosdb-f-field-not-ready").length === 0) { - resolve(field); - } else { - field.addEventListener(form_elements.field_ready_event.type, - (e) => resolve(e.target), true); - } - } catch (err) { - reject(err); - } - }); - } - this._query = async function (q) { const result = await query(q); this.logger.debug("query returned", result); @@ -527,802 +411,978 @@ var form_elements = new function () { return this.parse_script_result(result); } - this.parse_script_result = function (result) { - console.log(result); - const scriptNode = result.evaluate("/Response/script", result, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; - const code = result.evaluate("@code", scriptNode, null, XPathResult.STRING_TYPE, null).stringValue; + } - const call = result.evaluate("call", scriptNode, null, XPathResult.STRING_TYPE, null).stringValue; - const stderr = result.evaluate("stderr", scriptNode, null, XPathResult.STRING_TYPE, null).stringValue; - const stdout = result.evaluate("stdout", scriptNode, null, XPathResult.STRING_TYPE, null).stringValue; + /** + * @typedef {object} ScriptingResult + * @property {string} code + * @property {string} call + * @property {string} stdout + * @property {string} stderr + */ - return { - "code": code, - "call": call, - "stdout": stdout, - "stderr": stderr - }; + /** + * Bla, TODO + * + * @param {XMLDocument} result + * @return {ScriptingResult} + */ + this.parse_script_result = function (result) { + console.log(result); + const scriptNode = result.evaluate("/Response/script", result, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; + const code = result.evaluate("@code", scriptNode, null, XPathResult.STRING_TYPE, null).stringValue; + + const call = result.evaluate("call", scriptNode, null, XPathResult.STRING_TYPE, null).stringValue; + const stderr = result.evaluate("stderr", scriptNode, null, XPathResult.STRING_TYPE, null).stringValue; + const stdout = result.evaluate("stdout", scriptNode, null, XPathResult.STRING_TYPE, null).stringValue; + + const ret = { + "code": code, + "call": call, + "stdout": stdout, + "stderr": stderr + }; + + return ret; + } + + /** + * Search and retrieve entities and create a SELECT from element. + * + * @param {ReferenceDropDownConfig} config - all necessary parameters + * for the configuration. + * @returns {HTMLElement} SELECT element. + */ + this.make_reference_drop_down = function (config) { + let ret = $(this._make_field_wrapper(config.name)); + let label = this._make_input_label_str(config); + let loading = $('<div class="caosdb-f-field-not-ready">loading...</div>'); + let input_col = $('<div class="col-sm-9"/>'); + + input_col.append(loading); + this._query(config.query).then(async function (entities) { + let select = $(await form_elements.make_reference_select( + entities, config.make_desc, config.make_value, config.multiple, + config.value)); + select.attr("name", config.name); + loading.remove(); + input_col.append(select); + form_elements.init_select_picker(ret[0], config.value); + ret[0].dispatchEvent(form_elements.field_ready_event); + select.change(function () { + ret[0].dispatchEvent(form_elements.field_changed_event); + }); + }).catch(err => { + form_elements.logger.error(err); + loading.remove(); + input_col.append(err); + ret[0].dispatchEvent(form_elements.field_error_event); + }); + + return ret.append(label, input_col)[0]; + } + + + /** + * Test 16 + */ + this.init_select_picker = function (field, value) { + caosdb_utils.assert_html_element(field, "parameter `field`"); + const select = $(field).find("select")[0]; + const select_picker_options = {}; + if ($(select).prop("multiple")) { + select_picker_options["actionsBox"] = true; } + if ($(select).find("option").length > 8) { + select_picker_options["liveSearch"] = true; + select_picker_options["liveSearchNormalize"] = true; + select_picker_options["liveSearchPlaceholder"] = "search..."; + } + $(select).selectpicker(select_picker_options); + $(select).selectpicker("val", value); + this.init_actions_box(field); + } - /** - * generate a java script object representation of a form - */ - this.form_to_object = function (form) { - this.logger.trace("entity form_to_json", form); - caosdb_utils.assert_html_element(form, "parameter `form`"); - const _to_json = (element, data) => { - this.logger.trace("enter element_to_json", element, data); + /** + * Test 17 + */ + this.init_actions_box = function (field) { + this.logger.trace("enter init_actions_box", field); + caosdb_utils.assert_html_element(field, "parameter `field`"); + const select = $(field).find("select"); + var actions_box = select.siblings().find(".bs-actionsbox"); + if (actions_box.length === 0) { + actions_box = $(`<div class="bs-actionsbox"> + <div class="btn-group btn-group-sm btn-block"> + <button type="button" class="actions-btn btn-default bs-deselect-all btn btn-light">None</button> + </div> + </div>`) + .hide(); + + select + .siblings(".dropdown-menu") + .prepend(actions_box); + + field.addEventListener( + form_elements.field_changed_event.type, + (e) => { + if (form_elements.is_set(field)) { + actions_box.show(); + } else { + actions_box.hide(); + } + }, true); - for (const child of element.children) { - // ignore disabled fields and subforms - if ($(child).hasClass("caosdb-f-field-disabled")) { - continue; + actions_box + .find(".bs-deselect-all") + .click((e) => { + select.val(null) + .selectpicker("render") + .parent().toggleClass("open", false); + select[0].dispatchEvent(form_elements.field_changed_event); + }); + } + } + + /** + * Return a promise which resolves with the field when the field is ready. + * + * This function is especially useful if the caller can not be sure if + * the field_ready_event has been dispatched already and the field is + * ready or if the fields creation is still pending. + * + * @param {HTMLElement} field + * @return {Promise} the field-ready promise + */ + this.field_ready = function (field) { + // TODO add support for field name (string) as field parameter + // TODO check type of param field (not an array!) + caosdb_utils.assert_html_element(field, "parameter `field`"); + return new Promise(function (resolve, reject) { + try { + if (!$(field).hasClass("caosdb-f-field-not-ready") && $(field).find(".caosdb-f-field-not-ready").length === 0) { + resolve(field); + } else { + field.addEventListener(form_elements.field_ready_event.type, + (e) => resolve(e.target), true); + } + } catch (err) { + reject(err); + } + }); + } + + /** + * generate a java script object representation of a form + * + * @function + */ + this.form_to_object = function (form) { + this.logger.trace("entity form_to_json", form); + caosdb_utils.assert_html_element(form, "parameter `form`"); + + const _to_json = (element, data) => { + this.logger.trace("enter element_to_json", element, data); + + for (const child of element.children) { + // ignore disabled fields and subforms + if ($(child).hasClass("caosdb-f-field-disabled")) { + continue; + } + const name = $(child).attr("name"); + const is_subform = $(child).hasClass("caosdb-f-form-elements-subform"); + if (is_subform) { + const subform = $(child).data("subform-name"); + // recursive + var subform_obj = _to_json(child, {}); + if (typeof data[subform] === "undefined") { + data[subform] = subform_obj; + } else if (Array.isArray(data[subform])) { + data[subform].push(subform_obj); + } else { + data[subform] = [data[subform], subform_obj] } - const name = $(child).attr("name"); - const is_subform = $(child).hasClass("caosdb-f-form-elements-subform"); - if (is_subform) { - const subform = $(child).data("subform-name"); - // recursive - var subform_obj = _to_json(child, {}); - if (typeof data[subform] === "undefined") { - data[subform] = subform_obj; - } else if (Array.isArray(data[subform])) { - data[subform].push(subform_obj); - } else { - data[subform] = [data[subform], subform_obj] - } - } else if (name && name !== "") { - // input elements - const not_checkbox = !$(child).is(":checkbox"); - if (not_checkbox || $(child).is(":checked")) { - // checked or not a checkbox - var value = $(child).val(); - if (typeof data[name] === "undefined") { - data[name] = value; - } else if (Array.isArray(data[name])) { - data[name].push(value); - } else { - data[name] = [data[name], value] - } + } else if (name && name !== "") { + // input elements + const not_checkbox = !$(child).is(":checkbox"); + if (not_checkbox || $(child).is(":checked")) { + // checked or not a checkbox + var value = $(child).val(); + if (typeof data[name] === "undefined") { + data[name] = value; + } else if (Array.isArray(data[name])) { + data[name].push(value); } else { - // TODO checkbox + data[name] = [data[name], value] } - } else if (child.children.length > 0) { - // recursive - _to_json(child, data); + } else { + // TODO checkbox } + } else if (child.children.length > 0) { + // recursive + _to_json(child, data); } + } - this.logger.trace("leave element_to_json", element, data); - return data; - }; - - const ret = _to_json(form, {}); - this.logger.trace("leave form_to_json", ret); - return ret; - } + this.logger.trace("leave element_to_json", element, data); + return data; + }; - this.make_submit_button = function () { - var ret = $('<button class="caosdb-f-form-elements-submit-button btn btn-primary" type="submit">Submit</button>'); - return ret[0]; - } + const ret = _to_json(form, {}); + this.logger.trace("leave form_to_json", ret); + return ret; + } - this.make_cancel_button = function (form) { - var ret = $('<button class="caosdb-f-form-elements-cancel-button btn btn-primary" type="button">Cancel</button>'); - ret.on("click", e => { - this.logger.debug("cancel form", e, form); - form.dispatchEvent(this.cancel_form_event); - }); - return ret[0]; - } + this.make_submit_button = function () { + var ret = $('<button class="caosdb-f-form-elements-submit-button btn btn-primary" type="submit">Submit</button>'); + return ret[0]; + } - /** - * TODO make syncronous - */ - this.make_form_field = async function (config) { - caosdb_utils.assert_type(config, "object", "param `config`"); - caosdb_utils.assert_string(config.type, "`config.type` of param `config`"); - - var field = undefined; - const type = config.type; - if (type === "date") { - field = this.make_date_input(config); - } else if (type === "checkbox") { - field = this.make_checkbox_input(config); - } else if (type === "text") { - field = this.make_text_input(config); - } else if (type === "double") { - field = this.make_double_input(config); - } else if (type === "integer") { - field = this.make_integer_input(config); - } else if (type === "range") { - field = await this.make_range_input(config); - } else if (type === "reference_drop_down") { - field = this.make_reference_drop_down(config); - } else if (type === "subform") { - // TODO handle cache and required for subforms - return await this.make_subform(config); - } else { - throw new TypeError("undefined field type `" + type + "`"); - } + this.make_cancel_button = function (form) { + var ret = $('<button class="caosdb-f-form-elements-cancel-button btn btn-primary" type="button">Cancel</button>'); + ret.on("click", e => { + this.logger.debug("cancel form", e, form); + form.dispatchEvent(this.cancel_form_event); + }); + return ret[0]; + } - if (config.required) { - this.set_required(field); - } - if (config.cached) { - this.set_cached(field); - } - if (config.help) { - this.add_help(field, config.help); - } + /** + * TODO make syncronous + * + * @return {HTMLElement} + */ + this.make_form_field = async function (config) { + caosdb_utils.assert_type(config, "object", "param `config`"); + caosdb_utils.assert_string(config.type, "`config.type` of param `config`"); + + var field = undefined; + const type = config.type; + if (type === "date") { + field = this.make_date_input(config); + } else if (type === "checkbox") { + field = this.make_checkbox_input(config); + } else if (type === "text") { + field = this.make_text_input(config); + } else if (type === "double") { + field = this.make_double_input(config); + } else if (type === "integer") { + field = this.make_integer_input(config); + } else if (type === "range") { + field = await this.make_range_input(config); + } else if (type === "reference_drop_down") { + field = this.make_reference_drop_down(config); + } else if (type === "subform") { + // TODO handle cache and required for subforms + return await this.make_subform(config); + } else { + throw new TypeError("undefined field type `" + type + "`"); + } - return field; + if (config.required) { + this.set_required(field); + } + if (config.cached) { + this.set_cached(field); + } + if (config.help) { + this.add_help(field, config.help); } + return field; + } - this.add_help = function (field, config) { - var help_button = $('<span data-trigger="click focus" data-toggle="popover" class="caosdb-f-form-help pull-right glyphicon glyphicon-info-sign"/>') - .css({ - "cursor": "pointer" - }); - if (typeof config === "string" || config instanceof String) { - help_button.attr("data-content", config); - help_button.popover(); - } else { - help_button.popover(config); - } + this.add_help = function (field, config) { + var help_button = $('<span data-trigger="click focus" data-toggle="popover" class="caosdb-f-form-help pull-right glyphicon glyphicon-info-sign"/>') + .css({ + "cursor": "pointer" + }); + if (typeof config === "string" || config instanceof String) { + help_button.attr("data-content", config); + help_button.popover(); + } else { + help_button.popover(config); + } - var label = $(field).children("label"); - if (label.length > 0) { - help_button.css({ - "margin-left": "4px" - }); - label.first().append(help_button); - } else { - $(field).append(help_button); - } + + var label = $(field).children("label"); + if (label.length > 0) { + help_button.css({ + "margin-left": "4px" + }); + label.first().append(help_button); + } else { + $(field).append(help_button); } + } - this.make_heading = function (config) { - if (typeof config.header === "undefined") { - return; - } else if (typeof config.header === "string" || config.header instanceof String) { - return $('<div class="caosdb-f-form-header h3">' + config.header + '</div>')[0]; - } - caosdb_utils.assert_html_element(config.header, "member `header` of parameter `config`"); - return config.header; + this.make_heading = function (config) { + if (typeof config.header === "undefined") { + return; + } else if (typeof config.header === "string" || config.header instanceof String) { + return $('<div class="caosdb-f-form-header h3">' + config.header + '</div>')[0]; } + caosdb_utils.assert_html_element(config.header, "member `header` of parameter `config`"); + return config.header; + } - this.make_form_wrapper = function (form, config) { - var wrapper = $('<div class="caosdb-f-form-wrapper"/>'); + this.make_form_wrapper = function (form, config) { + var wrapper = $('<div class="caosdb-f-form-wrapper"/>'); - var header = this.make_heading(config); - wrapper.append(header); + var header = this.make_heading(config); + wrapper.append(header); - var loading = $('<div>loading...</div>'); - var logger = this.logger; - var cancel = (e) => { - logger.trace("cancel form", e); - wrapper.remove(); - }; + var loading = $('<div>loading...</div>'); + var logger = this.logger; + var cancel = (e) => { + logger.trace("cancel form", e); + wrapper.remove(); + }; - wrapper.append(loading); + wrapper.append(loading); - Promise.resolve(form).then(form => { - // form ready - loading.remove(); - wrapper.append(form); - wrapper[0].dispatchEvent(this.form_ready_event); + Promise.resolve(form).then(form => { + // form ready + loading.remove(); + wrapper.append(form); + wrapper[0].dispatchEvent(this.form_ready_event); - }).catch(err => { - logger.error("form loading error", err); - loading.remove(); - wrapper.append(err); - }); + }).catch(err => { + logger.error("form loading error", err); + loading.remove(); + wrapper.append(err); + }); - wrapper[0].addEventListener(this.cancel_form_event.type, cancel, true); + wrapper[0].addEventListener(this.cancel_form_event.type, cancel, true); - return wrapper[0]; - } + return wrapper[0]; + } - this.make_form = function (config) { - var form = undefined; + /** + * Configuration objects which are passed to {@link make_form}. + * + * Note: either the `script` or the `name` property must be defined. If the former is defined, the latter will be overriden. + * + * @typedef {object} FormConfig + * + * @property {FieldConfig[]} fields - array of fields. The order is the + * order in which they appear in the resulting form. + * @property {string} [script] - if present the form will call a + * server-side script on submission. + * @property {string} [name] - The name of the form. This is being + * overridden by the `script` parameter if present. + * @property {function} [submit] - a callback which handles the submission + * of the form. This parameter is being overridden if the `script` + * parameter is present. + */ - if (config.script) { - form = this.make_script_form(config, config.script); - } else { - form = this.make_generic_form(config); - } - var wrapper = this.make_form_wrapper(form, config); - return wrapper; - } + /** + * Create a form. + * + * The returned element is a container which will eventually contain a HTML + * form element. The container emits a {@link form_ready_event} when the + * form is ready. + * + * @param {FormConfig} config + * @return {HTMLElement} + */ + this.make_form = function (config) { + var form = undefined; - /** - * TODO make syncronous - */ - this.make_subform = async function (config) { - this.logger.trace("enter make_subform"); - caosdb_utils.assert_type(config, "object", "param `config`"); - caosdb_utils.assert_string(config.name, "`config.name` of param `config`"); - caosdb_utils.assert_array(config.fields, "`config.fields` of param `config`"); + if (config.script) { + form = this.make_script_form(config, config.script); + } else { + form = this.make_generic_form(config); + } + var wrapper = this.make_form_wrapper(form, config); + return wrapper; + } - const name = config.name; - var form = $('<div data-subform-name="' + name + '" class="caosdb-f-form-elements-subform"/>'); + /** + * TODO make syncronous + */ + this.make_subform = async function (config) { + this.logger.trace("enter make_subform"); + caosdb_utils.assert_type(config, "object", "param `config`"); + caosdb_utils.assert_string(config.name, "`config.name` of param `config`"); + caosdb_utils.assert_array(config.fields, "`config.fields` of param `config`"); + + const name = config.name; + var form = $('<div data-subform-name="' + name + '" class="caosdb-f-form-elements-subform"/>'); + + for (let field of config.fields) { + this.logger.trace("add subform field", field); + let elem = await this.make_form_field(field); + form.append(elem); + } - for (let field of config.fields) { - this.logger.trace("add subform field", field); - let elem = await this.make_form_field(field); - form.append(elem); - } + this.logger.trace("leave make_subform", form[0]); + return form[0]; + } - this.logger.trace("leave make_subform", form[0]); - return form[0]; + this.dismiss_form = function (form) { + if (form.tagName === "FORM") { + form.dispatchEvent(this.cancel_form_event); } - - this.dismiss_form = function (form) { - if (form.tagName === "FORM") { - form.dispatchEvent(this.cancel_form_event); - } - var _form = $(form).find("form"); - if (_form.length > 0) { - _form[0].dispatchEvent(this.cancel_form_event); - } + var _form = $(form).find("form"); + if (_form.length > 0) { + _form[0].dispatchEvent(this.cancel_form_event); } + } - this.enable_group = function (form, group) { - this.enable_fields(this.get_group_fields(form, group)); - } + this.enable_group = function (form, group) { + this.enable_fields(this.get_group_fields(form, group)); + } - this.disable_group = function (form, group) { - this.disable_fields(this.get_group_fields(form, group)); - } + this.disable_group = function (form, group) { + this.disable_fields(this.get_group_fields(form, group)); + } - this.get_group_fields = function (form, group) { - return $(form).find(".caosdb-f-field[data-groups*='(" + group + ")']").toArray(); - } + this.get_group_fields = function (form, group) { + return $(form).find(".caosdb-f-field[data-groups*='(" + group + ")']").toArray(); + } - /** - * Return an array of field with name - * - * @param {string} name - the field name - * @return {HTMLElement[]} array of fields - */ - this.get_fields = function (form, name) { - caosdb_utils.assert_html_element(form, "parameter `form`"); - caosdb_utils.assert_string(name, "parameter `name`"); - return $(form).find(".caosdb-f-field[data-field-name='" + name + "']").toArray(); - } + /** + * Return an array of field with name + * + * @param {string} name - the field name + * @return {HTMLElement[]} array of fields + */ + this.get_fields = function (form, name) { + caosdb_utils.assert_html_element(form, "parameter `form`"); + caosdb_utils.assert_string(name, "parameter `name`"); + return $(form).find(".caosdb-f-field[data-field-name='" + name + "']").toArray(); + } - this.add_field_to_group = function (field, group) { - this.logger.trace("enter add_field_to_group", field, group); - var groups = ($(field).attr("data-groups") ? $(field).attr("data-groups") : "") + "(" + group + ")"; - $(field).attr("data-groups", groups); - } + this.add_field_to_group = function (field, group) { + this.logger.trace("enter add_field_to_group", field, group); + var groups = ($(field).attr("data-groups") ? $(field).attr("data-groups") : "") + "(" + group + ")"; + $(field).attr("data-groups", groups); + } - this.disable_fields = function (fields) { - $(fields).toggleClass("caosdb-f-field-disabled", true).hide(); - for (const field of $(fields)) { - field.dispatchEvent(this.field_disabled_event); - } + this.disable_fields = function (fields) { + $(fields).toggleClass("caosdb-f-field-disabled", true).hide(); + for (const field of $(fields)) { + field.dispatchEvent(this.field_disabled_event); } + } - this.enable_fields = function (fields) { - $(fields).toggleClass("caosdb-f-field-disabled", false).show(); - for (const field of $(fields)) { - field.dispatchEvent(this.field_enabled_event); - } + this.enable_fields = function (fields) { + $(fields).toggleClass("caosdb-f-field-disabled", false).show(); + for (const field of $(fields)) { + field.dispatchEvent(this.field_enabled_event); } + } - this.enable_name = function (form, name) { - this.enable_fields($(form).find(".caosdb-f-field[data-field-name='" + name + "']").toArray()); - } + this.enable_name = function (form, name) { + this.enable_fields($(form).find(".caosdb-f-field[data-field-name='" + name + "']").toArray()); + } - this.disable_name = function (form, name) { - this.disable_fields($(form).find(".caosdb-f-field[data-field-name='" + name + "']").toArray()); - } + this.disable_name = function (form, name) { + this.disable_fields($(form).find(".caosdb-f-field[data-field-name='" + name + "']").toArray()); + } - this.make_script_form = async function (config, script) { - this.logger.trace("enter make_script_form"); + this.make_script_form = async function (config, script) { + this.logger.trace("enter make_script_form"); - const submit_callback = async function (form) { - form = $(form); + const submit_callback = async function (form) { + form = $(form); - // actually submit the form - var response = await form_elements._run_script(script, form); - var result = []; + // actually submit the form + var response = await form_elements._run_script(script, form); + var result = []; - if (response.code === "0") { - // handle success - result.push(form_elements.make_success_message(response.stdout)); - return result; + if (response.code === "0") { + // handle success + result.push(form_elements.make_success_message(response.stdout)); + return result; - } else { - // handle scripting error - result.push(form_elements.make_error_message(response.call)); - result.push(form_elements.make_error_message(response.stderr)); - throw result; - } - }; + } else { + // handle scripting error + result.push(form_elements.make_error_message(response.call)); + result.push(form_elements.make_error_message(response.stderr)); + throw result; + } + }; + + this.logger.trace("leave make_script_form"); + const new_config = $.extend({}, { + name: script, + submit: submit_callback + }, config); + return await this.make_generic_form(new_config); + } - this.logger.trace("leave make_script_form"); - const new_config = $.extend({}, { - name: script, - submit: submit_callback - }, config); - return await this.make_generic_form(new_config); - } + /** + * Return a generic form, bind the config.submit to the submit event + * of the form. + * + * The `config.fields` array may contain `form_elements.field_config` + * objects or HTMLElements. + * + * TODO + */ + this.make_generic_form = async function (config) { + this.logger.trace("enter make_generic_form"); - /** - * Return a generic form, bind the config.submit to the submit event - * of the form. - * - * The `config.fields` array may contain `form_elements.field_config` - * objects or HTMLElements. - * - * TODO - */ - this.make_generic_form = async function (config) { - this.logger.trace("enter make_generic_form"); + caosdb_utils.assert_type(config, "object", "param `config`"); + caosdb_utils.assert_string(config.name, "`config.name` of param `config`", true); + caosdb_utils.assert_array(config.fields, "`config.fields` of param `config`"); - caosdb_utils.assert_type(config, "object", "param `config`"); - caosdb_utils.assert_string(config.name, "`config.name` of param `config`", true); - caosdb_utils.assert_array(config.fields, "`config.fields` of param `config`"); + const form = $('<form class="form-horizontal" action="#" method="post" />'); - const form = $('<form class="form-horizontal" action="#" method="post" />'); + // set name + if (config.name) { + form.attr("name", config.name); + } - // set name - if (config.name) { - form.attr("name", config.name); + // add fields + for (let field of config.fields) { + this.logger.trace("add field", field); + if (field instanceof HTMLElement) { + form.append(field); + } else { + let elem = await this.make_form_field(field); + form.append(elem); } + } + + // set groups + if (config.groups) { + for (let group of config.groups) { + this.logger.trace("add group", group); + for (let fieldname of group.fields) { + let field = form.find(".caosdb-f-field[data-field-name='" + fieldname + "']"); + this.logger.trace("set group", field, group); + this.add_field_to_group(field, group.name) - // add fields - for (let field of config.fields) { - this.logger.trace("add field", field); - if (field instanceof HTMLElement) { - form.append(field); + } + // disable if necessary + if (typeof group.enabled === "undefined" || group.enabled) { + this.enable_group(form, group.name); } else { - let elem = await this.make_form_field(field); - form.append(elem); + this.disable_group(form, group.name); } } + } - // set groups - if (config.groups) { - for (let group of config.groups) { - this.logger.trace("add group", group); - for (let fieldname of group.fields) { - let field = form.find(".caosdb-f-field[data-field-name='" + fieldname + "']"); - this.logger.trace("set group", field, group); - this.add_field_to_group(field, group.name) + const footer = this.make_footer(); + form.append(footer); - } - // disable if necessary - if (typeof group.enabled === "undefined" || group.enabled) { - this.enable_group(form, group.name); - } else { - this.disable_group(form, group.name); - } - } + if (!(typeof config.submit === 'boolean' && config.submit === false)) { + // add submit button unless config.submit is false + footer.append(this.make_submit_button()); + } + form[0].addEventListener("submit", (e) => { + e.preventDefault(); + e.stopPropagation(); + if (form.find(".caosdb-f-form-submitting").length > 0) { + // do not submit twice + return; } - const footer = this.make_footer(); - form.append(footer); - - if (!(typeof config.submit === 'boolean' && config.submit === false)) { - // add submit button unless config.submit is false - footer.append(this.make_submit_button()); - } - form[0].addEventListener("submit", (e) => { - e.preventDefault(); - e.stopPropagation(); - if (form.find(".caosdb-f-form-submitting").length > 0) { - // do not submit twice - return; - } + this.logger.debug("submit form", e); - this.logger.debug("submit form", e); + form[0].dispatchEvent(this.submit_form_event); - form[0].dispatchEvent(this.submit_form_event); + form.find(":input").prop("disabled", true); + var submitting = form_elements.make_submitting_info(); + form.find(".caosdb-f-form-elements-footer").before(submitting); - form.find(":input").prop("disabled", true); - var submitting = form_elements.make_submitting_info(); - form.find(".caosdb-f-form-elements-footer").before(submitting); + form[0].addEventListener(this.form_success_event.type, (e) => { + submitting.remove(); + }, true); + form[0].addEventListener(this.form_error_event.type, (e) => { + submitting.remove(); + }, true); - form[0].addEventListener(this.form_success_event.type, (e) => { - submitting.remove(); - }, true); - form[0].addEventListener(this.form_error_event.type, (e) => { - submitting.remove(); - }, true); + // remove old messages + const error_handler = config.error; + const success_handler = config.success; + const submit_callback = config.submit; + form.find(".caosdb-f-form-elements-message").remove(); + if (typeof config.submit === "function") { + // wrap callback in async function + const _wrap_callback = async function () { + try { + var results = await submit_callback(form[0]); - // remove old messages - const error_handler = config.error; - const success_handler = config.success; - const submit_callback = config.submit; - form.find(".caosdb-f-form-elements-message").remove(); - if (typeof config.submit === "function") { - // wrap callback in async function - const _wrap_callback = async function () { - try { - var results = await submit_callback(form[0]); - - // success_handler - if (typeof success_handler === "function") { - var processed = await success_handler(form[0], results); - if (typeof processed !== "undefined") { - form_elements.show_results(form[0], processed); - } - } else { - form_elements.show_results(form[0], results); + // success_handler + if (typeof success_handler === "function") { + var processed = await success_handler(form[0], results); + if (typeof processed !== "undefined") { + form_elements.show_results(form[0], processed); } + } else { + form_elements.show_results(form[0], results); + } - form[0].dispatchEvent(form_elements.form_success_event); - } catch (err) { - - // error_handler - if (typeof error_handler === "function") { - var processed = await error_handler(form[0], err); - if (typeof processed !== "undefined") { - form_elements.show_results(form[0], processed); - } - } else { - form_elements.show_errors(form[0], err); - } + form[0].dispatchEvent(form_elements.form_success_event); + } catch (err) { - form[0].dispatchEvent(form_elements.form_error_event); + // error_handler + if (typeof error_handler === "function") { + var processed = await error_handler(form[0], err); + if (typeof processed !== "undefined") { + form_elements.show_results(form[0], processed); + } + } else { + form_elements.show_errors(form[0], err); } - }(); - } - return false; + form[0].dispatchEvent(form_elements.form_error_event); + } + }(); + } + return false; - }, true); - form[0].addEventListener(this.form_success_event.type, function (e) { - // remove submit button, show ok button - form.find("button[type='submit']").remove(); - form.find("button:contains('Cancel')").text("Ok").prop("disabled", false); - }, true); - form[0].addEventListener(this.form_error_event.type, function (e) { - // reenable inputs - form.find(":input").prop("disabled", false); - }, true); + }, true); - // add cancel button - $(footer).append(this.make_cancel_button(form[0])); + form[0].addEventListener(this.form_success_event.type, function (e) { + // remove submit button, show ok button + form.find("button[type='submit']").remove(); + form.find("button:contains('Cancel')").text("Ok").prop("disabled", false); + }, true); + form[0].addEventListener(this.form_error_event.type, function (e) { + // reenable inputs + form.find(":input").prop("disabled", false); + }, true); - // init caching for this form - form_elements.init_form_caching(config, form[0]); + // add cancel button + $(footer).append(this.make_cancel_button(form[0])); - // init validation - form_elements.init_validator(form[0]); + // init caching for this form + form_elements.init_form_caching(config, form[0]); - this.logger.trace("leave make_generic_form"); - return form[0]; - } + // init validation + form_elements.init_validator(form[0]); - this.init_form_caching = function (config, form) { - var default_config = { - "cache_event": form_elements.submit_form_event.type, - "cache_storage": localStorage - }; - var lconfig = $.extend({}, default_config, config); + this.logger.trace("leave make_generic_form"); + return form[0]; + } - this.logger.trace("init_form_caching", lconfig, form); + this.init_form_caching = function (config, form) { + var default_config = { + "cache_event": form_elements.submit_form_event.type, + "cache_storage": localStorage + }; + var lconfig = $.extend({}, default_config, config); - form.addEventListener(lconfig.cache_event, (e) => { - form_elements.cache_form(lconfig.cache_storage, form); - }, true); - form_elements.load_cached(lconfig.cache_storage, form); - } + this.logger.trace("init_form_caching", lconfig, form); - this.show_results = function (form, results) { - $(form).append(results); - } + form.addEventListener(lconfig.cache_event, (e) => { + form_elements.cache_form(lconfig.cache_storage, form); + }, true); + form_elements.load_cached(lconfig.cache_storage, form); + } - this.show_errors = function (form, errors) { - $(form).append(errors); - } + this.show_results = function (form, results) { + $(form).append(results); + } - this.make_footer = function () { - return $('<div class="text-right caosdb-f-form-elements-footer"/>') - .css({ - "margin": "20px", - }).append(this.make_required_marker()) - .append('<span style="margin-right: 4px; font-size: 11px">required field</span>')[0]; - } + this.show_errors = function (form, errors) { + $(form).append(errors); + } - this.make_error_message = function (message) { - return this.make_message(message, "error"); - } + this.make_footer = function () { + return $('<div class="text-right caosdb-f-form-elements-footer"/>') + .css({ + "margin": "20px", + }).append(this.make_required_marker()) + .append('<span style="margin-right: 4px; font-size: 11px">required field</span>')[0]; + } - this.make_success_message = function (message) { - return this.make_message(message, "success"); - } + this.make_error_message = function (message) { + return this.make_message(message, "error"); + } - this.make_submitting_info = function () { - // TODO styling - return $(this.make_message("Submitting... please wait. This might take some time.", "info")) - .toggleClass("h3", true) - .toggleClass("caosdb-f-form-submitting", true) - .toggleClass("text-right", true)[0]; - } + this.make_success_message = function (message) { + return this.make_message(message, "success"); + } - this.make_message = function (message, type) { - var ret = $('<div class="caosdb-f-form-elements-message"/>'); - if (type) { - ret.addClass("caosdb-f-form-elements-message-" + type); - } - return ret.append(markdown.textToHtml(message))[0]; + this.make_submitting_info = function () { + // TODO styling + return $(this.make_message("Submitting... please wait. This might take some time.", "info")) + .toggleClass("h3", true) + .toggleClass("caosdb-f-form-submitting", true) + .toggleClass("text-right", true)[0]; + } + + this.make_message = function (message, type) { + var ret = $('<div class="caosdb-f-form-elements-message"/>'); + if (type) { + ret.addClass("caosdb-f-form-elements-message-" + type); } + return ret.append(markdown.textToHtml(message))[0]; + } - /** - * TODO make syncronous - */ - this.make_range_input = async function (config) { - - // TODO - // 1. wrapp both inputs to separate it from the label into a container - // 2. make two rows for each input - // 3. make inline-block for all included elements - const from_config = $.extend({}, { - cached: config.cached, - required: config.required, - type: "double" - }, config.from); - const to_config = $.extend({}, { - cached: config.cached, - required: config.required, - type: "double" - }, config.to); - - const from_input = await this.make_form_field(from_config); - const to_input = await this.make_form_field(to_config); - - const ret = $(this._make_field_wrapper(config.name)); - if (config.label) { - ret.append(this._make_input_label_str(config)); - } + /** + * TODO make syncronous + */ + this.make_range_input = async function (config) { + + // TODO + // 1. wrapp both inputs to separate it from the label into a container + // 2. make two rows for each input + // 3. make inline-block for all included elements + const from_config = $.extend({}, { + cached: config.cached, + required: config.required, + type: "double" + }, config.from); + const to_config = $.extend({}, { + cached: config.cached, + required: config.required, + type: "double" + }, config.to); + + const from_input = await this.make_form_field(from_config); + const to_input = await this.make_form_field(to_config); + + const ret = $(this._make_field_wrapper(config.name)); + if (config.label) { + ret.append(this._make_input_label_str(config)); + } - ret.append(from_input); - ret.append(to_input); + ret.append(from_input); + ret.append(to_input); - // styling - $(from_input).toggleClass("form-group", false); - $(from_input).find(".col-sm-3").toggleClass("col-sm-3", false).toggleClass("col-sm-1"); - $(from_input).find(".col-sm-9").toggleClass("col-sm-9", false).toggleClass("col-sm-3"); - $(to_input).toggleClass("form-group", false); - $(to_input).find(".col-sm-3").toggleClass("col-sm-3", false).toggleClass("col-sm-1").toggleClass("col-sm-offset-1"); - $(to_input).find(".col-sm-9").toggleClass("col-sm-9", false).toggleClass("col-sm-3"); + // styling + $(from_input).toggleClass("form-group", false); + $(from_input).find(".col-sm-3").toggleClass("col-sm-3", false).toggleClass("col-sm-1"); + $(from_input).find(".col-sm-9").toggleClass("col-sm-9", false).toggleClass("col-sm-3"); + $(to_input).toggleClass("form-group", false); + $(to_input).find(".col-sm-3").toggleClass("col-sm-3", false).toggleClass("col-sm-1").toggleClass("col-sm-offset-1"); + $(to_input).find(".col-sm-9").toggleClass("col-sm-9", false).toggleClass("col-sm-3"); - return ret[0]; - } + return ret[0]; + } - /** - * Return a DIV with class `caosdb-f-field` and a data attribute - * `data-field-name` which contains the name. - * - * The DIV is used to wrap LABEL and INPUT elements of a form together. - * - * @param {string} name - the name of the field. - * @returns {HTMLElement} a DIV. - */ - this._make_field_wrapper = function (name) { - caosdb_utils.assert_string(name, "param `name`"); - return $('<div class="form-group caosdb-f-field" data-field-name="' + name + '" />') - .css({"padding": "0"})[0]; - } + /** + * Return a DIV with class `caosdb-f-field` and a data attribute + * `data-field-name` which contains the name. + * + * The DIV is used to wrap LABEL and INPUT elements of a form together. + * + * @param {string} name - the name of the field. + * @returns {HTMLElement} a DIV. + */ + this._make_field_wrapper = function (name) { + caosdb_utils.assert_string(name, "param `name`"); + return $('<div class="form-group caosdb-f-field" data-field-name="' + name + '" />') + .css({"padding": "0"})[0]; + } - this.make_date_input = function (config) { - return this._make_input(config); - } + this.make_date_input = function (config) { + return this._make_input(config); + } - this.make_text_input = function (config) { - return this._make_input(config); - } + this.make_text_input = function (config) { + return this._make_input(config); + } - /** - * Return an input field which accepts double values. - * - * `config.type` is set to "number" and overrides any other type. - * - * @param {form_elements.input_config} config. - * @returns {HTMLElement} a double form field. - */ - this.make_double_input = function (config) { - var clone = $.extend({}, config, { - type: "number" - }); - var ret = $(this._make_input(clone)) - ret.find("input").attr("step", "any"); - return ret[0]; - } + /** + * Return an input field which accepts double values. + * + * `config.type` is set to "number" and overrides any other type. + * + * @param {form_elements.input_config} config. + * @returns {HTMLElement} a double form field. + */ + this.make_double_input = function (config) { + var clone = $.extend({}, config, { + type: "number" + }); + var ret = $(this._make_input(clone)) + ret.find("input").attr("step", "any"); + return ret[0]; + } - /** - * Return an input field which accepts integers. - * - * `config.type` is set to "number" and overrides any other type. - * - * @param {form_elements.input_config} config. - * @returns {HTMLElement} an integer form field. - */ - this.make_integer_input = function (config) { - var ret = $(this.make_double_input(config)); - ret.find("input").attr("step", "1"); - return ret[0]; - } + /** + * Return an input field which accepts integers. + * + * `config.type` is set to "number" and overrides any other type. + * + * @param {form_elements.input_config} config. + * @returns {HTMLElement} an integer form field. + */ + this.make_integer_input = function (config) { + var ret = $(this.make_double_input(config)); + ret.find("input").attr("step", "1"); + return ret[0]; + } - /** - * Return a checkbox input field. - * - * @param {form_elements.checkbox_config} config. - * @returns {HTMLElement} a checkbox form field. - */ - this.make_checkbox_input = function (config) { - var clone = $.extend({}, config, { - type: "checkbox" - }); - var ret = $(this._make_input(clone)); - ret.find("input:checkbox").prop("checked", false); - ret.find("input:checkbox").toggleClass("form-control", false); - if (config.checked) { - ret.find("input:checkbox").prop("checked", true); - ret.find("input:checkbox").attr("checked", "checked"); - } - if (config.value) { - ret.find("input:checkbox").attr("value", config.value); - } - return ret[0]; + /** + * Return a checkbox input field. + * + * @param {form_elements.checkbox_config} config. + * @returns {HTMLElement} a checkbox form field. + */ + this.make_checkbox_input = function (config) { + var clone = $.extend({}, config, { + type: "checkbox" + }); + var ret = $(this._make_input(clone)); + ret.find("input:checkbox").prop("checked", false); + ret.find("input:checkbox").toggleClass("form-control", false); + if (config.checked) { + ret.find("input:checkbox").prop("checked", true); + ret.find("input:checkbox").attr("checked", "checked"); + } + if (config.value) { + ret.find("input:checkbox").attr("value", config.value); } + return ret[0]; + } - /** - * Add `caosdb-f-form-field-required` class to form field. - * - * @param {HTMLElement} field - the required form field. - */ - this.set_required = function (field) { - $(field).toggleClass("caosdb-f-form-field-required", true); - $(field).find(":input").prop("required", true); - $(field).find("label").prepend(this.make_required_marker()); - } + /** + * Add `caosdb-f-form-field-required` class to form field. + * + * @param {HTMLElement} field - the required form field. + */ + this.set_required = function (field) { + $(field).toggleClass("caosdb-f-form-field-required", true); + $(field).find(":input").prop("required", true); + $(field).find("label").prepend(this.make_required_marker()); + } - /** - * Return a span which is to be inserted before a field's label text - * and which marks that field as required. - * - * @returns {HTMLElement} span element. - */ - this.make_required_marker = function () { - // TODO create class and move to css file - return $('<span>*</span>') - .css({ - "font-size": "10px", - "color": "red", - "margin-right": "4px", - "font-weight": "100", - })[0]; - } + /** + * Return a span which is to be inserted before a field's label text + * and which marks that field as required. + * + * @returns {HTMLElement} span element. + */ + this.make_required_marker = function () { + // TODO create class and move to css file + return $('<span>*</span>') + .css({ + "font-size": "10px", + "color": "red", + "margin-right": "4px", + "font-weight": "100", + })[0]; + } - this.get_enabled_required_fields = function (form) { - return $(this.get_enabled_fields(form)) - .filter(".caosdb-f-form-field-required") - .toArray(); - } + this.get_enabled_required_fields = function (form) { + return $(this.get_enabled_fields(form)) + .filter(".caosdb-f-form-field-required") + .toArray(); + } - this.get_enabled_fields = function (form) { - return $(form) - .find(".caosdb-f-field") - .filter(function (idx) { - // remove disabled fields from results - return !$(this).hasClass("caosdb-f-field-disabled"); - }) - .toArray(); - } + this.get_enabled_fields = function (form) { + return $(form) + .find(".caosdb-f-field") + .filter(function (idx) { + // remove disabled fields from results + return !$(this).hasClass("caosdb-f-field-disabled"); + }) + .toArray(); + } - this.all_required_fields_set = function (form) { - const req = form_elements.get_enabled_required_fields(form); - for (const field of req) { - if (!form_elements.is_set(field)) { - return false; - } + this.all_required_fields_set = function (form) { + const req = form_elements.get_enabled_required_fields(form); + for (const field of req) { + if (!form_elements.is_set(field)) { + return false; } - return true; } + return true; + } - /** - * @param {HTMLElement} form - the form be validated. - */ - this.is_valid = function (form) { - return form_elements.all_required_fields_set(form); - } + /** + * @param {HTMLElement} form - the form be validated. + */ + this.is_valid = function (form) { + return form_elements.all_required_fields_set(form); + } - this.toggle_submit_button_form_valid = function (form, submit) { - // TODO do not change the submit button directly. change the - // `submittable` state of the form and handle the case where a form - // is submitting when this function is called. - if (form_elements.is_valid(form)) { - $(submit).prop("disabled", false); - } else { - $(submit).prop("disabled", true); - } + this.toggle_submit_button_form_valid = function (form, submit) { + // TODO do not change the submit button directly. change the + // `submittable` state of the form and handle the case where a form + // is submitting when this function is called. + if (form_elements.is_valid(form)) { + $(submit).prop("disabled", false); + } else { + $(submit).prop("disabled", true); } + } - this.init_validator = function (form) { - const submit = $(form).find(":input[type='submit']")[0]; - if (submit) { - form.addEventListener("caosdb.field.changed", (e) => form_elements.toggle_submit_button_form_valid(form, submit), true); - form.addEventListener("caosdb.field.enabled", (e) => form_elements.toggle_submit_button_form_valid(form, submit), true); - form.addEventListener("input", (e) => form_elements.toggle_submit_button_form_valid(form, submit), true); - } + this.init_validator = function (form) { + const submit = $(form).find(":input[type='submit']")[0]; + if (submit) { + form.addEventListener("caosdb.field.changed", (e) => form_elements.toggle_submit_button_form_valid(form, submit), true); + form.addEventListener("caosdb.field.enabled", (e) => form_elements.toggle_submit_button_form_valid(form, submit), true); + form.addEventListener("input", (e) => form_elements.toggle_submit_button_form_valid(form, submit), true); } + } - /** - * Return an input and a label, wrapped in a div with class - * `caosdb-f-field`. - * - * @param {object} config - config object with `name`, `type` and - * optional `label` - * @returns {HTMLElement} a form field. - */ - this._make_input = function (config) { - caosdb_utils.assert_string(config.name, "the name of a form field"); - let ret = $(this._make_field_wrapper(config.name)); - let name = config.name; - let label = this._make_input_label_str(config); - let type = config.type || "text"; - let value = config.value; - let input = $('<input class="form-control caosdb-f-property-single-raw-value" type="' + type + - '" name="' + name + - '" />'); - input.change(function () { - ret[0].dispatchEvent(form_elements.field_changed_event); - }); - let input_col = $('<div class="caosdb-f-property-value col-sm-9"/>'); - input_col.append(input); - if (value) { - input.val(value); - } - return ret.append(label, input_col)[0]; - } - - /** - * Return a string representation of a LABEL element, ready for parsing. - * - * This function is used by other functions to generate a LABEL element. - * - * The config's `name` goes to the `for` attribute, the `label` is the - * text node of the resulting LABEL element. - * - * @param {object} config - a config object with `name` and `label`. - * @returns {string} a html string for a LABEL element. - */ - this._make_input_label_str = function (config) { - let name = config.name; - let label = config.label; - return label ? '<label for="' + name + - '" data-property-name="' + name + - '" class="control-label col-sm-3">' + label + - '</label>' : ""; + /** + * Return an input and a label, wrapped in a div with class + * `caosdb-f-field`. + * + * @param {object} config - config object with `name`, `type` and + * optional `label` + * @returns {HTMLElement} a form field. + */ + this._make_input = function (config) { + caosdb_utils.assert_string(config.name, "the name of a form field"); + let ret = $(this._make_field_wrapper(config.name)); + let name = config.name; + let label = this._make_input_label_str(config); + let type = config.type || "text"; + let value = config.value; + let input = $('<input class="form-control caosdb-f-property-single-raw-value" type="' + type + + '" name="' + name + + '" />'); + input.change(function () { + ret[0].dispatchEvent(form_elements.field_changed_event); + }); + let input_col = $('<div class="caosdb-f-property-value col-sm-9"/>'); + input_col.append(input); + if (value) { + input.val(value); } + return ret.append(label, input_col)[0]; + } + /** + * Return a string representation of a LABEL element, ready for parsing. + * + * This function is used by other functions to generate a LABEL element. + * + * The config's `name` goes to the `for` attribute, the `label` is the + * text node of the resulting LABEL element. + * + * @param {object} config - a config object with `name` and `label`. + * @returns {string} a html string for a LABEL element. + */ + this._make_input_label_str = function (config) { + let name = config.name; + let label = config.label; + return label ? '<label for="' + name + + '" data-property-name="' + name + + '" class="control-label col-sm-3">' + label + + '</label>' : ""; } + this._init_functions(); } diff --git a/src/doc/Makefile b/src/doc/Makefile index b3c35b4d..f3519f27 100644 --- a/src/doc/Makefile +++ b/src/doc/Makefile @@ -33,8 +33,9 @@ BUILDDIR = ../../build/doc # npm is not always in the global PATH NPM_PATH = $(shell npm bin) +NPM_PREFIX = $(shell npm prefix) -.PHONY: doc-help Makefile apidoc +.PHONY: doc-help Makefile api # Put it first so that "make" without argument is like "make help". doc-help: @@ -42,10 +43,9 @@ doc-help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile +%: Makefile api PATH=$(NPM_PATH):$$PATH $(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) # sphinx-build -M html . ../../build/doc -# Not necessary in this repository, apidoc is doone with the sphinx-autoapi extension -# apidoc: -# @$(SPHINXAPIDOC) -o _apidoc --update --title="CaosDB Server" ../main/ +api: + PATH=$(NPM_PATH):$$PATH jsdoc -t $(NPM_PREFIX)/node_modules/jsdoc-sphinx/template -d $@ -r "../../src/core" diff --git a/src/doc/conf.py b/src/doc/conf.py index 988df3c3..8e627dd0 100644 --- a/src/doc/conf.py +++ b/src/doc/conf.py @@ -41,13 +41,15 @@ release = '0.x.y-beta-rc2' # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx_js', +# 'sphinx_js', 'sphinx.ext.todo', "sphinx.ext.autodoc", - 'autoapi.extension', +# 'autoapi.extension', "recommonmark", # For markdown files. "sphinx_rtd_theme", - # 'sphinx.ext.intersphinx', + 'sphinx.ext.intersphinx', + 'sphinx.ext.mathjax', + 'sphinx.ext.ifconfig', # 'sphinx.ext.napoleon', # For Google style docstrings ] @@ -207,3 +209,4 @@ autodoc_default_options = { autoapi_type = 'javascript' autoapi_dirs = ['../core/js/'] +autoapi_add_toctree_entry = False diff --git a/src/doc/extension.rst b/src/doc/extension.rst new file mode 100644 index 00000000..18fd0f25 --- /dev/null +++ b/src/doc/extension.rst @@ -0,0 +1,13 @@ + +Extending the CaosDB Web Interface +================================== + +Here we collect information on how to extend the web interface as a developer. + +.. toctree:: + :maxdepth: 1 + :glob: + + extension/* + + diff --git a/src/doc/extension/forms.rst b/src/doc/extension/forms.rst new file mode 100644 index 00000000..1bced612 --- /dev/null +++ b/src/doc/extension/forms.rst @@ -0,0 +1,80 @@ + +Creating forms for the CaosDB Web Interface +=========================================== + +The ``form_elements`` module provides a library for generating forms from simple config objects. The forms are styled for the seamless integration into the CaosDB web interface and are especially useful for calling server side scripts. + +See also the :doc:`API documentation <../api/module-form_elements>` + +Examples +-------- + +Generating a generic form +^^^^^^^^^^^^^^^^^^^^^^^^^ + +The following code snippet adds a form to the body of the HTML document. + +.. code-block:: javascript + + function my_special_submit_handler (form) { + // handle form submision + }; + const config = { + name: "my_form", + fields: [ + { type: "reference_drop_down", name: "experiment_id", label: "Experiment", query: "FIND Record Experiment", required: true }, + { type: "integer", name: "number", label: "A Number", required: true }, + { type: "date", name: "date", label: "A Date", required: false }, + { type: "text", name: "comment", label: "A Comment", required: false }, + ], + submit: my_special_submit_handler + }; + const form = form_elements.make_form(config); + $("body").append(form); + +The form has four fields: + + 1. A drop-down menu which contains all Records of type "Experiment" as options, + 2. an integer field, labeled "A Number", + 3. a date field, labeled "A Date", and + 4. a text field, labeled "A Comment". + +The first two fields are required and the form cannot be submitted without it. The latter are optional. + +On submission, the function ``my_special_submit_handler`` is being called with the form element as only parameter. + +As the generated form is a plain HTML form, the javascript form API can be used. However, there are special methods in the ``form_elements`` module e.g. :doc:`get_fields <../api/module-form_elements>` which are especially designed to interact with the forms generated by the ``make_form`` factory. + +Calling a server-side script +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If you intend to call a server-side script, the config has to be changed a litte bit and the script calling is done by the ``form_elements`` module. There is no need to define the submit_hander anymore. Instead, just name the script which is to be called. + +.. code-block:: javascript + + const config = { + script: "process.py", + fields: [ + { type: "reference_drop_down", name: "experiment_id", label: "Experiment", query: "FIND Record Experiment", required: true }, + { type: "integer", name: "number", label: "A Number", required: true }, + { type: "date", name: "date", label: "A Date", required: false }, + { type: "text", name: "comment", label: "A Comment", required: false }, + ], + }; + const form = form_elements.make_form(config); + $("body").append(form); + +On submission, the form data will be send as a json file to the script and passed as the first parameter. The call would look like ``./process.py form.json`` and the file would contain, for example, + +.. code-block:: json + + { + "experiment_id": "234234", + "number": "400", + "date": "2020-12-24", + "comment": "This is a comment", + } + +For more and advanced options for the form see the :doc:`API documentation <../api/module-form_elements>` + + diff --git a/src/doc/index.rst b/src/doc/index.rst index bc6fd9ed..107c9052 100644 --- a/src/doc/index.rst +++ b/src/doc/index.rst @@ -10,7 +10,8 @@ Welcome to the documentation of CaosDB's web UI! Getting started <getting_started> Tutorials <tutorials/index> Concepts <concepts> - API Index<genindex> + Extending the UI <extension> + API <api/index> This documentation helps you to :doc:`get started<getting_started>`, explains the most important diff --git a/test/docker/Dockerfile b/test/docker/Dockerfile index 8eff9aae..79f2dfcc 100644 --- a/test/docker/Dockerfile +++ b/test/docker/Dockerfile @@ -13,4 +13,5 @@ RUN pip3 install pandas xlrd==1.2.0 RUN pip3 install git+https://gitlab.com/caosdb/caosdb-advanced-user-tools.git@dev # For automatic documentation RUN npm install -g jsdoc +RUN npm install -g jsdoc-sphinx RUN pip3 install sphinx-js sphinx-autoapi recommonmark sphinx-rtd-theme -- GitLab