Recherche simple en utilisant HTMX & EF Core pour ASP.NET Core
Introduction
Ce n'est qu'un bref article qui s'appuie sur les autres dans la série de recherches complètes comme le rejet de typeahead et Recherche de texte complet Postgres. Dans ce post, Je vous montrerai comment mettre en oeuvre une page de recherche simple à l'aide d'HTMX et EF Core dans une application ASP
Don't J’ai déjà effectué une recherche
Bien oui, dans l'en-tête du site j'ai une fonction de recherche qui fournit typeahead (dans laquelle vous inscrivez les résultats arrivent en temps réelM SK2 Cependant je cache cela dans le mode mobile et je voulais aussi être en mesure d'établir un lien entre les résultats de la recherche (comme /search/umami) à une page de recherche dédiée. Cela permet d'améliorer l'expérience des utilisateurs ainsi que le travail sur les dispositifs mobiles .
Service de recherche
Pour ce faire, j’ai modifié la façon dont je fais mes recherches. J’ai créé un BlogSearchService, c’est basé sur mes deux méthodes de recherche du texte complet. Ces dernières doivent malheureusement être réparties en deux, étant donné la façon dont les demandes sont structurées avec l’extension Postgres de Recherche du texte entier , EF.Functions.WebSearchToTsQuery("english", processedQuery) et EF.Functions.ToTsQuery("english", query + ":*").
Le premier prend les termes de recherche appropriés et le deuxième les recherches à la carte sauvage.
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
}
Encore une fois, ils utilisent mon pré- SearchVector column qui est mis à jour lors de la création post et de l’actualisation. Ce dernier est créé dans mon DbContext à l’aide du OnModelCreating méthode.
entity.Property(b => b.SearchVector)
.HasComputedColumnSql("to_tsvector('english', coalesce(\"Title\", '') || ' ' || coalesce(\"PlainTextContent\", ''))", stored: true);
entity.HasIndex(b => b.SearchVector)
.HasMethod("GIN");
Encore une fois, l’inconvénient de cette approche est qu’elle ne fonctionne que pour l’anglais comme :
J'utilise ensuite ces méthodes dans mon BlogSearchService pour retourner les résultats basés sur la demande de recherche.
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
};
}
J'utilise la simple vérification pour déterminer s'il y a des espaces dans la demande afin de déterminer quelle méthode d'appeler.
Contrôleur de recherche
Le contrôleur de recherche suit le modèle que j’utilise pour la plupart de mes contrôleurs, où il détecte si l’appel provient d’HTMX ou non afin de permettre l’envoi de la page partielle ou entière du layout (ce qui signifie qu’il fonctionne aussi bien pour la navigation directe que pour les demandes 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);
}
}
Il s’agit de l’ensemble du contrôleur, vous pouvez voir que j’ai deux Actions, une qui retourne la page ( optionnellement remplie avec les résultatsM SK3 et l’autre qui retournait uniquement les résultats pour les demandes HTMXMSC4
if (Request.IsHtmx()) return PartialView("_SearchResultsPartial", searchModel.SearchResults);
Résultats de recherche Partiel
Vous pouvez voir que cela retourne optionnellement le _SearchResultsPartial voir si la demande est une demande HTMX pour les résultats.
Il s’agit d’une vue initiale assez simple qui comporte des bits de messagerie et les résultats.
@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>
Vue partielle de la liste d'affichage
J'utilise le même _ListPost vue partielle là où je dois faire la liste des postes.
@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 et la page de recherche
Encore une fois, mon utilisation de HTMX ici est assez simple. J'ai simplement attaché le bouton ( cette fois j'ai décidé de ne pas changer l'entrée sur keyup / modifier l'URLM SK3 et envoyer la demande lorsque le bouton est appuyé
I Inclure la demande dans la demande en utilisant hx-include et cibler les #content div pour remplacer les résultats.
<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>
Mise à jour
Ainsi, en suivant quelques commentaires de Khalid J'ai décidé d'améliorer cette fonctionnalité pour permettre la recherche à être:
- Triggered on Enter key press
- Trigger lors de l'entrée de plus de deux caractères (soit devient un typeahead linéaire
À l’avenir, j’ai besoin d’ajouter la fonctionnalité de taille de page à nouveau dans; c’est un biit d’un hack et il faut soutenir d’autres exigences.
Formulaire de recherche actualisé
Pour ce faire, j'ai d'abord emballé l'entrée dans un formulaire et utilisé Alpine.js pour soumettre le formulaire lorsqu'un utilisateur type.
Vous pouvez voir que j'utilise x-data pour créer une variable réactive pour la demande et ensuite je vérifie la longueur de la demande afin de déterminer si de soumettre le formulaire.
<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>
Pour réutiliser la même action de contrôleur, j'ai également pagerequest header pour indiquer que c'est une demande pagenée.
J'utilise également le Alpine.js x-on:keydown.enter.prevent pour déclencher le bouton à cliquer lorsque la touche Enter est pressée et un déblocage sur l'entrée afin de prévenir trop de demandes.
Mise à jour du contrôleur
Dans le contrôleur, j'ai supprimé l'action SearchResults et je lui ai ajouté plus de 'intelligence' au principal. Search action pour traiter à la fois la recherche initiale et les demandes paged.
Vous pouvez voir ici que j'ajoute un paramètre supplémentaire appelé pagerequest pour déterminer si cette demande est pagenée ou non et indiquer que celle-ci devrait être remplie à partir de la collection des titres.
[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);
}
Je reçois ensuite les résultats et je détecte cet en-tête pour déterminer quelle vue / partialview à retourner.
En outre, j’ai ajouté une action séparée pour traiter les /search/umami pour rediriger à la page principale de recherche avec la demande.
[HttpGet]
[Route("{query}")]
public IActionResult InitialSearch([FromRoute] string query)
{
return RedirectToAction("Search", new { query });
}
En conclusion
C'est donc une mise en oeuvre directe d'une page de recherche utilisant HTMX et EF Core dans une application ASP.