Improving the TodoMVC example

This commit is contained in:
Pauan 2019-07-23 04:45:31 +02:00
parent 08864347f4
commit 1cd3971a81
8 changed files with 470 additions and 431 deletions

View File

@ -36,7 +36,7 @@ features = [
#"CssStyleDeclaration",
"CssStyleRule",
"CssStyleSheet",
#"Document",
"Document",
#"DocumentFragment",
#"DomTokenList",
"Element",

241
examples/todomvc/src/app.rs Normal file
View File

@ -0,0 +1,241 @@
use std::rc::Rc;
use std::cell::Cell;
use wasm_bindgen::prelude::*;
use serde_derive::{Serialize, Deserialize};
use futures_signals::signal::{Signal, SignalExt, Mutable};
use futures_signals::signal_vec::{SignalVec, SignalVecExt, MutableVec};
use dominator::{Dom, text_signal, routing, html, clone, events};
use crate::todo::Todo;
use crate::routing::Route;
use crate::util::{trim, local_storage};
#[derive(Debug, Serialize, Deserialize)]
pub struct App {
todo_id: Cell<u32>,
#[serde(skip)]
new_todo_title: Mutable<String>,
todo_list: MutableVec<Rc<Todo>>,
}
impl App {
fn new() -> Rc<Self> {
Rc::new(App {
todo_id: Cell::new(0),
new_todo_title: Mutable::new("".to_owned()),
todo_list: MutableVec::new(),
})
}
pub fn deserialize() -> Rc<Self> {
local_storage()
.get_item("todos-rust-dominator")
.unwrap_throw()
.and_then(|state_json| {
serde_json::from_str(state_json.as_str()).ok()
})
.unwrap_or_else(App::new)
}
pub fn serialize(&self) {
let state_json = serde_json::to_string(self).unwrap_throw();
local_storage()
.set_item("todos-rust-dominator", state_json.as_str())
.unwrap_throw();
}
fn create_new_todo(&self) {
let trimmed = trim(&self.new_todo_title.lock_ref());
// Only create a new Todo if the text box is not empty
if let Some(title) = trimmed {
self.new_todo_title.set_neq("".to_owned());
let id = self.todo_id.get();
self.todo_id.set(id + 1);
self.todo_list.lock_mut().push_cloned(Todo::new(id, title));
self.serialize();
}
}
pub fn remove_todo(&self, todo: &Todo) {
// TODO make this more efficient ?
self.todo_list.lock_mut().retain(|x| **x != *todo);
}
fn remove_all_completed_todos(&self) {
self.todo_list.lock_mut().retain(|todo| todo.completed.get() == false);
}
fn set_all_todos_completed(&self, checked: bool) {
for todo in self.todo_list.lock_ref().iter() {
todo.completed.set_neq(checked);
}
self.serialize();
}
fn completed(&self) -> impl SignalVec<Item = bool> {
self.todo_list.signal_vec_cloned()
.map_signal(|todo| todo.completed.signal())
}
fn completed_len(&self) -> impl Signal<Item = usize> {
self.completed()
.filter(|completed| *completed)
.len()
}
fn not_completed_len(&self) -> impl Signal<Item = usize> {
self.completed()
.filter(|completed| !completed)
.len()
}
fn render_header(app: Rc<Self>) -> Dom {
html!("header", {
.class("header")
.children(&mut [
html!("h1", {
.text("todos")
}),
html!("input", {
.focused(true)
.class("new-todo")
.attribute("placeholder", "What needs to be done?")
.property_signal("value", app.new_todo_title.signal_cloned())
.event(clone!(app => move |event: events::Input| {
app.new_todo_title.set_neq(event.value().unwrap_throw());
}))
.event_preventable(clone!(app => move |event: events::KeyDown| {
if event.key() == "Enter" {
event.prevent_default();
Self::create_new_todo(&app);
}
}))
}),
])
})
}
fn render_main(app: Rc<Self>) -> Dom {
html!("section", {
.class("main")
// Hide if it doesn't have any todos.
.visible_signal(app.todo_list.signal_vec_cloned()
.len()
.map(|len| len > 0))
.children(&mut [
html!("input", {
.class("toggle-all")
.attribute("id", "toggle-all")
.attribute("type", "checkbox")
.property_signal("checked", app.not_completed_len().map(|len| len == 0))
.event(clone!(app => move |event: events::Change| {
let checked = event.checked().unwrap_throw();
app.set_all_todos_completed(checked);
}))
}),
html!("label", {
.attribute("for", "toggle-all")
.text("Mark all as complete")
}),
html!("ul", {
.class("todo-list")
.children_signal_vec(app.todo_list.signal_vec_cloned()
.map(clone!(app => move |todo| Todo::render(todo, app.clone()))))
}),
])
})
}
fn render_button(text: &str, route: Route) -> Dom {
html!("li", {
.children(&mut [
routing::link(route.url(), |dom| { dom
.text(text)
.class_signal("selected", Route::signal().map(move |x| x == route))
})
])
})
}
fn render_footer(app: Rc<Self>) -> Dom {
html!("footer", {
.class("footer")
// Hide if it doesn't have any todos.
.visible_signal(app.todo_list.signal_vec_cloned()
.len()
.map(|len| len > 0))
.children(&mut [
html!("span", {
.class("todo-count")
.children(&mut [
html!("strong", {
.text_signal(app.not_completed_len().map(|len| len.to_string()))
}),
text_signal(app.not_completed_len().map(|len| {
if len == 1 {
" item left"
} else {
" items left"
}
})),
])
}),
html!("ul", {
.class("filters")
.children(&mut [
Self::render_button("All", Route::All),
Self::render_button("Active", Route::Active),
Self::render_button("Completed", Route::Completed),
])
}),
html!("button", {
.class("clear-completed")
// Show if there is at least one completed item.
.visible_signal(app.completed_len().map(|len| len > 0))
.event(clone!(app => move |_: events::Click| {
app.remove_all_completed_todos();
app.serialize();
}))
.text("Clear completed")
}),
])
})
}
pub fn render(app: Rc<Self>) -> Dom {
html!("section", {
.class("todoapp")
.children(&mut [
Self::render_header(app.clone()),
Self::render_main(app.clone()),
Self::render_footer(app.clone()),
])
})
}
}

View File

@ -1,438 +1,15 @@
use std::rc::Rc;
use std::cell::Cell;
use wasm_bindgen::prelude::*;
use serde_derive::{Serialize, Deserialize};
use web_sys::{window, HtmlElement, Storage, Url};
use futures_signals::map_ref;
use futures_signals::signal::{Signal, SignalExt, Mutable};
use futures_signals::signal_vec::{SignalVecExt, MutableVec};
use dominator::{Dom, text, routing, html, clone, events};
fn local_storage() -> Storage {
window().unwrap_throw().local_storage().unwrap_throw().unwrap_throw()
}
// TODO make this more efficient
#[inline]
fn trim(input: &str) -> Option<String> {
let trimmed = input.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Filter {
Active,
Completed,
All,
}
impl Filter {
fn signal() -> impl Signal<Item = Self> {
routing::url()
.signal_ref(|url| Url::new(&url).unwrap_throw())
.map(|url| {
match url.hash().as_str() {
"#/active" => Filter::Active,
"#/completed" => Filter::Completed,
_ => Filter::All,
}
})
}
#[inline]
fn button(kind: Self) -> Dom {
let url = match kind {
Filter::Active => "#/active",
Filter::Completed => "#/completed",
Filter::All => "#/",
};
let text = match kind {
Filter::Active => "Active",
Filter::Completed => "Completed",
Filter::All => "All",
};
routing::link(url, |dom| { dom
.class_signal("selected", Self::signal()
.map(clone!(kind => move |filter| filter == kind)))
.text(text)
})
}
}
#[derive(Debug, Serialize, Deserialize)]
struct Todo {
id: u32,
title: Mutable<String>,
completed: Mutable<bool>,
#[serde(skip)]
editing: Mutable<Option<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
struct State {
todo_id: Cell<u32>,
#[serde(skip)]
new_todo_title: Mutable<String>,
todo_list: MutableVec<Rc<Todo>>,
}
impl State {
fn new() -> Self {
State {
todo_id: Cell::new(0),
new_todo_title: Mutable::new("".to_owned()),
todo_list: MutableVec::new(),
}
}
fn remove_todo(&self, todo: &Todo) {
// TODO make this more efficient ?
self.todo_list.lock_mut().retain(|x| x.id != todo.id);
}
fn deserialize() -> Self {
local_storage()
.get_item("todos-rust-dominator")
.unwrap_throw()
.and_then(|state_json| {
serde_json::from_str(state_json.as_str()).ok()
})
.unwrap_or_else(State::new)
}
fn serialize(&self) {
let state_json = serde_json::to_string(self).unwrap_throw();
local_storage().set_item("todos-rust-dominator", state_json.as_str()).unwrap_throw();
}
}
use crate::app::App;
mod util;
mod routing;
mod todo;
mod app;
#[wasm_bindgen(start)]
pub fn main_js() -> Result<(), JsValue> {
pub fn main_js() {
#[cfg(debug_assertions)]
console_error_panic_hook::set_once();
let state = Rc::new(State::deserialize());
dominator::append_dom(&dominator::body(),
html!("section", {
.class("todoapp")
.children(&mut [
html!("header", {
.class("header")
.children(&mut [
html!("h1", {
.text("todos")
}),
html!("input", {
.focused(true)
.class("new-todo")
.attribute("placeholder", "What needs to be done?")
.property_signal("value", state.new_todo_title.signal_cloned())
.event(clone!(state => move |event: events::Input| {
state.new_todo_title.set_neq(event.value().unwrap_throw());
}))
.event(clone!(state => move |event: events::KeyDown| {
if event.key() == "Enter" {
event.prevent_default();
let trimmed = trim(&state.new_todo_title.lock_ref());
if let Some(title) = trimmed {
state.new_todo_title.set_neq("".to_owned());
let id = state.todo_id.get();
state.todo_id.set(id + 1);
state.todo_list.lock_mut().push_cloned(Rc::new(Todo {
id: id,
title: Mutable::new(title),
completed: Mutable::new(false),
editing: Mutable::new(None),
}));
state.serialize();
}
}
}))
}),
])
}),
html!("section", {
.class("main")
// Hide if it doesn't have any todos.
.visible_signal(state.todo_list.signal_vec_cloned()
.len()
.map(|len| len > 0))
.children(&mut [
html!("input", {
.class("toggle-all")
.attribute("id", "toggle-all")
.attribute("type", "checkbox")
.property_signal("checked", state.todo_list.signal_vec_cloned()
.map_signal(|todo| todo.completed.signal())
.filter(|completed| !completed)
.len()
.map(|len| len != 0))
.event(clone!(state => move |event: events::Change| {
// Toggles the boolean
let checked = !event.checked().unwrap_throw();
{
let todo_list = state.todo_list.lock_ref();
for todo in todo_list.iter() {
todo.completed.set_neq(checked);
}
}
state.serialize();
}))
}),
html!("label", {
.attribute("for", "toggle-all")
.text("Mark all as complete")
}),
html!("ul", {
.class("todo-list")
.children_signal_vec(state.todo_list.signal_vec_cloned()
.map(clone!(state => move |todo| {
html!("li", {
.class_signal("editing", todo.editing.signal_cloned()
.map(|x| x.is_some()))
.class_signal("completed", todo.completed.signal())
.visible_signal(map_ref!(
let filter = Filter::signal(),
let completed = todo.completed.signal() =>
match *filter {
Filter::Active => !completed,
Filter::Completed => *completed,
Filter::All => true,
}
)
.dedupe())
.children(&mut [
html!("div", {
.class("view")
.children(&mut [
html!("input", {
.attribute("type", "checkbox")
.class("toggle")
.property_signal("checked", todo.completed.signal())
.event(clone!(state, todo => move |event: events::Change| {
todo.completed.set_neq(event.checked().unwrap_throw());
state.serialize();
}))
}),
html!("label", {
.event(clone!(todo => move |_: events::DoubleClick| {
todo.editing.set_neq(Some(todo.title.get_cloned()));
}))
.text_signal(todo.title.signal_cloned())
}),
html!("button", {
.class("destroy")
.event(clone!(state, todo => move |_: events::Click| {
state.remove_todo(&todo);
state.serialize();
}))
}),
])
}),
html!("input", {
.class("edit")
.property_signal("value", todo.editing.signal_cloned()
.map(|x| x.unwrap_or_else(|| "".to_owned())))
.visible_signal(todo.editing.signal_cloned()
.map(|x| x.is_some()))
// TODO dedupe this somehow ?
.focused_signal(todo.editing.signal_cloned()
.map(|x| x.is_some()))
.event(clone!(todo => move |event: events::KeyDown| {
match event.key().as_str() {
"Enter" => {
event.dyn_target::<HtmlElement>()
.unwrap_throw()
.blur()
.unwrap_throw();
},
"Escape" => {
todo.editing.set_neq(None);
},
_ => {}
}
}))
.event(clone!(todo => move |event: events::Input| {
todo.editing.set_neq(Some(event.value().unwrap_throw()));
}))
// TODO global_event ?
.event(clone!(state, todo => move |_: events::Blur| {
if let Some(title) = todo.editing.replace(None) {
if let Some(title) = trim(&title) {
todo.title.set_neq(title);
} else {
state.remove_todo(&todo);
}
state.serialize();
}
}))
}),
])
})
})))
}),
])
}),
html!("footer", {
.class("footer")
// Hide if it doesn't have any todos.
.visible_signal(state.todo_list.signal_vec_cloned()
.len()
.map(|len| len > 0))
.children(&mut [
html!("span", {
.class("todo-count")
.children_signal_vec(state.todo_list.signal_vec_cloned()
.map_signal(|todo| todo.completed.signal())
.filter(|completed| !completed)
.len()
// TODO make this more efficient
.map(|len| {
vec![
html!("strong", {
.text(&len.to_string())
}),
text(if len == 1 {
" item left"
} else {
" items left"
}),
]
})
.to_signal_vec())
}),
html!("ul", {
.class("filters")
.children(&mut [
html!("li", {
.children(&mut [
Filter::button(Filter::All),
])
}),
html!("li", {
.children(&mut [
Filter::button(Filter::Active),
])
}),
html!("li", {
.children(&mut [
Filter::button(Filter::Completed),
])
}),
])
}),
html!("button", {
.class("clear-completed")
// Hide if it doesn't have any completed items.
.visible_signal(state.todo_list.signal_vec_cloned()
.map_signal(|todo| todo.completed.signal())
.filter(|completed| *completed)
.len()
.map(|len| len > 0))
.event(clone!(state => move |_: events::Click| {
state.todo_list.lock_mut().retain(|todo| todo.completed.get() == false);
state.serialize();
}))
.text("Clear completed")
}),
])
}),
])
}),
);
dominator::append_dom(&dominator::body(),
html!("footer", {
.class("info")
.children(&mut [
html!("p", {
.text("Double-click to edit a todo")
}),
html!("p", {
.children(&mut [
text("Created by "),
html!("a", {
.attribute("href", "https://github.com/Pauan")
.text("Pauan")
}),
])
}),
html!("p", {
.children(&mut [
text("Part of "),
html!("a", {
.attribute("href", "http://todomvc.com")
.text("TodoMVC")
}),
])
}),
])
}),
);
Ok(())
dominator::append_dom(&dominator::get_id("app"), App::render(App::deserialize()));
}

View File

@ -0,0 +1,34 @@
use wasm_bindgen::prelude::*;
use web_sys::Url;
use futures_signals::signal::{Signal, SignalExt};
use dominator::routing;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Route {
Active,
Completed,
All,
}
impl Route {
pub fn signal() -> impl Signal<Item = Self> {
routing::url()
.signal_ref(|url| Url::new(&url).unwrap_throw())
.map(|url| {
match url.hash().as_str() {
"#/active" => Route::Active,
"#/completed" => Route::Completed,
_ => Route::All,
}
})
}
pub fn url(&self) -> &'static str {
match self {
Route::Active => "#/active",
Route::Completed => "#/completed",
Route::All => "#/",
}
}
}

View File

@ -0,0 +1,156 @@
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use serde_derive::{Serialize, Deserialize};
use web_sys::HtmlElement;
use futures_signals::map_ref;
use futures_signals::signal::{SignalExt, Mutable};
use dominator::{Dom, html, clone, events};
use crate::util::trim;
use crate::app::App;
use crate::routing::Route;
#[derive(Debug, Serialize, Deserialize)]
pub struct Todo {
id: u32,
title: Mutable<String>,
pub completed: Mutable<bool>,
#[serde(skip)]
editing: Mutable<Option<String>>,
}
impl Todo {
pub fn new(id: u32, title: String) -> Rc<Self> {
Rc::new(Self {
id: id,
title: Mutable::new(title),
completed: Mutable::new(false),
editing: Mutable::new(None),
})
}
fn set_completed(&self, app: &App, completed: bool) {
self.completed.set_neq(completed);
app.serialize();
}
fn remove(&self, app: &App) {
app.remove_todo(&self);
app.serialize();
}
fn cancel_editing(&self) {
self.editing.set_neq(None);
}
fn done_editing(&self, app: &App) {
if let Some(title) = self.editing.replace(None) {
if let Some(title) = trim(&title) {
self.title.set_neq(title);
} else {
app.remove_todo(&self);
}
app.serialize();
}
}
pub fn render(todo: Rc<Self>, app: Rc<App>) -> Dom {
html!("li", {
.class_signal("editing", todo.editing.signal_cloned().map(|x| x.is_some()))
.class_signal("completed", todo.completed.signal())
.visible_signal(map_ref!(
let route = Route::signal(),
let completed = todo.completed.signal() =>
match *route {
Route::Active => !completed,
Route::Completed => *completed,
Route::All => true,
}
)
.dedupe())
.children(&mut [
html!("div", {
.class("view")
.children(&mut [
html!("input", {
.attribute("type", "checkbox")
.class("toggle")
.property_signal("checked", todo.completed.signal())
.event(clone!(todo, app => move |event: events::Change| {
todo.set_completed(&app, event.checked().unwrap_throw());
}))
}),
html!("label", {
.event(clone!(todo => move |_: events::DoubleClick| {
todo.editing.set_neq(Some(todo.title.get_cloned()));
}))
.text_signal(todo.title.signal_cloned())
}),
html!("button", {
.class("destroy")
.event(clone!(todo, app => move |_: events::Click| {
todo.remove(&app);
}))
}),
])
}),
html!("input", {
.class("edit")
.property_signal("value", todo.editing.signal_cloned()
.map(|x| x.unwrap_or_else(|| "".to_owned())))
.visible_signal(todo.editing.signal_cloned()
.map(|x| x.is_some()))
// TODO dedupe this somehow ?
.focused_signal(todo.editing.signal_cloned()
.map(|x| x.is_some()))
.event(clone!(todo => move |event: events::KeyDown| {
match event.key().as_str() {
"Enter" => {
event.dyn_target::<HtmlElement>()
.unwrap_throw()
.blur()
.unwrap_throw();
},
"Escape" => {
todo.cancel_editing();
},
_ => {}
}
}))
.event(clone!(todo => move |event: events::Input| {
todo.editing.set_neq(Some(event.value().unwrap_throw()));
}))
// TODO global_event ?
.event(clone!(todo, app => move |_: events::Blur| {
todo.done_editing(&app);
}))
}),
])
})
}
}
impl PartialEq<Todo> for Todo {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}

View File

@ -0,0 +1,20 @@
use wasm_bindgen::prelude::*;
use web_sys::{window, Storage};
pub fn local_storage() -> Storage {
window().unwrap_throw().local_storage().unwrap_throw().unwrap_throw()
}
// TODO make this more efficient
#[inline]
pub fn trim(input: &str) -> Option<String> {
let trimmed = input.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
}

View File

@ -8,6 +8,12 @@
<link rel="stylesheet" href="lib/todomvc-app-css/index.css">
</head>
<body>
<div id="app"></div>
<footer class="info">
<p>Double-click to edit a todo</p>
<p>Created by <a href="https://github.com/Pauan">Pauan</a></p>
<p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
</footer>
<script src="lib/todomvc-common/base.js"></script>
<script src="index.js"></script>
</body>

View File

@ -96,6 +96,11 @@ pub fn body() -> HtmlElement {
}
pub fn get_id(id: &str) -> Element {
bindings::window().document().unwrap_throw().get_element_by_id(id).unwrap_throw()
}
pub struct DomHandle {
parent: Node,
dom: Dom,