Capitulo 12: Drag & Drop y Gateway HTTP
HTML5 Drag & Drop API en Rust
La API nativa de Drag & Drop del navegador expone tres eventos clave:
- dragstart: se dispara al comenzar a arrastrar un elemento
- dragover: se dispara mientras un elemento pasa sobre un drop target
- drop: se dispara al soltar el elemento sobre un target valido
Por que API nativa en vez de libreria
Existen crates como leptos-dnd o wrappers de SortableJS, pero la API nativa tiene ventajas claras:
- Cero dependencias adicionales: menos bytes en el bundle WASM
- Control total: sabemos exactamente que ocurre en cada evento
- Simplicidad: para un Kanban con 3 columnas no necesitamos reordenamiento complejo
- web-sys ya esta incluido: Leptos depende de
web-sysinternamente
web-sys: bindings de Rust a Web APIs
El crate web-sys provee bindings tipados de Rust a las Web APIs del navegador. Cada API es una feature flag en Cargo.toml:
[dependencies.web-sys]
version = "0.3"
features = ["DragEvent", "DataTransfer"]
Esto genera structs Rust que mapean directamente a los objetos JavaScript del navegador. DragEvent en Rust tiene los mismos metodos que DragEvent en JavaScript, pero con tipos estaticos.
CardItem: elemento draggable
El componente CardItem representa una tarjeta individual. Su responsabilidad es mostrarse y ser arrastrable.
use leptos::prelude::*;
use web_sys::DragEvent;
use crate::domain::entities::Card;
#[component]
pub fn CardItem(
card: Card,
on_delete: Callback<String>,
) -> impl IntoView {
let card_id = card.id.clone();
let card_id_delete = card.id.clone();
let is_done = card.completed;
let on_dragstart = move |ev: DragEvent| {
if let Some(dt) = ev.data_transfer() {
let _ = dt.set_data("text/plain", &card_id);
}
};
view! {
<div
class="card-item"
class:completed=is_done
draggable="true"
on:dragstart=on_dragstart
>
<div class="card-title">{card.title.clone()}</div>
<div class="card-desc">{card.description.clone()}</div>
<button
class="card-delete"
on:click=move |_| on_delete.run(card_id_delete.clone())
>
"x"
</button>
</div>
}
}
Desglose del drag
draggable="true": atributo HTML que habilita el arrastre del elementoon:dragstart: cuando el usuario empieza a arrastrar, almacenamos elcard_iden elDataTransferdt.set_data("text/plain", &card_id): el MIME type"text/plain"es la forma estandar de pasar datos entre drag y drop- Clones del id: necesitamos dos copias porque cada closure captura ownership independiente
La clase condicional class:completed=is_done aplica estilos visuales distintos cuando la tarjeta esta completada (en la columna Done).
Lane: drop target
El componente Lane representa una columna del tablero. Recibe tarjetas y actua como zona de drop.
use leptos::prelude::*;
use web_sys::DragEvent;
use crate::domain::entities::Card;
use crate::ui::components::card_item::CardItem;
#[component]
pub fn Lane(
title: &'static str,
lane_id: &'static str,
cards: Vec<Card>,
on_drop: Callback<(String, String)>,
on_delete: Callback<String>,
) -> impl IntoView {
let lane_id_owned = lane_id.to_string();
let on_dragover = |ev: DragEvent| {
ev.prevent_default();
};
let on_drop_handler = move |ev: DragEvent| {
ev.prevent_default();
if let Some(dt) = ev.data_transfer() {
if let Ok(card_id) = dt.get_data("text/plain") {
on_drop.run((card_id, lane_id_owned.clone()));
}
}
};
view! {
<div
class=format!("lane lane-{lane_id}")
on:dragover=on_dragover
on:drop=on_drop_handler
>
<h2 class="lane-title">{title}</h2>
<div class="lane-cards">
{cards.into_iter().map(|card| {
let on_del = on_delete.clone();
view! { <CardItem card=card on_delete=on_del /> }
}).collect_view()}
</div>
</div>
}
}
Desglose del drop
on:dragoverconprevent_default(): esto es obligatorio. Sin llamar aprevent_default()en dragover, el navegador no permite el drop. Es el detalle mas olvidado de la API.on:drop: extrae elcard_iddelDataTransfery ejecuta el callbackon_dropcon una tupla(card_id, lane_id)Callback<(String, String)>: el componente padre (Board) decide que hacer cuando se suelta una tarjeta. Lane solo notifica.
Flujo de datos entre componentes
flowchart TB
A["CardItem (dragstart)"] -->|"almacena card_id en DataTransfer"| B["Lane (dragover)"]
B -->|"prevent_default() para permitir drop"| C["Lane (drop)"]
C -->|"extrae card_id, callback on_drop(card_id, lane_id)"| D["Board"]
D -->|"gateway.move_card()"| E["HTTP PATCH al backend"]
Trait CardGateway: port del frontend
Siguiendo arquitectura hexagonal, el frontend tambien define sus ports. CardGateway es el trait que abstrae la comunicacion HTTP:
use crate::domain::entities::Card;
pub trait CardGateway {
fn fetch_all(
&self,
) -> impl std::future::Future<Output = Result<Vec<Card>, String>>;
fn create(
&self,
title: String,
description: String,
) -> impl std::future::Future<Output = Result<Card, String>>;
fn move_card(
&self,
id: &str,
lane: &str,
position: i32,
) -> impl std::future::Future<Output = Result<(), String>>;
fn delete(
&self,
id: &str,
) -> impl std::future::Future<Output = Result<(), String>>;
}
Async traits en Rust
En Rust estable, los traits no soportan async fn directamente. La solucion es retornar impl Future:
fn fetch_all(&self) -> impl std::future::Future<Output = Result<Vec<Card>, String>>;
Esto permite que la implementacion use async internamente sin requerir el crate async-trait ni boxing dinamico. Es mas eficiente porque el compilador monomorfica cada implementacion.
Diferencia con el backend
| Aspecto | Backend (CardRepository) | Frontend (CardGateway) |
|---|---|---|
| Proposito | Persistencia en BD | Comunicacion HTTP |
| Error type | DomainError (enum tipado) | String (simplificado) |
Bound Send + Sync | Si (multi-thread Tokio) | No (WASM es single-thread) |
| Mock | mockall::automock | No necesario (CSR) |
HttpCardGateway: adapter HTTP
La implementacion concreta usa gloo-net para hacer requests HTTP al backend:
use gloo_net::http::Request;
use serde_json::json;
use crate::domain::entities::Card;
use crate::domain::ports::CardGateway;
const BASE_URL: &str = "http://localhost:22000/api";
pub struct HttpCardGateway;
impl CardGateway for HttpCardGateway {
async fn fetch_all(&self) -> Result<Vec<Card>, String> {
Request::get(&format!("{BASE_URL}/cards"))
.send()
.await
.map_err(|e| e.to_string())?
.json::<Vec<Card>>()
.await
.map_err(|e| e.to_string())
}
async fn create(&self, title: String, description: String) -> Result<Card, String> {
Request::post(&format!("{BASE_URL}/cards"))
.json(&json!({ "title": title, "description": description }))
.map_err(|e| e.to_string())?
.send()
.await
.map_err(|e| e.to_string())?
.json::<Card>()
.await
.map_err(|e| e.to_string())
}
async fn move_card(&self, id: &str, lane: &str, position: i32) -> Result<(), String> {
Request::patch(&format!("{BASE_URL}/cards/{id}/move"))
.json(&json!({ "lane": lane, "position": position }))
.map_err(|e| e.to_string())?
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
async fn delete(&self, id: &str) -> Result<(), String> {
Request::delete(&format!("{BASE_URL}/cards/{id}"))
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
}
Patron del adapter
Cada metodo sigue el mismo patron:
- Construir el
Requestcon el metodo HTTP correcto (GET, POST, PATCH, DELETE) - Adjuntar body JSON si es necesario (con
serde_json::json!) - Enviar con
.send().await - Deserializar la respuesta con
.json::<T>().await - Mapear errores a
Stringcon.map_err(|e| e.to_string())
gloo-net vs reqwest
En WASM no podemos usar reqwest (que depende de sockets TCP). gloo-net es un wrapper tipado sobre la Fetch API del navegador, disenado especificamente para WASM.
URL base hardcodeada: trade-off consciente
La constante BASE_URL esta hardcodeada a http://localhost:22000/api. En un proyecto de produccion esto se externalizaria con variables de entorno o configuracion en tiempo de build. Para este tutorial, la simplicidad es preferible.
Flujo completo: drag a HTTP
Cuando el usuario arrastra una tarjeta de “Todo” a “Done”:
- dragstart en
CardItem: almacenacard_idenDataTransfer - dragover en
LaneDone:prevent_default()permite el drop - drop en
LaneDone: extraecard_id, ejecutaon_drop(card_id, "done") - Board recibe el callback: llama a
gateway.move_card(card_id, "done", 0) - HttpCardGateway: envia
PATCH /api/cards/{id}/movecon body{"lane": "done", "position": 0} - Backend: actualiza la BD, retorna 200
- Board: recarga la lista con
gateway.fetch_all()para reflejar el cambio
Glosario
| Termino | Definicion |
|---|---|
| DataTransfer | Objeto del navegador que transporta datos entre eventos drag/drop |
| prevent_default() | Cancela el comportamiento por defecto del navegador (necesario para habilitar drop) |
| web-sys | Crate de bindings Rust a Web APIs del navegador |
| gloo-net | Crate de networking para WASM, wrapper sobre Fetch API |
| CardGateway | Port (trait) que define la interfaz de comunicacion HTTP del frontend |
| CSR | Client-Side Rendering: el HTML se genera en el navegador |
← Anterior: Componentes y Senales Reactivas | Siguiente: Testing →