diff --git a/.changeset/eleven-dolls-doubt.md b/.changeset/eleven-dolls-doubt.md new file mode 100644 index 00000000000..14f663cc667 --- /dev/null +++ b/.changeset/eleven-dolls-doubt.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": patch +--- + +Use new promotion API to create and update discounts diff --git a/.changeset/empty-falcons-boil.md b/.changeset/empty-falcons-boil.md index 726fea08faa..756974c2a4a 100644 --- a/.changeset/empty-falcons-boil.md +++ b/.changeset/empty-falcons-boil.md @@ -1,5 +1,5 @@ --- -"saleor-dashboard": minor +"saleor-dashboard": patch --- Introduce intial component for catalog discounts diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index fb7b422a7e4..f9711b68e76 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -1134,6 +1134,9 @@ "context": "references attribute type", "string": "References" }, + "5dOOAB": { + "string": "Successfully created discount" + }, "5elC9k": { "context": "taxes section name", "string": "Taxes" @@ -1327,6 +1330,9 @@ "context": "attribute value deleted", "string": "Value deleted" }, + "7Hdiw2": { + "string": "Rule name is required" + }, "7J1ZKs": { "context": "button", "string": "Back to apps list" @@ -1614,6 +1620,9 @@ "context": "cancel button", "string": "Cancel order" }, + "9d24PR": { + "string": "Create rule" + }, "9eC0MZ": { "context": "collection", "string": "Hidden" @@ -1677,6 +1686,9 @@ "context": "product discount removed title", "string": "{productName} discount was removed by" }, + "A7W4KO": { + "string": "Catalog rule" + }, "A9QSur": { "context": "area units type", "string": "Area" @@ -1815,6 +1827,9 @@ "context": "pint unit", "string": "pint" }, + "B0fwAj": { + "string": "and {itemsLength} more" + }, "B2LE7A": { "context": "menu item loading", "string": "working..." @@ -1989,6 +2004,9 @@ "context": "card header title", "string": "Country list" }, + "CFlmRP": { + "string": "Rule reword value is required" + }, "CG+awx": { "context": "dialog content", "string": "Which address would you like to use as shipping address for selected customer:" @@ -3175,6 +3193,9 @@ "context": "delete attribute value", "string": "Are you sure you want to delete \"{name}\" value?" }, + "JyaQcP": { + "string": "Rule reword value must be less than 100" + }, "K+vjtE": { "string": "Search Variants" }, @@ -3491,6 +3512,9 @@ "context": "customers section name", "string": "Customers" }, + "MdFdbd": { + "string": "Delete rule" + }, "MewrtN": { "context": "section header", "string": "Fulfillment" @@ -3624,6 +3648,9 @@ "context": "order line discount updated title", "string": "{productName} discount was updated by" }, + "NgRa6m": { + "string": "Discount of {value} on the purchase of {items} through the {channel}" + }, "NhQboB": { "context": "dialog header", "string": "Saleor couldn’t cancel order" @@ -5114,6 +5141,9 @@ "context": "section header", "string": "All Media" }, + "XVuPMw": { + "string": "Are you sure you want to delete this rule?" + }, "XWGZLL": { "context": "transaction reference subtitle", "string": "Transaction reference" @@ -6174,6 +6204,9 @@ "context": "table head", "string": "Category Name" }, + "fXdkiI": { + "string": "is" + }, "fbH51z": { "context": "Amount error message", "string": "Amount cannot be bigger than max refund" @@ -6182,6 +6215,9 @@ "context": "order history message", "string": "Products were deleted from an order" }, + "fg8dzN": { + "string": "Add condition" + }, "fgHLXc": { "context": "attribute value", "string": "Value" @@ -8350,6 +8386,9 @@ "v1pNHW": { "string": "Attribute Class" }, + "v1vJ77": { + "string": "Edit rule" + }, "v2+u4c": { "context": "card subtitle", "string": "Assign and sort warehouses that will be used in this channel (warehouses can be assigned in multiple channels)." @@ -8526,6 +8565,9 @@ "w6kcxY": { "string": "What happens if I approve?" }, + "w7jT4W": { + "string": "No channels selected" + }, "w9xgN9": { "context": "see error log label in notification", "string": "See error log" @@ -8966,6 +9008,9 @@ "context": "attribute type", "string": "Content Attribute" }, + "zehNKT": { + "string": "Rule has error, open rule to see details" + }, "zf48rQ": { "context": "sum of captured amount of all transactions", "string": "Total captured" diff --git a/package-lock.json b/package-lock.json index bd603ebef08..83a965fb683 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@graphiql/plugin-explorer": "^0.1.12", "@graphiql/react": "^0.15.0", "@graphiql/toolkit": "^0.8.0", + "@hookform/resolvers": "^3.3.2", "@material-ui/core": "^4.12.4", "@material-ui/icons": "^4.11.3", "@material-ui/lab": "^4.0.0-alpha.61", @@ -92,7 +93,8 @@ "tslib": "^2.4.1", "url-join": "^4.0.1", "use-react-router": "^1.0.7", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "zod": "^3.22.4" }, "devDependencies": { "@babel/cli": "^7.5.5", @@ -6120,6 +6122,14 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@hookform/resolvers": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz", + "integrity": "sha512-Tw+GGPnBp+5DOsSg4ek3LCPgkBOuOgS5DsDV7qsWNH9LZc433kgsWICjlsh2J9p04H2K66hsXPPb9qn9ILdUtA==", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", @@ -43822,9 +43832,9 @@ } }, "node_modules/zod": { - "version": "3.19.1", - "dev": true, - "license": "MIT", + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -48163,6 +48173,11 @@ "@hapi/hoek": "^9.0.0" } }, + "@hookform/resolvers": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz", + "integrity": "sha512-Tw+GGPnBp+5DOsSg4ek3LCPgkBOuOgS5DsDV7qsWNH9LZc433kgsWICjlsh2J9p04H2K66hsXPPb9qn9ILdUtA==" + }, "@humanwhocodes/config-array": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", @@ -73708,8 +73723,9 @@ } }, "zod": { - "version": "3.19.1", - "dev": true + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==" }, "zwitch": { "version": "2.0.4", diff --git a/package.json b/package.json index aa50a067574..f0bfb643b5c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@graphiql/plugin-explorer": "^0.1.12", "@graphiql/react": "^0.15.0", "@graphiql/toolkit": "^0.8.0", + "@hookform/resolvers": "^3.3.2", "@material-ui/core": "^4.12.4", "@material-ui/icons": "^4.11.3", "@material-ui/lab": "^4.0.0-alpha.61", @@ -100,7 +101,8 @@ "tslib": "^2.4.1", "url-join": "^4.0.1", "use-react-router": "^1.0.7", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "zod": "^3.22.4" }, "devDependencies": { "@babel/cli": "^7.5.5", diff --git a/src/components/Combobox/components/Multiselect.tsx b/src/components/Combobox/components/Multiselect.tsx index 0202d137ab5..339dbf51f85 100644 --- a/src/components/Combobox/components/Multiselect.tsx +++ b/src/components/Combobox/components/Multiselect.tsx @@ -6,7 +6,7 @@ import { DynamicMultiselectProps, Option, } from "@saleor/macaw-ui-next"; -import React, { useEffect, useRef, useState } from "react"; +import React, { forwardRef, useEffect, useRef, useState } from "react"; import { useIntl } from "react-intl"; import { useCombbobxCustomOption } from "../hooks/useCombbobxCustomOption"; @@ -22,79 +22,87 @@ type MultiselectProps = Omit, "onChange"> & { onChange: (event: ChangeEvent) => void; }; -export const Multiselect = ({ - disabled, - options, - onChange, - fetchOptions, - value, - alwaysFetchOnFocus = false, - allowCustomValues = false, - loading, - fetchMore, - size = "small", - ...rest -}: MultiselectProps) => { - const intl = useIntl(); - const inputValue = useRef(""); +export const Multiselect = forwardRef( + ( + { + disabled, + options, + onChange, + fetchOptions, + value, + alwaysFetchOnFocus = false, + allowCustomValues = false, + loading, + fetchMore, + size = "small", + ...rest + }, + ref, + ) => { + const intl = useIntl(); + const inputValue = useRef(""); - const [selectedValues, setSelectedValues] = useState(value); + const [selectedValues, setSelectedValues] = useState(value); - useEffect(() => { - setSelectedValues(value); - }, [value]); + useEffect(() => { + setSelectedValues(value); + }, [value]); - const { handleFetchMore, handleFocus, handleInputChange } = - useComboboxHandlers({ - fetchOptions, - alwaysFetchOnFocus, - fetchMore, + const { handleFetchMore, handleFocus, handleInputChange } = + useComboboxHandlers({ + fetchOptions, + alwaysFetchOnFocus, + fetchMore, + }); + + const { customValueLabel, customValueOption } = useCombbobxCustomOption({ + query: inputValue.current, + allowCustomValues, + selectedValue: selectedValues, }); - const { customValueLabel, customValueOption } = useCombbobxCustomOption({ - query: inputValue.current, - allowCustomValues, - selectedValue: selectedValues, - }); + const handleOnChange = (values: Option[]) => { + const hasCustomValue = values.find(value => + value.label.includes(customValueLabel), + ); + const valuesWithCustom = values.map(toWithCustomValues(customValueLabel)); - const handleOnChange = (values: Option[]) => { - const hasCustomValue = values.find(value => - value.label.includes(customValueLabel), - ); - const valuesWithCustom = values.map(toWithCustomValues(customValueLabel)); + onChange({ + target: { + value: valuesWithCustom, + name: rest.name ?? "", + }, + }); - onChange({ - target: { - value: valuesWithCustom, - name: rest.name ?? "", - }, - }); + inputValue.current = ""; - inputValue.current = ""; + if (hasCustomValue) { + fetchOptions(""); + } + }; - if (hasCustomValue) { - fetchOptions(""); - } - }; + return ( + { + inputValue.current = value; + handleInputChange(value); + }} + onFocus={handleFocus} + onScrollEnd={handleFetchMore} + loading={loading || fetchMore?.hasMore || fetchMore?.loading} + locale={{ + loadingText: intl.formatMessage(commonMessages.loading), + }} + size={size} + {...rest} + /> + ); + }, +); - return ( - { - inputValue.current = value; - handleInputChange(value); - }} - onFocus={handleFocus} - onScrollEnd={handleFetchMore} - loading={loading || fetchMore?.hasMore || fetchMore?.loading} - locale={{ - loadingText: intl.formatMessage(commonMessages.loading), - }} - size={size} - {...rest} - /> - ); -}; +Multiselect.displayName = "Multiselect"; diff --git a/src/components/Combobox/hooks/useComboboxHandlers.ts b/src/components/Combobox/hooks/useComboboxHandlers.ts index 509d1781721..877f17479df 100644 --- a/src/components/Combobox/hooks/useComboboxHandlers.ts +++ b/src/components/Combobox/hooks/useComboboxHandlers.ts @@ -1,7 +1,7 @@ import { DEFAULT_INITIAL_SEARCH_DATA } from "@dashboard/config"; import useDebounce from "@dashboard/hooks/useDebounce"; import { FetchMoreProps } from "@dashboard/types"; -import { useRef } from "react"; +import { useCallback, useRef } from "react"; export const useComboboxHandlers = ({ fetchOptions, @@ -14,11 +14,12 @@ export const useComboboxHandlers = ({ }) => { const mounted = useRef(false); - const debouncedFetchOptions = useRef( + const debouncedFetchOptions = useCallback( useDebounce(async (value: string) => { fetchOptions(value); }, 500), - ).current; + [fetchOptions], + ); const handleFetchMore = () => { if (fetchMore?.hasMore) { diff --git a/src/discounts/components/DiscountCreateForm/DiscountCreateForm.tsx b/src/discounts/components/DiscountCreateForm/DiscountCreateForm.tsx new file mode 100644 index 00000000000..6fc84a0d452 --- /dev/null +++ b/src/discounts/components/DiscountCreateForm/DiscountCreateForm.tsx @@ -0,0 +1,69 @@ +import { Rule } from "@dashboard/discounts/models"; +import { DiscoutFormData } from "@dashboard/discounts/types"; +import { RichTextContext } from "@dashboard/utils/richText/context"; +import useRichText from "@dashboard/utils/richText/useRichText"; +import { zodResolver } from "@hookform/resolvers/zod"; +import React, { ReactNode } from "react"; +import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; +import { useIntl } from "react-intl"; + +import { useRulesHandlers } from "./hooks/useRulesHandlers"; +import { initialFormValues } from "./initialFormValues"; +import { getValidationSchema } from "./validationSchema"; + +interface CreateFormRenderProps { + rules: Rule[]; + onDeleteRule: (ruleDeleteIndex: number) => void; + onRuleSubmit: (data: Rule, ruleEditIndex: number | null) => void; + submitHandler: () => void; +} + +interface DiscountCreateFormProps { + children: (renderProps: CreateFormRenderProps) => ReactNode; + onSubmit: (data: DiscoutFormData) => void; +} + +export const DiscountCreateForm = ({ + children, + onSubmit, +}: DiscountCreateFormProps) => { + const intl = useIntl(); + + const methods = useForm({ + mode: "onBlur", + values: initialFormValues, + resolver: zodResolver(getValidationSchema(intl)), + }); + + const richText = useRichText({ + initial: "", + loading: false, + triggerChange: methods.trigger, + }); + + const { rules, onDeleteRule, onRuleSubmit } = useRulesHandlers(); + + const handleSubmit: SubmitHandler = data => { + onSubmit({ + ...data, + rules, + }); + }; + + const submitHandlerWithValidation = methods.handleSubmit(handleSubmit); + + return ( + + +
+ {children({ + onDeleteRule, + onRuleSubmit, + submitHandler: submitHandlerWithValidation, + rules, + })} +
+
+
+ ); +}; diff --git a/src/discounts/components/DiscountCreateForm/hooks/useRulesHandlers.test.ts b/src/discounts/components/DiscountCreateForm/hooks/useRulesHandlers.test.ts new file mode 100644 index 00000000000..a1350635048 --- /dev/null +++ b/src/discounts/components/DiscountCreateForm/hooks/useRulesHandlers.test.ts @@ -0,0 +1,81 @@ +import { Rule } from "@dashboard/discounts/models"; +import { RewardValueTypeEnum } from "@dashboard/graphql"; +import { act, renderHook } from "@testing-library/react-hooks"; + +import { useRulesHandlers } from "./useRulesHandlers"; + +const rule = { + name: "Rule 1", + description: "", + channel: { label: "Channel 1", value: "channel-1" }, + rewardValue: 10, + rewardValueType: RewardValueTypeEnum.FIXED, + conditions: [], +} as unknown as Rule; + +describe("DiscountCreateForm useRulesHandlers", () => { + it("should allow to add new rule ", () => { + // Arrange + const { result } = renderHook(() => useRulesHandlers()); + + // Act + act(() => { + result.current.onRuleSubmit({ ...rule } as Rule, null); + }); + + // Assert + expect(result.current.rules).toEqual([rule]); + }); + + it("should allow to edit rule at index", () => { + // Arrange + const { result } = renderHook(() => useRulesHandlers()); + + const rule = { + name: "Rule 1", + description: "", + channel: { label: "Channel 1", value: "channel-1" }, + rewardValue: 10, + rewardValueType: RewardValueTypeEnum.FIXED, + conditions: [], + } as unknown as Rule; + + // Act + act(() => { + result.current.onRuleSubmit(rule, null); + }); + + act(() => { + result.current.onRuleSubmit({ ...rule, name: "Rule 2" } as Rule, 0); + }); + + // Assert + expect(result.current.rules).toEqual([{ ...rule, name: "Rule 2" }]); + }); + + it("should allow to delete rule at index", () => { + // Arrange + const { result } = renderHook(() => useRulesHandlers()); + + const rule = { + name: "Rule 1", + description: "", + channel: { label: "Channel 1", value: "channel-1" }, + rewardValue: 10, + rewardValueType: RewardValueTypeEnum.FIXED, + conditions: [], + } as unknown as Rule; + + // Act + act(() => { + result.current.onRuleSubmit(rule, null); + }); + + act(() => { + result.current.onDeleteRule(0); + }); + + // Assert + expect(result.current.rules).toEqual([]); + }); +}); diff --git a/src/discounts/components/DiscountCreateForm/hooks/useRulesHandlers.ts b/src/discounts/components/DiscountCreateForm/hooks/useRulesHandlers.ts new file mode 100644 index 00000000000..2fa4de43e62 --- /dev/null +++ b/src/discounts/components/DiscountCreateForm/hooks/useRulesHandlers.ts @@ -0,0 +1,29 @@ +import { Rule } from "@dashboard/discounts/models"; +import { useState } from "react"; + +export const useRulesHandlers = () => { + const [rules, setRules] = useState([]); + + 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; + return rules; + }); + } else { + setRules([...rules, ruleObj]); + } + }; + + return { + rules, + onDeleteRule, + onRuleSubmit, + }; +}; diff --git a/src/discounts/components/DiscountCreateForm/index.ts b/src/discounts/components/DiscountCreateForm/index.ts new file mode 100644 index 00000000000..a39007d631b --- /dev/null +++ b/src/discounts/components/DiscountCreateForm/index.ts @@ -0,0 +1 @@ +export * from "./DiscountCreateForm"; diff --git a/src/discounts/components/DiscountCreateForm/initialFormValues.ts b/src/discounts/components/DiscountCreateForm/initialFormValues.ts new file mode 100644 index 00000000000..9d8c9cc23e7 --- /dev/null +++ b/src/discounts/components/DiscountCreateForm/initialFormValues.ts @@ -0,0 +1,14 @@ +import { DiscoutFormData } from "@dashboard/discounts/types"; + +export const initialFormValues: DiscoutFormData = { + name: "", + description: "", + dates: { + endDate: "", + endTime: "", + hasEndDate: false, + startDate: "", + startTime: "", + }, + rules: [], +}; diff --git a/src/discounts/components/DiscountCreateForm/validationSchema.ts b/src/discounts/components/DiscountCreateForm/validationSchema.ts new file mode 100644 index 00000000000..679b7213aca --- /dev/null +++ b/src/discounts/components/DiscountCreateForm/validationSchema.ts @@ -0,0 +1,39 @@ +import { defineMessages, IntlShape } from "react-intl"; +import * as z from "zod"; + +const validationMessages = defineMessages({ + nameRequired: { + id: "7Hdiw2", + defaultMessage: "Rule name is required", + }, +}); + +export const getValidationSchema = (intl: IntlShape) => { + return z.object({ + name: z + .string() + .min(1, intl.formatMessage(validationMessages.nameRequired)), + description: z.string().optional(), + dates: z + .object({ + endDate: z.string().optional(), + endTime: z.string().optional(), + hasEndDate: z.boolean().optional(), + startDate: z.string().optional(), + startTime: z.string().optional(), + }) + .refine( + data => { + if (data.hasEndDate && data.endDate && !data.startDate) { + return false; + } + return true; + }, + { + message: "Start date is required when end date is provided", + path: ["startDate"], + }, + ), + rules: z.array(z.any()).optional(), + }); +}; diff --git a/src/discounts/components/DiscountCreatePage/DiscountCreatePage.stories.tsx b/src/discounts/components/DiscountCreatePage/DiscountCreatePage.stories.tsx index 29adcecf367..396698d7d8e 100644 --- a/src/discounts/components/DiscountCreatePage/DiscountCreatePage.stories.tsx +++ b/src/discounts/components/DiscountCreatePage/DiscountCreatePage.stories.tsx @@ -1,20 +1,40 @@ +import { MockedProvider } from "@apollo/client/testing"; import { channelsList } from "@dashboard/channels/fixtures"; import React from "react"; +import { + searchCategoriesMock, + searchCollectionsMock, + searchProductsMock, + searchVariantsMock, +} from "../DiscountRules/componenets/RuleFormModal/mocks"; import { DiscountCreatePage, DiscountCreatePageProps, } from "./DiscountCreatePage"; const props: DiscountCreatePageProps = { - channels: channelsList, + channels: [channelsList[0]], disabled: false, onBack: () => undefined, onSubmit: () => undefined, + errors: [], + submitButtonState: "default", }; export default { title: "Discounts / Discounts create page", }; -export const Default = () => ; +export const Default = () => ( + + + +); diff --git a/src/discounts/components/DiscountCreatePage/DiscountCreatePage.tsx b/src/discounts/components/DiscountCreatePage/DiscountCreatePage.tsx index 83fdee43264..620a9c72fd7 100644 --- a/src/discounts/components/DiscountCreatePage/DiscountCreatePage.tsx +++ b/src/discounts/components/DiscountCreatePage/DiscountCreatePage.tsx @@ -1,80 +1,92 @@ import { TopNav } from "@dashboard/components/AppLayout"; +import { ConfirmButtonTransitionState } from "@dashboard/components/ConfirmButton"; import { DetailPageLayout } from "@dashboard/components/Layouts"; import Savebar from "@dashboard/components/Savebar"; +import { discountListUrl } from "@dashboard/discounts/discountsUrls"; import { DiscoutFormData } from "@dashboard/discounts/types"; -import { saleListUrl } from "@dashboard/discounts/urls"; -import { ChannelFragment } from "@dashboard/graphql"; -import { RichTextContext } from "@dashboard/utils/richText/context"; -import useRichText from "@dashboard/utils/richText/useRichText"; +import { + ChannelFragment, + PromotionCreateErrorCode, + PromotionCreateErrorFragment, +} from "@dashboard/graphql"; +import { getFormErrors } from "@dashboard/utils/errors"; +import { getCommonFormFieldErrorMessage } from "@dashboard/utils/errors/common"; import React from "react"; -import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; import { useIntl } from "react-intl"; +import { DiscountCreateForm } from "../DiscountCreateForm"; import { DiscountDatesWithController } from "../DiscountDates"; import { DiscountDescription } from "../DiscountDescription"; import { DiscountName } from "../DiscountName"; -import { DiscountRules } from "../DiscountRules"; -import { initialFormValues } from "./initialFormValues"; +import { DiscountRules, DiscountRulesErrors } from "../DiscountRules"; export interface DiscountCreatePageProps { channels: ChannelFragment[]; disabled: boolean; + errors: PromotionCreateErrorFragment[]; + submitButtonState: ConfirmButtonTransitionState; onBack: () => void; onSubmit: (data: DiscoutFormData) => void; } export const DiscountCreatePage = ({ + channels, disabled, + errors, + submitButtonState, onBack, - channels, onSubmit, }: DiscountCreatePageProps) => { const intl = useIntl(); + const formErrors = getFormErrors(["name"], errors); - const methods = useForm({ - mode: "onBlur", - values: initialFormValues, - }); + return ( + + + + + {({ rules, onDeleteRule, onRuleSubmit, submitHandler }) => ( + <> + - const richText = useRichText({ - initial: "", - loading: false, - triggerChange: methods.trigger, - }); + - const handleSubmit: SubmitHandler = data => { - onSubmit(data); - }; + - return ( - - - - - -
- - - - - -
-
+ } + channels={channels} + disabled={disabled} + rules={rules} + onRuleDelete={onDeleteRule} + onRuleSubmit={onRuleSubmit} + getRuleConfirmButtonState={() => "default"} + deleteButtonState="default" + /> - -
-
+ + + )} +
+
+
); }; diff --git a/src/discounts/components/DiscountCreatePage/initialFormValues.ts b/src/discounts/components/DiscountCreatePage/initialFormValues.ts deleted file mode 100644 index d46979ea18b..00000000000 --- a/src/discounts/components/DiscountCreatePage/initialFormValues.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Condition, DiscoutFormData, Rule } from "@dashboard/discounts/types"; -import { RewardValueTypeEnum } from "@dashboard/graphql"; - -export const initialFormValues: DiscoutFormData = { - name: "", - description: "", - dates: { - endDate: "", - endTime: "", - hasEndDate: false, - startDate: "", - startTime: "", - }, - rules: [], -}; - -export const intialConditionValues: Condition = { - type: "product", - condition: "is", - values: [], -}; - -export const initialRuleValues: Rule = { - channels: [], - description: "", - name: "", - rewardValue: 0, - conditions: [{ ...intialConditionValues }], - rewardValueType: RewardValueTypeEnum.PERCENTAGE, -}; diff --git a/src/discounts/components/DiscountDates/DiscountDates.tsx b/src/discounts/components/DiscountDates/DiscountDates.tsx index 43ca82b6d04..a3b568f01bd 100644 --- a/src/discounts/components/DiscountDates/DiscountDates.tsx +++ b/src/discounts/components/DiscountDates/DiscountDates.tsx @@ -1,13 +1,16 @@ import { DashboardCard } from "@dashboard/components/Card"; -import { DiscountErrorFragment } from "@dashboard/graphql"; import { commonMessages } from "@dashboard/intl"; import { getFormErrors } from "@dashboard/utils/errors"; -import getDiscountErrorMessage from "@dashboard/utils/errors/discounts"; +import { + CommonError, + getCommonFormFieldErrorMessage, +} from "@dashboard/utils/errors/common"; import { Box, Checkbox, Input, Text } from "@saleor/macaw-ui-next"; -import React from "react"; +import React, { ChangeEvent } from "react"; +import { FieldError } from "react-hook-form"; import { FormattedMessage, useIntl } from "react-intl"; -interface DiscountDatesProps { +interface DiscountDatesProps { data: { endDate: string; endTime: string; @@ -16,21 +19,25 @@ interface DiscountDatesProps { startTime: string; }; disabled: boolean; - errors: DiscountErrorFragment[]; + formErrors?: { + startDate?: FieldError; + }; + errors: Array>; onChange: (event: React.ChangeEvent) => void; onBlur?: (event: React.FocusEvent) => void; } -const DiscountDates = ({ +const DiscountDates = ({ data, disabled, errors, + formErrors, onChange, onBlur, -}: DiscountDatesProps) => { +}: DiscountDatesProps) => { const intl = useIntl(); - const formErrors = getFormErrors(["startDate", "endDate"], errors); + const apiErrors = getFormErrors(["startDate", "endDate"], errors); return ( @@ -46,8 +53,11 @@ const DiscountDates = ({ { + onChange({ + target: { + name: "hasEndDate", + value: !data.hasEndDate, + }, + } as ChangeEvent); + }} onBlur={onBlur} > @@ -88,8 +109,11 @@ const DiscountDates = ({ { +interface DiscountDatesWithControllerProps { + disabled?: boolean; + errors: Array>; +} + +export const DiscountDatesWithController = ({ + disabled, + errors, +}: DiscountDatesWithControllerProps) => { + const { formState } = useFormContext(); const { field } = useController({ name: "dates", }); + const startDateError = formState.errors?.dates?.startDate; + const handleChange = (e: ChangeEvent) => { field.onChange({ ...field.value, - [e.target.name]: getInputChangeValue(e), + [e.target.name]: e.target.value, }); }; return ( ); }; - -function getInputChangeValue(e: ChangeEvent) { - // When checkbox get value from e.target.checked - if (e.target.name === "hasEndDate") { - return e.target.checked; - } - - // Otherwise get value from e.target.value - return e.target.value; -} diff --git a/src/discounts/components/DiscountDescription/DiscountDescription.tsx b/src/discounts/components/DiscountDescription/DiscountDescription.tsx index 30e2b3515c5..aeb5fbec585 100644 --- a/src/discounts/components/DiscountDescription/DiscountDescription.tsx +++ b/src/discounts/components/DiscountDescription/DiscountDescription.tsx @@ -2,12 +2,22 @@ import { DashboardCard } from "@dashboard/components/Card"; import RichTextEditor from "@dashboard/components/RichTextEditor"; import { RichTextEditorLoading } from "@dashboard/components/RichTextEditor/RichTextEditorLoading"; import { DiscoutFormData } from "@dashboard/discounts/types"; +import { commonMessages } from "@dashboard/intl"; import { useRichTextContext } from "@dashboard/utils/richText/context"; import React from "react"; import { useController } from "react-hook-form"; -import { FormattedMessage } from "react-intl"; +import { FormattedMessage, useIntl } from "react-intl"; -export const DiscountDescription = () => { +interface DiscountDescriptionProps { + disabled?: boolean; + error?: boolean; +} + +export const DiscountDescription = ({ + disabled = false, + error = false, +}: DiscountDescriptionProps) => { + const intl = useIntl(); const { defaultValue, editorRef, isReadyForMount, handleChange } = useRichTextContext(); @@ -30,10 +40,10 @@ export const DiscountDescription = () => { field.onChange(JSON.stringify(data)); }} onBlur={field.onBlur} - disabled={false} - error={false} + disabled={disabled} + error={error} helperText="" - label="Optional" + label={intl.formatMessage(commonMessages.optionalField)} name="description" /> ) : ( diff --git a/src/discounts/components/DiscountDetailsForm/DiscountDetailsForm.tsx b/src/discounts/components/DiscountDetailsForm/DiscountDetailsForm.tsx new file mode 100644 index 00000000000..ce51e9fbad3 --- /dev/null +++ b/src/discounts/components/DiscountDetailsForm/DiscountDetailsForm.tsx @@ -0,0 +1,113 @@ +import { Rule } from "@dashboard/discounts/models"; +import { DiscoutFormData } from "@dashboard/discounts/types"; +import { + PromotionDetailsFragment, + PromotionRuleCreateErrorFragment, + PromotionRuleUpdateErrorFragment, +} from "@dashboard/graphql"; +import { splitDateTime } from "@dashboard/misc"; +import { CommonError } from "@dashboard/utils/errors/common"; +import { RichTextContext } from "@dashboard/utils/richText/context"; +import useRichText from "@dashboard/utils/richText/useRichText"; +import { zodResolver } from "@hookform/resolvers/zod"; +import React, { ReactNode } from "react"; +import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; +import { useIntl } from "react-intl"; + +import { getValidationSchema } from "../DiscountCreateForm/validationSchema"; +import { useRulesHandlers } from "./hooks/useRulesHandlers"; +import { filterRules } from "./utils"; + +interface DiscountDetailsFormRenderProps { + rulesErrors: Array>; + rules: Rule[]; + onSubmit: () => void; + onRuleSubmit: (rule: Rule, ruleEditIndex: number | null) => Promise; + onDeleteRule: (ruleDeleteIndex: number) => Promise; +} + +interface DiscountDetailsFormProps { + children: (renderProps: DiscountDetailsFormRenderProps) => ReactNode; + disabled: boolean; + data: PromotionDetailsFragment | undefined | null; + onSubmit: (data: DiscoutFormData) => void; + ruleConditionsOptionsDetailsMap: Record; + onRuleUpdateSubmit: ( + data: Rule, + ) => Promise>>; + onRuleCreateSubmit: ( + data: Rule, + ) => Promise>>; + onRuleDeleteSubmit: (id: string) => void; +} + +export const DiscountDetailsForm = ({ + children, + data, + disabled, + onSubmit, + onRuleCreateSubmit, + onRuleDeleteSubmit, + onRuleUpdateSubmit, + ruleConditionsOptionsDetailsMap, +}: DiscountDetailsFormProps) => { + const intl = useIntl(); + + const methods = useForm({ + mode: "onBlur", + values: { + dates: { + startDate: splitDateTime(data?.startDate ?? "").date, + startTime: splitDateTime(data?.startDate ?? "").time, + endDate: splitDateTime(data?.endDate ?? "").date, + endTime: splitDateTime(data?.endDate ?? "").time, + hasEndDate: !!data?.endDate, + }, + name: data?.name ?? "", + description: data?.description ? JSON.stringify(data.description) : "", + rules: [], + }, + resolver: zodResolver(getValidationSchema(intl)), + }); + + const richText = useRichText({ + initial: JSON.stringify(data?.description), + loading: disabled, + triggerChange: methods.trigger, + }); + + const handleSubmit: SubmitHandler = formData => { + const dirtyRulesIndexes = Object.keys( + methods.formState.dirtyFields?.rules ?? {}, + ); + + return onSubmit({ + ...formData, + rules: filterRules(data?.rules ?? [], formData.rules, dirtyRulesIndexes), + }); + }; + + const { onDeleteRule, onRuleSubmit, rules, rulesErrors } = useRulesHandlers({ + data, + onRuleCreateSubmit, + onRuleDeleteSubmit, + onRuleUpdateSubmit, + ruleConditionsOptionsDetailsMap, + }); + + return ( + + +
+ {children({ + rulesErrors, + rules, + onSubmit: methods.handleSubmit(handleSubmit), + onRuleSubmit, + onDeleteRule, + })} +
+
+
+ ); +}; diff --git a/src/discounts/components/DiscountDetailsForm/hooks/useRulesHandlers.ts b/src/discounts/components/DiscountDetailsForm/hooks/useRulesHandlers.ts new file mode 100644 index 00000000000..9e6cdfee757 --- /dev/null +++ b/src/discounts/components/DiscountDetailsForm/hooks/useRulesHandlers.ts @@ -0,0 +1,82 @@ +import { Rule } from "@dashboard/discounts/models"; +import { + PromotionDetailsFragment, + PromotionRuleCreateErrorFragment, + PromotionRuleUpdateErrorFragment, +} from "@dashboard/graphql"; +import { CommonError } from "@dashboard/utils/errors/common"; +import { useEffect, useState } from "react"; + +interface UseRulesHandlersProps { + data: PromotionDetailsFragment | undefined | null; + ruleConditionsOptionsDetailsMap: Record; + onRuleUpdateSubmit: ( + data: Rule, + ) => Promise>>; + onRuleCreateSubmit: ( + data: Rule, + ) => Promise>>; + onRuleDeleteSubmit: (id: string) => void; +} + +export const useRulesHandlers = ({ + data, + ruleConditionsOptionsDetailsMap, + onRuleUpdateSubmit, + onRuleCreateSubmit, + onRuleDeleteSubmit, +}: UseRulesHandlersProps) => { + const [rules, setRules] = useState([]); + const [rulesErrors, setRulesErrors] = useState>>([]); + + useEffect(() => { + if (data?.rules) { + setRules( + data.rules.map(rule => + Rule.fromAPI(rule, ruleConditionsOptionsDetailsMap), + ) ?? [], + ); + } + }, [data?.rules, ruleConditionsOptionsDetailsMap]); + + const onRuleSubmit = async (rule: Rule, ruleEditIndex: number | null) => { + let errors: Array< + CommonError< + PromotionRuleUpdateErrorFragment | PromotionRuleCreateErrorFragment + > + > = []; + const ruleObj = Rule.fromFormValues(rule); + + if (ruleEditIndex !== null) { + errors = await onRuleUpdateSubmit(ruleObj); + if (errors.length > 0) { + setRulesErrors(errors); + } + } else { + errors = await onRuleCreateSubmit(ruleObj); + if (errors.length > 0) { + setRulesErrors(errors); + } + } + }; + + const onDeleteRule = async (ruleDeleteIndex: number) => { + if (ruleDeleteIndex === null) { + return; + } + + const ruleId = rules[ruleDeleteIndex].id; + if (!ruleId) { + return; + } + + await onRuleDeleteSubmit(ruleId); + }; + + return { + rulesErrors, + rules, + onDeleteRule, + onRuleSubmit, + }; +}; diff --git a/src/discounts/components/DiscountDetailsForm/index.ts b/src/discounts/components/DiscountDetailsForm/index.ts new file mode 100644 index 00000000000..9cd396079d3 --- /dev/null +++ b/src/discounts/components/DiscountDetailsForm/index.ts @@ -0,0 +1 @@ +export * from "./DiscountDetailsForm"; diff --git a/src/discounts/components/DiscountDetailsForm/utils.test.ts b/src/discounts/components/DiscountDetailsForm/utils.test.ts new file mode 100644 index 00000000000..770399efbbf --- /dev/null +++ b/src/discounts/components/DiscountDetailsForm/utils.test.ts @@ -0,0 +1,177 @@ +import { Rule } from "@dashboard/discounts/models"; +import { + PromotionRuleDetailsFragment, + RewardValueTypeEnum, +} from "@dashboard/graphql"; + +import { filterRules } from "./utils"; + +describe("DiscountDetailsPage, utils", () => { + describe("filterRules", () => { + it("should return only dirty rules", () => { + // Arrange + const promotionRules = [ + { + id: "1", + name: "rule 1", + }, + { + id: "2", + name: "rule 2", + }, + ] as PromotionRuleDetailsFragment[]; + + const formRules = [ + { + id: "1", + name: "rule 1", + channel: null, + conditions: [], + description: "", + rewardValue: 10, + rewardValueType: RewardValueTypeEnum.FIXED, + }, + { + id: "2", + name: "rule 2", + channel: null, + conditions: [], + description: "", + rewardValue: 20, + rewardValueType: RewardValueTypeEnum.PERCENTAGE, + }, + ] as unknown as Rule[]; + const dirtyRulesIndexes = ["1"]; + + // Act + const result = filterRules(promotionRules, formRules, dirtyRulesIndexes); + + // Assert + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "channel": null, + "conditions": Array [], + "description": "", + "id": "2", + "name": "rule 2", + "rewardValue": 20, + "rewardValueType": "PERCENTAGE", + }, + ] + `); + }); + + it("should return dirty and new added rules", () => { + // Arrange + const promotionRules = [ + { + id: "1", + name: "rule 1", + }, + { + id: "2", + name: "rule 2", + }, + ] as PromotionRuleDetailsFragment[]; + + const formRules = [ + { + id: "1", + name: "rule 1", + channel: null, + conditions: [], + description: "", + rewardValue: 10, + rewardValueType: RewardValueTypeEnum.FIXED, + }, + { + id: "2", + name: "rule 2", + channel: null, + conditions: [], + description: "", + rewardValue: 20, + rewardValueType: RewardValueTypeEnum.PERCENTAGE, + }, + { + name: "", + channel: null, + conditions: [], + description: "", + rewardValue: 0, + rewardValueType: RewardValueTypeEnum.PERCENTAGE, + }, + ] as unknown as Rule[]; + const dirtyRulesIndexes = ["0"]; + + // Act + const result = filterRules(promotionRules, formRules, dirtyRulesIndexes); + + // Assert + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "channel": null, + "conditions": Array [], + "description": "", + "id": "1", + "name": "rule 1", + "rewardValue": 10, + "rewardValueType": "FIXED", + }, + Object { + "channel": null, + "conditions": Array [], + "description": "", + "name": "", + "rewardValue": 0, + "rewardValueType": "PERCENTAGE", + }, + ] + `); + }); + + it("should return empty array when no new added or dirty rules", () => { + // Arrange + const promotionRules = [ + { + id: "1", + name: "rule 1", + }, + { + id: "2", + name: "rule 2", + }, + ] as PromotionRuleDetailsFragment[]; + + const formRules = [ + { + id: "1", + name: "rule 1", + channel: null, + conditions: [], + description: "", + rewardValue: 10, + rewardValueType: RewardValueTypeEnum.FIXED, + }, + { + id: "2", + name: "rule 2", + channel: null, + conditions: [], + description: "", + rewardValue: 20, + rewardValueType: RewardValueTypeEnum.PERCENTAGE, + }, + ] as unknown as Rule[]; + const dirtyRulesIndexes: string[] = []; + + // Act + const result = filterRules(promotionRules, formRules, dirtyRulesIndexes); + + // Assert + expect(result).toEqual([]); + }); + }); +}); diff --git a/src/discounts/components/DiscountDetailsForm/utils.ts b/src/discounts/components/DiscountDetailsForm/utils.ts new file mode 100644 index 00000000000..f6178b1322f --- /dev/null +++ b/src/discounts/components/DiscountDetailsForm/utils.ts @@ -0,0 +1,22 @@ +import { Rule } from "@dashboard/discounts/models"; +import { PromotionRuleDetailsFragment } from "@dashboard/graphql"; + +export const filterRules = ( + promotionRules: PromotionRuleDetailsFragment[], + formRules: Rule[], + dirtyRulesIndexes: string[], +): Rule[] => { + if (!promotionRules) { + return []; + } + + return formRules.filter((rule, index) => { + // Selected only dirty rules to update + if (promotionRules.find(r => r.id === rule.id)) { + return dirtyRulesIndexes.includes(index.toString()); + } + + // Keep all new added rules + return true; + }); +}; diff --git a/src/discounts/components/DiscountDetailsPage/DiscountDeletailsPage.stories.tsx b/src/discounts/components/DiscountDetailsPage/DiscountDeletailsPage.stories.tsx index 54b15786296..b283589e40d 100644 --- a/src/discounts/components/DiscountDetailsPage/DiscountDeletailsPage.stories.tsx +++ b/src/discounts/components/DiscountDetailsPage/DiscountDeletailsPage.stories.tsx @@ -1,23 +1,55 @@ +import { MockedProvider } from "@apollo/client/testing"; import { channelsList } from "@dashboard/channels/fixtures"; import { discount } from "@dashboard/discounts/fixtures"; import React from "react"; +import { + searchCategoriesMock, + searchCollectionsMock, + searchProductsMock, + searchVariantsMock, +} from "../DiscountRules/componenets/RuleFormModal/mocks"; import { DiscountDetailsPage, DiscountDetailsPageProps, } from "./DiscountDetailsPage"; const props: DiscountDetailsPageProps = { - channels: channelsList, + channels: [channelsList[0]], disabled: false, onBack: () => undefined, onSubmit: () => undefined, - onRuleSubmit: () => undefined, - discount, + ruleConditionsOptionsDetailsMap: { + "UHJvZHVjdDo3OQ==": "Bean Juice", + "UHJvZHVjdDoxMTU=": "Black Hoodie", + UHJvZHVjdFZhcmlhbnQ6OTg3: "45cm x 45cm", + UHJvZHVjdFZhcmlhbnQ6MjE1: "1l", + }, + errors: [], + onRuleCreateSubmit: () => Promise.resolve([]), + onRuleDeleteSubmit: () => Promise.resolve([]), + onRuleUpdateSubmit: () => Promise.resolve([]), + ruleConditionsOptionsDetailsLoading: false, + ruleCreateButtonState: "default", + ruleDeleteButtonState: "default", + ruleUpdateButtonState: "default", + submitButtonState: "default", + data: discount, }; export default { title: "Discounts / Discounts details page", }; -export const Default = () => ; +export const Default = () => ( + + + +); diff --git a/src/discounts/components/DiscountDetailsPage/DiscountDetailsPage.tsx b/src/discounts/components/DiscountDetailsPage/DiscountDetailsPage.tsx index 53eb7963f17..ca5d208531c 100644 --- a/src/discounts/components/DiscountDetailsPage/DiscountDetailsPage.tsx +++ b/src/discounts/components/DiscountDetailsPage/DiscountDetailsPage.tsx @@ -1,90 +1,127 @@ import { TopNav } from "@dashboard/components/AppLayout"; +import { ConfirmButtonTransitionState } from "@dashboard/components/ConfirmButton"; import { DetailPageLayout } from "@dashboard/components/Layouts"; import Savebar from "@dashboard/components/Savebar"; -import { DiscoutFormData, Rule } from "@dashboard/discounts/types"; -import { saleListUrl } from "@dashboard/discounts/urls"; -import { ChannelFragment } from "@dashboard/graphql"; -import { RichTextContext } from "@dashboard/utils/richText/context"; -import useRichText from "@dashboard/utils/richText/useRichText"; +import { discountListUrl } from "@dashboard/discounts/discountsUrls"; +import { Rule } from "@dashboard/discounts/models"; +import { DiscoutFormData } from "@dashboard/discounts/types"; +import { + ChannelFragment, + PromotionDetailsFragment, + PromotionRuleCreateErrorFragment, + PromotionRuleUpdateErrorFragment, + PromotionUpdateErrorFragment, +} from "@dashboard/graphql"; +import { getFormErrors } from "@dashboard/utils/errors"; +import { + CommonError, + getCommonFormFieldErrorMessage, +} from "@dashboard/utils/errors/common"; import React from "react"; -import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; +import { useIntl } from "react-intl"; import { DiscountDatesWithController } from "../DiscountDates"; import { DiscountDescription } from "../DiscountDescription"; +import { DiscountDetailsForm } from "../DiscountDetailsForm"; import { DiscountName } from "../DiscountName"; import { DiscountRules } from "../DiscountRules"; export interface DiscountDetailsPageProps { channels: ChannelFragment[]; + ruleConditionsOptionsDetailsMap: Record; + ruleConditionsOptionsDetailsLoading: boolean; + data: PromotionDetailsFragment | undefined | null; disabled: boolean; - onBack: () => void; + errors: PromotionUpdateErrorFragment[]; + submitButtonState: ConfirmButtonTransitionState; onSubmit: (data: DiscoutFormData) => void; - onRuleSubmit: (ruleData: Rule) => void; - discount: any; // TODO: add type when handle API logic + onRuleUpdateSubmit: ( + data: Rule, + ) => Promise>>; + ruleUpdateButtonState: ConfirmButtonTransitionState; + onRuleCreateSubmit: ( + data: Rule, + ) => Promise>>; + ruleCreateButtonState: ConfirmButtonTransitionState; + onRuleDeleteSubmit: (id: string) => void; + ruleDeleteButtonState: ConfirmButtonTransitionState; + onBack: () => void; } export const DiscountDetailsPage = ({ channels, + ruleConditionsOptionsDetailsMap, + ruleConditionsOptionsDetailsLoading, disabled, + data, + errors, + submitButtonState, onBack, - discount, onSubmit, - onRuleSubmit, + onRuleCreateSubmit, + onRuleUpdateSubmit, + onRuleDeleteSubmit, + ruleCreateButtonState, + ruleUpdateButtonState, + ruleDeleteButtonState, }: DiscountDetailsPageProps) => { - const methods = useForm({ - mode: "onBlur", - values: { - dates: { - endDate: discount?.endDate, - startDate: discount?.startDate, - endTime: discount?.endTime, - hasEndDate: !!discount?.endDate, - startTime: discount?.startTime, - }, - name: discount?.name, - description: discount?.description, - rules: discount?.rules, - }, - }); + const intl = useIntl(); + + const formErrors = getFormErrors(["name"], errors); - const richText = useRichText({ - initial: "", - loading: false, - triggerChange: methods.trigger, - }); + return ( + + + + + {({ rulesErrors, rules, onDeleteRule, onRuleSubmit, onSubmit }) => ( + <> + - const handleSubmit: SubmitHandler = data => onSubmit(data); + - const handleRuleSubmit = (index: number) => { - const formData = methods.getValues(); - onRuleSubmit(formData.rules[index]); - }; + - return ( - - - - - -
- - - + ruleEditIndex !== null + ? ruleUpdateButtonState + : ruleCreateButtonState + } + deleteButtonState={ruleDeleteButtonState} + onRuleDelete={onDeleteRule} + onRuleSubmit={onRuleSubmit} channels={channels} - onRuleSubmit={handleRuleSubmit} + disabled={disabled} /> - -
-
- -
-
+ + + )} +
+
+
); }; diff --git a/src/discounts/components/DiscountName/DiscountName.tsx b/src/discounts/components/DiscountName/DiscountName.tsx index e0d170a64aa..7ebfa884987 100644 --- a/src/discounts/components/DiscountName/DiscountName.tsx +++ b/src/discounts/components/DiscountName/DiscountName.tsx @@ -2,11 +2,17 @@ import { DashboardCard } from "@dashboard/components/Card"; import { DiscoutFormData } from "@dashboard/discounts/types"; import { Input } from "@saleor/macaw-ui-next"; import React from "react"; -import { useController } from "react-hook-form"; +import { useController, useFormContext } from "react-hook-form"; import { FormattedMessage, useIntl } from "react-intl"; -export const DiscountName = () => { +interface DiscountNameProps { + disabled?: boolean; + error: string | undefined; +} + +export const DiscountName = ({ disabled, error }: DiscountNameProps) => { const intl = useIntl(); + const { formState } = useFormContext(); const { field } = useController({ name: "name", }); @@ -19,11 +25,14 @@ export const DiscountName = () => {
diff --git a/src/discounts/components/DiscountRules/DiscountRules.test.tsx b/src/discounts/components/DiscountRules/DiscountRules.test.tsx new file mode 100644 index 00000000000..2831bae12d0 --- /dev/null +++ b/src/discounts/components/DiscountRules/DiscountRules.test.tsx @@ -0,0 +1,396 @@ +import { MockedProvider } from "@apollo/client/testing"; +import { mockResizeObserver } from "@dashboard/components/Datagrid/testUtils"; +import { Rule } from "@dashboard/discounts/models"; +import { ChannelFragment, RewardValueTypeEnum } from "@dashboard/graphql"; +import { ThemeProvider as LegacyThemeProvider } from "@saleor/macaw-ui"; +import { ThemeProvider } from "@saleor/macaw-ui-next"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React, { ReactNode } from "react"; + +import { + searchCategoriesMock, + searchCollectionsMock, + searchProductsMock, + searchVariantsMock, +} from "./componenets/RuleFormModal/mocks"; +import { DiscountRules } from "./DiscountRules"; + +jest.mock("react-intl", () => ({ + useIntl: jest.fn(() => ({ + formatMessage: jest.fn(x => x.defaultMessage), + })), + defineMessages: jest.fn(x => x), + FormattedMessage: ({ defaultMessage }: { defaultMessage: string }) => ( + <>{defaultMessage} + ), +})); + +const Wrapper = ({ children }: { children: ReactNode }) => { + return ( + + + {children} + + + ); +}; + +const channels = [ + // Apollo mocks only work with test channel + // oif you want to use different channel, you need to update mocks + { + currencyCode: "$", + id: "Q2hhbm5lcDoy", + name: "Test", + slug: "test", + isActive: true, + }, +] as ChannelFragment[]; + +const rules = [ + { + id: "cat-1", + name: "Catalog rule 1", + description: "", + channel: { label: "Test", value: "Q2hhbm5lcDoy" }, + conditions: [ + { + type: "product", + condition: "is", + values: [ + { label: "Product-1", value: "prod-1" }, + { label: "Product-2", value: "prod-2" }, + ], + }, + ], + rewardValue: 12, + rewardValueType: RewardValueTypeEnum.FIXED, + }, + { + id: "cat-2", + name: "Catalog rule 2", + description: "", + channel: { label: "Test", value: "Q2hhbm5lcDoy" }, + conditions: [ + { + type: "category", + condition: "is", + values: [{ label: "Category-1", value: "cat-1" }], + }, + ], + rewardValue: 34, + rewardValueType: RewardValueTypeEnum.PERCENTAGE, + }, +] as Rule[]; + +describe("DiscountRules", () => { + beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); + + const mockIntersectionObserver = jest.fn(); + mockIntersectionObserver.mockReturnValue({ + observe: () => null, + unobserve: () => null, + disconnect: () => null, + }); + window.IntersectionObserver = mockIntersectionObserver; + + mockResizeObserver(); + }); + + it("should render placeholder when no rules", () => { + // Arrange & Act + render( + "default")} + />, + { wrapper: Wrapper }, + ); + + // Assert + expect( + screen.getByText(/add your first rule to set up a promotion/i), + ).toBeInTheDocument(); + }); + + it("should render discount rules", () => { + // Arrange & Act + render( + "default")} + />, + { wrapper: Wrapper }, + ); + + // Assert + expect( + screen.getByText(/catalog rule: catalog rule 2/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/catalog rule: catalog rule 1/i), + ).toBeInTheDocument(); + expect( + screen.getAllByText( + /discount of {value} on the purchase of {items} through the {channel}/i, + ).length, + ).toBe(2); + }); + + it("should allow to add new rule", async () => { + // Arrange + const onRuleAdd = jest.fn(); + render( + "default")} + />, + { wrapper: Wrapper }, + ); + + // Act + await act(async () => { + await userEvent.click(screen.getByRole("button", { name: /add rule/i })); + }); + + await waitFor(() => { + expect(screen.getByText(/^catalog$/i)).toBeInTheDocument(); + }); + + await act(async () => { + await userEvent.click(screen.getByText(/^catalog$/i)); + }); + + await userEvent.type( + screen.getByRole("input", { name: "Name" }), + "Name 123", + ); + await userEvent.click(screen.getByRole("combobox")); + expect(await screen.findByText(/test/i)).toBeInTheDocument(); + + await act(async () => { + await userEvent.click(screen.getAllByTestId("select-option")[0]); + }); + + await userEvent.click(await screen.findByTestId(/rule-type/i)); + await userEvent.click(screen.getAllByTestId("select-option")[0]); + + await userEvent.click(await screen.findByTestId(/rule-value/i)); + await userEvent.click(await screen.getAllByTestId("select-option")[0]); + + await userEvent.type( + screen.getByRole("input", { name: "Discount value" }), + "22", + ); + + await userEvent.click(screen.getByRole("button", { name: /save/i })); + + // Assert + expect(onRuleAdd).toHaveBeenCalledWith( + { + channel: { + label: "Test", + value: "Q2hhbm5lcDoy", + }, + conditions: [ + { + condition: "is", + type: "product", + values: [ + { + label: "Bean Juice", + value: "UHJvZHVjdDo3OQ==", + }, + ], + }, + ], + description: "", + id: "", + name: "Name 123", + rewardValue: 22, + rewardValueType: "PERCENTAGE", + }, + null, + ); + }); + + it("should allow to to handle delete rule", async () => { + // Arrange + const onRuleDelete = jest.fn(); + + render( + "default")} + />, + { wrapper: Wrapper }, + ); + + // Act + await act(async () => { + await userEvent.click(screen.getAllByTestId("rule-delete-button")[0]); + }); + + await screen.findByText(/delete rule/i); + + await act(async () => { + await userEvent.click(screen.getByRole("button", { name: /delete/i })); + }); + + // Assert + expect(onRuleDelete).toHaveBeenCalledWith(0); + expect(screen.queryByText(/delete rule/i)).not.toBeInTheDocument(); + }); + + it("should allow to to handle update rule", async () => { + // Arrange + const onRuleEdit = jest.fn(); + + render( + "default")} + />, + { wrapper: Wrapper }, + ); + + // Act + await act(async () => { + await userEvent.click(screen.getAllByTestId("rule-edit-button")[0]); + }); + + await screen.findAllByText(/edit rule/i); + + const nameField = screen.getByRole("input", { name: "Name" }); + await userEvent.clear(nameField); + await userEvent.type(nameField, "New name"); + + await userEvent.click(screen.getByRole("radio", { name: "$" })); + + const discountValueField = screen.getByRole("input", { + name: "Discount value", + }); + await userEvent.clear(discountValueField); + await userEvent.type(discountValueField, "122"); + + await userEvent.click(screen.getByRole("button", { name: /save/i })); + + // Assert + expect(onRuleEdit).toHaveBeenCalledWith( + { + id: "cat-1", + name: "New name", + channel: { + label: "Test", + value: "Q2hhbm5lcDoy", + }, + conditions: [ + { + condition: "is", + type: "product", + values: [ + { + label: "Product-1", + value: "prod-1", + }, + { + label: "Product-2", + value: "prod-2", + }, + ], + }, + ], + description: "", + rewardValue: 122, + rewardValueType: "FIXED", + }, + 0, + ); + }); + + it("should show error in rule", async () => { + // Arrange & Act + render( + "default")} + />, + { wrapper: Wrapper }, + ); + + // Assert + expect( + screen.getByText(/rule has error, open rule to see details/i), + ).toBeInTheDocument(); + }); +}); diff --git a/src/discounts/components/DiscountRules/DiscountRules.tsx b/src/discounts/components/DiscountRules/DiscountRules.tsx index 03abebeef55..5f38dcf8c3a 100644 --- a/src/discounts/components/DiscountRules/DiscountRules.tsx +++ b/src/discounts/components/DiscountRules/DiscountRules.tsx @@ -1,29 +1,76 @@ import { DashboardCard } from "@dashboard/components/Card"; -import { DiscoutFormData } from "@dashboard/discounts/types"; +import { ConfirmButtonTransitionState } from "@dashboard/components/ConfirmButton"; +import { Rule } from "@dashboard/discounts/models"; import { ChannelFragment } from "@dashboard/graphql"; +import { CommonError } from "@dashboard/utils/errors/common"; import { Box } from "@saleor/macaw-ui-next"; -import React from "react"; -import { useFieldArray } from "react-hook-form"; +import React, { useState } from "react"; import { useIntl } from "react-intl"; -import { initialRuleValues } from "../DiscountCreatePage/initialFormValues"; import { AddButton } from "./componenets/AddButton"; +import { RuleDeleteModal } from "./componenets/RuleDeleteModal/RuleDeleteModal"; +import { RuleFormModal } from "./componenets/RuleFormModal"; import { RulesList } from "./componenets/RulesList"; import { messages } from "./messages"; -interface DiscountRulesProps { +export type DiscountRulesErrors = Array< + CommonError & { index?: number } +>; + +interface DiscountRulesProps { + disabled?: boolean; channels: ChannelFragment[]; - onRuleSubmit?: (index: number) => void; + rules: Rule[]; + errors: Array>; + loading?: boolean; + deleteButtonState: ConfirmButtonTransitionState; + getRuleConfirmButtonState: ( + ruleEditIndex: number | null, + ) => ConfirmButtonTransitionState; + onRuleSubmit: (data: Rule, ruleIndex: number | null) => void; + onRuleDelete: (ruleIndex: number) => void; } -export const DiscountRules = ({ +export const DiscountRules = ({ + disabled, channels, + rules, + errors, + getRuleConfirmButtonState, + deleteButtonState, + loading, onRuleSubmit, -}: DiscountRulesProps) => { + onRuleDelete, +}: DiscountRulesProps) => { const intl = useIntl(); - const { append, fields: rules } = useFieldArray({ - name: "rules", - }); + + const [showRuleModal, setShowRuleModal] = useState(false); + const [ruleEditIndex, setRuleEditIndex] = useState(null); + const [ruleDeleteIndex, setRuleDeleteIndex] = useState(null); + + const handleOpenRuleModal = (editIndex: number) => { + setRuleEditIndex(editIndex); + setShowRuleModal(true); + }; + + const handleOpenRuleDeleteModal = (index: number) => { + setRuleDeleteIndex(index); + }; + + const handleRuleModalClose = () => { + setShowRuleModal(false); + setRuleEditIndex(null); + }; + + const handleRuleModalSubmit = async (data: Rule) => { + await onRuleSubmit(data, ruleEditIndex); + handleRuleModalClose(); + }; + + const handleRuleDelete = async () => { + await onRuleDelete(ruleDeleteIndex!); + setRuleDeleteIndex(null); + }; return ( @@ -31,21 +78,40 @@ export const DiscountRules = ({ {intl.formatMessage(messages.title)} - append({ - ...initialRuleValues, - }) - } + disabled={disabled} + onCatalogClick={() => setShowRuleModal(true)} /> + + + + setRuleDeleteIndex(null)} + onSubmit={handleRuleDelete} + confimButtonState={deleteButtonState} + /> ); }; diff --git a/src/discounts/components/DiscountRules/componenets/AddButton/AddButton.tsx b/src/discounts/components/DiscountRules/componenets/AddButton/AddButton.tsx index 08b25bedc32..5a8d8ff572e 100644 --- a/src/discounts/components/DiscountRules/componenets/AddButton/AddButton.tsx +++ b/src/discounts/components/DiscountRules/componenets/AddButton/AddButton.tsx @@ -13,10 +13,14 @@ import { useIntl } from "react-intl"; import { messages } from "../../messages"; interface AddButtonProps { + disabled?: boolean; onCatalogClick: () => void; } -export const AddButton = ({ onCatalogClick }: AddButtonProps) => { +export const AddButton = ({ + onCatalogClick, + disabled = false, +}: AddButtonProps) => { const intl = useIntl(); const [isSubMenuOpen, setSubMenuOpen] = useState(false); @@ -41,7 +45,7 @@ export const AddButton = ({ onCatalogClick }: AddButtonProps) => { return ( - - - )} - - - - ); -}; diff --git a/src/discounts/components/DiscountRules/componenets/Rule/components/RuleAccordion/RuleAccordion.tsx b/src/discounts/components/DiscountRules/componenets/Rule/components/RuleAccordion/RuleAccordion.tsx deleted file mode 100644 index 2ffa967beeb..00000000000 --- a/src/discounts/components/DiscountRules/componenets/Rule/components/RuleAccordion/RuleAccordion.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Accordion, Text } from "@saleor/macaw-ui-next"; -import React, { ReactNode, useState } from "react"; - -interface RuleAccordionProps { - children: ReactNode; - title: ReactNode; -} - -export const RuleAccordion = ({ children, title }: RuleAccordionProps) => { - const [collapsedId, setCollapsedId] = useState("ruleItem"); - - return ( - - - - {title} - - - {children} - - - ); -}; diff --git a/src/discounts/components/DiscountRules/componenets/Rule/components/RuleAccordion/index.ts b/src/discounts/components/DiscountRules/componenets/Rule/components/RuleAccordion/index.ts deleted file mode 100644 index 59e720bc05b..00000000000 --- a/src/discounts/components/DiscountRules/componenets/Rule/components/RuleAccordion/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./RuleAccordion"; diff --git a/src/discounts/components/DiscountRules/componenets/Rule/components/RuleConditionRow/RuleConditionRow.tsx b/src/discounts/components/DiscountRules/componenets/Rule/components/RuleConditionRow/RuleConditionRow.tsx deleted file mode 100644 index 94197c35f4b..00000000000 --- a/src/discounts/components/DiscountRules/componenets/Rule/components/RuleConditionRow/RuleConditionRow.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Combobox, Multiselect } from "@dashboard/components/Combobox"; -import { DiscoutFormData } from "@dashboard/discounts/types"; -import { Box, Button, RemoveIcon, Select } from "@saleor/macaw-ui-next"; -import React from "react"; -import { useController } from "react-hook-form"; - -import { discountConditionTypes } from "./const"; - -interface DiscountConditionRowProps { - ruleIndex: number; - conditionIndex: number; - onRemove: () => void; -} - -export const RuleConditionRow = ({ - ruleIndex, - conditionIndex, - onRemove, -}: DiscountConditionRowProps) => { - const ruleConditionTypeFieldName = - `rules.${ruleIndex}.conditions.${conditionIndex}.type` as const; - const { field: typeField } = useController< - DiscoutFormData, - typeof ruleConditionTypeFieldName - >({ - name: ruleConditionTypeFieldName, - }); - - const ruleConditionValuesFieldName = - `rules.${ruleIndex}.conditions.${conditionIndex}.values` as const; - const { field: valuesField } = useController< - DiscoutFormData, - typeof ruleConditionValuesFieldName - >({ - name: ruleConditionValuesFieldName, - }); - - return ( - - {}} - options={discountConditionTypes} - onChange={typeField.onChange} - onBlur={typeField.onBlur} - /> - - - - - - ); -}; diff --git a/src/discounts/components/DiscountRules/componenets/Rule/index.ts b/src/discounts/components/DiscountRules/componenets/Rule/index.ts deleted file mode 100644 index 79a4bfcb96a..00000000000 --- a/src/discounts/components/DiscountRules/componenets/Rule/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./Rule"; diff --git a/src/discounts/components/DiscountRules/componenets/RuleDeleteModal/RuleDeleteModal.tsx b/src/discounts/components/DiscountRules/componenets/RuleDeleteModal/RuleDeleteModal.tsx new file mode 100644 index 00000000000..87fb76a01df --- /dev/null +++ b/src/discounts/components/DiscountRules/componenets/RuleDeleteModal/RuleDeleteModal.tsx @@ -0,0 +1,57 @@ +import { + ConfirmButton, + ConfirmButtonTransitionState, +} from "@dashboard/components/ConfirmButton"; +import { DashboardModal } from "@dashboard/components/Modal"; +import { buttonMessages } from "@dashboard/intl"; +import { Box, Button, Text } from "@saleor/macaw-ui-next"; +import React from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import { messages } from "../../messages"; + +interface RuleDeleteModalProps { + open: boolean; + onClose: () => void; + onSubmit: () => void; + confimButtonState: ConfirmButtonTransitionState; +} + +export const RuleDeleteModal = ({ + open, + onClose, + onSubmit, + confimButtonState, +}: RuleDeleteModalProps) => { + const intl = useIntl(); + + return ( + + + + {intl.formatMessage(messages.deleteRule)} + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/discounts/components/DiscountRules/componenets/RuleForm/RuleForm.tsx b/src/discounts/components/DiscountRules/componenets/RuleForm/RuleForm.tsx new file mode 100644 index 00000000000..650cf782a73 --- /dev/null +++ b/src/discounts/components/DiscountRules/componenets/RuleForm/RuleForm.tsx @@ -0,0 +1,127 @@ +import { Condition, Rule as RuleType } from "@dashboard/discounts/models"; +import { ChannelFragment, RewardValueTypeEnum } from "@dashboard/graphql"; +import { commonMessages } from "@dashboard/intl"; +import { getFormErrors } from "@dashboard/utils/errors"; +import { + CommonError, + getCommonFormFieldErrorMessage, +} from "@dashboard/utils/errors/common"; +import { RichTextContext } from "@dashboard/utils/richText/context"; +import useRichText from "@dashboard/utils/richText/useRichText"; +import { Box, Input, Option, Select } from "@saleor/macaw-ui-next"; +import React, { useEffect, useMemo } from "react"; +import { useController, useFormContext } from "react-hook-form"; +import { useIntl } from "react-intl"; + +import { ConditionType } from "../../../../types"; +import { getCurencySymbol } from "../../utils"; +import { FetchOptions } from "./components/RuleConditionRow"; +import { RuleConditions } from "./components/RuleConditions"; +import { RuleDescription } from "./components/RuleDescription"; +import { RuleInputWrapper } from "./components/RuleInputWrapper/RuleInputWrapper"; +import { RuleReward } from "./components/RuleReward"; + +interface RuleFormProps { + channels: ChannelFragment[]; + disabled?: boolean; + errors: Array>; + typeToFetchMap: Record; +} + +export const RuleForm = ({ + channels, + disabled = false, + errors, + typeToFetchMap, +}: RuleFormProps) => { + const intl = useIntl(); + const { watch, getValues, setValue, formState } = useFormContext(); + const formErrors = getFormErrors(["rewardValue"], errors); + + const { trigger } = useFormContext(); + const { field: nameField } = useController({ + name: "name", + }); + + const { field: channelfield } = useController({ + name: "channel", + }); + + const selectedChannel = watch("channel"); + const hasSelectedChannel = !!selectedChannel; + const currencySymbol = getCurencySymbol(selectedChannel, channels); + + const richText = useRichText({ + initial: getValues("description"), + loading: false, + triggerChange: trigger, + }); + + const channelOptions = useMemo( + () => + channels.map