diff --git a/examples/auth/src/lib.rs b/examples/auth/src/lib.rs index 1b94f0314..a1d0ce736 100644 --- a/examples/auth/src/lib.rs +++ b/examples/auth/src/lib.rs @@ -18,7 +18,7 @@ fn init(url: Url, orders: &mut impl Orders) -> Model { Model { email: "john@example.com".to_owned(), password: "1234".to_owned(), - base_url: url.to_base_url(), + base_url: url.clone().truncate_relative_path(), page: Page::init(url, user.as_ref(), orders), secret_message: None, user, @@ -59,7 +59,7 @@ enum Page { impl Page { fn init(mut url: Url, user: Option<&LoggedUser>, orders: &mut impl Orders) -> Self { - match url.next_path_part() { + match url.pop_relative_path_part() { None => { if let Some(user) = user { send_request_to_top_secret(user.token.clone(), orders) @@ -99,7 +99,7 @@ impl<'a> Urls<'a> { self.base_url() } pub fn login(self) -> Url { - self.base_url().add_path_part(LOGIN) + self.base_url().push_path_part(LOGIN) } } diff --git a/examples/pages/src/lib.rs b/examples/pages/src/lib.rs index 3701f7643..dc982def8 100644 --- a/examples/pages/src/lib.rs +++ b/examples/pages/src/lib.rs @@ -16,7 +16,7 @@ fn init(url: Url, orders: &mut impl Orders) -> Model { ctx: Context { logged_user: "John Doe", }, - base_url: url.to_base_url(), + base_url: url.clone().truncate_relative_path(), page: Page::init(url), } } @@ -47,7 +47,7 @@ enum Page { impl Page { fn init(mut url: Url) -> Self { - match url.next_path_part() { + match url.pop_relative_path_part() { None => Self::Home, Some(ADMIN) => page::admin::init(url).map_or(Self::NotFound, Self::Admin), _ => Self::NotFound, @@ -65,7 +65,7 @@ impl<'a> Urls<'a> { self.base_url() } pub fn admin_urls(self) -> page::admin::Urls<'a> { - page::admin::Urls::new(self.base_url().add_path_part(ADMIN)) + page::admin::Urls::new(self.base_url().push_path_part(ADMIN)) } } diff --git a/examples/pages/src/page/admin.rs b/examples/pages/src/page/admin.rs index c00190e7a..967899d88 100644 --- a/examples/pages/src/page/admin.rs +++ b/examples/pages/src/page/admin.rs @@ -11,7 +11,7 @@ mod page; pub fn init(mut url: Url) -> Option { Some(Model { - report_page: match url.next_path_part() { + report_page: match url.pop_relative_path_part() { Some(REPORT) => page::report::init(url)?, _ => None?, }, @@ -33,7 +33,7 @@ pub struct Model { struct_urls!(); impl<'a> Urls<'a> { pub fn report_urls(self) -> page::report::Urls<'a> { - page::report::Urls::new(self.base_url().add_path_part(REPORT)) + page::report::Urls::new(self.base_url().push_path_part(REPORT)) } } diff --git a/examples/pages/src/page/admin/page/report.rs b/examples/pages/src/page/admin/page/report.rs index 25f290eee..252c607ff 100644 --- a/examples/pages/src/page/admin/page/report.rs +++ b/examples/pages/src/page/admin/page/report.rs @@ -9,9 +9,9 @@ const WEEKLY: &str = "weekly"; // ------ ------ pub fn init(mut url: Url) -> Option { - let base_url = url.to_base_url(); + let base_url = url.clone().truncate_relative_path(); - let frequency = match url.remaining_path_parts().as_slice() { + let frequency = match url.consume_relative_path().as_slice() { [] => { Urls::new(&base_url).default().go_and_replace(); Frequency::default() @@ -59,10 +59,10 @@ impl<'a> Urls<'a> { self.daily() } pub fn daily(self) -> Url { - self.base_url().add_path_part(DAILY) + self.base_url().push_path_part(DAILY) } pub fn weekly(self) -> Url { - self.base_url().add_path_part(WEEKLY) + self.base_url().push_path_part(WEEKLY) } } diff --git a/examples/pages_hash_routing/src/lib.rs b/examples/pages_hash_routing/src/lib.rs index d77c43ba8..be96838c4 100644 --- a/examples/pages_hash_routing/src/lib.rs +++ b/examples/pages_hash_routing/src/lib.rs @@ -16,7 +16,7 @@ fn init(url: Url, orders: &mut impl Orders) -> Model { ctx: Context { logged_user: "John Doe", }, - base_url: url.to_hash_base_url(), + base_url: url.clone().truncate_relative_hash_path(), page: Page::init(url), } } @@ -47,7 +47,7 @@ enum Page { impl Page { fn init(mut url: Url) -> Self { - match url.next_hash_path_part() { + match url.pop_relative_hash_path_part() { None => Self::Home, Some(ADMIN) => page::admin::init(url).map_or(Self::NotFound, Self::Admin), _ => Self::NotFound, @@ -65,7 +65,7 @@ impl<'a> Urls<'a> { self.base_url() } pub fn admin_urls(self) -> page::admin::Urls<'a> { - page::admin::Urls::new(self.base_url().add_hash_path_part(ADMIN)) + page::admin::Urls::new(self.base_url().push_hash_path_part(ADMIN)) } } diff --git a/examples/pages_hash_routing/src/page/admin.rs b/examples/pages_hash_routing/src/page/admin.rs index 87e972ff4..40242ebbd 100644 --- a/examples/pages_hash_routing/src/page/admin.rs +++ b/examples/pages_hash_routing/src/page/admin.rs @@ -11,7 +11,7 @@ mod page; pub fn init(mut url: Url) -> Option { Some(Model { - report_page: match url.next_hash_path_part() { + report_page: match url.pop_relative_hash_path_part() { Some(REPORT) => page::report::init(url)?, _ => None?, }, @@ -33,7 +33,7 @@ pub struct Model { struct_urls!(); impl<'a> Urls<'a> { pub fn report_urls(self) -> page::report::Urls<'a> { - page::report::Urls::new(self.base_url().add_hash_path_part(REPORT)) + page::report::Urls::new(self.base_url().push_hash_path_part(REPORT)) } } diff --git a/examples/pages_hash_routing/src/page/admin/page/report.rs b/examples/pages_hash_routing/src/page/admin/page/report.rs index 834b1cb87..33d2f766b 100644 --- a/examples/pages_hash_routing/src/page/admin/page/report.rs +++ b/examples/pages_hash_routing/src/page/admin/page/report.rs @@ -9,9 +9,9 @@ const WEEKLY: &str = "weekly"; // ------ ------ pub fn init(mut url: Url) -> Option { - let base_url = url.to_hash_base_url(); + let base_url = url.clone().truncate_relative_hash_path(); - let frequency = match url.remaining_hash_path_parts().as_slice() { + let frequency = match url.consume_relative_hash_path().as_slice() { [] => { Urls::new(&base_url).default().go_and_replace(); Frequency::default() @@ -59,10 +59,10 @@ impl<'a> Urls<'a> { self.daily() } pub fn daily(self) -> Url { - self.base_url().add_hash_path_part(DAILY) + self.base_url().push_hash_path_part(DAILY) } pub fn weekly(self) -> Url { - self.base_url().add_hash_path_part(WEEKLY) + self.base_url().push_hash_path_part(WEEKLY) } } diff --git a/examples/pages_keep_state/src/lib.rs b/examples/pages_keep_state/src/lib.rs index 64359ad8c..b33c1798f 100644 --- a/examples/pages_keep_state/src/lib.rs +++ b/examples/pages_keep_state/src/lib.rs @@ -11,16 +11,15 @@ const ADMIN: &str = "admin"; // ------ ------ fn init(url: Url, orders: &mut impl Orders) -> Model { - let base_url = url.to_base_url(); orders .subscribe(Msg::UrlChanged) - .notify(subs::UrlChanged(url)); + .notify(subs::UrlChanged(url.clone())); Model { ctx: Context { logged_user: "John Doe", }, - base_url, + base_url: url.truncate_relative_path(), page_id: None, admin_model: None, } @@ -61,7 +60,7 @@ impl<'a> Urls<'a> { self.base_url() } pub fn admin_urls(self) -> page::admin::Urls<'a> { - page::admin::Urls::new(self.base_url().add_path_part(ADMIN)) + page::admin::Urls::new(self.base_url().push_path_part(ADMIN)) } } @@ -76,7 +75,7 @@ enum Msg { fn update(msg: Msg, model: &mut Model, _: &mut impl Orders) { match msg { Msg::UrlChanged(subs::UrlChanged(mut url)) => { - model.page_id = match url.next_path_part() { + model.page_id = match url.pop_relative_path_part() { None => Some(PageId::Home), Some(ADMIN) => { page::admin::init(url, &mut model.admin_model).map(|_| PageId::Admin) diff --git a/examples/pages_keep_state/src/page/admin.rs b/examples/pages_keep_state/src/page/admin.rs index ded962584..806ab84de 100644 --- a/examples/pages_keep_state/src/page/admin.rs +++ b/examples/pages_keep_state/src/page/admin.rs @@ -11,7 +11,7 @@ mod page; pub fn init(mut url: Url, model: &mut Option) -> Option<()> { let model = model.get_or_insert_with(Model::default); - model.page_id.replace(match url.next_path_part() { + model.page_id.replace(match url.pop_relative_path_part() { Some(REPORT) => page::report::init(url, &mut model.report_model).map(|_| PageId::Report)?, _ => None?, }); @@ -42,7 +42,7 @@ enum PageId { struct_urls!(); impl<'a> Urls<'a> { pub fn report_urls(self) -> page::report::Urls<'a> { - page::report::Urls::new(self.base_url().add_path_part(REPORT)) + page::report::Urls::new(self.base_url().push_path_part(REPORT)) } } diff --git a/examples/pages_keep_state/src/page/admin/page/report.rs b/examples/pages_keep_state/src/page/admin/page/report.rs index 825d2fab2..f6b81a797 100644 --- a/examples/pages_keep_state/src/page/admin/page/report.rs +++ b/examples/pages_keep_state/src/page/admin/page/report.rs @@ -10,11 +10,11 @@ const WEEKLY: &str = "weekly"; pub fn init(mut url: Url, model: &mut Option) -> Option<()> { let model = model.get_or_insert_with(|| Model { - base_url: url.to_base_url(), + base_url: url.clone().truncate_relative_path(), frequency: Frequency::Daily, }); - model.frequency = match url.remaining_path_parts().as_slice() { + model.frequency = match url.consume_relative_path().as_slice() { [] => { match model.frequency { Frequency::Daily => Urls::new(&model.base_url).daily().go_and_replace(), @@ -56,10 +56,10 @@ impl<'a> Urls<'a> { self.base_url() } pub fn daily(self) -> Url { - self.base_url().add_path_part(DAILY) + self.base_url().push_path_part(DAILY) } pub fn weekly(self) -> Url { - self.base_url().add_path_part(WEEKLY) + self.base_url().push_path_part(WEEKLY) } } diff --git a/examples/todomvc/src/lib.rs b/examples/todomvc/src/lib.rs index 29bc91a42..226d158b7 100644 --- a/examples/todomvc/src/lib.rs +++ b/examples/todomvc/src/lib.rs @@ -117,7 +117,7 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { let data = &mut model.data; match msg { Msg::UrlChanged(subs::UrlChanged(mut url)) => { - data.filter = match url.next_path_part() { + data.filter = match url.pop_relative_path_part() { Some(path_part) if path_part == TodoFilter::Active.to_url_path() => { TodoFilter::Active } diff --git a/examples/unsaved_changes/src/lib.rs b/examples/unsaved_changes/src/lib.rs index 48ff062c8..6a389c510 100644 --- a/examples/unsaved_changes/src/lib.rs +++ b/examples/unsaved_changes/src/lib.rs @@ -18,7 +18,7 @@ fn init(url: Url, orders: &mut impl Orders) -> Model { let text = LocalStorage::get(STORAGE_KEY).unwrap_or_default(); Model { - base_url: url.to_base_url(), + base_url: url.clone().truncate_relative_path(), saved_text_hash: calculate_hash(&text), text, } @@ -44,7 +44,7 @@ impl<'a> Urls<'a> { self.base_url() } fn no_home(self) -> Url { - self.base_url().add_path_part("no-home") + self.base_url().push_path_part("no-home") } } diff --git a/examples/url/src/lib.rs b/examples/url/src/lib.rs index b0b5b418c..0e58f8222 100644 --- a/examples/url/src/lib.rs +++ b/examples/url/src/lib.rs @@ -32,10 +32,10 @@ impl Model { Self { base_path, initial_url: url.clone(), - base_url: url.to_base_url(), - next_path_part: url.next_path_part().map(ToOwned::to_owned), + base_url: url.clone().truncate_relative_path(), + next_path_part: url.pop_relative_path_part().map(ToOwned::to_owned), remaining_path_parts: url - .remaining_path_parts() + .consume_relative_path() .into_iter() .map(ToOwned::to_owned) .collect(), @@ -75,9 +75,9 @@ fn view(model: &Model) -> Node { ev(Ev::Click, |_| { Url::new() .set_path(&["ui", "a", "b", "c"]) - .set_search(UrlSearch::new(vec![ + .set_search(vec![ ("x", vec!["1"]) - ])) + ].into_iter().collect()) .set_hash("hash") .go_and_load() }) diff --git a/src/app.rs b/src/app.rs index 014b05ec1..4df93da82 100644 --- a/src/app.rs +++ b/src/app.rs @@ -184,7 +184,7 @@ impl + 'static, GMs: 'static> App| -> AfterMount { - let url = url.skip_base_path(&base_path); + let url = url.try_skip_base_path(&base_path); let model = init(url, orders); AfterMount::new(model).url_handling(UrlHandling::None) } diff --git a/src/browser/service/routing.rs b/src/browser/service/routing.rs index cc3e25aa9..455626e4b 100644 --- a/src/browser/service/routing.rs +++ b/src/browser/service/routing.rs @@ -46,7 +46,7 @@ pub fn setup_popstate_listener( }; notify(Notification::new(subs::UrlChanged( - url.clone().skip_base_path(&base_path), + url.clone().try_skip_base_path(&base_path), ))); if let Some(routes) = routes { @@ -85,7 +85,7 @@ pub fn setup_hashchange_listener( .expect("cast hashchange event url to `Url`"); notify(Notification::new(subs::UrlChanged( - url.clone().skip_base_path(&base_path), + url.clone().try_skip_base_path(&base_path), ))); if let Some(routes) = routes { @@ -117,7 +117,7 @@ pub(crate) fn url_request_handler( event.prevent_default(); // Prevent page refresh } notify(Notification::new(subs::UrlChanged( - url.skip_base_path(&base_path), + url.try_skip_base_path(&base_path), ))); } subs::url_requested::UrlRequestStatus::Handled(prevent_default) => { diff --git a/src/browser/url.rs b/src/browser/url.rs index 329c207d3..3d4e19269 100644 --- a/src/browser/url.rs +++ b/src/browser/url.rs @@ -1,24 +1,39 @@ use crate::browser::util; use serde::{Deserialize, Serialize}; -use std::{borrow::Cow, collections::BTreeMap, fmt, str::FromStr}; +use std::{borrow::Cow, fmt, str::FromStr}; use wasm_bindgen::JsValue; +mod search; +pub use search::UrlSearch; + pub const DUMMY_BASE_URL: &str = "http://example.com"; // ------ Url ------ -/// URL used for routing. +/// URL used for routing. The struct also keeps track of the "base" path vs the "relative" path components +/// within the URL. The relative path appended to the base path forms the "absolute" path or simply, the +/// path. For example: +/// +/// ```text +/// https://site.com/albums/seedlings/oak-45.png +/// ^base^ ^----relative------^ +/// ^---------absolute--------^ +/// ``` +/// +/// Note that methods exist to change which parts of the URL are considered the +/// "base" vs the "relative" parts. This concept also applies for "hash paths". /// /// - It represents relative URL. -/// - Two, almost identical, `Url`s that differ only with differently advanced -/// internal path or hash path iterators (e.g. `next_path_part()` was called on one of them) -/// are considered different also during comparison. +/// - Two `Url`s that represent the same absolute path but different base/relative +/// paths (e.g. `pop_path_part()` was called on one of them) are considered +/// different when compared. /// -/// (If the features above are problems for you, create an [issue](https://github.com/seed-rs/seed/issues/new)) +/// (If the features above are problems for you, please [create an issue on our +/// GitHub page](https://github.com/seed-rs/seed/issues/new). Thank you!) #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub struct Url { - next_path_part_index: usize, - next_hash_path_part_index: usize, + base_path_len: usize, + base_hash_path_len: usize, path: Vec, hash_path: Vec, hash: Option, @@ -26,183 +41,92 @@ pub struct Url { invalid_components: Vec, } +// Constructors + impl Url { /// Creates a new `Url` with the empty path. pub fn new() -> Self { Self::default() } - /// Change the browser URL, but do not trigger a page load. - /// - /// This will add a new entry to the browser history. - /// - /// # References - /// * [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/History_API) - pub fn go_and_push(&self) { - // We use data to evaluate the path instead of the path displayed in the url. - let data = JsValue::from_str( - &serde_json::to_string(&self).expect("Problem serializing route data"), - ); - - util::history() - .push_state_with_url(&data, "", Some(&self.to_string())) - .expect("Problem pushing state"); - } - - /// Change the browser URL, but do not trigger a page load. - /// - /// This will NOT add a new entry to the browser history. - /// - /// # References - /// * [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/History_API) - pub fn go_and_replace(&self) { - // We use data to evaluate the path instead of the path displayed in the url. - let data = JsValue::from_str( - &serde_json::to_string(&self).expect("Problem serializing route data"), - ); - - util::history() - .replace_state_with_url(&data, "", Some(&self.to_string())) - .expect("Problem pushing state"); - } - /// Creates a new `Url` from the one that is currently set in the browser. pub fn current() -> Url { let current_url = util::window().location().href().expect("get `href`"); Url::from_str(¤t_url).expect("create `web_sys::Url` from the current URL") } +} - /// Advances the internal path iterator and returns the next path part as `Option<&str>`. - /// - /// # Example +// Getters + +impl Url { + /// Get the (absolute) path. /// - /// ```rust,no_run - ///match url.next_path_part() { - /// None => Page::Home, - /// Some("report") => Page::Report(page::report::init(url)), - /// _ => Page::Unknown(url), - ///} - /// ```` - pub fn next_path_part(&mut self) -> Option<&str> { - let path_part = self.path.get(self.next_path_part_index); - if path_part.is_some() { - self.next_path_part_index += 1; - } - path_part.map(String::as_str) + /// # Refenences + /// * [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname) + pub fn path(&self) -> &[String] { + &self.path } - /// Advances the internal hash path iterator and returns the next hash path part as `Option<&str>`. - /// - /// # Example - /// - /// ```rust,no_run - ///match url.next_hash_path_part() { - /// None => Page::Home, - /// Some("report") => Page::Report(page::report::init(url)), - /// _ => Page::Unknown(url), - ///} - /// ```` - pub fn next_hash_path_part(&mut self) -> Option<&str> { - let hash_path_part = self.hash_path.get(self.next_hash_path_part_index); - if hash_path_part.is_some() { - self.next_hash_path_part_index += 1; - } - hash_path_part.map(String::as_str) + /// Get the base path. + pub fn base_path(&mut self) -> &[String] { + &self.path[0..self.base_path_len] } - /// Collects the internal path iterator and returns it as `Vec<&str>`. - /// - /// # Example - /// - /// ```rust,no_run - ///match url.remaining_path_parts().as_slice() { - /// [] => Page::Home, - /// ["report", rest @ ..] => { - /// match rest { - /// ["day"] => Page::ReportDay, - /// _ => Page::ReportWeek, - /// } - /// }, - /// _ => Page::NotFound, - ///} - /// ```` - pub fn remaining_path_parts(&mut self) -> Vec<&str> { - let path_part_index = self.next_path_part_index; - self.next_path_part_index = self.path.len(); - self.path - .iter() - .skip(path_part_index) - .map(String::as_str) - .collect() + /// Get the relative path. + pub fn relative_path(&mut self) -> &[String] { + &self.path[self.base_path_len..] } - /// Collects the internal hash path iterator and returns it as `Vec<&str>`. - /// - /// # Example - /// - /// ```rust,no_run - ///match url.remaining_hash_path_parts().as_slice() { - /// [] => Page::Home, - /// ["report", rest @ ..] => { - /// match rest { - /// ["day"] => Page::ReportDay, - /// _ => Page::ReportWeek, - /// } - /// }, - /// _ => Page::NotFound, - ///} - /// ```` - pub fn remaining_hash_path_parts(&mut self) -> Vec<&str> { - let hash_path_part_index = self.next_hash_path_part_index; - self.next_hash_path_part_index = self.hash_path.len(); - self.hash_path - .iter() - .skip(hash_path_part_index) - .map(String::as_str) - .collect() + /// Get the hash path. + pub fn hash_path(&self) -> &[String] { + &self.path } - /// Adds given path part and returns updated `Url`. - /// - /// # Example + /// Get the hash. /// - /// ```rust,no_run - ///let link_to_blog = url.add_path_part("blog"); - /// ```` - pub fn add_path_part(mut self, path_part: impl Into) -> Self { - self.path.push(path_part.into()); - self + /// # References + /// * [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/URL/hash) + pub fn hash(&self) -> Option<&String> { + self.hash.as_ref() } - /// Adds given hash path part and returns updated `Url`. - /// It also changes `hash`. + /// Get the search parameters. /// - /// # Example + /// # Refenences + /// * [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/URL/search) + pub const fn search(&self) -> &UrlSearch { + &self.search + } + + /// Get a mutable version of the search parameters. /// - /// ```rust,no_run - ///let link_to_blog = url.add_hash_path_part("blog"); - /// ```` - pub fn add_hash_path_part(mut self, hash_path_part: impl Into) -> Self { - self.hash_path.push(hash_path_part.into()); - self.hash = Some(self.hash_path.join("/")); - self + /// # Refenences + /// * [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/URL/search) + pub fn search_mut(&mut self) -> &mut UrlSearch { + &mut self.search } - /// Clone the `Url` and strip remaining path parts. - pub fn to_base_url(&self) -> Self { - let mut url = self.clone(); - url.path.truncate(self.next_path_part_index); - url + /// Get the invalid components. + /// + /// Undecodable / unparsable components are invalid. + pub fn invalid_components(&self) -> &[String] { + &self.invalid_components } - /// Clone the `Url` and strip remaining hash path parts. - pub fn to_hash_base_url(&self) -> Self { - let mut url = self.clone(); - url.hash_path.truncate(self.next_hash_path_part_index); - url + /// Get a mutable version of the invalid components. + /// + /// Undecodable / unparsable components are invalid. + pub fn invalid_components_mut(&mut self) -> &mut Vec { + &mut self.invalid_components } +} + +// Setters - /// Sets path and returns updated `Url`. It also resets internal path iterator. +impl Url { + /// Sets the (absolute) path and returns the updated `Url`. + /// + /// It also resets the base and relative paths. /// /// # Example /// @@ -220,12 +144,13 @@ impl Url { .into_iter() .map(|p| p.to_string()) .collect(); - self.next_path_part_index = 0; + self.base_path_len = 0; self } - /// Sets hash path and returns updated `Url`. - /// It also resets internal hash path iterator and sets `hash`. + /// Sets the (absolute) hash path and returns the updated `Url`. + /// + /// It also resets the base and relative hash paths and sets `hash`. /// /// # Example /// @@ -243,13 +168,14 @@ impl Url { .into_iter() .map(|p| p.to_string()) .collect(); - self.next_hash_path_part_index = 0; + self.base_hash_path_len = 0; self.hash = Some(self.hash_path.join("/")); self } - /// Sets hash and returns updated `Url`. - /// I also sets `hash_path`. + /// Sets the hash and returns the updated `Url`. + /// + /// It also sets the hash path, effectively calling `set_hash_path`. /// /// # Example /// @@ -259,14 +185,13 @@ impl Url { /// /// # References /// * [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/URL/hash) - pub fn set_hash(mut self, hash: impl Into) -> Self { - let hash = hash.into(); - self.hash_path = hash.split('/').map(ToOwned::to_owned).collect(); - self.hash = Some(hash); - self + pub fn set_hash(self, hash: impl Into) -> Self { + // TODO: Probably not an issue, but this effectively clones `hash` once. + // TODO: Optionally implement a private function to handle both. + self.set_hash_path(hash.into().split('/')) } - /// Sets search and returns updated `Url`. + /// Sets the search parameters and returns the updated `Url`. /// /// # Example /// @@ -279,59 +204,63 @@ impl Url { /// /// # Refenences /// * [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/URL/search) - pub fn set_search(mut self, search: impl Into) -> Self { + pub fn set_search(mut self, search: UrlSearch) -> Self { self.search = search.into(); self } +} - /// Get path. - /// - /// # Refenences - /// * [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname) - pub fn path(&self) -> &[String] { - &self.path - } - - /// Get hash path. - pub fn hash_path(&self) -> &[String] { - &self.path - } +// Browser actions dependent on the Url struct +// TODO: Consider moving all Browser actions into a separate `routing` module. - /// Get hash. +impl Url { + /// Change the browser URL, but do not trigger a page load. + /// + /// This will add a new entry to the browser history. /// /// # References - /// * [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/URL/hash) - pub fn hash(&self) -> Option<&String> { - self.hash.as_ref() - } + /// * [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/History_API) + pub fn go_and_push(&self) { + // We use data to evaluate the path instead of the path displayed in the url. + let data = JsValue::from_str( + &serde_json::to_string(&self).expect("Problem serializing route data"), + ); - /// Get search. - /// - /// # Refenences - /// * [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/URL/search) - pub const fn search(&self) -> &UrlSearch { - &self.search + util::history() + .push_state_with_url(&data, "", Some(&self.to_string())) + .expect("Problem pushing state"); } - /// Get mutable search. + /// Change the browser URL, but do not trigger a page load. /// - /// # Refenences - /// * [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/URL/search) - pub fn search_mut(&mut self) -> &mut UrlSearch { - &mut self.search + /// This will NOT add a new entry to the browser history. + /// + /// # References + /// * [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/History_API) + pub fn go_and_replace(&self) { + // We use data to evaluate the path instead of the path displayed in the url. + let data = JsValue::from_str( + &serde_json::to_string(&self).expect("Problem serializing route data"), + ); + + util::history() + .replace_state_with_url(&data, "", Some(&self.to_string())) + .expect("Problem pushing state"); } /// Change the browser URL and trigger a page load. pub fn go_and_load(&self) { - util::window() - .location() - .set_href(&self.to_string()) - .expect("set location href"); + Self::go_and_load_with_str(self.to_string()) } +} +// Actions independent of the Url struct +// TODO: consider making these free functions + +impl Url { /// Change the browser URL and trigger a page load. /// - /// Provided `url` isn't checked and it's passed into `location.href`. + /// Provided `url` isn't checked and directly set to `location.href`. pub fn go_and_load_with_str(url: impl AsRef) { util::window() .location() @@ -369,19 +298,212 @@ impl Url { pub fn go_forward(steps: i32) { util::history().go_with_delta(steps).expect("go forward"); } +} + +// Url `base_path`/`active_path` manipulation + +impl Url { + /// Returns the first part of the relative path and advances the base path. + /// Moves the first part of the relative path into the base path and returns + /// a reference to the moved portion. + /// + /// The effects are as follows. Before: + /// + /// ```text + /// https://site.com/albums/seedlings/oak-45.png + /// ^base^ ^----relative------^ + /// ^---------absolute--------^ + /// ``` + /// + /// and after: + /// + /// ```text + /// https://site.com/albums/seedlings/oak-45.png + /// ^-----base-----^ ^relative^ + /// ^---------absolute--------^ + /// ``` + /// + /// # Code example + /// + /// ```rust,no_run + ///match url.advance_base_path() { + /// None => Page::Home, + /// Some("report") => Page::Report(page::report::init(url)), + /// _ => Page::Unknown(url), + ///} + /// ```` + pub fn pop_relative_path_part(&mut self) -> Option<&str> { + let path_part = self.path.get(self.base_path_len); + if path_part.is_some() { + self.base_path_len += 1; + } + path_part.map(String::as_str) + } + + /// Moves the first part of the relative hash path into the base hash path + /// and returns a reference to the moved portion, similar to `pop_relative_path`. + /// + /// # Example + /// + /// ```rust,no_run + ///match url.pop_relative_hash_path() { + /// None => Page::Home, + /// Some("report") => Page::Report(page::report::init(url)), + /// _ => Page::Unknown(url), + ///} + /// ```` + pub fn pop_relative_hash_path_part(&mut self) -> Option<&str> { + let hash_path_part = self.hash_path.get(self.base_hash_path_len); + if hash_path_part.is_some() { + self.base_hash_path_len += 1; + } + hash_path_part.map(String::as_str) + } - /// If the current `Url`'s path prefix is equal to `path_base`, - /// then reset the internal path iterator and advance it to skip the prefix (aka `path_base`). + /// Moves all the components of the relative path to the base path and + /// returns them as `Vec<&str>`. + /// + /// The effects are as follows. Before: + /// + /// ```text + /// https://site.com/albums/seedlings/oak-45.png + /// ^base^ ^----relative------^ + /// ^---------absolute--------^ + /// ``` + /// + /// and after: + /// + /// ```text + /// https://site.com/albums/seedlings/oak-45.png + /// ^-----------base----------^ + /// ^---------absolute--------^ + /// ``` + /// + /// # Example + /// + /// ```rust,no_run + ///match url.consume_relative_path().as_slice() { + /// [] => Page::Home, + /// ["report", rest @ ..] => { + /// match rest { + /// ["day"] => Page::ReportDay, + /// _ => Page::ReportWeek, + /// } + /// }, + /// _ => Page::NotFound, + ///} + /// ```` + pub fn consume_relative_path(&mut self) -> Vec<&str> { + let path_part_index = self.base_path_len; + self.base_path_len = self.path.len(); + self.path + .iter() + .skip(path_part_index) + .map(String::as_str) + .collect() + } + + /// Moves all the components of the relative hash path to the base hash path + /// and returns them as `Vec<&str>`, similar to `consume_hash_path`. + /// + /// # Example + /// + /// ```rust,no_run + ///match url.consume_relative_hash_path().as_slice() { + /// [] => Page::Home, + /// ["report", rest @ ..] => { + /// match rest { + /// ["day"] => Page::ReportDay, + /// _ => Page::ReportWeek, + /// } + /// }, + /// _ => Page::NotFound, + ///} + /// ```` + pub fn consume_relative_hash_path(&mut self) -> Vec<&str> { + let hash_path_part_index = self.base_hash_path_len; + self.base_hash_path_len = self.hash_path.len(); + self.hash_path + .iter() + .skip(hash_path_part_index) + .map(String::as_str) + .collect() + } + + /// Clone the `Url` and strip relative path. + /// + /// The effects are as follows. Input: + /// + /// ```text + /// https://site.com/albums/seedlings/oak-45.png + /// ^-----base-----^ ^relative^ + /// ^---------absolute--------^ + /// ``` + /// + /// and output: + /// + /// ```text + /// https://site.com/albums/seedlings + /// ^-----base-----^ + /// ^---absolute---^ + /// ``` + pub fn truncate_relative_path(mut self) -> Self { + self.path.truncate(self.base_path_len); + self + } + + /// Clone the `Url` and strip relative hash path. Similar to + /// `truncate_relative_path`. + pub fn truncate_relative_hash_path(mut self) -> Self { + self.hash_path.truncate(self.base_hash_path_len); + self + } + + /// If the current `Url`'s path starts with `path_base`, then set the base + /// path to the provided `path_base` and the rest to the relative path. /// /// It's used mostly by Seed internals, but it can be useful in combination /// with `orders.clone_base_path()`. - pub fn skip_base_path(mut self, path_base: &[String]) -> Self { + // TODO potentially return `Result` so that the user can act on the check. + pub fn try_skip_base_path(mut self, path_base: &[String]) -> Self { if self.path.starts_with(path_base) { - self.next_path_part_index = path_base.len(); + self.base_path_len = path_base.len(); } self } + /// Adds the given path part and returns the updated `Url`. The path + /// part is added to the relative path. + /// + /// # Example + /// + /// ```rust,no_run + ///let link_to_blog = url.push_path_part("blog"); + /// ```` + pub fn push_path_part(mut self, path_part: impl Into) -> Self { + self.path.push(path_part.into()); + self + } + + /// Adds the given hash path part and returns the updated `Url`. + /// It also changes `hash`. + /// + /// # Example + /// + /// ```rust,no_run + ///let link_to_blog = url.push_hash_path_part("blog"); + /// ```` + pub fn push_hash_path_part(mut self, hash_path_part: impl Into) -> Self { + self.hash_path.push(hash_path_part.into()); + self.hash = Some(self.hash_path.join("/")); + self + } +} + +// Things that don't fit +// TODO: consider making this a free floating function, making it private, or both. + +impl Url { /// Decodes a Uniform Resource Identifier (URI) component. /// Aka percent-decoding. /// @@ -402,20 +524,6 @@ impl Url { let decoded = js_sys::decode_uri_component(component.as_ref())?; Ok(String::from(decoded)) } - - /// Get invalid components. - /// - /// Undecodable / unparsable components are invalid. - pub fn invalid_components(&self) -> &[String] { - &self.invalid_components - } - - /// Get mutable invalid components. - /// - /// Undecodable / unparsable components are invalid. - pub fn invalid_components_mut(&mut self) -> &mut Vec { - &mut self.invalid_components - } } /// `Url` components are automatically encoded. @@ -472,67 +580,52 @@ impl From<&web_sys::Url> for Url { fn from(url: &web_sys::Url) -> Self { let mut invalid_components = Vec::::new(); - let path = { + let path: Vec<_> = { let path = url.pathname(); path.split('/') - .filter_map(|path_part| { - if path_part.is_empty() { - None - } else { - let path_part = match Url::decode_uri_component(path_part) { - Ok(decoded_path_part) => decoded_path_part, - Err(_) => { - invalid_components.push(path_part.to_owned()); - path_part.to_owned() - } - }; - Some(path_part) + .filter(|path_part| !path_part.is_empty()) + .map(|path_part| match Url::decode_uri_component(path_part) { + Ok(decoded_path_part) => decoded_path_part, + Err(_) => { + invalid_components.push(path_part.to_owned()); + path_part.to_string() } }) - .collect::>() + .collect() }; - let hash = { - let mut hash = url.hash(); + let (hash, hash_path) = { + let hash = url.hash(); if hash.is_empty() { - None + (None, Vec::new()) } else { // Remove leading `#`. - hash.remove(0); + let hash = &hash['#'.len_utf8()..]; + + // Decode hash path parts. + let hash_path = hash + .split('/') + .filter(|path_part| !path_part.is_empty()) + .map(|path_part| match Url::decode_uri_component(path_part) { + Ok(decoded_path_part) => decoded_path_part, + Err(_) => { + invalid_components.push(path_part.to_owned()); + path_part.to_owned() + } + }) + .collect(); + + // Decode hash. let hash = match Url::decode_uri_component(&hash) { Ok(decoded_hash) => decoded_hash, Err(_) => { - invalid_components.push(hash.clone()); - hash + invalid_components.push(hash.to_owned()); + hash.to_owned() } }; - Some(hash) - } - }; - let hash_path = { - let mut hash = url.hash(); - if hash.is_empty() { - Vec::new() - } else { - // Remove leading `#`. - hash.remove(0); - hash.split('/') - .filter_map(|path_part| { - if path_part.is_empty() { - None - } else { - let path_part = match Url::decode_uri_component(path_part) { - Ok(decoded_path_part) => decoded_path_part, - Err(_) => { - invalid_components.push(path_part.to_owned()); - path_part.to_owned() - } - }; - Some(path_part) - } - }) - .collect::>() + // Return `(hash, hash_path)` + (Some(hash), hash_path) } }; @@ -540,8 +633,8 @@ impl From<&web_sys::Url> for Url { invalid_components.append(&mut search.invalid_components.clone()); Self { - next_path_part_index: 0, - next_hash_path_part_index: 0, + base_path_len: 0, + base_hash_path_len: 0, path, hash_path, hash, @@ -551,161 +644,6 @@ impl From<&web_sys::Url> for Url { } } -// ------ UrlSearch ------ - -#[allow(clippy::module_name_repetitions)] -#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct UrlSearch { - search: BTreeMap>, - invalid_components: Vec, -} - -impl UrlSearch { - /// Makes a new `UrlSearch` with the provided parameters. - /// - /// # Examples - /// - /// ```rust,no_run - /// UrlSearch::new(vec![ - /// ("sort", vec!["date", "name"]), - /// ("category", vec!["top"]) - /// ]) - /// ``` - pub fn new(params: impl IntoIterator) -> Self - where - K: Into, - V: Into, - VS: IntoIterator, - { - let mut search = BTreeMap::new(); - for (key, values) in params { - search.insert(key.into(), values.into_iter().map(Into::into).collect()); - } - Self { - search, - invalid_components: Vec::new(), - } - } - - /// Returns `true` if the `UrlSearch` contains a value for the specified key. - pub fn contains_key(&self, key: impl AsRef) -> bool { - self.search.contains_key(key.as_ref()) - } - - /// Returns a reference to values corresponding to the key. - pub fn get(&self, key: impl AsRef) -> Option<&Vec> { - self.search.get(key.as_ref()) - } - - /// Returns a mutable reference to values corresponding to the key. - pub fn get_mut(&mut self, key: impl AsRef) -> Option<&mut Vec> { - self.search.get_mut(key.as_ref()) - } - - /// Push the value into the vector of values corresponding to the key. - /// - If the key and values are not present, they will be crated. - pub fn push_value<'a>(&mut self, key: impl Into>, value: String) { - let key = key.into(); - if self.search.contains_key(key.as_ref()) { - self.search.get_mut(key.as_ref()).unwrap().push(value); - } else { - self.search.insert(key.into_owned(), vec![value]); - } - } - - /// Inserts a key-values pair into the `UrlSearch`. - /// - If the `UrlSearch` did not have this key present, `None` is returned. - /// - If the `UrlSearch` did have this key present, old values are overwritten by new ones, - /// and old values are returned. The key is not updated. - pub fn insert(&mut self, key: String, values: Vec) -> Option> { - self.search.insert(key, values) - } - - /// Removes a key from the `UrlSearch`, returning values at the key - /// if the key was previously in the `UrlSearch`. - pub fn remove(&mut self, key: impl AsRef) -> Option> { - self.search.remove(key.as_ref()) - } - - /// Gets an iterator over the entries of the `UrlSearch`, sorted by key. - pub fn iter(&self) -> impl Iterator)> { - self.search.iter() - } - - /// Get invalid components. - /// - /// Undecodable / unparsable components are invalid. - pub fn invalid_components(&self) -> &[String] { - &self.invalid_components - } - - /// Get mutable invalid components. - /// - /// Undecodable / unparsable components are invalid. - pub fn invalid_components_mut(&mut self) -> &mut Vec { - &mut self.invalid_components - } -} - -/// `UrlSearch` components are automatically encoded. -impl fmt::Display for UrlSearch { - fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { - let params = web_sys::UrlSearchParams::new().expect("create a new UrlSearchParams"); - - for (key, values) in &self.search { - for value in values { - params.append(key, value); - } - } - write!(fmt, "{}", String::from(params.to_string())) - } -} - -impl From for UrlSearch { - /// Creates a new `UrlSearch` from the browser native `UrlSearchParams`. - /// `UrlSearch`'s components are decoded if possible. When decoding fails, the component is cloned - /// into `invalid_components` and the original value is used. - fn from(params: web_sys::UrlSearchParams) -> Self { - let mut url_search = Self::default(); - let mut invalid_components = Vec::::new(); - - for param in js_sys::Array::from(¶ms).to_vec() { - let key_value_pair = js_sys::Array::from(¶m).to_vec(); - - let key = key_value_pair - .get(0) - .expect("get UrlSearchParams key from key-value pair") - .as_string() - .expect("cast UrlSearchParams key to String"); - let value = key_value_pair - .get(1) - .expect("get UrlSearchParams value from key-value pair") - .as_string() - .expect("cast UrlSearchParams value to String"); - - let key = match Url::decode_uri_component(&key) { - Ok(decoded_key) => decoded_key, - Err(_) => { - invalid_components.push(key.clone()); - key - } - }; - let value = match Url::decode_uri_component(&value) { - Ok(decoded_value) => decoded_value, - Err(_) => { - invalid_components.push(value.clone()); - value - } - }; - - url_search.push_value(key, value) - } - - url_search.invalid_components = invalid_components; - url_search - } -} - // ------ ------ Tests ------ ------ #[cfg(test)] @@ -723,13 +661,13 @@ mod tests { let expected = "/Hello%20G%C3%BCnter/path2?calc=5%2B6&x=1&x=2#he%C5%A1"; let native_url = web_sys::Url::new_with_base(expected, DUMMY_BASE_URL).unwrap(); let url = Url::from(&native_url); + let expected_search: UrlSearch = vec![("calc", vec!["5+6"]), ("x", vec!["1", "2"])] + .into_iter() + .collect(); assert_eq!(url.path()[0], "Hello Günter"); assert_eq!(url.path()[1], "path2"); - assert_eq!( - url.search(), - &UrlSearch::new(vec![("calc", vec!["5+6"]), ("x", vec!["1", "2"]),]) - ); + assert_eq!(url.search(), &expected_search,); assert_eq!(url.hash(), Some(&"heš".to_owned())); let actual = url.to_string(); @@ -747,7 +685,7 @@ mod tests { fn parse_url_with_hash_search() { let expected = Url::new() .set_path(&["path"]) - .set_search(UrlSearch::new(vec![("search", vec!["query"])])) + .set_search(vec![("search", vec!["query"])].into_iter().collect()) .set_hash("hash"); let actual: Url = "/path?search=query#hash".parse().unwrap(); assert_eq!(expected, actual) @@ -773,7 +711,11 @@ mod tests { let actual = Url::new() .set_path(&["foo", "bar"]) - .set_search(UrlSearch::new(vec![("q", vec!["42"]), ("z", vec!["13"])])) + .set_search( + vec![("q", vec!["42"]), ("z", vec!["13"])] + .into_iter() + .collect(), + ) .set_hash_path(&["discover"]) .to_string(); diff --git a/src/browser/url/search.rs b/src/browser/url/search.rs new file mode 100644 index 000000000..c8bc6b96d --- /dev/null +++ b/src/browser/url/search.rs @@ -0,0 +1,175 @@ +use super::Url; +use serde::{Deserialize, Serialize}; +use std::{borrow::Cow, collections::BTreeMap, fmt}; + +#[allow(clippy::module_name_repetitions)] +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct UrlSearch { + search: BTreeMap>, + pub(super) invalid_components: Vec, +} + +impl UrlSearch { + /// Create an empty `UrlSearch` object. + pub fn new() -> Self { + Self { + search: BTreeMap::new(), + invalid_components: Vec::new(), + } + } + + /// Returns `true` if the `UrlSearch` contains a value for the specified key. + pub fn contains_key(&self, key: impl AsRef) -> bool { + self.search.contains_key(key.as_ref()) + } + + /// Returns a reference to values corresponding to the key. + pub fn get(&self, key: impl AsRef) -> Option<&Vec> { + self.search.get(key.as_ref()) + } + + /// Returns a mutable reference to values corresponding to the key. + pub fn get_mut(&mut self, key: impl AsRef) -> Option<&mut Vec> { + self.search.get_mut(key.as_ref()) + } + + /// Push the value into the vector of values corresponding to the key. + /// - If the key and values are not present, they will be crated. + pub fn push_value<'a>(&mut self, key: impl Into>, value: String) { + let key = key.into(); + if self.search.contains_key(key.as_ref()) { + self.search.get_mut(key.as_ref()).unwrap().push(value); + } else { + self.search.insert(key.into_owned(), vec![value]); + } + } + + /// Inserts a key-values pair into the `UrlSearch`. + /// - If the `UrlSearch` did not have this key present, `None` is returned. + /// - If the `UrlSearch` did have this key present, old values are overwritten by new ones, + /// and old values are returned. The key is not updated. + pub fn insert(&mut self, key: String, values: Vec) -> Option> { + self.search.insert(key, values) + } + + /// Removes a key from the `UrlSearch`, returning values at the key + /// if the key was previously in the `UrlSearch`. + pub fn remove(&mut self, key: impl AsRef) -> Option> { + self.search.remove(key.as_ref()) + } + + /// Gets an iterator over the entries of the `UrlSearch`, sorted by key. + pub fn iter(&self) -> impl Iterator)> { + self.search.iter() + } + + /// Get invalid components. + /// + /// Undecodable / unparsable components are invalid. + pub fn invalid_components(&self) -> &[String] { + &self.invalid_components + } + + /// Get mutable invalid components. + /// + /// Undecodable / unparsable components are invalid. + pub fn invalid_components_mut(&mut self) -> &mut Vec { + &mut self.invalid_components + } +} + +/// `UrlSearch` components are automatically encoded. +impl fmt::Display for UrlSearch { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + let params = web_sys::UrlSearchParams::new().expect("create a new UrlSearchParams"); + + for (key, values) in &self.search { + for value in values { + params.append(key, value); + } + } + write!(fmt, "{}", String::from(params.to_string())) + } +} + +impl From for UrlSearch { + /// Creates a new `UrlSearch` from the browser native `UrlSearchParams`. + /// `UrlSearch`'s components are decoded if possible. When decoding fails, the component is cloned + /// into `invalid_components` and the original value is used. + fn from(params: web_sys::UrlSearchParams) -> Self { + let mut url_search = Self::default(); + let mut invalid_components = Vec::::new(); + + for param in js_sys::Array::from(¶ms).to_vec() { + let key_value_pair = js_sys::Array::from(¶m).to_vec(); + + let key = key_value_pair + .get(0) + .expect("get UrlSearchParams key from key-value pair") + .as_string() + .expect("cast UrlSearchParams key to String"); + let value = key_value_pair + .get(1) + .expect("get UrlSearchParams value from key-value pair") + .as_string() + .expect("cast UrlSearchParams value to String"); + + let key = match Url::decode_uri_component(&key) { + Ok(decoded_key) => decoded_key, + Err(_) => { + invalid_components.push(key.clone()); + key + } + }; + let value = match Url::decode_uri_component(&value) { + Ok(decoded_value) => decoded_value, + Err(_) => { + invalid_components.push(value.clone()); + value + } + }; + + url_search.push_value(key, value) + } + + url_search.invalid_components = invalid_components; + url_search + } +} + +impl std::iter::FromIterator<(K, VS)> for UrlSearch +where + K: Into, + V: Into, + VS: IntoIterator, +{ + fn from_iter>(iter: I) -> Self { + let search = iter + .into_iter() + .map(|(k, vs)| { + let k = k.into(); + let v: Vec<_> = vs.into_iter().map(Into::into).collect(); + (k, v) + }) + .collect(); + Self { + search, + invalid_components: Vec::new(), + } + } +} + +impl<'a, K, V, VS> std::iter::FromIterator<&'a (K, VS)> for UrlSearch +where + K: 'a, + &'a K: Into, + V: Into, + VS: 'a, + &'a VS: IntoIterator, +{ + fn from_iter>(iter: I) -> Self { + iter.into_iter() + .map(|(k, vs)| (k.into(), vs.into_iter())) + .collect() + } +}