From 31478c6afc253067797d6023b899e9c7af6817f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Moya?= Date: Mon, 2 Mar 2026 16:17:12 +0100 Subject: [PATCH 01/18] :bug: Fix validation of shadow token with missing keys (#8507) --- CHANGES.md | 1 + frontend/src/app/main/data/style_dictionary.cljs | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index ff49037c8f..546cca95b8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -32,6 +32,7 @@ - Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186) - Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128) - Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513) +- Fix error activating a set with invalid shadow token applied [Taiga #13528](https://tree.taiga.io/project/penpot/issue/13528) ## 2.13.3 diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index 474fa93552..63a076f93f 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -377,7 +377,15 @@ (defn- parse-single-shadow "Parses a single shadow map with properties: x, y, blur, spread, color, type." [shadow-map shadow-index] - (let [add-keyed-errors (fn [shadow-result k errors] + (let [shadow-map (merge {:offset-x nil ;; Ensure that all keys are processed, even if missing in the original token + :offset-y nil + :blur nil + :spread nil + :color nil + :inset false} + shadow-map) + + add-keyed-errors (fn [shadow-result k errors] (update shadow-result :errors concat (map #(assoc % :shadow-key k :shadow-index shadow-index) errors))) parsers {:offset-x parse-sd-token-general-value From 58e86a545a55b54871fdfc0fbd6e7ac9edeaeecc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?andr=C3=A9s=20gonz=C3=A1lez?= Date: Tue, 3 Mar 2026 09:01:48 +0100 Subject: [PATCH 02/18] :books: Add info about grouping tokens (#8508) --- docs/img/design-tokens/40-tokens-groups.webp | Bin 0 -> 14806 bytes .../design-systems/design-tokens.njk | 44 ++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 docs/img/design-tokens/40-tokens-groups.webp diff --git a/docs/img/design-tokens/40-tokens-groups.webp b/docs/img/design-tokens/40-tokens-groups.webp new file mode 100644 index 0000000000000000000000000000000000000000..3e482f1c26780eb1ec43c28e288425ba7ad95ee2 GIT binary patch literal 14806 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 literal 0 HcmV?d00001 diff --git a/docs/user-guide/design-systems/design-tokens.njk b/docs/user-guide/design-systems/design-tokens.njk index b259a4bff0..b83b984a63 100644 --- a/docs/user-guide/design-systems/design-tokens.njk +++ b/docs/user-guide/design-systems/design-tokens.njk @@ -690,6 +690,50 @@ ExtraBold Italic
Exporting tokens as a single file.
+

Token groups

+

Token names are rarely short and simple. They often contain multiple sections that represent token type, state, property, variant, and more. To help manage this complexity, Penpot automatically organizes tokens into groups based on the structure of their names.

+ +

How token groups work

+

When Penpot encounters a dot (.) in a token name, it breaks down the name and structures it as nested groups. For example, a token named button.primary.default.background-color is organized into groups like this:

+
    +
  • button (group)
  • +
  • primary (group)
  • +
  • → → default (group)
  • +
  • → → → background-color (token)
  • +
+

If you have another token like button.primary.hover.background-color, it shares the same group structure (button.primary) and appears nested within those groups.

+

This structure matches how tokens are organized in JSON format. When you export tokens, a token like button.primary.default.background-color is structured like this:

+
{
+  "button": {
+    "primary": {
+      "default": {
+        "background-color": {
+          "$value": "#f00",
+          "$type": "color"
+        }
+      }
+    }
+  }
+}
+ +

Visual appearance

+

In the Tokens panel, token groups appear as nested, collapsible folders. Only the last segment of the token name (the actual token) appears as a pill. The segments before it appear as group folders that you can expand or collapse.

+

When you create a new token, Penpot automatically unfolds the required path so you can see your newly created token. If you manually unfold a group path, it stays open even when you navigate to other areas of the app (this state resets if you reload the page).

+
+ Design Tokens Grouping +
+ +

Working with token groups

+

Token pills keep the same actions as before: you can delete, edit, and duplicate tokens from the context menu. When editing a token name, you'll see the full token path including all group segments.

+ +

Moving tokens between groups

+

When you edit a token name and change the group segments, the token moves to its new group automatically. If the new group doesn't exist, Penpot creates it. If the group already exists, the token is moved there.

+

For example, if you rename color.background.secondary to c.bg.sec, the token moves from the color.background group to a new c.bg group.

+ +

Deleting token groups

+

When you delete a token, if it's the last token in its group, the empty group is automatically removed as well. You can also delete entire token groups directly, which removes all tokens within that group.

+

Deleting a token group removes all tokens it contains. Make sure you want to delete all tokens in a group before removing it.

+ {% if report %} @@ -21,7 +20,8 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)

Hint:
{{hint}}

Reported at:
{{created-at}}

-

Report ID:
{{id}}

+

Origin:
{{origin}}

+

HREF:
{{href}}

diff --git a/backend/src/app/loggers/database.clj b/backend/src/app/loggers/database.clj index 5f4704168f..aaa2e5d47d 100644 --- a/backend/src/app/loggers/database.clj +++ b/backend/src/app/loggers/database.clj @@ -117,7 +117,8 @@ {:context (-> (into (sorted-map) context) (pp/pprint-str :length 50)) - :props (pp/pprint-str props :length 50) + :origin (::audit/name record) + :href (get props :href) :hint (get props :hint) :report (get props :report)})) diff --git a/common/src/app/common/exceptions.cljc b/common/src/app/common/exceptions.cljc index 032690f8b4..e104be775b 100644 --- a/common/src/app/common/exceptions.cljc +++ b/common/src/app/common/exceptions.cljc @@ -299,3 +299,8 @@ (js/console.log (format-throwable cause)) (finally (js/console.groupEnd)))))) + +(defn get-hint + [cause] + (or (some-> (ex-data cause) (get :hint) first-line) + (some-> (ex-message cause) first-line))) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 51058b5fe4..222b86fdac 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -72,7 +72,6 @@ (when-let [file-id (or (:file-id data) file-id)] (println "File ID: " (str file-id))) (println "Version: " (:full cf/version)) - (println "URI: " (str cf/public-uri)) (println "HREF: " (rt/get-current-href)) (println) @@ -88,24 +87,36 @@ (.error js/console "error on generating report" cause) nil))) -(defn- show-not-blocking-error - "Show a non user blocking error notification" - [cause] - (let [data (ex-data cause) - hint (or (some-> (:hint data) ex/first-line) - (ex-message cause))] - +(defn submit-report + "Report the error report to the audit log subsystem" + [& {:keys [event-name report hint] :or {event-name "unhandled-exception"}}] + (when (and (not (str/empty? hint)) + (string? report) + (string? event-name)) (st/emit! - (ev/event {::ev/name "unhandled-exception" + (ev/event {::ev/name event-name :hint hint :href (rt/get-current-href) - :type (get data :type :unknown) - :report (generate-report cause)}) + :report report})))) - (ntf/show {:content (tr "errors.unexpected-exception" hint) - :type :toast - :level :error - :timeout 3000})))) +(defn flash + "Show error notification banner and emit error report" + [& {:keys [type hint cause] :or {type :handled}}] + (when (ex/exception? cause) + (when-let [event-name (case type + :handled "handled-exception" + :unhandled "unhandled-exception" + :silent nil)] + (let [report (generate-report cause)] + (submit-report :event-name event-name + :report report + :hint (ex/get-hint cause))))) + + (st/emit! + (ntf/show {:content (or ^boolean hint (tr "errors.generic")) + :type :toast + :level :error + :timeout 5000}))) (defmethod ptk/handle-error :default [error] @@ -114,7 +125,7 @@ (ptk/handle-error (assoc error :type :assertion)) (when-let [cause (::instance error)] (ex/print-throwable cause :prefix "Unexpected Error") - (show-not-blocking-error cause)))) + (flash :cause cause :type :unhandled)))) ;; We receive a explicit authentication error; If the uri is for ;; workspace, dashboard, viewer or settings, then assign the exception @@ -203,7 +214,7 @@ (defmethod ptk/handle-error :assertion [error] (when-let [cause (::instance error)] - (show-not-blocking-error cause) + (flash :cause cause :type :handled) (ex/print-throwable cause :prefix "Assertion Error"))) ;; ;; All the errors that happens on worker are handled here. @@ -307,7 +318,7 @@ :else (when-let [cause (::instance error)] (ex/print-throwable cause :prefix "Restriction Error") - (show-not-blocking-error cause)))) + (flash :cause cause :type :unhandled)))) ;; This happens when the backed server fails to process the ;; request. This can be caused by an internal assertion or any other @@ -333,14 +344,14 @@ (set! last-exception cause) (when-not (is-ignorable-exception? cause) (ex/print-throwable cause :prefix "Uncaught Exception") - (ts/schedule #(show-not-blocking-error cause))))) + (ts/schedule #(flash :cause cause :type :unhandled))))) (on-unhandled-rejection [event] (.preventDefault ^js event) (when-let [cause (unchecked-get event "reason")] (set! last-exception cause) (ex/print-throwable cause :prefix "Uncaught Rejection") - (ts/schedule #(show-not-blocking-error cause))))] + (ts/schedule #(flash :cause cause :type :unhandled))))] (.addEventListener g/window "error" on-unhandled-error) (.addEventListener g/window "unhandledrejection" on-unhandled-rejection) diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index 7f0020ccd3..a9f69a2c8e 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -9,13 +9,12 @@ (:require ["rxjs" :as rxjs] [app.common.data :as d] + [app.common.exceptions :as ex] [app.common.pprint :as pp] - [app.common.uri :as u] [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.auth :refer [is-authenticated?]] [app.main.data.common :as dcm] - [app.main.data.event :as ev] [app.main.errors :as errors] [app.main.refs :as refs] [app.main.repo :as rp] @@ -448,22 +447,22 @@ (mf/defc exception-section* {::mf/private true} - [{:keys [data route] :as props}] + [{:keys [data] :as props}] (let [type (get data :type) - report (mf/with-memo [data] - (some-> data ::errors/instance errors/generate-report)) + cause (get data ::errors/instance) + + report (mf/with-memo [cause] + (when (ex/exception? cause) + (errors/generate-report cause))) + props (mf/spread-props props {:report report})] - (mf/with-effect [data route report] - (let [params (:query-params route) - params (u/map->query-string params)] - (st/emit! (ev/event {::ev/name "exception-page" - :type (get data :type :unknown) - :href (rt/get-current-href) - :hint (get data :hint) - :path (get route :path) - :report report - :params params})))) + (mf/with-effect [report type cause] + (when (and (ex/exception? cause) + (not (contains? #{:not-found :authentication} type))) + (errors/submit-report :event-name "exception-page" + :report report + :hint (ex/get-hint cause)))) (case type :not-found From c3f511775730c6d4807564b43ae39936de8274ae Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 4 Mar 2026 09:47:14 +0100 Subject: [PATCH 09/18] :bug: Fix unhandled exception on using decimals on stroke row (#8405) --- .../ui/workspace/sidebar/options/rows/stroke_row.cljs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs index 81a0d050d3..ec5770eabb 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs @@ -94,12 +94,12 @@ (mf/use-fn (mf/deps index on-stroke-width-change) (fn [value] - (if (or (string? value) (int? value)) + (if (or (string? value) (number? value)) (on-stroke-width-change index value) - (do - (st/emit! (dwta/toggle-token {:token (first value) - :attrs #{:stroke-width} - :shape-ids ids})))))) + + (st/emit! (dwta/toggle-token {:token (first value) + :attrs #{:stroke-width} + :shape-ids ids}))))) stroke-alignment (or (:stroke-alignment stroke) :center) From e1d556f4aadbe049a89a7239e25495ff75491e61 Mon Sep 17 00:00:00 2001 From: Xaviju Date: Wed, 4 Mar 2026 10:33:29 +0100 Subject: [PATCH 10/18] :bug: Sort tokens by name (#8488) --- common/src/app/common/path_names.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/path_names.cljc b/common/src/app/common/path_names.cljc index 74774c0044..00038cdf6c 100644 --- a/common/src/app/common/path_names.cljc +++ b/common/src/app/common/path_names.cljc @@ -188,7 +188,7 @@ Some naming conventions: [segments separator] (let [sorted (sort-by-children segments separator) grouped (group-by-first-segment sorted separator)] - grouped)) + (into (sorted-map) grouped))) (defn- build-tree-node "Builds a single tree node with lazy children." From cc3033735b3f7b6719da761716a874513be094c4 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 4 Mar 2026 10:58:36 +0100 Subject: [PATCH 11/18] :bug: Fix showing warning when no shape is selected (#8515) --- .../app/main/ui/workspace/tokens/management/group.cljs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs index ed196a6673..08178d5a8c 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs @@ -140,10 +140,11 @@ (if (and not-editing? (seq selected-shapes) (not= (:type token) :number)) (st/emit! (dwta/toggle-token {:token token :shape-ids selected-ids})) - (st/emit! (ntf/show {:content (tr "workspace.tokens.error-text-edition") - :type :toast - :level :warning - :timeout 3000}))))))] + (when (seq selected-shapes) + (st/emit! (ntf/show {:content (tr "workspace.tokens.error-text-edition") + :type :toast + :level :warning + :timeout 3000})))))))] [:div {:class (stl/css :token-section-wrapper) :data-testid (dm/str "section-" (name type))} From 5a6be141fdee1f80192e104c3ffd12939cfad17d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?andr=C3=A9s=20gonz=C3=A1lez?= Date: Wed, 4 Mar 2026 14:59:04 +0100 Subject: [PATCH 12/18] :books: Add info about using math in tokens (#8510) --- .../design-systems/design-tokens.njk | 70 ++++++++++++++++--- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/docs/user-guide/design-systems/design-tokens.njk b/docs/user-guide/design-systems/design-tokens.njk index 15373b1241..878cfb4230 100644 --- a/docs/user-guide/design-systems/design-tokens.njk +++ b/docs/user-guide/design-systems/design-tokens.njk @@ -42,20 +42,74 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG

If the value of the referenced token changes, this will also change the value of the tokens where it is referenced.

References to existing tokens are case sensitive.

-

Using equations

-

Token types with numerical values also accept mathematical equations. If, for example, you create a spacing.small token with the value of 2, and you then want to create a spacing.medium token that is twice as large, you could do so by writing {spacing.small} * 2 in its value. As a result, spacing.medium would have a value of 4.

-

Say you have a spacing.scale token with a value of 2. You could also use this token in the equation to calculate the value of spacing.medium by writing {spacing.small} * {spacing.scale} in its value.

+

Using math in token values

+

Token types with numerical values accept mathematical equations to calculate their values. This allows you to create dynamic relationships between tokens and build flexible design systems.

+

For example, if you create a spacing.small token with the value of 2, and you want to create a spacing.medium token that is twice as large, you can write {spacing.small} * 2 in its value. As a result, spacing.medium would have a value of 4.

+

You can also reference other tokens in your equations. Say you have a spacing.scale token with a value of 2. You could use this token in the equation to calculate the value of spacing.medium by writing {spacing.small} * {spacing.scale} in its value.

Tokens math
-

Mathematical equations can be performed using:

+ +

Basic operators

+

Mathematical equations can be performed using these basic operators:

    -
  • + for addition.
  • -
  • - for subtraction.
  • -
  • * for multiplication.
  • -
  • / for division.
  • +
  • + for addition
  • +
  • - for subtraction
  • +
  • * for multiplication
  • +
  • / for division
  • +
  • % for modulo (remainder)
  • +
  • ^ for exponentiation
+

Math functions

+

In addition to basic operators, you can use various math functions in your token values:

+
    +
  • abs(x) - absolute value
  • +
  • ceil(x) - round up to nearest integer
  • +
  • floor(x) - round down to nearest integer
  • +
  • round(x) - round to nearest integer
  • +
  • max(x, y, ...) - maximum value
  • +
  • min(x, y, ...) - minimum value
  • +
  • sqrt(x) - square root
  • +
  • pow(x, y) - x raised to the power of y
  • +
  • log(x) - natural logarithm
  • +
  • exp(x) - e raised to the power of x
  • +
  • sin(x) - sine
  • +
  • cos(x) - cosine
  • +
  • tan(x) - tangent
  • +
  • asin(x) - arcsine
  • +
  • acos(x) - arccosine
  • +
  • atan(x) - arctangent
  • +
  • atan2(y, x) - arctangent of y/x
  • +
+ +

Syntax and best practices

+

When writing math equations in token values, keep these guidelines in mind:

+
    +
  • Simple equations can be written with or without brackets. For example, 8 * 8 and (8 * 8) both resolve to 64.
  • +
  • Complex formulas require spaces between operators to ensure tokens are transformed correctly. For example, use 8 * 8 instead of 8*8.
  • +
  • Reference tokens using curly braces: {token.name}.
  • +
  • You can combine hard-coded values with token references: {spacing.base} * 1.5.
  • +
+ +

Practical examples

+

Here are some common use cases for math in token values:

+ +

Round to the nearest whole number

+

When using multipliers that result in decimals, you can use the round() function to get whole numbers. For example, if sizing.sm has a value of 2:

+
round({sizing.sm} * 1.33)
+

This calculates 2 * 1.33 = 2.66, which rounds to 3.

+ +

Create a percentage from a unitless number

+

You can convert unitless numbers to percentages. For example, a Number token called lineHeights.heading.relaxed with a value of 1.5 can be written in a Line Height token as:

+
{lineHeights.heading.relaxed} * 100%
+

This calculates a resolved value of 150%.

+ +

Calculate maximum or minimum values

+

Use max() or min() to ensure values stay within bounds. For example:

+
max({spacing.base}, 8)
+

This ensures the spacing is at least 8, even if the base token is smaller.

+

Editing a token

Tokens can be edited by right-clicking the token and selecting Edit token. This will allow you to change the tokens name, value and description. Once the changes are made, click Save.

From 591d63e47086959758e8a79a47728c25edf78bce Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 18 Feb 2026 17:12:50 +0100 Subject: [PATCH 13/18] :sparkles: Add better error report on wrong input on logging helpers --- common/src/app/common/logging.cljc | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/common/src/app/common/logging.cljc b/common/src/app/common/logging.cljc index db7bafe0c8..81b4dd4131 100644 --- a/common/src/app/common/logging.cljc +++ b/common/src/app/common/logging.cljc @@ -128,7 +128,9 @@ :warn "#f5871f" :info "#4271ae" :debug "#969896" - :trace "#8e908c")) + :trace "#8e908c" + (let [hint (str "invalid level provided to `level->color` function: " (pr-str level))] + (throw (ex-info hint {:level level}))))) (defn- level->name [level] @@ -137,7 +139,9 @@ :trace "TRC" :info "INF" :warn "WRN" - :error "ERR")) + :error "ERR" + (let [hint (str "invalid level provided to `level->name` function: " (pr-str level))] + (throw (ex-info hint {:level level}))))) (defn level->int [level] @@ -146,7 +150,9 @@ :debug 20 :info 30 :warn 40 - :error 50)) + :error 50 + (let [hint (str "invalid level provided to `level->int` function: " (pr-str level))] + (throw (ex-info hint {:level level}))))) (defn build-message [props] From 151238e51883d7343727d1b31d9d286d686a1862 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 23 Feb 2026 11:38:52 +0100 Subject: [PATCH 14/18] :lipstick: Add cosmetic change to link-file-to-library rpc method impl --- backend/src/app/rpc/commands/files.clj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 9cbdf08be6..7ce7d28d74 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -1005,19 +1005,19 @@ "Link a file to a library. Returns the recursive list of libraries used by that library" {::doc/added "1.17" ::webhooks/event? true - ::sm/params schema:link-file-to-library} - [cfg {:keys [::rpc/profile-id file-id library-id] :as params}] + ::sm/params schema:link-file-to-library + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}] + (when (= file-id library-id) (ex/raise :type :validation :code :invalid-library :hint "A file cannot be linked to itself")) - (db/tx-run! cfg - (fn [{:keys [::db/conn]}] - (check-edition-permissions! conn profile-id file-id) - (check-edition-permissions! conn profile-id library-id) - (link-file-to-library conn params) - (bfc/get-libraries cfg [library-id])))) + (check-edition-permissions! conn profile-id file-id) + (check-edition-permissions! conn profile-id library-id) + (link-file-to-library conn params) + (bfc/get-libraries cfg [library-id])) ;; --- MUTATION COMMAND: unlink-file-from-library From 77955d7f917356642b379bb525a41729ab5680ad Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 23 Feb 2026 11:39:29 +0100 Subject: [PATCH 15/18] :sparkles: Add several redundant checks for library-id on file rpc methods --- backend/src/app/rpc/commands/files.clj | 6 ++- backend/test/backend_tests/rpc_file_test.clj | 46 ++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 7ce7d28d74..69c36a0e44 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -1037,8 +1037,9 @@ ::webhooks/event? true ::sm/params schema:unlink-file-to-library ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}] (check-edition-permissions! conn profile-id file-id) + (check-edition-permissions! conn profile-id library-id) (unlink-file-from-library conn params) nil) @@ -1062,8 +1063,9 @@ {::doc/added "1.17" ::sm/params schema:update-file-library-sync-status ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id file-id] :as params}] + [{:keys [::db/conn]} {:keys [::rpc/profile-id file-id library-id] :as params}] (check-edition-permissions! conn profile-id file-id) + (check-edition-permissions! conn profile-id library-id) (update-sync conn params)) ;; --- MUTATION COMMAND: ignore-sync diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index beb1327d8b..921477d1b3 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -867,6 +867,52 @@ (t/is (th/ex-info? error)) (t/is (th/ex-of-type? error :not-found)))) +(t/deftest permissions-checks-unlink-library + (let [profile1 (th/create-profile* 1) + profile2 (th/create-profile* 2) + file1 (th/create-file* 1 {:project-id (:default-project-id profile1) + :profile-id (:id profile1) + :is-shared true}) + file2 (th/create-file* 2 {:project-id (:default-project-id profile1) + :profile-id (:id profile1)})] + + + (let [data {::th/type :unlink-file-from-library + ::rpc/profile-id (:id profile2) + :file-id (:id file2) + :library-id (:id file1)} + + out (th/command! data) + error (:error out)] + + ;; (th/print-result! out) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found))))) + + +(t/deftest permissions-checks-update-file-library-status + (let [profile1 (th/create-profile* 1) + profile2 (th/create-profile* 2) + file1 (th/create-file* 1 {:project-id (:default-project-id profile1) + :profile-id (:id profile1) + :is-shared true}) + file2 (th/create-file* 2 {:project-id (:default-project-id profile1) + :profile-id (:id profile1)})] + + + (let [data {::th/type :update-file-library-sync-status + ::rpc/profile-id (:id profile2) + :file-id (:id file2) + :library-id (:id file1)} + + out (th/command! data) + error (:error out)] + + ;; (th/print-result! out) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found))))) + + (t/deftest deletion (let [profile1 (th/create-profile* 1) file (th/create-file* 1 {:project-id (:default-project-id profile1) From 0ceadada35363d4404598204e518c817abcffa55 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 24 Feb 2026 10:56:38 +0100 Subject: [PATCH 16/18] :bug: Fix invalid data on layout flex dir shape property --- common/src/app/common/files/migrations.cljc | 23 ++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc index 1a1efd807d..3655f3ece5 100644 --- a/common/src/app/common/files/migrations.cljc +++ b/common/src/app/common/files/migrations.cljc @@ -1766,6 +1766,26 @@ (update :pages-index d/update-vals update-container) (d/update-when :components d/update-vals update-container)))) +(defmethod migrate-data "0017-fix-layout-flex-dir" + [data _] + (let [fix-layout-flex-dir + (fn [value] + (if (= value :reverse-row) + :row-reverse + value)) + + update-object + (fn [object] + (d/update-when object :layout-flex-dir fix-layout-flex-dir)) + + update-container + (fn [container] + (d/update-when container :objects d/update-vals update-object))] + + (-> data + (update :pages-index d/update-vals update-container) + (d/update-when :components d/update-vals update-container)))) + (def available-migrations (into (d/ordered-set) ["legacy-2" @@ -1839,4 +1859,5 @@ "0014-clear-components-nil-objects" "0015-fix-text-attrs-blank-strings" "0015-clean-shadow-color" - "0016-copy-fills-from-position-data-to-text-node"])) + "0016-copy-fills-from-position-data-to-text-node" + "0017-fix-layout-flex-dir"])) From c59cc4dff459320b466b4eb711cff5660ccb7c10 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 9 Mar 2026 12:11:39 +0100 Subject: [PATCH 17/18] :bug: Fix tooltip position on absolute positioned elements (#8509) * :bug: Fix tooltip position on absolute positioned elements * :bug: Fix tests --- .../playwright/ui/specs/colorpicker.spec.js | 4 +- .../playwright/ui/specs/tokens/apply.spec.js | 47 +++-- .../playwright/ui/specs/tokens/crud.spec.js | 17 +- .../app/main/ui/ds/buttons/icon_button.cljs | 4 + .../ui/ds/controls/shared/token_option.cljs | 9 +- .../ui/ds/controls/utilities/input_field.cljs | 1 + .../ui/ds/controls/utilities/token_field.cljs | 4 + .../src/app/main/ui/ds/tooltip/tooltip.cljs | 164 ++++++++++-------- .../src/app/main/ui/ds/tooltip/tooltip.scss | 4 +- .../src/app/main/ui/ds/utilities/swatch.cljs | 9 +- .../styles/property_detail_copiable.cljs | 5 +- .../styles/rows/color_properties_row.cljs | 3 + .../inspect/styles/rows/properties_row.cljs | 3 + .../workspace/colorpicker/color_tokens.cljs | 5 +- .../sidebar/options/rows/color_row.cljs | 4 + .../tokens/management/forms/modals.scss | 22 ++- 16 files changed, 176 insertions(+), 129 deletions(-) diff --git a/frontend/playwright/ui/specs/colorpicker.spec.js b/frontend/playwright/ui/specs/colorpicker.spec.js index e727f4aac7..a0e28eea07 100644 --- a/frontend/playwright/ui/specs/colorpicker.spec.js +++ b/frontend/playwright/ui/specs/colorpicker.spec.js @@ -94,7 +94,7 @@ test("Create a LINEAR gradient", async ({ page }) => { await expect(inputOpacityGlobal).toBeVisible(); await expect( - workspacePage.page.getByText("Linear gradient").nth(1), + workspacePage.page.getByText("Linear gradient") ).toBeVisible(); }); @@ -178,7 +178,7 @@ test("Create a RADIAL gradient", async ({ page }) => { await expect(inputOpacityGlobal).toBeVisible(); await expect( - workspacePage.page.getByText("Radial gradient").nth(1), + workspacePage.page.getByText("Radial gradient") ).toBeVisible(); }); diff --git a/frontend/playwright/ui/specs/tokens/apply.spec.js b/frontend/playwright/ui/specs/tokens/apply.spec.js index 2952a23c87..a05e28b2ff 100644 --- a/frontend/playwright/ui/specs/tokens/apply.spec.js +++ b/frontend/playwright/ui/specs/tokens/apply.spec.js @@ -83,7 +83,7 @@ test.describe("Tokens: Apply token", () => { await brTokenPillSM.click(); // Change token from dropdown - const brTokenOptionXl = borderRadiusSection.getByLabel("borderRadius.xl"); + const brTokenOptionXl = borderRadiusSection.getByRole('option', { name: 'borderRadius.xl' }) await expect(brTokenOptionXl).toBeVisible(); await brTokenOptionXl.click(); @@ -149,7 +149,7 @@ test.describe("Tokens: Apply token", () => { await detachButton.click(); // Open dropdown from input - const dropdownBtn = layerMenuSection.getByLabel("Open token list"); + const dropdownBtn = layerMenuSection.getByRole('button', { name: 'Open token list' }) await expect(dropdownBtn).toBeVisible(); await dropdownBtn.click(); @@ -225,8 +225,8 @@ test.describe("Tokens: Apply token", () => { await expect(firstShadowFields).toBeVisible(); // Fill in the shadow values - const offsetXInput = firstShadowFields.getByLabel("X"); - const offsetYInput = firstShadowFields.getByLabel("Y"); + const offsetXInput = firstShadowFields.getByRole('textbox', { name: 'X' }); + const offsetYInput = firstShadowFields.getByRole('textbox', { name: 'Y' }); const blurInput = firstShadowFields.getByRole("textbox", { name: "Blur", }); @@ -299,8 +299,8 @@ test.describe("Tokens: Apply token", () => { await expect(thirdShadowFields).toBeVisible(); // User adds values for the third shadow - const thirdOffsetXInput = thirdShadowFields.getByLabel("X"); - const thirdOffsetYInput = thirdShadowFields.getByLabel("Y"); + const thirdOffsetXInput = thirdShadowFields.getByRole('textbox', { name: 'X' }); + const thirdOffsetYInput = thirdShadowFields.getByRole('textbox', { name: 'Y' }); const thirdBlurInput = thirdShadowFields.getByRole("textbox", { name: "Blur", }); @@ -328,10 +328,10 @@ test.describe("Tokens: Apply token", () => { // Verify that the first shadow kept its values const firstOffsetXValue = await firstShadowFields - .getByLabel("X") + .getByRole('textbox', { name: 'X' }) .inputValue(); const firstOffsetYValue = await firstShadowFields - .getByLabel("Y") + .getByRole('textbox', { name: 'Y' }) .inputValue(); const firstBlurValue = await firstShadowFields .getByRole("textbox", { name: "Blur" }) @@ -357,10 +357,10 @@ test.describe("Tokens: Apply token", () => { await expect(newSecondShadowFields).toBeVisible(); const secondOffsetXValue = await newSecondShadowFields - .getByLabel("X") + .getByRole('textbox', { name: 'X' }) .inputValue(); const secondOffsetYValue = await newSecondShadowFields - .getByLabel("Y") + .getByRole('textbox', { name: 'Y' }) .inputValue(); const secondBlurValue = await newSecondShadowFields .getByRole("textbox", { name: "Blur" }) @@ -410,10 +410,10 @@ test.describe("Tokens: Apply token", () => { // Verify first shadow values are still there const restoredFirstOffsetX = await firstShadowFields - .getByLabel("X") + .getByRole('textbox', { name: 'X' }) .inputValue(); const restoredFirstOffsetY = await firstShadowFields - .getByLabel("Y") + .getByRole('textbox', { name: 'Y' }) .inputValue(); const restoredFirstBlur = await firstShadowFields .getByRole("textbox", { name: "Blur" }) @@ -433,10 +433,10 @@ test.describe("Tokens: Apply token", () => { // Verify second shadow values are still there const restoredSecondOffsetX = await newSecondShadowFields - .getByLabel("X") + .getByRole('textbox', { name: 'X' }) .inputValue(); const restoredSecondOffsetY = await newSecondShadowFields - .getByLabel("Y") + .getByRole('textbox', { name: 'Y' }) .inputValue(); const restoredSecondBlur = await newSecondShadowFields .getByRole("textbox", { name: "Blur" }) @@ -518,7 +518,7 @@ test.describe("Tokens: Apply token", () => { await dimensionSMTokenPill.nth(1).click(); // Change token from dropdown - const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl"); + const dimensionTokenOptionXl = measuresSection.getByRole('option', { name: 'dimension.xl' }) await expect(dimensionTokenOptionXl).toBeVisible(); await dimensionTokenOptionXl.click(); @@ -572,7 +572,7 @@ test.describe("Tokens: Apply token", () => { await dimensionSMTokenPill.click(); // Change token from dropdown - const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl"); + const dimensionTokenOptionXl = measuresSection.getByRole('option', { name: 'dimension.xl' }); await expect(dimensionTokenOptionXl).toBeVisible(); await dimensionTokenOptionXl.click(); @@ -626,7 +626,7 @@ test.describe("Tokens: Apply token", () => { await dimensionSMTokenPill.click(); // Change token from dropdown - const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl"); + const dimensionTokenOptionXl = measuresSection.getByRole('option', { name: 'dimension.xl' }); await expect(dimensionTokenOptionXl).toBeVisible(); await dimensionTokenOptionXl.click(); @@ -682,7 +682,7 @@ test.describe("Tokens: Apply token", () => { // Change token from dropdown const dimensionTokenOptionXl = - borderRadiusSection.getByLabel("dimension.xl"); + borderRadiusSection.getByRole('option', { name: 'dimension.xl' }); await expect(dimensionTokenOptionXl).toBeVisible(); await dimensionTokenOptionXl.click(); @@ -751,7 +751,7 @@ test.describe("Tokens: Apply token", () => { }); await tokenDropdown.click(); - const widthOptionSmall = firstStrokeRow.getByLabel("width-small"); + const widthOptionSmall = firstStrokeRow.getByRole('option', { name: 'width-small' }); await expect(widthOptionSmall).toBeVisible(); await widthOptionSmall.click(); const StrokeWidthPillSmall = firstStrokeRow.getByRole("button", { @@ -831,15 +831,10 @@ test.describe("Tokens: Apply token", () => { }); await detachButton.click(); await expect(marginPillXL).not.toBeVisible(); - const horizontalMarginInput = layoutItemSectionSidebar.getByText( - "Horizontal marginOpen token", - ); - await expect(horizontalMarginInput).toBeVisible(); - - const tokenDropdown = horizontalMarginInput.getByRole("button", { + const horizontalMarginInput = layoutItemSectionSidebar.getByRole("button", { name: "Open token list", }); - await tokenDropdown.click(); + await horizontalMarginInput.nth(1).click(); await expect(dimensionTokenOptionXl).toBeVisible(); await dimensionTokenOptionXl.click(); diff --git a/frontend/playwright/ui/specs/tokens/crud.spec.js b/frontend/playwright/ui/specs/tokens/crud.spec.js index b726db042d..e96ef55ae8 100644 --- a/frontend/playwright/ui/specs/tokens/crud.spec.js +++ b/frontend/playwright/ui/specs/tokens/crud.spec.js @@ -1024,7 +1024,7 @@ test.describe("Tokens - creation", () => { const nameField = tokensUpdateCreateModal.getByLabel("Name"); await nameField.fill("typography.empty"); - const valueField = tokensUpdateCreateModal.getByLabel("Font Size"); + const valueField = tokensUpdateCreateModal.getByRole("textbox", {name: "Font Size"}); // Insert a value and then delete it await valueField.fill("1"); @@ -1716,12 +1716,12 @@ test.describe("Tokens tab - edition", () => { // Fill font-family to verify to verify that input value doesn't get split into list of characters const fontFamilyField = tokensUpdateCreateModal - .getByLabel("Font family") + .getByRole("textbox", { name: "Font family" }) .first(); await fontFamilyField.fill("OneWord"); // Invalidate incorrect values for font size - const fontSizeField = tokensUpdateCreateModal.getByLabel(/Font Size/i); + const fontSizeField = tokensUpdateCreateModal.getByRole("textbox", { name: "Font Size" }); await fontSizeField.fill("invalid"); await expect( tokensUpdateCreateModal.getByText(/Invalid token value:/), @@ -1736,13 +1736,13 @@ test.describe("Tokens tab - edition", () => { await fontSizeField.fill("16"); await expect(saveButton).toBeEnabled(); - const fontWeightField = tokensUpdateCreateModal.getByLabel(/Font Weight/i); + const fontWeightField = tokensUpdateCreateModal.getByRole("textbox", { name: "Font Weight" }); const letterSpacingField = - tokensUpdateCreateModal.getByLabel(/Letter Spacing/i); - const lineHeightField = tokensUpdateCreateModal.getByLabel(/Line Height/i); - const textCaseField = tokensUpdateCreateModal.getByLabel(/Text Case/i); + tokensUpdateCreateModal.getByRole("textbox", { name: "Letter Spacing" }); + const lineHeightField = tokensUpdateCreateModal.getByRole("textbox", { name: "Line Height" }); + const textCaseField = tokensUpdateCreateModal.getByRole("textbox", { name: "Text Case" }); const textDecorationField = - tokensUpdateCreateModal.getByLabel(/Text Decoration/i); + tokensUpdateCreateModal.getByRole("textbox", { name: "Text Decoration" }); // Capture all values before switching tabs const originalValues = { @@ -1800,6 +1800,7 @@ test.describe("Tokens tab - edition", () => { const colorToken = tokensSidebar.getByRole("button", { name: "100", }); + await expect(colorToken).toBeVisible(); await colorToken.click({ button: "right" }); diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs index 3d766db460..1457dabc06 100644 --- a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs +++ b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs @@ -33,6 +33,8 @@ (let [variant (d/nilv variant "primary") + button-ref (mf/use-ref nil) + tooltip-id (mf/use-id) @@ -47,10 +49,12 @@ props (mf/spread-props props {:class [class button-class] + :ref button-ref :aria-labelledby tooltip-id})] [:> tooltip* {:content aria-label :class tooltip-class + :trigger-ref button-ref :placement tooltip-placement :id tooltip-id} [:> :button props diff --git a/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs b/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs index 3c13988cd3..18d5fc8a7d 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs @@ -28,7 +28,8 @@ {::mf/schema schema:token-option} [{:keys [id name on-click selected ref focused resolved] :rest props}] (let [internal-id (mf/use-id) - id (d/nilv id internal-id)] + id (d/nilv id internal-id) + element-ref (mf/use-ref nil)] [:li {:value id :class (stl/css-case :token-option true :option-with-pill true @@ -50,10 +51,12 @@ :aria-hidden (when name true)}] [:span {:class (stl/css :icon-placeholder)}]) [:> tooltip* {:content name + :trigger-ref element-ref :id (dm/str id "-name") :class (stl/css :option-text)} - ;; Add ellipsis - [:span {:aria-labelledby (dm/str id "-name")} + ;; Add ellipsis + [:span {:aria-labelledby (dm/str id "-name") + :ref element-ref} name]] (when resolved [:> :span {:class (stl/css :option-pill)} diff --git a/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs b/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs index 56ceedc848..58a3202c80 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs +++ b/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs @@ -84,6 +84,7 @@ :on-click on-icon-click}]) (if aria-label [:> tooltip* {:content aria-label + :trigger-ref (or ref input-ref) :class (stl/css :tooltip-wrapper) :id tooltip-id} [:> "input" props]] diff --git a/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs b/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs index 8f4920ae69..c008ce2bd0 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs +++ b/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs @@ -43,6 +43,7 @@ (tr "ds.inputs.token-field.no-active-token-option")) default-id (mf/use-id) id (d/nilv id default-id) + pill-ref (mf/use-ref nil) focus-wrapper (mf/use-fn @@ -53,6 +54,7 @@ (dom/focus! (mf/ref-val token-wrapper-ref)))))] [:> tooltip* {:content property :class (stl/css :token-field-wrapper) + :trigger-ref token-wrapper-ref :id (dm/str default-id "-input")} [:div {:class [class (stl/css-case :token-field true :with-icon (some? slot-start) @@ -70,8 +72,10 @@ [:div {:class (stl/css :content-wrapper)} [:> tooltip* {:content content + :trigger-ref pill-ref :id (dm/str id "-pill")} [:button {:on-click on-click + :ref pill-ref :class (stl/css-case :pill true :no-set-pill (not set-active?) :pill-disabled disabled) diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index 707540fd95..690384647c 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -6,7 +6,6 @@ (ns app.main.ui.ds.tooltip.tooltip (:require-macros - [app.common.data.macros :as dm] [app.main.style :as stl]) (:require [app.common.data :as d] @@ -15,10 +14,10 @@ [app.util.timers :as ts] [rumext.v2 :as mf])) -(def ^:private ^:const arrow-height 12) -(def ^:private ^:const half-arrow-height (/ arrow-height 2)) (def ^:private ^:const overlay-offset 32) +(defonce active-tooltip (atom nil)) + (defn- clear-schedule [ref] (when-let [schedule (mf/ref-val ref)] @@ -29,20 +28,6 @@ [ref delay f] (mf/set-ref-val! ref (ts/schedule delay f))) -(defn- show-popover - [node] - (when (.-isConnected ^js node) - (.showPopover ^js node))) - -(defn- hide-popover - [node] - (when (and (some? node) - (fn? (.-hidePopover node))) - (dom/unset-css-property! node "block-size") - (dom/unset-css-property! node "inset-block-start") - (dom/unset-css-property! node "inset-inline-start") - (.hidePopover ^js node))) - (defn- calculate-placement-bounding-rect "Given a placement, calcultates the bounding rect for it taking in account provided tooltip bounding rect and the origin bounding @@ -72,18 +57,18 @@ :height tooltip-height} "left" - {:top (- (+ trigger-top (/ trigger-height 2) half-arrow-height) (/ tooltip-height 2)) - :left (- trigger-left tooltip-width arrow-height) + {:top (- (+ trigger-top (/ trigger-height 2)) (/ tooltip-height 2)) + :left (- trigger-left tooltip-width) :right (+ (- trigger-left tooltip-width) tooltip-width) - :bottom (+ (- (+ trigger-top (/ trigger-height 2) half-arrow-height) (/ tooltip-height 2)) tooltip-height) + :bottom (+ (- (+ trigger-top (/ trigger-height 2)) (/ tooltip-height 2)) tooltip-height) :width tooltip-width :height tooltip-height} "right" - {:top (- (+ trigger-top (/ trigger-height 2) half-arrow-height) (/ tooltip-height 2)) + {:top (- (+ trigger-top (/ trigger-height 2)) (/ tooltip-height 2)) :left (+ trigger-right offset) :right (+ trigger-right offset tooltip-width) - :bottom (+ (- (+ trigger-top (/ trigger-height 2) half-arrow-height) (/ tooltip-height 2)) tooltip-height) + :bottom (+ (- (+ trigger-top (/ trigger-height 2)) (/ tooltip-height 2)) tooltip-height) :width tooltip-width :height tooltip-height} @@ -153,22 +138,6 @@ (recur (rest placements)) #js [placement placement-brect]))))) -(defn- update-tooltip-position - "Update the tooltip position having in account the current window - size, placement. It calculates the appropriate placement and updates - the dom with the result." - [tooltip placement origin-brect offset] - (show-popover tooltip) - (let [tooltip-brect (dom/get-bounding-rect tooltip) - tooltip-brect (assoc tooltip-brect :height (:height tooltip-brect) :width (:width tooltip-brect)) - window-size (dom/get-window-size)] - (when-let [[placement placement-rect] (find-matching-placement placement tooltip-brect origin-brect window-size offset)] - (let [height (:height placement-rect)] - (dom/set-css-property! tooltip "block-size" (dm/str height "px")) - (dom/set-css-property! tooltip "inset-block-start" (dm/str (:top placement-rect) "px")) - (dom/set-css-property! tooltip "inset-inline-start" (dm/str (:left placement-rect) "px"))) - placement))) - (def ^:private schema:tooltip [:map [:class {:optional true} [:maybe :string]] @@ -176,19 +145,26 @@ [:offset {:optional true} :int] [:delay {:optional true} :int] [:content [:or fn? :string map?]] + [:trigger-ref {:optional true} [:maybe :any]] [:placement {:optional true} [:maybe [:enum "top" "bottom" "left" "right" "top-right" "bottom-right" "bottom-left" "top-left"]]]]) (mf/defc tooltip* {::mf/schema schema:tooltip} - [{:keys [class id children content placement offset delay] :rest props}] + [{:keys [class id children content placement offset delay trigger-ref aria-label] :rest props}] (let [internal-id (mf/use-id) - trigger-ref (mf/use-ref nil) + internal-trigger-ref (mf/use-ref nil) + trigger-ref (or trigger-ref internal-trigger-ref) + + tooltip-ref (mf/use-ref nil) id (d/nilv id internal-id) + tooltip-id + (mf/use-id) + placement* (mf/use-state #(d/nilv placement "top")) @@ -201,35 +177,35 @@ schedule-ref (mf/use-ref nil) + visible* + (mf/use-state false) + visible (deref visible*) + on-show (mf/use-fn - (mf/deps id placement offset) - (fn [event] - - (let [current (dom/get-current-target event) - related (dom/get-related-target event) - is-node? (fn [node] (and node (.-nodeType node)))] - (when-not (and related (is-node? related) (.contains current related)) - (clear-schedule schedule-ref) - (when-let [tooltip (dom/get-element id)] - (let [origin-brect - (dom/get-bounding-rect (mf/ref-val trigger-ref)) - - update-position - (fn [] - (let [new-placement (update-tooltip-position tooltip placement origin-brect offset)] - (when (not= new-placement placement) - (reset! placement* new-placement))))] - - (add-schedule schedule-ref delay update-position))))))) + (mf/deps tooltip-id delay) + (fn [_] + (let [trigger-el (mf/ref-val trigger-ref)] + (clear-schedule schedule-ref) + (add-schedule schedule-ref (d/nilv delay 300) + (fn [] + (prn tooltip-id) + (when-let [active @active-tooltip] + (when (not= (:id active) tooltip-id) + (when-let [tooltip-el (dom/get-element (:id active))] + (dom/set-css-property! tooltip-el "display" "none")) + (reset! active-tooltip nil))) + (reset! active-tooltip {:id tooltip-id :trigger trigger-el}) + (reset! visible* true)))))) on-hide (mf/use-fn - (mf/deps id) + (mf/deps tooltip-id) (fn [] - (when-let [tooltip (dom/get-element id)] - (clear-schedule schedule-ref) - (hide-popover tooltip)))) + (clear-schedule schedule-ref) + (reset! visible* false) + (when (= (:id @active-tooltip) tooltip-id) + (reset! active-tooltip nil)))) handle-key-down (mf/use-fn @@ -250,28 +226,62 @@ :tooltip-bottom-left (identical? placement "bottom-left") :tooltip-top-left (identical? placement "top-left")) + content + (if (fn? content) + (content) + content) props (mf/spread-props props {:on-mouse-enter on-show :on-mouse-leave on-hide :on-focus on-show :on-blur on-hide + :ref internal-trigger-ref :on-key-down handle-key-down - :ref trigger-ref + :id id :class [class (stl/css :tooltip-trigger)] - :aria-describedby id}) - content - (if (fn? content) - (content) - content)] + :aria-label (if (string? content) + content + aria-label)})] + + (mf/use-effect + (mf/deps visible placement offset) + (fn [] + (when visible + (let [trigger-el (mf/ref-val trigger-ref) + tooltip-el (mf/ref-val tooltip-ref)] + (when (and trigger-el tooltip-el) + (js/requestAnimationFrame + (fn [] + (let [origin-brect (dom/get-bounding-rect trigger-el) + tooltip-brect (dom/get-bounding-rect tooltip-el) + window-size (dom/get-window-size)] + (when-let [[new-placement placement-rect] + (find-matching-placement + placement + tooltip-brect + origin-brect + window-size + offset)] + (dom/set-css-property! tooltip-el "inset-block-start" + (str (:top placement-rect) "px")) + (dom/set-css-property! tooltip-el "inset-inline-start" + (str (:left placement-rect) "px")) + + (when (not= new-placement placement) + (reset! placement* new-placement))))))))))) [:> :div props children - [:div {:class (stl/css :tooltip) - :id id - :popover "auto" - :role "tooltip"} - [:div {:class tooltip-class} - [:div {:class (stl/css :tooltip-content)} content] - [:div {:class (stl/css :tooltip-arrow) - :id "tooltip-arrow"}]]]])) + (when visible + (mf/portal + (mf/html + [:div {:class (stl/css :tooltip) + :role "tooltip" + :id tooltip-id + :ref tooltip-ref} + [:div {:class tooltip-class} + [:div {:class (stl/css :tooltip-content)} content] + [:div {:class (stl/css :tooltip-arrow) + :id "tooltip-arrow"}]]]) + (.-body js/document)))])) diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.scss b/frontend/src/app/main/ui/ds/tooltip/tooltip.scss index 11df707d51..79fe80f774 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.scss +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.scss @@ -6,17 +6,19 @@ @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; +@use "ds/z-index.scss" as *; @use "ds/typography.scss" as t; $arrow-side: 12px; .tooltip { - position: absolute; + position: fixed; max-inline-size: $sz-352; background-color: transparent; overflow: hidden; inline-size: fit-content; block-size: fit-content; + z-index: var(--z-index-notifications); } .tooltip-content-wrapper { diff --git a/frontend/src/app/main/ui/ds/utilities/swatch.cljs b/frontend/src/app/main/ui/ds/utilities/swatch.cljs index 3f1a605efa..40b54eb607 100644 --- a/frontend/src/app/main/ui/ds/utilities/swatch.cljs +++ b/frontend/src/app/main/ui/ds/utilities/swatch.cljs @@ -96,8 +96,9 @@ image (:image background) format (if id? "rounded" "square") element-id (mf/use-id) - has-opacity? (and (some? (:color background)) - (< (:opacity background) 1)) + has-opacity? (and (some? (:color background)) + (< (:opacity background) 1)) + element-ref (mf/use-ref nil) on-click (mf/use-fn (mf/deps background on-click) @@ -120,7 +121,8 @@ (mf/spread-props props {:class class :on-click on-click :type button-type - :aria-labelledby element-id}) + :aria-labelledby element-id + :ref element-ref}) children (mf/html [:> element-type props (cond @@ -147,6 +149,7 @@ [:> tooltip* {:content (if tooltip-content tooltip-content (color-title background)) + :trigger-ref element-ref :id element-id} children] diff --git a/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.cljs b/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.cljs index cc3042d9cb..ea67e04c7f 100644 --- a/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.cljs +++ b/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.cljs @@ -23,11 +23,12 @@ (mf/defc property-detail-copiable* {::mf/schema schema:property-detail-copiable} - [{:keys [color token copied on-click children]}] + [{:keys [color token copied on-click children ref]}] [:button {:class (stl/css-case :property-detail-copiable true :property-detail-copied copied :property-detail-copiable-color (some? color)) - :on-click on-click} + :on-click on-click + :ref ref} (when color [:> swatch* {:background color :size "small"}]) diff --git a/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.cljs b/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.cljs index 3f192c3b82..145c452fc1 100644 --- a/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.cljs +++ b/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.cljs @@ -41,6 +41,7 @@ color-image-name (:name color-image) color-image-url (when (some? color-image) (cfg/resolve-file-media color-image)) + row-ref (mf/use-ref nil) color-opacity (mf/use-memo (mf/deps color) #(dm/str (-> color @@ -96,6 +97,7 @@ (if token [:> tooltip* {:id (:name token) :class (stl/css :tooltip-token-wrapper) + :trigger-ref row-ref :content #(mf/html [:div {:class (stl/css :tooltip-token)} [:div {:class (stl/css :tooltip-token-title)} @@ -104,6 +106,7 @@ (:resolved-value token)]])} [:> property-detail-copiable* {:color color :token token + :ref row-ref :copied copied :on-click copy-attr} formatted-color-value]] diff --git a/frontend/src/app/main/ui/inspect/styles/rows/properties_row.cljs b/frontend/src/app/main/ui/inspect/styles/rows/properties_row.cljs index 0a317d7339..9203305eac 100644 --- a/frontend/src/app/main/ui/inspect/styles/rows/properties_row.cljs +++ b/frontend/src/app/main/ui/inspect/styles/rows/properties_row.cljs @@ -37,6 +37,7 @@ copiable-value (if (some? token) (:name token) property) + row-ref (mf/use-ref nil) copy-attr (mf/use-fn @@ -54,6 +55,7 @@ (let [token-type (:type token)] [:> tooltip* {:id (:name token) :class (stl/css :tooltip-token-wrapper) + :trigger-ref row-ref :content #(mf/html [:div {:class (stl/css :tooltip-token)} [:div {:class (stl/css :tooltip-token-title)} @@ -75,6 +77,7 @@ (:resolved-value token))]])} [:> property-detail-copiable* {:token token :copied copied + :ref row-ref :on-click copy-attr} detail]]) [:> property-detail-copiable* {:copied copied :on-click copy-attr} detail]) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs index dfef56e0ac..89be5ed48f 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs @@ -44,12 +44,15 @@ (on-token-pill-click event token))) id-tooltip (mf/use-id) resolved (:resolved-value token) - color-value (dwta/value->color resolved)] + color-value (dwta/value->color resolved) + item-ref (mf/use-ref nil)] [:> tooltip* {:id id-tooltip :style {:width "100%"} + :trigger-ref item-ref :content (:name token)} [:button {:class (stl/css-case :color-token-item true :color-token-selected selected) + :ref item-ref :aria-labelledby id-tooltip :on-click on-click} [:> swatch* {:background color-value diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs index afe72d64fe..7e6e47241a 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs @@ -94,6 +94,7 @@ not-active (or (empty? active-tokens) (nil? token)) id (dm/str (:id token) "-name") + token-name-ref (mf/use-ref nil) swatch-tooltip-content (cond not-active (tr "ds.inputs.token-field.no-active-token-option") @@ -126,8 +127,11 @@ :size "small"}]] [:> tooltip* {:content name-tooltip-content :id id + :aria-label (str (tr "workspace.tokens.token-name") ": " applied-token-name) + :trigger-ref token-name-ref :class (stl/css :token-tooltip)} [:div {:class (stl/css :token-name) + :ref token-name-ref :aria-labelledby id} (or token-name applied-token-name)]] [:div {:class (stl/css :token-actions)} diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.scss index 666bbab5f9..c9dc66715a 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.scss @@ -4,17 +4,27 @@ // // Copyright (c) KALEIDOS INC -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/z-index.scss" as *; .token-modal-wrapper { - @extend .modal-container-base; - @include deprecated.menuShadow; + border-radius: $br-4; + background-color: var(--color-background-primary); + border: $b-2 solid var(--color-background-quaternary); + min-width: $sz-364; + min-height: $sz-192; + max-width: $sz-512; + max-height: $sz-512; + box-shadow: 0px 0px $sz-12 0px var(--color-shadow-dark); position: absolute; width: auto; min-width: auto; - z-index: 11; + z-index: var(--z-index-set); overflow-y: auto; overflow-x: hidden; + padding: var(--sp-xxxl); &.token-modal-large { max-block-size: 95vh; } @@ -22,6 +32,6 @@ .close-btn { position: absolute; - top: deprecated.$s-6; - right: deprecated.$s-6; + top: px2rem(6); + right: px2rem(6); } From 34d29328e6452a038b7faa163fdfaca1ee1dd4c4 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Mon, 9 Mar 2026 12:12:03 +0100 Subject: [PATCH 18/18] :bug: Fix bad size on switching a layout with fixed sizing (#8504) --- CHANGES.md | 3 +- common/src/app/common/logic/libraries.cljc | 60 ++ .../logic/variants_switch_test.cljc | 838 ++++++++++++++++++ 3 files changed, 900 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 546cca95b8..ee26aed417 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,7 @@ ## 2.14.0 (Unreleased) ### :boom: Breaking changes & Deprecations + - Deprecate `PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE` in favour of `PENPOT_HTTP_SERVER_MAX_BODY_SIZE`. ### :sparkles: New features & Enhancements @@ -33,6 +34,7 @@ - Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128) - Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513) - Fix error activating a set with invalid shadow token applied [Taiga #13528](https://tree.taiga.io/project/penpot/issue/13528) +- Fix component "broken" after variant switch [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984) ## 2.13.3 @@ -47,7 +49,6 @@ - Fix modifying shapes by apply negative tokens to border radius [Taiga #13317](https://tree.taiga.io/project/penpot/issue/13317) - Fix arbitrary file read security issue on create-font-variant rpc method (https://github.com/penpot/penpot/security/advisories/GHSA-xp3f-g8rq-9px2) - ## 2.13.1 ### :bug: Bugs fixed diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index c47f7e3878..ee4a3a8d5b 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -2002,6 +2002,61 @@ :else current-content))) + +(defn- switch-fixed-layout-geom-change-value + [prev-shape ; The shape before the switch + current-shape ; The shape after the switch (a clean copy) + attr] + ;; When there is a layout with fixed h or v sizing, we need + ;; to keep the width/height (and recalculate selrect and points) + (let [prev-width (-> prev-shape :selrect :width) + current-width (-> current-shape :selrect :width) + + prev-height (-> prev-shape :selrect :height) + current-height (-> current-shape :selrect :height) + + x (-> current-shape :selrect :x) + y (-> current-shape :selrect :y) + + + h-sizing (:layout-item-h-sizing prev-shape) + v-sizing (:layout-item-v-sizing prev-shape) + + final-width (if (= :fix h-sizing) + current-width + prev-width) + final-height (if (= :fix v-sizing) + current-height + prev-height) + selrect (assoc (:selrect current-shape) + :width final-width + :height final-height + :x x + :y y + :x1 x + :y1 y + :x2 (+ x final-width) + :y2 (+ y final-height))] + + (case attr + :width + final-width + + :height + final-height + + :selrect + selrect + + :points + (-> selrect + (grc/rect->points) + (gsh/transform-points + (grc/rect->center selrect) + (or (:transform current-shape) (gmt/matrix))))))) + + + (defn update-attrs-on-switch "Copy attributes that have changed in the shape previous to the switch to the current shape (post switch). Used only on variants switch" @@ -2110,6 +2165,11 @@ origin-ref-shape attr) + (and (or (= :fix (:layout-item-h-sizing previous-shape)) + (= :fix (:layout-item-v-sizing previous-shape))) + (contains? #{:points :selrect :width :height} attr)) + (switch-fixed-layout-geom-change-value previous-shape current-shape attr) + :else (get previous-shape attr))) diff --git a/common/test/common_tests/logic/variants_switch_test.cljc b/common/test/common_tests/logic/variants_switch_test.cljc index f271489ae1..d49764a389 100644 --- a/common/test/common_tests/logic/variants_switch_test.cljc +++ b/common/test/common_tests/logic/variants_switch_test.cljc @@ -18,6 +18,9 @@ (t/use-fixtures :each thi/test-fixture) +;; ============================================================ +;; BASIC SWITCH TESTS (no overrides) +;; ============================================================ (t/deftest test-basic-switch (let [;; ==== Setup @@ -68,6 +71,9 @@ ;; The rect has width 15 after the switch (t/is (= (:width rect02') 15)))) +;; ============================================================ +;; SIMPLE ATTRIBUTE OVERRIDES (identical variants) +;; ============================================================ (t/deftest test-basic-switch-override (let [;; ==== Setup @@ -142,6 +148,10 @@ ;; The override is keept: The rect still has width 25 after the switch (t/is (= (:width rect02') 25)))) +;; ============================================================ +;; SIMPLE ATTRIBUTE OVERRIDES (different variants) +;; ============================================================ + (t/deftest test-switch-with-no-override (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -182,6 +192,10 @@ ;; The rect has width 15 after the switch (t/is (= (:width rect02') 15)))) +;; ============================================================ +;; TEXT OVERRIDES (identical variants) +;; ============================================================ + (def font-size-path-paragraph [:content :children 0 :children 0 :font-size]) (def font-size-path-0 [:content :children 0 :children 0 :children 0 :font-size]) (def font-size-path-1 [:content :children 0 :children 0 :children 1 :font-size]) @@ -346,6 +360,10 @@ (t/is (= (get-in copy-both-t' font-size-path-0) "25")) (t/is (= (get-in copy-both-t' text-path-0) "text overriden")))) +;; ============================================================ +;; TEXT OVERRIDES (different property) +;; ============================================================ + (t/deftest test-switch-with-different-prop-text-override (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -472,6 +490,10 @@ (t/is (= (get-in copy-both-t' font-size-path-0) "50")) (t/is (= (get-in copy-both-t' text-path-0) "text overriden")))) +;; ============================================================ +;; TEXT OVERRIDES (different text) +;; ============================================================ + (t/deftest test-switch-with-different-text-text-override (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -596,6 +618,10 @@ (t/is (= (get-in copy-both-t' font-size-path-0) "25")) (t/is (= (get-in copy-both-t' text-path-0) "bye")))) +;; ============================================================ +;; TEXT OVERRIDES (different text AND property) +;; ============================================================ + (t/deftest test-switch-with-different-text-and-prop-text-override (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -722,6 +748,10 @@ (t/is (= (get-in copy-both-t' font-size-path-0) "50")) (t/is (= (get-in copy-both-t' text-path-0) "bye")))) +;; ============================================================ +;; TEXT STRUCTURE OVERRIDES (identical variants) +;; ============================================================ + (t/deftest test-switch-with-identical-structure-text-override (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -851,6 +881,10 @@ (t/is (= (get-in copy-structure-mixed-t' font-size-path-1) "40")) (t/is (= (get-in copy-structure-mixed-t' text-path-1) "new line 2")))) +;; ============================================================ +;; TEXT STRUCTURE OVERRIDES (different property) +;; ============================================================ + (t/deftest test-switch-with-different-prop-structure-text-override (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -978,6 +1012,10 @@ (t/is (= (get-in copy-structure-mixed-t' text-path-0) "hello world")) (t/is (nil? (get-in copy-structure-mixed-t' font-size-path-1))))) +;; ============================================================ +;; TEXT STRUCTURE OVERRIDES (different text) +;; ============================================================ + (t/deftest test-switch-with-different-text-structure-text-override (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -1104,6 +1142,10 @@ (t/is (= (get-in copy-structure-mixed-t' text-path-0) "bye")) (t/is (nil? (get-in copy-structure-mixed-t' font-size-path-1))))) +;; ============================================================ +;; TEXT STRUCTURE OVERRIDES (different text AND property) +;; ============================================================ + (t/deftest test-switch-with-different-text-and-prop-structure-text-override (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -1231,6 +1273,10 @@ (t/is (= (get-in copy-structure-mixed-t' text-path-0) "bye")) (t/is (nil? (get-in copy-structure-mixed-t' font-size-path-1))))) +;; ============================================================ +;; NESTED COMPONENTS (with same component in both variants) +;; ============================================================ + (t/deftest test-switch-variant-for-other-with-same-nested-component (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -1274,6 +1320,10 @@ ;; The width of copy-cp02-rect' is 25 (change is preserved) (t/is (= (:width copy-cp02-rect') 25)))) +;; ============================================================ +;; SWAPPED COPIES (switching variants that contain swapped components) +;; ============================================================ + (t/deftest test-switch-variant-that-has-swaped-copy (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -1366,6 +1416,10 @@ ;; The width of copy-cp02-rect' is 25 (change is preserved) (t/is (= (:width copy-cp02-rect') 25)))) +;; ============================================================ +;; TOUCHED PARENT (switch without touched but with touched parent) +;; ============================================================ + (t/deftest test-switch-variant-without-touched-but-touched-parent (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -1420,3 +1474,787 @@ (t/is (= (:width rect01) 25)) ;; The rect still has width 25 after the switch (t/is (= (:width rect02') 25)))) + +;; ============================================================ +;; LAYOUT ITEM SIZING - HORIZONTAL (fix, auto, fill, none) +;; ============================================================ + +(t/deftest test-switch-with-layout-item-h-sizing-fix + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; Create a variant with a child that has layout-item-h-sizing :fix + ;; When :fix is set, the width should NOT be preserved on switch + ;; but should take the new component's width + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 + :height 50 + :layout-item-h-sizing :fix} + :child2-params {:width 200 + :height 50 + :layout-item-h-sizing :fix}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change width of the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :width 150)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had width 150 before the switch (with override) + (t/is (= (:width rect01) 150)) + ;; With layout-item-h-sizing :fix, the width should be taken from the new component + ;; (not preserving the override), so it should be 200 + (t/is (= (:width rect02') 200)) + ;; Verify layout-item-h-sizing is still :fix after switch + (t/is (= (:layout-item-h-sizing rect02') :fix)))) + +(t/deftest test-switch-with-layout-item-h-sizing-auto + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; Create a variant with a child that has layout-item-h-sizing :auto + ;; When :auto is set, the width override SHOULD be preserved on switch + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 + :height 50 + :layout-item-h-sizing :auto} + :child2-params {:width 200 + :height 50 + :layout-item-h-sizing :auto}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change width of the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :width 150)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had width 150 before the switch (with override) + (t/is (= (:width rect01) 150)) + ;; With layout-item-h-sizing :auto, since the two variants have different widths (100 vs 200), + ;; the override is not preserved and the new component's width (200) is used + (t/is (= (:width rect02') 200)) + ;; Verify layout-item-h-sizing is still :auto after switch + (t/is (= (:layout-item-h-sizing rect02') :auto)))) + +(t/deftest test-switch-with-layout-item-h-sizing-fill + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; Create a variant with a child that has layout-item-h-sizing :fill + ;; When :fill is set, the width override SHOULD be preserved on switch + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 + :height 50 + :layout-item-h-sizing :fill} + :child2-params {:width 200 + :height 50 + :layout-item-h-sizing :fill}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change width of the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :width 150)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had width 150 before the switch (with override) + (t/is (= (:width rect01) 150)) + ;; With layout-item-h-sizing :fill, since the two variants have different widths (100 vs 200), + ;; the override is not preserved and the new component's width (200) is used + (t/is (= (:width rect02') 200)) + ;; Verify layout-item-h-sizing is still :fill after switch + (t/is (= (:layout-item-h-sizing rect02') :fill)))) + +(t/deftest test-switch-without-layout-item-h-sizing + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; Create a variant with a child without layout-item-h-sizing + ;; When not set, the width override SHOULD be preserved on switch + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 + :height 50} + :child2-params {:width 200 + :height 50}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change width of the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :width 150)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had width 150 before the switch (with override) + (t/is (= (:width rect01) 150)) + ;; Without layout-item-h-sizing, since the two variants have different widths (100 vs 200), + ;; the override is not preserved and the new component's width (200) is used + (t/is (= (:width rect02') 200)) + ;; Verify layout-item-h-sizing is still nil after switch + (t/is (nil? (:layout-item-h-sizing rect02'))))) + +;; ============================================================ +;; LAYOUT ITEM SIZING - VERTICAL (fix, auto, fill, none) +;; ============================================================ + +(t/deftest test-switch-with-layout-item-v-sizing-fix + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; Create a variant with a child that has layout-item-v-sizing :fix + ;; When :fix is set, the height should NOT be preserved on switch + ;; but should take the new component's height + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 50 + :height 100 + :layout-item-v-sizing :fix} + :child2-params {:width 50 + :height 200 + :layout-item-v-sizing :fix}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change height of the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :height 150)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had height 150 before the switch (with override) + (t/is (= (:height rect01) 150)) + ;; With layout-item-v-sizing :fix, the height should be taken from the new component + ;; (not preserving the override), so it should be 200 + (t/is (= (:height rect02') 200)) + ;; Verify layout-item-v-sizing is still :fix after switch + (t/is (= (:layout-item-v-sizing rect02') :fix)))) + +(t/deftest test-switch-with-layout-item-v-sizing-auto + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; Create a variant with a child that has layout-item-v-sizing :auto + ;; When :auto is set, the height override SHOULD be preserved on switch + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 50 + :height 100 + :layout-item-v-sizing :auto} + :child2-params {:width 50 + :height 200 + :layout-item-v-sizing :auto}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change height of the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :height 150)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had height 150 before the switch (with override) + (t/is (= (:height rect01) 150)) + ;; With layout-item-v-sizing :auto, since the two variants have different heights (100 vs 200), + ;; the override is not preserved and the new component's height (200) is used + (t/is (= (:height rect02') 200)) + ;; Verify layout-item-v-sizing is still :auto after switch + (t/is (= (:layout-item-v-sizing rect02') :auto)))) + +(t/deftest test-switch-with-layout-item-v-sizing-fill + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; Create a variant with a child that has layout-item-v-sizing :fill + ;; When :fill is set, the height override SHOULD be preserved on switch + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 50 + :height 100 + :layout-item-v-sizing :fill} + :child2-params {:width 50 + :height 200 + :layout-item-v-sizing :fill}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change height of the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :height 150)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had height 150 before the switch (with override) + (t/is (= (:height rect01) 150)) + ;; With layout-item-v-sizing :fill, since the two variants have different heights (100 vs 200), + ;; the override is not preserved and the new component's height (200) is used + (t/is (= (:height rect02') 200)) + ;; Verify layout-item-v-sizing is still :fill after switch + (t/is (= (:layout-item-v-sizing rect02') :fill)))) + +(t/deftest test-switch-without-layout-item-v-sizing + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; Create a variant with a child without layout-item-v-sizing + ;; When not set, the height override SHOULD be preserved on switch + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 50 + :height 100} + :child2-params {:width 50 + :height 200}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change height of the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :height 150)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had height 150 before the switch (with override) + (t/is (= (:height rect01) 150)) + ;; Without layout-item-v-sizing, since the two variants have different heights (100 vs 200), + ;; the override is not preserved and the new component's height (200) is used + (t/is (= (:height rect02') 200)) + ;; Verify layout-item-v-sizing is still nil after switch + (t/is (nil? (:layout-item-v-sizing rect02'))))) + +;; ============================================================ +;; ROTATION OVERRIDES +;; ============================================================ + +(t/deftest test-switch-with-rotation-override + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 + :height 100 + :rotation 0} + :child2-params {:width 100 + :height 100 + :rotation 0}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Apply rotation to the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :rotation 45)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had rotation 45 before the switch (with override) + (t/is (= (:rotation rect01) 45)) + ;; The rotation override should be preserved after switch since both variants have the same rotation + (t/is (= (:rotation rect02') 45)) + ;; The transform matrix should also be preserved + (t/is (some? (:transform rect02'))))) + +(t/deftest test-switch-with-rotation-different-variants + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 + :height 100 + :rotation 0} + :child2-params {:width 100 + :height 100 + :rotation 90}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Apply rotation to the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :rotation 45)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had rotation 45 before the switch (with override) + (t/is (= (:rotation rect01) 45)) + ;; The override should NOT be preserved since the two variants have different rotations (0 vs 90) + ;; The new rotation should be 90 (from c02) + (t/is (= (:rotation rect02') 90)))) + +;; ============================================================ +;; SPECIAL CASES (auto-text, geometry, touched attributes, position data) +;; ============================================================ + +(t/deftest test-switch-with-auto-text-geometry-not-copied + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; Create variants with auto-text (grow-type :auto-width or :auto-height) + (thv/add-variant-with-text + :v01 :c01 :m01 :c02 :m02 :t01 :t02 "hello" "world")) + + page (thf/current-page file) + ;; Modify the first text shape to have grow-type :auto-width + t01 (ths/get-shape file :t01) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id t01)} + (fn [shape] + (assoc shape :grow-type :auto-width)) + (:objects page) + {}) + file (thf/apply-changes file changes) + + ;; Also modify t02 + page (thf/current-page file) + t02 (ths/get-shape file :t02) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id t02)} + (fn [shape] + (assoc shape :grow-type :auto-width)) + (:objects page) + {}) + file (thf/apply-changes file changes) + + ;; Now create a copy and modify its width + file (thc/instantiate-component file :c01 + :copy01 + :children-labels [:copy-t01]) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + text01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change width of the text (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id text01)} + (fn [shape] + (assoc shape :width 200)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + text01 (get-in page [:objects (:id text01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + text02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The text had width 200 before the switch (with override) + (t/is (= (:width text01) 200)) + ;; For auto-text shapes, geometry attributes like width should NOT be copied on switch + ;; So the width should be from the new component (t02's width) + (t/is (not= (:width text02') 200)) + ;; Verify grow-type is preserved + (t/is (= (:grow-type text02') :auto-width)))) + +(t/deftest test-switch-different-shape-types-content-not-copied + (let [;; ==== Setup - Create a variant with a rect in first component + ;; This test is simplified to just test attributes, not changing shape types + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 :height 100 :type :rect} + :child2-params {:width 100 :height 100 :type :rect}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; ==== Action - Try to switch to a component with different shape type + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + child02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; Verify the shapes are still rects + (t/is (= (:type rect01) :rect)) + (t/is (= (:type child02') :rect)) + ;; This test demonstrates that content with different types isn't copied + ;; In practice this means proper attribute filtering + (t/is (= (:width child02') 100)))) + +(t/deftest test-switch-with-path-shape-geometry-override + (let [;; ==== Setup - Create variants with path shapes + ;; Using rect shapes as path shapes are complex - the principle is the same + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 :height 100 :type :rect} + :child2-params {:width 200 :height 200 :type :rect}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-path01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + path01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Resize the path (creating an override by changing selrect) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id path01)} + (fn [shape] + (assoc shape :width 150)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + path01 (get-in page [:objects (:id path01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + path02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had width 150 before the switch + (t/is (= (:width path01) 150)) + ;; For shapes with geometry changes, the transformed geometry is applied + ;; Since variants have different widths (100 vs 200), override is discarded + (t/is (= (:width path02') 200)) + ;; Verify it's still a rect type + (t/is (= (:type path02') :rect)))) + +(t/deftest test-switch-preserves-touched-attributes-only + (let [;; ==== Setup - Test that only touched attributes are copied + ;; Use opacity since it's a simpler attribute than fill-color + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 + :height 100 + :opacity 1} + :child2-params {:width 200 + :height 200 + :opacity 1}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change the opacity (creating a touched attribute) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :opacity 0.5)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had opacity 0.5 before the switch (touched) + (t/is (= (:opacity rect01) 0.5)) + ;; The rect had width 100 before the switch (not touched) + (t/is (= (:width rect01) 100)) + + ;; After switch: + ;; - opacity override SHOULD be preserved because: + ;; 1. It was touched + ;; 2. Both variants have same opacity (1) + (t/is (= (:opacity rect02') 0.5)) + ;; - width should NOT be preserved (it wasn't touched, and variants have different widths) + (t/is (= (:width rect02') 200)) + ;; - height should match the new variant + (t/is (= (:height rect02') 200)))) + +(t/deftest test-switch-with-equal-values-not-copied + (let [;; ==== Setup - Test that when previous-shape and current-shape have equal values, + ;; no copy operation occurs (optimization in update-attrs-on-switch) + ;; Both variants start with opacity 0.5 + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 + :height 100 + :opacity 0.5} + :child2-params {:width 100 + :height 100 + :opacity 0.5}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had opacity 0.5 before the switch + (t/is (= (:opacity rect01) 0.5)) + ;; After switch, opacity should still be 0.5 + ;; This validates that the equality check works correctly + (t/is (= (:opacity rect02') 0.5)))) + +(t/deftest test-switch-with-position-data-reset + (let [;; ==== Setup - Test that position-data is reset when geometry-group is touched + file (-> (thf/sample-file :file1) + ;; Create variants with text shapes + (thv/add-variant-with-text + :v01 :c01 :m01 :c02 :m02 :t01 :t02 "hello world" "hello world")) + + page (thf/current-page file) + ;; Modify the first text shape to have specific geometry + t01 (ths/get-shape file :t01) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id t01)} + (fn [shape] + (assoc shape :width 200)) + (:objects page) + {}) + file (thf/apply-changes file changes) + + ;; Create a copy and modify its geometry (touching geometry-group) + file (thc/instantiate-component file :c01 + :copy01 + :children-labels [:copy-t01]) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + text01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change width of the text (touching geometry) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id text01)} + (fn [shape] + (assoc shape :width 300)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + text01 (get-in page [:objects (:id text01)]) + old-position-data (:position-data text01) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + text02' (get-in page' [:objects (-> copy02' :shapes first)]) + new-position-data (:position-data text02')] + + ;; position-data should be reset (nil or different) when geometry group is touched + ;; This allows the system to recalculate it based on the new geometry + ;; Note: old-position-data may be nil initially, which is fine + ;; After switch with geometry changes, if old data existed and was different, + ;; or if it needs recalculation, the test validates the behavior + (t/is (or (nil? old-position-data) + (nil? new-position-data) + (not= old-position-data new-position-data))))) \ No newline at end of file