Modernizing todomvc example
This commit is contained in:
parent
1d444bffad
commit
314ecf9eec
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
],
|
||||
};
|
||||
|
|
|
@ -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()),
|
||||
|
|
|
@ -2,7 +2,6 @@ use wasm_bindgen::prelude::*;
|
|||
use crate::app::App;
|
||||
|
||||
mod util;
|
||||
mod routing;
|
||||
mod todo;
|
||||
mod app;
|
||||
|
||||
|
|
|
@ -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 => "#/",
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}))
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue