on master help sign in
Add an example TodoMVC implementation
commit0ac0e9df9f38909c0b0e1b215f51736bc65f50d4 authorNick Fitzgerald <fitzgen@gmail.com> dateMon 11 Feb 2019, 18:48 (8 years ago) parent5837d53
15 files changed+1161
Cargo.toml+1 examples/todomvc/Cargo.toml+58 examples/todomvc/README.md+36 examples/todomvc/css/app.css+8 examples/todomvc/index.html+21 examples/todomvc/package-lock.json+16 examples/todomvc/package.json+7 examples/todomvc/src/controller.rs+124 examples/todomvc/src/keys.rs+7 examples/todomvc/src/lib.rs+59 examples/todomvc/src/router.rs+62 examples/todomvc/src/todo.rs+229 examples/todomvc/src/todos.rs+450 examples/todomvc/src/utils.rs+32 examples/todomvc/src/visibility.rs+51
Cargo.toml+1
@@ -49,4 +49,5 @@ members = [
4949 "./examples/counter",
5050 "./examples/hello-world",
5151 "./examples/input-form",
52+ "./examples/todomvc",
5253]
examples/todomvc/Cargo.tomlnew file+58
@@ -0,0 +1,58 @@
1+[package]
2+authors = ["Nick Fitzgerald <fitzgen@gmail.com>"]
3+edition = "2018"
4+name = "dodrio-todomvc"
5+version = "0.1.0"
6+
7+[dependencies]
8+cfg-if = "0.1.6"
9+futures = "0.1"
10+serde_json = "1.0.38"
11+wasm-bindgen = "0.2"
12+wasm-bindgen-futures = "0.3"
13+
14+[dependencies.console_error_panic_hook]
15+optional = true
16+version = "0.1.1"
17+
18+[dependencies.console_log]
19+optional = true
20+version = "0.1.2"
21+
22+[dependencies.dodrio]
23+path = "../.."
24+
25+[dependencies.log]
26+optional = true
27+version = "0.4.6"
28+
29+[dependencies.serde]
30+features = ["derive"]
31+version = "1"
32+
33+[dependencies.web-sys]
34+features = [
35+ "Document",
36+ "Event",
37+ "EventTarget",
38+ "HtmlElement",
39+ "HtmlInputElement",
40+ "KeyboardEvent",
41+ "Location",
42+ "Storage",
43+ "Node",
44+ "Window",
45+]
46+version = "0.3.8"
47+
48+[dev-dependencies]
49+wasm-bindgen-test = "0.2"
50+
51+[features]
52+logging = [
53+ "console_log",
54+ "console_error_panic_hook",
55+]
56+
57+[lib]
58+crate-type = ["cdylib"]
examples/todomvc/README.mdnew file+36
@@ -0,0 +1,36 @@
1+# TodoMVC
2+
3+`dodrio` implementation of the popular [TodoMVC](http://todomvc.com/) app. It
4+correctly and completely fulfills [the
5+specification](https://github.com/tastejs/todomvc/blob/master/app-spec.md) to
6+the best of my knowledge.
7+
8+## Source
9+
10+There are a number of modules in this `dodrio` implementation of TodoMVC. The
11+most important are:
12+
13+* `src/lib.rs`: The entry point to the application.
14+* `src/todos.rs`: Definition of `Todos` model and its rendering.
15+* `src/todo.rs`: Definition of `Todo` model and its rendering.
16+* `src/controller.rs`: The controller handles UI interactions and translates
17+ them into updates on the model. Finally, it triggers re-rendering after those
18+ updates.
19+* `src/router.rs`: A simple URL hash-based router.
20+
21+## Build
22+
23+```
24+# Get the TodoMVC CSS styles.
25+npm install
26+
27+wasm-pack build --target no-modules
28+```
29+
30+## Serve
31+
32+Use any HTTP server, for example:
33+
34+```
35+python -m SimpleHTTPServer
36+```
examples/todomvc/css/app.cssnew file+8
@@ -0,0 +1,8 @@
1+/*
2+
3+app-template.css overrides
4+
5+remove this comment if used
6+remove this file if not
7+
8+*/
examples/todomvc/index.htmlnew file+21
@@ -0,0 +1,21 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>dodrio • TodoMVC</title>
7+ <link rel="stylesheet" href="node_modules/todomvc-common/base.css">
8+ <link rel="stylesheet" href="node_modules/todomvc-app-css/index.css">
9+ </head>
10+ <body>
11+ <section class="todoapp">
12+ </section>
13+ <footer class="info">
14+ <p>Double-click to edit a todo</p>
15+ </footer>
16+ <script src="pkg/dodrio_todomvc.js"></script>
17+ <script>
18+ wasm_bindgen("pkg/dodrio_todomvc_bg.wasm");
19+ </script>
20+ </body>
21+</html>
examples/todomvc/package-lock.jsonnew file+16
@@ -0,0 +1,16 @@
1+{
2+ "requires": true,
3+ "lockfileVersion": 1,
4+ "dependencies": {
5+ "todomvc-app-css": {
6+ "version": "2.1.2",
7+ "resolved": "https://registry.npmjs.org/todomvc-app-css/-/todomvc-app-css-2.1.2.tgz",
8+ "integrity": "sha512-WgXLWY4snfC7yBkpzFb6xRmUbB06NGuji6njCByte0byW2DUpmyhh32o4sCQ8HX/pTwm71huKQlFiKYxR/2iVQ=="
9+ },
10+ "todomvc-common": {
11+ "version": "1.0.5",
12+ "resolved": "https://registry.npmjs.org/todomvc-common/-/todomvc-common-1.0.5.tgz",
13+ "integrity": "sha512-D8kEJmxVMQIWwztEdH+WeiAfXRbbSCpgXq4NkYi+gduJ2tr8CNq7sYLfJvjpQ10KD9QxJwig57rvMbV2QAESwQ=="
14+ }
15+ }
16+}
examples/todomvc/package.jsonnew file+7
@@ -0,0 +1,7 @@
1+{
2+ "private": true,
3+ "dependencies": {
4+ "todomvc-app-css": "^2.0.0",
5+ "todomvc-common": "^1.0.0"
6+ }
7+}
examples/todomvc/src/controller.rsnew file+124
@@ -0,0 +1,124 @@
1+//! The controller handles UI events, translates them into updates on the model,
2+//! and schedules re-renders.
3+
4+use crate::todo::{Todo, TodoActions};
5+use crate::todos::{Todos, TodosActions};
6+use crate::visibility::Visibility;
7+use dodrio::{RootRender, VdomWeak};
8+use serde::{Deserialize, Serialize};
9+
10+/// The controller for the TodoMVC app.
11+///
12+/// This `Controller` struct is never actually instantiated. It is only used for
13+/// its `*Actions` trait implementations, none of which take a `self` parameter.
14+///
15+/// One could imagine alternative controller implementations with `*Actions`
16+/// trait implementations for (e.g.) testing that will assert various expected
17+/// action methods are called after rendering TODO items and sending DOM events.
18+#[derive(Default, Deserialize, Serialize)]
19+pub struct Controller;
20+
21+/// Invoke `f` with the root `Todos` component, save the (likely just modified)
22+/// todos to local storage, and schedule a new `dodrio` render.
23+fn with_todos<F>(root: &mut dyn RootRender, vdom: VdomWeak, f: F)
24+where
25+ F: FnOnce(&mut Todos),
26+{
27+ let todos = root.unwrap_mut::<Todos>();
28+ f(todos);
29+ todos.save_to_local_storage();
30+ vdom.schedule_render();
31+}
32+
33+impl TodosActions for Controller {
34+ fn toggle_all(root: &mut dyn RootRender, vdom: VdomWeak) {
35+ with_todos(root, vdom, |todos| {
36+ let all_complete = todos.todos().iter().all(|t| t.is_complete());
37+ for t in todos.todos_mut() {
38+ t.set_complete(!all_complete);
39+ }
40+ });
41+ }
42+
43+ fn update_draft(root: &mut dyn RootRender, vdom: VdomWeak, draft: String) {
44+ with_todos(root, vdom, |todos| {
45+ todos.set_draft(draft);
46+ });
47+ }
48+
49+ fn finish_draft(root: &mut dyn RootRender, vdom: VdomWeak) {
50+ with_todos(root, vdom, |todos| {
51+ let title = todos.take_draft();
52+ let title = title.trim();
53+ if !title.is_empty() {
54+ let id = todos.todos().len();
55+ let new = Todo::new(id, title);
56+ todos.add_todo(new);
57+ }
58+ });
59+ }
60+
61+ fn change_visibility(root: &mut dyn RootRender, vdom: VdomWeak, vis: Visibility) {
62+ with_todos(root, vdom, |todos| {
63+ todos.set_visibility(vis);
64+ });
65+ }
66+
67+ fn delete_completed(root: &mut dyn RootRender, vdom: VdomWeak) {
68+ with_todos(root, vdom, |todos| {
69+ todos.delete_completed();
70+ });
71+ }
72+}
73+
74+impl TodoActions for Controller {
75+ fn toggle_completed(root: &mut dyn RootRender, vdom: VdomWeak, id: usize) {
76+ with_todos(root, vdom, |todos| {
77+ let t = &mut todos.todos_mut()[id];
78+ let completed = t.is_complete();
79+ t.set_complete(!completed);
80+ });
81+ }
82+
83+ fn delete(root: &mut dyn RootRender, vdom: VdomWeak, id: usize) {
84+ with_todos(root, vdom, |todos| {
85+ todos.delete_todo(id);
86+ });
87+ }
88+
89+ fn begin_editing(root: &mut dyn RootRender, vdom: VdomWeak, id: usize) {
90+ with_todos(root, vdom, |todos| {
91+ let t = &mut todos.todos_mut()[id];
92+ let desc = t.title().to_string();
93+ t.set_edits(Some(desc));
94+ });
95+ }
96+
97+ fn update_edits(root: &mut dyn RootRender, vdom: VdomWeak, id: usize, edits: String) {
98+ with_todos(root, vdom, |todos| {
99+ let t = &mut todos.todos_mut()[id];
100+ t.set_edits(Some(edits));
101+ });
102+ }
103+
104+ fn finish_edits(root: &mut dyn RootRender, vdom: VdomWeak, id: usize) {
105+ with_todos(root, vdom, |todos| {
106+ let t = &mut todos.todos_mut()[id];
107+ if let Some(edits) = t.take_edits() {
108+ let edits = edits.trim();
109+ if edits.is_empty() {
110+ todos.delete_todo(id);
111+ } else {
112+ t.set_title(edits);
113+ }
114+ }
115+ });
116+ }
117+
118+ fn cancel_edits(root: &mut dyn RootRender, vdom: VdomWeak, id: usize) {
119+ with_todos(root, vdom, |todos| {
120+ let t = &mut todos.todos_mut()[id];
121+ let _ = t.take_edits();
122+ });
123+ }
124+}
examples/todomvc/src/keys.rsnew file+7
@@ -0,0 +1,7 @@
1+//! Constants for `KeyboardEvent::key_code`.`
2+
3+/// The key code for the enter key.
4+pub const ENTER: u32 = 13;
5+
6+/// The key code for the escape key.
7+pub const ESCAPE: u32 = 27;
examples/todomvc/src/lib.rsnew file+59
@@ -0,0 +1,59 @@
1+//! TodoMVC implemented in `dodrio`!
2+
3+#![deny(missing_docs)]
4+
5+pub mod controller;
6+pub mod keys;
7+pub mod router;
8+pub mod todo;
9+pub mod todos;
10+pub mod utils;
11+pub mod visibility;
12+
13+use controller::Controller;
14+use dodrio::Vdom;
15+use todos::Todos;
16+use wasm_bindgen::prelude::*;
17+
18+/// Run the TodoMVC app!
19+///
20+/// Since this is marked `#[wasm_bindgen(start)]` it is automatically invoked
21+/// once the wasm module instantiated on the Web page.
22+#[wasm_bindgen(start)]
23+pub fn run() {
24+ // Set up the logging for debugging if/when things go wrong.
25+ init_logging();
26+
27+ // Grab the TODO app container.
28+ let document = utils::document();
29+ let container = document
30+ .query_selector(".todoapp")
31+ .unwrap_throw()
32+ .unwrap_throw();
33+
34+ // Create a new `Todos` render component.
35+ let todos = Todos::<Controller>::new();
36+
37+ // Create a virtual DOM and mount it and the `Todos` render component.
38+ let vdom = Vdom::new(&container, todos);
39+
40+ // Start the URL router.
41+ router::start(vdom.weak());
42+
43+ // Run the virtual DOM forever and don't unmount it.
44+ vdom.forget();
45+}
46+
47+cfg_if::cfg_if! {
48+ if #[cfg(feature = "logging")] {
49+ fn init_logging() {
50+ use log::*;
51+ console_error_panic_hook::set_once();
52+ console_log::init_with_level(Level::Trace).expect_throw("should initialize logging OK");
53+ }
54+ } else {
55+ fn init_logging() {
56+ // Do nothing.
57+ }
58+ }
59+}
examples/todomvc/src/router.rsnew file+62
@@ -0,0 +1,62 @@
1+//! A simple `#`-fragment router.
2+
3+use crate::todos::Todos;
4+use crate::utils;
5+use crate::visibility::Visibility;
6+use dodrio::VdomWeak;
7+use futures::prelude::*;
8+use std::str::FromStr;
9+use wasm_bindgen::prelude::*;
10+use wasm_bindgen::JsCast;
11+
12+/// Start the router.
13+pub fn start(vdom: VdomWeak) {
14+ // Callback fired whenever the URL's hash fragment changes. Keeps the root
15+ // TODOs collection's visibility in sync with the `#` fragment.
16+ let on_hash_change = move || {
17+ let new_vis = utils::hash()
18+ .and_then(|hash| {
19+ if hash.starts_with("#/") {
20+ Visibility::from_str(&hash[2..]).ok()
21+ } else {
22+ None
23+ }
24+ })
25+ .unwrap_or_else(|| {
26+ // If we couldn't parse a visibility, make sure we canonicalize
27+ // it back to our default hash.
28+ let v = Visibility::default();
29+ utils::set_hash(&format!("#/{}", v));
30+ v
31+ });
32+
33+ wasm_bindgen_futures::spawn_local(
34+ vdom.with_component({
35+ let vdom = vdom.clone();
36+ move |root| {
37+ let todos = root.unwrap_mut::<Todos>();
38+ // If the todos' visibility already matches the event's
39+ // visibility, then there is nothing to do (ha). If they
40+ // don't match, then we need to update the todos' visibility
41+ // and re-render.
42+ if todos.visibility() != new_vis {
43+ todos.set_visibility(new_vis);
44+ vdom.schedule_render();
45+ }
46+ }
47+ })
48+ .map_err(|_| ()),
49+ );
50+ };
51+
52+ // Call it once to handle the initial `#` fragment.
53+ on_hash_change();
54+
55+ // Now listen for hash changes forever.
56+ let on_hash_change = Closure::wrap(Box::new(on_hash_change) as Box<FnMut()>);
57+ let window = utils::window();
58+ window
59+ .add_event_listener_with_callback("hashchange", on_hash_change.as_ref().unchecked_ref())
60+ .unwrap_throw();
61+ on_hash_change.forget();
62+}
examples/todomvc/src/todo.rsnew file+229
@@ -0,0 +1,229 @@
1+//! Type definition and `dodrio::Render` implementation for a single TODO item.
2+
3+use crate::keys;
4+use dodrio::{bumpalo::Bump, on, Attribute, Node, Render, RootRender, VdomWeak};
5+use serde::{Deserialize, Serialize};
6+use std::marker::PhantomData;
7+use wasm_bindgen::{prelude::*, JsCast};
8+
9+/// A single TODO item.
10+#[derive(Serialize, Deserialize)]
11+pub struct Todo<C> {
12+ id: usize,
13+ title: String,
14+ completed: bool,
15+
16+ #[serde(skip)]
17+ edits: Option<String>,
18+
19+ #[serde(skip)]
20+ _controller: PhantomData<C>,
21+}
22+
23+/// Actions on a single TODO item that can be triggered from the UI.
24+pub trait TodoActions {
25+ /// Toggle the completion state of the TODO item with the given id.
26+ fn toggle_completed(root: &mut dyn RootRender, vdom: VdomWeak, id: usize);
27+
28+ /// Delete the TODO item with the given id.
29+ fn delete(root: &mut dyn RootRender, vdom: VdomWeak, id: usize);
30+
31+ /// Begin editing the TODO item with the given id.
32+ fn begin_editing(root: &mut dyn RootRender, vdom: VdomWeak, id: usize);
33+
34+ /// Update the edits for the TODO with the given id.
35+ fn update_edits(root: &mut dyn RootRender, vdom: VdomWeak, id: usize, edits: String);
36+
37+ /// Finish editing the TODO with the given id.
38+ fn finish_edits(root: &mut dyn RootRender, vdom: VdomWeak, id: usize);
39+
40+ /// Cancel editing the TODO with the given id.
41+ fn cancel_edits(root: &mut dyn RootRender, vdom: VdomWeak, id: usize);
42+}
43+
44+impl<C> Todo<C> {
45+ /// Construct a new `Todo` with the given identifier and title.
46+ pub fn new<S: Into<String>>(id: usize, title: S) -> Self {
47+ let title = title.into();
48+ let completed = false;
49+ let edits = None;
50+ Todo {
51+ id,
52+ title,
53+ completed,
54+ edits,
55+ _controller: PhantomData,
56+ }
57+ }
58+
59+ /// Set this TODO item's id.
60+ pub fn set_id(&mut self, id: usize) {
61+ self.id = id;
62+ }
63+
64+ /// Is this `Todo` complete?
65+ pub fn is_complete(&self) -> bool {
66+ self.completed
67+ }
68+
69+ /// Mark the `Todo` as complete or not.
70+ pub fn set_complete(&mut self, to: bool) {
71+ self.completed = to;
72+ }
73+
74+ /// Get this TODO's title.
75+ pub fn title(&self) -> &str {
76+ &self.title
77+ }
78+
79+ /// Set this TODO item's title.
80+ pub fn set_title<S: Into<String>>(&mut self, title: S) {
81+ self.title = title.into();
82+ }
83+
84+ /// Set the edits for this TODO.
85+ pub fn set_edits<S: Into<String>>(&mut self, edits: Option<S>) {
86+ self.edits = edits.map(Into::into);
87+ }
88+
89+ /// Take this TODO's edits, leaving `None` in their place.
90+ pub fn take_edits(&mut self) -> Option<String> {
91+ self.edits.take()
92+ }
93+}
94+
95+impl<C: TodoActions> Render for Todo<C> {
96+ fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> dodrio::Node<'bump>
97+ where
98+ 'a: 'bump,
99+ {
100+ use dodrio::bumpalo::{self, collections::String};
101+
102+ let mut class = String::new_in(bump);
103+ if self.completed {
104+ class.push_str("completed ");
105+ }
106+ if self.edits.is_some() {
107+ class.push_str("editing");
108+ }
109+
110+ let id = self.id;
111+ let title = self.edits.as_ref().unwrap_or(&self.title);
112+ let elem_id = bumpalo::format!(in bump, "todo-{}", id);
113+
114+ let mut input_attrs = bumpalo::vec![
115+ in bump;
116+ Attribute {
117+ name: "class",
118+ value: "toggle",
119+ },
120+ Attribute {
121+ name: "type",
122+ value: "checkbox",
123+ }
124+ ];
125+ if self.completed {
126+ input_attrs.push(Attribute {
127+ name: "checked",
128+ value: "",
129+ });
130+ }
131+ let input_attrs = input_attrs.into_bump_slice();
132+
133+ Node::element(
134+ bump,
135+ "li",
136+ [],
137+ [Attribute {
138+ name: "class",
139+ value: class.into_bump_str(),
140+ }],
141+ [
142+ Node::element(
143+ bump,
144+ "div",
145+ [],
146+ [Attribute {
147+ name: "class",
148+ value: "view",
149+ }],
150+ [
151+ Node::element(
152+ bump,
153+ "input",
154+ [on(bump, "click", move |root, vdom, _event| {
155+ C::toggle_completed(root, vdom, id);
156+ })],
157+ input_attrs,
158+ [],
159+ ),
160+ Node::element(
161+ bump,
162+ "label",
163+ [on(bump, "dblclick", move |root, vdom, _event| {
164+ C::begin_editing(root, vdom, id);
165+ })],
166+ [],
167+ [Node::text(title)],
168+ ),
169+ Node::element(
170+ bump,
171+ "button",
172+ [on(bump, "click", move |root, vdom, _event| {
173+ C::delete(root, vdom, id);
174+ })],
175+ [Attribute {
176+ name: "class",
177+ value: "destroy",
178+ }],
179+ [],
180+ ),
181+ ],
182+ ),
183+ Node::element(
184+ bump,
185+ "input",
186+ [
187+ on(bump, "input", move |root, vdom, event| {
188+ let input = event
189+ .target()
190+ .unwrap_throw()
191+ .unchecked_into::<web_sys::HtmlInputElement>();
192+ C::update_edits(root, vdom, id, input.value());
193+ }),
194+ on(bump, "blur", move |root, vdom, _event| {
195+ C::finish_edits(root, vdom, id);
196+ }),
197+ on(bump, "keydown", move |root, vdom, event| {
198+ let event = event.unchecked_into::<web_sys::KeyboardEvent>();
199+ match event.key_code() {
200+ keys::ENTER => C::finish_edits(root, vdom, id),
201+ keys::ESCAPE => C::cancel_edits(root, vdom, id),
202+ _ => {}
203+ }
204+ }),
205+ ],
206+ [
207+ Attribute {
208+ name: "class",
209+ value: "edit",
210+ },
211+ Attribute {
212+ name: "value",
213+ value: title,
214+ },
215+ Attribute {
216+ name: "name",
217+ value: "title",
218+ },
219+ Attribute {
220+ name: "id",
221+ value: elem_id.into_bump_str(),
222+ },
223+ ],
224+ [],
225+ ),
226+ ],
227+ )
228+ }
229+}
examples/todomvc/src/todos.rsnew file+450
@@ -0,0 +1,450 @@
1+//! Type definitions and `dodrio::Render` implementation for a collection of
2+//! TODO items.
3+
4+use crate::controller::Controller;
5+use crate::todo::{Todo, TodoActions};
6+use crate::visibility::Visibility;
7+use crate::{keys, utils};
8+use dodrio::{
9+ bumpalo::{self, Bump},
10+ on, Attribute, Node, Render, RootRender, VdomWeak,
11+};
12+use serde::{Deserialize, Serialize};
13+use std::marker::PhantomData;
14+use std::mem;
15+use wasm_bindgen::prelude::*;
16+use wasm_bindgen::JsCast;
17+
18+/// A collection of TODOs.
19+#[derive(Default, Serialize, Deserialize)]
20+#[serde(rename = "todos-dodrio", bound = "")]
21+pub struct Todos<C = Controller> {
22+ todos: Vec<Todo<C>>,
23+
24+ #[serde(skip)]
25+ draft: String,
26+
27+ #[serde(skip)]
28+ visibility: Visibility,
29+
30+ #[serde(skip)]
31+ _controller: PhantomData<C>,
32+}
33+
34+/// Actions for `Todos` that can be triggered by UI interactions.
35+pub trait TodosActions: TodoActions {
36+ /// Toggle the completion state of all TODO items.
37+ fn toggle_all(root: &mut dyn RootRender, vdom: VdomWeak);
38+
39+ /// Update the draft TODO item's text.
40+ fn update_draft(root: &mut dyn RootRender, vdom: VdomWeak, draft: String);
41+
42+ /// Finish the current draft TODO item and add it to the collection of
43+ /// TODOs.
44+ fn finish_draft(root: &mut dyn RootRender, vdom: VdomWeak);
45+
46+ /// Change the TODO item visibility filtering to the given `Visibility`.
47+ fn change_visibility(root: &mut dyn RootRender, vdom: VdomWeak, vis: Visibility);
48+
49+ /// Delete all completed TODO items.
50+ fn delete_completed(root: &mut dyn RootRender, vdom: VdomWeak);
51+}
52+
53+impl<C> Todos<C> {
54+ /// Construct a new TODOs set.
55+ ///
56+ /// If an existing set is available in local storage, then us that,
57+ /// otherwise create a new set.
58+ pub fn new() -> Self
59+ where
60+ C: Default,
61+ {
62+ Self::from_local_storage().unwrap_or_default()
63+ }
64+
65+ /// Deserialize a set of TODOs from local storage.
66+ pub fn from_local_storage() -> Option<Self> {
67+ utils::local_storage()
68+ .get("todomvc-dodrio")
69+ .ok()
70+ .and_then(|opt| opt)
71+ .and_then(|json| serde_json::from_str(&json).ok())
72+ }
73+
74+ /// Serialize this set of TODOs to local storage.
75+ pub fn save_to_local_storage(&self) {
76+ let serialized = serde_json::to_string(self).unwrap_throw();
77+ utils::local_storage()
78+ .set("todomvc-dodrio", &serialized)
79+ .unwrap_throw();
80+ }
81+
82+ /// Add a new TODO item to this collection.
83+ pub fn add_todo(&mut self, todo: Todo<C>) {
84+ self.todos.push(todo);
85+ }
86+
87+ /// Delete the TODO with the given id.
88+ pub fn delete_todo(&mut self, id: usize) {
89+ self.todos.remove(id);
90+ self.fix_ids();
91+ }
92+
93+ /// Delete all completed TODO items.
94+ pub fn delete_completed(&mut self) {
95+ self.todos.retain(|t| !t.is_complete());
96+ self.fix_ids();
97+ }
98+
99+ // Fix all TODO identifiers so that they match their index once again.
100+ fn fix_ids(&mut self) {
101+ for (id, todo) in self.todos.iter_mut().enumerate() {
102+ todo.set_id(id);
103+ }
104+ }
105+
106+ /// Get a shared slice of the underlying set of TODO items.
107+ pub fn todos(&self) -> &[Todo<C>] {
108+ &self.todos
109+ }
110+
111+ /// Get an exclusive slice of the underlying set of TODO items.
112+ pub fn todos_mut(&mut self) -> &mut [Todo<C>] {
113+ &mut self.todos
114+ }
115+
116+ /// Set the draft TODO item text.
117+ pub fn set_draft<S: Into<String>>(&mut self, draft: S) {
118+ self.draft = draft.into();
119+ }
120+
121+ /// Take the current draft text and replace it with an empty string.
122+ pub fn take_draft(&mut self) -> String {
123+ mem::replace(&mut self.draft, String::new())
124+ }
125+
126+ /// Get the current visibility for these TODOs.
127+ pub fn visibility(&self) -> Visibility {
128+ self.visibility
129+ }
130+
131+ /// Set the visibility for these TODOS.
132+ pub fn set_visibility(&mut self, vis: Visibility) {
133+ self.visibility = vis;
134+ }
135+}
136+
137+/// Rendering helpers.
138+impl<C: TodosActions> Todos<C> {
139+ fn header<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump>
140+ where
141+ 'a: 'bump,
142+ {
143+ Node::element(
144+ bump,
145+ "header",
146+ [],
147+ [Attribute {
148+ name: "class",
149+ value: "header",
150+ }],
151+ [
152+ Node::element(bump, "h1", [], [], [Node::text("todos")]),
153+ Node::element(
154+ bump,
155+ "input",
156+ [
157+ on(bump, "input", |root, vdom, event| {
158+ let input = event
159+ .target()
160+ .unwrap_throw()
161+ .unchecked_into::<web_sys::HtmlInputElement>();
162+ C::update_draft(root, vdom, input.value());
163+ }),
164+ on(bump, "keydown", |root, vdom, event| {
165+ let event = event.unchecked_into::<web_sys::KeyboardEvent>();
166+ if event.key_code() == keys::ENTER {
167+ C::finish_draft(root, vdom);
168+ }
169+ }),
170+ ],
171+ [
172+ Attribute {
173+ name: "class",
174+ value: "new-todo",
175+ },
176+ Attribute {
177+ name: "placeholder",
178+ value: "What needs to be done?",
179+ },
180+ Attribute {
181+ name: "autofocus",
182+ value: "",
183+ },
184+ Attribute {
185+ name: "value",
186+ value: &self.draft,
187+ },
188+ ],
189+ [],
190+ ),
191+ ],
192+ )
193+ }
194+
195+ fn todos_list<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump>
196+ where
197+ 'a: 'bump,
198+ {
199+ use dodrio::bumpalo::collections::Vec;
200+
201+ let mut todos = Vec::new_in(bump);
202+ todos.extend(
203+ self.todos
204+ .iter()
205+ .filter(|t| match self.visibility {
206+ Visibility::All => true,
207+ Visibility::Active => !t.is_complete(),
208+ Visibility::Completed => t.is_complete(),
209+ })
210+ .map(|t| t.render(bump)),
211+ );
212+ let todos = todos.into_bump_slice();
213+
214+ let mut input_attrs = bumpalo::vec![
215+ in bump;
216+ Attribute {
217+ name: "class",
218+ value: "toggle-all",
219+ },
220+ Attribute {
221+ name: "id",
222+ value: "toggle-all",
223+ },
224+ Attribute {
225+ name: "type",
226+ value: "checkbox",
227+ },
228+ Attribute {
229+ name: "name",
230+ value: "toggle",
231+ }
232+ ];
233+ if self.todos.iter().all(|t| t.is_complete()) {
234+ input_attrs.push(Attribute {
235+ name: "checked",
236+ value: "",
237+ });
238+ }
239+ let input_attrs = input_attrs.into_bump_slice();
240+
241+ Node::element(
242+ bump,
243+ "section",
244+ [],
245+ [
246+ Attribute {
247+ name: "class",
248+ value: "main",
249+ },
250+ Attribute {
251+ name: "visibility",
252+ value: if self.todos.is_empty() {
253+ "hidden"
254+ } else {
255+ "visible"
256+ },
257+ },
258+ ],
259+ [
260+ Node::element(
261+ bump,
262+ "input",
263+ [on(bump, "click", |root, vdom, _event| {
264+ C::toggle_all(root, vdom);
265+ })],
266+ input_attrs,
267+ [],
268+ ),
269+ Node::element(
270+ bump,
271+ "label",
272+ [],
273+ [Attribute {
274+ name: "for",
275+ value: "toggle-all",
276+ }],
277+ [Node::text("Mark all as complete")],
278+ ),
279+ Node::element(
280+ bump,
281+ "ul",
282+ [],
283+ [Attribute {
284+ name: "class",
285+ value: "todo-list",
286+ }],
287+ todos,
288+ ),
289+ ],
290+ )
291+ }
292+
293+ fn footer<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump>
294+ where
295+ 'a: 'bump,
296+ {
297+ let completed_count = self.todos.iter().filter(|t| t.is_complete()).count();
298+ let incomplete_count = self.todos.len() - completed_count;
299+ let items_left = if incomplete_count == 1 {
300+ " item left"
301+ } else {
302+ " items left"
303+ };
304+ let incomplete_count = bumpalo::format!(in bump, "{}", incomplete_count);
305+
306+ let attrs = if self.todos.is_empty() {
307+ &bump.alloc([
308+ Attribute {
309+ name: "class",
310+ value: "footer",
311+ },
312+ Attribute {
313+ name: "hidden",
314+ value: "",
315+ },
316+ ])[..]
317+ } else {
318+ &bump.alloc([Attribute {
319+ name: "class",
320+ value: "footer",
321+ }])[..]
322+ };
323+
324+ let clear_completed_text = bumpalo::format!(
325+ in bump,
326+ "Clear completed ({})",
327+ self.todos.iter().filter(|t| t.is_complete()).count()
328+ );
329+
330+ Node::element(
331+ bump,
332+ "footer",
333+ [],
334+ attrs,
335+ [
336+ Node::element(
337+ bump,
338+ "span",
339+ [],
340+ [Attribute {
341+ name: "class",
342+ value: "todo-count",
343+ }],
344+ [
345+ Node::element(
346+ bump,
347+ "strong",
348+ [],
349+ [],
350+ [Node::text(incomplete_count.into_bump_str())],
351+ ),
352+ Node::text(items_left),
353+ ],
354+ ),
355+ Node::element(
356+ bump,
357+ "ul",
358+ [],
359+ [Attribute {
360+ name: "class",
361+ value: "filters",
362+ }],
363+ [
364+ self.visibility_swap(bump, "#/", Visibility::All),
365+ self.visibility_swap(bump, "#/active", Visibility::Active),
366+ self.visibility_swap(bump, "#/completed", Visibility::Completed),
367+ ],
368+ ),
369+ Node::element(
370+ bump,
371+ "button",
372+ [on(bump, "click", |root, vdom, _event| {
373+ C::delete_completed(root, vdom);
374+ })],
375+ {
376+ let mut attrs = bumpalo::vec![
377+ in bump;
378+ Attribute {
379+ name: "class",
380+ value: "clear-completed",
381+ }
382+ ];
383+ if self.todos.iter().all(|t| !t.is_complete()) {
384+ attrs.push(Attribute {
385+ name: "hidden",
386+ value: "",
387+ });
388+ }
389+ attrs
390+ },
391+ [Node::text(clear_completed_text.into_bump_str())],
392+ ),
393+ ],
394+ )
395+ }
396+
397+ fn visibility_swap<'a, 'bump>(
398+ &'a self,
399+ bump: &'bump Bump,
400+ url: &'static str,
401+ target_vis: Visibility,
402+ ) -> Node<'bump>
403+ where
404+ 'a: 'bump,
405+ {
406+ Node::element(
407+ bump,
408+ "li",
409+ [on(bump, "click", move |root, vdom, _event| {
410+ C::change_visibility(root, vdom, target_vis);
411+ })],
412+ [],
413+ [Node::element(
414+ bump,
415+ "a",
416+ [],
417+ [
418+ Attribute {
419+ name: "href",
420+ value: url,
421+ },
422+ Attribute {
423+ name: "class",
424+ value: if self.visibility == target_vis {
425+ "selected"
426+ } else {
427+ ""
428+ },
429+ },
430+ ],
431+ [Node::text(target_vis.label())],
432+ )],
433+ )
434+ }
435+}
436+
437+impl<C: TodosActions> Render for Todos<C> {
438+ fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump>
439+ where
440+ 'a: 'bump,
441+ {
442+ Node::element(
443+ bump,
444+ "div",
445+ [],
446+ [],
447+ [self.header(bump), self.todos_list(bump), self.footer(bump)],
448+ )
449+ }
450+}
examples/todomvc/src/utils.rsnew file+32
@@ -0,0 +1,32 @@
1+//! Small utility functions.
2+
3+use wasm_bindgen::UnwrapThrowExt;
4+
5+/// Get the top-level window.
6+pub fn window() -> web_sys::Window {
7+ web_sys::window().unwrap_throw()
8+}
9+
10+/// Get the current location hash, if any.
11+pub fn hash() -> Option<String> {
12+ window()
13+ .location()
14+ .hash()
15+ .ok()
16+ .and_then(|h| if h.is_empty() { None } else { Some(h) })
17+}
18+
19+/// Set the current location hash.
20+pub fn set_hash(hash: &str) {
21+ window().location().set_hash(hash).unwrap_throw();
22+}
23+
24+/// Get the top-level document.
25+pub fn document() -> web_sys::Document {
26+ window().document().unwrap_throw()
27+}
28+
29+/// Get the top-level window's local storage.
30+pub fn local_storage() -> web_sys::Storage {
31+ window().local_storage().unwrap_throw().unwrap_throw()
32+}
examples/todomvc/src/visibility.rsnew file+51
@@ -0,0 +1,51 @@
1+//! Visibility filtering.
2+
3+use std::fmt;
4+use std::str::FromStr;
5+
6+/// The visibility filtering for TODO items.
7+#[derive(Clone, Copy, Eq, PartialEq)]
8+pub enum Visibility {
9+ /// Show all TODOs.
10+ All,
11+ /// Show only active, incomplete TODOs.
12+ Active,
13+ /// Show only inactive, completed TODOs.
14+ Completed,
15+}
16+
17+impl Default for Visibility {
18+ fn default() -> Visibility {
19+ Visibility::All
20+ }
21+}
22+
23+impl FromStr for Visibility {
24+ type Err = ();
25+
26+ fn from_str(s: &str) -> Result<Self, Self::Err> {
27+ match s {
28+ "all" => Ok(Visibility::All),
29+ "active" => Ok(Visibility::Active),
30+ "completed" => Ok(Visibility::Completed),
31+ _ => Err(()),
32+ }
33+ }
34+}
35+
36+impl fmt::Display for Visibility {
37+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
38+ write!(f, "{}", self.label().to_lowercase())
39+ }
40+}
41+
42+impl Visibility {
43+ /// Get a string label for this visibility.
44+ pub fn label(self) -> &'static str {
45+ match self {
46+ Visibility::All => "All",
47+ Visibility::Active => "Active",
48+ Visibility::Completed => "Completed",
49+ }
50+ }
51+}