A List of Components

A list of components is a common interface pattern. In mogwai we can express this using two component definitions. In this example we'll use a list component and an item component.

Contents

Explanation

Views

The list defines its view to include a button to create new items and an ol to hold them. Messages will flow from the view into two async tasks, which will patch the items using the patch:children RSX attribute (which we can be set to anything that implements Stream<Item = ListPatch<ViewBuilder<JsDom>>).

    rsx! {
        fieldset() {
            legend(){ "A List of Gizmos" }
            button(
                style:cursor = "pointer",
                on:click = new_item_clicked.sink().contra_map(|_: JsDomEvent| ())
            ) {
                "Create a new item"
            }
            fieldset() {
                legend(){ "Items" }
                ol(
                    patch:children = items
                        .stream()
                        .map(move |patch| map_item_patch(patch, remove_item_clicked_patch.clone()))
                ){}
            }
        }
    }

Each item has a unique id that helps us determine which items to remove.

Each item contains a button to remove the item from the list as well as a button to increment a counter. Click events on these buttons will be as an output into an async task that determines what to do next.

The view receives a count of the current number of clicks from a stream and uses that to display a nice message to the user.

    rsx! {
        li() {
            button(
                style:cursor = "pointer",
                on:click = increment_item_clicked.sink().contra_map(|_: JsDomEvent| ())
            ) {
                "Increment"
            }
            button(
                style:cursor = "pointer",
                // every time the user clicks, we'll send the id as output
                on:click = remove_item_clicked.sink().contra_map(move |_: JsDomEvent| id)
            ) {
                "Remove"
            }
            {" "}
            span() {
                {
                    ("", num_clicks.stream().map(|clicks| match clicks {
                        1 => "1 click".to_string(),
                        n => format!("{} clicks", n),
                    }))
                }
            }
        }
    }

Logic and Communication

Just like most other components, both list and item have their own async tasks that loop over incoming messages. These messages encode all the events that occur in our views.

The difference between this list of items and other less complex widgets is the way items communicate up to the parent list and how view updates are made to the parent list.

As you can see below, item takes the item ID _and a remove_item_clicked: Output<ItemId>, which it plugs into the "remove" button's on:click attribute. This sends any click events straight to the the caller of item.

It is this output that list uses to receiver remove events from its items.

Also notice that we're using Model to keep the state of the number of clicks. Model is convenient here because it tracks the state and automatically streams any updates to downstream observers.

/// An id to keep track of item nodes
#[derive(Clone, Copy, Debug, PartialEq)]
struct ItemId(usize);

/// Creates an individual item.
///
/// Takes the id of the item (which will be unique) and an `Output` to send
/// "remove item" click events (so the item itself can inform the parent when
/// it should be removed).
fn item(id: ItemId, remove_item_clicked: Output<ItemId>) -> ViewBuilder {
    let increment_item_clicked = Output::<()>::default();
    let num_clicks = Model::new(0u32);
    rsx! {
        li() {
            button(
                style:cursor = "pointer",
                on:click = increment_item_clicked.sink().contra_map(|_: JsDomEvent| ())
            ) {
                "Increment"
            }
            button(
                style:cursor = "pointer",
                // every time the user clicks, we'll send the id as output
                on:click = remove_item_clicked.sink().contra_map(move |_: JsDomEvent| id)
            ) {
                "Remove"
            }
            {" "}
            span() {
                {
                    ("", num_clicks.stream().map(|clicks| match clicks {
                        1 => "1 click".to_string(),
                        n => format!("{} clicks", n),
                    }))
                }
            }
        }
    }
    .with_task(async move {
        while let Some(_) = increment_item_clicked.get().await {
            num_clicks.visit_mut(|n| *n += 1).await;
        }
        log::info!("item {} loop is done", id.0);
    })
}

The list uses ListPatchModel to maintain our list if items. ListPatchModel is much like Model but is special in that every time itself is patched it sends a clone of that patch to downstream listeners. So while Model sends a clone of the full updated inner value, ListPatchModel sends the diff. This makes it easy to map the patch from ItemId to ViewBuilder and use the resulting stream to patch the list's view:

// Maps a patch of ItemId into a patch of ViewBuilder
fn map_item_patch(
    patch: ListPatch<ItemId>,
    remove_item_clicked: Output<ItemId>,
) -> ListPatch<ViewBuilder> {
    patch.map(|id| item(id, remove_item_clicked.clone()))
}

/// Our list of items.
///
/// Set up our communication from items to this logic loop by
/// * giving each created item a clone of a shared `Output<ItemId>` to send events from
/// * creating a list patch model to update from two separate async tasks (one to create, one to remove)
/// * receive output "removal" messages and patch the list patch model
/// * receive output "create" messages and patch the list patch model
fn list() -> ViewBuilder {
    let remove_item_clicked = Output::<ItemId>::default();
    let remove_item_clicked_patch = remove_item_clicked.clone();

    let new_item_clicked = Output::<()>::default();

    let items: ListPatchModel<ItemId> = ListPatchModel::new();
    let items_remove_loop = items.clone();

    rsx! {
        fieldset() {
            legend(){ "A List of Gizmos" }
            button(
                style:cursor = "pointer",
                on:click = new_item_clicked.sink().contra_map(|_: JsDomEvent| ())
            ) {
                "Create a new item"
            }
            fieldset() {
                legend(){ "Items" }
                ol(
                    patch:children = items
                        .stream()
                        .map(move |patch| map_item_patch(patch, remove_item_clicked_patch.clone()))
                ){}
            }
        }
    }
    .with_task(async move {
        // add new items
        let mut next_id = 0;
        while let Some(_) = new_item_clicked.get().await {
            log::info!("creating item {}", next_id);
            let id = ItemId(next_id);
            next_id += 1;
            let patch = ListPatch::push(id);
            items.patch(patch).await.expect("could not patch");
        }
        log::info!("list 'add' loop is done - should never happen");
    }).with_task(async move {
        // remove items
        while let Some(remove_id) = remove_item_clicked.get().await {
            let items_read = items_remove_loop.read().await;
            let index = items_read.iter().enumerate().find_map(|(i, id)| if id == &remove_id {
                Some(i)
            } else {
                None
            }).unwrap();
            drop(items_read);
            log::info!("removing item {} at index {}", remove_id.0, index);
            let patch = ListPatch::remove(index);
            items_remove_loop.patch(patch).await.expect("could not patch");
        }
        log::info!("list 'remove' loop is done - should never happen");
    })
}

This is a good example of how mogwai separates component state from component views. The list logic doesn't own the view and doesn't maintain the list of DOM nodes. Instead, the view has a patching mechanism that is set with a stream and then the logic maintains a collection of ItemIds that it patches locally - triggering downstream patches to the view automatically.

Notes

In this example you can see that unlike other vdom based libraries, mogwai's state is completely separate from its views. Indeed they even require separate handling to "keep them in sync". This is by design. In general a mogwai app tends to be more explicit and less magical than its vdom counterparts.

Code

You can see the code in its entirety at mogwai's list of components example.

Play with it