mirror of
https://github.com/penpot/penpot.git
synced 2026-04-25 11:18:36 +00:00
🐛 Fix errors from numeric input design review (#8993)
* 🐛 Fix blur input after enter value * 🐛 Catch error on invalid maths * 🐛 Fix race condition * 🎉 Add tests that cover issues * 🐛 Fix padding applying only to one side * 🐛 Fix show broken pill when reference is on not active set
This commit is contained in:
parent
78c48f1953
commit
f18670ed00
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -48,6 +48,7 @@
|
||||
:id id
|
||||
:name name
|
||||
:resolved (get option :resolved-value)
|
||||
:value (get option :value)
|
||||
:ref ref
|
||||
:role "option"
|
||||
:focused (= id focused)
|
||||
|
||||
@ -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)}
|
||||
"--"])]))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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?)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)))))
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user