🐛 Keep variant sets intact under group, mask, board, and layout

This commit is contained in:
Pablo Alba 2026-10-30 17:50:30 +01:00 committed by Pablo Alba
parent b2c9ca1d23
commit 69fa682217
7 changed files with 757 additions and 68 deletions

View File

@ -426,6 +426,52 @@
true
(has-any-copy-parent? objects (:parent-id shape))))))
(defn get-variant-boundary-id
"Id of the nearest variant main or variant container for this shape, or nil
when the shape is outside any variant set.
Nested children resolve to their enclosing main (not the container), so two
shapes under different variants are treated as different boundaries even
when they share a set."
[objects shape]
(some (fn [s]
(cond
(ctk/is-variant? s) (:id s)
(ctk/is-variant-container? s) (:id s)
:else nil))
(cfh/get-parents-with-self objects (:id shape))))
(defn crosses-variant-set-boundary?
"True when the selection mixes shapes that belong to different variant
boundaries — including outside vs in-set, two different variant mains
(even in the same set), or two different variant containers.
Structure commands (group, mask, board-from-selection, layout) must not
reparent across that boundary."
[objects shapes]
(let [ids (into #{} (map #(get-variant-boundary-id objects %)) shapes)]
(> (count ids) 1)))
(defn ids-for-structure-op
"Ids allowed for Group, Mask, and Create board from selection.
Drops copy-children and variant mains, refuses a cross-boundary or
container selection, then removes descendants whose ancestor is
still selected. Mains are dropped before loop-cleaning so a
selected main does not swallow nested children. Layout does not
use this helper."
[objects ids]
(let [shapes (->> ids
(remove #(has-any-copy-parent? objects (get objects %)))
(keep (d/getf objects)))]
(if (or (crosses-variant-set-boundary? objects shapes)
(some ctk/is-variant-container? shapes))
[]
(->> shapes
(remove ctk/is-variant?)
(map :id)
(cfh/clean-loops objects)))))
(defn has-any-main?
"Check if the shape is a main component or has any children or parent that is a main component."
[objects shape]

View File

@ -154,3 +154,140 @@
shape-b (make-shape id-b :parent-id id-a)
objects (objects-map shape-a shape-b)]
(t/is (nil? (ctc/find-component-main objects shape-a false))))))
(t/deftest get-variant-boundary-id-recognizes-container-main-and-nested
(let [page-id uuid/zero
container-id (uuid/next)
main-id (uuid/next)
nested-id (uuid/next)
outside-id (uuid/next)
container (make-shape container-id
:parent-id page-id
:type :frame
:is-variant-container true)
main (make-shape main-id
:parent-id container-id
:type :frame
:variant-id container-id)
nested (make-shape nested-id :parent-id main-id)
outside (make-shape outside-id :parent-id page-id)
objects (objects-map container main nested outside)]
(t/is (= main-id (ctc/get-variant-boundary-id objects nested)))
(t/is (= main-id (ctc/get-variant-boundary-id objects main)))
(t/is (= container-id (ctc/get-variant-boundary-id objects container)))
(t/is (nil? (ctc/get-variant-boundary-id objects outside)))))
(t/deftest crosses-variant-set-boundary?
(let [page-id uuid/zero
container-id (uuid/next)
main-id (uuid/next)
main-2-id (uuid/next)
nested-id (uuid/next)
nested-2-id (uuid/next)
outside-id (uuid/next)
other-set-id (uuid/next)
other-main-id (uuid/next)
other-nest-id (uuid/next)
container (make-shape container-id
:parent-id page-id
:type :frame
:is-variant-container true)
main (make-shape main-id
:parent-id container-id
:type :frame
:variant-id container-id)
main-2 (make-shape main-2-id
:parent-id container-id
:type :frame
:variant-id container-id)
nested (make-shape nested-id :parent-id main-id)
nested-2 (make-shape nested-2-id :parent-id main-2-id)
outside (make-shape outside-id :parent-id page-id)
other-set (make-shape other-set-id
:parent-id page-id
:type :frame
:is-variant-container true)
other-main (make-shape other-main-id
:parent-id other-set-id
:type :frame
:variant-id other-set-id)
other-nested (make-shape other-nest-id :parent-id other-main-id)
objects (objects-map container main main-2 nested nested-2
outside other-set other-main other-nested)]
(t/testing "nested child + outside crosses"
(t/is (true? (ctc/crosses-variant-set-boundary?
objects [nested outside]))))
(t/testing "main + outside crosses"
(t/is (true? (ctc/crosses-variant-set-boundary?
objects [main outside]))))
(t/testing "container + outside crosses"
(t/is (true? (ctc/crosses-variant-set-boundary?
objects [container outside]))))
(t/testing "nested children of two variants in the same set cross"
(t/is (true? (ctc/crosses-variant-set-boundary?
objects [nested nested-2]))))
(t/testing "nested children from two different sets cross"
(t/is (true? (ctc/crosses-variant-set-boundary?
objects [nested other-nested]))))
(t/testing "two outside shapes do not cross"
(let [outside-2 (make-shape (uuid/next) :parent-id page-id)]
(t/is (false? (ctc/crosses-variant-set-boundary?
(assoc objects (:id outside-2) outside-2)
[outside outside-2])))))
(t/testing "nested only does not cross"
(t/is (false? (ctc/crosses-variant-set-boundary? objects [nested]))))
(t/testing "main + nested under that main do not cross"
(t/is (false? (ctc/crosses-variant-set-boundary?
objects [main nested]))))
(t/testing "two nested under the same main do not cross"
(let [nested-b (make-shape (uuid/next) :parent-id main-id)]
(t/is (false? (ctc/crosses-variant-set-boundary?
(assoc objects (:id nested-b) nested-b)
[nested nested-b])))))))
(t/deftest ids-for-structure-op
(let [page-id uuid/zero
container-id (uuid/next)
main-id (uuid/next)
main-2-id (uuid/next)
nested-id (uuid/next)
nested-2-id (uuid/next)
outside-id (uuid/next)
outside-2-id (uuid/next)
container (make-shape container-id
:parent-id page-id
:type :frame
:is-variant-container true)
main (make-shape main-id
:parent-id container-id
:type :frame
:variant-id container-id)
main-2 (make-shape main-2-id
:parent-id container-id
:type :frame
:variant-id container-id)
nested (make-shape nested-id :parent-id main-id)
nested-2 (make-shape nested-2-id :parent-id main-id)
outside (make-shape outside-id :parent-id page-id)
outside-2 (make-shape outside-2-id :parent-id page-id)
objects (objects-map container main main-2 nested nested-2
outside outside-2)]
(t/testing "container-only is refused"
(t/is (= [] (vec (ctc/ids-for-structure-op objects [container-id])))))
(t/testing "nested + outside is refused"
(t/is (= [] (vec (ctc/ids-for-structure-op objects [nested-id outside-id])))))
(t/testing "main + outside is refused"
(t/is (= [] (vec (ctc/ids-for-structure-op objects [main-id outside-id])))))
(t/testing "two mains cross and are refused"
(t/is (= [] (vec (ctc/ids-for-structure-op objects [main-id main-2-id])))))
(t/testing "single main is dropped to empty"
(t/is (= [] (vec (ctc/ids-for-structure-op objects [main-id])))))
(t/testing "main + nested under that main keeps only the nested"
(t/is (= [nested-id]
(vec (ctc/ids-for-structure-op objects [main-id nested-id])))))
(t/testing "two nested under one main are both kept"
(t/is (= [nested-id nested-2-id]
(vec (ctc/ids-for-structure-op objects [nested-id nested-2-id])))))
(t/testing "two ordinary outside shapes are both kept"
(t/is (= [outside-id outside-2-id]
(vec (ctc/ids-for-structure-op objects [outside-id outside-2-id])))))))

View File

@ -30,6 +30,14 @@
reverse
(map #(get objects %))))
(defn- shapes-for-group-or-mask
"Shapes allowed for Group and Mask after the shared variant gates.
`ids-for-structure-op` drops mains before `clean-loops` so a selected
variant main does not swallow nested children."
[objects ids]
(shapes-for-grouping objects (ctn/ids-for-structure-op objects ids)))
(defn- get-empty-groups-after-group-creation
"An auxiliary function that finds and returns a set of ids that
corresponds to groups that should be deleted after a group creation.
@ -188,18 +196,11 @@
(ptk/reify ::group-shapes
ptk/WatchEvent
(watch [it state _]
(let [id (d/nilv id (uuid/next))
page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
shapes
(->> ids
(cfh/clean-loops objects)
(remove #(ctn/has-any-copy-parent? objects (get objects %)))
(shapes-for-grouping objects)
(remove ctk/is-variant?))
parents (into #{} (map :parent-id) shapes)]
(let [id (d/nilv id (uuid/next))
page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
shapes (shapes-for-group-or-mask objects ids)
parents (into #{} (map :parent-id) shapes)]
(when-not (empty? shapes)
(let [[group changes]
(prepare-create-group (pcb/empty-changes it) id objects page-id shapes "Group" false)]
@ -213,7 +214,9 @@
(ptk/reify ::group-selected
ptk/WatchEvent
(watch [_ state _]
(let [selected (dsh/lookup-selected state)]
;; Raw ids: lookup-selected already clean-loops, which would drop
;; nested children of a selected variant main before we can.
(let [selected (dsh/get-selected-ids state)]
(rx/of (group-shapes nil selected :change-selection? true))))))
(defn ungroup-shapes
@ -287,11 +290,9 @@
(watch [it state _]
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
selected (->> (or ids (dsh/lookup-selected state))
(cfh/clean-loops objects)
(remove #(ctn/has-any-copy-parent? objects (get objects %))))
shapes (->> (shapes-for-grouping objects selected)
(remove ctk/is-variant?))
shapes (shapes-for-group-or-mask
objects
(or ids (dsh/get-selected-ids state)))
first-shape (first shapes)]
(when-not (empty? shapes)
(let [;; If the selected shape is a group, we can use it. If not,

View File

@ -17,6 +17,7 @@
[app.common.logic.libraries :as cll]
[app.common.types.color :as clr]
[app.common.types.component :as ctc]
[app.common.types.container :as ctn]
[app.common.types.modifiers :as ctm]
[app.common.types.shape.layout :as ctl]
[app.common.uuid :as uuid]
@ -216,13 +217,15 @@
has-component? (some true? (map ctc/instance-root? selected-shapes))
is-component? (and single? has-component?)
has-variant? (some ctc/is-variant? selected-shapes)
has-container? (some ctc/is-variant-container? selected-shapes)
crosses-set? (ctn/crosses-variant-set-boundary? objects selected-shapes)
has-layout? (and single? (ctl/any-layout? (first selected-shapes)))
new-shape-id (uuid/next)
undo-id (js/Symbol)]
(if has-variant?
(if (or has-variant? has-container? crosses-set?)
(rx/empty)
(rx/concat
(rx/of (dwu/start-undo-transaction undo-id))
@ -294,12 +297,17 @@
undo-id (js/Symbol)]
(rx/of
(dwu/start-undo-transaction undo-id)
(if (and single? is-frame? (not has-layout?))
(create-layout-from-id (first selected) type :from-frame? true)
(create-layout-from-selection type))
(dwu/commit-undo-transaction undo-id))))))
;; Refuse before undo: container or cross-boundary. Leave single
;; mains on the from-id branch (selection refuses has-variant?).
(if (or (some ctc/is-variant-container? selected-shapes)
(ctn/crosses-variant-set-boundary? objects selected-shapes))
(rx/empty)
(rx/of
(dwu/start-undo-transaction undo-id)
(if (and single? is-frame? (not has-layout?))
(create-layout-from-id (first selected) type :from-frame? true)
(create-layout-from-selection type))
(dwu/commit-undo-transaction undo-id)))))))
(defn toggle-layout
[type]

View File

@ -476,16 +476,14 @@
(ptk/reify ::create-artboard-from-selection
ptk/WatchEvent
(watch [_ state _]
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
selected (->> (dsh/lookup-selected state)
(cfh/clean-loops objects)
(remove #(ctn/has-any-copy-parent? objects (get objects %)))
(remove #(->> %
(get objects)
(ctc/is-variant?))))]
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
selected (vec (ctn/ids-for-structure-op
objects
(dsh/get-selected-ids state)))]
(rx/of (create-artboard-from-shapes selected id parent-id index name delta)))))))
(when (seq selected)
(rx/of (create-artboard-from-shapes selected id parent-id index name delta))))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Shape Flags

View File

@ -398,6 +398,8 @@
objects (deref refs/workspace-page-objects)
any-in-copy? (some #(ctn/has-any-copy-parent? objects %) shapes)
any-is-variant? (some ctk/is-variant? shapes)
any-is-container? (some ctk/is-variant-container? shapes)
crosses-set? (ctn/crosses-variant-set-boundary? objects shapes)
;; components can't be ungrouped
has-frame? (->> shapes (d/seek #(and (cfh/frame-shape? %) (not (ctk/instance-head? %)) (not (ctk/is-variant-container? %)))))
@ -416,7 +418,7 @@
#(st/emit! (dwsh/create-artboard-from-selection))]
[:*
(when (not (or any-in-copy? any-is-variant?))
(when (not (or any-in-copy? any-is-variant? any-is-container? crosses-set?))
[:*
(when (or has-bool? has-group? has-mask? has-frame?)
[:> menu-entry* {:title (tr "workspace.shape.menu.ungroup")
@ -621,6 +623,10 @@
(and single? (every? ctl/grid-layout? shapes))
any-is-variant? (some ctk/is-variant? shapes)
;; shape-context-menu* already omits this menu for container-only;
;; gate here too so a future caller cannot show Add flex/grid.
any-is-container? (some ctk/is-variant-container? shapes)
crosses-set? (ctn/crosses-variant-set-boundary? objects shapes)
on-add-layout
(mf/use-fn
@ -653,7 +659,9 @@
:shortcut-key :toggle-layout-grid
:on-click on-remove-layout}])]
(when (or single? (not any-is-variant?))
(when (and (not crosses-set?)
(not any-is-container?)
(or single? (not any-is-variant?)))
[:div
[:> menu-separator* {}]
[:> menu-entry* {:title (tr "workspace.shape.menu.add-flex")

View File

@ -6,18 +6,18 @@
(ns frontend-tests.logic.groups-test
(:require
[app.common.data :as d]
[app.common.test-helpers.components :as cthc]
[app.common.test-helpers.compositions :as ctho]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as cthi]
[app.common.test-helpers.shapes :as cths]
[app.common.test-helpers.variants :as thv]
[app.common.types.component :as ctk]
[app.common.uuid :as uuid]
[app.common.types.container :as ctn]
[app.common.types.shape.layout :as ctl]
[app.main.data.changes :as dch]
[app.main.data.workspace :as dw]
[app.main.data.workspace.groups :as dwgr]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.shapes :as dwsh]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.pages :as thp]
@ -41,6 +41,24 @@
[]
(cths/add-sample-shape (variant-file) :rect))
(defn- variant-file-with-nested-and-outside
[]
(-> (variant-file)
(cths/add-sample-shape :nested :parent-label :root1)
(cths/add-sample-shape :outside)))
(defn- variant-file-with-two-nested
[]
(-> (variant-file)
(cths/add-sample-shape :nested-a :parent-label :root1)
(cths/add-sample-shape :nested-b :parent-label :root1)))
(defn- variant-file-with-nested-in-each-main
[]
(-> (variant-file)
(cths/add-sample-shape :nested-a :parent-label :root1)
(cths/add-sample-shape :nested-b :parent-label :root2)))
(defn- masked-groups
[file]
(->> (cthf/current-page file)
@ -103,29 +121,46 @@
;; Group has been created and is selected
(t/is (= (get-in new-state [:workspace-local :selected]) #{group-id}))))))))
(t/deftest mask-group-leaves-variant-main-in-the-set
(t/deftest mask-group-of-main-and-outside-does-not-commit
(t/async
done
(let [file (variant-file-with-rect)
rect (cths/get-shape file :rect)
main (cths/get-shape file :root1)
comp (cthc/get-component file :comp1)]
(let [file (variant-file-with-rect)
rect (cths/get-shape file :rect)
main (cths/get-shape file :root1)
commits (atom [])]
(run file
[(dwgr/mask-group [(cthi/id :root1) (cthi/id :rect)])]
done
(fn [state]
(let [file' (ths/get-file-from-state state)
main' (cths/get-shape file' :root1)
rect' (cths/get-shape file' :rect)
group (first (masked-groups file'))
comp' (cthc/get-component file' :comp1)]
(t/is (= (:parent-id main) (:parent-id main')))
(t/is (= (:variant-id main) (:variant-id main')))
(t/is (= (:variant-name main) (:variant-name main')))
(t/is (= (:variant-id comp) (:variant-id comp')))
(t/is (= (:id group) (:parent-id rect')))
(t/is (= (:parent-id rect) (:parent-id group)))
(t/is (= (:selrect rect) (:selrect group)))))))))
(let [file' (ths/get-file-from-state state)]
(t/is (= 0 (count @commits)))
(t/is (empty? (masked-groups file')))
(t/is (= (:parent-id main) (:parent-id (cths/get-shape file' :root1))))
(t/is (= (:parent-id rect) (:parent-id (cths/get-shape file' :rect))))))
:commits commits))))
(t/deftest mask-group-of-nested-and-outside-does-not-commit
(t/async
done
(let [file (variant-file-with-nested-and-outside)
nested (cths/get-shape file :nested)
outside (cths/get-shape file :outside)
commits (atom [])]
(t/is (ctn/crosses-variant-set-boundary?
(:objects (cthf/current-page file))
[nested outside]))
(run file
[(dwgr/mask-group [(cthi/id :nested) (cthi/id :outside)])]
done
(fn [state]
(let [file' (ths/get-file-from-state state)]
(t/is (= 0 (count @commits)))
(t/is (empty? (masked-groups file')))
(t/is (= (:parent-id nested)
(:parent-id (cths/get-shape file' :nested))))
(t/is (= (:parent-id outside)
(:parent-id (cths/get-shape file' :outside))))))
:commits commits))))
(t/deftest mask-group-of-only-variant-mains-does-not-commit
(t/async
@ -178,24 +213,480 @@
(t/is (= [group-id] (map :id (groups file'))))
(t/is (= group-id (:parent-id (cths/get-shape file' :child))))))))))
(t/deftest group-shapes-leaves-variant-main-in-the-set
(t/deftest group-shapes-of-main-and-outside-does-not-commit
(t/async
done
(let [file (variant-file-with-rect)
rect (cths/get-shape file :rect)]
(let [file (variant-file-with-rect)
rect (cths/get-shape file :rect)
main (cths/get-shape file :root1)
commits (atom [])]
(run file
[(dwgr/group-shapes nil [(cthi/id :root1) (cthi/id :rect)])]
done
(fn [state]
(let [file' (ths/get-file-from-state state)
board (cths/get-shape file' :variant)
main (cths/get-shape file' :root1)
rect' (cths/get-shape file' :rect)
group (first (groups file'))]
(t/is (= (:id board) (:parent-id main)))
(let [file' (ths/get-file-from-state state)]
(t/is (= 0 (count @commits)))
(t/is (empty? (groups file')))
(t/is (= (:parent-id main) (:parent-id (cths/get-shape file' :root1))))
(t/is (= (:parent-id rect) (:parent-id (cths/get-shape file' :rect))))))
:commits commits))))
(t/deftest group-shapes-of-nested-and-outside-does-not-commit
(t/async
done
(let [file (variant-file-with-nested-and-outside)
nested (cths/get-shape file :nested)
outside (cths/get-shape file :outside)
commits (atom [])]
(run file
[(dwgr/group-shapes nil [(cthi/id :nested) (cthi/id :outside)])]
done
(fn [state]
(let [file' (ths/get-file-from-state state)]
(t/is (= 0 (count @commits)))
(t/is (empty? (groups file')))
(t/is (= (:parent-id nested)
(:parent-id (cths/get-shape file' :nested))))
(t/is (= (:parent-id outside)
(:parent-id (cths/get-shape file' :outside))))))
:commits commits))))
(t/deftest group-shapes-of-two-nested-children-creates-a-group
(t/async
done
(let [file (variant-file-with-two-nested)
main (cths/get-shape file :root1)]
(run file
[(dwgr/group-shapes nil [(cthi/id :nested-a) (cthi/id :nested-b)])]
done
(fn [state]
(let [file' (ths/get-file-from-state state)
group (first (groups file'))]
(t/is (some? group))
(t/is (= (:id group) (:parent-id rect')))
(t/is (= (:parent-id rect) (:parent-id group)))))))))
(t/is (= (:id main) (:parent-id group)))
(t/is (= (:id group) (:parent-id (cths/get-shape file' :nested-a))))
(t/is (= (:id group) (:parent-id (cths/get-shape file' :nested-b))))))))))
(t/deftest group-shapes-of-main-and-nested-keeps-main-groups-nested
(t/async
done
(let [file (-> (variant-file)
(cths/add-sample-shape :nested :parent-label :root1))
main (cths/get-shape file :root1)
nested (cths/get-shape file :nested)
commits (atom [])]
(run file
[(dwgr/group-shapes nil [(cthi/id :root1) (cthi/id :nested)])]
done
(fn [state]
(let [file' (ths/get-file-from-state state)
main' (cths/get-shape file' :root1)
nested' (cths/get-shape file' :nested)
group (first (groups file'))]
(t/is (pos? (count @commits)))
(t/is (some? group))
(t/is (= (:parent-id main) (:parent-id main')))
(t/is (= (:variant-id main) (:variant-id main')))
(t/is (= (:id main') (:parent-id group)))
(t/is (= (:id group) (:parent-id nested')))
(t/is (= (:parent-id nested) (:id main)))))
:commits commits))))
(t/deftest mask-group-of-main-and-nested-keeps-main-masks-nested
(t/async
done
(let [file (-> (variant-file)
(cths/add-sample-shape :nested :parent-label :root1))
main (cths/get-shape file :root1)
nested (cths/get-shape file :nested)
commits (atom [])]
(run file
[(dwgr/mask-group [(cthi/id :root1) (cthi/id :nested)])]
done
(fn [state]
(let [file' (ths/get-file-from-state state)
main' (cths/get-shape file' :root1)
nested' (cths/get-shape file' :nested)
group (first (masked-groups file'))]
(t/is (pos? (count @commits)))
(t/is (some? group))
(t/is (:masked-group group))
(t/is (= (:parent-id main) (:parent-id main')))
(t/is (= (:variant-id main) (:variant-id main')))
(t/is (= (:id main') (:parent-id group)))
(t/is (= (:id group) (:parent-id nested')))
(t/is (= (:parent-id nested) (:id main)))))
:commits commits))))
(t/deftest group-shapes-of-nested-in-different-variants-does-not-commit
(t/async
done
(let [file (variant-file-with-nested-in-each-main)
nested-a (cths/get-shape file :nested-a)
nested-b (cths/get-shape file :nested-b)
commits (atom [])]
(t/is (ctn/crosses-variant-set-boundary?
(:objects (cthf/current-page file))
[nested-a nested-b]))
(run file
[(dwgr/group-shapes nil [(cthi/id :nested-a) (cthi/id :nested-b)])]
done
(fn [state]
(let [file' (ths/get-file-from-state state)]
(t/is (= 0 (count @commits)))
(t/is (empty? (groups file')))
(t/is (= (:parent-id nested-a)
(:parent-id (cths/get-shape file' :nested-a))))
(t/is (= (:parent-id nested-b)
(:parent-id (cths/get-shape file' :nested-b))))))
:commits commits))))
(t/deftest mask-group-of-nested-in-different-variants-does-not-commit
(t/async
done
(let [file (variant-file-with-nested-in-each-main)
nested-a (cths/get-shape file :nested-a)
nested-b (cths/get-shape file :nested-b)
commits (atom [])]
(run file
[(dwgr/mask-group [(cthi/id :nested-a) (cthi/id :nested-b)])]
done
(fn [state]
(let [file' (ths/get-file-from-state state)]
(t/is (= 0 (count @commits)))
(t/is (empty? (masked-groups file')))
(t/is (= (:parent-id nested-a)
(:parent-id (cths/get-shape file' :nested-a))))
(t/is (= (:parent-id nested-b)
(:parent-id (cths/get-shape file' :nested-b))))))
:commits commits))))
(t/deftest create-artboard-of-nested-and-outside-does-not-commit
(t/async
done
(let [file (variant-file-with-nested-and-outside)
nested (cths/get-shape file :nested)
outside (cths/get-shape file :outside)
commits (atom [])]
(run file
[(dws/select-shapes (d/ordered-set (cthi/id :nested) (cthi/id :outside)))
(dwsh/create-artboard-from-selection)]
done
(fn [state]
(let [file' (ths/get-file-from-state state)]
(t/is (= 0 (count @commits)))
(t/is (= (:parent-id nested)
(:parent-id (cths/get-shape file' :nested))))
(t/is (= (:parent-id outside)
(:parent-id (cths/get-shape file' :outside))))))
:commits commits))))
(t/deftest create-artboard-of-main-and-outside-does-not-commit
(t/async
done
(let [file (variant-file-with-rect)
rect (cths/get-shape file :rect)
main (cths/get-shape file :root1)
commits (atom [])]
(run file
[(dws/select-shapes (d/ordered-set (cthi/id :root1) (cthi/id :rect)))
(dwsh/create-artboard-from-selection)]
done
(fn [state]
(let [file' (ths/get-file-from-state state)]
(t/is (= 0 (count @commits)))
(t/is (= (:parent-id main) (:parent-id (cths/get-shape file' :root1))))
(t/is (= (:parent-id rect) (:parent-id (cths/get-shape file' :rect))))))
:commits commits))))
(t/deftest create-artboard-of-two-nested-children-commits
(t/async
done
(let [file (variant-file-with-two-nested)
main (cths/get-shape file :root1)
commits (atom [])]
(run file
[(dws/select-shapes (d/ordered-set (cthi/id :nested-a) (cthi/id :nested-b)))
(dwsh/create-artboard-from-selection)]
done
(fn [state]
(let [file' (ths/get-file-from-state state)
nested-a' (cths/get-shape file' :nested-a)
nested-b' (cths/get-shape file' :nested-b)
board (get (:objects (cthf/current-page file'))
(:parent-id nested-a'))]
(t/is (pos? (count @commits)))
(t/is (= :frame (:type board)))
(t/is (not (ctk/is-variant-container? board)))
(t/is (= (:id main) (:parent-id board)))
(t/is (= (:id board) (:parent-id nested-a')))
(t/is (= (:id board) (:parent-id nested-b')))))
:commits commits))))
(t/deftest create-artboard-of-main-and-nested-keeps-main-boards-nested
(t/async
done
(let [file (-> (variant-file)
(cths/add-sample-shape :nested :parent-label :root1))
main (cths/get-shape file :root1)
nested (cths/get-shape file :nested)
commits (atom [])]
(run file
[(dws/select-shapes (d/ordered-set (cthi/id :root1) (cthi/id :nested)))
(dwsh/create-artboard-from-selection)]
done
(fn [state]
(let [file' (ths/get-file-from-state state)
main' (cths/get-shape file' :root1)
nested' (cths/get-shape file' :nested)
board (get (:objects (cthf/current-page file'))
(:parent-id nested'))]
(t/is (pos? (count @commits)))
(t/is (= :frame (:type board)))
(t/is (not (ctk/is-variant-container? board)))
(t/is (= (:parent-id main) (:parent-id main')))
(t/is (= (:variant-id main) (:variant-id main')))
(t/is (= (:id main') (:parent-id board)))
(t/is (= (:id board) (:parent-id nested')))
(t/is (= (:parent-id nested) (:id main)))))
:commits commits))))
(t/deftest group-selected-of-main-and-nested-keeps-main-groups-nested
(t/async
done
(let [file (-> (variant-file)
(cths/add-sample-shape :nested :parent-label :root1))
main (cths/get-shape file :root1)
nested (cths/get-shape file :nested)
commits (atom [])]
(run file
[(dws/select-shapes (d/ordered-set (cthi/id :root1) (cthi/id :nested)))
(dwgr/group-selected)]
done
(fn [state]
(let [file' (ths/get-file-from-state state)
main' (cths/get-shape file' :root1)
nested' (cths/get-shape file' :nested)
group (first (groups file'))]
(t/is (pos? (count @commits)))
(t/is (some? group))
(t/is (= (:parent-id main) (:parent-id main')))
(t/is (= (:variant-id main) (:variant-id main')))
(t/is (= (:id main') (:parent-id group)))
(t/is (= (:id group) (:parent-id nested')))
(t/is (= (:parent-id nested) (:id main)))))
:commits commits))))
(t/deftest mask-group-from-selection-of-main-and-nested-keeps-main-masks-nested
(t/async
done
(let [file (-> (variant-file)
(cths/add-sample-shape :nested :parent-label :root1))
main (cths/get-shape file :root1)
nested (cths/get-shape file :nested)
commits (atom [])]
(run file
[(dws/select-shapes (d/ordered-set (cthi/id :root1) (cthi/id :nested)))
(dwgr/mask-group)]
done
(fn [state]
(let [file' (ths/get-file-from-state state)
main' (cths/get-shape file' :root1)
nested' (cths/get-shape file' :nested)
group (first (masked-groups file'))]
(t/is (pos? (count @commits)))
(t/is (some? group))
(t/is (:masked-group group))
(t/is (= (:parent-id main) (:parent-id main')))
(t/is (= (:variant-id main) (:variant-id main')))
(t/is (= (:id main') (:parent-id group)))
(t/is (= (:id group) (:parent-id nested')))
(t/is (= (:parent-id nested) (:id main)))))
:commits commits))))
(t/deftest create-layout-of-nested-and-outside-does-not-commit
(t/async
done
(let [file (variant-file-with-nested-and-outside)
nested (cths/get-shape file :nested)
outside (cths/get-shape file :outside)
commits (atom [])]
(run file
[(dws/select-shapes (d/ordered-set (cthi/id :nested) (cthi/id :outside)))
(dwsl/create-layout-from-selection :flex)]
done
(fn [state]
(let [file' (ths/get-file-from-state state)]
(t/is (= 0 (count @commits)))
(t/is (= (:parent-id nested)
(:parent-id (cths/get-shape file' :nested))))
(t/is (= (:parent-id outside)
(:parent-id (cths/get-shape file' :outside))))))
:commits commits))))
(t/deftest create-layout-of-nested-in-different-variants-does-not-commit
(t/async
done
(let [file (variant-file-with-nested-in-each-main)
nested-a (cths/get-shape file :nested-a)
nested-b (cths/get-shape file :nested-b)
commits (atom [])]
(run file
[(dws/select-shapes (d/ordered-set (cthi/id :nested-a) (cthi/id :nested-b)))
(dwsl/create-layout :flex)]
done
(fn [state]
(let [file' (ths/get-file-from-state state)]
(t/is (= 0 (count @commits)))
(t/is (= (:parent-id nested-a)
(:parent-id (cths/get-shape file' :nested-a))))
(t/is (= (:parent-id nested-b)
(:parent-id (cths/get-shape file' :nested-b))))))
:commits commits))))
(t/deftest group-shapes-of-variant-container-only-does-not-commit
(t/async
done
(let [file (variant-file)
board (cths/get-shape file :variant)
commits (atom [])]
(run file
[(dwgr/group-shapes nil [(cthi/id :variant)])]
done
(fn [state]
(let [file' (ths/get-file-from-state state)]
(t/is (= 0 (count @commits)))
(t/is (empty? (groups file')))
(t/is (= (:parent-id board)
(:parent-id (cths/get-shape file' :variant))))
(t/is (ctk/is-variant-container?
(cths/get-shape file' :variant)))))
:commits commits))))
(t/deftest mask-group-of-variant-container-only-does-not-commit
(t/async
done
(let [file (variant-file)
board (cths/get-shape file :variant)
commits (atom [])]
(run file
[(dwgr/mask-group [(cthi/id :variant)])]
done
(fn [state]
(let [file' (ths/get-file-from-state state)]
(t/is (= 0 (count @commits)))
(t/is (empty? (masked-groups file')))
(t/is (= (:parent-id board)
(:parent-id (cths/get-shape file' :variant))))
(t/is (ctk/is-variant-container?
(cths/get-shape file' :variant)))))
:commits commits))))
(t/deftest create-artboard-of-variant-container-only-does-not-commit
(t/async
done
(let [file (variant-file)
board (cths/get-shape file :variant)
commits (atom [])]
(run file
[(dws/select-shapes (d/ordered-set (cthi/id :variant)))
(dwsh/create-artboard-from-selection)]
done
(fn [state]
(let [file' (ths/get-file-from-state state)]
(t/is (= 0 (count @commits)))
(t/is (= (:parent-id board)
(:parent-id (cths/get-shape file' :variant))))
(t/is (ctk/is-variant-container?
(cths/get-shape file' :variant)))))
:commits commits))))
(t/deftest mask-group-of-two-nested-children-creates-a-mask
(t/async
done
(let [file (variant-file-with-two-nested)
main (cths/get-shape file :root1)]
(run file
[(dwgr/mask-group [(cthi/id :nested-a) (cthi/id :nested-b)])]
done
(fn [state]
(let [file' (ths/get-file-from-state state)
group (first (masked-groups file'))]
(t/is (= 1 (count (masked-groups file'))))
(t/is (:masked-group group))
(t/is (= (:id main) (:parent-id group)))
(t/is (= (:id group) (:parent-id (cths/get-shape file' :nested-a))))
(t/is (= (:id group) (:parent-id (cths/get-shape file' :nested-b))))))))))
(t/deftest create-layout-of-variant-container-only-does-not-commit
(t/async
done
(let [file (variant-file)
board (cths/get-shape file :variant)
commits (atom [])]
(t/is (not (ctl/any-layout? board)))
(run file
[(dws/select-shapes (d/ordered-set (cthi/id :variant)))
;; Pin the create-layout entry (from-id hole), not only
;; create-layout-from-selection which already no-ops.
(dwsl/create-layout :flex)]
done
(fn [state]
(let [file' (ths/get-file-from-state state)
board' (cths/get-shape file' :variant)]
(t/is (= 0 (count @commits)))
(t/is (not (ctl/any-layout? board')))
(t/is (ctk/is-variant-container? board'))
(t/is (= (:parent-id board) (:parent-id board')))))
:commits commits))))
(t/deftest create-layout-of-variant-main-commits
(t/async
done
(let [file (variant-file)
main (cths/get-shape file :root1)
board (cths/get-shape file :variant)
commits (atom [])]
(t/is (not (ctl/any-layout? main)))
(run file
[(dws/select-shapes (d/ordered-set (cthi/id :root1)))
;; Locks create-layout → create-layout-from-id for a single main.
(dwsl/create-layout :flex)]
done
(fn [state]
(let [file' (ths/get-file-from-state state)
main' (cths/get-shape file' :root1)]
(t/is (pos? (count @commits)))
(t/is (ctl/flex-layout? main'))
(t/is (ctk/is-variant? main'))
(t/is (= (:variant-id main) (:variant-id main')))
(t/is (= (:id board) (:parent-id main')))))
:commits commits))))
(t/deftest create-layout-of-one-nested-child-commits
(t/async
done
(let [file (-> (variant-file)
(cths/add-sample-shape :nested :parent-label :root1))
nested (cths/get-shape file :nested)
main (cths/get-shape file :root1)
commits (atom [])]
(run file
[(dws/select-shapes (d/ordered-set (cthi/id :nested)))
;; Same Shift+A / menu entry as the container refuse and main allow.
(dwsl/create-layout :flex)]
done
(fn [state]
(let [file' (ths/get-file-from-state state)
nested' (cths/get-shape file' :nested)
;; Layout wraps the child in a new board under the main.
parent' (get (:objects (cthf/current-page file'))
(:parent-id nested'))]
(t/is (pos? (count @commits)))
(t/is (not= (:parent-id nested) (:parent-id nested')))
(t/is (= (:id main) (:parent-id parent')))
(t/is (ctl/any-layout? parent'))))
:commits commits))))
(t/deftest ungroup-shapes-leaves-a-variant-container
(t/async