AWD 11: Tarea F: Una pizca de Ajax - LPC-Ltda/Ruby-on-Rails GitHub Wiki
##11.1 Iteración F1: Moviendo el carro
Moveremos el carro al sidebar del catálogo.
Templates partial
llevaremos el despliegue del detalle del carro a un partial
[rails40/depot_j/app/views/carts/show.html.erb]
<% if notice %>
<p id="notice"><%= notice %></p>
<% end %>
<h2>Your Cart</h2>
<table>
<%= render(@cart.line_items) %>
<tr class="total_line">
<td colspan="2">Total</td>
<td class="total_cell"><%= number_to_current(@cart.total_price) %></td>
</tr>
</table>
<%= button_to 'Empty cart', @cart, method: :delete, data: { confirm: 'Are you sure?' }
[rails40/depot_j/app/views/line_items/_line_item.html.erb]
<tr>
<td><%= line_item.quantity %>×</td>
<td><%= line_item.product.title %></td>
<td class="item_price"><%= number_to_currency(line_item.total_price) %></td>
</tr>Ahora haremos un partial para desplegar el carro
[rails40/depot_j/app/views/carts/_cart.html.erb]
<h2>Your Cart</h2>
<table>
<%= render(cart.line_items) %>
<tr class="total_line">
<td colspan="2">Total</td>
<td class="total_cell"><%= number_to_current(cart.total_price) %></td>
</tr>
</table>
<%= button_to 'Empty cart', cart, method: :delete, data: { confirm: 'Are you sure?' }
[rails40/depot_k/app/views/carts/show.html.erb]
<% if notice %>
<p id="notice"><%= notice %></p>
<% end %>
<%= render @cart %>Ahora cambiamos el layout de la aplicación para agregar este partial en el sidebar
[rails40/depot_k/app/views/layout/application.html.erb]
<!DOCTYPE html>
<html>
<head>
<title>Pragprog Books Online Store</title>
<%= stylesheet_link_tag "application", media: "all", "data-turbolink-track" => true %>
<%= javascript_include_tag "application", "data-turbolinks-track" => true %>
<%= csrf_meta_tags %>
</head>
<body class="<%= controller.controller_name %>">
<div id="banner">
<%= image_tag("logo.png") %>
<%= @page_title || "Pragmatic Bookshelf" %>
</div>
<div id="columns">
<div id="side">
<div id="cart">
<%= render @cart %>
</div>
<ul>
<li><a href="http://www....">Home</a></li>
<li><a href="http://www..../faq">Questions</a></li>
<li><a href="http://www..../news">News</a></li>
<li><a href="http://www..../contact">Contact</a></li>
</ul>
</div>
<div id="main">
<%= yield %>
</div>
</div>
</body>
</html>Pero invocamos el carro en un lugar donde no fue setado.
[rails40/depot_k/app/controllers/store_controller.rb]
class StoreController < ApplicationController
include CurrentCart
before_action :set_cart
def index
@products = Product.order(:title)
end
endFinalmente modificamos los estilos
[rails40/depot_k/app/assets/stylesheets/carts.css.scss]
// Place all the styles related to the Cart controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/
.carts, #side #cart {
.item_price, .total_line {
text-align: right;
}
.total_line .total_cell {
font-weight: bold;
border-top: 1px solid #595;
}
}[rails40/depot_k/app/assets/stylesheets/applications.css.scss]
#side {
float: left;
padding: 1em 2em;
width: 13em;
background: #141;
form, div {
display: inline;
}
input {
font-size: small
}
#cart {
font-size: smaller;
color: white;
table {
border-top: 1px dotted #595;
border-bottom: 1px dotted #595;
margin-bottom: 10px;
}
}
ul {
padding: 0;
li {
list-style: none;
a {
color: #bfb;
font-size: small;
}
}
}
}Cambiando el flujo
Ahora al crear una line item no debemos irnos al show del carro sino que a store_url.
[rails40/depot_k/app/controllers/line_items_controller.rb]
def create
product = Product.find(params[:product_id])
@line_item = @cart.add_product(product.id)
respond_to do |format|
if @line_item.save
format.html { redirect_to store_url }
format.json { render action: 'show', status: :created, location: @line_item }
else
format.html { render action: 'new' }
format.json { render json: @line_item.errors, status: :unprocessable_entity }
end
end
end##11.2 Iteración F2: Creando un carro basado en Ajax
En nuestro caso haremos que el botón agregar al carro invoque la acción create en el controlador LineItems en background. El servidor podrá entonces enviar sólo el HTML para el carro y nosotros lo reemplazaremos en el sidebar.
El primer paso es sólo agregar el parámetro remote: true en el buttom_to que agrega un producto al carro.
[rails40/depot_l/app/views/store/index.html.erb]
<% if notice %>
<p id="notice"><%= notice %></p>
<% end %>
<h1>Your Pragmatic Catalog</h1>
<% cache ['store', Product.latest] do %>
<% @products.each do |product| %>
<% cache ['entity', product] %>
<div class="entry">
<%= image_tag(product.image_url) %>
<h3><%= product.title %></h3>
<%= sanitize(product.description) %>
<div class="price_line">
<span class="price"><%= number_to_currency(product.price) %></span>
<%= button_to 'Add to Cart', line_items_path(product_id: product), remote: true %>
</div>
</div>
<% end %>
<% end %>
<% end %>El siguiente paso es hacer que la aplicación tenga una respuesta. El plan es crear un fragmento HTML actualizado que represente el carro y pegar ese HTML en la representación interna de la estructura y contenido del documento representado en el browser, llamada, el Document Object model (DOM).
El primer cambio es evitar que la acción create nos envíe al despliegue del index si el requerimiento es para JavaScript. Hacemos esto agregando una llamada a respond_to contándole que deseamos responder con un formato .js.
[rails40/depot_l/app/controllers/line_items_controller.rb]
def create
product = Product.find(params[:product_id])
@line_item = @cart.add_product(product.id)
respond_to do |format|
if @line_item.save
format.html { redirect_to store_url }
format.js
format.json { render action: 'show', status: :created, location: @line_item }
else
format.html { render action: 'new' }
format.json { render json: @line_item.errors, status: :unprocessable_entity }
end
end
endRails soporta templates que generan JavaScript. Un template .js.erb es una forma de obtener JavaScript sobre el browser para hacer lo que usted desee.
[rails40/depot_l/app/views/line_items/create.js.erb]
$('#cart').html("<%= escape_javascript render(@cart) %>");Este template le dice al browser que reemplace el contenido del elemento cuyo id="cart" con ese HTML.
Por simplicidad, la libreriá jQuery es reducida a un $.
La primera llamada $('#cart') le cuenta a jQuery que encuentre un elemento HTML que tiene un id="cart". El método html() es entonces llamado con un primer argumento que es el reemplazo deseado para ese elemento. Este contenido se forma con el llamado al método render() sobre el objeto @cart. La salida de este método es procesada por el método helper escape_javascript que convierte el string de Ruby en un formato acceptable como input de JavaScript.
Note que este script es ejecutado en el browser. La única parte ejecutada en el servidor es la delimitada por <%= %>.
Solución de problemas
Aquí algunas instrucciones por si su aplicación no muestra la magia de Ajax
-
Tendrá su browser algún encantamiento que lo fuerce a voler a cargar todo en la página? Algunas veces los browsers mantienen versiones en el cache local de los assets de las páginas, y esto puede aruinar las pruebas. Ahora es un buen momento para volver a cargar completamente.
-
Tiene algún error reportado? Mire en
development.logen el directoriologs. También mire en la vantana del servidor de Rails porque algunos errores son reportados ahí. -
Aún mirando en el archivo log, ve requerimientos entrantes a la acción
create?. Si no, esto significa que su browser no está haciendo los requerimientos Ajax. Si las librerías JavaScript han sido cargadas (usando View Source en su browser le mostrará el HTML), quizá su browser tiene la ejecución de JavaScript deshabilitada? -
Algunos lectores han reportado que tuvieron que detener y poner en marcha sus aplicaciones para que su carro basado en Ajax funcionara.
El cliente nunca está satisfecho
Ahora le preocupa que al cambiar poca información no se note que la página ha sido actualizada
##11.3 Iteración F3: Destacando cambios
JQuery UI es incluida con Rails, lo que permite decorar sus páginas web con un número de efectos visuales interesantes. Usaremos el yellow fade para destacar el item que se actualizó en el carro.
Para instalar jQuery UI:
[rails40/depot_m/Gemfile]
# Use jquery as the JavaScript library
gem 'jquery-rails'
gem 'jquery-ui-rails'
[Consola]
$ bundle install
Agregar una línea a nuestro archivo application.js
[rails40/depot_m/app/assets/javascripts/application.js]
// This is a manifest file that'll be compiled into application.js, which will
// include all the files listed below.
//
// Any JavaScript/Coffee file within this directory, lib/assets/javascripts,
// vendor/assets/javascripts, o vendor/assets/javascripts of plugins, if any,
// can be referenced here using a relative path.
//
// It's not advisable to add code directly here, but if you do, it'll appear at
// the bottom of the compiled file.
//
// Read Sprockets README
// (https://github.com/sstephenson/sprockets#sprockets-directives) for details
// about supported directives.
//
//= require jquery
//= require jquery.ui.effect-blind
//= require jquery_ujs
//= require tubolinks
//= require_tree
Si un item del carro es actualizado (ya sea si es agregado o cambió la cantidad), iluminaremos su fondo.
El primer problema consiste en reconocer el último item actualizado en el carro. El trabajo comienza en el LineItemsController.
[rails40/depot_m/app/controllers/line_items_controller.rb]
def create
product = Product.find(params[:product_id])
@line_item = @cart.add_product(product.id)
respond_to do |format|
if @line_item.save
format.html { redirect_to store_url }
format.js { @current_item = @line_item }
format.json { render action: 'show', status: :created, location: @line_item }
else
format.html { render action: 'new' }
format.json { render json: @line_item.errors, status: :unprocessable_entity }
end
end
endEn el partial _line_item.html.erb chequearemos si el item rendereado es el recién actualizado, lo tegearemos con un ID current_item.
[rails40/depot_m/app/views/line_items/_line_item.html.erb]
<% if line_item == @current_item %>
<tr id="current_item">
<% else %>
<tr>
<% end %>
<td><%= line_item.quantity %>×</td>
<td><%= line_item.product.title %></td>
<td class="item_price"><%= number_to_currency(line_item.total_price) %></td>
</tr>Ahora necesitamos contarle a JavaScript que cambie el color del background.
[rails40/depot_m/app/views/line_items/create.js.erb]
$('#cart').html("<%= escape_javascript render(@cart) %>");
$('#current_item').css({'background-color':'#88ff88'}).animate({'background-color':'#114411'}, 1000);Identificamos el elemento pasándole '#current_item' a la función $. Entonces llamamos a css() para setear el color de fondo inicial, seguido de una llamada al método animate para transitar de vuelta al color original en el periodo de 1000 milisegundos, más conocido como un segundo.
##11.4 Iteración F4: Ocultando un carro vacío
La forma más simple de hacer esto es hacer que el carro se despliegue sólo si contiene algo.
<% unless cart.line_items.empty? %>
<div class="cart_title">Your Cart</div>
<table>
...
</table>
...
<% end %>Aunque esto funciona la interface de usuario es un poco brutal: se redibuja toda la sidebar.
La librería jQuery UI provee transiciones que hacen aparecer elementos. Use la opción blind sobre show(), la cual revelará suavemente el carro.
[rails40/depot_m/app/views/line_items/create.js.erb]
if ($('#cart tr').length == 1) { $('#cart').show('blind', 1000); }
$('#cart').html("<%= escape_javascript render(@cart) %>");
$('#current_item').css({'background-color':'#88ff88'}).animate({'background-color':'#114411'}, 1000);También necesitamos hacer que el carro se oculte si está vacío. La mejor forma de hacer eso es crear el HTML del carro pero setear el estilo CSS a display: none si el carro está vacío. Para hacer esto, necesitamos cambiar el layout application.html.erb. Nuestro primer intento es algo como esto:
<div id="cart"
<% if @cart.line_items.empty? %>
style="display: none"
<% end %>
>
<%= render(@cart) %>
</div>Este código agrega el atributo CSS style= al <div> sólo si el carro está vacío. Funciona, pero es realmente feo. Mejor escribiremos un método helper.
Métodos helper
Crearemos un helper llamado hidden_div_if(). Toma una condición, un conjunto opcional de atributos y un bloque. Envuelve la salida generada por el block en un tag <div>, agregando el estilo display: none si la condición es verdadera.
[rails40/depot_n/app/views/layouts/application.html.arb]
<%= hidden_div_if(@cart.line_items.empty?, id: 'cart') do %>
<%= render @cart %>
<% end %>[rails40/depot_n/app/helpers/application_helper.rb]
module ApplicationHelper
def hidden_div_if(condition, attributes = {}, &block)
if condition
attributes["style"] = "display: none"
end
content_tag("div", attributes, &block)
end
endEste código usa el helper estándar de Rails content_tag(), que envuelve la salida creada por un bloque en un tag. Usando la notación &block, logramos pasarle el bloque que fue pasado a hidden_div_if().
Finalmente removeremos el mensaje flash que se desplegaba cuando vaciabamos el carro.
[rails40/depot_n/app/controllers/carts_controller.rb]
def destroy
@cart.destroy if @cart.id == session[:cart_id]
session[:cart_id] = nil
respond_to do |format|
format.html { redirect_to store_url }
format.json { head :no_content }
end
end##11.5 Iteración F5: Haciendo las imágenes clickeables
Primero recordemos como está organizada la página
[rails40/depot_n/app/views/store/index.html.erb]
<% if notice %>
<p id="notice"><%= notice %></p>
<% end %>
<h1>Your Pragmatic Catalog</h1>
<% cache ['store', Product.latest] do %>
<% @products.each do |product| %>
<% cache ['entry', product] do %>
<div class="entry">
<%= image_tag(product.image_url) &>
<h3><%= product.title %></h3>
<%= sanitize(product.description) %>
<div class="price_line">
<span class="price"><%= number_to_currency(product.price) %></span>
<%= button_to 'Add to Cart', line_items_path(product_id: product), remote: true %>
</div>
</div>
<% end %>
<% end %>
<% end %>Tomando en cuenta esto modificamos:
[rails40/depot_n/app/assets/javascripts/store.js.coffee]
# Place all the behaviors and hooks related to the matching controler here.
# All this logic will automatically be available in application.js.
# You can use CoffeeScript in this file: http://coffeescript.org/
$(document).on "ready page:change", ->
$('.store .entry > img').click ->
$(this).parent().find(':submit').click()CoffeeScript es otro preprocesador que simplifica la escritura de activos. En este caso, CoffeeScript le ayuda a expresar JavaScript en una forma más concisa. Combinado con jQuery, usted puede producir efectos significativos con poco esfuerzo.
En este caso lo primero que deseamos hacer es definir una función que se ejecute en la carga de página. Esto es lo que la primera línea de este script hace: define una función usando el operador -> y lo pasa a una función llamada on, la cual asocia la función con dos eventos: ready y page:change. ready es el eventoq que se dispara si la gente navega en su página desde fuera de su sitio, y page:change es el evento que Turbolinks dispara si la gente navega en su página desde dentro de su sitio. Asociando el script a ambos eventos nos aseguramos de cubrir ambos caminos.
La segunda línea encuentra todas las imágenes que son hijos inmediatos de elementos que están definios con class="entry", los cuales son descendientes de un elemento con class="store". Esta última parte es importante porque, igual que con las hojas de estilo, Rails puede combinar por defecto todos los JavaScripts en un único recurso. Para cada imagen encontrada, lo que puede ser cero cuando se corre contra otras páginas de nuestra aplicación, se define una función que es que es asociada con el evento click para esa imagen.
La tercera y última línea procesa ese evento click. La línea comienza con el elemento sobre el cual el evento ocurre, llamado this. Luego procede a buscar el elemento padre, el cual debe ser el div que especifica class="entry". Dentro de ese elemento encontramos el botón submit, y procedemos a clickearlo.
En este minuto hacer un click sobre la imagen provoca que el producto sea agregado al carro.
##11.6 Probando los cambios Ajax
El test se cae en el layout applications.html.erb. Al parecer el la funcion que agregamos se aplica incluso si el carro es nil. Resolvemos:
[rails40/depot_o/app/views/layouts/application.html.erb]
<% if @cart %>
<%= hidden_div_if(@cart.line_items.empty?, id: 'cart') do %>
<%= render @cart %>
<% end %>
<% end %>
Ahora cambiamos los test para ajustarlos a los cambios.
[rails40/depot_o/test/controllers/line_items_controller_test.rb]
test "should create line_item" do
assert_difference('LineItem.count') do
post :create, product_id: products(:ruby).id
end
assert_redirected_to store_path
end[rails40/depot_o/test/controllers/line_items_controller_test.rb]
test "should create line_item via ajax" do
assert_difference('LineItem.count') do
xhr :post, :create, product_id: products(:ruby).id
end
assert_response :success
assert_select_jquery :html, '#cart' do
assert_select 'tr#current_item td', /Programming Ruby 1.9/
end
end[rails40/depot_o/test/controllers/store_controller_test.rb]
test "markup needed for store.js.coffee is in place" do
get :index
assert_select '.store .entry > img', 3
assert_select '.entry input[type=submit]', 3
end