From 23df4920157a37444e70bbf6c7dbad97ca7621de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chy=C5=82a?= Date: Mon, 29 Jan 2024 15:25:17 +0100 Subject: [PATCH 1/2] Refactor rule model --- .../DiscountCreateForm/DiscountCreateForm.tsx | 2 +- .../hooks/useRulesHandlers.test.ts | 6 +- .../hooks/useRulesHandlers.ts | 17 +- .../hooks/useRulesHandlers.ts | 12 +- .../DiscountDetailsForm/utils.test.ts | 6 +- .../components/DiscountDetailsForm/utils.ts | 10 +- .../DiscountRules/DiscountRules.test.tsx | 35 +- .../componenets/RuleForm/RuleForm.tsx | 7 +- .../RuleConditionRow/RuleConditionRow.tsx | 23 +- .../components/RuleConditionRow/utils.ts | 3 +- .../RuleConditions/RuleConditions.tsx | 12 +- .../RuleFormModal/RuleFormModal.tsx | 7 +- .../RuleFormModal/defaultFormValues.ts | 13 + .../componenets/RuleFormModal/mocks.ts | 604 +++++++++++++++++- .../RuleFormModal/validationSchema.ts | 15 +- .../RulesList/components/RuleSummary/utils.ts | 18 +- .../CatalogRule/prepareConditions.test.ts | 56 ++ .../models/CatalogRule/prepareConditions.ts | 42 ++ .../models/CatalogRule/preparePredicate.ts | 37 ++ src/discounts/models/Condition.test.ts | 40 -- src/discounts/models/Condition.ts | 110 +--- src/discounts/models/Rule.test.ts | 148 ----- src/discounts/models/Rule.ts | 134 +--- src/discounts/models/helpers.ts | 107 ++++ src/discounts/models/index.ts | 1 + src/discounts/models/transformRule.ts | 52 ++ .../views/DiscountCreate/DiscountCreate.tsx | 4 +- .../views/DiscountCreate/handlers.ts | 5 +- .../views/DiscountDetails/handlers.ts | 6 +- 29 files changed, 1054 insertions(+), 478 deletions(-) create mode 100644 src/discounts/components/DiscountRules/componenets/RuleFormModal/defaultFormValues.ts create mode 100644 src/discounts/models/CatalogRule/prepareConditions.test.ts create mode 100644 src/discounts/models/CatalogRule/prepareConditions.ts create mode 100644 src/discounts/models/CatalogRule/preparePredicate.ts delete mode 100644 src/discounts/models/Condition.test.ts delete mode 100644 src/discounts/models/Rule.test.ts create mode 100644 src/discounts/models/helpers.ts create mode 100644 src/discounts/models/transformRule.ts diff --git a/src/discounts/components/DiscountCreateForm/DiscountCreateForm.tsx b/src/discounts/components/DiscountCreateForm/DiscountCreateForm.tsx index 6fc84a0d452..9b2dbd62910 100644 --- a/src/discounts/components/DiscountCreateForm/DiscountCreateForm.tsx +++ b/src/discounts/components/DiscountCreateForm/DiscountCreateForm.tsx @@ -41,7 +41,7 @@ export const DiscountCreateForm = ({ triggerChange: methods.trigger, }); - const { rules, onDeleteRule, onRuleSubmit } = useRulesHandlers(); + const { rules, onDeleteRule, onRuleSubmit } = useRulesHandlers("catalog"); const handleSubmit: SubmitHandler = data => { onSubmit({ diff --git a/src/discounts/components/DiscountCreateForm/hooks/useRulesHandlers.test.ts b/src/discounts/components/DiscountCreateForm/hooks/useRulesHandlers.test.ts index a1350635048..23bf9629d73 100644 --- a/src/discounts/components/DiscountCreateForm/hooks/useRulesHandlers.test.ts +++ b/src/discounts/components/DiscountCreateForm/hooks/useRulesHandlers.test.ts @@ -16,7 +16,7 @@ const rule = { describe("DiscountCreateForm useRulesHandlers", () => { it("should allow to add new rule ", () => { // Arrange - const { result } = renderHook(() => useRulesHandlers()); + const { result } = renderHook(() => useRulesHandlers("catalog")); // Act act(() => { @@ -29,7 +29,7 @@ describe("DiscountCreateForm useRulesHandlers", () => { it("should allow to edit rule at index", () => { // Arrange - const { result } = renderHook(() => useRulesHandlers()); + const { result } = renderHook(() => useRulesHandlers("catalog")); const rule = { name: "Rule 1", @@ -55,7 +55,7 @@ describe("DiscountCreateForm useRulesHandlers", () => { it("should allow to delete rule at index", () => { // Arrange - const { result } = renderHook(() => useRulesHandlers()); + const { result } = renderHook(() => useRulesHandlers("catalog")); const rule = { name: "Rule 1", diff --git a/src/discounts/components/DiscountCreateForm/hooks/useRulesHandlers.ts b/src/discounts/components/DiscountCreateForm/hooks/useRulesHandlers.ts index b41221582b0..931c94b2b56 100644 --- a/src/discounts/components/DiscountCreateForm/hooks/useRulesHandlers.ts +++ b/src/discounts/components/DiscountCreateForm/hooks/useRulesHandlers.ts @@ -1,24 +1,29 @@ import { Rule } from "@dashboard/discounts/models"; import { sortRules } from "@dashboard/discounts/utils"; -import { useState } from "react"; +import {} from "@dashboard/graphql"; +import { useEffect, useState } from "react"; -export const useRulesHandlers = () => { +export const useRulesHandlers = ( + discountType: "catalog", // to be replaced by PromotionTypeEnum when API return this field +) => { const [rules, setRules] = useState([]); + useEffect(() => { + setRules([]); + }, [discountType]); + const onDeleteRule = (ruleDeleteIndex: number) => { setRules(rules => rules.filter((_, index) => index !== ruleDeleteIndex)); }; const onRuleSubmit = async (data: Rule, ruleEditIndex: number | null) => { - const ruleObj = Rule.fromFormValues(data); - if (ruleEditIndex !== null) { setRules(rules => { - rules[ruleEditIndex] = ruleObj; + rules[ruleEditIndex] = data; return rules; }); } else { - setRules(sortRules([...rules, ruleObj])); + setRules(sortRules([...rules, data])); } }; diff --git a/src/discounts/components/DiscountDetailsForm/hooks/useRulesHandlers.ts b/src/discounts/components/DiscountDetailsForm/hooks/useRulesHandlers.ts index 3574927e5f8..0df9346e2ee 100644 --- a/src/discounts/components/DiscountDetailsForm/hooks/useRulesHandlers.ts +++ b/src/discounts/components/DiscountDetailsForm/hooks/useRulesHandlers.ts @@ -1,4 +1,4 @@ -import { Rule } from "@dashboard/discounts/models"; +import { mapAPIRuleToForm, Rule } from "@dashboard/discounts/models"; import { PromotionDetailsFragment, PromotionRuleCreateErrorFragment, @@ -31,7 +31,9 @@ export const useRulesHandlers = ({ const [rulesErrors, setRulesErrors] = useState>>([]); const [labelsMap, setLabelMap] = useState>({}); - const rules = data?.rules?.map(rule => Rule.fromAPI(rule, labelsMap)) ?? []; + const rules = + data?.rules?.map(rule => mapAPIRuleToForm("catalog", rule, labelsMap)) ?? + []; useEffect(() => { setLabelMap(labels => { @@ -55,16 +57,16 @@ export const useRulesHandlers = ({ PromotionRuleUpdateErrorFragment | PromotionRuleCreateErrorFragment > > = []; - const ruleObj = Rule.fromFormValues(rule); + if (ruleEditIndex !== null) { updateLabels(rule); - errors = await onRuleUpdateSubmit(ruleObj); + errors = await onRuleUpdateSubmit(rule); if (errors.length > 0) { setRulesErrors(errors); } } else { - errors = await onRuleCreateSubmit(ruleObj); + errors = await onRuleCreateSubmit(rule); if (errors.length > 0) { setRulesErrors(errors); } diff --git a/src/discounts/components/DiscountDetailsForm/utils.test.ts b/src/discounts/components/DiscountDetailsForm/utils.test.ts index 603aafb470f..24e98feaf7c 100644 --- a/src/discounts/components/DiscountDetailsForm/utils.test.ts +++ b/src/discounts/components/DiscountDetailsForm/utils.test.ts @@ -18,7 +18,7 @@ describe("getCurrentConditionsValuesLabels", () => { it("should return empty object if no values", () => { expect( getCurrentConditionsValuesLabels([ - { conditions: [{ values: [] }] }, + { conditions: [{ value: [] }] }, ] as unknown as Rule[]), ).toEqual({}); }); @@ -26,8 +26,8 @@ describe("getCurrentConditionsValuesLabels", () => { it("should return object with value as key and label as value", () => { expect( getCurrentConditionsValuesLabels([ - { conditions: [{ values: [{ value: "test", label: "test2" }] }] }, - { conditions: [{ values: [{ value: "test3", label: "test4" }] }] }, + { conditions: [{ value: [{ value: "test", label: "test2" }] }] }, + { conditions: [{ value: [{ value: "test3", label: "test4" }] }] }, ] as unknown as Rule[]), ).toEqual({ test: "test2", test3: "test4" }); }); diff --git a/src/discounts/components/DiscountDetailsForm/utils.ts b/src/discounts/components/DiscountDetailsForm/utils.ts index bdfcaea8fcd..9f2c67e8e35 100644 --- a/src/discounts/components/DiscountDetailsForm/utils.ts +++ b/src/discounts/components/DiscountDetailsForm/utils.ts @@ -1,9 +1,11 @@ -import { Rule } from "@dashboard/discounts/models"; +import { isArrayOfOptions, Rule } from "@dashboard/discounts/models"; +import { Option } from "@saleor/macaw-ui-next"; -export const getCurrentConditionsValuesLabels = (rules: Rule[]) => { - return rules +export const getCurrentConditionsValuesLabels = (rule: Rule[]) => { + return rule .flatMap(rule => rule.conditions) - .flatMap(condition => condition.values) + .filter(condition => isArrayOfOptions(condition.value)) + .flatMap(condition => condition.value as Option[]) .reduce((acc, value) => { // Initali value and label might contain id if (value.value !== value.label) { diff --git a/src/discounts/components/DiscountRules/DiscountRules.test.tsx b/src/discounts/components/DiscountRules/DiscountRules.test.tsx index ec3b0c3ff6a..781cd853e47 100644 --- a/src/discounts/components/DiscountRules/DiscountRules.test.tsx +++ b/src/discounts/components/DiscountRules/DiscountRules.test.tsx @@ -26,6 +26,11 @@ jest.mock("react-intl", () => ({ ), })); +jest.mock("@dashboard/hooks/useNotifier", () => ({ + __esModule: true, + default: jest.fn(() => () => undefined), +})); + const Wrapper = ({ children }: { children: ReactNode }) => { return ( { }, conditions: [ { - condition: "is", - type: "product", - values: [ + type: "is", + id: "product", + value: [ { - label: "Bean Juice", - value: "UHJvZHVjdDo3OQ==", + label: "Apple Juice", + value: "UHJvZHVjdDo3Mg==", }, ], }, @@ -415,9 +422,9 @@ describe("DiscountRules", () => { }, conditions: [ { - condition: "is", - type: "product", - values: [ + type: "is", + id: "product", + value: [ { label: "Product-1", value: "prod-1", diff --git a/src/discounts/components/DiscountRules/componenets/RuleForm/RuleForm.tsx b/src/discounts/components/DiscountRules/componenets/RuleForm/RuleForm.tsx index 2e656118fa7..b6ae22c48a0 100644 --- a/src/discounts/components/DiscountRules/componenets/RuleForm/RuleForm.tsx +++ b/src/discounts/components/DiscountRules/componenets/RuleForm/RuleForm.tsx @@ -1,4 +1,7 @@ -import { Condition, Rule as RuleType } from "@dashboard/discounts/models"; +import { + createEmptyCodition, + Rule as RuleType, +} from "@dashboard/discounts/models"; import { ChannelFragment, RewardValueTypeEnum } from "@dashboard/graphql"; import { commonMessages } from "@dashboard/intl"; import { getFormErrors } from "@dashboard/utils/errors"; @@ -78,7 +81,7 @@ export const RuleForm = ({ setValue("channel", selectedChannel, { shouldValidate: true }); if (conditions.length > 0) { - setValue("conditions", [Condition.empty()]); + setValue("conditions", [createEmptyCodition()]); } else { setValue("conditions", []); } diff --git a/src/discounts/components/DiscountRules/componenets/RuleForm/components/RuleConditionRow/RuleConditionRow.tsx b/src/discounts/components/DiscountRules/componenets/RuleForm/components/RuleConditionRow/RuleConditionRow.tsx index e23d8e771ad..c346ecfa8e5 100644 --- a/src/discounts/components/DiscountRules/componenets/RuleForm/components/RuleConditionRow/RuleConditionRow.tsx +++ b/src/discounts/components/DiscountRules/componenets/RuleForm/components/RuleConditionRow/RuleConditionRow.tsx @@ -1,5 +1,6 @@ import { Combobox, Multiselect } from "@dashboard/components/Combobox"; -import { Condition, Rule } from "@dashboard/discounts/models"; +import { Condition, isArrayOfOptions, Rule } from "@dashboard/discounts/models"; +import { ConditionType } from "@dashboard/discounts/types"; import { getSearchFetchMoreProps } from "@dashboard/hooks/makeTopLevelSearch/utils"; import { Box, Button, Option, RemoveIcon, Select } from "@saleor/macaw-ui-next"; import React from "react"; @@ -20,22 +21,21 @@ interface DiscountConditionRowProps { conditionIndex: number; onRemove: () => void; updateCondition: (index: number, value: Condition) => void; - fetchOptions: FetchOptions | undefined; + typeToFetchMap: Record; isConditionTypeSelected: (conditionType: string) => boolean; } export const RuleConditionRow = ({ conditionIndex, onRemove, - fetchOptions, + typeToFetchMap, isConditionTypeSelected, updateCondition, disabled = false, }: DiscountConditionRowProps) => { const intl = useIntl(); - const ruleConditionTypeFieldName = - `conditions.${conditionIndex}.type` as const; + const ruleConditionTypeFieldName = `conditions.${conditionIndex}.id` as const; const { field: typeField } = useController< Rule, typeof ruleConditionTypeFieldName @@ -44,7 +44,7 @@ export const RuleConditionRow = ({ }); const ruleConditionValuesFieldName = - `conditions.${conditionIndex}.values` as const; + `conditions.${conditionIndex}.value` as const; const { field: valuesField } = useController< Rule, typeof ruleConditionValuesFieldName @@ -54,8 +54,11 @@ export const RuleConditionRow = ({ const { watch } = useFormContext(); const condition = watch(`conditions.${conditionIndex}`); - - const { fetch = () => {}, fetchMoreProps, options } = fetchOptions || {}; + const { + fetch = () => {}, + fetchMoreProps, + options, + } = typeToFetchMap[(condition?.id ?? "") as ConditionType] || {}; const discountConditionType = initialDiscountConditionType.filter( condition => !isConditionTypeSelected(condition.value), @@ -78,7 +81,7 @@ export const RuleConditionRow = ({ fetchOptions={() => {}} options={discountConditionType} onChange={e => { - condition.values = []; + condition.value = []; updateCondition(conditionIndex, condition); typeField.onChange(e.target.value); }} @@ -111,7 +114,7 @@ export const RuleConditionRow = ({ { if (!conditionType) { diff --git a/src/discounts/components/DiscountRules/componenets/RuleForm/components/RuleConditions/RuleConditions.tsx b/src/discounts/components/DiscountRules/componenets/RuleForm/components/RuleConditions/RuleConditions.tsx index 41ce66bb9ab..5083cf69314 100644 --- a/src/discounts/components/DiscountRules/componenets/RuleForm/components/RuleConditions/RuleConditions.tsx +++ b/src/discounts/components/DiscountRules/componenets/RuleForm/components/RuleConditions/RuleConditions.tsx @@ -1,4 +1,4 @@ -import { Condition, Rule } from "@dashboard/discounts/models"; +import { createEmptyCodition, Rule } from "@dashboard/discounts/models"; import { ConditionType } from "@dashboard/discounts/types"; import { Box, Button, Text } from "@saleor/macaw-ui-next"; import React from "react"; @@ -60,7 +60,7 @@ export const RuleConditions = ({ size="small" alignSelf="start" disabled={disabled} - onClick={() => append(Condition.empty())} + onClick={() => append(createEmptyCodition())} > @@ -75,12 +75,10 @@ export const RuleConditions = ({ {fields.map((condition, conditionIndex) => ( { @@ -96,7 +94,7 @@ export const RuleConditions = ({ size="small" alignSelf="start" disabled={disabled} - onClick={() => append(Condition.empty())} + onClick={() => append(createEmptyCodition())} > diff --git a/src/discounts/components/DiscountRules/componenets/RuleFormModal/RuleFormModal.tsx b/src/discounts/components/DiscountRules/componenets/RuleFormModal/RuleFormModal.tsx index d7022095edd..635f9f7e6a0 100644 --- a/src/discounts/components/DiscountRules/componenets/RuleFormModal/RuleFormModal.tsx +++ b/src/discounts/components/DiscountRules/componenets/RuleFormModal/RuleFormModal.tsx @@ -21,6 +21,7 @@ import { useCollectionOptions } from "../RuleForm/components/RuleConditions/hook import { useProductOptions } from "../RuleForm/components/RuleConditions/hooks/useProductOptions"; import { useVariantOptions } from "../RuleForm/components/RuleConditions/hooks/useVariantOptions"; import { RuleForm } from "../RuleForm/RuleForm"; +import { defaultFormValues } from "./defaultFormValues"; import { getValidationSchema } from "./validationSchema"; interface RuleFormModalProps { @@ -46,11 +47,9 @@ export const RuleFormModal = ({ }: RuleFormModalProps) => { const intl = useIntl(); - const { toAPI, ...emptyRule } = Rule.empty(); - const methods = useForm({ mode: "onBlur", - values: initialFormValues || { ...emptyRule, toAPI }, + values: initialFormValues || defaultFormValues, resolver: zodResolver(getValidationSchema(intl)), }); @@ -73,7 +72,7 @@ export const RuleFormModal = ({ // Clear modal form useEffect(() => { if (!initialFormValues && open) { - methods.reset(Rule.empty()); + methods.reset(defaultFormValues); } }, [open]); diff --git a/src/discounts/components/DiscountRules/componenets/RuleFormModal/defaultFormValues.ts b/src/discounts/components/DiscountRules/componenets/RuleFormModal/defaultFormValues.ts new file mode 100644 index 00000000000..63fcb40fcd3 --- /dev/null +++ b/src/discounts/components/DiscountRules/componenets/RuleFormModal/defaultFormValues.ts @@ -0,0 +1,13 @@ +import { Rule } from "@dashboard/discounts/models"; +import { RewardValueTypeEnum } from "@dashboard/graphql"; + +export const defaultFormValues: Omit = { + id: "", + name: "", + description: "", + channel: null, + rewardType: null, + rewardValue: 0, + rewardValueType: RewardValueTypeEnum.FIXED, + conditions: [], +}; diff --git a/src/discounts/components/DiscountRules/componenets/RuleFormModal/mocks.ts b/src/discounts/components/DiscountRules/componenets/RuleFormModal/mocks.ts index cb70fc4ca16..19419d5d98c 100644 --- a/src/discounts/components/DiscountRules/componenets/RuleFormModal/mocks.ts +++ b/src/discounts/components/DiscountRules/componenets/RuleFormModal/mocks.ts @@ -138,41 +138,611 @@ export const searchProductsMock = { edges: [ { node: { - id: "UHJvZHVjdDo3OQ==", - name: "Bean Juice", - }, - }, - { - node: { - id: "UHJvZHVjdDoxMTU=", - name: "Black Hoodie", + id: "UHJvZHVjdDo3Mg==", + name: "Apple Juice", + thumbnail: { + url: "https://feature-checkout-and-order-promotions.api.saleor.rocks/thumbnail/UHJvZHVjdE1lZGlhOjc=/256/", + __typename: "Image", + }, + variants: [ + { + id: "UHJvZHVjdFZhcmlhbnQ6MjAz", + name: "1l", + sku: "43226647", + channelListings: [ + { + channel: { + id: "Q2hhbm5lbDox", + isActive: true, + name: "Channel-USD", + currencyCode: "USD", + __typename: "Channel", + }, + price: { + amount: 5, + currency: "USD", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + { + channel: { + id: "Q2hhbm5lbDoy", + isActive: true, + name: "Channel-PLN", + currencyCode: "PLN", + __typename: "Channel", + }, + price: { + amount: 20, + currency: "PLN", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + ], + __typename: "ProductVariant", + }, + { + id: "UHJvZHVjdFZhcmlhbnQ6MjA0", + name: "2l", + sku: "80884671", + channelListings: [ + { + channel: { + id: "Q2hhbm5lbDox", + isActive: true, + name: "Channel-USD", + currencyCode: "USD", + __typename: "Channel", + }, + price: { + amount: 7, + currency: "USD", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + { + channel: { + id: "Q2hhbm5lbDoy", + isActive: true, + name: "Channel-PLN", + currencyCode: "PLN", + __typename: "Channel", + }, + price: { + amount: 28, + currency: "PLN", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + ], + __typename: "ProductVariant", + }, + { + id: "UHJvZHVjdFZhcmlhbnQ6MjAy", + name: "500ml", + sku: "93855755", + channelListings: [ + { + channel: { + id: "Q2hhbm5lbDox", + isActive: true, + name: "Channel-USD", + currencyCode: "USD", + __typename: "Channel", + }, + price: { + amount: 5, + currency: "USD", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + { + channel: { + id: "Q2hhbm5lbDoy", + isActive: true, + name: "Channel-PLN", + currencyCode: "PLN", + __typename: "Channel", + }, + price: { + amount: 20, + currency: "PLN", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + ], + __typename: "ProductVariant", + }, + ], + collections: [], + __typename: "Product", }, + __typename: "ProductCountableEdge", }, { node: { - id: "UHJvZHVjdDo3Mw==", - name: "Carrot Juice", + id: "UHJvZHVjdDo3NA==", + name: "Banana Juice", + thumbnail: { + url: "https://feature-checkout-and-order-promotions.api.saleor.rocks/thumbnail/UHJvZHVjdE1lZGlhOjk=/256/", + __typename: "Image", + }, + variants: [ + { + id: "UHJvZHVjdFZhcmlhbnQ6MjA5", + name: "1l", + sku: "27512590", + channelListings: [ + { + channel: { + id: "Q2hhbm5lbDox", + isActive: true, + name: "Channel-USD", + currencyCode: "USD", + __typename: "Channel", + }, + price: { + amount: 5, + currency: "USD", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + { + channel: { + id: "Q2hhbm5lbDoy", + isActive: true, + name: "Channel-PLN", + currencyCode: "PLN", + __typename: "Channel", + }, + price: { + amount: 20, + currency: "PLN", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + ], + __typename: "ProductVariant", + }, + { + id: "UHJvZHVjdFZhcmlhbnQ6MjEw", + name: "2l", + sku: "40636347", + channelListings: [ + { + channel: { + id: "Q2hhbm5lbDox", + isActive: true, + name: "Channel-USD", + currencyCode: "USD", + __typename: "Channel", + }, + price: { + amount: 7, + currency: "USD", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + { + channel: { + id: "Q2hhbm5lbDoy", + isActive: true, + name: "Channel-PLN", + currencyCode: "PLN", + __typename: "Channel", + }, + price: { + amount: 28, + currency: "PLN", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + ], + __typename: "ProductVariant", + }, + { + id: "UHJvZHVjdFZhcmlhbnQ6MjA4", + name: "500ml", + sku: "45328412", + channelListings: [ + { + channel: { + id: "Q2hhbm5lbDox", + isActive: true, + name: "Channel-USD", + currencyCode: "USD", + __typename: "Channel", + }, + price: { + amount: 5, + currency: "USD", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + { + channel: { + id: "Q2hhbm5lbDoy", + isActive: true, + name: "Channel-PLN", + currencyCode: "PLN", + __typename: "Channel", + }, + price: { + amount: 20, + currency: "PLN", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + ], + __typename: "ProductVariant", + }, + ], + collections: [], + __typename: "Product", }, + __typename: "ProductCountableEdge", }, { node: { - id: "UHJvZHVjdDo4OQ==", - name: "Code Division T-shirt", + id: "UHJvZHVjdDo3OQ==", + name: "Bean Juice", + thumbnail: { + url: "https://feature-checkout-and-order-promotions.api.saleor.rocks/thumbnail/UHJvZHVjdE1lZGlhOjE0/256/", + __typename: "Image", + }, + variants: [ + { + id: "UHJvZHVjdFZhcmlhbnQ6MjI1", + name: "2l", + sku: "21438542", + channelListings: [ + { + channel: { + id: "Q2hhbm5lbDox", + isActive: true, + name: "Channel-USD", + currencyCode: "USD", + __typename: "Channel", + }, + price: { + amount: 7, + currency: "USD", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + { + channel: { + id: "Q2hhbm5lbDoy", + isActive: true, + name: "Channel-PLN", + currencyCode: "PLN", + __typename: "Channel", + }, + price: { + amount: 28, + currency: "PLN", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + ], + __typename: "ProductVariant", + }, + { + id: "UHJvZHVjdFZhcmlhbnQ6MjIz", + name: "500ml", + sku: "57211177", + channelListings: [ + { + channel: { + id: "Q2hhbm5lbDox", + isActive: true, + name: "Channel-USD", + currencyCode: "USD", + __typename: "Channel", + }, + price: { + amount: 5, + currency: "USD", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + { + channel: { + id: "Q2hhbm5lbDoy", + isActive: true, + name: "Channel-PLN", + currencyCode: "PLN", + __typename: "Channel", + }, + price: { + amount: 20, + currency: "PLN", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + ], + __typename: "ProductVariant", + }, + { + id: "UHJvZHVjdFZhcmlhbnQ6MjI0", + name: "1l", + sku: "57423879", + channelListings: [ + { + channel: { + id: "Q2hhbm5lbDox", + isActive: true, + name: "Channel-USD", + currencyCode: "USD", + __typename: "Channel", + }, + price: { + amount: 5, + currency: "USD", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + { + channel: { + id: "Q2hhbm5lbDoy", + isActive: true, + name: "Channel-PLN", + currencyCode: "PLN", + __typename: "Channel", + }, + price: { + amount: 20, + currency: "PLN", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + ], + __typename: "ProductVariant", + }, + ], + collections: [], + __typename: "Product", }, + __typename: "ProductCountableEdge", }, { node: { - id: "UHJvZHVjdDo4NQ==", - name: "Colored Parrot Cushion", + id: "UHJvZHVjdDoxMTU=", + name: "Black Hoodie", + thumbnail: { + url: "https://feature-checkout-and-order-promotions.api.saleor.rocks/thumbnail/UHJvZHVjdE1lZGlhOjQ2/256/", + __typename: "Image", + }, + variants: [ + { + id: "UHJvZHVjdFZhcmlhbnQ6Mjk5", + name: "XL", + sku: "19230637", + channelListings: [ + { + channel: { + id: "Q2hhbm5lbDox", + isActive: true, + name: "Channel-USD", + currencyCode: "USD", + __typename: "Channel", + }, + price: { + amount: 5, + currency: "USD", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + { + channel: { + id: "Q2hhbm5lbDoy", + isActive: true, + name: "Channel-PLN", + currencyCode: "PLN", + __typename: "Channel", + }, + price: { + amount: 20, + currency: "PLN", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + ], + __typename: "ProductVariant", + }, + { + id: "UHJvZHVjdFZhcmlhbnQ6Mjk4", + name: "L", + sku: "22119503", + channelListings: [ + { + channel: { + id: "Q2hhbm5lbDox", + isActive: true, + name: "Channel-USD", + currencyCode: "USD", + __typename: "Channel", + }, + price: { + amount: 5, + currency: "USD", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + { + channel: { + id: "Q2hhbm5lbDoy", + isActive: true, + name: "Channel-PLN", + currencyCode: "PLN", + __typename: "Channel", + }, + price: { + amount: 20, + currency: "PLN", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + ], + __typename: "ProductVariant", + }, + { + id: "UHJvZHVjdFZhcmlhbnQ6MzAw", + name: "XXL", + sku: "61630747", + channelListings: [ + { + channel: { + id: "Q2hhbm5lbDox", + isActive: true, + name: "Channel-USD", + currencyCode: "USD", + __typename: "Channel", + }, + price: { + amount: 5, + currency: "USD", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + { + channel: { + id: "Q2hhbm5lbDoy", + isActive: true, + name: "Channel-PLN", + currencyCode: "PLN", + __typename: "Channel", + }, + price: { + amount: 20, + currency: "PLN", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + ], + __typename: "ProductVariant", + }, + { + id: "UHJvZHVjdFZhcmlhbnQ6Mjk2", + name: "S", + sku: "62783187", + channelListings: [ + { + channel: { + id: "Q2hhbm5lbDox", + isActive: true, + name: "Channel-USD", + currencyCode: "USD", + __typename: "Channel", + }, + price: { + amount: 5, + currency: "USD", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + { + channel: { + id: "Q2hhbm5lbDoy", + isActive: true, + name: "Channel-PLN", + currencyCode: "PLN", + __typename: "Channel", + }, + price: { + amount: 20, + currency: "PLN", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + ], + __typename: "ProductVariant", + }, + { + id: "UHJvZHVjdFZhcmlhbnQ6Mjk3", + name: "M", + sku: "91406604", + channelListings: [ + { + channel: { + id: "Q2hhbm5lbDox", + isActive: true, + name: "Channel-USD", + currencyCode: "USD", + __typename: "Channel", + }, + price: { + amount: 5, + currency: "USD", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + { + channel: { + id: "Q2hhbm5lbDoy", + isActive: true, + name: "Channel-PLN", + currencyCode: "PLN", + __typename: "Channel", + }, + price: { + amount: 20, + currency: "PLN", + __typename: "Money", + }, + __typename: "ProductVariantChannelListing", + }, + ], + __typename: "ProductVariant", + }, + ], + collections: [], + __typename: "Product", }, + __typename: "ProductCountableEdge", }, ], pageInfo: { - endCursor: "WyJsYWtlLXR1bmVzIl0=", - hasNextPage: false, + endCursor: "WyJwaW5lYXBwbGUtanVpY2UiXQ==", + hasNextPage: true, hasPreviousPage: false, - startCursor: "WyJiZWFuLWp1aWNlIl0=", + startCursor: "WyJhcHBsZS1qdWljZSJd", + __typename: "PageInfo", }, + __typename: "ProductCountableConnection", + }, + }, + extensions: { + cost: { + requestedQueryCost: 120, + maximumAvailable: 50000, }, }, }, diff --git a/src/discounts/components/DiscountRules/componenets/RuleFormModal/validationSchema.ts b/src/discounts/components/DiscountRules/componenets/RuleFormModal/validationSchema.ts index 977a4c54895..ce3e6d1b029 100644 --- a/src/discounts/components/DiscountRules/componenets/RuleFormModal/validationSchema.ts +++ b/src/discounts/components/DiscountRules/componenets/RuleFormModal/validationSchema.ts @@ -40,13 +40,14 @@ export const getValidationSchema = (intl: IntlShape) => }, ), conditions: z.array( - z - .object({ - type: z.string().nullable(), - condition: z.string(), - values: z.array(z.object({ label: z.string(), value: z.string() })), - }) - .optional(), + z.object({ + id: z.string().nullable(), + type: z.string(), + value: z + .array(z.object({ label: z.string(), value: z.string() })) + .or(z.string()) + .or(z.tuple([z.string(), z.string()])), + }), ), rewardValue: z .number({ diff --git a/src/discounts/components/DiscountRules/componenets/RulesList/components/RuleSummary/utils.ts b/src/discounts/components/DiscountRules/componenets/RulesList/components/RuleSummary/utils.ts index 363925e1af9..777080e74da 100644 --- a/src/discounts/components/DiscountRules/componenets/RulesList/components/RuleSummary/utils.ts +++ b/src/discounts/components/DiscountRules/componenets/RulesList/components/RuleSummary/utils.ts @@ -3,7 +3,7 @@ import { hueToPillColorLight, stringToHue, } from "@dashboard/components/Datagrid/customCells/PillCell"; -import { Condition, Rule } from "@dashboard/discounts/models"; +import { Condition, isArrayOfOptions, Rule } from "@dashboard/discounts/models"; import { ConditionType } from "@dashboard/discounts/types"; import { DefaultTheme, Option } from "@saleor/macaw-ui-next"; @@ -29,12 +29,14 @@ export const mapConditionToOption = ( conditions: Condition[], ): OptionWithConditionType[] => { return conditions.reduce((acc, condition) => { - acc.push( - ...condition.values.map(value => ({ - ...value, - type: condition.type!, - })), - ); + if (isArrayOfOptions(condition.value)) { + acc.push( + ...condition.value.map(value => ({ + ...value, + type: condition.id as ConditionType, + })), + ); + } return acc; }, []); @@ -53,6 +55,6 @@ export const conditionTypeToHue = ( export const hasNoRuleConditions = (rule: Rule) => { return ( !rule.conditions.length || - rule.conditions.every(condition => !condition.values.length) + rule.conditions.every(condition => !condition.value?.length) ); }; diff --git a/src/discounts/models/CatalogRule/prepareConditions.test.ts b/src/discounts/models/CatalogRule/prepareConditions.test.ts new file mode 100644 index 00000000000..023d8bebbba --- /dev/null +++ b/src/discounts/models/CatalogRule/prepareConditions.test.ts @@ -0,0 +1,56 @@ +import { prepareCatalogueRuleConditions } from "./prepareConditions"; + +describe("prepareCataloguePredicate", () => { + it("should return empty array when cataloguePredicate is empty", () => { + const cataloguePredicate = {}; + + const result = prepareCatalogueRuleConditions(cataloguePredicate, {}); + + expect(result).toEqual([]); + }); + + it("should return array of conditions when cataloguePredicate is not empty", () => { + const cataloguePredicate = { + OR: [ + { + productPredicate: { + ids: ["3", "4"], + AND: [ + { + name: { + eq: "test", + }, + }, + ], + }, + }, + { + collectionPredicate: { + ids: ["5", "6"], + }, + }, + ], + }; + + const result = prepareCatalogueRuleConditions(cataloguePredicate, {}); + + expect(result).toEqual([ + { + id: "product", + type: "is", + value: [ + { label: "3", value: "3" }, + { label: "4", value: "4" }, + ], + }, + { + id: "collection", + type: "is", + value: [ + { label: "5", value: "5" }, + { label: "6", value: "6" }, + ], + }, + ]); + }); +}); diff --git a/src/discounts/models/CatalogRule/prepareConditions.ts b/src/discounts/models/CatalogRule/prepareConditions.ts new file mode 100644 index 00000000000..4a306d24056 --- /dev/null +++ b/src/discounts/models/CatalogRule/prepareConditions.ts @@ -0,0 +1,42 @@ +import { CataloguePredicateAPI } from "@dashboard/discounts/types"; + +import { Condition } from "../Condition"; + +export function prepareCatalogueRuleConditions( + cataloguePredicate: CataloguePredicateAPI, + ruleConditionsOptionsDetailsMap: Record, +): Condition[] { + const toOptions = createToOptionMap(ruleConditionsOptionsDetailsMap); + + return Object.entries(cataloguePredicate) + .map(([key, value]) => { + if (["OR", "AND"].includes(key)) { + return prepareCatalogueRuleConditions( + value.reduce(toObject, {} as CataloguePredicateAPI), + ruleConditionsOptionsDetailsMap, + ); + } + + return { + id: key.split("Predicate")[0], + type: "is", // Catalog predicate always has only "is" condition type + value: value.ids?.map(toOptions) ?? [], + }; + }) + .filter(Boolean) + .flat() as Condition[]; +} + +function createToOptionMap( + ruleConditionsOptionsDetailsMap: Record, +) { + return (id: string) => ({ + label: ruleConditionsOptionsDetailsMap[id] || id, + value: id, + }); +} + +function toObject(acc: CataloguePredicateAPI, val: Record) { + acc = { ...acc, ...val }; + return acc; +} diff --git a/src/discounts/models/CatalogRule/preparePredicate.ts b/src/discounts/models/CatalogRule/preparePredicate.ts new file mode 100644 index 00000000000..f2123e044c2 --- /dev/null +++ b/src/discounts/models/CatalogRule/preparePredicate.ts @@ -0,0 +1,37 @@ +import { CataloguePredicateInput } from "@dashboard/graphql"; + +import { Condition, isArrayOfOptions } from "../Condition"; + +export function prepareCataloguePredicate( + conditions: Condition[], +): CataloguePredicateInput { + const ruleConditions = conditions + .map(condition => { + if (!condition.id) { + return undefined; + } + + return { + [`${condition.id}Predicate`]: { + ids: isArrayOfOptions(condition.value) + ? condition.value.map(val => val.value) + : [condition.value], + }, + }; + }) + .filter(Boolean) as CataloguePredicateInput[]; + + if (ruleConditions.length === 0) { + return {}; + } + + if (ruleConditions.length === 1) { + return { + ...ruleConditions[0], + }; + } + + return { + OR: ruleConditions, + }; +} diff --git a/src/discounts/models/Condition.test.ts b/src/discounts/models/Condition.test.ts deleted file mode 100644 index 9ff66e96edd..00000000000 --- a/src/discounts/models/Condition.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { CataloguePredicateAPI } from "../types"; -import { Condition } from "./Condition"; - -describe("Condition model", () => { - it("should transform API object to domain format", () => { - const condition = { - productPredicate: { - ids: ["prod_1", "prod_2"], - }, - } as CataloguePredicateAPI; - - expect(Condition.fromAPI(condition, {})).toMatchObject({ - condition: "is", - type: "product", - values: [ - { value: "prod_1", label: "prod_1" }, - { value: "prod_2", label: "prod_2" }, - ], - }); - }); - - it("should transform domain object to API object", () => { - const condition = new Condition("product", "is", [ - { value: "prod_1", label: "prod_1" }, - { value: "prod_2", label: "prod_2" }, - ]); - - expect(condition.toAPI()).toMatchObject({ - productPredicate: { - ids: ["prod_1", "prod_2"], - }, - }); - }); - - it("should return undefined when transforming domian object to API and values array is empty", () => { - const condition = new Condition("product", "is", []); - - expect(condition.toAPI()).toBeUndefined(); - }); -}); diff --git a/src/discounts/models/Condition.ts b/src/discounts/models/Condition.ts index 6903cfd014e..9637270fc89 100644 --- a/src/discounts/models/Condition.ts +++ b/src/discounts/models/Condition.ts @@ -1,82 +1,40 @@ -import { CataloguePredicateInput } from "@dashboard/graphql"; import { Option } from "@saleor/macaw-ui-next"; -import { CataloguePredicateAPI, ConditionType } from "../types"; +export type ConditionType = "is" | "between" | "lower" | "greater"; -export class Condition { - constructor( - public type: ConditionType | null, - public condition: "is", - public values: Option[], - ) {} +export type ConditionValue = Option[] | string | [string, string] | null; - public toAPI(): CataloguePredicateInput | undefined { - if (!this.type || !this.values.length) { - return undefined; - } - - return { - [`${this.type}Predicate`]: { - ids: this.values.map(val => val.value), - }, - }; - } - - public static empty(): Condition { - return new Condition(null, "is", []); - } - - public static fromFormValues(data: Condition): Condition { - return new Condition(data.type, data.condition, data.values); - } - - public static fromAPI( - condition: CataloguePredicateAPI, - ruleConditionsOptionsDetailsMap: Record, - ): Condition { - const toOptions = createToOptionMap(ruleConditionsOptionsDetailsMap); - - if (condition.productPredicate) { - return new Condition( - "product", - "is", - condition.productPredicate.ids.map(toOptions), - ); - } - - if (condition.categoryPredicate) { - return new Condition( - "category", - "is", - condition.categoryPredicate.ids.map(toOptions), - ); - } - - if (condition.collectionPredicate) { - return new Condition( - "collection", - "is", - condition.collectionPredicate.ids.map(toOptions), - ); - } - - if (condition.variantPredicate) { - return new Condition( - "variant", - "is", - condition.variantPredicate.ids.map(toOptions), - ); - } - - return new Condition(null, "is", []); - } +export interface Condition { + id: string | null; + type: ConditionType; + value: ConditionValue; } -function createToOptionMap( - ruleConditionsOptionsDetailsMap: Record, -) { - return (id: string) => ({ - label: ruleConditionsOptionsDetailsMap[id] || id, - value: id, - }); -} +export const createEmptyCodition = (): Condition => ({ + id: null, + type: "is", + value: null, +}); + +export const isString = ( + conditionValue: ConditionValue, +): conditionValue is string => { + return typeof conditionValue === "string"; +}; + +export const isTuple = ( + conditionValue: ConditionValue, +): conditionValue is [string, string] => { + return ( + Array.isArray(conditionValue) && + conditionValue.length === 2 && + typeof conditionValue[0] === "string" && + typeof conditionValue[1] === "string" + ); +}; + +export const isArrayOfOptions = ( + conditionValue: ConditionValue, +): conditionValue is Option[] => { + return Array.isArray(conditionValue) && !isTuple(conditionValue); +}; diff --git a/src/discounts/models/Rule.test.ts b/src/discounts/models/Rule.test.ts deleted file mode 100644 index e69e9538528..00000000000 --- a/src/discounts/models/Rule.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { - PromotionRuleDetailsFragment, - RewardValueTypeEnum, -} from "@dashboard/graphql"; - -import { Condition } from "./Condition"; -import { Rule } from "./Rule"; - -describe("Rule model", () => { - it("should transform domain object to API format", () => { - const rule = new Rule( - "rule_1", - "name", - '{"text":"description"}', - { label: "Channel 1", value: "channel_1" }, - 1, - RewardValueTypeEnum.FIXED, - [ - new Condition("product", "is", [ - { value: "prod_1", label: "prod_1" }, - { value: "prod_2", label: "prod_2" }, - ]), - new Condition("category", "is", [ - { value: "cat_1", label: "cat_1" }, - { value: "cat_2", label: "cat_2" }, - ]), - new Condition("collection", "is", [ - { value: "coll_1", label: "coll_1" }, - { value: "coll_2", label: "coll_2" }, - ]), - new Condition("variant", "is", [ - { value: "var_1", label: "var_1" }, - { value: "var_2", label: "var_2" }, - ]), - ], - ); - - expect(rule.toAPI()).toEqual({ - cataloguePredicate: { - OR: [ - { - productPredicate: { - ids: ["prod_1", "prod_2"], - }, - }, - { - categoryPredicate: { - ids: ["cat_1", "cat_2"], - }, - }, - { - collectionPredicate: { - ids: ["coll_1", "coll_2"], - }, - }, - { - variantPredicate: { - ids: ["var_1", "var_2"], - }, - }, - ], - }, - channels: ["channel_1"], - description: { text: "description" }, - name: "name", - rewardValue: 1, - rewardValueType: "FIXED", - }); - }); - - it("should transform API object to domain format", () => { - const rule = { - id: "rule_1", - name: "name", - description: { text: "description" }, - channels: [{ id: "channel_1", name: "Channel 1" }], - rewardValue: 1, - rewardValueType: RewardValueTypeEnum.FIXED, - cataloguePredicate: { - OR: [ - { - productPredicate: { - ids: ["prod_1", "prod_2"], - }, - }, - { - categoryPredicate: { - ids: ["cat_1", "cat_2"], - }, - }, - { - collectionPredicate: { - ids: ["coll_1", "coll_2"], - }, - }, - { - variantPredicate: { - ids: ["var_1", "var_2"], - }, - }, - ], - }, - } as PromotionRuleDetailsFragment; - - expect(Rule.fromAPI(rule, {})).toMatchObject({ - id: "rule_1", - name: "name", - channel: { label: "Channel 1", value: "channel_1" }, - description: '{"text":"description"}', - rewardValue: 1, - rewardValueType: RewardValueTypeEnum.FIXED, - conditions: [ - { - condition: "is", - type: "product", - values: [ - { value: "prod_1", label: "prod_1" }, - { value: "prod_2", label: "prod_2" }, - ], - }, - { - condition: "is", - type: "category", - values: [ - { value: "cat_1", label: "cat_1" }, - { value: "cat_2", label: "cat_2" }, - ], - }, - { - condition: "is", - type: "collection", - values: [ - { value: "coll_1", label: "coll_1" }, - { value: "coll_2", label: "coll_2" }, - ], - }, - { - condition: "is", - type: "variant", - values: [ - { value: "var_1", label: "var_1" }, - { value: "var_2", label: "var_2" }, - ], - }, - ], - }); - }); -}); diff --git a/src/discounts/models/Rule.ts b/src/discounts/models/Rule.ts index f02538b743d..ca7d647f67c 100644 --- a/src/discounts/models/Rule.ts +++ b/src/discounts/models/Rule.ts @@ -1,120 +1,26 @@ -/* eslint-disable @typescript-eslint/no-extraneous-class */ -import { - CataloguePredicateInput, - PromotionRuleDetailsFragment, - PromotionRuleInput, - RewardValueTypeEnum, -} from "@dashboard/graphql"; +import { RewardValueTypeEnum } from "@dashboard/graphql"; import { Option } from "@saleor/macaw-ui-next"; -import { CataloguePredicateAPI } from "../types"; import { Condition } from "./Condition"; -export class Rule { - constructor( - public id: string, - public name: string, - public description: string | null, - public channel: Option | null, - public rewardValue: number, - public rewardValueType: RewardValueTypeEnum, - public conditions: Condition[], - ) {} - - public toAPI(): PromotionRuleInput { - return { - name: this.name, - description: this.description ? JSON.parse(this.description) : null, - channels: this?.channel ? [this.channel.value] : [], - rewardValue: this.rewardValue, - rewardValueType: this.rewardValueType, - cataloguePredicate: prepareCataloguePredicate(this.conditions), - }; - } - - public static empty(): Rule { - return new Rule("", "", "", null, 0, RewardValueTypeEnum.PERCENTAGE, []); - } - - public static fromAPI( - rule: PromotionRuleDetailsFragment, - ruleConditionsOptionsDetailsMap: Record, - ): Rule { - return new Rule( - rule.id, - rule.name ?? "", - rule.description ? JSON.stringify(rule.description) : "", - rule?.channels?.length - ? { label: rule?.channels[0].name, value: rule?.channels[0].id } - : null, - rule.rewardValue ?? null, - rule.rewardValueType ?? RewardValueTypeEnum.FIXED, - prepareRuleConditions( - rule.cataloguePredicate, - ruleConditionsOptionsDetailsMap, - ), - ); - } - - public static fromFormValues(data: Rule): Rule { - return new Rule( - data.id, - data.name, - data.description, - data.channel, - data.rewardValue, - data.rewardValueType, - data.conditions.map(condition => Condition.fromFormValues(condition)), - ); - } +export interface Rule { + id: string; + name: string; + description: string | null; + channel: Option | null; + rewardType: null; // to be replaced by RewardTypeEnum when API return this field + rewardValue: number; + rewardValueType: RewardValueTypeEnum; + conditions: Condition[]; } -function prepareCataloguePredicate( - conditions: Condition[], -): CataloguePredicateInput { - const ruleConditions = conditions - .map(condition => condition.toAPI()) - .filter(Boolean) as CataloguePredicateInput[]; - - if (ruleConditions.length === 0) { - return {}; - } - - if (ruleConditions.length === 1) { - return { - ...ruleConditions[0], - }; - } - - return { - OR: ruleConditions, - }; -} - -function prepareRuleConditions( - cataloguePredicate: CataloguePredicateAPI, - ruleConditionsOptionsDetailsMap: Record, -): Condition[] { - return Object.entries(cataloguePredicate) - .map(([key, value]) => { - if (key === "OR") { - return prepareRuleConditions( - value.reduce( - (acc: CataloguePredicateAPI, val: Record) => { - acc = { ...acc, ...val }; - return acc; - }, - {} as CataloguePredicateAPI, - ), - ruleConditionsOptionsDetailsMap, - ); - } - - return Condition.fromAPI( - { [key]: value } as unknown as CataloguePredicateAPI, - ruleConditionsOptionsDetailsMap, - ); - }) - .filter(Boolean) - .flat() as Condition[]; -} +export const createEmptyRule = (): Rule => ({ + id: "", + name: "", + description: "", + channel: null, + rewardType: null, + rewardValue: 0, + rewardValueType: RewardValueTypeEnum.FIXED, + conditions: [], +}); diff --git a/src/discounts/models/helpers.ts b/src/discounts/models/helpers.ts new file mode 100644 index 00000000000..7d8f583c7b7 --- /dev/null +++ b/src/discounts/models/helpers.ts @@ -0,0 +1,107 @@ +import { + DecimalFilterInput, + PromotionRuleDetailsFragment, + PromotionRuleInput, + RewardValueTypeEnum, +} from "@dashboard/graphql"; + +import { Condition, ConditionType, isTuple } from "./Condition"; +import { Rule } from "./Rule"; + +export const createBaseAPIInput = (data: Rule): PromotionRuleInput => { + return { + name: data.name, + description: data.description ? JSON.parse(data.description) : null, + channels: data?.channel ? [data.channel.value] : [], + rewardValue: data.rewardValue, + rewardValueType: data.rewardValueType, + }; +}; + +export const createBaseRuleInputFromAPI = ( + data: PromotionRuleDetailsFragment, +): Omit => { + return { + id: data.id, + name: data.name ?? "", + description: data.description ? JSON.stringify(data.description) : "", + // For now Dashboard supports only one channel per rule + // due to API product variant filtering limitations + // TODO: Add support for multiple channels + channel: data?.channels?.length + ? { label: data?.channels[0].name, value: data?.channels[0].id } + : null, + rewardType: null, // to be replaced when API return this field + rewardValue: data.rewardValue ?? null, + rewardValueType: data.rewardValueType ?? RewardValueTypeEnum.FIXED, + }; +}; + +export const createAPIWhereInput = (condition: Condition) => { + const label = condition.type; + const value = condition.value; + + if (label === "lower") { + return { range: { lte: value } }; + } + if (label === "greater") { + return { range: { gte: value } }; + } + + if (isTuple(value) && label === "between") { + const [gte, lte] = value; + return { range: { lte, gte } }; + } + + return { eq: value }; +}; + +export function getConditionType( + conditionValue: DecimalFilterInput, +): ConditionType { + if (conditionValue.eq) { + return "is"; + } + + if (conditionValue.range) { + if (conditionValue.range.lte && conditionValue.range.gte) { + return "between"; + } + + if (conditionValue.range.lte) { + return "lower"; + } + + if (conditionValue.range.gte) { + return "greater"; + } + } + + return "is"; +} + +export function getConditionValue(conditionValue: DecimalFilterInput) { + if (conditionValue.eq) { + return conditionValue.eq; + } + + if (conditionValue.oneOf) { + return conditionValue.oneOf; + } + + if (conditionValue.range) { + if (conditionValue.range.lte && conditionValue.range.gte) { + return [conditionValue.range.gte, conditionValue.range.lte]; + } + + if (conditionValue.range.lte) { + return conditionValue.range.lte; + } + + if (conditionValue.range.gte) { + return conditionValue.range.gte; + } + } + + return conditionValue.eq; +} diff --git a/src/discounts/models/index.ts b/src/discounts/models/index.ts index f749823207c..71b8fb38ef5 100644 --- a/src/discounts/models/index.ts +++ b/src/discounts/models/index.ts @@ -1,2 +1,3 @@ export * from "./Condition"; export * from "./Rule"; +export * from "./transformRule"; diff --git a/src/discounts/models/transformRule.ts b/src/discounts/models/transformRule.ts new file mode 100644 index 00000000000..4460600edc4 --- /dev/null +++ b/src/discounts/models/transformRule.ts @@ -0,0 +1,52 @@ +import { + PromotionRuleDetailsFragment, + PromotionRuleInput, +} from "@dashboard/graphql"; + +import { prepareCatalogueRuleConditions } from "./CatalogRule/prepareConditions"; +import { prepareCataloguePredicate } from "./CatalogRule/preparePredicate"; +import { createBaseAPIInput, createBaseRuleInputFromAPI } from "./helpers"; +import { Rule } from "./Rule"; + +export const mapAPIRuleToForm = ( + type: "catalog" | null | undefined, // to be replaced by PromotionTypeEnum when API return this field + rule: PromotionRuleDetailsFragment, + labelMap: Record, +): Rule => { + const baseRuleData = createBaseRuleInputFromAPI(rule); + + if (!type) { + return { + ...baseRuleData, + conditions: [], + }; + } + + const catalogueConditions = prepareCatalogueRuleConditions( + rule.cataloguePredicate, + labelMap, + ); + return { + ...baseRuleData, + conditions: catalogueConditions, + }; +}; + +export const toAPI = + ( + discountType: "catalog" | null | undefined, // to be replaced by PromotionTypeEnum when API return this field + ) => + (rule: Rule): PromotionRuleInput => { + const base = createBaseAPIInput(rule); + + if (!discountType) { + return base; + } + + const cataloguePredicate = prepareCataloguePredicate(rule.conditions); + + return { + ...base, + cataloguePredicate, + }; + }; diff --git a/src/discounts/views/DiscountCreate/DiscountCreate.tsx b/src/discounts/views/DiscountCreate/DiscountCreate.tsx index 97a6974c6be..4785408ad33 100644 --- a/src/discounts/views/DiscountCreate/DiscountCreate.tsx +++ b/src/discounts/views/DiscountCreate/DiscountCreate.tsx @@ -13,7 +13,7 @@ import { getMutationErrors } from "@dashboard/misc"; import React from "react"; import { useIntl } from "react-intl"; -import { createHandler } from "./handlers"; +import { useDiscountCreate } from "./handlers"; export const DiscountCreate = () => { const { availableChannels } = useAppChannel(false); @@ -38,7 +38,7 @@ export const DiscountCreate = () => { }, }); - const handlePromotionCreate = createHandler(variables => + const handlePromotionCreate = useDiscountCreate(variables => promotionCreate({ variables }), ); diff --git a/src/discounts/views/DiscountCreate/handlers.ts b/src/discounts/views/DiscountCreate/handlers.ts index 7db2dacb7b6..f667059057d 100644 --- a/src/discounts/views/DiscountCreate/handlers.ts +++ b/src/discounts/views/DiscountCreate/handlers.ts @@ -1,4 +1,5 @@ import { FetchResult } from "@apollo/client"; +import { toAPI } from "@dashboard/discounts/models"; import { DiscoutFormData } from "@dashboard/discounts/types"; import { PromotionCreateMutation, @@ -6,7 +7,7 @@ import { } from "@dashboard/graphql"; import { getMutationErrors, joinDateTime } from "@dashboard/misc"; -export const createHandler = ( +export const useDiscountCreate = ( create: ( varaibles: PromotionCreateMutationVariables, ) => Promise>, @@ -20,7 +21,7 @@ export const createHandler = ( ? joinDateTime(data.dates.endDate, data.dates.endTime) : null, startDate: joinDateTime(data.dates.startDate, data.dates.startTime), - rules: data.rules.map(rule => rule.toAPI()), + rules: data.rules.map(toAPI("catalog")), }, }); diff --git a/src/discounts/views/DiscountDetails/handlers.ts b/src/discounts/views/DiscountDetails/handlers.ts index 3141c8a45bc..a97691a6f07 100644 --- a/src/discounts/views/DiscountDetails/handlers.ts +++ b/src/discounts/views/DiscountDetails/handlers.ts @@ -1,5 +1,5 @@ import { FetchResult } from "@apollo/client"; -import { Rule } from "@dashboard/discounts/models"; +import { Rule, toAPI } from "@dashboard/discounts/models"; import { PromotionDetailsFragment, PromotionRuleCreateErrorFragment, @@ -69,7 +69,7 @@ export const createRuleUpdateHandler = ( const ruleChannels: string[] = ruleData?.channels?.map(channel => channel.id) ?? []; - const { channels, ...input } = data.toAPI(); + const { channels, ...input } = toAPI("catalog")(data); const response = await updateRule({ id: data.id!, @@ -97,7 +97,7 @@ export const createRuleCreateHandler = ( ) => Promise>, ) => { return async (data: Rule) => { - const ruleData = data.toAPI(); + const ruleData = toAPI("catalog")(data); const response = await createRule({ input: { From bd98e772c3be3b9f0ed105994c02b837b44bc9ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chy=C5=82a?= Date: Mon, 29 Jan 2024 15:41:16 +0100 Subject: [PATCH 2/2] Add changeset --- .changeset/clean-meals-obey.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clean-meals-obey.md diff --git a/.changeset/clean-meals-obey.md b/.changeset/clean-meals-obey.md new file mode 100644 index 00000000000..6c5cc0f8608 --- /dev/null +++ b/.changeset/clean-meals-obey.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": minor +--- + +Refactor Rule model