Steps can now be ordered (via drag and drop)
authorGreg Burri <greg.burri@gmail.com>
Wed, 15 Jan 2025 00:37:49 +0000 (01:37 +0100)
committerGreg Burri <greg.burri@gmail.com>
Wed, 15 Jan 2025 00:37:49 +0000 (01:37 +0100)
Cargo.toml
backend/src/data/db/recipe.rs
backend/src/main.rs
backend/src/services/ron.rs
frontend/Cargo.toml
frontend/deploy.nu
frontend/src/recipe_edit.rs
frontend/src/utils.rs

index ffc5e91..6fa07ed 100644 (file)
@@ -5,8 +5,12 @@ resolver = "2"
 members = ["backend", "frontend", "common"]\r
 \r
 [profile.release]\r
-strip = true\r
 codegen-units = 1\r
 lto = true\r
-# Tell `rustc` to optimize for small code size.\r
-# opt-level = "s"\r
+strip = true\r
+\r
+[profile.release.package.frontend]\r
+codegen-units = 1\r
+strip = true\r
+# To reduce the 'wasm' file size.\r
+opt-level = "z"\r
index f8628e8..0e41b8f 100644 (file)
@@ -507,12 +507,14 @@ ORDER BY [name]
         .await?
         .unwrap_or(-1);
 
-        let db_result = sqlx::query("INSERT INTO [Group] ([recipe_id, [order]) VALUES ($1, $2)")
+        let db_result = sqlx::query("INSERT INTO [Group] ([recipe_id], [order]) VALUES ($1, $2)")
             .bind(recipe_id)
             .bind(last_order + 1)
             .execute(&mut *tx)
             .await?;
 
+        tx.commit().await?;
+
         Ok(db_result.last_insert_rowid())
     }
 
@@ -562,10 +564,24 @@ ORDER BY [name]
     }
 
     pub async fn add_recipe_step(&self, group_id: i64) -> Result<i64> {
-        let db_result = sqlx::query("INSERT INTO [Step] ([group_id]) VALUES ($1)")
+        let mut tx = self.tx().await?;
+
+        let last_order = sqlx::query_scalar(
+            "SELECT [order] FROM [Step] WHERE [group_id] = $1 ORDER BY [order] DESC LIMIT 1",
+        )
+        .bind(group_id)
+        .fetch_optional(&mut *tx)
+        .await?
+        .unwrap_or(-1);
+
+        let db_result = sqlx::query("INSERT INTO [Step] ([group_id], [order]) VALUES ($1, $2)")
             .bind(group_id)
-            .execute(&self.pool)
+            .bind(last_order + 1)
+            .execute(&mut *tx)
             .await?;
+
+        tx.commit().await?;
+
         Ok(db_result.last_insert_rowid())
     }
 
index 38b7f55..ad2fd2a 100644 (file)
@@ -137,7 +137,7 @@ async fn main() {
         )
         .route(
             "/recipe/set_groups_order",
-            put(services::ron::set_group_orders),
+            put(services::ron::set_groups_order),
         )
         .route("/recipe/add_step", post(services::ron::add_step))
         .route("/recipe/remove_step", delete(services::ron::rm_step))
@@ -145,6 +145,10 @@ async fn main() {
             "/recipe/set_step_action",
             put(services::ron::set_step_action),
         )
+        .route(
+            "/recipe/set_steps_order",
+            put(services::ron::set_steps_order),
+        )
         .route(
             "/recipe/add_ingredient",
             post(services::ron::add_ingredient),
index 2a90013..28e9a9b 100644 (file)
@@ -435,7 +435,7 @@ pub async fn set_group_comment(
 }
 
 #[debug_handler]
-pub async fn set_group_orders(
+pub async fn set_groups_order(
     State(connection): State<db::Connection>,
     Extension(user): Extension<Option<model::User>>,
     ExtractRon(ron): ExtractRon<common::ron_api::SetGroupOrders>,
@@ -483,7 +483,7 @@ pub async fn set_step_action(
 }
 
 #[debug_handler]
-pub async fn set_step_orders(
+pub async fn set_steps_order(
     State(connection): State<db::Connection>,
     Extension(user): Extension<Option<model::User>>,
     ExtractRon(ron): ExtractRon<common::ron_api::SetStepOrders>,
index 7f156bf..affb787 100644 (file)
@@ -31,6 +31,7 @@ web-sys = { version = "0.3", features = [
     "EventTarget",
     "DragEvent",
     "DataTransfer",
+    "DomRect",
     "KeyboardEvent",
     "Element",
     "HtmlElement",
@@ -50,10 +51,5 @@ gloo = "0.11"
 # code size when deploying.
 console_error_panic_hook = { version = "0.1", optional = true }
 
-[profile.release]
-# Tell `rustc` to optimize for small code size.
-opt-level = "s"
-lto = true
-
 [package.metadata.wasm-pack.profile.release]
 wasm-opt = false
index 6a69206..6b9d549 100644 (file)
@@ -1,8 +1,10 @@
 def main [release: bool = false] {
 
-    mut wasm_pack_args = [ build --target web ]
+    mut wasm_pack_args = [ build --target web --no-pack --no-typescript ]
     if $release {
-        $wasm_pack_args = $wasm_pack_args ++ [ --release ]
+        $wasm_pack_args ++= [ --release ]
+    } else {
+        $wasm_pack_args ++= [ --dev ]
     }
 
     wasm-pack ...$wasm_pack_args
index 8d7032b..df491d7 100644 (file)
@@ -18,7 +18,7 @@ use common::ron_api;
 use crate::{
     modal_dialog, request,
     toast::{self, Level},
-    utils::{by_id, selector, selector_all, selector_and_clone, SelectorExt},
+    utils::{by_id, selector, selector_and_clone, SelectorExt},
 };
 
 async fn reload_recipes_list(current_recipe_id: i64) {
@@ -336,9 +336,7 @@ pub fn setup_page(recipe_id: i64) -> Result<(), JsValue> {
 fn create_group_element(group: &ron_api::Group) -> Element {
     let group_id = group.id;
     let group_element: Element = selector_and_clone("#hidden-templates .group");
-    group_element
-        .set_attribute("id", &format!("group-{}", group.id))
-        .unwrap();
+    group_element.set_id(&format!("group-{}", group.id));
 
     let groups_container: Element = by_id("groups-container");
     groups_container.append_child(&group_element).unwrap();
@@ -348,7 +346,7 @@ fn create_group_element(group: &ron_api::Group) -> Element {
             let group_ids = by_id::<Element>("groups-container")
                 .selector_all::<Element>(".group")
                 .into_iter()
-                .map(|e| e.get_attribute("id").unwrap()[6..].parse::<i64>().unwrap())
+                .map(|e| e.id()[6..].parse::<i64>().unwrap())
                 .collect();
 
             let body = ron_api::SetGroupOrders { group_ids };
@@ -488,11 +486,25 @@ where
 fn create_step_element(group_element: &Element, step: &ron_api::Step) -> Element {
     let step_id = step.id;
     let step_element: Element = selector_and_clone("#hidden-templates .step");
-    step_element
-        .set_attribute("id", &format!("step-{}", step.id))
-        .unwrap();
+    step_element.set_id(&format!("step-{}", step.id));
     group_element.append_child(&step_element).unwrap();
 
+    set_draggable(&step_element, "step", |element| {
+        let element = element.clone();
+        spawn_local(async move {
+            let step_ids = element
+                .parent_element()
+                .unwrap()
+                .selector_all::<Element>(".step")
+                .into_iter()
+                .map(|e| e.id()[5..].parse::<i64>().unwrap())
+                .collect();
+
+            let body = ron_api::SetStepOrders { step_ids };
+            let _ = request::put::<(), _>("recipe/set_steps_order", body).await;
+        });
+    });
+
     // Step action.
     let action: HtmlTextAreaElement = step_element.selector(".text-area-step-action");
     action.set_value(&step.action);
@@ -556,9 +568,7 @@ fn create_step_element(group_element: &Element, step: &ron_api::Step) -> Element
 fn create_ingredient_element(step_element: &Element, ingredient: &ron_api::Ingredient) -> Element {
     let ingredient_id = ingredient.id;
     let ingredient_element: Element = selector_and_clone("#hidden-templates .ingredient");
-    ingredient_element
-        .set_attribute("id", &format!("ingredient-{}", ingredient.id))
-        .unwrap();
+    ingredient_element.set_id(&format!("ingredient-{}", ingredient.id));
     step_element.append_child(&ingredient_element).unwrap();
 
     // Ingredient name.
@@ -664,6 +674,32 @@ fn create_ingredient_element(step_element: &Element, ingredient: &ron_api::Ingre
     ingredient_element
 }
 
+// "text/plain" is avoided to prevent dropping to a input text box.
+const DRAG_AND_DROP_MIME_TYPE: &str = "recipes/element-id";
+
+enum CursorPosition {
+    UpperPart,
+    LowerPart,
+}
+
+fn get_cursor_position(mouse_y: f64, element: &Element) -> CursorPosition {
+    let element_y = element.get_bounding_client_rect().y();
+    // Between 0 (top) and 1 (bottom).
+    let y_relative_pos = (mouse_y - element_y) / element.get_bounding_client_rect().height();
+    if y_relative_pos < 0.5 {
+        CursorPosition::UpperPart
+    } else {
+        CursorPosition::LowerPart
+    }
+}
+
+fn get_parent_with_id_starting_with(mut element: Element, prefix: &str) -> Element {
+    while !element.id().starts_with(prefix) {
+        element = element.parent_element().unwrap();
+    }
+    element
+}
+
 /// Set an element as draggable and add an element before and after
 /// cloned from "#hidden-templates .dropzone".
 /// All elements set as draggable in a given container can be dragged
@@ -687,10 +723,139 @@ where
     element.after_with_node_1(&dropzone).unwrap();
     setup_dragzone_events(&dropzone, prefix, dropped.clone());
 
+    // DRAGOVER.
+    let prefix_copied = prefix.to_string();
+    EventListener::new_with_options(
+        element,
+        "dragover",
+        EventListenerOptions::enable_prevent_default(),
+        move |event| {
+            let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
+            let drag_data = event
+                .data_transfer()
+                .unwrap()
+                .get_data(DRAG_AND_DROP_MIME_TYPE)
+                .unwrap();
+
+            if drag_data.starts_with(&prefix_copied) {
+                let element: Element = by_id(&drag_data);
+                let element_target = event
+                    .current_target()
+                    .unwrap()
+                    .dyn_into::<Element>()
+                    .unwrap();
+
+                if element.parent_element() != element_target.parent_element() {
+                    return;
+                }
+
+                event.prevent_default();
+
+                let cursor_position = get_cursor_position(event.client_y() as f64, &element_target);
+
+                element_target
+                    .previous_element_sibling()
+                    .unwrap()
+                    .set_class_name(match cursor_position {
+                        CursorPosition::UpperPart => "dropzone hover",
+                        CursorPosition::LowerPart => "dropzone active",
+                    });
+
+                element_target
+                    .next_element_sibling()
+                    .unwrap()
+                    .set_class_name(match cursor_position {
+                        CursorPosition::UpperPart => "dropzone active",
+                        CursorPosition::LowerPart => "dropzone hover",
+                    });
+            }
+        },
+    )
+    .forget();
+
+    // DRAGLEAVE.
+    let prefix_copied = prefix.to_string();
+    EventListener::new(element, "dragleave", move |event| {
+        let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
+        let drag_data = event
+            .data_transfer()
+            .unwrap()
+            .get_data(DRAG_AND_DROP_MIME_TYPE)
+            .unwrap();
+
+        if drag_data.starts_with(&prefix_copied) {
+            let element: Element = by_id(&drag_data);
+            let element_target = event
+                .current_target()
+                .unwrap()
+                .dyn_into::<Element>()
+                .unwrap();
+
+            if element.parent_element() != element_target.parent_element() {
+                return;
+            }
+
+            element_target
+                .previous_element_sibling()
+                .unwrap()
+                .set_class_name("dropzone active");
+
+            element_target
+                .next_element_sibling()
+                .unwrap()
+                .set_class_name("dropzone active");
+        }
+    })
+    .forget();
+
+    // DROP.
+    let prefix_copied = prefix.to_string();
+    EventListener::new(element, "drop", move |event| {
+        let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
+        let drag_data = event
+            .data_transfer()
+            .unwrap()
+            .get_data(DRAG_AND_DROP_MIME_TYPE)
+            .unwrap();
+
+        if drag_data.starts_with(&prefix_copied) {
+            let target: Element = event.current_target().unwrap().dyn_into().unwrap();
+            let element: Element = by_id(&drag_data);
+            let dropzone: Element = element.next_element_sibling().unwrap();
+
+            if element.parent_element() != target.parent_element() {
+                return;
+            }
+
+            match get_cursor_position(event.client_y() as f64, &element) {
+                CursorPosition::UpperPart => {
+                    target
+                        .previous_element_sibling()
+                        .unwrap()
+                        .after_with_node_1(&element)
+                        .unwrap();
+                    element.after_with_node_1(&dropzone).unwrap();
+                }
+                CursorPosition::LowerPart => {
+                    target
+                        .next_element_sibling()
+                        .unwrap()
+                        .after_with_node_1(&element)
+                        .unwrap();
+                    element.after_with_node_1(&dropzone).unwrap();
+                }
+            }
+
+            dropped(&element);
+        }
+    })
+    .forget();
+
+    // MOUSEDOWN.
     let drag_handle: Element = element.selector(".drag-handle");
     EventListener::new(&drag_handle, "mousedown", |event| {
         event
-            .target()
+            .current_target()
             .unwrap()
             .dyn_into::<Element>()
             .unwrap()
@@ -701,9 +866,10 @@ where
     })
     .forget();
 
+    // MOUSEUP.
     EventListener::new(&drag_handle, "mouseup", |event| {
         event
-            .target()
+            .current_target()
             .unwrap()
             .dyn_into::<Element>()
             .unwrap()
@@ -714,52 +880,41 @@ where
     })
     .forget();
 
+    // DRAGSTART.
     let prefix_copied = prefix.to_string();
     EventListener::new(element, "dragstart", move |event| {
         let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
-        let target_element: Element = event.target().unwrap().dyn_into().unwrap();
+        let target_element: Element = event.current_target().unwrap().dyn_into().unwrap();
+
+        if target_element.id().starts_with(&prefix_copied) {
+            event.stop_propagation();
 
-        if target_element
-            .get_attribute("id")
-            .unwrap()
-            .starts_with(&prefix_copied)
-        {
             // Highlight where the group can be droppped.
-            // TODO: only select direct children.
             for dp in target_element
                 .parent_element()
                 .unwrap()
                 .selector_all::<HtmlDivElement>(".dropzone")
             {
-                dp.set_class_name("dropzone active");
+                if dp.parent_element() == target_element.parent_element() {
+                    dp.set_class_name("dropzone active");
+                }
             }
             event
                 .data_transfer()
                 .unwrap()
-                .set_data("text/plain", &target_element.get_attribute("id").unwrap())
+                .set_data(DRAG_AND_DROP_MIME_TYPE, &target_element.id())
                 .unwrap();
             event.data_transfer().unwrap().set_effect_allowed("move");
         }
     })
     .forget();
 
+    // DRAGEND.
     let prefix_copied = prefix.to_string();
     EventListener::new(element, "dragend", move |event| {
-        // let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
-        event
-            .target()
-            .unwrap()
-            .dyn_into::<Element>()
-            .unwrap()
-            .set_attribute("draggable", "false")
-            .unwrap();
-
-        let target_element: Element = event.target().unwrap().dyn_into().unwrap();
-        if target_element
-            .get_attribute("id")
-            .unwrap()
-            .starts_with(&prefix_copied)
-        {
+        let target_element: Element = event.current_target().unwrap().dyn_into().unwrap();
+        target_element.set_attribute("draggable", "false").unwrap();
+        if target_element.id().starts_with(&prefix_copied) {
             for dp in target_element
                 .parent_element()
                 .unwrap()
@@ -770,80 +925,107 @@ where
         }
     })
     .forget();
-}
 
-fn setup_dragzone_events<T>(dropzone: &Element, prefix: &str, dropped: rc::Rc<T>)
-where
-    T: Fn(&Element) + 'static,
-{
-    let prefix_copied = prefix.to_string();
-    EventListener::new_with_options(
-        dropzone,
-        "dragover",
-        EventListenerOptions::enable_prevent_default(),
-        move |event| {
+    fn setup_dragzone_events<T>(dropzone: &Element, prefix: &str, dropped: rc::Rc<T>)
+    where
+        T: Fn(&Element) + 'static,
+    {
+        // DRAGOVER (dropzone).
+        let prefix_copied = prefix.to_string();
+        EventListener::new_with_options(
+            dropzone,
+            "dragover",
+            EventListenerOptions::enable_prevent_default(),
+            move |event| {
+                let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
+                let drag_data = event
+                    .data_transfer()
+                    .unwrap()
+                    .get_data(DRAG_AND_DROP_MIME_TYPE)
+                    .unwrap();
+
+                if drag_data.starts_with(&prefix_copied) {
+                    let element: Element = by_id(&drag_data);
+                    let element_target = event
+                        .current_target()
+                        .unwrap()
+                        .dyn_into::<Element>()
+                        .unwrap();
+
+                    if element.parent_element() != element_target.parent_element() {
+                        return;
+                    }
+
+                    event.prevent_default();
+
+                    event
+                        .current_target()
+                        .unwrap()
+                        .dyn_into::<Element>()
+                        .unwrap()
+                        .set_class_name("dropzone hover");
+                }
+            },
+        )
+        .forget();
+
+        // DRAGLEAVE (dropzone).
+        let prefix_copied = prefix.to_string();
+        EventListener::new(dropzone, "dragleave", move |event| {
             let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
             let drag_data = event
                 .data_transfer()
                 .unwrap()
-                .get_data("text/plain")
+                .get_data(DRAG_AND_DROP_MIME_TYPE)
                 .unwrap();
 
             if drag_data.starts_with(&prefix_copied) {
-                event.prevent_default();
-                // event.data_transfer().unwrap().set_effect_allowed("move");
-                // log!("drag over");
+                let element: Element = by_id(&drag_data);
+                let element_target = event
+                    .current_target()
+                    .unwrap()
+                    .dyn_into::<Element>()
+                    .unwrap();
+
+                if element.parent_element() != element_target.parent_element() {
+                    return;
+                }
+
                 event
-                    .target()
+                    .current_target()
                     .unwrap()
                     .dyn_into::<Element>()
                     .unwrap()
-                    .set_class_name("dropzone hover");
+                    .set_class_name("dropzone active");
             }
-        },
-    )
-    .forget();
-
-    let prefix_copied = prefix.to_string();
-    EventListener::new(dropzone, "dragleave", move |event| {
-        let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
-        let drag_data = event
-            .data_transfer()
-            .unwrap()
-            .get_data("text/plain")
-            .unwrap();
+        })
+        .forget();
 
-        if drag_data.starts_with(&prefix_copied) {
-            // log!("drag leave");
-            event
-                .target()
-                .unwrap()
-                .dyn_into::<Element>()
+        // DROP (dropzone).
+        let prefix_copied = prefix.to_string();
+        EventListener::new(dropzone, "drop", move |event| {
+            let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
+            let drag_data = event
+                .data_transfer()
                 .unwrap()
-                .set_class_name("dropzone active");
-        }
-    })
-    .forget();
+                .get_data(DRAG_AND_DROP_MIME_TYPE)
+                .unwrap();
 
-    let prefix_copied = prefix.to_string();
-    EventListener::new(dropzone, "drop", move |event| {
-        let event: &DragEvent = event.dyn_ref::<DragEvent>().unwrap();
-        let drag_data = event
-            .data_transfer()
-            .unwrap()
-            .get_data("text/plain")
-            .unwrap();
+            if drag_data.starts_with(&prefix_copied) {
+                let target: Element = event.current_target().unwrap().dyn_into().unwrap();
+                let element: Element = by_id(&drag_data);
+                let dropzone: Element = element.next_element_sibling().unwrap();
 
-        if drag_data.starts_with(&prefix_copied) {
-            let id: i64 = drag_data[prefix_copied.len() + 1..].parse().unwrap();
-            let target: Element = event.target().unwrap().dyn_into().unwrap();
-            let element: Element = by_id(&format!("{}-{}", &prefix_copied, id));
-            let group_dropzone: Element = element.next_element_sibling().unwrap(); // = by_id(&format!("dropzone-group-{}", id));
-            target.after_with_node_1(&element).unwrap();
-            element.after_with_node_1(&group_dropzone).unwrap();
+                if element.parent_element() != target.parent_element() {
+                    return;
+                }
 
-            dropped(&element);
-        }
-    })
-    .forget();
+                target.after_with_node_1(&element).unwrap();
+                element.after_with_node_1(&dropzone).unwrap();
+
+                dropped(&element);
+            }
+        })
+        .forget();
+    }
 }
index 58e7803..6e400e0 100644 (file)
@@ -1,4 +1,4 @@
-use gloo::{console::log, utils::document};
+use gloo::utils::document;
 use wasm_bindgen::prelude::*;
 use web_sys::Element;