Last active
April 6, 2026 17:19
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| }; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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