Part 2: Have you tried the new composite controls?

|
Publikováno:

In the previous part, you could learn about the new approach for creating custom controls in DotVVM – the Composite controls. I’ve shown how to declare a composite control, how the new type ValueOrBinding<T> works, and how to work with templated controls.

In this part, I’d like to show another feature – Control capabilities.

Sharing properties between controls

You’ve probably met a lot of controls which have similar properties. For example, look at ComboBox and ListBox – they both have DataSource, ItemTextBinding and other properties. In case of classic controls, this is typically done by inheritance. Both controls inherit from SelectorBase class (which defines the ItemTextBinding property) and this class inherits from ItemsControl (which defines the DataSource) property.

The problem of this approach is that you often cannot use inheritance. One reason is that .NET doesn’t support inheriting from multiple classes, so you cannot “import” properties from two different base classes. Another reason is when you have two controls which should have similar API even if they don’t have anything in common.

Extracting patterns of usage

If you use the Button control, you have two options on how to set the content of the button:

<dot:Button Click="..." Text="Save changes" />

<dot:Button Click="...">
    <i class="fa fa-disk"></i> Save changes
</dot:Button>

If you want to set just a text content, you can use the Text property (of type string). To set a HTML content (which may include another DotVVM controls), you can just enter the content inside the control. This way it will get into its Children collection. The control also contains a usage validation which will throw an error if you try to set the content using both ways.

A lot of controls uses the same approach – for example CheckBox. You can also either set the Text property or write the contents inside, and there is the same validation involved – only one of these options can be used.

Package a usage pattern with validation

A control capability allows you to declare a “group of properties” that belong together, and use them in a control. Here is how you can make the usage pattern we mentioned in the previous section:

[DotvvmControlCapability]
public sealed record TextOrContentCapability
{
    public ValueOrBinding<string>? Text { get; init; }
    public List<DotvvmControl>? Content { get; init; }

    public IEnumerable<DotvvmControl> ToControls()
    {
        if (!Text.HasValue && Content == null)
        {
            throw new DotvvmControlException("Either Text property or Content must be set.");
        }

        if (Text.HasValue)
            return new DotvvmControl[] { new Literal(Text.Value) };
        else
            return Content;
    }
}

This class is actually part of DotVVM framework itself (the code snippet has been shortened for clarity), but you can implement your own capabilities like this. There are couple of things to notice:

  • Each property represents a DotVVM property that will be “generated” in the control that uses this capability. You can use plain get/init properties, no need to use the long DotVVM property declarations.
  • The capability must be a class or a record and must be sealed. We don’t support inheriting capabilities, but you can use a capability inside another capability.
  • You can declare any methods that the controls can use. We’ve added the ToControls method which performs the usage validation and returns either a Literal with the Text, or the controls specified.
  • There is a similar “heuristic” as in the composite controls to figure out the MarkupOptions and other features of the control. If the property is ValueOrBinding, both hard-coded value and a binding will be accepted. If the property is a collection of controls, it will be declared as an inner element. If the property name is Content or ContentTemplate, it will automatically map to the children of the control if not specified otherwise using the ControlMarkupOptions attribute.

Now let’s use the capability to create a simple Panel control:

public class Panel : CompositeControl
{
    public static DotvvmControl GetContents(
        TextOrContentCapability body
    )
    {
        return new HtmlGenericControl("div")
            .AddCssClass("card")
            .AppendChildren(
                new HtmlGenericControl("div")
                    .AddCssClass("card-body")
                    .AppendChildren(
                        body.ToControls()
                    )
            );
    }
}

The control renders something like this:

<div class="card">
    <div class="card-body">
        TEXT OR CONTENT HERE – we are calling body.ToControls()
    </div>
</div>

You can use the control in your page using both options:

<cc:Panel Text="My panel" />

<cc:Panel>
    My <strong>panel</strong>
</cc:Panel>

Using prefixes

What if we want to use the same capability multiple times in a control? For example, our Panel could also render a header where we would like to use the same logic, just use the HeaderText and HeaderContent properties.

We can do it easily using the DotvvmControlCapability attribute:

public class Panel : CompositeControl
{
    public static DotvvmControl GetContents(
        TextOrContentCapability body,

        [DotvvmControlCapability("Header")]
        TextOrContentCapability header
    )
    {
        return new HtmlGenericControl("div")
            .AddCssClass("card")
            .AppendChildren(
                new HtmlGenericControl("div")
                    .AddCssClass("card-header")
                    .AppendChildren(header.ToControls()),
                new HtmlGenericControl("div")
                    .AddCssClass("card-body")
                    .AppendChildren(body.ToControls())
            );
    }
}

You can then specify the HeaderText or HeaderContent inner property in the markup:

<cc:Panel HeaderText="Panel 1" Text="My panel" />

<cc:Panel>
    <HeaderContent>
        <h2>Panel 2</h2>
    </HeaderContent>

    My <strong>panel</strong>
</cc:Panel>

More complex scenarios

After we introduced the composite controls in DotVVM 4.0, we started using them on a new version of Bootstrap for DotVVM built on Bootstrap 5. We’ll publish the controls soon – during the implementation, we’ve discovered several issues and also many areas of improvement – all that will be released in DotVVM 4.1.

One capability we’ve implemented in our Bootstrap package allows the control to either specify a list of items in the markup, or via a DataSource property – it’s called ItemsOrDataSourceCapability. This is useful for scenarios where sometimes you need fixed data and sometimes you need to generate them from something in the viewmodel.

[DotvvmControlCapability]
public sealed record ItemsOrDataSourceCapability<TItem> where TItem : DotvvmControl
{
    public List<TItem>? Items { get; init; }
    public IValueBinding<object>? DataSource { get; init; }

    public HtmlGenericControl ToItemsOrRepeater(string wrapperTagName, Func<TItem> generateItem)
    {
        if (DataSource != null && Items?.Any() == true)
        {
            throw new DotvvmControlException("The Items in the control and the DataSource property cannot be set at the same time!");
        }

        if (DataSource != null)
        {
            return new Repeater()
                {
                    WrapperTagName = wrapperTagName,
                    ItemTemplate = new DelegateTemplate(_ => generateItem())
                }
                .SetProperty(r => r.DataSource, DataSource);
        }
        else
        {
            return new HtmlGenericControl(wrapperTagName)
                .AppendChildren(Items);
        }
    }
}

You can see that the capability has Items and DataSource properties, both nullable (thus, DotVVM will not complain if they are not set).

In the ToItemsOrRepeater method, we’ll check which of these properties is set, and decide what to do:

  • If Items are set, we’ll create a wrapper control with a specified wrapperTagName (e. g. <ul>) and just place the items inside this control.
  • If DataSource is set, we’ll create a Repeater, pass it the DataSource and set the WrapperTagName. The most interesting thing is the generateItem method – it tells the Repeater how the individual item should be created. We’ll see how it’s used later.

Before we continue, let’s see how a control using this capability can be used. In this example, I want to create a simple menu control. It can either specify the items in the markup, or get a DataSource of any custom objects (this is important – we don’t want to force the user to use our type of menu item class) and get some instructions where it can find the title, URL and whether the item is active or not.

<h2>Hard-coded</h2>
<cc:Menu>
    <cc:MenuItem Text="Page 1" Url="/Menus/1" IsActive="{value: ActivePageIndex == 1}" />
    <cc:MenuItem Text="Page 2" Url="/Menus/2" IsActive="{value: ActivePageIndex == 2}" />
    <cc:MenuItem Text="Page 3" Url="/Menus/3" IsActive="{value: ActivePageIndex == 3}" />
</cc:Menu>

<h2>Generated</h2>
<cc:Menu DataSource="{value: MenuItems}"
         ItemText="{value: Title}"
         ItemUrl="{value: "/Menus/" + Id}"
         ItemIsActive="{value: Id == _root.ActivePageIndex}">
</cc:Menu>

The collection MenuItems in the viewmodel contains my custom objects. It should be possible even to map it to a collection of string or any other type – same as when you use the Repeater control.

public List<PageEntry> MenuItems { get; set; } = new()
{
    new PageEntry() { Title = "Page 1", Id = 1 },
    new PageEntry() { Title = "Page 2", Id = 2 },
    new PageEntry() { Title = "Page 3", Id = 3 }
};

Now let’s start with implementing the MenuItem control first. It’s quite simple and there is nothing we wouldn’t see before.

public class MenuItem : CompositeControl
{
    public static DotvvmControl GetContents(
        MenuItemCapability props)
    {
        return new HtmlGenericControl("li")
            .AddCssClass("nav-item")
            .AppendChildren(
                new HtmlGenericControl("a")
                    .AddCssClass("nav-link")
                    .SetProperty(HtmlGenericControl.CssClassesGroupDescriptor.GetDotvvmProperty("active"), props.IsActive)
                    .SetAttribute("href", props.Url)
                    .SetProperty(HtmlGenericControl.InnerTextProperty, props.Text)
            );
    }
}

[DotvvmControlCapability]
public sealed record MenuItemCapability
{
    public ValueOrBinding<string> Text { get; set; }
    public ValueOrBinding<string> Url { get; set; }
    
    [DefaultValue(true)]
    public ValueOrBinding<bool> IsActive { get; set; }
}

The only special thing here is that I’ve put all the control properties in a capability. That’s because I want to reuse the same capability for the ItemText, ItemUrl and ItemIsActive properties in the Menu control.

Now let’s put all this together:

[ControlMarkupOptions(AllowContent = false, DefaultContentProperty = "Items")]
public class Menu : CompositeControl
{
    public static DotvvmControl GetContents(
        ItemsOrDataSourceCapability<MenuItem> itemsOrDataSource,

        [DotvvmControlCapability("Item")]
        [ControlPropertyBindingDataContextChange("DataSource", order: 0)]
        [CollectionElementDataContextChange(order: 1)]
        MenuItemCapability itemProps)
    {
        return itemsOrDataSource
            .ToItemsOrRepeater(
                wrapperTagName: "ul",
                generateItem: () => new MenuItem().SetCapability(itemProps))
            .AddCssClasses("nav", "nav-pills", "mb-4");
    }
}

You can see that the Menu control contains the ItemsOrDataSourceCapability of type MenuItem. Also, the ControlMarkupOptions attribute tells DotVVM that if the user puts anything inside the control, it will go to the Items collection (which is declared in the capability).

The Menu control also specifies a second property of type MenuItemCapability.

  • The DotvvmControlCapability attribute specifies the “Item” prefix for the capability properties – that’s how we’ll get to ItemText, ItemUrl and ItemIsActive in the markup.
  • There are also DataContext change attributes. If the user puts a binding into ItemText property, the DataContext of the binding will not be the same as the DataContext of the control.
    • First, we’ll look at the binding passed into the DataSource property and extract the type from there (because the property is declared as object, we need to inspect the binding). That’s what the ControlPropertyBindingDataContextChange attribute does – it will find out that there is List<PageEntry> specified in our sample.
    • Second, we’ll look at the collection and take the type of the element – PageEntry in our case.
    • The binding set to any property in the capability will assume that the DataContext is of type PageEntry (and the _parent is the control’s DataContext). This is what we want so we can set ItemIsActive="{value: Id == _root.ActivePageIndex}" on the control. The Id for each menu item will come from the current PageEntry object.

The last piece is just to call ToItemsOrRepeater, tell it that it should wrap the list in <ul> element, and pass it the generateItem function. The function should return what will be in the ItemTemplate of the Repeater. We want MenuItem there, and we want to set its properties to the bindings the user specified on the Menu control. Instead of setting the properties one by one, we can just call SetCapability.

Finally, no matter whether we built a Repeater or just a bunch of HtmlGenericControls, we’ll apply some CSS classes on the element.


That’s enough for the second part. We’ll be happy if you try to build your own composite controls and give us feedback. We’ve found this new way of building controls quite compelling, and we’d like to continue improving this method.

You can find all the sample code in this GitHub repository.

Tomáš Herceg
Tomáš Herceg

BIO: 

I am the CEO of RIGANTI, small software development company located in Prague, Czech Republic.

I am a Microsoft Regional Director and Microsoft Most Valuable Professional.

I am the author of DotVVM, an open source .NET-based web framework which lets you build Line-of-Business applications easily and without writing thousands lines of Javascript code.

Ostatní články z kategorie: DotVVM Blog