4
\$\begingroup\$

I have created a Backbone Movie app as part of a learning exercise, the app is hosted on codepen although I actually have it built on my local but thought this approach would be handier for code review. I have some dummy JSON containing some movies that are stored in a Movie Collection and then another collection to store movies added to the watchlist, I created a watchlist collection instead of filtering the Movies collection so that I could save these models to local storage and then show the watchlist on deeplink. I'm pretty happy with what I have but unsure about areas such as the WatchlistItemView and how I dispose this view when I click to delete a movie from the watchlist. I'm also still quite unsure about what level of management each view should have, if I have a collection view with sub views does that mean the collection view is primarily in charge of the subviews or can the subviews manage themselves? With Backbone being so unopinionated I often find it so hard to know what views should be taking care of what management.

Here is the link to the codepen http://codepen.io/styler/pen/wKVZxr

Will be great to get your feedback!

JS

console.clear();

// Namespace our app.
var App = { Models: {}, Views: {}, Collections: {}, utils: {} }

// Model Classes
App.Models.MovieModel = Backbone.Model.extend({

    localStorage: new Store('watchlist'),

    defaults: {
        watchlist: false
    },

    toggleWatchlist: function() {
        this.set( 'watchlist', !this.get('watchlist') )
    },

    parse: function(response) {

        if(response.backdrop_path) {
            response.backdrop_small = 'http://image.tmdb.org/t/p/w500/'+response.backdrop_path;
            response.backdrop_large = 'http://image.tmdb.org/t/p/w1280/'+response.backdrop_path;
        } else {
            response.backdrop_small = 'https://placeimg.com/500/281/any/grayscale/1';
            response.backdrop_large = 'https://placeimg.com/1280/720/grayscale/1';
        }

        return response;
    }
});

// Collection Classes
App.Collections.MovieCollection = Backbone.Collection.extend({

    model: App.Models.MovieModel,

    url: 'http://codepen.io/styler/pen/yYmWWY.js',
    // Original http://codepen.io/styler/pen/yYmWWY.js
    // Missing attributes http://codepen.io/styler/pen/PZYjOr.js
    // Updated attribute names http://codepen.io/styler/pen/Rrbgxv.js

    initialize: function(options) {
        console.log('MovieCollection', options);
    },

    parse: function(response) {
        return response[0].results;
    }
});

App.Collections.WatchlistCollection = Backbone.Collection.extend({

    model: App.Models.MovieModel,

    localStorage: new Store('watchlist')
});

// View Classes
App.Views.AppView = Backbone.View.extend({

    currentView: null,

    _firstLoad: true,

    initialize: function() {

        // Set up route listeners
        this.listenTo(Backbone.Events, 'show:movies', this.onShowMovies);
        this.listenTo(Backbone.Events, 'show:movie', this.onShowMovie, this);
        this.listenTo(Backbone.Events, 'show:watchlist', this.onShowWatchlist);

        this.listenTo(Backbone.Events, 'add:watchlist', this.onWatchlistAdd, this);
        this.listenTo(Backbone.Events, 'remove:watchlist', this.onWatchlistRemove, this);
    },

    onShowMovies: function() {

        var movieCollectionView = new App.Views.MovieCollectionView({
            collection: dataStore.movies
        });

        this._changeView(movieCollectionView);

        if(dataStore.movies.length <= 0) {
            dataStore.movies.fetch();
        }
    },

    onShowMovie: function(id) {

        var deferred = $.Deferred(),
            self = this;

        if(dataStore.movies.length > 0) {
            deferred.resolve();
        } else {
            dataStore.movies.fetch().done(function() {
                deferred.resolve();
            });
        }

        deferred.done(function() {

            var movieModel = dataStore.movies.get(id);

            var detailedMovieView = new App.Views.DetailedMovieView({
                model: movieModel
            });

            this._changeView(detailedMovieView);
        }.bind(this));
    },

    onShowWatchlist: function() {
        console.log('onShowWatchlist');

        if(this._firstLoad && dataStore.watchlist.length <= 0) {
            dataStore.watchlist.fetch().done(function(response) {
                console.log(response);

                var watchlistCollectionView = new App.Views.WatchlistCollectionView({
                    collection: dataStore.watchlist
                });

                this._changeView(watchlistCollectionView);
                this._firstLoad = false;
            }.bind(this));
        } else {
            var watchlistCollectionView = new App.Views.WatchlistCollectionView({
                collection: dataStore.watchlist
            });

            this._changeView(watchlistCollectionView);
        }
    },

    _changeView: function(view) {

        if (this.currentView) {
            this.currentView.dispose();
            this.currentView = null;
        }

        this.currentView = view;
        this.$el.html(this.currentView.render().el);
    },

    onWatchlistAdd: function(model) {
        console.log('AppView::onWatchlistAdd', model);

        model.set('watchlist', true);

        var clonedFilm = model.clone();

        dataStore.watchlist.add(clonedFilm);

        clonedFilm.save(null, {
            success: function(model, response, options) {
                console.log('Success::', model, response, options);
            },
            error: function(model, response, options) {
                console.log('Error::', model, response, options);
            }
        });

        console.info(dataStore.watchlist);
    },

    onWatchlistRemove: function(model) {
        console.log('AppView::onWatchlistRemove', model);

        model.set('watchlist', false);

        //@todo need to add a url to model
        var clonedFilm = model.clone();

        dataStore.watchlist.remove(model);

        clonedFilm.destroy(null, {
            success: function(model, response, options) {
                console.log('Success::', model, response, options);
            },
            error: function(model, response, options) {
                console.log('Error::', model, response, options);
            }
        });

        console.info(dataStore.watchlist);
    }
});

App.Views.MainHeaderView = Backbone.View.extend({

    events: {
        'click .js-nav-link': 'onNavItemClicked'
    },

    initialize: function() {
        console.log('MainHeaderView::initialize', this.$el);

        Backbone.Events.on('route:change', this.onDeeplink, this);
    },

    onNavItemClicked: function(event) {
        event.preventDefault();

        var $clickedEl = $(event.currentTarget),
            clickedHref = $clickedEl.attr('href');

        this.toggleActiveNavClass($clickedEl);

        App.utils.router.navigate(clickedHref, {
            trigger: true
        });
    },

    onDeeplink: function(route) {
        console.log('onDeeplink', route);

        var $element = $('.js-nav-link[href="/'+route+'"]');

        this.toggleActiveNavClass($element);
    },

    /**
     * [toggleActiveNavClass description]
     * @param  {obj} element jQuery selector
     */
    toggleActiveNavClass: function(element) {

        $('.js-nav-link').removeClass('is-active');

        element.addClass('is-active');
    }
});

App.Views.MovieCollectionView = Backbone.View.extend({
    className: 'movie-grid',

    events: {
        'click .js-movie--show': 'onMovieClick'
    },

    template: _.template( $('.tmpl-movies').html() ),

    initialize: function() {
        console.log('MovieCollectionView::initialize');

        this.collection.on('sync', this.render, this);
    },

    render: function() {
        console.log('MovieCollectionView::render');

        this.$el.html(this.template({
            movies: this.collection.toJSON()
        }));

        setTimeout(function() {
            this.afterRender();
        }.bind(this), 0);

        return this;
    },

    afterRender: function() {
        console.log('MovieCollectionView::afterRender');

        var $images = imagesLoaded( this.$el.find('.js-trans-img') );

        $images.on('progress', function(instance, image) {
            $(image.img).removeClass('is-hidden');
        });
    },

    onMovieClick: function(event) {
        event.preventDefault();

        var $clickedEl = $(event.currentTarget),
            clickedHref = $clickedEl.attr('href'),
            movieId = $clickedEl.data('id');

        Backbone.Events.trigger('show:movie', movieId);

        App.utils.router.navigate(clickedHref, {trigger: false});
    },

    dispose: function() {
        this.remove();

        console.info('MovieCollectionView::dispose', this);
    }
});

App.Views.DetailedMovieView = Backbone.View.extend({
    className: 'movie-detailed',

    events: {
        'click .js-action--back': 'onBackBtnClick',
        'click .js-watchlist--add': 'onAddClick',
        'click .js-watchlist--remove': 'onRemoveClick'
    },

    template: _.template( $('.tmpl-movie-detailed').html() ),

    initialize: function() {
        console.log('DetailedMovieView::initialize');
    },

    render: function() {
        console.log('DetailedMovieView::render', this.model.toJSON());

        this.$el.html( this.template( this.model.toJSON() ) );

        setTimeout(function() {
            this.afterRender();
        }.bind(this), 0);

        return this;
    },

    afterRender: function() {
        console.log('DetailedMovieView::afterRender');

        var loader = new App.Views.LoaderView();
        this.$('.js-img-placeholder').append(loader.render().el);

        loader.show();

        imagesLoaded(this.$('.js-trans-img'), function() {
            this.images.forEach(function(i) {
                var $image = $(i.img);
                $image.removeClass('is-hidden');

                loader.hide();
            });
        });

        this.createWatchlistBtn();
    },

    createWatchlistBtn: function() {

        var watchlistBtn = new App.Views.WatchlistBtnSubview({
            model: this.model
        });

        this.$('.js-watchlist--add').replaceWith( watchlistBtn.render().el );
    },

    onBackBtnClick: function(event) {
        event.preventDefault();

        window.history.back();
    },

    onAddClick: function(event) {
        event.preventDefault();

        Backbone.Events.trigger('add:watchlist', this.model);
    },

    onRemoveClick: function(event) {
        event.preventDefault();

        Backbone.Events.trigger('remove:watchlist', this.model);
    },

    dispose: function() {
        this.remove();

        console.info('DetailedMovieView::dispose', this);
    }
});

App.Views.WatchlistCollectionView = Backbone.View.extend({

    className: 'movie-grid',

    events: {
        'click .js-movie--show': 'onMovieClick'
    },

    template: _.template( $('.tmpl-watchlist').html() ),

    initialize: function() {
        console.log('MovieListItemView::initialize', this.collection.toJSON());

        this.listenTo(this.collection, 'add', this.onAddMovie, this);
        this.listenTo(this.collection, 'remove', this.onMovieRemoved, this);
    },

    render: function() {
        console.log('MovieListItemView::render', this.collection.toJSON());

        this.$el.html(this.template());

        if(this.collection.length > 0) {
            this.collection.forEach(this.onAddMovie, this);
        }

        setTimeout(function() {
            this.afterRender();
        }.bind(this), 0);

        return this;
    },

    afterRender: function() {
        console.log('WatchlistCollectionView::afterRender');

        var $images = imagesLoaded( this.$el.find('.js-trans-img') );

        $images.on('progress', function(instance, image) {
            $(image.img).removeClass('is-hidden');
        });
    },

    onAddMovie: function(movie) {
        console.log('ADD MOVIE');

        var watchlistMovie = new App.Views.WatchlistItemView({
            model: movie
        });

        this.$el.prepend(watchlistMovie.render().el);
    },

    onMovieClick: function(event) {
        event.preventDefault();

        var $clickedEl = $(event.currentTarget),
            clickedHref = $clickedEl.attr('href'),
            movieId = $clickedEl.data('id');

        Backbone.Events.trigger('show:movie', movieId);

        App.utils.router.navigate(clickedHref, {trigger: false});
    },

    onMovieRemoved: function(model) {
        console.log('REMOVE MOVIE', model.toJSON());

        // this.collection.remove(model);
        console.log(this.collection.toJSON());

        if(this.collection.length === 0) {
            this.$el.html(this.template());
        }
    },

    dispose: function() {
        this.remove();

        console.info('WatchlistCollectionView::dispose', this);
    }
});

App.Views.WatchlistItemView = Backbone.View.extend({
    className: 'movie',

    template: _.template( $('.tmpl-watchlist-movie').html() ),

    events: {
        'click .js-watchlist--remove': 'onWatchlistRemove'
    },

    render: function() {
        this.$el.html( this.template( this.model.toJSON() ) );

        return this;
    },

    onWatchlistRemove: function(event) {
        event.preventDefault();
        event.stopPropagation();

        this.dispose();

        var model = dataStore.movies.get(this.model.get('id'));

        Backbone.Events.trigger('remove:watchlist', model);
    },

    dispose: function() {
        this.remove();
    }
});

App.Views.WatchlistBtnSubview = Backbone.View.extend({

    template: _.template( $('.tmpl-watchlist-movie-btn').html() ),

    initialize: function() {
        console.log('WatchlistBtnSubview::initialize', this.model.toJSON());

        this.listenTo(this.model, 'change:watchlist', this.render, this);
    },

    render: function() {
        console.log('WatchlistBtnSubview::render', this.model.toJSON());

        this.$el.html( this.template( this.model.toJSON() ) );

        return this;
    }
});

App.Views.LoaderView = Backbone.View.extend({

    className: 'loading tt is-hidden',

    stateClass: 'is-hidden',

    template: _.template( $('.tmpl-loader').html() ),

    initialize: function() {

        this.listenTo(Backbone.Events, 'loader:show', this.show, this);
        this.listenTo(Backbone.Events, 'loader:hide', this.hide, this);
    },

    render: function() {

        this.$el.html( this.template() );

        return this;
    },

    show: function() {
        this.$el.removeClass(this.stateClass);
    },

    hide: function() {

        this.$el.addClass(this.stateClass);

        this.$el.one('transitionend', function() {
            this.dispose();
        }.bind(this));
    },

    dispose: function() {
        this.remove();
    }
});

// Utilities
App.utils.router = new (Backbone.Router.extend({
    routes: {
        '': 'onShowMovies',
        'movie/:id': 'onShowMovie',
        // 'movie': 'onShowMovie',
        'watchlist': 'onShowWatchlist'
    },

    initialize: function() {
        console.log('Router Started');

        this.on('all', this._signalRouteChange);
    },

    onShowMovies: function() {
        console.log('router::onShowMovies', this);

        Backbone.Events.trigger('show:movies');
    },

    /**
     * [onShowMovie description]
     * @param {[number]} id movie id
     */
    onShowMovie: function(id) {
        console.log('router::onShowMovie', this);

        Backbone.Events.trigger('show:movie', id);
    },

    onShowWatchlist: function() {
        console.log('router::onShowWatchlist');

        Backbone.Events.trigger('show:watchlist');
    },

    _signalRouteChange: function(route) {
        if(route === 'route') return;

        console.log('Firing', route);

        var fragment = Backbone.history.getFragment();

        if(fragment.split('/')[0] === 'movie') {
            fragment = '';
        }

        console.log('Fragment::', fragment);

        Backbone.Events.trigger('route:change', fragment);
    }
}));

// Create a data store
var dataStore = {};
dataStore.movies = new App.Collections.MovieCollection();
dataStore.watchlist = new App.Collections.WatchlistCollection();

(function() {
    new App.Views.MainHeaderView({
        el: '.js-header'
    });

    // Start App.
    new App.Views.AppView({
        el: '.js-app'
    });

    // Start History API.
    Backbone.history.start();
})();

Templates

<header class="global-header js-header">
    <nav class="main-nav">
        <a href="/" class="main-nav__link is-active js-nav-link">Popular</a>
        <a href="/watchlist" class="main-nav__link js-nav-link">Watchlist</a>
    </nav>
</header>

<div class="main-content js-app"></div>

<!-- Templates -->
<script type="text/template" class="tmpl-movies">
    <% _.each(movies, function(movie) { %>
        <a href="/movie/<%= movie.id %>" class="movie js-movie--show" data-id="<%= movie.id %>">
            <img src="<%= movie.backdrop_small %>" alt="" class="trans-img is-hidden js-trans-img">

            <span class="movie__info">
                <h2><%= movie.title %></h2>
            </span>
        </a>
    <% }); %>
</script>

<script type="text/template" class="tmpl-movie-detailed">
    <div class="poster-holder js-img-placeholder">
        <img src="<%= backdrop_large %>" alt="" class="movie-detailed__poster trans-img is-hidden js-trans-img">
    </div>      

    <div class="movie-details">
        <a href="/" class="action action--back js-action--back">
            <svg height="80px" width="80px" class="action__icon action__icon--back" viewBox="0 0 80 80"><rect height="38" width="5" x="19" y="20.547"/><polygon points="46.371,59 49.906,55.465 36.488,42.047 61,42.047 61,37.047 36.652,37.047 49.906,23.793 46.371,20.258 27,39.629"/></svg>
        </a>

        <h1 class="movie-details__title"><%= title %></h1>

        <div class="movie-details__content">
            <p><%= overview %></p>
        </div>

        <button class="action action-watchlist--added js-watchlist--add">
            <svg height="80px" width="80px" class="action__icon action__icon--add" viewBox="0 0 80 80"><polygon points="61,37 43,37 43,19 37,19 37,37 19,37 19,43 37,43 37,61 43,61 43,43 61,43 "/></svg>
            Add to watchlist
        </button>
    </div>
</script>

<script type="text/template" class="tmpl-watchlist">
    <% if(this.collection.length <= 0) { %>
        <div class="notification">
            <img src="http://cdn.meme.am/instances2/500x/3145144.jpg" alt="" class="sadface" />
        </div>
    <% } %>
</script>

<script type="text/template" class="tmpl-watchlist-movie">
    <a href="/movie/<%= id %>" class="js-movie--show" data-id="<%= id %>">
        <img src="<%= backdrop_small %>" alt="" class="trans-img is-hidden js-trans-img">

        <span class="movie__info">
            <h2><%= title %></h2>

            <button class="action action-watchlist--remove js-watchlist--remove">
                <svg height="80px" width="80px" class="action__icon action__icon--remove" viewBox="0 0 80 80"><polygon points="56.971,52.728 44.243,40 56.971,27.272 52.728,23.029 40,35.757 27.272,23.029 23.029,27.272 35.757,40 23.029,52.728 27.272,56.971 40,44.243 52.728,56.971"/></svg>
            </button>
        </span>
    </a>
</script>

<script type="text/template" class="tmpl-watchlist-movie-btn">
     <% if(!watchlist) { %>
        <button class="action action-watchlist--added js-watchlist--add">
            <svg height="80px" width="80px" class="action__icon action__icon--add" viewBox="0 0 80 80"><polygon points="61,37 43,37 43,19 37,19 37,37 19,37 19,43 37,43 37,61 43,61 43,43 61,43 "/></svg>
            Add to watchlist
        </button>
    <% } else { %>
        <button class="action action-watchlist--removed js-watchlist--remove">
            <svg height="80px" width="80px" class="action__icon action__icon--remove" viewBox="0 0 80 80"><polygon points="56.971,52.728 44.243,40 56.971,27.272 52.728,23.029 40,35.757 27.272,23.029 23.029,27.272 35.757,40 23.029,52.728 27.272,56.971 40,44.243 52.728,56.971"/></svg>
            Remove from watchlist
        </button>
    <% } %>
</script>

<script type="text/template" class="tmpl-loader">
    <div class="loader">Loading...</div>
</script>
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.