Mini-tutorial: client-server widgets

This short tutorial is an example of client-server Eliom application. It gives an example of client-server widgets.

It is probably a good starting point if you want to learn quickly how to write a client-server Eliom application with a short example and few explanations. For more explanation, see the "graffiti" tutorial or read the manuals.

The goal is to show that, unlike many javascript libraries that build their widgets programmatically (by instanciating classes or calling functions), Eliom makes possible to generate page widgets on server side, before sending them to the client. Thus pages can be indexed by search engines.

This tutorial also shows that it is possible to use the same code to build the widget either on client or server side.

We chose a very simple widget, than could be the base for example for implementing a drop-down menu. It consists in several boxes with a title and a content. Clicking on the title opens or closes the content. And it is possible to group some of the boxes together to make them behave like radio buttons: when you open one of them the previously opened one are closed.

screenshot

First step: define an application with a basic service

The following code defines a client-server Web application with only one service, registered at URL / (the root of the Web site).

It also defines a client side application (section {client{ ... }} ) that appends to the page a widget, generated on client side.

{shared{
  open Eliom_content
  open Html5
  open Html5.D
}}

module Ex_app =
  Eliom_registration.App (struct let application_name = "ex" end)

let _ =
  Ex_app.register_service ~path:[] ~get_params:Eliom_parameter.unit
    (fun () () ->
      Lwt.return (Eliom_tools.D.html ~title:"ex" ~css:[["css"; "ex.css"]]
                   (body [h2 [pcdata "Welcome to Ocsigen!"]])))


{client{

  let mywidget s1 s2 =
    let button  = div ~a:[a_class ["button"]] [pcdata s1] in
    let content = div ~a:[a_class ["content"]] [pcdata s2] in
    div ~a:[a_class ["mywidget"]] [button; content]

  let _ =
    lwt _ = Lwt_js_events.onload () in
    Dom.appendChild
      (Dom_html.document##body)
      (To_dom.of_element (mywidget "Click me" "Hello!"));
    Lwt.return ()

}}

To compile it, first create a project by calling

  eliom-distillery -name ex

The name of the project must match the name given to the functor Eliom_registration.App.

Adapt the file ex.eliom and compile, by calling make and run the server, by calling make test.byte. Download the CSS file and place it in directory static/css. Then open a browser window and go to URL http://localhost:8080.

screenshot

More explanations

This section gives very quick explanations on the rest of the program. For more detailed explanations, see the tutorial for the graffiti app or the manual of each of the projects.

  • The ## is used to call a JS method from OCaml (see js_of_ocaml's documentation).
  • If there are several services in your application, the client-side program will be sent only with the first page, and will not stop if you go to another page of the application.
  • Lwt is the concurrent library used to program threads on both client and server sides. The syntax lwt a = e1 in e2 allows to wait (without blocking the rest of the program) for a Lwt thread to terminate before continuing. e2 must be a Lwt thread itself. Lwt.return makes possible to create a Lwt thread already terminated.
  • Lwt_js_events defines a very convenient way to program events. Here Lwt_js_events.onload is a Lwt thread that waits until the page is loaded.

Second step: bind the button

To make to widget work, we must bind the click event. Replace function mywidget by the following lines:

let switch_visibility elt =
    let elt = To_dom.of_element elt in
    if Js.to_bool (elt##classList##contains(Js.string "hidden"))
    then elt##classList##remove(Js.string "hidden")
    else elt##classList##add(Js.string "hidden")

  let mywidget s1 s2 =
    let button  = div ~a:[a_class ["button"]] [pcdata s1] in
    let content = div ~a:[a_class ["content"]] [pcdata s2] in
    Lwt_js_events.(
      async (fun () ->
        clicks (To_dom.of_element button)
          (fun _ _ -> switch_visibility content; Lwt.return ())));
    div ~a:[a_class ["mywidget"]] [button; content]
  • Once again we use Lwt_js_events. Function async runs a Lwt thread asynchronously (without waiting for its result).
  • Lwt_js_events.clicks elt f calls function f for each mouse click on element elt.
  • To_dom.of_element, Js.string and Js.to_bool are conversion functions between OCaml values and JS values.

Third step: Generating the widget on server side

The following version of the program shows how to generate the widget on server side, before sending it to the client.

The code is exactly the same, but:

  • We place function mywidget outside client section.
  • The portion of code that must be run on client side (binding the click event) is written as a client value, inside {unit{ ... }} . This code will be executed by the client side program when it receives the page. Note that you must give the type (here unit) as there is no type inference for client values for now. The client section may refer server side values, using the %x syntax. These values will be serialized and sent to the client automatically with the page.
  • We include the widget on the server side generated page instead of adding it to the page from client side.
{shared{
  open Eliom_content
  open Html5
  open Html5.D
}}

module Ex_app =
  Eliom_registration.App (struct let application_name = "ex" end)

{client{

  let switch_visibility elt =
    let elt = To_dom.of_element elt in
    if Js.to_bool (elt##classList##contains(Js.string "hidden"))
    then elt##classList##remove(Js.string "hidden")
    else elt##classList##add(Js.string "hidden")
}}

  let mywidget s1 s2 =
    let button  = div ~a:[a_class ["button"]] [pcdata s1] in
    let content = div ~a:[a_class ["content"]] [pcdata s2] in
    let _ = {unit{
      Lwt_js_events.(
        async (fun () ->
          clicks (To_dom.of_element %button)
            (fun _ _ -> switch_visibility %content; Lwt.return ()))) }}
    in
    div ~a:[a_class ["mywidget"]] [button; content]

let _ =
  Ex_app.register_service ~path:[] ~get_params:Eliom_parameter.unit
    (fun () () ->
      Lwt.return (Eliom_tools.D.html ~title:"ex" ~css:[["css"; "ex.css"]]
                   (body [h2 [pcdata "Welcome to Ocsigen!"];
                          mywidget "Click me" "Hello!"])))

Fourth step: widget usable either on client or server sides

If you place function mywidget into a shared section (syntax {{shared{ ... }} ), it will be available both on server and client sides.

{shared{

  let mywidget s1 s2 =
    let button  = div ~a:[a_class ["button"]] [pcdata s1] in
    let content = div ~a:[a_class ["content"]] [pcdata s2] in
    let _ = {unit{
      Lwt_js_events.(
        async (fun () ->
          clicks (To_dom.of_element %button)
            (fun _ _ -> switch_visibility %content; Lwt.return ()))) }}
    in
    div ~a:[a_class ["mywidget"]] [button; content]

}}

screenshot

Fifth step: close last window when opening a new one

To implement this, we just record in a client side reference the function to be used to close the currently opened window.

{shared{
  open Eliom_content
  open Html5
  open Html5.D
}}

module Ex_app =
  Eliom_registration.App (struct let application_name = "ex" end)


{client{
  let close_last = ref (fun () -> ())

  let switch_visibility elt =
    let elt = To_dom.of_element elt in
    if Js.to_bool (elt##classList##contains(Js.string "hidden"))
    then elt##classList##remove(Js.string "hidden")
    else elt##classList##add(Js.string "hidden")
}}

{shared{

  let mywidget s1 s2 =
    let button  = div ~a:[a_class ["button"]] [pcdata s1] in
    let content = div ~a:[a_class ["content"; "hidden"]] [pcdata s2] in
    let _ = {unit{
      Lwt_js_events.(
        async (fun () ->
          clicks (To_dom.of_element %button)
            (fun _ _ ->
               !close_last();
               close_last := (fun () -> switch_visibility %content);
               switch_visibility %content; Lwt.return ()))) }}
    in
    div ~a:[a_class ["mywidget"]] [button; content]

}}

let _ =
  Ex_app.register_service ~path:[] ~get_params:Eliom_parameter.unit
    (fun () () ->
      let _ = {unit{ Dom.appendChild
         (Dom_html.document##body)
         (To_dom.of_element (mywidget "Click me" "client side")) }}
      in
      Lwt.return (Eliom_tools.D.html ~title:"ex" ~css:[["css"; "ex.css"]]
                   (body [h2 [pcdata "Welcome to Ocsigen!"];
                          mywidget "Click me" "server side";
                          mywidget "Click me" "server side";
                          mywidget "Click me" "server side"])))

Last step: several sets of widgets

Now we want to be able to have several sets of widgets in the same page. To do that we can't use only one reference any more. In the following version, the server side program asks the client side program to generate two different references by calling function new_set. This function returns what we call a client value. On server side, it is not evaluated, and has an abstract type.

{shared{
  open Eliom_content
  open Html5
  open Html5.D
}}

module Ex_app =
  Eliom_registration.App (struct let application_name = "ex" end)


let new_set () = {(unit -> unit) ref{ ref (fun () -> ()) }}

{client{
  let switch_visibility elt =
    let elt = To_dom.of_element elt in
    if Js.to_bool (elt##classList##contains(Js.string "hidden"))
    then elt##classList##remove(Js.string "hidden")
    else elt##classList##add(Js.string "hidden")
}}

{shared{

  let mywidget set s1 s2 =
    let button  = div ~a:[a_class ["button"]] [pcdata s1] in
    let content = div ~a:[a_class ["content"; "hidden"]] [pcdata s2] in
    let _ = {unit{
      Lwt_js_events.(
        async (fun () ->
          clicks (To_dom.of_element %button)
            (fun _ _ ->
               ! %set();
               %set := (fun () -> switch_visibility %content);
               switch_visibility %content; Lwt.return ()))) }}
    in
    div ~a:[a_class ["mywidget"]] [button; content]

}}

let _ =
  Ex_app.register_service ~path:[] ~get_params:Eliom_parameter.unit
    (fun () () ->
      let set1 = new_set () in
      let set2 = new_set () in
      let _ = {unit{ Dom.appendChild
         (Dom_html.document##body)
         (To_dom.of_element (mywidget %set2 "Click me" "client side")) }}
      in
      Lwt.return (Eliom_tools.D.html ~title:"ex" ~css:[["css"; "ex.css"]]
                   (body [h2 [pcdata "Welcome to Ocsigen!"];
                          mywidget set1 "Click me" "server side";
                          mywidget set1 "Click me" "server side";
                          mywidget set2 "Click me" "server side"])))

screenshot

And now?

Calling server functions

An important feature missing in this tutorial is the ability to call server functions from the client side program ("server functions"). You can have a quick description of this in mini HOWTOs or in Eliom's manual.

Services

For many applications, you will need several services. Remember that by default, client-side Eliom program do not stop when you follow a link or send a form. This make possible to combine Rich client side feature (playing music, animations, stateful applications ...) with traditional Web interaction (links, forms, bookmarks, back button ...). Eliom proposes several way to identify services, either by the URL (and parameters) or by a session identifier (we call this kind of service a coservice). Eliom also makes possible to create dynamically new (co-)services dynamically, for example coservices depending on previous interaction with a user. More information on the service identification mechanism in Eliom's manual.

Sessions

Eliom also offers a rich session mechanism, with scopes (see Eliom's manual).