diff --git a/CHANGES.md b/CHANGES.md index 00d3918679..d4205366f8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,7 @@ ### :boom: Breaking changes & Deprecations ### :rocket: Epics and highlights + - WebGL rendering (beta) user preference [#9683](https://github.com/penpot/penpot/issues/9683) (PR:[9113](https://github.com/penpot/penpot/pull/9113)) - Design Tokens at the design tab: numeric fields with token selection in place [#9358](https://github.com/penpot/penpot/issues/9358) @@ -64,6 +65,7 @@ - Allow deleting the profile avatar after uploading (by @moorsecopers99) [#9067](https://github.com/penpot/penpot/issues/9067) (PR: [#9068](https://github.com/penpot/penpot/pull/9068)) ### :bug: Bugs fixed + - Fix Alt/Option to draw shapes from center point (by @offreal) [#8360](https://github.com/penpot/penpot/issues/8360) (PR: [#8361](https://github.com/penpot/penpot/pull/8361)) - Fix library update button freezing [#9330](https://github.com/penpot/penpot/issues/9330) (PR: [#9513](https://github.com/penpot/penpot/pull/9513)) - Fix typo in subscription settings success key (by @jack-stormentswe) [#9203](https://github.com/penpot/penpot/issues/9203) (PR: [#9204](https://github.com/penpot/penpot/pull/9204)) @@ -125,6 +127,7 @@ - Fix text fill color stops updating in multiselect with texts [#9608](https://github.com/penpot/penpot/issues/9608) (PR: [#9549](https://github.com/penpot/penpot/pull/9549)) - Fix editing a legacy text element silently detaches its color token [Taiga #13958](https://tree.taiga.io/project/penpot/issue/13958) - Fix token application to grid paddings [Taiga #14136](https://tree.taiga.io/project/penpot/issue/14136) +- Fix copying and pasting a swapped component causes a referential integrity error [Github #9547](https://github.com/penpot/penpot/issues/9547) ## 2.15.4 (Unreleased) @@ -149,7 +152,6 @@ - Fix mcp related internal config for docker images [GH #9565](https://github.com/penpot/penpot/pull/9565) - ## 2.15.1 ### :sparkles: New features & Enhancements @@ -160,7 +162,6 @@ - Fix "Help & Learning" submenu vertical alignment in account menu (by @juan-flores077) [#9137](https://github.com/penpot/penpot/issues/9137) (PR: [#9138](https://github.com/penpot/penpot/pull/9138)) - ## 2.15.0 ### :sparkles: New features & Enhancements diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index f67b04b98e..2fd12c6bf6 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -170,13 +170,28 @@ objects)) (advance-shape [file libraries page level-delta objects shape] - (let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true}) - container (ctn/make-container page :page) - new-touched (ctf/get-touched-from-ref-chain-until-target-ref container libraries shape new-shape-ref)] + (let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true}) + container (ctn/make-container page :page) + new-touched (ctf/get-touched-from-ref-chain-until-target-ref container libraries shape new-shape-ref) + old-shape-ref (:shape-ref shape) + ref-changed? (and (some? new-shape-ref) (not= new-shape-ref old-shape-ref)) + ;; If we advance the shape-ref of a subinstance head, the new + ;; shape-ref will no longer match the parent component child + ;; at the same position, so the shape becomes "swapped" with + ;; respect to the enclosing instance. Preserve the original + ;; shape-ref as swap-slot so that referential integrity holds + ;; when the copy is pasted in a context where the parent's + ;; near main is reachable. + needs-swap-slot? (and ref-changed? + (ctc/subinstance-head? shape) + (nil? (ctc/get-swap-slot shape)))] (cond-> objects - (and (some? new-shape-ref) (not= new-shape-ref (:shape-ref shape))) + ref-changed? (-> (assoc-in [(:id shape) :shape-ref] new-shape-ref) - (assoc-in [(:id shape) :touched] new-touched))))) + (assoc-in [(:id shape) :touched] new-touched)) + + needs-swap-slot? + (update (:id shape) ctc/set-swap-slot old-shape-ref)))) (on-copy-error [error] (js/console.error "clipboard blocked:" error) diff --git a/frontend/test/frontend_tests/helpers/pages.cljs b/frontend/test/frontend_tests/helpers/pages.cljs index 8993326237..d8976b1d44 100644 --- a/frontend/test/frontend_tests/helpers/pages.cljs +++ b/frontend/test/frontend_tests/helpers/pages.cljs @@ -247,10 +247,21 @@ objects)) (advance-shape [file libraries page level-delta objects shape] - (let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true})] + (let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true}) + container (ctn/make-container page :page) + new-touched (ctf/get-touched-from-ref-chain-until-target-ref container libraries shape new-shape-ref) + old-shape-ref (:shape-ref shape) + ref-changed? (and (some? new-shape-ref) (not= new-shape-ref old-shape-ref)) + needs-swap-slot? (and ref-changed? + (ctk/subinstance-head? shape) + (nil? (ctk/get-swap-slot shape)))] (cond-> objects - (and (some? new-shape-ref) (not= new-shape-ref (:shape-ref shape))) - (assoc-in [(:id shape) :shape-ref] new-shape-ref))))] + ref-changed? + (-> (assoc-in [(:id shape) :shape-ref] new-shape-ref) + (assoc-in [(:id shape) :touched] new-touched)) + + needs-swap-slot? + (update (:id shape) ctk/set-swap-slot old-shape-ref))))] (let [file-id (:id file) frame-id (cfh/common-parent-frame objects selected) diff --git a/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs b/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs index ac8da0c92d..15bf3a480f 100644 --- a/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs +++ b/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs @@ -870,3 +870,73 @@ (t/is (some? copied-blue1')) (t/is (nil? (ctk/get-swap-slot copied-blue1'))))))))) +(t/deftest test-set-swap-slot-copy-paste-two-levels-nested-head + ;; Test that when copy-pasting a sub-instance head that contains a deeper sub-instance + ;; head (with no swap-slot), the advance-shape step correctly sets a swap-slot on the + ;; inner head. Without the fix this causes a backend :referential-integrity / :missing-slot + ;; validation error. + (t/async + done + (let [;; ==== Setup + ;; {:main1-root} [:name Frame1] # [Component :comp1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main2-root} [:name Frame2] # [Component :comp2] + ;; :nested-head1 [:name Frame1] @--> [Component :comp1] :main1-root + ;; [:name Rect1] ---> :main1-child + ;; + ;; {:main3-root} [:name Frame3] # [Component :comp3] + ;; :nested-head2 [:name Frame2] @--> [Component :comp2] :main2-root + ;; :nested-subhead2 [:name Frame1] @--> [Component :comp1] :main1-root + ;; [:name Rect1] ---> :main1-child + ;; + ;; :copy3-root [:name Frame3] #--> [Component :comp3] :main3-root + ;; [:name Frame2] @--> [Component :comp2] :nested-head2 (no swap-slot) + ;; [:name Frame1] @--> [Component :comp1] :nested-subhead2 (no swap-slot) + ;; [:name Rect1] ---> + file (-> (cthf/sample-file :file1) + (ctho/add-two-levels-nested-component-with-copy + :comp1 :main1-root :main1-child + :comp2 :main2-root :nested-head1 + :comp3 :main3-root :nested-head2 :nested-subhead2 + :copy3-root)) + store (ths/setup-store file) + + ;; ==== Action + page (cthf/current-page file) + copy3-root (cths/get-shape file :copy3-root) + ;; The copy of :nested-head2 inside copy3-root (sub-instance head of comp2, no swap-slot, + ;; nested 1 level deep — so level-delta=1 during copy-paste) + copy-head2-id (first (:shapes copy3-root)) + copy-head2 (get (:objects page) copy-head2-id) + features #{"components/v2"} + version 46 + + pdata (thp/simulate-copy-shape #{copy-head2-id} (:objects page) {(:id file) file} page file features version) + + events + [(dws/select-shape uuid/zero) + (dw/paste-shapes pdata)]] + + (ths/run-store + store done events + (fn [new-state] + (let [;; ==== Get + file' (ths/get-file-from-state new-state) + page' (cthf/current-page file') + ;; Find the pasted copy-head2 at the page root (parent = uuid/zero) + pasted-head2' (find-copied-shape copy-head2 page' uuid/zero) + ;; Its first child is the copy of :nested-subhead2 (sub-instance head of comp1) + pasted-subhead1-id' (some-> pasted-head2' :shapes first) + pasted-subhead1' (get (:objects page') pasted-subhead1-id')] + + ;; ==== Check + (t/is (some? pasted-head2')) + (t/is (some? pasted-subhead1')) + + ;; The inner sub-instance head must have a swap-slot set because advance-shape + ;; changed its shape-ref (level-delta=1). The swap-slot records the pre-advance + ;; shape-ref so the backend can validate referential integrity. + (t/is (some? (ctk/get-swap-slot pasted-subhead1')) + "Pasted inner sub-instance head must have a swap-slot after advance-shape"))))))) +