diff --git a/packages/pluggableWidgets/range-slider-web/CHANGELOG.md b/packages/pluggableWidgets/range-slider-web/CHANGELOG.md index e214f1de22..325ffd94cc 100644 --- a/packages/pluggableWidgets/range-slider-web/CHANGELOG.md +++ b/packages/pluggableWidgets/range-slider-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- Mark labels and numeric tooltips now respect the configured `decimalPlaces` and the user's session locale (decimal separator and thousands grouping). Previously, marks and tooltips rendered raw numbers via `.toString()`, ignoring locale and decimal precision settings. + ## [3.0.1] - 2026-02-10 ### Added diff --git a/packages/pluggableWidgets/range-slider-web/e2e/dataTypes.spec.js b/packages/pluggableWidgets/range-slider-web/e2e/dataTypes.spec.js index aedfc6039c..e09aa65b83 100644 --- a/packages/pluggableWidgets/range-slider-web/e2e/dataTypes.spec.js +++ b/packages/pluggableWidgets/range-slider-web/e2e/dataTypes.spec.js @@ -12,11 +12,11 @@ test.describe("Range Slider", () => { }); test("renders slider min value text", async ({ page }) => { - await expect(page.locator(".mx-name-rangeSlider1 .rc-slider-mark-text").first()).toHaveText("0"); + await expect(page.locator(".mx-name-rangeSlider1 .rc-slider-mark-text").first()).toHaveText("0.0"); }); test("renders slider max value text", async ({ page }) => { - await expect(page.locator(".mx-name-rangeSlider1 .rc-slider-mark-text").nth(1)).toHaveText("100"); + await expect(page.locator(".mx-name-rangeSlider1 .rc-slider-mark-text").nth(1)).toHaveText("100.0"); }); test("upper bound value is higher than lower bound value", async ({ page }) => { diff --git a/packages/pluggableWidgets/range-slider-web/package.json b/packages/pluggableWidgets/range-slider-web/package.json index ff46e5d998..224f32a04c 100644 --- a/packages/pluggableWidgets/range-slider-web/package.json +++ b/packages/pluggableWidgets/range-slider-web/package.json @@ -44,6 +44,7 @@ }, "dependencies": { "@mendix/widget-plugin-component-kit": "workspace:*", + "@mendix/widget-plugin-platform": "workspace:*", "@rc-component/slider": "^1.0.1", "@rc-component/tooltip": "^1.3.3", "classnames": "^2.5.1" @@ -55,7 +56,6 @@ "@mendix/prettier-config-web-widgets": "workspace:*", "@mendix/run-e2e": "workspace:*", "@mendix/widget-plugin-hooks": "workspace:*", - "@mendix/widget-plugin-platform": "workspace:*", "@types/rc-slider": "^8.6.6", "@types/rc-tooltip": "^3.7.7", "cross-env": "^7.0.3" diff --git a/packages/pluggableWidgets/range-slider-web/src/RangeSlider.editorPreview.tsx b/packages/pluggableWidgets/range-slider-web/src/RangeSlider.editorPreview.tsx index 91679d8cd3..e0e982931e 100644 --- a/packages/pluggableWidgets/range-slider-web/src/RangeSlider.editorPreview.tsx +++ b/packages/pluggableWidgets/range-slider-web/src/RangeSlider.editorPreview.tsx @@ -1,7 +1,7 @@ import { ReactNode } from "react"; +import { createMarks } from "@mendix/widget-plugin-platform/utils/slider-marks"; import { RangeSliderPreviewProps } from "../typings/RangeSliderProps"; import { RangeSlider } from "./components/RangeSlider"; -import { createMarks } from "./utils/marks"; import { getPreviewValues } from "./utils/getPreviewValues"; import { getStyleProp, isVertical } from "./utils/prop-utils"; @@ -11,11 +11,13 @@ export function getPreviewCss(): string { export function preview(props: RangeSliderPreviewProps): ReactNode { const { min, max, step, value } = getPreviewValues(props); + const decimalPlaces = props.decimalPlaces ?? 0; const marks = createMarks({ min, max, numberOfMarks: props.noOfMarkers ?? 1, - decimalPlaces: props.decimalPlaces ?? 0 + decimalPlaces, + format: (v: number) => v.toFixed(decimalPlaces) }); const style = getStyleProp({ diff --git a/packages/pluggableWidgets/range-slider-web/src/components/Container.tsx b/packages/pluggableWidgets/range-slider-web/src/components/Container.tsx index 55990a2fa7..1e242c8a56 100644 --- a/packages/pluggableWidgets/range-slider-web/src/components/Container.tsx +++ b/packages/pluggableWidgets/range-slider-web/src/components/Container.tsx @@ -1,12 +1,14 @@ +import { NumberFormatter } from "mendix"; import { ReactElement, useMemo, useRef } from "react"; +import { useScheduleUpdateOnce } from "@mendix/widget-plugin-hooks/useScheduleUpdateOnce"; +import { createValueFormatter } from "@mendix/widget-plugin-platform/utils/number-formatter"; +import { RangeSlider as RangeComponent } from "./RangeSlider"; +import { HandleTooltip } from "./TooltipHandler"; import { RangeSliderContainerProps } from "../../typings/RangeSliderProps"; +import { getStyleProp, isVertical, maxProp, minProp, stepProp } from "../utils/prop-utils"; +import { useMarks } from "../utils/useMarks"; import { useNumber } from "../utils/useNumber"; -import { RangeSlider as RangeComponent } from "./RangeSlider"; import { useOnChangeDebounced } from "../utils/useOnChangeDebounced"; -import { useMarks } from "../utils/useMarks"; -import { getStyleProp, isVertical, maxProp, minProp, stepProp } from "../utils/prop-utils"; -import { useScheduleUpdateOnce } from "@mendix/widget-plugin-hooks/useScheduleUpdateOnce"; -import { HandleTooltip } from "./TooltipHandler"; export function Container(props: RangeSliderContainerProps): ReactElement { const min = useNumber(minProp(props)); @@ -41,9 +43,16 @@ function InnerContainer(props: InnerContainerProps): ReactElement { const { onChange } = useOnChangeDebounced({ lowerBoundAttribute, upperBoundAttribute, onChange: props.onChange }); + const format = useMemo( + () => createValueFormatter(lowerBoundAttribute.formatter as NumberFormatter, props.decimalPlaces), + // eslint-disable-next-line react-hooks/exhaustive-deps + [lowerBoundAttribute.formatter, props.decimalPlaces] + ); + const marks = useMarks({ noOfMarkers: props.noOfMarkers, decimalPlaces: props.decimalPlaces, + format, min, max }); @@ -73,7 +82,9 @@ function InnerContainer(props: InnerContainerProps): ReactElement { max={props.max} handleRender={(node, handleProps) => { const isCustomText = tooltipTypeCheck[handleProps.index] === "customText"; - const displayValue = isCustomText ? (tooltipValue[handleProps.index]?.value ?? "") : handleProps.value; + const displayValue = isCustomText + ? (tooltipValue[handleProps.index]?.value ?? "") + : format(handleProps.value); return ( ; - -export interface CreateMarksParams { - numberOfMarks: number; - decimalPlaces: number; - min: number; - max: number; -} - -export function isParamsValidToCalcMarks(params: CreateMarksParams): boolean { - return params.numberOfMarks > 0 && params.min < params.max; -} - -export function createMarks(params: CreateMarksParams): Marks | undefined { - if (!isParamsValidToCalcMarks(params)) { - return; - } - - const marks: Marks = {}; - const { numberOfMarks, decimalPlaces, min, max } = params; - const interval = (max - min) / numberOfMarks; - - for (let i = 0; i <= numberOfMarks; i++) { - const value = parseFloat((min + i * interval).toFixed(decimalPlaces)); - marks[value] = value.toString(); - } - - return marks; -} diff --git a/packages/pluggableWidgets/range-slider-web/src/utils/useMarks.ts b/packages/pluggableWidgets/range-slider-web/src/utils/useMarks.ts index 4e8dd10dee..ab9790fa05 100644 --- a/packages/pluggableWidgets/range-slider-web/src/utils/useMarks.ts +++ b/packages/pluggableWidgets/range-slider-web/src/utils/useMarks.ts @@ -1,24 +1,27 @@ import { useMemo } from "react"; -import { createMarks } from "./marks"; +import { type ValueFormatter } from "@mendix/widget-plugin-platform/utils/number-formatter"; +import { createMarks } from "@mendix/widget-plugin-platform/utils/slider-marks"; type UseMarksParams = { noOfMarkers: number; decimalPlaces: number; + format: ValueFormatter; min?: number; max?: number; }; export function useMarks(props: UseMarksParams): ReturnType { - const { noOfMarkers, decimalPlaces, min = 0, max = 100 } = props; + const { noOfMarkers, decimalPlaces, format, min = 0, max = 100 } = props; return useMemo( () => createMarks({ numberOfMarks: noOfMarkers, decimalPlaces, + format, min, max }), - [min, max, noOfMarkers, decimalPlaces] + [min, max, noOfMarkers, decimalPlaces, format] ); } diff --git a/packages/pluggableWidgets/slider-web/CHANGELOG.md b/packages/pluggableWidgets/slider-web/CHANGELOG.md index 20079df8d5..5d80c65204 100644 --- a/packages/pluggableWidgets/slider-web/CHANGELOG.md +++ b/packages/pluggableWidgets/slider-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed mark labels and tooltip values not respecting the decimal places and locale settings configured on the value attribute. Numbers now display with the correct decimal separator, digit grouping, and number of decimal places. + ## [3.0.2] - 2026-02-19 ### Fixed diff --git a/packages/pluggableWidgets/slider-web/e2e/Slider.spec.js b/packages/pluggableWidgets/slider-web/e2e/Slider.spec.js index 02376b95e0..0681753473 100644 --- a/packages/pluggableWidgets/slider-web/e2e/Slider.spec.js +++ b/packages/pluggableWidgets/slider-web/e2e/Slider.spec.js @@ -6,19 +6,17 @@ test.describe("Slider", () => { await page.goto("/"); await waitForMendixApp(page); - const minimumValue = await page.inputValue(".mx-name-textBoxMinimumValue input"); const minimumValueText = await page .locator(".mx-name-sliderContext .rc-slider-mark > span") .first() .textContent(); - await expect(minimumValueText).toBe(minimumValue); + await expect(minimumValueText).toBe("0.0"); - const maximumValue = await page.inputValue(".mx-name-textBoxMaximumValue input"); const maximumValueText = await page .locator(".mx-name-sliderContext .rc-slider-mark > span") .nth(2) .textContent(); - await expect(maximumValueText).toBe(maximumValue); + await expect(maximumValueText).toBe("20.0"); const value = await page.inputValue(".mx-name-textBoxValue input"); await expect(value).toContain("10"); @@ -38,13 +36,13 @@ test.describe("Slider", () => { .locator(".mx-name-sliderNoContext .rc-slider-mark > span") .first() .textContent(); - await expect(minimumValueText).toBe("0"); + await expect(minimumValueText).toBe("0.0"); const maximumValueText = await page .locator(".mx-name-sliderNoContext .rc-slider-mark > span") .nth(2) .textContent(); - await expect(maximumValueText).toBe("100"); + await expect(maximumValueText).toBe("100.0"); const handleStyle = await page.locator(".mx-name-sliderNoContext .rc-slider-handle").getAttribute("style"); await expect(handleStyle).toContain("left: 0%;"); @@ -173,7 +171,7 @@ test.describe("Slider", () => { await waitForMendixApp(page); await expect(page.locator(".mx-name-slider")).toBeVisible(); - await expect(page.locator(".mx-name-slider .rc-slider-mark > span").nth(1)).toHaveText("140000"); + await expect(page.locator(".mx-name-slider .rc-slider-mark > span").nth(1)).toHaveText("140000.0"); await expect(page.locator(".mx-name-slider .rc-slider-mark > span").nth(1)).toHaveAttribute( "style", /left: 33.3333%;/ diff --git a/packages/pluggableWidgets/slider-web/e2e/Slider.spec.js-snapshots/sliderStyles-chromium-linux.png b/packages/pluggableWidgets/slider-web/e2e/Slider.spec.js-snapshots/sliderStyles-chromium-linux.png index 7e9275f52b..1a666cffd1 100644 Binary files a/packages/pluggableWidgets/slider-web/e2e/Slider.spec.js-snapshots/sliderStyles-chromium-linux.png and b/packages/pluggableWidgets/slider-web/e2e/Slider.spec.js-snapshots/sliderStyles-chromium-linux.png differ diff --git a/packages/pluggableWidgets/slider-web/package.json b/packages/pluggableWidgets/slider-web/package.json index 746d7a1d39..1f20f33976 100644 --- a/packages/pluggableWidgets/slider-web/package.json +++ b/packages/pluggableWidgets/slider-web/package.json @@ -44,6 +44,7 @@ }, "dependencies": { "@mendix/widget-plugin-component-kit": "workspace:*", + "@mendix/widget-plugin-platform": "workspace:*", "@rc-component/slider": "^1.0.1", "@rc-component/tooltip": "^1.3.3", "classnames": "^2.5.1" @@ -55,7 +56,6 @@ "@mendix/prettier-config-web-widgets": "workspace:*", "@mendix/run-e2e": "workspace:*", "@mendix/widget-plugin-hooks": "workspace:*", - "@mendix/widget-plugin-platform": "workspace:*", "cross-env": "^7.0.3" } } diff --git a/packages/pluggableWidgets/slider-web/src/Slider.editorPreview.tsx b/packages/pluggableWidgets/slider-web/src/Slider.editorPreview.tsx index 8ad56d8f9d..b8774909ae 100644 --- a/packages/pluggableWidgets/slider-web/src/Slider.editorPreview.tsx +++ b/packages/pluggableWidgets/slider-web/src/Slider.editorPreview.tsx @@ -1,8 +1,8 @@ import { ReactNode } from "react"; +import { createMarks } from "@mendix/widget-plugin-platform/utils/slider-marks"; import { Slider } from "./components/Slider"; import { SliderPreviewProps } from "../typings/SliderProps"; import { getPreviewValues } from "./utils/getPreviewValues"; -import { createMarks } from "./utils/marks"; import { getStyleProp, isVertical } from "./utils/prop-utils"; export function getPreviewCss(): string { @@ -11,11 +11,13 @@ export function getPreviewCss(): string { export function preview(props: SliderPreviewProps): ReactNode { const values = getPreviewValues(props); + const decimalPlaces = props.decimalPlaces ?? 2; const marks = createMarks({ min: values.min, max: values.max, numberOfMarks: props.noOfMarkers ?? 2, - decimalPlaces: props.decimalPlaces ?? 2 + decimalPlaces, + format: (v: number) => v.toFixed(decimalPlaces) }); const style = getStyleProp({ orientation: props.orientation, diff --git a/packages/pluggableWidgets/slider-web/src/components/Container.tsx b/packages/pluggableWidgets/slider-web/src/components/Container.tsx index b26135235b..a1ad7092d2 100644 --- a/packages/pluggableWidgets/slider-web/src/components/Container.tsx +++ b/packages/pluggableWidgets/slider-web/src/components/Container.tsx @@ -1,15 +1,15 @@ +import { NumberFormatter } from "mendix"; import { ReactElement, useMemo, useRef } from "react"; +import { createValueFormatter } from "@mendix/widget-plugin-platform/utils/number-formatter"; +import { Slider as SliderComponent } from "./Slider"; import { SliderContainerProps } from "../../typings/SliderProps"; - import { createHandleRender } from "../utils/createHandleRender"; +import { getSliderLabel } from "../utils/helpers"; import { getStyleProp, isVertical, maxProp, minProp, stepProp } from "../utils/prop-utils"; import { useMarks } from "../utils/useMarks"; import { useNumber } from "../utils/useNumber"; -import { getSliderLabel } from "../utils/helpers"; import { useOnChangeDebounced } from "../utils/useOnChangeDebounced"; -import { Slider as SliderComponent } from "./Slider"; - export function Container(props: SliderContainerProps): ReactElement { const min = useNumber(minProp(props)); const max = useNumber(maxProp(props)); @@ -30,19 +30,32 @@ interface InnerContainerProps extends SliderContainerProps { function InnerContainer(props: InnerContainerProps): ReactElement { const sliderRef = useRef(null); - const handleRender = props.showTooltip - ? createHandleRender({ - tooltip: props.tooltip, - tooltipType: props.tooltipType, - tooltipAlwaysVisible: props.tooltipAlwaysVisible, - sliderRef - }) - : undefined; + + const format = useMemo( + () => createValueFormatter(props.valueAttribute.formatter as NumberFormatter, props.decimalPlaces), + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.valueAttribute.formatter, props.decimalPlaces] + ); + + const handleRender = useMemo( + () => + props.showTooltip + ? createHandleRender({ + tooltip: props.tooltip, + tooltipType: props.tooltipType, + tooltipAlwaysVisible: props.tooltipAlwaysVisible, + sliderRef, + format + }) + : undefined, + [props.showTooltip, props.tooltip, props.tooltipType, props.tooltipAlwaysVisible, format] + ); const { onChange } = useOnChangeDebounced({ valueAttribute: props.valueAttribute, onChange: props.onChange }); const marks = useMarks({ noOfMarkers: props.noOfMarkers, decimalPlaces: props.decimalPlaces, + format, min: props.min, max: props.max }); diff --git a/packages/pluggableWidgets/slider-web/src/utils/__tests__/createHandleRender.spec.tsx b/packages/pluggableWidgets/slider-web/src/utils/__tests__/createHandleRender.spec.tsx new file mode 100644 index 0000000000..33eef11b9d --- /dev/null +++ b/packages/pluggableWidgets/slider-web/src/utils/__tests__/createHandleRender.spec.tsx @@ -0,0 +1,80 @@ +import { SliderProps as RcSliderProps } from "@rc-component/slider"; +import { createRef, ReactElement } from "react"; +import { createHandleRender } from "../createHandleRender"; + +const defaultRenderProps = { + dragging: false, + index: 0, + prefixCls: "rc-slider-handle", + draggingDelete: false, + onFocus: jest.fn(), + onBlur: jest.fn() +}; + +const mockNode =
; + +const formatWith = + (decimalPlaces: number, decimalSeparator = ".") => + (value: number): string => { + const fixed = value.toFixed(decimalPlaces); + return decimalSeparator === "." ? fixed : fixed.replace(".", decimalSeparator); + }; + +function buildHandleRender( + decimalPlaces: number, + tooltipType: "value" | "customText" = "value", + decimalSeparator = "." +): NonNullable { + const sliderRef = createRef(); + return createHandleRender({ + tooltipType, + tooltipAlwaysVisible: true, + sliderRef, + format: formatWith(decimalPlaces, decimalSeparator) + })!; +} + +describe("createHandleRender tooltip value formatting", () => { + it("formats whole number with trailing zeros when decimalPlaces=2", () => { + const result = buildHandleRender(2)(mockNode, { ...defaultRenderProps, value: 10 } as any) as ReactElement; + expect(result.props.overlay).toBe("10.00"); + }); + + it("formats partial decimal with trailing zero when decimalPlaces=2", () => { + const result = buildHandleRender(2)(mockNode, { + ...defaultRenderProps, + value: 9.2 + } as any) as ReactElement; + expect(result.props.overlay).toBe("9.20"); + }); + + it("formats value without decimals when decimalPlaces=0", () => { + const result = buildHandleRender(0)(mockNode, { ...defaultRenderProps, value: 10 } as any) as ReactElement; + expect(result.props.overlay).toBe("10"); + }); + + it("uses locale decimal separator", () => { + const result = buildHandleRender( + 2, + "value", + "," + )(mockNode, { + ...defaultRenderProps, + value: 9.2 + } as any) as ReactElement; + expect(result.props.overlay).toBe("9,20"); + }); + + it("renders custom text tooltip ignoring value formatting", () => { + const sliderRef = createRef(); + const handleRender = createHandleRender({ + tooltip: { value: "custom label" } as any, + tooltipType: "customText", + tooltipAlwaysVisible: true, + sliderRef, + format: formatWith(2) + })!; + const result = handleRender(mockNode, { ...defaultRenderProps, value: 10 } as any) as ReactElement; + expect(result.props.overlay.props.children).toBe("custom label"); + }); +}); diff --git a/packages/pluggableWidgets/slider-web/src/utils/createHandleRender.tsx b/packages/pluggableWidgets/slider-web/src/utils/createHandleRender.tsx index 5b1a86bdc4..6f23854652 100644 --- a/packages/pluggableWidgets/slider-web/src/utils/createHandleRender.tsx +++ b/packages/pluggableWidgets/slider-web/src/utils/createHandleRender.tsx @@ -2,6 +2,7 @@ import { SliderProps as RcSliderProps } from "@rc-component/slider"; import RcTooltip from "@rc-component/tooltip"; import { DynamicValue } from "mendix"; import { RefObject } from "react"; +import { type ValueFormatter } from "@mendix/widget-plugin-platform/utils/number-formatter"; import "@rc-component/tooltip/assets/bootstrap.css"; @@ -10,26 +11,28 @@ type CreateHandleRenderProps = { tooltipType: "value" | "customText"; tooltipAlwaysVisible: boolean; sliderRef: RefObject; + format: ValueFormatter; }; export function createHandleRender({ tooltip, tooltipType, tooltipAlwaysVisible, - sliderRef + sliderRef, + format }: CreateHandleRenderProps): RcSliderProps["handleRender"] | undefined { const isCustomText = tooltipType === "customText"; const handleRender: RcSliderProps["handleRender"] = (node, props) => { const { dragging, index, ...restProps } = props; - const overlay =
{tooltip?.value ?? ""}
; + const overlay = isCustomText ?
{tooltip?.value ?? ""}
: null; return ( sliderRef.current ?? document.body} defaultVisible prefixCls="rc-slider-tooltip" - overlay={isCustomText ? overlay : restProps.value} + overlay={isCustomText ? overlay : format(restProps.value)} trigger={["hover", "click", "focus"]} visible={tooltipAlwaysVisible || dragging} placement="top" diff --git a/packages/pluggableWidgets/slider-web/src/utils/marks.ts b/packages/pluggableWidgets/slider-web/src/utils/marks.ts deleted file mode 100644 index 2c27eba8e6..0000000000 --- a/packages/pluggableWidgets/slider-web/src/utils/marks.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { MarkObj } from "@rc-component/slider/lib/Marks"; -import { ReactNode } from "react"; - -export type Marks = Record; - -export interface CreateMarksParams { - numberOfMarks: number; - decimalPlaces: number; - min: number; - max: number; -} - -export function isParamsValidToCalcMarks(params: CreateMarksParams): boolean { - return params.numberOfMarks > 0 && params.min < params.max; -} - -export function createMarks(params: CreateMarksParams): Marks | undefined { - if (!isParamsValidToCalcMarks(params)) { - return; - } - - const marks: Marks = {}; - const { numberOfMarks, decimalPlaces, min, max } = params; - const interval = (max - min) / numberOfMarks; - - for (let i = 0; i <= numberOfMarks; i++) { - const value = parseFloat((min + i * interval).toFixed(decimalPlaces)); - marks[value] = value.toString(); - } - - return marks; -} diff --git a/packages/pluggableWidgets/slider-web/src/utils/useMarks.ts b/packages/pluggableWidgets/slider-web/src/utils/useMarks.ts index 4e8dd10dee..2e67f68e31 100644 --- a/packages/pluggableWidgets/slider-web/src/utils/useMarks.ts +++ b/packages/pluggableWidgets/slider-web/src/utils/useMarks.ts @@ -1,24 +1,27 @@ import { useMemo } from "react"; -import { createMarks } from "./marks"; +import type { ValueFormatter } from "@mendix/widget-plugin-platform/utils/number-formatter"; +import { createMarks } from "@mendix/widget-plugin-platform/utils/slider-marks"; type UseMarksParams = { noOfMarkers: number; decimalPlaces: number; + format: ValueFormatter; min?: number; max?: number; }; export function useMarks(props: UseMarksParams): ReturnType { - const { noOfMarkers, decimalPlaces, min = 0, max = 100 } = props; + const { noOfMarkers, decimalPlaces, format, min = 0, max = 100 } = props; return useMemo( () => createMarks({ numberOfMarks: noOfMarkers, decimalPlaces, + format, min, max }), - [min, max, noOfMarkers, decimalPlaces] + [min, max, noOfMarkers, decimalPlaces, format] ); } diff --git a/packages/pluggableWidgets/slider-web/typings/declare-svg.ts b/packages/pluggableWidgets/slider-web/typings/declare-svg.ts index e6958d5a9f..d966c93688 100644 --- a/packages/pluggableWidgets/slider-web/typings/declare-svg.ts +++ b/packages/pluggableWidgets/slider-web/typings/declare-svg.ts @@ -2,3 +2,5 @@ declare module "*.svg" { const content: string; export = content; } + +declare module "*.css"; diff --git a/packages/shared/widget-plugin-platform/jest.config.cjs b/packages/shared/widget-plugin-platform/jest.config.cjs index 1a6d9d630f..1e6e270219 100644 --- a/packages/shared/widget-plugin-platform/jest.config.cjs +++ b/packages/shared/widget-plugin-platform/jest.config.cjs @@ -15,6 +15,7 @@ module.exports = { ] }, moduleNameMapper: { + "^big\\.js$": "/../../../node_modules/big.js/big.js", "(.+)\\.js": "$1" }, extensionsToTreatAsEsm: [".ts"], diff --git a/packages/shared/widget-plugin-platform/package.json b/packages/shared/widget-plugin-platform/package.json index 8180132dbf..d6fcafbef3 100644 --- a/packages/shared/widget-plugin-platform/package.json +++ b/packages/shared/widget-plugin-platform/package.json @@ -36,6 +36,7 @@ "@mendix/tsconfig-web-widgets": "workspace:*", "@swc/core": "^1.7.26", "@swc/jest": "^0.2.36", + "big.js": "^6.2.1", "classnames": "^2.5.1", "jest-environment-jsdom": "^29.7.0" } diff --git a/packages/shared/widget-plugin-platform/src/utils/__tests__/number-formatter.spec.ts b/packages/shared/widget-plugin-platform/src/utils/__tests__/number-formatter.spec.ts new file mode 100644 index 0000000000..43007115a1 --- /dev/null +++ b/packages/shared/widget-plugin-platform/src/utils/__tests__/number-formatter.spec.ts @@ -0,0 +1,57 @@ +import { Big } from "big.js"; +import { NumberFormatter } from "mendix"; +import { createValueFormatter } from "../number-formatter"; + +function fakeNumberFormatter( + config: { groupDigits: boolean; decimalPrecision?: number }, + locale: { decimal: string; group: string } = { decimal: ".", group: "," } +): NumberFormatter { + return { + type: "number", + config, + withConfig: (next: { groupDigits: boolean; decimalPrecision?: number }) => + fakeNumberFormatter({ ...config, ...next }, locale), + format: (value?: Big) => { + if (value == null) { + return ""; + } + const fixed = value.toNumber().toFixed(config.decimalPrecision ?? 0); + const [intPart, fracPart] = fixed.split("."); + const grouped = config.groupDigits ? intPart.replace(/\B(?=(\d{3})+(?!\d))/g, locale.group) : intPart; + return fracPart != null ? `${grouped}${locale.decimal}${fracPart}` : grouped; + }, + parse: () => ({ valid: false }) + } as unknown as NumberFormatter; +} + +describe("createValueFormatter", () => { + it("redefines the formatter's decimal precision (forces trailing zeros)", () => { + const format = createValueFormatter(fakeNumberFormatter({ groupDigits: false }), 2); + expect(format(10)).toBe("10.00"); + expect(format(9.2)).toBe("9.20"); + }); + + it("formats without decimals when decimalPlaces is 0", () => { + const format = createValueFormatter(fakeNumberFormatter({ groupDigits: false }), 0); + expect(format(10)).toBe("10"); + expect(format(9.7)).toBe("10"); + }); + + it("respects the locale decimal separator from the formatter", () => { + const format = createValueFormatter( + fakeNumberFormatter({ groupDigits: false }, { decimal: ",", group: "." }), + 2 + ); + expect(format(9.2)).toBe("9,20"); + }); + + it("keeps the attribute's thousands grouping when groupDigits is enabled", () => { + const format = createValueFormatter(fakeNumberFormatter({ groupDigits: true }), 0); + expect(format(1000000)).toBe("1,000,000"); + }); + + it("omits thousands grouping when groupDigits is disabled", () => { + const format = createValueFormatter(fakeNumberFormatter({ groupDigits: false }), 0); + expect(format(1000000)).toBe("1000000"); + }); +}); diff --git a/packages/shared/widget-plugin-platform/src/utils/__tests__/slider-marks.spec.ts b/packages/shared/widget-plugin-platform/src/utils/__tests__/slider-marks.spec.ts new file mode 100644 index 0000000000..b3b46edc93 --- /dev/null +++ b/packages/shared/widget-plugin-platform/src/utils/__tests__/slider-marks.spec.ts @@ -0,0 +1,77 @@ +import { createMarks } from "../slider-marks"; + +const formatWith = + (decimalPlaces: number, decimalSeparator = ".") => + (value: number): string => { + const fixed = value.toFixed(decimalPlaces); + return decimalSeparator === "." ? fixed : fixed.replace(".", decimalSeparator); + }; + +describe("createMarks", () => { + it("forces trailing zeros when decimalPlaces > 0 and value is whole number", () => { + const marks = createMarks({ numberOfMarks: 2, decimalPlaces: 2, format: formatWith(2), min: 0, max: 10 }); + expect(marks).toBeDefined(); + expect(marks![0]).toBe("0.00"); + expect(marks![5]).toBe("5.00"); + expect(marks![10]).toBe("10.00"); + }); + + it("forces trailing zeros when decimalPlaces > 0 and value has fewer decimals", () => { + const marks = createMarks({ numberOfMarks: 2, decimalPlaces: 2, format: formatWith(2), min: 0, max: 9.2 }); + expect(marks).toBeDefined(); + expect(marks![4.6]).toBe("4.60"); + expect(marks![9.2]).toBe("9.20"); + }); + + it("does not add decimal places when decimalPlaces is 0", () => { + const marks = createMarks({ numberOfMarks: 4, decimalPlaces: 0, format: formatWith(0), min: 0, max: 100 }); + expect(marks).toBeDefined(); + expect(marks![0]).toBe("0"); + expect(marks![25]).toBe("25"); + expect(marks![100]).toBe("100"); + }); + + it("uses locale decimal separator", () => { + const marks = createMarks({ numberOfMarks: 2, decimalPlaces: 2, format: formatWith(2, ","), min: 0, max: 10 }); + expect(marks![0]).toBe("0,00"); + expect(marks![5]).toBe("5,00"); + expect(marks![10]).toBe("10,00"); + }); + + it("uses correct numeric keys for fractional values with comma locale", () => { + const marks = createMarks({ + numberOfMarks: 2, + decimalPlaces: 2, + format: formatWith(2, ","), + min: 0, + max: 9.2 + }); + expect(marks![4.6]).toBe("4,60"); + expect(marks![9.2]).toBe("9,20"); + }); + + it("rounds mark keys to the configured decimal places so dots align with their labels", () => { + const marks = createMarks({ numberOfMarks: 9, decimalPlaces: 1, format: formatWith(1), min: 0, max: 20 }); + expect(Object.keys(marks!)).toContain("6.7"); + expect(Object.keys(marks!)).not.toContain("6.666666666666667"); + expect(marks![6.7]).toBe("6.7"); + }); + + it("returns undefined when numberOfMarks is 0", () => { + expect( + createMarks({ numberOfMarks: 0, decimalPlaces: 2, format: formatWith(2), min: 0, max: 100 }) + ).toBeUndefined(); + }); + + it("returns undefined when min equals max", () => { + expect( + createMarks({ numberOfMarks: 4, decimalPlaces: 2, format: formatWith(2), min: 5, max: 5 }) + ).toBeUndefined(); + }); + + it("returns undefined when min > max", () => { + expect( + createMarks({ numberOfMarks: 2, decimalPlaces: 1, format: formatWith(1), min: 10, max: 5 }) + ).toBeUndefined(); + }); +}); diff --git a/packages/shared/widget-plugin-platform/src/utils/number-formatter.ts b/packages/shared/widget-plugin-platform/src/utils/number-formatter.ts new file mode 100644 index 0000000000..1c365fe5e6 --- /dev/null +++ b/packages/shared/widget-plugin-platform/src/utils/number-formatter.ts @@ -0,0 +1,12 @@ +import { Big } from "big.js"; +import { NumberFormatter } from "mendix"; + +export type ValueFormatter = (value: number) => string; + +export function createValueFormatter(formatter: NumberFormatter, decimalPlaces: number): ValueFormatter { + const configured = formatter.withConfig({ + groupDigits: formatter.config.groupDigits, + decimalPrecision: decimalPlaces + }); + return (value: number) => configured.format(new Big(value)); +} diff --git a/packages/shared/widget-plugin-platform/src/utils/slider-marks.ts b/packages/shared/widget-plugin-platform/src/utils/slider-marks.ts new file mode 100644 index 0000000000..0d6e09893b --- /dev/null +++ b/packages/shared/widget-plugin-platform/src/utils/slider-marks.ts @@ -0,0 +1,33 @@ +import { ValueFormatter } from "./number-formatter"; + +export interface CreateMarksParams { + numberOfMarks: number; + decimalPlaces: number; + format: ValueFormatter; + min: number; + max: number; +} + +export function isParamsValidToCalcMarks(params: CreateMarksParams): boolean { + return params.numberOfMarks > 0 && params.min < params.max; +} + +export function createMarks(params: CreateMarksParams): Record | undefined { + if (!isParamsValidToCalcMarks(params)) { + return; + } + + const marks: Record = {}; + const { numberOfMarks, decimalPlaces, format, min, max } = params; + const interval = (max - min) / numberOfMarks; + + for (let i = 0; i <= numberOfMarks; i++) { + const rawValue = min + i * interval; + // Round the key to configured precision so rc-slider positions dot where label reads. + // toFixed always uses "." so parseFloat is locale-safe. + const key = parseFloat(rawValue.toFixed(decimalPlaces)); + marks[key] = format(rawValue); + } + + return marks; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de09f6caeb..61152bb109 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2085,6 +2085,9 @@ importers: '@mendix/widget-plugin-component-kit': specifier: workspace:* version: link:../../shared/widget-plugin-component-kit + '@mendix/widget-plugin-platform': + specifier: workspace:* + version: link:../../shared/widget-plugin-platform '@rc-component/slider': specifier: ^1.0.1 version: 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2113,9 +2116,6 @@ importers: '@mendix/widget-plugin-hooks': specifier: workspace:* version: link:../../shared/widget-plugin-hooks - '@mendix/widget-plugin-platform': - specifier: workspace:* - version: link:../../shared/widget-plugin-platform '@types/rc-slider': specifier: ^8.6.6 version: 8.6.6 @@ -2351,6 +2351,9 @@ importers: '@mendix/widget-plugin-component-kit': specifier: workspace:* version: link:../../shared/widget-plugin-component-kit + '@mendix/widget-plugin-platform': + specifier: workspace:* + version: link:../../shared/widget-plugin-platform '@rc-component/slider': specifier: ^1.0.1 version: 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2379,9 +2382,6 @@ importers: '@mendix/widget-plugin-hooks': specifier: workspace:* version: link:../../shared/widget-plugin-hooks - '@mendix/widget-plugin-platform': - specifier: workspace:* - version: link:../../shared/widget-plugin-platform cross-env: specifier: ^7.0.3 version: 7.0.3 @@ -3031,6 +3031,9 @@ importers: '@swc/jest': specifier: ^0.2.36 version: 0.2.39(@swc/core@1.13.5) + big.js: + specifier: ^6.2.1 + version: 6.2.2 classnames: specifier: ^2.5.1 version: 2.5.1