← Volver al listado de tecnologías

Capitulo 12: Drag & Drop y Gateway HTTP

Por: SiempreListo
rustwasmaxumleptos

HTML5 Drag & Drop API en Rust

La API nativa de Drag & Drop del navegador expone tres eventos clave:

Por que API nativa en vez de libreria

Existen crates como leptos-dnd o wrappers de SortableJS, pero la API nativa tiene ventajas claras:

  1. Cero dependencias adicionales: menos bytes en el bundle WASM
  2. Control total: sabemos exactamente que ocurre en cada evento
  3. Simplicidad: para un Kanban con 3 columnas no necesitamos reordenamiento complejo
  4. web-sys ya esta incluido: Leptos depende de web-sys internamente

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

  1. draggable="true": atributo HTML que habilita el arrastre del elemento
  2. on:dragstart: cuando el usuario empieza a arrastrar, almacenamos el card_id en el DataTransfer
  3. dt.set_data("text/plain", &card_id): el MIME type "text/plain" es la forma estandar de pasar datos entre drag y drop
  4. 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

  1. on:dragover con prevent_default(): esto es obligatorio. Sin llamar a prevent_default() en dragover, el navegador no permite el drop. Es el detalle mas olvidado de la API.
  2. on:drop: extrae el card_id del DataTransfer y ejecuta el callback on_drop con una tupla (card_id, lane_id)
  3. 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

AspectoBackend (CardRepository)Frontend (CardGateway)
PropositoPersistencia en BDComunicacion HTTP
Error typeDomainError (enum tipado)String (simplificado)
Bound Send + SyncSi (multi-thread Tokio)No (WASM es single-thread)
Mockmockall::automockNo 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:

  1. Construir el Request con el metodo HTTP correcto (GET, POST, PATCH, DELETE)
  2. Adjuntar body JSON si es necesario (con serde_json::json!)
  3. Enviar con .send().await
  4. Deserializar la respuesta con .json::<T>().await
  5. Mapear errores a String con .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”:

  1. dragstart en CardItem: almacena card_id en DataTransfer
  2. dragover en Lane Done: prevent_default() permite el drop
  3. drop en Lane Done: extrae card_id, ejecuta on_drop(card_id, "done")
  4. Board recibe el callback: llama a gateway.move_card(card_id, "done", 0)
  5. HttpCardGateway: envia PATCH /api/cards/{id}/move con body {"lane": "done", "position": 0}
  6. Backend: actualiza la BD, retorna 200
  7. Board: recarga la lista con gateway.fetch_all() para reflejar el cambio

Glosario

TerminoDefinicion
DataTransferObjeto del navegador que transporta datos entre eventos drag/drop
prevent_default()Cancela el comportamiento por defecto del navegador (necesario para habilitar drop)
web-sysCrate de bindings Rust a Web APIs del navegador
gloo-netCrate de networking para WASM, wrapper sobre Fetch API
CardGatewayPort (trait) que define la interfaz de comunicacion HTTP del frontend
CSRClient-Side Rendering: el HTML se genera en el navegador

← Anterior: Componentes y Senales Reactivas | Siguiente: Testing →