From 0d04c548f5a00f664b0808768bf9eb356f1cad86 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Tue, 18 Jun 2024 18:47:42 +0200 Subject: [PATCH] feat(dashboard,types,promotion,medusa): hide fields on promotions depending on templates (#7746) **what:** - hides different fields depending on the chosen template - remove operator values API - fixes to edit promotion rules - make currency optional for promotion RESOLVES CORE-2297 --- .../dashboard/src/hooks/api/promotions.tsx | 22 - .../dashboard/src/i18n/translations/en.json | 6 +- .../dashboard/src/lib/client/promotions.ts | 8 - .../edit-rules-form/edit-rules-form.tsx | 34 +- .../components/edit-rules-form/form-schema.ts | 29 ++ .../components/edit-rules-form/utils.ts | 17 + .../edit-rules-wrapper/edit-rules-wrapper.tsx | 14 +- .../components/edit-rules-wrapper/utils.ts | 4 - .../rules-form-field/rules-form-field.tsx | 96 ++-- .../create-promotion-form.tsx | 459 ++++++++++-------- .../create-promotion-form/form-schema.ts | 4 +- .../create-promotion-form/templates.ts | 13 +- .../promotion-conditions-section.tsx | 2 +- .../promotion/admin/rule-attribute-options.ts | 5 + .../promotion/common/application-method.ts | 2 +- .../promotions/[id]/[rule_type]/route.ts | 60 +-- .../[rule_type]/route.ts | 5 +- .../promotions/rule-operator-options/route.ts | 14 - .../[rule_type]/[rule_attribute_id]/route.ts | 15 +- .../promotions/utils/rule-attributes-map.ts | 52 +- .../utils/validate-rule-attribute.ts | 19 +- .../src/api/admin/promotions/validators.ts | 6 +- .../src/migrations/Migration20240617102917.ts | 22 + .../promotion/src/types/application-method.ts | 4 +- 24 files changed, 523 insertions(+), 389 deletions(-) create mode 100644 packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-form/form-schema.ts create mode 100644 packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-form/utils.ts delete mode 100644 packages/medusa/src/api/admin/promotions/rule-operator-options/route.ts create mode 100644 packages/modules/promotion/src/migrations/Migration20240617102917.ts diff --git a/packages/admin-next/dashboard/src/hooks/api/promotions.tsx b/packages/admin-next/dashboard/src/hooks/api/promotions.tsx index 88710649d7..e234d0083b 100644 --- a/packages/admin-next/dashboard/src/hooks/api/promotions.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/promotions.tsx @@ -2,7 +2,6 @@ import { AdminGetPromotionsParams } from "@medusajs/medusa" import { AdminPromotionRuleListResponse, AdminRuleAttributeOptionsListResponse, - AdminRuleOperatorOptionsListResponse, AdminRuleValueOptionsListResponse, } from "@medusajs/types" import { @@ -49,7 +48,6 @@ export const promotionsQueryKeys = { ruleValue, query, ], - listRuleOperators: () => [PROMOTIONS_QUERY_KEY], } export const usePromotion = ( @@ -107,26 +105,6 @@ export const usePromotions = ( return { ...data, ...rest } } -export const usePromotionRuleOperators = ( - options?: Omit< - UseQueryOptions< - AdminRuleOperatorOptionsListResponse, - Error, - AdminRuleOperatorOptionsListResponse, - QueryKey - >, - "queryFn" | "queryKey" - > -) => { - const { data, ...rest } = useQuery({ - queryKey: promotionsQueryKeys.listRuleOperators(), - queryFn: async () => client.promotions.listRuleOperators(), - ...options, - }) - - return { ...data, ...rest } -} - export const usePromotionRuleAttributes = ( ruleType: string, promotionType?: string, diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json index a7839c1501..0852a13dc6 100644 --- a/packages/admin-next/dashboard/src/i18n/translations/en.json +++ b/packages/admin-next/dashboard/src/i18n/translations/en.json @@ -1151,15 +1151,15 @@ "description": "The code your customers will enter during checkout." }, "value": { - "title": "Value" + "title": "Promotion Value" }, "value_type": { "fixed": { - "title": "Fixed amount", + "title": "Promotion Value", "description": "eg. 100" }, "percentage": { - "title": "Percentage", + "title": "Promotion Value", "description": "eg. 8%" } } diff --git a/packages/admin-next/dashboard/src/lib/client/promotions.ts b/packages/admin-next/dashboard/src/lib/client/promotions.ts index deac50b069..e422b46eea 100644 --- a/packages/admin-next/dashboard/src/lib/client/promotions.ts +++ b/packages/admin-next/dashboard/src/lib/client/promotions.ts @@ -15,7 +15,6 @@ import { PromotionListRes, PromotionRes, PromotionRuleAttributesListRes, - PromotionRuleOperatorsListRes, } from "../../types/api-responses" import { deleteRequest, getRequest, postRequest } from "./common" @@ -112,12 +111,6 @@ async function listPromotionRuleValues( ) } -async function listPromotionRuleOperators() { - return getRequest( - `/admin/promotions/rule-operator-options` - ) -} - export const promotions = { retrieve: retrievePromotion, list: listPromotions, @@ -129,6 +122,5 @@ export const promotions = { updateRules: updatePromotionRules, listRules: listPromotionRules, listRuleAttributes: listPromotionRuleAttributes, - listRuleOperators: listPromotionRuleOperators, listRuleValues: listPromotionRuleValues, } diff --git a/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-form/edit-rules-form.tsx b/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-form/edit-rules-form.tsx index 68089c9903..e10bf1a2d3 100644 --- a/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-form/edit-rules-form.tsx +++ b/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-form/edit-rules-form.tsx @@ -1,14 +1,13 @@ import { zodResolver } from "@hookform/resolvers/zod" import { PromotionDTO, PromotionRuleDTO } from "@medusajs/types" import { Button } from "@medusajs/ui" -import i18n from "i18next" import { useState } from "react" import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" -import * as zod from "zod" import { RouteDrawer } from "../../../../../../components/route-modal" import { RuleTypeValues } from "../../edit-rules" import { RulesFormField } from "../rules-form-field" +import { EditRules, EditRulesType } from "./form-schema" type EditPromotionFormProps = { promotion: PromotionDTO @@ -18,31 +17,6 @@ type EditPromotionFormProps = { isSubmitting: boolean } -const EditRules = zod.object({ - type: zod.string().optional(), - rules: zod.array( - zod.object({ - id: zod.string().optional(), - attribute: zod - .string() - .min(1, { message: i18n.t("promotions.form.required") }), - operator: zod - .string() - .min(1, { message: i18n.t("promotions.form.required") }), - values: zod.union([ - zod.number().min(1, { message: i18n.t("promotions.form.required") }), - zod.string().min(1, { message: i18n.t("promotions.form.required") }), - zod - .array(zod.string()) - .min(1, { message: i18n.t("promotions.form.required") }), - ]), - required: zod.boolean().optional(), - disguised: zod.boolean().optional(), - field_type: zod.string().optional(), - }) - ), -}) - export const EditRulesForm = ({ promotion, ruleType, @@ -52,7 +26,7 @@ export const EditRulesForm = ({ const { t } = useTranslation() const [rulesToRemove, setRulesToRemove] = useState([]) - const form = useForm>({ + const form = useForm({ defaultValues: { rules: [], type: promotion.type }, resolver: zodResolver(EditRules), }) @@ -64,11 +38,11 @@ export const EditRulesForm = ({
diff --git a/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-form/form-schema.ts b/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-form/form-schema.ts new file mode 100644 index 0000000000..e89b9f1c7b --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-form/form-schema.ts @@ -0,0 +1,29 @@ +import i18n from "i18next" +import { z } from "zod" + +export const EditRules = z.object({ + type: z.string().optional(), + rules: z.array( + z.object({ + id: z.string().optional(), + attribute: z + .string() + .min(1, { message: i18n.t("promotions.form.required") }), + operator: z + .string() + .min(1, { message: i18n.t("promotions.form.required") }), + values: z.union([ + z.number().min(1, { message: i18n.t("promotions.form.required") }), + z.string().min(1, { message: i18n.t("promotions.form.required") }), + z + .array(z.string()) + .min(1, { message: i18n.t("promotions.form.required") }), + ]), + required: z.boolean().optional(), + disguised: z.boolean().optional(), + field_type: z.string().optional(), + }) + ), +}) + +export type EditRulesType = z.infer diff --git a/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-form/utils.ts b/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-form/utils.ts new file mode 100644 index 0000000000..9f59a821ed --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-form/utils.ts @@ -0,0 +1,17 @@ +import { PromotionRuleResponse } from "@medusajs/types" + +export const generateRuleAttributes = (rules?: PromotionRuleResponse[]) => + (rules || []).map((rule) => ({ + id: rule.id, + required: rule.required, + field_type: rule.field_type, + disguised: rule.disguised, + attribute: rule.attribute!, + operator: rule.operator!, + values: + rule.field_type === "number" || rule.operator === "eq" + ? typeof rule.values === "object" + ? rule.values[0]?.value + : rule.values + : rule?.values?.map((v: { value: string }) => v.value!), + })) diff --git a/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-wrapper/edit-rules-wrapper.tsx b/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-wrapper/edit-rules-wrapper.tsx index 655561ff7a..76c4c4c7fa 100644 --- a/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-wrapper/edit-rules-wrapper.tsx +++ b/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-wrapper/edit-rules-wrapper.tsx @@ -42,11 +42,15 @@ export const EditRulesWrapper = ({ const { mutateAsync: updatePromotionRules, isPending } = usePromotionUpdateRules(promotion.id, ruleType) - const handleSubmit = (rulesToRemove?: { id: string }[]) => { + const handleSubmit = ( + rulesToRemove?: { id: string; disguised: boolean; attribute: string }[] + ) => { return async function (data: { rules: PromotionRuleResponse[] }) { const applicationMethodData: Record = {} const { rules: allRules = [] } = data const disguisedRules = allRules.filter((rule) => rule.disguised) + const disguisedRulesToRemove = + rulesToRemove?.filter((r) => r.disguised) || [] // For all the rules that were disguised, convert them to actual values in the // database, they are currently all under application_method. If more of these are coming @@ -55,6 +59,10 @@ export const EditRulesWrapper = ({ applicationMethodData[rule.attribute] = getRuleValue(rule) } + for (const rule of disguisedRulesToRemove) { + applicationMethodData[rule.attribute] = null + } + // This variable will contain the rules that are actual rule objects, without the disguised // objects const rulesData = allRules.filter((rule) => !rule.disguised) @@ -77,14 +85,14 @@ export const EditRulesWrapper = ({ return { attribute: rule.attribute, operator: rule.operator, - values: rule.operator === "eq" ? rule.values[0] : rule.values, + values: rule.values, } as any }), })) rulesToRemove?.length && (await removePromotionRules({ - rule_ids: rulesToRemove.map((r) => r.id!), + rule_ids: rulesToRemove.map((r) => r.id).filter(Boolean), })) rulesToUpdate.length && diff --git a/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-wrapper/utils.ts b/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-wrapper/utils.ts index 1916a36232..b88d48b9dd 100644 --- a/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-wrapper/utils.ts +++ b/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/edit-rules-wrapper/utils.ts @@ -5,9 +5,5 @@ export const getRuleValue = (rule: PromotionRuleResponse) => { return parseInt(rule.values as unknown as string) } - if (rule.field_type === "select") { - return rule.values[0] - } - return rule.values } diff --git a/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/rules-form-field/rules-form-field.tsx b/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/rules-form-field/rules-form-field.tsx index 82681e1a16..a6d0699d08 100644 --- a/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/rules-form-field/rules-form-field.tsx +++ b/packages/admin-next/dashboard/src/routes/promotions/common/edit-rules/components/rules-form-field/rules-form-field.tsx @@ -1,5 +1,5 @@ import { XMarkMini } from "@medusajs/icons" -import { PromotionRuleResponse } from "@medusajs/types" +import { PromotionDTO } from "@medusajs/types" import { Badge, Button, Heading, Select, Text } from "@medusajs/ui" import { Fragment, useEffect } from "react" import { useFieldArray, UseFormReturn, useWatch } from "react-hook-form" @@ -7,15 +7,15 @@ import { useTranslation } from "react-i18next" import { Form } from "../../../../../../components/common/form" import { usePromotionRuleAttributes, - usePromotionRuleOperators, usePromotionRules, } from "../../../../../../hooks/api/promotions" import { CreatePromotionSchemaType } from "../../../../promotion-create/components/create-promotion-form/form-schema" +import { generateRuleAttributes } from "../edit-rules-form/utils" import { RuleValueFormField } from "../rule-value-form-field" import { requiredProductRule } from "./constants" type RulesFormFieldType = { - promotionId?: string + promotion?: PromotionDTO form: UseFormReturn ruleType: "rules" | "target-rules" | "buy-rules" setRulesToRemove?: any @@ -26,32 +26,17 @@ type RulesFormFieldType = { | "application_method.target_rules" } -const generateRuleAttributes = (rules?: PromotionRuleResponse[]) => - (rules || []).map((rule) => ({ - id: rule.id, - required: rule.required, - field_type: rule.field_type, - disguised: rule.disguised, - attribute: rule.attribute!, - operator: rule.operator!, - values: - rule.field_type === "number" - ? rule.values - : rule?.values?.map((v: { value: string }) => v.value!), - })) - export const RulesFormField = ({ form, ruleType, setRulesToRemove, rulesToRemove, scope = "rules", - promotionId, + promotion, }: RulesFormFieldType) => { const { t } = useTranslation() const formData = form.getValues() const { attributes } = usePromotionRuleAttributes(ruleType, formData.type) - const { operators } = usePromotionRuleOperators() const { fields, append, remove, update, replace } = useFieldArray({ control: form.control, @@ -59,21 +44,31 @@ export const RulesFormField = ({ keyName: scope, }) - const promotionType: string = useWatch({ + const promotionType = useWatch({ control: form.control, name: "type", + defaultValue: promotion?.type, + }) + + const applicationMethodType = useWatch({ + control: form.control, + name: "application_method.type", + defaultValue: promotion?.application_method?.type, }) const query: Record = promotionType - ? { promotion_type: promotionType } + ? { + promotion_type: promotionType, + application_method_type: applicationMethodType, + } : {} const { rules, isLoading } = usePromotionRules( - promotionId || null, + promotion?.id || null, ruleType, query, { - enabled: !!promotionType, + enabled: !!promotion?.id || (!!promotionType && !!applicationMethodType), } ) @@ -104,12 +99,18 @@ export const RulesFormField = ({ promotionType === "buyget" ) { form.resetField(scope) - const rulesToAppend = promotionId + const rulesToAppend = promotion?.id ? rules : [...rules, requiredProductRule] replace(generateRuleAttributes(rulesToAppend) as any) + + return } + + form.resetField(scope) + + replace(generateRuleAttributes(rules) as any) }, [promotionType, isLoading]) return ( @@ -165,10 +166,18 @@ export const RulesFormField = ({ + @@ -262,8 +273,7 @@ export const RulesFormField = ({ }`} onClick={() => { if (!fieldRule.required) { - fieldRule.id && - setRulesToRemove && + setRulesToRemove && setRulesToRemove([...rulesToRemove, fieldRule]) remove(index) diff --git a/packages/admin-next/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx b/packages/admin-next/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx index 7640f3316b..dc52c6ed72 100644 --- a/packages/admin-next/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx +++ b/packages/admin-next/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/create-promotion-form.tsx @@ -1,6 +1,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { Alert, + Badge, Button, clx, CurrencyInput, @@ -15,7 +16,13 @@ import { useForm, useWatch } from "react-hook-form" import { Trans, useTranslation } from "react-i18next" import { z } from "zod" -import { PromotionRuleOperatorValues } from "@medusajs/types" +import { + ApplicationMethodAllocationValues, + ApplicationMethodTargetTypeValues, + ApplicationMethodTypeValues, + PromotionRuleOperatorValues, + PromotionTypeValues, +} from "@medusajs/types" import { Divider } from "../../../../../components/common/divider" import { Form } from "../../../../../components/common/form" import { PercentageInput } from "../../../../../components/inputs/percentage-input" @@ -33,6 +40,25 @@ import { Tab } from "./constants" import { CreatePromotionSchema } from "./form-schema" import { templates } from "./templates" +const defaultValues = { + campaign_id: undefined, + template_id: templates[0].id!, + campaign_choice: "none" as "none", + is_automatic: "false", + code: "", + type: "standard" as PromotionTypeValues, + rules: [], + application_method: { + allocation: "each" as ApplicationMethodAllocationValues, + type: "fixed" as ApplicationMethodTypeValues, + target_type: "items" as ApplicationMethodTargetTypeValues, + max_quantity: 1, + target_rules: [], + buy_rules: [], + }, + campaign: undefined, +} + export const CreatePromotionForm = () => { const [tab, setTab] = useState(Tab.TYPE) const [detailsValidated, setDetailsValidated] = useState(false) @@ -41,24 +67,7 @@ export const CreatePromotionForm = () => { const { handleSuccess } = useRouteModal() const form = useForm>({ - defaultValues: { - campaign_id: undefined, - template_id: templates[0].id!, - campaign_choice: "none", - is_automatic: "false", - code: "", - type: "standard", - rules: [], - application_method: { - allocation: "each", - type: "fixed", - target_type: "items", - max_quantity: 1, - target_rules: [], - buy_rules: [], - }, - campaign: undefined, - }, + defaultValues, resolver: zodResolver(CreatePromotionSchema), }) @@ -172,7 +181,7 @@ export const CreatePromotionForm = () => { name: "template_id", }) - useMemo(() => { + const currentTemplate = useMemo(() => { const currentTemplate = templates.find( (template) => template.id === watchTemplateId ) @@ -181,6 +190,8 @@ export const CreatePromotionForm = () => { return } + form.reset({ ...defaultValues, template_id: watchTemplateId }) + for (const [key, value] of Object.entries(currentTemplate.defaults)) { if (typeof value === "object") { for (const [subKey, subValue] of Object.entries(value)) { @@ -190,6 +201,8 @@ export const CreatePromotionForm = () => { form.setValue(key, value) } } + + return currentTemplate }, [watchTemplateId]) const watchValueType = useWatch({ @@ -215,6 +228,14 @@ export const CreatePromotionForm = () => { }) const isTypeStandard = watchType === "standard" + + const targetType = useWatch({ + control: form.control, + name: "application_method.target_type", + }) + + const isTargetTypeOrder = targetType === "order" + const formData = form.getValues() let campaignQuery: object = {} @@ -385,9 +406,22 @@ export const CreatePromotionForm = () => { - {t(`promotions.sections.details`)} + + {t(`promotions.sections.details`)} + + {currentTemplate?.title && ( + + {currentTemplate?.title} + + )} + {form.formState.errors.root && ( { /> - { - return ( - - {t("promotions.fields.type")} - - - - - - - - - - ) - }} - /> - - - - - - - - { - return ( - - - {t("promotions.fields.value_type")} - - - - - - - - - - - ) - }} - /> - -
+ {!currentTemplate?.hiddenFields?.includes("type") && ( { - const currencyCode = - form.getValues().application_method.currency_code - - return ( - - - {isFixedValueType - ? t("fields.amount") - : t("fields.percentage")} - - - - {isFixedValueType ? ( - { - onChange(value ? parseInt(value) : "") - }} - code={currencyCode} - symbol={ - currencyCode - ? getCurrencySymbol(currencyCode) - : "" - } - value={value} - disabled={!currencyCode} - /> - ) : ( - { - onChange( - e.target.value === "" - ? null - : parseInt(e.target.value) - ) - }} - /> - )} - - - - ) - }} - /> -
- - {isTypeStandard && ( - { return ( - - {t("promotions.fields.allocation")} - - + {t("promotions.fields.type")} { onValueChange={field.onChange} > @@ -666,8 +545,141 @@ export const CreatePromotionForm = () => { /> )} - {isTypeStandard && watchAllocation === "each" && ( -
+ + + + + + + {!currentTemplate?.hiddenFields?.includes( + "application_method.type" + ) && ( + { + return ( + + + {t("promotions.fields.value_type")} + + + + + + + + + + + ) + }} + /> + )} + +
+ {!currentTemplate?.hiddenFields?.includes( + "application_method.value" + ) && ( + { + const currencyCode = + form.getValues().application_method.currency_code + + return ( + + + {t("promotions.form.value.title")} + + + + {isFixedValueType ? ( + { + onChange(value ? parseInt(value) : "") + }} + code={currencyCode} + symbol={ + currencyCode + ? getCurrencySymbol(currencyCode) + : "" + } + value={value} + disabled={!currencyCode} + /> + ) : ( + { + onChange( + e.target.value === "" + ? null + : parseInt(e.target.value) + ) + }} + /> + )} + + + ]} + /> + + + + ) + }} + /> + )} + + {isTypeStandard && watchAllocation === "each" && ( { ) }} /> -
- )} + )} +
- + {isTypeStandard && + !currentTemplate?.hiddenFields?.includes( + "application_method.allocation" + ) && ( + { + return ( + + + {t("promotions.fields.allocation")} + + + + + + + + + + + + ) + }} + /> + )} {!isTypeStandard && ( <> @@ -717,16 +777,19 @@ export const CreatePromotionForm = () => { ruleType={"buy-rules"} scope="application_method.buy_rules" /> - - )} - + {!isTargetTypeOrder && ( + <> + + + + )}
diff --git a/packages/admin-next/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/templates.ts b/packages/admin-next/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/templates.ts index 6bbc5a2367..e4b41bc844 100644 --- a/packages/admin-next/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/templates.ts +++ b/packages/admin-next/dashboard/src/routes/promotions/promotion-create/components/create-promotion-form/templates.ts @@ -1,9 +1,16 @@ +const commonHiddenFields = [ + "type", + "application_method.type", + "application_method.allocation", +] + export const templates = [ { id: "amount_off_products", type: "standard", title: "Amount off products", description: "Discount specific products or collection of products", + hiddenFields: [...commonHiddenFields], defaults: { is_automatic: "false", type: "standard", @@ -19,6 +26,7 @@ export const templates = [ type: "standard", title: "Amount off order", description: "Discounts the total order amount", + hiddenFields: [...commonHiddenFields], defaults: { is_automatic: "false", type: "standard", @@ -34,6 +42,7 @@ export const templates = [ type: "standard", title: "Percentage off product", description: "Discounts a percentage off selected products", + hiddenFields: [...commonHiddenFields], defaults: { is_automatic: "false", type: "standard", @@ -49,12 +58,13 @@ export const templates = [ type: "standard", title: "Percentage off order", description: "Discounts a percentage of the total order amount", + hiddenFields: [...commonHiddenFields], defaults: { is_automatic: "false", type: "standard", application_method: { allocation: "across", - target_type: "items", + target_type: "order", type: "percentage", }, }, @@ -64,6 +74,7 @@ export const templates = [ type: "buy_get", title: "Buy X Get Y", description: "Buy X product(s), get Y product(s)", + hiddenFields: [...commonHiddenFields, "application_method.value"], defaults: { is_automatic: "false", type: "buyget", diff --git a/packages/admin-next/dashboard/src/routes/promotions/promotion-detail/components/promotion-conditions-section/promotion-conditions-section.tsx b/packages/admin-next/dashboard/src/routes/promotions/promotion-detail/components/promotion-conditions-section/promotion-conditions-section.tsx index f41519d652..80a2abe85c 100644 --- a/packages/admin-next/dashboard/src/routes/promotions/promotion-detail/components/promotion-conditions-section/promotion-conditions-section.tsx +++ b/packages/admin-next/dashboard/src/routes/promotions/promotion-detail/components/promotion-conditions-section/promotion-conditions-section.tsx @@ -82,7 +82,7 @@ export const PromotionConditionsSection = ({ className="h-[180px]" title="No records yet." message="Please check back later or add a target condition today" - action={{ to: "/promotions", label: "Add condition" }} + action={{ to: `${ruleType}/edit`, label: "Add condition" }} buttonVariant="transparentIconLeft" /> )} diff --git a/packages/core/types/src/http/promotion/admin/rule-attribute-options.ts b/packages/core/types/src/http/promotion/admin/rule-attribute-options.ts index 3f1536614a..9b52c2a6e4 100644 --- a/packages/core/types/src/http/promotion/admin/rule-attribute-options.ts +++ b/packages/core/types/src/http/promotion/admin/rule-attribute-options.ts @@ -5,6 +5,11 @@ export interface RuleAttributeOptionsResponse { field_type: string required: boolean disguised: boolean + operators: { + id: string + value: string + label: string + }[] } export interface AdminRuleAttributeOptionsListResponse { diff --git a/packages/core/types/src/promotion/common/application-method.ts b/packages/core/types/src/promotion/common/application-method.ts index 92f69cedb4..7426fae84b 100644 --- a/packages/core/types/src/promotion/common/application-method.ts +++ b/packages/core/types/src/promotion/common/application-method.ts @@ -124,7 +124,7 @@ export interface CreateApplicationMethodDTO { /** * Currency of the value to apply. */ - currency_code: string + currency_code?: string /** * The max quantity allowed in the cart for the associated promotion to be applied. diff --git a/packages/medusa/src/api/admin/promotions/[id]/[rule_type]/route.ts b/packages/medusa/src/api/admin/promotions/[id]/[rule_type]/route.ts index 5097499b12..e2ee06587d 100644 --- a/packages/medusa/src/api/admin/promotions/[id]/[rule_type]/route.ts +++ b/packages/medusa/src/api/admin/promotions/[id]/[rule_type]/route.ts @@ -33,9 +33,11 @@ export const GET = async ( }) const [promotion] = await remoteQuery(queryObject) - const ruleAttributes = getRuleAttributesMap( - promotion?.type || req.query.promotion_type - )[ruleType] + const ruleAttributes = getRuleAttributesMap({ + promotionType: promotion?.type || req.query.promotion_type, + applicationMethodType: + promotion?.application_method?.type || req.query.application_method_type, + })[ruleType] const promotionRules: any[] = [] if (dasherizedRuleType === RuleType.RULES) { @@ -48,7 +50,6 @@ export const GET = async ( const transformedRules: AdminGetPromotionRulesRes = [] const disguisedRules = ruleAttributes.filter((attr) => !!attr.disguised) - const requiredRules = ruleAttributes.filter((attr) => !!attr.required) for (const disguisedRule of disguisedRules) { const getValues = () => { @@ -65,18 +66,22 @@ export const GET = async ( return [] } - transformedRules.push({ - id: undefined, - attribute: disguisedRule.id, - attribute_label: disguisedRule.label, - field_type: disguisedRule.field_type, - hydrate: disguisedRule.hydrate || false, - operator: RuleOperator.EQ, - operator_label: operatorsMap[RuleOperator.EQ].label, - values: getValues(), - disguised: true, - required: true, - }) + const required = disguisedRule.required ?? true + const applicationMethod = promotion?.application_method + const recordValue = applicationMethod?.[disguisedRule.id] + + if (required || recordValue) { + transformedRules.push({ + ...disguisedRule, + id: undefined, + attribute: disguisedRule.id, + attribute_label: disguisedRule.label, + operator: RuleOperator.EQ, + operator_label: operatorsMap[RuleOperator.EQ].label, + value: undefined, + values: getValues(), + }) + } continue } @@ -125,36 +130,15 @@ export const GET = async ( if (!currentRuleAttribute.hydrate) { transformedRules.push({ + ...currentRuleAttribute, ...promotionRule, attribute_label: currentRuleAttribute.label, - field_type: currentRuleAttribute.field_type, operator_label: operatorsMap[promotionRule.operator]?.label || promotionRule.operator, - disguised: false, - required: currentRuleAttribute.required || false, }) } } - if (requiredRules.length && !transformedRules.length) { - for (const requiredRule of requiredRules) { - transformedRules.push({ - id: undefined, - attribute: requiredRule.value, - attribute_label: requiredRule.label, - operator: RuleOperator.EQ, - field_type: requiredRule.field_type, - operator_label: operatorsMap[RuleOperator.EQ].label, - values: [], - disguised: true, - required: true, - hydrate: false, - }) - - continue - } - } - res.json({ rules: transformedRules, }) diff --git a/packages/medusa/src/api/admin/promotions/rule-attribute-options/[rule_type]/route.ts b/packages/medusa/src/api/admin/promotions/rule-attribute-options/[rule_type]/route.ts index de5d3bdd71..022bad0cf0 100644 --- a/packages/medusa/src/api/admin/promotions/rule-attribute-options/[rule_type]/route.ts +++ b/packages/medusa/src/api/admin/promotions/rule-attribute-options/[rule_type]/route.ts @@ -14,7 +14,10 @@ export const GET = async ( validateRuleType(ruleType) const attributes = - getRuleAttributesMap(req.query.promotion_type as string)[ruleType] || [] + getRuleAttributesMap({ + promotionType: req.query.promotion_type as string, + applicationMethodType: req.query.application_method_type as string, + })[ruleType] || [] res.json({ attributes, diff --git a/packages/medusa/src/api/admin/promotions/rule-operator-options/route.ts b/packages/medusa/src/api/admin/promotions/rule-operator-options/route.ts deleted file mode 100644 index 7bd25b91fa..0000000000 --- a/packages/medusa/src/api/admin/promotions/rule-operator-options/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { - AuthenticatedMedusaRequest, - MedusaResponse, -} from "../../../../types/routing" -import { operatorsMap } from "../utils" - -export const GET = async ( - req: AuthenticatedMedusaRequest, - res: MedusaResponse -) => { - res.json({ - operators: Object.values(operatorsMap), - }) -} diff --git a/packages/medusa/src/api/admin/promotions/rule-value-options/[rule_type]/[rule_attribute_id]/route.ts b/packages/medusa/src/api/admin/promotions/rule-value-options/[rule_type]/[rule_attribute_id]/route.ts index f1623f7b11..48e5b5cbae 100644 --- a/packages/medusa/src/api/admin/promotions/rule-value-options/[rule_type]/[rule_attribute_id]/route.ts +++ b/packages/medusa/src/api/admin/promotions/rule-value-options/[rule_type]/[rule_attribute_id]/route.ts @@ -13,6 +13,13 @@ import { } from "../../../utils" import { AdminGetPromotionRuleParamsType } from "../../../validators" +/* + This endpoint returns all the potential values for rules (promotion rules, target rules and buy rules) + given an attribute of a rule. The response for different rule_attributes are returned uniformly + as an array of labels and values. + Eg. If the rule_attribute requested is "currency_code" for "rules" rule type, we return currencies + from the currency module. +*/ export const GET = async ( req: AuthenticatedMedusaRequest, res: MedusaResponse @@ -21,6 +28,7 @@ export const GET = async ( rule_type: ruleType, rule_attribute_id: ruleAttributeId, promotion_type: promotionType, + application_method_type: applicationMethodType, } = req.params const queryConfig = ruleQueryConfigurations[ruleAttributeId] const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) @@ -33,7 +41,12 @@ export const GET = async ( } validateRuleType(ruleType) - validateRuleAttribute(promotionType, ruleType, ruleAttributeId) + validateRuleAttribute({ + promotionType, + ruleType, + ruleAttributeId, + applicationMethodType, + }) const { rows } = await remoteQuery( remoteQueryObjectFromString({ diff --git a/packages/medusa/src/api/admin/promotions/utils/rule-attributes-map.ts b/packages/medusa/src/api/admin/promotions/utils/rule-attributes-map.ts index b7942d0484..7c97740506 100644 --- a/packages/medusa/src/api/admin/promotions/utils/rule-attributes-map.ts +++ b/packages/medusa/src/api/admin/promotions/utils/rule-attributes-map.ts @@ -1,4 +1,9 @@ -import { PromotionType } from "@medusajs/utils" +import { + ApplicationMethodType, + PromotionType, + RuleOperator, +} from "@medusajs/utils" +import { operatorsMap } from "./operators-map" export enum DisguisedRule { APPLY_TO_QUANTITY = "apply_to_quantity", @@ -7,21 +12,13 @@ export enum DisguisedRule { } const ruleAttributes = [ - { - id: DisguisedRule.CURRENCY_CODE, - value: DisguisedRule.CURRENCY_CODE, - label: "Currency Code", - field_type: "select", - required: true, - disguised: true, - hydrate: true, - }, { id: "customer_group", value: "customer.groups.id", label: "Customer Group", required: false, field_type: "multiselect", + operators: Object.values(operatorsMap), }, { id: "region", @@ -29,6 +26,7 @@ const ruleAttributes = [ label: "Region", required: false, field_type: "multiselect", + operators: Object.values(operatorsMap), }, { id: "country", @@ -36,6 +34,7 @@ const ruleAttributes = [ label: "Country", required: false, field_type: "multiselect", + operators: Object.values(operatorsMap), }, { id: "sales_channel", @@ -43,6 +42,7 @@ const ruleAttributes = [ label: "Sales Channel", required: false, field_type: "multiselect", + operators: Object.values(operatorsMap), }, ] @@ -53,6 +53,7 @@ const commonAttributes = [ label: "Product", required: false, field_type: "multiselect", + operators: Object.values(operatorsMap), }, { id: "product_category", @@ -60,6 +61,7 @@ const commonAttributes = [ label: "Product Category", required: false, field_type: "multiselect", + operators: Object.values(operatorsMap), }, { id: "product_collection", @@ -67,6 +69,7 @@ const commonAttributes = [ label: "Product Collection", required: false, field_type: "multiselect", + operators: Object.values(operatorsMap), }, { id: "product_type", @@ -74,6 +77,7 @@ const commonAttributes = [ label: "Product Type", required: false, field_type: "multiselect", + operators: Object.values(operatorsMap), }, { id: "product_tag", @@ -81,9 +85,21 @@ const commonAttributes = [ label: "Product Tag", required: false, field_type: "multiselect", + operators: Object.values(operatorsMap), }, ] +const currencyRule = { + id: DisguisedRule.CURRENCY_CODE, + value: DisguisedRule.CURRENCY_CODE, + label: "Currency Code", + field_type: "select", + required: true, + disguised: true, + hydrate: true, + operators: [operatorsMap[RuleOperator.EQ]], +} + const buyGetBuyRules = [ { id: DisguisedRule.BUY_RULES_MIN_QUANTITY, @@ -92,6 +108,7 @@ const buyGetBuyRules = [ field_type: "number", required: true, disguised: true, + operators: [operatorsMap[RuleOperator.EQ]], }, ] @@ -103,16 +120,29 @@ const buyGetTargetRules = [ field_type: "number", required: true, disguised: true, + operators: [operatorsMap[RuleOperator.EQ]], }, ] -export const getRuleAttributesMap = (promotionType?: string) => { +export const getRuleAttributesMap = ({ + promotionType, + applicationMethodType, +}: { + promotionType?: string + applicationMethodType?: string +}) => { const map = { rules: [...ruleAttributes], "target-rules": [...commonAttributes], "buy-rules": [...commonAttributes], } + if (applicationMethodType === ApplicationMethodType.FIXED) { + map["rules"].push({ ...currencyRule }) + } else { + map["rules"].push({ ...currencyRule, required: false }) + } + if (promotionType === PromotionType.BUYGET) { map["buy-rules"].push(...buyGetBuyRules) map["target-rules"].push(...buyGetTargetRules) diff --git a/packages/medusa/src/api/admin/promotions/utils/validate-rule-attribute.ts b/packages/medusa/src/api/admin/promotions/utils/validate-rule-attribute.ts index 1b944bff86..5fd2097adc 100644 --- a/packages/medusa/src/api/admin/promotions/utils/validate-rule-attribute.ts +++ b/packages/medusa/src/api/admin/promotions/utils/validate-rule-attribute.ts @@ -1,12 +1,21 @@ import { MedusaError } from "@medusajs/utils" import { getRuleAttributesMap } from "./rule-attributes-map" -export function validateRuleAttribute( - promotionType: string | undefined, - ruleType: string, +export function validateRuleAttribute(attributes: { + promotionType: string | undefined + ruleType: string ruleAttributeId: string -) { - const ruleAttributes = getRuleAttributesMap(promotionType)[ruleType] || [] + applicationMethodType?: string +}) { + const { promotionType, ruleType, ruleAttributeId, applicationMethodType } = + attributes + + const ruleAttributes = + getRuleAttributesMap({ + promotionType, + applicationMethodType, + })[ruleType] || [] + const ruleAttribute = ruleAttributes.find((obj) => obj.id === ruleAttributeId) if (!ruleAttribute) { diff --git a/packages/medusa/src/api/admin/promotions/validators.ts b/packages/medusa/src/api/admin/promotions/validators.ts index 769744c4fb..5858e12582 100644 --- a/packages/medusa/src/api/admin/promotions/validators.ts +++ b/packages/medusa/src/api/admin/promotions/validators.ts @@ -49,6 +49,7 @@ export type AdminGetPromotionRuleParamsType = z.infer< > export const AdminGetPromotionRuleParams = z.object({ promotion_type: z.string().optional(), + application_method_type: z.string().optional(), }) export type AdminGetPromotionRuleTypeParamsType = z.infer< @@ -57,6 +58,7 @@ export type AdminGetPromotionRuleTypeParamsType = z.infer< export const AdminGetPromotionRuleTypeParams = createSelectParams().merge( z.object({ promotion_type: z.string().optional(), + application_method_type: z.string().optional(), }) ) @@ -105,7 +107,7 @@ export const AdminCreateApplicationMethod = z .object({ description: z.string().optional(), value: z.number(), - currency_code: z.string(), + currency_code: z.string().optional().nullable(), max_quantity: z.number().optional().nullable(), type: z.nativeEnum(ApplicationMethodType), target_type: z.nativeEnum(ApplicationMethodTargetType), @@ -125,7 +127,7 @@ export const AdminUpdateApplicationMethod = z description: z.string().optional(), value: z.number().optional(), max_quantity: z.number().optional().nullable(), - currency_code: z.string().optional(), + currency_code: z.string().optional().nullable(), type: z.nativeEnum(ApplicationMethodType).optional(), target_type: z.nativeEnum(ApplicationMethodTargetType).optional(), allocation: z.nativeEnum(ApplicationMethodAllocation).optional(), diff --git a/packages/modules/promotion/src/migrations/Migration20240617102917.ts b/packages/modules/promotion/src/migrations/Migration20240617102917.ts new file mode 100644 index 0000000000..81652e1813 --- /dev/null +++ b/packages/modules/promotion/src/migrations/Migration20240617102917.ts @@ -0,0 +1,22 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20240617102917 extends Migration { + async up(): Promise { + this.addSql( + 'alter table "promotion_application_method" alter column "currency_code" type text using ("currency_code"::text);' + ) + + this.addSql( + 'alter table "promotion_application_method" alter column "currency_code" drop not null;' + ) + } + + async down(): Promise { + this.addSql( + 'alter table "promotion_application_method" alter column "currency_code" type text using ("currency_code"::text);' + ) + this.addSql( + 'alter table "promotion_application_method" alter column "currency_code" set not null;' + ) + } +} diff --git a/packages/modules/promotion/src/types/application-method.ts b/packages/modules/promotion/src/types/application-method.ts index c173d63452..30c264d6a8 100644 --- a/packages/modules/promotion/src/types/application-method.ts +++ b/packages/modules/promotion/src/types/application-method.ts @@ -13,7 +13,7 @@ export interface CreateApplicationMethodDTO { target_type: ApplicationMethodTargetTypeValues allocation?: ApplicationMethodAllocationValues value?: BigNumberInput - currency_code: string + currency_code?: string | null promotion: Promotion | string | PromotionDTO max_quantity?: BigNumberInput | null buy_rules_min_quantity?: BigNumberInput | null @@ -26,7 +26,7 @@ export interface UpdateApplicationMethodDTO { target_type?: ApplicationMethodTargetTypeValues allocation?: ApplicationMethodAllocationValues value?: BigNumberInput - currency_code?: string + currency_code?: string | null promotion?: Promotion | string | PromotionDTO max_quantity?: BigNumberInput | null buy_rules_min_quantity?: BigNumberInput | null