Shopping list (WIP)
This commit is contained in:
parent
ce3821b94e
commit
084be9fb00
16 changed files with 296 additions and 90 deletions
|
|
@ -124,11 +124,11 @@ body {
|
||||||
h1 {
|
h1 {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#hidden-templates {
|
#hidden-templates {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#recipe-edit {
|
#recipe-edit {
|
||||||
.drag-handle {
|
.drag-handle {
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,7 @@ CREATE TABLE [RecipeScheduled] (
|
||||||
FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE
|
FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX [RecipeScheduled_user_id_index] ON [RecipeScheduled]([user_id]);
|
||||||
CREATE INDEX [RecipeScheduled_date_index] ON [RecipeScheduled]([date]);
|
CREATE INDEX [RecipeScheduled_date_index] ON [RecipeScheduled]([date]);
|
||||||
|
|
||||||
CREATE TABLE [ShoppingEntry] (
|
CREATE TABLE [ShoppingEntry] (
|
||||||
|
|
@ -200,6 +201,8 @@ CREATE TABLE [ShoppingEntry] (
|
||||||
FOREIGN KEY([recipe_scheduled_id]) REFERENCES [RecipeScheduled]([id]) ON DELETE SET NULL
|
FOREIGN KEY([recipe_scheduled_id]) REFERENCES [RecipeScheduled]([id]) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX [ShoppingEntry_user_id_index] ON [ShoppingEntry]([user_id]);
|
||||||
|
|
||||||
-- When an ingredient is deleted, its values are copied to any shopping entry
|
-- When an ingredient is deleted, its values are copied to any shopping entry
|
||||||
-- that referenced it.
|
-- that referenced it.
|
||||||
CREATE TRIGGER [Ingredient_trigger_delete]
|
CREATE TRIGGER [Ingredient_trigger_delete]
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ use crate::consts;
|
||||||
|
|
||||||
pub mod recipe;
|
pub mod recipe;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
pub mod shopping_list;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|
||||||
const CURRENT_DB_VERSION: u32 = 1;
|
const CURRENT_DB_VERSION: u32 = 1;
|
||||||
|
|
|
||||||
38
backend/src/data/db/shopping_list.rs
Normal file
38
backend/src/data/db/shopping_list.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
use sqlx;
|
||||||
|
|
||||||
|
use super::{Connection, DBError, Result};
|
||||||
|
use crate::data::model;
|
||||||
|
|
||||||
|
impl Connection {
|
||||||
|
pub async fn get_shopping_list(&self, user_id: i64) -> Result<Vec<model::ShoppingListItem>> {
|
||||||
|
sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT [ShoppingEntry].[id],
|
||||||
|
CASE [ShoppingEntry].[name]
|
||||||
|
WHEN '' THEN [Ingredient].[name]
|
||||||
|
ELSE [ShoppingEntry].[name]
|
||||||
|
END AS [name],
|
||||||
|
CASE WHEN [ShoppingEntry].[quantity_value] IS NOT NULL THEN [ShoppingEntry].[quantity_value]
|
||||||
|
ELSE [Ingredient].[quantity_value]
|
||||||
|
END AS [quantity_value],
|
||||||
|
CASE [ShoppingEntry].[quantity_unit] WHEN '' THEN [Ingredient].[quantity_unit]
|
||||||
|
ELSE [ShoppingEntry].[quantity_unit]
|
||||||
|
END AS [quantity_unit],
|
||||||
|
[Recipe].[id] AS [recipe_id],
|
||||||
|
[Recipe].[title] AS [recipe_title],
|
||||||
|
[RecipeScheduled].[date],
|
||||||
|
[is_checked]
|
||||||
|
FROM [ShoppingEntry]
|
||||||
|
LEFT JOIN [Ingredient] ON [Ingredient].[id] = [ShoppingEntry].[ingredient_id]
|
||||||
|
LEFT JOIN [RecipeScheduled] ON [RecipeScheduled].[id] = [ShoppingEntry].[recipe_scheduled_id]
|
||||||
|
LEFT JOIN [Recipe] ON [Recipe].[id] = [RecipeScheduled].[recipe_id]
|
||||||
|
WHERE [ShoppingEntry].[user_id] = $1
|
||||||
|
ORDER BY [is_checked], [recipe_id], [name]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(DBError::from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -72,3 +72,15 @@ pub struct Ingredient {
|
||||||
pub quantity_value: Option<f64>,
|
pub quantity_value: Option<f64>,
|
||||||
pub quantity_unit: String,
|
pub quantity_unit: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, FromRow)]
|
||||||
|
pub struct ShoppingListItem {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub quantity_value: Option<f64>,
|
||||||
|
pub quantity_unit: String,
|
||||||
|
pub recipe_id: Option<i64>,
|
||||||
|
pub recipe_title: Option<String>,
|
||||||
|
pub date: Option<NaiveDate>,
|
||||||
|
pub is_checked: bool,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,10 @@ async fn main() {
|
||||||
"/calendar/remove_scheduled_recipe",
|
"/calendar/remove_scheduled_recipe",
|
||||||
delete(services::ron::rm_scheduled_recipe),
|
delete(services::ron::rm_scheduled_recipe),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/shopping_list/get_list",
|
||||||
|
get(services::ron::get_shopping_list),
|
||||||
|
)
|
||||||
.fallback(services::ron::not_found);
|
.fallback(services::ron::not_found);
|
||||||
|
|
||||||
let fragments_routes = Router::new().route(
|
let fragments_routes = Router::new().route(
|
||||||
|
|
|
||||||
|
|
@ -618,7 +618,7 @@ pub async fn set_ingredients_order(
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calendar ///
|
/*** Calendar ***/
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn get_scheduled_recipes(
|
pub async fn get_scheduled_recipes(
|
||||||
|
|
@ -692,7 +692,46 @@ pub async fn rm_scheduled_recipe(
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 404 ///
|
/*** Shopping list ***/
|
||||||
|
|
||||||
|
impl From<model::ShoppingListItem> for common::ron_api::ShoppingListItem {
|
||||||
|
fn from(item: model::ShoppingListItem) -> Self {
|
||||||
|
Self {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
quantity_value: item.quantity_value,
|
||||||
|
quantity_unit: item.quantity_unit,
|
||||||
|
recipe_id: item.recipe_id,
|
||||||
|
recipe_title: item.recipe_title,
|
||||||
|
date: item.date,
|
||||||
|
is_checked: item.is_checked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn get_shopping_list(
|
||||||
|
State(connection): State<db::Connection>,
|
||||||
|
Extension(user): Extension<Option<model::User>>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
if let Some(user) = user {
|
||||||
|
Ok(ron_response_ok(
|
||||||
|
connection
|
||||||
|
.get_shopping_list(user.id)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(common::ron_api::ShoppingListItem::from)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Err(ErrorResponse::from(ron_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
NOT_AUTHORIZED_MESSAGE,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** 404 ***/
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn not_found(Extension(_user): Extension<Option<model::User>>) -> impl IntoResponse {
|
pub async fn not_found(Extension(_user): Extension<Option<model::User>>) -> impl IntoResponse {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,20 @@
|
||||||
|
|
||||||
<div class="content" id="home">
|
<div class="content" id="home">
|
||||||
{% include "calendar.html" %}
|
{% include "calendar.html" %}
|
||||||
|
|
||||||
|
<div id="shopping-list">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hidden-templates">
|
||||||
|
<div class="shopping-item">
|
||||||
|
<input class="item-is-checked" type="checkbox"/>
|
||||||
|
<div class="item-quantity"></div>
|
||||||
|
<div class="item-name"></div>
|
||||||
|
<div class="item-scheduled-recipe"><a></a></div>
|
||||||
|
<div class="item-delete"></div>
|
||||||
|
</div>
|
||||||
|
<span class="calendar-date-format">{{ tr.t(Sentence::CalendarDateFormat) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
{%+ if recipe.is_published %}
|
{%+ if recipe.is_published %}
|
||||||
checked
|
checked
|
||||||
{% endif %}
|
{% endif %}
|
||||||
>
|
/>
|
||||||
<label for="input-is-published">{{ tr.t(Sentence::RecipeIsPublished) }}</label>
|
<label for="input-is-published">{{ tr.t(Sentence::RecipeIsPublished) }}</label>
|
||||||
|
|
||||||
<input id="input-delete" type="button" value="{{ tr.t(Sentence::RecipeDelete) }}" />
|
<input id="input-delete" type="button" value="{{ tr.t(Sentence::RecipeDelete) }}" />
|
||||||
|
|
@ -83,6 +83,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input id="input-add-group" type="button" value="{{ tr.t(Sentence::RecipeAddAGroup) }}" />
|
<input id="input-add-group" type="button" value="{{ tr.t(Sentence::RecipeAddAGroup) }}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="hidden-templates">
|
<div id="hidden-templates">
|
||||||
<div class="group">
|
<div class="group">
|
||||||
|
|
@ -140,6 +141,5 @@
|
||||||
<span class="recipe-step-delete-confirmation">{{ tr.t(Sentence::RecipeStepDeleteConfirmation) }}</span>
|
<span class="recipe-step-delete-confirmation">{{ tr.t(Sentence::RecipeStepDeleteConfirmation) }}</span>
|
||||||
<span class="recipe-ingredient-delete-confirmation">{{ tr.t(Sentence::RecipeIngredientDeleteConfirmation) }}</span>
|
<span class="recipe-ingredient-delete-confirmation">{{ tr.t(Sentence::RecipeIngredientDeleteConfirmation) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -79,6 +79,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="hidden-templates">
|
<div id="hidden-templates">
|
||||||
{# To create a modal dialog to choose a date and and servings #}
|
{# To create a modal dialog to choose a date and and servings #}
|
||||||
|
|
@ -112,6 +113,5 @@
|
||||||
<span class="calendar-add-to-planner-already-exists">{{ tr.t(Sentence::CalendarAddToPlannerAlreadyExists) }}</span>
|
<span class="calendar-add-to-planner-already-exists">{{ tr.t(Sentence::CalendarAddToPlannerAlreadyExists) }}</span>
|
||||||
<span class="calendar-date-format">{{ tr.t(Sentence::CalendarDateFormat) }}</span>
|
<span class="calendar-date-format">{{ tr.t(Sentence::CalendarDateFormat) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -209,6 +209,20 @@ pub struct ScheduledRecipe {
|
||||||
pub date: NaiveDate,
|
pub date: NaiveDate,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*** Shopping list ***/
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
pub struct ShoppingListItem {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub quantity_value: Option<f64>,
|
||||||
|
pub quantity_unit: String,
|
||||||
|
pub recipe_id: Option<i64>,
|
||||||
|
pub recipe_title: Option<String>,
|
||||||
|
pub date: Option<NaiveDate>,
|
||||||
|
pub is_checked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/*** Misc ***/
|
/*** Misc ***/
|
||||||
|
|
||||||
pub fn to_string<T>(ron: T) -> String
|
pub fn to_string<T>(ron: T) -> String
|
||||||
|
|
|
||||||
|
|
@ -222,11 +222,7 @@ fn display_month(
|
||||||
let scheduled_recipes_element: Element =
|
let scheduled_recipes_element: Element =
|
||||||
selector(&format!("#day-grid-{}{} .scheduled-recipes", i, j));
|
selector(&format!("#day-grid-{}{} .scheduled-recipes", i, j));
|
||||||
|
|
||||||
let recipe_element = recipe_template
|
let recipe_element = recipe_template.deep_clone();
|
||||||
.clone_node_with_deep(true)
|
|
||||||
.unwrap()
|
|
||||||
.dyn_into::<Element>()
|
|
||||||
.unwrap();
|
|
||||||
recipe_element.set_id(&id);
|
recipe_element.set_id(&id);
|
||||||
|
|
||||||
scheduled_recipes_element
|
scheduled_recipes_element
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,9 @@ use crate::{
|
||||||
calendar, modal_dialog,
|
calendar, modal_dialog,
|
||||||
recipe_scheduler::RecipeScheduler,
|
recipe_scheduler::RecipeScheduler,
|
||||||
request,
|
request,
|
||||||
|
shopping_list::ShoppingList,
|
||||||
toast::{self, Level},
|
toast::{self, Level},
|
||||||
utils::{get_locale, selector, SelectorExt},
|
utils::{by_id, get_locale, selector, SelectorExt},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn setup_page(is_user_logged: bool) -> Result<(), JsValue> {
|
pub fn setup_page(is_user_logged: bool) -> Result<(), JsValue> {
|
||||||
|
|
@ -26,5 +27,44 @@ pub fn setup_page(is_user_logged: bool) -> Result<(), JsValue> {
|
||||||
},
|
},
|
||||||
recipe_scheduler,
|
recipe_scheduler,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let shopping_list = ShoppingList::new(!is_user_logged);
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
let item_template: Element = selector("#hidden-templates .shopping-item");
|
||||||
|
let container: Element = by_id("shopping-list");
|
||||||
|
let date_format =
|
||||||
|
selector::<Element>("#hidden-templates .calendar-date-format").inner_html();
|
||||||
|
for item in shopping_list.get_items().await.unwrap() {
|
||||||
|
let item_element = item_template.deep_clone();
|
||||||
|
item_element
|
||||||
|
.selector::<Element>(".item-name")
|
||||||
|
.set_inner_html(&item.name);
|
||||||
|
|
||||||
|
if let Some(quantity_value) = item.quantity_value {
|
||||||
|
item_element
|
||||||
|
.selector::<Element>(".item-quantity")
|
||||||
|
.set_inner_html(&format!("{} {}", quantity_value, item.quantity_unit));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display associated sheduled recipe information if it exists.
|
||||||
|
if let (Some(recipe_id), Some(recipe_title), Some(date)) =
|
||||||
|
(item.recipe_id, item.recipe_title, item.date)
|
||||||
|
{
|
||||||
|
let recipe_element = item_element.selector::<Element>(".item-scheduled-recipe a");
|
||||||
|
recipe_element.set_inner_html(&format!(
|
||||||
|
"{} @ {}",
|
||||||
|
recipe_title,
|
||||||
|
date.format_localized(&date_format, get_locale()),
|
||||||
|
));
|
||||||
|
recipe_element
|
||||||
|
.set_attribute("href", &format!("/recipe/view/{}", recipe_id))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
container.append_child(&item_element).unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ mod recipe_edit;
|
||||||
mod recipe_scheduler;
|
mod recipe_scheduler;
|
||||||
mod recipe_view;
|
mod recipe_view;
|
||||||
mod request;
|
mod request;
|
||||||
|
mod shopping_list;
|
||||||
mod toast;
|
mod toast;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
|
|
|
||||||
35
frontend/src/shopping_list.rs
Normal file
35
frontend/src/shopping_list.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
use chrono::{Datelike, Days, Months, NaiveDate};
|
||||||
|
use common::ron_api;
|
||||||
|
use gloo::storage::{LocalStorage, Storage};
|
||||||
|
use ron::ser::{to_string_pretty, PrettyConfig};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::{calendar, request};
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("Request error: {0}")]
|
||||||
|
Request(#[from] request::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct ShoppingList {
|
||||||
|
is_local: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShoppingList {
|
||||||
|
pub fn new(is_local: bool) -> Self {
|
||||||
|
Self { is_local }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_items(&self) -> Result<Vec<ron_api::ShoppingListItem>> {
|
||||||
|
if self.is_local {
|
||||||
|
Ok(vec![]) // TODO
|
||||||
|
} else {
|
||||||
|
Ok(request::get("shopping_list/get_list", ()).await?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,8 @@ pub trait SelectorExt {
|
||||||
fn selector_all<T>(&self, selectors: &str) -> Vec<T>
|
fn selector_all<T>(&self, selectors: &str) -> Vec<T>
|
||||||
where
|
where
|
||||||
T: JsCast;
|
T: JsCast;
|
||||||
|
|
||||||
|
fn deep_clone(&self) -> Self;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SelectorExt for Element {
|
impl SelectorExt for Element {
|
||||||
|
|
@ -38,6 +40,13 @@ impl SelectorExt for Element {
|
||||||
.map(|e| e.unwrap().dyn_into::<T>().unwrap())
|
.map(|e| e.unwrap().dyn_into::<T>().unwrap())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn deep_clone(&self) -> Self {
|
||||||
|
self.clone_node_with_deep(true)
|
||||||
|
.unwrap()
|
||||||
|
.dyn_into::<Element>()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn selector<T>(selectors: &str) -> T
|
pub fn selector<T>(selectors: &str) -> T
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue