# Hotwire: Turbo y Stimulus

Referencia completa de Hotwire para Rails 7+: Turbo Drive, Turbo Frames,
Turbo Streams, Stimulus y Action Cable.

---

## Turbo Drive — navegación sin page reload

Turbo Drive intercepta clics en `<a>` y envíos de `<form>` para hacer
fetch en segundo plano y reemplazar solo `<body>`. Está activo por defecto.

```erb
<%# Deshabilitar Turbo Drive en un enlace específico %>
<%= link_to "Descargar PDF", reporte_path, data: { turbo: false } %>

<%# Deshabilitar en un formulario que redirecciona a URL externa %>
<%= form_with url: pago_externo_path, data: { turbo: false } do |f| %>
```

**Regla**: no deshabilitar Turbo Drive globalmente. Si hay conflictos, resolver
usando `data-turbo="false"` selectivamente.

---

## Turbo Frames — actualización parcial de la página

Un Turbo Frame delimita una región de la página. Los enlaces y formularios
dentro del frame solo actualizan ese frame, no toda la página.

```erb
<%# app/views/posts/index.html.erb %>
<%= turbo_frame_tag "lista-posts" do %>
  <%= render @posts %>
  <%= link_to "Cargar más", posts_path(page: @proxima_pagina) %>
<% end %>

<%# El controller debe devolver la misma etiqueta en la respuesta %>
<%# app/views/posts/index.html.erb (respuesta al frame) %>
<%= turbo_frame_tag "lista-posts" do %>
  <%= render @posts %>
<% end %>
```

```ruby
# Edición inline sin modal — el formulario de edición está en su propio frame
# app/views/posts/_post.html.erb
<%= turbo_frame_tag dom_id(post) do %>
  <h2><%= post.titulo %></h2>
  <%= link_to "Editar", edit_post_path(post) %>
<% end %>

# app/views/posts/edit.html.erb
<%= turbo_frame_tag dom_id(@post) do %>
  <%= render "form", post: @post %>
<% end %>
```

**Regla**: el `id` del `turbo_frame_tag` en la respuesta debe coincidir exactamente
con el del frame que hizo la solicitud. Si no coincide, Turbo lo descarta.

---

## Turbo Streams desde controller

Para actualizar múltiples partes de la página en una sola respuesta.

```ruby
# app/controllers/posts_controller.rb
def create
  @post = Post.new(post_params.merge(author: current_user))

  respond_to do |format|
    if @post.save
      format.turbo_stream
      format.html { redirect_to @post }
    else
      format.turbo_stream do
        render turbo_stream: turbo_stream.replace(
          "new_post_form",
          partial: "posts/form",
          locals: { post: @post }
        )
      end
      format.html { render :new, status: :unprocessable_entity }
    end
  end
end
```

```erb
<%# app/views/posts/create.turbo_stream.erb %>
<%= turbo_stream.prepend "lista-posts", partial: "posts/post", locals: { post: @post } %>
<%= turbo_stream.update  "new_post_form", partial: "posts/form", locals: { post: Post.new } %>
<%= turbo_stream.update  "posts-count", @posts_count %>
```

**Acciones disponibles**: `append`, `prepend`, `replace`, `update`, `remove`,
`before`, `after`.

---

## Turbo Streams desde jobs con Action Cable (broadcast)

Para actualizar la UI de múltiples usuarios en tiempo real.

```ruby
# app/models/post.rb
class Post < ApplicationRecord
  after_create_commit  -> { broadcast_prepend_to "posts" }
  after_update_commit  -> { broadcast_replace_to "posts" }
  after_destroy_commit -> { broadcast_remove_to "posts" }

  # Con partial personalizado
  after_create_commit -> {
    broadcast_prepend_to "posts",
      partial: "posts/post_card",
      locals: { post: self }
  }
end
```

```erb
<%# En la vista que recibe los broadcasts %>
<%= turbo_stream_from "posts" %>

<div id="lista-posts">
  <%= render @posts %>
</div>
```

```ruby
# Broadcast desde un job (para operaciones async)
class PublicarPostJob < ApplicationJob
  def perform(post_id)
    post = Post.find(post_id)
    post.update!(status: :publicado)
    Turbo::StreamsChannel.broadcast_replace_to(
      "posts",
      target: dom_id(post),
      partial: "posts/post",
      locals: { post: post }
    )
  end
end
```

---

## Stimulus — controllers de JavaScript

Stimulus conecta comportamiento JS a elementos HTML mediante atributos `data-*`.

```javascript
// app/javascript/controllers/contador_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["valor"]
  static values  = { inicio: { type: Number, default: 0 } }

  connect() {
    this.count = this.inicioValue
    this.actualizar()
  }

  incrementar() {
    this.count++
    this.actualizar()
  }

  actualizar() {
    this.valorTarget.textContent = this.count
  }
}
```

```erb
<%# En la vista %>
<div data-controller="contador" data-contador-inicio-value="5">
  <span data-contador-target="valor"></span>
  <button data-action="click->contador#incrementar">+1</button>
</div>
```

**Reglas de Stimulus:**
- Un controller por responsabilidad (no acumular lógica no relacionada)
- `connect()` para setup, `disconnect()` para cleanup (event listeners externos)
- Usar `values` para pasar datos desde el servidor, no leer del DOM
- Preferir `targets` sobre `querySelector` para referencias a elementos

---

## Action Cable — canal completo

```ruby
# app/channels/notificaciones_channel.rb
class NotificacionesChannel < ApplicationCable::Channel
  def subscribed
    stream_for current_user
  end

  def unsubscribed
    stop_all_streams
  end
end

# app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_verified_user
    end

    private

    def find_verified_user
      if (verified = User.find_by(id: cookies.encrypted[:user_id]))
        verified
      else
        reject_unauthorized_connection
      end
    end
  end
end

# Broadcast desde cualquier parte del sistema
NotificacionesChannel.broadcast_to(
  usuario,
  { tipo: "nueva_tarea", mensaje: "Te asignaron una tarea" }
)
```

```javascript
// app/javascript/controllers/notificaciones_controller.js
import { Controller } from "@hotwired/stimulus"
import consumer from "channels/consumer"

export default class extends Controller {
  connect() {
    this.subscription = consumer.subscriptions.create("NotificacionesChannel", {
      received: (data) => this.mostrar(data)
    })
  }

  disconnect() {
    this.subscription.unsubscribe()
  }

  mostrar(data) {
    // Actualizar UI con la notificación
    console.info("Notificación:", data.mensaje)
  }
}
```

---

## Morphing con Turbo 8 (Rails 7.1+)

Turbo 8 introduce morphing: en lugar de reemplazar el DOM completo, hace un diff
inteligente que preserva el estado del cliente (scroll, focus, valores de input).

```ruby
# app/controllers/posts_controller.rb
def index
  @posts = Post.includes(:author).publicados.recientes
  # Turbo 8 hace morph del <body> en lugar de reemplazarlo completo
  # No requiere configuración extra si las vistas tienen IDs estables en elementos clave
end
```

```erb
<%# Forzar refresh completo de un frame específico (sin morph) %>
<%= turbo_frame_tag "estadisticas", refresh: :morph do %>
  <%= render "estadisticas" %>
<% end %>
```

**Regla para morphing**: los elementos clave DEBEN tener `id` estable
(`dom_id(@post)` o equivalente). Sin `id`, Turbo no puede hacer el diff correctamente
y puede perder estado o duplicar elementos.

---

## Referencias externas

- [Turbo Handbook](https://turbo.hotwired.dev/handbook/introduction)
- [Stimulus Handbook](https://stimulus.hotwired.dev/handbook/introduction)
- [Action Cable — Rails Guides](https://guides.rubyonrails.org/action_cable_overview.html)
- [Hotwire — repositorio oficial](https://github.com/hotwired/hotwire-rails)
