fix: <For/> in todomvc example (#504)

This commit is contained in:
Greg Johnston 2023-02-11 16:30:09 -05:00 committed by GitHub
parent d1ae3b49cc
commit 1cba54d47e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23

View file

@ -1,7 +1,4 @@
use leptos::{ use leptos::{web_sys::HtmlInputElement, *};
web_sys::HtmlInputElement,
*,
};
use storage::TodoSerialized; use storage::TodoSerialized;
use uuid::Uuid; use uuid::Uuid;
@ -14,111 +11,112 @@ const STORAGE_KEY: &str = "todos-leptos";
// Basic operations to manipulate the todo list: nothing really interesting here // 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 let Ok(Some(storage)) = window().local_storage() { let starting_todos = if let Ok(Some(storage)) = window().local_storage()
storage {
.get_item(STORAGE_KEY) storage
.ok() .get_item(STORAGE_KEY)
.flatten() .ok()
.and_then(|value| { .flatten()
serde_json::from_str::<Vec<TodoSerialized>>(&value).ok() .and_then(|value| {
}) serde_json::from_str::<Vec<TodoSerialized>>(&value).ok()
.map(|values| { })
values .map(|values| {
.into_iter() values
.map(|stored| stored.into_todo(cx)) .into_iter()
.collect() .map(|stored| stored.into_todo(cx))
}) .collect()
.unwrap_or_default() })
} else { .unwrap_or_default()
Vec::new() } else {
}; Vec::new()
Self(starting_todos) };
} Self(starting_todos)
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn add(&mut self, todo: Todo) {
self.0.push(todo);
}
pub fn remove(&mut self, id: Uuid) {
self.0.retain(|todo| todo.id != id);
}
pub fn remaining(&self) -> usize {
// `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 {
// `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) {
// if all are complete, mark them all active
if self.remaining() == 0 {
for todo in &self.0 {
todo.completed.update(|completed| {
if *completed {
*completed = false
}
});
}
} }
// otherwise, mark them all complete
else {
for todo in &self.0 {
todo.completed.set(true);
}
}
}
fn clear_completed(&mut self) { pub fn is_empty(&self) -> bool {
self.0.retain(|todo| !todo.completed.get()); self.0.is_empty()
} }
pub fn add(&mut self, todo: Todo) {
self.0.push(todo);
}
pub fn remove(&mut self, id: Uuid) {
self.0.retain(|todo| todo.id != id);
}
pub fn remaining(&self) -> usize {
// `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 {
// `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) {
// if all are complete, mark them all active
if self.remaining() == 0 {
for todo in &self.0 {
todo.completed.update(|completed| {
if *completed {
*completed = false
}
});
}
}
// otherwise, mark them all complete
else {
for todo in &self.0 {
todo.completed.set(true);
}
}
}
fn clear_completed(&mut self) {
self.0.retain(|todo| !todo.completed.get());
}
} }
#[derive(Debug, PartialEq, Eq, Clone)] #[derive(Debug, PartialEq, Eq, Clone)]
pub struct Todo { pub struct Todo {
pub id: Uuid, pub id: Uuid,
pub title: RwSignal<String>, pub title: RwSignal<String>,
pub completed: RwSignal<bool>, pub completed: RwSignal<bool>,
} }
impl Todo { impl Todo {
pub fn new(cx: Scope, id: Uuid, 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: Uuid,
title: String,
completed: bool,
) -> Self {
// RwSignal combines the getter and setter in one struct, rather than separating
// 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 {
id,
title,
completed,
} }
}
pub fn toggle(&self) { pub fn new_with_completed(
// A signal's `update()` function gives you a mutable reference to the current value cx: Scope,
// You can use that to modify the value in place, which will notify any subscribers. id: Uuid,
self.completed.update(|completed| *completed = !*completed); title: String,
} completed: bool,
) -> Self {
// RwSignal combines the getter and setter in one struct, rather than separating
// 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 {
id,
title,
completed,
}
}
pub fn toggle(&self) {
// A signal's `update()` function gives you a mutable reference to the current value
// You can use that to modify the value in place, which will notify any subscribers.
self.completed.update(|completed| *completed = !*completed);
}
} }
const ESCAPE_KEY: u32 = 27; const ESCAPE_KEY: u32 = 27;
@ -126,231 +124,232 @@ const ENTER_KEY: u32 = 13;
#[component] #[component]
pub fn TodoMVC(cx: Scope) -> impl IntoView { pub fn TodoMVC(cx: Scope) -> impl IntoView {
// The `todos` are a signal, since we need to reactively update the list // The `todos` are a signal, since we need to reactively update the list
let (todos, set_todos) = create_signal(cx, Todos::new(cx)); let (todos, set_todos) = create_signal(cx, Todos::new(cx));
// We provide a context that each <Todo/> component can use to update the list // 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 // 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 // (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 // a new todo is added! This kind of hygiene is why `create_signal` defaults to read-write
// segregation.) // segregation.)
provide_context(cx, set_todos); provide_context(cx, set_todos);
// Handle the three filter modes: All, Active, and Completed // 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 =
set_mode(new_mode); location_hash().map(|hash| route(&hash)).unwrap_or_default();
}); set_mode(new_mode);
});
// Callback to add a todo on pressing the `Enter` key, if the field isn't empty // Callback to add a todo on pressing the `Enter` key, if the field isn't empty
let add_todo = move |ev: web_sys::KeyboardEvent| { let add_todo = move |ev: web_sys::KeyboardEvent| {
let target = event_target::<HtmlInputElement>(&ev); let target = event_target::<HtmlInputElement>(&ev);
ev.stop_propagation(); ev.stop_propagation();
let key_code = ev.key_code(); let key_code = ev.key_code();
if key_code == ENTER_KEY { if key_code == ENTER_KEY {
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, Uuid::new_v4(), 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));
target.set_value(""); target.set_value("");
} }
}
};
// 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() {
Mode::All => todos.0.to_vec(),
Mode::Active => todos
.0
.iter()
.filter(|todo| !todo.completed.get())
.cloned()
.collect(),
Mode::Completed => todos
.0
.iter()
.filter(|todo| todo.completed.get())
.cloned()
.collect(),
})
};
// Serialization
//
// 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 |_| {
if let Ok(Some(storage)) = window().local_storage() {
let objs = todos
.get()
.0
.iter()
.map(TodoSerialized::from)
.collect::<Vec<_>>();
let json =
serde_json::to_string(&objs).expect("couldn't serialize Todos");
if storage.set_item(STORAGE_KEY, &json).is_err() {
log::error!("error while trying to set item in localStorage");
}
}
});
view! { cx,
<main>
<section class="todoapp">
<header class="header">
<h1>"todos"</h1>
<input
class="new-todo"
placeholder="What needs to be done?"
autofocus
on:keydown=add_todo
/>
</header>
<section
class="main"
class:hidden={move || todos.with(|t| t.is_empty())}
>
<input id="toggle-all" class="toggle-all" type="checkbox"
prop:checked={move || todos.with(|t| t.remaining() > 0)}
on:input=move |_| todos.with(|t| t.toggle_all())
/>
<label for="toggle-all">"Mark all as complete"</label>
<ul class="todo-list">
<For
each=filtered_todos
key=|todo| todo.id
view=move |cx, todo: Todo| view! { cx, <Todo todo /> }
/>
</ul>
</section>
<footer
class="footer"
class:hidden={move || todos.with(|t| t.is_empty())}
>
<span class="todo-count">
<strong>{move || todos.with(|t| t.remaining().to_string())}</strong>
{move || if todos.with(|t| t.remaining()) == 1 {
" item"
} else {
" items"
}}
" left"
</span>
<ul class="filters">
<li><a href="#/" class="selected" class:selected={move || mode() == Mode::All}>"All"</a></li>
<li><a href="#/active" class:selected={move || mode() == Mode::Active}>"Active"</a></li>
<li><a href="#/completed" class:selected={move || mode() == Mode::Completed}>"Completed"</a></li>
</ul>
<button
class="clear-completed hidden"
class:hidden={move || todos.with(|t| t.completed() == 0)}
on:click=move |_| set_todos.update(|t| t.clear_completed())
>
"Clear completed"
</button>
</footer>
</section>
<footer class="info">
<p>"Double-click to edit a todo"</p>
<p>"Created by "<a href="http://todomvc.com">"Greg Johnston"</a></p>
<p>"Part of "<a href="http://todomvc.com">"TodoMVC"</a></p>
</footer>
</main>
} }
};
// 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() {
Mode::All => todos.0.to_vec(),
Mode::Active => todos
.0
.iter()
.filter(|todo| !todo.completed.get())
.cloned()
.collect(),
Mode::Completed => todos
.0
.iter()
.filter(|todo| todo.completed.get())
.cloned()
.collect(),
})
};
// Serialization
//
// 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 |_| {
if let Ok(Some(storage)) = window().local_storage() {
let objs = todos
.get()
.0
.iter()
.map(TodoSerialized::from)
.collect::<Vec<_>>();
let json =
serde_json::to_string(&objs).expect("couldn't serialize Todos");
if storage.set_item(STORAGE_KEY, &json).is_err() {
log::error!("error while trying to set item in localStorage");
}
}
});
view! { cx,
<main>
<section class="todoapp">
<header class="header">
<h1>"todos"</h1>
<input
class="new-todo"
placeholder="What needs to be done?"
autofocus
on:keydown=add_todo
/>
</header>
<section
class="main"
class:hidden={move || todos.with(|t| t.is_empty())}
>
<input id="toggle-all" class="toggle-all" type="checkbox"
prop:checked={move || todos.with(|t| t.remaining() > 0)}
on:input=move |_| todos.with(|t| t.toggle_all())
/>
<label for="toggle-all">"Mark all as complete"</label>
<ul class="todo-list">
<For
each=filtered_todos
key=|todo| todo.id
view=move |todo: Todo| view! { cx, <Todo todo /> }
/>
</ul>
</section>
<footer
class="footer"
class:hidden={move || todos.with(|t| t.is_empty())}
>
<span class="todo-count">
<strong>{move || todos.with(|t| t.remaining().to_string())}</strong>
{move || if todos.with(|t| t.remaining()) == 1 {
" item"
} else {
" items"
}}
" left"
</span>
<ul class="filters">
<li><a href="#/" class="selected" class:selected={move || mode() == Mode::All}>"All"</a></li>
<li><a href="#/active" class:selected={move || mode() == Mode::Active}>"Active"</a></li>
<li><a href="#/completed" class:selected={move || mode() == Mode::Completed}>"Completed"</a></li>
</ul>
<button
class="clear-completed hidden"
class:hidden={move || todos.with(|t| t.completed() == 0)}
on:click=move |_| set_todos.update(|t| t.clear_completed())
>
"Clear completed"
</button>
</footer>
</section>
<footer class="info">
<p>"Double-click to edit a todo"</p>
<p>"Created by "<a href="http://todomvc.com">"Greg Johnston"</a></p>
<p>"Part of "<a href="http://todomvc.com">"TodoMVC"</a></p>
</footer>
</main>
}
} }
#[component] #[component]
pub fn Todo(cx: Scope, todo: Todo) -> impl IntoView { pub fn Todo(cx: Scope, todo: Todo) -> impl IntoView {
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 // this will be filled by _ref=input below
let todo_input = NodeRef::<Input>::new(cx); let todo_input = NodeRef::<Input>::new(cx);
let save = move |value: &str| { let save = move |value: &str| {
let value = value.trim(); let value = value.trim();
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.title.set(value.to_string()); todo.title.set(value.to_string());
}
set_editing(false);
};
view! { cx,
<li
class="todo"
class:editing={editing}
class:completed={move || todo.completed.get()}
>
<div class="view">
<input
_ref=todo_input
class="toggle"
type="checkbox"
prop:checked={move || (todo.completed)()}
on:input={move |ev| {
let checked = event_target_checked(&ev);
todo.completed.set(checked);
}}
/>
<label on:dblclick=move |_| {
set_editing(true);
if let Some(input) = todo_input.get() {
_ = input.focus();
}
}>
{move || todo.title.get()}
</label>
<button class="destroy" on:click=move |_| set_todos.update(|t| t.remove(todo.id))/>
</div>
{move || editing().then(|| view! { cx,
<input
class="edit"
class:hidden={move || !(editing)()}
prop:value={move || todo.title.get()}
on:focusout=move |ev: web_sys::FocusEvent| save(&event_target_value(&ev))
on:keyup={move |ev: web_sys::KeyboardEvent| {
let key_code = ev.key_code();
if key_code == ENTER_KEY {
save(&event_target_value(&ev));
} else if key_code == ESCAPE_KEY {
set_editing(false);
}
}}
/>
})
}
</li>
} }
set_editing(false);
};
view! { cx,
<li
class="todo"
class:editing={editing}
class:completed={move || todo.completed.get()}
>
<div class="view">
<input
_ref=todo_input
class="toggle"
type="checkbox"
prop:checked={move || (todo.completed)()}
on:input={move |ev| {
let checked = event_target_checked(&ev);
todo.completed.set(checked);
}}
/>
<label on:dblclick=move |_| {
set_editing(true);
if let Some(input) = todo_input.get() {
_ = input.focus();
}
}>
{move || todo.title.get()}
</label>
<button class="destroy" on:click=move |_| set_todos.update(|t| t.remove(todo.id))/>
</div>
{move || editing().then(|| view! { cx,
<input
class="edit"
class:hidden={move || !(editing)()}
prop:value={move || todo.title.get()}
on:focusout=move |ev: web_sys::FocusEvent| save(&event_target_value(&ev))
on:keyup={move |ev: web_sys::KeyboardEvent| {
let key_code = ev.key_code();
if key_code == ENTER_KEY {
save(&event_target_value(&ev));
} else if key_code == ESCAPE_KEY {
set_editing(false);
}
}}
/>
})
}
</li>
}
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode { pub enum Mode {
Active, Active,
Completed, Completed,
All, All,
} }
impl Default for Mode { impl Default for Mode {
fn default() -> Self { fn default() -> Self {
Mode::All Mode::All
} }
} }
pub fn route(hash: &str) -> Mode { pub fn route(hash: &str) -> Mode {
match hash { match hash {
"/active" => Mode::Active, "/active" => Mode::Active,
"/completed" => Mode::Completed, "/completed" => Mode::Completed,
_ => Mode::All, _ => Mode::All,
} }
} }