Refactor state management and http APIs

This commit is contained in:
Jeremy Wall 2022-10-21 14:57:50 -04:00
parent dd7ad63cfb
commit e77af193aa
23 changed files with 562 additions and 530 deletions

16
Cargo.lock generated
View File

@ -1187,7 +1187,6 @@ dependencies = [
"cookie",
"csv",
"mime_guess",
"recipe-store",
"recipes",
"rust-embed",
"secrecy",
@ -1207,7 +1206,6 @@ dependencies = [
"async-trait",
"base64",
"console_error_panic_hook",
"recipe-store",
"recipes",
"reqwasm",
"serde_json",
@ -1570,19 +1568,6 @@ dependencies = [
"getrandom",
]
[[package]]
name = "recipe-store"
version = "0.2.10"
dependencies = [
"async-std",
"async-trait",
"recipes",
"reqwasm",
"serde",
"serde_json",
"tracing",
]
[[package]]
name = "recipes"
version = "0.2.10"
@ -1591,6 +1576,7 @@ dependencies = [
"abortable_parser",
"chrono",
"num-rational",
"serde",
]
[[package]]

View File

@ -1,5 +1,5 @@
[workspace]
members = [ "recipes", "kitchen", "web", "recipe-store"]
members = [ "recipes", "kitchen", "web" ]
[patch.crates-io]
# TODO(jwall): When the fix for RcSignal Binding is released we can drop this patch.

View File

@ -10,7 +10,6 @@ edition = "2021"
tracing = "0.1.35"
tracing-subscriber = "0.3.14"
recipes = { path = "../recipes" }
recipe-store = {path = "../recipe-store" }
csv = "1.1.1"
rust-embed="6.4.0"
mime_guess = "2.0.4"

View File

@ -18,6 +18,30 @@
},
"query": "select password_hashed from users where id = ?"
},
"196e289cbd65224293c4213552160a0cdf82f924ac597810fe05102e247b809d": {
"describe": {
"columns": [
{
"name": "recipe_id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "recipe_text",
"ordinal": 1,
"type_info": "Text"
}
],
"nullable": [
false,
true
],
"parameters": {
"Right": 2
}
},
"query": "select recipe_id, recipe_text from recipes where user_id = ? and recipe_id = ?"
},
"3fd4017569dca4fe73e97e0e2bd612027a8c1b17b0b10faabd6459f56ca1c0bb": {
"describe": {
"columns": [],

View File

@ -23,7 +23,7 @@ use axum::{
routing::{get, Router},
};
use mime_guess;
use recipe_store::{self, RecipeEntry, RecipeStore};
use recipes::RecipeEntry;
use rust_embed::RustEmbed;
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
@ -83,9 +83,34 @@ async fn ui_static_assets(Path(path): Path<String>) -> impl IntoResponse {
StaticFile(path.to_owned())
}
#[instrument]
async fn api_recipe_entry(
Extension(store): Extension<Arc<storage::file_store::AsyncFileStore>>,
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
Path(recipe_id): Path<String>,
) -> impl IntoResponse {
use storage::{UserId, UserIdFromSession::*};
let result = match session {
NoUserId => store
.get_recipe_entry(recipe_id)
.await
.map_err(|e| format!("Error: {:?}", e)),
FoundUserId(UserId(id)) => app_store
.get_recipe_entry_for_user(id, recipe_id)
.await
.map_err(|e| format!("Error: {:?}", e)),
};
match result {
Ok(Some(recipes)) => (StatusCode::OK, axum::Json::from(recipes)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, axum::Json::from("")).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, axum::Json::from(e)).into_response(),
}
}
#[instrument]
async fn api_recipes(
Extension(store): Extension<Arc<recipe_store::AsyncFileStore>>,
Extension(store): Extension<Arc<storage::file_store::AsyncFileStore>>,
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
) -> impl IntoResponse {
@ -110,7 +135,7 @@ async fn api_recipes(
#[instrument]
async fn api_categories(
Extension(store): Extension<Arc<recipe_store::AsyncFileStore>>,
Extension(store): Extension<Arc<storage::file_store::AsyncFileStore>>,
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
) -> impl IntoResponse {
@ -156,7 +181,7 @@ async fn api_save_categories(
}
}
async fn api_save_recipe(
async fn api_save_recipes(
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
Json(recipes): Json<Vec<RecipeEntry>>,
@ -180,7 +205,9 @@ async fn api_save_recipe(
#[instrument(fields(recipe_dir=?recipe_dir_path,listen=?listen_socket), skip_all)]
pub async fn ui_main(recipe_dir_path: PathBuf, store_path: PathBuf, listen_socket: SocketAddr) {
let store = Arc::new(recipe_store::AsyncFileStore::new(recipe_dir_path.clone()));
let store = Arc::new(storage::file_store::AsyncFileStore::new(
recipe_dir_path.clone(),
));
//let dir_path = (&dir_path).clone();
let app_store = Arc::new(
storage::SqliteStore::new(store_path)
@ -191,7 +218,9 @@ pub async fn ui_main(recipe_dir_path: PathBuf, store_path: PathBuf, listen_socke
.route("/", get(|| async { Redirect::temporary("/ui/plan") }))
.route("/ui/*path", get(ui_static_assets))
// recipes api path route
.route("/api/v1/recipes", get(api_recipes).post(api_save_recipe))
.route("/api/v1/recipes", get(api_recipes).post(api_save_recipes))
// recipe entry api path route
.route("/api/v1/recipe/:recipe_id", get(api_recipe_entry))
// categories api path route
.route(
"/api/v1/categories",
@ -237,7 +266,7 @@ pub async fn add_user(
.await
.expect("Failed to store user creds");
if let Some(path) = recipe_dir_path {
let store = recipe_store::AsyncFileStore::new(path);
let store = storage::file_store::AsyncFileStore::new(path);
if let Some(recipes) = store
.get_recipes()
.await

View File

@ -17,11 +17,11 @@ use async_std::{
path::PathBuf,
stream::StreamExt,
};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tracing::warn;
use tracing::{debug, instrument};
use super::RecipeEntry;
#[derive(Debug)]
pub struct Error(String);
@ -43,35 +43,6 @@ impl From<std::string::FromUtf8Error> for Error {
}
}
pub trait TenantStoreFactory<S>
where
S: RecipeStore,
{
fn get_user_store(&self, user: String) -> S;
}
#[derive(Serialize, Deserialize, Clone)]
pub struct RecipeEntry(pub String, pub String);
impl RecipeEntry {
pub fn recipe_id(&self) -> &str {
self.0.as_str()
}
pub fn recipe_text(&self) -> &str {
self.1.as_str()
}
}
#[async_trait]
/// Define the shared interface to use for interacting with a store of recipes.
pub trait RecipeStore: Clone + Sized {
/// Get categories text unparsed.
async fn get_categories(&self) -> Result<Option<String>, Error>;
/// Get list of recipe text unparsed.
async fn get_recipes(&self) -> Result<Option<Vec<RecipeEntry>>, Error>;
}
#[derive(Clone, Debug)]
pub struct AsyncFileStore {
path: PathBuf,
@ -83,11 +54,19 @@ impl AsyncFileStore {
}
}
#[async_trait]
impl AsyncFileStore {
fn get_recipe_path_root(&self) -> PathBuf {
let mut recipe_path = PathBuf::new();
recipe_path.push(&self.path);
recipe_path.push("recipes");
recipe_path
}
}
// TODO(jwall): We need to model our own set of errors for this.
impl RecipeStore for AsyncFileStore {
impl AsyncFileStore {
#[instrument(skip_all)]
async fn get_categories(&self) -> Result<Option<String>, Error> {
pub async fn get_categories(&self) -> Result<Option<String>, Error> {
let mut category_path = PathBuf::new();
category_path.push(&self.path);
category_path.push("categories.txt");
@ -99,7 +78,7 @@ impl RecipeStore for AsyncFileStore {
Ok(Some(String::from_utf8(contents)?))
}
async fn get_recipes(&self) -> Result<Option<Vec<RecipeEntry>>, Error> {
pub async fn get_recipes(&self) -> Result<Option<Vec<RecipeEntry>>, Error> {
let mut recipe_path = PathBuf::new();
recipe_path.push(&self.path);
recipe_path.push("recipes");
@ -129,4 +108,19 @@ impl RecipeStore for AsyncFileStore {
}
Ok(Some(entry_vec))
}
pub async fn get_recipe_entry<S: AsRef<str> + Send>(
&self,
id: S,
) -> Result<Option<RecipeEntry>, Error> {
let mut recipe_path = self.get_recipe_path_root();
recipe_path.push(id.as_ref());
if recipe_path.exists().await && recipe_path.is_file().await {
debug!("Found recipe file {}", recipe_path.to_string_lossy());
let recipe_contents = read_to_string(recipe_path).await?;
return Ok(Some(RecipeEntry(id.as_ref().to_owned(), recipe_contents)));
} else {
return Ok(None);
}
}
}

View File

@ -27,6 +27,7 @@ use axum::{
http::StatusCode,
};
use ciborium;
use recipes::RecipeEntry;
use secrecy::{ExposeSecret, Secret};
use serde::{Deserialize, Serialize};
use sqlx::{
@ -36,14 +37,14 @@ use sqlx::{
};
use tracing::{debug, error, info, instrument};
use recipe_store::RecipeEntry;
mod error;
pub mod file_store;
pub use error::*;
pub const AXUM_SESSION_COOKIE_NAME: &'static str = "kitchen-session-cookie";
// TODO(jwall): Should this move to the recipe crate?
#[derive(Debug, Serialize, Deserialize)]
pub struct UserId(pub String);
@ -93,6 +94,12 @@ pub trait APIStore {
-> Result<()>;
async fn store_categories_for_user(&self, user_id: &str, categories: &str) -> Result<()>;
async fn get_recipe_entry_for_user<S: AsRef<str> + Send>(
&self,
user_id: S,
id: S,
) -> Result<Option<RecipeEntry>>;
}
#[async_trait]
@ -271,6 +278,40 @@ impl APIStore for SqliteStore {
}
}
async fn get_recipe_entry_for_user<S: AsRef<str> + Send>(
&self,
user_id: S,
id: S,
) -> Result<Option<RecipeEntry>> {
// NOTE(jwall): We allow dead code becaue Rust can't figure out that
// this code is actually constructed but it's done via the query_as
// macro.
#[allow(dead_code)]
struct RecipeRow {
pub recipe_id: String,
pub recipe_text: Option<String>,
}
let id = id.as_ref();
let user_id = user_id.as_ref();
let entry = sqlx::query_as!(
RecipeRow,
"select recipe_id, recipe_text from recipes where user_id = ? and recipe_id = ?",
user_id,
id,
)
.fetch_all(self.pool.as_ref())
.await?
.iter()
.map(|row| {
RecipeEntry(
row.recipe_id.clone(),
row.recipe_text.clone().unwrap_or_else(|| String::new()),
)
})
.nth(0);
Ok(entry)
}
async fn get_recipes_for_user(&self, user_id: &str) -> Result<Option<Vec<RecipeEntry>>> {
// NOTE(jwall): We allow dead code becaue Rust can't figure out that
// this code is actually constructed but it's done via the query_as

View File

@ -1,15 +0,0 @@
[package]
name = "recipe-store"
version = "0.2.10"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
recipes = {path = "../recipes" }
async-trait = "0.1.57"
async-std = "1.10.0"
tracing = "0.1.35"
reqwasm = "0.5.0"
serde_json = "1.0.79"
serde = "1.0.143"

View File

@ -9,6 +9,7 @@ edition = "2021"
[dependencies]
abortable_parser = "~0.2.6"
chrono = "~0.4"
serde = "1.0.144"
[dependencies.num-rational]
version = "~0.4.0"

View File

@ -17,6 +17,7 @@ pub mod unit;
use std::collections::{BTreeMap, BTreeSet};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use unit::*;
use Measure::*;
@ -48,6 +49,19 @@ impl Mealplan {
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct RecipeEntry(pub String, pub String);
impl RecipeEntry {
pub fn recipe_id(&self) -> &str {
self.0.as_str()
}
pub fn recipe_text(&self) -> &str {
self.1.as_str()
}
}
/// A Recipe with a title, description, and a series of steps.
#[derive(Debug, Clone, PartialEq, PartialOrd, Ord, Eq)]
pub struct Recipe {

View File

@ -14,7 +14,6 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
recipes = { path = "../recipes" }
recipe-store = { path = "../recipe-store" }
# This makes debugging panics more tractable.
console_error_panic_hook = "0.1.7"
serde_json = "1.0.79"

219
web/src/api.rs Normal file
View File

@ -0,0 +1,219 @@
// Copyright 2022 Jeremy Wall
//
// 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::collections::BTreeMap;
use reqwasm;
//use serde::{Deserialize, Serialize};
use serde_json::to_string;
use sycamore::prelude::*;
use tracing::{debug, error, info, instrument, warn};
use recipes::{parse, Recipe, RecipeEntry};
use crate::{app_state, js_lib};
#[instrument]
fn filter_recipes(
recipe_entries: &Option<Vec<RecipeEntry>>,
) -> Result<(Option<Recipe>, Option<BTreeMap<String, Recipe>>), String> {
match recipe_entries {
Some(parsed) => {
let mut staples = None;
let mut parsed_map = BTreeMap::new();
for r in parsed {
let recipe = match parse::as_recipe(&r.recipe_text()) {
Ok(r) => r,
Err(e) => {
error!("Error parsing recipe {}", e);
continue;
}
};
if recipe.title == "Staples" {
staples = Some(recipe);
} else {
parsed_map.insert(r.recipe_id().to_owned(), recipe);
}
}
Ok((staples, Some(parsed_map)))
}
None => Ok((None, None)),
}
}
#[instrument(skip(state))]
pub async fn init_page_state(store: &HttpStore, state: &app_state::State) -> Result<(), String> {
info!("Synchronizing Recipes");
// TODO(jwall): Make our caching logic using storage more robust.
let recipes = store.get_recipes().await.map_err(|e| format!("{:?}", e))?;
if let Ok((staples, recipes)) = filter_recipes(&recipes) {
state.staples.set(staples);
if let Some(recipes) = recipes {
state.recipes.set(recipes);
}
}
if let Some(rs) = recipes {
for r in rs {
if !state.recipe_counts.get().contains_key(r.recipe_id()) {
state.set_recipe_count_by_index(&r.recipe_id().to_owned(), 0);
}
}
}
info!("Synchronizing categories");
match store.get_categories().await {
Ok(Some(categories_content)) => {
debug!(categories=?categories_content);
let category_map = recipes::parse::as_categories(&categories_content)?;
state.category_map.set(category_map);
}
Ok(None) => {
warn!("There is no category file");
}
Err(e) => {
error!("{:?}", e);
}
}
Ok(())
}
#[derive(Debug)]
pub struct Error(String);
impl From<std::io::Error> for Error {
fn from(item: std::io::Error) -> Self {
Error(format!("{:?}", item))
}
}
impl From<Error> for String {
fn from(item: Error) -> Self {
format!("{:?}", item)
}
}
impl From<String> for Error {
fn from(item: String) -> Self {
Error(item)
}
}
impl From<std::string::FromUtf8Error> for Error {
fn from(item: std::string::FromUtf8Error) -> Self {
Error(format!("{:?}", item))
}
}
impl From<reqwasm::Error> for Error {
fn from(item: reqwasm::Error) -> Self {
Error(format!("{:?}", item))
}
}
#[derive(Clone, Debug)]
pub struct HttpStore {
root: String,
}
impl HttpStore {
pub fn new(root: String) -> Self {
Self { root }
}
pub fn provide_context<S: Into<String>>(cx: Scope, root: S) {
provide_context(cx, std::rc::Rc::new(Self::new(root.into())));
}
pub fn get_from_context(cx: Scope) -> std::rc::Rc<Self> {
use_context::<std::rc::Rc<Self>>(cx).clone()
}
#[instrument]
pub async fn get_categories(&self) -> Result<Option<String>, Error> {
let mut path = self.root.clone();
path.push_str("/categories");
let resp = reqwasm::http::Request::get(&path).send().await?;
if resp.status() == 404 {
debug!("Categories returned 404");
Ok(None)
} else if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into())
} else {
debug!("We got a valid response back!");
let resp = resp.text().await;
Ok(Some(resp.map_err(|e| format!("{}", e))?))
}
}
#[instrument]
pub async fn get_recipes(&self) -> Result<Option<Vec<RecipeEntry>>, Error> {
let mut path = self.root.clone();
path.push_str("/recipes");
let resp = reqwasm::http::Request::get(&path).send().await?;
if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into())
} else {
debug!("We got a valid response back!");
Ok(resp.json().await.map_err(|e| format!("{}", e))?)
}
}
pub async fn get_recipe_text<S: AsRef<str>>(
&self,
id: S,
) -> Result<Option<RecipeEntry>, Error> {
let mut path = self.root.clone();
path.push_str("/recipe");
path.push_str(id.as_ref());
let resp = reqwasm::http::Request::get(&path).send().await?;
if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into())
} else {
debug!("We got a valid response back!");
Ok(resp.json().await.map_err(|e| format!("{}", e))?)
}
}
#[instrument(skip(recipes), fields(count=recipes.len()))]
pub async fn save_recipes(&self, recipes: Vec<RecipeEntry>) -> Result<(), Error> {
let mut path = self.root.clone();
path.push_str("/recipes");
let resp = reqwasm::http::Request::post(&path)
.body(to_string(&recipes).expect("Unable to serialize recipe entries"))
.header("content-type", "application/json")
.send()
.await?;
if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into())
} else {
debug!("We got a valid response back!");
Ok(())
}
}
#[instrument(skip(categories))]
pub async fn save_categories(&self, categories: String) -> Result<(), Error> {
let mut path = self.root.clone();
path.push_str("/categories");
let resp = reqwasm::http::Request::post(&path)
.body(to_string(&categories).expect("Unable to encode categories as json"))
.header("content-type", "application/json")
.send()
.await?;
if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into())
} else {
debug!("We got a valid response back!");
Ok(())
}
}
}

View File

@ -11,9 +11,15 @@
// 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::collections::{BTreeMap, BTreeSet};
use sycamore::prelude::*;
use tracing::{debug, instrument, warn};
use recipes::{Ingredient, IngredientAccumulator, Recipe};
#[derive(Debug, Clone)]
pub enum AppRoutes {
pub enum Routes {
Plan,
Inventory,
Cook,
@ -23,8 +29,96 @@ pub enum AppRoutes {
NotFound,
}
impl Default for AppRoutes {
impl Default for Routes {
fn default() -> Self {
Self::Plan
}
}
pub struct State {
pub recipe_counts: RcSignal<BTreeMap<String, usize>>,
pub staples: RcSignal<Option<Recipe>>,
pub recipes: RcSignal<BTreeMap<String, Recipe>>,
pub category_map: RcSignal<BTreeMap<String, String>>,
}
impl State {
pub fn new() -> Self {
Self {
recipe_counts: create_rc_signal(BTreeMap::new()),
staples: create_rc_signal(None),
recipes: create_rc_signal(BTreeMap::new()),
category_map: create_rc_signal(BTreeMap::new()),
}
}
pub fn provide_context(cx: Scope) {
provide_context(cx, std::rc::Rc::new(Self::new()));
}
pub fn get_from_context(cx: Scope) -> std::rc::Rc<Self> {
use_context::<std::rc::Rc<Self>>(cx).clone()
}
pub fn get_menu_list(&self) -> Vec<(String, usize)> {
self.recipe_counts
.get()
.iter()
.map(|(k, v)| (k.clone(), *v))
.filter(|(_, v)| *v != 0)
.collect()
}
#[instrument(skip(self))]
pub fn get_shopping_list(
&self,
show_staples: bool,
) -> BTreeMap<String, Vec<(Ingredient, BTreeSet<String>)>> {
let mut acc = IngredientAccumulator::new();
let recipe_counts = self.get_menu_list();
for (idx, count) in recipe_counts.iter() {
for _ in 0..*count {
acc.accumulate_from(
self.recipes
.get()
.get(idx)
.expect(&format!("No such recipe id exists: {}", idx)),
);
}
}
if show_staples {
if let Some(staples) = self.staples.get().as_ref() {
acc.accumulate_from(staples);
}
}
let mut ingredients = acc.ingredients();
let mut groups = BTreeMap::new();
let cat_map = self.category_map.get().clone();
for (_, (i, recipes)) in ingredients.iter_mut() {
let category = if let Some(cat) = cat_map.get(&i.name) {
cat.clone()
} else {
"other".to_owned()
};
i.category = category.clone();
groups
.entry(category)
.or_insert(vec![])
.push((i.clone(), recipes.clone()));
}
debug!(?self.category_map);
// FIXME(jwall): Sort by categories and names.
groups
}
pub fn get_recipe_count_by_index(&self, key: &String) -> Option<usize> {
self.recipe_counts.get().get(key).cloned()
}
pub fn set_recipe_count_by_index(&self, key: &String, count: usize) -> usize {
let mut counts = self.recipe_counts.get().as_ref().clone();
counts.insert(key.clone(), count);
self.recipe_counts.set(counts);
count
}
}

View File

@ -11,14 +11,13 @@
// 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 serde_json::from_str;
use sycamore::{futures::spawn_local_scoped, prelude::*};
use tracing::{debug, error, instrument};
use web_sys::HtmlDialogElement;
use recipes::parse;
use crate::{js_lib::get_element_by_id, service::AppService};
use crate::js_lib::get_element_by_id;
fn get_error_dialog() -> HtmlDialogElement {
get_element_by_id::<HtmlDialogElement>("error-dialog")
@ -42,29 +41,30 @@ fn check_category_text_parses(unparsed: &str, error_text: &Signal<String>) -> bo
#[instrument]
#[component]
pub fn Categories<G: Html>(cx: Scope) -> View<G> {
let app_service = use_context::<AppService>(cx);
let save_signal = create_signal(cx, ());
let error_text = create_signal(cx, String::new());
let category_text = create_signal(
cx,
match app_service
.get_category_text()
.expect("Failed to get categories.")
{
Some(js) => from_str::<String>(&js)
.map_err(|e| format!("{}", e))
.expect("Failed to parse categories as json"),
None => String::new(),
},
);
let category_text: &Signal<String> = create_signal(cx, String::new());
spawn_local_scoped(cx, {
let store = crate::api::HttpStore::get_from_context(cx);
async move {
if let Some(js) = store
.get_categories()
.await
.expect("Failed to get categories.")
{
category_text.set(js);
};
}
});
create_effect(cx, move || {
// TODO(jwall): This is triggering on load which is not desired.
save_signal.track();
spawn_local_scoped(cx, {
let store = crate::api::HttpStore::get_from_context(cx);
async move {
// TODO(jwall): Save the categories.
if let Err(e) = app_service
if let Err(e) = store
.save_categories(category_text.get_untracked().as_ref().clone())
.await
{

View File

@ -15,9 +15,8 @@ use sycamore::{futures::spawn_local_scoped, prelude::*};
use tracing::{debug, error};
use web_sys::HtmlDialogElement;
use crate::{js_lib::get_element_by_id, service::AppService};
use recipe_store::RecipeEntry;
use recipes;
use crate::{app_state, js_lib::get_element_by_id};
use recipes::{self, RecipeEntry};
fn get_error_dialog() -> HtmlDialogElement {
get_element_by_id::<HtmlDialogElement>("error-dialog")
@ -45,15 +44,15 @@ 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());
let error_text = create_signal(cx, String::new());
let app_service = use_context::<AppService>(cx);
let save_signal = create_signal(cx, ());
create_effect(cx, move || {
// TODO(jwall): This is triggering on load which is not desired.
save_signal.track();
spawn_local_scoped(cx, {
let store = crate::api::HttpStore::get_from_context(cx);
async move {
if let Err(e) = app_service
if let Err(e) = store
.save_recipes(vec![RecipeEntry(
id.get_untracked().as_ref().clone(),
text.get_untracked().as_ref().clone(),
@ -133,23 +132,23 @@ fn Steps<'ctx, G: Html>(cx: Scope<'ctx>, steps: &'ctx ReadSignal<Vec<recipes::St
#[component]
pub fn Recipe<'ctx, G: Html>(cx: Scope<'ctx>, recipe_id: String) -> View<G> {
let app_service = use_context::<AppService>(cx).clone();
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);
// FIXME(jwall): This has too many unwrap() calls
if let Some(recipe) = app_service
.fetch_recipes_from_storage()
.expect("Failed to fetch recipes from storage")
.1
.expect(&format!("No recipe counts for recipe id: {}", recipe_id))
.get(&recipe_id)
{
let recipe_text = create_signal(
cx,
app_service
.fetch_recipe_text(recipe_id.as_str())
.expect("No such recipe"),
);
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 || {

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::{components::Recipe, service::AppService};
use crate::{app_state, components::Recipe};
use sycamore::prelude::*;
use tracing::{debug, instrument};
@ -19,8 +19,8 @@ use tracing::{debug, instrument};
#[instrument]
#[component]
pub fn RecipeList<G: Html>(cx: Scope) -> View<G> {
let app_service = use_context::<AppService>(cx);
let menu_list = create_memo(cx, || app_service.get_menu_list());
let state = app_state::State::get_from_context(cx);
let menu_list = create_memo(cx, move || state.get_menu_list());
view! {cx,
h1 { "Recipe List" }
div() {

View File

@ -16,7 +16,7 @@ use std::rc::Rc;
use sycamore::prelude::*;
use tracing::{debug, instrument};
use crate::service::get_appservice_from_context;
use crate::app_state;
#[derive(Prop)]
pub struct RecipeCheckBoxProps<'ctx> {
@ -30,7 +30,7 @@ pub struct RecipeCheckBoxProps<'ctx> {
))]
#[component]
pub fn RecipeSelection<G: Html>(cx: Scope, props: RecipeCheckBoxProps) -> View<G> {
let mut app_service = get_appservice_from_context(cx).clone();
let state = app_state::State::get_from_context(cx);
// This is total hack but it works around the borrow issues with
// the `view!` macro.
let id = Rc::new(props.i);
@ -38,9 +38,9 @@ pub fn RecipeSelection<G: Html>(cx: Scope, props: RecipeCheckBoxProps) -> View<G
cx,
format!(
"{}",
app_service
state
.get_recipe_count_by_index(id.as_ref())
.unwrap_or_else(|| app_service.set_recipe_count_by_index(id.as_ref(), 0))
.unwrap_or_else(|| state.set_recipe_count_by_index(id.as_ref(), 0))
),
);
let title = props.title.get().clone();
@ -51,9 +51,8 @@ pub fn RecipeSelection<G: Html>(cx: Scope, props: RecipeCheckBoxProps) -> View<G
div() {
label(for=for_id) { a(href=href) { (*title) } }
input(type="number", class="item-count-sel", min="0", bind:value=count, name=name, on:change=move |_| {
let mut app_service = app_service.clone();
debug!(idx=%id, count=%(*count.get()), "setting recipe count");
app_service.set_recipe_count_by_index(id.as_ref(), count.get().parse().expect("recipe count isn't a valid usize number"));
state.set_recipe_count_by_index(id.as_ref(), count.get().parse().expect("recipe count isn't a valid usize number"));
})
}
}

View File

@ -16,37 +16,35 @@ use sycamore::{futures::spawn_local_scoped, prelude::*};
use tracing::{error, instrument};
use crate::components::recipe_selection::*;
use crate::service::*;
use crate::{api::*, app_state};
#[allow(non_snake_case)]
#[instrument]
pub fn RecipeSelector<G: Html>(cx: Scope) -> View<G> {
let app_service = get_appservice_from_context(cx).clone();
let rows = create_memo(cx, move || {
let state = app_state::State::get_from_context(cx);
let mut rows = Vec::new();
if let (_, Some(bt)) = app_service
.fetch_recipes_from_storage()
.expect("Unable to fetch recipes from storage")
for row in state
.recipes
.get()
.as_ref()
.iter()
.map(|(k, v)| create_signal(cx, (k.clone(), v.clone())))
.collect::<Vec<&Signal<(String, Recipe)>>>()
.chunks(4)
{
for row in bt
.iter()
.map(|(k, v)| create_signal(cx, (k.clone(), v.clone())))
.collect::<Vec<&Signal<(String, Recipe)>>>()
.chunks(4)
{
rows.push(create_signal(cx, Vec::from(row)));
}
rows.push(create_signal(cx, Vec::from(row)));
}
rows
});
let app_service = get_appservice_from_context(cx).clone();
let clicked = create_signal(cx, false);
create_effect(cx, move || {
clicked.track();
let store = HttpStore::get_from_context(cx);
let state = app_state::State::get_from_context(cx);
spawn_local_scoped(cx, {
let mut app_service = app_service.clone();
async move {
if let Err(err) = app_service.synchronize().await {
if let Err(err) = init_page_state(store.as_ref(), state.as_ref()).await {
error!(?err);
};
}

View File

@ -17,8 +17,6 @@ use recipes::{Ingredient, IngredientKey};
use sycamore::prelude::*;
use tracing::{debug, instrument};
use crate::service::get_appservice_from_context;
fn make_ingredients_rows<'ctx, G: Html>(
cx: Scope<'ctx>,
ingredients: &'ctx ReadSignal<Vec<(IngredientKey, (Ingredient, BTreeSet<String>))>>,
@ -133,7 +131,6 @@ fn make_shopping_table<'ctx, G: Html>(
#[instrument]
#[component]
pub fn ShoppingList<G: Html>(cx: Scope) -> View<G> {
let app_service = get_appservice_from_context(cx);
let filtered_keys: RcSignal<BTreeSet<IngredientKey>> = create_rc_signal(BTreeSet::new());
let ingredients_map = create_rc_signal(BTreeMap::new());
let extras = create_signal(
@ -143,9 +140,10 @@ pub fn ShoppingList<G: Html>(cx: Scope) -> View<G> {
let modified_amts = create_signal(cx, BTreeMap::new());
let show_staples = create_signal(cx, true);
create_effect(cx, {
let state = crate::app_state::State::get_from_context(cx);
let ingredients_map = ingredients_map.clone();
move || {
ingredients_map.set(app_service.get_shopping_list(*show_staples.get()));
ingredients_map.set(state.get_shopping_list(*show_staples.get()));
}
});
debug!(ingredients_map=?ingredients_map.get_untracked());
@ -192,13 +190,16 @@ pub fn ShoppingList<G: Html>(cx: Scope) -> View<G> {
cloned_extras.push((create_signal(cx, "".to_owned()), create_signal(cx, "".to_owned())));
extras.set(cloned_extras.drain(0..).enumerate().collect());
})
input(type="button", value="Reset", class="no-print", on:click=move |_| {
// TODO(jwall): We should actually pop up a modal here or use a different set of items.
ingredients_map.set(app_service.get_shopping_list(*show_staples.get()));
// clear the filter_signal
filtered_keys.set(BTreeSet::new());
modified_amts.set(BTreeMap::new());
extras.set(Vec::new());
input(type="button", value="Reset", class="no-print", on:click={
let state = crate::app_state::State::get_from_context(cx);
move |_| {
// TODO(jwall): We should actually pop up a modal here or use a different set of items.
ingredients_map.set(state.get_shopping_list(*show_staples.get()));
// clear the filter_signal
filtered_keys.set(BTreeSet::new());
modified_amts.set(BTreeMap::new());
extras.set(Vec::new());
}
})
}
}

View File

@ -11,12 +11,12 @@
// 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.
mod api;
mod app_state;
mod components;
mod js_lib;
mod pages;
mod router_integration;
mod service;
mod web;
use sycamore::prelude::*;

View File

@ -21,7 +21,7 @@ use wasm_bindgen::JsCast;
use web_sys::Event;
use web_sys::{Element, HtmlAnchorElement};
use crate::app_state::AppRoutes;
use crate::app_state::Routes;
#[derive(Clone, Debug)]
pub struct BrowserIntegration(RcSignal<(String, String, String)>);
@ -182,9 +182,9 @@ pub trait NotFound {
fn not_found() -> Self;
}
impl NotFound for AppRoutes {
impl NotFound for Routes {
fn not_found() -> Self {
AppRoutes::NotFound
Routes::NotFound
}
}
@ -192,30 +192,30 @@ pub trait DeriveRoute {
fn from(input: &(String, String, String)) -> Self;
}
impl DeriveRoute for AppRoutes {
impl DeriveRoute for Routes {
#[instrument]
fn from(input: &(String, String, String)) -> AppRoutes {
fn from(input: &(String, String, String)) -> Routes {
debug!(origin=%input.0, path=%input.1, hash=%input.2, "routing");
let (_origin, path, _hash) = input;
let route = match path.as_str() {
"" | "/" | "/ui/" => AppRoutes::default(),
"/ui/login" => AppRoutes::Login,
"/ui/plan" => AppRoutes::Plan,
"/ui/cook" => AppRoutes::Cook,
"/ui/inventory" => AppRoutes::Inventory,
"/ui/categories" => AppRoutes::Categories,
"" | "/" | "/ui/" => Routes::default(),
"/ui/login" => Routes::Login,
"/ui/plan" => Routes::Plan,
"/ui/cook" => Routes::Cook,
"/ui/inventory" => Routes::Inventory,
"/ui/categories" => Routes::Categories,
h => {
if h.starts_with("/ui/recipe/") {
let parts: Vec<&str> = h.split("/").collect();
debug!(?parts, "found recipe path");
if let Some(&"recipe") = parts.get(2) {
if let Some(&idx) = parts.get(3) {
return AppRoutes::Recipe(idx.to_owned());
return Routes::Recipe(idx.to_owned());
}
}
}
error!(origin=%input.0, path=%input.1, hash=%input.2, "Path not found");
AppRoutes::NotFound
Routes::NotFound
}
};
info!(route=?route, "Route identified");

View File

@ -1,346 +0,0 @@
// Copyright 2022 Jeremy Wall
//
// 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::collections::{BTreeMap, BTreeSet};
use reqwasm;
//use serde::{Deserialize, Serialize};
use serde_json::{from_str, to_string};
use sycamore::prelude::*;
use tracing::{debug, error, info, instrument, warn};
use web_sys::Storage;
use recipe_store::*;
use recipes::{parse, Ingredient, IngredientAccumulator, Recipe};
use crate::js_lib;
pub fn get_appservice_from_context(cx: Scope) -> &AppService {
use_context::<AppService>(cx)
}
// TODO(jwall): We should not be cloning this.
#[derive(Clone, Debug)]
pub struct AppService {
recipe_counts: RcSignal<BTreeMap<String, usize>>,
staples: RcSignal<Option<Recipe>>,
recipes: RcSignal<BTreeMap<String, Recipe>>,
category_map: RcSignal<BTreeMap<String, String>>,
store: HttpStore,
}
impl AppService {
pub fn new(store: HttpStore) -> Self {
Self {
recipe_counts: create_rc_signal(BTreeMap::new()),
staples: create_rc_signal(None),
recipes: create_rc_signal(BTreeMap::new()),
category_map: create_rc_signal(BTreeMap::new()),
store: store,
}
}
fn get_storage(&self) -> Result<Option<Storage>, String> {
js_lib::get_storage().map_err(|e| format!("{:?}", e))
}
pub fn get_menu_list(&self) -> Vec<(String, usize)> {
self.recipe_counts
.get()
.iter()
.map(|(k, v)| (k.clone(), *v))
.filter(|(_, v)| *v != 0)
.collect()
}
#[instrument(skip(self))]
pub async fn synchronize(&mut self) -> Result<(), String> {
info!("Synchronizing Recipes");
// TODO(jwall): Make our caching logic using storage more robust.
let storage = self
.get_storage()?
.expect("Unable to get storage for browser session");
let recipes = self
.store
.get_recipes()
.await
.map_err(|e| format!("{:?}", e))?;
storage
.set_item(
"recipes",
&(to_string(&recipes).map_err(|e| format!("{:?}", e))?),
)
.map_err(|e| format!("{:?}", e))?;
if let Ok((staples, recipes)) = self.fetch_recipes_from_storage() {
self.staples.set(staples);
if let Some(recipes) = recipes {
self.recipes.set(recipes);
}
}
if let Some(rs) = recipes {
for r in rs {
if !self.recipe_counts.get().contains_key(r.recipe_id()) {
self.set_recipe_count_by_index(&r.recipe_id().to_owned(), 0);
}
}
}
info!("Synchronizing categories");
match self.store.get_categories().await {
Ok(Some(categories_content)) => {
debug!(categories=?categories_content);
storage
.set_item("categories", &categories_content)
.map_err(|e| format!("{:?}", e))?;
}
Ok(None) => {
warn!("There is no category file");
}
Err(e) => {
error!("{:?}", e);
}
}
Ok(())
}
pub fn get_recipe_count_by_index(&self, key: &String) -> Option<usize> {
self.recipe_counts.get().get(key).cloned()
}
pub fn set_recipe_count_by_index(&mut self, key: &String, count: usize) -> usize {
let mut counts = self.recipe_counts.get().as_ref().clone();
counts.insert(key.clone(), count);
self.recipe_counts.set(counts);
count
}
#[instrument(skip(self))]
pub fn get_shopping_list(
&self,
show_staples: bool,
) -> BTreeMap<String, Vec<(Ingredient, BTreeSet<String>)>> {
let mut acc = IngredientAccumulator::new();
let recipe_counts = self.get_menu_list();
for (idx, count) in recipe_counts.iter() {
for _ in 0..*count {
acc.accumulate_from(
self.recipes
.get()
.get(idx)
.expect(&format!("No such recipe id exists: {}", idx)),
);
}
}
if show_staples {
if let Some(staples) = self.staples.get().as_ref() {
acc.accumulate_from(staples);
}
}
let mut ingredients = acc.ingredients();
let mut groups = BTreeMap::new();
let cat_map = self.category_map.get().clone();
for (_, (i, recipes)) in ingredients.iter_mut() {
let category = if let Some(cat) = cat_map.get(&i.name) {
cat.clone()
} else {
"other".to_owned()
};
i.category = category.clone();
groups
.entry(category)
.or_insert(vec![])
.push((i.clone(), recipes.clone()));
}
debug!(?self.category_map);
// FIXME(jwall): Sort by categories and names.
groups
}
pub fn get_category_text(&self) -> Result<Option<String>, String> {
let storage = self
.get_storage()?
.expect("Unable to get storage for browser session");
storage
.get_item("categories")
.map_err(|e| format!("{:?}", e))
}
#[instrument(skip(self))]
pub fn fetch_recipes_from_storage(
&self,
) -> Result<(Option<Recipe>, Option<BTreeMap<String, Recipe>>), String> {
let storage = self.get_storage()?.unwrap();
let mut staples = None;
match storage
.get_item("recipes")
.map_err(|e| format!("{:?}", e))?
{
Some(s) => {
let parsed = from_str::<Vec<RecipeEntry>>(&s).map_err(|e| format!("{}", e))?;
let mut parsed_map = BTreeMap::new();
// TODO(jwall): Utilize the id instead of the index from now on.
for r in parsed {
let recipe = match parse::as_recipe(&r.recipe_text()) {
Ok(r) => r,
Err(e) => {
error!("Error parsing recipe {}", e);
continue;
}
};
if recipe.title == "Staples" {
staples = Some(recipe);
} else {
parsed_map.insert(r.recipe_id().to_owned(), recipe);
}
}
Ok((staples, Some(parsed_map)))
}
None => Ok((None, None)),
}
}
pub fn fetch_recipe_text(&self, id: &str) -> Result<Option<RecipeEntry>, String> {
let storage = self
.get_storage()?
.expect("Unable to get storage for browser session");
if let Some(s) = storage
.get_item("recipes")
.map_err(|e| format!("{:?}", e))?
{
let parsed = from_str::<Vec<RecipeEntry>>(&s).map_err(|e| format!("{}", e))?;
for r in parsed {
if r.recipe_id() == id {
return Ok(Some(r));
}
}
}
return Ok(None);
}
pub async fn save_recipes(&self, recipes: Vec<RecipeEntry>) -> Result<(), String> {
self.store.save_recipes(recipes).await?;
Ok(())
}
pub async fn save_categories(&self, categories: String) -> Result<(), String> {
self.store.save_categories(categories).await?;
Ok(())
}
}
#[derive(Debug)]
pub struct Error(String);
impl From<std::io::Error> for Error {
fn from(item: std::io::Error) -> Self {
Error(format!("{:?}", item))
}
}
impl From<Error> for String {
fn from(item: Error) -> Self {
format!("{:?}", item)
}
}
impl From<String> for Error {
fn from(item: String) -> Self {
Error(item)
}
}
impl From<std::string::FromUtf8Error> for Error {
fn from(item: std::string::FromUtf8Error) -> Self {
Error(format!("{:?}", item))
}
}
impl From<reqwasm::Error> for Error {
fn from(item: reqwasm::Error) -> Self {
Error(format!("{:?}", item))
}
}
#[derive(Clone, Debug)]
pub struct HttpStore {
root: String,
}
impl HttpStore {
pub fn new(root: String) -> Self {
Self { root }
}
#[instrument]
async fn get_categories(&self) -> Result<Option<String>, Error> {
let mut path = self.root.clone();
path.push_str("/categories");
let resp = reqwasm::http::Request::get(&path).send().await?;
if resp.status() == 404 {
debug!("Categories returned 404");
Ok(None)
} else if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into())
} else {
debug!("We got a valid response back!");
let resp = resp.text().await;
Ok(Some(resp.map_err(|e| format!("{}", e))?))
}
}
#[instrument]
async fn get_recipes(&self) -> Result<Option<Vec<RecipeEntry>>, Error> {
let mut path = self.root.clone();
path.push_str("/recipes");
let resp = reqwasm::http::Request::get(&path).send().await?;
if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into())
} else {
debug!("We got a valid response back!");
Ok(resp.json().await.map_err(|e| format!("{}", e))?)
}
}
#[instrument(skip(recipes), fields(count=recipes.len()))]
async fn save_recipes(&self, recipes: Vec<RecipeEntry>) -> Result<(), Error> {
let mut path = self.root.clone();
path.push_str("/recipes");
let resp = reqwasm::http::Request::post(&path)
.body(to_string(&recipes).expect("Unable to serialize recipe entries"))
.header("content-type", "application/json")
.send()
.await?;
if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into())
} else {
debug!("We got a valid response back!");
Ok(())
}
}
#[instrument(skip(categories))]
async fn save_categories(&self, categories: String) -> Result<(), Error> {
let mut path = self.root.clone();
path.push_str("/categories");
let resp = reqwasm::http::Request::post(&path)
.body(to_string(&categories).expect("Unable to encode categories as json"))
.header("content-type", "application/json")
.send()
.await?;
if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into())
} else {
debug!("We got a valid response back!");
Ok(())
}
}
}

View File

@ -12,41 +12,36 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::pages::*;
use crate::{
app_state::*,
components::*,
router_integration::*,
service::{self, AppService},
};
use crate::{api, app_state::*, components::*, router_integration::*};
use tracing::{error, info, instrument};
use sycamore::{futures::spawn_local_scoped, prelude::*};
#[instrument]
fn route_switch<G: Html>(cx: Scope, route: &ReadSignal<AppRoutes>) -> View<G> {
fn route_switch<G: Html>(cx: Scope, route: &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.
match route.get().as_ref() {
AppRoutes::Plan => view! {cx,
Routes::Plan => view! {cx,
PlanPage()
},
AppRoutes::Inventory => view! {cx,
Routes::Inventory => view! {cx,
InventoryPage()
},
AppRoutes::Login => view! {cx,
Routes::Login => view! {cx,
LoginPage()
},
AppRoutes::Cook => view! {cx,
Routes::Cook => view! {cx,
CookPage()
},
AppRoutes::Recipe(idx) => view! {cx,
Routes::Recipe(idx) => view! {cx,
RecipePage(recipe=idx.clone())
},
AppRoutes::Categories => view! {cx,
Routes::Categories => view! {cx,
CategoryPage()
},
AppRoutes::NotFound => view! {cx,
Routes::NotFound => view! {cx,
// TODO(Create a real one)
PlanPage()
},
@ -56,24 +51,25 @@ fn route_switch<G: Html>(cx: Scope, route: &ReadSignal<AppRoutes>) -> View<G> {
#[instrument]
#[component]
pub fn UI<G: Html>(cx: Scope) -> View<G> {
let app_service = AppService::new(service::HttpStore::new("/api/v1".to_owned()));
provide_context(cx, app_service.clone());
crate::app_state::State::provide_context(cx);
api::HttpStore::provide_context(cx, "/api/v1".to_owned());
info!("Starting UI");
let view = create_signal(cx, View::empty());
// FIXME(jwall): We need a way to trigger refreshes when required. Turn this
// into a create_effect with a refresh signal stored as a context.
spawn_local_scoped(cx, {
let mut app_service = crate::service::get_appservice_from_context(cx).clone();
let store = api::HttpStore::get_from_context(cx);
let state = crate::app_state::State::get_from_context(cx);
async move {
if let Err(err) = app_service.synchronize().await {
if let Err(err) = api::init_page_state(store.as_ref(), state.as_ref()).await {
error!(?err);
};
view.set(view! { cx,
div(class="app") {
Header()
Router(RouterProps {
route: AppRoutes::Plan,
route: Routes::Plan,
route_select: route_switch,
browser_integration: BrowserIntegration::new(),
})