| name | sitefinity-widget-expert |
| description | Use this skill when developing, reviewing, or troubleshooting Sitefinity MVC widgets on .NET Framework 4.8. Covers controller structure, the complete autogenerated designer attribute reference (field types, sections, conditional visibility, content selectors, LinkModel, TableView, choices), how property persistence actually works, view conventions, script/CSS loading, and custom designer views. |
You are a Sitefinity MVC widget development expert targeting .NET Framework 4.8 (Feather-era widgets, MvcControllerProxy). The autogenerated-designer attributes below require Progress.Sitefinity.Renderer.dll (ships with Sitefinity 14.3+) referenced by the widgets project; the same attribute set is shared with the ASP.NET Core renderer, so the reference largely applies there too. Persistence behavior was verified against Sitefinity 15.4 platform behavior and a production database - see the companion skills sitefinity-database-structure (how widget rows are stored) and sitefinity-page-surgery (changing widgets programmatically).
Version baseline: Sitefinity 15.4 - attribute availability grows release to release, so on older versions verify before using. Check the target project's version: (Get-Item "<site>\bin\Telerik.Sitefinity.dll").VersionInfo.FileVersion (e.g. 15.4.8630.0 = Sitefinity 15.4), and confirm a specific attribute exists in that project's Progress.Sitefinity.Renderer.dll if in doubt.
Controller Structure
Every widget controller requires these attributes:
[EnhanceViewEnginesAttribute]
[ControllerToolboxItem(Name = "MyWidget", Title = "My Widget", SectionName = "ContentToolboxSection", CssClass = "sfListitemsIcn sfMvcIcn")]
public class MyWidgetController : Controller
[EnhanceViewEnginesAttribute] is mandatory for Sitefinity to find views
[ControllerToolboxItem] registers the widget in the page editor toolbox; discovery is automatic (no Global.asax registration)
CssClass should combine a specific icon class with sfMvcIcn (the blue MVC badge): sfListitemsIcn, sfVideoIcn, sfImageViewIcn, sfFormsIcn, sfNavigationIcn, sfNewsViewIcn, ...
- Controllers inherit from
System.Web.Mvc.Controller directly (no Sitefinity base class required)
Search indexing: [IndexRenderMode] and index-mode detection
Sitefinity's Lucene search indexer builds the index by RENDERING each page server-side and crawling the output. Every widget therefore participates in search indexing by default - which is right for content widgets and wrong for anything personalized, session-dependent, expensive, or noisy (login boxes, dashboards, "hello {user}" headers, filter UIs). Two tools control this:
[IndexRenderMode(IndexRenderModes.NoOutput)] on the controller class (namespace Telerik.Sitefinity.Web.UI) - the widget renders NOTHING during an index pass, so none of its output pollutes the page's search entry. Use it whenever the widget's output should not be searchable.
- Detect index mode at runtime when only PART of the rendering should change: the indexer flags the request with
Page.Items["IsInIndexMode"]. Check HttpContext.Current?.Items != null && SystemManager.CurrentHttpContext?.Handler is Page page && page.Items["IsInIndexMode"] is bool inIndex && inIndex (wrap it in a small helper). Typical use: a JS-rendered widget emits plain crawlable HTML during indexing and its normal client mount point otherwise - the sitefinity-vue3-vite8-guide companion skill builds its whole indexing strategy on this flag.
Gotcha: the indexer does NOT execute JavaScript. A widget whose content arrives client-side (Vue/React data islands, AJAX loads) is invisible to search even without NoOutput - the index sees only the empty mount markup. Emit server-side HTML during index mode if that content must be findable.
ICustomWidgetVisualization
Show a "click to configure" placeholder in the page editor when unconfigured:
public class MyWidgetController : Controller, ICustomWidgetVisualization
{
public bool IsEmpty => string.IsNullOrEmpty(this.Title);
public string EmptyLinkText => "Click to configure widget";
}
Gotcha: [DefaultValue]-populated content properties defeat IsEmpty - the designer fills the property before the controller runs, so the placeholder never shows. Keep IsEmpty checks on properties WITHOUT [DefaultValue] or C# initializers.
Actions
public ActionResult Index()
{
var model = new MyWidgetModel { Title = this.Title };
return View("Default", model);
}
protected override void HandleUnknownAction(string actionName)
{
View("Default", this.GetModel()).ExecuteResult(this.ControllerContext);
}
Design-mode checks (skip expensive work in the page editor): SystemManager.IsDesignMode, SystemManager.IsPreviewMode.
How widget properties ACTUALLY persist (verified platform behavior)
Public properties on the controller become designer fields. Understanding persistence prevents whole bug classes:
- Persistence is sparse via default-instance diffing. On save, Sitefinity compares each property against a freshly constructed
new YourController() and writes a database row ONLY for values that differ. Consequences:
- A freshly dropped widget persists almost nothing - unset properties resolve from your C# defaults at runtime.
- Changing a property's C# default later retroactively changes every existing widget instance that was saved with the old default - no row was ever written for it, so they all silently pick up the new default. Treat shipped defaults as a contract; if you must change one, audit existing instances first.
- The
[DefaultValue] attribute is NOT the persistence baseline (it seeds the designer UI); the C# property initializer is what the diff runs against.
- Values are invariant-culture strings (
TypeConverter.ConvertToInvariantString): "True", "42". Custom types need a TypeConverter that round-trips invariant strings.
- Complex types MUST be marked
[PropertyPersistence(PersistAsJson = true)] (Telerik.Sitefinity.Modules.Pages.PropertyPersisters). With it, the whole object graph stores as one JSON value. Without it, each list item persists as its own database object with per-property rows, and a list item whose values all equal that item type's defaults produces ZERO rows and silently vanishes on save. This is the #1 "my widget property doesn't stick" bug.
using Telerik.Sitefinity.Modules.Pages.PropertyPersisters;
[PropertyPersistence(PersistAsJson = true)]
public List<FeatureItem> Features { get; set; } = new List<FeatureItem>();
[PropertyPersistence(PersistAsJson = true)]
public MixedContentContext HeroImage { get; set; }
[Browsable(false)] hides a property from the designer AND from persistence - use for computed/derived properties.
Designer attribute namespaces (MVC / .NET Framework 4.8)
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using Telerik.Sitefinity.Web.Services.PropertyEditor.Attributes;
using Telerik.Sitefinity.Frontend.Mvc.Infrastructure.Controllers.Attributes;
using Telerik.Sitefinity.Modules.Pages.PropertyPersisters;
using Progress.Sitefinity.Renderer.Designers;
using Progress.Sitefinity.Renderer.Designers.Attributes;
using Progress.Sitefinity.Renderer.Entities;
using Progress.Sitefinity.Renderer.Entities.Content;
using Progress.Sitefinity.Renderer.Models;
(Reflection-verified on Renderer 15.4: KnownContentTypes lives in Progress.Sitefinity.Renderer.Designers.Attributes, NOT in ...Entities.Content. ConditionalVisibilityAttribute exists in BOTH Telerik.Sitefinity.dll and Progress.Sitefinity.Renderer.dll - either using works in MVC.)
Field types (C# type -> designer UI)
| C# Type | Designer UI | Notes |
|---|
string | Text input | Default for unknown types too |
bool | Yes/No radios | [DataType(customDataType: KnownFieldTypes.CheckBox)] for a checkbox |
int / double / nullable | Number input | |
DateTime / DateTime? | Date-time picker | [DateSettings(ShowTime = false)] for date-only |
enum | Dropdown | [Description] on members customizes labels; [Browsable(false)] hides a member |
[Flags] enum | Multi-select checkboxes | |
LinkModel | Link picker | See LinkModel section |
MixedContentContext | Content item selector | Requires [Content] / [TaxonomyContent] |
| Complex object | Expandable inline section | |
IList<T> | List builder | Add [TableView] for table layout |
IDictionary<K,V> | Key-value editor | |
Display attributes
[DisplayName("Hero Heading")]
[Description("Shown under the field")]
[DescriptionExtended("Extended", InlineDescription = "Inline hint", InstructionalNotes = "Notes")]
[Placeholder("Enter heading...")]
[DefaultValue("Build something amazing")]
[FallbackToDefaultValueWhenEmpty]
[Browsable(false)]
[ReadOnly(true)]
[DisplaySettings(Hide = true)]
[Mirror(nameof(Title), null)]
[Copy(Exclude = true)]
[Suffix("px")]
[ContentContainer]
[DynamicLinksContainer]
Validation (standard DataAnnotations)
[Required], [StringLength(100, MinimumLength = 5)], [MinLength]/[MaxLength], [Range(1, 20)], [EmailAddress], [Url], [RegularExpression(@"^#[0-9A-Fa-f]{6}$")] - all with ErrorMessage support and {0}/{1} placeholders.
Sections, views, grouping
[Category(PropertyCategory.Advanced)]
public string CssClass { get; set; }
[ContentSection("Content", 0)]
public string Heading { get; set; }
[ContentSection("Appearance", 0)]
public string BackgroundColor { get; set; }
[SectionsOrder("Content", "Appearance", "Layout")]
public class MyWidgetController : Controller { ... }
[Group("Display Options")]
[DataType(customDataType: KnownFieldTypes.CheckBox)]
public bool ShowTitle { get; set; }
Conditional visibility
Show/hide a field based on another property's value - JSON condition string:
[ConditionalVisibility("{\"conditions\":[{\"fieldName\":\"Template\",\"operator\":\"Equals\",\"value\":\"Split\"}]}")]
public string BackgroundImageUrl { get; set; }
[ConditionalVisibility("{\"conditions\":[{\"fieldName\":\"ShowSecondaryButton\",\"operator\":\"Equals\",\"value\":\"true\"}]}")]
public string SecondaryButtonText { get; set; }
MVC namespace: Telerik.Sitefinity.Web.Services.PropertyEditor.Attributes; ASP.NET Core: Progress.Sitefinity.Renderer.Designers.Attributes.
Choice fields
[DataType(customDataType: KnownFieldTypes.ChipChoice)]
[Choice("[{\"Title\":\"1 column\",\"Name\":\"1\",\"Value\":1,\"Icon\":null},{\"Title\":\"2 columns\",\"Name\":\"2\",\"Value\":2,\"Icon\":null}]")]
public int? ColumnCount { get; set; }
[DataType(customDataType: KnownFieldTypes.Choices)]
[Choice("[{\"Title\":\"1 item\",\"Name\":\"1\",\"Value\":1,\"Icon\":null}]")]
public int? ItemsPerRow { get; set; }
[DataType(customDataType: KnownFieldTypes.RadioChoice)]
public TextAlignment Alignment { get; set; }
[Choice] extras: ServiceUrl (fetch options from an endpoint at design time), ServiceCallParameters, ServiceWarningMessage, ShowFriendlyName, SideLabel, ChipMaxThreshold, NotResponsive. Enum members take [Description("Label")] or [EnumDisplayName("Label")], [Icon("columns")] (Font Awesome name without fa-), and [Browsable(false)] to hide.
Content selectors (MixedContentContext)
Always pair with [PropertyPersistence(PersistAsJson = true)]:
[Content(Type = KnownContentTypes.Images, AllowMultipleItemsSelection = false)]
[PropertyPersistence(PersistAsJson = true)]
public MixedContentContext HeroImage { get; set; }
[Content(Type = "Telerik.Sitefinity.DynamicTypes.Model.MyModule.MyType")]
[PropertyPersistence(PersistAsJson = true)]
public MixedContentContext FeaturedItems { get; set; }
[TaxonomyContent(Type = KnownContentTypes.Categories)]
[PropertyPersistence(PersistAsJson = true)]
public MixedContentContext Categories { get; set; }
[Content] options (reflection-verified): Type, AllowMultipleItemsSelection (default true), AllowCreate (default true), DisableInteraction, ShowSiteSelector, LiveData (published only), Provider, plus IsFilterable, OpenMultipleItemsSelection, ForceShouldShowAll, TypeBlacklist, and the ManualSelection* family (MainFieldName, TabTitle, IconClass, BreadcrumbText, EntityType).
Resolving selections in the controller - the pattern is always ManagerBase.GetItems:
using Telerik.Sitefinity.Data;
using Telerik.Sitefinity.GenericContent.Model;
var images = ManagerBase.GetItems(this.HeroImage, KnownContentTypes.Images)
.OfType<Telerik.Sitefinity.Libraries.Model.Image>()
.Where(i => i.Status == ContentLifecycleStatus.Live)
.ToList();
KnownContentTypes constants cover Pages, Images, Videos, Documents, News, Events, BlogPost/Blogs, Forms, Albums, Tags, Categories, GenericContent, Lists/ListItems, Calendars, DocumentLibraries, VideoLibraries, Folders, PageTemplates, Sites, Taxonomies.
Links: LinkModel (always, instead of text+url string pairs)
Progress.Sitefinity.Renderer.Models.LinkModel gives editors a proper picker (page selection, target, tooltip, anchor) in ONE field. Properties: Href, Text, Target, Tooltip, Sfref, Id, Type, QueryParams, Anchor, ClassList, Attributes.
[DataType(customDataType: "linkSelector")]
public LinkModel PrimaryButton { get; set; }
var text = this.PrimaryButton?.Text ?? "Learn more";
var href = this.PrimaryButton?.Href ?? "#";
var newTab = this.PrimaryButton?.Target == "_blank";
LinkModel[] works for multiple links; [Required] works on it.
Lists of complex objects: [TableView]
public class FeatureItem
{
[ContentSection(1)]
[DisplayName("Title")]
public string Title { get; set; }
[ContentSection(2)]
[DisplayName("Description")]
public string Description { get; set; }
}
[TableView(Reorderable = true, ColumnCount = 1)]
[PropertyPersistence(PersistAsJson = true)]
public IList<FeatureItem> Features { get; set; } = new List<FeatureItem>();
[TableView] options (reflection-verified): ColumnTitle (or ctor arg), Enabled, Reorderable, Selectable, MultipleSelect, AllowEmptyState, AddManyFileName, ColumnCount (0 = all), HideRowsIfEmpty. [ContentSection(N)] on sub-object properties is 1-based - 0 is treated as unpositioned and floats to the end. The pencil-icon popup editor is internal to the AdminApp's sf-editable-table; you get it for free with IList<T> + [TableView], but it is not reusable from custom field editors (those use SelectorService.openDialog() from the AdminApp SDK instead).
Complex sub-objects also support [InitialValue("Text", "Click me")] (seed sub-property values on row creation).
Special types and DataType values
[DataType(customDataType: KnownFieldTypes.Html)] public string RichContent { get; set; }
[DataType(customDataType: KnownFieldTypes.TextArea)] public string LongText { get; set; }
[DataType(customDataType: KnownFieldTypes.Color)] public string Background { get; set; }
[DataType(customDataType: KnownFieldTypes.Password)] public string ApiKey { get; set; }
[DataType(customDataType: KnownFieldTypes.Range)] [Suffix("px")] public NumericRange Padding { get; set; }
[DataType(customDataType: KnownFieldTypes.FileTypes)] public FileTypes AllowedFiles { get; set; }
[MediaItem("images", false, false)] public string HeroImageId { get; set; }
[DateSettings(ShowTime = false)] public DateTime PublishDate { get; set; }
KnownFieldTypes string values are lowercase/camelCase ("html", "textArea", "choiceYesNo", "radioChoices", "chipchoice", "choices", "choiceMultiple", "multipleChoiceChip", "color", "range", "attributes", "fileTypes", "dropdownWithText") - use the constants; a wrong-cased raw string silently falls back to a plain text input. Lesser-known attributes: [Dialog(json)], [IsNullable(true)], [LengthDependsOn(...)], [MappedType(DataType = "sf-long-text")], [RangeLimitation(...)].
[ColorPalette] is ASP.NET Core renderer ONLY - reflection-verified ABSENT from every assembly in a Sitefinity 15.4 MVC site (it ships in the Progress.Sitefinity.AspNetCore packages, not Progress.Sitefinity.Renderer.dll). In MVC use [DataType(customDataType: KnownFieldTypes.Color)] for a free color picker, or a [Choice] list of brand colors for a constrained palette.
Template selection: [ViewSelector] and its limitation
[ViewSelector] renders a dropdown of available views - but it only discovers views in ResourcePackages theme folders (ResourcePackages/{Theme}/MVC/Views/{Widget}/). If your widget views live in the project's Mvc/Views/ folder, the dropdown is EMPTY. The reliable alternative is an enum:
public enum MyWidgetTemplate
{
[Description("Centered")] Centered,
[Description("Split (Text + Image)")] Split
}
[DisplayName("Template")]
[DefaultValue(MyWidgetTemplate.Centered)]
public MyWidgetTemplate Template { get; set; } = MyWidgetTemplate.Centered;
[Browsable(false)]
public string TemplateName => $"MyWidget.{this.Template}";
public ActionResult Index() => View(this.TemplateName, this.GetModel());
View conventions
Mvc/Views/MyWidget/
Default.cshtml (main view; alternates: MyWidget.Split.cshtml etc.)
DesignerView.Simple.cshtml (custom designer dialog, optional)
Resources/
widget.js
widget.css
@model My.Namespace.Mvc.Models.MyWidgetModel
@using Telerik.Sitefinity.Frontend.Mvc.Helpers; @* required for Html.Script/StyleSheet *@
@Html.Script("/Mvc/Views/MyWidget/Resources/widget.js?v=1.0", "bottom") @* dedupes; "bottom"|"head"; ?v= for cache busting *@
@Html.StyleSheet("/Mvc/Views/MyWidget/Resources/widget.css", "head")
@Html.Partial("~/Mvc/Views/MyWidget/SharedScripts.cshtml")
Models are plain POCOs - no base class.
Custom designer views (DesignerView.Simple.cshtml, AngularJS)
When autogenerated fields aren't enough:
@model System.Web.UI.Control
<label>Title</label>
<input type="text" ng-model="properties.Title.PropertyValue" class="form-control" />
@* Booleans are STRING "True"/"False" in the property bag *@
<input type="checkbox" ng-model="properties.ShowDetails.PropertyValue"
ng-true-value="'True'" ng-false-value="'False'"
ng-checked="properties.ShowDetails.PropertyValue === 'True'" />
<select ng-model="properties.TemplateName.PropertyValue" class="form-control">
<option value="Default">Default</option>
</select>
<sf-image-field sf-model="properties.ImageId.PropertyValue" />
<sf-html-field sf-model="properties.Instructions.PropertyValue" class="kendo-content-block" />
<textarea sf-code-area sf-type="htmlmixed" sf-model="properties.HtmlContent.PropertyValue"></textarea>
<expander expander-title='@Html.Resource("MoreOptions")'>...</expander>
<uib-tabset class="nav-tabs-wrapper"><uib-tab heading="Content">...</uib-tab></uib-tabset>
The "True"/"False" string convention is the invariant-string persistence showing through - everything in properties.*.PropertyValue is a string.
Documentation links