@@ -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+}