Creación de aplicaciones web de una sola página con Sinatra: Parte 1
() translation by (you can also view the original English article)
¿Alguna vez has querido aprender a crear una aplicación de una sola página con Sinatra y Knockout.js? Bueno, ¡hoy es el día en que aprenderás! En esta primera sección de una serie de dos partes, revisaremos el proceso para crear una aplicación de tareas pendientes de una sola página donde los usuarios pueden ver sus tareas, ordenarlas, marcarlas como completas, eliminarlas, buscarlas y agregar nuevas tareas.
¿Qué es Sinatra?
Según su sitio web:
Sinatra es un DSL para crear rápidamente aplicaciones web en Ruby con un mínimo esfuerzo.
Sinatra te permite hacer cosas como:
1 |
get "/task/new" do |
2 |
erb :form |
3 |
end
|
Esta es una ruta que maneja solicitudes GET para "/task/new" y presenta un formulario erb
llamado form.erb
. No usaremos Sinatra para renderizar plantillas Ruby; en su lugar, lo usaremos solo para enviar respuestas JSON a nuestro front-end administrado por Knockout.js (y algunas funciones de utilidad de jQuery como $.ajax
). Usaremos erb solo para representar el archivo HTML principal.
¿Qué es Knockout?
Knockout es un framework de JavaScript de tipo Model-View-ViewModel (Modelo - Vista - Vista - Modelo) (MVVM) que te permite mantener tus modelos en objetos especiales "observables". También mantiene tu interfaz de usuario actualizada, según los objetos observados.
1 |
-ToDo/ |
2 |
-app.rb |
3 |
-models.rb |
4 |
--views/ |
5 |
-index.erb |
6 |
-- public / |
7 |
--- scripts/ |
8 |
- knockout.js |
9 |
- jquery.js |
10 |
- app.js |
11 |
--- styles/ |
12 |
- styles.css |
Esto es lo que estarás construyendo:



Comenzaremos definiendo nuestro modelo y luego nuestras acciones CRUD en Sinatra. Confiaremos en DataMapper y SQLite para el almacenamiento persistente, pero puedes usar cualquier ORM que prefieras.
Agreguemos el modelo Task al archivo models.rb
:
1 |
DataMapper.setup(:default, 'sqlite:///path/to/project.db') |
2 |
class Task |
3 |
include DataMapper::Resource |
4 |
|
5 |
property :id, Serial |
6 |
property :complete, Boolean |
7 |
property :description, Text |
8 |
property :created_at, DateTime |
9 |
property :updated_at, DateTime |
10 |
|
11 |
end
|
12 |
DataMapper.auto_upgrade! |
Este modelo "Task" consiste esencialmente en algunas propiedades diferentes que queremos manipular en nuestra aplicación de tareas pendientes.
A continuación, escribamos nuestro servidor JSON Sinatra. En el archivo app.rb
, comenzaremos requiriendo algunos módulos diferentes:
1 |
require 'rubygems' |
2 |
require 'sinatra' |
3 |
require 'data_mapper' |
4 |
require File.dirname(__FILE__) + '/models.rb' |
5 |
require 'json' |
6 |
require 'Date' |
El siguiente paso es definir algunos valores predeterminados globales; en particular, necesitamos un tipo MIME enviado con cada uno de nuestros encabezados de respuesta para especificar que cada respuesta es JSON.
1 |
before do |
2 |
content_type 'application/json' |
3 |
end
|
La función de ayuda before
se ejecuta antes de cada coincidencia de ruta. También puedes especificar rutas coincidentes después de before
; si, por ejemplo, solo quisieras ejecutar respuestas JSON si la URL terminaba en ".json", usarías esto:
1 |
before %r{.+\.json$} do |
2 |
content_type 'application/json' |
3 |
end
|
A continuación, definimos nuestras rutas CRUD, así como una ruta para servir nuestro archivo index.erb
:
1 |
get "/" do |
2 |
content_type 'html' |
3 |
erb :index |
4 |
end
|
5 |
get "/tasks" do |
6 |
@tasks = Task.all |
7 |
@tasks.to_json |
8 |
end
|
9 |
post "/tasks/new" do |
10 |
@task = Task.new |
11 |
@task.complete = false |
12 |
@task.description = params[:description] |
13 |
@task.created_at = DateTime.now |
14 |
@task.updated_at = null |
15 |
end
|
16 |
put "/tasks/:id" do |
17 |
@task = Task.find(params[:id]) |
18 |
@task.complete = params[:complete] |
19 |
@task.description = params[:description] |
20 |
@task.updated_at = DateTime.now |
21 |
if @task.save |
22 |
{:task => @task, :status => "success"}.to_json |
23 |
else
|
24 |
{:task => @task, :status => "failure"}.to_json |
25 |
end
|
26 |
end
|
27 |
delete "/tasks/:id" do |
28 |
@task = Task.find(params[:id]) |
29 |
if @task.destroy |
30 |
{:task => @task, :status => "success"}.to_json |
31 |
else
|
32 |
{:task => @task, :status => "failure"}.to_json |
33 |
end
|
34 |
end
|
Entonces, el archivo app.rb
ahora se ve así:
1 |
require 'rubygems' |
2 |
require 'sinatra' |
3 |
require 'data_mapper' |
4 |
require File.dirname(__FILE__) + '/models.rb' |
5 |
require 'json' |
6 |
require 'Date' |
7 |
before do |
8 |
content_type 'application/json' |
9 |
end
|
10 |
get "/" do |
11 |
content_type 'html' |
12 |
erb :index |
13 |
end
|
14 |
get "/tasks" do |
15 |
@tasks = Task.all |
16 |
@tasks.to_json |
17 |
end
|
18 |
post "/tasks/new" do |
19 |
@task = Task.new |
20 |
@task.complete = false |
21 |
@task.description = params[:description] |
22 |
@task.created_at = DateTime.now |
23 |
@task.updated_at = null |
24 |
if @task.save |
25 |
{:task => @task, :status => "success"}.to_json |
26 |
else
|
27 |
{:task => @task, :status => "failure"}.to_json |
28 |
end
|
29 |
end
|
30 |
put "/tasks/:id" do |
31 |
@task = Task.find(params[:id]) |
32 |
@task.complete = params[:complete] |
33 |
@task.description = params[:description] |
34 |
@task.updated_at = DateTime.now |
35 |
if @task.save |
36 |
{:task => @task, :status => "success"}.to_json |
37 |
else
|
38 |
{:task => @task, :status => "failure"}.to_json |
39 |
end
|
40 |
end
|
41 |
delete "/tasks/:id" do |
42 |
@task = Task.find(params[:id]) |
43 |
if @task.destroy |
44 |
{:task => @task, :status => "success"}.to_json |
45 |
else
|
46 |
{:task => @task, :status => "failure"}.to_json |
47 |
end
|
48 |
end
|
Cada una de estas rutas se asigna a una acción. Solo hay una vista (la vista "todas las tareas") que contiene cada acción. Recuerda: en Ruby, el valor final regresa implícitamente. Puedes regresar explícitamente antes, pero cualquier contenido que devuelvan estas rutas será la respuesta enviada desde el servidor.
Knockout: Modelos
A continuación, comenzamos definiendo nuestros modelos en Knockout. En app.js
, coloca el siguiente código:
1 |
function Task(data) { |
2 |
this.description = ko.observable(data.description); |
3 |
this.complete = ko.observable(data.complete); |
4 |
this.created_at = ko.observable(data.created_at); |
5 |
this.updated_at = ko.observable(data.updated_at); |
6 |
this.id = ko.observable(data.id); |
7 |
} |
Como puedes ver, estas propiedades se asignan directamente a nuestro modelo en models.rb
. Un ko.observable
mantiene el valor actualizado en la interfaz de usuario cuando esta cambia sin tener que depender del servidor o del DOM para realizar un seguimiento de su estado.
A continuación, agregaremos un TaskViewModel
.
1 |
function TaskViewModel() { |
2 |
var t = this; |
3 |
t.tasks = ko.observableArray([]); |
4 |
$.getJSON("/tasks", function(raw) { |
5 |
var tasks = $.map(raw, function(item) { return new Task(item) }); |
6 |
self.tasks(tasks); |
7 |
}); |
8 |
} |
9 |
ko.applyBindings(new TaskListViewModel()); |
Este es el comienzo de lo que será el meollo de nuestra aplicación. Comenzamos creando una función constructora TaskViewModel
; se pasa una nueva instancia de esta función a la función de Knockout llamada applyBindings()
al final de nuestro archivo.
Dentro de nuestro TaskViewModel
hay una llamada inicial para recuperar tareas de la base de datos, a través de la url "/tasks". Después, se asignan a ko.observableArray
, que se establece en t.tasks
. Esta matriz es el corazón de la funcionalidad de nuestra aplicación.
Entonces, ahora, tenemos una función de recuperación que muestra las tareas. Hagamos una función de creación y luego creemos nuestra vista de plantilla real. Agrega el siguiente código al TaskViewModel
:
1 |
t.newTaskDesc = ko.observable(); |
2 |
t.addTask = function() { |
3 |
var newtask = new Task({ description: this.newTaskDesc() }); |
4 |
$.getJSON("/getdate", function(data){ |
5 |
newtask.created_at(data.date); |
6 |
newtask.updated_at(data.date); |
7 |
t.tasks.push(newtask); |
8 |
t.saveTask(newtask); |
9 |
t.newTaskDesc(""); |
10 |
}) |
11 |
}; |
12 |
t.saveTask = function(task) { |
13 |
var t = ko.toJS(task); |
14 |
$.ajax({ |
15 |
url: "http://localhost:9393/tasks", |
16 |
type: "POST", |
17 |
data: t |
18 |
}).done(function(data){ |
19 |
task.id(data.task.id); |
20 |
}); |
21 |
} |
Knockout proporciona una capacidad de iteración conveniente...
Primero, configuramos newTaskDesc
como un observable. Esto nos permite usar un campo de entrada fácilmente para escribir una descripción de la tarea. A continuación, definimos nuestra función addTask()
, que agrega una tarea al observableArray
; llama a la función saveTask()
, pasando el nuevo objeto de tarea.
La función saveTask()
es independiente del tipo de guardado que realiza. (Más tarde, usamos la función saveTask()
para eliminar tareas o marcarlas como completas.) Una nota importante aquí: confiamos en una función conveniente para tomar la marca de tiempo actual. Esta no será la marca de tiempo exacta guardada en la base de datos, pero proporciona algunos datos para colocar en la vista.
La ruta es muy simple:
1 |
get "/getdate" do |
2 |
{:date => DateTime.now}.to_json |
3 |
end
|
También debe tenerse en cuenta que la identificación de la tarea no se establece hasta que se completa la solicitud Ajax, ya que debemos asignarla en función de la respuesta del servidor.
Creemos el HTML que controla nuestro JavaScript recién creado. Una gran parte de este archivo proviene del archivo de índice repetitivo HTML5. Esto va al archivo index.erb
:
1 |
<!DOCTYPE html >
|
2 |
<html>
|
3 |
<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]-->
|
4 |
<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8"> <![endif]-->
|
5 |
<!--[if IE 8]> <html class="no-js lt-ie9"> <![endif]-->
|
6 |
<!--[if gt IE 8]><!--> <!--<![endif]--> |
7 |
<body>
|
8 |
<meta charset="utf-8"> |
9 |
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> |
10 |
<title>ToDo</title> |
11 |
<meta name="description" content=""> |
12 |
<meta name="viewport" content="width=device-width"> |
13 |
|
14 |
<!-- Place favicon.ico and apple-touch-icon.png in the root directory -->
|
15 |
<link rel="stylesheet" href="styles/styles.css"> |
16 |
<script src="scripts/modernizr-2.6.2.min.js"></script> |
17 |
|
18 |
|
19 |
<!--[if lt IE 7]>
|
20 |
<p class="chromeframe">You are using an outdated browser. <a href="http://browsehappy.com/">Upgrade your browser today</a> or <a href="http://www.google.com/chromeframe/?redirect=true">install Google Chrome Frame</a> to better experience this site.</p>
|
21 |
<![endif]-->
|
22 |
<!-- Add your site or application content here -->
|
23 |
<div id="container"> |
24 |
<section id="taskforms" class="clearfix"> |
25 |
<div id="newtaskform" class="floatleft fifty"> |
26 |
<h2>Create a New Task</h2> |
27 |
<form id="addtask"> |
28 |
<input>
|
29 |
<input type="submit"> |
30 |
</form>
|
31 |
</div>
|
32 |
<div id="tasksearchform" class="floatright fifty"> |
33 |
<h2>Search Tasks</h2> |
34 |
<form id="searchtask"> |
35 |
<input>
|
36 |
</form>
|
37 |
</div>
|
38 |
</section>
|
39 |
<section id="tasktable"> |
40 |
<h2>Incomplete Tasks remaining: <span></span></h2> |
41 |
<a>Delete All Complete Tasks</a> |
42 |
<table>
|
43 |
<tbody><tr>
|
44 |
<th>DB ID</th> |
45 |
<th>Description</th> |
46 |
<th>Date Added</th> |
47 |
<th>Date Modified</th> |
48 |
<th>Complete?</th> |
49 |
<th>Delete</th> |
50 |
</tr>
|
51 |
<tr>
|
52 |
<td></td>
|
53 |
<td></td>
|
54 |
<td></td>
|
55 |
<td></td>
|
56 |
<td><input type="checkbox"> </td> |
57 |
<td class="destroytask"><a>X</a></td> |
58 |
</tr>
|
59 |
</tbody></table>
|
60 |
</section>
|
61 |
</div>
|
62 |
|
63 |
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.8.1/jquery.min.js"></script> |
64 |
<script>window.jQuery || document.write('<script src="scripts/jquery.js"><\/script>')</script> |
65 |
<script src="scripts/knockout.js"></script> |
66 |
<script src="scripts/app.js"></script> |
67 |
|
68 |
<!-- Google Analytics: change UA-XXXXX-X to be your site's ID. -->
|
69 |
<script>
|
70 |
var _gaq=[['_setAccount','UA-XXXXX-X'],['_trackPageview']]; |
71 |
(function(d,t){var g=d.createElement(t),s=d.getElementsByTagName(t)[0]; |
72 |
g.src=('https:'==location.protocol?'//ssl':'//www')+'.google-analytics.com/ga.js'; |
73 |
s.parentNode.insertBefore(g,s)}(document,'script')); |
74 |
</script>
|
75 |
</body>
|
76 |
</html>
|
Tomemos esta plantilla y completemos los enlaces que utiliza Knockout para mantener la interfaz de usuario sincronizada. Para esta parte, cubrimos la creación de elementos de tareas pendientes. En la segunda parte, cubriremos funciones más avanzadas (incluida la búsqueda, clasificación, eliminación y marcado como terminada).
Antes de continuar, démosle un poco de estilo a nuestra página. Dado que este tutorial no se trata de CSS, lo incluiremos y seguiremos adelante. El siguiente código está dentro del archivo CSS HTML5 Boilerplate, que incluye un restablecimiento y algunas otras cosas.
1 |
section { |
2 |
width: 800px; |
3 |
margin: 20px auto; |
4 |
}
|
5 |
table { |
6 |
width: 100%; |
7 |
}
|
8 |
th { |
9 |
cursor: pointer; |
10 |
}
|
11 |
tr { |
12 |
border-bottom: 1px solid #ddd; |
13 |
}
|
14 |
tr.complete, tr.complete:nth-child(odd) { |
15 |
background: #efffd7; |
16 |
color: #ddd; |
17 |
}
|
18 |
tr:nth-child(odd) { |
19 |
background-color: #dedede; |
20 |
}
|
21 |
td { |
22 |
padding: 10px 20px; |
23 |
}
|
24 |
td.destroytask { |
25 |
background: #ffeaea; |
26 |
color: #943c3c; |
27 |
font-weight: bold; |
28 |
opacity: 0.4; |
29 |
}
|
30 |
td.destroytask:hover { |
31 |
cursor: pointer; |
32 |
background: #ffacac; |
33 |
color: #792727; |
34 |
opacity: 1; |
35 |
}
|
36 |
.fifty { width: 50%; } |
37 |
input { |
38 |
background: #fefefe; |
39 |
box-shadow: inset 0 0 6px #aaa; |
40 |
padding: 6px; |
41 |
border: none; |
42 |
width: 90%; |
43 |
margin: 4px; |
44 |
}
|
45 |
input:focus { |
46 |
outline: none; |
47 |
box-shadow: inset 0 0 6px rgb(17, 148, 211); |
48 |
-webkit-transition: 0.2s all; |
49 |
background: rgba(17, 148, 211, 0.05); |
50 |
}
|
51 |
input[type=submit] { |
52 |
background-color: #1194d3; |
53 |
background-image: -webkit-gradient(linear, left top, left bottom, from(rgb(17, 148, 211)), to(rgb(59, 95, 142))); |
54 |
background-image: -webkit-linear-gradient(top, rgb(17, 148, 211), rgb(59, 95, 142)); |
55 |
background-image: -moz-linear-gradient(top, rgb(17, 148, 211), rgb(59, 95, 142)); |
56 |
background-image: -o-linear-gradient(top, rgb(17, 148, 211), rgb(59, 95, 142)); |
57 |
background-image: -ms-linear-gradient(top, rgb(17, 148, 211), rgb(59, 95, 142)); |
58 |
background-image: linear-gradient(top, rgb(17, 148, 211), rgb(59, 95, 142)); |
59 |
filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr='#1194d3', EndColorStr='#3b5f8e'); |
60 |
padding: 6px 9px; |
61 |
border-radius: 3px; |
62 |
color: #fff; |
63 |
text-shadow: 1px 1px 1px #0a3d52; |
64 |
border: none; |
65 |
width: 30%; |
66 |
}
|
67 |
input[type=submit]:hover { |
68 |
background: #0a3d52; |
69 |
}
|
70 |
.floatleft { float: left; } |
71 |
.floatright { float: right; } |
Agrega este código a tu archivo styles.css
.
Ahora, cubramos el formulario "new task". Agregaremos atributos de tipo data-bind
al formulario para que los enlaces Knockout funcionen. El atributo de enlace de datos o conocido como data-bind
es la forma en que Knockout mantiene la interfaz de usuario sincronizada y permite el enlace de eventos y otras funciones importantes. Reemplaza el formulario "new task" con el siguiente código.
1 |
<div id="newtaskform" class="floatleft fifty"> |
2 |
<h2>Create a New Task</h2> |
3 |
<form id="addtask" data-bind="submit: addTask"> |
4 |
<input data-bind="value: newTaskDesc"> |
5 |
<input type="submit"> |
6 |
</form>
|
7 |
</div>
|
Veremos estos uno por uno. Primero, el elemento de formulario tiene un enlace para el evento submit
. Cuando se envía el formulario, se ejecuta la función addTask()
definida en TaskViewModel
. El primer elemento de entrada (que es implícitamente type="text") contiene el valor ko.observable newTaskDesc
que definimos anteriormente. Todo lo que esté en este campo al enviar el formulario se convierte en la propiedad de descripción llamado description
de la tarea.
Tenemos una manera de agregar tareas, pero necesitamos mostrarlas. También necesitamos agregar cada una de las propiedades de la tarea. Repitamos las tareas y agrégalas a la tabla. Knockout proporciona una capacidad de iteración conveniente para facilitar esto; define un bloque de comentarios con la siguiente sintaxis:
1 |
<!-- ko foreach: tasks -->
|
2 |
<td data-bind="text: id"></td> |
3 |
<td data-bind="text: description"></td> |
4 |
<td data-bind="text: created_at"></td> |
5 |
<td data-bind="text: updated_at"></td> |
6 |
<td> <input type="checkbox"></td> |
7 |
<td> <a>X</a></td> |
8 |
<!-- /ko -->
|
En Ruby, el valor final se devuelve implícitamente.
Esto usa la capacidad de iteración de Knockout. Cada tarea se define específicamente en TaskViewModel
(t.tasks
) y permanece sincronizada en toda la interfaz de usuario. La identificación de cada tarea se agrega solo después de que hayamos terminado la llamada a la base de datos (ya que no hay forma de asegurarnos de que tengamos la identificación correcta de la base de datos hasta que esté escrita), pero la interfaz no necesita reflejar inconsistencias como estas.
Ahora deberías poder usar shotgun app.rb
(gem install shotgun
) desde tu directorio de trabajo y probar tu aplicación en el navegador en http://localhost:9393. (Nota: asegúrate de haber ejecutado gem install
para tener todas tus dependencias/bibliotecas requeridas antes de intentar ejecutar tu aplicación). Deberías poder agregar tareas y verlas aparecer inmediatamente.
Hasta la segunda parte
En este tutorial, aprendiste cómo crear una interfaz JSON con Sinatra y, posteriormente, cómo reflejar esos modelos en Knockout.js. También aprendiste a crear enlaces para mantener nuestra interfaz de usuario sincronizada con nuestros datos. En la siguiente parte de este tutorial, hablaremos únicamente sobre Knockout y explicaremos cómo crear funciones de clasificación, búsqueda y actualización.