Búsqueda sencilla utilizando HTMX & EF Core para ASP.NET Core (Español (Spanish))

Búsqueda sencilla utilizando HTMX & EF Core para ASP.NET Core

Comments

NOTE: Apart from English (and even then it's questionable, I'm Scottish). These are machine translated in languages I don't read. If they're terrible please contact me.
You can see how this translation was done in this article.

Tuesday, 17 September 2024

//

9 minute read

Introducción

Este es sólo un artículo rápido, ya que se basa en los otros en la serie de búsqueda de texto completo, como el tipoaheaddropdown y Búsqueda de texto completo de Postgres. En este post, te mostraré cómo implementar una página de búsqueda simple usando HTMX y EF Core en una aplicación ASP.NET Core.

¿No tengo ya una búsqueda?

Bueno sí, en la cabecera del sitio tengo una función de búsqueda que proporciona typeahead (donde a medida que escribe los resultados vienen en tiempo real). Sin embargo, oculto que en modo móvil y también quería ser capaz de vincular los resultados de búsqueda (como /search/umami) a una página de búsqueda dedicada. Esto proporciona una mejor experiencia de usuario, así como trabajar en dispositivos móviles.

Servicio de búsqueda

Para hacer esto modifiqué cómo hice mis búsquedas. He creado un BlogSearchService, esto se basa en mis dos métodos de consulta de texto completo. Estos desafortunadamente necesitan ser divididos en dos métodos debido a la forma en que las consultas se estructuran con las extensiones de búsqueda de texto completo de Postgres, EF.Functions.WebSearchToTsQuery("english", processedQuery) y EF.Functions.ToTsQuery("english", query + ":*").

El primero toma los términos de búsqueda adecuados y el segundo toma las búsquedas de comodines.

    private IQueryable<BlogPostEntity> QueryForSpaces(string processedQuery)
    {
        return context.BlogPosts
            .Include(x => x.Categories)
            .Include(x => x.LanguageEntity)
            .AsNoTrackingWithIdentityResolution()
            .Where(x =>
                // Search using the precomputed SearchVector
                (x.SearchVector.Matches(EF.Functions.WebSearchToTsQuery("english",
                     processedQuery)) // Use precomputed SearchVector for title and content
                 || x.Categories.Any(c =>
                     EF.Functions.ToTsVector("english", c.Name)
                         .Matches(EF.Functions.WebSearchToTsQuery("english", processedQuery)))) // Search in categories
                && x.LanguageEntity.Name == "en") // Filter by language
            .OrderByDescending(x =>
                // Rank based on the precomputed SearchVector
                x.SearchVector.Rank(EF.Functions.WebSearchToTsQuery("english",
                    processedQuery)));
    }

    private IQueryable<BlogPostEntity> QueryForWildCard(string query)
    {
        return context.BlogPosts
            .Include(x => x.Categories)
            .Include(x => x.LanguageEntity)
            .AsNoTrackingWithIdentityResolution()
            .Where(x =>
                // Search using the precomputed SearchVector
                (x.SearchVector.Matches(EF.Functions.ToTsQuery("english",
                     query + ":*")) // Use precomputed SearchVector for title and content
                 || x.Categories.Any(c =>
                     EF.Functions.ToTsVector("english", c.Name)
                         .Matches(EF.Functions.ToTsQuery("english", query + ":*")))) // Search in categories
                && x.LanguageEntity.Name == "en") // Filter by language
            .OrderByDescending(x =>
                // Rank based on the precomputed SearchVector
                x.SearchVector.Rank(EF.Functions.ToTsQuery("english",
                    query + ":*"))); // Use precomputed SearchVector for ranking
    }

De nuevo estos usan mi precomputado SearchVector columna que se actualiza en la creación y actualización de post. Esto es creado en mi DbContext utilizando la OnModelCreating método.

      entity.Property(b => b.SearchVector)
                .HasComputedColumnSql("to_tsvector('english', coalesce(\"Title\", '') || ' ' || coalesce(\"PlainTextContent\", ''))", stored: true);
            
           entity.HasIndex(b => b.SearchVector)
                .HasMethod("GIN");

Una vez más, el inconveniente de este enfoque es que sólo funciona para el inglés tal y como es. Necesitaría una reconstrucción radical de la base de datos para que funcione para otros idiomas (probablemente una tabla para cada idioma).

Entonces uso estos métodos en mi BlogSearchService para devolver los resultados basados en la consulta de búsqueda.

    public async Task<PostListViewModel> GetPosts(string? query, int page = 1, int pageSize = 10)
    {
        if(string.IsNullOrEmpty(query))
        {
            return new PostListViewModel();
        }
        IQueryable<BlogPostEntity> blogPostQuery = query.Contains(" ") ? QueryForSpaces(query) : QueryForWildCard(query);
        var totalPosts = await blogPostQuery.CountAsync();
        var results = await blogPostQuery
            .Select(x => x.ToListModel())
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync();
        
        return new PostListViewModel()
        {
            Posts = results,
            TotalItems = totalPosts,
            Page = page,
            PageSize = pageSize
        };
        
    }

Utilizo la simple comprobación de si hay espacios en la consulta para determinar qué método llamar.

Controlador de búsqueda

El controlador de búsqueda sigue el patrón que utilizo para la mayoría de mis controladores donde detecta si la llamada proviene de HTMX o no para permitir el envío de la página de diseño parcial o completo (lo que significa que funciona para la navegación directa, así como las solicitudes de HTMX).

[Route("search")]
public class SearchController(
    BaseControllerService baseControllerService,
    BlogSearchService searchService,
    ILogger<SearchController> logger)
    : BaseController(baseControllerService, logger)
{
    [HttpGet]
    [Route("{query?}")]
    public async Task<IActionResult> Search([FromRoute] string? query)
    {
        var searchResults = await searchService.GetPosts(query);
        var searchModel = new SearchResultsModel
        {
            Query = query,
            SearchResults = searchResults
        };
        searchModel = await PopulateBaseModel(searchModel);
        searchModel.SearchResults.LinkUrl = Url.Action("SearchResults", "Search");
        if (Request.IsHtmx()) return PartialView("SearchResults", searchModel);
        return View("SearchResults", searchModel);
    }

    [HttpGet]
    [Route("results")]
    public async Task<IActionResult> SearchResults([Required] string query, int page = 1, int pageSize = 10)
    {
        var searchResults = await searchService.GetPosts(query, page, pageSize);
        var searchModel = new SearchResultsModel
        {
            Query = query,
            SearchResults = searchResults
        };
        searchModel = await PopulateBaseModel(searchModel);
        searchModel.SearchResults.LinkUrl = Url.Action("SearchResults", "Search");
        if (Request.IsHtmx()) return PartialView("_SearchResultsPartial", searchModel.SearchResults);
        return View("SearchResults", searchModel);
    }
}

Este es todo el controlador, se puede ver que tengo dos Acciones, una que devuelve la página (opcionalmente poblada con resultados) y una que devuelve sólo los resultados para las solicitudes HTMX.

 if (Request.IsHtmx()) return PartialView("_SearchResultsPartial", searchModel.SearchResults);

Resultados de la búsqueda Parcial

Puede ver que esto devuelve opcionalmente el _SearchResultsPartial ver si la solicitud es una solicitud HTMX de resultados.

Esta es una vista paritial bastante simple que tiene bits de paginación y los resultados.

@model Mostlylucid.Models.Blog.PostListViewModel
<div class="pt-2" id="content">
    @if (Model.Posts?.Any() is true)
    {
        <div class="inline-flex w-full items-center justify-center print:!hidden">
            @if (Model.TotalItems > Model.PageSize)
            {
                <pager
                    x-ref="pager"
                    link-url="@Model.LinkUrl"
                    hx-boost="true"
                    hx-target="#content"
                    hx-swap="show:none"
                    page="@Model.Page"
                    page-size="@Model.PageSize"
                    total-items="@Model.TotalItems"
                    hx-headers='{"pagerequest": "true"}'>
                </pager>
            }
            <partial name="_Pager" model="Model"/>

        </div>
        @foreach (var post in Model.Posts)
        {
            <partial name="_ListPost" model="post"/>
        }
    }
</div>

Vista parcial de la publicación de la lista

Yo uso lo mismo. _ListPost vista parcial donde sea que necesite listar los posts.

@model Mostlylucid.Models.Blog.PostListModel

<div class="border-b border-grey-lighter pb-8 mb-8">
 
    <a asp-controller="Blog" asp-action="Show" hx-boost="true"  hx-swap="show:window:top"  hx-target="#contentcontainer" asp-route-slug="@Model.Slug"
       class="block font-body text-lg font-semibold transition-colors hover:text-green text-blue-dark dark:text-white  dark:hover:text-secondary">@Model.Title</a>  
    <div class="flex flex-wrap space-x-2 items-center py-4 print:!hidden">
    @foreach (var category in Model.Categories)
    {
        <partial name="_Category" model="category"/>
    }
    @{ var languageModel = (Model.Slug, Model.Languages, Model.Language); }
        <partial name="_LanguageList" model="languageModel"/>
    </div>
    <div class="block font-body text-black dark:text-white">@Model.Summary</div>
    <div class="flex items-center pt-4">
        <p class="pr-2 font-body font-light text-primary light:text-black dark:text-white">
            @Model.PublishedDate.ToString("f")
        </p>
        <span class="font-body text-grey dark:text-white">//</span>
        <p class="pl-2 font-body font-light text-primary light:text-black dark:text-white">
            @Model.ReadingTime
        </p>
    </div>
</div>

HTMX y la página de búsqueda

De nuevo mi uso de HTMX aquí es bastante simple. Simplemente me engancho en el botón (esta vez decidí NO cambiar la entrada en la tecla / cambiar la URL) y enviar la solicitud cuando se hace clic en el botón. Incluyo la consulta en la solicitud utilizando hx-include y el objetivo de la #content div para reemplazar los resultados.

<div class="flex items-center gap-2 bg-neutral-500 bg-opacity-10 p-2 rounded-lg">
    <button
        hx-get="@Url.Action("SearchResults", "Search")"
        hx-target="#content"
        hx-include="[name='query']"
        hx-swap="outerHTML"
        class="btn btn-outline btn-sm flex items-center gap-2 text-black dark:text-white">
        Search
        <i class="bx bx-search text-lg"></i>
    </button>
    <input
        type="text"
        placeholder="Search..."
        value="@Model.Query"
        name="query"
        class="input input-sm border-0 grow text-black dark:text-white bg-transparent focus:outline-none"/>

</div>

Actualizar

Así que siguiendo algunos comentarios de Khalid Decidí mejorar esta funcionalidad para permitir que la búsqueda fuera:

  1. Activado en la tecla Intro
  2. Activado al introducir más de dos caracteres (así se convierte en un mecanografiado suelto).

En el futuro tengo que añadir la funcionalidad de tamaño de página de nuevo en; es un biit de un hack y necesita para soportar más requisitos.

Formulario de búsqueda actualizado

Para hacer esto primero envolví la entrada en un formulario y usé Alpine.js para enviar el formulario cuando un usuario está escribiendo. Puedes ver que uso x-data para crear una variable reactiva para la consulta y luego comprobar la longitud de la consulta para determinar si se debe enviar el formulario.

<form
    x-data="{ query: '@Model.Query', checkSubmit() { if (this.query.length > 2) { $refs.searchButton.click(); } } }"
    class="flex items-center gap-2 bg-neutral-500 bg-opacity-10 p-2 rounded-lg"
    action="@Url.Action("Search", "Search")"
    hx-push-url="true"
    hx-boost="true"
    hx-target="#content"
    hx-swap="outerHTML show:window:top"
    hx-headers='{"pagerequest": "true"}'>
    <button
        type="submit"
        x-ref="searchButton"
        class="btn btn-outline btn-sm flex items-center gap-2 text-black dark:text-white">
        Search
        <i class="bx bx-search text-lg"></i>
    </button>
    <input
        type="text"
        placeholder="Search..."
        name="query"
        value="@Model.Query"
        x-model="query"
        x-on:input.debounce.200ms="checkSubmit"
        x-on:keydown.enter.prevent="$refs.searchButton.click()"
        class="input input-sm border-0 grow text-black dark:text-white bg-transparent focus:outline-none"
    />
</form>

Con el fin de reutilizar la misma acción controladora también se establece la pagerequest encabezado para indicar que se trata de una solicitud paginada.

También uso el Alpine.js x-on:keydown.enter.prevent para activar el botón haga clic cuando se presiona la tecla Intro y un debunce en la entrada para evitar demasiadas peticiones.

Actualización del controlador

En el controlador he eliminado la acción SearchResults y en su lugar he añadido más 'inteligencia' a la principal Search acción para manejar tanto la búsqueda inicial como las peticiones paginadas.

Aquí puedes ver que añado un parámetro extra llamado pagerequest para determinar si se trata de una solicitud paginada o no e indicar que esta debe estar poblada a partir de la colección de cabeceras.


    [HttpGet]
    [Route("")]
    public async Task<IActionResult> Search(string? query, int page = 1, int pageSize = 10,[FromHeader] bool pagerequest=false)
    {
        var searchResults = await searchService.GetPosts(query, page, pageSize);
        var searchModel = new SearchResultsModel
        {
            Query = query,
            SearchResults = searchResults
        };
        searchModel = await PopulateBaseModel(searchModel);
        var linkUrl = Url.Action("Search", "Search");
        searchModel.SearchResults.LinkUrl = linkUrl;
        if(pagerequest && Request.IsHtmx()) return PartialView("_SearchResultsPartial", searchModel.SearchResults);
        
        if (Request.IsHtmx()) return PartialView("SearchResults", searchModel);
        return View("SearchResults", searchModel);
    }

Entonces obtengo los resultados y detecto este encabezado para determinar qué vista / vista parcial volver.

Además añadí una acción separada jubilada para manejar los gustos de /search/umami para redirigir a la página principal de búsqueda con la consulta.

   [HttpGet]
    [Route("{query}")]
    public  IActionResult InitialSearch([FromRoute] string query)
    {
        return RedirectToAction("Search", new { query });
    }

Conclusión

Así que, bastante simple, ¿verdad? Esta es una implementación sencilla de una página de búsqueda usando HTMX y EF Core en una aplicación ASP.NET Core. Puede ampliar esto fácilmente para incluir más funciones como filtrado, clasificación o incluso integración con otros servicios de búsqueda. La clave para llevar es cómo aprovechar HTMX para una experiencia de usuario suave mientras mantiene la lógica de backend limpia y eficiente.

logo

©2024 Scott Galloway