diff --git a/client/css/style.css b/client/css/style.css index d2df23d..c0bad72 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -185,3 +185,54 @@ div .txt-help { background: #EFEFEF; padding: 30px; } + +/* Instructors */ +.instructor .main, .instructors .main, +.course .main, .courses .main { + background: #555; + max-width: 700px; + margin: auto; + color: #fff; + padding-bottom: 1em; + border-radius: 10px; +} +.instructor h1, .instructors h1, +.course h1, .courses h1 { + text-align: center; + margin: 5px 0 1em; +} +.instructor p, .instructors p, +.course p, .course p { + font-size: 1.2em; +} +.instructor #single, .instructors #single, +.course #single, .courses #single { + padding-bottom: 2em; +} +.instructor .content, .instructors .content, +.course .content, .courses .content { + padding: 2em 4em 1em; +} +#result, #added { + padding: 1em 0 0; +} +.success { + color: #8DC63F; +} +button.btn { + margin: 0 5px 10px 0; +} +input, select { + margin: 0 0 1em 0; + color: #000; +} +option { + padding: 8px 10px; +} +#collections ul { + padding: 1em 0 0; +} +#collections li { + list-style: none; + padding: 0 0 1em; +} diff --git a/client/spa-index.html b/client/spa-index.html index d48068c..8040132 100644 --- a/client/spa-index.html +++ b/client/spa-index.html @@ -1,13 +1,16 @@ - - SPA - - + + SPA + + + + + -Hi, I'm the spa index from the spa folder. - + Hi, I'm the spa index from the spa folder. + diff --git a/client/spa/js/instructor/editInstructor.html b/client/spa/js/instructor/editInstructor.html index ca8ff13..457bf72 100644 --- a/client/spa/js/instructor/editInstructor.html +++ b/client/spa/js/instructor/editInstructor.html @@ -1,20 +1,14 @@ - -
-
+
+
-
-
-
- - +
+ + + +
+ + +
-
+ diff --git a/client/spa/js/instructor/instructor.controller.js b/client/spa/js/instructor/instructor.controller.js index 8701efa..f0d5e7b 100644 --- a/client/spa/js/instructor/instructor.controller.js +++ b/client/spa/js/instructor/instructor.controller.js @@ -7,7 +7,8 @@ var View = require('./instructor.view'); module.exports = Backbone.Controller.extend({ routes: { - 'instructors/:id': 'showInstructor' + 'instructors/:id': 'showInstructor', + 'instructors/new': 'addInstructor' }, initialize: function(){ this.options.container = this.options.container || 'body'; @@ -18,12 +19,13 @@ module.exports = Backbone.Controller.extend({ this.fetchModel(instructorId, function(err){ var view; - this.remove(); + this.view.remove(); this.view = new View({model: this.model}); if (err){ view = this.renderError(); } else { + this.view.template = this.view.showTemplate; view = this.renderView(); } if (cb){ @@ -32,6 +34,15 @@ module.exports = Backbone.Controller.extend({ }.bind(this)); }, + addInstructor: function() { + this.model = new Model(); + this.model.isNew(); + + this.view.remove(); + + this.view.template = this.view.editTemplate; + this.renderView(); + }, fetchModel: function(instructorId, cb){ this.model.set({id: instructorId}); this.model.fetch({ @@ -50,6 +61,7 @@ module.exports = Backbone.Controller.extend({ }, renderView: function(){ this.renderToContainer(this.view.render().$el); + this.view.delegateEvents(); // delegate for add in collections return this.view; }, renderError: function(){ diff --git a/client/spa/js/instructor/instructor.html b/client/spa/js/instructor/instructor.html index 7db63c3..1fc39e9 100644 --- a/client/spa/js/instructor/instructor.html +++ b/client/spa/js/instructor/instructor.html @@ -1,21 +1,9 @@ - -
-
+
+

<%- firstName %> <%- lastName %>

<%- skills %>

- - + +
-
+ diff --git a/client/spa/js/instructor/instructor.model.js b/client/spa/js/instructor/instructor.model.js index 0cae394..61c9a68 100644 --- a/client/spa/js/instructor/instructor.model.js +++ b/client/spa/js/instructor/instructor.model.js @@ -24,6 +24,6 @@ module.exports = Backbone.Model.extend({ if (!attrs.skills){ errors.push('skills cannot be empty'); } - return errors; + return errors.length > 0 ? errors: false; } }); diff --git a/client/spa/js/instructor/instructor.view.js b/client/spa/js/instructor/instructor.view.js index eb9bf32..4926226 100644 --- a/client/spa/js/instructor/instructor.view.js +++ b/client/spa/js/instructor/instructor.view.js @@ -10,12 +10,13 @@ var editTemplate = fs.readFileSync(__dirname + '/editInstructor.html', 'utf8'); module.exports = Backbone.View.extend({ className: 'instructor', template: _.template(template), + showTemplate: _.template(template), editTemplate: _.template(editTemplate), events: { - 'click .i-delete': 'destroy', - 'click .i-edit': 'edit', - 'click .i-save': 'save', - 'click .i-cancel': 'cancel' + 'click .delete': 'destroy', + 'click .modify': 'modify', + 'click .save': 'save', + 'click .cancel': 'cancel' }, initialize: function(){ this.listenTo(this.model, 'destroy', this.remove); @@ -25,37 +26,51 @@ module.exports = Backbone.View.extend({ var context = this.model.toJSON(); this.$el.html(this.template(context)); + // if it's adding new model, change button to Add + if (this.model.get('id') === undefined) { + this.$('.save').html('Add'); + } + return this; }, destroy: function(){ this.model.destroy(); }, - edit: function(e){ + modify: function(e){ var context = this.model.toJSON(); this.$el.html(this.editTemplate(context)); return this; }, save: function(e) { - e.preventDefault(); // if there's no changes, do not do anything + // if there's no changes, do not do anything + e.preventDefault(); var formData = { firstName: this.$('#firstName').val().trim(), lastName: this.$('#lastName').val().trim(), skills: this.$('#skills').val().trim() }; - var validate = { + + var check = { success: function() { $('#result').addClass('success') - .html('Successfully updated instructor') - .fadeIn().delay(4000).fadeOut(); - }, - error: function(model, error) { + .html('Successfully updated instructor') + .fadeIn().delay(4000).fadeOut(); + var addNew = $('.save').html(); + + if (addNew === 'Add') { + $('#added').addClass('success') + .html('Successfully added new instructor') + .fadeIn().delay(4000).fadeOut(); + } + }, + error: function(model, errors) { } }; - this.model.save(formData, validate); + this.model.save(formData, check); }, cancel: function(e) { e.preventDefault(); // prevent event bubbling diff --git a/client/spa/js/instructor/instructors.collection.js b/client/spa/js/instructor/instructors.collection.js new file mode 100644 index 0000000..dc1c9cb --- /dev/null +++ b/client/spa/js/instructor/instructors.collection.js @@ -0,0 +1,44 @@ +'use strict'; + +var Backbone = require('../vendor/index').Backbone; + +module.exports = Backbone.Collection.extend({ + url: '/api/instructors/', + + initialize: function(){ + this.on('sortById', this.sortById); + this.on('sortByFirstName', this.sortByFirstName); + this.on('sortByLastName', this.sortByLastName); + this.on('addNew', this.addNew); + this.trigger('sortById'); + }, + + sortById: function(){ + this.comparator = function(model){ + return model.get('id'); + }; + this.sort(); + }, + + sortByFirstName: function(){ + this.comparator = function(model){ + return model.get('firstName'); + }; + this.sort(); + }, + + sortByLastName: function(){ + this.comparator = function(model){ + return model.get('lastName'); + }; + this.sort(); + }, + + addNew: function() { + this.create = function(model) { + model.get('firstName'); + model.get('lastName'); + model.get('skills'); + }; + } +}); diff --git a/client/spa/js/instructor/instructors.controller.js b/client/spa/js/instructor/instructors.controller.js new file mode 100644 index 0000000..cffaef4 --- /dev/null +++ b/client/spa/js/instructor/instructors.controller.js @@ -0,0 +1,59 @@ +'use strict'; + +var Backbone = require('../vendor/index').Backbone; +var $ = require('../vendor/index').$; +var Model = require('./instructor.model'); +var Collection = require('./instructors.collection'); +var View = require('./instructors.view'); + +module.exports = Backbone.Controller.extend({ + routes: { + 'instructors': 'showInstructors' + }, + initialize: function(){ + this.options.container = this.options.container || 'body'; + }, + getCollection: function(){ + if (!this.collection){ + Collection = Collection.extend({model: Model}); + this.collection = new Collection(); + } + return this.collection; + }, + getView: function(){ + if (!this.view){ + var V = View.extend({collection: this.collection}); + this.view = new V(); + this.view.on('addNew', function() { + // trigger the router for addNew + this.navigate('instructors/new', { trigger: true }); + }.bind(this)); + } + return this.view; + }, + showInstructors: function(){ + var self = this; + + this.getCollection().fetch({ + success: function(collection, response, options){ + self.getView(); + self.renderView(); + }, + error: function(collection, response, options){ + self.renderError(); + } + }); + }, + renderToContainer: function(html){ + return $(this.options.container).html(html); + }, + renderView: function(){ + this.renderToContainer(this.view.render().$el); + this.view.delegateEvents(); + return this.view; + }, + renderError: function(){ + return this.renderToContainer( + '

There was a problem rendering instructors

'); + } +}); diff --git a/client/spa/js/instructor/instructors.html b/client/spa/js/instructor/instructors.html new file mode 100644 index 0000000..4734408 --- /dev/null +++ b/client/spa/js/instructor/instructors.html @@ -0,0 +1,16 @@ +
+
+

Instructors

+ + + + + +
+
diff --git a/client/spa/js/instructor/instructors.view.js b/client/spa/js/instructor/instructors.view.js new file mode 100644 index 0000000..3257946 --- /dev/null +++ b/client/spa/js/instructor/instructors.view.js @@ -0,0 +1,57 @@ +'use strict'; + +var Backbone = require('../vendor/index').Backbone; +var _ = require('../vendor/index')._; +var fs = require('fs'); //will be replaced by brfs in the browser +// readFileSync will be evaluated statically so errors can't be caught +var template = fs.readFileSync(__dirname + '/instructors.html', 'utf8'); + + +module.exports = Backbone.View.extend({ + className: 'instructors', + template: _.template(template), + events:{ + 'click .sortById': 'sortById', + 'click .sortByFirstName': 'sortByFirstName', + 'click .sortByLastName': 'sortByLastName', + 'click .addNew': 'addNew' + }, + + initialize: function() { + this.listenTo(this.collection, 'add', function(){ + this.render(); + }); + this.listenTo(this.collection, 'reset', function(){ + this.render(); + }); + this.listenTo(this.collection, 'sort', function(){ + this.render(); + }); + }, + + render: function() { + var context = this.collection; + this.$el.html(this.template(context)); + return this; + }, + + addNew: function() { + this.trigger('addNew'); + this.render(); + }, + + sortById: function(){ + this.collection.trigger('sortById'); + this.render(); + }, + + sortByFirstName: function(){ + this.collection.trigger('sortByFirstName'); + this.render(); + }, + + sortByLastName: function(){ + this.collection.trigger('sortByLastName'); + this.render(); + } +}); diff --git a/client/spa/js/instructor/spec/instructor.view.spec.js b/client/spa/js/instructor/spec/instructor.view.spec.js index 7b51e47..c383339 100644 --- a/client/spa/js/instructor/spec/instructor.view.spec.js +++ b/client/spa/js/instructor/spec/instructor.view.spec.js @@ -69,7 +69,7 @@ describe('Instructor view ', function(){ // call delegate after spyOn view.delegateEvents(); view.render(); - view.$('.i-edit').trigger('click'); + view.$('.modify').trigger('click'); }); describe('when the user enters new instructor information ', function(){ @@ -77,7 +77,7 @@ describe('Instructor view ', function(){ describe('when user clicks on the cancel button', function(){ beforeEach(function(){ - view.$('.i-cancel').trigger('click'); + view.$('.cancel').trigger('click'); }); it('cancels the user input', function(){ @@ -91,7 +91,7 @@ describe('Instructor view ', function(){ view.$('#lastName').val('changed lastName'); view.$('#skills').val('changed skills'); - view.$('.i-save').trigger('click'); + view.$('.save').trigger('click'); }); it('updates the model', function(){ @@ -120,7 +120,7 @@ describe('Instructor view ', function(){ it('deletes the model', function(){ // Must render for the event to be fired view.render(); - view.$('.i-delete').trigger('click'); + view.$('.delete').trigger('click'); expect(view.destroy).toHaveBeenCalled(); expect(model.destroy).toHaveBeenCalled(); }); // end delete model test diff --git a/client/spa/js/instructor/spec/instructors.collection.spec.js b/client/spa/js/instructor/spec/instructors.collection.spec.js new file mode 100644 index 0000000..4fe9f2c --- /dev/null +++ b/client/spa/js/instructor/spec/instructors.collection.spec.js @@ -0,0 +1,85 @@ +'use strict'; + +/* +global jasmine, describe, it, expect, beforeEach, afterEach, xdescribe, xit, +spyOn +*/ +// Get the code you want to test +var Collection = require('../instructors.collection'); +// Test suite +console.log('test instructors.collection'); +describe('Instructors collection ', function(){ + var collection; + var modelA; + var modelB; + var modelC; + + beforeEach(function(){ + + // Set up test data + modelA = {id: 3, firstName: 'Jeff', lastName: 'Thomas'}; + modelB = {id: 1, firstName: 'Tom', lastName: 'Shell'}; + modelC = {id: 2, firstName: 'Emily', lastName: 'Row'}; + }); + + describe('when models are added to the collection ', function(){ + beforeEach(function(){ + collection = new Collection(); + collection.add([ + modelA, + modelC, + modelB + ], + {silent: false} // Set to true to suppress add event + ); + }); + + it('orders the models by the instructor id', function(){ + collection.trigger('sortById'); + expect(collection.at(2).get('id')).toEqual(modelA.id); + expect(collection.at(0).get('id')).toEqual(modelB.id); + expect(collection.at(1).get('id')).toEqual(modelC.id); + }); + }); + + describe('when the collection interacts with the server', function(){ + it('fetches from the correct url', function(){ + collection = new Collection(); + expect(collection.url).toEqual('/api/instructors/'); + }); + }); + + describe('when a sort event is triggered', function(){ + beforeEach(function(){ + collection = new Collection(); + collection.add([ + modelC, + modelB, + modelA + ], + {silent: false} // Set to true to suppress add event + ); + }); + + it('sorts by id', function(){ + collection.trigger('sortById'); + expect(collection.at(2).get('id')).toEqual(modelA.id); + expect(collection.at(0).get('id')).toEqual(modelB.id); + expect(collection.at(1).get('id')).toEqual(modelC.id); + }); + + it('sorts by firstName', function(){ + collection.trigger('sortByFirstName'); + expect(collection.at(1).get('firstName')).toEqual(modelA.firstName); + expect(collection.at(2).get('firstName')).toEqual(modelB.firstName); + expect(collection.at(0).get('firstName')).toEqual(modelC.firstName); + }); + + it('sorts by lastName', function(){ + collection.trigger('sortByLastName'); + expect(collection.at(2).get('lastName')).toEqual(modelA.lastName); + expect(collection.at(1).get('lastName')).toEqual(modelB.lastName); + expect(collection.at(0).get('lastName')).toEqual(modelC.lastName); + }); + }); +}); diff --git a/client/spa/js/instructor/spec/instructors.controller.spec.js b/client/spa/js/instructor/spec/instructors.controller.spec.js new file mode 100644 index 0000000..b0ebe71 --- /dev/null +++ b/client/spa/js/instructor/spec/instructors.controller.spec.js @@ -0,0 +1,114 @@ +'use strict'; + +/* +global jasmine, describe, it, expect, beforeEach, afterEach, xdescribe, xit, +spyOn +*/ +// Get the code you want to test +var Backbone = require('../../vendor/index').Backbone; +var Controller = require('../instructors.controller'); +var $ = require('jquery'); +var matchers = require('jasmine-jquery-matchers'); + +// Test suite +console.log('test instructors.controller'); + +describe('Instructors controller', function(){ + var controller; + + beforeEach(function(){ + controller = new Controller(); + }); + + it('can be created', function(){ + expect(controller).toBeDefined(); + }); + + describe('when it is created', function(){ + it('has the expected routes', function(){ + expect(controller.routes).toEqual(jasmine.objectContaining({ + 'instructors': 'showInstructors' + })); + }); + + it('without a container option, uses body as the container', function(){ + expect(controller.options.container).toEqual('body'); + }); + + it('with a container option, uses specified container', function(){ + var ctrl = new Controller({container: '.newcontainer'}); + expect(ctrl.options.container).toEqual('.newcontainer'); + }); + }); + + describe('when asked to showInstructors', function(){ + beforeEach(function(){ + jasmine.addMatchers(matchers); + }); + + describe('and fetch is successful', function(){ + beforeEach(function(){ + spyOn(Backbone.Collection.prototype, 'fetch').and.callFake( + function(options){ + options.success(); + } + ); + }); + + it('sets up the collection if it is not already', function(){ + expect(controller.collection).not.toBeDefined(); + controller.showInstructors(); + expect(controller.collection).toBeDefined(); + }); + + it('uses the existing collection if it is already setup', function(){ + controller.showInstructors(); + controller.collection.add({id: 'xyz'}); + controller.showInstructors(); + expect(controller.collection.at(0).get('id')).toEqual('xyz'); + }); + + it('fetches data for the collection', function(){ + controller.showInstructors(); + expect(controller.collection.fetch).toHaveBeenCalled(); + }); + + it('sets up the view if it is not already', function(){ + expect(controller.view).not.toBeDefined(); + controller.showInstructors(); + expect(controller.view).toBeDefined(); + }); + + it('uses the existing view if it is already setup', function(){ + controller.showInstructors(); + controller.view.test = true; + controller.showInstructors(); + expect(controller.view.test).toBeTruthy(); + }); + + it('renders the view to the correct container', function() { + spyOn(controller, 'renderView').and.callThrough(); + controller.showInstructors(); + var returnedView = controller.renderView.calls.mostRecent().object.view; + expect(returnedView).toEqual(controller.view); + expect($('body h1')).toHaveText('Instructors'); + }); + }); + + describe('and fetch errors', function(){ + beforeEach(function(){ + + spyOn(Backbone.Collection.prototype, 'fetch').and.callFake( + function(options){ + options.error(); + } + ); + }); + + it('renders error', function(){ + controller.showInstructors(); + expect($('body')).toHaveText('There was a problem rendering instructors'); + }); + }); +}); +}); diff --git a/client/spa/js/instructor/spec/instructors.view.spec.js b/client/spa/js/instructor/spec/instructors.view.spec.js new file mode 100644 index 0000000..fc7256e --- /dev/null +++ b/client/spa/js/instructor/spec/instructors.view.spec.js @@ -0,0 +1,141 @@ +'use strict'; + +/* +global jasmine, describe, it, expect, beforeEach, afterEach, xdescribe, xit, +spyOn +*/ +// Get the code you want to test +var View = require('../instructors.view.js'); +var matchers = require('jasmine-jquery-matchers'); +var _ = require('../../vendor/index')._; +var Backbone = require('../../vendor/index').Backbone; + +// Test suite +console.log('test instructors.view'); + +describe('Instructors view ', function(){ + var model; + var collection; + var view; + + beforeEach(function(){ + // Add some convenience tests for working with the DOM + jasmine.addMatchers(matchers); + var Model = Backbone.Model.extend({}); + var Collection = Backbone.Collection.extend({model: Model}); + + // Needs to have the fields required by the template + model = new Model({ + firstName: 'Instructor <3', + lastName: 'new', + skills: 'Teaching, Cooking' + }); + + collection = new Collection(model); + view = new View({ + collection: collection + }); + }); + + describe('when the view is instantiated ', function() { + it('creates the correct element', function () { + // Element has to be uppercase + expect(view.el.nodeName).toEqual('DIV'); + }); + + it('sets the correct class', function () { + view.render(); + expect(view.$el).toHaveClass('instructors'); + }); + }); + + describe('when collection events happen', function(){ + beforeEach(function () { + spyOn(view, 'render').and.callThrough(); + }); + + it('renders when something is added to the collection', function(){ + collection.trigger('add'); + expect(view.render).toHaveBeenCalled(); + }); + + it('renders when the collection is reset', function(){ + collection.trigger('reset'); + expect(view.render).toHaveBeenCalled(); + }); + + it('renders when the collection is sorted', function(){ + collection.trigger('sort'); + expect(view.render).toHaveBeenCalled(); + }); + }); + + describe('when the view is rendered', function(){ + it('returns the view object', function(){ + expect(view.render()).toEqual(view); + }); + + it('produces the correct HTML', function(){ + view.render(); + expect(view.$('h1').html()).toEqual('Instructors'); + expect(view.$('.instructor')[0]).toHaveText('Instructor <3'); + }); + }); + + describe('when the user clicks on the Sort By Id button ', function(){ + beforeEach(function(){ + view.render(); + }); + + it('triggers the sortById event on the collection', function(){ + var spy = jasmine.createSpy('sortById'); + collection.on('sortById', spy); + view.$('.sortById').trigger('click'); + expect(spy).toHaveBeenCalled(); + }); + + it('renders the view', function(){ + spyOn(view, 'render'); + view.$('.sortById').trigger('click'); + expect(view.render).toHaveBeenCalled(); + }); + }); + + describe('when the user clicks on the Sort By First Name button ', function(){ + beforeEach(function(){ + view.render(); + }); + + it('triggers the sortByFirstName event on the collection', function(){ + var spy = jasmine.createSpy('sortByFirstName'); + collection.on('sortByFirstName', spy); + view.$('.sortByFirstName').trigger('click'); + expect(spy).toHaveBeenCalled(); + }); + + it('renders the view', function(){ + spyOn(view, 'render'); + view.$('.sortByFirstName').trigger('click'); + expect(view.render).toHaveBeenCalled(); + }); + }); + + describe('when the user clicks on the Sort By Last Name button ', function(){ + beforeEach(function(){ + view.render(); + }); + + it('triggers the sortByLastName event on the collection', function(){ + var spy = jasmine.createSpy('sortByLastName'); + collection.on('sortByLastName', spy); + view.$('.sortByLastName').trigger('click'); + expect(spy).toHaveBeenCalled(); + }); + + it('renders the view', function(){ + spyOn(view, 'render'); + view.$('.sortByLastName').trigger('click'); + expect(view.render).toHaveBeenCalled(); + }); + }); +}); diff --git a/client/spa/js/main.js b/client/spa/js/main.js index b1b493f..67671d0 100644 --- a/client/spa/js/main.js +++ b/client/spa/js/main.js @@ -4,11 +4,12 @@ window.Backbone = require('./vendor').Backbone; // Include your code var Instructor = require('./instructor/instructor.controller'); +var Instructors = require('./instructor/instructors.controller'); var Resource = require('./learning-resource/learning-resource.controller'); // Initialize it window.instructor = new Instructor({router:true, container: 'body'}); +window.instructors = new Instructors({router:true, container: 'body'}); window.resource = new Resource({router:true, container: 'body'}); - // Additional modules go here