Modernizing todomvc example

This commit is contained in:
Pauan 2021-05-28 15:10:10 +02:00
parent 1d444bffad
commit 314ecf9eec
8 changed files with 162 additions and 135 deletions

View File

@ -14,21 +14,24 @@ lto = true
[lib]
crate-type = ["cdylib"]
[workspace]
[dependencies]
console_error_panic_hook = "0.1.5"
dominator = "0.5.0"
futures-signals = "0.3.0"
wasm-bindgen = "0.2.48"
serde_json = "1.0.10"
serde_derive = "1.0.27"
console_error_panic_hook = "0.1.6"
dominator = "0.5.18"
wasm-bindgen = "0.2.74"
futures-signals = "0.3.20"
serde_json = "1.0.64"
serde_derive = "1.0.126"
[dependencies.serde]
version = "1.0.27"
version = "1.0.126"
features = ["rc"]
[dependencies.web-sys]
version = "0.3.22"
version = "0.3.51"
features = [
"HtmlInputElement",
"Storage",
"Url",
]

View File

@ -8,10 +8,11 @@
"start": "rimraf dist/js && rollup --config --watch"
},
"devDependencies": {
"@wasm-tool/rollup-plugin-rust": "^1.0.0",
"@wasm-tool/rollup-plugin-rust": "^1.0.6",
"rimraf": "^3.0.2",
"rollup": "^1.31.0",
"rollup-plugin-livereload": "^1.2.0",
"rollup-plugin-serve": "^1.0.1"
"rollup": "^2.50.2",
"rollup-plugin-livereload": "^2.0.0",
"rollup-plugin-serve": "^1.1.0",
"rollup-plugin-terser": "^7.0.2"
}
}

View File

@ -1,6 +1,7 @@
import rust from "@wasm-tool/rollup-plugin-rust";
import serve from "rollup-plugin-serve";
import livereload from "rollup-plugin-livereload";
import { terser } from "rollup-plugin-terser";
const is_watch = !!process.env.ROLLUP_WATCH;
@ -16,7 +17,6 @@ export default {
plugins: [
rust({
serverPath: "js/",
debug: false,
}),
is_watch && serve({
@ -25,5 +25,7 @@ export default {
}),
is_watch && livereload("dist"),
!is_watch && terser(),
],
};

View File

@ -1,17 +1,51 @@
use std::rc::Rc;
use std::sync::Arc;
use std::cell::Cell;
use wasm_bindgen::prelude::*;
use web_sys::{Url, HtmlInputElement};
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, html, clone, events, link};
use dominator::{Dom, text_signal, html, clone, events, link, with_node, routing};
use crate::todo::Todo;
use crate::routing::Route;
use crate::util::{trim, local_storage};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Route {
Active,
Completed,
All,
}
impl Route {
// This could use more advanced URL parsing, but it isn't needed
pub fn from_url(url: &str) -> Self {
let url = Url::new(&url).unwrap_throw();
match url.hash().as_str() {
"#/active" => Route::Active,
"#/completed" => Route::Completed,
_ => Route::All,
}
}
pub fn to_url(&self) -> &'static str {
match self {
Route::Active => "#/active",
Route::Completed => "#/completed",
Route::All => "#/",
}
}
}
impl Default for Route {
fn default() -> Self {
// Create the Route based on the current URL
Self::from_url(&routing::url().lock_ref())
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct App {
todo_id: Cell<u32>,
@ -19,19 +53,23 @@ pub struct App {
#[serde(skip)]
new_todo_title: Mutable<String>,
todo_list: MutableVec<Rc<Todo>>,
todo_list: MutableVec<Arc<Todo>>,
#[serde(skip)]
route: Mutable<Route>,
}
impl App {
fn new() -> Rc<Self> {
Rc::new(App {
fn new() -> Arc<Self> {
Arc::new(App {
todo_id: Cell::new(0),
new_todo_title: Mutable::new("".to_owned()),
todo_list: MutableVec::new(),
route: Mutable::new(Route::default()),
})
}
pub fn deserialize() -> Rc<Self> {
pub fn deserialize() -> Arc<Self> {
local_storage()
.get_item("todos-rust-dominator")
.unwrap_throw()
@ -49,23 +87,27 @@ impl App {
.unwrap_throw();
}
pub fn route(&self) -> impl Signal<Item = Route> {
self.route.signal()
}
fn create_new_todo(&self) {
let trimmed = trim(&self.new_todo_title.lock_ref());
let mut title = self.new_todo_title.lock_mut();
// 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());
if let Some(trimmed) = trim(&title) {
let id = self.todo_id.get();
self.todo_id.set(id + 1);
self.todo_list.lock_mut().push_cloned(Todo::new(id, title));
self.todo_list.lock_mut().push_cloned(Todo::new(id, trimmed.to_string()));
*title = "".to_string();
self.serialize();
}
}
pub fn remove_todo(&self, todo: &Todo) {
// TODO make this more efficient ?
self.todo_list.lock_mut().retain(|x| **x != *todo);
}
@ -98,7 +140,14 @@ impl App {
.len()
}
fn render_header(app: Rc<Self>) -> Dom {
fn has_todos(&self) -> impl Signal<Item = bool> {
self.todo_list.signal_vec_cloned()
.len()
.map(|len| len > 0)
.dedupe()
}
fn render_header(app: Arc<Self>) -> Dom {
html!("header", {
.class("header")
.children(&mut [
@ -106,20 +155,22 @@ impl App {
.text("todos")
}),
html!("input", {
html!("input" => HtmlInputElement, {
.focused(true)
.class("new-todo")
.attribute("placeholder", "What needs to be done?")
.property_signal("value", app.new_todo_title.signal_cloned())
.attr("placeholder", "What needs to be done?")
.prop_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());
}))
.with_node!(element => {
.event(clone!(app => move |_: events::Input| {
app.new_todo_title.set_neq(element.value());
}))
})
.event_preventable(clone!(app => move |event: events::KeyDown| {
if event.key() == "Enter" {
event.prevent_default();
Self::create_new_todo(&app);
app.create_new_todo();
}
}))
}),
@ -127,30 +178,28 @@ impl App {
})
}
fn render_main(app: Rc<Self>) -> Dom {
fn render_main(app: Arc<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))
.visible_signal(app.has_todos())
.children(&mut [
html!("input", {
html!("input" => HtmlInputElement, {
.class("toggle-all")
.attribute("id", "toggle-all")
.attribute("type", "checkbox")
.property_signal("checked", app.not_completed_len().map(|len| len == 0))
.attr("id", "toggle-all")
.attr("type", "checkbox")
.prop_signal("checked", app.not_completed_len().map(|len| len == 0).dedupe())
.event(clone!(app => move |event: events::Change| {
let checked = event.checked().unwrap_throw();
app.set_all_todos_completed(checked);
}))
.with_node!(element => {
.event(clone!(app => move |_: events::Change| {
app.set_all_todos_completed(element.checked());
}))
})
}),
html!("label", {
.attribute("for", "toggle-all")
.attr("for", "toggle-all")
.text("Mark all as complete")
}),
@ -163,25 +212,22 @@ impl App {
})
}
fn render_button(text: &str, route: Route) -> Dom {
fn render_button(app: &App, text: &str, route: Route) -> Dom {
html!("li", {
.children(&mut [
link!(route.url(), {
link!(route.to_url(), {
.text(text)
.class_signal("selected", Route::signal().map(move |x| x == route))
.class_signal("selected", app.route().map(move |x| x == route))
})
])
})
}
fn render_footer(app: Rc<Self>) -> Dom {
fn render_footer(app: Arc<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))
.visible_signal(app.has_todos())
.children(&mut [
html!("span", {
@ -205,9 +251,9 @@ impl App {
html!("ul", {
.class("filters")
.children(&mut [
Self::render_button("All", Route::All),
Self::render_button("Active", Route::Active),
Self::render_button("Completed", Route::Completed),
Self::render_button(&app, "All", Route::All),
Self::render_button(&app, "Active", Route::Active),
Self::render_button(&app, "Completed", Route::Completed),
])
}),
@ -215,7 +261,7 @@ impl App {
.class("clear-completed")
// Show if there is at least one completed item.
.visible_signal(app.completed_len().map(|len| len > 0))
.visible_signal(app.completed_len().map(|len| len > 0).dedupe())
.event(clone!(app => move |_: events::Click| {
app.remove_all_completed_todos();
@ -228,9 +274,18 @@ impl App {
})
}
pub fn render(app: Rc<Self>) -> Dom {
pub fn render(app: Arc<Self>) -> Dom {
html!("section", {
.class("todoapp")
// Update the Route when the URL changes
.future(routing::url()
.signal_ref(|url| Route::from_url(url))
.for_each(clone!(app => move |route| {
app.route.set_neq(route);
async {}
})))
.children(&mut [
Self::render_header(app.clone()),
Self::render_main(app.clone()),

View File

@ -2,7 +2,6 @@ use wasm_bindgen::prelude::*;
use crate::app::App;
mod util;
mod routing;
mod todo;
mod app;

View File

@ -1,34 +0,0 @@
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

@ -1,14 +1,13 @@
use std::rc::Rc;
use std::sync::Arc;
use wasm_bindgen::prelude::*;
use serde_derive::{Serialize, Deserialize};
use futures_signals::map_ref;
use futures_signals::signal::{SignalExt, Mutable};
use futures_signals::signal::{Signal, SignalExt, Mutable};
use dominator::{Dom, html, clone, events, with_node};
use web_sys::HtmlInputElement;
use crate::util::trim;
use crate::app::App;
use crate::routing::Route;
use crate::app::{App, Route};
#[derive(Debug, Serialize, Deserialize)]
@ -22,8 +21,8 @@ pub struct Todo {
}
impl Todo {
pub fn new(id: u32, title: String) -> Rc<Self> {
Rc::new(Self {
pub fn new(id: u32, title: String) -> Arc<Self> {
Arc::new(Self {
id: id,
title: Mutable::new(title),
completed: Mutable::new(false),
@ -41,6 +40,22 @@ impl Todo {
app.serialize();
}
fn is_visible(&self, app: &App) -> impl Signal<Item = bool> {
(map_ref! {
let route = app.route(),
let completed = self.completed.signal() =>
match *route {
Route::Active => !completed,
Route::Completed => *completed,
Route::All => true,
}
}).dedupe()
}
fn is_editing(&self) -> impl Signal<Item = bool> {
self.editing.signal_ref(|x| x.is_some()).dedupe()
}
fn cancel_editing(&self) {
self.editing.set_neq(None);
}
@ -48,7 +63,7 @@ impl Todo {
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);
self.title.set_neq(title.to_string());
} else {
app.remove_todo(&self);
@ -58,35 +73,27 @@ impl Todo {
}
}
pub fn render(todo: Rc<Self>, app: Rc<App>) -> Dom {
pub fn render(todo: Arc<Self>, app: Arc<App>) -> Dom {
html!("li", {
.class_signal("editing", todo.editing.signal_cloned().map(|x| x.is_some()))
.class_signal("editing", todo.is_editing())
.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())
.visible_signal(todo.is_visible(&app))
.children(&mut [
html!("div", {
.class("view")
.children(&mut [
html!("input", {
.attribute("type", "checkbox")
html!("input" => HtmlInputElement, {
.class("toggle")
.attr("type", "checkbox")
.prop_signal("checked", todo.completed.signal())
.property_signal("checked", todo.completed.signal())
.event(clone!(todo, app => move |event: events::Change| {
todo.set_completed(&app, event.checked().unwrap_throw());
}))
.with_node!(element => {
.event(clone!(todo, app => move |_: events::Change| {
todo.set_completed(&app, element.checked());
}))
})
}),
html!("label", {
@ -109,15 +116,11 @@ impl Todo {
html!("input", {
.class("edit")
.property_signal("value", todo.editing.signal_cloned()
.prop_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()))
.visible_signal(todo.is_editing())
.focused_signal(todo.is_editing())
.with_node!(element => {
.event(clone!(todo => move |event: events::KeyDown| {
@ -137,7 +140,6 @@ impl Todo {
todo.editing.set_neq(Some(event.value().unwrap_throw()));
}))
// TODO global_event ?
.event(clone!(todo, app => move |_: events::Blur| {
todo.done_editing(&app);
}))

View File

@ -6,15 +6,14 @@ 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> {
pub fn trim(input: &str) -> Option<&str> {
let trimmed = input.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
Some(trimmed)
}
}