« Back to Index

Backbone example

View original Gist on GitHub

Example.js

define(['../Utils/Templating/hogan', '../Utils/Datepicker/kalendae', '../Models/LoanApplication', '../Utils/DOM/getEl', 'Backbone'], function (hogan, Kalendae, LoanApplication, getElement) {

    return Backbone.View.extend({
        model: new LoanApplication(),

        initialize: function(){
            // Store other elements that will be interacted with.
            // Any element that will potentially utilise jQuery we pre-wrap in a single jQuery instance.
            this.promocode = getElement('js-promocode');
            this.amount = getElement('js-amount');
            this.error_amount = $('#js-amounterror');
            this.error_amount_popup = $('#js-amounterror-popup');
            this.popup = $('#js-loanpopup');
            this.popup_amount = getElement('js-popupamount');
            this.loan_details = getElement('js-loandetails');
            this.apply_now = $('#js-applynow');
            this.trigger_popup = getElement('js-pickdate'); // this is the form input that triggers the popup to be shown

            // We use this to tell whether the calendar widget has already been rendered,
            // as there is no point re-rendering it every time the popup is closed then opened again.
            this.is_calendar_rendered = false;

            // Details that will be passed around in different methods
            this.days_to_pay = null;
            this.amount_to_borrow = null;
            this.selected_date = null;

            // This holds the template file we'll compile with data pulled from server
            this.template = null;
            this.template_content = null;

            // The Model triggers custom events when certain actions happen which the View should ideally handle
            this.model.on('item:invalid', this.process_errors, this);
            this.model.on('amount:changed', this.validation_success, this);

            // There is one instance where we need the Model to have access to the View (so we can set a shared property)
            this.model.view = this;

            // Used within 'remove_error' method
            this.allow_app_process = false;

            // Used to determine if the application can proceed
            this.valid_date = false;

            // Used to determine if the validation passed
            this.validation_pass = false;
        },

        // The containing element
        el: getElement('js-loanapplication'),

        // Selectors are scoped to the parent element
        events: {
            'focus #js-pickdate': 'validate_amount',
            'click #js-calendarclose': 'close_popup',
            'click #js-applynow': 'validate_amount',
            'keyup #js-popupamount': 'validate_amount'
        },

        validate_amount: function (e) {
            /*
                First thing we need to do is to lose focus on the #js-pickdate input element
                Otherwise, if the user has the popup open and then decides to view a different screen and comes back.
                Returning back causes the input to gain focus again.
             */
            this.trigger_popup.blur();

            /*
                We validate a different field depending on whether the popup is open (the popup has its own copy of the application fields)
                Note: Model's "set" method calls Backbone validation by default (see Model for validation rules)
             */

            // If popup is hidden
            if (this.popup.hasClass('hide')) {
                this.model.set({
                    amount: this.amount.value,
                    event_type: e.type
                });
            } 
            // If popup is visible
            else {
                if (e.type === 'keyup') {
                    /*
                        We don't want to validate the amount if the user is just pressing the shift key or the left/right/up/down arrow keys:
                        Shift = 16 | Left = 37 | Up = 38 | Right = 39 | Down = 40
                     */
                    if (!_.contains([16, 37, 38, 39, 40], e.keyCode)) {
                        this.model.set({
                            amount: this.popup_amount.value,
                            event_type: e.type // passing through the event type means we can prevent the 'remove_error' method from processing the application
                        });
                    }
                }
                else {
                    this.model.set({
                        amount: this.popup_amount.value,
                        event_type: e.type
                    });
                }
            }
        },

        process_errors: function (item) {
            // Hide the 'apply' button if it's already viewable
            this.apply_now.addClass('hide');

            // Check what field was invalid and display corresponding error message
            switch (item.field) {
                case 'amount':
                    // We display the error message in a different place depending on whether the popup is open
                    
                    // If the popup is NOT visible
                    if (this.popup.hasClass('hide')) {
                        this.error_amount.removeClass('invisible');
                    } else {
                        this.error_amount_popup.removeClass('invisible');
                    }
                    
                    break;
            }
        },

        validation_success: function(){
            this.validation_pass = true;
            this.check_apply_display();
        },

        check_apply_display: function(){
            // If there is a valid date then we can show the 'apply' button
            if (this.valid_date) {
                this.apply_now.removeClass('hide');
            }

            // Now we hide any errors
            this.remove_error();
        },

        // TODO: refactor this function - surely the inner if statement logic can be abstracted into a separate function?
        remove_error: function(){
            // We remove the error message from different places depending on whether the popup is open

            // If the popup is NOT visible
            if (this.popup.hasClass('hide')) {
                this.error_amount.addClass('invisible');
                this.display_calendar();
            } else {
                this.error_amount_popup.addClass('invisible');

                // Only process the application if the user has explictly clicked on the 'apply' button and their data has been validate
                if (this.allow_app_process && this.valid_date) {
                    window.location = 'create-account.php?amount=' + this.popup_amount.value + '&paymentdate=' + this.selected_date;
                } else {
                    // Noticed issue with keyup event constantly firing, so safer just to force the input to lose focus
                    this.popup_amount.blur();
                }
            }
        },

        display_calendar: function(){
            /*
                We only load the calendar on screens large enough to display it
                And we make sure to only render it once by check "is_calendar_rendered" is false

                UPDATE: Since moving this script into an 'advanced' file and having a seperate 'basic' version - the clientWidth check is redundant here.
                Although it probably isn't much of a performance hit to have that extra check in place still.
             */
            if (document.documentElement.clientWidth >= 585 && !this.is_calendar_rendered) {
                this.render_calendar();
            }

            // Pass through the value into this new popup view
            this.popup_amount.value = this.amount.value;

            // Make the popup visible
            this.popup.removeClass('hide');
        },

        render_calendar: function(){
            this.is_calendar_rendered = true;

            // Context of "this" gets lost within the Kalendae script
            var self = this;

            // the following variables are used for calculating the difference between 
            // today's date and the selected date to pay back the loan
            var calendar_container = getElement('js-calendar');
            var calendar;
            
            calendar = new Kalendae({
                // element to attach the calendar to
                attachTo: calendar_container,
                
                // blackout days after 45 days from current date
                blackout: function (date) {
                    return Kalendae.moment().yearDay() + 45 < date.yearDay(); // yearDay() is an extension Kalendae adds to moment.js to calculate the total number of days since epoch.
                },
                
                // how many characters from the week day name to display (e.g. we've gone with 3 = Mon, Tue, Wed, Thu, Fri, Sat, Sun)
                columnHeaderLength: 3,
                
                // restricts date selectability to past or future ('future' blacks out all days previous to current date)
                direction: 'future',
                
                // only allows selection of one day
                mode: 'single',
                
                // determines the number of months to display
                months: 2,
                
                // determines when the week should start (Sunday = 0 [default] or Monday = 1 etc)
                weekStart: 1,
                
                // causes the <input> to update to the selected date
                subscribe: {
                    'date-clicked': function(){
                        // This event is fire before the selection is changed
                        if (!self.validation_pass) {
                            return false;
                        }
                    },
                    'change': function(){
                        // We don't want to let the user select a date if the amount is invalid
                        // allow_app_process is being used to determine if the app can move to stage 1 of application
                        // but it's set every time validation is carried out so we can use it here
                        if (self.validation_pass) {
                            // We know a date was selected so we store that information
                            self.valid_date = true;

                            /*
                                There could be an instance where the user enters a valid amount *before* the popup is shown, 
                                and then selects a date from the date picker, but that wont trigger a change event on the amount 
                                and so the model doesn't validate the amount and doesn't then cause the 'apply' button to appear.

                                This means we need to check here (once a date is picked) if the user should see the 'apply' button.
                                We do this by setting the attribute value again (thus causing the validation to be triggered)
                             */
                            self.model.set({
                                amount: self.popup_amount.value
                            });

                            // The following code works out the number of days selected to pay back the loan

                            var selected_date = this.getSelected();
                            var temp_integer_month;
                            var one_day;
                            var payback_date;
                            var days_to_pay;

                            days_to_pay = selected_date.split('-');

                            // Make sure the date is in the correct order
                            self.selected_date = days_to_pay[2] + '-' + days_to_pay[1] + '-' + days_to_pay[0];
                            
                            // the date is returned as non-zero index format, so put it back to be zero-indexed
                            temp_integer_month = parseInt(days_to_pay[1], 10);
                            days_to_pay[1] = '0' + --temp_integer_month;

                            one_day = 24*60*60*1000; // hours * minutes * seconds * milliseconds
                            payback_date = new Date(days_to_pay[0], days_to_pay[1], days_to_pay[2]);
                            days_to_pay = Math.ceil(Math.abs(((new Date()).getTime() - payback_date.getTime()) / (one_day)));
                            
                            // Keep reference to number of days to pay
                            self.days_to_pay = days_to_pay;

                            // call function which will pull in the relevant template and populate with relevant costs
                            self.calculate();
                        }
                    }
                }
            });
        },

        close_popup: function(){
            this.amount.value = ''; // We reset the value so the Model's "change" event can be fired (which is what we rely upon to trigger the popup)
            this.trigger_popup.value = ''; // was finding Firefox would randomly put the 'amount' value into this input, can't see why though?
            this.loan_details.innerHTML = '<dt></dt><dd></dd>'; // if the user re-opens the popup then we don't want the old details to be there still
            this.allow_app_process = false;
            this.valid_date = false;
            this.validation_pass = false;
            this.apply_now.addClass('hide');
            this.popup.addClass('hide');
        },

        calculate: function(){
            // Noticed issue with keyup event constantly firing, so safer just to force the input to lose focus
            this.popup_amount.blur();

            // Context of "this" gets lost within the jQuery methods
            var self = this;

            // Wait for async functions to finish before inserting HTML
            function process(){
                $.when(self.get_costs(self.popup_amount.value)).then(function (data) {
                    // Take the JSON data and compile it into the template file
                    self.template_content = self.template.render(JSON.parse(data));

                    // Generate the HTML code
                    self.generate_html();
                });
            }
            
            // The following code prevents calling the server to load/compile the same template code every time the button is pressed.
            // Instead we retrieve the template and compile it once
            if (self.template) {
                process();
            } else {
                $.ajax({
                    url: 'Assets/Templates/Application-Calculator.txt',
                    dataType: 'html',
                    success: function (tmp) {
                        self.template = hogan.compile(tmp);
                        process();
                    }
                });
            }
        },

        get_costs: function (amount) {
            var dfd = $.Deferred();
            
            $.ajax({
                url: 'Assets/PHP/calculator.php',
                type: 'POST',
                data: 'amount=' + amount + '&days=' + this.days_to_pay,
                success: function (data) {
                    dfd.resolve(data);
                }
            });
            
            return dfd.promise();
        },

        generate_html: function(){
            // Insert the template content into the DOM
            this.loan_details.innerHTML = this.template_content;
        }

    });

});