penpot/frontend/test/frontend_tests/data/workspace_variants_test.cljs
Alonso Torres 4fb0ea9eee
🐛 Give unique values to same-name components combined as variants (#12881)
When components whose name matches the variant container are moved into
it, removing the container name leaves an empty value. Combining several
components named the same produced "Value 1" for the first variant and
empty values for the rest, so variants shared the same combination.

Assign the first unused "Value N" to the last property when all computed
values are blank, tracking values given within the same batch.

Combine moves the remaining mains into the container one at a time, in
the combine order, so the generated values follow that order instead of
the layer order.

Closes #12878
2026-11-10 09:16:08 +01:00

612 lines
26 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.data.workspace-variants-test
(:require
[app.common.files.changes-builder :as pcb]
[app.common.files.variant :as cfv]
[app.common.logic.variants :as clv]
[app.common.path-names :as cpn]
[app.common.test-helpers.components :as cthc]
[app.common.test-helpers.compositions :as tho]
[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.types.variant :as ctv]
[app.common.uuid :as uuid]
[app.main.data.workspace :as dw]
[app.main.data.workspace.variants :as dwv]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.state :as ths]
[potok.v2.core :as ptk]))
(t/use-fixtures :each
{:before cthi/reset-idmap!})
(defn- two-property-file-on-other-page
[]
(-> (cthf/sample-file :file1 :page-label :page1)
(thv/add-variant-two-properties :v01 :c01 :m01 :c02 :m02)
(cthf/add-sample-page :page2)))
(defn- run
[file events done assert-fn]
(ths/run-store
(ths/setup-store file)
done
events
(fn [state]
(cthf/validate-file! (ths/get-file-from-state state))
(assert-fn state))))
(defn- variant-name
[file label]
(:variant-name (cths/get-shape file label :page-label :page1)))
(t/deftest update-property-value-from-another-page
(t/async
done
(let [file (two-property-file-on-other-page)
component-id (:id (cthc/get-component file :c01))
other-id (cthi/id :page2)]
(run file
[(dwv/update-property-value component-id 0 "active")
(dwv/update-property-value component-id 1 "crítica")]
done
(fn [state]
(let [file (ths/get-file-from-state state)
props (:variant-properties (cthc/get-component file :c01))]
(t/is (= other-id (:current-page-id state)))
(t/is (= "crítica" (-> props second :value)))
(t/is (= "active, crítica" (variant-name file :m01)))
(t/is (= (ctv/properties-to-name props) (variant-name file :m01)))))))))
(t/deftest update-property-value-on-main-page
(t/async
done
(let [file (-> (two-property-file-on-other-page)
(cthf/switch-to-page :page1))
component-id (:id (cthc/get-component file :c01))]
(run file
[(dwv/update-property-value component-id 1 "crítica")]
done
(fn [state]
(let [file (ths/get-file-from-state state)
props (:variant-properties (cthc/get-component file :c01))]
(t/is (= (cthi/id :page1) (:current-page-id state)))
(t/is (= "crítica" (-> props second :value)))
(t/is (= (ctv/properties-to-name props) (variant-name file :m01)))))))))
(t/deftest add-remove-reorder-from-another-page
(t/async
done
(let [file (two-property-file-on-other-page)
variant-id (cthi/id :v01)]
(run file
[(dwv/add-new-property variant-id {:fill-values? true})
(dwv/reorder-variant-poperties variant-id 0 3)
(dwv/remove-property variant-id 0)]
done
(fn [state]
(let [file (ths/get-file-from-state state)
comp1 (cthc/get-component file :c01)
comp2 (cthc/get-component file :c02)
names1 (mapv :name (:variant-properties comp1))
names2 (mapv :name (:variant-properties comp2))]
(t/is (= names1 names2))
(t/is (= (ctv/properties-to-name (:variant-properties comp1))
(variant-name file :m01)))
(t/is (= (ctv/properties-to-name (:variant-properties comp2))
(variant-name file :m02)))))))))
(t/deftest formula-rename-from-another-page
(t/async
done
(let [file (two-property-file-on-other-page)
comp (cthc/get-component file :c01)
previous (:variant-properties comp)
updated [{:name "Property 1" :value "active"}
{:name "Property 2" :value "crítica"}]]
(run file
[(dwv/update-properties-names-and-values (:id comp) (:variant-id comp) previous updated)
(dwv/remove-empty-properties (:variant-id comp))]
done
(fn [state]
(let [file (ths/get-file-from-state state)
props (:variant-properties (cthc/get-component file :c01))]
(t/is (= "active" (-> props first :value)))
(t/is (= "crítica" (-> props second :value)))
(t/is (= (ctv/properties-to-name props) (variant-name file :m01)))))))))
(t/deftest update-property-name-from-another-page
(t/async
done
(let [file (two-property-file-on-other-page)
variant-id (cthi/id :v01)]
(run file
[(dwv/update-property-name variant-id 0 "Status" {:trigger "test"})]
done
(fn [state]
(let [file (ths/get-file-from-state state)
names (mapv :name (:variant-properties (cthc/get-component file :c01)))]
(t/is (= names (mapv :name (:variant-properties (cthc/get-component file :c02)))))
(t/is (= "Status" (first names)))))))))
(t/deftest rename-variant-from-another-page
(t/async
done
(let [file (two-property-file-on-other-page)
variant-id (cthi/id :v01)
other-id (cthi/id :page2)
clean-name (cpn/clean-path "Renamed set")]
(run file
[(dwv/rename-variant variant-id "Renamed set")]
done
(fn [state]
(let [file (ths/get-file-from-state state)
container (cths/get-shape file :v01 :page-label :page1)
main1 (cths/get-shape file :m01 :page-label :page1)
main2 (cths/get-shape file :m02 :page-label :page1)
comp1 (cthc/get-component file :c01)
comp2 (cthc/get-component file :c02)]
(t/is (= other-id (:current-page-id state)))
(t/is (= clean-name (:name container)))
(t/is (= clean-name (:name main1)))
(t/is (= clean-name (:name main2)))
(t/is (= clean-name (cpn/merge-path-item (:path comp1) (:name comp1))))
(t/is (= clean-name (cpn/merge-path-item (:path comp2) (:name comp2))))))))))
;; ---------------------------------------------------------------------------
;; Create-set / combine: every commit must be a valid file
;; ---------------------------------------------------------------------------
(def ^:private apply-local?
(ptk/type? :app.main.data.changes/apply-changes-localy))
(defn- simple-main-file
[]
(-> (cthf/sample-file :file1)
(tho/add-simple-component :c01 :m01 :r01)))
(defn- two-mains-file
[]
(-> (cthf/sample-file :file1)
(tho/add-simple-component :c01 :m01 :r01
:root-params {:name "Alpha" :x 0 :y 0 :width 100 :height 80})
(tho/add-simple-component :c02 :m02 :r02
:root-params {:name "Beta" :x 150 :y 0 :width 100 :height 80})))
(defn- two-mains-shared-prefix-file
[]
(-> (cthf/sample-file :file1)
(tho/add-simple-component :c01 :m01 :r01
:root-params {:name "UI / Alpha" :x 0 :y 0 :width 100 :height 80})
(tho/add-simple-component :c02 :m02 :r02
:root-params {:name "UI / Beta" :x 150 :y 0 :width 100 :height 80})))
(defn- three-same-name-mains-file
[]
(-> (cthf/sample-file :file1)
(tho/add-simple-component :c01 :m01 :r01
:root-params {:name "Tag" :x 0 :y 0 :width 100 :height 80})
(tho/add-simple-component :c02 :m02 :r02
:root-params {:name "Tag" :x 150 :y 0 :width 100 :height 80})
(tho/add-simple-component :c03 :m03 :r03
:root-params {:name "Tag" :x 300 :y 0 :width 100 :height 80})))
(defn- path-named-main-file
[]
(-> (cthf/sample-file :file1)
(tho/add-simple-component :c01 :m01 :r01
:root-params {:name "Group / Button"})))
(defn- two-mains-on-page1-with-page2
"Two mains on :page1; the open page is :page2 (Assets combine while elsewhere)."
[]
(-> (cthf/sample-file :file1 :page-label :page1)
(tho/add-simple-component :c01 :m01 :r01
:root-params {:name "Alpha" :x 0 :y 0 :width 100 :height 80})
(tho/add-simple-component :c02 :m02 :r02
:root-params {:name "Beta" :x 150 :y 0 :width 100 :height 80})
(cthf/add-sample-page :page2)
(cthf/switch-to-page :page2)))
(defn- post-artboard-file
"A normal frame holding one main — the state after create-artboard-from-shapes
and before the container flag."
[]
(-> (cthf/sample-file :file1)
(tho/add-frame :v01 {:name "Set"})
(tho/add-simple-component :c01 :m01 :r01
:root-params {:parent-label :v01 :name "Set"})))
(defn- component-full-name
[component]
(cpn/merge-path-item (:path component) (:name component)))
(defn- page-objects
[file]
(:objects (cthf/current-page file)))
(defn- page-objects-on
[file page-label]
(:objects (cthf/get-page file page-label)))
(defn- commit-error
[file]
(try
(cthf/validate-file! file)
nil
(catch :default cause
(or (ex-message cause) (str cause)))))
(defn- watch-applied-commits
"Record the file after every local apply. Keep the subscription for the
whole run-store; assertions run in the completed callback."
[store snapshots]
(->> (ptk/input-stream store)
(rx/filter apply-local?)
(rx/subs! (fn [_]
(let [file (ths/get-file-from-state @store)]
(swap! snapshots conj {:file file
:error (commit-error file)}))))))
(defn- after-commits-idle
"Stop once applied commits have been idle. Combine schedules an async
undo commit after the main stream."
[stream]
(->> stream
(rx/filter apply-local?)
(rx/debounce 100)))
(defn- assert-valid-snapshots
[snapshots]
(doseq [{:keys [error]} snapshots]
(t/is (nil? error) error)))
(defn- first-flagged
[snapshots container-id]
(first (filter #(ctk/is-variant-container?
(get (page-objects (:file %)) container-id))
snapshots)))
(t/deftest define-variant-container-commit-validates
(let [file (post-artboard-file)
page (cthf/current-page file)
variant-id (cthi/id :v01)
main-id (cthi/id :m01)
component (cthc/get-component file :c01)
component-id (:id component)
name (:name (cths/get-shape file :v01))
changes (-> (pcb/empty-changes nil (:id page))
(pcb/with-objects (:objects page))
(pcb/with-library-data (:data file))
(clv/generate-define-variant-container
variant-id main-id component-id name))
file' (cthf/apply-changes file changes)
container' (cths/get-shape file' :v01)
main' (cths/get-shape file' :m01)
comp' (cthc/get-component file' :c01)]
(t/is (ctk/is-variant-container? container'))
(t/is (= [main-id] (:shapes container')))
(t/is (= variant-id (:variant-id main')))
(t/is (= variant-id (:variant-id comp')))
(t/is (some? (:variant-id comp')))
(t/is (= (ctv/properties-to-name (:variant-properties comp'))
(:variant-name main')))))
(t/deftest transform-in-variant-validates-after-every-commit
(t/async
done
(let [file (simple-main-file)
main-id (cthi/id :m01)
variant-id (uuid/next)
snapshots (atom [])
store (ths/setup-store file)]
(watch-applied-commits store snapshots)
(ths/run-store
store
done
[(dwv/transform-in-variant main-id variant-id nil [] false true true)]
(fn [state]
(let [snaps @snapshots
flagged (first-flagged snaps variant-id)
file' (ths/get-file-from-state state)
objects' (page-objects file')
container' (get objects' variant-id)
main' (get objects' main-id)
comp' (cthc/get-component file' :c01)
before-flag (take-while
#(not (ctk/is-variant-container?
(get (page-objects (:file %)) variant-id)))
snaps)]
(assert-valid-snapshots snaps)
(t/is (some? flagged) "a commit must set :is-variant-container")
(doseq [snap before-flag]
(t/is (not (ctk/is-variant-container?
(get (page-objects (:file snap)) variant-id)))))
(when flagged
(let [ffile (:file flagged)
fobjs (page-objects ffile)
fcont (get fobjs variant-id)
fmain (get fobjs main-id)
fcomp (cthc/get-component ffile :c01)]
(t/is (= [main-id] (:shapes fcont)))
(t/is (ctk/main-instance? fmain))
(t/is (= variant-id (:variant-id fmain)))
(t/is (= variant-id (:variant-id fcomp)))
(t/is (= (ctv/properties-to-name (:variant-properties fcomp))
(:variant-name fmain)))))
(t/is (ctk/is-variant-container? container'))
(t/is (= variant-id (:variant-id main')))
(t/is (= variant-id (:variant-id comp')))))
after-commits-idle))))
(t/deftest transform-in-variant-path-name-validates-after-every-commit
(t/async
done
(let [file (path-named-main-file)
main-id (cthi/id :m01)
variant-id (uuid/next)
snapshots (atom [])
store (ths/setup-store file)]
(watch-applied-commits store snapshots)
(ths/run-store
store
done
[(dwv/transform-in-variant main-id variant-id nil [] false true true)]
(fn [state]
(let [snaps @snapshots
flagged (first-flagged snaps variant-id)
file' (ths/get-file-from-state state)
objects' (page-objects file')
container' (get objects' variant-id)
main' (get objects' main-id)
comp' (cthc/get-component file' :c01)
before-flag (take-while
#(not (ctk/is-variant-container?
(get (page-objects (:file %)) variant-id)))
snaps)]
(assert-valid-snapshots snaps)
(t/is (some? flagged) "a commit must set :is-variant-container")
(doseq [snap before-flag]
(let [ffile (:file snap)
fmain (get (page-objects ffile) main-id)
fcomp (cthc/get-component ffile :c01)]
(t/is (= "Group / Button" (:name fmain)))
(t/is (= "Group / Button" (component-full-name fcomp)))))
(when flagged
(let [ffile (:file flagged)
fobjs (page-objects ffile)
fcont (get fobjs variant-id)
fmain (get fobjs main-id)
fcomp (cthc/get-component ffile :c01)]
(t/is (= "Group" (:name fcont) (:name fmain) (component-full-name fcomp)))
(t/is (= variant-id (:variant-id fmain) (:variant-id fcomp)))
(t/is (= (ctv/properties-to-name (:variant-properties fcomp))
(:variant-name fmain)))))
(t/is (ctk/is-variant-container? container'))
(t/is (= "Group" (:name container') (:name main') (component-full-name comp')))
(t/is (= variant-id (:variant-id main') (:variant-id comp')))))
after-commits-idle))))
(t/deftest add-new-variant-on-existing-set-still-validates
(t/async
done
(let [file (-> (two-property-file-on-other-page)
(cthf/switch-to-page :page1))
main-id (cthi/id :m01)
c01-before (cthc/get-component file :c01)
c02-before (cthc/get-component file :c02)
last-c01 (-> c01-before :variant-properties last)
last-c02 (-> c02-before :variant-properties last)]
(run file
[(dwv/add-new-variant main-id)]
done
(fn [state]
(let [file' (ths/get-file-from-state state)
container' (cths/get-shape file' :v01 :page-label :page1)
c01' (cthc/get-component file' :c01)
c02' (cthc/get-component file' :c02)
new-id (first (:shapes container'))
new-shape (cths/get-shape-by-id file' new-id :page-label :page1)
new-comp (cthc/get-component-by-id file' (:component-id new-shape))]
(t/is (= 3 (count (:shapes container'))))
(t/is (= last-c01 (-> c01' :variant-properties last)))
(t/is (= last-c02 (-> c02' :variant-properties last)))
(t/is (not= last-c01 (-> new-comp :variant-properties last)))
(t/is (= (ctv/properties-to-name (:variant-properties new-comp))
(:variant-name new-shape)))))))))
(t/deftest combine-as-variants-validates-after-every-commit
(t/async
done
(let [file (two-mains-file)
id1 (cthi/id :m01)
id2 (cthi/id :m02)
variant-id (uuid/next)
snapshots (atom [])
store (ths/setup-store file)]
(watch-applied-commits store snapshots)
(ths/run-store
store
done
[(dwv/combine-as-variants [id1 id2] {:variant-id variant-id :trigger "test"})]
(fn [state]
(let [snaps @snapshots
file' (ths/get-file-from-state state)
objects (page-objects file')
container (get objects variant-id)
m01' (get objects id1)
m02' (get objects id2)
c01' (cthc/get-component file' :c01)
c02' (cthc/get-component file' :c02)
names1 (mapv :name (:variant-properties c01'))
names2 (mapv :name (:variant-properties c02'))
ordered (mapv :main-instance-id
(cfv/find-variant-components (:data file') objects variant-id))
before-flag (take-while
#(not (ctk/is-variant-container?
(get (page-objects (:file %)) variant-id)))
snaps)
flagged (first-flagged snaps variant-id)]
(assert-valid-snapshots snaps)
(doseq [snap before-flag]
(let [ffile (:file snap)
fmain (get (page-objects ffile) id1)
fcomp (cthc/get-component ffile :c01)]
(t/is (= "Alpha" (:name fmain)))
(t/is (= "Alpha" (component-full-name fcomp)))))
(when flagged
(let [ffile (:file flagged)
fobjs (page-objects ffile)
fcont (get fobjs variant-id)
fmain (get fobjs id1)
fcomp (cthc/get-component ffile :c01)]
(t/is (= "Component" (:name fcont) (:name fmain) (component-full-name fcomp)))))
(doseq [{:keys [file]} snaps]
(let [objs (page-objects file)
cont (get objs variant-id)
kids (set (:shapes cont))]
(when (contains? kids id2)
(t/is (ctk/is-variant-container? cont)
"other mains must not enter the frame before it is a variant container"))))
(t/is (ctk/is-variant-container? container))
(t/is (= names1 names2))
(t/is (= [id1 id2] ordered))
(t/is (= variant-id (:variant-id m01')))
(t/is (= variant-id (:variant-id m02')))
(t/is (= variant-id (:parent-id m01')))
(t/is (= variant-id (:parent-id m02')))))
after-commits-idle))))
(t/deftest combine-as-variants-shared-prefix-validates-after-every-commit
(t/async
done
(let [file (two-mains-shared-prefix-file)
id1 (cthi/id :m01)
id2 (cthi/id :m02)
variant-id (uuid/next)
snapshots (atom [])
store (ths/setup-store file)]
(watch-applied-commits store snapshots)
(ths/run-store
store
done
[(dwv/combine-as-variants [id1 id2] {:variant-id variant-id :trigger "test"})]
(fn [state]
(let [snaps @snapshots
file' (ths/get-file-from-state state)
objects (page-objects file')
container (get objects variant-id)
m01' (get objects id1)
m02' (get objects id2)
c01' (cthc/get-component file' :c01)
c02' (cthc/get-component file' :c02)
flagged (first-flagged snaps variant-id)]
(assert-valid-snapshots snaps)
(doseq [{:keys [file]} snaps]
(let [objs (page-objects file)
cont (get objs variant-id)
kids (set (:shapes cont))]
(when (contains? kids id2)
(t/is (ctk/is-variant-container? cont)
"other mains must not enter the frame before it is a variant container"))))
(when flagged
(let [ffile (:file flagged)
fobjs (page-objects ffile)
fcont (get fobjs variant-id)
fmain (get fobjs id1)
fcomp (cthc/get-component ffile :c01)]
(t/is (= "UI" (:name fcont) (:name fmain) (component-full-name fcomp)))))
(t/is (ctk/is-variant-container? container))
(t/is (= "UI" (:name container) (:name m01') (:name m02')
(component-full-name c01') (component-full-name c02')))
(t/is (= variant-id (:variant-id m01') (:variant-id m02')
(:variant-id c01') (:variant-id c02')))
(t/is (= variant-id (:parent-id m01') (:parent-id m02')))))
after-commits-idle))))
(t/deftest combine-as-variants-same-name-gives-unique-values
(t/async
done
(let [file (three-same-name-mains-file)
ids [(cthi/id :m01) (cthi/id :m02) (cthi/id :m03)]
variant-id (uuid/next)
store (ths/setup-store file)]
(ths/run-store
store
done
[(dwv/combine-as-variants ids {:variant-id variant-id :trigger "test"})]
(fn [state]
(let [file' (ths/get-file-from-state state)
values (mapv #(-> (cthc/get-component file' %) :variant-properties first :value)
[:c01 :c02 :c03])]
(cthf/validate-file! file')
(t/is (= ["Value 1" "Value 2" "Value 3"] values))))
after-commits-idle))))
(t/deftest combine-as-variants-same-name-values-follow-combine-order
;; The combine order differs from the layer order of the mains
(t/async
done
(let [file (three-same-name-mains-file)
ids [(cthi/id :m03) (cthi/id :m01) (cthi/id :m02)]
variant-id (uuid/next)
store (ths/setup-store file)]
(ths/run-store
store
done
[(dwv/combine-as-variants ids {:variant-id variant-id :trigger "test"})]
(fn [state]
(let [file' (ths/get-file-from-state state)
objects (page-objects file')
values (mapv #(-> (cthc/get-component file' %) :variant-properties first :value)
[:c03 :c01 :c02])
ordered (->> (cfv/find-variant-components (:data file') objects variant-id)
(mapv #(-> % :variant-properties first :value)))]
(cthf/validate-file! file')
(t/is (= ["Value 1" "Value 2" "Value 3"] values))
(t/is (= ["Value 1" "Value 2" "Value 3"] ordered))))
after-commits-idle))))
(t/deftest combine-as-variants-from-another-page-validates-after-every-commit
(t/async
done
(let [file (two-mains-on-page1-with-page2)
id1 (cthi/id :m01)
id2 (cthi/id :m02)
page1 (cthi/id :page1)
variant-id (uuid/next)
snapshots (atom [])
store (ths/setup-store file)]
(watch-applied-commits store snapshots)
(ths/run-store
store
done
[(dwv/combine-as-variants [id1 id2] {:page-id page1 :variant-id variant-id :trigger "test"})
(dw/initialize-page (:id file) page1)]
(fn [state]
(let [snaps @snapshots
file' (ths/get-file-from-state state)
objects (page-objects-on file' :page1)
container (get objects variant-id)
m01' (get objects id1)
m02' (get objects id2)
c01' (cthc/get-component file' :c01)
c02' (cthc/get-component file' :c02)]
(assert-valid-snapshots snaps)
(t/is (ctk/is-variant-container? container))
(t/is (= variant-id (:variant-id m01')))
(t/is (= variant-id (:variant-id m02')))
(t/is (= variant-id (:variant-id c01')))
(t/is (= variant-id (:variant-id c02')))))
after-commits-idle))))