A proposal for advanced routing in Seed equivalent to what we have in Angular/React/Vue.
- Quickest way to use it
- Rules
- Motivation
- Description
- Example ParseUrl
- Example DefaultRoute
- Example Modules
seed_routing = { git="https://github.com/arn-the-long-beard/seed-routing.git" ,branch ="main" }
In your lib.rs you need this code :
- with init :
add_router!();
// ------ ------
// Init
// ------ ------
fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
orders.subscribe(Msg::UrlChanged);
sync_router!();
Model {
// Your Model
}
}
- with update :
fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
match msg {
Msg::UrlChanged(subs::UrlChanged(_url)) => {
router().current_route().init(model, orders);
}
// rest of the match arms.
}
}
- with view :
fn view(model: &Model) -> impl IntoNodes<Msg> {
vec![router().current_route().view(model)]
}
There is nothing more to write for the router to do its job.
#[derive(Debug, PartialEq, Clone, RoutingModules)]
pub enum Routes {
Login {
query: IndexMap<String, String>, // -> http://localhost:8000/login?name=JohnDoe
},
#[guard = " => guard => forbidden"]
Dashboard(pages::dashboard::Routes), // -> http://localhost:8000/dashboard/*
#[guard = "logged_user => 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 = " => home"] // -> http://localhost:8000/
Home,
}
cargo install proto_seeder
In the route of your project :
proto_seeder ./src/lib.rs
This will generate from Routes
for you the following :
- local views.
- guards.
- sub modules with their content.
- init with payload.
- empty Model.
- empty Message.
- update.
- view.
Here is the experimental cli repo : https://github.com/arn-the-long-beard/proto-seeder
It is still very alpha and it needs more inputs & feedbacks to get better !
proto_seeder ./src/dashboard.rs
It will generate the submodules/codes for it as well.
Enjoy the saving time !
Here are the rules I try to respect
- Respect for TEA = One single path of truth.
- Minimize the boilerplate and the risks of bugs.
- Easy API so we can follow the flow of an app.
- Making routing in TEA should be as easy as in popular frameworks.
My idea and motivation come mainly from the fact that I had a very bad experience with large App in Elm.
I got pain there with :
- Routing was absent.
- Single path of truth is hard to see.
- How to add and load components was not framed.
- Need to update 5 or 6 files code in order to just add a tiny component.
- Could not see guards and protected routes that require user permission or authentication.
I also got a lot of happiness doing routing in Angular
- Routes are easy to write with the path, guard and component or module to load.
- Sub routes are easy since you add them to sub modules and they are added automatically later on.
- Lazy loading is easy.
- Guarding routes is easy.
- You can pass data to routes.
- The router is easy to use via dependency injection at runtime.
- RouterLink is OP in html template.
<a [routerLink]="['/user/bob']" [queryParams]="{debug: true}" queryParamsHandling="merge">
link to user component
</a>
- Management state from the router is possible with Ngrx.
- Little code in html.
<router-outlet></router-outlet>
- Routing is framed as a standard in Angular so you know where to find consistent information to make good routing.
The discussion regarding routing in seed is there seed-rs/seed#383
This repos actually contains 2 distinct but linked concepts :
-
A router for :
- Managing history.
- Go back.
- Go forward .
- Use default route.
- Standard navigation.
- You know if you can go back of forward and display it on UI easy.
-
Derive macro for generating Url and call to init & view with guard functions
- With ParseUrl , you can easily convert URL for all your enums and vis versa.
- With RoutingModules, you will get :
- Nested route / children routes.
- Id parameter / dynamic urls.
- Search query / query parameters.
- Guard with protected routes :
- Inject the model you need.
- Give you a view to display when access is deny.
- Will display the right view in the router.view().
- init function calls & match automatically ( not protected but we could to it => Todo) :
- Has access to URL payload ( query, id & children) .
- Let you init your model from the previous state.
- Let you init your model based on Url payload ( Awesome :D ).
- view function calls & Match.
- Has access to children & nested .
- Is accessible only if condition are true from the guard.
Derive an enum to enable conversion from
and to url
.
You can change the value of a path for a given route this way.
#[derive(Debug, PartialEq, Copy, Clone, ParseUrl)]
pub enum DashboardAdminRoutes {
// "/my_stuff"
#[as_path = "my_stuff"]
Other,
// ""
#[as_path = ""]
Root,
}
fn test_url() {
let mut query_search: IndexMap<String, String> = IndexMap::new();
query_search.insert("user".to_string(), "arn".to_string());
query_search.insert("role".to_string(), "baby_programmer".to_string());
query_search.insert("location".to_string(), "norway".to_string());
let url = ExampleRoutes::Admin {
query: query_search.clone(),
}
.to_url();
let url_to_compare: Url = "/admin?user=arn&role=baby_programmer&location=norway"
.parse()
.unwrap();
assert_eq!(url, url_to_compare);
}
Define a routing config with a default_route for your navigation. The default route used by the router when it cannot find the right url.
#[derive(Debug, PartialEq, Copy, Clone, DefaultRoute)]
pub enum DashboardAdminRoutes {
// An invalid route, such as /blablablabla, re-routes to /not_found
#[default_route]
NotFound,
Root,
}
RoutingModule contains DefaultRoute and ParseUrl as well.
use seed::{prelude::*, *};
#[macro_use]
extern crate seed_routing;
use seed_routing::{View, *};
add_router!();
enum Theme {
Dark,
Light
}
impl Default for Theme {
fn default() -> Self {
Self::Dark
}
}
fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
orders.subscribe(Msg::UrlChanged).subscribe(Msg::UserLogged);
router().init(url).subscribe(orders.subscribe_with_handle(
|subs::UrlRequested(requested_url, _)| router().confirm_navigation(requested_url),
)); // ---> is equal to sync_router!();
Model {
register: Default::default(),
login: Default::default(),
dashboard: Default::default(),
admin: Default::default(),
router,
logged_user: None,
theme: Theme::default(),
}
}
struct Model {
pub register: pages::register::Model,
pub login: pages::login::Model,
pub dashboard: pages::dashboard::Model,
pub admin: pages::admin::Model,
router: Router<Routes>,
logged_user: Option<LoggedUser>,
theme: Theme,
}
#[derive(Debug, PartialEq, Clone, RoutingModules)]
pub enum Routes {
// http://localhost:8000/register
Register,
// will handle:
// * loading "admin.rs" file
// * loading model.admin field
// * verify init function arguments
// * verify view function arguments
// These are done for other Routes variants assosciated with a module.
#[guard = "logged_user => admin_guard => forbidden_user"]
Admin {
query: IndexMap<String, String>,
},
// http://localhost:8000/login?name=JohnDoe
// query pairs are held and generated by this query field
Login {
query: IndexMap<String, String>,
},
#[guard = " => user_guard => forbidden"]
Dashboard(DashboardRoutes),
Profile { // will load module "profile"
id: String,
},
// use the value of the `logged_user` field in the `admin_guard` function,
// falling back to the view generated by `fn forbidden_user` in case of
// `admin_guard` not returning `Some(true)`
#[guard = "logged_user => admin_guard => forbidden_user"]
#[view = " => my_stuff"]
MyStuff,
// use the value of of the `theme` field in the model in the view generating
// function `theme_eg` when creating the view for the `/theme_example` route
#[view = "theme => theme_eg"]
ThemeExample,
#[view = " => not_found"]
#[default_route]
NotFound,
#[view = " => home"]
#[as_path = ""]
Root,
}
fn user_guard(model: &Model) -> Option<bool> {
// could check local storage, cookie or what ever you want
if model.logged_user.is_some() {
Some(true)
} else {
None
}
}
fn admin_guard(logged_user: Option<&LoggedUser>) -> Option<bool> {
// could check local storage, cookie or what ever you want
if let Some(user) = logged_user {
match user.role {
Role::StandardUser => Some(false),
Role::Admin => Some(true),
}
} else {
None
}
}
fn not_found(_: &Model) -> Node<Msg> {
div!["404 page not found"]
}
fn forbidden(_: &Model) -> Node<Msg> {
div!["401 access denied"]
}
fn forbidden_user(logged_user: Option<&LoggedData>) -> Node<Msg> {
if let Some(user) = 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"]
}
}
fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
match msg {
Msg::UrlChanged(subs::UrlChanged(_)) => {
router().current_route().init(model, orders);
}
// ...remaining arms
}
}
fn view(model: &Model) -> impl IntoNodes<Msg> {
vec![
header(&model),
router().current_route().view(model)
]
}
fn theme_eg(theme: &Theme) -> impl IntoNodes<Msg> {
match theme {
Theme::Dark => {/* ... */},
Theme::Light => {/* ... */},
}
}
Then we go back to the router listening
Then we go back to the router listening
cd examples/backbone_app
cargo make watch
cargo make serve
Open 127.0.0.1:8000 in your browser.