diff --git a/backend/tests/uxbox/tests/test_common_pages.clj b/backend/tests/uxbox/tests/test_common_pages.clj index 7d3347d49..bfd40df0e 100644 --- a/backend/tests/uxbox/tests/test_common_pages.clj +++ b/backend/tests/uxbox/tests/test_common_pages.clj @@ -15,19 +15,42 @@ (t/deftest process-change-add-obj-1 (let [data cp/default-page-data - id (uuid/next) - chg {:type :add-obj - :id id - :frame-id uuid/zero - :obj {:id id - :frame-id uuid/zero - :type :rect - :name "rect"}} - res (cp/process-changes data [chg])] + id-a (uuid/next) + id-b (uuid/next) + id-c (uuid/next)] + (t/testing "Adds single object" + (let [chg {:type :add-obj + :id id-a + :frame-id uuid/zero + :obj {:id id-a + :frame-id uuid/zero + :type :rect + :name "rect"}} + res (cp/process-changes data [chg])] - (t/is (= 2 (count (:objects res)))) - (t/is (= (:obj chg) (get-in res [:objects id]))) - (t/is (= [id] (get-in res [:objects uuid/zero :shapes]))))) + (t/is (= 2 (count (:objects res)))) + (t/is (= (:obj chg) (get-in res [:objects id-a]))) + (t/is (= [id-a] (get-in res [:objects uuid/zero :shapes]))))) + (t/testing "Adds several objects with different indexes" + (let [data cp/default-page-data + + chg (fn [id index] {:type :add-obj + :id id + :frame-id uuid/zero + :index index + :obj {:id id + :frame-id uuid/zero + :type :rect + :name (str id)}}) + res (cp/process-changes data [(chg id-a 0) + (chg id-b 0) + (chg id-c 1)])] + + (t/is (= 4 (count (:objects res)))) + (t/is (not (nil? (get-in res [:objects id-a])))) + (t/is (not (nil? (get-in res [:objects id-b])))) + (t/is (not (nil? (get-in res [:objects id-c])))) + (t/is (= [id-b id-c id-a] (get-in res [:objects uuid/zero :shapes]))))))) (t/deftest process-change-mod-obj (let [data cp/default-page-data @@ -177,3 +200,115 @@ (t/is (= [id3 id1 id2] (get-in res [:objects uuid/zero :shapes]))))) )) +(t/deftest process-changes-move-objects + (let [frame-a-id (uuid/next) + frame-b-id (uuid/next) + group-a-id (uuid/next) + group-b-id (uuid/next) + rect-a-id (uuid/next) + rect-b-id (uuid/next) + rect-c-id (uuid/next) + rect-d-id (uuid/next) + rect-e-id (uuid/next) + data (-> cp/default-page-data + (assoc-in [cp/root :shapes] [frame-a-id]) + (assoc-in [:objects frame-a-id] {:id frame-a-id :name "Frame a" :type :frame}) + (assoc-in [:objects frame-b-id] {:id frame-b-id :name "Frame b" :type :frame}) + + ;; Groups + (assoc-in [:objects group-a-id] {:id group-a-id :name "Group A" :type :group :frame-id frame-a-id}) + (assoc-in [:objects group-b-id] {:id group-b-id :name "Group B" :type :group :frame-id frame-a-id}) + + ;; Shapes + (assoc-in [:objects rect-a-id] {:id rect-a-id :name "Rect A" :type :rect :frame-id frame-a-id}) + (assoc-in [:objects rect-b-id] {:id rect-b-id :name "Rect B" :type :rect :frame-id frame-a-id}) + (assoc-in [:objects rect-c-id] {:id rect-c-id :name "Rect C" :type :rect :frame-id frame-a-id}) + (assoc-in [:objects rect-d-id] {:id rect-d-id :name "Rect D" :type :rect :frame-id frame-a-id}) + (assoc-in [:objects rect-e-id] {:id rect-e-id :name "Rect E" :type :rect :frame-id frame-a-id}) + + ;; Relationships + (assoc-in [:objects cp/root :shapes] [frame-a-id frame-b-id]) + (assoc-in [:objects frame-a-id :shapes] [group-a-id group-b-id rect-e-id]) + (assoc-in [:objects group-a-id :shapes] [rect-a-id rect-b-id rect-c-id]) + (assoc-in [:objects group-b-id :shapes] [rect-d-id]))] + (t/testing "Create new group an add objects from the same group" + (let [new-group-id (uuid/next) + changes [{:type :add-obj + :id new-group-id + :frame-id frame-a-id + :obj {:id new-group-id + :type :group + :frame-id frame-a-id + :name "Group C"}} + {:type :mov-objects + :parent-id new-group-id + :shapes [rect-b-id rect-c-id]}] + res (cp/process-changes data changes)] + (t/is (= [group-a-id group-b-id rect-e-id new-group-id] (get-in res [:objects frame-a-id :shapes]))) + (t/is (= [rect-b-id rect-c-id] (get-in res [:objects new-group-id :shapes]))) + (t/is (= [rect-a-id] (get-in res [:objects group-a-id :shapes]))))) + + (t/testing "Move elements to an existing group at index" + (let [changes [{:type :mov-objects + :parent-id group-b-id + :index 0 + :shapes [rect-a-id rect-c-id]}] + res (cp/process-changes data changes)] + (t/is (= [group-a-id group-b-id rect-e-id] (get-in res [:objects frame-a-id :shapes]))) + (t/is (= [rect-b-id] (get-in res [:objects group-a-id :shapes]))) + (t/is (= [rect-a-id rect-c-id rect-d-id] (get-in res [:objects group-b-id :shapes]))))) + + (t/testing "Move elements from group and frame to an existing group at index" + (let [changes [{:type :mov-objects + :parent-id group-b-id + :index 0 + :shapes [rect-a-id rect-e-id]}] + res (cp/process-changes data changes)] + (t/is (= [group-a-id group-b-id] (get-in res [:objects frame-a-id :shapes]))) + (t/is (= [rect-b-id rect-c-id] (get-in res [:objects group-a-id :shapes]))) + (t/is (= [rect-a-id rect-e-id rect-d-id] (get-in res [:objects group-b-id :shapes]))))) + + (t/testing "Move elements from several groups" + (let [changes [{:type :mov-objects + :parent-id group-b-id + :index 0 + :shapes [rect-a-id rect-e-id]}] + res (cp/process-changes data changes)] + (t/is (= [group-a-id group-b-id] (get-in res [:objects frame-a-id :shapes]))) + (t/is (= [rect-b-id rect-c-id] (get-in res [:objects group-a-id :shapes]))) + (t/is (= [rect-a-id rect-e-id rect-d-id] (get-in res [:objects group-b-id :shapes]))))) + + (t/testing "Move elements and delete the empty group" + (let [changes [{:type :mov-objects + :parent-id group-a-id + :shapes [rect-d-id]}] + res (cp/process-changes data changes)] + (t/is (= [group-a-id rect-e-id] (get-in res [:objects frame-a-id :shapes]))) + (t/is (nil? (get-in res [:objects group-b-id]))))) + + (t/testing "Move elements to a group with different frame" + (let [changes [{:type :mov-objects + :parent-id frame-b-id + :shapes [group-a-id]}] + res (cp/process-changes data changes)] + (t/is (= [group-b-id rect-e-id] (get-in res [:objects frame-a-id :shapes]))) + (t/is (= [group-a-id] (get-in res [:objects frame-b-id :shapes]))) + (t/is (= frame-b-id (get-in res [:objects group-a-id :frame-id]))) + (t/is (= frame-b-id (get-in res [:objects rect-a-id :frame-id]))) + (t/is (= frame-b-id (get-in res [:objects rect-b-id :frame-id]))) + (t/is (= frame-b-id (get-in res [:objects rect-c-id :frame-id]))))) + + (t/testing "Move elements to frame zero" + (let [changes [{:type :mov-objects + :parent-id cp/root + :shapes [group-a-id] + :index 0}] + res (cp/process-changes data changes)] + (t/is (= [group-a-id frame-a-id frame-b-id] (get-in res [:objects cp/root :shapes]))))) + + (t/testing "Don't allow to move inside self" + (let [changes [{:type :mov-objects + :parent-id group-a-id + :shapes [group-a-id]}] + res (cp/process-changes data changes)] + (t/is (= data res)))))) diff --git a/common/uxbox/common/data.cljc b/common/uxbox/common/data.cljc index d1c4bb607..ade40bfcc 100644 --- a/common/uxbox/common/data.cljc +++ b/common/uxbox/common/data.cljc @@ -90,6 +90,11 @@ (persistent! (reduce #(dissoc! %1 %2) (transient data) keys))) +(defn insert-at-index [vector index elements] + (let [[before after] (split-at index vector)] + (concat [] before elements after))) + + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Data Parsing / Conversion ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/common/uxbox/common/pages.cljc b/common/uxbox/common/pages.cljc index a04a09ba9..49ffe4d0d 100644 --- a/common/uxbox/common/pages.cljc +++ b/common/uxbox/common/pages.cljc @@ -25,6 +25,7 @@ (s/def ::shape-id uuid?) (s/def ::session-id uuid?) (s/def ::name string?) +(s/def ::parent-id uuid?) ;; Page Options (s/def ::grid-x number?) @@ -151,6 +152,10 @@ (s/keys :req-un [::id ::frame-id] :opt-un [::session-id])) +(defmethod change-spec-impl :mov-objects [_] + (s/keys :req-un [::parent-id ::shapes] + :opt-un [::index])) + (s/def ::change (s/multi-spec change-spec-impl :type)) (s/def ::changes (s/coll-of ::change)) @@ -201,15 +206,9 @@ (update-in [:objects frame-id :shapes] (fn [shapes] (cond - (some #{id} shapes) - shapes - - (nil? index) - (conj shapes id) - - :else - (let [[before after] (split-at index shapes)] - (d/concat [] before [id] after)))))))) + (some #{id} shapes) shapes + (nil? index) (conj shapes id) + :else (d/insert-at-index shapes index [id]))))))) (defmethod process-change :mod-obj [data {:keys [id operations] :as change}] @@ -238,6 +237,73 @@ (seq shapes) ; Recursive delete all dependend objects (as-> $ (reduce #(or (process-change %1 {:type :del-obj :id %2}) %1) $ shapes)))))) +(defn- calculate-child-parent-map + [objects] + (let [red-fn + (fn [acc {:keys [id shapes]}] + ;; Insert every pair shape -> parent into accumulated value + (into acc (map #(vector % id) (or shapes []))))] + (reduce red-fn {} (vals objects)))) + +(defn- calculate-invalid-targets [shape-id objects] + (let [result #{shape-id} + children (get-in objects [shape-id :shape]) + reduce-fn (fn [result child-id] + (into result (calculate-invalid-targets child-id objects)))] + (reduce reduce-fn result children))) + +(defmethod process-change :mov-objects + [data {:keys [parent-id shapes index] :as change}] + (let [child->parent (calculate-child-parent-map (:objects data)) + ;; Check if the move from shape-id -> parent-id is valid + is-valid-move + (fn [shape-id] + (let [invalid (calculate-invalid-targets shape-id (:objects data))] + (not (invalid parent-id)))) + + valid? (every? is-valid-move shapes) + + ;; Add items into the :shapes property of the target parent-id + add-items + (fn [old-shapes] + (let [old-shapes (or old-shapes [])] + (if index + (d/insert-at-index old-shapes index shapes) + (into old-shapes shapes)))) + + ;; Remove from the old :shapes the references that have been moved + remove-in-parent + (fn [data shape-id] + (let [parent-id (child->parent shape-id) + filter-shape (partial filterv #(not (= % shape-id)) ) + data-removed (update-in data [:objects parent-id :shapes] filter-shape) + parent (get-in data-removed [:objects parent-id])] + ;; When the group is empty we should remove it + (if (and (= :group (:type parent)) + (empty? (:shapes parent))) + (-> data-removed + (update :objects dissoc parent-id ) + (recur parent-id)) + data-removed))) + + ;; Frame-id of the target element + frame-id (if (= :frame (get-in data [:objects parent-id :type])) + parent-id + (get-in data [:objects parent-id :frame-id])) + + ;; Updates the frame-id references that might be outdated + update-frame-ids + (fn update-frame-ids [data shape-id] + (as-> data $ + (assoc-in $ [:objects shape-id :frame-id] frame-id) + (reduce update-frame-ids $ (get-in $ [:objects shape-id :shapes]))))] + (if valid? + (as-> data $ + (update-in $ [:objects parent-id :shapes] add-items) + (reduce remove-in-parent $ shapes) + (reduce update-frame-ids $ (get-in $ [:objects parent-id :shapes]))) + data))) + (defmethod process-operation :set [shape op] (let [attr (:attr op) diff --git a/frontend/src/uxbox/main/data/workspace.cljs b/frontend/src/uxbox/main/data/workspace.cljs index 00f2b822b..e66585658 100644 --- a/frontend/src/uxbox/main/data/workspace.cljs +++ b/frontend/src/uxbox/main/data/workspace.cljs @@ -2211,7 +2211,7 @@ {:id id :type :group :name (name (gensym "Group-")) - :shapes (vec selected) + :shapes [] :frame-id frame-id :x (:x selection-rect) :y (:y selection-rect) @@ -2227,30 +2227,26 @@ (if (not-empty selected) (let [page-id (get-in state [:workspace-page :id]) objects (get-in state [:workspace-data page-id :objects]) - parent (get-parent (first selected) (vals objects)) - parent-id (:id parent) selected-objects (map (partial get objects) selected) selection-rect (geom/selection-rect selected-objects) frame-id (-> selected-objects first :frame-id) - group-shape (group-shape id frame-id selected selection-rect)] + group-shape (group-shape id frame-id selected selection-rect) + frame-children (get-in objects [frame-id :shapes]) + index-frame (->> frame-children (map-indexed vector) (filter #(selected (second %))) first first)] - (let [updated-parent (helpers/replace-shapes parent selected id) - rchanges [{:type :add-obj + (let [rchanges [{:type :add-obj :id id :frame-id frame-id - :obj group-shape} - {:type :mod-obj - :id parent-id - :operations [{:type :set - :attr :shapes - :val (:shapes updated-parent)}]}] - uchanges [{:type :del-obj - :id id} - {:type :mod-obj - :id parent-id - :operations [{:type :set - :attr :shapes - :val (:shapes parent)}]}]] + :obj group-shape + :index index-frame} + {:type :mov-objects + :parent-id id + :shapes (into [] selected)}] + uchanges [{:type :mov-objects + :parent-id frame-id + :shapes (into [] selected)} + {:type :del-obj + :id id}]] (rx/of (commit-changes rchanges uchanges {:commit-local? true}) (fn [state] (assoc-in state [:workspace-local :selected] #{id}))))) rx/empty)))))) @@ -2264,26 +2260,28 @@ group (get-in state [:workspace-data (::page-id state) :objects group-id])] (if (and (= (count selected) 1) (= (:type group) :group)) (let [objects (get-in state [:workspace-data (::page-id state) :objects]) + shapes (get-in objects [group-id :shapes]) parent-id (helpers/get-parent group-id objects) - parent (get objects parent-id)] - (let [changed-parent (helpers/replace-shapes parent group-id (:shapes group)) - rchanges [{:type :mod-obj - :id parent-id - :operations [{:type :set :attr :shapes :val (:shapes changed-parent)}]} - - ;; Need to modify the object otherwise the children will be deleted - {:type :mod-obj - :id group-id - :operations [{:type :set :attr :shapes :val []}]} - {:type :del-obj - :id group-id}] + parent (get objects parent-id) + index-in-parent (->> (:shapes parent) + (map-indexed vector) + (filter #(#{group-id} (second %))) + first first)] + (let [rchanges [{:type :mov-objects + :parent-id parent-id + :shapes shapes + :index index-in-parent}] uchanges [{:type :add-obj :id group-id :frame-id (:frame-id group) - :obj group} - {:type :mod-obj - :id parent-id - :operations [{:type :set :attr :shapes :val (:shapes parent)}]}]] + :obj (assoc group :shapes [])} + {:type :mov-objects + :parent-id group-id + :shapes shapes} + {:type :mov-objects + :parent-id parent-id + :shapes [group-id] + :index index-in-parent}]] (rx/of (commit-changes rchanges uchanges {:commit-local? true})))) rx/empty)))))