Episode 11 - Part 2

Umbraco 13 Tutorial - Episode 11 Part 2 - Examine Search Filtering by Tags and Pagination

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

https://discord.gg/umbraco

🗣️ Facebook Group

https://www.facebook.com/groups/umbracowebdevs

🐘 Mastodon

https://umbracocommunity.social/

🗨️ Umbraco Forum

https://our.umbraco.com/forum/

☕Buy me a coffee

https://codeshare.co.uk/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);
    }
}

SearchFormViewComponent.cs

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);
    }
}

PaginationViewComponent.cs

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);
    }
}

SearchResultsViewComponent.cs

IndexComposer.cs

using Freelancer.Search;

using Umbraco.Cms.Core.Composing;

namespace Freelancer.Composers;

public class IndexComposer : ComponentComposer<IndexComponent>
{
}

IndexComposer.cs

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>();
    }
}

RegisterNotificationsComposer.cs

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; }
}

FreelancerConfig.cs

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);
    }
}

SearchPageController.cs

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;
    }
}

PublishedContentExtensions.cs

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];
    }
}

StringArrayExtensions.cs

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));
    }
}

PaginationHelper.cs

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
                    }
        );
    }
}

SearchRequestModel.cs

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);
            }
        }
    }
}

ContentSavingNotificationHandler.cs

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));
        }
    }
}

IndexComponent.cs

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);
    }
}

SearchService.cs

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>
}

Default.cshtml

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>

Default.cshtml

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>
}

Default.cshtml

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>

SearchPage.cshtml

appsettings.json

,
"Freelancer": {
    "EmailSettings": {
        "From": "[email protected]",
        "To": "[email protected]"
    },
    "SearchSettings": {
        "PageSize": 1
    }
}

appsettings.json

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;
    }
}

Constants.cs