diff --git a/.changeset/selfish-coats-pull.md b/.changeset/selfish-coats-pull.md new file mode 100644 index 0000000000..1e695b7607 --- /dev/null +++ b/.changeset/selfish-coats-pull.md @@ -0,0 +1,5 @@ +--- +"@medusajs/dashboard": patch +--- + +fix(dashboard): Add FE validation for conditional prices diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index 9e3667c732..2579af1ee8 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -2978,15 +2978,11 @@ "type": "string" } }, - "required": [ - "stockedQuantity" - ], + "required": ["stockedQuantity"], "additionalProperties": false } }, - "required": [ - "errors" - ], + "required": ["errors"], "additionalProperties": false }, "toast": { @@ -3792,10 +3788,7 @@ "type": "string" } }, - "required": [ - "title", - "requestSuccess" - ], + "required": ["title", "requestSuccess"], "additionalProperties": false }, "shippingAddress": { @@ -3808,10 +3801,7 @@ "type": "string" } }, - "required": [ - "title", - "requestSuccess" - ], + "required": ["title", "requestSuccess"], "additionalProperties": false }, "billingAddress": { @@ -3824,18 +3814,11 @@ "type": "string" } }, - "required": [ - "title", - "requestSuccess" - ], + "required": ["title", "requestSuccess"], "additionalProperties": false } }, - "required": [ - "email", - "shippingAddress", - "billingAddress" - ], + "required": ["email", "shippingAddress", "billingAddress"], "additionalProperties": false }, "returns": { @@ -4987,11 +4970,7 @@ "type": "string" } }, - "required": [ - "shipping_address", - "billing_address", - "email" - ], + "required": ["shipping_address", "billing_address", "email"], "additionalProperties": false } }, @@ -5560,9 +5539,7 @@ "type": "string" } }, - "required": [ - "cartItemTotal" - ], + "required": ["cartItemTotal"], "additionalProperties": false }, "summaries": { @@ -5578,11 +5555,7 @@ "type": "string" } }, - "required": [ - "range", - "greaterThan", - "lessThan" - ], + "required": ["range", "greaterThan", "lessThan"], "additionalProperties": false }, "actions": { @@ -5595,10 +5568,7 @@ "type": "string" } }, - "required": [ - "addPrice", - "manageConditionalPrices" - ], + "required": ["addPrice", "manageConditionalPrices"], "additionalProperties": false }, "rules": { @@ -5614,11 +5584,7 @@ "type": "string" } }, - "required": [ - "amount", - "gte", - "lte" - ], + "required": ["amount", "gte", "lte"], "additionalProperties": false }, "customRules": { @@ -5640,13 +5606,7 @@ "type": "string" } }, - "required": [ - "label", - "tooltip", - "eq", - "gt", - "lt" - ], + "required": ["label", "tooltip", "eq", "gt", "lt"], "additionalProperties": false }, "errors": { @@ -5660,12 +5620,20 @@ }, "minGreaterThanMax": { "type": "string" + }, + "duplicateAmount": { + "type": "string" + }, + "overlappingConditions": { + "type": "string" } }, "required": [ "amountRequired", "minOrMaxRequired", - "minGreaterThanMax" + "minGreaterThanMax", + "duplicateAmount", + "overlappingConditions" ], "additionalProperties": false } @@ -8842,16 +8810,6 @@ "deleteSalesChannelWarning": { "type": "string" }, - "tooltip": { - "type": "object", - "properties": { - "cannotDeleteDefault": { - "type": "string" - } - }, - "required": ["cannotDeleteDefault"], - "additionalProperties": false - }, "toast": { "type": "object", "properties": { @@ -8868,6 +8826,16 @@ "required": ["create", "update", "delete"], "additionalProperties": false }, + "tooltip": { + "type": "object", + "properties": { + "cannotDeleteDefault": { + "type": "string" + } + }, + "required": ["cannotDeleteDefault"], + "additionalProperties": false + }, "products": { "type": "object", "properties": { @@ -8916,6 +8884,7 @@ "productAlreadyAdded", "deleteSalesChannelWarning", "toast", + "tooltip", "products" ], "additionalProperties": false diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index 056492c9f1..d1889efe55 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -1493,7 +1493,9 @@ "errors": { "amountRequired": "Shipping option price is required", "minOrMaxRequired": "At least one of minimum or maximum cart item total must be provided", - "minGreaterThanMax": "Minimum cart item total must be less than or equal to maximum cart item total" + "minGreaterThanMax": "Minimum cart item total must be less than or equal to maximum cart item total", + "duplicateAmount": "Shipping option price must be unique for each condition", + "overlappingConditions": "Conditions must be unique across all price rules" } }, "fields": { diff --git a/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/conditional-price-form.tsx b/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/conditional-price-form.tsx index d58764defe..d79f0ae95a 100644 --- a/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/conditional-price-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/common/components/conditional-price-form/conditional-price-form.tsx @@ -9,6 +9,7 @@ import { import { Badge, Button, + clx, CurrencyInput, Heading, IconButton, @@ -110,14 +111,28 @@ export const ConditionalPriceForm = ({ remove(index) } - const handleOnSubmit = conditionalPriceForm.handleSubmit((values) => { - setFormValue(name, values.prices, { - shouldDirty: true, - shouldValidate: true, - shouldTouch: true, - }) - onCloseConditionalPricesModal() - }) + const handleOnSubmit = conditionalPriceForm.handleSubmit( + (values) => { + setFormValue(name, values.prices, { + shouldDirty: true, + shouldValidate: true, + shouldTouch: true, + }) + onCloseConditionalPricesModal() + }, + (e) => { + const indexesWithErrors = Object.keys(e.prices || {}) + setValue((prev) => { + const values = new Set(prev) + + indexesWithErrors.forEach((index) => { + values.add(getRuleValue(Number(index))) + }) + + return Array.from(values) + }) + } + ) // Intercept the Cmd + Enter key to only save the inner form. const handleOnKeyDown = (event: React.KeyboardEvent) => { @@ -255,7 +270,9 @@ const ConditionalPriceItem = ({ return (
diff --git a/packages/admin/dashboard/src/routes/locations/common/schema.ts b/packages/admin/dashboard/src/routes/locations/common/schema.ts index 3548d75774..1c2f97fe63 100644 --- a/packages/admin/dashboard/src/routes/locations/common/schema.ts +++ b/packages/admin/dashboard/src/routes/locations/common/schema.ts @@ -73,18 +73,120 @@ export type UpdateConditionalPrice = z.infer< typeof UpdateConditionalPriceSchema > -export const CondtionalPriceRuleSchema = z.object({ - prices: z.array(ConditionalPriceSchema), -}) +function refineDuplicates( + data: { + prices: { + amount: string | number + gte?: string | number | null | undefined + lte?: string | number | null | undefined + lt?: number | null | undefined + gt?: number | null | undefined + eq?: number | null | undefined + }[] + }, + ctx: z.RefinementCtx +) { + const prices = data.prices + + for (let i = 0; i < prices.length; i++) { + for (let j = i + 1; j < prices.length; j++) { + const price1 = prices[i] + const price2 = prices[j] + + if (price1.amount === "" || price2.amount === "") { + continue + } + + const price1Amount = castNumber(price1.amount) + const price2Amount = castNumber(price2.amount) + + if (price1Amount === price2Amount) { + addDuplicateAmountError(ctx, j) + } + + // Then check conditions separately + const conditions = [ + { value: price1.gte, type: "gte" }, + { value: price1.lte, type: "lte" }, + { value: price1.eq, type: "eq" }, + { value: price1.lt, type: "lt" }, + { value: price1.gt, type: "gt" }, + ] as const + + conditions.forEach((condition1) => { + if (!condition1.value && condition1.value !== 0) { + return + } + + const conditions2 = [ + { value: price2.gte, type: "gte" }, + { value: price2.lte, type: "lte" }, + { value: price2.eq, type: "eq" }, + { value: price2.lt, type: "lt" }, + { value: price2.gt, type: "gt" }, + ] as const + + conditions2.forEach((condition2) => { + if (!condition2.value && condition2.value !== 0) { + return + } + + const condition1Value = castNumber( + condition1.value as string | number + ) + const condition2Value = castNumber( + condition2.value as string | number + ) + + if (condition1Value === condition2Value) { + addOverlappingConditionError(ctx, j, condition2.type) + } + }) + }) + } + } +} + +export const CondtionalPriceRuleSchema = z + .object({ + prices: z.array(ConditionalPriceSchema), + }) + .superRefine(refineDuplicates) export type CondtionalPriceRuleSchemaType = z.infer< typeof CondtionalPriceRuleSchema > -export const UpdateConditionalPriceRuleSchema = z.object({ - prices: z.array(UpdateConditionalPriceSchema), -}) +export const UpdateConditionalPriceRuleSchema = z + .object({ + prices: z.array(UpdateConditionalPriceSchema), + }) + .superRefine(refineDuplicates) export type UpdateConditionalPriceRuleSchemaType = z.infer< typeof UpdateConditionalPriceRuleSchema > + +const addDuplicateAmountError = (ctx: z.RefinementCtx, index: number) => { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t( + "stockLocations.shippingOptions.conditionalPrices.errors.duplicateAmount" + ), + path: ["prices", index, "amount"], + }) +} + +const addOverlappingConditionError = ( + ctx: z.RefinementCtx, + index: number, + type: "gte" | "lte" | "eq" | "lt" | "gt" +) => { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t( + "stockLocations.shippingOptions.conditionalPrices.errors.overlappingConditions" + ), + path: ["prices", index, type], + }) +}