This is a viewer only at the moment see the article on how this works.
To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk
This is a preview from the server running through my markdig pipeline
Ce n'est qu'un article rapide car il s'appuie sur les autres dans la série complète de recherche de texte tels que le typeahead déroulant et Postgres recherche texte complet. Dans ce post, je vais vous montrer comment implémenter une page de recherche simple en utilisant HTMX et EF Core dans une application ASP.NET Core.
Eh bien oui, dans l'en-tête du site j'ai une fonction de recherche qui fournit typeahead (où lorsque vous tapez les résultats viennent en temps réel). Cependant, je cache qu'en mode mobile et je voulais aussi pouvoir lier les résultats de la recherche (comme /recherche/umami) à une page de recherche dédiée. Cela donne une meilleure expérience utilisateur ainsi que de travailler sur les appareils mobiles.
Pour ce faire, j'ai modifié la façon dont j'ai fait mes recherches. J'ai créé un BlogSearchService
, ceci est basé sur mes deux méthodes de requête Full Text. Ceux-ci doivent malheureusement être divisés en deux méthodes en raison de la façon dont les requêtes sont structurées avec les extensions Postgres Full Text Search, EF.Functions.WebSearchToTsQuery("english", processedQuery)
et EF.Functions.ToTsQuery("english", query + ":*")
.
Le premier prend des termes de recherche appropriés et le second prend des recherches wildcard.
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, ceux-ci utilisent mon précalculé SearchVector
colonne qui est mise à jour après la création et la mise à jour. Ceci est créé dans mon DbContext
à l'aide de la 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 tel qu'il est. J'aurais besoin d'une reconstruction radicale de la base de données pour la faire fonctionner pour d'autres langues (probablement une table pour chaque langue).
J'utilise ensuite ces méthodes dans mon BlogSearchService
pour retourner les résultats en fonction de la 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 savoir s'il y a des espaces dans la requête pour déterminer quelle méthode appeler.
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 vient de HTMX ou non pour permettre l'envoi de la page de mise en page partielle ou complète (ce qui signifie qu'il fonctionne pour la navigation directe ainsi que les requêtes 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);
}
}
C'est l'ensemble du contrôleur, vous pouvez voir que j'ai deux Actions, une qui renvoie la page (facultativement remplie avec des résultats) et une qui renvoie juste les résultats pour les requêtes HTMX.
if (Request.IsHtmx()) return PartialView("_SearchResultsPartial", searchModel.SearchResults);
Vous pouvez voir que cette option renvoie le _SearchResultsPartial
voir si la demande est une demande de résultats HTMX.
Il s'agit d'une vue paritiale assez simple qui a des bits de pagination 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>
J'utilise la même chose. _ListPost
vue partielle où j'ai besoin d'énumérer les messages.
@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>
Encore une fois, mon utilisation de HTMX ici est assez simple. Je m'accroche simplement au bouton (cette fois, j'ai décidé de NE PAS modifier l'entrée sur le keyup / changer l'URL) et d'envoyer la demande lorsque le bouton est cliqué.
J'inclus la requête dans la requête en utilisant hx-include
et de cibler la #content
div 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>
Donc, après quelques commentaires de Khalid J'ai décidé d'améliorer cette fonctionnalité pour que la recherche soit :
À l'avenir, j'ai besoin d'ajouter la fonctionnalité de taille de page à nouveau; c'est un biit d'un hack et doit supporter d'autres exigences.
Pour ce faire, j'ai d'abord emballé l'entrée dans un formulaire et utilisé Alpine.js pour soumettre le formulaire quand un utilisateur tape.
Vous pouvez voir que j'utilise x-data
pour créer une variable réactive pour la requête, puis je vérifie la longueur de la requête pour déterminer s'il faut 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>
Afin de réutiliser la même action de contrôleur, j'ai également défini le pagerequest
en-tête pour indiquer qu'il s'agit d'une requête paginée.
J'utilise aussi le Alpine.js x-on:keydown.enter.prevent
pour déclencher le bouton cliquez lorsque la touche Entrée est pressée et un débonflage sur l'entrée pour éviter un trop grand nombre de requêtes.
Dans le contrôleur, j'ai supprimé l'action SearchResults et j'ai plutôt ajouté plus 'intelligence' au principal Search
action pour gérer à la fois la recherche initiale et les requêtes paginées.
Ici vous pouvez voir que j'ajoute un paramètre supplémentaire appelé pagerequest
pour déterminer s'il s'agit d'une demande paginée ou non et indiquer qu'elle devrait être remplie à partir de la collection d'en-têtes.
[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 détecte cet en-tête pour déterminer quelle vue / vue partielle retourner.
J'ai ajouté une action de retraite séparée pour gérer les semblables de /search/umami
pour rediriger vers la page de recherche principale avec la requête.
[HttpGet]
[Route("{query}")]
public IActionResult InitialSearch([FromRoute] string query)
{
return RedirectToAction("Search", new { query });
}
Donc, c'est assez simple, n'est-ce pas? Il s'agit d'une implémentation simple d'une page de recherche utilisant HTMX et EF Core dans une application ASP.NET Core. Vous pouvez facilement l'étendre pour inclure d'autres fonctionnalités comme le filtrage, le tri ou même l'intégration avec d'autres services de recherche. La clé à retenir est de savoir comment utiliser HTMX pour une expérience utilisateur fluide tout en maintenant la logique du moteur propre et efficace.