diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ccceb2a5..d24053197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable, unreleased changes to this project will be documented in this file. ## [Unreleased] - Added links instead of imperative navigation with onClick - #1969 by @taniotanio7 - Fixed clearing attribute values - #2047 by @witoszekdev +- Fixed EditorJS integration in RichTextEditor input - #2052 by @witoszekdev ## 3.1 ### PREVIEW FEATURES diff --git a/package-lock.json b/package-lock.json index f393bea06..c74d1d63b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2022,35 +2022,35 @@ } }, "@editorjs/editorjs": { - "version": "2.22.2", - "resolved": "https://registry.npmjs.org/@editorjs/editorjs/-/editorjs-2.22.2.tgz", - "integrity": "sha512-rPCv7Z5LZebreQaaL4DZuWzoVGEqwB+P7BF1dsefGQNBmLyeLF412topeW2b6e+g4l1oQ7t75kCOACNTEyYYIA==", + "version": "2.24.3", + "resolved": "https://registry.npmjs.org/@editorjs/editorjs/-/editorjs-2.24.3.tgz", + "integrity": "sha512-VzrWaQ7mggNUAPTDGcqXJNIlBZH3S2IqsIUGA43UM2Q9VFaeS5KuVFVOTrFJvAzF7G+vZTO52ocm+hrDhTwvyw==", "requires": { "codex-notifier": "^1.1.2", - "codex-tooltip": "^1.0.2", + "codex-tooltip": "^1.0.5", "nanoid": "^3.1.22" } }, "@editorjs/embed": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@editorjs/embed/-/embed-2.4.6.tgz", - "integrity": "sha512-c/1TzBBMrU1hpPHRWXlVanBhgyoPZvE2A3qHt23rMVxJVvzu13Zozj0ZOO8pL1XGcubf+Yvalxu7AURn7X3jPw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@editorjs/embed/-/embed-2.5.1.tgz", + "integrity": "sha512-QoBvC6upo5ubO0toGlt5b1Upcq15MC4eqejxcjcm291Ww5cO+lKhSFY6KNk3ldL9PnWKtORxFCd6pAawtkkijA==", "dev": true }, "@editorjs/header": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@editorjs/header/-/header-2.6.1.tgz", - "integrity": "sha512-EsnyVFv5uThpU9tbQ/dUPFCQoa/sBFy2n+9tN3wOXJGx7sjea4fdcacJ2UYhO+7pCgZ+aSgmMOyGLYHUFbchvA==" + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@editorjs/header/-/header-2.6.2.tgz", + "integrity": "sha512-U1dnT+KGjwFmpWneEEyR2Nqp42hn9iKwQDgRHWQM+y6qx82pg+eAyuIf0QWt2Mluu9uPD2CzNfvJ+pxIuwX8Lw==" }, "@editorjs/image": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@editorjs/image/-/image-2.6.0.tgz", - "integrity": "sha512-lX4Pz9cW3gGFzlmYLRAsBXTiqUG/MRG7NK4QVU+n/VnUWPU1e791eiIpgRLHfpPj6Maaw5a+GRut90D5EdXtqg==" + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@editorjs/image/-/image-2.6.2.tgz", + "integrity": "sha512-lai6LFJ8m3qRmSjio66o0CX7/75OupC3FQ5JWrV/biRT6GvUHtRNWKaMowKcC2ndXtfs4w6WwRxcXlB4WhUAdg==" }, "@editorjs/list": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@editorjs/list/-/list-1.6.2.tgz", - "integrity": "sha512-OxowV0yuE11G01czYM1dEQlz1F37ehX0ak5vAbZ9ncSXrPh0fDRw/fBxTY654FlmrsQ40UFom3owSG++tLvVGw==" + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@editorjs/list/-/list-1.7.0.tgz", + "integrity": "sha512-0k0RKbQqfV32u24UYHHz5mrmSu4wr246qqXBT7xQiS533Bfd4hzki6UGzvy4f275ULzi+egbjI3BXLkpoTh9iQ==" }, "@editorjs/paragraph": { "version": "2.8.0", @@ -5117,6 +5117,22 @@ "url-parse": "^1.4.7" } }, + "@reach/auto-id": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@reach/auto-id/-/auto-id-0.16.0.tgz", + "integrity": "sha512-5ssbeP5bCkM39uVsfQCwBBL+KT8YColdnMN5/Eto6Rj7929ql95R3HZUOkKIvj7mgPtEb60BLQxd1P3o6cjbmg==", + "requires": { + "@reach/utils": "0.16.0", + "tslib": "^2.3.0" + }, + "dependencies": { + "tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" + } + } + }, "@reach/router": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/@reach/router/-/router-1.3.4.tgz", @@ -5129,6 +5145,22 @@ "react-lifecycles-compat": "^3.0.4" } }, + "@reach/utils": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@reach/utils/-/utils-0.16.0.tgz", + "integrity": "sha512-PCggBet3qaQmwFNcmQ/GqHSefadAFyNCUekq9RrWoaU9hh/S4iaFgf2MBMdM47eQj5i/Bk0Mm07cP/XPFlkN+Q==", + "requires": { + "tiny-warning": "^1.0.3", + "tslib": "^2.3.0" + }, + "dependencies": { + "tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" + } + } + }, "@react-dnd/asap": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.0.tgz", @@ -5144,6 +5176,27 @@ "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz", "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==" }, + "@react-editor-js/client": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@react-editor-js/client/-/client-2.0.6.tgz", + "integrity": "sha512-LMMJLAXAwk1kVMy7fxTRFK6OdouvoseqJbmVUygJb2EcfuT84nC9OAtvGEL4vsVLUcnzEV400+F9t5OKa77FGQ==", + "requires": { + "@react-editor-js/core": "2.0.6" + } + }, + "@react-editor-js/core": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@react-editor-js/core/-/core-2.0.6.tgz", + "integrity": "sha512-mvHM2I+gT3AnvFpFhTZI0EFLKD9pRpgXDf286uwv6n6tngwLfnCCmtCbgiGI9ICph2GJvRZfaQubE+MHQ6YV8g==" + }, + "@react-editor-js/server": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@react-editor-js/server/-/server-2.0.6.tgz", + "integrity": "sha512-soW/bV5auciYr8gEYISWK4fuIblAcc4bcwPuCKnDBj9W9r/nAxMmNgCG+z9rs9Gnroa0Ko3Hzwzs9d5MdOShzg==", + "requires": { + "@react-editor-js/core": "2.0.6" + } + }, "@release-it/bumper": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@release-it/bumper/-/bumper-2.0.0.tgz", @@ -10975,9 +11028,9 @@ "integrity": "sha512-DCp6xe/LGueJ1N5sXEwcBc3r3PyVkEEDNWCVigfvywAkeXcZMk9K41a31tkEFBW0Ptlwji6/JlAb49E3Yrxbtg==" }, "codex-tooltip": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/codex-tooltip/-/codex-tooltip-1.0.4.tgz", - "integrity": "sha512-Ud+N+y8PMIa9xGyKuo2j3q8QlfTzkMWQ5KeRrbCDerwVn7xq45nqPKQCFBXEMV0YI42/OqSMnsxP8MyVAyVhnA==" + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/codex-tooltip/-/codex-tooltip-1.0.5.tgz", + "integrity": "sha512-IuA8LeyLU5p1B+HyhOsqR6oxyFQ11k3i9e9aXw40CrHFTRO2Y1npNBVU3W1SvhKAbUU7R/YikUBdcYFP0RcJag==" }, "collapse-white-space": { "version": "1.0.6", @@ -13213,11 +13266,6 @@ "resolved": "https://registry.npmjs.org/editorjs-inline-tool/-/editorjs-inline-tool-0.4.0.tgz", "integrity": "sha512-Ppb4e8IFPjWuNcoNM4tg9bDSo7FgMYAlqP4UhuV5W2JoJBubV5pUcpLrFrSyGTt1HJVEpbrib134zf4wxO+7VA==" }, - "editorjs-undo": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/editorjs-undo/-/editorjs-undo-0.1.5.tgz", - "integrity": "sha512-+qRmTe7Asn9KrsYHMCtNQi6rCBa+qrinJx/p7/Hj8K62HFzhMEuy7aMRbADd/KqcV2AsPVzgCj3PMHPmb6ZAkA==" - }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -22711,9 +22759,9 @@ "optional": true }, "nanoid": { - "version": "3.1.30", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.30.tgz", - "integrity": "sha512-zJpuPDwOv8D2zq2WRoMe1HsfZthVewpel9CAvTfc/2mBD1uUT/agc5f7GHGWXlYkFvi1mVxe4IjvP2HNrop7nQ==" + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", + "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==" }, "nanomatch": { "version": "1.2.13", @@ -25423,6 +25471,15 @@ "prop-types": "^15.7.2" } }, + "react-editor-js": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/react-editor-js/-/react-editor-js-2.0.6.tgz", + "integrity": "sha512-8u47IbhExiFB2kGNdJYlsX5iVlSzac38A3oJ7bmnTz3Lp7Slys1xreoYdG71+KiOcfX0dEgOIavV4e6TJeB5eg==", + "requires": { + "@react-editor-js/client": "2.0.6", + "@react-editor-js/server": "2.0.6" + } + }, "react-error-boundary": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-1.2.5.tgz", diff --git a/package.json b/package.json index 4b11370a3..7b6c3a663 100644 --- a/package.json +++ b/package.json @@ -18,16 +18,17 @@ }, "dependencies": { "@apollo/client": "^3.4.15", - "@editorjs/editorjs": "^2.22.2", - "@editorjs/header": "^2.6.1", - "@editorjs/image": "^2.6.0", - "@editorjs/list": "^1.6.1", + "@editorjs/editorjs": "^2.24.3", + "@editorjs/header": "^2.6.2", + "@editorjs/image": "^2.6.2", + "@editorjs/list": "^1.7.0", "@editorjs/paragraph": "^2.8.0", "@editorjs/quote": "^2.4.0", "@material-ui/core": "^4.11.4", "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.58", "@material-ui/styles": "^4.11.4", + "@reach/auto-id": "^0.16.0", "@saleor/macaw-ui": "^0.5.2", "@saleor/sdk": "^0.4.4", "@sentry/react": "^6.0.0", @@ -44,7 +45,6 @@ "cypress-mochawesome-reporter": "^2.3.0", "downshift": "^6.1.7", "editorjs-inline-tool": "^0.4.0", - "editorjs-undo": "^0.1.4", "faker": "^5.1.0", "fast-array-diff": "^0.2.0", "fuzzaldrin": "^2.1.0", @@ -60,6 +60,7 @@ "react": "^16.12.0", "react-dom": "^16.9.0", "react-dropzone": "^11.2.4", + "react-editor-js": "^2.0.6", "react-error-boundary": "^1.2.5", "react-gtm-module": "^2.0.11", "react-helmet": "^6.1.0", @@ -92,7 +93,7 @@ "@babel/preset-react": "^7.7.4", "@babel/preset-typescript": "^7.13.0", "@babel/runtime": "^7.7.6", - "@editorjs/embed": "^2.4.6", + "@editorjs/embed": "^2.5.1", "@formatjs/cli": "^4.5.0", "@graphql-codegen/add": "^3.1.1", "@graphql-codegen/cli": "^2.1.1", diff --git a/src/attributes/utils/data.ts b/src/attributes/utils/data.ts index 62f5b915e..d18e32381 100644 --- a/src/attributes/utils/data.ts +++ b/src/attributes/utils/data.ts @@ -28,6 +28,11 @@ import { mapNodeToChoice, mapPagesToChoices } from "@saleor/utils/maps"; +import { RichTextContextValues } from "@saleor/utils/richText/context"; +import { + GetRichTextValues, + RichTextGetters +} from "@saleor/utils/richText/useMultipleRichText"; import { AttributePageFormData } from "../components/AttributePage"; @@ -36,6 +41,11 @@ type AtributesOfFiles = Pick< "file" | "id" | "values" | "contentType" >; +export interface RichTextProps { + richText: RichTextContextValues; + attributeRichTextGetters: RichTextGetters; +} + export const ATTRIBUTE_TYPES_WITH_DEDICATED_VALUES = [ AttributeInputTypeEnum.DROPDOWN, AttributeInputTypeEnum.MULTISELECT, @@ -257,6 +267,41 @@ export const mergeAttributeValues = ( : attributeValues; }; +export const mergeAttributes = ( + ...attributeLists: AttributeInput[][] +): AttributeInput[] => + attributeLists.reduce((prev, attributes) => { + const newAttributeIds = new Set(attributes.map(attr => attr.id)); + return [ + ...prev.filter(attr => !newAttributeIds.has(attr.id)), + ...attributes + ]; + }, []); + +export function getRichTextAttributesFromMap( + attributes: AttributeInput[], + values: GetRichTextValues +): AttributeInput[] { + return attributes + .filter(({ data }) => data.inputType === AttributeInputTypeEnum.RICH_TEXT) + .map(attribute => ({ + ...attribute, + value: [JSON.stringify(values[attribute.id])] + })); +} + +export function getRichTextDataFromAttributes( + attributes: AttributeInput[] = [] +): Record { + const keyValuePairs = attributes + .filter( + attribute => attribute.data.inputType === AttributeInputTypeEnum.RICH_TEXT + ) + .map(attribute => [attribute.id, attribute.value[0]]); + + return Object.fromEntries(keyValuePairs); +} + export const getFileValuesToUploadFromAttributes = ( attributesWithNewFileValue: FormsetData ) => attributesWithNewFileValue.filter(fileAttribute => !!fileAttribute.value); diff --git a/src/categories/components/CategoryCreatePage/CategoryCreatePage.tsx b/src/categories/components/CategoryCreatePage/CategoryCreatePage.tsx index c83f73a2e..f11c07bdd 100644 --- a/src/categories/components/CategoryCreatePage/CategoryCreatePage.tsx +++ b/src/categories/components/CategoryCreatePage/CategoryCreatePage.tsx @@ -53,7 +53,6 @@ export const CategoryCreatePage: React.FC = ({ disabled={disabled} errors={errors} onChange={change} - onDescriptionChange={handlers.changeDescription} /> { handlers: CategoryCreateHandlers; @@ -48,10 +51,10 @@ const initialData: CategoryCreateFormData = { function useCategoryCreateForm( onSubmit: (data: CategoryCreateData) => Promise, disabled: boolean -): UseCategoryCreateFormResult { +): UseCategoryCreateFormResult & { richText: RichTextContextValues } { const { handleChange, - data, + data: formData, triggerChange, formId, setIsSubmitDisabled @@ -66,7 +69,7 @@ function useCategoryCreateForm( formId }); - const [description, changeDescription] = useRichText({ + const richText = useRichText({ initial: null, triggerChange }); @@ -77,13 +80,18 @@ function useCategoryCreateForm( const changeMetadata = makeMetadataChangeHandler(handleChange); + const data: CategoryCreateData = { + ...formData, + description: null + }; + // Need to make it function to always have description.current up to date - const getData = (): CategoryCreateData => ({ - ...data, - description: description.current + const getData = async (): Promise => ({ + ...formData, + description: await richText.getValue() }); - const submit = () => handleFormSubmit(getData()); + const submit = async () => handleFormSubmit(await getData()); useEffect(() => setExitDialogSubmitRef(submit), [submit]); @@ -91,13 +99,13 @@ function useCategoryCreateForm( return { change: handleChange, - data: getData(), + data, handlers: { - changeDescription, changeMetadata }, submit, - isSaveDisabled: disabled + isSaveDisabled: disabled, + richText }; } @@ -106,9 +114,15 @@ const CategoryCreateForm: React.FC = ({ onSubmit, disabled }) => { - const props = useCategoryCreateForm(onSubmit, disabled); + const { richText, ...props } = useCategoryCreateForm(onSubmit, disabled); - return
{children(props)}
; + return ( +
+ + {children(props)} + +
+ ); }; CategoryCreateForm.displayName = "CategoryCreateForm"; diff --git a/src/categories/components/CategoryDetailsForm/CategoryDetailsForm.tsx b/src/categories/components/CategoryDetailsForm/CategoryDetailsForm.tsx index c47b30c41..ce9f33d39 100644 --- a/src/categories/components/CategoryDetailsForm/CategoryDetailsForm.tsx +++ b/src/categories/components/CategoryDetailsForm/CategoryDetailsForm.tsx @@ -2,12 +2,11 @@ import { OutputData } from "@editorjs/editorjs"; import { Card, CardContent, TextField } from "@material-ui/core"; import CardTitle from "@saleor/components/CardTitle"; import FormSpacer from "@saleor/components/FormSpacer"; -import RichTextEditor, { - RichTextEditorChange -} from "@saleor/components/RichTextEditor"; +import RichTextEditor from "@saleor/components/RichTextEditor"; import { ProductErrorFragment } from "@saleor/graphql"; import { commonMessages } from "@saleor/intl"; import { getFormErrors, getProductErrorMessage } from "@saleor/utils/errors"; +import { useRichTextContext } from "@saleor/utils/richText/context"; import React from "react"; import { useIntl } from "react-intl"; @@ -19,17 +18,21 @@ interface CategoryDetailsFormProps { disabled: boolean; errors: ProductErrorFragment[]; onChange: (event: React.ChangeEvent) => void; - onDescriptionChange: RichTextEditorChange; } export const CategoryDetailsForm: React.FC = ({ disabled, data, onChange, - onDescriptionChange, errors }) => { const intl = useIntl(); + const { + defaultValue, + editorRef, + isReadyForMount, + handleChange + } = useRichTextContext(); const formErrors = getFormErrors(["name", "description"], errors); @@ -55,18 +58,21 @@ export const CategoryDetailsForm: React.FC = ({ /> - + {isReadyForMount && ( + + )} ); diff --git a/src/categories/components/CategoryUpdatePage/CategoryUpdatePage.tsx b/src/categories/components/CategoryUpdatePage/CategoryUpdatePage.tsx index 456a52594..c37125679 100644 --- a/src/categories/components/CategoryUpdatePage/CategoryUpdatePage.tsx +++ b/src/categories/components/CategoryUpdatePage/CategoryUpdatePage.tsx @@ -110,7 +110,6 @@ export const CategoryUpdatePage: React.FC = ({ disabled={disabled} errors={errors} onChange={change} - onDescriptionChange={handlers.changeDescription} /> { handlers: CategoryUpdateHandlers; @@ -55,10 +58,10 @@ function useCategoryUpdateForm( category: CategoryDetailsFragment, onSubmit: (data: CategoryUpdateData) => Promise, disabled: boolean -): UseCategoryUpdateFormResult { +): UseCategoryUpdateFormResult & { richText: RichTextContextValues } { const { handleChange, - data, + data: formData, triggerChange, formId, setIsSubmitDisabled @@ -73,7 +76,7 @@ function useCategoryUpdateForm( formId }); - const [description, changeDescription] = useRichText({ + const richText = useRichText({ initial: category?.description, triggerChange }); @@ -86,18 +89,23 @@ function useCategoryUpdateForm( const changeMetadata = makeMetadataChangeHandler(handleChange); + const data: CategoryUpdateData = { + ...formData, + description: null + }; + // Need to make it function to always have description.current up to date - const getData = (): CategoryUpdateData => ({ - ...data, - description: description.current + const getData = async (): Promise => ({ + ...formData, + description: await richText.getValue() }); - const getSubmitData = (): CategoryUpdateData => ({ - ...getData(), + const getSubmitData = async (): Promise => ({ + ...(await getData()), ...getMetadata(data, isMetadataModified, isPrivateMetadataModified) }); - const submit = () => handleFormSubmit(getSubmitData()); + const submit = async () => handleFormSubmit(await getSubmitData()); useEffect(() => setExitDialogSubmitRef(submit), [submit]); @@ -105,13 +113,13 @@ function useCategoryUpdateForm( return { change: handleChange, - data: getData(), + data, handlers: { - changeDescription, changeMetadata }, submit, - isSaveDisabled: disabled + isSaveDisabled: disabled, + richText }; } @@ -121,9 +129,19 @@ const CategoryUpdateForm: React.FC = ({ onSubmit, disabled }) => { - const props = useCategoryUpdateForm(category, onSubmit, disabled); + const { richText, ...props } = useCategoryUpdateForm( + category, + onSubmit, + disabled + ); - return
{children(props)}
; + return ( +
+ + {children(props)} + +
+ ); }; CategoryUpdateForm.displayName = "CategoryUpdateForm"; diff --git a/src/collections/components/CollectionCreatePage/CollectionCreatePage.tsx b/src/collections/components/CollectionCreatePage/CollectionCreatePage.tsx index 057d72255..49c6b140d 100644 --- a/src/collections/components/CollectionCreatePage/CollectionCreatePage.tsx +++ b/src/collections/components/CollectionCreatePage/CollectionCreatePage.tsx @@ -77,7 +77,6 @@ const CollectionCreatePage: React.FC = ({ disabled={disabled} errors={errors} onChange={change} - onDescriptionChange={handlers.changeDescription} /> @@ -74,7 +76,7 @@ function useCollectionCreateForm( setChannels: (data: ChannelCollectionData[]) => void, onSubmit: (data: CollectionCreateData) => SubmitPromise, disabled: boolean -): UseCollectionCreateFormResult { +): UseCollectionCreateFormResult & { richText: RichTextContextValues } { const { handleChange, data: formData, @@ -95,7 +97,7 @@ function useCollectionCreateForm( formId }); - const [description, changeDescription] = useRichText({ + const richText = useRichText({ initial: null, triggerChange }); @@ -106,10 +108,15 @@ function useCollectionCreateForm( const changeMetadata = makeMetadataChangeHandler(handleChange); - // Need to make it function to always have description.current up to date - const getData = (): CollectionCreateData => ({ + const data: CollectionCreateData = { ...formData, - description: description.current + description: null + }; + + // Need to make it function to always have description.current up to date + const getData = async (): Promise => ({ + ...formData, + description: await richText.getValue() }); const handleChannelChange = createChannelsChangeHandler( @@ -118,7 +125,7 @@ function useCollectionCreateForm( triggerChange ); - const submit = () => handleFormSubmit(getData()); + const submit = async () => handleFormSubmit(await getData()); useEffect(() => setExitDialogSubmitRef(submit), [submit]); @@ -127,14 +134,14 @@ function useCollectionCreateForm( return { change: handleChange, - data: getData(), + data, handlers: { changeChannels: handleChannelChange, - changeDescription, changeMetadata }, submit, - isSaveDisabled + isSaveDisabled, + richText }; } @@ -145,14 +152,20 @@ const CollectionCreateForm: React.FC = ({ onSubmit, disabled }) => { - const props = useCollectionCreateForm( + const { richText, ...props } = useCollectionCreateForm( currentChannels, setChannels, onSubmit, disabled ); - return
{children(props)}
; + return ( +
+ + {children(props)} + +
+ ); }; CollectionCreateForm.displayName = "CollectionCreateForm"; diff --git a/src/collections/components/CollectionDetails/CollectionDetails.tsx b/src/collections/components/CollectionDetails/CollectionDetails.tsx index ac164fb4e..0b4821047 100644 --- a/src/collections/components/CollectionDetails/CollectionDetails.tsx +++ b/src/collections/components/CollectionDetails/CollectionDetails.tsx @@ -2,12 +2,11 @@ import { OutputData } from "@editorjs/editorjs"; import { Card, CardContent, TextField } from "@material-ui/core"; import CardTitle from "@saleor/components/CardTitle"; import FormSpacer from "@saleor/components/FormSpacer"; -import RichTextEditor, { - RichTextEditorChange -} from "@saleor/components/RichTextEditor"; +import RichTextEditor from "@saleor/components/RichTextEditor"; import { CollectionErrorFragment } from "@saleor/graphql"; import { commonMessages } from "@saleor/intl"; import { getFormErrors, getProductErrorMessage } from "@saleor/utils/errors"; +import { useRichTextContext } from "@saleor/utils/richText/context"; import React from "react"; import { useIntl } from "react-intl"; @@ -19,18 +18,21 @@ export interface CollectionDetailsProps { disabled: boolean; errors: CollectionErrorFragment[]; onChange: (event: React.ChangeEvent) => void; - onDescriptionChange: RichTextEditorChange; } const CollectionDetails: React.FC = ({ disabled, data, onChange, - onDescriptionChange, errors }) => { const intl = useIntl(); - + const { + defaultValue, + editorRef, + isReadyForMount, + handleChange + } = useRichTextContext(); const formErrors = getFormErrors(["name", "description"], errors); return ( @@ -54,15 +56,18 @@ const CollectionDetails: React.FC = ({ fullWidth /> - + {isReadyForMount && ( + + )} ); diff --git a/src/collections/components/CollectionDetailsPage/CollectionDetailsPage.tsx b/src/collections/components/CollectionDetailsPage/CollectionDetailsPage.tsx index 2c173a11d..5dc70bd0d 100644 --- a/src/collections/components/CollectionDetailsPage/CollectionDetailsPage.tsx +++ b/src/collections/components/CollectionDetailsPage/CollectionDetailsPage.tsx @@ -88,7 +88,6 @@ const CollectionDetailsPage: React.FC = ({ disabled={disabled} errors={errors} onChange={change} - onDescriptionChange={handlers.changeDescription} /> @@ -71,7 +73,7 @@ function useCollectionUpdateForm( setChannels: (data: ChannelCollectionData[]) => void, onSubmit: (data: CollectionUpdateData) => Promise, disabled: boolean -): UseCollectionUpdateFormResult { +): UseCollectionUpdateFormResult & { richText: RichTextContextValues } { const { handleChange, data: formData, @@ -92,7 +94,7 @@ function useCollectionUpdateForm( formId: COLLECTION_DETAILS_FORM_ID }); - const [description, changeDescription] = useRichText({ + const richText = useRichText({ initial: collection?.description, triggerChange }); @@ -105,14 +107,19 @@ function useCollectionUpdateForm( const changeMetadata = makeMetadataChangeHandler(handleChange); - // Need to make it function to always have description.current up to date - const getData = (): CollectionUpdateData => ({ + const data: CollectionUpdateData = { ...formData, - description: description.current + description: null + }; + + // Need to make it function to always have description.current up to date + const getData = async (): Promise => ({ + ...formData, + description: await richText.getValue() }); - const getSubmitData = (): CollectionUpdateData => ({ - ...getData(), + const getSubmitData = async (): Promise => ({ + ...(await getData()), ...getMetadata(formData, isMetadataModified, isPrivateMetadataModified) }); @@ -122,7 +129,7 @@ function useCollectionUpdateForm( triggerChange ); - const submit = () => handleFormSubmit(getSubmitData()); + const submit = async () => handleFormSubmit(await getSubmitData()); useEffect(() => setExitDialogSubmitRef(submit), [submit]); @@ -130,13 +137,13 @@ function useCollectionUpdateForm( return { change: handleChange, - data: getData(), + data, handlers: { changeChannels: handleChannelChange, - changeDescription, changeMetadata }, - submit + submit, + richText }; } @@ -148,7 +155,7 @@ const CollectionUpdateForm: React.FC = ({ onSubmit, disabled }) => { - const props = useCollectionUpdateForm( + const { richText, ...props } = useCollectionUpdateForm( collection, currentChannels, setChannels, @@ -156,7 +163,13 @@ const CollectionUpdateForm: React.FC = ({ disabled ); - return
{children(props)}
; + return ( +
+ + {children(props)} + +
+ ); }; CollectionUpdateForm.displayName = "CollectionUpdateForm"; diff --git a/src/components/Attributes/AttributeRow.tsx b/src/components/Attributes/AttributeRow.tsx index 0c85cca9a..d6cf790c8 100644 --- a/src/components/Attributes/AttributeRow.tsx +++ b/src/components/Attributes/AttributeRow.tsx @@ -10,7 +10,6 @@ import { getMultiChoices, getMultiDisplayValue, getReferenceDisplayValue, - getRichTextData, getSingleChoices, getSingleDisplayValue } from "@saleor/components/Attributes/utils"; @@ -43,8 +42,8 @@ const AttributeRow: React.FC = ({ onChange, fetchAttributeValues, fetchMoreAttributeValues, - entityId, - onAttributeSelectBlur + onAttributeSelectBlur, + richTextGetters }) => { const intl = useIntl(); const classes = useStyles(); @@ -126,18 +125,27 @@ const AttributeRow: React.FC = ({ /> ); case AttributeInputTypeEnum.RICH_TEXT: + const { + getShouldMount, + getDefaultValue, + getMountEditor, + getHandleChange + } = richTextGetters; + const defaultValue = getDefaultValue(attribute.id); return ( - onChange(attribute.id, JSON.stringify(data))} - data={getRichTextData(attribute)} - /> + {getShouldMount(attribute.id) && ( + + )} ); case AttributeInputTypeEnum.NUMERIC: diff --git a/src/components/Attributes/Attributes.stories.tsx b/src/components/Attributes/Attributes.stories.tsx index b7ea8081b..223d75c2a 100644 --- a/src/components/Attributes/Attributes.stories.tsx +++ b/src/components/Attributes/Attributes.stories.tsx @@ -20,7 +20,13 @@ const props: AttributesProps = { onReferencesReorder: () => undefined, fetchAttributeValues: () => undefined, fetchMoreAttributeValues: fetchMoreProps, - onAttributeSelectBlur: () => undefined + onAttributeSelectBlur: () => undefined, + richTextGetters: { + getDefaultValue: () => undefined, + getHandleChange: () => () => undefined, + getMountEditor: () => () => undefined, + getShouldMount: () => true + } }; storiesOf("Attributes / Attributes", module) diff --git a/src/components/Attributes/Attributes.tsx b/src/components/Attributes/Attributes.tsx index 059ed6552..fa34521b7 100644 --- a/src/components/Attributes/Attributes.tsx +++ b/src/components/Attributes/Attributes.tsx @@ -15,6 +15,7 @@ import { import { FormsetAtomicData } from "@saleor/hooks/useFormset"; import { IconButton, makeStyles } from "@saleor/macaw-ui"; import { FetchMoreProps } from "@saleor/types"; +import { RichTextGetters } from "@saleor/utils/richText/useMultipleRichText"; import classNames from "classnames"; import React from "react"; import { defineMessages, FormattedMessage, useIntl } from "react-intl"; @@ -46,7 +47,7 @@ export interface AttributesProps extends AttributeRowHandlers { ProductErrorWithAttributesFragment | PageErrorWithAttributesFragment >; title?: React.ReactNode; - entityId?: string; + richTextGetters: RichTextGetters; } const useStyles = makeStyles( @@ -122,7 +123,7 @@ const Attributes: React.FC = ({ errors, title, onAttributeSelectBlur, - entityId = "_defaultId", + richTextGetters, ...props }) => { const intl = useIntl(); @@ -170,11 +171,11 @@ const Attributes: React.FC = ({ {attributeIndex > 0 &&
}
diff --git a/src/components/Attributes/types.ts b/src/components/Attributes/types.ts index dfcd14863..8213c3a57 100644 --- a/src/components/Attributes/types.ts +++ b/src/components/Attributes/types.ts @@ -5,6 +5,7 @@ import { } from "@saleor/graphql"; import { FormsetChange } from "@saleor/hooks/useFormset"; import { FetchMoreProps, ReorderEvent } from "@saleor/types"; +import { RichTextGetters } from "@saleor/utils/richText/useMultipleRichText"; import { AttributeInput } from "./Attributes"; @@ -31,6 +32,6 @@ export interface AttributeRowProps extends AttributeRowHandlers { disabled: boolean; error: ProductErrorWithAttributesFragment | PageErrorWithAttributesFragment; loading: boolean; - entityId: string; onAttributeSelectBlur?: () => void; + richTextGetters: RichTextGetters; } diff --git a/src/components/Grid/Grid.tsx b/src/components/Grid/Grid.tsx index c97f278cc..d192e8121 100644 --- a/src/components/Grid/Grid.tsx +++ b/src/components/Grid/Grid.tsx @@ -7,6 +7,7 @@ export interface GridProps { children: React.ReactNodeArray | React.ReactNode; className?: string; variant?: GridVariant; + richText?: boolean; } const useStyles = makeStyles( @@ -31,13 +32,18 @@ const useStyles = makeStyles( }, uniform: { gridTemplateColumns: "1fr 1fr" + }, + richText: { + "&& > div": { + overflow: "visible" + } } }), { name: "Grid" } ); export const Grid: React.FC = props => { - const { className, children, variant } = props; + const { className, children, variant, richText } = props; const classes = useStyles(props); @@ -46,7 +52,8 @@ export const Grid: React.FC = props => { className={classNames(className, classes.root, { [classes.default]: variant === "default", [classes.inverted]: variant === "inverted", - [classes.uniform]: variant === "uniform" + [classes.uniform]: variant === "uniform", + [classes.richText]: richText })} > {children} diff --git a/src/components/RichTextEditor/RichTextEditor.stories.tsx b/src/components/RichTextEditor/RichTextEditor.stories.tsx index 9ae532917..db2629c9f 100644 --- a/src/components/RichTextEditor/RichTextEditor.stories.tsx +++ b/src/components/RichTextEditor/RichTextEditor.stories.tsx @@ -9,16 +9,16 @@ import * as fixtures from "./fixtures.json"; import { RichTextEditorProps } from "./RichTextEditor"; import RichTextEditorContent from "./RichTextEditorContent"; -export const data: OutputData = fixtures.richTextEditor; +export const defaultValue: OutputData = fixtures.richTextEditor; const props: RichTextEditorProps = { - data, + defaultValue, disabled: false, error: false, helperText: "Lorem ipsum dolor sit amet, consectetur adipiscing elit", label: "Content", name: "content", - onChange: () => undefined + editorRef: null }; storiesOf("Generics / Rich text editor", module) @@ -27,4 +27,6 @@ storiesOf("Generics / Rich text editor", module) .add("default", () => ) .add("disabled", () => ) .add("error", () => ) - .add("static", () => ); + .add("static", () => ( + + )); diff --git a/src/components/RichTextEditor/RichTextEditor.tsx b/src/components/RichTextEditor/RichTextEditor.tsx index ed3ae99d7..e1e2a7409 100644 --- a/src/components/RichTextEditor/RichTextEditor.tsx +++ b/src/components/RichTextEditor/RichTextEditor.tsx @@ -1,98 +1,73 @@ -import EditorJS, { LogLevels, OutputData } from "@editorjs/editorjs"; +import { LogLevels, OutputData } from "@editorjs/editorjs"; import { FormControl, FormHelperText, InputLabel } from "@material-ui/core"; -import { PromiseQueue } from "@saleor/misc"; +import { useId } from "@reach/auto-id"; +import { Props as ReactEditorJSProps } from "@react-editor-js/core"; import classNames from "classnames"; import React from "react"; +import { createReactEditorJS } from "react-editor-js"; -import { RichTextEditorContentProps, tools } from "./RichTextEditorContent"; +import { tools } from "./consts"; +import { useHasRendered } from "./hooks"; import useStyles from "./styles"; -import { clean } from "./utils"; -export type RichTextEditorChange = (data: OutputData) => void; -export interface RichTextEditorProps extends RichTextEditorContentProps { +export type EditorJsProps = Omit; + +// https://github.com/Jungwoo-An/react-editor-js#how-to-access-editor-js-instance +export interface EditorCore { + destroy(): Promise; + clear(): Promise; + save(): Promise; + render(data: OutputData): Promise; +} + +export interface RichTextEditorProps extends Omit { + id?: string; disabled: boolean; error: boolean; helperText: string; label: string; name: string; - onChange: RichTextEditorChange; + editorRef: + | React.RefCallback + | React.MutableRefObject + | null; + // onChange with value shouldn't be used due to issues with React and EditorJS integration + onChange?: () => void; } +const ReactEditorJS = createReactEditorJS(); + const RichTextEditor: React.FC = ({ - data, + id: defaultId, disabled, error, - helperText, label, name, - onChange, - onReady + helperText, + editorRef, + onInitialize, + onReady, + ...props }) => { const classes = useStyles({}); + const id = useId(defaultId); + const [isFocused, setIsFocused] = React.useState(false); - const [isFocused, setFocus] = React.useState(false); - const editor = React.useRef(); - const editorContainer = React.useRef(); - const togglePromiseQueue = React.useRef(PromiseQueue()); // used to await subsequent toggle invocations + const handleInitialize = React.useCallback((editor: EditorCore) => { + if (onInitialize) { + onInitialize(editor); + } - React.useEffect( - () => { - if (data !== undefined && !editor.current) { - editor.current = new EditorJS({ - data, - holder: editorContainer.current, - logLevel: "ERROR" as LogLevels, - onChange: async api => { - const savedData = await api.saver.save(); - onChange(savedData); - }, - onReady: () => { - // FIXME: This throws an error and is not working - // const undo = new Undo({ editor }); - // undo.initialize(data); + if (typeof editorRef === "function") { + return editorRef(editor); + } + if (editorRef) { + return (editorRef.current = editor); + } + }, []); - if (onReady) { - onReady(); - } - }, - tools - }); - } - - return () => { - clean(editor.current); - editor.current = null; - }; - }, - // Rerender editor only if changed from undefined to defined state - [data === undefined] - ); - - React.useEffect(() => { - const toggle = async () => { - if (!editor.current) { - return; - } - - await editor.current.isReady; - if (editor.current?.readOnly) { - // readOnly.toggle() by itself does not enqueue the events and will result in a broken output if invocations overlap - // Remove this logic when this is fixed in EditorJS - togglePromiseQueue.current.add(() => - editor.current.readOnly.toggle(disabled) - ); - - // Switching to readOnly with empty blocks present causes the editor to freeze - // Remove this logic when this is fixed in EditorJS - if (!disabled && !data?.blocks?.length) { - await togglePromiseQueue.current.queue; - editor.current.clear(); - } - } - }; - - toggle(); - }, [disabled]); + // We need to render FormControl first to get id from @reach/auto-id + const hasRendered = useHasRendered(); return ( = ({ {label} -
setFocus(true)} - onBlur={() => setFocus(false)} - /> + {hasRendered && ( + +
setIsFocused(true)} + onBlur={() => setIsFocused(false)} + /> + + )} {helperText} ); diff --git a/src/components/RichTextEditor/RichTextEditorContent.tsx b/src/components/RichTextEditor/RichTextEditorContent.tsx index 08fdd90ff..f1f2ca490 100644 --- a/src/components/RichTextEditor/RichTextEditorContent.tsx +++ b/src/components/RichTextEditor/RichTextEditorContent.tsx @@ -1,104 +1,52 @@ -import EditorJS, { - LogLevels, - OutputData, - ToolConstructable, - ToolSettings -} from "@editorjs/editorjs"; -import Embed from "@editorjs/embed"; -import Header from "@editorjs/header"; -import List from "@editorjs/list"; -import Paragraph from "@editorjs/paragraph"; -import Quote from "@editorjs/quote"; -import strikethroughIcon from "@saleor/icons/StrikethroughIcon"; +import { LogLevels } from "@editorjs/editorjs"; +import { useId } from "@reach/auto-id"; import classNames from "classnames"; -import createGenericInlineTool from "editorjs-inline-tool"; import React from "react"; +import { createReactEditorJS } from "react-editor-js"; +import { tools } from "./consts"; +import { useHasRendered } from "./hooks"; +import { EditorJsProps } from "./RichTextEditor"; import useStyles from "./styles"; -import { clean } from "./utils"; -export interface RichTextEditorContentProps { +export interface RichTextEditorContentProps + extends Omit { + id?: string; className?: string; - data: OutputData; - onReady?: () => void; } -const inlineToolbar = ["link", "bold", "italic", "strikethrough"]; - -export const tools: Record = { - embed: Embed, - header: { - class: Header, - config: { - defaultLevel: 1, - levels: [1, 2, 3] - }, - inlineToolbar - }, - list: { - class: List, - inlineToolbar - }, - quote: { - class: Quote, - inlineToolbar - }, - paragraph: { - class: Paragraph, - inlineToolbar - }, - strikethrough: createGenericInlineTool({ - sanitize: { - s: {} - }, - shortcut: "CMD+S", - tagName: "s", - toolboxIcon: strikethroughIcon - }) -}; +const ReactEditorJS = createReactEditorJS(); const RichTextEditorContent: React.FC = ({ + id: defaultId, className, - data, - onReady + value, + ...props }) => { const classes = useStyles({}); + const id = useId(defaultId); - const editor = React.useRef(); - const editorContainer = React.useRef(); - React.useEffect( - () => { - if (data !== undefined && !editor.current) { - const editorjs = new EditorJS({ - data, - holder: editorContainer.current, - logLevel: "ERROR" as LogLevels, - onReady: () => { - editor.current = editorjs; + // We need to render FormControl first to get id from @reach/auto-id + const hasRendered = useHasRendered(); - if (onReady) { - onReady(); - } - }, - readOnly: true, - tools - }); - } - - return () => { - clean(editor.current); - editor.current = null; - }; - }, - // Rerender editor only if changed from undefined to defined state - [data === undefined] - ); + if (!hasRendered) { + return
; + } return ( -
+ +
+ ); }; diff --git a/src/components/RichTextEditor/__mocks__/RichTextEditor.tsx b/src/components/RichTextEditor/__mocks__/RichTextEditor.tsx new file mode 100644 index 000000000..3f8f74954 --- /dev/null +++ b/src/components/RichTextEditor/__mocks__/RichTextEditor.tsx @@ -0,0 +1,30 @@ +import { FormControl, FormHelperText, InputLabel } from "@material-ui/core"; +import React from "react"; + +import { RichTextEditorProps } from "../RichTextEditor"; + +export const HOLDER = "TEST_HOLDER"; + +const RichTextEditor: React.FC = ({ + disabled, + error, + label, + name, + helperText +}) => ( + + + {label} + + + {helperText} + +); + +export default RichTextEditor; diff --git a/src/components/RichTextEditor/consts.ts b/src/components/RichTextEditor/consts.ts new file mode 100644 index 000000000..f52f865bf --- /dev/null +++ b/src/components/RichTextEditor/consts.ts @@ -0,0 +1,42 @@ +import { ToolConstructable, ToolSettings } from "@editorjs/editorjs"; +import Embed from "@editorjs/embed"; +import Header from "@editorjs/header"; +import List from "@editorjs/list"; +import Paragraph from "@editorjs/paragraph"; +import Quote from "@editorjs/quote"; +import strikethroughIcon from "@saleor/icons/StrikethroughIcon"; +import createGenericInlineTool from "editorjs-inline-tool"; + +const inlineToolbar = ["link", "bold", "italic", "strikethrough"]; + +export const tools: Record = { + embed: Embed, + header: { + class: Header, + config: { + defaultLevel: 1, + levels: [1, 2, 3] + }, + inlineToolbar + }, + list: { + class: List, + inlineToolbar + }, + quote: { + class: Quote, + inlineToolbar + }, + paragraph: { + class: Paragraph, + inlineToolbar + }, + strikethrough: createGenericInlineTool({ + sanitize: { + s: {} + }, + shortcut: "CMD+S", + tagName: "s", + toolboxIcon: strikethroughIcon + }) +}; diff --git a/src/components/RichTextEditor/hooks.ts b/src/components/RichTextEditor/hooks.ts new file mode 100644 index 000000000..f514771ba --- /dev/null +++ b/src/components/RichTextEditor/hooks.ts @@ -0,0 +1,11 @@ +import { useLayoutEffect, useState } from "react"; + +export const useHasRendered = () => { + const [hasRendered, setHasRendereed] = useState(false); + + useLayoutEffect(() => { + setHasRendereed(true); + }, []); + + return hasRendered; +}; diff --git a/src/pages/components/PageDetailsPage/PageDetailsPage.tsx b/src/pages/components/PageDetailsPage/PageDetailsPage.tsx index e8fe2acc3..bc7c19cf1 100644 --- a/src/pages/components/PageDetailsPage/PageDetailsPage.tsx +++ b/src/pages/components/PageDetailsPage/PageDetailsPage.tsx @@ -139,7 +139,14 @@ const PageDetailsPage: React.FC = ({ onSubmit={onSubmit} disabled={loading} > - {({ change, data, handlers, submit, isSaveDisabled }) => ( + {({ + change, + data, + handlers, + submit, + isSaveDisabled, + attributeRichTextGetters + }) => ( {intl.formatMessage(sectionNames.pages)} @@ -162,7 +169,6 @@ const PageDetailsPage: React.FC = ({ disabled={loading} errors={errors} onChange={change} - onContentChange={handlers.changeContent} /> = ({ fetchAttributeValues={fetchAttributeValues} fetchMoreAttributeValues={fetchMoreAttributeValues} onAttributeSelectBlur={onAttributeSelectBlur} + richTextGetters={attributeRichTextGetters} /> )} diff --git a/src/pages/components/PageDetailsPage/form.tsx b/src/pages/components/PageDetailsPage/form.tsx index 3852b1103..d88d768c4 100644 --- a/src/pages/components/PageDetailsPage/form.tsx +++ b/src/pages/components/PageDetailsPage/form.tsx @@ -1,5 +1,11 @@ import { OutputData } from "@editorjs/editorjs"; -import { getAttributesDisplayData } from "@saleor/attributes/utils/data"; +import { + getAttributesDisplayData, + getRichTextAttributesFromMap, + getRichTextDataFromAttributes, + mergeAttributes, + RichTextProps +} from "@saleor/attributes/utils/data"; import { createAttributeChangeHandler, createAttributeFileChangeHandler, @@ -12,7 +18,6 @@ import { import { AttributeInput } from "@saleor/components/Attributes"; import { useExitFormDialog } from "@saleor/components/Form/useExitFormDialog"; import { MetadataFormData } from "@saleor/components/Metadata"; -import { RichTextEditorChange } from "@saleor/components/RichTextEditor"; import { PageDetailsFragment, SearchPagesQuery, @@ -39,6 +44,8 @@ import getPublicationData from "@saleor/utils/data/getPublicationData"; import { mapMetadataItemToInput } from "@saleor/utils/maps"; import getMetadata from "@saleor/utils/metadata/getMetadata"; import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; +import { RichTextContext } from "@saleor/utils/richText/context"; +import { useMultipleRichText } from "@saleor/utils/richText/useMultipleRichText"; import useRichText from "@saleor/utils/richText/useRichText"; import React, { useEffect } from "react"; @@ -64,7 +71,6 @@ export interface PageSubmitData extends PageFormData { export interface PageUpdateHandlers { changeMetadata: FormChange; - changeContent: RichTextEditorChange; selectPageType: FormChange; selectAttribute: FormsetChange; selectAttributeMulti: FormsetChange; @@ -75,11 +81,17 @@ export interface PageUpdateHandlers { fetchMoreReferences: FetchMoreProps; } -export interface UsePageUpdateFormResult - extends CommonUseFormResultWithHandlers { +export interface UsePageUpdateFormOutput + extends CommonUseFormResultWithHandlers, + RichTextProps { valid: boolean; } +export type UsePageUpdateFormRenderProps = Omit< + UsePageUpdateFormOutput, + "richText" +>; + export interface UsePageFormOpts { pageTypes?: RelayToFlat; referencePages: RelayToFlat; @@ -94,7 +106,7 @@ export interface UsePageFormOpts { } export interface PageFormProps extends UsePageFormOpts { - children: (props: UsePageUpdateFormResult) => React.ReactNode; + children: (props: UsePageUpdateFormRenderProps) => React.ReactNode; page: PageDetailsFragment; onSubmit: (data: PageData) => SubmitPromise; disabled: boolean; @@ -117,18 +129,9 @@ function usePageForm( onSubmit: (data: PageData) => SubmitPromise, disabled: boolean, opts: UsePageFormOpts -): UsePageUpdateFormResult { +): UsePageUpdateFormOutput { const pageExists = page !== null; - const attributes = useFormset( - pageExists - ? getAttributeInputFromPage(page) - : opts.selectedPageType - ? getAttributeInputFromPageType(opts.selectedPageType) - : [] - ); - const attributesWithNewFileValue = useFormset([]); - const { handleChange, triggerChange, data: formData, formId } = useForm( getInitialFormData(page), undefined, @@ -137,11 +140,28 @@ function usePageForm( } ); + const attributes = useFormset( + pageExists + ? getAttributeInputFromPage(page) + : opts.selectedPageType + ? getAttributeInputFromPageType(opts.selectedPageType) + : [] + ); + + const { + getters: attributeRichTextGetters, + getValues: getAttributeRichTextValues + } = useMultipleRichText({ + initial: getRichTextDataFromAttributes(attributes.data), + triggerChange + }); + const attributesWithNewFileValue = useFormset([]); + const { setExitDialogSubmitRef, setIsSubmitDisabled } = useExitFormDialog({ formId }); - const [content, changeContent] = useRichText({ + const richText = useRichText({ initial: pageExists ? page?.content : null, triggerChange }); @@ -195,8 +215,7 @@ function usePageForm( triggerChange ); - // Need to make it function to always have content.current up to date - const getData = (): PageData => ({ + const data: PageData = { ...formData, attributes: getAttributesDisplayData( attributes.data, @@ -204,14 +223,22 @@ function usePageForm( opts.referencePages, opts.referenceProducts ), - content: content.current, + content: null, pageType: pageExists ? page?.pageType : opts.selectedPageType - }); + }; - const getSubmitData = (): PageSubmitData => ({ - ...getData(), + const getSubmitData = async (): Promise => ({ + ...data, ...getMetadata(formData, isMetadataModified, isPrivateMetadataModified), ...getPublicationData(formData), + content: await richText.getValue(), + attributes: mergeAttributes( + attributes.data, + getRichTextAttributesFromMap( + attributes.data, + await getAttributeRichTextValues() + ) + ), attributesWithNewFileValue: attributesWithNewFileValue.data }); @@ -230,7 +257,7 @@ function usePageForm( onSubmit: handleSubmit }); - const submit = () => handleFormSubmit(getSubmitData()); + const submit = async () => handleFormSubmit(await getSubmitData()); useEffect(() => setExitDialogSubmitRef(submit), [submit]); @@ -241,10 +268,9 @@ function usePageForm( return { change: handleChange, - data: getData(), + data, valid, handlers: { - changeContent, changeMetadata, fetchMoreReferences: handleFetchMoreReferences, fetchReferences: handleFetchReferences, @@ -256,7 +282,9 @@ function usePageForm( selectPageType: handlePageTypeSelect }, submit, - isSaveDisabled + isSaveDisabled, + richText, + attributeRichTextGetters }; } @@ -267,9 +295,15 @@ const PageForm: React.FC = ({ disabled, ...rest }) => { - const props = usePageForm(page, onSubmit, disabled, rest); + const { richText, ...props } = usePageForm(page, onSubmit, disabled, rest); - return
{children(props)}
; + return ( +
+ + {children(props)} + +
+ ); }; PageForm.displayName = "PageForm"; diff --git a/src/pages/components/PageInfo/PageInfo.tsx b/src/pages/components/PageInfo/PageInfo.tsx index 1393cc4b6..c473ac8ca 100644 --- a/src/pages/components/PageInfo/PageInfo.tsx +++ b/src/pages/components/PageInfo/PageInfo.tsx @@ -1,14 +1,13 @@ import { Card, CardContent, TextField } from "@material-ui/core"; import CardTitle from "@saleor/components/CardTitle"; import FormSpacer from "@saleor/components/FormSpacer"; -import RichTextEditor, { - RichTextEditorChange -} from "@saleor/components/RichTextEditor"; +import RichTextEditor from "@saleor/components/RichTextEditor"; import { PageErrorFragment } from "@saleor/graphql"; import { commonMessages } from "@saleor/intl"; import { makeStyles } from "@saleor/macaw-ui"; import { getFormErrors } from "@saleor/utils/errors"; import getPageErrorMessage from "@saleor/utils/errors/page"; +import { useRichTextContext } from "@saleor/utils/richText/context"; import React from "react"; import { useIntl } from "react-intl"; @@ -19,7 +18,6 @@ export interface PageInfoProps { disabled: boolean; errors: PageErrorFragment[]; onChange: (event: React.ChangeEvent) => void; - onContentChange: RichTextEditorChange; } const useStyles = makeStyles( @@ -32,11 +30,17 @@ const useStyles = makeStyles( ); const PageInfo: React.FC = props => { - const { data, disabled, errors, onChange, onContentChange } = props; + const { data, disabled, errors, onChange } = props; const classes = useStyles(props); const intl = useIntl(); + const { + defaultValue, + editorRef, + isReadyForMount, + handleChange + } = useRichTextContext(); const formErrors = getFormErrors(["title", "content"], errors); return ( @@ -60,19 +64,22 @@ const PageInfo: React.FC = props => { onChange={onChange} /> - + {isReadyForMount && ( + + )} ); diff --git a/src/products/components/ProductCreatePage/ProductCreatePage.tsx b/src/products/components/ProductCreatePage/ProductCreatePage.tsx index 181f851a5..d7e06547c 100644 --- a/src/products/components/ProductCreatePage/ProductCreatePage.tsx +++ b/src/products/components/ProductCreatePage/ProductCreatePage.tsx @@ -206,7 +206,15 @@ export const ProductCreatePage: React.FC = ({ assignReferencesAttributeId={assignReferencesAttributeId} loading={loading} > - {({ change, data, formErrors, handlers, submit, isSaveDisabled }) => { + {({ + change, + data, + formErrors, + handlers, + submit, + isSaveDisabled, + attributeRichTextGetters + }) => { // Comparing explicitly to false because `hasVariants` can be undefined const isSimpleProduct = data.productType?.hasVariants === false; @@ -223,7 +231,6 @@ export const ProductCreatePage: React.FC = ({ disabled={loading} errors={errors} onChange={change} - onDescriptionChange={handlers.changeDescription} /> {data.attributes.length > 0 && ( @@ -242,6 +249,7 @@ export const ProductCreatePage: React.FC = ({ fetchAttributeValues={fetchAttributeValues} fetchMoreAttributeValues={fetchMoreAttributeValues} onAttributeSelectBlur={onAttributeSelectBlur} + richTextGetters={attributeRichTextGetters} /> )} diff --git a/src/products/components/ProductCreatePage/form.tsx b/src/products/components/ProductCreatePage/form.tsx index 4abb35e17..109951275 100644 --- a/src/products/components/ProductCreatePage/form.tsx +++ b/src/products/components/ProductCreatePage/form.tsx @@ -1,5 +1,11 @@ import { OutputData } from "@editorjs/editorjs"; -import { getAttributesDisplayData } from "@saleor/attributes/utils/data"; +import { + getAttributesDisplayData, + getRichTextAttributesFromMap, + getRichTextDataFromAttributes, + mergeAttributes, + RichTextProps +} from "@saleor/attributes/utils/data"; import { createAttributeChangeHandler, createAttributeFileChangeHandler, @@ -17,7 +23,6 @@ import { import { useExitFormDialog } from "@saleor/components/Form/useExitFormDialog"; import { MetadataFormData } from "@saleor/components/Metadata"; import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField"; -import { RichTextEditorChange } from "@saleor/components/RichTextEditor"; import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField"; import { ProductTypeQuery, @@ -56,6 +61,8 @@ import { FetchMoreProps, RelayToFlat, ReorderEvent } from "@saleor/types"; import createMultiAutocompleteSelectHandler from "@saleor/utils/handlers/multiAutocompleteSelectChangeHandler"; import createSingleAutocompleteSelectHandler from "@saleor/utils/handlers/singleAutocompleteSelectChangeHandler"; import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; +import { RichTextContext } from "@saleor/utils/richText/context"; +import { useMultipleRichText } from "@saleor/utils/richText/useMultipleRichText"; import useRichText from "@saleor/utils/richText/useRichText"; import React, { useEffect } from "react"; import { useIntl } from "react-intl"; @@ -119,20 +126,25 @@ export interface ProductCreateHandlers Record<"selectAttributeFile", FormsetChange>, Record<"reorderAttributeValue", FormsetChange>, Record<"addStock" | "deleteStock", (id: string) => void> { - changeDescription: RichTextEditorChange; changePreorderEndDate: FormChange; fetchReferences: (value: string) => void; fetchMoreReferences: FetchMoreProps; } -export interface UseProductCreateFormResult +export interface UseProductCreateFormOutput extends CommonUseFormResultWithHandlers< - ProductCreateData, - ProductCreateHandlers - > { + ProductCreateData, + ProductCreateHandlers + >, + RichTextProps { disabled: boolean; formErrors: FormErrors; } +export type UseProductCreateFormRenderProps = Omit< + UseProductCreateFormOutput, + "richText" +>; + export interface UseProductCreateFormOpts extends Record< "categories" | "collections" | "taxTypes", @@ -160,7 +172,7 @@ export interface UseProductCreateFormOpts } export interface ProductCreateFormProps extends UseProductCreateFormOpts { - children: (props: UseProductCreateFormResult) => React.ReactNode; + children: (props: UseProductCreateFormRenderProps) => React.ReactNode; initial?: Partial; onSubmit: (data: ProductCreateData) => SubmitPromise; loading: boolean; @@ -171,7 +183,7 @@ function useProductCreateForm( onSubmit: (data: ProductCreateData) => SubmitPromise, loading: boolean, opts: UseProductCreateFormOpts -): UseProductCreateFormResult { +): UseProductCreateFormOutput { const intl = useIntl(); const defaultInitialFormData: ProductCreateFormData & Record<"productType", string> = { @@ -224,9 +236,16 @@ function useProductCreateForm( ? getAttributeInputFromProductType(opts.selectedProductType) : [] ); + const { + getters: attributeRichTextGetters, + getValues: getAttributeRichTextValues + } = useMultipleRichText({ + initial: getRichTextDataFromAttributes(attributes.data), + triggerChange + }); const attributesWithNewFileValue = useFormset([]); const stocks = useFormset([]); - const [description, changeDescription] = useRichText({ + const richText = useRichText({ initial: null, triggerChange }); @@ -329,7 +348,7 @@ function useProductCreateForm( intl.formatMessage(errorMessages.preorderEndDateInFutureErrorText) ); - const getData = (): ProductCreateData => ({ + const data: ProductCreateData = { ...formData, attributes: getAttributesDisplayData( attributes.data, @@ -338,19 +357,29 @@ function useProductCreateForm( opts.referenceProducts ), attributesWithNewFileValue: attributesWithNewFileValue.data, - description: description.current, + description: null, productType: opts.selectedProductType, stocks: stocks.data - }); + }; - const data = getData(); + const getData = async (): Promise => ({ + ...data, + description: await richText.getValue(), + attributes: mergeAttributes( + attributes.data, + getRichTextAttributesFromMap( + attributes.data, + await getAttributeRichTextValues() + ) + ) + }); const handleFormSubmit = useHandleFormSubmit({ formId, onSubmit }); - const submit = () => handleFormSubmit(data); + const submit = async () => handleFormSubmit(await getData()); const { setExitDialogSubmitRef, setIsSubmitDisabled } = useExitFormDialog({ formId: PRODUCT_CREATE_FORM_ID @@ -398,7 +427,6 @@ function useProductCreateForm( addStock: handleStockAdd, changeChannelPrice: handleChannelPriceChange, changeChannels: handleChannelsChange, - changeDescription, changeMetadata, changeStock: handleStockChange, changePreorderEndDate: handlePreorderEndDateChange, @@ -416,7 +444,9 @@ function useProductCreateForm( selectTaxRate: handleTaxTypeSelect }, submit, - isSaveDisabled + isSaveDisabled, + richText, + attributeRichTextGetters }; } @@ -427,9 +457,20 @@ const ProductCreateForm: React.FC = ({ loading, ...rest }) => { - const props = useProductCreateForm(initial || {}, onSubmit, loading, rest); + const { richText, ...props } = useProductCreateForm( + initial || {}, + onSubmit, + loading, + rest + ); - return
{children(props)}
; + return ( +
+ + {children(props)} + +
+ ); }; ProductCreateForm.displayName = "ProductCreateForm"; diff --git a/src/products/components/ProductDetailsForm/ProductDetailsForm.tsx b/src/products/components/ProductDetailsForm/ProductDetailsForm.tsx index 98c4c2883..86be69b7b 100644 --- a/src/products/components/ProductDetailsForm/ProductDetailsForm.tsx +++ b/src/products/components/ProductDetailsForm/ProductDetailsForm.tsx @@ -4,12 +4,11 @@ import CardTitle from "@saleor/components/CardTitle"; import FormSpacer from "@saleor/components/FormSpacer"; import Grid from "@saleor/components/Grid"; import Hr from "@saleor/components/Hr"; -import RichTextEditor, { - RichTextEditorChange -} from "@saleor/components/RichTextEditor"; +import RichTextEditor from "@saleor/components/RichTextEditor"; import { ProductErrorFragment } from "@saleor/graphql"; import { commonMessages } from "@saleor/intl"; import { getFormErrors, getProductErrorMessage } from "@saleor/utils/errors"; +import { useRichTextContext } from "@saleor/utils/richText/context"; import React from "react"; import { useIntl } from "react-intl"; @@ -22,7 +21,6 @@ interface ProductDetailsFormProps { disabled?: boolean; errors: ProductErrorFragment[]; - onDescriptionChange: RichTextEditorChange; onChange(event: any); } @@ -30,13 +28,17 @@ export const ProductDetailsForm: React.FC = ({ data, disabled, errors, - onDescriptionChange, onChange }) => { const intl = useIntl(); + const { + editorRef, + defaultValue, + isReadyForMount, + handleChange + } = useRichTextContext(); const formErrors = getFormErrors(["name", "description", "rating"], errors); - return ( = ({ onChange={onChange} /> - + {isReadyForMount && ( + + )}
diff --git a/src/products/components/ProductUpdatePage/ProductUpdatePage.test.tsx b/src/products/components/ProductUpdatePage/ProductUpdatePage.test.tsx index 8c622d013..b06a500d8 100644 --- a/src/products/components/ProductUpdatePage/ProductUpdatePage.test.tsx +++ b/src/products/components/ProductUpdatePage/ProductUpdatePage.test.tsx @@ -17,12 +17,15 @@ const channels = createChannelsData(channelsList); import * as _useNavigator from "@saleor/hooks/useNavigator"; import Adapter from "enzyme-adapter-react-16"; +import { act } from "react-dom/test-utils"; import { MemoryRouter } from "react-router-dom"; configure({ adapter: new Adapter() }); const onSubmit = jest.fn(); const useNavigator = jest.spyOn(_useNavigator, "default"); +jest.mock("@saleor/components/RichTextEditor/RichTextEditor"); +jest.mock("@saleor/utils/richText/useRichText"); (global as any).document.createRange = () => ({ // eslint-disable-next-line @@ -94,7 +97,7 @@ const selectors = { describe("Product details page", () => { useNavigator.mockImplementation(); - it("can select empty option on attribute", () => { + it("can select empty option on attribute", async () => { const component = mount( @@ -124,10 +127,15 @@ describe("Product details page", () => { .first() .prop("value") ).toEqual(""); - component - .find("form") - .first() - .simulate("submit"); + + await act(async () => { + component + .find("form") + .first() + .simulate("submit"); + // wait for async function to complete + await new Promise(process.nextTick); + }); expect(onSubmit.mock.calls[0][0].attributes[0].value.length).toEqual(0); }); }); diff --git a/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx b/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx index d15805152..e7b514eb0 100644 --- a/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx +++ b/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx @@ -282,7 +282,15 @@ export const ProductUpdatePage: React.FC = ({ assignReferencesAttributeId={assignReferencesAttributeId} disabled={disabled} > - {({ change, data, formErrors, handlers, submit, isSaveDisabled }) => ( + {({ + change, + data, + formErrors, + handlers, + submit, + isSaveDisabled, + attributeRichTextGetters + }) => ( <> @@ -293,13 +301,12 @@ export const ProductUpdatePage: React.FC = ({ )} - +
@@ -331,6 +338,7 @@ export const ProductUpdatePage: React.FC = ({ fetchAttributeValues={fetchAttributeValues} fetchMoreAttributeValues={fetchMoreAttributeValues} onAttributeSelectBlur={onAttributeSelectBlur} + richTextGetters={attributeRichTextGetters} /> )} diff --git a/src/products/components/ProductUpdatePage/form.tsx b/src/products/components/ProductUpdatePage/form.tsx index e37302e5c..0235eb52e 100644 --- a/src/products/components/ProductUpdatePage/form.tsx +++ b/src/products/components/ProductUpdatePage/form.tsx @@ -1,5 +1,11 @@ import { OutputData } from "@editorjs/editorjs"; -import { getAttributesDisplayData } from "@saleor/attributes/utils/data"; +import { + getAttributesDisplayData, + getRichTextAttributesFromMap, + getRichTextDataFromAttributes, + mergeAttributes, + RichTextProps +} from "@saleor/attributes/utils/data"; import { createAttributeChangeHandler, createAttributeFileChangeHandler, @@ -18,7 +24,6 @@ import { AttributeInput } from "@saleor/components/Attributes"; import { useExitFormDialog } from "@saleor/components/Form/useExitFormDialog"; import { MetadataFormData } from "@saleor/components/Metadata"; import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField"; -import { RichTextEditorChange } from "@saleor/components/RichTextEditor"; import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField"; import { ProductFragment, @@ -62,6 +67,8 @@ import createMultiAutocompleteSelectHandler from "@saleor/utils/handlers/multiAu import createSingleAutocompleteSelectHandler from "@saleor/utils/handlers/singleAutocompleteSelectChangeHandler"; import getMetadata from "@saleor/utils/metadata/getMetadata"; import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; +import { RichTextContext } from "@saleor/utils/richText/context"; +import { useMultipleRichText } from "@saleor/utils/richText/useMultipleRichText"; import useRichText from "@saleor/utils/richText/useRichText"; import React, { useEffect, useMemo } from "react"; import { useIntl } from "react-intl"; @@ -147,19 +154,25 @@ export interface ProductUpdateHandlers Record<"selectAttributeFile", FormsetChange>, Record<"reorderAttributeValue", FormsetChange>, Record<"addStock" | "deleteStock", (id: string) => void> { - changeDescription: RichTextEditorChange; changePreorderEndDate: FormChange; fetchReferences: (value: string) => void; fetchMoreReferences: FetchMoreProps; } -export interface UseProductUpdateFormResult + +export interface UseProductUpdateFormOutput extends CommonUseFormResultWithHandlers< - ProductUpdateData, - ProductUpdateHandlers - > { + ProductUpdateData, + ProductUpdateHandlers + >, + RichTextProps { formErrors: FormErrors; } +export type UseProductUpdateFormRenderProps = Omit< + UseProductUpdateFormOutput, + "richText" +>; + export interface UseProductUpdateFormOpts extends Record< "categories" | "collections" | "taxTypes", @@ -189,7 +202,7 @@ export interface UseProductUpdateFormOpts } export interface ProductUpdateFormProps extends UseProductUpdateFormOpts { - children: (props: UseProductUpdateFormResult) => React.ReactNode; + children: (props: UseProductUpdateFormRenderProps) => React.ReactNode; product: ProductFragment; onSubmit: (data: ProductUpdateSubmitData) => SubmitPromise; disabled: boolean; @@ -224,7 +237,7 @@ function useProductUpdateForm( onSubmit: (data: ProductUpdateSubmitData) => SubmitPromise, disabled: boolean, opts: UseProductUpdateFormOpts -): UseProductUpdateFormResult { +): UseProductUpdateFormOutput { const intl = useIntl(); const initial = useMemo( () => @@ -257,9 +270,16 @@ function useProductUpdateForm( } = form; const attributes = useFormset(getAttributeInputFromProduct(product)); + const { + getters: attributeRichTextGetters, + getValues: getAttributeRichTextValues + } = useMultipleRichText({ + initial: getRichTextDataFromAttributes(attributes.data), + triggerChange + }); const attributesWithNewFileValue = useFormset([]); const stocks = useFormset(getStockInputFromProduct(product)); - const [description, changeDescription] = useRichText({ + const richText = useRichText({ initial: product?.description, triggerChange }); @@ -382,18 +402,23 @@ function useProductUpdateForm( opts.referencePages, opts.referenceProducts ), - description: description.current, + description: null, stocks: stocks.data }; - // Need to make it function to always have description.current up to date - const getSubmitData = (): ProductUpdateSubmitData => ({ + const getSubmitData = async (): Promise => ({ ...data, ...getStocksData(product, stocks.data), ...getMetadata(data, isMetadataModified, isPrivateMetadataModified), - attributes: attributes.data, + attributes: mergeAttributes( + attributes.data, + getRichTextAttributesFromMap( + attributes.data, + await getAttributeRichTextValues() + ) + ), attributesWithNewFileValue: attributesWithNewFileValue.data, - description: description.current + description: await richText.getValue() }); const handleSubmit = async (data: ProductUpdateSubmitData) => { @@ -411,7 +436,7 @@ function useProductUpdateForm( onSubmit: handleSubmit }); - const submit = async () => handleFormSubmit(getSubmitData()); + const submit = async () => handleFormSubmit(await getSubmitData()); useEffect(() => setExitDialogSubmitRef(submit), [submit]); @@ -458,7 +483,6 @@ function useProductUpdateForm( changeChannelPrice: handleChannelPriceChange, changeChannelPreorder: handleChannelPreorderChange, changeChannels: handleChannelsChange, - changeDescription, changeMetadata, changeStock: handleStockChange, changePreorderEndDate: handlePreorderEndDateChange, @@ -475,7 +499,9 @@ function useProductUpdateForm( selectTaxRate: handleTaxTypeSelect }, submit, - isSaveDisabled + isSaveDisabled, + richText, + attributeRichTextGetters }; } @@ -486,9 +512,20 @@ const ProductUpdateForm: React.FC = ({ disabled, ...rest }) => { - const props = useProductUpdateForm(product, onSubmit, disabled, rest); + const { richText, ...props } = useProductUpdateForm( + product, + onSubmit, + disabled, + rest + ); - return
{children(props)}
; + return ( +
+ + {children(props)} + +
+ ); }; ProductUpdateForm.displayName = "ProductUpdateForm"; diff --git a/src/products/components/ProductVariantCreatePage/ProductVariantCreatePage.tsx b/src/products/components/ProductVariantCreatePage/ProductVariantCreatePage.tsx index 67728d70b..404967065 100644 --- a/src/products/components/ProductVariantCreatePage/ProductVariantCreatePage.tsx +++ b/src/products/components/ProductVariantCreatePage/ProductVariantCreatePage.tsx @@ -159,7 +159,15 @@ const ProductVariantCreatePage: React.FC = ({ assignReferencesAttributeId={assignReferencesAttributeId} disabled={disabled} > - {({ change, data, formErrors, handlers, submit, isSaveDisabled }) => ( + {({ + change, + data, + formErrors, + handlers, + submit, + isSaveDisabled, + attributeRichTextGetters + }) => ( {product?.name} @@ -193,6 +201,7 @@ const ProductVariantCreatePage: React.FC = ({ fetchAttributeValues={fetchAttributeValues} fetchMoreAttributeValues={fetchMoreAttributeValues} onAttributeSelectBlur={onAttributeSelectBlur} + richTextGetters={attributeRichTextGetters} /> = ({ fetchAttributeValues={fetchAttributeValues} fetchMoreAttributeValues={fetchMoreAttributeValues} onAttributeSelectBlur={onAttributeSelectBlur} + richTextGetters={attributeRichTextGetters} /> { + ProductVariantCreateData, + ProductVariantCreateHandlers + >, + Omit { formErrors: FormErrors; disabled: boolean; } export interface ProductVariantCreateFormProps extends UseProductVariantCreateFormOpts { - children: (props: UseProductVariantCreateFormResult) => React.ReactNode; + children: (props: UseProductVariantCreateFormOutput) => React.ReactNode; product: ProductVariantCreateDataQuery["product"]; onSubmit: (data: ProductVariantCreateData) => void; disabled: boolean; @@ -116,7 +124,7 @@ function useProductVariantCreateForm( onSubmit: (data: ProductVariantCreateData) => void, disabled: boolean, opts: UseProductVariantCreateFormOpts -): UseProductVariantCreateFormResult { +): UseProductVariantCreateFormOutput { const intl = useIntl(); const attributeInput = getVariantAttributeInputFromProduct(product); @@ -131,6 +139,13 @@ function useProductVariantCreateForm( } = form; const attributes = useFormset(attributeInput); + const { + getters: attributeRichTextGetters, + getValues: getAttributeRichTextValues + } = useMultipleRichText({ + initial: getRichTextDataFromAttributes(attributes.data), + triggerChange + }); const attributesWithNewFileValue = useFormset([]); const stocks = useFormset([]); @@ -218,12 +233,23 @@ function useProductVariantCreateForm( stocks: stocks.data }; + const getSubmitData = async (): Promise => ({ + ...data, + attributes: mergeAttributes( + attributes.data, + getRichTextAttributesFromMap( + attributes.data, + await getAttributeRichTextValues() + ) + ) + }); + const handleFormSubmit = useHandleFormSubmit({ formId, onSubmit }); - const submit = () => handleFormSubmit(data); + const submit = async () => handleFormSubmit(await getSubmitData()); useEffect(() => setExitDialogSubmitRef(submit), [submit]); @@ -255,7 +281,8 @@ function useProductVariantCreateForm( selectAttributeReference: handleAttributeReferenceChange }, submit, - isSaveDisabled + isSaveDisabled, + attributeRichTextGetters }; } diff --git a/src/products/components/ProductVariantPage/ProductVariantPage.tsx b/src/products/components/ProductVariantPage/ProductVariantPage.tsx index da858a725..d116cc8a6 100644 --- a/src/products/components/ProductVariantPage/ProductVariantPage.tsx +++ b/src/products/components/ProductVariantPage/ProductVariantPage.tsx @@ -224,7 +224,15 @@ const ProductVariantPage: React.FC = ({ assignReferencesAttributeId={assignReferencesAttributeId} loading={loading} > - {({ change, data, formErrors, isSaveDisabled, handlers, submit }) => { + {({ + change, + data, + formErrors, + isSaveDisabled, + handlers, + submit, + attributeRichTextGetters + }) => { const nonSelectionAttributes = data.attributes.filter( byAttributeScope(VariantAttributeScope.NOT_VARIANT_SELECTION) ); @@ -250,7 +258,6 @@ const ProductVariantPage: React.FC = ({ {nonSelectionAttributes.length > 0 && ( <> = ({ fetchAttributeValues={fetchAttributeValues} fetchMoreAttributeValues={fetchMoreAttributeValues} onAttributeSelectBlur={onAttributeSelectBlur} + richTextGetters={attributeRichTextGetters} /> @@ -275,7 +283,6 @@ const ProductVariantPage: React.FC = ({ {selectionAttributes.length > 0 && ( <> = ({ fetchAttributeValues={fetchAttributeValues} fetchMoreAttributeValues={fetchMoreAttributeValues} onAttributeSelectBlur={onAttributeSelectBlur} + richTextGetters={attributeRichTextGetters} /> diff --git a/src/products/components/ProductVariantPage/form.tsx b/src/products/components/ProductVariantPage/form.tsx index 785bf0c33..2cd818360 100644 --- a/src/products/components/ProductVariantPage/form.tsx +++ b/src/products/components/ProductVariantPage/form.tsx @@ -1,4 +1,10 @@ -import { getAttributesDisplayData } from "@saleor/attributes/utils/data"; +import { + getAttributesDisplayData, + getRichTextAttributesFromMap, + getRichTextDataFromAttributes, + mergeAttributes, + RichTextProps +} from "@saleor/attributes/utils/data"; import { createAttributeChangeHandler, createAttributeFileChangeHandler, @@ -50,6 +56,7 @@ import { arrayDiff } from "@saleor/utils/arrays"; import { mapMetadataItemToInput } from "@saleor/utils/maps"; import getMetadata from "@saleor/utils/metadata/getMetadata"; import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; +import { useMultipleRichText } from "@saleor/utils/richText/useMultipleRichText"; import React, { useEffect } from "react"; import { useIntl } from "react-intl"; @@ -119,9 +126,10 @@ export interface ProductVariantUpdateHandlers export interface UseProductVariantUpdateFormResult extends CommonUseFormResultWithHandlers< - ProductVariantUpdateData, - ProductVariantUpdateHandlers - > { + ProductVariantUpdateData, + ProductVariantUpdateHandlers + >, + Omit { formErrors: FormErrors; disabled: boolean; } @@ -189,6 +197,13 @@ function useProductVariantUpdateForm( }); const attributes = useFormset(attributeInput); + const { + getters: attributeRichTextGetters, + getValues: getAttributeRichTextValues + } = useMultipleRichText({ + initial: getRichTextDataFromAttributes(attributes.data), + triggerChange + }); const attributesWithNewFileValue = useFormset([]); const stocks = useFormset(stockInput); const channels = useFormset(channelsInput); @@ -302,16 +317,22 @@ function useProductVariantUpdateForm( data.hasPreorderEndDate && !!form.errors.preorderEndDateTime); - const submitData: ProductVariantUpdateSubmitData = { + const getSubmitData = async (): Promise => ({ ...formData, ...getMetadata(formData, isMetadataModified, isPrivateMetadataModified), addStocks, - attributes: attributes.data, + attributes: mergeAttributes( + attributes.data, + getRichTextAttributesFromMap( + attributes.data, + await getAttributeRichTextValues() + ) + ), attributesWithNewFileValue: attributesWithNewFileValue.data, channelListings: channels.data, removeStocks: stockDiff.removed, updateStocks - }; + }); const handleSubmit = async (data: ProductVariantUpdateSubmitData) => { const errors = await onSubmit(data); @@ -328,7 +349,7 @@ function useProductVariantUpdateForm( onSubmit: handleSubmit }); - const submit = () => handleFormSubmit(submitData); + const submit = async () => handleFormSubmit(await getSubmitData()); useEffect(() => setExitDialogSubmitRef(submit), [submit]); @@ -356,7 +377,8 @@ function useProductVariantUpdateForm( selectAttributeReference: handleAttributeReferenceChange }, submit, - isSaveDisabled + isSaveDisabled, + attributeRichTextGetters }; } diff --git a/src/shipping/components/ShippingRateInfo/ShippingRateInfo.tsx b/src/shipping/components/ShippingRateInfo/ShippingRateInfo.tsx index 3f392eabe..c3570a688 100644 --- a/src/shipping/components/ShippingRateInfo/ShippingRateInfo.tsx +++ b/src/shipping/components/ShippingRateInfo/ShippingRateInfo.tsx @@ -2,14 +2,13 @@ import { OutputData } from "@editorjs/editorjs"; import { Card, CardContent, TextField } from "@material-ui/core"; import CardSpacer from "@saleor/components/CardSpacer"; import CardTitle from "@saleor/components/CardTitle"; -import RichTextEditor, { - RichTextEditorChange -} from "@saleor/components/RichTextEditor"; +import RichTextEditor from "@saleor/components/RichTextEditor"; import { ShippingErrorFragment } from "@saleor/graphql"; import { commonMessages } from "@saleor/intl"; import { makeStyles } from "@saleor/macaw-ui"; import { getFormErrors } from "@saleor/utils/errors"; import getShippingErrorMessage from "@saleor/utils/errors/shipping"; +import { useRichTextContext } from "@saleor/utils/richText/context"; import React from "react"; import { defineMessages, useIntl } from "react-intl"; @@ -64,15 +63,21 @@ export interface ShippingRateInfoProps { disabled: boolean; errors: ShippingErrorFragment[]; onChange: (event: React.ChangeEvent) => void; - onDescriptionChange: RichTextEditorChange; } const ShippingRateInfo: React.FC = props => { - const { data, disabled, errors, onChange, onDescriptionChange } = props; + const { data, disabled, errors, onChange } = props; const intl = useIntl(); const classes = useStyles(props); + const { + defaultValue, + editorRef, + isReadyForMount, + handleChange + } = useRichTextContext(); + const formErrors = getFormErrors( ["name", "description", "minDays", "maxDays"], errors @@ -95,15 +100,18 @@ const ShippingRateInfo: React.FC = props => { onChange={onChange} /> - + {isReadyForMount && ( + + )}
({ + const data: ShippingZoneRateCommonFormData = { ...formData, - description: description.current - }); - const data = getData(); - - const handleFormElementSubmit: FormEventHandler = event => { - event.preventDefault(); - handleFormSubmit(getData()); + description: null }; - const handleSubmit = () => handleFormSubmit(getData()); + + const getData = async (): Promise => ({ + ...formData, + description: await richText.getValue() + }); + + const handleFormElementSubmit: FormEventHandler = async event => { + event.preventDefault(); + handleFormSubmit(await getData()); + }; + + const handleSubmit = async () => handleFormSubmit(await getData()); const handleChannelsChange = createChannelsChangeHandler( shippingChannels, @@ -130,90 +135,91 @@ export const ShippingZoneRatesCreatePage: React.FC - - - - - - -
- - - {isPriceVariant ? ( - +
+ + + + + + +
+ - ) : ( - - )} - - - - -
-
- -
-
- navigate(backUrl)} - onDelete={onDelete} - onSubmit={handleSubmit} - state={saveButtonBarState} - /> -
-
+ + {isPriceVariant ? ( + + ) : ( + + )} + + + + +
+
+ +
+
+ navigate(backUrl)} + onDelete={onDelete} + onSubmit={handleSubmit} + state={saveButtonBarState} + /> +
+ + ); }; diff --git a/src/shipping/components/ShippingZoneRatesPage/ShippingZoneRatesPage.tsx b/src/shipping/components/ShippingZoneRatesPage/ShippingZoneRatesPage.tsx index f6c3ce7d7..0534fa061 100644 --- a/src/shipping/components/ShippingZoneRatesPage/ShippingZoneRatesPage.tsx +++ b/src/shipping/components/ShippingZoneRatesPage/ShippingZoneRatesPage.tsx @@ -31,6 +31,7 @@ import { createChannelsChangeHandler } from "@saleor/shipping/handlers"; import { ListActions, ListProps } from "@saleor/types"; import { mapEdgesToItems, mapMetadataItemToInput } from "@saleor/utils/maps"; import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; +import { RichTextContext } from "@saleor/utils/richText/context"; import useRichText from "@saleor/utils/richText/useRichText"; import React, { FormEventHandler } from "react"; import { FormattedMessage } from "react-intl"; @@ -127,7 +128,7 @@ export const ShippingZoneRatesPage: React.FC = ({ onSubmit }); - const [description, changeDescription] = useRichText({ + const richText = useRichText({ initial: rate?.description, triggerChange }); @@ -136,18 +137,22 @@ export const ShippingZoneRatesPage: React.FC = ({ makeChangeHandler: makeMetadataChangeHandler } = useMetadataChangeTrigger(); - // Prevents closing ref in submit functions - const getData = () => ({ + const data: ShippingZoneRateUpdateFormData = { ...formData, - description: description.current - }); - const data = getData(); - - const handleFormElementSubmit: FormEventHandler = event => { - event.preventDefault(); - handleFormSubmit(getData()); + description: null }; - const handleSubmit = () => handleFormSubmit(getData()); + + // Prevents closing ref in submit functions + const getData = async (): Promise => ({ + ...data, + description: await richText.getValue() + }); + + const handleFormElementSubmit: FormEventHandler = async event => { + event.preventDefault(); + handleFormSubmit(await getData()); + }; + const handleSubmit = async () => handleFormSubmit(await getData()); const handleChannelsChange = createChannelsChangeHandler( shippingChannels, @@ -164,89 +169,90 @@ export const ShippingZoneRatesPage: React.FC = ({ setIsSubmitDisabled(isSaveDisabled); return ( -
- - - - - - -
- - - {isPriceVariant ? ( - + + + + + + + +
+ - ) : ( - - )} - - - - - - - - -
-
- ({ - id: channel.id, - name: channel.name - }))} - openModal={openChannelsModal} - /> -
-
- navigate(backHref)} - onDelete={onDelete} - onSubmit={handleSubmit} - state={saveButtonBarState} - /> -
- + + {isPriceVariant ? ( + + ) : ( + + )} + + + + + + + + +
+
+ ({ + id: channel.id, + name: channel.name + }))} + openModal={openChannelsModal} + /> +
+
+ navigate(backHref)} + onDelete={onDelete} + onSubmit={handleSubmit} + state={saveButtonBarState} + /> +
+ + ); }; diff --git a/src/storybook/__snapshots__/Stories.test.ts.snap b/src/storybook/__snapshots__/Stories.test.ts.snap index 4ba83cda5..76b28c4a6 100644 --- a/src/storybook/__snapshots__/Stories.test.ts.snap +++ b/src/storybook/__snapshots__/Stories.test.ts.snap @@ -376,9 +376,6 @@ exports[`Storyshots Attributes / Attributes default 1`] = ` > Value -

@@ -1104,9 +1101,6 @@ exports[`Storyshots Attributes / Attributes disabled 1`] = ` > Value -

@@ -2036,9 +2030,6 @@ exports[`Storyshots Attributes / Attributes selected 1`] = ` > Value -

@@ -13612,9 +13603,6 @@ exports[`Storyshots Generics / Rich text editor default 1`] = ` > Content -

@@ -13647,9 +13635,6 @@ exports[`Storyshots Generics / Rich text editor disabled 1`] = ` > Content -

@@ -13682,9 +13667,6 @@ exports[`Storyshots Generics / Rich text editor error 1`] = ` > Content -

@@ -13707,9 +13689,7 @@ exports[`Storyshots Generics / Rich text editor static 1`] = `

-
+
@@ -21075,9 +21055,6 @@ exports[`Storyshots Shipping / ShippingZoneRatesCreatePage page create price 1`] > Shipping Rate Description -

@@ -21988,9 +21965,6 @@ exports[`Storyshots Shipping / ShippingZoneRatesCreatePage page create weight 1` > Shipping Rate Description -

@@ -22911,9 +22885,6 @@ exports[`Storyshots Shipping / ShippingZoneRatesCreatePage page loading 1`] = ` > Shipping Rate Description -

@@ -39305,9 +39276,6 @@ exports[`Storyshots Views / Categories / Create category When loading 1`] = ` > Category Description -

@@ -39620,9 +39588,6 @@ exports[`Storyshots Views / Categories / Create category default 1`] = ` > Category Description -

@@ -39940,9 +39905,6 @@ exports[`Storyshots Views / Categories / Create category form errors 1`] = ` > Category Description -

@@ -40256,9 +40218,6 @@ exports[`Storyshots Views / Categories / Update category default 1`] = ` > Category Description -

@@ -41031,9 +40990,6 @@ exports[`Storyshots Views / Categories / Update category form errors 1`] = ` > Category Description -

@@ -41800,23 +41756,6 @@ exports[`Storyshots Views / Categories / Update category loading 1`] = `

-
- -
-

-

Category Description -

@@ -43151,9 +43087,6 @@ exports[`Storyshots Views / Categories / Update category no products 1`] = ` > Category Description -

@@ -43923,9 +43856,6 @@ exports[`Storyshots Views / Categories / Update category no subcategories 1`] = > Category Description -

@@ -44693,9 +44623,6 @@ exports[`Storyshots Views / Categories / Update category products 1`] = ` > Category Description -

@@ -51307,9 +51234,6 @@ exports[`Storyshots Views / Collections / Collection detailsCollection details d > Description -

@@ -52797,9 +52721,6 @@ exports[`Storyshots Views / Collections / Collection detailsCollection details f > Description -

@@ -54281,23 +54202,6 @@ exports[`Storyshots Views / Collections / Collection detailsCollection details l

-
- -
-

-

Description -

@@ -57678,9 +57579,6 @@ exports[`Storyshots Views / Collections / Create collection default 1`] = ` > Description -

@@ -59581,9 +59479,6 @@ exports[`Storyshots Views / Collections / Create collection form errors 1`] = ` > Description -

@@ -61482,9 +61377,6 @@ exports[`Storyshots Views / Collections / Create collection loading 1`] = ` > Description -

@@ -154092,9 +153984,6 @@ exports[`Storyshots Views / Pages / Page details default 1`] = ` > Content -

@@ -154986,9 +154875,6 @@ exports[`Storyshots Views / Pages / Page details form errors 1`] = ` > Content -

@@ -155882,23 +155768,6 @@ exports[`Storyshots Views / Pages / Page details loading 1`] = `

-
- -
-

-

Description -

@@ -181756,9 +181622,6 @@ exports[`Storyshots Views / Products / Create product default 1`] = ` > Description -

@@ -182525,9 +182388,6 @@ exports[`Storyshots Views / Products / Create product form errors 1`] = ` > Description -

@@ -188311,7 +188171,7 @@ exports[`Storyshots Views / Products / Product edit form errors 1`] = `

Description -

@@ -190214,7 +190071,7 @@ exports[`Storyshots Views / Products / Product edit limits reached 1`] = `

Description -

@@ -192181,7 +192035,7 @@ exports[`Storyshots Views / Products / Product edit no limits 1`] = `

Description -

@@ -194069,7 +193920,7 @@ exports[`Storyshots Views / Products / Product edit no product attributes 1`] =

Description -

@@ -195691,7 +195539,7 @@ exports[`Storyshots Views / Products / Product edit no stock and no variants 1`]

Description -

@@ -197467,7 +197312,7 @@ exports[`Storyshots Views / Products / Product edit no stock, no variants and no

Description -

@@ -199243,7 +199085,7 @@ exports[`Storyshots Views / Products / Product edit no variants 1`] = `

Description -

@@ -201019,7 +200858,7 @@ exports[`Storyshots Views / Products / Product edit when data is fully loaded 1`

Description -

@@ -202913,7 +202749,7 @@ exports[`Storyshots Views / Products / Product edit when loading data 1`] = `

-
- -
-

-

@@ -204019,7 +203838,7 @@ exports[`Storyshots Views / Products / Product edit when product has no images 1
Description -

@@ -205736,7 +205552,7 @@ exports[`Storyshots Views / Products / Product edit when product has no variants

Description -

@@ -207512,7 +207325,7 @@ exports[`Storyshots Views / Products / Product edit with channels 1`] = `

Description -

@@ -231258,9 +231068,6 @@ exports[`Storyshots Views / Shipping / Shipping rate create price rate 1`] = ` > Shipping Rate Description -

@@ -232536,9 +232343,6 @@ exports[`Storyshots Views / Shipping / Shipping rate create weight rate 1`] = ` > Shipping Rate Description -

@@ -233736,23 +233540,6 @@ exports[`Storyshots Views / Shipping / Shipping rate loading 1`] = `

-
- -
-

-

@@ -234711,9 +234498,6 @@ exports[`Storyshots Views / Shipping / Shipping rate update price rate 1`] = ` > Shipping Rate Description -

@@ -235909,9 +235693,6 @@ exports[`Storyshots Views / Shipping / Shipping rate update weight rate 1`] = ` > Shipping Rate Description -

diff --git a/src/translations/components/TranslationFields/TranslationFieldsRich.tsx b/src/translations/components/TranslationFields/TranslationFieldsRich.tsx index 3e7d765e7..c63876b08 100644 --- a/src/translations/components/TranslationFields/TranslationFieldsRich.tsx +++ b/src/translations/components/TranslationFields/TranslationFieldsRich.tsx @@ -34,30 +34,39 @@ const TranslationFieldsRich: React.FC = ({ const { setIsDirty, setExitDialogSubmitRef } = useExitFormDialog(); - const [content, change] = useRichText({ + const { + defaultValue, + editorRef, + isReadyForMount, + handleChange, + getValue + } = useRichText({ initial, triggerChange: () => setIsDirty(true) }); - useEffect(() => setExitDialogSubmitRef(onSubmit), [content]); + useEffect(() => setExitDialogSubmitRef(onSubmit), [onSubmit]); - const submit = () => onSubmit(content.current); + const submit = async () => onSubmit(await getValue()); return edit ? (

- + {isReadyForMount && ( + + )} = ({ ) : ( - + {isReadyForMount && ( + + )} ); }; diff --git a/src/utils/objects/useMap.ts b/src/utils/objects/useMap.ts new file mode 100644 index 000000000..c8ced2a91 --- /dev/null +++ b/src/utils/objects/useMap.ts @@ -0,0 +1,56 @@ +import { useCallback, useState } from "react"; + +export type MapOrEntries = Map | Array<[K, V]>; + +// Public interface +export interface Actions { + set: (key: K, value: V) => void; + setAll: (entries: MapOrEntries) => void; + remove: (key: K) => void; + reset: Map["clear"]; +} + +// We hide some setters from the returned map to disable autocompletion +type Return = [ + Omit, "set" | "clear" | "delete">, + Actions +]; + +function useMap( + initialState: MapOrEntries = new Map() +): Return { + const [map, setMap] = useState(() => new Map(initialState)); + + const actions: Actions = { + set: useCallback((key, value) => { + setMap(prev => { + if (prev.get(key) === value) { + return prev; + } + const copy = new Map(prev); + copy.set(key, value); + return copy; + }); + }, []), + + setAll: useCallback(entries => { + setMap(() => new Map(entries)); + }, []), + + remove: useCallback(key => { + setMap(prev => { + const copy = new Map(prev); + copy.delete(key); + return copy; + }); + }, []), + + reset: useCallback(() => { + setMap(() => new Map()); + }, []) + }; + + return [map, actions]; +} + +export default useMap; diff --git a/src/utils/richText/__mocks__/useRichText.ts b/src/utils/richText/__mocks__/useRichText.ts new file mode 100644 index 000000000..6cc3522ec --- /dev/null +++ b/src/utils/richText/__mocks__/useRichText.ts @@ -0,0 +1,14 @@ +import { useRichText } from "../useRichText"; + +const useRichTextMocked = ({ + initial, + triggerChange +}): ReturnType => ({ + editorRef: { current: null }, + defaultValue: initial ? JSON.parse(initial) : undefined, + getValue: async () => ({ blocks: [] }), + handleChange: triggerChange, + isReadyForMount: true +}); + +export default useRichTextMocked; diff --git a/src/utils/richText/context.ts b/src/utils/richText/context.ts new file mode 100644 index 000000000..7939462b0 --- /dev/null +++ b/src/utils/richText/context.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from "react"; + +import { useRichText } from "./useRichText"; + +export type RichTextContextValues = ReturnType; + +export const RichTextContext = createContext( + null +); + +export const useRichTextContext = () => { + const value = useContext(RichTextContext); + if (!value) { + throw new Error("useRichTextContext used outside of RichTextContext"); + } + + return value; +}; diff --git a/src/utils/richText/useMultipleRichText.ts b/src/utils/richText/useMultipleRichText.ts new file mode 100644 index 000000000..f31902577 --- /dev/null +++ b/src/utils/richText/useMultipleRichText.ts @@ -0,0 +1,84 @@ +import { OutputData } from "@editorjs/editorjs"; +import { EditorCore } from "@saleor/components/RichTextEditor"; +import { useCallback, useRef } from "react"; + +import useMap from "../objects/useMap"; + +export type RefsMap = Record; + +export interface RichTextGetters { + getShouldMount: (id: TKey) => boolean; + getDefaultValue: (id: TKey) => OutputData; + getHandleChange: (id: TKey) => () => void; + getMountEditor: (id: TKey) => (editor: EditorCore) => void; +} + +export type GetRichTextValues = Record; + +export interface RichTextMultipleOptions { + initial: Record; + triggerChange: () => void; +} + +export const useMultipleRichText = ({ + initial, + triggerChange +}: RichTextMultipleOptions) => { + const editorRefs = useRef>({} as RefsMap); + const [shouldMountMap, { set: setShouldMountById }] = useMap(); + + const getMountEditor = useCallback( + (id: TKey) => (ref: EditorCore | null) => { + editorRefs.current = { + ...editorRefs.current, + [id]: ref + }; + }, + [] + ); + + const getHandleChange = (_: TKey) => () => triggerChange(); + + const getDefaultValue = useCallback( + (id: TKey) => { + try { + const result = JSON.parse(initial[id]); + setShouldMountById(id, true); + return result; + } catch (e) { + return undefined; + } + }, + [initial] + ); + + const getShouldMount = useCallback( + (id: TKey) => shouldMountMap.get(id) ?? false, + [shouldMountMap] + ); + + const getValues = async () => { + const availableRefs = Object.entries(editorRefs.current).filter( + ([, value]) => value !== null + ) as Array<[string, EditorCore]>; + + const results = await Promise.all( + availableRefs.map(async ([key, ref]) => { + const value = await ref.save(); + return [key, value] as [string, OutputData]; + }) + ); + + return Object.fromEntries(results) as Record; + }; + + return { + getters: { + getShouldMount, + getDefaultValue, + getHandleChange, + getMountEditor + } as RichTextGetters, + getValues + }; +}; diff --git a/src/utils/richText/useRichText.test.ts b/src/utils/richText/useRichText.test.ts index 7ae0ce4ce..9ed1ef3d9 100644 --- a/src/utils/richText/useRichText.test.ts +++ b/src/utils/richText/useRichText.test.ts @@ -3,25 +3,8 @@ import { renderHook } from "@testing-library/react-hooks"; import useRichText from "./useRichText"; -type Fixtures = Record<"short" | "long", OutputData>; +type Fixtures = Record<"short", OutputData>; const fixtures: Fixtures = { - long: { - blocks: [ - { - data: { - level: 1, - text: "Some header" - }, - type: "header" - }, - { - data: { - text: "Some text" - }, - type: "paragraph" - } - ] - }, short: { blocks: [ { @@ -34,40 +17,65 @@ const fixtures: Fixtures = { } }; +const triggerChange = jest.fn(); + describe("useRichText", () => { - it("properly saves data in form", () => { - const triggerChange = jest.fn(); - const hook = renderHook(() => - useRichText({ - initial: null, - triggerChange - }) + it("properly informs RichTextEditor when data is ready to mount", () => { + // eslint-disable-next-line prefer-const + let initial: string | undefined; + const { result, rerender } = renderHook(() => + useRichText({ initial, triggerChange }) ); - const [data, change] = hook.result.current; - expect(data.current).toMatchObject({ blocks: [] }); + expect(result.current.isReadyForMount).toBe(false); - change(fixtures.short); + initial = JSON.stringify(fixtures.short); // for JSON.parse() + rerender(); - expect(data.current).toMatchObject(fixtures.short); - expect(triggerChange).toHaveBeenCalled(); + expect(result.current.defaultValue).toStrictEqual(fixtures.short); + expect(result.current.isReadyForMount).toBe(true); }); - it("properly updates data in form", () => { - const triggerChange = jest.fn(); - const hook = renderHook(() => - useRichText({ - initial: JSON.stringify(fixtures.short), - triggerChange - }) + it("returns undefined when JSON cannot be parsed", () => { + // eslint-disable-next-line prefer-const + let initial: string | undefined; + const { result, rerender } = renderHook(() => + useRichText({ initial, triggerChange }) ); - const [data, change] = hook.result.current; - expect(data.current).toMatchObject(fixtures.short); + expect(result.current.isReadyForMount).toBe(false); - change(fixtures.long); + initial = "this-isnt-valid-json"; + rerender(); + + expect(result.current.defaultValue).toBe(undefined); + expect(result.current.isReadyForMount).toBe(false); + }); + + it("runs editorJS .save() when getValue is called", async () => { + const saveFn = jest.fn(async () => fixtures.short); + const { result } = renderHook(() => + useRichText({ initial: "", triggerChange }) + ); + result.current.editorRef.current = { + save: saveFn, + destroy: jest.fn(), + clear: jest.fn(), + render: jest.fn() + }; + + expect(await result.current.getValue()).toStrictEqual(fixtures.short); + expect(saveFn).toHaveBeenCalled(); + }); + + it("calls triggerChange when change is made in the editor", () => { + triggerChange.mockClear(); + const { result } = renderHook(() => + useRichText({ initial: "", triggerChange }) + ); + + result.current.handleChange(); - expect(data.current).toMatchObject(fixtures.long); expect(triggerChange).toHaveBeenCalled(); }); }); diff --git a/src/utils/richText/useRichText.ts b/src/utils/richText/useRichText.ts index 6fee9aebd..0a723544e 100644 --- a/src/utils/richText/useRichText.ts +++ b/src/utils/richText/useRichText.ts @@ -1,44 +1,45 @@ import { OutputData } from "@editorjs/editorjs"; -import { RichTextEditorChange } from "@saleor/components/RichTextEditor"; -import isEqual from "lodash/isEqual"; -import { MutableRefObject, useEffect, useRef, useState } from "react"; +import { EditorCore } from "@saleor/components/RichTextEditor"; +import { useMemo, useRef, useState } from "react"; -const emptyContent: OutputData = { - blocks: [] -}; - -function useRichText(opts: { +interface UseRichTextOptions { initial: string | null; triggerChange: () => void; -}): [MutableRefObject, RichTextEditorChange] { - const data = useRef(); - const [, setLoaded] = useState(false); +} - useEffect(() => { - if (opts.initial === null) { - data.current = emptyContent; - setLoaded(true); - return; - } +export function useRichText({ initial, triggerChange }: UseRichTextOptions) { + const editorRef = useRef(null); + const [isReadyForMount, setIsReadyForMount] = useState(false); - try { - data.current = JSON.parse(opts.initial); - setLoaded(true); - } catch { - data.current = undefined; - } - }, [opts.initial]); - - const change: RichTextEditorChange = newData => { - if (isEqual(data.current.blocks, newData.blocks)) { - return; - } - - opts.triggerChange(); - data.current = newData; + const handleChange = () => { + triggerChange(); }; - return [data, change]; + const getValue = async () => { + if (editorRef.current) { + return editorRef.current.save(); + } else { + throw new Error("Editor instance is not available"); + } + }; + + const defaultValue = useMemo(() => { + try { + const result = JSON.parse(initial); + setIsReadyForMount(true); + return result; + } catch (e) { + return undefined; + } + }, [initial]); + + return { + editorRef, + handleChange, + getValue, + defaultValue, + isReadyForMount + }; } export default useRichText;