Display an upgraded page with structured data and Page Builder functionality
In this final step, we’ll cover the code adjustments needed to display the Contacts page from the Kentico Xperience 13 version of the Dancing Goat in Xperience by Kentico, including the structured Contact and Cafe data it displays, and the widget zone containing the system Form widget.
Look over the contacts page
To start out, let’s take a look at the Contacts page in KX13, located at the /Contacts path.
It displays the contact information of the Dancing Goat company alongside a form widget. Then below, it shows a list of company cafes and a map.
In order to recreate this page in Xperience by Kentico, we’ll need to handle all of these components.
If you do not have a Google Maps API key, the map won’t render on your page.
If you do have such a key, store it in the appsettings.json file of both your KX13 and XbyK applications.
...
"GoogleMapsApiKey": "[Your API key here]",
...
Retrieve content items in XbyK
Looking through the code of the Dancing Goat project in KX13, we can see that it uses repositories to centralize the querying code for each page type into its own file, such as ContactRepository
and CafeRepository
.
Let’s centralize this further using a content retrieval service that can be shared among repositories that retrieve content items.
Handle the base logic
In the KX13 Dancing Goat, you can look at the ContactsController
to see which repository methods the application uses to feed the Contacts page. Here you can see it selects the first and only page of the Contact type.
...
/// <summary>
/// Returns company's contact information.
/// </summary>
public Contact GetCompanyContact()
{
return pageRetriever.Retrieve<Contact>(
query => query
.TopN(1),
cache => cache
.Key($"{nameof(ContactRepository)}|{nameof(GetCompanyContact)}"))
.FirstOrDefault();
}
...
The ContactsController
also relies on the CafeRepository
for a query that selects all Cafe pages under a specified path, filtered to only those which are company cafes.
...
/// <summary>
/// Returns an enumerable collection of company cafes ordered by a position in the content tree.
/// </summary>
/// <param name="nodeAliasPath">The node alias path of the articles section in the content tree.</param>
/// <param name="count">The number of cafes to return. Use 0 as value to return all records.</param>
public IEnumerable<Cafe> GetCompanyCafes(string nodeAliasPath, int count = 0)
{
return pageRetriever.Retrieve<Cafe>(
query => query
.Path(nodeAliasPath, PathTypeEnum.Children)
.TopN(count)
.WhereTrue("CafeIsCompanyCafe")
.OrderBy("NodeOrder"),
cache => cache
.Key($"{nameof(CafeRepository)}|{nameof(GetCompanyCafes)}|{nodeAliasPath}|{count}")
// Include path dependency to flush cache when a new child page is created or page order is changed.
.Dependencies((_, builder) => builder.PagePath(nodeAliasPath, PathTypeEnum.Children).PageOrder()));
}
...
We’ll make a service with methods that provide each of these options for a given content type.
Create the class
Add a new generic-typed class for retrieving content items in your XbyK solution, for example, in a ~/Services/Shared folder.
using CMS.ContentEngine;
using CMS.Helpers;
using CMS.Websites;
using CMS.Websites.Routing;
using Kentico.Content.Web.Mvc.Routing;
namespace DancingGoat.Web.Services.Shared;
public class ContentItemRetrieverService<T> : IContentItemRetrieverService<T> where T : IContentItemFieldsSource
{
// Use Dependency Injection to populate these services.
private readonly IContentQueryExecutor contentQueryExecutor;
private readonly IWebsiteChannelContext webSiteChannelContext;
private readonly IPreferredLanguageRetriever preferredLanguageRetriever;
}
Then, add a private method that we can reuse among the services members, which constructs and executes a Content item query for web page items.
...
//// <summary>
/// Retrieves web page content items of a specified content type and applies a query filter.
/// </summary>
/// <typeparam name="T">The type of the content items to retrieve.</typeparam>
/// <param name="contentTypeName">The name of the content type to retrieve.</param>
/// <param name="queryFilter">A function to apply additional query parameters to filter the content items.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an enumerable of content items of type <typeparamref name="T"/>.</returns>
private async Task<IEnumerable<T>> RetrieveWebPageContentItems(
string contentTypeName,
Func<ContentTypeQueryParameters,ContentTypeQueryParameters> queryFilter)
{
var builder = new ContentItemQueryBuilder()
.ForContentType(
contentTypeName,
config => queryFilter(config)
.ForWebsite(webSiteChannelContext.WebsiteChannelName)
)
.InLanguage(preferredLanguageRetriever.Get());
var queryExecutorOptions = new ContentQueryExecutionOptions
{
ForPreview = webSiteChannelContext.IsPreview
};
var pages = await contentQueryExecutor.GetMappedWebPageResult<T>(builder, queryExecutorOptions);
return pages;
}
...
Find more details about how to use the queryFilter
parameter in the Content item query reference.
Query the first web page item of a given type
RetrieveWebPageContentItems
is fairly non-specific, but we can use it to create more specialized methods, such as one that retrieves the first item of a specified content type, achieving the Contact scenario mentioned above.
It won’t be used in the Contacts page, but you can optionally add a method that retrieves a web page item based on a provided Guid
. This will come in handy for future reference if you ever need to retrieve the current page with IWebPageDataContextRetriever
.
...
private async Task<T?> RetrieveFirstWebPageOfType(
string contentTypeName,
int depth = 1)
{
var pages = await RetrieveWebPageContentItems(
contentTypeName,
config => config
.TopN(1)
.WithLinkedItems(depth));
return pages.FirstOrDefault();
}
// Optional method for reference, not used for the Contacts page.
private async Task<T?> RetrieveWebPageByGuid(
Guid webPageItemGuid,
string contentTypeName,
int depth = 1)
{
var pages = await RetrieveWebPageContentItems(
contentTypeName,
config => config
.TopN(1)
.Where(where => where.WhereEquals(nameof(WebPageFields.WebPageItemGUID), webPageItemGuid))
.WithLinkedItems(depth));
return pages.FirstOrDefault();
}
...
Find child items under a given path
With the Company contact scenario covered, we can move on to the Cafes requirement.
Add a new method that uses PathMatch
and conditionally adds additional query parameters.
...
private async Task<IEnumerable<T>> RetrieveWebPageChildrenByPath(
string parentPageContentTypeName,
string parentPagePath,
Action<ContentTypeQueryParameters>? customContentTypeQueryParameters,
int depth = 1)
{
Action<ContentTypeQueryParameters> contentQueryParameters = customContentTypeQueryParameters is not null
? config => customContentTypeQueryParameters(config
.ForWebsite(webSiteChannelContext.WebsiteChannelName, [PathMatch.Children(parentPagePath)])
.WithLinkedItems(depth)
)
: config => config
.ForWebsite(webSiteChannelContext.WebsiteChannelName, [PathMatch.Children(parentPagePath)])
.WithLinkedItems(depth);
var builder = new ContentItemQueryBuilder()
.ForContentType(
parentPageContentTypeName,
contentQueryParameters
)
.InLanguage(preferredLanguageRetriever.Get());
var queryExecutorOptions = new ContentQueryExecutionOptions
{
ForPreview = webSiteChannelContext.IsPreview
};
var pages = await contentQueryExecutor.GetMappedWebPageResult<T>(builder, queryExecutorOptions);
return pages;
}
...
Add caching
So far, we have the basic functionality to retrieve the items, but looking over the page retriever queries from KX13’s Dancing Goat, you can see that there is caching functionality that we’re still missing, for example:
...
public IEnumerable<Cafe> GetCompanyCafes(string nodeAliasPath, int count = 0)
{
return pageRetriever.Retrieve<Cafe>(
...
cache => cache
.Key($"{nameof(CafeRepository)}|{nameof(GetCompanyCafes)}|{nodeAliasPath}|{count}")
// Include path dependency to flush cache when a new child page is created or page order is changed.
.Dependencies((_, builder) => builder.PagePath(nodeAliasPath, PathTypeEnum.Children).PageOrder()));
}
...
Lets find a way to include this functionality in our ContentItemRetrieverService
class.
Taking a look at the data caching documentation for XbyK, we can see that the format differs somewhat from the IPageRetriever
in KX13.
Add caching utility methods
Copy the IsCacheEnabled
example from the data caching link above.
Then, create a method similar to the GetDependencyCacheKeys
example on the same page, using IWebPageFieldsSource
instead of a specific content type. Expand the method to optionally create a cache dependency on all siblings of the provided pages.
Finally, add new methods that allow you to combine additional dependency keys with those returned by GetDependencyCacheKeys
.
...
private bool IsCacheEnabled()
{
return !webSiteChannelContext.IsPreview;
}
private async Task<ISet<string>> GetDependencyCacheKeysForWebPages(IEnumerable<IWebPageFieldsSource?> webPages, int maxLevel, bool includeParentPathDependency = false)
{
var webPageItems = webPages.Where(item => item is not null);
if (webPageItems.Count() == 0)
return new HashSet<string>();
HashSet<string> dependencyCacheKeys;
try
{
dependencyCacheKeys = (await linkedItemsDependencyRetriever.Get(webPageItems.Select(item => item!.SystemFields.WebPageItemID), maxLevel: maxLevel)).ToHashSet(StringComparer.InvariantCultureIgnoreCase);
}
catch
{
dependencyCacheKeys = new HashSet<string>();
}
List<string> parentPaths = new();
foreach (IWebPageFieldsSource webPageItem in webPageItems)
{
dependencyCacheKeys.Add(CacheHelper.BuildCacheItemName(new[] { "webpageitem",
"byid",
webPageItem!.SystemFields.WebPageItemID.ToString() },
lowerCase: false));
dependencyCacheKeys.Add(CacheHelper.BuildCacheItemName(new[] { "webpageitem",
"bychannel",
webSiteChannelContext.WebsiteChannelName,
"bypath",
webPageItem.SystemFields.WebPageItemTreePath },
lowerCase: false));
if (includeParentPathDependency)
{
var pathElements = webPageItem.SystemFields.WebPageItemTreePath
.Split('/');
parentPaths.Add(
pathElements.Take(pathElements.Length - 1)
.Join("/")
);
}
}
if (includeParentPathDependency)
{
var distinctParentPaths = parentPaths.Distinct();
foreach (string parentPath in distinctParentPaths)
{
dependencyCacheKeys.Add(CacheHelper.BuildCacheItemName(new[] { "webpageitem",
"bychannel",
webSiteChannelContext.WebsiteChannelName,
"childrenofpath",
$"/{parentPath}" },
lowerCase: false));
}
}
return dependencyCacheKeys;
}
private async Task<CMSCacheDependency> GetCacheDependencyForWebPages(IEnumerable<T> webPages, int depth, IEnumerable<string>? extraDependencyKeys = null, bool includeParentPathDependency = false)
{
var dependencyCacheKeys = await GetDependencyCacheKeysForWebPages(webPages.Select(item => item as IWebPageFieldsSource), depth, includeParentPathDependency);
if (extraDependencyKeys is not null)
dependencyCacheKeys.UnionWith(extraDependencyKeys);
return CacheHelper.GetCacheDependency(dependencyCacheKeys);
}
private async Task<CMSCacheDependency> GetCacheDependencyForWebPage(T webPage, int depth, IEnumerable<string>? extraDependencyKeys = null) =>
await GetCacheDependencyForWebPages([webPage], depth, extraDependencyKeys);
...
Create a class to configure caching in methods
Before we add caching to the data retrieval methods, let’s create a DTO class that we can reuse for configuring caching across the service. We can include a parameter of this new type, rather than typing out the same group of separate parameters each time.
Based on the caching documentation, we can configure the time interval for which data is cached, and whether it uses a sliding expiration window. Include corresponding properties in the class.
You can also configure the dependencies that specify when to purge the cache (dependencies), and the name of the cache entry. For these, we can include appropriate defaults in each method, but we should allow callers to specify additional dependencies and a custom name.
...
public class ContentItemRetrieverServiceCacheConfig
{
/// <summary>
/// The number of minutes the cache should be stored.
/// </summary>
public int CacheMinutes { get; set; } = 10;
/// <summary>
/// Determines whether the cache should use a sliding expiration, resetting the timer if accessed within the cache duration.
/// </summary>
public bool UseSlidingExpiration { get; set; } = true;
/// <summary>
/// Additional cache keys to depend on.
/// </summary>
public IEnumerable<string>? ExtraDependencyKeys { get; set; } = null;
/// <summary>
/// Custom cache name parts that should be combined with the web channel name and language.
/// </summary>
public IEnumerable<string>? CustomCacheNameParts { get; set; } = null;
}
For the simplicity of this example, include this new class in the same file as the ContentItemRetrieverService<T>
.
Cache your queries
Now you can wrap each of the private data retrieval methods from earlier with public counterparts that include a ContentItemRetrieverServiceCacheConfig
parameter.
Make sure to include default name parts that are unique for distinct method calls, and include the web channel and language no matter what, like the KX13 caching functionality does.
...
private string[] GetStartingNameParts() =>
[webSiteChannelContext.WebsiteChannelName, preferredLanguageRetriever.Get()];
...
/// <inheritdoc/>
public async Task<T?> RetrieveFirstWebPageOfType(
string contentTypeName,
int depth = 1,
ContentItemRetrieverServiceCacheConfig? contentItemRetrieverCacheConfig = null)
{
var retrieverCacheConfig = contentItemRetrieverCacheConfig ?? new ContentItemRetrieverServiceCacheConfig();
string[] defaultNameParts = [nameof(RetrieveFirstWebPageOfType), contentTypeName, depth.ToString()];
var startingNameParts = GetStartingNameParts();
var cacheItemNameParts = retrieverCacheConfig.CustomCacheNameParts is not null
? startingNameParts.Concat(retrieverCacheConfig.CustomCacheNameParts).ToArray()
: startingNameParts.Concat(defaultNameParts).ToArray();
return await progressiveCache.LoadAsync(async (cacheSettings) =>
{
cacheSettings.Cached = IsCacheEnabled();
var webPage = await RetrieveFirstWebPageOfType(contentTypeName, depth);
if (webPage is not null)
cacheSettings.CacheDependency = await GetCacheDependencyForWebPage(webPage, depth, retrieverCacheConfig.ExtraDependencyKeys);
return webPage;
},
new CacheSettings(cacheMinutes: retrieverCacheConfig.CacheMinutes,
useSlidingExpiration: retrieverCacheConfig.UseSlidingExpiration,
cacheItemNameParts: cacheItemNameParts));
}
// Optional method for reference, not used for the Contacts page.
/// <inheritdoc/>
public async Task<T?> RetrieveWebPageByGuid(
Guid webPageItemGuid,
string contentTypeName,
int depth = 1,
ContentItemRetrieverServiceCacheConfig? contentItemRetrieverCacheConfig = null)
{
var retrieverCacheConfig = contentItemRetrieverCacheConfig ?? new ContentItemRetrieverServiceCacheConfig();
string[] defaultNameParts = [nameof(RetrieveWebPageByGuid), contentTypeName, webPageItemGuid.ToString(), depth.ToString()];
var startingNameParts = GetStartingNameParts();
var cacheItemNameParts = retrieverCacheConfig.CustomCacheNameParts is not null
? startingNameParts.Concat(retrieverCacheConfig.CustomCacheNameParts).ToArray()
: startingNameParts.Concat(defaultNameParts).ToArray();
return await progressiveCache.LoadAsync(async (cacheSettings) =>
{
cacheSettings.Cached = IsCacheEnabled();
var webPage = await RetrieveWebPageByGuid(webPageItemGuid, contentTypeName, depth);
if (webPage is not null)
cacheSettings.CacheDependency = await GetCacheDependencyForWebPage(webPage, depth, retrieverCacheConfig.ExtraDependencyKeys);
return webPage;
},
new CacheSettings(cacheMinutes: retrieverCacheConfig.CacheMinutes,
useSlidingExpiration: retrieverCacheConfig.UseSlidingExpiration,
cacheItemNameParts: cacheItemNameParts));
}
/// <inheritdoc/>
public async Task<IEnumerable<T>> RetrieveWebPageChildrenByPath(
string parentPageContentTypeName,
string parentPagePath,
Action<ContentTypeQueryParameters>? customContentTypeQueryParameters,
int depth = 1,
ContentItemRetrieverServiceCacheConfig? contentItemRetrieverCacheConfig = null)
{
var retrieverCacheConfig = contentItemRetrieverCacheConfig ?? new ContentItemRetrieverServiceCacheConfig();
string[] defaultNameParts = [nameof(RetrieveWebPageChildrenByPath), parentPageContentTypeName, parentPagePath, depth.ToString()];
var startingNameParts = GetStartingNameParts();
var cacheItemNameParts = retrieverCacheConfig.CustomCacheNameParts is not null
? startingNameParts.Concat(retrieverCacheConfig.CustomCacheNameParts).ToArray()
: startingNameParts.Concat(defaultNameParts).ToArray();
return await progressiveCache.LoadAsync(async (cacheSettings) =>
{
cacheSettings.Cached = IsCacheEnabled();
var webPages = await RetrieveWebPageChildrenByPath(parentPageContentTypeName, parentPagePath, customContentTypeQueryParameters, depth);
if (webPages.Count() > 0)
cacheSettings.CacheDependency = await GetCacheDependencyForWebPages(webPages, depth, retrieverCacheConfig.ExtraDependencyKeys, true);
return webPages;
},
new CacheSettings(cacheMinutes: retrieverCacheConfig.CacheMinutes,
useSlidingExpiration: retrieverCacheConfig.UseSlidingExpiration,
cacheItemNameParts: cacheItemNameParts));
}
...
In the end, your ContentItemRetrieverService
should look like this:
ContentItemRetrieverService.cs
Register your service
Now, let’s set up the service and register it.
Create an interface with corresponding signatures for the public methods.
using CMS.ContentEngine;
namespace DancingGoat.Web.Services.Shared;
public interface IContentItemRetrieverService<T> where T : IContentItemFieldsSource
{
/// <summary>
/// Retrieves the first web page item of the provided type.
/// </summary>
/// <param name="contentTypeName"Content type name of the Web page.></param>
/// <param name="depth">The maximum level of recursively linked content items that should be included in the results. Default value is 1.</param>
/// <param name="contentItemRetrieverServiceCacheSettings">Settings to determine how the page data is cached.</param>
/// <returns></returns>
public Task<T?> RetrieveFirstWebPageOfType(
string contentTypeName,
int depth = 1,
ContentItemRetrieverServiceCacheConfig? contentItemRetrieverServiceCacheSettings = null);
/// <summary>
/// Retrieves a web page item based on the provided Guid.
/// </summary>
/// <param name="webPageItemGuid">The Guid of the Web page content item.</param>
/// <param name="contentTypeName">Content type name of the Web page.</param>
/// <param name="depth">The maximum level of recursively linked content items that should be included in the results. Default value is 1.</param>
/// <param name="contentItemRetrieverServiceCacheSettings">Settings to determine how the page data is cached.</param>
/// <returns>A Web page content item of specified type, with the specified Id</returns>
public Task<T?> RetrieveWebPageByGuid(Guid webPageItemGuid,
string contentTypeName,
int depth = 1,
ContentItemRetrieverServiceCacheConfig? contentItemRetrieverServiceCacheSettings = null);
/// <summary>
/// Retrieves the child pages of a given web page tree path.
/// </summary>
/// <param name="parentPageContentTypeName">Content type name of the parent page.</param>
/// <param name="parentPagePath">Path of the parent page.</param>
/// <param name="customContentTypeQueryParameters">Parameters to further filter the query.</param>
/// <param name="depth">The maximum level of recursively linked content items that should be included in the results. Default value is 1.</param>
/// <param name="contentItemRetrieverServiceCacheSettings">Settings to determine how the page data is cached.</param>
/// <returns></returns>
public Task<IEnumerable<T>> RetrieveWebPageChildrenByPath(
string parentPageContentTypeName,
string parentPagePath,
Action<ContentTypeQueryParameters>? customContentTypeQueryParameters,
int depth = 1,
ContentItemRetrieverServiceCacheConfig? contentItemRetrieverServiceCacheSettings = null);
}
Now, register the ContentItemRetrieverService<T>
class as the implementation of this interface with the DI container in the ServiceCollectionExtensions
class.
using DancingGoat.Models;
using DancingGoat.Web.Services.Shared;
namespace DancingGoat
{
public static class ServiceCollectionExtensions
{
public static void AddDancingGoatServices(this IServiceCollection services)
{
...
services.AddTransient(typeof(IContentItemRetrieverService<>), typeof(ContentItemRetrieverService<>));
...
}
...
}
}
Transfer the repositories
With this shared querying logic in place, we can start bringing over the repositories used in the KX13 site.
Contact repository
The KX13 ContactRepository
has one method called GetCompanyContact
. It retrieves the first page of the Contact type, and caches the result using the name of the repository and the method.
...
// Populated with Dependency Injection
private readonly IPageRetriever pageRetriever;
...
/// <summary>
/// Returns company's contact information.
/// </summary>
public Contact GetCompanyContact()
{
return pageRetriever.Retrieve<Contact>(
query => query
.TopN(1),
cache => cache
.Key($"{nameof(ContactRepository)}|{nameof(GetCompanyContact)}"))
.FirstOrDefault();
}
...
With the RetrieveFirstWebPageOfType
method, we can replicate this functionality here. Add a new file located at ~/Models/Contacts/ in the DancingGoat.Web project, or copy and modify the repository from KX13.
...
// Use dependency injection to populate this.
private readonly IContentItemRetrieverService<Contact> contentItemRetriever;
...
/// <summary>
/// Returns company's contact information.
/// </summary>
public async Task<Contact?> GetCompanyContact()
{
return await contentItemRetriever.RetrieveFirstWebPageOfType(
Contact.CONTENT_TYPE_NAME,
contentItemRetrieverServiceCacheSettings: new()
{
CustomCacheNameParts = [nameof(ContactRepository), nameof(GetCompanyContact)]
}
);
}
...
Cafe repository
The CafeRepository
class in KX13 has one method that is involved in the delivery of the Contacts page. It retrieves the cafe pages located under the provided path, filtering the query to contain only company cafes.
...
// Use Dependency Injection to populate this.
private readonly IPageRetriever pageRetriever;
...
/// <summary>
/// Returns an enumerable collection of company cafes ordered by a position in the content tree.
/// </summary>
/// <param name="nodeAliasPath">The node alias path of the articles section in the content tree.</param>
/// <param name="count">The number of cafes to return. Use 0 as value to return all records.</param>
public IEnumerable<Cafe> GetCompanyCafes(string nodeAliasPath, int count = 0)
{
return pageRetriever.Retrieve<Cafe>(
query => query
.Path(nodeAliasPath, PathTypeEnum.Children)
.TopN(count)
.WhereTrue("CafeIsCompanyCafe")
.OrderBy("NodeOrder"),
cache => cache
.Key($"{nameof(CafeRepository)}|{nameof(GetCompanyCafes)}|{nodeAliasPath}|{count}")
// Include path dependency to flush cache when a new child page is created or page order is changed.
.Dependencies((_, builder) => builder.PagePath(nodeAliasPath, PathTypeEnum.Children).PageOrder()));
}
We can use our RetrieveWebPageChildrenByPath
method to replicate this functionality. Add a new file located at ~/Models/Cafes/ in the DancingGoat.Web project, or copy and modify the KX13 repository.
using DancingGoat.Web.Services.Shared;
using DancingGoatCore;
namespace DancingGoat.Models
{
/// <summary>
/// Represents a collection of cafes.
/// </summary>
public partial class CafeRepository
{
// Use Dependency Injection to populate this.
private readonly IContentItemRetrieverService<Cafe> cafeRetrieverService;
...
/// <summary>
/// Returns an enumerable collection of company cafes ordered by a position in the content tree.
/// </summary>
/// <param name="parentPagePath">The node alias path of the articles section in the content tree.</param>
/// <param name="count">The number of cafes to return. Use 0 as value to return all records.</param>
public async Task<IEnumerable<Cafe>> GetCompanyCafes(string parentPagePath, int count = 0)
{
return await cafeRetrieverService.RetrieveWebPageChildrenByPath(
Cafe.CONTENT_TYPE_NAME,
parentPagePath,
config => config
.TopN(count)
.Where(where => where.WhereTrue(nameof(Cafe.CafeIsCompanyCafe)))
.WithLinkedItems(3)
.OrderBy(nameof(Cafe.SystemFields.WebPageItemOrder)),
1,
new()
{
CustomCacheNameParts = [nameof(CafeRepository), nameof(GetCompanyCafes), parentPagePath, count.ToString()],
}
);
}
}
}
Thanks to the way we set up caching in the RetrieveWebPageChildrenByPath
method, we don’t need to specify the dependency on sibling pages in the repository; it happens automatically.
Country repository
In the KX13 site, two methods from the CountryRepository
are used when retrieving the Contacts page. They use the appropriate info providers to retrieve countries and states by name, then cache the results.
...
// Populated with Dependency Injection.
private readonly ICountryInfoProvider countryInfoProvider;
private readonly IStateInfoProvider stateInfoProvider;
private readonly RepositoryCacheHelper repositoryCacheHelper;
...
/// <summary>
/// Returns the country with the specified code name.
/// </summary>
/// <param name="countryName">The code name of the country.</param>
/// <returns>The country with the specified code name, if found; otherwise, null.</returns>
public CountryInfo GetCountry(string countryName)
{
return repositoryCacheHelper.CacheObject(() =>
{
return countryInfoProvider.Get(countryName);
}, $"{nameof(CountryRepository)}|{nameof(GetCountry)}|{countryName}");
}
...
/// <summary>
/// Returns the state with the specified code name.
/// </summary>
/// <param name="stateName">The code name of the state.</param>
/// <returns>The state with the specified code name, if found; otherwise, null.</returns>
public StateInfo GetState(string stateName)
{
return repositoryCacheHelper.CacheObject(() =>
{
return stateInfoProvider.Get(stateName);
}, $"{nameof(CountryRepository)}|{nameof(GetState)}|{stateName}");
}
...
With Xperience by Kentico’s new generic-typed info provider, we can make these data retrieval methods asynchronous.
For the sake of brevity, we’ll add object caching directly in the repository code instead of recreating the RepositoryCacheHelper
class from KX13.
Add a new CountryRepository.cs file located at ~/Models/Contacts/ in the DancingGoat.Web project, or copy and modify the repository from KX13.
using System.Collections.Generic;
using System.Threading.Tasks;
using CMS.DataEngine;
using CMS.Globalization;
using CMS.Helpers;
// using DancingGoat.Infrastructure;
namespace DancingGoat.Models
{
/// <summary>
/// Represents a collection of countries and states.
/// </summary>
public class CountryRepository
{
// Use Dependency Injection to populate these.
private readonly IInfoProvider<CountryInfo> countryInfoProvider;
private readonly IInfoProvider<StateInfo> stateInfoProvider;
private readonly IProgressiveCache progressiveCache;
...
/// <summary>
/// Returns the country with the specified code name.
/// </summary>
/// <param name="countryName">The code name of the country.</param>
/// <returns>The country with the specified code name, if found; otherwise, null.</returns>
public async Task<CountryInfo?> GetCountry(string countryName)=>
await progressiveCache.LoadAsync(async cacheSettings =>
{
var result = await countryInfoProvider.GetAsync(countryName);
cacheSettings.CacheDependency = CacheHelper.GetCacheDependency($"cms.country|byid|{result?.CountryID ?? 0}");
return result;
}, new CacheSettings(10, true, [nameof(CountryRepository), nameof(GetCountry), countryName]));
/// <summary>
/// Returns the state with the specified code name.
/// </summary>
/// <param name="stateName">The code name of the state.</param>
/// <returns>The state with the specified code name, if found; otherwise, null.</returns>
public async Task<StateInfo?> GetState(string stateName) =>
await progressiveCache.LoadAsync(async cacheSettings =>
{
var result = await stateInfoProvider.GetAsync(stateName);
cacheSettings.CacheDependency = CacheHelper.GetCacheDependency($"cms.state|byid|{result?.StateID ?? 0}");
return result;
}, new CacheSettings(10, true, [nameof(CountryRepository), nameof(GetState), stateName]));
}
}
Register the repositories
Now, switch to the ServiceCollectionExtensions.cs file once more and uncomment the lines for the Cafe, Contact, and Country repositories in the AddRepositories
method.
...
private static void AddRepositories(IServiceCollection services)
{
services.AddSingleton<CafeRepository>();
services.AddSingleton<ContactRepository>();
services.AddSingleton<CountryRepository>();
//services.AddSingleton<ArticleRepository>();
}
...
Display the page
Now that we’ve handled data retrieval, let’s make sure the page displays.
View models
The view models in the Dancing Goat KX13 project are more than just DTOs; they contain static methods to construct the view model based on Xperience objects. Let’s bring over the view models we need for displaying the Contacts page.
Contact view model
The KX13 version of Dancing Goat customizes the generated classes for Contact
and Cafe
so that they both implement an interface called IContact
. This is still possible in Xperience by Kentico, but will be left out of this example for the sake of brevity.
In the ~/Models/Contacts/ folder of the DancingGoat.Web project, create a new file for the Contact view model.
Copy the contents of the ContactViewModel
from the corresponding file in KX13, providing default values for the properties and changing the GetViewModel
class to take a Contact
parameter instead of IContact
.
using DancingGoatCore;
using Microsoft.Extensions.Localization;
namespace DancingGoat.Models;
public class ContactViewModel
{
public string Name { get; set; } = string.Empty;
public string Phone { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string ZIP { get; set; } = string.Empty;
public string Street { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string Country { get; set; } = string.Empty;
public string CountryCode { get; set; } = string.Empty;
public string State { get; set; } = string.Empty;
public string StateCode { get; set; } = string.Empty;
public ContactViewModel()
{
}
public ContactViewModel(Contact contact)
{
Name = contact.ContactName;
Phone = contact.ContactPhone;
Email = contact.ContactEmail;
ZIP = contact.ContactZipCode;
Street = contact.ContactStreet;
City = contact.ContactCity;
}
public static async Task<ContactViewModel> GetViewModel(Contact contact, CountryRepository countryProvider, IStringLocalizer localizer)
{
var countryStateName = CountryStateName.Parse(contact.ContactCountry);
var country = await countryProvider.GetCountry(countryStateName.CountryName);
var state = await countryProvider.GetState(countryStateName.StateName);
var model = new ContactViewModel(contact)
{
CountryCode = country?.CountryTwoLetterCode ?? string.Empty,
Country = localizer[country?.CountryDisplayName ?? string.Empty],
StateCode = state?.StateCode ?? string.Empty,
State = localizer[state?.StateDisplayName ?? string.Empty]
};
return model;
}
}
Cafe view model
In the ~/Models/Cafes/ folder of DancingGoat.Web, create a new file for the Cafe view model.
Copy over the contents of the corresponding file from KX13, and replace the ContactViewModel
property with direct address properties. This change is necessary due to the absence of IContact
discussed earlier.
Then set default values for all the properties and adjust the GetViewModel
method to populate the new properties.
using DancingGoatCore;
using Microsoft.Extensions.Localization;
namespace DancingGoat.Models;
public class CafeViewModel
{
public string PhotoPath { get; set; } = string.Empty;
public string Note { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Phone { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string ZIP { get; set; } = string.Empty;
public string Street { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string Country { get; set; } = string.Empty;
public string CountryCode { get; set; } = string.Empty;
public string State { get; set; } = string.Empty;
public string StateCode { get; set; } = string.Empty;
public static async Task<CafeViewModel> GetViewModel(Cafe cafe, CountryRepository countryRepository, IStringLocalizer<SharedResources> localizer)
{
var countryStateName = CountryStateName.Parse(cafe.CafeCountry);
var country = await countryRepository.GetCountry(countryStateName.CountryName);
var state = await countryRepository.GetState(countryStateName.StateName);
var photo = cafe.CafePhoto.FirstOrDefault() as Legacy.Attachment;
return new CafeViewModel
{
PhotoPath = photo?.Asset.Url ?? string.Empty,
Note = cafe.CafeAdditionalNotes,
City = cafe.CafeCity,
Name = cafe.CafeName,
Phone = cafe.CafePhone,
ZIP = cafe.CafeZipCode,
Street = cafe.CafeStreet,
Country = localizer[country?.CountryDisplayName ?? string.Empty],
CountryCode = country?.CountryTwoLetterCode ?? string.Empty,
State = localizer[state?.StateDisplayName ?? string.Empty],
StateCode = state?.StateName ?? string.Empty
};
}
}
Handling legacy attachments and media files
By default, certain versions the migration tool create a field of the Pages and reusable content data type for attachment fields on pages, with two allowed content types: Legacy attachment and Legacy media file. During code generation, this translates to a property of the type IEnumerable<IContentItemFieldsSource>
.
In this case, we know the field will always hold attachments, so there are two potential ways to handle this:
First, you can cast the photo field into an attachment, as we did in this code sample.
The other is to remove Legacy media file as an allowed content type in the Xperience UI and re-generate code files. Then, the property will be strongly-typed and will not need casting. This option is more robust, but requires extra steps, so we opted for the quicker alternative in this walkthrough.
Views
The Contacts page in the KX13 Dancing Goat is rendered by the ~/Views/Contacts/Index.cshtml view.
Contacts view
Copy this view into the same location within the DancingGoat.Web project in your Xperience by Kentico solution.
Thanks to the ViewStart and ViewImports files we set up in the global code guide, very little change is necessary. Simply rename the HtmlLocalizer
service to localizer
, as specified in the ViewImports file.
@model DancingGoat.Models.ContactsIndexViewModel
@{
ViewBag.Title = localizer["Contacts"].Value;
}
@section scripts {
<partial name="_GoogleMaps" />
}
<div class="contacts-page">
<div class="col-md-12">
<div class="col-md-6">
<h2 class="contact-title">@localizer["Roastery"]</h2>
<ul class="contact-info">
<li>@Model.CompanyContact.Phone</li>
<li>
<email address="@Model.CompanyContact.Email" />
</li>
<li>
<a href="javascript:void(0)" data-address="@Model.CompanyContact.City, @Model.CompanyContact.Street" class="js-scroll-to-map">
@Model.CompanyContact.Street @Model.CompanyContact.City,<br />
@Model.CompanyContact.ZIP, @Model.CompanyContact.CountryCode, @Model.CompanyContact.State<br />
</a>
</li>
</ul>
</div>
<div class="col-md-6">
<h2>@localizer["Send us a message"]</h2>
<div id="message-form" class="contact-us-form">
<editable-area area-identifier="ContactUs" area-options-allowed-widgets="new[] { SystemComponentIdentifiers.FORM_WIDGET_IDENTIFIER }" />
</div>
</div>
</div>
<div class="row"><h2>@localizer["Our cafes"]</h2></div>
<div class="row">
@foreach (var cafe in @Model.CompanyCafes)
{
<div class="col-md-6 col-lg-3">
<div class="cafe-tile cursor-hand js-scroll-to-map" data-address="@cafe.City, @cafe.Street">
<div class="cafe-tile-content">
<h3 class="cafe-tile-name">@cafe.Name</h3>
<address class="cafe-tile-address">
<a href="javascript:void(0)" class="cafe-tile-address-anchor">
@cafe.Street, @cafe.City<br />
@cafe.ZIP, @cafe.Country, @cafe.State
</a>
</address>
<p class="cafe-tile-phone">@cafe.Phone</p>
</div>
</div>
</div>
}
</div>
<h2 class="map-title">@localizer["Drop in"]</h2>
<div class="map js-map"></div>
</div>
Layout view
With our standard view ready, let’s double-check that we have a layout view to wrap it.
You copied the _Layout.cshtml file from the KX13 site in the previous part of this walkthrough, and modified it to get around some compiler errors. If it’s giving you trouble, make sure it looks something like this:
Controller
The ContactsController
class in the KX13 Dancing Goat uses the repositories and GetViewModel
methods from earlier to gather the necessary data for the Contacts page.
Copy it to the ~/Controllers/ folder in your Xperience by Kentico solution. Then adjust the private methods to handle the asynchronous nature of our new repository methods, and to better handle null
values.
You’ll also need to change RegisterPageRoute
to RegisterWebPageRoute
in the registration attribute, and swap the old page type class name for the new content type identifier.
Thanks to the name and location of the index view, we do not need to include its path in the registration attribute. It will work automatically.
using DancingGoat.Controllers;
using DancingGoat.Models;
using DancingGoatCore;
using Kentico.Content.Web.Mvc.Routing;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
[assembly: RegisterWebPageRoute(
Contacts.CONTENT_TYPE_NAME,
typeof(ContactsController))]
namespace DancingGoat.Controllers
{
public class ContactsController : Controller
{
private readonly ContactRepository contactRepository;
private readonly CountryRepository countryRepository;
private readonly CafeRepository cafeRepository;
private readonly IStringLocalizer<SharedResources> localizer;
public ContactsController(ContactRepository contactRepository,
CountryRepository countryRepository,
CafeRepository cafeRepository,
IStringLocalizer<SharedResources> localizer)
{
this.countryRepository = countryRepository;
this.contactRepository = contactRepository;
this.cafeRepository = cafeRepository;
this.localizer = localizer;
}
public async Task<IActionResult> Index()
{
var model = await GetIndexViewModel();
return View(model);
}
private async Task<ContactsIndexViewModel> GetIndexViewModel()
{
var cafes = await cafeRepository.GetCompanyCafes(ContentItemIdentifiers.CAFES, 4);
return new ContactsIndexViewModel
{
CompanyContact = await GetCompanyContactModel(),
CompanyCafes = await GetCompanyCafesModel(cafes)
};
}
private async Task<ContactViewModel> GetCompanyContactModel()
{
var contact = await contactRepository.GetCompanyContact();
return contact is not null
? await ContactViewModel.GetViewModel(contact, countryRepository, localizer)
: new ContactViewModel();
}
private async Task<List<CafeViewModel>> GetCompanyCafesModel(IEnumerable<Cafe> cafes)
{
List<CafeViewModel> cafeModels = new();
foreach (Cafe cafe in cafes)
{
cafeModels.Add(await CafeViewModel.GetViewModel(cafe, countryRepository, localizer));
}
return cafeModels;
}
}
}
Check your progress
Try running the site in debug mode. We haven’t brought over any of the home page code, so you’ll see an error when it loads, but if you visit the /Contacts path, you should see something like this:
If you’re seeing an error that the application cannot find the index view, you can either adjust the registration attribute to point to where you’ve put your Index.cshtml file, or move the file to the location referenced by the error.
Page Builder sections
If you look at the the Contacts page in KX13, you’ll notice that it holds a form widget that’s missing from the XbyK version.
In order to display this form widget, we’ll need to bring over the Page Builder section that the widget lives in.
From the ~/Components/Sections/ folder of the KX13 Dancing Goat project, copy the _DancingGoat_SingleColumnSection.cshtml and ThemeSectionProperties.cs files into a new ~/Components/Sections/ folder in the DancingGoat.Web project of your XbyK solution. We don’t need to change either of these files.
@using DancingGoat.Sections
@model Kentico.PageBuilder.Web.Mvc.ComponentViewModel<ThemeSectionProperties>
<div class="row @Model.Properties.Theme">
<div class="col-md-12">
<widget-zone />
</div>
</div>
using Kentico.Forms.Web.Mvc;
using Kentico.PageBuilder.Web.Mvc;
namespace DancingGoat.Sections
{
/// <summary>
/// Section properties to define the theme.
/// </summary>
public class ThemeSectionProperties : ISectionProperties
{
/// <summary>
/// Theme of the section.
/// </summary>
[EditingComponent(DropDownComponent.IDENTIFIER, Label = "Color scheme", Order = 1)]
[EditingComponentProperty(nameof(DropDownProperties.DataSource), ";None\r\nsection-white;Flat white\r\nsection-cappuccino;Cappuccino")]
public string Theme { get; set; }
}
}
Now, copy ComponentIdentifiers.cs and PageBuilderComponentRegister.cs from the ~/Components/ folder in KX13 into the corresponding folder in XbyK.
In each file, comment out or remove everything except for the line referencing the single column section.
namespace DancingGoat
{
/// <summary>
/// Encapsulated identifiers of components.
/// </summary>
public static class ComponentIdentifiers
{
// Sections
public const string SINGLE_COLUMN_SECTION = "DancingGoat.SingleColumnSection";
}
}
using DancingGoat;
using DancingGoat.Sections;
using Kentico.PageBuilder.Web.Mvc;
// Sections
[assembly: RegisterSection(ComponentIdentifiers.SINGLE_COLUMN_SECTION, "Single column", typeof(ThemeSectionProperties), "~/Components/Sections/_DancingGoat_SingleColumnSection.cshtml", Description = "Single-column section with one zone.", IconClass = "icon-square")]
See the result
Now restart the site. Navigating to the /Contacts page, you should see the “Send us a message” form now correctly displayed.
Previous step: Adjust global code on the backend — Next step: Explore your next steps
Completed steps: 4 of 5