Filtros y Busqueda en Tiempo Real

Por: Artiko
gotemplhtmxproyectotodo-list

La app ya crea, edita y elimina tareas. Ahora agregamos filtros por estado (todos, activos, completados) y busqueda en tiempo real que filtra mientras el usuario escribe. Tambien actualizamos el contador de tareas pendientes con OOB swaps.

Filtros por Estado

Los filtros son botones que piden al servidor la lista filtrada. Cada boton envia un query param diferente.

Componente de Filtros

package views

templ TodoFilters(active string) {
    <div class="todo-filters" id="todo-filters">
        <button class={ "filter-btn", templ.KV("active", active == "all") }
                hx-get="/todos?filter=all"
                hx-target="#todo-list"
                hx-swap="innerHTML">
            Todos
        </button>
        <button class={ "filter-btn", templ.KV("active", active == "active") }
                hx-get="/todos?filter=active"
                hx-target="#todo-list"
                hx-swap="innerHTML">
            Activos
        </button>
        <button class={ "filter-btn", templ.KV("active", active == "completed") }
                hx-get="/todos?filter=completed"
                hx-target="#todo-list"
                hx-swap="innerHTML">
            Completados
        </button>
    </div>
}

La clase active se agrega al boton del filtro actual usando templ.KV.

Actualizar la Vista Principal

Agregamos los filtros y la busqueda a IndexPage:

templ IndexPage(todos []models.Todo) {
    @Layout("Todo List") {
        @TodoForm()
        @SearchBar()
        @TodoFilters("all")
        <div id="todo-list">
            @TodoList(todos)
        </div>
        <div id="todo-count">
            @TodoCount(countPending(todos))
        </div>
    }
}

Handler con Filtro

Modificamos el handler List para aceptar el query param filter:

func (h *TodoHandler) List(w http.ResponseWriter, r *http.Request) {
    filter := r.URL.Query().Get("filter")
    todos := h.store.GetAll()

    filtered := filterTodos(todos, filter)
    views.TodoList(filtered).Render(r.Context(), w)
}

func filterTodos(todos []models.Todo, filter string) []models.Todo {
    if filter == "" || filter == "all" {
        return todos
    }

    var result []models.Todo
    for _, t := range todos {
        switch filter {
        case "active":
            if !t.Completed {
                result = append(result, t)
            }
        case "completed":
            if t.Completed {
                result = append(result, t)
            }
        }
    }
    return result
}

El filtrado es simple: si es “all” o vacio, devolver todo. Si es “active”, solo los no completados. Si es “completed”, solo los completados.

Busqueda en Tiempo Real

La busqueda filtra tareas mientras el usuario escribe, con un delay para no saturar el servidor.

Componente de Busqueda

package views

templ SearchBar() {
    <div class="search-bar">
        <input type="search"
               name="q"
               placeholder="Buscar tareas..."
               hx-get="/todos/search"
               hx-target="#todo-list"
               hx-swap="innerHTML"
               hx-trigger="keyup changed delay:300ms"
               hx-indicator="#search-spinner"
               autocomplete="off"/>
        <span id="search-spinner" class="htmx-indicator">
            Buscando...
        </span>
    </div>
}

Atributos clave:

Handler de Busqueda

func (h *TodoHandler) Search(w http.ResponseWriter, r *http.Request) {
    query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
    todos := h.store.GetAll()

    if query == "" {
        views.TodoList(todos).Render(r.Context(), w)
        return
    }

    var results []models.Todo
    for _, t := range todos {
        if strings.Contains(strings.ToLower(t.Title), query) {
            results = append(results, t)
        }
    }

    views.TodoList(results).Render(r.Context(), w)
}

La busqueda es case-insensitive: convertimos tanto el query como el titulo a minusculas antes de comparar.

Ruta de Busqueda

mux.HandleFunc("GET /todos/search", h.Search)

Contador con OOB Swap

Cuando se agrega, elimina o cambia el estado de una tarea, el contador de pendientes debe actualizarse. Usamos Out of Band (OOB) swaps para actualizar multiples partes del DOM con una sola respuesta.

Como Funciona OOB

Normalmente HTMX pone la respuesta en un solo target. Con hx-swap-oob="true", un elemento dentro de la respuesta se coloca en su posicion por ID, fuera del flujo normal.

Actualizar el Handler Create

func (h *TodoHandler) Create(w http.ResponseWriter, r *http.Request) {
    title := strings.TrimSpace(r.FormValue("title"))
    if title == "" {
        http.Error(w, "El titulo es requerido", http.StatusBadRequest)
        return
    }

    todo := h.store.Add(title)
    todos := h.store.GetAll()
    pending := countPending(todos)

    views.TodoItemWithCount(todo, pending).Render(r.Context(), w)
}

Componente con OOB

package views

import "todo-app/models"

templ TodoItemWithCount(todo models.Todo, pending int) {
    @TodoItem(todo)
    <div id="todo-count" hx-swap-oob="true">
        @TodoCount(pending)
    </div>
}

La respuesta contiene dos cosas:

  1. El TodoItem que va al target normal (#todo-list)
  2. El TodoCount con hx-swap-oob="true" que se coloca en #todo-count

HTMX procesa ambos: inserta el item en la lista y actualiza el contador, todo en un solo request.

Helper countPending en Go

Para usar countPending tanto en templates como en handlers:

func countPending(todos []models.Todo) int {
    count := 0
    for _, t := range todos {
        if !t.Completed {
            count++
        }
    }
    return count
}

Boton Limpiar Completados

Un boton que elimina todas las tareas completadas de un golpe:

package views

templ ClearCompleted() {
    <button class="btn-clear"
            hx-delete="/todos/completed"
            hx-target="#todo-list"
            hx-swap="innerHTML"
            hx-confirm="Eliminar todas las tareas completadas?">
        Limpiar completados
    </button>
}

Handler DELETE /todos/completed

func (h *TodoHandler) ClearCompleted(w http.ResponseWriter, r *http.Request) {
    h.store.DeleteCompleted()
    todos := h.store.GetAll()
    pending := countPending(todos)

    views.TodoListWithCount(todos, pending).Render(r.Context(), w)
}

Metodo DeleteCompleted en el Store

func (s *TodoStore) DeleteCompleted() {
    s.mu.Lock()
    defer s.mu.Unlock()

    var active []Todo
    for _, t := range s.todos {
        if !t.Completed {
            active = append(active, t)
        }
    }
    s.todos = active
}

Respuesta con OOB para el Contador

package views

import "todo-app/models"

templ TodoListWithCount(todos []models.Todo, pending int) {
    @TodoList(todos)
    <div id="todo-count" hx-swap-oob="true">
        @TodoCount(pending)
    </div>
}

Ruta

mux.HandleFunc("DELETE /todos/completed", h.ClearCompleted)

Aplicar OOB a Toggle y Delete

El mismo patron OOB se aplica a toggle y delete para mantener el contador sincronizado:

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
    }

    todos := h.store.GetAll()
    pending := countPending(todos)
    views.TodoItemWithCount(todo, pending).Render(r.Context(), w)
}

Resumen

FeatureAtributo HTMXMecanismo
Filtroshx-get con query paramReemplaza lista completa
Busquedahx-trigger="keyup changed delay:300ms"Debounce automatico
Indicadorhx-indicatorMuestra/oculta spinner
Contadorhx-swap-oob="true"Actualiza fuera del target
Limpiarhx-delete con hx-confirmElimina batch con confirmacion

OOB swaps son una de las caracteristicas mas poderosas de HTMX: permiten actualizar multiples partes de la pagina con una sola respuesta del servidor.


<— Capitulo 11: Editar y Eliminar | Capitulo 13: Estilos y UX —>