Using Composite Controls for the implementation of Bootstrap 5 components

Published: 3/21/2023 11:49:39 AM

You may already be aware that DotVVM 4.0 has introduced a new feature called Composite Controls, which provides a new way to write controls in DotVVM. This approach eliminates the need to declare DotVVM properties in code-only controls, for which you had to use the dotprop code snippet and work with long property declaration blocks. Instead, it only requires declaring a `GetContents` method and placing individual control properties as arguments of the method. By using this approach, the control code appears much more attractive and readable.

For instance, consider the following example of a Bootstrap Badge control:

public class Badge : BootstrapCompositeControl
{
     public static DotvvmControl GetContents(
         HtmlCapability htmlCapability,
         TextOrContentCapability textOrContentCapability,
         [DefaultValue(BadgeColor.Primary)] ValueOrBinding<BadgeColor> type,
         BadgeVisualStyle visualStyle = BadgeVisualStyle.Default,
         ICommandBinding? click = null
     )
     {
         return new HtmlGenericControl("span", textOrContentCapability, htmlCapability)
             .AddCssClass($"badge {visualStyle.ToEnumString()}")
             .AddCssClass(type.Select(t => t.ToEnumString()))
             .SetProperty(Events.ClickProperty, click);
     }
}

Right away, we can observe a few new types introduced by the composite control approach. The first argument type is HtmlCapability, which is a collection of properties utilized across multiple controls. DotVVM provide some composite control capabilities out of the box, such as HtmlCapability (containing common properties such as ID, Class-*, Style-*, or Visible) and TextOrContentCapability (which renders either Text (string-only value or binding) or Content properties (any hierarchy of HTML and other controls specified as inner elements). It also throws the DotvvmControlException when both of these properties are set – this helps to ensure that the syntax is clear and consistent.

The greatest benefit we learned is possible to do with your own capabilities to share logic between multiple controls. To achieve this, such capability classes must be marked with the DotvvmControlCapability attribute and be sealed. Since the classes are sealed, you cannot use inheritance, but you can easily include the child capability as one of the properties.

To illustrate, consider this example of CheckBoxCapability and the inner CheckBoxCoreCapability:

[DotvvmControlCapability()]
public sealed record CheckBoxCapability
{
    public CheckBoxCoreCapability CoreCapability { get; init; } = null!;

    [DefaultValue(null)]
    public IValueBinding<IEnumerable>? CheckedItems { get; init; }

    public CheckBox ToControls()
    {
        return CoreCapability.ToControls()
            .SetProperty(a => a.CheckedItems, CheckedItems);
    }
}


As you can see, the child capability is reused in the ToControl method. In many capabilities, we’ve created a convention of declaring a ToControl/ToControls method which builds the corresponding UI from the capability properties, which helps to encapsulate various pieces of the presentation logic.

The inner CheckBoxCoreCapability can be reused in multiple components – for example ButtonGroupCheckBox, FormControlCheckBox, and others. The CheckBoxCapability is used only in the CheckBox control as it adds several things which are relevant to the CheckBox control only.


Dealing with enums

Let's revisit the sample Badge control. Its next property is a new generic type called ValueOrBinding<T>. When the control is used in DotHTML files, the Type property can allow either a hard-coded value of the BadgeColor type (<bs:Badge Type={value: BadgeColor.Blue} />), or a data-binding from a view model (<bs:Badge Type={value: _root.MyBadgeColorProperty} />).

It’s also possible to allow only either hardcoded values (with composite control property of type BadgeColor) or binding only (with composite control property of type IValueBinding<BadgeColor>).


When it comes to handling enums, specifying an underlying CSS class is very easy with EnumMember attribute. Consider this example of a BadgeColor enum:
public enum BadgeColor
{
    [EnumMember(Value = "bg-primary")]
    Primary,
    [EnumMember(Value = "bg-secondary")]
    Secondary
}

The ToEnumString() extension method converts the EnumMember value to a specified string CSS class.

The properties of type ValueOrBinding<T> can use the Select extension method to transform the value or binding expression to something else: type.Select(t => t.ToEnumString()) 

Using of enums has another benefit – the DotVVM for Visual Studio extension automatically infers the allowed values in IntelliSense.

Limitations

The composite control approach of course has its limitations, as we discovered the hard way during the development of the Bootstrap 5 control pack.

Exclude a property

If a property defined in a capability needs to be excluded in a specific control for some reason, there are a few things that can be done. One approach is to create a new capability without the corresponding property and move the capability logic to a child capability.

Another approach is faster but less elegant. Even when using the composite approach to write code-only controls, properties can still be specified using the dotprop snippet. Then, a MarkupOptions attribute with a mapping mode of Exclude can be added, so the property is ignored. In the code bellow, the Style property is excluded.

public class CardImage : BootstrapCompositeControl, ICardItem
{
     [MarkupOptions(MappingMode = MappingMode.Exclude)]
     public CardImageStyle Style
     {
         get { return (CardImageStyle)GetValue(StyleProperty)!; }
         set { SetValue(StyleProperty, value); }
     }     public static readonly DotvvmProperty StyleProperty
         = DotvvmProperty.Register<CardImageStyle, CardImage>(c => c.Style, CardImageStyle.Default);     public IEnumerable<DotvvmControl> GetContents(
         HtmlCapability htmlCapability,
         ValueOrBinding<string>? alternateText,
         ValueOrBinding<string> imageUrl,
         ValueOrBinding<string>? toolTip,
         [MarkupOptions(AllowBinding = false, MappingMode = MappingMode.InnerElement)]
         CardBody? overlay = null
     )
     {
         yield return new HtmlGenericControl("img", htmlCapability)
             .SetAttribute("src", imageUrl)
             .SetAttribute("alt", alternateText)
             .SetAttribute("title", toolTip);         if (overlay is not null)
         {
             yield return overlay;
         }
     }
}

Composite Controls Cannot Contain ContentPlaceHolder

Keep in mind, that composite controls cannot contain ContentPlaceHolder controls, as the content for the master pages are resolved before the Load phase when the composite controls call their GetContents method.

For this reason, layout-like controls such as Container in Bootstrap 5 were written in an old-fashioned way.

Throwing a DotvvmControlException requires an instance

By default, the GetContents method should be static and can return either a collection of controls, or a single DotvvmControl instance. In case you want to throw an exception from your control, the standard way is to use the DotvvmControlException. However, the DotvvmControlException has an optional argument of type DotvvmBindableObject, and if you provide it, the error page highlights the specific line of code in the view if the control throws the exception.

To pass this agrument, the GetContents method should be then changed to instance method:

public DotvvmControl GetContents(HtmlCapability htmlCapability, DropDownCapability capability)
{        
    if (!capability.IsSplitButton)
    {
        throw new DotvvmControlException(this, "Cannot set parent reference.");
    }
}

Thinking is a composite way

The new composite control approach encourages a different way of thinking. By reusing capabilities and helper methods, the way of how can we build a DOM tree is much more versatile. Things like validation of a specific properties, that work together can be shifted to the separate capability logic and aligned with a different controls scenarios.

The Fluent API methods like AddCssClass, AddAttribute or AppendChildren allow to produce fairly advanced control logic with just a few lines of code.

If you are interested in more info about the Composite controls, see the recent blog post series:


Maxim Dužij