From 2058e047eb20d56f817a35c802db0263211cafb4 Mon Sep 17 00:00:00 2001 From: Jeremy Wall Date: Mon, 7 Nov 2022 16:47:46 -0500 Subject: [PATCH] Use nested routing and expand the recipe page into two --- recipes/src/lib.rs | 4 + web/src/components/header.rs | 4 +- web/src/components/recipe.rs | 202 ++++++++++++-------- web/src/components/recipe_list.rs | 8 +- web/src/components/recipe_selection.rs | 2 +- web/src/components/tabs.rs | 5 +- web/src/pages/manage/mod.rs | 6 +- web/src/pages/planning/mod.rs | 8 +- web/src/pages/{recipe.rs => recipe/edit.rs} | 16 +- web/src/pages/recipe/mod.rs | 53 +++++ web/src/pages/recipe/view.rs | 30 +++ web/src/routing/mod.rs | 92 ++++++--- 12 files changed, 301 insertions(+), 129 deletions(-) rename web/src/pages/{recipe.rs => recipe/edit.rs} (64%) create mode 100644 web/src/pages/recipe/mod.rs create mode 100644 web/src/pages/recipe/view.rs diff --git a/recipes/src/lib.rs b/recipes/src/lib.rs index 3afb23b..2259b23 100644 --- a/recipes/src/lib.rs +++ b/recipes/src/lib.rs @@ -53,6 +53,10 @@ impl Mealplan { pub struct RecipeEntry(pub String, pub String); impl RecipeEntry { + pub fn new, TS: Into>(recipe_id: IS, text: TS) -> Self { + Self(recipe_id.into(), text.into()) + } + pub fn set_recipe_id>(&mut self, id: S) { self.0 = id.into(); } diff --git a/web/src/components/header.rs b/web/src/components/header.rs index 339833d..d06904d 100644 --- a/web/src/components/header.rs +++ b/web/src/components/header.rs @@ -20,8 +20,8 @@ pub fn Header(cx: Scope) -> View { nav(class="no-print") { h1(class="title") { "Kitchen" } ul { - li { a(href="/ui/plan") { "MealPlan" } } - li { a(href="/ui/categories") { "Manage" } } + li { a(href="/ui/planning/plan") { "MealPlan" } } + li { a(href="/ui/manage/categories") { "Manage" } } li { a(href="/ui/login") { "Login" } } li { a(href="https://github.com/zaphar/kitchen") { "Github" } } } diff --git a/web/src/components/recipe.rs b/web/src/components/recipe.rs index 8d974f6..4f899f4 100644 --- a/web/src/components/recipe.rs +++ b/web/src/components/recipe.rs @@ -40,9 +40,28 @@ fn check_recipe_parses(text: &str, error_text: &Signal) -> bool { } #[component] -pub fn Editor(cx: Scope, recipe: RecipeEntry) -> View { - let id = create_signal(cx, recipe.recipe_id().to_owned()); - let text = create_signal(cx, recipe.recipe_text().to_owned()); +pub fn Editor(cx: Scope, recipe_id: String) -> View { + let store = crate::api::HttpStore::get_from_context(cx); + let recipe: &Signal = + create_signal(cx, RecipeEntry::new(&recipe_id, String::new())); + let text = create_signal(cx, String::new()); + spawn_local_scoped(cx, { + let store = store.clone(); + async move { + let entry = store + .get_recipe_text(recipe_id.as_str()) + .await + .expect("Failure getting recipe"); + if let Some(entry) = entry { + text.set(entry.recipe_text().to_owned()); + recipe.set(entry); + } else { + // FIXME(jwall): Show error message for missing recipe + } + } + }); + + let id = create_memo(cx, || recipe.get().recipe_id().to_owned()); let error_text = create_signal(cx, String::new()); let save_signal = create_signal(cx, ()); let dirty = create_signal(cx, false); @@ -57,6 +76,7 @@ pub fn Editor(cx: Scope, recipe: RecipeEntry) -> View { debug!("Recipe text is changed"); spawn_local_scoped(cx, { let store = crate::api::HttpStore::get_from_context(cx); + let state = app_state::State::get_from_context(cx); async move { debug!("Attempting to save recipe"); if let Err(e) = store @@ -69,7 +89,14 @@ pub fn Editor(cx: Scope, recipe: RecipeEntry) -> View { error!(?e, "Failed to save recipe"); error_text.set(format!("{:?}", e)); } else { + // We also need to set recipe in our state dirty.set(false); + if let Ok(recipe) = recipes::parse::as_recipe(text.get_untracked().as_ref()) { + state + .recipes + .modify() + .insert(id.get_untracked().as_ref().to_owned(), recipe); + } }; } }); @@ -114,91 +141,114 @@ pub fn Editor(cx: Scope, recipe: RecipeEntry) -> View { } #[component] -fn Steps<'ctx, G: Html>(cx: Scope<'ctx>, steps: &'ctx ReadSignal>) -> View { +fn Steps(cx: Scope, steps: Vec) -> View { + let step_fragments = View::new_fragment(steps.iter().map(|step| { + let mut step = step.clone(); + let ingredient_fragments = View::new_fragment(step.ingredients.drain(0..).map(|i| { + view! {cx, + li { + (i.amt) " " (i.name) " " (i.form.as_ref().map(|f| format!("({})", f)).unwrap_or(String::new())) + } + } + }).collect()); + view! {cx, + div { + h3 { "Instructions" } + ul(class="ingredients") { + (ingredient_fragments) + } + div(class="instructions") { + (step.instructions) + } + } + } + }).collect()); view! {cx, h2 { "Steps: " } div(class="recipe_steps") { - Indexed( - iterable=steps, - view = |cx, step: recipes::Step| { view! {cx, - div { - h3 { "Instructions" } - ul(class="ingredients") { - Indexed( - iterable = create_signal(cx, step.ingredients), - view = |cx, i| { view! {cx, - li { - (i.amt) " " (i.name) " " (i.form.as_ref().map(|f| format!("({})", f)).unwrap_or(String::new())) - } - }} - ) - } - div(class="instructions") { - (step.instructions) - } - }} - } - ) + (step_fragments) } } } #[component] -pub fn Recipe<'ctx, G: Html>(cx: Scope<'ctx>, recipe_id: String) -> View { +pub fn Viewer(cx: Scope, recipe_id: String) -> View { let state = app_state::State::get_from_context(cx); - let store = crate::api::HttpStore::get_from_context(cx); let view = create_signal(cx, View::empty()); - let show_edit = create_signal(cx, false); if let Some(recipe) = state.recipes.get_untracked().get(&recipe_id) { - // FIXME(jwall): This should be create_effect rather than create_signal - let recipe_text: &Signal> = create_signal(cx, None); - spawn_local_scoped(cx, { - let store = store.clone(); - async move { - let entry = store - .get_recipe_text(recipe_id.as_str()) - .await - .expect("Failure getting recipe"); - recipe_text.set(entry); - } - }); - let recipe = create_signal(cx, recipe.clone()); - let title = create_memo(cx, move || recipe.get().title.clone()); - let desc = create_memo(cx, move || { - recipe - .clone() - .get() - .desc - .clone() - .unwrap_or_else(|| String::new()) - }); - let steps = create_memo(cx, move || recipe.get().steps.clone()); - create_effect(cx, move || { - debug!("Choosing edit or view for recipe."); - if *show_edit.get() { - { - debug!("Showing editor for recipe."); - view.set(view! {cx, - Editor(recipe_text.get().as_ref().clone().unwrap()) - }); - } - } else { - debug!("Showing text for recipe."); - view.set(view! {cx, - div(class="recipe") { - h1(class="recipe_title") { (title.get()) } - div(class="recipe_description") { - (desc.get()) - } - Steps(steps) - } - }); + let title = recipe.title.clone(); + let desc = recipe.desc.clone().unwrap_or_else(|| String::new()); + let steps = recipe.steps.clone(); + debug!("Viewing recipe."); + view.set(view! {cx, + div(class="recipe") { + h1(class="recipe_title") { (title) } + div(class="recipe_description") { + (desc) + } + Steps(steps) } }); } - view! {cx, - span(role="button", on:click=move |_| { show_edit.set(true); }) { "Edit" } " " - span(role="button", on:click=move |_| { show_edit.set(false); }) { "View" } - (view.get().as_ref()) - } + view! {cx, (view.get().as_ref()) } } + +//#[component] +//pub fn Recipe<'ctx, G: Html>(cx: Scope<'ctx>, recipe_id: String) -> View { +// let state = app_state::State::get_from_context(cx); +// let store = crate::api::HttpStore::get_from_context(cx); +// let view = create_signal(cx, View::empty()); +// let show_edit = create_signal(cx, false); +// if let Some(recipe) = state.recipes.get_untracked().get(&recipe_id) { +// // FIXME(jwall): This should be create_effect rather than create_signal +// let recipe_text: &Signal> = create_signal(cx, None); +// spawn_local_scoped(cx, { +// let store = store.clone(); +// async move { +// let entry = store +// .get_recipe_text(recipe_id.as_str()) +// .await +// .expect("Failure getting recipe"); +// recipe_text.set(entry); +// } +// }); +// let recipe = create_signal(cx, recipe.clone()); +// let title = create_memo(cx, move || recipe.get().title.clone()); +// let desc = create_memo(cx, move || { +// recipe +// .clone() +// .get() +// .desc +// .clone() +// .unwrap_or_else(|| String::new()) +// }); +// let steps = create_memo(cx, move || recipe.get().steps.clone()); +// create_effect(cx, move || { +// debug!("Choosing edit or view for recipe."); +// if *show_edit.get() { +// { +// debug!("Showing editor for recipe."); +// view.set(view! {cx, +// Editor(recipe_text.get().as_ref().clone().unwrap()) +// }); +// } +// } else { +// debug!("Showing text for recipe."); +// view.set(view! {cx, +// div(class="recipe") { +// h1(class="recipe_title") { (title.get()) } +// div(class="recipe_description") { +// (desc.get()) +// } +// Steps(steps) +// } +// }); +// } +// }); +// } +// view! {cx, +// span(role="button", on:click=move |_| { show_edit.set(true); }) { "Edit" } " " +// span(role="button", on:click=move |_| { show_edit.set(false); }) { "View" } +// (view.get().as_ref()) +// } +//} diff --git a/web/src/components/recipe_list.rs b/web/src/components/recipe_list.rs index 26b54f0..3cc3ffe 100644 --- a/web/src/components/recipe_list.rs +++ b/web/src/components/recipe_list.rs @@ -11,7 +11,7 @@ // 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::Recipe}; +use crate::{app_state, components::recipe::Viewer}; use sycamore::prelude::*; use tracing::{debug, instrument}; @@ -26,10 +26,10 @@ pub fn RecipeList(cx: Scope) -> View { div() { Indexed( iterable=menu_list, - view= |cx, (idx, _count)| { - debug!(idx=%idx, "Rendering recipe"); + view= |cx, (id, _count)| { + debug!(id=%id, "Rendering recipe"); view ! {cx, - Recipe(idx) + Viewer(id) hr() } } diff --git a/web/src/components/recipe_selection.rs b/web/src/components/recipe_selection.rs index f68887a..ade45ec 100644 --- a/web/src/components/recipe_selection.rs +++ b/web/src/components/recipe_selection.rs @@ -45,7 +45,7 @@ pub fn RecipeSelection(cx: Scope, props: RecipeCheckBoxProps) -> View { pub children: Children<'a, G>, pub selected: Option, - tablist: Vec<(&'static str, &'static str)>, + tablist: Vec<(String, &'static str)>, } #[component] @@ -32,7 +32,8 @@ pub fn TabbedView<'a, G: Html>(cx: Scope<'a>, state: TabState<'a, G>) -> View let menu = View::new_fragment( tablist .iter() - .map(|&(href, show)| { + .map(|&(ref href, show)| { + let href = href.clone(); debug!(?selected, show, "identifying tab"); let class = if selected.as_ref().map_or(false, |selected| selected == show) { "no-print selected" diff --git a/web/src/pages/manage/mod.rs b/web/src/pages/manage/mod.rs index d3aa03d..47c0ab3 100644 --- a/web/src/pages/manage/mod.rs +++ b/web/src/pages/manage/mod.rs @@ -27,9 +27,9 @@ pub struct PageState<'a, G: Html> { pub fn ManagePage<'a, G: Html>(cx: Scope<'a>, state: PageState<'a, G>) -> View { let PageState { children, selected } = state; let children = children.call(cx); - let manage_tabs: Vec<(&'static str, &'static str)> = vec![ - ("/ui/categories", "Categories"), - ("/ui/new_recipe", "New Recipe"), + let manage_tabs: Vec<(String, &'static str)> = vec![ + ("/ui/manage/categories".to_owned(), "Categories"), + ("/ui/manage/new_recipe".to_owned(), "New Recipe"), ]; view! {cx, diff --git a/web/src/pages/planning/mod.rs b/web/src/pages/planning/mod.rs index 3bd9f15..927659f 100644 --- a/web/src/pages/planning/mod.rs +++ b/web/src/pages/planning/mod.rs @@ -28,10 +28,10 @@ pub struct PageState<'a, G: Html> { pub fn PlanningPage<'a, G: Html>(cx: Scope<'a>, state: PageState<'a, G>) -> View { let PageState { children, selected } = state; let children = children.call(cx); - let planning_tabs: Vec<(&'static str, &'static str)> = vec![ - ("/ui/plan", "Plan"), - ("/ui/inventory", "Inventory"), - ("/ui/cook", "Cook"), + let planning_tabs: Vec<(String, &'static str)> = vec![ + ("/ui/planning/plan".to_owned(), "Plan"), + ("/ui/planning/inventory".to_owned(), "Inventory"), + ("/ui/planning/cook".to_owned(), "Cook"), ]; view! {cx, diff --git a/web/src/pages/recipe.rs b/web/src/pages/recipe/edit.rs similarity index 64% rename from web/src/pages/recipe.rs rename to web/src/pages/recipe/edit.rs index 6a60459..841dc35 100644 --- a/web/src/pages/recipe.rs +++ b/web/src/pages/recipe/edit.rs @@ -1,4 +1,4 @@ -// Copyright 2022 Jeremy Wall (jeremy@marzhillstudios.com) +// Copyright 2022 Jeremy Wall (Jeremy@marzhilsltudios.com) // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -11,20 +11,20 @@ // 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::components::recipe::Recipe; +use crate::components::recipe::Editor; use sycamore::prelude::*; use tracing::instrument; -#[derive(Debug, Props)] -pub struct RecipePageProps { - pub recipe: String, -} +use super::{RecipePage, RecipePageProps}; #[instrument] #[component()] -pub fn RecipePage(cx: Scope, props: RecipePageProps) -> View { +pub fn RecipeEditPage(cx: Scope, props: RecipePageProps) -> View { view! {cx, - Recipe(props.recipe) + RecipePage( + selected=Some("Edit".to_owned()), + recipe=props.recipe.clone(), + ) { Editor(props.recipe) } } } diff --git a/web/src/pages/recipe/mod.rs b/web/src/pages/recipe/mod.rs new file mode 100644 index 0000000..f137c8c --- /dev/null +++ b/web/src/pages/recipe/mod.rs @@ -0,0 +1,53 @@ +// Copyright 2022 Jeremy Wall (jeremy@marzhillstudios.com) +// +// 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 sycamore::prelude::*; + +use crate::components::tabs::*; + +mod edit; +mod view; +pub use edit::*; +pub use view::*; + +#[derive(Debug, Props)] +pub struct RecipePageProps { + pub recipe: String, +} + +#[derive(Props)] +pub struct PageState<'a, G: Html> { + pub recipe: String, + pub children: Children<'a, G>, + pub selected: Option, +} + +#[component] +pub fn RecipePage<'ctx, G: Html>(cx: Scope<'ctx>, state: PageState<'ctx, G>) -> View { + let PageState { + children, + selected, + recipe, + } = state; + let children = children.call(cx); + let recipe_tabs: Vec<(String, &'static str)> = vec![ + (format!("/ui/recipe/view/{}", recipe), "View"), + (format!("/ui/recipe/edit/{}", recipe), "Edit"), + ]; + view! {cx, + TabbedView( + selected= selected, + tablist=recipe_tabs, + ) { (children) } + } +} diff --git a/web/src/pages/recipe/view.rs b/web/src/pages/recipe/view.rs new file mode 100644 index 0000000..8685608 --- /dev/null +++ b/web/src/pages/recipe/view.rs @@ -0,0 +1,30 @@ +// Copyright 2022 Jeremy Wall (Jeremy@marzhilsltudios.com) +// +// 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 crate::components::recipe::Viewer; + +use sycamore::prelude::*; +use tracing::instrument; + +use super::{RecipePage, RecipePageProps}; + +#[instrument] +#[component()] +pub fn RecipeViewPage(cx: Scope, props: RecipePageProps) -> View { + view! {cx, + RecipePage( + selected=Some("View".to_owned()), + recipe=props.recipe.clone(), + ) { Viewer(props.recipe) } + } +} diff --git a/web/src/routing/mod.rs b/web/src/routing/mod.rs index 302f8e3..287a8f4 100644 --- a/web/src/routing/mod.rs +++ b/web/src/routing/mod.rs @@ -13,71 +13,105 @@ // limitations under the License. use sycamore::prelude::*; -//use sycamore_router::{HistoryIntegration, Route, Router}; use sycamore_router::{HistoryIntegration, Route, Router}; -use tracing::instrument; +use tracing::{debug, instrument}; use crate::pages::*; -//mod router; -//use router::{HistoryIntegration, Router}; - #[instrument] fn route_switch<'a, G: Html>(cx: Scope<'a>, route: &'a ReadSignal) -> View { // 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. - view! {cx, - (match route.get().as_ref() { - Routes::Plan => view! {cx, + + let switcher = |cx: Scope, route: &Routes| { + debug!(?route, "Dispatching for route"); + match route { + Routes::Planning(Plan) => view! {cx, PlanPage() }, - Routes::Inventory => view! {cx, + Routes::Planning(Inventory) => view! {cx, InventoryPage() }, + Routes::Planning(Cook) => view! {cx, + CookPage() + }, Routes::Login => view! {cx, LoginPage() }, - Routes::Cook => view! {cx, - CookPage() + Routes::Recipe(RecipeRoutes::View(id)) => view! {cx, + RecipeViewPage(recipe=id.clone()) }, - Routes::Recipe(idx) => view! {cx, - RecipePage(recipe=idx.clone()) + Routes::Recipe(RecipeRoutes::Edit(id)) => view! {cx, + RecipeEditPage(recipe=id.clone()) }, - Routes::Categories => view! {cx, + Routes::Manage(ManageRoutes::Categories) => view! {cx, CategoryPage() }, - Routes::NewRecipe => view! {cx, + Routes::Manage(ManageRoutes::NewRecipe) => view! {cx, AddRecipePage() }, - Routes::NotFound => view! {cx, + Routes::NotFound + | Routes::Manage(ManageRoutes::NotFound) + | Routes::Planning(PlanningRoutes::NotFound) + | Routes::Recipe(RecipeRoutes::NotFound) => view! {cx, // TODO(Create a real one) PlanPage() }, - }) + } + }; + use PlanningRoutes::*; + view! {cx, + (switcher(cx, route.get().as_ref())) } } #[derive(Route, Debug)] pub enum Routes { - #[to("/ui/plan")] - Plan, - #[to("/ui/inventory")] - Inventory, - #[to("/ui/cook")] - Cook, - #[to("/ui/recipe/")] - Recipe(String), - #[to("/ui/new_recipe")] - NewRecipe, - #[to("/ui/categories")] - Categories, + #[to("/ui/planning/<_..>")] + Planning(PlanningRoutes), + #[to("/ui/recipe/<_..>")] + Recipe(RecipeRoutes), + #[to("/ui/manage/<_..>")] + Manage(ManageRoutes), #[to("/ui/login")] Login, #[not_found] NotFound, } +#[derive(Route, Debug)] +pub enum RecipeRoutes { + #[to("/edit/")] + Edit(String), + #[to("/view/")] + View(String), + #[not_found] + NotFound, +} + +#[derive(Route, Debug)] +pub enum ManageRoutes { + #[to("/new_recipe")] + NewRecipe, + #[to("/categories")] + Categories, + #[not_found] + NotFound, +} + +#[derive(Route, Debug)] +pub enum PlanningRoutes { + #[to("/plan")] + Plan, + #[to("/inventory")] + Inventory, + #[to("/cook")] + Cook, + #[not_found] + NotFound, +} + #[component] pub fn Handler(cx: Scope) -> View { view! {cx,