diff --git a/frontend/playwright/ui/specs/tokens/apply.spec.js b/frontend/playwright/ui/specs/tokens/apply.spec.js index fccad0cfe9..5b20d089d0 100644 --- a/frontend/playwright/ui/specs/tokens/apply.spec.js +++ b/frontend/playwright/ui/specs/tokens/apply.spec.js @@ -5,6 +5,7 @@ import { setupTokensFileRender, setupTypographyTokensFileRender, unfoldTokenType, + createToken, } from "./helpers"; test.beforeEach(async ({ page }) => { @@ -813,7 +814,7 @@ test.describe("Tokens: Apply token", () => { // Check if token pill is visible on right sidebar const layoutItemSectionSidebar = rightSidebar.getByRole("region", { - name: "layout item menu", + name: "Layout item section", }); await expect(layoutItemSectionSidebar).toBeVisible(); const marginPillMd = layoutItemSectionSidebar.getByRole("button", { @@ -931,8 +932,7 @@ test.describe("Tokens: Detach token", () => { test("Bug: 13959, User select shapes with different hidden state.", async ({ page, }) => { - const { workspacePage } = - await setupTokensFileRender(page); + const { workspacePage } = await setupTokensFileRender(page); await page.getByRole("tab", { name: "Layers" }).click(); @@ -955,8 +955,7 @@ test("Bug: 13959, User select shapes with different hidden state.", async ({ test("Bug: 13960, User select shapes with different opacity and input show mixed state.", async ({ page, }) => { - const { workspacePage } = - await setupTokensFileRender(page); + const { workspacePage } = await setupTokensFileRender(page); await page.getByRole("tab", { name: "Layers" }).click(); @@ -965,21 +964,22 @@ test("Bug: 13960, User select shapes with different opacity and input show mixed name: "Layer menu section", }); await expect(layerMenuSection).toBeVisible(); - await layerMenuSection - .getByRole('textbox', { name: 'Opacity' }) - .fill('50'); + await layerMenuSection.getByRole("textbox", { name: "Opacity" }).fill("50"); await expect(layerMenuSection).toBeVisible(); await workspacePage.layers .getByTestId("layer-row") .nth(0) .click({ modifiers: ["Shift"] }); - await expect(layerMenuSection - .getByRole('textbox', { name: 'Opacity' })).toBeVisible(); - await expect(layerMenuSection - .getByRole('textbox', { name: 'Opacity' })).toBeVisible(); + await expect( + layerMenuSection.getByRole("textbox", { name: "Opacity" }), + ).toBeVisible(); + await expect( + layerMenuSection.getByRole("textbox", { name: "Opacity" }), + ).toBeVisible(); - await expect(layerMenuSection - .getByRole('textbox', { name: 'Opacity' })).toHaveAttribute("placeholder", "Mixed"); + await expect( + layerMenuSection.getByRole("textbox", { name: "Opacity" }), + ).toHaveAttribute("placeholder", "Mixed"); }); test("BUG: 13930, Token colors are shown on selected colors section", async ({ @@ -1029,3 +1029,396 @@ test("BUG: 13930, Token colors are shown on selected colors section", async ({ .getByRole("button", { name: "colors.black" }), ).toBeVisible(); }); + +test.describe("Numeric Input and Token Integration Tests", () => { + test("Token pill persists after blur in gap inputs", async ({ page }) => { + // Setup the workspace with token features enabled + const { workspacePage, tokensSidebar, tokenContextMenuForToken } = + await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + + // Transform a rectangle into a flex container to expose gap properties + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers.getByTestId("layer-row").nth(1).click(); + + const layoutSection = + workspacePage.rightSidebar.getByTestId("inspect-layout"); + + const addLayoutButton = layoutSection + .getByRole("button", { name: "Add layout" }) + .first(); + await addLayoutButton.click(); + await page.getByText("Flex layout").click(); + + // Apply a spacing token to the Column gap property + const tokensTabButton = page.getByRole("tab", { name: "Tokens" }); + await tokensTabButton.click(); + await unfoldTokenType(tokensSidebar, "spacing"); + + await tokensSidebar + .getByRole("button", { name: "spacing.lg" }) + .click({ button: "right" }); + + await tokenContextMenuForToken.getByText("Column gap").click(); + + // Verify that the token pill appears in the layout section, check after blur + await expect( + page + .getByTestId("inspect-layout") + .getByRole("button", { name: "spacing.lg" }), + ).toBeVisible(); + + await page + .getByTestId("inspect-layout") + .getByRole("textbox", { name: "Vertical padding" }) + .click(); + + await expect( + page + .getByTestId("inspect-layout") + .getByRole("button", { name: "spacing.lg" }), + ).toBeVisible(); + }); + + test("Padding tokens are applied to both vertical or horizontal properties", async ({ + page, + }) => { + // Setup the workspace with token features enabled + const { workspacePage, tokensSidebar, tokenContextMenuForToken } = + await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + + // Transform a rectangle into a flex container to expose gap properties + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers.getByTestId("layer-row").nth(1).click(); + + const layoutSection = + workspacePage.rightSidebar.getByTestId("inspect-layout"); + + const addLayoutButton = layoutSection + .getByRole("button", { name: "Add layout" }) + .first(); + await addLayoutButton.click(); + await page.getByText("Flex layout").click(); + + // Apply a spacing token to the Column gap property + const tokensTabButton = page.getByRole("tab", { name: "Tokens" }); + await tokensTabButton.click(); + await unfoldTokenType(tokensSidebar, "spacing"); + + await tokensSidebar + .getByRole("button", { name: "spacing.lg" }) + .click({ button: "right" }); + + await tokenContextMenuForToken.getByText("Horizontal").click(); + + // Verify that the token pill appears in the layout section, check after blur + await expect( + page + .getByTestId("inspect-layout") + .getByRole("button", { name: "spacing.lg" }), + ).toBeVisible(); + + await layoutSection + .getByRole("button", { name: "Show 4 sided padding options" }) + .click(); + + await expect( + page + .getByTestId("inspect-layout") + .getByRole("button", { name: "spacing.lg" }), + ).toHaveCount(2); + + await layoutSection + .getByRole("button", { name: "Show 4 sided padding options" }) + .click(); + + await expect( + page + .getByTestId("inspect-layout") + .getByRole("button", { name: "spacing.lg" }), + ).toBeVisible(); + }); + + test("Token pill persists after blur in min/max width inputs", async ({ + page, + }) => { + // Setup the workspace with token features enabled + const { workspacePage } = await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + + // Create a flex container to expose min/max width properties + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers.getByTestId("layer-row").nth(2).click(); + + const layoutSection = + workspacePage.rightSidebar.getByTestId("inspect-layout"); + + const addLayoutButton = layoutSection + .getByRole("button", { name: "Add layout" }) + .first(); + await addLayoutButton.click(); + await page.getByText("Flex layout").click(); + + // Verify that the flex container (Flex board) is created + await expect( + page.getByRole("button", { name: "Flex board" }), + ).toBeVisible(); + + // Select element inside flex container to access to layout constrains inputs + // Apply token to min width property + await workspacePage.layers + .getByTestId("layer-row") + .nth(2) + .getByTestId("toggle-content") + .click(); + + await workspacePage.layers.getByTestId("layer-row").nth(3).click(); + + const layoutItemSection = page.getByRole("region", { + name: "Layout item section", + }); + + await layoutItemSection.getByTestId("behaviour-h-fill").click(); + + const constraintsSection = layoutItemSection.getByRole("region", { + name: "layout item size constraints", + }); + await expect(constraintsSection).toBeVisible(); + + await constraintsSection + .getByRole("button", { name: "Open token list" }) + .nth(0) + .click(); + + await expect( + page.getByRole("option", { name: "dimension.md" }), + ).toBeVisible(); + await page.getByRole("option", { name: "dimension.md" }).click(); + + await expect( + constraintsSection.getByRole("button", { name: "dimension.md" }), + ).toBeVisible(); + + // Focus another input (Max width) to trigger blur and check if token pill persists + await constraintsSection + .getByRole("textbox", { name: "Max width" }) + .click(); + + await expect( + constraintsSection.getByRole("button", { name: "dimension.md" }), + ).toBeVisible(); + }); + + test("Invalid formula reverts to previous value in padding inputs", async ({ + page, + }) => { + const { workspacePage, tokensSidebar, tokenContextMenuForToken } = + await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers.getByTestId("layer-row").nth(1).click(); + + const layoutSection = + workspacePage.rightSidebar.getByTestId("inspect-layout"); + + const addLayoutButton = layoutSection + .getByRole("button", { name: "Add layout" }) + .first(); + + await addLayoutButton.click(); + + await page.getByText("Flex layout").click(); + + // Apply a spacing token to the Column gap property + const tokensTabButton = page.getByRole("tab", { name: "Tokens" }); + await tokensTabButton.click(); + await unfoldTokenType(tokensSidebar, "spacing"); + + await tokensSidebar + .getByRole("button", { name: "spacing.lg" }) + .click({ button: "right" }); + + await tokenContextMenuForToken.getByText("Column gap").click(); + + const verticalPaddingInput = layoutSection.getByRole("textbox", { + name: "Vertical padding", + }); + + // Enter a valid value first + await verticalPaddingInput.fill("23"); + await verticalPaddingInput.press("Enter"); + // Wait for potential error handling + await page.waitForTimeout(500); + + expect(await verticalPaddingInput.inputValue()).toMatch("23"); + + // Enter invalid expression + await verticalPaddingInput.fill("abc+1"); + await verticalPaddingInput.press("Enter"); + + // Wait for potential error handling + await page.waitForTimeout(500); + + // Value should revert to previous valid value + expect(await verticalPaddingInput.inputValue()).toMatch("23"); + + // Should NOT contain invalid characters + expect(await verticalPaddingInput.inputValue()).not.toContain("abc"); + }); + + test("Division by zero reverts to previous value", async ({ page }) => { + const { workspacePage, tokensSidebar, tokenContextMenuForToken } = + await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers.getByTestId("layer-row").nth(1).click(); + + const layoutSection = + workspacePage.rightSidebar.getByTestId("inspect-layout"); + + const addLayoutButton = layoutSection + .getByRole("button", { name: "Add layout" }) + .first(); + + await addLayoutButton.click(); + + await page.getByText("Flex layout").click(); + + // Apply a spacing token to the Column gap property + const tokensTabButton = page.getByRole("tab", { name: "Tokens" }); + await tokensTabButton.click(); + await unfoldTokenType(tokensSidebar, "spacing"); + + await tokensSidebar + .getByRole("button", { name: "spacing.lg" }) + .click({ button: "right" }); + + await tokenContextMenuForToken.getByText("Column gap").click(); + + const verticalPaddingInput = layoutSection.getByRole("textbox", { + name: "Vertical padding", + }); + + // Enter a valid value first + await verticalPaddingInput.fill("23"); + await verticalPaddingInput.press("Enter"); + // Wait for potential error handling + await page.waitForTimeout(500); + + expect(await verticalPaddingInput.inputValue()).toMatch("23"); + + // Enter invalid expression + await verticalPaddingInput.fill("10/0"); + await verticalPaddingInput.press("Enter"); + + // Wait for potential error handling + await page.waitForTimeout(500); + + // Value should revert to previous valid value + expect(await verticalPaddingInput.inputValue()).toMatch("23"); + + // Should NOT contain invalid characters + expect(await verticalPaddingInput.inputValue()).not.toContain("10/0"); + + // Value should revert + expect(await verticalPaddingInput.inputValue()).toMatch(/^(\d+|--)$/); + expect(await verticalPaddingInput.inputValue()).not.toBe("Infinity"); + }); + + test("Negative expression result handled correctly", async ({ page }) => { + const { workspacePage, tokensSidebar, tokenContextMenuForToken } = + await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers.getByTestId("layer-row").nth(1).click(); + const widthInput = workspacePage.rightSidebar.getByRole("textbox", { + name: "Width", + }); + await expect(widthInput).toBeVisible(); + + // Enter a valid value first + await widthInput.fill("23"); + await widthInput.press("Enter"); + + // Wait for potential error handling + await page.waitForTimeout(500); + expect(await widthInput.inputValue()).toMatch("23"); + + // Enter a negative expression + await widthInput.fill("10-50"); + await widthInput.press("Enter"); + + // Wait for potential error handling + await page.waitForTimeout(500); + + expect(await widthInput.inputValue()).toMatch("0.01"); + + // Should NOT negative values + expect(await widthInput.inputValue()).not.toContain("-40"); + }); + + test("Token pill show broken reference when set is not activated", async ({ + page, + }) => { + // Setup the workspace with token features enabled + const { + workspacePage, + tokensSidebar, + tokenContextMenuForToken, + tokenThemesSetsSidebar, + } = await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + // Create a token with a reference value in other set. + await createToken(page, "Dimensions", "reference-token", "Value", "{card.padding}"); + + + // Apply this token to a shape + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers.getByTestId("layer-row").nth(1).click(); + + const tokensTabButton = page.getByRole("tab", { name: "Tokens" }); + await tokensTabButton.click(); + await unfoldTokenType(tokensSidebar, "dimensions"); + + await tokensSidebar + .getByRole("button", { name: "reference-token" }) + .click({ button: "right" }); + + await tokenContextMenuForToken.getByText("X", { exact: true }).click(); + + //Check if token is applied and visible on right sidebar + const measuresSection = page.getByRole("region", { + name: "shape-measures-section", + }); + await expect(measuresSection).toBeVisible(); + + await expect(measuresSection.getByRole('button', { name: 'reference-token' })).toBeVisible(); + + // Deactivate token set where reference token exist to make token broken + await tokenThemesSetsSidebar.getByRole('button', { name: 'theme' }).getByRole('checkbox').click(); + + // Check if token pill show broken reference state + const brokenPill = measuresSection.getByRole("button", { + name: "is not in any active set", + }); + await expect(brokenPill).toHaveCount(2); + }); +}); diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs index 1f624bdb7f..54da21ed03 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs @@ -109,6 +109,12 @@ j))) indices))) +(defn- find-token-by-name + [data name] + (some (fn [tokens-data] + (some #(when (= (:name %) name) %) tokens-data)) + (vals data))) + (def ^:private schema:icon [:and :string [:fn #(contains? icon-list %)]]) @@ -153,7 +159,7 @@ icon disabled inner-class min max max-length step is-selected-on-focus nillable - tokens applied-token empty-to-end + tokens applied-token-name empty-to-end on-change on-change-start on-change-end on-blur on-focus on-detach property align ref name @@ -166,9 +172,16 @@ tokens (if (object? tokens) (mfu/bean tokens) tokens) - value (if (= :multiple applied-token) + + value (if (= :multiple applied-token-name) :multiple value) + + token-applied (mf/with-memo [tokens applied-token-name] + (find-token-by-name tokens applied-token-name)) + + token-has-errors? (-> token-applied :errors seq boolean) + is-multiple? (= :multiple value) value (cond is-multiple? nil @@ -203,8 +216,8 @@ is-open* (mf/use-state false) is-open (deref is-open*) - token-applied* (mf/use-state applied-token) - token-applied (deref token-applied*) + token-applied-name* (mf/use-state applied-token-name) + token-applied-name (deref token-applied-name*) focused-id* (mf/use-state nil) focused-id (deref focused-id*) @@ -215,6 +228,10 @@ raw-value* (mf/use-ref nil) last-value* (mf/use-ref nil) + ;; Flag to prevent effect from overwriting token during selection + ;; This prevents race condition between blur and token selection + token-selection-in-progress* (mf/use-ref false) + ;; Refs wrapper-ref (mf/use-ref nil) nodes-ref (mf/use-ref nil) @@ -237,8 +254,8 @@ selected-id* (mf/use-state (fn [] - (if applied-token - (:id (get-option-by-name dropdown-options applied-token)) + (if applied-token-name + (:id (get-option-by-name dropdown-options applied-token-name)) nil))) selected-id (deref selected-id*) @@ -272,7 +289,7 @@ (if-let [parsed (parse-value raw-value (mf/ref-val last-value*) min max nillable)] (when-not (= parsed (mf/ref-val last-value*)) (mf/set-ref-val! last-value* parsed) - (reset! token-applied* nil) + (reset! token-applied-name* nil) (when (fn? on-change) (on-change parsed)) @@ -283,7 +300,7 @@ (do (mf/set-ref-val! last-value* nil) (mf/set-ref-val! raw-value* "") - (reset! token-applied* nil) + (reset! token-applied-name* nil) (update-input "") (when (fn? on-change) (on-change nil))) @@ -291,7 +308,7 @@ (let [fallback-value (or (mf/ref-val last-value*) default)] (mf/set-ref-val! raw-value* fallback-value) (mf/set-ref-val! last-value* fallback-value) - (reset! token-applied* nil) + (reset! token-applied-name* nil) (update-input (fmt/format-number fallback-value)) (when (and (fn? on-change) (not= fallback-value (str value))) @@ -318,13 +335,15 @@ (mf/use-fn (mf/deps apply-token) (fn [id value name] + (mf/set-ref-val! token-selection-in-progress* true) (reset! selected-id* id) (reset! focused-id* nil) (reset! is-open* false) - (reset! token-applied* name) + (reset! token-applied-name* name) (apply-token value name) (ts/schedule-on-idle (fn [] + (mf/set-ref-val! token-selection-in-progress* false) (when token-wrapper-ref (dom/focus! (mf/ref-val token-wrapper-ref))))))) @@ -354,7 +373,7 @@ (on-token-apply focused-id value name) (reset! filter-id* "")))) - on-blur + handle-blur (mf/use-fn (mf/deps apply-value on-blur) (fn [event] @@ -369,7 +388,8 @@ (when (mf/ref-val dirty-ref) (apply-value (mf/ref-val raw-value*))) (when (fn? on-blur) - (on-blur event)))) + (on-blur event)) + (dom/blur! (mf/ref-val ref)))) on-key-down (mf/use-fn @@ -409,8 +429,9 @@ value (get option :resolved-value) name (get option :name)] (on-token-apply option-id value name) - (reset! filter-id* "")))) - (on-blur event)) + (reset! filter-id* "") + (handle-blur event)))) + (handle-blur event)) esc? (do @@ -485,7 +506,7 @@ (when-not (or disabled is-open is-multiple?) (let [node (mf/ref-val ref) is-focused (and (some? node) (dom/active? node)) - has-token (some? (deref token-applied*))] + has-token (some? (deref token-applied-name*))] (when-not (or is-focused has-token) (let [client-x (.-clientX event) parsed (parse-value (mf/ref-val raw-value*) (mf/ref-val last-value*) min max nillable) @@ -569,16 +590,16 @@ detach-token (mf/use-fn - (mf/deps on-detach tokens disabled token-applied) + (mf/deps on-detach tokens disabled token-applied-name) (fn [event] (when-not disabled (dom/prevent-default event) (dom/stop-propagation event) - (reset! token-applied* nil) + (reset! token-applied-name* nil) (reset! selected-id* nil) (reset! focused-id* nil) (when on-detach - (on-detach token-applied)) + (on-detach token-applied-name)) (ts/schedule-on-idle (fn [] (dom/focus! (mf/ref-val ref))))))) @@ -640,7 +661,7 @@ (tr "labels.mixed-values") placeholder) :default-value (or (mf/ref-val last-value*) (fmt/format-number value)) - :on-blur on-blur + :on-blur handle-blur :on-key-down on-key-down :on-focus on-focus :on-change store-raw-value @@ -665,10 +686,10 @@ :max-length max-length}) token-props - (when (and token-applied (not= :multiple token-applied)) - (let [token (get-option-by-name dropdown-options token-applied) + (when (and token-applied-name (not= :multiple token-applied-name)) + (let [token (get-option-by-name dropdown-options token-applied-name) id (get token :id) - label (or (get token :name) applied-token) + label (or (get token :name) applied-token-name) token-value (or (get token :resolved-value) (or (mf/ref-val last-value*) (fmt/format-number value))) @@ -683,7 +704,8 @@ :on-focus on-focus :on-token-key-down on-token-key-down :disabled disabled - :on-blur on-blur + :on-blur handle-blur + :token-has-errors token-has-errors? :class inner-class :property property :is-open is-open @@ -704,7 +726,7 @@ :token-detach-btn-ref token-detach-btn-ref :detach-token detach-token})))] - (mf/with-effect [value default applied-token] + (mf/with-effect [value default applied-token-name] (let [value' (cond is-multiple? "" @@ -714,22 +736,27 @@ :else (fmt/format-number (d/parse-double value default)))] - (mf/set-ref-val! raw-value* value') (mf/set-ref-val! last-value* value') - (reset! token-applied* applied-token) - (if applied-token - (let [token-id (:id (get-option-by-name dropdown-options applied-token))] - (reset! selected-id* token-id)) - (reset! selected-id* nil)) + + ;; Only sync token state if not in the middle of a selection + ;; This prevents race condition between blur and token selection + (when-not (mf/ref-val token-selection-in-progress*) + (reset! token-applied-name* applied-token-name) + (if applied-token-name + (let [token-id (:id (get-option-by-name dropdown-options applied-token-name))] + (reset! selected-id* token-id)) + (reset! selected-id* nil))) (when-let [node (mf/ref-val ref)] (dom/set-value! node value')))) - (mf/with-effect [applied-token] - (when (nil? applied-token) - (reset! token-applied* nil) - (reset! selected-id* nil))) + (mf/with-effect [applied-token-name] + (when (nil? applied-token-name) + ;; Only clear if not in the middle of a selection + (when-not (mf/ref-val token-selection-in-progress*) + (reset! token-applied-name* nil) + (reset! selected-id* nil)))) (mf/with-layout-effect [on-mouse-wheel] (when-let [node (mf/ref-val ref)] @@ -746,8 +773,8 @@ :on-pointer-up on-scrub-pointer-up :on-lost-pointer-capture on-scrub-lost-pointer-capture} - (if (and (some? token-applied) - (not= :multiple token-applied)) + (if (and (some? token-applied-name) + (not= :multiple token-applied-name)) [:> token-field* token-props] [:> input-field* input-props]) diff --git a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs index e868e4ab2d..3ed3d6b3a3 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs @@ -32,7 +32,7 @@ [:map [:id {:optional true} :string] [:resolved-value {:optional true} - [:or :int :string :float]] + [:maybe [:or :int :string :float]]] [:name {:optional true} :string] [:value {:optional true} :keyword] [:icon {:optional true} schema:icon-list] diff --git a/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs b/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs index 8237f63f81..ef015467be 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs @@ -48,6 +48,7 @@ :id id :name name :resolved (get option :resolved-value) + :value (get option :value) :ref ref :role "option" :focused (= id focused) 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 45189a3156..2d989bca02 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 @@ -18,7 +18,8 @@ [:map [:id {:optiona true} :string] [:ref some?] - [:resolved {:optional true} [:or :int :string :float :map]] + [:resolved {:optional true} [:maybe [:or :int :string :float :map]]] + [:value {:optional true} [:maybe [:or :int :string :float :map]]] [:name {:optional true} :string] [:on-click {:optional true} fn?] [:selected {:optional true} :boolean] @@ -26,7 +27,7 @@ (mf/defc token-option* {::mf/schema schema:token-option} - [{:keys [id name on-click selected ref focused resolved] :rest props}] + [{:keys [id name on-click selected ref focused resolved value] :rest props}] (let [internal-id (mf/use-id) id (d/nilv id internal-id) element-ref (mf/use-ref nil)] @@ -61,5 +62,8 @@ :ref element-ref} name]] (when (and resolved (not (map? resolved))) - [:> :span {:class (stl/css :option-pill)} - resolved])])) + [:span {:class (stl/css :option-pill)} + resolved]) + (when (and (nil? resolved) value) + [:span {:class (stl/css :option-pill)} + "--"])])) 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 04c5900961..86146fe36b 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 @@ -39,11 +39,15 @@ {::mf/schema schema:token-field} [{:keys [id label value slot-start disabled class on-click on-token-key-down on-blur detach-token tooltip-placement - token-wrapper-ref token-detach-btn-ref on-focus property is-open]}] + token-wrapper-ref token-detach-btn-ref on-focus property is-open + token-has-errors]}] (let [set-active? (some? id) - content (if set-active? - label - (tr "ds.inputs.token-field.no-active-token-option" label)) + + content (cond + token-has-errors (tr "workspace.tokens.ref-not-valid") + (not set-active?) (tr "ds.inputs.token-field.no-active-token-option" label) + :else label) + default-id (mf/use-id) id (d/nilv id default-id) pill-ref (mf/use-ref nil) @@ -80,13 +84,15 @@ [:button {:on-click on-click :ref pill-ref :class (stl/css-case :pill true - :no-set-pill (not set-active?) + :no-set-pill (or (not set-active?) + token-has-errors) :pill-disabled disabled) :disabled disabled :aria-labelledby (dm/str id "-pill") :on-key-down on-token-key-down} value - (when-not set-active? + (when (or (not set-active?) + token-has-errors) [:div {:class (stl/css :pill-dot)}])]]] (when-not ^boolean disabled diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.cljs index 099a38b5c9..5ca03e3d29 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.cljs @@ -25,7 +25,7 @@ (tr "settings.multiple") "--")) :class [class (stl/css :numeric-input-wrapper)] - :applied-token applied-token + :applied-token-name applied-token :tokens (if (delay? tokens) @tokens tokens) :align align :on-detach on-detach-attr diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs index 0d16dd4664..dd992ce08b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs @@ -337,7 +337,7 @@ (mf/deps on-change ids) (fn [value attr event] (let [on-change-fn #(on-change :simple attr % event)] - (soc/emit-value-or-token value on-change-fn ids #{attr})))) + (soc/emit-value-or-token value on-change-fn ids attr)))) on-detach-token (mf/use-fn @@ -364,10 +364,10 @@ (mf/use-fn (mf/deps on-focus) #(on-focus :p2)) on-p1-change - (mf/use-fn (mf/deps on-change') #(on-change' % :p1)) + (mf/use-fn (mf/deps on-change') #(on-change' % #{:p1 :p3})) on-p2-change - (mf/use-fn (mf/deps on-change') #(on-change' % :p2))] + (mf/use-fn (mf/deps on-change') #(on-change' % #{:p2 :p4}))] [:div {:class (stl/css :paddings-simple)} (if token-numeric-inputs @@ -460,12 +460,8 @@ (mf/use-fn (mf/deps on-change ids) (fn [value attr event] - (if (or (string? value) (number? value)) - (on-change :multiple attr value event) - (do - (st/emit! (dwta/apply-token-from-input {:token (first value) - :attrs #{attr} - :shape-ids ids})))))) + (let [on-change-fn #(on-change :multiple attr % event)] + (soc/emit-value-or-token value on-change-fn ids #{attr})))) on-focus (mf/use-fn @@ -642,7 +638,7 @@ :value p4}]])])) (mf/defc padding-section* - [{:keys [type on-type-change on-change] :as props}] + [{:keys [type on-type-change] :as props}] (let [on-type-change' (mf/use-fn (mf/deps on-type-change) @@ -650,9 +646,7 @@ (let [type (-> (dom/get-current-target event) (dom/get-data "type")) type (if (= type "multiple") :simple :multiple)] - (on-type-change type)))) - - props (mf/spread-object props {:on-change on-change})] + (on-type-change type))))] (mf/with-effect [] ;; on destroy component @@ -1182,10 +1176,10 @@ (fn [type prop val] (let [val (mth/finite val 0)] (cond - (and (= type :simple) (= prop :p1)) + (and (= type :simple) (or (= prop :p1) (= prop #{:p1 :p3}))) (st/emit! (dwsl/update-layout ids {:layout-padding {:p1 val :p3 val}})) - (and (= type :simple) (= prop :p2)) + (and (= type :simple) (or (= prop :p2) (= prop #{:p2 :p4}))) (st/emit! (dwsl/update-layout ids {:layout-padding {:p2 val :p4 val}})) (some? prop) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs index c30905a99f..769d3a182c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs @@ -586,7 +586,8 @@ on-layout-item-max-h-change (mf/use-fn (mf/deps on-size-change) #(on-size-change % :layout-item-max-h))] - [:div {:class (stl/css :advanced-options)} + [:section {:class (stl/css :advanced-options) + :aria-label "Layout item size constraints"} (when (= (:layout-item-h-sizing values) :fill) [:div {:class (stl/css :horizontal-fill)} (if token-numeric-inputs @@ -834,7 +835,7 @@ (st/emit! (dwsl/update-layout-child ids {:layout-item-z-index value}))))] [:section {:class (stl/css :element-set) - :aria-label "layout item menu"} + :aria-label "Layout item section"} [:div {:class (stl/css :element-title)} [:> title-bar* {:collapsable has-content? :collapsed (not open?) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/utils.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/utils.cljs index 99c08fcf1d..39a4675dc0 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/utils.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/utils.cljs @@ -9,7 +9,8 @@ [token] {:id (str (get token :id)) :type :token - :resolved-value (or (get token :resolved-value) (get token :value)) + :value (get token :value) + :resolved-value (get token :resolved-value) :name (get token :name)}) (defn- generate-dropdown-options diff --git a/frontend/src/app/util/simple_math.cljs b/frontend/src/app/util/simple_math.cljs index 7b203fdd12..ca6f05536a 100644 --- a/frontend/src/app/util/simple_math.cljs +++ b/frontend/src/app/util/simple_math.cljs @@ -90,7 +90,16 @@ init-value (or init-value 0)] (s/assert number? init-value) (if-not (insta/failure? result) - (interpret result init-value) + (try + (let [value (interpret result init-value)] + ;; Check for division by zero (Infinity or -Infinity) + (if (or (js/Number.isFinite value) (nil? value)) + value + nil)) + (catch :default err + (js/console.debug (str "Expression evaluation error: " (ex-message err)) + (str "Expression: '" expr "'")) + nil)) (let [text (:text result) index (:index result) expecting (->> result diff --git a/frontend/test/frontend_tests/util_simple_math_test.cljs b/frontend/test/frontend_tests/util_simple_math_test.cljs index 15bb2198c2..eeae0ed40f 100644 --- a/frontend/test/frontend_tests/util_simple_math_test.cljs +++ b/frontend/test/frontend_tests/util_simple_math_test.cljs @@ -8,7 +8,6 @@ (:require [app.common.math :as cm] [app.util.simple-math :as sm] - [cljs.pprint :refer [pprint]] [cljs.test :as t :include-macros true])) (t/deftest test-parser-inst @@ -88,3 +87,24 @@ result2 (sm/expr-eval "(20,333 + 10%) * (1 / 3)" 20)] (t/is (cm/close? result1 result2 7.44433333))))) +(t/deftest test-error-handling + (t/testing "Division by zero should return nil" + (let [result (sm/expr-eval "10/0" 999)] + (t/is (= result nil)))) + + (t/testing "Expression with division by zero should return nil" + (let [result (sm/expr-eval "(10 + 5) / 0" 999)] + (t/is (= result nil)))) + + (t/testing "Invalid syntax should return nil" + (let [result (sm/expr-eval "asdasd+2" 999)] + (t/is (= result nil)))) + + (t/testing "Empty expression with no init-value should return nil" + (let [result (sm/expr-eval "" nil)] + (t/is (= result nil)))) + + (t/testing "Partial invalid expression should return nil" + (let [result (sm/expr-eval "10 + abc" 100)] + (t/is (= result nil))))) +