Creación de un Widget JavaScript usando Backbone.js y Require.js

3
17987

Creación de un Widget JavaScript usando Backbone.js y Require.js

Índice

Introducción

Hace un tiempo participé en un proyecto que me hizo darme cuenta que mi forma de programar en JavaScript era más parecido a programación espagueti, que a un código que siga patrones de diseño, fácilmente comprensible, modular, mantenible, testeable.

Mi código Javascript realizaba su función correctamente usando JQuery y JSON, pero repito, no era un código de calidad, ¿lo es el tuyo?

En este tutorial vamos a crear una aplicación JavaScript tipo Widget usando las siguientes tecnologías:

  • Backbone: Para crear la aplicación siguiendo el patrón Modelo-Vista-Controlador.
  • RequireJS: Para descomponer en módulos (AMD) la aplicación (modelos, vistas, controladores) con sus dependencias gestionadas por RequireJS.
  • Plantillas (Templating): Usaremos el sistema de plantillas (html) que tiene por defecto Backbone y que se basa en underscore.

    Las plantillas no estará hardcodeada en el código fuente, sino que estarán en un archivo de texto independiente que será cargado por el plugin text.js de RequireJS.
  • Internacionalización de cadenas (i18n) Los mensajes que genere la aplicación estarán internazionalizados usando el plugin i18n.js de RequireJS.
NOTA IMPORTANTE:

Este tutorial está dirigido a personas que ya tengan conocimientos sobre JavaScript, JQuery, Undescore, Require.js y Backbone.

Lo publico para que sirva de guia de consulta, pues hay muy pocos ejemplos en donde se puedan ver estas tecnologías trabajando juntas.

Si no es tu caso y te interesa aprender te recomiendo que veas la sección refererencias en la parte inferior de este tutorial.

Aplicación a construir. Ver aplicación en vivo

Captura de pantalla de la aplicación a construir:

Los requisitos funcionales son:

Una actividad tiene:

  • Un enunciado y un numero indeterminado de palabras a ser organizadas según las instrucciones del enunciado.
  • El usuario podrá responder haciendo click en la palabra (en cuyo caso se colocará en el primer hueco libre), arrastrándola o escribiéndola directamente.
  • Un tiempo máximo en segundos para ser contestada, pasado ese tiempo el usuario ya no podrá responder.
    Además cuando queden 20 segundos el contador cambiará a color rojo.
  • Un número máximo de intentos, además el indicador de número de intentos cambiará a rojo al primer fallo que cometa el usuario.
  • Al hacer clic en cancelar se borrarán todas las respuestas que el usuario habia dado hasta el momento.
  • Al hacer clic en la X se borrarán todas las respuestas asociada.
  • La aplicación deberá controlar que el usuario no introduzca palabras que no formen parte del enunciado previniéndose así errores de introducción de datos.

Código fuente de la aplicación

Captura de pantalla de la estructura de archivos y directorios:

El HTML que importa el JS principal de la aplicación

El Widget JavaScript se autoenlazará al DOM en la capa con id «activity-container».

sortwords.html
  
  
    Ejemplo de Widget con Backbone, RequireJS  
      
      
  
  
    

Creación de un Widget con Backbone y RequireJS

Creado por http://carlos-garcia.es

El JS principal de la aplicación

resources/js/activities/sortwords/sortwords.js
require.config( {
	baseUrl: "resources/",
	paths: {
        "jquery": 	  "js/helpers/jquery",
		"underscore": "js/helpers/underscore",
		"json2": 	  "js/helpers/json2",
		"backbone":   "js/helpers/backbone",
		"text":	 	  "js/helpers/text",
		"i18n":	 	  "js/helpers/i18n",
		"activity":	  		 "js/activities/sortwords/models/activity",
		"sortWordsActivity": "js/activities/sortwords/models/sortWordsActivity",
		"sortWordsResponses": "js/activities/sortwords/models/sortWordsResponses",
		"sortWordsResponse": "js/activities/sortwords/models/sortWordsResponse",
		"userActivityAnswer": "js/activities/sortwords/models/userActivityAnswer",
		"activityView":		 		  "js/activities/sortwords/views/activityView",
		"sortWordResponseView":		  "js/activities/sortwords/views/sortWordResponseView",
		"sortWordResponseAnswerView": "js/activities/sortwords/views/sortWordResponseAnswerView",
		"sortwordResultView": 		  "js/activities/sortwords/views/sortWordResultView",
	},
    shim: {
        underscore: {
            exports: "_"
        },
        backbone: {
            deps: ["underscore", "jquery"],
            exports: "Backbone",
        },
    },
	waitSeconds: 10,
});

require(["backbone", "underscore", "activity", "sortWordsActivity", "sortWordsResponse", "activityView"],  
		function(Backbone, _, Activity, SortWordsActivity, SortWordsResponse, ActivityView){
	console.log("sortwords module loaded");
	
	Backbone.emulateHTTP = true;
	
/*
	var sortWordsActivity = new SortWordsActivity({id: 1});
	sortWordsActivity.fetch({
		   success: function () {
			   var v1 = new ActivityView({model: sortWordsActivity});
			   v1.render();
		   }, 
		   error: function (collection, response)  {
			   alert("Error");
		   }
		});
*/	
	
	var sortWordsActivity = new SortWordsActivity({id: 1, alias: "alias1", timeLimit: 30, attempts: 2, title: "Ordene de las siguientes provincias espa$ntilde;olas de norte a sur"});
	sortWordsActivity.addWord(new SortWordsResponse({word: "Barcelona", position: 0}));
	sortWordsActivity.addWord(new SortWordsResponse({word: "Madrid",	position: 1}));		
	sortWordsActivity.addWord(new SortWordsResponse({word: "Granada",	position: 2}));	

	var v1 = new ActivityView({model: sortWordsActivity});
	v1.render();
});

Modelo de la aplicación

resources/js/activities/sortwords/models/actitity.js
define("activity", ["backbone"],  function(Backbone){
	console.log("activity module loaded");
	
	var Activity = Backbone.Model.extend({
		url: "http://localhost:8080/mobiletest/activities/",
		defaults: {
				id: "",
	        	alias: "aliasPorDefecto",
	        	category: "",
				title: "",
				category: "",
	        	description: "",
	        	definition: "",
	        	timeLimit: 0,
	        	attempts: 0,
		}
	});
	return Activity;
});
resources/js/activities/sortwords/models/sortWordsActitity.js
define("sortWordsActivity", ["activity", "sortWordsResponses", "sortWordsResponse", "userActivityAnswer", "underscore", "backbone"],  function(Activity, SortWordsResponses, SortWordsResponse, UserActivityAnswer, _, Backbone){
	console.log("sortWordsActivity module loaded");
	
	var SortWordsActivity = Activity.extend({
 		url : function() {
 			  var base = "http://localhost:8080/mobiletest/activities/";
 			  if (this.isNew()) return base;
 			  return base + (base.charAt(base.length - 1) == '/' ? '' : '/') + this.id;
 		},
		defaults: {
				words:    new SortWordsResponses(),
				answeres: new SortWordsResponses(),
				category: "SortWords",
		},		
		parse: function(obj) {
			for (var i = 0; i < obj.words.length; i++){
				var word = obj.words[i];
				this.get("words").add(new SortWordsResponse({position: word.position, word: word.word}), {silent: true});
				this.get("answeres").add(new SortWordsResponse({position: i, word: ""}), {silent: true});
			}
	        delete obj.words;
	        
	        // Now backbone init rest properties
	        return obj;
	    },
		getWordCount: function() {
			return this.get("words").length;
		},
		getAnsweres: function() {
			return this.get("answeres");
		},
		addWord: function(w) {
			this.get("words").add(w, {silent: true});
			this._addEmpyAnswerGapForWord(w);

		},
		_addEmpyAnswerGapForWord: function(word){
			var index = this.getWordCount() - 1;
			var answer = new SortWordsResponse({position: index, word: ""});
			this.get("answeres").add(answer, {silent: true});
		},
		clearAnswers: function(){
			var i  = 0;
			this.get("answeres").each(function(answer){ 
				answer.set({"position": i++});
				answer.clearWord();
			});
		},
		isValid: function(){
			var answeres = this.get("answeres");
			var words    = this.get("words");		
			var toReturn = true;
			
			var numWords = this.getWordCount();
			for (var i = 0; i < numWords; i++){
				var word   = words.at(i);	// TODO: ¿Sort by position first?
				var answer = answeres.at(i);
				if (! word.equals(answer)){
					toReturn = false;
					break;
				}
			}
			return toReturn;
		},
	});	
	
	// Enable property inheritance
	SortWordsActivity.prototype.defaults =  _.extend(Activity.prototype.defaults, SortWordsActivity.prototype.defaults);
	 
	return SortWordsActivity;
});
resources/js/activities/sortwords/models/sortWordsResponse.js
define("sortWordsResponse", ["backbone"],  function(Backbone){
	console.log("sortWordsResponse module loaded");

	var SortWordsResponse = Backbone.Model.extend({
		defaults: {
			word: "",
		},
		equals: function(other){
			return ((this.get("word") == other.get("word")) && (this.get("position") == other.get("position")));
		},
		clearWord: function(){
			this.set({"word": ""});
		}
	});
	return SortWordsResponse;
});
    
resources/js/activities/sortwords/models/sortWordsResponses.js
define("sortWordsResponses", ["sortWordsResponse", "backbone"],  function(SortWordsResponse, Backbone){
	console.log("SortWordsResponses module loaded");
	
	var SortWordsResponses = Backbone.Collection.extend({
		model: SortWordsResponse,
	});
	
	return SortWordsResponses;
});
    

Vistas de la aplicación

resources/js/activities/sortwords/views/activityView.js
define("activityView", ["jquery", "underscore", "backbone",  "sortWordsActivity", "userActivityAnswer", "sortWordResponseView", "sortWordResponseAnswerView", 
	"sortwordResultView", "text!js/activities/sortwords/views/templates/sortwords-template.html", "i18n!js/activities/sortwords/views/nls/strings"],  
	function($, _, Backbone, SortWordsActivity, UserActivityAnswer, SortWordResponseView, SortWordResponseAnswerView, SortWordResultView, sortwords_template, str){
	
	var ActivityView = Backbone.View.extend({
		el : 	  $("#activity-container"),
		template: _.template(sortwords_template),
		initialize: function() {
			this.bind("mobiletest:activity:finished", 	this.onAttemptsFinished, this);
			this.bind("mobiletest:activity:timeEnd",	this.onTimeFinished, this);
			this.model.bind("change", this.render, this);
			
			this.numFails  = 0;
			this.timeLimit = this.model.get("timeLimit");
			this.timerID   = setInterval(function(){this.tick();}.bind(this), 1000);
			
		},
		events : {
			"click #cancel": "clearUserAnsweres",
			"click #accept": "doAccept",
		},
		render: function() {
			this.model.set({"i18n": str});
			$(this.el).html(this.template(this.model.toJSON()));

			var model    = this.model;
			var words    = this.model.get("words");
			var answeres = this.model.get("answeres");
			var palabras = _.shuffle(_.toArray(words));
			for (var i = 0; i < palabras.length; i++){
				var w	  = palabras[i];
				var view  = new SortWordResponseView({model: w, sortWordActivity: model});
				$("#responses").append(view.render().el);
				
				var answer = answeres.at(i);
				var viewAnswer  = new SortWordResponseAnswerView({model: answer});
				$("#user-response ol").append(viewAnswer.render().el);
			}
			
			return this;
		},
		doAccept: function(ev){
			var isActivityOkAnswered = this.model.isValid();
			
			this._loadAnsweresModelFromUI();
			
			// ¿Is set num attempts limit?
			if (this.model.get("attempts") > 0){
				if (isActivityOkAnswered){
					this.trigger("mobiletest:activity:finished");
				} else {
					this.numFails++;
					$("#fails").text(this.numFails);
					$("#fails").addClass("activityWithFailAttempts");
					
					if (this.numFails >= this.model.get("attempts")){
						this.trigger("mobiletest:activity:finished");
					}	
				}
			} else {
				if (this._verifyExistAllWords()){
					this.trigger("mobiletest:activity:finished");
				} else {
					$("#alert-incomplete-activity").show();
				}
				
			}
		},
		onAttemptsFinished: function(){
			this._disableUserGUIControls();
			var userActivityAnswer = new UserActivityAnswer();
			
			userActivityAnswer.set({"activityId": this.model.get("id")});
			userActivityAnswer.set({"attempts":   this.numFails});
			userActivityAnswer.set({"result": 	  JSON.stringify(this.model.getAnsweres())});
			userActivityAnswer.save();
			
			var resultView = new SortWordResultView({model: this.model});
			resultView.render();
		},
		tick: function(){
			this.timeLimit--;
			$("#timeLimit").text(this.timeLimit);

			if (this.timeLimit <= 0){
				this.trigger("mobiletest:activity:timeEnd");
			} else if (this.timeLimit <= 20){
				$("#timeLimit").addClass("littleTime");	
			}
		},
		onTimeFinished: function(){
			this._disableUserGUIControls();
			var resultView = new SortWordResultView({model: this.model, playTimeout: true});
			resultView.render();
		},
		resetTimer: function(){
			if (this.model.get("timeLimit") > 0){
				clearInterval(this.timerID);
			}
		},
		clearUserAnsweres : function(ev) {
			this.model.clearAnswers();
			$("#alert-incomplete-activity").hide();
		},
		_disableUserGUIControls: function(){
			this.resetTimer();
			$("#cancel").attr("disabled", true);
			$("#accept").attr("disabled", true);
		},
		_loadAnsweresModelFromUI: function(){
			var wordsWritten = $("input[name='response']");
			var modelRef 	 = this.model;
			this.model.clearAnswers();
			
			$.each(wordsWritten, function(index, value) { 
				var currentSortWordsResponse = modelRef.get("answeres").at(index);
				currentSortWordsResponse.set({"word": $(value).val()});
			});
		},
		_verifyExistAllWords: function(){
			var answeres = this.model.get("answeres");
			var words    = this.model.get("words");		
			var toReturn = true;
			
			for (var i = 0; i < answeres.length; i++){
				var answerText  = answeres.at(i).get("word");
				var existWord	= false;
				
				for (var j = 0; j < words.length; j++){
					var wordText = words.at(j).get("word");
					if (answerText == wordText){
						existWord = true;
						break;
					}
				}
				
				if (! existWord){
					toReturn = false;
					break;
				}
			}
			return toReturn;
		},		
		
	});
		
	return ActivityView;
});
resources/js/activities/sortwords/views/sortWordResponseAnswerView.js
define("sortWordResponseAnswerView", ["jquery", "underscore", "backbone", "text!js/activities/sortwords/views/templates/sortwords-response-template.html"],  function($, _, Backbone, sortwords_response_template){
	console.log("SortWordResponseAnswerView module loaded");
	
	var SortWordResponseAnswerView = Backbone.View.extend({
		tagName: "li",
		template: _.template(sortwords_response_template),
		initialize: function() {
			this.model.bind("change", this.render, this);
		},
		render: function() {
			$(this.el).html(this.template(this.model.toJSON()));
			return this;
		},
		events: {
			"click .delete" : "clearResponseUI",
			"change input[name='response']": "updateAnswerModel",
		},
		clearResponseUI: function(ev){
			this.model.clearWord();
	        return true;
		},
		updateAnswerModel: function(ev){
			var newValue = $(ev.target).val();
			this.model.set({"word": newValue});
	        return true;
		},		
	});
	
	return SortWordResponseAnswerView;
});

Plantillas de las vista de la aplicación

resources/js/activities/sortwords/views/templates/sortwords-template.html
 

<%= i18n.activityType %>

<%= i18n.instructions %>

<%= i18n.title %>:

<%= description %>

<%= i18n.alias %>: <%= alias %>

<%= i18n.timeLimit %>: <%= timeLimit %>

<%= i18n.attempts %>: 0 / <%= attempts %>

<%= i18n.anweres %>:

    resources/js/activities/sortwords/views/templates/sortwords-response-template.html
    X
    resources/js/activities/sortwords/views/templates/sortwords-result.html
    resources/js/activities/sortwords/views/templates/sortwords-timeout-result.html

    <%= i18n.activityResultTimeout %>

    Internacionalizacón de los mensajes de la aplicaión

    resources/js/activities/sortwords/views/nls/es/strings.js
    define({
      cancel: "Cancelar",
      accept: "Aceptar",
      activityType: "Ordenar Palabras",
      instructions: "Ordene las siguientes palabras según el enunciado, puede hacer click, arrastrarlas a los huecos o escribir la respuesta directamente.",
      title: "Título",
      alias: "Alias",
      enunciado: "Enunciado",
      anweres: "Solución",
      alertIncompleteActivity: "Actividad incompleta o contiene palabras que no forman parte del enunciado.",
      alertActivityCompleteKO: "Lo sentimos pero no ha respondido correctamente.",
      alertActivityCompleteOK: "Felicidades! La actividad se ha realizado con éxito.",
    	  
      attempts: "Nº Intentos",
      timeLimit: "Tiempo",
      activityResultTile: "Resultado de la actividad",
      activityResultTimeout: "Lo sentimos, el tiempo ha terminado.",
      
    });
    

    3 COMENTARIOS

    DEJA UNA RESPUESTA

    Por favor ingrese su comentario!

    He leído y acepto la política de privacidad

    Por favor ingrese su nombre aquí

    Información básica acerca de la protección de datos

    • Responsable:
    • Finalidad:
    • Legitimación:
    • Destinatarios:
    • Derechos:
    • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad