Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 59 additions & 4 deletions .github/workflows/publish-containers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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 }}
30 changes: 22 additions & 8 deletions src/components/Datagrid/customCells/Money/MoneyCell.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Locale } from "@dashboard/components/Locale";
import { getCurrencyDecimalPoints } from "@dashboard/components/PriceField/utils";
import {
CustomCell,
CustomRenderer,
getMiddleCenterBias,
GridCellKind,
ProvideEditorCallback,
} from "@glideapps/glide-data-grid";
import { ChangeEvent, KeyboardEvent, useMemo } from "react";

import { usePriceField } from "../../../PriceField/usePriceField";
import { hasDiscountValue } from "./utils";

interface MoneyCellProps {
Expand All @@ -22,25 +23,38 @@ const MoneyCellEdit: ReturnType<ProvideEditorCallback<MoneyCell>> = ({
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<HTMLInputElement>) => {
onChangeBase({
...cell,
data: {
...cell.data,
value: event.target.value,
value: e.target.value ? parseFloat(e.target.value) : null,
},
Comment on lines +26 to 38
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous implementation relied on usePriceField to enforce currency-specific decimal precision (including blocking decimal input for zero-decimal currencies like JPY) and to normalize pasted values with locale-specific separators. The new MoneyCellEdit now only uses step and a basic parseFloat, so maxDecimalPlaces is not actually used to constrain the input and users can enter more fractional digits than the currency allows (and even fractions for zero-decimal currencies), which is inconsistent with PriceField and may lead to invalid values being submitted. Consider either reusing formatPriceInput/usePriceField here or applying similar decimal-length trimming inside handleChange so that the datagrid editor respects maxDecimalPlaces in the same way as other price inputs.

Copilot uses AI. Check for mistakes.
}),
);
});
};

const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
// 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 (
<input
type="number"
onChange={onChange}
onKeyDown={onKeyDown}
onChange={handleChange}
onKeyDown={handleKeyDown}
value={Array.isArray(cell.data.value) ? "" : (cell.data.value ?? "")}
min={minValue}
min={0}
step={step}
autoFocus
/>
Expand Down
52 changes: 24 additions & 28 deletions src/components/PriceField/PriceField.tsx
Original file line number Diff line number Diff line change
@@ -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<InputProps, "onChange"> {
className?: string;
currencySymbol?: string;
disabled?: boolean;
Expand All @@ -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 (
<Input
Expand All @@ -42,14 +43,13 @@ const PriceField = (props: PriceFieldProps) => {
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={
<Text size={2} marginRight={2}>
{currencySymbol || ""}
Expand All @@ -60,8 +60,4 @@ const PriceField = (props: PriceFieldProps) => {
);
};

PriceField.defaultProps = {
name: "price",
};
PriceField.displayName = "PriceField";
export default PriceField;
1 change: 0 additions & 1 deletion src/components/PriceField/consts.ts

This file was deleted.

4 changes: 2 additions & 2 deletions src/components/PriceField/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { default } from "./PriceField";
export * from "./PriceField";
export type { PriceFieldChangeEvent, PriceFieldProps } from "./PriceField";
export { PriceField } from "./PriceField";
50 changes: 12 additions & 38 deletions src/components/PriceField/usePriceField.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading