Skip to content

Instantly share code, notes, and snippets.

@PNergard
Last active April 6, 2026 17:19
Show Gist options
  • Select an option

  • Save PNergard/6ff6e9cb01a8240be4d41c65ca7670c0 to your computer and use it in GitHub Desktop.

Select an option

Save PNergard/6ff6e9cb01a8240be4d41c65ca7670c0 to your computer and use it in GitHub Desktop.
An example of a implemenation of a view for a classic Optimizely settingspage. Idea is to make it easier to find a property hidden somewhere among all tabs and not so clear property names
using System.ComponentModel.DataAnnotations;
using EPiServer.Core;
using EPiServer.DataAbstraction;
using EPiServer.DataAnnotations;
using EPiServer.Web;
namespace SettingsSearchDemo;
// ── Tab definitions ──────────────────────────────────────────────────────────
// Optimizely resolves GroupName strings via a class decorated with
// [GroupDefinitions]. All tab constants must be defined here.
[GroupDefinitions]
public static class DemoTabs
{
[Display(Name = "Global", Order = 10)] public const string Global = "Global";
[Display(Name = "Header", Order = 20)] public const string Header = "Header";
[Display(Name = "Footer", Order = 30)] public const string Footer = "Footer";
[Display(Name = "Email", Order = 40)] public const string Email = "Email";
[Display(Name = "Cache", Order = 50)] public const string Cache = "Cache";
[Display(Name = "Developer", Order = 60)] public const string Developer = "Developer";
}
// ── Content type ─────────────────────────────────────────────────────────────
[ContentType(
DisplayName = "Demo Settings Page",
GUID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
Description = "Site-wide configuration page used in the settings-search demo.")]
[AvailableContentTypes(Availability = Availability.None)]
public class DemoSettingsPage : PageData
{
// ── Global ───────────────────────────────────────────────────────────────
[Display(GroupName = DemoTabs.Global, Order = 100,
Name = "Site Name",
Description = "The public-facing name of the website. Used in browser titles, meta tags and structured-data markup.")]
public virtual string SiteName { get; set; }
[Display(GroupName = DemoTabs.Global, Order = 200,
Name = "Organisation Name",
Description = "Legal name of the organisation that owns the site. Appears in copyright notices and structured data.")]
public virtual string OrganisationName { get; set; }
[Display(GroupName = DemoTabs.Global, Order = 300,
Name = "Google Analytics Tracking ID",
Description = "GA4 measurement ID (e.g. G-XXXXXXXXXX). Leave blank to disable analytics tracking entirely.")]
public virtual string GoogleAnalyticsId { get; set; }
[Display(GroupName = DemoTabs.Global, Order = 400,
Name = "Site Is In Production",
Description = "When enabled, analytics, caching and third-party integrations run in live mode. Disable on staging environments.")]
public virtual bool SiteIsInProduction { get; set; }
[Display(GroupName = DemoTabs.Global, Order = 500,
Name = "Cookie Consent Banner Enabled",
Description = "Show the GDPR cookie-consent banner to first-time visitors. Disable if consent is handled by a third-party script.")]
public virtual bool CookieConsentEnabled { get; set; }
[Display(GroupName = DemoTabs.Global, Order = 600,
Name = "Start Page",
Description = "Reference to the site's start page. Used by components that need to resolve the root of the content tree.")]
public virtual ContentReference StartPage { get; set; }
// ── Header ───────────────────────────────────────────────────────────────
[Display(GroupName = DemoTabs.Header, Order = 100,
Name = "Logo Image",
Description = "Content-library reference to the primary logo image shown in the site header on all pages.")]
public virtual ContentReference LogoImage { get; set; }
[Display(GroupName = DemoTabs.Header, Order = 200,
Name = "Logo Alt Text",
Description = "Accessible alt text for the logo image. Defaults to the Organisation Name if left empty.")]
public virtual string LogoAltText { get; set; }
[Display(GroupName = DemoTabs.Header, Order = 300,
Name = "Primary Navigation Root",
Description = "The page whose children are rendered as the top-level navigation items in the main menu.")]
public virtual ContentReference PrimaryNavigationRoot { get; set; }
[Display(GroupName = DemoTabs.Header, Order = 400,
Name = "Search Page",
Description = "The page rendered when the user submits a search query from the header search box.")]
public virtual ContentReference SearchPage { get; set; }
[Display(GroupName = DemoTabs.Header, Order = 500,
Name = "Sticky Header",
Description = "Keep the header fixed at the top of the viewport while the user scrolls. Adds a CSS class to the header element.")]
public virtual bool StickyHeader { get; set; }
[Display(GroupName = DemoTabs.Header, Order = 600,
Name = "Max Favourites",
Description = "Maximum number of pages a user can bookmark. Enforced client-side and on the favourites API endpoint. Default: 10.")]
public virtual int MaxFavourites { get; set; }
// ── Footer ───────────────────────────────────────────────────────────────
[Display(GroupName = DemoTabs.Footer, Order = 100,
Name = "Footer Logo Image",
Description = "Optional separate logo variant (e.g. white/inverted) used specifically in the site footer. Falls back to the header logo if empty.")]
public virtual ContentReference FooterLogoImage { get; set; }
[Display(GroupName = DemoTabs.Footer, Order = 200,
Name = "Copyright Text",
Description = "Static copyright notice at the bottom of every page. Use {year} as a placeholder for the current year.")]
public virtual string CopyrightText { get; set; }
[Display(GroupName = DemoTabs.Footer, Order = 300,
Name = "Left Footer Column",
Description = "Content area for the left column of the three-column footer layout. Typically contains an about-blurb or contact block.")]
public virtual ContentArea LeftFooterColumn { get; set; }
[Display(GroupName = DemoTabs.Footer, Order = 400,
Name = "Centre Footer Column",
Description = "Content area for the centre column of the footer. Typically contains quick-links or a sitemap excerpt.")]
public virtual ContentArea CentreFooterColumn { get; set; }
[Display(GroupName = DemoTabs.Footer, Order = 500,
Name = "Right Footer Column",
Description = "Content area for the right column of the footer. Typically contains social-media icons or a newsletter sign-up block.")]
public virtual ContentArea RightFooterColumn { get; set; }
// ── Email ────────────────────────────────────────────────────────────────
[Display(GroupName = DemoTabs.Email, Order = 100,
Name = "SMTP Host",
Description = "Hostname or IP address of the outbound SMTP relay used for all transactional emails sent by the site.")]
public virtual string SmtpHost { get; set; }
[Display(GroupName = DemoTabs.Email, Order = 200,
Name = "SMTP Port",
Description = "Port number for the SMTP connection. Common values: 25 (plain), 587 (STARTTLS), 465 (implicit TLS).")]
public virtual int SmtpPort { get; set; }
[Display(GroupName = DemoTabs.Email, Order = 300,
Name = "Default From Address",
Description = "The From address applied to all outgoing emails unless overridden by a specific sending feature.")]
public virtual string DefaultFromAddress { get; set; }
[Display(GroupName = DemoTabs.Email, Order = 400,
Name = "Default From Display Name",
Description = "Human-readable sender name shown next to the From address in email clients, e.g. 'Acme Support'.")]
public virtual string DefaultFromDisplayName { get; set; }
[Display(GroupName = DemoTabs.Email, Order = 500,
Name = "Error Notification Recipients",
Description = "Comma-separated email addresses that receive automated error alerts from scheduled jobs and failed integrations.")]
public virtual string ErrorNotificationRecipients { get; set; }
// ── Cache ────────────────────────────────────────────────────────────────
[Display(GroupName = DemoTabs.Cache, Order = 100,
Name = "Default Output Cache Duration (minutes)",
Description = "How long rendered page responses are stored in the output cache before being revalidated. Set to 0 to disable output caching.")]
public virtual int DefaultCacheDurationMinutes { get; set; }
[Display(GroupName = DemoTabs.Cache, Order = 200,
Name = "Navigation Cache Duration (minutes)",
Description = "How long the navigation tree is cached before being rebuilt from the content tree. Increase this on sites with infrequent structural changes.")]
public virtual int NavigationCacheDurationMinutes { get; set; }
[Display(GroupName = DemoTabs.Cache, Order = 300,
Name = "Search Results Cache Duration (minutes)",
Description = "How long search-result pages are cached. Lower values give fresher results; higher values reduce load on the search index.")]
public virtual int SearchCacheDurationMinutes { get; set; }
[Display(GroupName = DemoTabs.Cache, Order = 400,
Name = "CDN Cache-Control Max-Age (seconds)",
Description = "Value written into the Cache-Control: max-age header for static assets. Consumed by Varnish, CloudFront and similar edge caches.")]
public virtual int CdnMaxAgeSeconds { get; set; }
// ── Developer ────────────────────────────────────────────────────────────
[Display(GroupName = DemoTabs.Developer, Order = 100,
Name = "Debug Mode",
Description = "Enables verbose logging, disables HTML/JS minification and exposes the /debug route. Never enable in production.")]
public virtual bool DebugMode { get; set; }
[Display(GroupName = DemoTabs.Developer, Order = 200,
Name = "Maintenance Mode",
Description = "When enabled, all non-admin requests are redirected to the Maintenance Page. Use during deployments or database migrations.")]
public virtual bool MaintenanceMode { get; set; }
[Display(GroupName = DemoTabs.Developer, Order = 300,
Name = "Maintenance Page",
Description = "The page shown to visitors when Maintenance Mode is active. Must be publicly accessible without authentication.")]
public virtual ContentReference MaintenancePage { get; set; }
[Display(GroupName = DemoTabs.Developer, Order = 400,
Name = "Allowed IP Whitelist",
Description = "Newline-separated list of IP addresses or CIDR ranges that bypass Maintenance Mode. Include your office and VPN egress IPs.")]
[UIHint(UIHint.Textarea)]
public virtual string AllowedIpWhitelist { get; set; }
[Display(GroupName = DemoTabs.Developer, Order = 500,
Name = "Feature Flags (JSON)",
Description = "Raw JSON object whose keys are feature-flag names and values are booleans. Parsed at startup; requires an application restart to take effect.")]
[UIHint(UIHint.Textarea)]
public virtual string FeatureFlagsJson { get; set; }
}
using EPiServer.DataAbstraction;
using EPiServer.Web.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace SettingsSearchDemo;
/// <summary>
/// Renders a searchable index of all properties on the settings page.
/// The view is completely standalone (Layout = null) — no site chrome, no
/// Bootstrap, no external dependencies.
///
/// How it works:
/// 1. IContentTypeRepository resolves the Optimizely ContentType for DemoSettingsPage.
/// 2. IPropertyDefinitionRepository returns every PropertyDefinition registered
/// for that ContentType, including display name, description and tab assignment.
/// 3. The controller groups definitions by tab and hands a flat ViewModel to the
/// view. All filtering happens client-side in vanilla JS.
/// </summary>
public class DemoSettingsPageController(
IContentTypeRepository contentTypeRepository,
IPropertyDefinitionRepository propertyDefinitionRepository)
: PageController<DemoSettingsPage>
{
public IActionResult Index(DemoSettingsPage currentPage)
{
var contentType = contentTypeRepository.Load<DemoSettingsPage>();
var definitions = propertyDefinitionRepository.List(contentType.ID);
var tabs = definitions
// Skip system properties that have no editor tab (e.g. PageName, PagePeerOrder).
.Where(p => p.Tab != null && !string.IsNullOrEmpty(p.Tab.Name))
.GroupBy(p => p.Tab!)
.OrderBy(g => g.Key.SortIndex)
.Select(g => new SettingsTabGroup(
TabName: g.Key.Name,
SortOrder: g.Key.SortIndex,
Properties: g
.OrderBy(p => p.FieldOrder)
.Select(p => new SettingsPropertyItem(
DisplayName: p.EditCaption ?? p.Name,
PropertyName: p.Name,
Description: p.HelpText ?? string.Empty,
TabName: g.Key.Name,
TypeName: FriendlyTypeName(p.Type?.DefinitionType)))
.ToList()))
.ToList();
return View(new SettingsSearchViewModel(
PageTitle: currentPage.Name,
Tabs: tabs));
}
// Maps Optimizely's internal property type names to human-readable labels.
private static string FriendlyTypeName(Type? t) => t?.Name switch
{
"PropertyString" => "Text",
"PropertyLongString" => "Text (long)",
"PropertyXhtmlString" => "Rich text",
"PropertyBoolean" => "Boolean",
"PropertyNumber" => "Integer",
"PropertyFloatNumber" => "Float",
"PropertyDate" => "Date",
"PropertyContentReference" => "Content reference",
"PropertyPageReference" => "Page reference",
"PropertyContentArea" => "Content area",
"PropertyUrl" => "URL",
null => string.Empty,
var name => name
};
}
@model SettingsSearchDemo.SettingsSearchViewModel
@{
Layout = null;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>@Model.PageTitle — Settings Search</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/*
* IMPORTANT: .ss-property uses display:flex.
* The browser's built-in [hidden] { display:none } has no !important, so
* the class selector wins and cards stay visible after JS sets .hidden = true.
* This one line is the fix.
*/
[hidden] { display: none !important; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 1rem;
line-height: 1.5;
color: #1a1a2e;
background: #f4f6f8;
padding: 2rem 1rem 4rem;
}
.ss-shell {
max-width: 860px;
margin: 0 auto;
}
/* ── Page header ── */
.ss-header {
margin-bottom: 1.75rem;
}
.ss-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: #0a2540;
}
.ss-header p {
margin-top: .35rem;
font-size: .875rem;
color: #556677;
}
/* ── Search input ── */
.ss-search-wrap {
position: relative;
margin-bottom: .75rem;
}
.ss-search-wrap svg {
position: absolute;
left: .85rem;
top: 50%;
transform: translateY(-50%);
color: #8899aa;
pointer-events: none;
}
#ss-input {
width: 100%;
padding: .7rem 1rem .7rem 2.5rem;
font-size: 1rem;
border: 1.5px solid #cdd5df;
border-radius: 8px;
outline: none;
background: #fff;
transition: border-color .15s, box-shadow .15s;
}
#ss-input:focus {
border-color: #0074cc;
box-shadow: 0 0 0 3px rgba(0, 116, 204, .15);
}
/* ── Status line ── */
#ss-status {
font-size: .8rem;
color: #778899;
min-height: 1.25rem;
margin-bottom: 1rem;
}
/* ── No-results message ── */
#ss-empty {
text-align: center;
padding: 3rem 1rem;
color: #889;
font-size: .95rem;
}
/* ── Tab group ── */
.ss-tab-group {
margin-bottom: 1.5rem;
}
.ss-tab-heading {
display: flex;
align-items: center;
gap: .5rem;
font-size: .7rem;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: #667788;
margin-bottom: .5rem;
}
.ss-tab-heading::after {
content: "";
flex: 1;
height: 1px;
background: #dde3ea;
}
/* ── Property card ── */
.ss-property {
display: flex;
align-items: flex-start;
gap: 1rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: .85rem 1rem;
margin-bottom: .5rem;
transition: border-color .12s, box-shadow .12s;
}
.ss-property:hover {
border-color: #b3cde8;
box-shadow: 0 2px 6px rgba(0, 0, 0, .06);
}
.ss-property-body {
flex: 1;
min-width: 0;
}
.ss-name-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: .4rem;
}
.ss-display-name {
font-weight: 600;
font-size: .95rem;
color: #0a2540;
}
.ss-tab-badge {
font-size: .68rem;
font-weight: 600;
padding: .15rem .55rem;
border-radius: 999px;
background: #e8f2fd;
color: #0074cc;
white-space: nowrap;
}
.ss-description {
margin-top: .3rem;
font-size: .825rem;
color: #445566;
line-height: 1.5;
}
.ss-meta {
display: flex;
gap: .75rem;
margin-top: .4rem;
flex-wrap: wrap;
}
.ss-prop-name {
font-size: .73rem;
color: #99aabb;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
}
.ss-type {
font-size: .73rem;
color: #aabbcc;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
}
.ss-type::before {
content: "·";
margin-right: .35rem;
}
</style>
</head>
<body>
<div class="ss-shell">
<div class="ss-header">
<h1>@Model.PageTitle</h1>
<p>Search properties by display name, description or property name to find which tab it lives on.</p>
</div>
<div class="ss-search-wrap">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input id="ss-input" type="search" autocomplete="off" spellcheck="false"
placeholder="Search properties, descriptions, property names…"
aria-label="Search settings properties" />
</div>
<p id="ss-status" aria-live="polite"></p>
<p id="ss-empty" hidden>No properties match your search.</p>
<div id="ss-results">
@foreach (var tab in Model.Tabs)
{
<div class="ss-tab-group" data-group="@tab.TabName">
<div class="ss-tab-heading">@tab.TabName</div>
@foreach (var prop in tab.Properties)
{
<div class="ss-property"
data-display="@prop.DisplayName.ToLowerInvariant()"
data-desc="@prop.Description.ToLowerInvariant()"
data-prop="@prop.PropertyName.ToLowerInvariant()"
data-tab="@prop.TabName.ToLowerInvariant()">
<div class="ss-property-body">
<div class="ss-name-row">
<span class="ss-display-name">@prop.DisplayName</span>
<span class="ss-tab-badge">@prop.TabName</span>
</div>
@if (!string.IsNullOrEmpty(prop.Description))
{
<p class="ss-description">@prop.Description</p>
}
<div class="ss-meta">
<span class="ss-prop-name">@prop.PropertyName</span>
@if (!string.IsNullOrEmpty(prop.TypeName))
{
<span class="ss-type">@prop.TypeName</span>
}
</div>
</div>
</div>
}
</div>
}
</div>
</div>
<script>
(function () {
"use strict";
var input = document.getElementById("ss-input");
var statusEl = document.getElementById("ss-status");
var emptyEl = document.getElementById("ss-empty");
var results = document.getElementById("ss-results");
// Snapshot the cards and groups once at startup — the DOM never changes.
var allCards = Array.from(results.querySelectorAll(".ss-property"));
var allGroups = Array.from(results.querySelectorAll(".ss-tab-group"));
var total = allCards.length;
statusEl.textContent = total + " properties across " + allGroups.length + " tabs";
input.addEventListener("input", function () {
var q = this.value.trim().toLowerCase();
if (q === "") {
allCards.forEach(function (c) { c.hidden = false; });
allGroups.forEach(function (g) { g.hidden = false; });
statusEl.textContent = total + " properties across " + allGroups.length + " tabs";
emptyEl.hidden = true;
return;
}
// Show a card if the query appears in any of the four indexed fields.
var visible = 0;
allCards.forEach(function (card) {
var match =
card.dataset.display.indexOf(q) !== -1 ||
card.dataset.desc.indexOf(q) !== -1 ||
card.dataset.prop.indexOf(q) !== -1 ||
card.dataset.tab.indexOf(q) !== -1;
card.hidden = !match;
if (match) visible++;
});
// Hide the tab group heading when none of its cards match.
allGroups.forEach(function (group) {
var hasMatch = Array.from(group.querySelectorAll(".ss-property"))
.some(function (c) { return !c.hidden; });
group.hidden = !hasMatch;
});
statusEl.textContent = visible === 1 ? "1 property found" : visible + " properties found";
emptyEl.hidden = visible > 0;
});
}());
</script>
</body>
</html>
namespace SettingsSearchDemo;
public record SettingsSearchViewModel(
string PageTitle,
IReadOnlyList<SettingsTabGroup> Tabs);
public record SettingsTabGroup(
string TabName,
int SortOrder,
IReadOnlyList<SettingsPropertyItem> Properties);
public record SettingsPropertyItem(
string DisplayName,
string PropertyName,
string Description,
string TabName,
string TypeName);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment