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
Enkel sökning med HTMX & EF Core för ASP.NET Core
Tuesday, 17 September 2024
Inleiding
Det här är bara en snabb artikel som bygger på de andra i hela sökserien som typahead dropdown och Postgres fulltextsökning. I det här läget kommer jag att visa er hur man implementerar en enkel söksida med hjälp av HTMX och EF Core i ett ASP-program.
Don't Jag har redan en sökning
Ja, i titeln på hemsidan har jag en sökfunktion som ger dig typhead ( där du skriver och resultaten kommer i realtid | ). | Men jag gömmer det i mobila medier och jag ville också kunna länka till sökresultaten /sökning/umami) till en dedikerad söksida. Det ger en bättre användarupplevelse såväl som att kunna arbeta på mobila enheter .
söktjänster
För att göra detta ändrade jag hur jag gjorde mina sökningar. Jag skapade en BlogSearchService, det här är baserat på mina två fulltext sökmetoder . De måste tyvärr delas upp i två metoder eftersom querierna är strukturerade med Postgres Full Text Search-extensioner EF.Functions.WebSearchToTsQuery("english", processedQuery) och EF.Functions.ToTsQuery("english", query + ":*").
Den första tar rätt sökord och den andra tar wildcards sökningar.
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
}
Återigen använder dessa mina förutberäknade SearchVector column som är uppdaterad på postkreation och update. Det här är skapat i min DbContext med hjälp av OnModelCreating metod.
entity.Property(b => b.SearchVector)
.HasComputedColumnSql("to_tsvector('english', coalesce(\"Title\", '') || ' ' || coalesce(\"PlainTextContent\", ''))", stored: true);
entity.HasIndex(b => b.SearchVector)
.HasMethod("GIN");
En annan nackdel med den här metoden är att den bara fungerar för engelska som
Jag använder sedan dessa metoder i BlogSearchService att ge tillbaka resultaten baserat på sökfrågan.
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
};
}
Jag använder den enkla checken för att se om det finns spationer i sökningen för att avgöra vilken metod man ska ringa.
sökandekontroller
Den sökkontrollen följer mönstret jag använder för de flesta av mina kontroller där den upptäcker om samtalet kommer från HTMX eller inte för att möjliggöra att skicka antingen den partiella eller hela layoutsidan ( vilket betyder att den fungerar både för direkt navigering och HT MX- begärningar).
[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);
}
}
Det här är hela kontrollören.
if (Request.IsHtmx()) return PartialView("_SearchResultsPartial", searchModel.SearchResults);
Resultate delvis
Ni kan se att detta optionellt återvänder _SearchResultsPartial se om begäran är en HTMX- begäran efter resultat.
Det här är en ganska enkel initiala bild som har pipande bitar och resultat.
@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>
Lys postpartiell bild
Jag använder samma _ListPost partiell syn var jag behöver lista inträg.
@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 och sökandets sida
Återigen är mitt användande av HTMX här ganska enkelt.
Jag inkluderar queryn i begäran med hx-include och rikta #content div för att ersätta resultaten.
<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>
uppdatera
Så jag följer några kommentarer från Khalid Jag bestämde mig för att förbättra denna funktion för att möjliggöra att sökandet skulle vara
- Triggerat när man trycker på Enter
- Triggeras när du kommer in på mer än två tecken ( så blir det en luss typandel ).
I framtiden behöver jag lägga till sidans storleksfunktionalitet tillbaka i:
Utvecklingsform
För att göra det så ompackade jag först inmatningen i ett form och använde Alpine.js för att skicka informen när en användare skriver
Ni kan se att jag använder x-data att skapa en reaktiv variabel för query och sen kolla jag lengthen av queryn för att avgöra om man ska skicka in formen.
<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>
För att återanvända samma kontrollaktion så har jag också pagerequest header för att indikera att det här är en paginad begäran.
Jag använder också Alpine.js x-on:keydown.enter.prevent för att slå på knappen när Enter-knappen trycks och ett avtryck på intrycket för att förhindra för många begäran.
Regleruppdatering
I kontrollen tog jag bort SearchResults-aktionen och lade istället mer 'intelligence' till huvuden Search åtgärd för att hantera både den första sökandet och de paginade begäran.
Här kan ni se att jag lägger till en extra parameter som kallas pagerequest för att avgöra om det här är en paginad begäran eller inte och påpeka att den borde fyllas i från huvudets samling.
[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);
}
Jag får sedan resultaten och upptäcker den här headern för att avgöra vilket synsätt / partialview skall återfå
Jag lade till med en separat pensionerad handling för att hantera /search/umami att redirectera till den stora söksidan med query.
[HttpGet]
[Route("{query}")]
public IActionResult InitialSearch([FromRoute] string query)
{
return RedirectToAction("Search", new { query });
}
I slutsats
Det här är en enkel implementation av en söksida som använder HTMX och EF Core i ett ASP-program.