gocodebox/lifterlms

View on GitHub
assets/js/builder/Controllers/Sync.js

Summary

Maintainability
C
1 day
Test Coverage
/**
 * Sync builder data to the server
 *
 * @since 3.16.0
 * @version 4.17.0
 */
define( [], function() {

    return function( Course, settings ) {

        this.saving = false;

        var self              = this,
            autosave          = ( 'yes' === window.llms_builder.autosave ),
            check_interval    = null,
            check_interval_ms = settings.check_interval_ms || ( ( 'yes' === window.llms_builder.autosave ) ? 10000 : 1000 ),
            detached          = new Backbone.Collection(),
            trashed           = new Backbone.Collection();

        /**
         * init
         *
         * @since 3.16.7
         *
         * @return {Void}
         */
        function init() {

            // determine if autosaving is possible
            if ( 'undefined' === typeof wp.heartbeat ) {

                window.llms_builder.debug.log( 'WordPress Heartbeat disabled. Autosaving is disabled!' );
                autosave = false;

            }

            // setup the check interval
            if ( check_interval_ms ) {
                self.set_check_interval( check_interval_ms );
            }

            // warn when users attempt to leave the page
            $( window ).on( 'beforeunload', function() {

                if ( self.has_unsaved_changes() ) {
                    check_for_changes();
                    return 'Are you sure you want to abandon your changes?';
                }

            } );

        };

        /*
              /$$             /$$                                             /$$                           /$$
             |__/            | $$                                            | $$                          |__/
              /$$ /$$$$$$$  /$$$$$$    /$$$$$$   /$$$$$$  /$$$$$$$   /$$$$$$ | $$        /$$$$$$   /$$$$$$  /$$
             | $$| $$__  $$|_  $$_/   /$$__  $$ /$$__  $$| $$__  $$ |____  $$| $$       |____  $$ /$$__  $$| $$
             | $$| $$  \ $$  | $$    | $$$$$$$$| $$  \__/| $$  \ $$  /$$$$$$$| $$        /$$$$$$$| $$  \ $$| $$
             | $$| $$  | $$  | $$ /$$| $$_____/| $$      | $$  | $$ /$$__  $$| $$       /$$__  $$| $$  | $$| $$
             | $$| $$  | $$  |  $$$$/|  $$$$$$$| $$      | $$  | $$|  $$$$$$$| $$      |  $$$$$$$| $$$$$$$/| $$
             |__/|__/  |__/   \___/   \_______/|__/      |__/  |__/ \_______/|__/       \_______/| $$____/ |__/
                                                                                                 | $$
                                                                                                 | $$
                                                                                                 |__/
         */

        /**
         * Adds error message(s) to the data object returned by heartbeat-tick
         *
         * @param    obj            data  llms_builder data object from heartbeat-tick
         * @param    string|array   err   error messages array or string
         * @return   obj
         * @since    3.16.0
         * @version  3.16.0
         */
        function add_error_msg( data, err ) {

            if ( 'success' === data.status ) {
                data.message = [];
            }

            data.status = 'error';
            if ( 'string' === typeof err ) {
                err = [ err ];
            }
            data.message = data.message.concat( err );

            return data;

        };

        /**
         * Publish sync status so other areas of the application can see what's happening here
         *
         * @return   void
         * @since    3.16.0
         * @version  3.16.0
         */
        function check_for_changes() {

            var data                 = {};
            data.changes             = self.get_unsaved_changes();
            data.has_unsaved_changes = self.has_unsaved_changes( data.changes );
            data.saving              = self.saving;

            window.llms_builder.debug.log( '==== start changes check ====', data, '==== finish changes check ====' );

            Backbone.pubSub.trigger( 'current-save-status', data );

        };

        /**
         * Manually Save data via Admin AJAX when the heartbeat API has been disabled
         *
         * @since 3.16.7
         * @since 4.17.0 Fixed undefined variable error when logging an error response.
         *
         * @return void
         */
        function do_ajax_save() {

            // prevent simultaneous saves
            if ( self.saving ) {
                return;
            }

            var changes = self.get_unsaved_changes();

            // only send data if we have data to send
            if ( self.has_unsaved_changes( changes ) ) {

                changes.id = Course.get( 'id' );

                LLMS.Ajax.call( {
                    data: {
                        action: 'llms_builder',
                        action_type: 'ajax_save',
                        course_id: changes.id,
                        llms_builder: JSON.stringify( changes ),
                    },
                    beforeSend: function() {

                        window.llms_builder.debug.log( '==== start do_ajax_save before ====', changes, '==== finish do_ajax_save before ====' );

                        self.saving = true;

                        Backbone.pubSub.trigger( 'heartbeat-send', self );

                    },
                    error: function( xhr, status, error ) {

                        window.llms_builder.debug.log( '==== start do_ajax_save error ====', xhr, '==== finish do_ajax_save error ====' );

                        self.saving = false;

                        Backbone.pubSub.trigger( 'heartbeat-tick', self, {
                            status: 'error',
                            message: xhr.responseText + ' (' + error + ' ' + status + ')',
                        } );

                    },
                    success: function( res ) {

                        if ( ! res.llms_builder ) {
                            return;
                        }

                        window.llms_builder.debug.log( '==== start do_ajax_save success ====', res, '==== finish do_ajax_save success ====' );

                        res.llms_builder = process_removals( res.llms_builder );
                        res.llms_builder = process_updates( res.llms_builder );

                        self.saving = false;

                        Backbone.pubSub.trigger( 'heartbeat-tick', self, res.llms_builder );

                    }

                } );

            }

        };

        /**
         * Retrieve all the attributes changed on a model since the last sync
         *
         * For a new model (a model with a temp ID) or a model where _forceSync has been defined ALL atts will be returned
         * For an existing model (without a temp ID) only retrieves changed attributes as tracked by Backbone.TrackIt
         *
         * This function excludes any attributes defined as child attributes via the models relationship settings
         *
         * @param    obj   model  instance of a Backbone.Model
         * @return   obj
         * @since    3.16.0
         * @version  3.16.6
         */
        function get_changed_attributes( model ) {

            var atts = {},
                sync_type;

            // don't save mid editing
            if ( model.get( '_has_focus' ) ) {
                return atts;
            }

            // model hasn't been persisted to the database to get a real ID yet
            // send *all* of it's atts
            if ( has_temp_id( model ) || true === model.get( '_forceSync' ) ) {

                atts      = _.clone( model.attributes );
                sync_type = 'full';

                // only send the changed atts
            } else {

                atts      = model.unsavedAttributes();
                sync_type = 'partial';

            }

            var exclude = ( model.get_relationships ) ? model.get_child_props() : [];
            atts        = _.omit( atts, function( val, key ) {

                // exclude keys that start with an underscore which are used by the
                // application but don't need to be stored in the database
                if ( 0 === key.indexOf( '_' ) ) {
                    return true;
                } else if ( -1 !== exclude.indexOf( key ) ) {
                    return true;
                }
                return false;

            } );

            if ( model.before_save ) {
                atts = model.before_save( atts, sync_type );
            }

            return atts;

        };

        /**
         * Get all the changes to an object (either a Model or a Collection of models)
         * Returns only changes to models and the IDs of that model (should changes exist)
         * Uses get_changed_attributes() to determine if all atts or only changed atts are needed
         * Processes children intelligently to only return changed children rather than the entire collection of children
         *
         * @param    obj        object  instance of a Backbone.Model or Backbone.Collection
         * @return   obj|array              if object is a model, returns an object
         *                                if object is a collection, returns an array of objects
         * @since    3.16.0
         * @version  3.16.11
         */
        function get_changes_to_object( object ) {

            var changed_atts;

            if ( object instanceof Backbone.Model ) {

                changed_atts = get_changed_attributes( object );

                if ( object.get_relationships ) {

                    _.each( object.get_child_props(), function( prop ) {

                        var children = get_changes_to_object( object.get( prop ) );
                        if ( ! _.isEmpty( children ) ) {
                            changed_atts[ prop ] = children;
                        }

                    } );

                }

                // if we have any data, add the id to the model
                if ( ! _.isEmpty( changed_atts ) ) {
                    changed_atts.id = object.get( 'id' );
                }

            } else if ( object instanceof Backbone.Collection ) {

                changed_atts = [];
                object.each( function( model ) {
                    var model_changes = get_changes_to_object( model );
                    if ( ! _.isEmpty( model_changes ) ) {
                        changed_atts.push( model_changes );
                    }
                } );

            }

            return changed_atts;

        };

        /**
         * Determines if a model has a temporary ID or a real persisted ID
         *
         * @param    obj   model  instance of a model
         * @return   boolean
         * @since    3.16.0
         * @version  3.16.0
         */
        function has_temp_id( model ) {

            return ( ! _.isNumber( model.id ) && 0 === model.id.indexOf( 'temp_' ) );

        };

        /**
         * Compares changes synced to the server against current model and restarts
         * tracking on elements that haven't changed since the last sync
         *
         * @param    obj   model  instance of a Backbone.Model
         * @param    obj   data   data set that was processed by the server
         * @return   void
         * @since    3.16.11
         * @version  3.19.4
         */
        function maybe_restart_tracking( model, data ) {

            Backbone.pubSub.trigger( model.get( 'type' ) + '-maybe-restart-tracking', model, data );

            var omit = [ 'id', 'orig_id' ];

            if ( model.get_relationships ) {
                omit.concat( model.get_child_props() );
            }

            _.each( _.omit( data, omit ), function( val, prop ) {

                if ( _.isEqual( model.get( prop ), val ) ) {
                    delete model._unsavedChanges[ prop ];
                    model._originalAttrs[ prop ] = val;
                }

            } );

            // if syncing was forced, allow tracking to move forward as normal moving forward
            model.unset( '_forceSync' );

        };

        /**
         * Processes response data from heartbeat-tick related to trashing & detaching models
         * On success, removes from local removal collection
         * On error, appends error messages to the data object returned to UI for on-screen feedback
         *
         * @param    obj   data  data.llms_builder object from heartbeat-tick response
         * @return   obj
         * @since    3.16.0
         * @version  3.17.1
         */
        function process_removals( data ) {

            // check removals for errors
            var removals = {
                detach: detached,
                trash: trashed,
            };

            _.each( removals, function( coll, key ) {

                if ( data[ key ] ) {

                    var errors = [];

                    _.each( data[ key ] , function( info ) {

                        // successfully detached, remove it from the detached collection
                        if ( ! info.error ) {

                            coll.remove( info.id );

                        } else {

                            errors.push( info.error );

                        }

                    } );

                    if ( errors.length ) {
                        _.extend( data, add_error_msg( data, errors ) );
                    }

                }

            } );

            return data;
        }

        /**
         * Processes response data from heartbeat-tick related to creating / updating a single object
         * Handles both collections and models as a recursive function
         *
         * @param    {[type]}   data       [description]
         * @param    {[type]}   type       [description]
         * @param    {[type]}   parent     [description]
         * @param    {[type]}   main_data  [description]
         * @return   {[type]}
         * @since    3.16.0
         * @version  3.16.11
         */
        function process_object_updates( data, type, parent, main_data ) {

            if ( ! data[ type ] ) {
                return data;
            }

            if ( parent.get( type ) instanceof Backbone.Model ) {

                var info = data[ type ];

                if ( info.error ) {

                    _.extend( main_data, add_error_msg( main_data, info.error ) );

                } else {

                    var model = parent.get( type );

                    // update temp ids with the real id
                    if ( info.id != info.orig_id ) {
                        model.set( 'id', info.id );
                        delete model._unsavedChanges.id;
                    }
                    maybe_restart_tracking( model, info );

                    // check children
                    if ( model.get_relationships ) {

                        _.each( model.get_child_props(), function( child_key ) {
                            _.extend( data[ type ], process_object_updates( data[ type ], child_key, model, main_data ) );
                        } );

                    }

                }

            } else if ( parent.get( type ) instanceof Backbone.Collection ) {

                _.each( data[ type ], function( info, index ) {

                    if ( info.error ) {

                        _.extend( main_data, add_error_msg( main_data, info.error ) );

                    } else {

                        var model = parent.get( type ).get( info.orig_id );

                        // update temp ids with the real id
                        if ( info.id != info.orig_id ) {
                            model.set( 'id', info.id );
                            delete model._unsavedChanges.id;
                        }
                        maybe_restart_tracking( model, info );

                        // check children
                        if ( model.get_relationships ) {

                            _.each( model.get_child_props(), function( child_key ) {
                                _.extend( data[ type ], process_object_updates( data[ type ][ index ], child_key, model, main_data ) );
                            } );

                        }

                    }

                } );

            }

            return main_data;

        };

        /**
         * Processes response data from heartbeat-tick related to updating & creating new models
         * On success, removes from local removal collection
         * On error, appends error messages to the data object returned to UI for on-screen feedback
         *
         * @param    obj   data  data.llms_builder object from heartbeat-tick response
         * @return   obj
         * @since    3.16.0
         * @version  3.16.0
         */
        function process_updates( data ) {

            // only mess with updates data
            if ( ! data.updates ) {
                return data;
            }

            if ( data.updates ) {
                data = process_object_updates( data.updates, 'sections', Course, data );
            }

            return data;

        };

        /*
                                 /$$       /$$ /$$                                     /$$
                                | $$      | $$|__/                                    |__/
              /$$$$$$  /$$   /$$| $$$$$$$ | $$ /$$  /$$$$$$$        /$$$$$$   /$$$$$$  /$$
             /$$__  $$| $$  | $$| $$__  $$| $$| $$ /$$_____/       |____  $$ /$$__  $$| $$
            | $$  \ $$| $$  | $$| $$  \ $$| $$| $$| $$              /$$$$$$$| $$  \ $$| $$
            | $$  | $$| $$  | $$| $$  | $$| $$| $$| $$             /$$__  $$| $$  | $$| $$
            | $$$$$$$/|  $$$$$$/| $$$$$$$/| $$| $$|  $$$$$$$      |  $$$$$$$| $$$$$$$/| $$
            | $$____/  \______/ |_______/ |__/|__/ \_______/       \_______/| $$____/ |__/
            | $$                                                            | $$
            | $$                                                            | $$
            |__/                                                            |__/
        */

        /**
         * Retrieve all unsaved changes for the builder instance
         *
         * @return   obj
         * @since    3.16.0
         * @version  3.17.1
         */
        this.get_unsaved_changes = function() {

            return {
                detach: detached.pluck( 'id' ),
                trash: trashed.pluck( 'id' ),
                updates: get_changes_to_object( Course ),

            }
        };

        /**
         * Check if the builder instance has unsaved changes
         *
         * @param    obj      changes    optionally pass in an object from the return of this.get_unsaved_changes()
         *                               save some resources by not running the check twice during heartbeats
         * @return   boolean
         * @since    3.16.0
         * @version  3.16.0
         */
        this.has_unsaved_changes = function( changes ) {

            if ( 'undefined' === typeof changes ) {
                changes = self.get_unsaved_changes();
            }

            // check all possible keys, once we find one with content we have some changes to persist
            var found = _.find( changes, function( data ) {

                return ( false === _.isEmpty( data ) );

            } );

            return found ? true : false;

        };

        /**
         * Save changes right now.
         *
         * @return   void
         * @since    3.16.0
         * @version  3.16.7
         */
        this.save_now = function() {
            if ( autosave ) {
                wp.heartbeat.connectNow();
            } else {
                do_ajax_save();
            }
        };

        /**
         * Update the interval that checks for changes to the builder instance
         *
         * @param    int        ms   time (in milliseconds) to run the check on
         *                           pass 0 to disable the check
         * @return   void
         * @since    3.16.0
         * @version  3.16.0
         */
        this.set_check_interval = function( ms ) {
            check_interval_ms = ms;
            if ( check_interval ) {
                clearInterval( check_interval );
            }
            if ( check_interval_ms ) {
                check_interval = setInterval( check_for_changes, check_interval_ms );
            }
        };

        /*
             /$$ /$$             /$$
            | $$|__/            | $$
            | $$ /$$  /$$$$$$$ /$$$$$$    /$$$$$$  /$$$$$$$   /$$$$$$   /$$$$$$   /$$$$$$$
            | $$| $$ /$$_____/|_  $$_/   /$$__  $$| $$__  $$ /$$__  $$ /$$__  $$ /$$_____/
            | $$| $$|  $$$$$$   | $$    | $$$$$$$$| $$  \ $$| $$$$$$$$| $$  \__/|  $$$$$$
            | $$| $$ \____  $$  | $$ /$$| $$_____/| $$  | $$| $$_____/| $$       \____  $$
            | $$| $$ /$$$$$$$/  |  $$$$/|  $$$$$$$| $$  | $$|  $$$$$$$| $$       /$$$$$$$/
            |__/|__/|_______/    \___/   \_______/|__/  |__/ \_______/|__/      |_______/
        */

        /**
         * Listen for detached models and send them to the server for persistence
         *
         * @since    3.16.0
         * @version  3.16.0
         */
        Backbone.pubSub.on( 'model-detached', function( model ) {

            // detached models with temp ids haven't been persisted so we don't care
            if ( has_temp_id( model ) ) {
                return;
            }

            detached.add( _.clone( model.attributes ) );

        } );

        /**
         * Listen for trashed models and send them to the server for deletion
         *
         * @since    3.16.0
         * @version  3.17.1
         */
        Backbone.pubSub.on( 'model-trashed', function( model ) {

            // if the model has a temp ID we don't have to persist the deletion
            if ( has_temp_id( model ) ) {
                return;
            }

            var data = _.clone( model.attributes );

            if ( model.get_trash_id ) {
                data.id = model.get_trash_id();
            }

            trashed.add( data );

        } );

        /*
             /$$                                       /$$     /$$                             /$$
            | $$                                      | $$    | $$                            | $$
            | $$$$$$$   /$$$$$$   /$$$$$$   /$$$$$$  /$$$$$$  | $$$$$$$   /$$$$$$   /$$$$$$  /$$$$$$
            | $$__  $$ /$$__  $$ |____  $$ /$$__  $$|_  $$_/  | $$__  $$ /$$__  $$ |____  $$|_  $$_/
            | $$  \ $$| $$$$$$$$  /$$$$$$$| $$  \__/  | $$    | $$  \ $$| $$$$$$$$  /$$$$$$$  | $$
            | $$  | $$| $$_____/ /$$__  $$| $$        | $$ /$$| $$  | $$| $$_____/ /$$__  $$  | $$ /$$
            | $$  | $$|  $$$$$$$|  $$$$$$$| $$        |  $$$$/| $$$$$$$/|  $$$$$$$|  $$$$$$$  |  $$$$/
            |__/  |__/ \_______/ \_______/|__/         \___/  |_______/  \_______/ \_______/   \___/
        */


        /**
         * Add data to the WP heartbeat to persist new models, changes, and deletions to the DB
         *
         * @since 3.16.0
         * @since 3.16.7 Unknown
         * @since 4.14.0 Return early when autosaving is disabled.
         */
        $( document ).on( 'heartbeat-send', function( event, data ) {

            // Autosaving is disabled.
            if ( ! autosave ) {
                return;
            }

            // prevent simultaneous saves
            if ( self.saving ) {
                return;
            }

            var changes = self.get_unsaved_changes();

            // only send data if we have data to send
            if ( self.has_unsaved_changes( changes ) ) {

                changes.id        = Course.get( 'id' );
                self.saving       = true;
                data.llms_builder = JSON.stringify( changes );

            }

            window.llms_builder.debug.log( '==== start heartbeat-send ====', data, '==== finish heartbeat-send ====' );

            Backbone.pubSub.trigger( 'heartbeat-send', self );

        } );

        /**
         * Confirm detachments & deletions and replace temp IDs with new persisted IDs
         *
         * @since 3.16.0
         * @since 4.14.0 Return early when autosaving is disabled.
         */
        $( document ).on( 'heartbeat-tick', function( event, data ) {

            // Autosaving is disabled.
            if ( ! autosave ) {
                return;
            }

            if ( ! data.llms_builder ) {
                return;
            }

            window.llms_builder.debug.log( '==== start heartbeat-tick ====', data, '==== finish heartbeat-tick ====' );

            data.llms_builder = process_removals( data.llms_builder );
            data.llms_builder = process_updates( data.llms_builder );

            self.saving = false;

            Backbone.pubSub.trigger( 'heartbeat-tick', self, data.llms_builder );

        } );

        /**
         * On heartbeat errors publish an error to the main builder application
         *
         * @since 3.16.0
         * @since 4.14.0 Return early when autosaving is disabled.
         */
        $( document ).on( 'heartbeat-error', function( event, data ) {

            // Autosaving is disabled.
            if ( ! autosave ) {
                return;
            }

            window.llms_builder.debug.log( '==== start heartbeat-error ====', data, '==== finish heartbeat-error ====' );

            self.saving = false;

            Backbone.pubSub.trigger( 'heartbeat-tick', self, {
                status: 'error',
                message: data.responseText + ' (' + data.status + ' ' + data.statusText + ')',
            } );

        } );

        /*
             /$$           /$$   /$$
            |__/          |__/  | $$
             /$$ /$$$$$$$  /$$ /$$$$$$
            | $$| $$__  $$| $$|_  $$_/
            | $$| $$  \ $$| $$  | $$
            | $$| $$  | $$| $$  | $$ /$$
            | $$| $$  | $$| $$  |  $$$$/
            |__/|__/  |__/|__/   \___/
        */
        init();

        return this;

    };

} );