Posty z tej serii:

Konstruowanie elementów UI w kodzie F#. Czy jest to dobre podejście? Kiedy pierwszy raz zobaczyłem taką możliwość, pomyślałem - ciekawe, ale nie jestem do końca przekonany. Z jednej strony mamy wsparcie kompilatora, który pilnuje poprawności wyniku. Z drugiej strony, każda, nawet najmniejsza zmiana wymaga re-kompilacji całości. Z trzeciej strony, dla osób, które piszą backend, re-kompilacja po zmianie to norma. Z czwartej strony, nie da się podzielić pracy tak, aby projektowaniem UI oraz przygotowaniem prototypu zajmowały się osoby od projektowania, a programowaniem całości zajmowały się osoby od programowania. Wszystko jest w kodzie F#, więc wszystko trzeba zaprogramować. Na chwilę obecną nadal podchodzę z pewną rezerwą do takiego podejścia, ale nie skreślam całkowicie.

Bolero umożliwia także alternatywny sposób konstruowania elementów UI - za pomocą tzw. HTML Templates. Do realizacji używamy standardowych znaczników HTML, w których możemy zawrzeć tzw. Holes. Służą one jako łącznik pomiędzy kodem UI napisanym w HTML, a logiką napisaną w F#. Dzięki temu możemy wyświetlać dynamiczne elementy na stronie oraz pobierać informacje, wprowadzane w formularzach. Nazywa się to bindowaniem danych. Możemy również wydzielać powtarzalne części za pomocą tzw. Nested Templates.

Zobaczmy, na przykładach, jak to wszystko działa.

Całą implementację znajdziesz na GitHub’e.

Simple Counter

W poprzedniej części stworzyliśmy prosty licznik, przesuwający wartość w dwie strony. Zamienimy kod renderujący widok tak, aby używał HTML Templates.

Tworzymy plik counter.html z poniższym kodem i umieszczamy w katalogu wwwroot:

<div>
    <button onclick="${Decrement}">-</button>
    <span>${Value}</span>
    <button onclick="${Increment}">+</button>
</div>
<div>
    <button onclick="${Reset}">Reset</button>
</div>

Między znacznikami HTML znajdują się Holes ${Decrement}, ${Value}, ${Increment} oraz ${Reset} pod które podstawimy wartości w kodzie F#.

W pliku Main.fs definiujemy typ, który będzie reprezentował Template:

type Counter = Template<"wwwroot/counter.html">

Zamieniamy definicję funkcji view z:

let view model dispatch =
    concat [
        div [] [
            button [on.click (fun _ -> dispatch Decrement)] [text "-"]
            text (string model.value)
            button [on.click (fun _ -> dispatch Increment)] [text "+"]
        ]

        div [] [
            button [on.click (fun _ -> dispatch Reset)] [text "Reset"]
        ]
    ]

na:

let view model dispatch =
    Counter()
        .Decrement(fun _ -> dispatch Decrement)
        .Value(string model.value)
        .Increment(fun _ -> dispatch Increment)
        .Reset(fun _ -> dispatch Reset)
        .Elt()

Konstruktor Counter() inicjalizuje Template. Bolero rozpoznaje Holes zdefiniowane w pliku counter.html. Dla każdego tworzy odpowiednik w postaci metody z taką samą nazwą oraz pasującą sygnaturą. W ten sposób możemy zakodować zachowanie programu:

  • Decrement, Increment oraz Reset reprezentują kliknięcie odpowiedniego przycisku na stronie
  • Value reprezentuje wyświetlaną wartość licznika
  • Elt jest wewnętrzną metodą Bolero, która konwertuje całą definicję do końcowego typu

Wszystko jest silnie typowane. Mamy pełne wsparcie kompilatora oraz podpowiadanie składni. W JetBrains Rider wygląda to tak:

Picture1

Stan licznika wyświetlamy, wołając metodę Value. Do parametru metody przekazujemy wartość model.value, skonwertowaną z typu int - taki przechowujemy w modelu, na typ string - takiego spodziewa się metoda. Służy do tego operator o nazwie string.

Zachowanie kodujemy, wykorzystując funkcję dispatch, której podajemy odpowiedni Message reprezentujący konkretną akcję.

A jak działa bindowanie? Sprawdźmy to, dodając do licznika funkcjonalność ustawiania liczby, która będzie wskazywać, o ile licznik ma się przesuwać.

Rozszerzamy counter.html o możliwość wprowadzenia wartości:

<div>
    <label>
        Step: <input type="text"  bind="${Step}" onkeydown="${KeyDown}" />
        <span>Key: ${Key} - Key Code: ${KeyCode}</span>
    </label>
</div>
<!-- whole code you can see on GitHub
// ... -->

Atrybut bind łączy wartość znacznika input z Hole ${Step}. Przykład zawiera dodatkową funkcjonalność - wyświetlenie informacji o naciśniętym klawiszu - Holes ${KeyDown}, ${Key} oraz ${KeyCode}

Rozszerzamy model dodając elementy step, key oraz keyCode:

type Model =
    {
        // whole code you can see on GitHub
        // ...
        step: int
        key: string
        keyCode: string        
    }

Ustawiamy wartości początkowe initModel:

let initModel =
    {
        // whole code you can see on GitHub
        // ...
        step = 1
        key = ""
        keyCode = ""
    }

Dodajemy Message’e:

type Message =
    // whole code you can see on GitHub
    // ...
    | SetStep of int
    | KeyDown of string * string

SetStep - wysyłany w momencie wprowadzania nowej wartości przesunięcia licznika - wartość podawana w parametrze typu int

KeyDown - wysyłany w momencie naciśnięcia klawisza na klawiaturze - klawisz oraz jego kod podawane w parametrze typu string * string - Tuple

Aktualizujemy funkcję update:

let update message model =
    match message with
    | Increment -> { model with value = model.value + model.step }
    | Decrement -> { model with value = model.value - model.step }
    | Reset -> { model with value = 0; step = 1 }
    | SetStep step -> { model with step = step }
    | KeyDown (key, keyCode) -> { model with key = key; keyCode = keyCode }

Licznik przesuwany jest o wartość, zapamiętaną w model.step. Reset ustawia step na wartość początkową. SetStep ustawia wartość przesunięcia, a KeyDown informacje o naciśniętym klawiszu.

Rozszerzamy funkcję view:

let view model dispatch =
    // whole code you can see on GitHub
    // ...
    .Step(string model.step, fun inputStep ->
        match System.Int32.TryParse inputStep with
        | true, step -> dispatch (SetStep (step))
        | false, _ -> ())
    .Key(model.key)
    .KeyCode(model.keyCode)
    .KeyDown(fun key -> dispatch (KeyDown (key.Key, key.Code)))
    .Elt()

Metoda Step wyśle Message aktualizujący wartość przesunięcia tylko wtedy, kiedy wprowadzony napis jest liczbą. W tym miejscu powinna być pełna walidacja, ale dla uproszczenia pomijamy ten element. Metody Key oraz KeyCode wyświetlają informacje o klawiszu. Metoda KeyDown wysyła Message aktualizujący informacje o naciśniętym klawiszu. Tak jak w poprzednim przykładzie, tak i tu, wszystko jest silnie typowane i wspierane przez kompilator F#.

Ostatecznie otrzymujemy program:

Picture2

Zobaczmy kolejny przykład.

Enter Values

W sytuacji, kiedy pewne elementy zawierają tę samą strukturę kodu HTML, ale różnią się wartościami, warto stworzyć dla nich re-używalny komponent. W Bolero możemy to zrobić, używając Nested Templates. Zobaczmy, jak to działa na przykładzie programu wyświetlającego wprowadzane napisy.

Definiujemy Template:

<div>
    <label>Enter value and press ENTER: <input type="text" bind-onchange="${NewValue}"/></label>
    <div><span>Entered Values:</span></div>
    ${EnteredValues}
    <div><button onclick="${ClearValues}">Clear</button></div>
</div>

<template id="ShowValue">
    <div>
        <span>${Value}</span>
    </div>
</template>

Zasada działania jest taka sama jak w przykładzie z licznikiem - HTML + Holes:

  • ${NewValue} - reprezentuje wprowadzaną wartość
  • ${EnteredValues} - reprezentuje listę wprowadzonych wartości
  • ${ClearValues} - reprezentuje naciśnięcie przycisku czyszczącego wprowadzone wartości

Nowością jest <template id="ShowValue"> - definicja Nested Template. Template nie jest wyświetlony na ekranie, można go użyć w kodzie F#. Hole ${Value} reprezentuje pojedynczą wyświetlaną wartość.

Druga nowość to zastąpienie atrybutu bind przez bind-onchange. Dzięki temu Message wysyłany jest po naciśnięciu klawisza ENTER, a nie po każdorazowym wprowadzeniu znaku.

Pozostaje zakodować logikę w F# wg standardowego schematu.

Model:

type Model =
    {
        enteredValues: string list
    }

Wartości trzymamy jako listę napisów.

Init:

let initModel =
    {
        enteredValues = []
    }

Message:

type Message =
    | NewValue of string
    | ClearValues

Zgłaszamy wprowadzenie nowej wartości lub wyczyszczenie wprowadzonych danych.

Template:

type EnterValuesTemplate = Template<"wwwroot/enterValues.html">

Ładowany z pliku enterValues.html

Update:

let update message model =
    match message with
    | NewValue value -> { model with enteredValues = model.enteredValues @ [value] }
    | ClearValues -> { model with enteredValues = [] }

Operator @ pozwala połączyć dwie listy w jedną.

View:

let view model dispatch =
    EnterValuesTemplate()
        .EnteredValues(forEach model.enteredValues (fun value -> EnterValuesTemplate.ShowValue().Value(value).Elt()))
        .NewValue("", fun value -> dispatch (NewValue value))
        .ClearValues(fun _ -> dispatch ClearValues)
        .Elt()

Do Nested Template odwołujemy się po identyfikatorze zdefiniowanym w pliku .html, wg schematu - Template.NestedTemplate(). W przykładzie jest to EnterValuesTemplate.ShowValue(). Wartości z listy, podajemy jako parametr do metody EnteredValues. Każdy element wyświetlany jest jako osobny Nested Template.

Ostatecznie otrzymujemy program:

Picture3

Hot Reloading

Wspólną cechą framework’ów webowych generujących wynik po stronie klienta, jest automatyczne odświeżanie widoku, w momencie zapisu zmienionego kodu. Nazywa się to Hot Reloading. Bolero również posiada taką możliwości. Po zapisaniu zmian w pliku *.html, następuje przeładowanie i wynik dostępny jest na stronie. Oczywiście, jeśli zmiany wprowadzamy w kodzie F#, to dalej trzeba dokonać kompilacji.

Dla mnie, jako backend’owca, kompilacja kodu po zmianach to standardowa, wręcz naturalna rzecz. Obecnie, gdy działam z Bolero nie używam mechanizmu Hot Reloading. Być może to się zmieni, kiedy będę pisał więcej kodu UI, zwłaszcza na etapie prototypowania interfejsów.

Generowany kod za pomocą dotnet new bolero-app -o MyApp zawiera konfigurację Hot Reloading. Szczegóły, jak włączyć tę funkcjonalność we własnym projekcie znajdują się na stronie dokumentacji.

Tak wygląda programowanie w Bolero z użyciem HTML Templates. W następnym artykule zajmiemy się mechanizmem View Components.

=