Use nested routing and expand the recipe page into two

This commit is contained in:
Jeremy Wall 2022-11-07 16:47:46 -05:00
parent efbd5140a8
commit 2058e047eb
12 changed files with 301 additions and 129 deletions

View File

@ -53,6 +53,10 @@ impl Mealplan {
pub struct RecipeEntry(pub String, pub String);
impl RecipeEntry {
pub fn new<IS: Into<String>, TS: Into<String>>(recipe_id: IS, text: TS) -> Self {
Self(recipe_id.into(), text.into())
}
pub fn set_recipe_id<S: Into<String>>(&mut self, id: S) {
self.0 = id.into();
}

View File

@ -20,8 +20,8 @@ pub fn Header<G: Html>(cx: Scope) -> View<G> {
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" } }
}

View File

@ -40,9 +40,28 @@ fn check_recipe_parses(text: &str, error_text: &Signal<String>) -> bool {
}
#[component]
pub fn Editor<G: Html>(cx: Scope, recipe: RecipeEntry) -> View<G> {
let id = create_signal(cx, recipe.recipe_id().to_owned());
let text = create_signal(cx, recipe.recipe_text().to_owned());
pub fn Editor<G: Html>(cx: Scope, recipe_id: String) -> View<G> {
let store = crate::api::HttpStore::get_from_context(cx);
let recipe: &Signal<RecipeEntry> =
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<G: Html>(cx: Scope, recipe: RecipeEntry) -> View<G> {
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<G: Html>(cx: Scope, recipe: RecipeEntry) -> View<G> {
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<G: Html>(cx: Scope, recipe: RecipeEntry) -> View<G> {
}
#[component]
fn Steps<'ctx, G: Html>(cx: Scope<'ctx>, steps: &'ctx ReadSignal<Vec<recipes::Step>>) -> View<G> {
fn Steps<G: Html>(cx: Scope, steps: Vec<recipes::Step>) -> View<G> {
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<G> {
pub fn Viewer<G: Html>(cx: Scope, recipe_id: String) -> View<G> {
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<Option<RecipeEntry>> = 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<G> {
// 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<Option<RecipeEntry>> = 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())
// }
//}

View File

@ -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<G: Html>(cx: Scope) -> View<G> {
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()
}
}

View File

@ -45,7 +45,7 @@ pub fn RecipeSelection<G: Html>(cx: Scope, props: RecipeCheckBoxProps) -> View<G
);
let title = props.title.get().clone();
let for_id = id.clone();
let href = format!("/ui/recipe/{}", id);
let href = format!("/ui/recipe/view/{}", id);
let name = format!("recipe_id:{}", id);
view! {cx,
div() {

View File

@ -18,7 +18,7 @@ use tracing::debug;
pub struct TabState<'a, G: Html> {
pub children: Children<'a, G>,
pub selected: Option<String>,
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<G>
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"

View File

@ -27,9 +27,9 @@ pub struct PageState<'a, G: Html> {
pub fn ManagePage<'a, G: Html>(cx: Scope<'a>, state: PageState<'a, G>) -> View<G> {
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,

View File

@ -28,10 +28,10 @@ pub struct PageState<'a, G: Html> {
pub fn PlanningPage<'a, G: Html>(cx: Scope<'a>, state: PageState<'a, G>) -> View<G> {
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,

View File

@ -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<G: Html>(cx: Scope, props: RecipePageProps) -> View<G> {
pub fn RecipeEditPage<G: Html>(cx: Scope, props: RecipePageProps) -> View<G> {
view! {cx,
Recipe(props.recipe)
RecipePage(
selected=Some("Edit".to_owned()),
recipe=props.recipe.clone(),
) { Editor(props.recipe) }
}
}

View File

@ -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<String>,
}
#[component]
pub fn RecipePage<'ctx, G: Html>(cx: Scope<'ctx>, state: PageState<'ctx, G>) -> View<G> {
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) }
}
}

View File

@ -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<G: Html>(cx: Scope, props: RecipePageProps) -> View<G> {
view! {cx,
RecipePage(
selected=Some("View".to_owned()),
recipe=props.recipe.clone(),
) { Viewer(props.recipe) }
}
}

View File

@ -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<Routes>) -> 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.
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/<id>")]
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/<id>")]
Edit(String),
#[to("/view/<id>")]
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<G: Html>(cx: Scope) -> View<G> {
view! {cx,