1
\$\begingroup\$

I have two collections - Movies and Watchlist. Both of these have Views that output Movie and WatchlistMovie Views. If you click add on a movie you should see it appear in the Watchlist colum`. If you remove then the movie should be removed. When clicking the add and remove buttons the view should rerender to show the selected action.

I'm unsure if my structure is correct, I'm triggering Backbone.Events up to App level to notify the Movies and Watchlist, I'm listening for the collection add event to render new watchlist views and then I'm listening to the model remove event to instantly remove the View. I'm finding this all quite confusing as I constantly ask myself if this is the correct approach so would love your feedback to put this to rest a little.

console.clear();

// Movie Data
var data = [
  {
      "id": 1,
      "title": "Reservoir Dogs"
  },
  {
    "id": 2,
    "title": "Pulp Fiction"
  }
];

var App = {
    Models: {},
    Views: {},
    Collections: {},
    regions: {
        'movies': $('.js-movies'),
        'watchlist': $('.js-watchlist')
    },
    start: function() {

        _.extend(this, Backbone.Events);

        this.createMovies();
        this.createWatchlist();

        this.listenTo(Backbone.Events, 'add:watchlist', this.onAddMovie);
        this.listenTo(Backbone.Events, 'remove:watchlist', this.onRemoveMovie);
    },
    createMovies: function() {
        this.movies = new App.Collections.Movies(data, {parse: true});

        this.moviesView = new App.Views.Movies({
            collection: this.movies
        });

        this.regions.movies.html( this.moviesView.render().el );
    },
    createWatchlist: function() {
        this.watchlist = new App.Collections.Watchlist();

        this.watchlistView = new App.Views.WatchlistMovies({
            collection: this.watchlist
        });

        this.regions.watchlist.html( this.watchlistView.render().el );
    },
    onAddMovie: function(model) {
        model.set('watchlist', true);
        this.watchlist.add(model);
    },
    onRemoveMovie: function(model) {
        model.set('watchlist', false);
        this.watchlist.remove(model);
    }
};


// Classes
App.Models.Movie = Backbone.Model.extend({
    defaults: {
        watchlist: false
    },

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

App.Collections.Movies = Backbone.Collection.extend({
    model: App.Models.Movie
});

App.Collections.Watchlist = Backbone.Collection.extend({
    model: App.Models.Movie
});

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

    tagName: 'ul',

    className: 'movies',

    render: function() {

        this.collection.forEach(this.addMovie, this);

        return this;
    },

    addMovie: function(movie) {
        var movie = new App.Views.Movie({
            model: movie
        });

        this.$el.prepend( movie.render().el );
    }

});

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

    tagName: 'ul',

    className: 'movies',

    initialize: function() {
        this.listenTo(this.collection, 'add', this.addMovie, this);
    },

    render: function() {
        this.collection.forEach(this.addMovie, this);

        return this;
    },

    addMovie: function(movie) {
        var movie = new App.Views.WatchlistMovie({
            model: movie
        });

        this.$el.prepend( movie.render().el );
    }
});

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

    tagName: 'li',

    className: 'movie',

    events: {
        'click .js-add': 'onMovieAdd',
        'click .js-remove': 'onMovieRemove'
    },

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

    initialize: function() {
        this.listenTo(this.model, 'change', this.render);
    },

    render: function() {
        this.$el.html( this.template( this.model.attributes ) );
        return this;
    },

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

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

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

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

});

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

    tagName: 'li',

    className: 'movie',

    events: {
      'click .js-remove': 'onMovieRemove'
    },

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

    initialize: function() {
        this.listenTo(this.model, 'remove', this.remove);
    },

    render: function() {
        this.$el.html( this.template( this.model.attributes ) );
        return this;
    },

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


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

// Add to DOM
(function() {
    App.start();
})();

JS Fiddle

\$\endgroup\$
1
\$\begingroup\$

I think there is a lot of duplication in your code:

  • App.Collections.Movies and App.Collections.Watchlist have the exact same definition. You don't need both of them.
  • App.Views.WatchlistMovies varies only slightly from App.Views.Movies. Why not have WatchlistMovies inherit from Movies and override the methods that vary.
  • Similarly, App.Views.WatchlistMovie can inherit from App.Views.Movie.

I do not think it is necessary to have two separate movie collections. watchlist is just a filtered subset of movies. App.Views.Movies and App.Views.WatchlistMovies can be constructed with the same movie collection and the latter can apply the necessary filtering when rendering.

Once there is only one collection, I see no point in using the Event Hub, so I would have the movie views directly set the watchlist property on their models.

I am including my version of the code with some clarifying comments:

var data = [
    {
        "id": 1,
        "title": "Reservoir Dogs"
    },
    {
        "id": 2,
        "title": "Pulp Fiction"
    }
];

var App = {
    Models: {},
    Views: {},
    Collections: {},
    regions: {
        movies: $('.js-movies'),
        watchlist: $('.js-watchlist')
    },
    start: function() {
        // There is no need to pass { parse: true } because there
        // is no parse method defined on App.Collections.Movies.
        this.movies = new App.Collections.Movies(data);

        this.createMovies();
        this.createWatchlist();
    },
    createMovies: function() {
        this.moviesView = new App.Views.Movies({
            collection: this.movies
        });

        this.regions.movies.html( this.moviesView.render().el );
    },
    createWatchlist: function() {
        this.watchlistView = new App.Views.WatchlistMovies({
            collection: this.movies,
            MovieView: App.Views.WatchlistMovie
        });

        this.regions.watchlist.html( this.watchlistView.render().el );
    }
};

App.Models.Movie = Backbone.Model.extend({
    defaults: {
        watchlist: false
    }
});

App.Collections.Movies = Backbone.Collection.extend({
    model: App.Models.Movie
});

App.Views.Movies = Backbone.View.extend({
    tagName: 'ul',
    className: 'movies',

    initialize: function (options) {
        // Note: We are taking the MovieView constructor as an option
        // so that WatchlistMovies can be passed the WatchlistMovie constructor.
        this.MovieView = (options.MovieView || App.Views.Movie);
    },

    render: function() {
        this.collection.forEach(this.addMovie, this);
        return this;
    },

    addMovie: function (movie) {
        var movie_view = new this.MovieView({
            model: movie
        });

        this.$el.prepend( movie_view.render().el );
    }
});

App.Views.WatchlistMovies = App.Views.Movies.extend({
    initialize: function (options) {
        this.listenTo(this.collection, 'change:watchlist', function (movie)  {
            // Only call .addMovie if the movie was added to watchlist (not removed).
            if (movie.get('watchlist')) {
                this.addMovie(movie);
            }
        });

        // We need to do this to call the "super" (base class) constructor.
        App.Views.Movies.prototype.initialize.call(this, options);
    },

    render: function() {
        var watchlist = new App.Collections.Movies(this.collection.where({ watchlist: true }));

        watchlist.forEach(this.addMovie, this);

        return this;
    }
});

App.Views.Movie = Backbone.View.extend({
    tagName: 'li',
    className: 'movie',

    events: {
        'click .js-add': 'onMovieAdd',
        'click .js-remove': 'onMovieRemove'
    },

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

    initialize: function() {
        this.listenTo(this.model, 'change', this.render);
    },

    render: function() {
        this.$el.html( this.template( this.model.attributes ) );
        return this;
    },

    onMovieAdd: function(event) {
        event.preventDefault();
        this.model.set('watchlist', true);
    },

    onMovieRemove: function(event) {
        event.preventDefault();
        this.model.set('watchlist', false);
    }
});

App.Views.WatchlistMovie = App.Views.Movie.extend({
    initialize: function () {
        // Any change to this view's model will be a remove.
        // We don't want to call the base constructor because there is no
        // functionality from it that we need here.
        this.listenTo(this.model, 'change:watchlist', this.remove);
    }
});

// I don't understand the point of this IIFE.
// Perhaps replace with a jQuery ready handler.
(function() {
    App.start();
})();
\$\endgroup\$

Your Answer

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

Not the answer you're looking for? Browse other questions tagged or ask your own question.