js/jsonform.ext.js

/**=====LICENSE STATEMENT START=====
    Translator++ 
    CAT (Computer-Assisted Translation) tools and framework to create quality
    translations and localizations efficiently.
        
    Copyright (C) 2018  Dreamsavior<dreamsavior@gmail.com>

    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program.  If not, see <https://www.gnu.org/licenses/>.
=====LICENSE STATEMENT END=====*/
/**
* Extending JSONform with custom fields
* https://github.com/jsonform/jsonform/wiki
* @file jsonform.ext.js
* @sample
* {
// The template describes the HTML that the field will generate.
// It uses Underscore.js templates.
template: '<div><div id="<%=node.id%>"><%=value%></div></div>',

// Set the inputfield flag when the field is a real input field
// that produces a value. Set the array flag when it creates an
// array of fields. Both flags are mutually exclusive.
// Do not set any of these flags for containers and other types
// of fields.
inputfield: (true || false || undefined),
array: (true || false || undefined),

// Most real input fields should set this flag that wraps the
// generated content into the HTML code needed to report errors
fieldtemplate: (true || false),

// Return the root element created by the field
// (el is the DOM element whose id is node.id,
// this function is only useful when el is not the root
// element in the field's template)
getElement: function (el) {
    // Adjust the following based on your template. In this example
    // there is an additional <div> so we need to go one level up.
    return $(el).parent().get(0);
},

// This is where you can complete the data that will be used
// to run the template string
onBeforeRender: function (data, node) {},

// This is where you can enhance the generated HTML by adding
// event handlers if needed
onInsert: function (evt, node) {}
};
*/



const JSONFormExt = {};
JSONFormExt.htmlentities = function(str){
    var element = document.createElement('div');
    element.textContent = str;
    return element.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
JSONFormExt.applySelectionLabel = function(formNode, titleMap) {
    // apply selection label onInsert()
    let elm = $(formNode.el).find("select");
    if (!elm.length) return;
    for (let key in titleMap) {
        elm.find(`option[value='${key}']`).text(titleMap[key]);
    }
}

JSONFormExt.deepCloneWithoutDefault = function(obj) {
    if (obj === null || typeof obj !== 'object') {
        return obj;
    }

    // Create an array or object to hold the values
    const clone = Array.isArray(obj) ? [] : {};

    for (const key in obj) {
        // eslint-disable-next-line no-prototype-builtins
        if (obj.hasOwnProperty(key) && key !== 'default') {
            clone[key] = JSONFormExt.deepCloneWithoutDefault(obj[key]);
        }
    }

    return clone;
}

// generate unique ID based on the structure of its JSON schema
// return the ID if available
JSONFormExt.generateFingerprint = function(node) {
    const formTree = node.ownerTree.formDesc;
    if (formTree.id) return formTree.id;
    const clonedSchema = this.deepCloneWithoutDefault(formTree.schema);
    console.log("clonedSchema", clonedSchema);
    const id = common.crc32String(JSON.stringify(clonedSchema));
    formTree.id = id;
    return id;
}

JSONFormExt.getExportableFormData = function(node, value=null) {
    if (!node) return null;

    const data = {
        header: {
            type: "jsonformvalue",
            date:new Date().toISOString(),
            id: this.generateFingerprint(node)
        },
        value: value || node.ownerTree.root.getFormValues()
    }
    return data;
}

JSONFormExt.exportForm = async function(node) {
    // add header to the value before exporting
    const data = this.getExportableFormData(node);
    const blob = new Blob([JSON.stringify(data)], {type: "application/json"});
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = "form.jsv";
    a.click();
}

JSONFormExt.importForm = async function(node) {
    const input = document.createElement("input");
    input.type = "file";
    input.accept = ".jsv";
    input.onchange = async function(e) {
        const file = e.target.files[0];
        const reader = new FileReader();
        reader.onload = async function(e) {
            const data = JSON.parse(e.target.result);
            if (data.header?.type !== "jsonformvalue") {
                alert("Invalid file format");
                console.error("Invalid file format");
                return;
            }
            const fingerPrint = JSONFormExt.generateFingerprint(node);
            if (data.header.id !== fingerPrint) {
                const conf = confirm("The imported form ID is different from the current form. Do you want to import anyway?");
                if (!conf) return console.error("Invalid form ID");
                
                // overwrite the form ID
                data.header.id = fingerPrint;
            }
            $(node.ownerTree.domRoot).jsonFormValue(data.value);
        }
        reader.readAsText(file);
    }
    input.click();
}



JSONFormExt.init = function() {
    const BLS = require("better-localstorage");
    this.localStorage = new BLS("jsonform");

    console.log("JSONFormExt.init loaded");
    // JSONForm.fieldTypes['html'] = {
    //     template : '<div class="form-group"><div id="<%=node.id%>"><%=elt.content%></div></div>',
    //     inputfield :false,
    //     array:false,
    //     getElement: function (el) {
    //         // el is the place with node.id
    //         // Adjust the following based on your template. In this example
    //         // there is an additional <div> so we need to go one level up.
    //         return $(el).parent().get(0);
    //     },
    //     onBeforeRender: function (data, node) {},
    //     onInsert: function (evt, node) {}
    // };
    JSONForm.fieldTypes['html'] = {
        template : '<div class="form-group"><%=elt.content || elt.value%></div>',
    };
    JSONForm.fieldTypes['hr'] = {
        template : '<div class="form-group"><hr /></div>',
    };
    JSONForm.fieldTypes['info'] = {
        template : `<div class="form-group blockBox infoBlock withIcon" ><%=elt.content || elt.value%></div>`,
    };
    JSONForm.fieldTypes['warn'] = {
        template : `<div class="form-group blockBox attentionBlock withIcon" ><%=elt.content || elt.value%></div>`,
    };
    JSONForm.fieldTypes['error'] = {
        template : `<div class="form-group blockBox errorBlock withIcon" ><%=elt.content || elt.value%></div>`,
    };
    
    JSONForm.fieldTypes['selectencoding'] = {...{}, ...JSONForm.fieldTypes.select};
    JSONForm.fieldTypes['selectencoding'].onBeforeRender = function(data, node) {
        node.options = [
            "", 
            "utf-7",
            "utf-7-imap",
            "utf8", 
            "UTF-16", 
            "UTF-16BE",
            "UTF-16LE",
            "UTF-32", 
            "UTF-32BE",
            "UTF-32LE",
            "ascii",
            "ISO-8859-1", 
            "ISO-8859-16", 
            "koi8-r", 
            "koi8-u", 
            "koi8-ru", 
            "koi8-t", 
            "Shift_JIS",
            "Windows-31j",
            "Windows932",
            "CP874",
            "Windows-1250",
            "Windows-1251",
            "Windows-1252",
            "Windows-1253",
            "Windows-1254",
            "Windows-1255",
            "Windows-1256",
            "Windows-1257",
            "Windows-1258",
            "CP932",
            "CP936",
            "CP949",
            "CP950",
            "GB2312",
            "EUC-JP",
            "EUC-CN",
            "EUC-KR",
            "GBK",
            "GB18030",
            "Windows936",
            "KS_C_5601",
            "Windows949",
            "Big5",
            "Big5-HKSCS",
            "Windows950",
            "maccroatian",
            "maccyrillic",
            "macgreek",
            "maciceland",
            "macroman",
            "macromania",
            "macthai",
            "macturkish",
            "macukraine",
            "maccenteuro",
            "macintosh",
        ]
    };
    
    JSONForm.fieldTypes['selectcolumn'] = {...{}, ...JSONForm.fieldTypes.select};
    JSONForm.fieldTypes['selectcolumn'].onBeforeRender = function(data, node) {
        node.options = [];
        node.formElement.titleMap = {};
        for (let colId=0; colId<trans.colHeaders.length; colId++) {
            node.options.push(colId);
            node.formElement.titleMap[colId] = trans.colHeaders[colId];
        }
    };
    JSONForm.fieldTypes['selectcolumn'].onInsert = function(evt, node) {
        JSONFormExt.applySelectionLabel(node, node.formElement.titleMap);
    }

    JSONForm.fieldTypes['selecttranslationcolumn'] = {...{}, ...JSONForm.fieldTypes.select};
    JSONForm.fieldTypes['selecttranslationcolumn'].onBeforeRender = function(data, node) {
        console.log("selecttranslationcolumn.onBeforeRender()", data, node, this);
        node.options = [];
        node.formElement.titleMap = {};
        for (let colId=0; colId<trans.colHeaders.length; colId++) {
            if (colId == trans.keyColumn) continue; // skip key column
            node.options.push(colId);
            node.formElement.titleMap[colId] = trans.colHeaders[colId];
        }
    };
    JSONForm.fieldTypes['selecttranslationcolumn'].onInsert = function(evt, node) {
        JSONFormExt.applySelectionLabel(node, node.formElement.titleMap);
    }

    JSONForm.fieldTypes['combobox'] = {
        "template": `<div><input type="search" class='form-control<%= (fieldHtmlClass ? " " + fieldHtmlClass : "") %>'name="<%= node.name %>" value="<%= escape(value) %>" id="<%= id %>" aria-label="<%= node.title ? escape(node.title) : node.name %>"<%= (node.disabled? " disabled" : "")%><%= (node.readOnly ? " readonly='readonly'" : "") %><%= (node.schemaElement && (node.schemaElement.step > 0 || node.schemaElement.step == "any") ? " step='" + node.schemaElement.step + "'" : "") %><%= (node.schemaElement && node.schemaElement.minLength ? " minlength='" + node.schemaElement.minLength + "'" : "") %><%= (node.schemaElement && node.schemaElement.maxLength ? " maxlength='" + node.schemaElement.maxLength + "'" : "") %><%= (node.schemaElement && node.schemaElement.required && (node.schemaElement.type !== "boolean") ? " required='required'" : "") %><%= (node.placeholder? " placeholder=" + '"' + escape(node.placeholder) + '"' : "")%> list="<%= id %>_datalist" /><datalist id="<%= id %>_datalist"><%=datalist%></datalist></div>`,
        "fieldtemplate": true,
        "inputfield": true,
        onBeforeRender: function (data, node) {
            data.datalist = ""
            if (!node.schemaElement?.enum?.length) return;
            const options = []
            for (let item of node.schemaElement.enum) {
                options.push(`<option value="${JSONFormExt.htmlentities(item)}">`)
            }
            data.datalist = options.join("")
        },
    }

    /**
     * Add tag elements.
     * Schema must be in 'object' type
     */
    JSONForm.fieldTypes['tag'] = {
        "template": `<input type="text" class='jsonform-tags form-control<%= (fieldHtmlClass ? " " + fieldHtmlClass : "") %>'name="<%= node.name %>" value="<%= escape(JSON.stringify(node.value)) %>" id="<%= id %>" aria-label="<%= node.title ? escape(node.title) : node.name %>"<%= (node.disabled? " disabled" : "")%><%= (node.readOnly ? " readonly='readonly'" : "") %><%= (node.schemaElement && (node.schemaElement.step > 0 || node.schemaElement.step == "any") ? " step='" + node.schemaElement.step + "'" : "") %><%= (node.schemaElement && node.schemaElement.minLength ? " minlength='" + node.schemaElement.minLength + "'" : "") %><%= (node.schemaElement && node.schemaElement.maxLength ? " maxlength='" + node.schemaElement.maxLength + "'" : "") %><%= (node.schemaElement && node.schemaElement.required && (node.schemaElement.type !== "boolean") ? " required='required'" : "") %><%= (node.placeholder? " placeholder=" + '"' + escape(node.placeholder) + '"' : "")%> />`,
        "fieldtemplate": true,
        "inputfield": true,
        onInsert: function (evt, node) {
            const $elm = $(node.el).find(".jsonform-tags");
            const whitelist = node.formElement.whitelist || node.schemaElement.enum || []
            const enforceWhitelist = node.formElement.enforceWhitelist || false;
            const maxTags = node.formElement.maxTags || Infinity;
            const mode = node.formElement.mode; // select

            const tagify = new Tagify($elm[0], {
                dropdown: {
                    highlightFirst: true
                },
                whitelist: whitelist, // predefined values
                enforceWhitelist: enforceWhitelist, // only allow values from the whitelist
                maxTags:maxTags,
                mode:mode,
                originalInputValueFormat: (valuesArr)=> {
                    console.log("%c originalInputValueFormat", "color:orange", valuesArr);
                    const result = [];
                    for (let val of valuesArr) {
                        result.push(val.value)
                    }
                    return JSON.stringify(result);
                }
            });

            if (node.formElement.sortable) {
                void async function() {
                    if (!window.DragSort) {
                        await common.loadDomScript("modules/dragsort/dragsort.js");
                        await common.loadCssFile("modules/dragsort/dragsort.css");
                    }
                    const dragsort = new DragSort(tagify.DOM.scope, {
                        selector: '.'+tagify.settings.classNames.tag,
                        callbacks: {
                            dragEnd: onDragEnd
                        }
                    });

                    function onDragEnd(elm){
                        tagify.updateValueByDOMTags()
                    }
                }();
            }
            $elm.data("tagify", tagify);
            console.log("initialized tag elm : ", $elm);
            console.log("node : ", node);
        },
        // getFormValuesHook : function(originalValues) {
        //     console.log("getFormValuesHook() custom getValue's this", this);
        //     console.log("getFormValuesHook() original value is", originalValues);
        //     const thisValue = originalValues[this.key];
        //     const valObj = JSON.parse(thisValue || "[]");
        //     const result = []
        //     for (let val of valObj) {
        //         result.push(val.value)
        //     }
        //     originalValues[this.key] = result;
        //     console.log("getFormValuesHook(), about to return", originalValues);
        //     return originalValues;
        // }

    };

    /**
     * Draw grid elements with HandsOnTable
     */
    JSONForm.fieldTypes['grid'] = {
        "template": `<input type="hidden" class='hot-value form-control<%= (fieldHtmlClass ? " " + fieldHtmlClass : "") %>'name="<%= node.name %>" value="<%= escape(JSON.stringify(node.value)) %>" id="<%= id %>" aria-label="<%= node.title ? escape(node.title) : node.name %>"<%= (node.disabled? " disabled" : "")%><%= (node.readOnly ? " readonly='readonly'" : "") %><%= (node.schemaElement && (node.schemaElement.step > 0 || node.schemaElement.step == "any") ? " step='" + node.schemaElement.step + "'" : "") %><%= (node.schemaElement && node.schemaElement.minLength ? " minlength='" + node.schemaElement.minLength + "'" : "") %><%= (node.schemaElement && node.schemaElement.maxLength ? " maxlength='" + node.schemaElement.maxLength + "'" : "") %><%= (node.schemaElement && node.schemaElement.required && (node.schemaElement.type !== "boolean") ? " required='required'" : "") %><%= (node.placeholder? " placeholder=" + '"' + escape(node.placeholder) + '"' : "")%> />
        <div class="hot-wrapper"></div>`,
        "fieldtemplate": true,
        "inputfield": true,
        onInsert: async function (evt, node) {
            if (!window.Handsontable) {
                await common.loadDomScript("/www/modules/handsontable/handsontable.js");
                await common.loadCssFile("/www/modules/handsontable/handsontable.css");
            }

            const $elm = $(node.el).find(".hot-wrapper");
            console.log("%con insert grid", "color:aqua", evt, node, this, $elm[0]);
            if (node.schemaElement?.type !== "array") {
                console.error("Grid field must be array type");
                return;
            }

            const hot = new Handsontable($elm[0], {
                data: [],
                colHeaders: true,
                rowHeaders: true,
                filters: true,
                // on change/edit, update the hidden field
                afterChange: function (changes, source) {
                    if (source === "loadData") return; // don't save this change
                    const data = hot.getData();
                    const jsonData = JSON.stringify(data);
                    $(node.el).find(".hot-value").val(jsonData);
                }
            });

            if (typeof node.value === "string") {
                node.value = JSON.parse(node.value);
            }
            if (typeof node.schemaElement?.default === "string" && node.schemaElement?.default.length > 0) {
                node.schemaElement.default = JSON.parse(node.schemaElement.default);
            }

            hot.loadData(node.value);
            hot.render();
        },
        // getFormValuesHook : function(originalValues) {
        //     console.log("getFormValuesHook() custom getValue's this", this);
        //     console.log("getFormValuesHook() original value is", originalValues);
        //     const thisValue = originalValues[this.key];
        //     const valObj = JSON.parse(thisValue || "[]");
        //     const result = []
        //     for (let val of valObj) {
        //         result.push(val.value)
        //     }
        //     originalValues[this.key] = result;
        //     console.log("getFormValuesHook(), about to return", originalValues);
        //     return originalValues;
        // }

    };

    JSONForm.fieldTypes['selectfile'] = {
        "template": `<input type="text" class='jsonform-selectfile form-control<%= (fieldHtmlClass ? " " + fieldHtmlClass : "") %>'name="<%= node.name %>" value="<%= escape(value) %>" id="<%= id %>" accept="<%= node.formElement.accept %>" aria-label="<%= node.title ? escape(node.title) : node.name %>"<%= (node.disabled? " disabled" : "")%><%= (node.readOnly ? " readonly='readonly'" : "") %><%= (node.schemaElement && (node.schemaElement.step > 0 || node.schemaElement.step == "any") ? " step='" + node.schemaElement.step + "'" : "") %><%= (node.schemaElement && node.schemaElement.minLength ? " minlength='" + node.schemaElement.minLength + "'" : "") %><%= (node.schemaElement && node.schemaElement.maxLength ? " maxlength='" + node.schemaElement.maxLength + "'" : "") %><%= (node.schemaElement && node.schemaElement.required && (node.schemaElement.type !== "boolean") ? " required='required'" : "") %><%= (node.placeholder? " placeholder=" + '"' + escape(node.placeholder) + '"' : "")%> />`,
        "fieldtemplate": true,
        "inputfield": true,
        onInsert: function (evt, node) {
            const $elm = $(node.el).find(".jsonform-selectfile");
            $elm.attr("data-renderdvfield", "this");
            var accept = node.formElement.accept || "*";
            $elm.attr("accept", accept);
            if (node.formElement.multiple) $elm.attr("multiple", "multiple");
            if (node.formElement.directory) $elm.attr("nwdirectory", "nwdirectory");
            if (node.formElement.nwsaveas) $elm.attr("nwsaveas", "nwsaveas");
           
            const dvFields = new DVField();
            dvFields.renderAll($elm);
        }
    };
    JSONForm.fieldTypes['selectdir'] = {
        "template": `<input type="text" class='jsonform-selectfile form-control<%= (fieldHtmlClass ? " " + fieldHtmlClass : "") %>'name="<%= node.name %>" value="<%= escape(value) %>" id="<%= id %>" accept="<%= node.formElement.accept %>" aria-label="<%= node.title ? escape(node.title) : node.name %>"<%= (node.disabled? " disabled" : "")%><%= (node.readOnly ? " readonly='readonly'" : "") %><%= (node.schemaElement && (node.schemaElement.step > 0 || node.schemaElement.step == "any") ? " step='" + node.schemaElement.step + "'" : "") %><%= (node.schemaElement && node.schemaElement.minLength ? " minlength='" + node.schemaElement.minLength + "'" : "") %><%= (node.schemaElement && node.schemaElement.maxLength ? " maxlength='" + node.schemaElement.maxLength + "'" : "") %><%= (node.schemaElement && node.schemaElement.required && (node.schemaElement.type !== "boolean") ? " required='required'" : "") %><%= (node.placeholder? " placeholder=" + '"' + escape(node.placeholder) + '"' : "")%> />`,
        "fieldtemplate": true,
        "inputfield": true,
        onInsert: function (evt, node) {
            const $elm = $(node.el).find(".jsonform-selectfile");
            $elm.attr("data-renderdvfield", "this");
            var accept = node.formElement.accept || "*";
            $elm.attr("accept", accept);
            if (node.formElement.multiple) $elm.attr("multiple", "multiple");
            if (node.formElement.nwsaveas) $elm.attr("nwsaveas", "nwsaveas");
            $elm.attr("nwdirectory", true);

            const dvFields = new DVField();
            dvFields.renderAll($elm);
        }
    };
    // patch for ace field.
    // load ace if ace is not defined
    const aceOnInsert = JSONForm.fieldTypes['ace'].onInsert;
    JSONForm.fieldTypes['ace'].onInsert = async function(evt, node) {
        if (!window.ace) {
            await common.loadDomScript("modules/ace/src-min-noconflict/ace.js");
        }
        aceOnInsert.call(this, evt, node);
    };

    /**
     * Add color tag component.
     * Schema must be in 'object' type
     */
    JSONForm.fieldTypes['colortag'] = {
        "template": `<div class="colorTagSelector"></div><input type="hidden" class='jsonform-colortags form-control<%= (fieldHtmlClass ? " " + fieldHtmlClass : "") %>'name="<%= node.name %>" value="<%= escape(JSON.stringify(node.value)) %>" id="<%= id %>" aria-label="<%= node.title ? escape(node.title) : node.name %>"<%= (node.disabled? " disabled" : "")%><%= (node.readOnly ? " readonly='readonly'" : "") %><%= (node.schemaElement && (node.schemaElement.step > 0 || node.schemaElement.step == "any") ? " step='" + node.schemaElement.step + "'" : "") %><%= (node.schemaElement && node.schemaElement.minLength ? " minlength='" + node.schemaElement.minLength + "'" : "") %><%= (node.schemaElement && node.schemaElement.maxLength ? " maxlength='" + node.schemaElement.maxLength + "'" : "") %><%= (node.schemaElement && node.schemaElement.required && (node.schemaElement.type !== "boolean") ? " required='required'" : "") %><%= (node.placeholder? " placeholder=" + '"' + escape(node.placeholder) + '"' : "")%> />`,
        "fieldtemplate": true,
        "inputfield": true,
        onInsert: function (evt, node) {
            const $elm = $(node.el).find(".jsonform-colortags");
            const mode = node.formElement.mode; // select

            //console.log("Preparing tagify for elm", $elm[0]);
            const tags = new UiTags();
            tags.on("change", function(info) {
                console.log("tag change", info[0], info[1]);
                $elm.val(JSON.stringify(info[1]));
            });
            $(node.el).find(".colorTagSelector").empty();
            $(node.el).find(".colorTagSelector").append(tags.element);
            console.log("initialized tag elm : ", $elm);
            console.log("node : ", node);
        }
    };


    // todo
    // select file / folder field
    JSONForm.fieldTypes['selectfileorfolder'] = {
        "template": `<input type="text" class='jsonform-selectfile form-control<%= (fieldHtmlClass ? " " + fieldHtmlClass : "") %>'name="<%= node.name %>" value="<%= escape(value) %>" id="<%= id %>" accept="<%= node.formElement.accept %>" aria-label="<%= node.title ? escape(node.title) : node.name %>"<%= (node.disabled? " disabled" : "")%><%= (node.readOnly ? " readonly='readonly'" : "") %><%= (node.schemaElement && (node.schemaElement.step > 0 || node.schemaElement.step == "any") ? " step='" + node.schemaElement.step + "'" : "") %><%= (node.schemaElement && node.schemaElement.minLength ? " minlength='" + node.schemaElement.minLength + "'" : "") %><%= (node.schemaElement && node.schemaElement.maxLength ? " maxlength='" + node.schemaElement.maxLength + "'" : "") %><%= (node.schemaElement && node.schemaElement.required && (node.schemaElement.type !== "boolean") ? " required='required'" : "") %><%= (node.placeholder? " placeholder=" + '"' + escape(node.placeholder) + '"' : "")%> />`,
        "fieldtemplate": true,
        "inputfield": true,
        onInsert: function (evt, node) {
            const $elm = $(node.el).find(".jsonform-selectfile");
            $elm.attr("data-renderdvfield", "this");
            var accept = node.formElement.accept || "*";
            $elm.attr("accept", accept);
            if (node.formElement.multiple) $elm.attr("multiple", "multiple");
            if (node.formElement.directory) $elm.attr("nwdirectory", "nwdirectory");
            if (node.formElement.nwsaveas) $elm.attr("nwsaveas", "nwsaveas");
           
            const dvFields = new DVField();
            dvFields.renderAll($elm);
        }
    };

    // Consist of sets of buttons
    // Reset, quick save, export and import the value of the form using bootstrap 5
    JSONForm.fieldTypes['formcontrol'] = {
        template: `<div class="bs5 form-group jsonform-formcontrol <%= elt.htmlClass?elt.htmlClass:"" %>">
            <label><%= elt.title %></label>
            <nav class="navbar bg-body-tertiary">
                <div class="container-fluid justify-content-end">
                    <div class="btn-group me-3" role="group">
                        <button class="btn btn-primary jsonform-save">Quick Save</button>
                        <button class="btn btn-primary jsonform-load">Quick Load</button>
                    </div>
                    <div class="btn-group me-3" role="group">
                        <button class="btn btn-primary jsonform-export">Export</button>
                        <button class="btn btn-primary jsonform-import">Import</button>
                    </div>
                    <button class="btn btn btn-outline-danger jsonform-reset">Reset</button>
                </div>
            </nav>
            <nav class="navbar bg-body-tertiary">
                <div class="container-fluid">
                    <div class="input-group">
                            <label class="input-group-text">Preset</label>
                        <select class="form-select custom-select jsonform-preset-list">
                            <optgroup label="Default Presets" class="defaultPresets">
                                <option value="default" selected>Default</option>
                            </optgroup>
                            <optgroup label="User's Presets" class="userPresets">
                            </optgroup>
                        </select>
                            <button type="button" class="btn btn-outline-secondary jsonform-preset-save" title="save"><i class="icon-save"></i></button>
                            <button type="button" class="btn btn-outline-secondary jsonform-preset-saveas" title="save-as"><i class="icon-save-as"></i></button>
                            <button type="button" class="btn btn-danger jsonform-preset-remove" title="delete"><i class="icon-trash"></i></button>
                    </div>
                </div>
            </nav>
        </div>`,
        onInsert: function (evt, node) {
            console.log("%cformcontrol onInsert", "color:orange", arguments);
            console.log("%c- formcontrol node:", "color:orange", node);
            console.log("%c- formcontrol this:", "color:orange", this);
            const $elm = $(node.ownerTree.domRoot).find(".jsonform-formcontrol");

            

            const formFingerprint = JSONFormExt.generateFingerprint(node);
            console.log("formFingerprint", formFingerprint);



                
            $elm.find(".jsonform-reset").on("click", async function(e) {
                e.preventDefault();
                console.log("reseting form", node);
                const conf = confirm("Resetting will discard all changes. Continue?");
                if (!conf) return;
                if (typeof node.formElement.onReset === "function") {
                    await node.formElement.onReset(node);
                }
                $(node.ownerTree.domRoot).jsonFormReset();
            });
            $elm.find(".jsonform-save").on("click", async function(e) {
                e.preventDefault();
                const value = node.ownerTree.root.getFormValues();
                JSONFormExt.localStorage.set(formFingerprint+"/quickSave", value);
            });
            $elm.find(".jsonform-load").on("click", async function(e) {
                e.preventDefault();
                const value = await JSONFormExt.localStorage.get(formFingerprint+"/quickSave");
                if (value) {
                    $(node.ownerTree.domRoot).jsonFormValue(value);
                }
            });
            $elm.find(".jsonform-export").on("click", async function(e) {
                e.preventDefault();
                JSONFormExt.exportForm(node);
            });
            $elm.find(".jsonform-import").on("click", async function(e) {
                e.preventDefault();
                const conf = confirm("Importing will overwrite the current form value. Continue?");
                if (!conf) return;
                JSONFormExt.importForm(node);
            });

            // handle presets
            const getPresetBaseLocation = () => {
                return "data/form/"+formFingerprint+"/presets";
            }
            /**
             * renders default presets
             */
            const renderDefaultPresets = async () => {
                // get user's preset from common.localStorage and append it to the select menu
                // get all *.jsv files in the default preset folder
                const defaultPresetFolder = getPresetBaseLocation()+"/default";
                if (await common.isDir(defaultPresetFolder)) {
                    let files = await common.searchFile("*.jsv", defaultPresetFolder);
                    console.log("default preset files", files);
                    for (let file of files) {
                        const presetName = common.getFilename(file);
                        $elm.find(".jsonform-preset-list optgroup.defaultPresets").append(`<option value="${presetName}">${presetName}</option>`);
                    }
                }

                // load default preset from schema definition
                if (node.formElement?.presets) {
                    for (let preset of node.formElement.presets) {
                        // if preset.file is defined, add it into option's data attribute
                        if (preset.file) {
                            $elm.find(".jsonform-preset-list optgroup.defaultPresets").append(`<option value="${preset.name}" data-file="${preset.file}">${preset.name}</option>`);
                        } else {
                            $elm.find(".jsonform-preset-list optgroup.defaultPresets").append(`<option value="${preset.name}">${preset.name}</option>`);
                        }
                    }
                }

                // do the same thing for user's preset
                const userPresetFolder = getPresetBaseLocation()+"/user";
                if (await common.isDir(userPresetFolder)) {
                    let files = await common.searchFile("*.jsv", userPresetFolder);
                    for (let file of files) {
                        const presetName = common.getFilename(file);
                        $elm.find(".jsonform-preset-list optgroup.userPresets").append(`<option value="${presetName}">${presetName}</option>`);
                    }
                }

                // immidiately select the activePreset
                let activePreset = node.ownerTree?.formDesc?.activePreset || await getActivePreset() || "default";
                $elm.find(".jsonform-preset-list").val(activePreset);
            }
            renderDefaultPresets();

            const savePreset = async (presetName, value) => {
                //JSONFormExt.localStorage.set(formFingerprint+"/preset/"+presetName, value);
                const data = JSONFormExt.getExportableFormData(node, value);
                const path = getPresetBaseLocation()+"/user/"+presetName+".jsv";
                await common.mkDir(getPresetBaseLocation()+"/user");
                await common.filePutContents(path, JSON.stringify(data), "utf8", false);
            }

            const savePresetAs = async (value) =>{
                if (!value) return;
                // eslint-disable-next-line no-constant-condition
                while (true) {
                    const presetName = prompt("Enter preset name");
                    if (!presetName) return;
                    if (presetName == "default") {
                        alert("Invalid preset name");
                        continue;
                    }
                    // if presetName is not a valid filename, prompt again
                    if (!common.isValidFilename(presetName)) {
                        alert("Invalid preset name. Preseet name must not contain special characters.");
                        continue;
                    }
                    if ($elm.find(".jsonform-preset-list option[value='"+presetName+"']").length) {
                        alert("Preset name already exists. Please use another name.");
                        continue;
                    }
                    await savePreset(presetName, value);
                    // append to the userPresets
                    $elm.find(".jsonform-preset-list optgroup.userPresets").append(`<option value="${presetName}">${presetName}</option>`);
                    //immidiate select the new preset
                    $elm.find(".jsonform-preset-list").val(presetName);
                    break;
                }
            }

            const deletePreset = async (presetName) => {
                //JSONFormExt.localStorage.delete(formFingerprint+"/preset/"+presetName);
                await common.unlink(getPresetBaseLocation()+"/user/"+presetName+".jsv");
            }

            const loadPreset = async (presetName) => {
                // get options element by its value, then check if data-file is defined, if so, load the file
                console.log("loading preset", presetName);
                const option = $elm.find(".jsonform-preset-list option[value='"+presetName+"']");
                const defaultPreset = getPresetBaseLocation()+"/default/"+presetName+".jsv";
                const userPreset = getPresetBaseLocation()+"/user/"+presetName+".jsv";
                var fileContent = null;
                if (option.data("file")) {
                    const file = option.data("file");
                    fileContent = await common.fileGetContents(file, "utf8");
                }
                
                // check in default preset first
                else if (await common.isFileAsync(defaultPreset)) {
                    fileContent = await common.fileGetContents(defaultPreset, "utf8");
                } else {
                    fileContent = await common.fileGetContents(userPreset, "utf8");
                }
                if (!fileContent) return null;
                
                try {
                    console.log("%c-Loading preset", "color:green", presetName);
                    const data = JSON.parse(fileContent);
                    
                    if (typeof node.formElement?.onPresetLoad == "function") {
                        node.formElement.onPresetLoad(data.value, node);
                    }

                    return data.value;
                } catch (e) {
                    console.error("Failed to load preset", e);
                    return null;
                }
            }


            /**
             * Save the active preset name
             * @param {*} activePreset 
             */
            const saveActivePreset = async (activePreset) => {
                const configPath = getPresetBaseLocation()+"/config.json";
                const data = {
                    activePreset: activePreset
                }
                await common.filePutContents(configPath, JSON.stringify(data), "utf8", false);
            }

            const getActivePreset = async () => {
                const configPath = getPresetBaseLocation()+"/config.json";
                if (!await common.isFileAsync(configPath)) return null;
                const data = await common.fileGetContents(configPath, "utf8");
                const config =  JSON.parse(data);
                return config?.activePreset;
            }

            // save as a new preset. Prevent the use of default preset and existing preset.
            $elm.find(".jsonform-preset-saveas").on("click", async function(e) {
                e.preventDefault();
                const value = node.ownerTree.root.getFormValues();
                savePresetAs(value);
            });

            
            /**
             * save current preset. Get the current preset name from the select menu
             * and save the value to the localStorage.
             * If the current preset is default, prompt saveAs instead.
             */
            $elm.find(".jsonform-preset-save").on("click", async function(e) {
                e.preventDefault();
                const value = node.ownerTree.root.getFormValues();
                const presetName = $elm.find(".jsonform-preset-list").val();
                if (presetName == "default") {
                    savePresetAs(value);
                    return;
                }
                savePreset(presetName, value);
            });

            /**
             * Delete the current preset. Cannot delete presets under the group of Default Presets.
             */
            $elm.find(".jsonform-preset-remove").on("click", async function(e) {
                e.preventDefault();
                // get the selected option element
                const selectedOption = $elm.find(".jsonform-preset-list option:selected");
                if (selectedOption.parent().hasClass("defaultPresets")) {
                    alert("Cannot delete default preset");
                    return;
                }

                const presetName = $elm.find(".jsonform-preset-list").val();
                await deletePreset(presetName);
                
                $elm.find(".jsonform-preset-list option[value='"+presetName+"']").remove();

                // select the default preset
                $elm.find(".jsonform-preset-list").val("default");
            });


            $elm.find(".jsonform-preset-list").on("focus", function() {
                $(this).data("prevValue", $(this).val());
            });
            /**
             * Load the selected preset value to the form.
             */
            $elm.find(".jsonform-preset-list").on("change", async function(e) {
                // save previous preset
                let lastLoadedPresets = $(this).data("prevValue");
                console.log("%c-Saving previous preset", "color:orange", lastLoadedPresets);

                if (lastLoadedPresets) {
                    const oldValue = node.ownerTree.root.getFormValues();
                    await savePreset(lastLoadedPresets, oldValue);
                }

                $(this).data("prevValue", $(this).val());
                
                const presetName = $(this).val();
                // store the current preset name to the form
                node.ownerTree.formDesc.activePreset = presetName;
                console.log("%c-Saving active preset name", "color:orange", presetName);
                await saveActivePreset(presetName);

                // if presetName is default, reset the form
                if (presetName == "default") {
                    $(node.ownerTree.domRoot).jsonFormReset();
                    return;
                }
                const value = await loadPreset(presetName);
                if (value) {
                    $(node.ownerTree.domRoot).jsonFormValue(value);
                } else {
                    // reset the form
                    $(node.ownerTree.domRoot).jsonFormReset();
                }
                
            });

            /**
             * Assign default value to the select menu
             */
        }
    };





    JSONForm.fieldTypes.fieldset.template = `<fieldset class="form-group jsonform-error-<%= keydash %> <% if (elt.expandable) { %>expandable<% } %> <%= elt.htmlClass?elt.htmlClass:"" %>" <% if (id) { %> id="<%= id %>"<% } %>><% if (node.title || node.legend) { %><legend role="treeitem" aria-expanded="false"><%= node.title || node.legend %></legend><% } %><% if (elt.expandable) { %><div class="form-group"><% } %><div class="fieldset-content"><%= children %></div><% if (elt.expandable) { %></div><% } %></fieldset>`

}



$(document).ready(function() {
    // apply some css
    $("head").append(`<style>
        .form-group.btn-select-jumbo .controls > div > label {
            width: calc(46% - 1em);
            white-space: normal;
            margin: .5em;
        }

        .form-group.btn-select-jumbo .controls > div {
            display: flex;
            width: 100%;
            flex-flow: wrap;
            justify-content: center;
        }
    </style>`);
});