Ingredients can now be manually ordered
authorGreg Burri <greg.burri@gmail.com>
Wed, 15 Jan 2025 23:17:08 +0000 (00:17 +0100)
committerGreg Burri <greg.burri@gmail.com>
Wed, 15 Jan 2025 23:17:08 +0000 (00:17 +0100)
Cargo.lock
backend/scss/style.scss
backend/sql/data_test.sql
backend/sql/version_1.sql
backend/src/data/db/recipe.rs
backend/src/main.rs
backend/src/services/ron.rs
backend/templates/recipe_edit.html
backend/templates/recipe_view.html
common/src/ron_api.rs
frontend/src/recipe_edit.rs

index 9905452..24c22c1 100644 (file)
@@ -308,9 +308,9 @@ dependencies = [
 
 [[package]]
 name = "bitflags"
-version = "2.7.0"
+version = "2.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1be3f42a67d6d345ecd59f675f3f012d6974981560836e938c22b424b85ce1be"
+checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36"
 dependencies = [
  "serde",
 ]
index ddd3d8c..8c9f9b0 100644 (file)
@@ -138,10 +138,14 @@ body {
 
             .step {
                 border: 0.1em solid lighten($color-3, 30%);
+                margin-top: 0px;
+                margin-bottom: 0px;
             }
 
             .ingredient {
                 border: 0.1em solid lighten($color-3, 30%);
+                margin-top: 0px;
+                margin-bottom: 0px;
             }
 
             .dropzone {
@@ -160,7 +164,6 @@ body {
                 }
             }
 
-
             #hidden-templates {
                 display: none;
             }
index 7e4a46b..c68174b 100644 (file)
@@ -23,8 +23,8 @@ VALUES (
 INSERT INTO [Recipe] ([id], [user_id], [title], [is_published], [creation_datetime])
 VALUES (1, 1, 'Croissant au jambon', true, '2025-01-07T10:41:05.697884837+00:00');
 
-INSERT INTO [Recipe] ([id], [user_id], [title], [is_published], [creation_datetime])
-VALUES (2, 1, 'Gratin de thon aux olives', true, '2025-01-07T10:41:05.697884837+00:00');
+INSERT INTO [Recipe] ([id], [user_id], [title], [is_published], [creation_datetime], [servings], [estimated_time], [difficulty])
+VALUES (2, 1, 'Gratin de thon aux olives', true, '2025-01-07T10:41:05.697884837+00:00', 4, 40, 1);
 
 INSERT INTO [Recipe] ([id], [user_id], [title], [is_published], [creation_datetime])
 VALUES (3, 1, 'Saumon en croute', true, '2025-01-07T10:41:05.697884837+00:00');
@@ -58,20 +58,20 @@ VALUES (2, 2, "Sel à l'origan", "", 1, "c-à-c");
 INSERT INTO [Step] ([id], [order], [group_id], [action])
 VALUES (3, 3, 2, "Mélanger au fouet et verser sur le thon dans le plat");
 
-INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
-VALUES (3, 3, "Concentré de tomate", "", 4, "c-à-s");
+INSERT INTO [Ingredient] ([id], [order], [step_id], [name], [comment], [quantity_value], [quantity_unit])
+VALUES (3, 0, 3, "Concentré de tomate", "", 4, "c-à-s");
 
-INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
-VALUES (4, 3, "Poivre", "", 0.25, "c-à-c");
+INSERT INTO [Ingredient] ([id], [order], [step_id], [name], [comment], [quantity_value], [quantity_unit])
+VALUES (4, 1, 3, "Poivre", "", 0.25, "c-à-c");
 
-INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
-VALUES (5, 3, "Herbe de Provence", "", 0.5, "c-à-c");
+INSERT INTO [Ingredient] ([id], [order], [step_id], [name], [comment], [quantity_value], [quantity_unit])
+VALUES (5, 2, 3, "Herbe de Provence", "", 0.5, "c-à-c");
 
-INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
-VALUES (6, 3, "Crème à café ou demi-crème", "", 2, "dl");
+INSERT INTO [Ingredient] ([id], [order], [step_id], [name], [comment], [quantity_value], [quantity_unit])
+VALUES (6, 3, 3, "Crème à café ou demi-crème", "", 2, "dl");
 
-INSERT INTO [Ingredient] ([id], [step_id], [name], [comment], [quantity_value], [quantity_unit])
-VALUES (7, 3, "Olives farcies coupées en deuxs", "", 50, "g");
+INSERT INTO [Ingredient] ([id], [order], [step_id], [name], [comment], [quantity_value], [quantity_unit])
+VALUES (7, 4, 3, "Olives farcies coupées en deuxs", "", 50, "g");
 
 
 INSERT INTO [Group] ([id], [order], [recipe_id], [name], [comment])
index aa9c14d..be6ae95 100644 (file)
@@ -56,6 +56,7 @@ CREATE TABLE [Recipe] (
     [lang] TEXT NOT NULL DEFAULT 'en',
     [estimated_time] INTEGER, -- in [s].
     [description] TEXT NOT NULL DEFAULT '',
+    -- 0: Unknown, 1: Easy, 2: Medium, 4: Hard.
     [difficulty] INTEGER NOT NULL DEFAULT 0,
     [servings] INTEGER DEFAULT 4,
     [is_published] INTEGER NOT NULL DEFAULT FALSE,
@@ -64,6 +65,28 @@ CREATE TABLE [Recipe] (
     FOREIGN KEY([user_id]) REFERENCES [User]([id]) ON DELETE SET NULL
 ) STRICT;
 
+CREATE TRIGGER [Recipe_trigger_update_difficulty]
+BEFORE UPDATE OF [difficulty]
+ON [Recipe]
+BEGIN
+    SELECT
+        CASE
+            WHEN NEW.[difficulty] < 0 OR NEW.[difficulty] > 3 THEN
+                RAISE (ABORT, 'Invalid [difficulty] value')
+        END;
+END;
+
+CREATE TRIGGER [Recipe_trigger_insert_difficulty]
+BEFORE INSERT
+ON [Recipe]
+BEGIN
+    SELECT
+        CASE
+            WHEN NEW.[difficulty] < 0 OR NEW.[difficulty] > 3 THEN
+                RAISE (ABORT, 'Invalid [difficulty] value')
+        END;
+END;
+
 CREATE TABLE [Image] (
     [Id] INTEGER PRIMARY KEY,
     [recipe_id] INTEGER NOT NULL,
@@ -124,6 +147,7 @@ CREATE INDEX [Step_order_index] ON [Group]([order]);
 CREATE TABLE [Ingredient] (
     [id] INTEGER PRIMARY KEY,
 
+    [order] INTEGER NOT NULL DEFAULT 0,
     [step_id] INTEGER NOT NULL,
 
     [name] TEXT NOT NULL DEFAULT '',
@@ -134,14 +158,4 @@ CREATE TABLE [Ingredient] (
     FOREIGN KEY([step_id]) REFERENCES [Step]([id]) ON DELETE CASCADE
 ) STRICT;
 
--- CREATE TABLE [IntermediateSubstance] (
---     [id] INTEGER PRIMARY KEY,
---     [name] TEXT NOT NULL DEFAULT '',
---     [quantity_value] REAL,
---     [quantity_unit] TEXT NOT NULL DEFAULT '',
---     [output_group_id] INTEGER NOT NULL,
---     [input_group_id] INTEGER NOT NULL,
-
---     FOREIGN KEY([output_group_id]) REFERENCES [group]([id]) ON DELETE CASCADE,
---     FOREIGN KEY([input_group_id]) REFERENCES [group]([id]) ON DELETE CASCADE
--- ) STRICT;
+CREATE INDEX [Ingredient_order_index] ON [Ingredient]([order]);
index f474220..64ed972 100644 (file)
@@ -172,6 +172,33 @@ WHERE [Ingredient].[id] = $1 AND [user_id] = $2
         .map_err(DBError::from)
     }
 
+    pub async fn can_edit_recipe_all_ingredients(
+        &self,
+        user_id: i64,
+        ingredients_ids: &[i64],
+    ) -> Result<bool> {
+        let params = (0..ingredients_ids.len())
+            .map(|n| format!("${}", n + 2))
+            .join(", ");
+        let query_str = format!(
+            r#"
+SELECT COUNT(*)
+FROM [Recipe]
+INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
+INNER JOIN [Step] ON [Step].[group_id] = [Group].[id]
+INNER JOIN [Ingredient] ON [Ingredient].[step_id] = [Step].[id]
+WHERE [Ingredient].[id] IN ({}) AND [user_id] = $1
+            "#,
+            params
+        );
+
+        let mut query = sqlx::query_scalar::<_, u64>(&query_str).bind(user_id);
+        for id in ingredients_ids {
+            query = query.bind(id);
+        }
+        Ok(query.fetch_one(&self.pool).await? == ingredients_ids.len() as u64)
+    }
+
     pub async fn get_recipe(&self, id: i64, complete: bool) -> Result<Option<model::Recipe>> {
         match sqlx::query_as::<_, model::Recipe>(
             r#"
@@ -485,7 +512,7 @@ ORDER BY [order]
 SELECT [id], [name], [comment], [quantity_value], [quantity_unit]
 FROM [Ingredient]
 WHERE [step_id] = $1
-ORDER BY [name]
+ORDER BY [order]
                     "#,
                 )
                 .bind(step.id)
@@ -622,10 +649,27 @@ ORDER BY [name]
     }
 
     pub async fn add_recipe_ingredient(&self, step_id: i64) -> Result<i64> {
-        let db_result = sqlx::query("INSERT INTO [Ingredient] ([step_id]) VALUES ($1)")
-            .bind(step_id)
-            .execute(&self.pool)
-            .await?;
+        let mut tx = self.tx().await?;
+
+        let last_order = sqlx::query_scalar(
+            "SELECT [order] FROM [Ingredient] WHERE [step_id] = $1 ORDER BY [order] DESC LIMIT 1",
+        )
+        .bind(step_id)
+        .fetch_optional(&mut *tx)
+        .await?
+        .unwrap_or(-1);
+
+        let db_result = sqlx::query(
+            r#"
+INSERT INTO [Ingredient] ([step_id], [order])
+VALUES ($1, $2)
+            "#,
+        )
+        .bind(step_id)
+        .bind(last_order as i64)
+        .execute(&mut *tx)
+        .await?;
+
         Ok(db_result.last_insert_rowid())
     }
 
@@ -681,6 +725,22 @@ ORDER BY [name]
             .map(|_| ())
             .map_err(DBError::from)
     }
+
+    pub async fn set_ingredients_order(&self, ingredient_ids: &[i64]) -> Result<()> {
+        let mut tx = self.tx().await?;
+
+        for (order, id) in ingredient_ids.iter().enumerate() {
+            sqlx::query("UPDATE [Ingredient] SET [order] = $2 WHERE [id] = $1")
+                .bind(id)
+                .bind(order as i64)
+                .execute(&mut *tx)
+                .await?;
+        }
+
+        tx.commit().await?;
+
+        Ok(())
+    }
 }
 
 #[cfg(test)]
index ad2fd2a..092c2b3 100644 (file)
@@ -173,6 +173,10 @@ async fn main() {
             "/recipe/set_ingredient_unit",
             put(services::ron::set_ingredient_unit),
         )
+        .route(
+            "/recipe/set_ingredients_order",
+            put(services::ron::set_ingredients_order),
+        )
         .fallback(services::ron::not_found);
 
     let fragments_routes = Router::new().route(
index 71e0635..ddf19b4 100644 (file)
@@ -180,6 +180,25 @@ async fn check_user_rights_recipe_ingredient(
     }
 }
 
+async fn check_user_rights_recipe_ingredients(
+    connection: &db::Connection,
+    user: &Option<model::User>,
+    step_ids: &[i64],
+) -> Result<()> {
+    if user.is_none()
+        || !connection
+            .can_edit_recipe_all_ingredients(user.as_ref().unwrap().id, step_ids)
+            .await?
+    {
+        Err(ErrorResponse::from(ron_error(
+            StatusCode::UNAUTHORIZED,
+            NOT_AUTHORIZED_MESSAGE,
+        )))
+    } else {
+        Ok(())
+    }
+}
+
 #[debug_handler]
 pub async fn set_recipe_title(
     State(connection): State<db::Connection>,
@@ -579,6 +598,19 @@ pub async fn set_ingredient_unit(
     Ok(StatusCode::OK)
 }
 
+#[debug_handler]
+pub async fn set_ingredients_order(
+    State(connection): State<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+    ExtractRon(ron): ExtractRon<common::ron_api::SetIngredientOrders>,
+) -> Result<impl IntoResponse> {
+    check_user_rights_recipe_ingredients(&connection, &user, &ron.ingredient_ids).await?;
+    connection
+        .set_ingredients_order(&ron.ingredient_ids)
+        .await?;
+    Ok(StatusCode::OK)
+}
+
 ///// 404 /////
 #[debug_handler]
 pub async fn not_found(Extension(_user): Extension<Option<model::User>>) -> impl IntoResponse {
index b830569..c14c8fc 100644 (file)
         </div>
 
         <div class="ingredient">
+            <span class="drag-handle"></span>
+
             <label for="input-ingredient-name">{{ tr.t(Sentence::RecipeIngredientName) }}</label>
             <input class="input-ingredient-name" type="text" />
 
index 926aa91..39aea85 100644 (file)
     {% else %}
     {% endmatch %}
 
+    <span class="difficulty">
+    {% match recipe.difficulty %}
+    {% when common::ron_api::Difficulty::Unknown %}
+    {% when common::ron_api::Difficulty::Easy %}
+        {{ tr.t(Sentence::RecipeDifficultyEasy) }}
+    {% when common::ron_api::Difficulty::Medium %}
+        {{ tr.t(Sentence::RecipeDifficultyMedium) }}
+    {% when common::ron_api::Difficulty::Hard %}
+        {{ tr.t(Sentence::RecipeDifficultyHard) }}
+    {% endmatch %}
+    </span>
+
     {% if !recipe.description.is_empty() %}
         <div class="recipe-description" >
-        {{ recipe.description.clone() }}
+        {{ recipe.description }}
         </div>
     {% endif %}
+
+    {% for group in recipe.groups %}
+    <div class="group">
+        <h3>{{ group.name }}</h3>
+
+        <div class="steps">
+        {% for step in group.steps %}
+            <div class="ingredients">
+            {% for ingredient in step.ingredients %}
+                <div class="ingredient">
+                    {% if let Some(quantity) = ingredient.quantity_value %}
+                        {{ quantity +}}
+                        {{+ ingredient.quantity_unit }}
+                    {% endif +%}
+                    {{+ ingredient.name }}
+                </div>
+            {% endfor %}
+            </div>
+            <div class="step">
+                {{ step.action }}
+            </div>
+        {% endfor %}
+        </div>
+    </div>
+    {% endfor %}
 </div>
 
 {% endblock %}
\ No newline at end of file
index d533243..f3a6b1a 100644 (file)
@@ -178,6 +178,11 @@ pub struct SetIngredientUnit {
     pub unit: String,
 }
 
+#[derive(Serialize, Deserialize, Clone)]
+pub struct SetIngredientOrders {
+    pub ingredient_ids: Vec<i64>,
+}
+
 #[derive(Serialize, Deserialize, Clone)]
 pub struct Tags {
     pub recipe_id: i64,
index 5b8535c..e605028 100644 (file)
@@ -574,6 +574,22 @@ fn create_ingredient_element(step_element: &Element, ingredient: &ron_api::Ingre
     ingredient_element.set_id(&format!("ingredient-{}", ingredient.id));
     step_element.append_child(&ingredient_element).unwrap();
 
+    set_draggable(&ingredient_element, "ingredient", |element| {
+        let element = element.clone();
+        spawn_local(async move {
+            let ingredient_ids = element
+                .parent_element()
+                .unwrap()
+                .selector_all::<Element>(".ingredient")
+                .into_iter()
+                .map(|e| e.id()[11..].parse::<i64>().unwrap())
+                .collect();
+
+            let body = ron_api::SetIngredientOrders { ingredient_ids };
+            let _ = request::put::<(), _>("recipe/set_ingredients_order", body).await;
+        });
+    });
+
     // Ingredient name.
     let name: HtmlInputElement = ingredient_element.selector(".input-ingredient-name");
     name.set_value(&ingredient.name);