Use Plugin Code Editor - Part 3/4: Custom

If you are visiting this page, I suppose you have been encountering these issues I mentioned in my previous post. I am joking. May be or may be not. That's OK.

In this post, I will go through these issues case by case,  illustrate what I did to fix or provide a workaround and then explain how I customize Native, Hybrid and Editor to Textarea demo pages.

Click the link in TOC  to jump to the section you are interested in or

See more:

If you use Code Editor plugin directly, you will get lots of errors  logged in console as below for button title/name translation. And from page, you will find these button title letters are upper case.
Format(CODE_EDITOR.SHORTCUT_TITLE): too many arguments. Expecting 0, got 2
In order to fix this issue or provide a workaround, I had to use a dynamic action to re-add message before Code Editor loading and wrote a function to rename these button titles after Code Editor loading.

Basically, when rendering page, APEX engine will prepare these messages for all page components. If you open toolbar of browser(F12) and switch to source tab, you can find a source named wwv_flow.js_messages?p_app_id=xxx&p_lang=en-us&p_version=xxx-xxx. There are three functions in this source file.
apex.lang.addMessages(...);
apex.locale.init(...);
apex.message.registerTemplates(...);
The first function apex.lang.addMessages (click to APEX 5.1 Doc for more information) is used to translate these button titles. In this case, the messages for Code Editor are not included in this function as default. I don't know why but we can simply fix this by adding these message in a dynamic action when page loading.

Why dynamic action? Because if we want to keep the script execution order, we need run this function before plugin initialization. According to my previous post, we can utilize substitution string #GENERATED_CSS# where DA script goes to, to make sure DA script will be executed before scripts in placeholder #GENERATED_JAVASCRIPT# where plugin initialization goes to.
// set message for code editor plugin 
apex.lang.addMessages({
        "CODE_EDITOR.VALIDATION_SUCCESS": "Validation successful",
        "CODE_EDITOR.UNDO": "Undo",
        "CODE_EDITOR.REDO": "Redo",
        "CODE_EDITOR.FIND": "Find",
        "CODE_EDITOR.FIND_NEXT": "Find Next",
        "CODE_EDITOR.FIND_PREV": "Find Prev",
        "CODE_EDITOR.REPLACE": "Replace",
        "CODE_EDITOR.HINT": "Auto Complete",
        "CODE_EDITOR.VALIDATE": "Validate",
        "CODE_EDITOR.SETTINGS": "Settings",
        "CODE_EDITOR.USE_PLAIN_TEXT_EDITOR": "Use Plain Text Editor",
        "CODE_EDITOR.SHOW_LINE_NUMBERS": "Show Line Numbers",
        "CODE_EDITOR.INDENT_WITH_TABS": "Tab Inserts Spaces",
        "CODE_EDITOR.TAB_SIZE": "Tab Size",
        "CODE_EDITOR.INDENT_SIZE": "Indent Size",
        "CODE_EDITOR.THEMES": "Themes",
        "CODE_EDITOR.SHORTCUT_TITLE": "\u00250 - \u00251",
        "CODE_EDITOR.SHOW_RULER": "Show Ruler",
        "CODE_EDITOR.MATCH_CASE": "Match Case",
        "CODE_EDITOR.MATCH_RE": "Regular Expression",
        "CODE_EDITOR.CLOSE": "Close",
        "CODE_EDITOR.FIND_INPUT": "Find",
        "CODE_EDITOR.REPLACE_INPUT": "Replace",
        "CODE_EDITOR.REPLACE_ALL": "Replace All",
        "CODE_EDITOR.REPLACE_SKIP": "Skip",
        "CODE_EDITOR.QUERY_BUILDER": "Query Builder",
    });
After adding this JavaScript, we can translate some of the buttons for Code Editor, but not all. Then the errors caused by the line 19 highlighted above will prevent translation of other 7 buttons/7 times in toolbar area of Code Editor.

Adding JavaScript below to page JavaScript (Execute when Page Loads) will help to translate other buttons.
// translate button title
(function transButtons() {
    // translation metadata
    var titleObj = {
        undo: "Undo - Ctrl+Z",
        redo: "Redo - Ctrl+Shift+Z",
        find: "Find - Ctrl+F",
        replace: "Replace - Ctrl+Shift+F",
        queryBuilder: "Query Builder",
        autocomplete: "Auto Complete - Ctrl+Space",
        settings: "Settings",
        findNext: "Find Next - Ctrl+G",
        findPrev: "Find Prev - Ctrl+Shift+G",
        sClose: "Close",
        mClose: "Close",
        FIND_INPUT: "Find",
        MATCH_CASE: "Match Case",
        MATCH_RE: "Regular Expression",
        REPLACE_INPUT: "Replace",
        REPLACE: "Replace",
        REPLACE_ALL: "Replace All",
        REPLACE_SKIP: "Skip",
        USE_PLAIN_TEXT_EDITOR: "Use Plain Text Editor",
        INDENT_WITH_TABS: "Tab Inserts Space",
        TAB_SIZE: "Tab Size",
        INDENT_SIZE: "Indent Size",
        THEMES: "Themes",
        SHOW_LINE_NUMBERS: "Show Line Numbers",
        SHOW_RULER: "Show Ruler"
    };

    // translate buttons title starting with "CODE_EDITOR."
    apex.jQuery("[title^='CODE_EDITOR.']")
        .each(function(i) {
            var subSource = apex.jQuery(this).attr('id');
            var titleStr = titleObj[subSource.substring(subSource.lastIndexOf('_') + 1)];
            apex.jQuery(this).prop('title', titleStr).attr('aria-label', titleStr);
        });

    // translate labels with text containing "CODE_EDITOR."
    apex.jQuery("label:contains('CODE_EDITOR.')")
        .each(function(i) {
            var subSource = apex.jQuery(this).text();
            apex.jQuery(this).text(titleObj[subSource.substring(subSource.indexOf('.') + 1)]);
        });

    // translate buttons with text containing "CODE_EDITOR."
    apex.jQuery("button:contains('CODE_EDITOR.')")
        .each(function(i) {
            var subSource = apex.jQuery(this).text();
            apex.jQuery(this).text(titleObj[subSource.substring(subSource.indexOf('.') + 1)]);
        });

    // translate buttons in pop-menu with text containing "CODE_EDITOR."
    function menuHandler(event) {
        var divID = $(this).attr('id');
        apex.jQuery(function($) {
            $('#' + divID + 'Menu')
                .show(120, function() {
                    $("button:contains('CODE_EDITOR.')").each(function(i) {
                        var subSource = $(this).text();
                        $(this).text(titleObj[subSource.substring(subSource.indexOf('.') + 1)]);
                    });
                })
                .show()
                .css({ 'position': "absolute", 'top': event.pageY, 'left': event.pageX });
        });
    };
    apex.jQuery("button[id$='_widget_settings']")
        .click(menuHandler);
})();
Actually, this script will fix all button title translation as all button titles are included in metadata.

You probably need to custom your own translation metadata depending on your own locale setting, e.g. you are using Français or 中文.

Native

For Native demo pages (including Native - Migrated page ), I used 2 steps above to revise button titles. And it can reduce JavaScript errors but can not fix all. I have not yet figure out why. 

Anyway, there errors, if you don't check console, you will not notice them. I mean from usage point of view, you can ignore them. However, from technical point of view, this is an issue. 

Hybrid

For two Hybrid demo pages, I removed 53 to 69 lines in the script above because re-generating Code Editor helps to translate buttons in setting menu.

Editor to Textarea

For Editor to Textarea demo page, it's no necessary to include these two scripts above because there is no Code Editor on the page, and no errors.

Maximum Length 

When you use item type Code Editor, you might want to define max-length for code input. Unfortunately, you can not.  Even though you can set the value for this property, it doesn't work.
This is a Code Editor issue, or an un-implemented feature.

Native

In Native demo pages, I used validation binding to item to check the max length. It did work.

Hybrid

In Hybrid demo pages, I didn't handle this issue because I suppose all columns are nullable and they are CLOB columns. Actually, you can use some JavaScript to restrict the input length since there is a JavaScript API to getValue of Code Editor I mention in my previous post.

Editor to Textarea

In Editor to Textarea demo page, I use maximum length property to restrict the length. It's handy and perfect solution for this issue due to the restriction of input when max-length reaching.

Resize 

Sometimes, you might want to expand Code Editor to utilize your screen width for better experience, typically in dialog as what I did in my demo. 
In my previous post, I have mentioned there is a resize event for handling resize in main page but no in dialog. So here this issue is talking about resize in dialog.

Native

Even though there is a resize event defined for _widget div, I prefer to use a stupid way to handle this issue. First, trigger a click to search button, and then set a timeout to click the close button. These two steps will trigger internal resize event of Code Editor.

There is a more logical way to fix this issue by re-calculating width and height of the dialog and Code Editor. Maybe I am too lazy to implement this.

Hybrid

Stupid way as above.

Editor to Textarea

For Editor to Textarea demo page, I didn't implement expand and restore dialog. And there should be an easy way to handle this issue if you want, just using the class below as what I added in page inline CSS for resizing when navigation menu hide and show.
.t-Form-inputContainer fieldset {
    width: 100% !important;
}
textarea {
    width: inherit !important;
}

Validate 

Validate is a great feature for Code Editor in APEX. But it's disabled as default and there is no interface provided to enable from page designer.

Native

In Native demo pages, I used validations to check syntax as what I mentioned in my previous post. the only one issue is that error message for SQL mode can not be translated to the detailed info like ORA-xxxx, but it will display as "SQL syntax error" customized by me.

Hybrid

In Hybrid demo pages,  I used another stupid way which is similar with the way I handled resize issue. OK.

Let's go to detail:
  • First, set a custom attribute validation="true" for the region which has a Code Editor plugin, which you want to enable validate button in toolbar
  • Then, re-generate these Code Editors with validation="true" attribute to enable validate button
  • Define a global JavaScript function in page to sniffer the error messages after trigger validate button click event (check Function and Global Variable Declaration property in Hybrid demo Pages)
    // use attribute to select plugin with validation button and trigger click
    apex.jQuery("div[id^='REGION_']").filter("[validation=true]").each(function(i) {
        var rID = $(this).attr("id");
        var wID = rID.substring(rID.indexOf("_") + 1) + "_widget"
        var vID = wID + "_validate";

        // trigger validate button click and bind own click event
        // after checking unbind own click event
        apex.jQuery("#" + vID).on("click.myPlugin", function() {
            var i = setInterval(function() {
                checkedCounter = 0;
                validateChecked(wID);
                if (checkedCounter == 1) {
                    apex.jQuery("#" + vID).off("click.myPlugin");
                    clearInterval(i);
                }

            }, 200);
        }).trigger("click");
    });
The code above need to integrate with message handling and page submission in next section to work properly.

Editor to Textarea

As same as Native demo.

Message

If you set error display location to be "inline with Field and in Notification" or "inline with Field" when using validations,  you will get error information from browser console log as below.
Uncaught TypeError: f.addClass is not a function
And the error message will only be displayed in the field, typically below the widget.
I suppose this issue is related to the third function apex.message.registerTemplates in js file wwv_flow.js_messages I mentioned above. When ajax returning error messages, page wants to render them to a certain div or certain message container, but it can not find the corresponding tag or class in the message templates defined in this function.

Unfortunately, I have not yet found a solution for this issue but I can offer some workarounds depending on various scenarios.

Native

In Native demo pages, I set all validations to return the message to "inline in Notification". This is a great workaround with the benefit that you also can click the message to jump to certain Code Editor.

Hybrid

In Hybrid demo pages, I used the following steps to handle messages after trigger validate.
  • First trigger a click event to all validate buttons
  • Then set an interval in JavaScript to check whether the error messages returned to page by ajax call triggered by this click event
  • Collect the messages to a message array and cleanup messages in the page
  • Then again, set another interval to check whether all messages generated or not 
  • If yes, then use APEX JavaScript API apex.message.showErrors to show all messages
Please check the code below I added in page JavaScript property (Function and Global Variable Declaration) for more information.
// define message display location
// supported: ["page"], ["inline"] or ["page", "inline"]
var messageLocation = ["page"];

// validate and submit page
function submitP() {

    // show all tabs before re-submit, in order to remove all error message before re-validate
    if ($("div.apex-rds-container li a").filter("[href='#SHOW_ALL']").length == 1) {
        $("div.apex-rds-container li a").filter("[href='#SHOW_ALL']").trigger("click").blur();
    }
    // remove previous error or warning messages
    // keeping this order is important!
    apex.jQuery("button[id$='_widget_mClose']").trigger("click");
    apex.jQuery("div.is-error").remove();
    apex.jQuery("div.is-warning").remove();
    apex.jQuery("li.is-success").remove();
    apex.jQuery("div.a-CodeEditor-message").empty();
    apex.jQuery("div[id$='_widget_error'").empty();

    // remove previous messages and messages stored in Array
    apex.message.clearErrors();
    var messageArr = [];
    var checkedCounter = 0;
    var messageCounter = 0;

    // detect error messages and store to messageArr after click the validate button
    function validateChecked(widget) {
        if (apex.jQuery("#" + widget + " div.is-error").length > 0 || apex.jQuery("#" + widget + " div.is-warning") > 0) {
            checkedCounter = 1;
            var msg = (apex.jQuery("#" + widget + " div.is-warning").text() == "") ? apex.jQuery("#" + widget + " div.is-error").text() : apex.jQuery("#" + widget + " div.is-warning").text();
            // *** generate message for current plugin widget to avoid property value waving among other widgets
            // during frequent checking poll
            var eObj = {};
            eObj[widget] = {
                type: "error",
                location: messageLocation,
                pageItem: widget,
                message: msg,
                unsafe: true
            };
            messageArr.push(eObj[widget]);
            messageCounter++;
        }

        if (apex.jQuery("#" + widget + " .is-success").length > 0) {
            checkedCounter = 1;
            messageCounter++;
        }
    }

    // use attribute to select plugin with validation button and trigger click
    apex.jQuery("div[id^='REGION_']").filter("[validation=true]").each(function(i) {
        var rID = $(this).attr("id");
        var wID = rID.substring(rID.indexOf("_") + 1) + "_widget"
        var vID = wID + "_validate";

        // trigger validate button click and bind own click event
        // after checking unbind own click event
        apex.jQuery("#" + vID).on("click.myPlugin", function() {
            var i = setInterval(function() {
                checkedCounter = 0;
                validateChecked(wID);
                if (checkedCounter == 1) {
                    apex.jQuery("#" + vID).off("click.myPlugin");
                    clearInterval(i);
                }

            }, 200);
        }).trigger("click");
    });

    // submit all plugin contents after validation passed
    function pageSubmit(req) {
    ...
    }

    // set interval to check if validation passed or not
    // if passed, then submit the page
    // if no, then show messages
    var s = setInterval(function() {
        if (messageCounter == apex.jQuery("div[id^='REGION_']").filter("[validation=true]").length) {
            if (messageArr.length == 0) {
                pageSubmit("APPLY_CHANGES");
            } else {
                apex.message.showErrors(messageArr);
                // fix message click event
                setTimeout(function() {
                    $("#APEX_ERROR_MESSAGE li a").each(function() {
                        $(this).on("click.myRDS", function() {
                            var wID = $(this).attr("data-for");
                            $("div.apex-rds-container li a").filter("[href='#REGION_" + wID.substring(0, (wID.length - 7)) + "']").trigger("click").blur();
                        });
                    });
                }, 500);
            }
            clearInterval(s);
        }
    }, 200);

}

Editor to Textarea

In Editor to Textarea demo page, there is no this issue at all. So no fix needed.

Customize 

In this section I will continue to explain what I did in my demo.

Dialog

When we use page designer in APEX, we could find a lot of usage of dialog to expand and restore a Code Editor. Here in my demo, I used jQuery .dialog function to implement this customized feature. Check the code below for detail.
// expand and restore Dialog
(function showDialog() {
    apex.jQuery("button[id$= '_EXPAND']")
        .click(function() {
            var expandID = $(this).attr("id");
            var itemID = expandID.substring(0, expandID.lastIndexOf("_"));
            // custom dialog title
            var itemTitle = $("#REGION_" + itemID + " label.a-Form-label").text();
            var sItemTitle = itemTitle.substring(0, itemTitle.lastIndexOf('-') - 1);
            var s = itemTitle.substring(itemTitle.lastIndexOf('-') + 1);
            var pItemTitle = s.substring(0, s.lastIndexOf('(') - 1);
            var editorTitle = '' +
                '  ' + pItemTitle + ' - ' + sItemTitle;

            var codeItemWidth = $('#' + itemID + '_widget').width();
            var codeItemHeight = $('#' + itemID + '_widget').height();

            var dlgWidth = $(window).width() * 0.995;
            var dlgHeight = $(window).height() * 0.98;

            $("#" + itemID + "_widget").dialog({
                close: function() {
                    $(this).dialog("destroy");
                    // show scroll bar
                    $("body").css("overflow", "");
                    // resize Code Editor
                    // *** here trigger click is better than default resize event
                    $(this).css({ height: codeItemHeight + 2, width: codeItemWidth + 2 });
                    $("#" + itemID + "_widget_find").click();
                    setTimeout(function() { $("#" + itemID + "_widget_sClose").click(); }, 100);
                    // setTimeout(function() {
                    //     $(this).trigger("resize").blur();
                    //  }, 100);

                    $('#' + itemID + '_widget_settings').off("click.myMenuOffset");
                },
                create: function() {
                    // hide scroll bar
                    $("body").css("overflow", "hidden");
                    // transfer region title to dialog
                    $(".ui-dialog span.ui-dialog-title").html(editorTitle);
                    // customize restore button
                    $("button.ui-dialog-titlebar-close").removeClass()
                        .addClass("ui-dialog-titlebar-close a-Button a-Button--noLabel a-Button--withIcon a-Button--simple")
                        .css({ "border-radius": "inherit" })
                        .html('');
                    // resize Code Editor
                    // *** here trigger click is better than default resize event
                    $("#" + itemID + "_widget_find").click();
                    setTimeout(function() { $("#" + itemID + "_widget_sClose").click(); }, 100);
                    // setTimeout(function() {
                    //     $(this).trigger("resize").blur();
                    //  }, 100);

                    // adjust offset of pop-menu
                    $('#' + itemID + '_widget_settings').on("click.myMenuOffset",
                        function(event) {
                            var menuTop = $(this).offset().top + 32;
                            var menuLeft = $(this).offset().left - 121;
                            $('#' + itemID + '_widget_settingsMenu').one("focusin.myMenuOffset",
                                function(event) {
                                    $(this).offset({
                                        'top': menuTop,
                                        'left': menuLeft,
                                    });
                                });
                        });
                },
                hide: "scale",
                show: "scale",
                height: dlgHeight,
                width: dlgWidth,
                draggable: false,
                modal: true,
                resizable: false,
                closeText: "Restore",
                overlay: {
                    background: "#000",
                    opacity: 0.15
                }
            });
        });
})();

Hybrid: Re-Generate

By default Code Editor doesn't enable validate button in its toolbar area. I used JavaScript to extract original initialization code, re-form and re-generate Code Editors in page, specifically in Hybrid demo page. Check code below for more information.
// re-generate plugin
function reGeneratePlugin(widget) {
    // get script contain ajaxIdentifier
    var scriptStr = apex.jQuery("script:not([src]):contains('ajaxIdentifier')").text();
    // set regexp to match plugin initialization code
    var re = new RegExp(widget + "'\\,\\s*?\\{[\\S\\s]*?\\}", "ig");
    var reArr = scriptStr.match(re);

    // store original options to object
    var widgetObj = JSON.parse(reArr[0].substring(reArr[0].indexOf("{")));
    var itemID = widget.substring(0, widget.lastIndexOf("_"));

    // according to region attribute, to enable validate button or not
    widgetObj["validate"] = (apex.jQuery("#REGION_" + itemID).attr("validation") == "true") ? true : false;
    // get current app id
    widgetObj["appId"] = $v("pFlowId");
    widgetObj["adjustableHeight"] = (widgetObj["adjustableHeight"] == true) ? true : false;
    var widgetOriginalWidth = apex.jQuery("#" + widget).width();
    var widgetOriginalHeight = apex.jQuery("#" + widget).height();

    // remove page elements before re-generation, to avoid duplicate
    var pa = apex.jQuery("#" + itemID + "_CONTAINER > div.a-Form-inputContainer");
    var ch = apex.jQuery("#" + itemID + "_CONTAINER > div.a-Form-inputContainer > div.a-CodeEditor--resizeWrapper");
    apex.jQuery("#" + widget).prependTo(pa);
    ch.remove();
    apex.jQuery("#" + widget + "_settingsMenu").remove();

    // regenerate
    apex.builder.plugin.codeEditor("#" + widget, {
        "adjustableHeight": widgetObj["adjustableHeight"],
        "mode": widgetObj["mode"],
        "validate": widgetObj["validate"],
        "queryBuilder": widgetObj["queryBuilder"],
        "parsingSchema": widgetObj["parsingSchema"],
        "readOnly": widgetObj["readOnly"],
        "settings": widgetObj["settings"],
        "ajaxIdentifier": widgetObj["ajaxIdentifier"],
        "appId": widgetObj["appId"]
    });

    // remove duplicate setting menu after re-generate
    apex.jQuery("#" + widget + "_settingsMenu").remove();
    apex.jQuery("#" + widget).css({ height: widgetOriginalHeight + 2, width: widgetOriginalWidth + 2 });
}

// set timeout to call re-generate function
(function redefinePlugin() {
    setTimeout(function() {
        apex.jQuery("div[id$='_widget']").each(function(i) {
            reGeneratePlugin(apex.jQuery(this).attr("id"));
        });
        transButtons();
    }, 500);

})();

Migrated: UI

When migrating Native and Hybrid pages from Theme APEX5.0 to Theme 42, the major work is to adjust UI, theme and template.

If you have not yet viewed my previous post Use Theme APEX5.0. Please check the link to see more.

During migration, there are 3 steps below to follow.

Step 1: add Code Editor related CSS and JS files

First from theme Level, copy needed CSS and JavaScript files from Theme APEX5.0 to Theme 42.
#IMAGE_PREFIX#apex_ui/js/minified/builder_all.min.js?v=#APEX_VERSION#
#IMAGE_PREFIX#sc/sc_core.js?v=#APEX_VERSION#
#IMAGE_PREFIX#libraries/raphaeljs/2.1.2/apex.raphael#MIN#.js?v=#APEX_VERSION#
#IMAGE_PREFIX#apex_version.js?v=#APEX_VERSION#

#THEME_IMAGES#css/Core#MIN#.css?v=#APEX_VERSION#
#IMAGE_PREFIX#css/apex_builder#MIN#.css?v=#APEX_VERSION#
#IMAGE_PREFIX#css/apex_ui#MIN#.css?v=#APEX_VERSION#
From page level, copy another CSS file for Code Editor toolbar rendering. Because this CSS conflict with Theme 42 Core CSS, if put it in Theme level, it will not be emitted by APEX engine and meanwhile we need to keep that its order is after Core CSS generated by Theme 42.
/i/apex_ui/css/Core.min.css?v=5.1.0.00.45
Step 2: cope pages and match template

The fantastic copy page feature in APEX will prompt for your confirmation to match these templates used in source page to target page.  Most of time, I will select them according to the name.

If you can not confirm during coping, you can choose any one first and adjust later in page designer.Please check my demo pages for detailed templates.

And one more thing, for this case we need to copy one label template named APEX 5.0 - Required Label (Above) for Code Editor item type from Theme APEX5.0 to Theme 42. Then set it to item type Code Editor later in page designer.

Step 3: adjust components' CSS attributes

After theme and template level adjustment, we now need to fix the page level rendering issues. Introducing two themes in one page unavoidably causes some conflicts, since we can not prevent Theme 42 related CSS files generation during page rendering. I used page level inline CSS to revise font, rds and button rendering issues after theme merge.
ul.apex-rds {
    margin-top: 8px !important; 
}

ul.apex-rds li a {
    padding: 0px !important; 
    font-size: 13px !important;
}

ul.apex-rds li.apex-rds-selected a {
    background-color: #FFF;
    box-shadow: none !important;
}

.t-Region-body {
    font-size: 12px;
}

.a-Button--listManager, .a-Button--small {
    font-size: 11px !important;
    padding: 4px 8px !important;
}

.t-Region-body.a-Collapsible-content {
    font-size: 1.4rem;
}
OK. Now you will have a compatible UI for Code Editor in Theme 42, specifically for Native and Hybrid demo pages.

BTW, for Editor to Textarea demo page, you don't need to migrate theme, just use original theme 42.

I hope I mention all major parts in my demo. Dear readers, if you have any questions, please add to comment below. Thanks.

Comments

Popular posts from this blog

Note for APEX 5.1 UI, Theme, Templates and Substitution Strings

RDS Customizable for APEX 5.1

Use Plugin Code Editor - Part 1/4: Demo