Code-only Controls
This kind of controls is useful when you need to render a piece of HTML and/or you need to support data-binding and manipulate with the viewmodel.
Building code-only controls is more difficult, but they can do much more things. All controls built-in DotVVM are implemented as code-only controls.
If you want to learn about how to write controls in DotVVM, we encourage you to look in the GitHub repository how the built-in controls are implemented.
Control Registration
First, you need to register the code-only control in the DotvvmStartup.cs
file.
config.Add(new DotvvmControlConfiguration()
{
TagPrefix = "cc",
Namespace = "DotvvmDemo.Controls",
Assembly = "DotvvmDemo"
});
Using this code snippet, if you use the <cc:
tag prefix, DotVVM will search for the control in the specified namespace and assembly.
If you register a markup control with code behind like this, it won't work. If the control has a markup, it must be registered using the
AddMarkupControl
method.
Basics
All controls in DotVVM
derive from DotvvmControl
class. This base class provides only
basic functionality and it is not a good base class to inherit directly for most purposes.
The most useful class to be derived from is HtmlGenericControl
. It is prepared to render one HTML element (which can contain child elements of course). Most built-in controls in DotVVM derive from HtmlGenericControl
class.
Creating Code-only Control
The best example to learn how to write controls in DotVVM is to look how the built-in controls are implemented. Let's begin with the TextBox.
The textbox in HTML (with Knockout JS binding) looks like this:
<input type="text" data-bind="value: FirstName" />
This is what we'd like to render when we see <dot:TextBox Text="{value: FirstName"}" />
.
So let's create a class that derives from HtmlGenericControl
. In the constructor, we call the base constructor and tell the name of the HTML element - in our case we want input
.
public class TextBox : HtmlGenericControl
{
public TextBox() : base("input")
{
}
}
This would render just <input></input>
in the page. Also, if you add any custom attributes (e.g. style
, class
) on the TextBox
control, it would append them in the page. The HtmlGenericControl
takes care about all this stuff for you.
Rendering Pipeline
Now, the HtmlGenericControl
has 4 methods which we can override to modify the rendered HTML. They are called in this order:
AddAttributesToRender
- by default, this method takes all HTML attributes set to the control, and prepares them to be rendered.RenderBeginTag
- by default, this method renders the begin tag.RenderContents
- by default, this method renders the child controls.RenderEndTag
- by default, this method renders the end tag.
HtmlWriter
Now, let's see how to render the HTML element. In DotVVM, we use the HtmlWriter
to generate HTML. To render the <input type="text" />
we need to call something like this:
writer.AddAttribute("type", "text");
writer.RenderSelfClosingTag("input");
There are also methods RenderBeginTag("input")
, RenderEndTag()
, WriteText("some text")
or WriteUnencodedText("some HTML")
.
The AddAttribute
method is called before rendering the tag and it also has a third argument called append
. If you call AddAttribute("class", "blue")
and then AddAttribute("class", "red", true)
, the class will be appended.
The HtmlWriter
knows that values in the class
HTML attribute are separated by spaces, values in the style
attribute by semicolons etc. You can also specify your own separator character as the fourth argument.
Rendering HTML
Let's continue with the TextBox class. We don't want to render begin and end tags <input></input>
, but the self closing one <input />
.
It doesn't make sense to allow the TextBox
to have any content inside. We can decorate the class with the [ControlMarkupOptions(AllowContent = false)]
attribute to tell DotVVM that there should be no content inside the <dot:TextBox>
and </dot:TextBox>
tags. If the user places anything there, DotVVM will display an error page.
We can override the RenderBeginTag
method to render the self-closing tag, and the RenderEndTag
to render nothings.
Between these two methods, the rendering pipeline calls also the RenderContents
method which renders the contents between the <dot:TextBox>
and </dot:TextBox>
tags, but we won't have anything here thanks to the ControlMarkupOptions
attribute.
protected override void RenderBeginTag(IHtmlWriter writer, IDotvvmRequestContext context)
{
// TagName contains the value passed to the base constructor.
// We don't want to call base.RenderBeginTag here because it would render the begin tag and then the closing tag.
// We want the self closing tag.
writer.RenderSelfClosingTag(TagName);
}
protected override void RenderEndTag(IHtmlWriter writer, IDotvvmRequestContext context)
{
// do nothing, we have already rendered the self-closing tag
}
Rendering HTML attributes
The most interesting is the AddAttributesToRender
method.
The default implementation takes all HTML attributes that are not mapped to DotVVM properties, and add them to the HtmlWriter
. So, if the user uses the following snippet, the default implementation of AddAttributesToRender
will add the class
, style
and placeholder
attributes
to the HtmlWriter
.
Remember that
HtmlWriter
requires to add all attributes before we callRenderBeginTag
. After you render a tag, you cannot go back and add any attributes to it.
The custom attributes even support data-bindings, so you don't have to care about this. You just need to take care of the control properties.
<dot:TextBox Text="{value: FirstName}" style="border: none" class="txb1" placeholder="Enter first name" />
We need to declare the Text
property first:
public string Text
{
get { return Convert.ToString(GetValue(TextProperty)); }
set { SetValue(TextProperty, value); }
}
public static readonly DotvvmProperty TextProperty =
DotvvmProperty.Register<string, TextBox>(t => t.Text, "");
However, we should support two scenarios:
<dot:TextBox Text="{value: FirstName}" />
<dot:TextBox Text="Test" />
In the first case, we need to render data-bind="value: FirstName"
, in the second case we need to render value="Test"
.
We can solve this like this:
protected override void AddAttributesToRender(IHtmlWriter writer, IDotvvmRequestContext context)
{
var textBinding = GetValueBinding(TextProperty);
if (textBinding != null)
{
// the property contains binding - this will render data-bind="value: expression"
writer.AddKnockoutDataBind("value", this, TextProperty);
}
else
{
// render the value in the HTML
writer.AddAttribute("value", Text);
}
writer.AddAttribute("type", "text");
base.AddAttributesToRender(writer, context);
}
Because this pattern is quite usual and in most controls you would have written the if
statement checking the presence of binding and rendering the appropriate output, there is an overload of the AddKnockoutDataBind
method with four arguments.
It allows you to specify a function which is called when the specified property doesn't contain a binding.
So we could simplify the function above like this:
protected override void AddAttributesToRender(IHtmlWriter writer, IDotvvmRequestContext context)
{
writer.AddKnockoutDataBind("value", this, TextProperty, () => {
writer.AddAttribute("value", Text);
});
writer.AddAttribute("type", "text");
base.AddAttributesToRender(writer, context);
}
Thanks to this, the syntax is much shorter.
Building Control Tree
Rendering HTML using the HtmlWriter
class is good for simple controls. If the control is more complicated or can contain controls which invoke postbacks, you need to build a control tree inside the control.
This is especially handy if you need to compose a complex control of already existing ones.
This approach often results in a cleaner code, but rendering the HTML using the HtmlWriter
is much faster than creating a control for the <div>
element using new HtmlGenericControl("div")
.
You need to decide if rendering raw HTML is OK for your case, or if the control is more complex and you need to build a tree of child controls and manipulate with them somehow.
Composite Control
Let's create a control that is composed of two existing controls (TextBox
and Literal
) placed inside a div
element.
We will create a new class which derives from HtmlGenericControl
and renders a div
:
public class TextBoxWithLabel : HtmlGenericControl
{
public TextBoxWithLabel() : base("div")
{
}
}
Next, let's add the Text
and LabelText
properties.
Both of them are required. We can indicate this by using the MarkupOptions
attribute. The attribute can also specify whether the property can contain a data-binding or a hard-coded value or both. By default, it can contain both of them.
[MarkupOptions(AllowHardCodedValue = false)]
public string Text
{
get { return (string)GetValue(TextProperty); }
set { SetValue(TextProperty, value); }
}
public static readonly DotvvmProperty TextProperty
= DotvvmProperty.Register<string, TextBoxWithLabel>(c => c.Text, null);
[MarkupOptions(AllowBinding = false)]
public string LabelText
{
get { return (string)GetValue(LabelTextProperty); }
set { SetValue(LabelTextProperty, value); }
}
public static readonly DotvvmProperty LabelTextProperty
= DotvvmProperty.Register<string, TextBoxWithLabel>(c => c.LabelText, null);
Child Controls
Similarly to the viewmodels, every control has lifecycle events OnInit
, OnLoad
and OnPreRender
which follow the logic of the viewmodel Init
, Load
and PreRender
events.
A basic rule is to create the controls as soon as possible. If you don't need data from the viewmodel (which are deserialized after the Init
event), build the child controls in the OnInit
phase. If you rely on values entered by the user, build the controls in the OnLoad
phase.
protected override void OnInit(IDotvvmRequestContext context)
{
var textBox = new TextBox();
// copy the binding from the control's Text property to the TextBox.Text property
textBox.SetBinding(TextBox.TextProperty, GetValueBinding(TextProperty));
// we can set LabelText now, it cannot contain binding
var label = new Literal(LabelText);
// the controls are always the same, they don't depend on the viewmodel data, so we can use the OnInit event
Children.Add(label);
Children.Add(textBox);
base.OnInit(context);
}
After the Load
phase, the commands are executed and the control tree must be complete at that moment. Moreover, the control tree must be equal as it was in the previous postback, otherwise DotVVM won't be able to find the control which triggered the postback. DotVVM validates postback information and if the control doesn't exist in the page, an error page shows up and the postback is not processed.