Extend static command capabilities with JavaScript translations

|
Publikováno:

Static commands are a powerful feature of DotVVM which lets you modify the viewmodel without calling the server. This is possible because DotVVM can translate some C# calls into JavaScript snippets. We don’t involve WebAssembly in this process – the translation is just a simple transform of the C# expression to a JS one, and the list of supported syntax constructs and APIs is limited.

Sometimes, the behavior of the translated expression is slightly different. The most visible nuance is that DotVVM bindings are propagating null values, so the C# expression Object.SomeProperty will be treated as Object?.SomeProperty.

Providing custom translations

You can register your own JavaScript translations to let DotVVM translate methods it doesn’t know about. You can use this mechanism to either allow calling your custom JS code from bindings, or to just fill the gaps in the methods DotVVM supports out of the box.

Imagine you want to create a GridView with an option to select the rows. Each row will have the IsSelected property to indicate whether the row is selected. The CheckBox in the header cell should be able to select or deselect all rows, and ideally without calling the server.

The data-bindings don’t support foreach loops, however the List<T> class has the ForEach method which accepts a lambda function. DotVVM cannot translate it out of the box, but you can extend the translation quite easily.

<dot:GridView DataSource="{value: Entries}">
    <dot:GridViewTemplateColumn>
        <HeaderTemplate>
            <dot:CheckBox Checked="{value: Entries.All(e => e.IsSelected)}"
                          Changed="{staticCommand: var newState = !Entries.All(e => e.IsSelected); Entries.ForEach(e => e.IsSelected = newState)}"/>
        </HeaderTemplate>
        <ContentTemplate>
            <dot:CheckBox Checked="{value: IsSelected}" />
        </ContentTemplate>
    </dot:GridViewTemplateColumn>

    <dot:GridViewTextColumn ValueBinding="{value: Name}" HeaderText="Item" />
</dot:GridView>

You can see that the CheckBox in the table header first determines whether all rows are already selected. Then, it uses ForEach to set the IsSelected property to the new state.

How the viewmodels looks like in JavaScript

On the client, the viewmodel is represented as a hierarchy of Knockout observables. You can imagine the observable as a C# property represented by function which implements both get and set functionality. If you call the function without parameters, it is a getter. If you call it with one parameter, it is a setter.

const viewModel = {
  SomeProperty: ko.observable("initial value")
};

// get
const value = viewModel.SomeProperty();

// set
viewModel.SomeProperty("new value");

The viewmodel in JS will have the same structure as the viewmodel in C# (it is just JSON-serialized), but each expression is wrapped in these Knockout observables, so to read the value of Object.SomeProperty you’ll need to emit Object().SomeProperty(). DotVVM already contains an API to build these expression – you can find it in the DotVVM.Framework.Compilation.Javascript.Ast, so there is no need to build these pieces of JS code yourself. The expressions can be combined and nested, and the engine will add parentheses automatically based on the operator priority. This leads to a much cleaner output code.

What we need to emit

In our case, we need to translate the expression Entries.ForEach(e => e.IsSelected = newState) to a forEach method on a JS array – in Knockout syntax, it will be something like vm.Entries().forEach(e => e().IsSelected(newState)). Since DotVVM can already translate the inner lambda and the expression on which we call Entries, the only remaining bit is to provide the mapping of the List<T>.ForEach method to calling the forEach function.

In DotvvmStartup, we can add the following registration:

config.Markup.JavascriptTranslator.MethodCollection.AddMethodTranslator(
    typeof(List<>).GetMethod("ForEach"),   // method to translate
    new GenericMethodCompiler(             // args[0] is this, the others are the arguments passed to the method
        args => args[0].Member("forEach").Invoke(args[1])
          .WithAnnotation(ShouldBeObservableAnnotation.Instance)
));

The first argument is the method we are translating. Since we don’t know the concrete type in the list and the translation would work for all list types, we can use open generic definition.

The second argument is GenericMethodCompiler which tells DotVVM how to translate the method call. Basically, it needs a lambda which accepts an array of JsExpression objects (first item is this, the other items are the arguments passed to the function), and it should bind these objects together.

In our case, args[0] will contain the JsExpression representing the Entries part of the expression – the object on which we are calling the method. It is a List<T> in C#, and it will be represented as an array in JavaScript.

We need to call the forEach function on this JS array, so we’ll call .Member(“forEach”).Invoke(…). These methods are building an expression tree – the first one adds “.forEach”, the second one adds the parentheses and the arguments. The method has only one argument – the lambda which we should have in args[1] because it was the only argument passed to the C# ForEach method.

The last important thing is that we need to add ShouldBeObservableAnnotation. Annotations are a way to tell DotVVM what it can expect on various places in the expression tree. If we use this annotation, DotVVM will make sure that the arguments will be passed as Knockout observables (if possible), so the lambda could change them. If we don’t use the annotation, DotVVM would use dotvvm.state on the JS side (it is a snapshot of the viewmodel without the observables), so any change made to the objects would be lost.


If you try this yourself and look in the Dev Tools, you will see the emitted JavaScript:

image

You can see DotVVM added some extra things – there is a question mark in case the Entries would be null, it is also adding ko.unwrap(e) to the lambda parameter because it is not sure that the object in the variable is observable or not. Also, the lambda has an explicit return null because we didn’t provide any return value.

Conclusion

The JS translation engine is very powerful and can enhance the set of things that can be done on the client-side. It is not difficult to write a collection of C# methods (they even don’t need server-side implementation) and provide JS translations – this way you can call any browser API.

You can find more info about JS translations in the documentation.

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