Links
Support from me and my employers ClerksWell
https://www.clerkswell.com/contact/
GitHub Repo
🐙 GitHub
https://github.com/prjseal/Umbraco-13-Series/
📝 Guest Book
https://github.com/prjseal/Umbraco-13-Series/issues/1
Get Help
💬 Discord
🗣️ Facebook Group
https://www.facebook.com/groups/umbracowebdevs
🐘 Mastodon
https://umbracocommunity.social/
🗨️ Umbraco Forum
https://our.umbraco.com/forum/
☕Buy me a coffee
Code
SearchFormViewComponent.cs
using Freelancer.Models.Search;
using Microsoft.AspNetCore.Mvc;
namespace Freelancer.Components;
[ViewComponent(Name = "SearchForm")]
public class SearchFormViewComponent : ViewComponent
{
public IViewComponentResult Invoke(SearchRequestModel model)
{
return View(model);
}
}
PaginationViewComponent.cs
using Freelancer.Models.Search;
using Freelancer.Models.ViewModels;
using Microsoft.AspNetCore.Mvc;
namespace Freelancer.Components;
[ViewComponent(Name = "Pagination")]
public class PaginationViewComponent : ViewComponent
{
public IViewComponentResult Invoke(PaginationViewModel model)
{
model ??= new PaginationViewModel();
return View(model);
}
}
SearchResultsViewComponent.cs
using Freelancer.Models.Search;
using Microsoft.AspNetCore.Mvc;
namespace Freelancer.Components;
[ViewComponent(Name = "SearchResults")]
public class SearchResultsViewComponent : ViewComponent
{
public IViewComponentResult Invoke(SearchResponseModel model)
{
return View(model);
}
}
IndexComposer.cs
using Freelancer.Search;
using Umbraco.Cms.Core.Composing;
namespace Freelancer.Composers;
public class IndexComposer : ComponentComposer<IndexComponent>
{
}
RegisterNotificationsComposer.cs
using Freelancer.Notifications;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.Notifications;
namespace Freelancer.Composers;
public class RegisterNotificationsComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
{
builder.AddNotificationHandler<ContentSavingNotification, ContentSavingNotificationHandler>();
}
}
FreelancerConfig.cs
namespace Freelancer.Configuration;
public class FreelancerConfig
{
public const string SectionName = "Freelancer";
public EmailSettings? EmailSettings { get; set; }
public SearchSettings? SearchSettings { get; set; }
}
public class EmailSettings
{
public string? From { get; set; }
public string? To { get; set; }
}
public class SearchSettings
{
public int PageSize { get; set; }
}
SearchPageController.cs
using Freelancer.Configuration;
using Freelancer.Extensions;
using Freelancer.Helpers;
using Freelancer.Models.ContentModels;
using Freelancer.Models.Search;
using Freelancer.Models.ViewModels;
using Freelancer.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Web.Common.Controllers;
namespace Freelancer.Controllers.Render;
public class SearchPageController(
ILogger<RenderController> logger,
ICompositeViewEngine compositeViewEngine,
IUmbracoContextAccessor umbracoContextAccessor,
IHttpContextAccessor httpContextAccessor,
ISearchService searchService,
IOptions<FreelancerConfig> freelancerConfig)
: RenderController(logger, compositeViewEngine, umbracoContextAccessor)
{
private readonly FreelancerConfig _freelancerConfig = freelancerConfig.Value;
public override IActionResult Index()
{
var httpContext = httpContextAccessor.HttpContext;
var query = httpContext?.Request.Query[Constants.QueryStrings.Query];
var page = httpContext?.Request.Query[Constants.QueryStrings.Page];
var tags = httpContext?.Request.Query[Constants.QueryStrings.Tags];
if (CurrentPage == null) return BadRequest();
var allTags = CurrentPage.GetPageTagsSelectList();
var pageSize = _freelancerConfig?.SearchSettings?.PageSize ?? Constants.Search.DefaultPageSize;
var searchRequest = new SearchRequestModel(query, page, pageSize, tags, allTags);
var searchResponse = searchService.Search(searchRequest);
var pagination = new PaginationViewModel
{
TotalResults = searchResponse.TotalResultCount,
TotalPages = (int)Math.Ceiling((double)(searchResponse.TotalResultCount / searchRequest.PageSize)),
ResultsPerPage = searchRequest.PageSize,
CurrentPage = searchRequest.Page,
PaginationUrlFormat = PaginationHelper.GetPaginationUrlFormat(Request.Path, Request?.QueryString.ToString(), page)
};
var model = new SearchPageContentModel(CurrentPage)
{
SearchRequest = searchRequest,
SearchResponse = searchResponse,
Pagination = pagination
};
return CurrentTemplate(model);
}
}
PublishedContentExtensions.cs
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace Freelancer.Extensions;
public static class PublishedContentExtensions
{
public static HomePage? GetHomePage(this IPublishedContent publishedContent)
{
return publishedContent.AncestorOrSelf<HomePage>();
}
public static SiteSettings? GetSiteSettings(this IPublishedContent publishedContent)
{
var homePage = GetHomePage(publishedContent);
return homePage?.FirstChild<SiteSettings>();
}
public static string GetMetaTitleOrName(this IPublishedContent publishedContent, string? metaTitle)
{
if (!string.IsNullOrWhiteSpace(metaTitle)) return metaTitle;
return publishedContent.Name;
}
public static string? GetSiteName(this IPublishedContent publishedContent)
{
var homePage = publishedContent.GetHomePage();
if (homePage == null) return null;
var siteSettings = homePage.GetSiteSettings();
if (siteSettings == null) return null;
return siteSettings?.SiteName ?? null;
}
public static IEnumerable<SelectListItem>? GetPageTagsSelectList(this IPublishedContent publishedContent)
{
IEnumerable<SelectListItem>? allTags = null;
var siteSettings = publishedContent.GetSiteSettings();
if (siteSettings == null) return null;
var pageTagsContainer = siteSettings.FirstChildOfType(PageTags.ModelTypeAlias);
if (pageTagsContainer?.Children != null && pageTagsContainer.Children.Any())
{
var pageTags = pageTagsContainer.Children.Select(x => x as PageTag).Where(y => y != null);
allTags = pageTags.Select(x => new SelectListItem() { Text = x!.Name, Value = x.TagAlias });
}
return allTags;
}
}
StringArrayExtensions.cs
using Examine;
using Examine.Search;
namespace Freelancer.Extensions;
public static class StringArrayExtensions
{
public static IExamineValue[]? Fuzzy(this string[] terms, float fuzziness = 0.5f)
{
if (terms == null) return null;
List<IExamineValue> values = [];
foreach (var item in terms)
{
values.Add(item.Fuzzy(fuzziness));
}
return [.. values];
}
public static IExamineValue[]? Boost(this string[] terms, float boost)
{
if (terms == null) return null;
List<IExamineValue> values = [];
foreach (var item in terms)
{
values.Add(item.Boost(boost));
}
return [.. values];
}
public static IExamineValue[]? MultipleCharacterWildcard(this string[] terms)
{
if (terms == null) return null;
List<IExamineValue> values = [];
foreach (var item in terms)
{
values.Add(item.MultipleCharacterWildcard());
}
return [.. values];
}
}
PaginationHelper.cs
using System.Net;
using Microsoft.AspNetCore.WebUtilities;
namespace Freelancer.Helpers;
public static class PaginationHelper
{
public static string? GetPaginationUrlFormat(PathString path, string? queryString, string? page)
{
var nameValues = QueryHelpers.ParseQuery(queryString);
if (!string.IsNullOrWhiteSpace(page))
{
nameValues.Remove(Constants.QueryStrings.Page);
nameValues.Add(Constants.QueryStrings.Page, "{0}");
}
else
{
nameValues.Add(Constants.QueryStrings.Page, "{0}");
}
return WebUtility.UrlDecode(QueryHelpers.AddQueryString(path, nameValues));
}
}
SearchRequestModel.cs
using Microsoft.AspNetCore.Mvc.Rendering;
namespace Freelancer.Models.Search;
public class SearchRequestModel
{
public string? Query { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
public int Skip => Page > 1 ? (Page - 1) * PageSize : 0;
public IEnumerable<SelectListItem>? AllTags { get; set; }
public string? SelectedTags { get; set; }
public SearchRequestModel(string? query, string? page, int pageSize, string? selectedTags, IEnumerable<SelectListItem>? allTags)
{
Query = query;
if (int.TryParse(page, out int pageNumber) && pageNumber > 0)
{
Page = pageNumber;
}
PageSize = pageSize;
SelectedTags = selectedTags;
AllTags = allTags?.Select(item =>
new SelectListItem
{
Value = item.Value,
Text = item.Text,
Selected = selectedTags?.Contains(item.Value, StringComparison.CurrentCultureIgnoreCase) ?? false
}
);
}
}
ContentSavingNotificationHandler.cs
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Strings;
namespace Freelancer.Notifications;
public class ContentSavingNotificationHandler(IShortStringHelper shortStringHelper) : INotificationHandler<ContentSavingNotification>
{
private readonly IShortStringHelper _shortStringHelper = shortStringHelper;
public void Handle(ContentSavingNotification notification)
{
foreach (var node in notification.SavedEntities)
{
if (node.ContentType.Alias.Equals(PageTag.ModelTypeAlias) && node.Id == 0)
{
var safeAlias = node?.Name?.ToSafeAlias(_shortStringHelper);
node?.SetValue("tagAlias", safeAlias);
}
}
}
}
IndexComponent.cs
using Examine;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.Web;
namespace Freelancer.Search;
public class IndexComponent(IExamineManager examineManager, IUmbracoContextFactory umbracoContextFactory) : IComponent
{
public void Initialize()
{
if (!examineManager.TryGetIndex(UmbracoConstants.UmbracoIndexes.ExternalIndexName, out IIndex index))
{
throw new InvalidOperationException($"No index found by name {UmbracoConstants.UmbracoIndexes.ExternalIndexName}");
}
index.TransformingIndexValues += UmbracoContextIndex_TransforminIndexValues;
}
public void Terminate()
{ }
private void UmbracoContextIndex_TransforminIndexValues(object? sender, IndexingItemEventArgs e)
{
if (int.TryParse(e.ValueSet.Id, out var nodeId))
{
var values = e.ValueSet.Values.ToDictionary(x => x.Key, x => x.Value.ToList());
if (values.TryGetValue("pageTags", out var pageTags))
{
using var umbracoContext = umbracoContextFactory.EnsureUmbracoContext();
var contentNode = umbracoContext?.UmbracoContext?.Content?.GetById(nodeId);
if (contentNode is not ITaggingProperties tagging) return;
var tags = tagging.PageTags;
if (tags == null || !tags.Any()) return;
if (!values.TryGetValue("tags", out var value))
{
value = [];
}
foreach (PageTag tag in tags.OfType<PageTag>())
{
if (string.IsNullOrWhiteSpace(tag?.TagAlias)) continue;
value.Add(tag.TagAlias);
}
values["tags"] = value;
}
e.SetValues(values.ToDictionary(x => x.Key, x => (IEnumerable<object>)x.Value));
}
}
}
SearchService.cs
using Examine;
using Examine.Search;
using Freelancer.Extensions;
using Freelancer.Models.Search;
using Lucene.Net.Analysis.Core;
using StackExchange.Profiling.Internal;
using Umbraco.Cms.Infrastructure.Examine;
namespace Freelancer.Services;
public class SearchService : ISearchService
{
private readonly IExamineManager _examineManager;
private readonly string[] _docTypesToExclude =
[PageTags.ModelTypeAlias,
PageTag.ModelTypeAlias,
SiteSettings.ModelTypeAlias,
ReusableContentRepository.ModelTypeAlias,
ReusableContentItem.ModelTypeAlias];
public SearchService(IExamineManager examineManager)
{
_examineManager = examineManager ?? throw new ArgumentNullException(nameof(examineManager));
}
public SearchResponseModel Search(SearchRequestModel searchRequest)
{
if (searchRequest == null || !_examineManager.TryGetIndex(UmbracoConstants.UmbracoIndexes.ExternalIndexName, out IIndex? index))
{
return new SearchResponseModel();
}
IBooleanOperation? query = index.Searcher.CreateQuery(IndexTypes.Content)
.GroupedNot(["umbracoNaviHide"], ["1"])
.And().GroupedNot(["__NodeTypeAlias"], _docTypesToExclude);
string[]? terms = !string.IsNullOrWhiteSpace(searchRequest.Query)
? searchRequest.Query.Split(" ", StringSplitOptions.RemoveEmptyEntries)
.Where(x => !StopAnalyzer.ENGLISH_STOP_WORDS_SET.Contains(x.ToLower()) && x.Length > 2).ToArray() : null;
if (terms != null && terms.Length > 0)
{
query!.And().Group(q => q
.GroupedOr(["metaTitle"], terms.Boost(80))
.Or()
.GroupedOr(["nodeName"], terms.Boost(70))
.Or()
.GroupedOr(["metaTitle"], terms.Fuzzy())
.Or()
.GroupedOr(["metaTitle"], terms.MultipleCharacterWildcard())
.Or()
.GroupedOr(["nodeName"], terms.Fuzzy())
.Or()
.GroupedOr(["nodeName"], terms.MultipleCharacterWildcard())
.Or()
.GroupedOr(["metaDescription"], terms.Boost(50))
.Or()
.GroupedOr(["headerContent"], terms.Boost(40))
.Or()
.GroupedOr(["mainContent"], terms.Boost(40)
), BooleanOperation.Or);
}
if (searchRequest.SelectedTags != null)
{
query.And().GroupedOr(["tags"], searchRequest.SelectedTags);
}
ISearchResults? pageOfResults = query.Execute(new QueryOptions(searchRequest.Skip, searchRequest.PageSize));
return new SearchResponseModel(searchRequest.Query, pageOfResults.TotalItemCount, pageOfResults);
}
}
Pagination Default.cshtml
@inherits UmbracoViewPage<PaginationViewModel>
@using Freelancer.Models.ViewModels
@if(Model != null && Model?.TotalPages > 1)
{
<nav aria-label="Page naviation">
<ul class="pagination">
@if(Model.CurrentPage > 1)
{
<li class="page-item"><a class="page-link" href="@string.Format(Model.PaginationUrlFormat ?? "", (Model?.CurrentPage - 1).ToString())">Previous</a></li>
}
@for(int i = 1; i <= Model?.TotalPages; i++)
{
<li class="page-item"><a class="page-link" href="@string.Format(Model.PaginationUrlFormat ?? "", i.ToString())">@i</a></li>
}
@if (Model?.CurrentPage < Model?.TotalPages)
{
<li class="page-item"><a class="page-link" href="@string.Format(Model.PaginationUrlFormat ?? "", (Model?.CurrentPage + 1).ToString())">Next</a></li>
}
</ul>
</nav>
}
SearchForm Default.csthml
@inherits UmbracoViewPage<SearchRequestModel>
@using Freelancer.Controllers.Surface
@using Freelancer.Models.Search
<form method="get" action="@Umbraco.AssignedContentItem.Url()">
<div class="mb-3">
<label for="query" class="form-label d-none" aria-hidden="true">Search Term</label>
<input class="form-control" id="query" type="text" placeholder="Search term" name="query" value="@Model?.Query" />
</div>
<div class="mb-3">
@if(Model?.AllTags != null && Model.AllTags.Any())
{
var i = 0;
@foreach(var tag in Model.AllTags)
{
<div class="form-check">
<input class="form-check-input" type="checkbox" name="SelectedTags" id="@($"SelectedTags_{i}")" value="@(tag.Value)" checked="@(tag.Selected ? "selected" : null)" />
<label class="form-check-label" for="@($"SelectedTags_{i}")">@tag.Text</label>
</div>
i++;
}
}
</div>
<button class="btn btn-primary">Submit</button>
</form>
SearchResults Default.cshtml
@inherits UmbracoViewPage<SearchResponseModel>
@using Freelancer.Controllers.Surface
@using Freelancer.Models.Search
<p>Total Results: @Model?.TotalResultCount</p>
@if(Model?.HasResults ?? false)
{
var itemCount = 0;
<div class="row">
@foreach(var item in Model?.SearchResults)
{
var contentItem = Umbraco.Content(item.Id);
if (contentItem == null) continue;
<div class="card mb-3 @(itemCount % 2 == 0 ? "bg-white" : "bg-light")">
<div class="card-header bg-transparent">
<a href="@contentItem.Url()">@contentItem.Name</a>
</div>
<div class="card-body">
@if(contentItem is ISEoproperties seo)
{
<p>@seo.MetaDescription</p>
}
</div>
<div class="card-footer bg-transparent">
<small>Last Updated: @contentItem.UpdateDate</small>
</div>
</div>
itemCount++;
}
</div>
}
SearchPage.cshtml
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<SearchPageContentModel>
@{
Layout = "Main.cshtml";
}
<header class="masthead bg-primary text-white text-center">
<div class="container d-flex align-items-center flex-column">
<h1 class="masthead-heading text-uppercase mb-0">@Model.Content.Name</h1>
</div>
</header>
<div class="container my-5">
<div class="row">
<div class="col-lg-3 col-12 mx-auto">
@await Component.InvokeAsync("SearchForm", Model?.SearchRequest)
</div>
<div class="col-lg-9 col-12 mx-auto">
@await Component.InvokeAsync("SearchResults", Model?.SearchResponse)
@await Component.InvokeAsync("Pagination", Model?.Pagination)
</div>
</div>
</div>
appsettings.json
,
"Freelancer": {
"EmailSettings": {
"From": "[email protected]",
"To": "[email protected]"
},
"SearchSettings": {
"PageSize": 1
}
}
Constants.cs
namespace Freelancer;
public static class Constants
{
public static class QueryStrings
{
public const string Query = "query";
public const string Page = "page";
public const string Tags = "selectedTags";
}
public static class Search
{
public const int DefaultPageSize = 10;
}
}