diff --git a/.github/workflows/publish-containers.yml b/.github/workflows/publish-containers.yml index e9c80b90011..708dfdc736f 100644 --- a/.github/workflows/publish-containers.yml +++ b/.github/workflows/publish-containers.yml @@ -7,7 +7,7 @@ on: jobs: prepare-variables: name: Prepare variables - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 permissions: contents: read outputs: @@ -30,19 +30,48 @@ jobs: - name: Checkout uses: actions/checkout@v6 + - name: Check if tag should be tagged as "latest" + id: check-latest + env: + GH_TOKEN: ${{ github.token }} + CURRENT_TAG: ${{ github.ref_name }} + run: | + set -ux + + # Only stable semver releases (e.g. 3.22.0) can be tagged as latest. + # Non-semver refs (branch names, PR refs) are not valid candidates to become latest. + if ! [[ "$CURRENT_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Tag inferred from this run ('$CURRENT_TAG') is not a semver release - can't be tagged as latest." + echo "IS_LATEST=false" >> $GITHUB_OUTPUT + exit 0 + fi + + CURRENT_LATEST=$(gh api "/repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name) + HIGHEST=$( (echo "$CURRENT_LATEST"; echo "$CURRENT_TAG") | sort -V | tail -1) + + echo "Selected: $CURRENT_TAG, Latest: $CURRENT_LATEST" + + if [[ "$CURRENT_TAG" == "$HIGHEST" ]]; then + echo "IS_LATEST=true" >> $GITHUB_OUTPUT + else + echo "IS_LATEST=false" >> $GITHUB_OUTPUT + fi + - name: Docker metadata id: metadata uses: docker/metadata-action@v5 with: images: | ghcr.io/${{ steps.get-image-name.outputs.image_name }} + flavor: | + latest=false tags: | type=ref,event=branch type=pep440,pattern={{version}} type=pep440,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ steps.check-latest.outputs.IS_LATEST }} context: git - build-push: needs: prepare-variables uses: saleor/saleor-internal-actions/.github/workflows/build-push-image-multi-platform.yaml@92c29aa0e4545de579b892b2ef9f2d6366c29c11 # v1.5.2 @@ -65,10 +94,9 @@ jobs: COMMIT_ID=${{ github.sha }} PROJECT_VERSION=${{ needs.prepare-variables.outputs.version }} - summary: needs: [prepare-variables, build-push] - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 permissions: contents: read steps: @@ -79,3 +107,30 @@ jobs: run: | echo "Tags: $tags" echo "Digest: $digest" + + load-failure-secrets: + if: failure() + needs: [prepare-variables, build-push] + runs-on: ubuntu-24.04 + permissions: {} + outputs: + slack-webhook-url: ${{ steps.load-secrets.outputs.SLACK_WEBHOOK_URL }} + steps: + - name: Load secrets + uses: 1password/load-secrets-action@8d0d610af187e78a2772c2d18d627f4c52d3fbfb # v3.1.0 + id: load-secrets + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + SLACK_WEBHOOK_URL: "op://Continuous Integration/DASHBOARD_BUILD_FAILURE_SLACK_WEBHOOK/password" + + notify-failure: + if: failure() + needs: [prepare-variables, build-push, load-failure-secrets] + permissions: {} + uses: saleor/saleor-internal-actions/.github/workflows/notify-slack.yaml@eb0c692da7bf13f5e1a82c17488b24c514dd10a1 # v1.10.0 + with: + custom_title: "🚨 Docker Image Build Failed for *${{ needs.prepare-variables.outputs.version || github.ref_name }}*" + status: failure + secrets: + slack-webhook-url: ${{ needs.load-failure-secrets.outputs.slack-webhook-url }} + mention_group_id: ${{ secrets.SLACK_DASHBOARD_GROUP_ID }} diff --git a/src/components/Datagrid/customCells/Money/MoneyCell.tsx b/src/components/Datagrid/customCells/Money/MoneyCell.tsx index 36d210832c4..b643ca283fa 100644 --- a/src/components/Datagrid/customCells/Money/MoneyCell.tsx +++ b/src/components/Datagrid/customCells/Money/MoneyCell.tsx @@ -1,4 +1,5 @@ import { Locale } from "@dashboard/components/Locale"; +import { getCurrencyDecimalPoints } from "@dashboard/components/PriceField/utils"; import { CustomCell, CustomRenderer, @@ -6,8 +7,8 @@ import { GridCellKind, ProvideEditorCallback, } from "@glideapps/glide-data-grid"; +import { ChangeEvent, KeyboardEvent, useMemo } from "react"; -import { usePriceField } from "../../../PriceField/usePriceField"; import { hasDiscountValue } from "./utils"; interface MoneyCellProps { @@ -22,25 +23,38 @@ const MoneyCellEdit: ReturnType> = ({ value: cell, onChange: onChangeBase, }) => { - const { onChange, onKeyDown, minValue, step } = usePriceField(cell.data.currency, event => + const maxDecimalPlaces = useMemo( + () => getCurrencyDecimalPoints(cell.data.currency), + [cell.data.currency], + ); + const step = 1 / Math.pow(10, maxDecimalPlaces ?? 2); + + const handleChange = (e: ChangeEvent) => { onChangeBase({ ...cell, data: { ...cell.data, - value: event.target.value, + value: e.target.value ? parseFloat(e.target.value) : null, }, - }), - ); + }); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + // Block exponent notation and negative sign + if (e.key === "e" || e.key === "E" || e.key === "-") { + e.preventDefault(); + } + }; // TODO: range is read only - we don't need support for editing, // it is better to split component into range and editable money cell return ( diff --git a/src/components/PriceField/PriceField.tsx b/src/components/PriceField/PriceField.tsx index e5c3abbab8e..0f9b028235c 100644 --- a/src/components/PriceField/PriceField.tsx +++ b/src/components/PriceField/PriceField.tsx @@ -1,9 +1,12 @@ -// @ts-strict-ignore import { Input, InputProps, Text } from "@saleor/macaw-ui-next"; import { usePriceField } from "./usePriceField"; -export interface PriceFieldProps extends InputProps { +export interface PriceFieldChangeEvent { + target: { name: string; value: string | null }; +} + +export interface PriceFieldProps extends Omit { className?: string; currencySymbol?: string; disabled?: boolean; @@ -12,26 +15,24 @@ export interface PriceFieldProps extends InputProps { label?: string; name?: string; value?: string; - minValue?: string; required?: boolean; - onChange: (event: any) => any; + onChange: (event: PriceFieldChangeEvent) => void; } -const PriceField = (props: PriceFieldProps) => { - const { - className, - disabled, - error, - label, - hint = "", - currencySymbol, - name, - onChange: onChangeBase, - required, - value, - ...inputProps - } = props; - const { onChange, onKeyDown, minValue, step } = usePriceField(currencySymbol, onChangeBase); +export const PriceField = ({ + className, + disabled, + error, + label, + hint = "", + currencySymbol, + name = "price", + onChange: onChangeBase, + required, + value, + ...inputProps +}: PriceFieldProps) => { + const { onChange } = usePriceField(currencySymbol, onChangeBase); return ( { data-test-id="price-field" error={error} helperText={hint} - value={value} - min={props.minValue || minValue} - step={step} + value={value ?? ""} name={name} required={required} onChange={onChange} - onKeyDown={onKeyDown} - type="number" + type="text" + inputMode="decimal" + autoComplete="off" endAdornment={ {currencySymbol || ""} @@ -60,8 +60,4 @@ const PriceField = (props: PriceFieldProps) => { ); }; -PriceField.defaultProps = { - name: "price", -}; PriceField.displayName = "PriceField"; -export default PriceField; diff --git a/src/components/PriceField/consts.ts b/src/components/PriceField/consts.ts deleted file mode 100644 index 14ddd116b73..00000000000 --- a/src/components/PriceField/consts.ts +++ /dev/null @@ -1 +0,0 @@ -export const SEPARATOR_CHARACTERS = [",", "."]; diff --git a/src/components/PriceField/index.ts b/src/components/PriceField/index.ts index 17b4249c583..31ad1dab8cd 100644 --- a/src/components/PriceField/index.ts +++ b/src/components/PriceField/index.ts @@ -1,2 +1,2 @@ -export { default } from "./PriceField"; -export * from "./PriceField"; +export type { PriceFieldChangeEvent, PriceFieldProps } from "./PriceField"; +export { PriceField } from "./PriceField"; diff --git a/src/components/PriceField/usePriceField.ts b/src/components/PriceField/usePriceField.ts index 1ef44212dac..79dd4887db0 100644 --- a/src/components/PriceField/usePriceField.ts +++ b/src/components/PriceField/usePriceField.ts @@ -1,56 +1,30 @@ import { FormChange } from "@dashboard/hooks/useForm"; -import { TextFieldProps } from "@material-ui/core"; import { useMemo } from "react"; -import { SEPARATOR_CHARACTERS } from "./consts"; -import { findPriceSeparator, getCurrencyDecimalPoints } from "./utils"; +import { formatPriceInput, getCurrencyDecimalPoints } from "./utils"; +/** + * Hook for handling price input with currency-aware decimal validation. + * - Filters non-numeric input + * - Limits decimal places based on currency (e.g., 2 for USD, 0 for JPY) + * - Normalizes decimal separator to dot (10,50 → 10.50) + */ export function usePriceField(currency: string | undefined, onChange: FormChange) { - const minValue = 0; - const maxDecimalLength = useMemo(() => getCurrencyDecimalPoints(currency), [currency]); - const handleChange: FormChange = e => { - let value = e.target.value; - const splitCharacter = findPriceSeparator(value); - const [integerPart, decimalPart] = value.split(splitCharacter); - - if ((maxDecimalLength ?? 0) === 0 && decimalPart) { - // This shouldn't happen - decimal character should be ignored - value = integerPart; - } - - if (decimalPart?.length && maxDecimalLength && decimalPart.length > maxDecimalLength) { - const shortenedDecimalPart = decimalPart.slice(0, maxDecimalLength); + const maxDecimalPlaces = useMemo(() => getCurrencyDecimalPoints(currency), [currency]); - value = `${integerPart}${splitCharacter}${shortenedDecimalPart}`; - } + const handleChange: FormChange = e => { + const rawValue = String(e.target.value ?? ""); + const formattedValue = formatPriceInput(rawValue, maxDecimalPlaces); onChange({ target: { name: e.target.name, - value: value ? parseFloat(value) : null, + value: formattedValue || null, }, }); }; - const handleKeyDown: TextFieldProps["onKeyDown"] = e => { - // Disallow entering e (exponent) - if (e.key === "e" || e.key === "E" || e.key === "-") { - e.preventDefault(); - } - - // ignore separator input when currency doesn't support decimal values - if ( - (maxDecimalLength ?? 0) === 0 && - SEPARATOR_CHARACTERS.some(separator => e.key === separator) - ) { - e.preventDefault(); - } - }; - const step = 1 / Math.pow(10, maxDecimalLength ?? 2); return { onChange: handleChange, - onKeyDown: handleKeyDown, - minValue, - step, }; } diff --git a/src/components/PriceField/utils.test.ts b/src/components/PriceField/utils.test.ts index 3d4676938ee..2de019af18f 100644 --- a/src/components/PriceField/utils.test.ts +++ b/src/components/PriceField/utils.test.ts @@ -1,116 +1,38 @@ -import { - getCurrencyDecimalPoints, - limitDecimalPlaces, - normalizeDecimalSeparator, - parseDecimalValue, -} from "./utils"; +import { formatPercentInput, formatPriceInput, getCurrencyDecimalPoints } from "./utils"; -describe("normalizeDecimalSeparator", () => { - it("converts comma to dot", () => { - expect(normalizeDecimalSeparator("10,50")).toBe("10.50"); +describe("formatPercentInput", () => { + it("filters non-numeric characters", () => { + expect(formatPercentInput("abc23.5def")).toBe("23.5"); + expect(formatPercentInput("%8.5")).toBe("8.5"); }); - it("leaves dot unchanged", () => { - expect(normalizeDecimalSeparator("10.50")).toBe("10.50"); + it("normalizes comma to dot", () => { + expect(formatPercentInput("8,5")).toBe("8.5"); }); - it("handles integers without separator", () => { - expect(normalizeDecimalSeparator("100")).toBe("100"); + it("limits to 3 decimal places by default", () => { + expect(formatPercentInput("8.12345")).toBe("8.123"); }); - it("handles empty string", () => { - expect(normalizeDecimalSeparator("")).toBe(""); - }); - - it("handles European format with thousand separator (dot) and decimal comma", () => { - // 1.234,56 = one thousand two hundred thirty-four and 56 cents - expect(normalizeDecimalSeparator("1.234,56")).toBe("1234.56"); - }); - - it("handles US format with thousand separator (comma) and decimal dot", () => { - // 1,234.56 = one thousand two hundred thirty-four and 56 cents - expect(normalizeDecimalSeparator("1,234.56")).toBe("1234.56"); - }); - - it("handles large European format numbers", () => { - expect(normalizeDecimalSeparator("1.234.567,89")).toBe("1234567.89"); - }); - - it("handles large US format numbers", () => { - expect(normalizeDecimalSeparator("1,234,567.89")).toBe("1234567.89"); - }); - - it("handles US thousands-only format without decimal", () => { - // 1,234,567 = one million two hundred thirty-four thousand five hundred sixty-seven - expect(normalizeDecimalSeparator("1,234,567")).toBe("1234567"); - }); - - it("handles European thousands-only format without decimal", () => { - // 1.234.567 = one million two hundred thirty-four thousand five hundred sixty-seven - expect(normalizeDecimalSeparator("1.234.567")).toBe("1234567"); - }); -}); - -describe("parseDecimalValue", () => { - it("parses dot-separated value", () => { - expect(parseDecimalValue("10.50")).toBe(10.5); - }); - - it("parses comma-separated value", () => { - expect(parseDecimalValue("10,50")).toBe(10.5); - }); - - it("parses integer", () => { - expect(parseDecimalValue("100")).toBe(100); + it("accepts custom decimal places", () => { + expect(formatPercentInput("8.12345", 2)).toBe("8.12"); }); - it("returns 0 for empty string", () => { - expect(parseDecimalValue("")).toBe(0); + it("handles integers", () => { + expect(formatPercentInput("23")).toBe("23"); }); - it("returns 0 for invalid input", () => { - expect(parseDecimalValue("abc")).toBe(0); - }); - - it("handles negative values", () => { - expect(parseDecimalValue("-10.50")).toBe(-10.5); - }); -}); - -describe("limitDecimalPlaces", () => { - it("limits decimal places with dot separator", () => { - expect(limitDecimalPlaces("10.12345", 2)).toBe("10.12"); - }); - - it("limits decimal places with comma separator", () => { - expect(limitDecimalPlaces("10,12345", 2)).toBe("10,12"); - }); - - it("preserves original separator when limiting", () => { - expect(limitDecimalPlaces("10,999", 2)).toBe("10,99"); - expect(limitDecimalPlaces("10.999", 2)).toBe("10.99"); - }); - - it("returns integer when maxDecimalPlaces is 0", () => { - expect(limitDecimalPlaces("10.50", 0)).toBe("10"); - expect(limitDecimalPlaces("10,50", 0)).toBe("10"); - }); - - it("returns value unchanged if decimal places are within limit", () => { - expect(limitDecimalPlaces("10.12", 2)).toBe("10.12"); - expect(limitDecimalPlaces("10.1", 2)).toBe("10.1"); - }); - - it("returns value unchanged if no decimal part", () => { - expect(limitDecimalPlaces("100", 2)).toBe("100"); + it("handles empty string", () => { + expect(formatPercentInput("")).toBe(""); }); - it("handles three decimal places for currencies like KWD", () => { - expect(limitDecimalPlaces("10.1234", 3)).toBe("10.123"); + it("preserves trailing decimal while typing", () => { + expect(formatPercentInput("23.")).toBe("23."); }); - it("handles zero decimal places for currencies like JPY", () => { - expect(limitDecimalPlaces("1000.99", 0)).toBe("1000"); + it("handles multiple separators as typing accidents (first wins)", () => { + expect(formatPercentInput("8.5.3")).toBe("8.53"); + expect(formatPercentInput("8,5,3")).toBe("8.53"); }); }); @@ -139,3 +61,118 @@ describe("getCurrencyDecimalPoints", () => { expect(getCurrencyDecimalPoints("INVALID")).toBe(2); }); }); + +describe("formatPriceInput", () => { + describe("basic input", () => { + it("filters non-numeric characters", () => { + expect(formatPriceInput("abc123.45def", 2)).toBe("123.45"); + expect(formatPriceInput("$100.00", 2)).toBe("100.00"); + }); + + it("normalizes comma to dot", () => { + expect(formatPriceInput("10,50", 2)).toBe("10.50"); + }); + + it("limits decimal places", () => { + expect(formatPriceInput("10.12345", 2)).toBe("10.12"); + expect(formatPriceInput("10,12345", 2)).toBe("10.12"); + }); + + it("handles integers without decimal", () => { + expect(formatPriceInput("100", 2)).toBe("100"); + }); + + it("handles empty string", () => { + expect(formatPriceInput("", 2)).toBe(""); + }); + + it("preserves trailing decimal point while typing", () => { + expect(formatPriceInput("10.", 2)).toBe("10."); + }); + + it("adds leading zero for decimal-only values", () => { + expect(formatPriceInput(".5", 2)).toBe("0.5"); + expect(formatPriceInput(",5", 2)).toBe("0.5"); + expect(formatPriceInput(".50", 2)).toBe("0.50"); + }); + }); + + describe("currency-specific decimal places", () => { + it("handles zero decimal places for currencies like JPY", () => { + expect(formatPriceInput("1000.99", 0)).toBe("1000"); + expect(formatPriceInput("1000,99", 0)).toBe("1000"); + }); + + it("handles three decimal places for currencies like KWD", () => { + expect(formatPriceInput("10.12345", 3)).toBe("10.123"); + }); + }); + + describe("typing (same separator type) - first wins", () => { + it("handles multiple dots as typing accidents", () => { + expect(formatPriceInput("10.5.6", 2)).toBe("10.56"); + }); + + it("handles consecutive dots", () => { + expect(formatPriceInput("10..5", 2)).toBe("10.5"); + }); + + it("handles trailing dot after decimal", () => { + expect(formatPriceInput("10.5.", 2)).toBe("10.5"); + }); + + it("handles many same separators as typing accidents", () => { + expect(formatPriceInput("1,2,3,4", 2)).toBe("1.23"); + expect(formatPriceInput("1.2.3.4", 2)).toBe("1.23"); + }); + + it("returns empty for just separators (no digits)", () => { + expect(formatPriceInput(".", 2)).toBe(""); + expect(formatPriceInput(",", 2)).toBe(""); + expect(formatPriceInput("....", 2)).toBe(""); + expect(formatPriceInput(".,.,", 2)).toBe(""); + }); + }); + + describe("paste (mixed separators) - smart detection", () => { + it("handles pasted European format", () => { + expect(formatPriceInput("1.234,56", 2)).toBe("1234.56"); + }); + + it("handles pasted US format", () => { + expect(formatPriceInput("1,234.56", 2)).toBe("1234.56"); + }); + + it("handles large pasted numbers", () => { + expect(formatPriceInput("1,234,567.89", 2)).toBe("1234567.89"); + expect(formatPriceInput("1.234.567,89", 2)).toBe("1234567.89"); + }); + + it("handles mixed separators with smart detection", () => { + expect(formatPriceInput("1,234.5", 2)).toBe("1234.5"); + expect(formatPriceInput("1.234,5", 2)).toBe("1234.5"); + }); + }); + + describe("edge cases", () => { + it("strips negative sign (prices are positive)", () => { + expect(formatPriceInput("-10.50", 2)).toBe("10.50"); + }); + + it("keeps leading zeros", () => { + expect(formatPriceInput("007.50", 2)).toBe("007.50"); + }); + + it("handles malformed mixed format (multiple of both separators)", () => { + // "1.222,333.88" has both multiple dots AND a comma - not a clean format + // Falls back to "first separator wins" (typing mode) + expect(formatPriceInput("1.222,333.88", 2)).toBe("1.22"); + expect(formatPriceInput("1,222.333,88", 2)).toBe("1.22"); + }); + + it("limits integer part to 15 digits (float64 precision)", () => { + expect(formatPriceInput("123456789012345678", 2)).toBe("123456789012345"); + expect(formatPriceInput("12345678901234567.89", 2)).toBe("123456789012345.89"); + }); + }); +}); diff --git a/src/components/PriceField/utils.ts b/src/components/PriceField/utils.ts index ca684d12b40..2179c1cd96e 100644 --- a/src/components/PriceField/utils.ts +++ b/src/components/PriceField/utils.ts @@ -1,106 +1,160 @@ -import { SEPARATOR_CHARACTERS } from "./consts"; - const FALLBACK_MAX_FRACTION_DIGITS = 2; -const resolveDigitsFromCurrencyOrFallback = (currency = "USD"): number => { +/** + * Formats percent input (e.g., tax rates). + * Simpler than price input - no thousand separator handling needed. + * + * @param value - Raw input value + * @param maxDecimalPlaces - Maximum decimal places (default: 3 for tax precision) + * @returns Normalized percent string with dot decimal separator + */ +export const formatPercentInput = (value: string, maxDecimalPlaces = 3): string => { + // Filter to only digits and one decimal separator + const filtered = value.replace(/[^\d.,]/g, ""); + + if (!filtered) { + return ""; + } + + // Normalize comma to dot, keep only first separator + const withDot = filtered.replace(/,/g, "."); + const firstDotIndex = withDot.indexOf("."); + + if (firstDotIndex === -1) { + return withDot; + } + + const integerPart = withDot.slice(0, firstDotIndex); + const decimalPart = withDot.slice(firstDotIndex + 1).replace(/\./g, ""); + + if (!decimalPart) { + return `${integerPart}.`; + } + + return `${integerPart}.${decimalPart.slice(0, maxDecimalPlaces)}`; +}; + +/** + * Gets the number of decimal places for a currency using Intl API. + * Examples: USD → 2, JPY → 0, KWD → 3 + */ +export const getCurrencyDecimalPoints = (currency?: string): number => { try { return ( new Intl.NumberFormat("en-GB", { style: "currency", - currency, + currency: currency || "USD", }).resolvedOptions().maximumFractionDigits ?? FALLBACK_MAX_FRACTION_DIGITS ); - } catch (e) { - try { - // fallback to "USD" if currency wasn't recognised - return ( - new Intl.NumberFormat("en-GB", { - style: "currency", - currency: "USD", - }).resolvedOptions().maximumFractionDigits ?? FALLBACK_MAX_FRACTION_DIGITS - ); - } catch { - // everything is broken - try to return something that makes sense - return FALLBACK_MAX_FRACTION_DIGITS; - } + } catch { + return FALLBACK_MAX_FRACTION_DIGITS; } }; -export const getCurrencyDecimalPoints = (currency?: string) => { - return resolveDigitsFromCurrencyOrFallback(currency); -}; - -export const findPriceSeparator = (input: string) => - SEPARATOR_CHARACTERS.find(separator => input.includes(separator)); +// Max digits to prevent overflow issues with float64 precision +const MAX_INTEGER_DIGITS = 15; /** - * Normalizes decimal separator to JavaScript standard (dot). - * Handles different locale formats: - * - European: "1.234,56" → "1234.56" (comma decimal, dot thousand) - * - US: "1,234.56" → "1234.56" (dot decimal, comma thousand) - * - Simple: "10,50" or "10.50" → "10.50" - * - US thousands only: "1,234,567" → "1234567" - * - European thousands only: "1.234.567" → "1234567" + * Formats price input for controlled input fields. + * + * Handles two scenarios: + * 1. **Typing** (same separator type): First separator wins, rest are accidents + * - "10.5.6" → "10.56" (double-tap accident) + * 2. **Paste** (mixed separators): Smart detection of thousands format + * - "1,234.56" → "1234.56" (US format) + * - "1.234,56" → "1234.56" (EU format) + * + * @param value - Raw input value + * @param maxDecimalPlaces - Maximum decimal places allowed (e.g., 2 for USD, 0 for JPY) + * @returns Normalized price string with dot decimal separator */ -export const normalizeDecimalSeparator = (value: string): string => { - const commaCount = (value.match(/,/g) || []).length; - const dotCount = (value.match(/\./g) || []).length; +export const formatPriceInput = (value: string, maxDecimalPlaces: number): string => { + // Filter to only allow digits and decimal separators + const filtered = value.replace(/[^\d.,]/g, ""); + + if (!filtered) { + return ""; + } + + // Just separators with no digits = empty + if (!/\d/.test(filtered)) { + return ""; + } - if (commaCount > 0 && dotCount > 0) { - // Both separators present - last one is decimal, other is thousand - const lastComma = value.lastIndexOf(","); - const lastDot = value.lastIndexOf("."); + const commaCount = (filtered.match(/,/g) || []).length; + const dotCount = (filtered.match(/\./g) || []).length; + const hasComma = commaCount > 0; + const hasDot = dotCount > 0; + + let integerPart: string; + let decimalPart: string | undefined; + + // Mixed separators = pasted formatted number (smart detection) + // Valid formats have ONE type as decimal (last position) and other type as thousands + // - US: "1,234.56" or "1,234,567.89" (dot is last, commas before) + // - EU: "1.234,56" or "1.234.567,89" (comma is last, dots before) + const lastComma = filtered.lastIndexOf(","); + const lastDot = filtered.lastIndexOf("."); + const lastSeparatorIndex = Math.max(lastComma, lastDot); + const afterLastSeparator = filtered.slice(lastSeparatorIndex + 1); + + // Clean format: after the last separator, only digits (no more separators) + const isCleanMixedFormat = + hasComma && + hasDot && + /^\d*$/.test(afterLastSeparator) && // Only digits after last separator + ((dotCount === 1 && lastDot > lastComma) || // US: one dot at end + (commaCount === 1 && lastComma > lastDot)); // EU: one comma at end + + if (isCleanMixedFormat) { + // Clean mixed format - detect US vs EU by last separator if (lastComma > lastDot) { - // European format: 1.234,56 → remove dots, convert comma to dot - return value.replace(/\./g, "").replace(",", "."); + // EU format: "1.234,56" or "1.234.567,89" → comma is decimal + integerPart = filtered.slice(0, lastComma).replace(/\./g, ""); + decimalPart = filtered.slice(lastComma + 1); } else { - // US format: 1,234.56 → remove commas - return value.replace(/,/g, ""); + // US format: "1,234.56" or "1,234,567.89" → dot is decimal + integerPart = filtered.slice(0, lastDot).replace(/,/g, ""); + decimalPart = filtered.slice(lastDot + 1); + } + } else { + // Same separator type = typing, first separator wins + const withDots = filtered.replace(/,/g, "."); + const firstDotIndex = withDots.indexOf("."); + + if (firstDotIndex === -1) { + integerPart = withDots; + decimalPart = undefined; + } else { + integerPart = withDots.slice(0, firstDotIndex); + // Remove any extra dots from decimal part + decimalPart = withDots.slice(firstDotIndex + 1).replace(/\./g, ""); } } - // Multiple commas = US thousands separators (e.g., "1,234,567") - if (commaCount > 1) { - return value.replace(/,/g, ""); + // Add leading zero for ".50" → "0.50" + if (!integerPart && decimalPart !== undefined) { + integerPart = "0"; } - // Multiple dots = European thousands separators (e.g., "1.234.567") - if (dotCount > 1) { - return value.replace(/\./g, ""); + // Limit integer part to prevent float64 precision issues + if (integerPart.length > MAX_INTEGER_DIGITS) { + integerPart = integerPart.slice(0, MAX_INTEGER_DIGITS); } - // Single comma (European decimal) or single dot (US decimal) or no separator - return value.replace(",", "."); -}; - -/** - * Parses a decimal string value to a number, handling locale-specific separators. - * Returns 0 if the value cannot be parsed. - */ -export const parseDecimalValue = (value: string): number => - parseFloat(normalizeDecimalSeparator(value)) || 0; - -/** - * Limits decimal places in a string value, preserving the user's original separator. - * Useful for input validation while typing. - */ -export const limitDecimalPlaces = (value: string, maxDecimalPlaces: number): string => { - const normalized = normalizeDecimalSeparator(value); - const separator = value.includes(",") ? "," : "."; - const [integerPart, decimalPart] = normalized.split("."); - - if (!decimalPart) { - return value; + // No decimal - return integer + if (decimalPart === undefined) { + return integerPart; } + // Currency doesn't support decimals (e.g., JPY) if (maxDecimalPlaces === 0) { return integerPart; } - if (decimalPart.length > maxDecimalPlaces) { - return `${integerPart}${separator}${decimalPart.slice(0, maxDecimalPlaces)}`; - } + // Truncate and format + const truncated = decimalPart.slice(0, maxDecimalPlaces); - return value; + return `${integerPart}.${truncated}`; }; diff --git a/src/discounts/components/VoucherRequirements/VoucherRequirements.tsx b/src/discounts/components/VoucherRequirements/VoucherRequirements.tsx index 13ce96f7de2..8a6a74cb022 100644 --- a/src/discounts/components/VoucherRequirements/VoucherRequirements.tsx +++ b/src/discounts/components/VoucherRequirements/VoucherRequirements.tsx @@ -2,7 +2,7 @@ import { DashboardCard } from "@dashboard/components/Card"; import { FormSpacer } from "@dashboard/components/FormSpacer"; import { Placeholder } from "@dashboard/components/Placeholder"; -import PriceField from "@dashboard/components/PriceField"; +import { PriceField } from "@dashboard/components/PriceField"; import RadioGroupField from "@dashboard/components/RadioGroupField"; import { ResponsiveTable } from "@dashboard/components/ResponsiveTable"; import TableHead from "@dashboard/components/TableHead"; diff --git a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx index ab4a47abd70..63a854d7f54 100644 --- a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx +++ b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx @@ -6,11 +6,7 @@ import { DashboardModal } from "@dashboard/components/Modal"; import { ModalSectionHeader } from "@dashboard/components/Modal/ModalSectionHeader"; import Money from "@dashboard/components/Money"; import { Pill } from "@dashboard/components/Pill"; -import { - getCurrencyDecimalPoints, - limitDecimalPlaces, - parseDecimalValue, -} from "@dashboard/components/PriceField/utils"; +import { formatPriceInput, getCurrencyDecimalPoints } from "@dashboard/components/PriceField/utils"; import { OrderErrorFragment, TransactionRequestActionErrorFragment } from "@dashboard/graphql"; import getOrderErrorMessage from "@dashboard/utils/errors/order"; import { getOrderTransactionErrorMessage } from "@dashboard/utils/errors/transaction"; @@ -119,10 +115,10 @@ export const OrderCaptureDialog = ({ const maxDecimalPlaces = useMemo(() => getCurrencyDecimalPoints(currency), [currency]); const handleCustomAmountChange = (e: ChangeEvent): void => { - const limitedValue = limitDecimalPlaces(e.target.value, maxDecimalPlaces); + const formattedValue = formatPriceInput(e.target.value, maxDecimalPlaces); - setCustomAmountInput(limitedValue); - setCustomAmount(parseDecimalValue(limitedValue)); + setCustomAmountInput(formattedValue); + setCustomAmount(parseFloat(formattedValue) || 0); }; const getSelectedAmount = (): number => { diff --git a/src/orders/components/OrderDiscountCommonModal/OrderDiscountCommonModal.tsx b/src/orders/components/OrderDiscountCommonModal/OrderDiscountCommonModal.tsx index dc81ea0c8e0..7cf1a1734bd 100644 --- a/src/orders/components/OrderDiscountCommonModal/OrderDiscountCommonModal.tsx +++ b/src/orders/components/OrderDiscountCommonModal/OrderDiscountCommonModal.tsx @@ -1,7 +1,7 @@ import { DashboardCard } from "@dashboard/components/Card"; import CardSpacer from "@dashboard/components/CardSpacer"; import { ConfirmButton, ConfirmButtonTransitionState } from "@dashboard/components/ConfirmButton"; -import PriceField from "@dashboard/components/PriceField"; +import { PriceField, PriceFieldChangeEvent } from "@dashboard/components/PriceField"; import { NewRadioGroupField as RadioGroupField } from "@dashboard/components/RadioGroupField"; import { DiscountValueTypeEnum, MoneyFragment } from "@dashboard/graphql"; import { useUpdateEffect } from "@dashboard/hooks/useUpdateEffect"; @@ -10,7 +10,6 @@ import { toFixed } from "@dashboard/utils/toFixed"; import { Button, Input, Text } from "@saleor/macaw-ui-next"; import { X } from "lucide-react"; import { ChangeEvent, useEffect, useRef, useState } from "react"; -import * as React from "react"; import { defineMessages, useIntl } from "react-intl"; import { ORDER_LINE_DISCOUNT, OrderDiscountCommonInput, OrderDiscountType } from "./types"; @@ -135,8 +134,8 @@ const OrderDiscountCommonModal = ({ }, ]; const isDiscountTypePercentage = calculationMode === DiscountValueTypeEnum.PERCENTAGE; - const handleSetDiscountValue = (event: React.ChangeEvent) => { - const value = event.target.value; + const handleSetDiscountValue = (event: PriceFieldChangeEvent) => { + const value = event.target.value ?? ""; setValueErrorMsg(getErrorMessage(value)); setValue(value); diff --git a/src/orders/components/OrderGrantRefundPage/OrderGrantRefundPage.tsx b/src/orders/components/OrderGrantRefundPage/OrderGrantRefundPage.tsx index 6e5966e0c4d..7be68626384 100644 --- a/src/orders/components/OrderGrantRefundPage/OrderGrantRefundPage.tsx +++ b/src/orders/components/OrderGrantRefundPage/OrderGrantRefundPage.tsx @@ -3,7 +3,7 @@ import { DashboardCard } from "@dashboard/components/Card"; import { ConfirmButtonTransitionState } from "@dashboard/components/ConfirmButton"; import { DetailPageLayout } from "@dashboard/components/Layouts"; import { formatMoneyAmount } from "@dashboard/components/Money"; -import PriceField from "@dashboard/components/PriceField"; +import { PriceField } from "@dashboard/components/PriceField"; import { Savebar } from "@dashboard/components/Savebar"; import { OrderDetailsGrantedRefundFragment, diff --git a/src/orders/components/OrderManualTransactionForm/components/PriceInputField.tsx b/src/orders/components/OrderManualTransactionForm/components/PriceInputField.tsx index 4bf10ea94a2..c63743465b9 100644 --- a/src/orders/components/OrderManualTransactionForm/components/PriceInputField.tsx +++ b/src/orders/components/OrderManualTransactionForm/components/PriceInputField.tsx @@ -1,5 +1,5 @@ // @ts-strict-ignore -import PriceField, { PriceFieldProps } from "@dashboard/components/PriceField"; +import { PriceField, PriceFieldProps } from "@dashboard/components/PriceField"; import { useManualTransactionContext } from "../context"; diff --git a/src/orders/components/OrderManualTransactionForm/hooks.ts b/src/orders/components/OrderManualTransactionForm/hooks.ts index 346f0ab5b1d..1b8eb9f293b 100644 --- a/src/orders/components/OrderManualTransactionForm/hooks.ts +++ b/src/orders/components/OrderManualTransactionForm/hooks.ts @@ -1,4 +1,5 @@ import { ConfirmButtonTransitionState } from "@dashboard/components/ConfirmButton"; +import { PriceFieldChangeEvent } from "@dashboard/components/PriceField"; import * as React from "react"; interface ManualRefundHookProps { @@ -31,8 +32,8 @@ export const useManualRefund = ({ submitState, initialData }: ManualRefundHookPr const handleChangeDescription: React.ChangeEventHandler = e => { setDescription(e.target.value); }; - const handleChangeAmount: React.ChangeEventHandler = e => { - const value = parseFloat(e.target.value); + const handleChangeAmount = (e: PriceFieldChangeEvent) => { + const value = parseFloat(e.target.value ?? ""); if (!Number.isNaN(value)) { setAmount(value); diff --git a/src/orders/components/OrderReturnPage/components/PaymentSubmitCard/RefundAmountInput.tsx b/src/orders/components/OrderReturnPage/components/PaymentSubmitCard/RefundAmountInput.tsx index 031872dc780..fe0b0c663d3 100644 --- a/src/orders/components/OrderReturnPage/components/PaymentSubmitCard/RefundAmountInput.tsx +++ b/src/orders/components/OrderReturnPage/components/PaymentSubmitCard/RefundAmountInput.tsx @@ -1,5 +1,5 @@ // @ts-strict-ignore -import PriceField from "@dashboard/components/PriceField"; +import { PriceField } from "@dashboard/components/PriceField"; import { OrderErrorFragment } from "@dashboard/graphql"; import { getFormErrors } from "@dashboard/utils/errors"; import getOrderErrorMessage from "@dashboard/utils/errors/order"; diff --git a/src/orders/components/OrderReturnPage/components/TransactionSubmitCard/TransactionSubmitCard.tsx b/src/orders/components/OrderReturnPage/components/TransactionSubmitCard/TransactionSubmitCard.tsx index 0036faff418..a3df1db73f6 100644 --- a/src/orders/components/OrderReturnPage/components/TransactionSubmitCard/TransactionSubmitCard.tsx +++ b/src/orders/components/OrderReturnPage/components/TransactionSubmitCard/TransactionSubmitCard.tsx @@ -1,7 +1,7 @@ import { DashboardCard } from "@dashboard/components/Card"; import { ConfirmButton, ConfirmButtonTransitionState } from "@dashboard/components/ConfirmButton"; import { iconSize, iconStrokeWidth } from "@dashboard/components/icons"; -import PriceField from "@dashboard/components/PriceField"; +import { PriceField } from "@dashboard/components/PriceField"; import { OrderDetailsFragment, OrderGrantRefundCreateErrorFragment, @@ -109,7 +109,7 @@ export const TransactionSubmitCard = ({ /> onAmountChange(e.target.value)} + onChange={e => onAmountChange(parseFloat(e.target.value ?? "") || 0)} name="amount" value={getReturnRefundValue({ autoGrantRefund, diff --git a/src/orders/components/OrderSendRefundPage/components/TransactionCard.tsx b/src/orders/components/OrderSendRefundPage/components/TransactionCard.tsx index 30973037311..df2935e6e43 100644 --- a/src/orders/components/OrderSendRefundPage/components/TransactionCard.tsx +++ b/src/orders/components/OrderSendRefundPage/components/TransactionCard.tsx @@ -1,6 +1,6 @@ // @ts-strict-ignore import { ConfirmButton } from "@dashboard/components/ConfirmButton"; -import PriceField from "@dashboard/components/PriceField"; +import { PriceField } from "@dashboard/components/PriceField"; import { OrderDetailsFragment, TransactionActionEnum, diff --git a/src/products/components/ProductVariantPrice/ProductVariantPrice.tsx b/src/products/components/ProductVariantPrice/ProductVariantPrice.tsx index 00f4428cd71..56ebe1c8255 100644 --- a/src/products/components/ProductVariantPrice/ProductVariantPrice.tsx +++ b/src/products/components/ProductVariantPrice/ProductVariantPrice.tsx @@ -5,7 +5,7 @@ import { ChannelPriceArgs, } from "@dashboard/channels/utils"; import { DashboardCard } from "@dashboard/components/Card"; -import PriceField from "@dashboard/components/PriceField"; +import { PriceField } from "@dashboard/components/PriceField"; import { ResponsiveTable } from "@dashboard/components/ResponsiveTable"; import TableRowLink from "@dashboard/components/TableRowLink"; import { ProductChannelListingErrorFragment, ProductErrorFragment } from "@dashboard/graphql"; diff --git a/src/shipping/components/OrderValue/OrderValue.tsx b/src/shipping/components/OrderValue/OrderValue.tsx index a9c9d238114..53d071224b4 100644 --- a/src/shipping/components/OrderValue/OrderValue.tsx +++ b/src/shipping/components/OrderValue/OrderValue.tsx @@ -1,7 +1,7 @@ import { ChannelShippingData } from "@dashboard/channels/utils"; import { DashboardCard } from "@dashboard/components/Card"; import ControlledCheckbox from "@dashboard/components/ControlledCheckbox"; -import PriceField from "@dashboard/components/PriceField"; +import { PriceField } from "@dashboard/components/PriceField"; import { ResponsiveTable } from "@dashboard/components/ResponsiveTable"; import TableHead from "@dashboard/components/TableHead"; import TableRowLink from "@dashboard/components/TableRowLink"; @@ -137,7 +137,7 @@ const OrderValue = ({ onChange={e => onChannelsChange(channel.id, { ...channel, - minValue: e.target.value, + minValue: e.target.value ?? "", }) } currencySymbol={channel.currency} @@ -156,11 +156,10 @@ const OrderValue = ({ })} name={`maxValue:${channel.name}`} value={channel.maxValue} - minValue={channel.minValue} onChange={e => onChannelsChange(channel.id, { ...channel, - maxValue: e.target.value, + maxValue: e.target.value ?? "", }) } currencySymbol={channel.currency} diff --git a/src/shipping/components/PricingCard/PricingCard.tsx b/src/shipping/components/PricingCard/PricingCard.tsx index e5bcb478144..222b56d3105 100644 --- a/src/shipping/components/PricingCard/PricingCard.tsx +++ b/src/shipping/components/PricingCard/PricingCard.tsx @@ -1,7 +1,7 @@ // @ts-strict-ignore import { ChannelShippingData } from "@dashboard/channels/utils"; import { DashboardCard } from "@dashboard/components/Card"; -import PriceField from "@dashboard/components/PriceField"; +import { PriceField } from "@dashboard/components/PriceField"; import { ResponsiveTable } from "@dashboard/components/ResponsiveTable"; import TableHead from "@dashboard/components/TableHead"; import TableRowLink from "@dashboard/components/TableRowLink"; diff --git a/src/taxes/components/TaxInput/TaxInput.tsx b/src/taxes/components/TaxInput/TaxInput.tsx index 6cbbd50892b..1f56cc7aa7d 100644 --- a/src/taxes/components/TaxInput/TaxInput.tsx +++ b/src/taxes/components/TaxInput/TaxInput.tsx @@ -1,6 +1,6 @@ -import { findPriceSeparator } from "@dashboard/components/PriceField/utils"; +import { formatPercentInput } from "@dashboard/components/PriceField/utils"; import { FormChange } from "@dashboard/hooks/useForm"; -import { InputAdornment, TextField, TextFieldProps } from "@material-ui/core"; +import { InputAdornment, TextField } from "@material-ui/core"; import { useStyles } from "./styles"; @@ -12,55 +12,36 @@ interface TaxInputProps { const TaxInput = ({ placeholder, value, change }: TaxInputProps) => { const classes = useStyles(); - const handleChange: FormChange = e => { - let value = e.target.value; - const splitCharacter = findPriceSeparator(value); - const [integerPart, decimalPart] = value.split(splitCharacter); - - if (decimalPart?.length > 3) { - const shortenedDecimalPart = decimalPart.slice(0, 3); - value = `${integerPart}${splitCharacter}${shortenedDecimalPart}`; - } + const handleChange: FormChange = e => { + const formatted = formatPercentInput(String(e.target.value ?? "")); change({ target: { name: e.target.name, - value, + value: formatted || null, }, }); }; - const handleKeyDown: TextFieldProps["onKeyDown"] = event => { - switch (event.key.toLowerCase()) { - case "e": - case "-": { - event.preventDefault(); - break; - } - } - }; return ( %, - className: classes.hideSpinboxes, }} inputProps={{ className: classes.inputPadding, - min: 0, - max: 100, - step: 0.001, }} onChange={handleChange} - onKeyDown={handleKeyDown} /> ); }; -export default TaxInput; +export { TaxInput }; diff --git a/src/taxes/components/TaxInput/index.ts b/src/taxes/components/TaxInput/index.ts index cbe259f57da..2f93cc3abc1 100644 --- a/src/taxes/components/TaxInput/index.ts +++ b/src/taxes/components/TaxInput/index.ts @@ -1,2 +1 @@ -export { default } from "./TaxInput"; -export * from "./TaxInput"; +export { TaxInput } from "./TaxInput"; diff --git a/src/taxes/components/TaxInput/styles.ts b/src/taxes/components/TaxInput/styles.ts index 3053a87d530..b76ae0a0531 100644 --- a/src/taxes/components/TaxInput/styles.ts +++ b/src/taxes/components/TaxInput/styles.ts @@ -2,22 +2,6 @@ import { makeStyles } from "@saleor/macaw-ui"; export const useStyles = makeStyles( () => ({ - /** - * Spinboxes are up & down arrows that are used to change the value of a number - * in html input elements with type=number. There is a different styling for - * hiding them, dependent on browser (as of mid-2022). - */ - hideSpinboxes: { - // chrome, safari - "& input::-webkit-outer-spin-button, input::-webkit-inner-spin-button": { - appearance: "none", - margin: 0, - }, - // firefox - "& input": { - "-moz-appearance": "textfield", - }, - }, inputPadding: { padding: "16px 0 16px 0", }, diff --git a/src/taxes/pages/TaxClassesPage/TaxClassesPage.tsx b/src/taxes/pages/TaxClassesPage/TaxClassesPage.tsx index 2e3f08a5abf..2a8d4fc5c82 100644 --- a/src/taxes/pages/TaxClassesPage/TaxClassesPage.tsx +++ b/src/taxes/pages/TaxClassesPage/TaxClassesPage.tsx @@ -28,7 +28,7 @@ import { Box } from "@saleor/macaw-ui-next"; import { useEffect, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import TaxInput from "../../components/TaxInput"; +import { TaxInput } from "../../components/TaxInput"; import TaxClassesForm from "./form"; import { useStyles } from "./styles"; import { TaxClassesMenu } from "./TaxClassesMenu"; diff --git a/src/taxes/pages/TaxCountriesPage/TaxCountriesPage.tsx b/src/taxes/pages/TaxCountriesPage/TaxCountriesPage.tsx index 3e3e8992ee4..166aff2682e 100644 --- a/src/taxes/pages/TaxCountriesPage/TaxCountriesPage.tsx +++ b/src/taxes/pages/TaxCountriesPage/TaxCountriesPage.tsx @@ -25,7 +25,7 @@ import { Box } from "@saleor/macaw-ui-next"; import { useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import TaxInput from "../../components/TaxInput"; +import { TaxInput } from "../../components/TaxInput"; import TaxCountriesForm from "./form"; import { TaxCountriesMenu } from "./TaxCountriesMenu";