diff --git a/frontend/src/app/main/data/workspace/groups.cljs b/frontend/src/app/main/data/workspace/groups.cljs index bf930e16d..32324c854 100644 --- a/frontend/src/app/main/data/workspace/groups.cljs +++ b/frontend/src/app/main/data/workspace/groups.cljs @@ -25,7 +25,7 @@ (= (count shapes) 1) (= (:type (first shapes)) :group)) (:name (first shapes)) - (name (gensym prefix)))] + (name (gensym prefix)))] ; TODO: we should something like in new shapes (-> (cp/make-minimal-group frame-id selrect group-name) (gsh/setup selrect) (assoc :shapes (mapv :id shapes))))) diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 5fcdf4010..a5bd1fd44 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -17,7 +17,6 @@ [app.common.geom.shapes :as geom] [app.main.data.messages :as dm] [app.main.data.workspace.common :as dwc] - [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.libraries-helpers :as dwlh] [app.common.pages :as cp] [app.main.repo :as rp] @@ -224,87 +223,22 @@ (rx/of (dwc/commit-changes [rchg] [uchg] {:commit-local? true})))))) (def add-component - "Add a new component to current file library, from the currently selected shapes" + "Add a new component to current file library, from the currently selected shapes." (ptk/reify ::add-component ptk/WatchEvent (watch [_ state stream] (let [file-id (:current-file-id state) page-id (:current-page-id state) objects (dwc/lookup-page-objects state page-id) - selected (get-in state [:workspace-local :selected]) - shapes (dwg/shapes-for-grouping objects selected)] - (when-not (empty? shapes) - (let [;; If the selected shape is a group, we can use it. If not, - ;; we need to create a group before creating the component. - [group rchanges uchanges] - (if (and (= (count shapes) 1) - (= (:type (first shapes)) :group)) - [(first shapes) [] []] - (dwg/prepare-create-group page-id shapes "Component-" true)) - - [new-shape new-shapes updated-shapes] - (dwlh/make-component-shape group objects file-id) - - rchanges (conj rchanges - {:type :add-component - :id (:id new-shape) - :name (:name new-shape) - :shapes new-shapes}) - - rchanges (into rchanges - (map (fn [updated-shape] - {:type :mod-obj - :page-id page-id - :id (:id updated-shape) - :operations [{:type :set - :attr :component-id - :val (:component-id updated-shape)} - {:type :set - :attr :component-file - :val (:component-file updated-shape)} - {:type :set - :attr :component-root? - :val (:component-root? updated-shape)} - {:type :set - :attr :shape-ref - :val (:shape-ref updated-shape)} - {:type :set - :attr :touched - :val (:touched updated-shape)}]}) - updated-shapes)) - - uchanges (conj uchanges - {:type :del-component - :id (:id new-shape)}) - - uchanges (into uchanges - (map (fn [updated-shape] - (let [original-shape (get objects (:id updated-shape))] - {:type :mod-obj - :page-id page-id - :id (:id updated-shape) - :operations [{:type :set - :attr :component-id - :val (:component-id original-shape)} - {:type :set - :attr :component-file - :val (:component-file original-shape)} - {:type :set - :attr :component-root? - :val (:component-root? original-shape)} - {:type :set - :attr :shape-ref - :val (:shape-ref original-shape)} - {:type :set - :attr :touched - :val (:touched original-shape)}]})) - updated-shapes))] - - + selected (get-in state [:workspace-local :selected])] + (let [[group rchanges uchanges] + (dwlh/generate-add-component selected objects page-id file-id)] + (when-not (empty? rchanges) (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true}) (dwc/select-shapes (d/ordered-set (:id group)))))))))) (defn rename-component + "Rename the component with the given id, in the current file library." [id new-name] (us/assert ::us/uuid id) (us/assert ::us/string new-name) diff --git a/frontend/src/app/main/data/workspace/libraries_helpers.cljs b/frontend/src/app/main/data/workspace/libraries_helpers.cljs index 48d65b964..d6f8bff60 100644 --- a/frontend/src/app/main/data/workspace/libraries_helpers.cljs +++ b/frontend/src/app/main/data/workspace/libraries_helpers.cljs @@ -16,6 +16,7 @@ [app.common.geom.point :as gpt] [app.common.geom.shapes :as geom] [app.common.pages :as cp] + [app.main.data.workspace.groups :as dwg] [app.util.logging :as log] [app.util.text :as ut])) @@ -122,6 +123,78 @@ (cp/clone-object shape nil objects update-new-shape update-original-shape))) +(defn generate-add-component + "If there is exactly one id, and it's a group, use it as root. Otherwise, + create a group that contains all ids. Then, make a component with it, + and link all shapes to their corresponding one in the component." + [ids objects page-id file-id] + (let [shapes (dwg/shapes-for-grouping objects ids) + + [group rchanges uchanges] + (if (and (= (count shapes) 1) + (= (:type (first shapes)) :group)) + [(first shapes) [] []] + (dwg/prepare-create-group page-id shapes "Component-" true)) + + [new-shape new-shapes updated-shapes] + (make-component-shape group objects file-id) + + rchanges (conj rchanges + {:type :add-component + :id (:id new-shape) + :name (:name new-shape) + :shapes new-shapes}) + + rchanges (into rchanges + (map (fn [updated-shape] + {:type :mod-obj + :page-id page-id + :id (:id updated-shape) + :operations [{:type :set + :attr :component-id + :val (:component-id updated-shape)} + {:type :set + :attr :component-file + :val (:component-file updated-shape)} + {:type :set + :attr :component-root? + :val (:component-root? updated-shape)} + {:type :set + :attr :shape-ref + :val (:shape-ref updated-shape)} + {:type :set + :attr :touched + :val (:touched updated-shape)}]}) + updated-shapes)) + + uchanges (conj uchanges + {:type :del-component + :id (:id new-shape)}) + + uchanges (into uchanges + (map (fn [updated-shape] + (let [original-shape (get objects (:id updated-shape))] + {:type :mod-obj + :page-id page-id + :id (:id updated-shape) + :operations [{:type :set + :attr :component-id + :val (:component-id original-shape)} + {:type :set + :attr :component-file + :val (:component-file original-shape)} + {:type :set + :attr :component-root? + :val (:component-root? original-shape)} + {:type :set + :attr :shape-ref + :val (:shape-ref original-shape)} + {:type :set + :attr :touched + :val (:touched original-shape)}]})) + updated-shapes))] + [group rchanges uchanges])) + (defn duplicate-component "Clone the root shape of the component and all children. Generate new ids from all of them." diff --git a/frontend/tests/app/test_helpers/events.cljs b/frontend/tests/app/test_helpers/events.cljs new file mode 100644 index 000000000..106510ab3 --- /dev/null +++ b/frontend/tests/app/test_helpers/events.cljs @@ -0,0 +1,39 @@ +(ns app.test-helpers.events + (:require [cljs.test :as t :include-macros true] + [cljs.pprint :refer [pprint]] + [beicon.core :as rx] + [potok.core :as ptk] + [app.common.uuid :as uuid] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] + [app.common.pages :as cp] + [app.common.pages.helpers :as cph] + [app.main.data.workspace :as dw])) + +;; ---- Helpers to manage global events + +(defn do-update + "Execute an update event and returns the new state." + [event state] + (ptk/update event state)) + +(defn do-watch + "Execute a watch event and return an observable, that + emits once a list with all new events." + [event state] + (->> (ptk/watch event state nil) + (rx/reduce conj []))) + +(defn do-watch-update + "Execute a watch event and return an observable, that + emits once the new state, after all new events applied + in sequence (considering they are all update events)." + [event state] + (->> (do-watch event state) + (rx/map (fn [new-events] + (reduce + (fn [new-state new-event] + (do-update new-event new-state)) + state + new-events))))) + diff --git a/frontend/tests/app/test_helpers/libraries.cljs b/frontend/tests/app/test_helpers/libraries.cljs new file mode 100644 index 000000000..b4bd7aa0e --- /dev/null +++ b/frontend/tests/app/test_helpers/libraries.cljs @@ -0,0 +1,90 @@ +(ns app.test-helpers.libraries + (:require [cljs.test :as t :include-macros true] + [cljs.pprint :refer [pprint]] + [beicon.core :as rx] + [potok.core :as ptk] + [app.common.uuid :as uuid] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] + [app.common.pages :as cp] + [app.common.pages.helpers :as cph] + [app.main.data.workspace :as dw] + [app.main.data.workspace.libraries-helpers :as dwlh] + [app.test-helpers.pages :as thp])) + +;; ---- Helpers to manage libraries and synchronization + +(defn is-instance-root + [shape] + (t/is (some? (:shape-ref shape))) + (t/is (some? (:component-id shape))) + (t/is (= (:component-root? shape) true))) + +(defn is-instance-subroot + [shape] + (t/is (some? (:shape-ref shape))) + (t/is (some? (:component-id shape))) + (t/is (nil? (:component-root? shape)))) + +(defn is-instance-head + [shape] + (t/is (some? (:shape-ref shape))) + (t/is (some? (:component-id shape)))) + +(defn is-instance-child + [shape] + (t/is (some? (:shape-ref shape))) + (t/is (nil? (:component-id shape))) + (t/is (nil? (:component-file shape))) + (t/is (nil? (:component-root? shape)))) + +(defn is-noninstance + [shape] + (t/is (nil? (:shape-ref shape))) + (t/is (nil? (:component-id shape))) + (t/is (nil? (:component-file shape))) + (t/is (nil? (:component-root? shape))) + (t/is (nil? (:remote-synced? shape))) + (t/is (nil? (:touched shape)))) + +(defn is-from-file + [shape file] + (t/is (= (:component-file shape) + (:id file)))) + +(defn resolve-instance-and-master + [state root-inst-id] + (let [page (thp/current-page state) + root-inst (cph/get-shape page root-inst-id) + + file (dwlh/get-local-file state) + component (cph/get-component + (:component-id root-inst) + (:id file) + file + nil) + + shapes-inst (cph/get-object-with-children + root-inst-id + (:objects page)) + shapes-master (cph/get-object-with-children + (:shape-ref root-inst) + (:objects component)) + + unique-refs (into #{} (map :shape-ref shapes-inst)) + + master-exists? (fn [shape] + (t/is (some #(= (:id %) (:shape-ref shape)) + shapes-master)))] + + ;; Validate that the instance tree is well constructed + (t/is (is-instance-root (first shapes-inst))) + (run! is-instance-child (rest shapes-inst)) + (run! is-noninstance shapes-master) + (t/is (= (count shapes-inst) + (count shapes-master) + (count unique-refs))) + (run! master-exists? shapes-inst) + + [shapes-inst shapes-master component])) + diff --git a/frontend/tests/app/test_helpers.cljs b/frontend/tests/app/test_helpers/pages.cljs similarity index 54% rename from frontend/tests/app/test_helpers.cljs rename to frontend/tests/app/test_helpers/pages.cljs index b704c2a3d..113828b86 100644 --- a/frontend/tests/app/test_helpers.cljs +++ b/frontend/tests/app/test_helpers/pages.cljs @@ -1,4 +1,4 @@ -(ns app.test-helpers +(ns app.test-helpers.pages (:require [cljs.test :as t :include-macros true] [cljs.pprint :refer [pprint]] [beicon.core :as rx] @@ -8,36 +8,9 @@ [app.common.geom.shapes :as gsh] [app.common.pages :as cp] [app.common.pages.helpers :as cph] - [app.main.data.workspace :as dw])) - - -;; ---- Helpers to manage global events - -(defn do-update - "Execute an update event and returns the new state." - [event state] - (ptk/update event state)) - -(defn do-watch - "Execute a watch event and return an observable, that - emits once a list with all new events." - [event state] - (->> (ptk/watch event state nil) - (rx/reduce conj []))) - -(defn do-watch-update - "Execute a watch event and return an observable, that - emits once the new state, after all new events applied - in sequence (considering they are all update events)." - [event state] - (->> (do-watch event state) - (rx/map (fn [new-events] - (reduce - (fn [new-state new-event] - (do-update new-event new-state)) - state - new-events))))) - + [app.main.data.workspace :as dw] + [app.main.data.workspace.groups :as dwg] + [app.main.data.workspace.libraries-helpers :as dwlh])) ;; ---- Helpers to manage pages and objects @@ -53,16 +26,32 @@ :pages-index {}} :workspace-libraries {}}) +(def ^:private idmap (atom {})) + +(defn reset-idmap! [] + (reset! idmap {})) + (defn current-page [state] (let [page-id (:current-page-id state)] (get-in state [:workspace-data :pages-index page-id]))) +(defn id + [label] + (get @idmap label)) + +(defn get-shape + [state label] + (let [page (current-page state)] + (get-in page [:objects (id label)]))) + (defn sample-page ([state] (sample-page state {})) ([state {:keys [id name] :as props :or {id (uuid/next) name "page1"}}] + + (swap! idmap assoc :page id) (-> state (assoc :current-page-id id) (update :workspace-data @@ -72,13 +61,14 @@ :name name}])))) (defn sample-shape - ([state type] (sample-shape state type {})) - ([state type props] + ([state label type] (sample-shape state type {})) + ([state label type props] (let [page (current-page state) frame (cph/get-top-frame (:objects page)) shape (-> (cp/make-minimal-shape type) (gsh/setup {:x 0 :y 0 :width 1 :height 1}) (merge props))] + (swap! idmap assoc label (:id shape)) (update state :workspace-data cp/process-changes [{:type :add-obj @@ -87,3 +77,30 @@ :frame-id (:id frame) :obj shape}])))) +(defn group-shapes + ([state label ids] (group-shapes state label ids "Group-")) + ([state label ids prefix] + (let [page (current-page state) + shapes (dwg/shapes-for-grouping (:objects page) ids) + + [group rchanges uchanges] + (dwg/prepare-create-group (:id page) shapes prefix true)] + + (swap! idmap assoc label (:id group)) + (update state :workspace-data + cp/process-changes rchanges)))) + +(defn make-component + [state label ids] + (let [page (current-page state) + + [group rchanges uchanges] + (dwlh/generate-add-component ids + (:objects page) + (:id page) + current-file-id)] + + (swap! idmap assoc label (:id group)) + (update state :workspace-data + cp/process-changes rchanges))) + diff --git a/frontend/tests/app/test_library_sync.cljs b/frontend/tests/app/test_library_sync.cljs index a624e900f..d98bdf57a 100644 --- a/frontend/tests/app/test_library_sync.cljs +++ b/frontend/tests/app/test_library_sync.cljs @@ -2,37 +2,40 @@ (:require [cljs.test :as t :include-macros true] [cljs.pprint :refer [pprint]] [beicon.core :as rx] - [app.test-helpers :as th] + [linked.core :as lks] + [app.test-helpers.events :as the] + [app.test-helpers.pages :as thp] + [app.test-helpers.libraries :as thl] [app.common.data :as d] - [app.common.uuid :as uuid] [app.common.pages.helpers :as cph] [app.main.data.workspace :as dw] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.libraries-helpers :as dwlh])) +(t/use-fixtures :each + {:before thp/reset-idmap!}) + (t/deftest test-create-page (t/testing "create page" - (let [state (-> th/initial-state - (th/sample-page)) - page (th/current-page state)] + (let [state (-> thp/initial-state + (thp/sample-page)) + page (thp/current-page state)] (t/is (= (:name page) "page1"))))) (t/deftest test-create-shape (t/testing "create shape" - (let [id (uuid/next) - state (-> th/initial-state - (th/sample-page) - (th/sample-shape :rect {:id id - :name "Rect 1"})) - page (th/current-page state) - shape (cph/get-shape page id)] + (let [state (-> thp/initial-state + (thp/sample-page) + (thp/sample-shape :shape1 :rect + {:name "Rect 1"})) + shape (thp/get-shape state :shape1)] (t/is (= (:name shape) "Rect 1"))))) (t/deftest synctest (t/testing "synctest" (let [state {:workspace-local {:color-for-rename "something"}} new-state (->> state - (th/do-update + (the/do-update dwl/clear-color-for-rename))] (t/is (= (get-in new-state [:workspace-local :color-for-rename]) nil))))) @@ -43,7 +46,7 @@ (let [state {} color {:color "#ffffff"}] (->> state - (th/do-watch-update + (the/do-watch-update (dwl/add-recent-color color)) (rx/map (fn [new-state] @@ -56,43 +59,148 @@ [color])))) (rx/subs done)))))) -(t/deftest test-add-component - (t/testing "Add a component" +(t/deftest test-add-component-from-single-shape + (t/testing "Add a component from a single shape" (t/async done - (let [id1 (uuid/next) - state (-> th/initial-state - (th/sample-page) - (th/sample-shape :rect - {:id id1 - :name "Rect 1"}))] + (let [state (-> thp/initial-state + (thp/sample-page) + (thp/sample-shape :shape1 :rect + {:name "Rect 1"}))] + (->> state - (th/do-update (dw/select-shape id1)) - (th/do-watch-update dwl/add-component) + (the/do-update (dw/select-shape (thp/id :shape1))) + (the/do-watch-update dwl/add-component) (rx/map (fn [new-state] - (let [page (th/current-page new-state) - shape (cph/get-shape page id1) - group (cph/get-shape page (:parent-id shape)) + (let [shape1 (thp/get-shape new-state :shape1) - component (cph/get-component - (:component-id group) - (:current-file-id new-state) - (dwlh/get-local-file new-state) - nil) + [[group shape1] [c-group c-shape1] component] + (thl/resolve-instance-and-master + new-state + (:parent-id shape1)) - c-shape (cph/get-shape - component - (:shape-ref shape)) + file (dwlh/get-local-file new-state)] - c-group (cph/get-shape - component - (:shape-ref group))] - - (t/is (= (:name shape) "Rect 1")) + (t/is (= (:name shape1) "Rect 1")) (t/is (= (:name group) "Component-1")) (t/is (= (:name component) "Component-1")) - (t/is (= (:name c-shape) "Rect 1")) - (t/is (= (:name c-group) "Component-1"))))) + (t/is (= (:name c-shape1) "Rect 1")) + (t/is (= (:name c-group) "Component-1")) + + (thl/is-from-file group file)))) + + (rx/subs done)))))) + +(t/deftest test-add-component-from-several-shapes + (t/testing "Add a component from several shapes" + (t/async done + (let [state (-> thp/initial-state + (thp/sample-page) + (thp/sample-shape :shape1 :rect + {:name "Rect 1"}) + (thp/sample-shape :shape2 :rect + {:name "Rect 2"}))] + + (->> state + (the/do-update (dw/select-shapes (lks/set + (thp/id :shape1) + (thp/id :shape2)))) + (the/do-watch-update dwl/add-component) + (rx/map + (fn [new-state] + (let [shape1 (thp/get-shape new-state :shape1) + + [[group shape1 shape2] + [c-group c-shape1 c-shape2] + component] + (thl/resolve-instance-and-master + new-state + (:parent-id shape1)) + + file (dwlh/get-local-file new-state)] + + ;; NOTE: the group name depends on having executed + ;; the previous test. + (t/is (= (:name group) "Component-2")) + (t/is (= (:name shape1) "Rect 1")) + (t/is (= (:name shape2) "Rect 2")) + (t/is (= (:name component) "Component-2")) + (t/is (= (:name c-group) "Component-2")) + (t/is (= (:name c-shape1) "Rect 1")) + (t/is (= (:name c-shape2) "Rect 2")) + + (thl/is-from-file group file)))) + + (rx/subs done)))))) + +(t/deftest test-add-component-from-group + (t/testing "Add a component from a group" + (t/async done + (let [ + state (-> thp/initial-state + (thp/sample-page) + (thp/sample-shape :shape1 :rect + {:name "Rect 1"}) + (thp/sample-shape :shape2 :rect + {:name "Rect 2"}) + (thp/group-shapes :group1 + [(thp/id :shape1) + (thp/id :shape2)]))] + + (->> state + (the/do-update (dw/select-shape (thp/id :group1))) + (the/do-watch-update dwl/add-component) + (rx/map + (fn [new-state] + (let [[[group shape1 shape2] + [c-group c-shape1 c-shape2] + component] + (thl/resolve-instance-and-master + new-state + (thp/id :group1)) + + file (dwlh/get-local-file new-state)] + + (t/is (= (:name shape1) "Rect 1")) + (t/is (= (:name shape2) "Rect 2")) + (t/is (= (:name group) "Group-3")) + (t/is (= (:name component) "Group-3")) + (t/is (= (:name c-shape1) "Rect 1")) + (t/is (= (:name c-shape2) "Rect 2")) + (t/is (= (:name c-group) "Group-3")) + + (thl/is-from-file group file)))) + + (rx/subs done)))))) + +(t/deftest test-rename-component + (t/testing "Rename a component" + (t/async done + (let [ + state (-> thp/initial-state + (thp/sample-page) + (thp/sample-shape :shape1 :rect + {:name "Rect 1"}) + (thp/make-component :instance1 + [(thp/id :shape1)])) + + instance1 (thp/get-shape state :instance1)] + + (->> state + (the/do-watch-update (dwl/rename-component + (:component-id instance1) + "Renamed component")) + (rx/map + (fn [new-state] + (let [file (dwlh/get-local-file new-state) + component (cph/get-component + (:component-id instance1) + (:component-file instance1) + file + {})] + + (t/is (= (:name component) + "Renamed component"))))) (rx/subs done))))))