DEV Community

loading...
Cover image for YEW Tutorial: 04 ...and services for all!

YEW Tutorial: 04 ...and services for all!

davidedelpapa profile image Davide Del Papa ・12 min read

Never break a promise (Photo by LexScope on Unsplash)

In this fourth part we are going first to do some "minor" improvements,
and hopefully show a little more the potential of the Yew framework

In this article we will be tinkering around,as usual; after which we'll start to see the light and usefulness at the end of the tunnel (still, the exit is far away).

Code to follow this tutorial

The code has been tagged with the relative tutorial and part.

git clone https://github.com/davidedelpapa/yew-tutorial.git
cd yew-tutorial
git checkout tags/v4p0
Enter fullscreen mode Exit fullscreen mode

Part 0: Cleanup

First of all, let's remove some clutter from our src/app.rs

use yew::prelude::*;

pub enum Msg {
    AddOne,
    RemoveOne,
}

pub struct App {
    items: Vec<i64>,
    link: ComponentLink<Self>,
}

impl Component for App {
    type Message = Msg;
    type Properties = ();

    fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
        App {
            link,
            items: Vec::new(),
        }
    }

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        match msg {
            Msg::AddOne => {
                self.items.push(1);
            }
            Msg::RemoveOne => {
                let _ = self.items.pop();
            }
        }
        true
    }

    fn view(&self) -> Html {
        let render_item = |item| {
            html! {
                <>
                    <tr><td>{ item }</td></tr>
                </>
            }
        };
        html! {
            <div class="main">
              <div class="flex three">
                <div class="card">
                    <header>
                        <h2>{"Items: "}</h2>
                    </header>
                    <div class="card-body">
                        <table class="primary">
                            { for self.items.iter().map(render_item) }
                        </table>
                    </div>
                    <footer>
                        <button onclick=self.link.callback(|_| Msg::AddOne)>{ "Add 1" }</button> {" "}
                        <button onclick=self.link.callback(|_| Msg::RemoveOne)>{ "Remove 1" }</button>
                    </footer>
                </div>
              </div>
            </div>
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Away with consoles and dialogs, and even random numbers. We are just adding 1 and removing 1, literally.

Part 1: Being persistent

Code to follow this part

git checkout tags/v4p1
Enter fullscreen mode Exit fullscreen mode

After cleaning up we can now bring back a service, the storage.

use yew::services::storage;
Enter fullscreen mode Exit fullscreen mode

What is the storage, and how does it work? Well, first of all MDN has got a better explanation than what I could ever provide. Let's say that we are using a persistent Key-Value store that any modern browser provides for us, in order to save data. There are two kinds really, session storage, and local storage. We are using the local storage, that is saved across browser sessions.

Since it is a Key-Value store, we can either save all our data as a key-Value pair, or set a key and dump all data in it. The lazy bum in me always prefers the dump and forget, obviously..

We need therefore to establish a main key under which all our data will live.

const KEY: &'static str = "yew.tut.database";
Enter fullscreen mode Exit fullscreen mode

Now we need also to create a virtual database with a custom data structure.
We will use serde, one of the most mature crates in all Rust's ecosystem, to come to our rescue. Don't forget to update the Cargo.toml with:

serde = "1.0"
Enter fullscreen mode Exit fullscreen mode

Setup

This is all the use area of the src/app.rs with our KEY right after it.

use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
use yew::format::Json;
use yew::prelude::*;
use yew::services::storage::Area;
use yew::services::StorageService;

const KEY: &'static str = "yew.tut.database";
Enter fullscreen mode Exit fullscreen mode

It is a lot more than just serde! In order:

  • we have serde's serialize to format the data, and deserialize to dump it
  • we have wasm_bindgen to go lower in the stack. We're getting more hardcore here. Explanations later on.
  • from Yew we import Json so we can create and dump data using JSON, because if we are lazy why not be also classy while doing it?
  • of course we need still Yew's prelude
  • then we import also yew's mod to relate to the storage area onto which we will save data (the local or session area we were discussing before) and the storage service itself

(If you look at the code you see i have implemented right away the hardcore stuff needed with wasm_bindgen, but we'll talk about it later on)

Custom Data Structures

Now we can start to build our custom data structures!

#[derive(Serialize, Deserialize)]
pub struct Database {
    tasks: Vec<Task>,
}
Enter fullscreen mode Exit fullscreen mode

Our virtual database is a vec of tasks. Sometimes trying to be as simple as possible is just right.
I like to impl my structs right away, just to keep together code that belong together, and that I might want to refactor out later on:

impl Database {
    pub fn new() -> Self {
        Database { tasks: Vec::new() }
    }
}
Enter fullscreen mode Exit fullscreen mode

Nothing too fancy, just a new constructor.

Following this, we can declare our Task:

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Task {
    title: String,
    description: String,
}
Enter fullscreen mode Exit fullscreen mode

The Debug derive is there also if you want to call back the console and resume logging...
Go ahead, I won't judge you.

Instead, notice the Clone derive: if we want to copy a task and bring it out of any function or callback, or if we want to just save a new task inside our database, most probably we will need to clone a task somewhere. Otherwise be prepared to face the compiler's wrath because you will end up borrowing mutably some immutable Task at some point, or making a Task survive its scope!

And now a beautiful impl for our Task:

impl Task {
    pub fn new() -> Self {
        Task {
            title: "".to_string(),
            description: "".to_string(),
        }
    }
    pub fn is_filledin(&self) -> bool {
        (self.title != "") && (self.description != "")
    }
}
Enter fullscreen mode Exit fullscreen mode
  • We have here an empty new constructor
  • We have also a check to know if the Task is filled-in or not, that we'll end up using later on

Msg and App

We need to update our Msg to manage our Task:

pub enum Msg {
    AddTask,
    RemoveTask(usize),
    SetTitle(String),
    SetDescription(String),
}
Enter fullscreen mode Exit fullscreen mode

We add and remove tasks, however we will not add them all at once: since we will have a form with a field for the title and a field for the description we need a way to collect those two separately.
We therefore have a SetTitle message, containing the title in a string, and a SetDescription message, containing the description.
The AddTask will be shot by the submit button: when the message arrives we will collect title and description and save them in a new task. More on this mechanism later on.

Now, in our app, we can take away the items and add storage and database

pub struct App {
    link: ComponentLink<Self>,
    storage: StorageService,
    database: Database,
    temp_task: Task,
}
Enter fullscreen mode Exit fullscreen mode

We added a temporary task as well, to hold information on the task being filled in the form, before it will be stored in the database.

Let's move on to the 3 functions in the impl of our App

fn create

    fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
        let storage = StorageService::new(Area::Local);
        let Json(database) = storage.restore(KEY);
        let database = database.unwrap_or_else(|_| Database::new());
        App {
            link,
            storage,
            database,
            temp_task: Task::new(),
        }
    }
Enter fullscreen mode Exit fullscreen mode

The let lines are those of concern here:

  • First we need to create a new storage object, and we are referencing the local, not the session storage (specified passing Area::Local to the constructor)
  • We make a JSON out of the database, and we create it using the restore function. That is, we restore the database loading it as JSON from the storage connected to the KEY (if it exists).
  • Of course the first time around the store area will be empty: in this case the restore will return an error, that is why we unpack it in the next line with a unwrap_or_else: if there is something to restore we reassign the unwrapped content to the database, otherwise we create a new database by calling its constructor inside the closure of the unwrap_or_else.

fn update

The update function is where most of the magic usually happens, so we'll examine it carefully:

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        match msg {
            Msg::AddTask => {
                if self.temp_task.is_filledin() {
                    self.database.tasks.push(self.temp_task.clone());
                    self.storage.store(KEY, Json(&self.database));
                    self.temp_task = Task::new();
                    refreshform("taskform");
                }
            }
            Msg::RemoveTask(pos) => {
                let _ = self.database.tasks.remove(pos);
                self.storage.store(KEY, Json(&self.database));
            }
            Msg::SetTitle(title) => {
                self.temp_task.title = title;
            }
            Msg::SetDescription(description) => {
                self.temp_task.description = description;
            }
        }
        true
    }
Enter fullscreen mode Exit fullscreen mode

We go message by message. Remember that we have a temporary Task that we are using as buffer in order to insert title and description separately with two different from input fields.

Msg::AddTask: What we are doing here is simple. After the user has filled the form containing a field for the title, and a field for the description, the user will click on the submit button (that we will call Add task) However, before adding this temporary Task permanently on our storage, we need to check that both title and description have been inserted. We do it by calling the is_filledin() function we implemented for the Task struct earlier.

After this simple integrity check on the temporary Task, we are going to push it into the virtual database. Here we need to clone() it, that is, we are saving a copy of the temporary task inside our virtual DB.

We update the storage by re-dumping the whole content of the virtual DB inside the value corresponding to our key: that is the purpose of the self.storage.store(KEY, Json(&self.database)); otherwise the virtual database and the storage of the page will be out of sync. Admittedly it is a lazy workaround, but the other option, that is, to have a proper serialize-deserialize method implemented in order to save each task as a pair of Key-Value takes much more coding effort. Of course, for a serious project, that should be the way, but for a tutorial's purpose (and small quick projects) I think this is sufficient.

Next we call the mysterious function refreshform("taskform"). This is the function we created with the wasm-bindgen. Let's analyze its purpose first and its implementation next.

Purpose: When the user fills out a form in a page and clicks on submit, usually the page sends information to another page, whicih is loaded if the form action field is set, or it refreshes the same page with updated information.

What happens here instead is that when we fire the Add Task button the message is sent to the WASM app we are writing. This app has to refresh the form in some way, if we want to reset the title and description inputs. As of today there is not a straightforward way of doing that with Yew. However, we are using Yew on top of the web_sys stack.
That means that we can use it to accomplish this task.

We can use #[wasm-bindgen] to bind a function created with javascript to Rust. It is just FFI in action.
The wasm-bindgen proposes us two ways of doing this: we can either write a .js file with a function that does this, and bind it to a Rust function interface, or we can write an inline javascript function right away. Of course, for a simple function it is OK to write inline javascript, while for a complex one we will need to write a separate file and bundle it in this project.

#[wasm_bindgen(
    inline_js = "export function refreshform(form) { document.getElementById(form).reset(); }"
)]
extern "C" {
    fn refreshform(form: &str);
}
Enter fullscreen mode Exit fullscreen mode

Implementation: What we are doing here is to define the inline_js function first, and its Rust interface next; the interface is simple fn refreshform(form: &str) it takes a string and does not have a return type.

The inline JavaScript function is simple as well:

export function refreshform(form) {
  document.getElementById(form).reset();
}
Enter fullscreen mode Exit fullscreen mode

We pass the form name to document.getElementById and we use the reset method on it in order to reset the fields of the form.
Of course you are allowed and encouraged to reuse this snippet in your own code.

After this brief detour into the wasm_bindgen lowlands, we can analyze the rest of the messages.

Msg::RemoveTask: we use the position inside the vector in order to pop that element out of the database's tasks vector with self.database.tasks.remove(pos); after this we dump again the database in the storage, in order to update it.

Wait a moment, how do we get the position inside the vector using a message from the view of the element? Well, we will see later on how to implement this trick, and I let you judge if indeed it is a really a trick or not.

Msg::SetTitle and Msg::SetDescription: both messages are self explanatory, we just set the self.temp_task title and description respectively, no big deal.

fn view

Finally, we can analyze the view.

    fn view(&self) -> Html {
        let render_item = |(idx, task): (usize, &Task)| {
            html! {
                <>
                    <div class="card">
                        <header><label>{ &task.title }</label></header>
                        <div class="card-body"><label>{ &task.description }</label></div>
                        <footer><button onclick=self.link.callback(move |_| Msg::RemoveTask(idx))>{ "Remove" }</button></footer>
                    </div>
                </>
            }
        };
        html! {
            <div>
                <h2>{"Tasks: "}</h2>
                { for self.database.tasks.iter().enumerate().map(render_item) }
                <div class="card">
                    <form id="taskform">
                        <label class="stack"><input placeholder="Title" oninput=self.link.callback(|e: InputData|  Msg::SetTitle(e.value)) /></label>
                        <label class="stack"><textarea rows=2 placeholder="Description" oninput=self.link.callback(|e: InputData|  Msg::SetDescription(e.value))></textarea></label>
                        <button class="stack icon-paper-plane" onclick=self.link.callback(|_|  Msg::AddTask)>{ "Add task" }</button>
                    </form>
                </div>
            </div>
        }
    }
Enter fullscreen mode Exit fullscreen mode

The render_item closure has changed a little, however we left the original name just to keep the structure.

We are not creating rows of a table as before, but creating new picnic card items.

We also have to annotate the variables we are capturing with our closure, because it's getting more complicated for the compiler to keep track of types. We are passing to it a usize as index, called idx and a Task

We are using the task.title in the card's header, the task.description in the card's _body (watch out for it, it is a custom style we created inside index.html ), but the real magic is now in the code of the footer. In it we are creating a button to remove the task, with a callback that will fire the Msg::RemoveTask passing to it the usize idx where it is stored the index of the task, index given by the position inside the tasks vector. This index is used in order to remove the task from the database. But where did we get that index from? Again: good things will come to those who are patient.

The html! macro is simple, however, it is a little bit cluttered with many <div> and class for presentation purposes.

The structure is simple enough, once the clutter is removed: there is really only the list of the tasks, and the submission form to enter a new task.

We use the for syntactic sugar to call the render_item closure. The thing to notice here is that before the map() we call the enumerate(). This trick is what allows us to know the index that each task has inside the tasks vector!

Beware that enumerate() creates a tuple in the form of (index, iter() content), so that same tuple we are passing to map(). This is the reason we had to annotate our closure as a (usize, &Task) tuple.

The form field is straightforward:

  • we have an input with an oninput callback to update the temporary Task with the message Msg::SetTitle
  • likewise, we have a textarea with an oninput callback, used to update the temporary Task's description
  • the button Add task completes the form, firing the message Msg::AddTask.

What is really of notice is the <form id="taskform">: here we are setting the id of the form, that we pass to the inline javascript function inside the Msg::AddTask handling logic.
Now we can understand the purpose of the refreshform("taskform").

Let's give it a run, and a try.

Alt Text

Alt Text

Not bad at all. Now if you are playing with it, you can notice that the task list is persistent even if you refresh the page.
It stays the same also when you close the server and restart it (easy, it's not a server side storage)

We can inspect the storage with the browser tools. Here some images with Firefox and Chrome.

Alt Text

In firefox is under the tab storage of the dev tools, in the local storage.

Alt Text

You can inspect the JSON stored in the value.

Likewise you can inspect the JSON with Chrome's dev tools:

Alt Text

However, here the Local Storage is found under the Application tab.

Part 2: Being practical

You didn't think I would ever get to this point, did you?

Well, checkout my yew-todolist.
Live at yew-todolist.surge.sh.
It has got a manifest.json to make it real PWA. Well not really, because to make a PWA we need https, but that is easy to get on premises.

Code to follow this part

It is another repository:

git clone https://github.com/davidedelpapa/yew-todolist.git
cd yew-todolist
Enter fullscreen mode Exit fullscreen mode

In order to make the manifest, I used this firebase app, then you just link back to the index.html

<link rel="manifest" href="manifest.json" />
Enter fullscreen mode Exit fullscreen mode

While the rest is needed for ios compatibility.

Speaking of the things you should really notice is that I made a separate deploy/ directory in order to be able to run surge, and deploy there. Of course you can't get real PWA without https support, and surge with https cost$ (quite so).
Maybe with some other provider?

An interesting thing is that I refactored the code to separate the virtualdb out of the app.rs in its own database.rs.

Moreover, I implemented a different status system for each todo, Active, Completed, Archived. For now there is no turning back from completed to active (if someone completed it by error), and the archived are removed from the interface but sit on the database. In the future I should make filters for views (active, completed, archived), ways to restore, adding the beginning date-completion date. Ways to completely remove old archived tasks. An export function could be useful too. Some of the these things we could implement with the knowledge so far gained, some other we really have to learn more concepts in ordr to implement; so stay tuned for more!

PS: If yu want to thinker with it, pass it to some other provider that guarantees https, try out implementing new functions... That repo is there as a study/starting point. Take it as a homework source, play with it, and let me know how it goes.

Stay tuned for next istallment, Drive through libraries, where we will see how to interface with online API's, NPM libraries, and more...

Discussion (1)

pic
Editor guide
Collapse
terkwood profile image
Felix Terkhorn

This is really helpful, thanks