Simplify and add comments on TodoMVC

This commit is contained in:
Greg Johnston 2022-11-05 19:14:45 -04:00
parent 6c04e91088
commit 8f56a52615
3 changed files with 81 additions and 48 deletions

View File

@ -5,15 +5,16 @@ edition = "2021"
[dependencies] [dependencies]
leptos = { path = "../../leptos", default-features = false } leptos = { path = "../../leptos", default-features = false }
miniserde = "0.1"
log = "0.4" log = "0.4"
console_log = "0.2" console_log = "0.2"
console_error_panic_hook = "0.1.7" console_error_panic_hook = "0.1.7"
uuid = { version = "1", features = ["v4", "js", "serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dev-dependencies] [dev-dependencies]
wasm-bindgen-test = "0.3.0" wasm-bindgen-test = "0.3.0"
[features] [features]
default = ["csr"] default = ["csr"]
csr = ["leptos/csr"] csr = ["leptos/csr"]

View File

@ -1,6 +1,6 @@
use leptos::{web_sys::HtmlInputElement, *}; use leptos::{web_sys::HtmlInputElement, *};
use miniserde::json;
use storage::TodoSerialized; use storage::TodoSerialized;
use uuid::Uuid;
mod storage; mod storage;
@ -9,6 +9,7 @@ pub struct Todos(pub Vec<Todo>);
const STORAGE_KEY: &str = "todos-leptos"; const STORAGE_KEY: &str = "todos-leptos";
// Basic operations to manipulate the todo list: nothing really interesting here
impl Todos { impl Todos {
pub fn new(cx: Scope) -> Self { pub fn new(cx: Scope) -> Self {
let starting_todos = if is_server!() { let starting_todos = if is_server!() {
@ -18,7 +19,7 @@ impl Todos {
.get_item(STORAGE_KEY) .get_item(STORAGE_KEY)
.ok() .ok()
.flatten() .flatten()
.and_then(|value| json::from_str::<Vec<TodoSerialized>>(&value).ok()) .and_then(|value| serde_json::from_str::<Vec<TodoSerialized>>(&value).ok())
.map(|values| { .map(|values| {
values values
.into_iter() .into_iter()
@ -40,31 +41,33 @@ impl Todos {
self.0.push(todo); self.0.push(todo);
} }
pub fn remove(&mut self, id: usize) { pub fn remove(&mut self, id: Uuid) {
self.0.retain(|todo| todo.id != id); self.0.retain(|todo| todo.id != id);
} }
pub fn remaining(&self) -> usize { pub fn remaining(&self) -> usize {
self.0.iter().filter(|todo| !(todo.completed)()).count() // `todo.completed` is a signal, so we call .get() to access its value
self.0.iter().filter(|todo| !todo.completed.get()).count()
} }
pub fn completed(&self) -> usize { pub fn completed(&self) -> usize {
self.0.iter().filter(|todo| (todo.completed)()).count() // `todo.completed` is a signal, so we call .get() to access its value
self.0.iter().filter(|todo| todo.completed.get()).count()
} }
pub fn toggle_all(&self) { pub fn toggle_all(&self) {
// if all are complete, mark them all active instead // if all are complete, mark them all active
if self.remaining() == 0 { if self.remaining() == 0 {
for todo in &self.0 { for todo in &self.0 {
if todo.completed.get() { if todo.completed.get() {
(todo.set_completed)(false); todo.completed.set(false);
} }
} }
} }
// otherwise, mark them all complete // otherwise, mark them all complete
else { else {
for todo in &self.0 { for todo in &self.0 {
(todo.set_completed)(true); todo.completed.set(true);
} }
} }
} }
@ -76,33 +79,35 @@ impl Todos {
#[derive(Debug, PartialEq, Eq, Clone)] #[derive(Debug, PartialEq, Eq, Clone)]
pub struct Todo { pub struct Todo {
pub id: usize, pub id: Uuid,
pub title: ReadSignal<String>, pub title: RwSignal<String>,
pub set_title: WriteSignal<String>, pub completed: RwSignal<bool>,
pub completed: ReadSignal<bool>,
pub set_completed: WriteSignal<bool>,
} }
impl Todo { impl Todo {
pub fn new(cx: Scope, id: usize, title: String) -> Self { pub fn new(cx: Scope, id: Uuid, title: String) -> Self {
Self::new_with_completed(cx, id, title, false) Self::new_with_completed(cx, id, title, false)
} }
pub fn new_with_completed(cx: Scope, id: usize, title: String, completed: bool) -> Self { pub fn new_with_completed(cx: Scope, id: Uuid, title: String, completed: bool) -> Self {
let (title, set_title) = create_signal(cx, title); // RwSignal combines the getter and setter in one struct, rather than separating
let (completed, set_completed) = create_signal(cx, completed); // the getter from the setter. This makes it more convenient in some cases, such
// as when we're putting the signals into a struct and passing it around. There's
// no real difference: you could use `create_signal` here, or use `create_rw_signal`
// everywhere.
let title = create_rw_signal(cx, title);
let completed = create_rw_signal(cx, completed);
Self { Self {
id, id,
title, title,
set_title,
completed, completed,
set_completed,
} }
} }
pub fn toggle(&self) { pub fn toggle(&self) {
self.set_completed // A signal's `update()` function gives you a mutable reference to the current value
.update(|completed| *completed = !*completed); // You can use that to modify the value in place, which will notify any subscribers.
self.completed.update(|completed| *completed = !*completed);
} }
} }
@ -111,23 +116,24 @@ const ENTER_KEY: u32 = 13;
#[component] #[component]
pub fn TodoMVC(cx: Scope, todos: Todos) -> Element { pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
let mut next_id = todos // The `todos` are a signal, since we need to reactively update the list
.0
.iter()
.map(|todo| todo.id)
.max()
.map(|last| last + 1)
.unwrap_or(0);
let (todos, set_todos) = create_signal(cx, todos); let (todos, set_todos) = create_signal(cx, todos);
// We provide a context that each <Todo/> component can use to update the list
// Here, I'm just passing the `WriteSignal`; a <Todo/> doesn't need to read the whole list
// (and shouldn't try to, as that would cause each individual <Todo/> to re-render when
// a new todo is added! This kind of hygiene is why `create_signal` defaults to read-write
// segregation.)
provide_context(cx, set_todos); provide_context(cx, set_todos);
// Handle the three filter modes: All, Active, and Completed
let (mode, set_mode) = create_signal(cx, Mode::All); let (mode, set_mode) = create_signal(cx, Mode::All);
window_event_listener("hashchange", move |_| { window_event_listener("hashchange", move |_| {
let new_mode = location_hash().map(|hash| route(&hash)).unwrap_or_default(); let new_mode = location_hash().map(|hash| route(&hash)).unwrap_or_default();
set_mode(new_mode); set_mode(new_mode);
}); });
// Callback to add a todo on pressing the `Enter` key, if the field isn't empty
let add_todo = move |ev: web_sys::Event| { let add_todo = move |ev: web_sys::Event| {
let target = event_target::<HtmlInputElement>(&ev); let target = event_target::<HtmlInputElement>(&ev);
ev.stop_propagation(); ev.stop_propagation();
@ -136,15 +142,16 @@ pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
let title = event_target_value(&ev); let title = event_target_value(&ev);
let title = title.trim(); let title = title.trim();
if !title.is_empty() { if !title.is_empty() {
let new = Todo::new(cx, next_id, title.to_string()); let new = Todo::new(cx, Uuid::new_v4(), title.to_string());
set_todos.update(|t| t.add(new)); set_todos.update(|t| t.add(new));
next_id += 1;
target.set_value(""); target.set_value("");
} }
} }
}; };
let filtered_todos = create_memo::<Vec<Todo>>(cx, move |_| { // A derived signal that filters the list of the todos depending on the filter mode
// This doesn't need to be a `Memo`, because we're only reading it in one place
let filtered_todos = move || {
todos.with(|todos| match mode.get() { todos.with(|todos| match mode.get() {
Mode::All => todos.0.to_vec(), Mode::All => todos.0.to_vec(),
Mode::Active => todos Mode::Active => todos
@ -160,10 +167,15 @@ pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
.cloned() .cloned()
.collect(), .collect(),
}) })
}); };
// effect to serialize to JSON // Serialization
// this does reactive reads, so it will automatically serialize on any relevant change //
// the effect reads the `todos` signal, and each `Todo`'s title and completed
// status, so it will automatically re-run on any change to the list of tasks
//
// this is the main point of `create_effect`: to synchronize reactive state
// with something outside the reactive system (like localStorage)
create_effect(cx, move |_| { create_effect(cx, move |_| {
if let Ok(Some(storage)) = window().local_storage() { if let Ok(Some(storage)) = window().local_storage() {
let objs = todos let objs = todos
@ -172,7 +184,7 @@ pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
.iter() .iter()
.map(TodoSerialized::from) .map(TodoSerialized::from)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let json = json::to_string(&objs); let json = serde_json::to_string(&objs).expect("couldn't serialize Todos");
if storage.set_item(STORAGE_KEY, &json).is_err() { if storage.set_item(STORAGE_KEY, &json).is_err() {
log::error!("error while trying to set item in localStorage"); log::error!("error while trying to set item in localStorage");
} }
@ -184,9 +196,17 @@ pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
<section class="todoapp"> <section class="todoapp">
<header class="header"> <header class="header">
<h1>"todos"</h1> <h1>"todos"</h1>
<input class="new-todo" placeholder="What needs to be done?" autofocus on:keydown=add_todo /> <input
class="new-todo"
placeholder="What needs to be done?"
autofocus
on:keydown=add_todo
/>
</header> </header>
<section class="main" class:hidden={move || todos.with(|t| t.is_empty())}> <section
class="main"
class:hidden={move || todos.with(|t| t.is_empty())}
>
<input id="toggle-all" class="toggle-all" type="checkbox" <input id="toggle-all" class="toggle-all" type="checkbox"
prop:checked={move || todos.with(|t| t.remaining() > 0)} prop:checked={move || todos.with(|t| t.remaining() > 0)}
on:input=move |_| set_todos.update(|t| t.toggle_all()) on:input=move |_| set_todos.update(|t| t.toggle_all())
@ -198,7 +218,10 @@ pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
</For> </For>
</ul> </ul>
</section> </section>
<footer class="footer" class:hidden={move || todos.with(|t| t.is_empty())}> <footer
class="footer"
class:hidden={move || todos.with(|t| t.is_empty())}
>
<span class="todo-count"> <span class="todo-count">
<strong>{move || todos.with(|t| t.remaining().to_string())}</strong> <strong>{move || todos.with(|t| t.remaining().to_string())}</strong>
{move || if todos.with(|t| t.remaining()) == 1 { {move || if todos.with(|t| t.remaining()) == 1 {
@ -235,6 +258,8 @@ pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
pub fn Todo(cx: Scope, todo: Todo) -> Element { pub fn Todo(cx: Scope, todo: Todo) -> Element {
let (editing, set_editing) = create_signal(cx, false); let (editing, set_editing) = create_signal(cx, false);
let set_todos = use_context::<WriteSignal<Todos>>(cx).unwrap(); let set_todos = use_context::<WriteSignal<Todos>>(cx).unwrap();
// this will be filled by _ref=input below
let input: Element; let input: Element;
let save = move |value: &str| { let save = move |value: &str| {
@ -242,16 +267,19 @@ pub fn Todo(cx: Scope, todo: Todo) -> Element {
if value.is_empty() { if value.is_empty() {
set_todos.update(|t| t.remove(todo.id)); set_todos.update(|t| t.remove(todo.id));
} else { } else {
(todo.set_title)(value.to_string()); todo.title.set(value.to_string());
} }
set_editing(false); set_editing(false);
}; };
// the `input` variable above is filled by a ref, when the template is created
// so we create the template and store it in a variable here, so we can
// set up an effect using the `input` below
let tpl = view! { cx, let tpl = view! { cx,
<li <li
class="todo" class="todo"
class:editing={editing} class:editing={editing}
class:completed={move || (todo.completed)()} class:completed={move || todo.completed.get()}
_ref=input _ref=input
> >
<div class="view"> <div class="view">
@ -261,7 +289,7 @@ pub fn Todo(cx: Scope, todo: Todo) -> Element {
prop:checked={move || (todo.completed)()} prop:checked={move || (todo.completed)()}
on:input={move |ev| { on:input={move |ev| {
let checked = event_target_checked(&ev); let checked = event_target_checked(&ev);
(todo.set_completed)(checked); todo.completed.set(checked);
}} }}
/> />
<label on:dblclick=move |_| set_editing(true)> <label on:dblclick=move |_| set_editing(true)>
@ -289,10 +317,13 @@ pub fn Todo(cx: Scope, todo: Todo) -> Element {
</li> </li>
}; };
// toggling to edit mode should focus the input
#[cfg(any(feature = "csr", feature = "hydrate"))] #[cfg(any(feature = "csr", feature = "hydrate"))]
create_effect(cx, move |_| { create_effect(cx, move |_| {
if editing() { if editing() {
_ = input.unchecked_ref::<HtmlInputElement>().focus(); if let Some(input) = input.dyn_ref::<HtmlInputElement>() {
input.focus();
}
} }
}); });

View File

@ -1,10 +1,11 @@
use crate::Todo; use crate::Todo;
use leptos::Scope; use leptos::Scope;
use miniserde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct TodoSerialized { pub struct TodoSerialized {
pub id: usize, pub id: Uuid,
pub title: String, pub title: String,
pub completed: bool, pub completed: bool,
} }
@ -20,7 +21,7 @@ impl From<&Todo> for TodoSerialized {
Self { Self {
id: todo.id, id: todo.id,
title: todo.title.get(), title: todo.title.get(),
completed: (todo.completed)(), completed: todo.completed.get(),
} }
} }
} }