Editar y Eliminar Tareas

Por: Artiko
gotemplhtmxproyectotodo-list

Con la lista y creacion funcionando, agregamos las operaciones restantes: toggle de completado, edicion inline del titulo y eliminacion con confirmacion. Cada accion usa un verbo HTTP diferente y HTMX se encarga de actualizar el DOM.

Toggle Completado

El checkbox de cada todo ya tiene hx-put configurado. Solo falta el handler:

func (h *TodoHandler) Toggle(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        http.Error(w, "ID invalido", http.StatusBadRequest)
        return
    }

    todo, ok := h.store.Toggle(id)
    if !ok {
        http.Error(w, "Todo no encontrado", http.StatusNotFound)
        return
    }

    views.TodoItem(todo).Render(r.Context(), w)
}

Cuando el usuario marca/desmarca el checkbox:

  1. HTMX envia PUT /todos/{id}/toggle
  2. El handler invierte Completed y devuelve el item actualizado
  3. hx-swap="outerHTML" reemplaza todo el div.todo-item

El item se re-renderiza completo, incluyendo la clase completed que aplica el estilo tachado.

Estilos para Completados

La clase completed se agrega condicionalmente en el TodoItem (capitulo anterior). El CSS correspondiente:

.todo-item.completed .todo-title {
    text-decoration: line-through;
    opacity: 0.6;
}

Edicion Inline

La edicion inline convierte el titulo en un input al hacer click en “Editar”. Necesitamos un nuevo componente Templ para el modo edicion.

Componente de Edicion

package views

import (
    "fmt"
    "todo-app/models"
)

templ TodoEditForm(todo models.Todo) {
    <div class="todo-item editing"
         id={ fmt.Sprintf("todo-%d", todo.ID) }>
        <form hx-put={ fmt.Sprintf("/todos/%d", todo.ID) }
              hx-target={ fmt.Sprintf("#todo-%d", todo.ID) }
              hx-swap="outerHTML">
            <input type="text"
                   name="title"
                   value={ todo.Title }
                   class="edit-input"
                   autofocus
                   onkeydown="if(event.key==='Escape') htmx.ajax('GET', this.closest('form').getAttribute('hx-cancel'), {target: this.closest('.todo-item'), swap: 'outerHTML'})"
            />
            <button type="submit" class="btn-save">Guardar</button>
            <button type="button"
                    class="btn-cancel"
                    hx-get={ fmt.Sprintf("/todos/%d/view", todo.ID) }
                    hx-target={ fmt.Sprintf("#todo-%d", todo.ID) }
                    hx-swap="outerHTML">
                Cancelar
            </button>
        </form>
    </div>
}

El formulario de edicion tiene tres elementos clave:

Cancelar con Escape

La tecla Escape dispara la misma accion que el boton “Cancelar”: un GET que devuelve la vista normal del item. Usamos onkeydown con un poco de JavaScript inline que delega a HTMX.

Una alternativa mas limpia es usar hx-trigger:

<input type="text"
       name="title"
       value={ todo.Title }
       class="edit-input"
       autofocus
       hx-get={ fmt.Sprintf("/todos/%d/view", todo.ID) }
       hx-target={ fmt.Sprintf("#todo-%d", todo.ID) }
       hx-swap="outerHTML"
       hx-trigger="keyup[key=='Escape']"/>

Asi el input escucha la tecla Escape directamente con HTMX, sin JavaScript manual.

Handler GET /todos/{id}/edit

Devuelve el formulario de edicion:

func (h *TodoHandler) EditForm(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        http.Error(w, "ID invalido", http.StatusBadRequest)
        return
    }

    todo, ok := h.store.GetByID(id)
    if !ok {
        http.Error(w, "Todo no encontrado", http.StatusNotFound)
        return
    }

    views.TodoEditForm(todo).Render(r.Context(), w)
}

Handler GET /todos/{id}/view

Devuelve la vista normal (para cancelar la edicion):

func (h *TodoHandler) View(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        http.Error(w, "ID invalido", http.StatusBadRequest)
        return
    }

    todo, ok := h.store.GetByID(id)
    if !ok {
        http.Error(w, "Todo no encontrado", http.StatusNotFound)
        return
    }

    views.TodoItem(todo).Render(r.Context(), w)
}

Handler PUT /todos/{id} - Actualizar Titulo

func (h *TodoHandler) Update(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        http.Error(w, "ID invalido", http.StatusBadRequest)
        return
    }

    title := strings.TrimSpace(r.FormValue("title"))
    if title == "" {
        http.Error(w, "El titulo es requerido", http.StatusBadRequest)
        return
    }

    todo, ok := h.store.Update(id, title)
    if !ok {
        http.Error(w, "Todo no encontrado", http.StatusNotFound)
        return
    }

    views.TodoItem(todo).Render(r.Context(), w)
}

Eliminar Tareas

El boton de eliminar ya tiene los atributos HTMX configurados en el TodoItem. El handler:

func (h *TodoHandler) Delete(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        http.Error(w, "ID invalido", http.StatusBadRequest)
        return
    }

    if !h.store.Delete(id) {
        http.Error(w, "Todo no encontrado", http.StatusNotFound)
        return
    }

    // Respuesta vacia: HTMX reemplaza el item con nada
    w.WriteHeader(http.StatusOK)
}

El truco esta en devolver una respuesta vacia con status 200. Como el TodoItem tiene hx-swap="outerHTML", HTMX reemplaza el div completo con… nada. El item desaparece del DOM.

Confirmacion antes de Eliminar

El atributo hx-confirm en el boton de eliminar muestra un confirm() nativo del navegador:

<button class="btn-delete"
        hx-delete={ fmt.Sprintf("/todos/%d", todo.ID) }
        hx-target={ fmt.Sprintf("#todo-%d", todo.ID) }
        hx-swap="outerHTML"
        hx-confirm="Eliminar esta tarea?">
    X
</button>

Si el usuario cancela, HTMX no envia el request.

Animacion al Eliminar

Para que el item no desaparezca abruptamente, usamos CSS transitions con las clases de HTMX:

.todo-item {
    transition: opacity 0.3s ease, transform 0.3s ease;
}

.todo-item.htmx-swapping {
    opacity: 0;
    transform: translateX(-20px);
}

HTMX agrega la clase htmx-swapping justo antes de hacer el swap. La transicion CSS se ejecuta y luego el elemento se remueve.

Para que la transicion tenga tiempo de ejecutarse, configura el swap delay:

hx-swap="outerHTML swap:300ms"

Nuevas Rutas en main.go

Agregamos las rutas de edicion y vista:

mux.HandleFunc("GET /todos/{id}/edit", h.EditForm)
mux.HandleFunc("GET /todos/{id}/view", h.View)
mux.HandleFunc("PUT /todos/{id}/toggle", h.Toggle)
mux.HandleFunc("PUT /todos/{id}", h.Update)
mux.HandleFunc("DELETE /todos/{id}", h.Delete)

Flujo de Edicion Completo

  1. Usuario hace click en “Editar”
  2. HTMX envia GET /todos/{id}/edit
  3. El servidor devuelve el formulario de edicion
  4. El titulo se convierte en un input editable
  5. El usuario modifica y presiona “Guardar” (o Enter)
  6. HTMX envia PUT /todos/{id} con el nuevo titulo
  7. El servidor devuelve el item actualizado en vista normal

Si presiona Escape o “Cancelar”, HTMX pide GET /todos/{id}/view y restaura la vista original.

Resumen

OperacionVerboRutaSwap
TogglePUT/todos/{id}/toggleouterHTML
Modo edicionGET/todos/{id}/editouterHTML
Cancelar edicionGET/todos/{id}/viewouterHTML
Guardar edicionPUT/todos/{id}outerHTML
EliminarDELETE/todos/{id}outerHTML (vacio)

El patron es siempre el mismo: un atributo HTMX dispara un request, el servidor responde con HTML parcial y HTMX reemplaza el fragmento en el DOM.


<— Capitulo 10: Listar y Crear Tareas | Capitulo 12: Filtros y Busqueda —>