diff --git a/client/spa/js/instructor/editInstructor.html b/client/spa/js/instructor/editInstructor.html index ca8ff13..e7ce1d8 100644 --- a/client/spa/js/instructor/editInstructor.html +++ b/client/spa/js/instructor/editInstructor.html @@ -10,11 +10,11 @@
-
-
-
- - +
+
+
+ +
diff --git a/client/spa/js/instructor/instructor.html b/client/spa/js/instructor/instructor.html index 7db63c3..cea4e70 100644 --- a/client/spa/js/instructor/instructor.html +++ b/client/spa/js/instructor/instructor.html @@ -14,8 +14,8 @@

<%- 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..e93f400 100644 --- a/client/spa/js/instructor/instructor.view.js +++ b/client/spa/js/instructor/instructor.view.js @@ -12,10 +12,10 @@ module.exports = Backbone.View.extend({ template: _.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); @@ -30,7 +30,7 @@ module.exports = Backbone.View.extend({ destroy: function(){ this.model.destroy(); }, - edit: function(e){ + modify: function(e){ var context = this.model.toJSON(); this.$el.html(this.editTemplate(context)); @@ -44,21 +44,34 @@ module.exports = Backbone.View.extend({ 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(); + this.hideErrors(); // hide if successful for validation array to work }, - error: function(model, error) { - + error: function(model, errors) { + this.showErrors(errors); } }; - this.model.save(formData, validate); + this.model.save(formData, check); }, cancel: function(e) { e.preventDefault(); // prevent event bubbling this.render(); + }, + showErrors: function(errors) { + _.each(errors, function (error) { + var fields = this.$('.' + error.name); + fields.addClass('error'); + fields.find('.help-inline').text(error.message); + }, this); + }, + + hideErrors: function () { + this.$('.textfield').removeClass('error'); + this.$('.help-inline').text(''); } }); 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/main.js b/client/spa/js/main.js index b1b493f..3b49c64 100644 --- a/client/spa/js/main.js +++ b/client/spa/js/main.js @@ -5,10 +5,11 @@ window.Backbone = require('./vendor').Backbone; // Include your code var Instructor = require('./instructor/instructor.controller'); var Resource = require('./learning-resource/learning-resource.controller'); +var Student = require('./student/student.controller'); // Initialize it window.instructor = new Instructor({router:true, container: 'body'}); window.resource = new Resource({router:true, container: 'body'}); - +window.student = new Student({router:true, container: 'body'}); // Additional modules go here diff --git a/client/spa/js/student/editStudent.html b/client/spa/js/student/editStudent.html new file mode 100644 index 0000000..5bb7e59 --- /dev/null +++ b/client/spa/js/student/editStudent.html @@ -0,0 +1,10 @@ +
+
+
+
+
+ + +
+
+
diff --git a/client/spa/js/student/spec/student.controller.spec.js b/client/spa/js/student/spec/student.controller.spec.js new file mode 100644 index 0000000..0294529 --- /dev/null +++ b/client/spa/js/student/spec/student.controller.spec.js @@ -0,0 +1,94 @@ +'use strict'; + +/* +global jasmine, describe, it, expect, beforeEach, afterEach, xdescribe, xit, +spyOn +*/ +// Get the code you want to test +var Controller = require('../student.controller'); +var $ = require('jquery'); +var matchers = require('jasmine-jquery-matchers'); +// Test suite +console.log('test student.controller'); +describe('student 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({ + 'students/:id': 'showStudent' + })); + }); + + 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 calling showstudent', function(){ + + beforeEach(function(){ + jasmine.addMatchers(matchers); + }); + + var success = function(callbacks){ + controller.model.set({'firstName': 'valid firstName', + 'lastName': 'valid lastName'}); + callbacks.success(controller.model); + }; + + var err = function(callbacks){ + callbacks.error('error', controller.model); + }; + + it('with a valid student id, fetches the model', function(){ + spyOn(controller.model, 'fetch').and.callFake(success); + var cb = function(err, view){ + expect(err).toBeNull(); + expect(controller.model.get('firstName')).toEqual('valid firstName'); + expect(controller.model.get('lastName')).toEqual('valid lastName'); + }; + + controller.showStudent(1, cb); + + }); + + it('with a valid student id, renders the view', function(){ + spyOn(controller.model, 'fetch').and.callFake(success); + spyOn(controller.view, 'render').and.callFake(function(){ + controller.view.$el = 'fake render'; + return controller.view; + }); + var cb = function(err, view){ + expect($('body')).toHaveText(''); + expect(view.cid).toEqual(controller.view.cid); + }; + controller.showStudent(1, cb); + }); + + it('with an invalid student id, renders an error message', function(){ + spyOn(controller.model, 'fetch').and.callFake(err); + var cb = function(err, view){ + expect(err).toBeTruthy(); + expect($('body')).toHaveText( + 'There was a problem rendering this student'); + }; + controller.showStudent('whatid', cb); + }); + }); +}); diff --git a/client/spa/js/student/spec/student.model.spec.js b/client/spa/js/student/spec/student.model.spec.js new file mode 100644 index 0000000..7104bb8 --- /dev/null +++ b/client/spa/js/student/spec/student.model.spec.js @@ -0,0 +1,89 @@ +'use strict'; + +/* +global jasmine, describe, it, expect, beforeEach, afterEach, xdescribe, xit, +spyOn +*/ +// Get the code you want to test +var Model = require('../student.model'); + +// Test suite +console.log('test student.model'); +describe('student model ', function(){ + var model; + + describe('when creating a new model ', function(){ + beforeEach(function(){ + model = new Model(); + }); + + it('has the expected routes', function(){ + expect(model.urlRoot).toEqual('/api/students'); + }); + }); + + describe('when updating the model for student with errorSpy ', function(){ + var errorSpy; + + beforeEach(function(){ + errorSpy = jasmine.createSpy('Invalid'); + model = new Model({ + id: 1, + firstName: 'Frances', + lastName: 'Go' + }); + model.on('invalid', errorSpy); + }); + + it('does not save when firstName is empty ', function(){ + model.set('firstName', null); + model.save(); + expect(errorSpy).toHaveBeenCalled(); + expect(errorSpy.calls.mostRecent().args[0]).toBe(model); + expect(errorSpy.calls.mostRecent().args[1][0]).toEqual( + 'firstName cannot be empty'); + }); + + it('does not save when lastName is empty ', function(){ + model.set('lastName', null); + model.save(); + expect(errorSpy).toHaveBeenCalled(); + expect(errorSpy.calls.mostRecent().args[0]).toBe(model); + expect(errorSpy.calls.mostRecent().args[1][0]).toEqual( + 'lastName cannot be empty'); + }); + }); + + describe('when changing the state of the model without errorSpy', function(){ + + beforeEach(function(){ + + model = new Model({ + id: 1, + firstName: 'Mike', + lastName: 'Foster' + }); + + }); + + it('does not save when firstName is empty ', function(){ + model.set('firstName', null); + model.save(); + expect(model.validationError).toEqual(['firstName cannot be empty']); + }); + + it('does not save when lastName is empty ', function(){ + model.set('lastName', null); + model.save(); + expect(model.validationError).toEqual(['lastName cannot be empty']); + }); + + it('does not save when firstName and lastName are empty ', function(){ + model.set({firstName:null, lastName:null}); + model.save(); + expect(model.validationError).toEqual(['firstName cannot be empty', + 'lastName cannot be empty']); + }); + + }); +}); diff --git a/client/spa/js/student/spec/student.view.spec.js b/client/spa/js/student/spec/student.view.spec.js new file mode 100644 index 0000000..8af96d9 --- /dev/null +++ b/client/spa/js/student/spec/student.view.spec.js @@ -0,0 +1,128 @@ +'use strict'; + +/* +global jasmine, describe, it, expect, beforeEach, afterEach, xdescribe, xit, +spyOn +*/ +// Get the code you want to test +var View = require('../student.view.js'); +var matchers = require('jasmine-jquery-matchers'); +var Backbone = require('../../vendor/index').Backbone; +// Test suite +console.log('test student.view'); +describe('student view ', function(){ + + var model; + var view; + var Model; + + beforeEach(function(){ + + // Add some convenience tests for working with the DOM + jasmine.addMatchers(matchers); + Model = Backbone.Model.extend({}); + + spyOn(Model.prototype, 'save'); + + // Needs to have the fields required by the template + model = new Model({ + firstName: 'Jojo', + lastName: 'Jones' + }); + + view = new View({ + model: model + }); + }); + + 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(){ + expect(view.$el).toHaveClass('student'); + }); + }); + + 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('Jojo Jones'); + }); + }); + + describe('when the user clicks on the Edit button ', function(){ + beforeEach(function(){ + // do all spyOn before rendering + spyOn(view, 'save').and.callThrough(); + spyOn(view, 'cancel').and.callThrough(); + // call delegate after spyOn + view.delegateEvents(); + view.render(); + view.$('.modify').trigger('click'); + }); + + describe('when the user enters new student information ', function(){ + + describe('when user clicks on the cancel button', function(){ + + beforeEach(function(){ + view.$('.cancel').trigger('click'); + }); + + it('cancels the user input', function(){ + expect(view.cancel).toHaveBeenCalled(); + }); + }); + + describe('when user clicks on the save button', function(){ + beforeEach(function(){ + view.$('#firstName').val('changed firstName'); + view.$('#lastName').val('changed lastName'); + + view.$('.save').trigger('click'); + }); + + it('updates the model', function(){ + expect(view.save).toHaveBeenCalled(); + expect(Model.prototype.save).toHaveBeenCalled(); + }); + + }); + + }); + + }); // end edit/update test + + describe('when the user clicks on the Delete button ', function(){ + + beforeEach(function(){ + + // Must call through otherwise the actual view function won't be called + spyOn(view, 'destroy').and.callThrough(); + + // Must delegateEvents for the spy on a DOM event to work + view.delegateEvents(); + spyOn(model, 'destroy'); + }); + + it('deletes the model', function(){ + // Must render for the event to be fired + view.render(); + view.$('.delete').trigger('click'); + expect(view.destroy).toHaveBeenCalled(); + expect(model.destroy).toHaveBeenCalled(); + }); // end delete model test + + }); // end delete + +}); // end entire suite diff --git a/client/spa/js/student/student.controller.js b/client/spa/js/student/student.controller.js new file mode 100644 index 0000000..9e198d8 --- /dev/null +++ b/client/spa/js/student/student.controller.js @@ -0,0 +1,69 @@ +'use strict'; + +var Backbone = require('../vendor/index').Backbone; +var $ = require('../vendor/index').$; +var Model = require('./student.model'); +var View = require('./student.view'); + +module.exports = Backbone.Controller.extend({ + + routes: { + 'students/:id': 'showStudent' + }, + + initialize: function(){ + this.options.container = this.options.container || 'body'; + this.model = new Model(); + this.view = new View({model: this.model}); + }, + + showStudent: function(studentId, cb){ + this.fetchModel(studentId, function(err){ + var view; + + if (err){ + view = this.renderError(); + } else { + view = this.renderView(); + } + + if (cb){ + cb(err, view); + } + + }.bind(this)); + }, + + fetchModel: function(studentId, cb){ + this.model.set({id: studentId}); + + this.model.fetch({ + success: function(model, response, options){ + //console.log(model); + cb(null, model); + }, + error: function(model, response, options){ + //console.error(response); + cb(response, model); + } + }); + }, + + renderToContainer: function(view){ + return $(this.options.container).html(view); + }, + + renderView: function(){ + this.renderToContainer(this.view.render().$el); + return this.view; + }, + + renderError: function(){ + return this.renderToContainer( + '

There was a problem rendering this student

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

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

+ + +
+
+
diff --git a/client/spa/js/student/student.model.js b/client/spa/js/student/student.model.js new file mode 100644 index 0000000..c6a23a7 --- /dev/null +++ b/client/spa/js/student/student.model.js @@ -0,0 +1,29 @@ +'use strict'; + +var Backbone = require('../vendor/index').Backbone; + +module.exports = Backbone.Model.extend({ + defaults: { + firstName: '', + lastName: '' + }, + + urlRoot: '/api/students', + + initialize: function(){ + this.on('change', function(){ + this.trigger('foo', 'bar'); + }); + }, + + validate: function(attrs){ + var errors = []; + if (!attrs.firstName){ + errors.push('firstName cannot be empty'); + } + if (!attrs.lastName){ + errors.push('lastName cannot be empty'); + } + return errors.length > 0 ? errors: false; + } +}); diff --git a/client/spa/js/student/student.view.js b/client/spa/js/student/student.view.js new file mode 100644 index 0000000..e8956ce --- /dev/null +++ b/client/spa/js/student/student.view.js @@ -0,0 +1,87 @@ +'use strict'; +var Backbone = require('../vendor/index').Backbone; +var _ = require('../vendor/index')._; +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 + '/student.html', 'utf8'); +var editTemplate = fs.readFileSync(__dirname + '/editStudent.html', 'utf8'); + +module.exports = Backbone.View.extend({ + + className: 'student', + + template: _.template(template), + + editTemplate: _.template(editTemplate), + + events: { + 'click .delete': 'destroy', + 'click .modify': 'modify', + 'click .save': 'save', + 'click .cancel': 'cancel' + }, + + initialize: function(){ + this.listenTo(this.model, 'destroy', this.remove); + this.listenTo(this.model, 'change', this.render); + }, + + render: function(){ + var context = this.model.toJSON(); + this.$el.html(this.template(context)); + return this; + }, + + destroy: function(){ + this.model.destroy(); + }, + + 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 + + var formData = { + firstName: this.$('#firstName').val().trim(), + lastName: this.$('#lastName').val().trim() + }; + var check = { + success: function() { + $('#result').addClass('success') + .html('Successfully updated student') + .fadeIn().delay(4000).fadeOut(); + this.hideErrors(); // hide if successful for validation array to work + }, + error: function(model, errors) { + this.showErrors(errors); + } + }; + + this.model.save(formData, check); + }, + + cancel: function(e) { + e.preventDefault(); // prevent event bubbling + this.render(); + }, + + showErrors: function(errors) { + _.each(errors, function (error) { + var fields = this.$('.' + error.name); + fields.addClass('error'); + fields.find('.help-inline').text(error.message); + }, this); + }, + + hideErrors: function () { + this.$('.textfield').removeClass('error'); + this.$('.help-inline').text(''); + } + +});