diff --git a/Cargo.toml b/Cargo.toml index 52c41688e36..7dbd19743e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ http = "0.1" serde = "1" serde_json = "1" stdweb = "0.4" +url = "1.7.0" [dev-dependencies] serde_derive = "1" diff --git a/examples/routing/Cargo.toml b/examples/routing/Cargo.toml new file mode 100644 index 00000000000..ede5739dab3 --- /dev/null +++ b/examples/routing/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "routing" +version = "0.1.0" +authors = ["Henry Zimmerman "] + +[dependencies] +yew = { path = "../.." } diff --git a/examples/routing/src/button.rs b/examples/routing/src/button.rs new file mode 100644 index 00000000000..8f852666f29 --- /dev/null +++ b/examples/routing/src/button.rs @@ -0,0 +1,63 @@ +use yew::prelude::*; + +pub struct Button { + title: String, + onsignal: Option>, +} + +pub enum Msg { + Clicked, +} + +#[derive(PartialEq, Clone)] +pub struct Props { + pub title: String, + pub onsignal: Option>, +} + +impl Default for Props { + fn default() -> Self { + Props { + title: "Send Signal".into(), + onsignal: None, + } + } +} + +impl Component for Button { + type Msg = Msg; + type Properties = Props; + + fn create(props: Self::Properties, _: &mut Env) -> Self { + Button { + title: props.title, + onsignal: props.onsignal, + } + } + + fn update(&mut self, msg: Self::Msg, _: &mut Env) -> ShouldRender { + match msg { + Msg::Clicked => { + if let Some(ref mut callback) = self.onsignal { + callback.emit(()); + } + } + } + false + } + + fn change(&mut self, props: Self::Properties, _: &mut Env) -> ShouldRender { + self.title = props.title; + self.onsignal = props.onsignal; + true + } +} + +impl Renderable for Button { + fn view(&self) -> Html { + html! { + + } + } +} + diff --git a/examples/routing/src/forum_router.rs b/examples/routing/src/forum_router.rs new file mode 100644 index 00000000000..bc18808ac04 --- /dev/null +++ b/examples/routing/src/forum_router.rs @@ -0,0 +1,77 @@ + +use yew::prelude::*; +use Context; +use yew::services::route::RouteInfo; +use yew::services::route::RouteSection; +use yew::services::route::Router; + +use button::Button; + +use Model; +use Msg; +use Route as MainRoute; + +// Oftentimes the route doesn't need to hold any state or react to any changes, so it doesn't need to be a component. +#[derive(Clone, Debug, PartialEq)] +pub enum Route { + CatForum, + DogForum, + ForumsList +} + +// It can be seen that the operations for this could possibly be derived +impl Router for Route { + fn from_route(route: &mut RouteInfo) -> Option { + if let Some(RouteSection::Node{segment}) = route.next() { + match segment.as_str() { + "cat" => Some(Route::CatForum), + "dog" => Some(Route::DogForum), + _ => Some(Route::ForumsList) // If the route can't be resolved, return None to let the parent router know that it should redirect to a failed route. + } + } else { + Some(Route::ForumsList) + } + } + fn to_route(&self) -> RouteInfo { + match *self { + Route::CatForum => RouteInfo::parse("/cat").unwrap(), // TODO I would like to refactor this into a macro that will fail at compile time if the parse fails + Route::DogForum => RouteInfo::parse("/dog").unwrap(), + Route::ForumsList => RouteInfo::parse("/").unwrap() + } + } +} + +// Renderable needs to have the generic signature of the parent component, in this case, Model. +impl Renderable for Route { + fn view(&self) -> Html { + match *self { + Route::CatForum => { + html! { + // Conceptually, these could also be components to which routing props can be passed + <> + {"I'm the forum for talking about cats"} + + } + } + Route::DogForum => { + html! { + <> + {"I'm the forum for talking about dogs"} + + } + } + Route::ForumsList => { + html!{ +
+
+ +
+
+ +
+
+ } + } + } + } +} diff --git a/examples/routing/src/main.rs b/examples/routing/src/main.rs new file mode 100644 index 00000000000..f210a1f920c --- /dev/null +++ b/examples/routing/src/main.rs @@ -0,0 +1,171 @@ + +#[macro_use] +extern crate yew; + +mod forum_router; +mod button; + +use yew::prelude::*; +//use yew::html::Scope; +use yew::services::route::*; + +use yew::html::Renderable; + +use button::Button; + +use yew::services::route::Router; + +use forum_router::Route as ForumRoute; + + +pub struct Context { + routing: RouteService +} + +struct Model { + route: Route +} + +#[derive(Clone, Debug)] +enum Route { + Forums(ForumRoute), + PageNotFoundRoute +} + + +enum Msg { + Navigate(Route), +} + +impl From for Msg { + fn from( result: RouteResult) -> Self { + match result { + Ok(mut route_info) => { + Msg::Navigate(Route::from_route_main(&mut route_info)) + } + Err(e) => { + eprintln!("Couldn't route: '{:?}'", e); + Msg::Navigate(Route::PageNotFoundRoute) + } + } + } +} + + +impl Router for Route { + // For the top level case, this _MUST_ return Some. + fn from_route(route: &mut RouteInfo) -> Option { + Some(Self::from_route_main(route)) + } + fn to_route(&self) -> RouteInfo { + match *self { + // You can add RouteInfos together to combine paths in logical order. + // The fragment and query of the rhs take precedence over any fragment or query set by the lhs. + Route::Forums(ref forum_route)=> RouteInfo::parse("/forums").unwrap() + forum_route.to_route(), + Route::PageNotFoundRoute => RouteInfo::parse("/PageNotFound").unwrap(), + } + } +} + +impl MainRouter for Route { + fn from_route_main(route: &mut RouteInfo) -> Self { + if let Some(RouteSection::Node{segment}) = route.next() { + match segment.as_str() { + "forums" => { + // If the child can't be resolved, redirect to the right page here. + if let Some(child) = ForumRoute::from_route(route) { // Pass the route info to the child for it to figure itself out. + Route::Forums(child) + } else { + Route::PageNotFoundRoute + } + }, + _ => Route::PageNotFoundRoute + } + } else { + Route::PageNotFoundRoute + } + } +} + + +impl Component for Model { + type Msg = Msg; + type Properties = (); + + fn create(_: Self::Properties, context: &mut Env) -> Self { + + let callback = context.send_back(|route_result: RouteResult| { + Msg::from(route_result) + }); + // When the user presses the back or forward button, an event will file and cause the callback to fire + context.routing.register_router(callback); + + + let route: Route = Route::from_route_main(&mut context.routing.get_current_route_info()); + context.routing.replace_url(route.clone()); // sets the url to be dependent on what the route_info was resolved to + + Model { + route + } + } + + fn update(&mut self, msg: Msg, context: &mut Env) -> ShouldRender { + match msg { + Msg::Navigate(route) => { + println!("Main route: Navigating"); + context.routing.set_route(route.clone()); + self.route = route; + true + } + } + } +} + +impl Renderable for Model { + fn view(&self) -> Html { + html! { +
+ {"This could be some html that will be on every page, like a header."} + +
+ {self.route.view()} +
+
+ } + } +} + + +impl Renderable for Route { + fn view(&self) -> Html { + match *self { + Route::Forums(ref forum_route) => { + html! { + <> + {forum_route.view()} + + } + } + Route::PageNotFoundRoute => { + html! { +
+ {"Page not found"} +
+ } + } + } + } +} + + +fn main() { + yew::initialize(); + let context = Context { + routing: RouteService::new() + }; + // We use `Scope` here for demonstration. + // You can also use `App` here too. + let app: App = App::new(context); + app.mount_to_body(); + yew::run_loop(); +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 1482e9a7b6b..281edda25d1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,6 +50,7 @@ #[macro_use] extern crate failure; +extern crate url; extern crate http; extern crate serde; extern crate serde_json; diff --git a/src/services/mod.rs b/src/services/mod.rs index 234b94142ef..fb32ab0eb3f 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -10,6 +10,7 @@ pub mod interval; pub mod storage; pub mod timeout; pub mod websocket; +pub mod route; use std::time::Duration; diff --git a/src/services/route.rs b/src/services/route.rs new file mode 100644 index 00000000000..4c0d3c1c298 --- /dev/null +++ b/src/services/route.rs @@ -0,0 +1,329 @@ +//! This module contains the implementation of a service for +//! setting the url and responding to changes to the url +//! that are initiated by the browser.. + +use stdweb::web::{History, Location, window}; +use stdweb::Value; + +use stdweb::web::{EventListenerHandle, IEventTarget}; +use stdweb::web::event::PopStateEvent; +use stdweb::unstable::TryFrom; +use callback::Callback; + +use url::{Url}; +use std::ops::Add; +use std::usize; + + +/// An alias for `Result`. +pub type RouteResult = Result; + +/// Service used for routing. +pub struct RouteService { + history: History, + location: Location, + event_listener: Option, + callback: Option> +} + +/// Subsection of a RouteInfo produced by iterating over the RouteInfo. +pub enum RouteSection { + /// When iterating over a RouteInfo, Nodes will be produced when the segment isn't the last in + /// the vector of path_segments. + Node { + /// The path segment. + segment: String + }, + /// When iterating over a RouteInfo, Leafs will be produced when the segment is the last in + /// the vector of path_segments. + /// This includes the segment, as well as the query and fragment. + Leaf { + /// The query string. + query: Option, + /// The fragment. + fragment: Option + } +} + +/// A subset of the url crate's Url object that can be passed +/// to crate consumers to deal with routing. +#[derive(Clone, PartialEq, Debug)] +pub struct RouteInfo { + /// The segments of the path string + pub path_segments: Vec, + /// The query parameter + pub query: Option, // TODO it might make sense to store the query as a hashmap + /// The fragment + pub fragment: Option +} + +impl Iterator for RouteInfo { + type Item = RouteSection; + fn next(&mut self) -> Option { + + match self.path_segments.len() { + 1...usize::MAX => { + let mut first_element = self.path_segments.drain(0..1); + let node = RouteSection::Node { + segment: first_element.next().unwrap() + }; + Some(node) + } + _ => { + // Return None if no meaningful leaf can be created. + if let None = self.query { + if let None = self.fragment { + return None + } + } + + let leaf = RouteSection::Leaf { + query: self.query.take(), + fragment: self.fragment.take() + }; + + Some(leaf) + } + } + } +} + + +impl Add for RouteInfo { + type Output = RouteInfo; + fn add(self, rhs: RouteInfo) -> RouteInfo { + let mut path_segments = self.path_segments; + path_segments.extend(rhs.path_segments); + RouteInfo { + path_segments, + query: rhs.query, + fragment: rhs.fragment + } + } +} + + +/// An error that can occur in the course of routing +#[derive(Debug, Clone, PartialEq)] +pub enum RoutingError { + /// An error indicating that the string passed to the `RouteInfo::parse()` function couldn't parse the url. + CouldNotParseRoute { + /// In the event that url crate can't parse the route string, the route string will be passed back to the crate user to use. + route: String + }, + /// If the full Url can't be parsed this will be returned + CouldNotParseUrl { + /// This will contain the full url, not just the route. + full_url: String + }, + /// An error indicating that the string passed to the `RouteInfo::parse()` function did not start with a slash. + RouteDoesNotStartWithSlash, + /// An error indicating that the string passed to the `RouteInfo::parse()` function did not contain ary characters + RouteIsEmpty, + /// Indicates that the url could not be retrieved from the Location API. + CouldNotGetLocationHref +} + + +/// For the route service to choose to render the component, the following needs to be implemented. +pub trait Routable { + /// converts itself to a section + fn to_part(&self) -> RouteSection; + /// Takes part of a route and converts it to Properties that are used to set itself. + fn tune_from_part(route_section: RouteSection) -> Option where Self: Sized; + +} + +/// Works with a RouterComponent to set its child +pub trait Router { + /// Form a route based on the router's state. + fn to_route(&self) -> RouteInfo; + + /// Given a route info, try to resolve a child. + fn from_route(route: &mut RouteInfo) -> Option where Self: Sized; +} + +/// The top-level router at the root of the application. +/// Every possible route needs to be handled by redirecting to a 404 like page if it can't be resolved. +pub trait MainRouter: Router { + /// Will not return an option, all cases must be handled. + fn from_route_main(route: &mut RouteInfo) -> Self; +} + +impl RouteInfo { + /// This expects a string with a leading slash` + pub fn parse(route_string: &str) -> Result { + // Perform some validation on the string before parsing it. + if let Some(first_character) = route_string.chars().next() { + if first_character != '/' { + eprintln!("does not start with slash: '{}'", route_string); + return Err(RoutingError::RouteDoesNotStartWithSlash) + } + } else { + return Err(RoutingError::RouteIsEmpty) + } + + let full_url = format!("http://dummy_url.com{}", route_string); + Url::parse(&full_url) + .map(RouteInfo::from) + .map_err(|_| RoutingError::CouldNotParseRoute { route: route_string.to_string() }) + } + + /// Converts the RouteInfo into a string that can be matched upon, + /// as well as stored in the History Api. + pub fn to_string(&self) -> String { + let path = self.path_segments.join("/"); + let mut path = format!("/{}", path); // add the leading '/' + if let Some(ref query) = self.query { + path = format!("{}?{}", path, query); + } + if let Some(ref fragment) = self.fragment { + path = format!("{}#{}", path, fragment) + } + path + } + + /// Gets the path segment at the specified index. + pub fn get_segment_at_index<'a>(&'a self, index: usize) -> Option<&'a str> { + self.path_segments.get(index).map(String::as_str) + } +} + +impl From for RouteInfo { + fn from(url: Url) -> RouteInfo { + RouteInfo { + path_segments: url + .path_segments() + .expect("The route should always start with '/', so this should never error.") + .map(str::to_string) + .collect::>(), + query: url.query().map(str::to_string), + fragment: url.fragment().map(str::to_string) + } + } +} + + +impl RouteService { + + /// Creates a new route service + pub fn new() -> RouteService { + RouteService { + history: window().history(), + location: window().location().expect("Could not find Location API, routing is not supported in your browser."), + event_listener: None, + callback: None + } + } + + /// Clones the route service. + /// This facilitates creating copies of the route service so that it may be moved into callbacks that aren't attached to components. + pub fn clone_without_listener(&self) -> Self { + RouteService { + history: self.history.clone(), + location: self.location.clone(), + event_listener: None, + callback: self.callback.clone(), + } + } + + + /// Will return the current route info based on the location API. + // TODO this should probably return a RouteResult and avoid expecting + pub fn get_current_route_info(&mut self) -> RouteInfo { + // If the location api errors, recover by redirecting to a valid address + let href = self.get_location().expect("Couldn't get href from location Api"); + let url = Url::parse(&href).expect("The href returned from the location api should always be parsable."); + RouteInfo::from(url) + } + + /// Registers the router. + /// There can only be one router. + /// The component in which it is set up will be the source from which routing logic emanates. + pub fn register_router(&mut self, callback: Callback) + { + if let Some(_) = self.event_listener { + panic!("You cannot register two separate routers."); + } + + // Hold on to the callback so it can be used to update the main router component + // when a user clicks a link, independent of the event listener. + self.callback = Some(callback.clone()); + + // Set the event listener to listen for the history's pop state events and call the callback when that occurs + self.event_listener = Some( window().add_event_listener(move |event: PopStateEvent| { + let state_value: Value = event.state(); + + if let Ok(state) = String::try_from(state_value) { + callback.emit(RouteInfo::parse(&state)) + } else { + eprintln!("Nothing farther back in history, not calling routing callback."); + } + })); + } + + + /// Sets the route via the history api. + /// If the route is not already set to the string corresponding to the provided RouteInfo, + /// the history will be updated, and the routing callback will be invoked. + pub fn set_route(&mut self, r: T) { + let route_info: RouteInfo = r.to_route(); + if route_info != self.get_current_route_info() { + let route_string: String = route_info.to_string(); + println!("Setting route: {}", route_string); // this line needs to be removed eventually + let r = js! { + return @{route_string.clone()} + }; + // Set the state using the history API + self.history.push_state(r, "", Some(&route_string)); + self.go_to_current_route(); + } + } + + /// Set the route using a string instead of something that implements a router. + pub fn set_route_from_string(&mut self, r: String) { + let route_string: String = r; + println!("Setting route: {}", route_string); // this line needs to be removed eventually + let r = js! { + return @{route_string.clone()} + }; + // Set the state using the history API + self.history.push_state(r, "", Some(&route_string)); + self.go_to_current_route(); + } + + + /// Replaces the url with the one provided by the route info. + /// This will not invoke the routing callback. + pub fn replace_url(&mut self, r: T) { + let route_string: String = r.to_route().to_string(); + let r = js! { + return @{route_string.clone()} + }; + let _ = self.history.replace_state(r, "", Some(&route_string)); + } + + /// Based on the location API, set the route by calling the callback. + pub fn go_to_current_route(&mut self) { + if let Some(ref cb) = self.callback { + + let route_result: RouteResult = match self.get_location() { + Ok(full_url) => { + Url::parse(&full_url) + .map(RouteInfo::from) + .map_err(|_|RoutingError::CouldNotParseUrl {full_url: full_url.to_string()}) + } + Err(e) => Err(e) + }; + cb.emit(route_result) + + } else { + eprintln!("Callback was never set.") + } + } + + /// Gets the location. + pub fn get_location(&self) -> Result { + self.location.href().map_err(|_|RoutingError::CouldNotGetLocationHref) + } +}