bitbucketatlassian-plugin-sdkgoogle-closure-templatessoy-templates

Custom Bitbucket Merge Check - dynamic fields render twice after submitting the configuration


I'm creating a custom Merge Check for Bitbucket. I started by following this tutorial: https://developer.atlassian.com/server/bitbucket/how-tos/hooks-merge-checks-guide/

I want the view to be dynamic, e.g. have button that creates multiple similar input fields (of specified IDs), which eventually get stored in the config.

First of all, I used soy for this - I created static template with call to one e.g. .textField. It worked okay, but I couldn't create new, similar fields on the fly (after pressing 'Add new' button).

So I use JavaScript to get data from soy's config. I rewrite the whole config to JS "map" and then render all the fields dynamically (by appending them to HTML code), filling them with values from configuration or creating new fields by pressing the button.

It works - I get all the data saved in config for keys like field_[id], e.g field_1, field_2 etc.

But there's a bug. When I press the "Save" button and view the pop-up for editing once again, I can see the JavaScript get executed twice: I get all my fields rendered two times - first time during first execution and second time during the second, appearing just a few seconds later. There's no such problem when I save the configuration, refresh the page and then view the pop-up once again.

Here's my merge check's configuration in atlassian-plugin.xml file:

<repository-merge-check key="isAdmin" class="com.bitbucket.plugin.MergeCheck" name="my check" configurable="true">
        <config-form name="Simple Hook Config" key="simpleHook-config">
            <view>hook.guide.example.hook.simple.myForm</view>
            <directory location="/static/"/>
        </config-form>
    </repository-merge-check>

And my simplified .soy template code:

{namespace hook.guide.example.hook.simple}

/**
 * @param config
 * @param? errors
 */
{template .myForm}
    <script type="text/javascript">
            var configuration = new Object();

            {foreach $key in keys($config)}
                configuration["{$key}"] = "{$config[$key]}";
            {/foreach}

            var keys = Object.keys(configuration);

            function addNewConfiguration() {lb}
                var index = keys.length;
                addNewItem(index);
                keys.push("field_" + index);
            {rb}


            function addNewItem(id) {lb}
                var html = `<label for="field_${lb}id{rb}">Field </label><input class="text" type="text" name="field_${lb}id{rb}" id="branch_${lb}id{rb}" value=${lb}configuration["field_" + id] || ""{rb}><br>`;
                document.getElementById('items').insertAdjacentHTML('beforeend', html);
            {rb}

            keys.forEach(function(key) {lb}
                var id = key.split("_")[1];
                addNewItem(id);
            {rb});

             var button = `<button style="margin:auto;display:block" id="add_new_configuration_button">Add new</button>`;
             document.getElementById('add_new').innerHTML = button;
             document.getElementById('add_new_configuration_button').addEventListener("click", addNewConfiguration);

    </script>

    <div id="items"></div>
    <div id="add_new"></div>

    <div class="error" style="color:#FF0000">
        {$errors ? $errors['errors'] : ''}
    </div>
{/template}

Why does JavaScript get executed twice in this case? Is there any other way of creating such dynamic views?


Solution

  • The soy template will get loaded and executed again whenever you click to edit the configuration. Therefore the javascript will also get executed again. To prevent this you can create a javascript file and put it next to your simpleHook-config.soy template file with the same filename, so simpleHook-config.js. The javascript file will be loaded automatically with your soy template, but once. Therefore you can hold a global initialisation state within a new introduced js object.

    Furthermore, even though you are adding fields dynamically, you can still and should build the saved configuration inside the soy template already and not building it with javascript.

    For me this approach works quite good (I wrote the code more or less blindly, so maybe you need to adjust it a bit):

    In .soy file:

    {namespace hook.guide.example.hook.simple}
    
    /**
     * @param config
     * @param? errors
     */
    {template .myForm}
    <div id="merge-check-config">
        <div id="items">
            {foreach $key in keys($config)}
            {let $id: strSub($key, strIndexOf($key, "_") + 1) /}
            {call .field}
                {param id: $id /}
                {param value: $config[$key] /}
            {/foreach}
        </div>
    
        <div id="add_new">
            <button style="margin:auto; display:block" id="add_new_configuration_button">Add new</button>
        </div>
    
        <div class="error" style="color:#FF0000">
            {$errors ? $errors['errors'] : ''}
        </div>
    
        <script>
            myPluginMergeCheck.init();
        </script>
    </div>
    {/template}
    
    /**
     * @param id
     * @param? value
     */
    {template .field}
    <div>
        <label for="field_${id}">Field</label>
        <input class="text" type="text" name="field_${id}" id="branch_${id}" value="${value}">
    </div>
    {/template}
    

    In .js file:

    myPluginMergeCheck = {
        initialized: false,
        init: function () {
            if (this.initialized) {
                return;
            }
    
            var me = this;
            AJS.$(document).on("click", "div#merge-check-config button#add_new_configuration_button"), function (event) {
                me.addNewItem();
            }
            this.initialized = true;
        },
        addNewItem: function() {
            var itemsDiv = AJS.$("div#merge-check-config div#items");
            var newId = itemsDiv.children("div").length;
            var html = hook.guide.example.hook.simple.field({id: newId});
            itemsDiv.append(html);
        }
    };