Creating Mixins: ClickOnce (1)

This page demonstrates another custom Mixin. It's a Mixin that tackles a classic problem on the web: how to prevent duplicate submissions caused by additional clicks after a page has been submitted and before the response has come back. The ClickOnce mixin can be mixed in to the Submit, EventLink, and ActionLink components.

Without the ClickOnce Mixin. Here's an example of the duplicate submissions problem.
See how you can easily order more than 1 item by clicking impatiently on any or all of these elements...
    Order 1 Orange    Order 1 Banana
With the ClickOnce Mixin. The Mixin uses JavaScript to ignore clicks after the first one.
See how the mixin prevents ordering more than 1 item...

    Order 1 Orange    Order 1 Banana
Mixin location is important. Mixins must be put in a package called mixins because Tapestry gives it special treatment.

WARNING: This solution has limitations. It might not work on the submit button in some versions of Internet Explorer, and it may have problems when used in a form that has validated fields and client-side validation enabled. See http://tapestry.1045711.n5.nabble.com/Prevent-double-submission-w-linkSubmit-tt3291904.html .

References: @InjectContainer, ClientElement, Component Mixins, Tapestry JavaScript, JavaScriptSupport, RequireJS, jQuery API, Session Storage.

Home


<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<!-- We need a doctype to allow us to use special characters like &nbsp; 
     We use a "strict" DTD to make IE follow the alignment rules. -->
     
<html xmlns:t="http://tapestry.apache.org/schema/tapestry_5_4.xsd">
<body class="container">
    <h3>Creating Mixins: ClickOnce (1)</h3>

    <noscript class="js-required">
        ${message:javascript_required}
    </noscript>     

    This page demonstrates another custom Mixin.  It's a Mixin that tackles a classic problem on the web: how to 
    prevent <strong>duplicate submissions</strong> caused by additional clicks after a page has been submitted and before 
    the response has come back.  The ClickOnce mixin can be mixed in to the Submit, EventLink, and ActionLink components.<br/><br/>
    
    <strong>Without the ClickOnce Mixin.</strong> Here's an example of the duplicate submissions  problem.<br/>
    See how you can easily order more than 1 item by clicking impatiently on any or all of these elements...

    <div class="eg">
        <form t:type="form" t:id="plainForm">
            <input t:type="submit" value="Order 1 Apple"/>&nbsp;&nbsp;&nbsp;
            <a t:type="eventlink" t:event="orderOneOrange" href="#">Order 1 Orange</a>&nbsp;&nbsp;&nbsp;
            <a t:type="actionlink" t:id="orderOneBanana" href="#">Order 1 Banana</a>
        </form>
    </div>

    <strong>With the ClickOnce Mixin.</strong>  The Mixin uses JavaScript to ignore clicks after the first one.<br/> 
    See how the mixin prevents ordering more than 1 item...<br/><br/>
    
    <div class="eg">
        <form t:type="form" t:id="mixinForm">
            <input t:type="submit" value="Order 1 Apple" t:mixins="clickonce"/>&nbsp;&nbsp;&nbsp;
            <a t:type="eventlink" t:event="orderOneOrange" t:mixins="clickonce" href="#">Order 1 Orange</a>&nbsp;&nbsp;&nbsp;
            <a t:type="actionlink" t:id="orderOneBananaWithMixin" t:mixins="clickonce" href="#">Order 1 Banana</a>
        </form>
    </div>
    
    Mixin location is important.  Mixins must be put in a package called <code>mixins</code> because Tapestry gives it 
    special treatment.<br/><br/>
    
    <p class="alert alert-warning">
    WARNING: This solution has limitations. It might not work on the submit button in some versions of Internet Explorer, 
    and it may have problems when used in a form that has validated fields and client-side validation enabled.
    See <a href="http://tapestry.1045711.n5.nabble.com/Prevent-double-submission-w-linkSubmit-tt3291904.html">
    http://tapestry.1045711.n5.nabble.com/Prevent-double-submission-w-linkSubmit-tt3291904.html</a> . 
    </p>

    References: 
    <a href="http://tapestry.apache.org/5.4/apidocs/org/apache/tapestry5/annotations/InjectContainer.html">@InjectContainer</a>, 
    <a href="http://tapestry.apache.org/5.4/apidocs/org/apache/tapestry5/ClientElement.html">ClientElement</a>, 
    <a href="http://tapestry.apache.org/component-mixins.html">Component Mixins</a>,
    <a href="http://tapestry.apache.org/javascript.html">Tapestry JavaScript</a>, 
    <a href="http://tapestry.apache.org/5.4/apidocs/org/apache/tapestry5/services/javascript/JavaScriptSupport.html">JavaScriptSupport</a>,
    <a href="http://requirejs.org">RequireJS</a>, 
    <a href="http://api.jquery.com">jQuery API</a>,     
    <a href="http://tapestry.apache.org/session-storage.html">Session Storage</a>.<br/><br/>
    
    <t:pagelink page="Index">Home</t:pagelink><br/><br/>
    
    <t:tabgroup>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/examples/javascript/CreatingMixins1.tml"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/pages/examples/javascript/CreatingMixins1.java"/>
        <t:sourcecodetab src="/web/src/main/resources/META-INF/assets/css/examples/js.css"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/mixins/ClickOnce.java"/>
        <t:sourcecodetab src="/web/src/main/resources/META-INF/modules/click-once.js"/>
        <t:sourcecodetab src="/web/src/main/java/jumpstart/web/state/examples/javascript/MyOrder.java"/>
    </t:tabgroup>
</body>
</html>


package jumpstart.web.pages.examples.javascript;

import jumpstart.web.state.examples.javascript.MyOrder;

import org.apache.tapestry5.annotations.Import;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.annotations.SessionState;

@Import(stylesheet = "css/examples/js.css")
public class CreatingMixins1 {

    // Work fields

    @SessionState
    @Property
    private MyOrder myOrder;

    // The code

    void setupRender() {
        myOrder.setApplesQuantity(0);
        myOrder.setOrangesQuantity(0);
        myOrder.setBananasQuantity(0);
    }

    Object onSuccessFromPlainForm() {
        orderOneApple();
        return CreatingMixins2.class;
    }

    Object onOrderOneOrange() {
        orderOneOrange();
        return CreatingMixins2.class;
    }

    Object onActionFromOrderOneBanana() {
        orderOneBanana();
        return CreatingMixins2.class;
    }

    Object onSuccessFromMixinForm() {
        orderOneApple();
        return CreatingMixins2.class;
    }

    Object onActionFromOrderOneBananaWithMixin() {
        orderOneBanana();
        return CreatingMixins2.class;
    }

    void orderOneApple() {
        sleep(1500); // Sleep 1.5 seconds to simulate busy system
        myOrder.setApplesQuantity(myOrder.getApplesQuantity() + 1);
    }

    void orderOneOrange() {
        sleep(1500); // Sleep 1.5 seconds to simulate busy system
        myOrder.setOrangesQuantity(myOrder.getOrangesQuantity() + 1);
    }

    void orderOneBanana() {
        sleep(1500); // Sleep 1.5 seconds to simulate busy system
        myOrder.setBananasQuantity(myOrder.getBananasQuantity() + 1);
    }

    private void sleep(long duration) {
        try {
            Thread.sleep(duration);
        }
        catch (InterruptedException e) {
        }
    }

}


.eg {
                margin: 20px 0;
                padding: 14px;
                color: #888;
                border: 1px solid #ddd;
                border-radius: 6px;
                -webkit-border-radius: 6px;
                -mox-border-radius: 6px;
}

.js-required {
                color: red;
                display: block;
                margin-bottom: 14px;
}

.js-recommended {
                color: red;
                display: block;
                margin-bottom: 14px;
}


/**
 * A simple mixin that uses JavaScript to observe an element, detecting whether it has been clicked. The click will be 
 * ignored if any element using this mixin has already been clicked.
 */
package jumpstart.web.mixins;

import org.apache.tapestry5.ClientElement;
import org.apache.tapestry5.annotations.InjectContainer;
import org.apache.tapestry5.ioc.annotations.Inject;
import org.apache.tapestry5.json.JSONObject;
import org.apache.tapestry5.services.javascript.JavaScriptSupport;

public class ClickOnce {

    // Generally useful bits and pieces

    @Inject
    private JavaScriptSupport javaScriptSupport;

    @InjectContainer
    private ClientElement attachedTo;

    // The code

    public void afterRender() {
        JSONObject spec = new JSONObject();
        spec.put("elementId", attachedTo.getClientId());
        javaScriptSupport.require("click-once").invoke("init").with(spec);
    }

}


// A module that ignores clicks after the first one.
// For each clickable element that you want to participate, call init. 
// Once any of the elements is clicked, this module will cancel subsequent clicks from any of the elements.

define([ "jquery" ], function($) {
    
    var alreadyClickedOnce = false;

    var init = function(spec) {
        $element = $("#" + spec.elementId);

        var doClickOnce = function() {
            
            if (alreadyClickedOnce) {
                // Cancel the original click event.
                return false;
            }

            alreadyClickedOnce = true;
            return true;
        }
        
        $element.on("click", doClickOnce);
    };
    
    return {
        init : init
    };
    
});


package jumpstart.web.state.examples.javascript;

public class MyOrder {
    private int applesQuantity;
    private int orangesQuantity;
    private int bananasQuantity;

    public int getApplesQuantity() {
        return applesQuantity;
    }

    public void setApplesQuantity(int applesQuantity) {
        this.applesQuantity = applesQuantity;
    }

    public int getOrangesQuantity() {
        return orangesQuantity;
    }

    public void setOrangesQuantity(int orangesQuantity) {
        this.orangesQuantity = orangesQuantity;
    }

    public int getBananasQuantity() {
        return bananasQuantity;
    }

    public void setBananasQuantity(int bananasQuantity) {
        this.bananasQuantity = bananasQuantity;
    }
}