diff --git a/Cargo.toml b/Cargo.toml index 203c7cbd3..0bbcf10f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -116,6 +116,7 @@ features = [ [workspace] members = [ + "examples/advanced_routing", "examples/animation", "examples/auth", "examples/bunnies", diff --git a/examples/advanced_routing/Cargo.toml b/examples/advanced_routing/Cargo.toml new file mode 100644 index 000000000..196c36b3d --- /dev/null +++ b/examples/advanced_routing/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "advanced_routing" +version = "0.0.1" +authors = ["arn-the-long-beard "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +crate-type = ["cdylib" ,"rlib"] + +[dependencies] +seed = { git = "https://github.com/seed-rs/seed" } +seed_routing = { git ="https://github.com/arn-the-long-beard/seed-routing" , branch="main" } +serde = "1.0.115" +serde_json = "1.0.51" +heck="0.3.1" + +[dependencies.web-sys] +version = "0.3" + + +[dev-dependencies] +wasm-bindgen-test = "0.3.17" + +[profile.release] +lto = true + +opt-level = 'z' +codegen-units = 1 + +[package.metadata.wasm-pack.profile.release] +wasm-opt = ['-O3'] + diff --git a/examples/advanced_routing/Makefile.toml b/examples/advanced_routing/Makefile.toml new file mode 100755 index 000000000..e188fabea --- /dev/null +++ b/examples/advanced_routing/Makefile.toml @@ -0,0 +1,27 @@ +extend = "../../Makefile.toml" + +# ---- BUILD ---- + +[tasks.build] +alias = "default_build" + +[tasks.build_release] +alias = "default_build_release" + +# ---- START ---- + +[tasks.start] +alias = "default_start" + +[tasks.start_release] +alias = "default_start_release" + +# ---- TEST ---- + +[tasks.test_firefox] +alias = "default_test_firefox" + +# ---- LINT ---- + +[tasks.clippy] +alias = "default_clippy" diff --git a/examples/advanced_routing/index.html b/examples/advanced_routing/index.html new file mode 100644 index 000000000..d6e7a82a2 --- /dev/null +++ b/examples/advanced_routing/index.html @@ -0,0 +1,139 @@ + + + + + + + + + Simple Dashboard + + + + +
+ + + + diff --git a/examples/advanced_routing/src/lib.rs b/examples/advanced_routing/src/lib.rs new file mode 100644 index 000000000..cfcf1764f --- /dev/null +++ b/examples/advanced_routing/src/lib.rs @@ -0,0 +1,423 @@ +mod request; +use seed::{prelude::*, *}; +extern crate heck; +use crate::{ + models::user::{LoggedData, Role}, + theme::Theme, + top_bar::TopBar, +}; +#[macro_use] +extern crate seed_routing; +use seed_routing::{View, *}; +pub mod models; +mod pages; +mod theme; +mod top_bar; +use std::fmt::Debug; + +add_router!(); +// ------ ------ +// Init +// ------ ------ + +fn init(url: Url, orders: &mut impl Orders) -> Model { + orders.subscribe(Msg::UrlChanged).subscribe(Msg::UserLogged); + + router() + .init(url) + .set_handler(orders, move |subs::UrlRequested(requested_url, _)| { + router().confirm_navigation(requested_url) + }); + + Model { + theme: Theme::default(), + login: Default::default(), + dashboard: Default::default(), + admin: Default::default(), + logged_user: None, + } +} +#[derive(Debug, PartialEq, Clone, RoutingModules)] +#[modules_path = "pages"] +pub enum Route { + Login { + query: IndexMap, // -> http://localhost:8000/login?name=JohnDoe + }, + #[guard = " => guard => forbidden"] + Dashboard(pages::dashboard::Routes), // -> http://localhost:8000/dashboard/* + #[guard = " => admin_guard => forbidden_user"] + Admin { + // -> /admin/:id/* + id: String, + children: pages::admin::Routes, + }, + #[default_route] + #[view = " => not_found"] // -> http://localhost:8000/not_found* + NotFound, + #[view = " => forbidden"] // -> http://localhost:8000/forbidden* + Forbidden, + #[as_path = ""] + #[view = "theme => home"] // -> http://localhost:8000/ + Home, +} + +fn guard(model: &Model) -> Option { + // could check local storage, cookie or what ever you want + if model.logged_user.is_some() { + Some(true) + } else { + None + } +} + +fn admin_guard(model: &Model) -> Option { + // could check local storage, cookie or what ever you want + if let Some(user) = &model.logged_user { + match user.role { + Role::StandardUser => Some(false), + Role::Admin => Some(true), + } + } else { + None + } +} + +fn not_found(_: &Model) -> Node { + div!["404 page not found"] +} + +fn forbidden(_: &Model) -> Node { + div!["401 access denied"] +} + +fn forbidden_user(model: &Model) -> Node { + if let Some(user) = &model.logged_user { + p![format!( + "Sorry {} {} , but you are missing the Admin Role. Ask your administrator for more information. ", + user.first_name, user.last_name + )] + } else { + div!["401"] + } +} + +// ------ ------ +// Model +// ------ ------ + +struct Model { + pub login: pages::login::Model, + pub dashboard: pages::dashboard::Model, + pub admin: pages::admin::Model, + logged_user: Option, + theme: Theme, +} + +// ------ ------ +// Update +// ------ ------ +/// Root actions for your app. +/// Each component will have single action/message mapped to its message later +/// in update + +pub enum Msg { + UrlChanged(subs::UrlChanged), + Login(pages::login::Msg), + Admin(pages::admin::Msg), + UserLogged(LoggedData), + Dashboard(pages::dashboard::Msg), + GoBack, + GoForward, + Logout, + GoLogin, + SwitchToTheme(Theme), +} + +fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { + match msg { + Msg::UrlChanged(subs::UrlChanged(url)) => { + router().current_route().init(model, orders); + } + Msg::Login(login_message) => pages::login::update( + login_message, + &mut model.login, + &mut orders.proxy(Msg::Login), + ), + Msg::Dashboard(dashboard_message) => pages::dashboard::update( + dashboard_message, + &mut model.dashboard, + &mut orders.proxy(Msg::Dashboard), + ), + + Msg::Admin(admin_msg) => { + pages::admin::update(admin_msg, &mut model.admin, &mut orders.proxy(Msg::Admin)) + } + Msg::UserLogged(user) => { + model.logged_user = Some(user); + } + + Msg::SwitchToTheme(theme) => model.theme = theme, + + Msg::GoBack => { + router().request_moving_back(|r| orders.notify(subs::UrlRequested::new(r))); + } + Msg::GoForward => { + router().request_moving_forward(|r| orders.notify(subs::UrlRequested::new(r))); + } + Msg::Logout => model.logged_user = None, + Msg::GoLogin => { + // model.router.current_route = Some(Routes::Login { + // query: IndexMap::new(), + // },) + } + } +} + +// ------ ------ +// View +// ------ ------ +/// View function which renders stuff to html +fn view(model: &Model) -> impl IntoNodes { + vec![header(&model), router().current_route().view(model)] +} + +fn header(model: &Model) -> Node { + div![ + TopBar::new(who_is_connected(model)) + .style(model.theme.clone()) + .set_user_login_state(model.logged_user.is_some()) + .content(div![ + style! {St::Display => "flex" }, + button![ + "back", + attrs! { + At::Disabled => (! router().can_back()).as_at_value(), + }, + ev(Ev::Click, |_| Msg::GoBack) + ], + button![ + "forward", + attrs! { + At::Disabled => (! router().can_forward()).as_at_value(), + }, + ev(Ev::Click, |_| Msg::GoForward) + ], + span![style! {St::Flex => "5" },], + build_account_button(model.logged_user.is_some()) + ]), + render_route(model) + ] +} + +fn who_is_connected(model: &Model) -> String { + if let Some(user) = &model.logged_user { + let full_welcome = format!("Welcome {} {}", user.first_name, user.last_name); + full_welcome + } else { + "Welcome Guest".to_string() + } +} + +fn build_account_button(user_logged_in: bool) -> Node { + if user_logged_in { + span![button![ + "logout ", + ev(Ev::Click, |_| Msg::Logout), + C!["user_button"], + i![C!["far fa-user-circle"]] + ]] + } else { + span![button![ + "sign in ", + ev(Ev::Click, |_| Msg::GoLogin), + C!["user_button"], + i![C!["fas fa-user-circle"]] + ]] + } +} + +fn make_query_for_john_doe() -> IndexMap { + let mut query: IndexMap = IndexMap::new(); + query.insert("name".to_string(), "JohnDoe".to_string()); + query +} + +fn render_route(model: &Model) -> Node { + ul![ + generate_root_nodes(), + li![a![C!["route"], "Admin",]], + ul![generate_admin_nodes(&model)], + li![a![C!["route"], "Dashboard",]], + ul![generate_dashboard_nodes(&model)], + ] +} + +fn generate_root_routes() -> Vec<(Route, &'static str)> { + let mut vec: Vec<(Route, &'static str)> = vec![]; + vec.push(( + Route::Login { + query: IndexMap::new(), + }, + "Login", + )); + vec.push(( + Route::Login { + query: make_query_for_john_doe(), + }, + "Login for JohnDoe", + )); + vec.push((Route::NotFound, "NotFound")); + vec.push((Route::Home, "Home")); + vec +} + +fn generate_root_nodes() -> Vec> { + let mut list: Vec> = vec![]; + for route in generate_root_routes().iter() { + list.push(li![a![ + C![ + "route", + IF!( router().is_current_route(&route.0 ) => "active-route" ) + ], + attrs! { At::Href => &route.0.to_url() }, + route.1, + ]]) + } + list +} + +fn generate_admin_routes() -> Vec<(Route, &'static str)> { + let mut vec: Vec<(Route, &'static str)> = vec![]; + vec.push(( + Route::Admin { + id: "1".to_string(), + children: pages::admin::Routes::Root, + }, + "Admin Project 1", + )); + vec.push(( + Route::Admin { + id: "2".to_string(), + children: pages::admin::Routes::Root, + }, + "Admin Project 2", + )); + vec.push(( + Route::Admin { + id: "3".to_string(), + children: pages::admin::Routes::Root, + }, + "Admin Project 3", + )); + vec.push(( + Route::Admin { + id: "3".to_string(), + children: pages::admin::Routes::NotFound, + }, + "Not found project 3", + )); + vec.push(( + Route::Admin { + id: "1".to_string(), + children: pages::admin::Routes::Manager, + }, + "Manage project 1", + )); + vec +} + +fn generate_admin_nodes(model: &Model) -> Vec> { + let mut list: Vec> = vec![]; + for route in generate_admin_routes().iter() { + list.push(li![a![ + C![ + "route", + IF!( router().is_current_route(&route.0 ) => "active-route") + IF!(admin_guard(model).is_none() => "locked-route"), + IF!(admin_guard(model).is_some() && !admin_guard(model).unwrap() + => "locked-admin-route" ) + ], + attrs! { At::Href => &route.0.to_url() }, + route.1, + ]]) + } + list +} + +fn generate_dashboard_routes() -> Vec<(Route, &'static str)> { + let mut vec: Vec<(Route, &'static str)> = vec![]; + vec.push((Route::Dashboard(pages::dashboard::Routes::Root), "Profile")); + vec.push(( + Route::Dashboard(pages::dashboard::Routes::Message), + "Message", + )); + vec.push(( + Route::Dashboard(pages::dashboard::Routes::Statistics), + "Statistics", + )); + vec.push(( + Route::Dashboard(pages::dashboard::Routes::Tasks { + query: IndexMap::new(), + children: pages::dashboard::tasks::Routes::Root, + }), + "Tasks", + )); + vec.push(( + Route::Dashboard(pages::dashboard::Routes::Tasks { + query: make_query(), + children: pages::dashboard::tasks::Routes::Root, + }), + "Tasks with url query", + )); + vec +} + +fn generate_dashboard_nodes(model: &Model) -> Vec> { + let mut list: Vec> = vec![]; + for route in generate_dashboard_routes().iter() { + list.push(li![a![ + C![ + "route", + IF!( router().is_current_route(&route.0 ) => "active-route" ) + IF!(guard(model).is_none() => "locked-route" ), + ], + attrs! { At::Href => &route.0.to_url() }, + route.1, + ]]) + } + list +} + +fn make_query() -> IndexMap { + let mut index_map: IndexMap = IndexMap::new(); + index_map.insert("select1".to_string(), "1".to_string()); + index_map +} + +fn home(theme: &Theme) -> Node { + div![ + div!["Welcome home!"], + match theme { + Theme::Dark => { + button![ + "Switch to Light", + ev(Ev::Click, |_| Msg::SwitchToTheme(Theme::Light)) + ] + } + Theme::Light => { + button![ + "Switch to Dark", + ev(Ev::Click, |_| Msg::SwitchToTheme(Theme::Dark)) + ] + } + } + ] +} +// ------ ------ +// Start +// ------ ------ + +#[wasm_bindgen(start)] +pub fn start() { + App::start("app", init, update, view); +} diff --git a/examples/advanced_routing/src/models/auth.rs b/examples/advanced_routing/src/models/auth.rs new file mode 100644 index 000000000..4121f8fd7 --- /dev/null +++ b/examples/advanced_routing/src/models/auth.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; +#[derive(Debug, Deserialize, Serialize, Default)] +/// Base Credential used for user authentication +pub struct Data { + email: String, + username: String, + password: String, +} +/// Setters and getters for password +impl Data { + pub fn set_password(&mut self, pwd: String) { + self.password = pwd + } + + pub fn password(&self) -> &str { + self.password.as_str() + } + + pub fn set_email(&mut self, email: String) { + self.email = email + } + + pub fn email(&self) -> &str { + self.email.as_str() + } + + pub fn set_username(&mut self, username: String) { + self.username = username + } + + pub fn username(&self) -> &str { + self.username.as_str() + } +} + +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct LoginCredentials { + target: String, + password: String, +} + +impl LoginCredentials { + pub fn target(&self) -> &str { + &self.target + } + + pub fn password(&self) -> &str { + &self.password + } + + /// Set email or username + pub fn set_target(&mut self, target: String) { + self.target = target; + } + + pub fn set_password(&mut self, password: String) { + self.password = password; + } +} diff --git a/examples/advanced_routing/src/models/mod.rs b/examples/advanced_routing/src/models/mod.rs new file mode 100644 index 000000000..f9bae3db2 --- /dev/null +++ b/examples/advanced_routing/src/models/mod.rs @@ -0,0 +1,2 @@ +pub mod auth; +pub mod user; diff --git a/examples/advanced_routing/src/models/user.rs b/examples/advanced_routing/src/models/user.rs new file mode 100644 index 000000000..0d24fe741 --- /dev/null +++ b/examples/advanced_routing/src/models/user.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, Default, Clone)] +pub struct LoggedData { + pub first_name: String, + pub last_name: String, + username: String, + email: String, + pub role: Role, +} + +impl LoggedData { + pub fn new(first_name: &str, last_name: &str, username: &str, email: &str, role: Role) -> Self { + LoggedData { + first_name: first_name.to_string(), + last_name: last_name.to_string(), + username: username.to_string(), + email: email.to_string(), + role, + } + } +} + +impl LoggedData { + pub fn username(&self) -> &str { + &self.username + } + + pub fn email(&self) -> &str { + &self.email + } +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub enum Role { + StandardUser, + Admin, +} + +impl Default for Role { + fn default() -> Self { + Role::StandardUser + } +} diff --git a/examples/advanced_routing/src/pages/admin/mod.rs b/examples/advanced_routing/src/pages/admin/mod.rs new file mode 100644 index 000000000..cd092696a --- /dev/null +++ b/examples/advanced_routing/src/pages/admin/mod.rs @@ -0,0 +1,102 @@ +use crate::{router, Route as Root}; +pub use router::View; +use router::*; + +use seed::{ + prelude::{wasm_bindgen::__rt::std::collections::HashMap, *}, + *, +}; + +pub fn init( + _: Url, + _: &mut Model, + id: &str, + children: &Routes, + orders: &mut impl Orders, +) -> Model { + let models = load_models(); + let model_to_load = models.get(id); + + if let Some((name, description)) = model_to_load { + Model { + id: id.to_string(), + name: name.to_string(), + description: description.to_string(), + } + } else if children.eq(&Routes::NotFound) { + let mut not_found_model = Model::default(); + not_found_model.id = id.to_string(); + not_found_model + } else { + orders.notify(subs::UrlRequested::new( + Root::Admin { + id: id.to_string(), + children: Routes::NotFound, + } + .to_url(), + )); + let mut not_found_model = Model::default(); + not_found_model.id = id.to_string(); + not_found_model + } +} +#[derive(Default)] +pub struct Model { + id: String, + name: String, + description: String, +} + +pub enum Msg {} + +#[derive(Debug, PartialEq, Clone, RoutingModules)] +pub enum Routes { + #[view = " => root"] + Root, + #[view = " => manager"] + Manager, + #[default_route] + #[view = " => not_found"] + NotFound, +} + +pub fn update(_: Msg, _: &mut Model, _: &mut impl Orders) {} + +pub fn view(routes: &Routes, model: &Model) -> Node { + routes.view(model) +} +fn manager(model: &Model) -> Node { + div![ + "Management", + h3![&model.name], + br![], + p![&model.description] + ] +} +fn root(model: &Model) -> Node { + div!["Root", h3![&model.name], br![], p![&model.description]] +} +fn not_found(model: &Model) -> Node { + div!["model not found with id ", span![&model.id]] +} + +fn load_models() -> HashMap { + let mut models: HashMap = HashMap::new(); + + models.insert( + "1".to_string(), + ( + "Custom Router".to_string(), + "Develop a Custom Router for Seed".to_string(), + ), + ); + models.insert( + "2".to_string(), + ( + "Seed Router".to_string(), + "Help to make an official Router for Seed".to_string(), + ), + ); + + models +} diff --git a/examples/advanced_routing/src/pages/dashboard/message.rs b/examples/advanced_routing/src/pages/dashboard/message.rs new file mode 100644 index 000000000..b291ee605 --- /dev/null +++ b/examples/advanced_routing/src/pages/dashboard/message.rs @@ -0,0 +1,22 @@ +use seed::{prelude::*, *}; + +pub fn init(_: Url, _: &mut Model, _: &mut impl Orders) -> Model { + Model::default() +} + +#[derive(Default, Clone)] +pub struct Model { + pub messages: Vec, +} + +pub enum Msg { + AddMessage(String), +} +pub fn update(msg: Msg, _: &mut Model, _: &mut impl Orders) { + match msg { + Msg::AddMessage(_) => {} + } +} +pub fn view(_: &Model) -> Node { + div!["messages list"] +} diff --git a/examples/advanced_routing/src/pages/dashboard/mod.rs b/examples/advanced_routing/src/pages/dashboard/mod.rs new file mode 100644 index 000000000..6d0212c9e --- /dev/null +++ b/examples/advanced_routing/src/pages/dashboard/mod.rs @@ -0,0 +1,62 @@ +use seed::{prelude::*, *}; +pub mod message; +pub mod statistics; +pub mod tasks; + +pub use router::{Init, View}; +use seed_routing::*; + +#[derive(Debug, PartialEq, Clone, RoutingModules)] +pub enum Routes { + Message, + Tasks { + query: IndexMap, + children: tasks::Routes, + }, + Statistics, + #[default_route] + #[view = "=> root"] + #[as_path = ""] + Root, +} +pub fn init(_: Url, model: &mut Model, nested: &Routes, orders: &mut impl Orders) -> Model { + nested.init(model, orders); + model.clone() +} + +#[derive(Default, Clone)] +pub struct Model { + pub name: String, + pub message: message::Model, + pub statistics: statistics::Model, + pub tasks: tasks::Model, +} + +pub enum Msg { + ChangeName, + Message(message::Msg), + Statistics(statistics::Msg), + Tasks(tasks::Msg), +} + +pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { + match msg { + Msg::ChangeName => {} + Msg::Message(message) => { + message::update(message, &mut model.message, &mut orders.proxy(Msg::Message)) + } + Msg::Statistics(statistics) => statistics::update( + statistics, + &mut model.statistics, + &mut orders.proxy(Msg::Statistics), + ), + Msg::Tasks(task) => tasks::update(task, &mut model.tasks, &mut orders.proxy(Msg::Tasks)), + } +} +pub fn view(dashboard_routes: &Routes, model: &Model) -> Node { + dashboard_routes.view(model) +} + +pub fn root(_: &Model) -> Node { + div!["root for dashboard"] +} diff --git a/examples/advanced_routing/src/pages/dashboard/statistics.rs b/examples/advanced_routing/src/pages/dashboard/statistics.rs new file mode 100644 index 000000000..dc1b45897 --- /dev/null +++ b/examples/advanced_routing/src/pages/dashboard/statistics.rs @@ -0,0 +1,26 @@ +use seed::{prelude::*, *}; + +pub fn init(_: Url, _: &mut Model, orders: &mut impl Orders) -> Model { + orders.subscribe(Msg::UrlChanged); + + Model::default() +} + +#[derive(Default, Clone)] +pub struct Model { + pub routes_history_count: u32, +} + +pub enum Msg { + UrlChanged(subs::UrlChanged), +} +pub fn update(msg: Msg, model: &mut Model, _: &mut impl Orders) { + match msg { + Msg::UrlChanged(_) => { + model.routes_history_count += 1; + } + } +} +pub fn view(model: &Model) -> Node { + div!["route visited => {} ", &model.routes_history_count] +} diff --git a/examples/advanced_routing/src/pages/dashboard/tasks/mod.rs b/examples/advanced_routing/src/pages/dashboard/tasks/mod.rs new file mode 100644 index 000000000..db1a86bc7 --- /dev/null +++ b/examples/advanced_routing/src/pages/dashboard/tasks/mod.rs @@ -0,0 +1,159 @@ +use crate::Route as Root; + +use crate::pages::dashboard::Routes as Parent; +use seed::{prelude::*, *}; +use seed_routing::*; + +pub mod task; +pub fn init( + _: Url, + model: &mut Model, + query: &IndexMap, + _: &Routes, + _: &mut impl Orders, +) -> Model { + if model.is_default { + let mut selected_no: Vec = vec![]; + for selected in query.iter() { + if selected.0.contains("select") { + let no: u32 = selected + .1 + .parse() + .expect("expect value from query parameters"); + selected_no.push(no) + } + } + Model { + tasks: get_dummy_data(), + checked_tasks_no: selected_no, + is_default: false, + } + } else { + Model { + tasks: get_dummy_data(), + checked_tasks_no: model.checked_tasks_no.clone(), + is_default: false, + } + } +} +#[derive(Clone)] +pub struct Model { + pub tasks: Vec, + pub checked_tasks_no: Vec, + pub is_default: bool, +} + +impl Default for Model { + fn default() -> Self { + Model { + checked_tasks_no: vec![], + tasks: vec![], + is_default: true, + } + } +} +#[derive(Debug, PartialEq, Clone, ParseUrl)] +pub enum Routes { + Task { id: String }, + // #[as_path = ""] this makes run time error + Root, +} + +#[derive(Debug, Copy, Clone)] +pub enum Msg { + ClickTask(u32, bool), + Task(task::Msg), + LoadTasks, +} + +pub fn update(msg: Msg, model: &mut Model, _: &mut impl Orders) { + match msg { + Msg::ClickTask(task_no, will_uncheck) => { + if will_uncheck { + if let Some(index) = model.checked_tasks_no.iter().position(|no| no == &task_no) { + model.checked_tasks_no.remove(index); + } + } else { + model.checked_tasks_no.push(task_no) + } + } + Msg::LoadTasks => model.tasks = get_dummy_data(), + Msg::Task(_) => {} + } +} + +fn render_tasks(model: &Model) -> Node { + ul![list(&model.tasks, &model.checked_tasks_no)] +} + +pub fn list(tasks: &[task::Model], list: &[u32]) -> Vec> { + let mut tasks_list = Vec::new(); + for t in tasks { + tasks_list.push(render_task(t, list.contains(&t.task_no))); + } + tasks_list +} + +pub fn render_task(task: &task::Model, is_checked: bool) -> Node { + let task_url = Root::Dashboard(Parent::Tasks { + children: Routes::Task { + id: task.task_no.to_string(), + }, + query: IndexMap::new(), + }) + .to_url(); + + let task_no = task.task_no; + li![div![ + input![ + attrs! { + At::Checked => is_checked.as_at_value(), + At::Id=> &task.task_no.to_string(), + At::Type=> "checkbox" + }, + ev(Ev::Click, move |_| Msg::ClickTask(task_no, is_checked)), + ], + a![ + C![ + "route", + IF!(is_current_url(task_url.clone()) => "active-route") + ], + attrs! { At::Href => task_url}, + task.task_title.to_string(), + ] + ]] +} +fn is_current_url(url: Url) -> bool { + Url::current() == url +} +pub fn get_dummy_data() -> Vec { + vec![ + task::Model { + task_no: 0, + task_title: "Nested Url".to_string(), + task_description: "Try to find an easy way to manipulate nested route".to_string(), + }, + task::Model { + task_no: 1, + task_title: "Guard & permission".to_string(), + task_description: "FInd a way to set Guard for protected routes".to_string(), + }, + task::Model { + task_no: 2, + task_title: "Stuff".to_string(), + task_description: "Additional stuff to do".to_string(), + }, + ] +} +pub fn view(task_routes: &Routes, model: &Model) -> Node { + div![vec![ + render_tasks(model), + match task_routes { + Routes::Task { id } => { + let task = model.tasks.iter().find(|t| t.task_no.to_string() == *id); + task::view(task.unwrap()).map_msg(Msg::Task) + } + Routes::Root => div!["no task selected"], + }, + ]] +} diff --git a/examples/advanced_routing/src/pages/dashboard/tasks/task.rs b/examples/advanced_routing/src/pages/dashboard/tasks/task.rs new file mode 100644 index 000000000..7214176a0 --- /dev/null +++ b/examples/advanced_routing/src/pages/dashboard/tasks/task.rs @@ -0,0 +1,30 @@ +use seed::{prelude::*, *}; + +#[derive(Default, Debug)] +pub struct Model { + pub task_no: u32, + pub task_title: String, + pub task_description: String, +} + +impl Clone for Model { + fn clone(&self) -> Self { + Model { + task_no: self.task_no, + task_title: self.task_title.clone(), + task_description: self.task_description.clone(), + } + } +} + +#[derive(Debug, Copy, Clone)] +pub enum Msg { + ClickTask, +} +pub fn view(model: &Model) -> Node { + div![ + "Title", + h3![model.task_title.to_string()], + p![model.task_description.to_string()] + ] +} diff --git a/examples/advanced_routing/src/pages/login/mod.rs b/examples/advanced_routing/src/pages/login/mod.rs new file mode 100644 index 000000000..e539bf548 --- /dev/null +++ b/examples/advanced_routing/src/pages/login/mod.rs @@ -0,0 +1,132 @@ +use crate::{ + models::{ + auth::LoginCredentials, + user::{LoggedData, Role}, + }, + request::State, +}; +use seed::{prelude::*, *}; + +/// Can trigger specific update when loading the page +pub fn init( + _: Url, + _: &mut Model, + query: &IndexMap, + _: &mut impl Orders, +) -> Model { + let name = query.get("name"); + + if let Some(name_from_query) = name { + let mut model = Model { + credentials: Default::default(), + request_state: Default::default(), + }; + + model.credentials.set_target(name_from_query.to_string()); + model + } else { + Model { + credentials: Default::default(), + request_state: Default::default(), + } + } +} + +#[derive(Default, Debug)] +pub struct Model { + credentials: LoginCredentials, + request_state: State, +} + +pub enum Msg { + AutoLogin(Role), +} + +pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { + match msg { + Msg::AutoLogin(role) => { + let logged_user = match role { + Role::StandardUser => LoggedData::new( + "John", + "Doe", + "JohnUnknown", + "unknown@gmail.com", + Role::StandardUser, + ), + Role::Admin => LoggedData::new( + "Janne", + "Doe", + "JanneUnknown", + "unknown@gmail.com", + Role::Admin, + ), + }; + model.request_state = State::Success(logged_user.clone()); + orders.notify(logged_user); + } + } +} +pub fn view(model: &Model) -> Node { + match &model.request_state { + State::Success(user) => div![p![ + C!["centred"], + "Welcome ", + style! {St::Color => "darkblue"}, + user.username(), + ". :)" + ]], + State::IsPending(status) => form(model, status), + } +} + +fn form(model: &Model, status: &bool) -> Node { + form![ + fieldset![ + attrs! { + At::Disabled=> status.as_at_value(), + }, + legend!["credentials"], + label![attrs! { At::For => "username"}, "Username/Email"], + input![ + id!("username"), + attrs! { + At::Required => true, + At::Value=> model.credentials.target(), + At::MinLength=> "5", + At::Name => "username", + At::MaxLength=> "25", + At::Type=> "text" + }, + ], + label![attrs! { At::For => "password"}, "Password"], + input![ + id!("password"), + attrs! { + At::Required => true, + At::MinLength=> "8", + At::MaxLength=> "30" + At::Value => model.credentials.password(), + At::Name => "password", + At::Type=> "password" + }, + ], + ], + button![ + "Login", + attrs! { + At::Type=> "submit" + }, + ], + IF!(*status => div![C!["lds-ring"], div![], div![], div![], div![]] ), + br![], + button![ + "Sign in as John Doe", + ev(Ev::Click, |_| Msg::AutoLogin(Role::StandardUser)), + ], + br![], + button![ + "Sign in as Janne Doe", + ev(Ev::Click, |_| Msg::AutoLogin(Role::Admin)), + ], + ] +} diff --git a/examples/advanced_routing/src/pages/mod.rs b/examples/advanced_routing/src/pages/mod.rs new file mode 100644 index 000000000..f96136387 --- /dev/null +++ b/examples/advanced_routing/src/pages/mod.rs @@ -0,0 +1,3 @@ +pub mod admin; +pub mod dashboard; +pub mod login; diff --git a/examples/advanced_routing/src/request.rs b/examples/advanced_routing/src/request.rs new file mode 100644 index 000000000..81981e4d0 --- /dev/null +++ b/examples/advanced_routing/src/request.rs @@ -0,0 +1,11 @@ +#[derive(Debug)] +pub enum State { + Success(T), + IsPending(bool), +} + +impl Default for State { + fn default() -> Self { + State::IsPending(false) + } +} diff --git a/examples/advanced_routing/src/theme.rs b/examples/advanced_routing/src/theme.rs new file mode 100644 index 000000000..a5e72c07e --- /dev/null +++ b/examples/advanced_routing/src/theme.rs @@ -0,0 +1,11 @@ +impl Default for Theme { + fn default() -> Self { + Self::Light + } +} +/// Theme for the App. +#[derive(Clone)] +pub enum Theme { + Light, + Dark, +} diff --git a/examples/advanced_routing/src/top_bar.rs b/examples/advanced_routing/src/top_bar.rs new file mode 100644 index 000000000..bf5207c93 --- /dev/null +++ b/examples/advanced_routing/src/top_bar.rs @@ -0,0 +1,140 @@ +use seed::{prelude::*, Style as Css, *}; +use std::borrow::Cow; + +use crate::theme::Theme; +use web_sys::HtmlElement; + +/// The top bar is the component used for navigation, user actions and title +/// located on the top of the applicatiob +pub struct TopBar { + title: Option>, + style: Theme, + block: bool, + attrs: Attrs, + user_logged_in: bool, + disabled: bool, + content: Vec>, + el_ref: ElRef, + css: Css, +} + +impl TopBar { + pub fn new(title: impl Into>) -> Self { + Self::default().title(title) + } + + pub fn title(mut self, title: impl Into>) -> Self { + self.title = Some(title.into()); + self + } + + // --- style --- + + pub const fn style(mut self, style: Theme) -> Self { + self.style = style; + self + } + + pub const fn set_user_login_state(mut self, is_user_logged_in: bool) -> Self { + self.user_logged_in = is_user_logged_in; + self + } + + pub fn content(mut self, content: impl IntoNodes) -> Self { + self.content = content.into_nodes(); + self + } + + fn view(mut self) -> Node { + let tag = Tag::Div; + let content = div![self.title.take().map(Node::new_text),]; + let attrs = { + let mut attrs = attrs! {}; + + if self.disabled { + attrs.add(At::from("aria-disabled"), true); + attrs.add(At::TabIndex, -1); + } + attrs + }; + + let css = { + let mut css = style! { + St::TextDecoration => "none", + St::Height=>"60px" + }; + + let color = match self.style { + Theme::Dark => "lightgrey", + Theme::Light => "darkblue", + }; + + let background = match self.style { + Theme::Dark => "blue", + Theme::Light => "lightskyblue", + }; + + let font_color = match self.style { + Theme::Dark => "white", + Theme::Light => "black", + }; + if self.user_logged_in { + css.merge(style! { + St::Color => color, + + St::BackgroundColor => "transparent", + St::Border => format!("{} {} {}", px(5), "solid", color), + }); + } else { + css.merge(style! { St::Color => font_color, + St::BackgroundColor => background }); + }; + + if self.block { + css.merge(style! {St::Display => "block"}); + } + + if self.disabled { + css.merge(style! {St::Opacity => 0.5}); + } else { + css.merge(style! {St::Cursor => "pointer"}); + } + + css + }; + + let top_bar = custom![ + tag, + el_ref(&self.el_ref), + css, + self.css, + attrs, + self.attrs, + content, + self.content, + ]; + + top_bar + } +} +impl Default for TopBar { + fn default() -> Self { + Self { + title: None, + style: Theme::default(), + block: false, + attrs: Attrs::empty(), + user_logged_in: false, + disabled: false, + content: Vec::new(), + el_ref: ElRef::default(), + css: Css::empty(), + } + } +} + +impl UpdateEl for TopBar { + fn update_el(self, el: &mut El) { + self.view().update_el(el) + } +}