Homegrown Router

This commit is contained in:
Jeremy Wall 2022-07-15 19:30:06 -04:00
parent 24fd30af8b
commit 5ffe339626
12 changed files with 259 additions and 127 deletions

43
Cargo.lock generated
View File

@ -787,7 +787,6 @@ dependencies = [
"reqwasm",
"serde_json",
"sycamore",
"sycamore-router",
"wasm-bindgen",
"web-sys",
]
@ -924,12 +923,6 @@ dependencies = [
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "mio"
version = "0.8.0"
@ -970,17 +963,6 @@ dependencies = [
"twoway",
]
[[package]]
name = "nom"
version = "7.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1d11e1ef389c76fe5b81bcaf2ea32cf88b62bc494e19f493d0b30e7a930109"
dependencies = [
"memchr",
"minimal-lexical",
"version_check",
]
[[package]]
name = "ntapi"
version = "0.3.7"
@ -1424,31 +1406,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "sycamore-router"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cace57b69d923ef7ac5a1291bee73fa62e7d75b1f3a713db70d30ab0ee032185"
dependencies = [
"sycamore",
"sycamore-router-macro",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "sycamore-router-macro"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a1f83a4862484dba897a6dc64c4a72b5c808c9af05573f7a55133b4f110ac66"
dependencies = [
"nom",
"proc-macro2",
"quote",
"syn",
"unicode-xid",
]
[[package]]
name = "syn"
version = "1.0.86"

View File

@ -17,7 +17,6 @@ recipes = {path = "../recipes" }
reqwasm = "0.5.0"
# This makes debugging panics more tractable.
console_error_panic_hook = "0.1.7"
sycamore-router = "0.7.1"
serde_json = "1.0.79"
[dependencies.wasm-bindgen]
@ -26,7 +25,19 @@ version = "0.2.78"
[dependencies.web-sys]
version = "0.3"
features = [ "Storage", "Window" ]
features = [
"Event",
"EventTarget",
"History",
"HtmlAnchorElement",
"HtmlBaseElement",
"KeyboardEvent",
"Location",
"PopStateEvent",
"Url",
"Window",
"Storage"
]
[dependencies.sycamore]
version = "0.7.1"

View File

@ -12,10 +12,18 @@
// See the License for the specific language governing permissions and
// limitations under the License.
#[derive(Debug)]
#[derive(Debug, Clone)]
pub enum AppRoutes {
Plan,
Inventory,
Cook,
Recipe(usize),
Error(String),
NotFound,
}
impl Default for AppRoutes {
fn default() -> Self {
Self::Plan
}
}

View File

@ -13,11 +13,8 @@
// limitations under the License.
use sycamore::prelude::*;
use crate::app_state::AppRoutes;
#[derive(Clone)]
pub struct TabState<G: GenericNode> {
pub route: Signal<AppRoutes>,
pub inner: View<G>,
}
@ -27,17 +24,11 @@ pub fn tabbed_view(state: TabState<G>) -> View<G> {
header(class="no-print") {
nav {
ul {
li { a(href="#", class="no-print", on:click=cloned!((state) => move |_| {
state.route.set(AppRoutes::Plan);
})) { "Plan" } " > "
li { a(href="#plan", class="no-print") { "Plan" } " > "
}
li { a(href="#", class="no-print", on:click=cloned!((state) => move |_| {
state.route.set(AppRoutes::Inventory);
})) { "Inventory" } " > "
li { a(href="#inventory", class="no-print") { "Inventory" } " > "
}
li { a(href="#", class="no-print", on:click=cloned!((state) => move |_| {
state.route.set(AppRoutes::Cook);
})) { "Cook" }
li { a(href="#cook", class="no-print") { "Cook" }
}
}
ul {

View File

@ -14,6 +14,7 @@
mod app_state;
mod components;
mod pages;
mod router_integration;
mod service;
mod typings;
mod web;

View File

@ -12,20 +12,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::components::{recipe_list::*, tabs::*};
use crate::pages::PageState;
use sycamore::prelude::*;
#[derive(Clone)]
pub struct CookPageProps {
pub page_state: PageState,
}
#[component(CookPage<G>)]
pub fn cook_page(props: CookPageProps) -> View<G> {
pub fn cook_page() -> View<G> {
view! {
TabbedView(TabState {
route: props.page_state.route.clone(),
inner: view! {
RecipeList()
},

View File

@ -12,20 +12,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::components::{shopping_list::*, tabs::*};
use crate::pages::PageState;
use sycamore::prelude::*;
#[derive(Clone)]
pub struct InventoryPageProps {
pub page_state: PageState,
}
#[component(InventoryPage<G>)]
pub fn inventory_page(props: InventoryPageProps) -> View<G> {
pub fn inventory_page() -> View<G> {
view! {
TabbedView(TabState {
route: props.page_state.route.clone(),
inner: view! {
ShoppingList()
},

View File

@ -11,10 +11,6 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use sycamore::prelude::*;
use crate::app_state::AppRoutes;
mod cook;
mod inventory;
mod plan;
@ -24,8 +20,3 @@ pub use cook::*;
pub use inventory::*;
pub use plan::*;
pub use recipe::*;
#[derive(Clone)]
pub struct PageState {
pub route: Signal<AppRoutes>,
}

View File

@ -12,20 +12,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::components::{recipe_selector::*, tabs::*};
use crate::pages::PageState;
use sycamore::prelude::*;
#[derive(Clone)]
pub struct PlanPageProps {
pub page_state: PageState,
}
#[component(PlanPage<G>)]
pub fn plan_page(props: PlanPageProps) -> View<G> {
pub fn plan_page() -> View<G> {
view! {
TabbedView(TabState {
route: props.page_state.route.clone(),
inner: view! {
RecipeSelector()
},

View File

@ -12,12 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::components::{recipe::Recipe, tabs::*};
use crate::pages::PageState;
use sycamore::prelude::*;
pub struct RecipePageProps {
pub page_state: PageState,
pub recipe: Signal<usize>,
}
@ -25,7 +23,6 @@ pub struct RecipePageProps {
pub fn recipe_page(props: RecipePageProps) -> View<G> {
view! {
TabbedView(TabState {
route: props.page_state.route.clone(),
inner: view! {
Recipe(props.recipe.handle())
}

View File

@ -0,0 +1,191 @@
// Copyright 2022 zaphar
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::fmt::Debug;
use std::rc::Rc;
use std::str::FromStr;
use sycamore::prelude::*;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::Event;
use web_sys::{Element, HtmlAnchorElement};
use crate::app_state::AppRoutes;
use crate::console_debug;
use crate::console_error;
#[derive(Clone)]
pub struct BrowserIntegration(Signal<(String, String, String)>);
impl BrowserIntegration {
pub fn new() -> Self {
let location = web_sys::window().unwrap_throw().location();
Self(Signal::new((
location.origin().unwrap_or(String::new()),
location.pathname().unwrap_or(String::new()),
location.hash().unwrap_or(String::new()),
)))
}
pub fn click_handler(&self) -> Box<dyn Fn(web_sys::Event)> {
let route_signal = self.0.clone();
Box::new(move |ev| {
if let Some(tgt) = ev
.target()
.unwrap_throw()
.unchecked_into::<Element>()
.closest("a[href]")
.unwrap_throw()
.map(|e| e.unchecked_into::<HtmlAnchorElement>())
{
console_debug!("handling navigation event.");
let location = web_sys::window().unwrap_throw().location();
if tgt.rel() == "external" {
return;
console_debug!("External Link so ignoring.");
}
let origin = tgt.origin();
let tgt_pathname = tgt.pathname();
let hash = tgt.hash();
match (location.origin().as_ref() == Ok(&origin), location.pathname().as_ref() == Ok(&tgt_pathname), location.hash().as_ref() == Ok(&hash)) {
(true, true, true) // Same location
| (false, _, _) /* different origin */ => {
// Do nothing this is the same location as we are already at.
}
(true, _, false) // different hash
| (true, false, _) /* different path */ => {
console_debug!("different path or hash");
ev.prevent_default();
// Signal the pathname change
let path = format!("{}{}{}", &origin, &tgt_pathname, &hash);
console_debug!("new route: ({}, {}, {})", origin, tgt_pathname, hash);
console_debug!("new path: ({})", &path);
route_signal.set((origin, tgt_pathname, hash));
// Update History API.
let window = web_sys::window().unwrap_throw();
let history = window.history().unwrap_throw();
history
.push_state_with_url(&JsValue::UNDEFINED, "", Some(&path))
.unwrap_throw();
window.scroll_to_with_x_and_y(0.0, 0.0);
}
}
}
})
}
}
pub struct RouterProps<R, F, G>
where
G: GenericNode,
R: DeriveRoute + NotFound + Clone + Default + Debug + 'static,
F: Fn(ReadSignal<R>) -> View<G> + 'static,
{
pub route: R,
pub route_select: F,
pub browser_integration: BrowserIntegration,
}
#[component(Router<G>)]
pub fn router<R, F>(props: RouterProps<R, F, G>) -> View<G>
where
R: DeriveRoute + NotFound + Clone + Default + Debug + 'static,
F: Fn(ReadSignal<R>) -> View<G> + 'static,
{
console_debug!("Setting up router");
let integration = Rc::new(props.browser_integration);
let route_select = Rc::new(props.route_select);
let view_signal = Signal::new(View::empty());
create_effect(
cloned!((view_signal, integration, route_select) => move || {
let path_signal = integration.0.clone();
console_debug!("new path: {:?}", path_signal.get());
let path = path_signal.clone();
let route = R::from(path.get().as_ref());
console_debug!("new route: {:?}", &route);
// TODO(jwall): this is an unnecessary use of signal.
let view = route_select.as_ref()(Signal::new(route).handle());
register_click_handler(&view, integration.clone());
view_signal.set(view);
}),
);
// NOTE(jwall): This needs to be a dynamic node so Sycamore knows to rerender it
// based on the results of the effect above.
view! {
(view_signal.get().as_ref())
}
}
fn register_click_handler<G>(view: &View<G>, integration: Rc<BrowserIntegration>)
where
G: GenericNode<EventType = Event>,
{
console_debug!("Registring click handler on node(s)");
if let Some(node) = view.as_node() {
node.event("click", integration.click_handler());
} else if let Some(frag) = view.as_fragment() {
console_debug!("Fragment? {:?}", frag);
for n in frag {
register_click_handler(n, integration.clone());
}
} else if let Some(dyn_node) = view.as_dyn() {
console_debug!("Dynamic node? {:?}", dyn_node);
} else {
console_debug!("Unknown node? {:?}", view);
}
}
pub trait NotFound {
fn not_found() -> Self;
}
impl NotFound for AppRoutes {
fn not_found() -> Self {
AppRoutes::NotFound
}
}
pub trait DeriveRoute {
fn from(input: &(String, String, String)) -> Self;
}
impl DeriveRoute for AppRoutes {
fn from(input: &(String, String, String)) -> AppRoutes {
console_debug!("routing: {input:?}");
match input.2.as_str() {
"" => AppRoutes::default(),
"#plan" => AppRoutes::Plan,
"#cook" => AppRoutes::Cook,
"#inventory" => AppRoutes::Inventory,
h => {
// TODO(jwall): Parse the recipe hash
let parts: Vec<&str> = h.splitn(2, "/").collect();
if let Some(&"#recipe") = parts.get(0) {
if let Some(&idx) = parts.get(1) {
return match usize::from_str(idx) {
Ok(idx) => AppRoutes::Recipe(idx),
Err(e) => AppRoutes::Error(format!("{:?}", e)),
};
}
}
console_error!("Path not found: [{:?}]", input);
AppRoutes::NotFound
}
}
}
}

View File

@ -11,8 +11,9 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::{app_state::*, components::*, service::AppService};
use crate::{console_debug, console_error, console_log};
use crate::pages::*;
use crate::{app_state::*, components::*, router_integration::*, service::AppService};
use crate::{console_error, console_log};
use sycamore::{
context::{ContextProvider, ContextProviderProps},
@ -20,25 +21,33 @@ use sycamore::{
prelude::*,
};
use crate::pages::*;
fn route_switch<G: Html>(page_state: PageState) -> View<G> {
let route = page_state.route.clone();
cloned!((page_state, route) => view! {
(match route.get().as_ref() {
AppRoutes::Plan => view! {
PlanPage(PlanPageProps { page_state: page_state.clone() })
},
AppRoutes::Inventory => view! {
InventoryPage(InventoryPageProps { page_state: page_state.clone() })
},
AppRoutes::Cook => view! {
CookPage(CookPageProps { page_state: page_state.clone() })
},
AppRoutes::Recipe(idx) => view! {
RecipePage(RecipePageProps { page_state: page_state.clone(), recipe: Signal::new(*idx) })
fn route_switch<G: Html>(route: ReadSignal<AppRoutes>) -> View<G> {
// NOTE(jwall): This needs to not be a dynamic node. The rules around
// this are somewhat unclear and underdocumented for Sycamore. But basically
// avoid conditionals in the `view!` macro calls here.
cloned!((route) => match route.get().as_ref() {
AppRoutes::Plan => view! {
PlanPage()
},
AppRoutes::Inventory => view! {
InventoryPage()
},
AppRoutes::Cook => view! {
CookPage()
},
AppRoutes::Recipe(idx) => view! {
RecipePage(RecipePageProps { recipe: Signal::new(*idx) })
},
AppRoutes::NotFound => view! {
// TODO(Create a real one)
PlanPage()
},
AppRoutes::Error(ref e) => {
let e = e.clone();
view! {
"Error: " (e)
}
})
}
})
}
@ -52,11 +61,8 @@ pub fn ui() -> View<G> {
ContextProvider(ContextProviderProps {
value: app_service.clone(),
children: || {
let view = Signal::new(View::empty());
let route = Signal::new(AppRoutes::Plan);
let page_state = PageState { route: route.clone() };
create_effect(cloned!((page_state, view) => move || {
spawn_local_in_scope(cloned!((page_state, view) => {
create_effect(move || {
spawn_local_in_scope({
let mut app_service = app_service.clone();
async move {
match AppService::fetch_recipes_from_storage() {
@ -68,18 +74,18 @@ pub fn ui() -> View<G> {
}
Err(msg) => console_error!("Failed to get recipes {}", msg),
}
console_debug!("Determining route.");
view.set(route_switch(page_state.clone()));
console_debug!("Created our route view effect.");
}
}));
}));
});
});
view! {
// NOTE(jwall): The Router component *requires* there to be exactly one node as the root of this view.
// No fragments or missing nodes allowed or it will panic at runtime.
div(class="app") {
Header()
(view.get().as_ref().clone())
Router(RouterProps {
route: AppRoutes::Plan,
route_select: route_switch,
browser_integration: BrowserIntegration::new(),
})
}
}
}