diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 414af4209..d786531cc 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -1785,28 +1785,6 @@ "src_dot_components_dot_RadioGroupField_dot_4205644805": { "string": "No results found" }, - "src_dot_components_dot_RichTextEditor_dot_1603794322": { - "context": "dialog header", - "string": "Add Image Link" - }, - "src_dot_components_dot_RichTextEditor_dot_2049070632": { - "context": "replace image, button", - "string": "Replace" - }, - "src_dot_components_dot_RichTextEditor_dot_2160163587": { - "context": "button", - "string": "Add or Edit Link" - }, - "src_dot_components_dot_RichTextEditor_dot_286109898": { - "context": "rich text error", - "string": "Invalid content" - }, - "src_dot_components_dot_RichTextEditor_dot_2925475978": { - "string": "URL Linked" - }, - "src_dot_components_dot_RichTextEditor_dot_4035057905": { - "string": "Image URL" - }, "src_dot_components_dot_RowNumberSelect_dot_1154361791": { "string": "No of Rows:" }, diff --git a/package-lock.json b/package-lock.json index 73d557208..99cf8bf48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1949,6 +1949,35 @@ } } }, + "@editorjs/editorjs": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/@editorjs/editorjs/-/editorjs-2.19.0.tgz", + "integrity": "sha512-8PUVaBZx69IrG8dNrE+FZbHSiRTR8ql8L/cmEi1mOdEdTqnOLq5Wv9dgemK00mBWEgNoavMAjtGQpItGknAa8A==", + "requires": { + "codex-notifier": "^1.1.2", + "codex-tooltip": "^1.0.1" + } + }, + "@editorjs/header": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@editorjs/header/-/header-2.6.0.tgz", + "integrity": "sha512-1psNX/irDjJ8Bp1l7DjkYWz7IBtjVIRANk7kPkNoY2CfAeeCFYbJmMlXdqTF2WeAjYv2WMy5ey/aR5fTccgFaw==" + }, + "@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==" + }, + "@editorjs/list": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@editorjs/list/-/list-1.6.0.tgz", + "integrity": "sha512-2oJ3Nj3lDcIKS6GcrHYHzUUabIjg7zlXTYXQWdEWXevbnM0/fq+4psyI/AYtqbaa3jN+bycPBIW4OG3zD+3d5A==" + }, + "@editorjs/quote": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@editorjs/quote/-/quote-2.4.0.tgz", + "integrity": "sha512-IWOBWjL2ngPP63GcIAltyD9kc7OVZFma4kS+T5JRHvKKDspYsnmrxsbRmCPc+coZQzqPxXHkiOZuNMdmGX/Y3w==" + }, "@emotion/cache": { "version": "10.0.19", "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.19.tgz", @@ -8778,6 +8807,16 @@ "urlgrey": "0.4.4" } }, + "codex-notifier": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/codex-notifier/-/codex-notifier-1.1.2.tgz", + "integrity": "sha512-DCp6xe/LGueJ1N5sXEwcBc3r3PyVkEEDNWCVigfvywAkeXcZMk9K41a31tkEFBW0Ptlwji6/JlAb49E3Yrxbtg==" + }, + "codex-tooltip": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/codex-tooltip/-/codex-tooltip-1.0.1.tgz", + "integrity": "sha512-1xLb1NZbxguNtf02xBRhDphq/EXvMMeEbY0ievjQTHqf8UjXsD41evGk9rqcbjpl+JOjNgtwnp1OaU/X/h6fhQ==" + }, "coffeescript": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/coffeescript/-/coffeescript-2.5.1.tgz", @@ -10306,6 +10345,16 @@ "safer-buffer": "^2.1.0" } }, + "editorjs-inline-tool": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/editorjs-inline-tool/-/editorjs-inline-tool-0.4.0.tgz", + "integrity": "sha512-Ppb4e8IFPjWuNcoNM4tg9bDSo7FgMYAlqP4UhuV5W2JoJBubV5pUcpLrFrSyGTt1HJVEpbrib134zf4wxO+7VA==" + }, + "editorjs-undo": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/editorjs-undo/-/editorjs-undo-0.1.4.tgz", + "integrity": "sha512-YW+5x8tqMXQ+iQD+1OrroqUT1O/erDrysZLJ+8yB+zjls55rwUw9P7hdQCJ0DFm+WDSaRuEXb2p3dQ6VdrkYCg==" + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", diff --git a/package.json b/package.json index 87724860c..f9f472a16 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,11 @@ "npm": ">=6.11.0" }, "dependencies": { + "@editorjs/editorjs": "^2.19.0", + "@editorjs/header": "^2.6.0", + "@editorjs/image": "^2.6.0", + "@editorjs/list": "^1.6.0", + "@editorjs/quote": "^2.4.0", "@material-ui/core": "^4.5.1", "@material-ui/icons": "^4.5.1", "@material-ui/styles": "^4.5.2", @@ -36,6 +41,8 @@ "draft-js": "^0.10.5", "draftail": "^1.2.1", "draftjs-to-html": "^0.9.1", + "editorjs-inline-tool": "^0.4.0", + "editorjs-undo": "^0.1.4", "fast-array-diff": "^0.2.0", "fuzzaldrin": "^2.1.0", "graphql": "^14.4.2", diff --git a/src/Baseline.tsx b/src/Baseline.tsx index 169464771..db200dda8 100644 --- a/src/Baseline.tsx +++ b/src/Baseline.tsx @@ -1,12 +1,19 @@ import CssBaseline from "@material-ui/core/CssBaseline"; -import { createStyles, withStyles } from "@material-ui/core/styles"; +import { createStyles, Theme, withStyles } from "@material-ui/core/styles"; +import { fade } from "@material-ui/core/styles/colorManipulator"; import React from "react"; -const styles = createStyles({ +const styles = createStyles((theme: Theme) => ({ "@global": { - "@import": "url('https://rsms.me/inter/inter.css')" + "@import": "url('https://rsms.me/inter/inter.css')", + + // For some reason @import clause must be put on top + // eslint-disable-next-line sort-keys + "::selection": { + background: fade(theme.palette.primary.main, 0.2) + } } -}); +})); const Baseline = withStyles(styles, { name: "Baseline" diff --git a/src/categories/components/CategoryBackground/CategoryBackground.tsx b/src/categories/components/CategoryBackground/CategoryBackground.tsx index b801afa95..29e4a93f4 100644 --- a/src/categories/components/CategoryBackground/CategoryBackground.tsx +++ b/src/categories/components/CategoryBackground/CategoryBackground.tsx @@ -13,7 +13,7 @@ import React from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { CategoryDetails_category_backgroundImage } from "../../types/CategoryDetails"; -import { FormData } from "../CategoryUpdatePage"; +import { CategoryUpdateData } from "../CategoryUpdatePage/form"; const useStyles = makeStyles( theme => ({ @@ -42,7 +42,7 @@ const useStyles = makeStyles( ); export interface CategoryBackgroundProps { - data: FormData; + data: CategoryUpdateData; image: CategoryDetails_category_backgroundImage; onChange: (event: React.ChangeEvent) => void; onImageDelete: () => void; diff --git a/src/categories/components/CategoryCreatePage/CategoryCreatePage.tsx b/src/categories/components/CategoryCreatePage/CategoryCreatePage.tsx index 0ac329841..91b91d136 100644 --- a/src/categories/components/CategoryCreatePage/CategoryCreatePage.tsx +++ b/src/categories/components/CategoryCreatePage/CategoryCreatePage.tsx @@ -2,43 +2,23 @@ import AppHeader from "@saleor/components/AppHeader"; import { CardSpacer } from "@saleor/components/CardSpacer"; import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; import Container from "@saleor/components/Container"; -import Form from "@saleor/components/Form"; -import Metadata, { MetadataFormData } from "@saleor/components/Metadata"; +import Metadata from "@saleor/components/Metadata"; import PageHeader from "@saleor/components/PageHeader"; import SaveButtonBar from "@saleor/components/SaveButtonBar"; import SeoForm from "@saleor/components/SeoForm"; import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment"; import { sectionNames } from "@saleor/intl"; -import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; -import { ContentState, convertToRaw, RawDraftContentState } from "draft-js"; import React from "react"; import { useIntl } from "react-intl"; import CategoryDetailsForm from "../../components/CategoryDetailsForm"; - -export interface FormData extends MetadataFormData { - description: RawDraftContentState; - name: string; - slug: string; - seoTitle: string; - seoDescription: string; -} - -const initialData: FormData = { - description: convertToRaw(ContentState.createFromText("")), - metadata: [], - name: "", - privateMetadata: [], - seoDescription: "", - seoTitle: "", - slug: "" -}; +import CategoryCreateForm, { CategoryCreateData } from "./form"; export interface CategoryCreatePageProps { errors: ProductErrorFragment[]; disabled: boolean; saveButtonBarState: ConfirmButtonTransitionState; - onSubmit(data: FormData); + onSubmit(data: CategoryCreateData); onBack(); } @@ -50,63 +30,57 @@ export const CategoryCreatePage: React.FC = ({ saveButtonBarState }) => { const intl = useIntl(); - const { - makeChangeHandler: makeMetadataChangeHandler - } = useMetadataChangeTrigger(); return ( -
- {({ data, change, submit, hasChanged }) => { - const changeMetadata = makeMetadataChangeHandler(change); - - return ( - - - {intl.formatMessage(sectionNames.categories)} - - + {({ data, change, handlers, submit, hasChanged }) => ( + + + {intl.formatMessage(sectionNames.categories)} + + +
+ -
- - - - - - -
- - ); - }} - + + + + + +
+
+ )} + ); }; CategoryCreatePage.displayName = "CategoryCreatePage"; diff --git a/src/categories/components/CategoryCreatePage/form.tsx b/src/categories/components/CategoryCreatePage/form.tsx new file mode 100644 index 000000000..8572efc91 --- /dev/null +++ b/src/categories/components/CategoryCreatePage/form.tsx @@ -0,0 +1,96 @@ +import { OutputData } from "@editorjs/editorjs"; +import { MetadataFormData } from "@saleor/components/Metadata"; +import { RichTextEditorChange } from "@saleor/components/RichTextEditor"; +import useForm, { FormChange } from "@saleor/hooks/useForm"; +import handleFormSubmit from "@saleor/utils/handlers/handleFormSubmit"; +import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; +import useRichText from "@saleor/utils/richText/useRichText"; +import React from "react"; + +export interface CategoryCreateFormData extends MetadataFormData { + name: string; + seoDescription: string; + seoTitle: string; + slug: string; +} +export interface CategoryCreateData extends CategoryCreateFormData { + description: OutputData; +} + +interface CategoryCreateHandlers { + changeMetadata: FormChange; + changeDescription: RichTextEditorChange; +} +export interface UseCategoryCreateFormResult { + change: FormChange; + data: CategoryCreateData; + handlers: CategoryCreateHandlers; + hasChanged: boolean; + submit: () => Promise; +} + +export interface CategoryCreateFormProps { + children: (props: UseCategoryCreateFormResult) => React.ReactNode; + onSubmit: (data: CategoryCreateData) => Promise; +} + +function useCategoryCreateForm( + onSubmit: (data: CategoryCreateData) => Promise +): UseCategoryCreateFormResult { + const [changed, setChanged] = React.useState(false); + const triggerChange = () => setChanged(true); + + const form = useForm({ + metadata: [], + name: "", + privateMetadata: [], + seoDescription: "", + seoTitle: "", + slug: "" + }); + const [description, changeDescription] = useRichText({ + initial: null, + triggerChange + }); + + const { + makeChangeHandler: makeMetadataChangeHandler + } = useMetadataChangeTrigger(); + + const handleChange: FormChange = (event, cb) => { + form.change(event, cb); + triggerChange(); + }; + const changeMetadata = makeMetadataChangeHandler(handleChange); + + // Need to make it function to always have description.current up to date + const getData = (): CategoryCreateData => ({ + ...form.data, + description: description.current + }); + + const submit = () => handleFormSubmit(getData(), onSubmit, setChanged); + + return { + change: handleChange, + data: getData(), + handlers: { + changeDescription, + changeMetadata + }, + hasChanged: changed, + submit + }; +} + +const CategoryCreateForm: React.FC = ({ + children, + onSubmit +}) => { + const props = useCategoryCreateForm(onSubmit); + + return
{children(props)}
; +}; + +CategoryCreateForm.displayName = "CategoryCreateForm"; +export default CategoryCreateForm; diff --git a/src/categories/components/CategoryDetailsForm/CategoryDetailsForm.tsx b/src/categories/components/CategoryDetailsForm/CategoryDetailsForm.tsx index 4779571ba..d1e984b25 100644 --- a/src/categories/components/CategoryDetailsForm/CategoryDetailsForm.tsx +++ b/src/categories/components/CategoryDetailsForm/CategoryDetailsForm.tsx @@ -1,35 +1,34 @@ +import { OutputData } from "@editorjs/editorjs"; import Card from "@material-ui/core/Card"; import CardContent from "@material-ui/core/CardContent"; import TextField from "@material-ui/core/TextField"; import CardTitle from "@saleor/components/CardTitle"; import FormSpacer from "@saleor/components/FormSpacer"; -import RichTextEditor from "@saleor/components/RichTextEditor"; +import RichTextEditor, { + RichTextEditorChange +} from "@saleor/components/RichTextEditor"; import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment"; import { commonMessages } from "@saleor/intl"; import { getFormErrors, getProductErrorMessage } from "@saleor/utils/errors"; -import { RawDraftContentState } from "draft-js"; import React from "react"; import { useIntl } from "react-intl"; -import { maybe } from "../../../misc"; -import { CategoryDetails_category } from "../../types/CategoryDetails"; - interface CategoryDetailsFormProps { - category?: CategoryDetails_category; data: { name: string; - description: RawDraftContentState; + description: OutputData; }; disabled: boolean; errors: ProductErrorFragment[]; onChange: (event: React.ChangeEvent) => void; + onDescriptionChange: RichTextEditorChange; } export const CategoryDetailsForm: React.FC = ({ - category, disabled, data, onChange, + onDescriptionChange, errors }) => { const intl = useIntl(); @@ -58,15 +57,15 @@ export const CategoryDetailsForm: React.FC = ({ JSON.parse(category.descriptionJson))} name="description" - onChange={onChange} + onChange={onDescriptionChange} /> diff --git a/src/categories/components/CategoryUpdatePage/CategoryUpdatePage.tsx b/src/categories/components/CategoryUpdatePage/CategoryUpdatePage.tsx index 96c46e1c2..9446f74c3 100644 --- a/src/categories/components/CategoryUpdatePage/CategoryUpdatePage.tsx +++ b/src/categories/components/CategoryUpdatePage/CategoryUpdatePage.tsx @@ -5,9 +5,7 @@ import { CardSpacer } from "@saleor/components/CardSpacer"; import CardTitle from "@saleor/components/CardTitle"; import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; import Container from "@saleor/components/Container"; -import Form from "@saleor/components/Form"; import Metadata from "@saleor/components/Metadata/Metadata"; -import { MetadataFormData } from "@saleor/components/Metadata/types"; import PageHeader from "@saleor/components/PageHeader"; import SaveButtonBar from "@saleor/components/SaveButtonBar"; import SeoForm from "@saleor/components/SeoForm"; @@ -15,9 +13,6 @@ import { Tab, TabContainer } from "@saleor/components/Tab"; import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment"; import { SubmitPromise } from "@saleor/hooks/useForm"; import { sectionNames } from "@saleor/intl"; -import { mapMetadataItemToInput } from "@saleor/utils/maps"; -import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; -import { RawDraftContentState } from "draft-js"; import React from "react"; import { FormattedMessage, useIntl } from "react-intl"; @@ -32,15 +27,7 @@ import { } from "../../types/CategoryDetails"; import CategoryBackground from "../CategoryBackground"; import CategoryProducts from "../CategoryProducts"; - -export interface FormData extends MetadataFormData { - backgroundImageAlt: string; - description: RawDraftContentState; - name: string; - slug: string; - seoTitle: string; - seoDescription: string; -} +import CategoryUpdateForm, { CategoryUpdateData } from "./form"; export enum CategoryPageTab { categories = "categories", @@ -62,7 +49,7 @@ export interface CategoryUpdatePageProps }; saveButtonBarState: ConfirmButtonTransitionState; onImageDelete: () => void; - onSubmit: (data: FormData) => SubmitPromise; + onSubmit: (data: CategoryUpdateData) => SubmitPromise; onImageUpload(file: File); onNextPage(); onPreviousPage(); @@ -106,180 +93,136 @@ export const CategoryUpdatePage: React.FC = ({ toggleAll }: CategoryUpdatePageProps) => { const intl = useIntl(); - const { - isMetadataModified, - isPrivateMetadataModified, - makeChangeHandler: makeMetadataChangeHandler - } = useMetadataChangeTrigger(); - - const initialData: FormData = category - ? { - backgroundImageAlt: maybe(() => category.backgroundImage.alt, ""), - description: maybe(() => JSON.parse(category.descriptionJson)), - metadata: category?.metadata?.map(mapMetadataItemToInput), - name: category.name || "", - privateMetadata: category?.privateMetadata?.map(mapMetadataItemToInput), - seoDescription: category.seoDescription || "", - seoTitle: category.seoTitle || "", - slug: category?.slug || "" - } - : { - backgroundImageAlt: "", - description: "", - metadata: undefined, - name: "", - privateMetadata: undefined, - seoDescription: "", - seoTitle: "", - slug: "" - }; - - const handleSubmit = (data: FormData) => { - const metadata = isMetadataModified ? data.metadata : undefined; - const privateMetadata = isPrivateMetadataModified - ? data.privateMetadata - : undefined; - - return onSubmit({ - ...data, - metadata, - privateMetadata - }); - }; return ( -
- {({ data, change, submit, hasChanged }) => { - const changeMetadata = makeMetadataChangeHandler(change); - - return ( - - - {intl.formatMessage(sectionNames.categories)} - - - - - category.backgroundImage)} - onChange={change} - /> - - - - - - - - - - - - - - - {currentTab === CategoryPageTab.categories && ( - - - - - } - /> - undefined} - /> - - )} - {currentTab === CategoryPageTab.products && ( - category.name)} - products={products} + + {({ data, change, handlers, submit, hasChanged }) => ( + + + {intl.formatMessage(sectionNames.categories)} + + + + + category.backgroundImage)} + onChange={change} + /> + + + + + + + + + + + + + + + {currentTab === CategoryPageTab.categories && ( + + + + + } + /> + undefined} /> - )} - + )} + {currentTab === CategoryPageTab.products && ( + - - ); - }} - + )} + +
+ )} + ); }; CategoryUpdatePage.displayName = "CategoryUpdatePage"; diff --git a/src/categories/components/CategoryUpdatePage/form.tsx b/src/categories/components/CategoryUpdatePage/form.tsx new file mode 100644 index 000000000..809b5a851 --- /dev/null +++ b/src/categories/components/CategoryUpdatePage/form.tsx @@ -0,0 +1,111 @@ +import { OutputData } from "@editorjs/editorjs"; +import { CategoryDetails_category } from "@saleor/categories/types/CategoryDetails"; +import { MetadataFormData } from "@saleor/components/Metadata"; +import { RichTextEditorChange } from "@saleor/components/RichTextEditor"; +import useForm, { FormChange } from "@saleor/hooks/useForm"; +import handleFormSubmit from "@saleor/utils/handlers/handleFormSubmit"; +import { mapMetadataItemToInput } from "@saleor/utils/maps"; +import getMetadata from "@saleor/utils/metadata/getMetadata"; +import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; +import useRichText from "@saleor/utils/richText/useRichText"; +import React from "react"; + +export interface CategoryUpdateFormData extends MetadataFormData { + backgroundImageAlt: string; + name: string; + slug: string; + seoTitle: string; + seoDescription: string; +} +export interface CategoryUpdateData extends CategoryUpdateFormData { + description: OutputData; +} + +interface CategoryUpdateHandlers { + changeMetadata: FormChange; + changeDescription: RichTextEditorChange; +} +export interface UseCategoryUpdateFormResult { + change: FormChange; + data: CategoryUpdateData; + handlers: CategoryUpdateHandlers; + hasChanged: boolean; + submit: () => Promise; +} + +export interface CategoryUpdateFormProps { + children: (props: UseCategoryUpdateFormResult) => React.ReactNode; + category: CategoryDetails_category; + onSubmit: (data: CategoryUpdateData) => Promise; +} + +function useCategoryUpdateForm( + category: CategoryDetails_category, + onSubmit: (data: CategoryUpdateData) => Promise +): UseCategoryUpdateFormResult { + const [changed, setChanged] = React.useState(false); + const triggerChange = () => setChanged(true); + + const form = useForm({ + backgroundImageAlt: category?.backgroundImage?.alt || "", + metadata: category?.metadata?.map(mapMetadataItemToInput), + name: category?.name || "", + privateMetadata: category?.privateMetadata?.map(mapMetadataItemToInput), + seoDescription: category?.seoDescription || "", + seoTitle: category?.seoTitle || "", + slug: category?.slug || "" + }); + const [description, changeDescription] = useRichText({ + initial: category?.descriptionJson, + triggerChange + }); + + const { + isMetadataModified, + isPrivateMetadataModified, + makeChangeHandler: makeMetadataChangeHandler + } = useMetadataChangeTrigger(); + + const handleChange: FormChange = (event, cb) => { + form.change(event, cb); + triggerChange(); + }; + const changeMetadata = makeMetadataChangeHandler(handleChange); + + // Need to make it function to always have description.current up to date + const getData = (): CategoryUpdateData => ({ + ...form.data, + ...getMetadata(form.data, isMetadataModified, isPrivateMetadataModified), + description: description.current + }); + const getSubmitData = (): CategoryUpdateData => ({ + ...getData(), + ...getMetadata(form.data, isMetadataModified, isPrivateMetadataModified) + }); + + const submit = () => handleFormSubmit(getSubmitData(), onSubmit, setChanged); + + return { + change: handleChange, + data: getData(), + handlers: { + changeDescription, + changeMetadata + }, + hasChanged: changed, + submit + }; +} + +const CategoryUpdateForm: React.FC = ({ + children, + category, + onSubmit +}) => { + const props = useCategoryUpdateForm(category, onSubmit); + + return
{children(props)}
; +}; + +CategoryUpdateForm.displayName = "CategoryUpdateForm"; +export default CategoryUpdateForm; diff --git a/src/categories/fixtures.ts b/src/categories/fixtures.ts index ddc277a83..aabbc37e9 100644 --- a/src/categories/fixtures.ts +++ b/src/categories/fixtures.ts @@ -1,8 +1,10 @@ import { CategoryFragment } from "@saleor/fragments/types/CategoryFragment"; -import { content } from "../storybook/stories/components/RichTextEditor"; +import * as richTextEditorFixtures from "../components/RichTextEditor/fixtures.json"; import { CategoryDetails_category } from "./types/CategoryDetails"; +const content = richTextEditorFixtures.richTextEditor; + export const categories: CategoryFragment[] = [ { __typename: "Category", diff --git a/src/categories/views/CategoryCreate.tsx b/src/categories/views/CategoryCreate.tsx index 761b12e20..c31329714 100644 --- a/src/categories/views/CategoryCreate.tsx +++ b/src/categories/views/CategoryCreate.tsx @@ -9,7 +9,8 @@ import { import React from "react"; import { useIntl } from "react-intl"; -import CategoryCreatePage, { FormData } from "../components/CategoryCreatePage"; +import CategoryCreatePage from "../components/CategoryCreatePage"; +import { CategoryCreateData } from "../components/CategoryCreatePage/form"; import { useCategoryCreateMutation } from "../mutations"; import { CategoryCreate } from "../types/CategoryCreate"; import { categoryListUrl, categoryUrl } from "../urls"; @@ -43,7 +44,7 @@ export const CategoryCreateView: React.FC = ({ onCompleted: handleSuccess }); - const handleCreate = async (formData: FormData) => { + const handleCreate = async (formData: CategoryCreateData) => { const result = await createCategory({ variables: { input: { diff --git a/src/categories/views/CategoryDetails.tsx b/src/categories/views/CategoryDetails.tsx index cd52af91c..5efdaafd6 100644 --- a/src/categories/views/CategoryDetails.tsx +++ b/src/categories/views/CategoryDetails.tsx @@ -27,9 +27,9 @@ import { productAddUrl, productUrl } from "../../products/urls"; import { CategoryInput } from "../../types/globalTypes"; import { CategoryPageTab, - CategoryUpdatePage, - FormData + CategoryUpdatePage } from "../components/CategoryUpdatePage/CategoryUpdatePage"; +import { CategoryUpdateData } from "../components/CategoryUpdatePage/form"; import { useCategoryBulkDeleteMutation, useCategoryDeleteMutation, @@ -175,7 +175,7 @@ export const CategoryDetails: React.FC = ({ params ); - const handleUpdate = async (formData: FormData) => { + const handleUpdate = async (formData: CategoryUpdateData) => { const result = await updateCategory({ variables: { id, diff --git a/src/collections/components/CollectionCreatePage/CollectionCreatePage.tsx b/src/collections/components/CollectionCreatePage/CollectionCreatePage.tsx index 777ef78c7..b4fb94166 100644 --- a/src/collections/components/CollectionCreatePage/CollectionCreatePage.tsx +++ b/src/collections/components/CollectionCreatePage/CollectionCreatePage.tsx @@ -2,64 +2,31 @@ import AppHeader from "@saleor/components/AppHeader"; import { CardSpacer } from "@saleor/components/CardSpacer"; import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; import { Container } from "@saleor/components/Container"; -import Form from "@saleor/components/Form"; import Grid from "@saleor/components/Grid"; -import Metadata, { MetadataFormData } from "@saleor/components/Metadata"; +import Metadata from "@saleor/components/Metadata"; import PageHeader from "@saleor/components/PageHeader"; import SaveButtonBar from "@saleor/components/SaveButtonBar"; import SeoForm from "@saleor/components/SeoForm"; import VisibilityCard from "@saleor/components/VisibilityCard"; import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment"; import useDateLocalize from "@saleor/hooks/useDateLocalize"; +import { SubmitPromise } from "@saleor/hooks/useForm"; import { sectionNames } from "@saleor/intl"; -import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; -import { ContentState, convertToRaw, RawDraftContentState } from "draft-js"; import React from "react"; import { useIntl } from "react-intl"; import CollectionDetails from "../CollectionDetails/CollectionDetails"; import { CollectionImage } from "../CollectionImage/CollectionImage"; - -export interface CollectionCreatePageFormData extends MetadataFormData { - backgroundImage: { - url: string; - value: string; - }; - backgroundImageAlt: string; - description: RawDraftContentState; - name: string; - slug: string; - publicationDate: string; - isPublished: boolean; - seoDescription: string; - seoTitle: string; -} +import CollectionCreateForm, { CollectionCreateData } from "./form"; export interface CollectionCreatePageProps { disabled: boolean; errors: ProductErrorFragment[]; saveButtonBarState: ConfirmButtonTransitionState; onBack: () => void; - onSubmit: (data: CollectionCreatePageFormData) => void; + onSubmit: (data: CollectionCreateData) => SubmitPromise; } -const initialForm: CollectionCreatePageFormData = { - backgroundImage: { - url: null, - value: null - }, - backgroundImageAlt: "", - description: convertToRaw(ContentState.createFromText("")), - isPublished: false, - metadata: [], - name: "", - privateMetadata: [], - publicationDate: "", - seoDescription: "", - seoTitle: "", - slug: "" -}; - const CollectionCreatePage: React.FC = ({ disabled, errors, @@ -69,127 +36,121 @@ const CollectionCreatePage: React.FC = ({ }: CollectionCreatePageProps) => { const intl = useIntl(); const localizeDate = useDateLocalize(); - const { - makeChangeHandler: makeMetadataChangeHandler - } = useMetadataChangeTrigger(); return ( -
- {({ change, data, hasChanged, submit }) => { - const changeMetadata = makeMetadataChangeHandler(change); - - return ( - - - {intl.formatMessage(sectionNames.collections)} - - - -
- - - - change({ - target: { - name: "backgroundImage", - value: { - url: null, - value: null - } + + {({ change, data, handlers, hasChanged, submit }) => ( + + + {intl.formatMessage(sectionNames.collections)} + + + +
+ + + - change({ - target: { - name: "backgroundImage", - value: { - url: URL.createObjectURL(file), - value: file - } + : null + } + onImageDelete={() => + change({ + target: { + name: "backgroundImage", + value: { + url: null, + value: null } - } as any) - } - onChange={change} - data={data} - /> - - - - -
-
- + change({ + target: { + name: "backgroundImage", + value: { + url: URL.createObjectURL(file), + value: file } - ), - visibleLabel: intl.formatMessage({ - defaultMessage: "Visible", - description: "collection label" - }) - }} - onChange={change} - /> -
-
- -
- ); - }} - + } + } as any) + } + onChange={change} + data={data} + /> + + + + +
+
+ +
+
+ +
+ )} + ); }; CollectionCreatePage.displayName = "CollectionCreatePage"; diff --git a/src/collections/components/CollectionCreatePage/form.tsx b/src/collections/components/CollectionCreatePage/form.tsx new file mode 100644 index 000000000..9857022ee --- /dev/null +++ b/src/collections/components/CollectionCreatePage/form.tsx @@ -0,0 +1,110 @@ +import { OutputData } from "@editorjs/editorjs"; +import { MetadataFormData } from "@saleor/components/Metadata"; +import { RichTextEditorChange } from "@saleor/components/RichTextEditor"; +import useForm, { FormChange, SubmitPromise } from "@saleor/hooks/useForm"; +import handleFormSubmit from "@saleor/utils/handlers/handleFormSubmit"; +import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; +import useRichText from "@saleor/utils/richText/useRichText"; +import React from "react"; + +export interface CollectionCreateFormData extends MetadataFormData { + backgroundImage: { + url: string; + value: string; + }; + backgroundImageAlt: string; + name: string; + slug: string; + publicationDate: string; + isPublished: boolean; + seoDescription: string; + seoTitle: string; +} +export interface CollectionCreateData extends CollectionCreateFormData { + description: OutputData; +} + +interface CollectionCreateHandlers { + changeMetadata: FormChange; + changeDescription: RichTextEditorChange; +} +export interface UseCollectionCreateFormResult { + change: FormChange; + data: CollectionCreateData; + handlers: CollectionCreateHandlers; + hasChanged: boolean; + submit: () => Promise; +} + +export interface CollectionCreateFormProps { + children: (props: UseCollectionCreateFormResult) => React.ReactNode; + onSubmit: (data: CollectionCreateData) => SubmitPromise; +} + +function useCollectionCreateForm( + onSubmit: (data: CollectionCreateData) => SubmitPromise +): UseCollectionCreateFormResult { + const [changed, setChanged] = React.useState(false); + const triggerChange = () => setChanged(true); + + const form = useForm({ + backgroundImage: { + url: null, + value: null + }, + backgroundImageAlt: "", + isPublished: false, + metadata: [], + name: "", + privateMetadata: [], + publicationDate: "", + seoDescription: "", + seoTitle: "", + slug: "" + }); + const [description, changeDescription] = useRichText({ + initial: null, + triggerChange + }); + + const { + makeChangeHandler: makeMetadataChangeHandler + } = useMetadataChangeTrigger(); + + const handleChange: FormChange = (event, cb) => { + form.change(event, cb); + triggerChange(); + }; + const changeMetadata = makeMetadataChangeHandler(handleChange); + + // Need to make it function to always have description.current up to date + const getData = (): CollectionCreateData => ({ + ...form.data, + description: description.current + }); + + const submit = () => handleFormSubmit(getData(), onSubmit, setChanged); + + return { + change: handleChange, + data: getData(), + handlers: { + changeDescription, + changeMetadata + }, + hasChanged: changed, + submit + }; +} + +const CollectionCreateForm: React.FC = ({ + children, + onSubmit +}) => { + const props = useCollectionCreateForm(onSubmit); + + return
{children(props)}
; +}; + +CollectionCreateForm.displayName = "CollectionCreateForm"; +export default CollectionCreateForm; diff --git a/src/collections/components/CollectionDetails/CollectionDetails.tsx b/src/collections/components/CollectionDetails/CollectionDetails.tsx index 2de3ae015..90187bcd0 100644 --- a/src/collections/components/CollectionDetails/CollectionDetails.tsx +++ b/src/collections/components/CollectionDetails/CollectionDetails.tsx @@ -1,35 +1,34 @@ +import { OutputData } from "@editorjs/editorjs"; import Card from "@material-ui/core/Card"; import CardContent from "@material-ui/core/CardContent"; import TextField from "@material-ui/core/TextField"; import CardTitle from "@saleor/components/CardTitle"; import FormSpacer from "@saleor/components/FormSpacer"; -import RichTextEditor from "@saleor/components/RichTextEditor"; +import RichTextEditor, { + RichTextEditorChange +} from "@saleor/components/RichTextEditor"; import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment"; import { commonMessages } from "@saleor/intl"; -import { maybe } from "@saleor/misc"; import { getFormErrors, getProductErrorMessage } from "@saleor/utils/errors"; -import { RawDraftContentState } from "draft-js"; import React from "react"; import { useIntl } from "react-intl"; -import { CollectionDetails_collection } from "../../types/CollectionDetails"; - export interface CollectionDetailsProps { - collection?: CollectionDetails_collection; data: { - description: RawDraftContentState; + description: OutputData; name: string; }; disabled: boolean; errors: ProductErrorFragment[]; onChange: (event: React.ChangeEvent) => void; + onDescriptionChange: RichTextEditorChange; } const CollectionDetails: React.FC = ({ - collection, disabled, data, onChange, + onDescriptionChange, errors }) => { const intl = useIntl(); @@ -57,13 +56,13 @@ const CollectionDetails: React.FC = ({ /> JSON.parse(collection.descriptionJson))} label={intl.formatMessage(commonMessages.description)} name="description" disabled={disabled} - onChange={onChange} + onChange={onDescriptionChange} /> diff --git a/src/collections/components/CollectionDetailsPage/CollectionDetailsPage.tsx b/src/collections/components/CollectionDetailsPage/CollectionDetailsPage.tsx index 0b37e6e51..34d214dfc 100644 --- a/src/collections/components/CollectionDetailsPage/CollectionDetailsPage.tsx +++ b/src/collections/components/CollectionDetailsPage/CollectionDetailsPage.tsx @@ -3,22 +3,18 @@ import { CardSpacer } from "@saleor/components/CardSpacer"; import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; import { Container } from "@saleor/components/Container"; import ControlledCheckbox from "@saleor/components/ControlledCheckbox"; -import Form from "@saleor/components/Form"; import FormSpacer from "@saleor/components/FormSpacer"; import Grid from "@saleor/components/Grid"; import Hr from "@saleor/components/Hr"; import Metadata from "@saleor/components/Metadata/Metadata"; -import { MetadataFormData } from "@saleor/components/Metadata/types"; import PageHeader from "@saleor/components/PageHeader"; import SaveButtonBar from "@saleor/components/SaveButtonBar"; import SeoForm from "@saleor/components/SeoForm"; import VisibilityCard from "@saleor/components/VisibilityCard"; import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment"; import useDateLocalize from "@saleor/hooks/useDateLocalize"; +import { SubmitPromise } from "@saleor/hooks/useForm"; import { sectionNames } from "@saleor/intl"; -import { mapMetadataItemToInput } from "@saleor/utils/maps"; -import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; -import { RawDraftContentState } from "draft-js"; import React from "react"; import { useIntl } from "react-intl"; @@ -28,18 +24,7 @@ import { CollectionDetails_collection } from "../../types/CollectionDetails"; import CollectionDetails from "../CollectionDetails/CollectionDetails"; import { CollectionImage } from "../CollectionImage/CollectionImage"; import CollectionProducts from "../CollectionProducts/CollectionProducts"; - -export interface CollectionDetailsPageFormData extends MetadataFormData { - backgroundImageAlt: string; - description: RawDraftContentState; - name: string; - slug: string; - publicationDate: string; - seoDescription: string; - seoTitle: string; - isFeatured: boolean; - isPublished: boolean; -} +import CollectionUpdateForm, { CollectionUpdateData } from "./form"; export interface CollectionDetailsPageProps extends PageListProps, ListActions { collection: CollectionDetails_collection; @@ -51,7 +36,7 @@ export interface CollectionDetailsPageProps extends PageListProps, ListActions { onImageDelete: () => void; onImageUpload: (file: File) => void; onProductUnassign: (id: string, event: React.MouseEvent) => void; - onSubmit: (data: CollectionDetailsPageFormData) => void; + onSubmit: (data: CollectionUpdateData) => SubmitPromise; } const CollectionDetailsPage: React.FC = ({ @@ -69,150 +54,113 @@ const CollectionDetailsPage: React.FC = ({ }: CollectionDetailsPageProps) => { const intl = useIntl(); const localizeDate = useDateLocalize(); - const { - isMetadataModified, - isPrivateMetadataModified, - makeChangeHandler: makeMetadataChangeHandler - } = useMetadataChangeTrigger(); - - const handleSubmit = (data: CollectionDetailsPageFormData) => { - const metadata = isMetadataModified ? data.metadata : undefined; - const privateMetadata = isPrivateMetadataModified - ? data.privateMetadata - : undefined; - - return onSubmit({ - ...data, - isPublished: data.isPublished || !!data.publicationDate, - metadata, - privateMetadata - }); - }; return ( -
collection.backgroundImage.alt, ""), - description: maybe(() => JSON.parse(collection.descriptionJson)), - isFeatured, - isPublished: maybe(() => collection.isPublished, false), - metadata: collection?.metadata?.map(mapMetadataItemToInput), - name: maybe(() => collection.name, ""), - privateMetadata: collection?.privateMetadata?.map( - mapMetadataItemToInput - ), - publicationDate: maybe(() => collection.publicationDate, ""), - seoDescription: maybe(() => collection.seoDescription, ""), - seoTitle: maybe(() => collection.seoTitle, ""), - slug: collection?.slug || "" - }} - onSubmit={handleSubmit} - confirmLeave + - {({ change, data, hasChanged, submit }) => { - const changeMetadata = makeMetadataChangeHandler(change); - - return ( - - - {intl.formatMessage(sectionNames.collections)} - - collection.name)} /> - + {({ change, data, handlers, hasChanged, submit }) => ( + + + {intl.formatMessage(sectionNames.collections)} + + collection.name)} /> + +
+ + + collection.backgroundImage)} + onImageDelete={onImageDelete} + onImageUpload={onImageUpload} + onChange={change} + /> + + + + + + collection.name)} + onChange={change} + /> +
+
- - - collection.backgroundImage)} - onImageDelete={onImageDelete} - onImageUpload={onImageUpload} - onChange={change} - /> - - - - - - collection.name)} - onChange={change} - /> -
-
-
- + +
+ - -
- -
-
+ disabled={disabled} + /> +
- - - - ); - }} - +
+
+ +
+ )} +
); }; CollectionDetailsPage.displayName = "CollectionDetailsPage"; diff --git a/src/collections/components/CollectionDetailsPage/form.tsx b/src/collections/components/CollectionDetailsPage/form.tsx new file mode 100644 index 000000000..00364cf2b --- /dev/null +++ b/src/collections/components/CollectionDetailsPage/form.tsx @@ -0,0 +1,122 @@ +import { OutputData } from "@editorjs/editorjs"; +import { CollectionDetails_collection } from "@saleor/collections/types/CollectionDetails"; +import { MetadataFormData } from "@saleor/components/Metadata"; +import { RichTextEditorChange } from "@saleor/components/RichTextEditor"; +import useForm, { FormChange } from "@saleor/hooks/useForm"; +import getPublicationData from "@saleor/utils/data/getPublicationData"; +import handleFormSubmit from "@saleor/utils/handlers/handleFormSubmit"; +import { mapMetadataItemToInput } from "@saleor/utils/maps"; +import getMetadata from "@saleor/utils/metadata/getMetadata"; +import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; +import useRichText from "@saleor/utils/richText/useRichText"; +import React from "react"; + +export interface CollectionUpdateFormData extends MetadataFormData { + backgroundImageAlt: string; + name: string; + slug: string; + publicationDate: string; + seoDescription: string; + seoTitle: string; + isFeatured: boolean; + isPublished: boolean; +} +export interface CollectionUpdateData extends CollectionUpdateFormData { + description: OutputData; +} + +interface CollectionUpdateHandlers { + changeMetadata: FormChange; + changeDescription: RichTextEditorChange; +} +export interface UseCollectionUpdateFormResult { + change: FormChange; + data: CollectionUpdateData; + handlers: CollectionUpdateHandlers; + hasChanged: boolean; + submit: () => Promise; +} + +export interface CollectionUpdateFormProps { + children: (props: UseCollectionUpdateFormResult) => React.ReactNode; + collection: CollectionDetails_collection; + isFeatured: boolean; + onSubmit: (data: CollectionUpdateData) => Promise; +} + +function useCollectionUpdateForm( + collection: CollectionDetails_collection, + onSubmit: (data: CollectionUpdateData) => Promise, + isFeatured: boolean +): UseCollectionUpdateFormResult { + const [changed, setChanged] = React.useState(false); + const triggerChange = () => setChanged(true); + + const form = useForm({ + backgroundImageAlt: collection?.backgroundImage?.alt || "", + isFeatured, + isPublished: !!collection?.isPublished, + metadata: collection?.metadata?.map(mapMetadataItemToInput), + name: collection?.name || "", + privateMetadata: collection?.privateMetadata?.map(mapMetadataItemToInput), + publicationDate: collection?.publicationDate || "", + seoDescription: collection?.seoDescription || "", + seoTitle: collection?.seoTitle || "", + slug: collection?.slug || "" + }); + const [description, changeDescription] = useRichText({ + initial: collection?.descriptionJson, + triggerChange + }); + + const { + isMetadataModified, + isPrivateMetadataModified, + makeChangeHandler: makeMetadataChangeHandler + } = useMetadataChangeTrigger(); + + const handleChange: FormChange = (event, cb) => { + form.change(event, cb); + triggerChange(); + }; + const changeMetadata = makeMetadataChangeHandler(handleChange); + + // Need to make it function to always have description.current up to date + const getData = (): CollectionUpdateData => ({ + ...form.data, + description: description.current + }); + + const getSubmitData = (): CollectionUpdateData => ({ + ...getData(), + ...getMetadata(form.data, isMetadataModified, isPrivateMetadataModified), + ...getPublicationData(form.data) + }); + + const submit = () => handleFormSubmit(getSubmitData(), onSubmit, setChanged); + + return { + change: handleChange, + data: getData(), + handlers: { + changeDescription, + changeMetadata + }, + hasChanged: changed, + submit + }; +} + +const CollectionUpdateForm: React.FC = ({ + children, + collection, + isFeatured, + onSubmit +}) => { + const props = useCollectionUpdateForm(collection, onSubmit, isFeatured); + + return
{children(props)}
; +}; + +CollectionUpdateForm.displayName = "CollectionUpdateForm"; +export default CollectionUpdateForm; diff --git a/src/collections/fixtures.ts b/src/collections/fixtures.ts index f604aa199..4e515428f 100644 --- a/src/collections/fixtures.ts +++ b/src/collections/fixtures.ts @@ -1,7 +1,9 @@ -import { content } from "../storybook/stories/components/RichTextEditor"; +import * as richTextEditorFixtures from "../components/RichTextEditor/fixtures.json"; import { CollectionDetails_collection } from "./types/CollectionDetails"; import { CollectionList_collections_edges_node } from "./types/CollectionList"; +const content = richTextEditorFixtures.richTextEditor; + export const collections: CollectionList_collections_edges_node[] = [ { __typename: "Collection", diff --git a/src/collections/views/CollectionCreate.tsx b/src/collections/views/CollectionCreate.tsx index 549a93efc..277c67be9 100644 --- a/src/collections/views/CollectionCreate.tsx +++ b/src/collections/views/CollectionCreate.tsx @@ -2,6 +2,7 @@ import { WindowTitle } from "@saleor/components/WindowTitle"; import useNavigator from "@saleor/hooks/useNavigator"; import useNotifier from "@saleor/hooks/useNotifier"; import { commonMessages } from "@saleor/intl"; +import getPublicationData from "@saleor/utils/data/getPublicationData"; import createMetadataCreateHandler from "@saleor/utils/handlers/metadataCreateHandler"; import { useMetadataUpdate, @@ -11,9 +12,8 @@ import React from "react"; import { useIntl } from "react-intl"; import { CollectionCreateInput } from "../../types/globalTypes"; -import CollectionCreatePage, { - CollectionCreatePageFormData -} from "../components/CollectionCreatePage/CollectionCreatePage"; +import CollectionCreatePage from "../components/CollectionCreatePage/CollectionCreatePage"; +import { CollectionCreateData } from "../components/CollectionCreatePage/form"; import { useCollectionCreateMutation } from "../mutations"; import { collectionListUrl, collectionUrl } from "../urls"; @@ -47,15 +47,7 @@ export const CollectionCreate: React.FC = () => { } }); - const getPublicationData = ({ - publicationDate, - isPublished - }: CollectionCreatePageFormData) => ({ - isPublished: !!publicationDate || isPublished, - publicationDate: publicationDate || null - }); - - const handleCreate = async (formData: CollectionCreatePageFormData) => { + const handleCreate = async (formData: CollectionCreateData) => { const result = await createCollection({ variables: { input: { diff --git a/src/collections/views/CollectionDetails.tsx b/src/collections/views/CollectionDetails.tsx index e1e5a0af5..d9c886825 100644 --- a/src/collections/views/CollectionDetails.tsx +++ b/src/collections/views/CollectionDetails.tsx @@ -25,9 +25,8 @@ import { FormattedMessage, useIntl } from "react-intl"; import { getMutationState, maybe } from "../../misc"; import { productUrl } from "../../products/urls"; import { CollectionInput } from "../../types/globalTypes"; -import CollectionDetailsPage, { - CollectionDetailsPageFormData -} from "../components/CollectionDetailsPage/CollectionDetailsPage"; +import CollectionDetailsPage from "../components/CollectionDetailsPage/CollectionDetailsPage"; +import { CollectionUpdateData } from "../components/CollectionDetailsPage/form"; import { useCollectionAssignProductMutation, useCollectionRemoveMutation, @@ -168,9 +167,7 @@ export const CollectionDetails: React.FC = ({ return ; } - const handleUpdate = async ( - formData: CollectionDetailsPageFormData - ) => { + const handleUpdate = async (formData: CollectionUpdateData) => { const input: CollectionInput = { backgroundImageAlt: formData.backgroundImageAlt, descriptionJson: JSON.stringify(formData.description), diff --git a/src/components/RichTextEditor/ImageEntity.tsx b/src/components/RichTextEditor/ImageEntity.tsx deleted file mode 100644 index 2bc502f3f..000000000 --- a/src/components/RichTextEditor/ImageEntity.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import Button from "@material-ui/core/Button"; -import ClickAwayListener from "@material-ui/core/ClickAwayListener"; -import Grow from "@material-ui/core/Grow"; -import IconButton from "@material-ui/core/IconButton"; -import Paper from "@material-ui/core/Paper"; -import Popper from "@material-ui/core/Popper"; -import { makeStyles } from "@material-ui/core/styles"; -import DeleteIcon from "@material-ui/icons/Delete"; -import { ContentState } from "draft-js"; -import React from "react"; -import { FormattedMessage } from "react-intl"; - -interface ImageEntityProps { - children: React.ReactNode; - contentState: ContentState; - entityKey: string; - onEdit: (entityKey: string) => void; - onRemove: (entityKey: string) => void; -} - -const useStyles = makeStyles( - theme => ({ - anchor: { - display: "inline-block" - }, - container: { - alignItems: "center", - display: "flex" - }, - image: { maxWidth: "100%" }, - inline: { - display: "inline-block" - }, - root: { - alignItems: "center", - display: "flex", - minHeight: 72, - padding: theme.spacing(1.5) - } - }), - { name: "ImageEntity" } -); - -const ImageEntity: React.FC = props => { - const { contentState, entityKey, onEdit, onRemove } = props; - const classes = useStyles(props); - - const [isOpened, setOpenStatus] = React.useState(false); - const anchor = React.useRef(); - - const disable = () => setOpenStatus(false); - const toggle = () => setOpenStatus(!isOpened); - - return ( - <> -
- - {({ TransitionProps, placement }) => ( - - - -
- - onRemove(entityKey)}> - - -
-
-
-
- )} -
-
- - - ); -}; -export default ImageEntity; diff --git a/src/components/RichTextEditor/ImageSource.tsx b/src/components/RichTextEditor/ImageSource.tsx deleted file mode 100644 index d3ae28ff9..000000000 --- a/src/components/RichTextEditor/ImageSource.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import Button from "@material-ui/core/Button"; -import Dialog from "@material-ui/core/Dialog"; -import DialogActions from "@material-ui/core/DialogActions"; -import DialogContent from "@material-ui/core/DialogContent"; -import DialogTitle from "@material-ui/core/DialogTitle"; -import TextField from "@material-ui/core/TextField"; -import { buttonMessages } from "@saleor/intl"; -import { AtomicBlockUtils, EditorState, EntityInstance } from "draft-js"; -import React from "react"; -import { FormattedMessage, useIntl } from "react-intl"; - -import Form from "../Form"; - -interface ImageSourceProps { - editorState: EditorState; - entity?: EntityInstance; - entityKey?: string; - entityType: { - type: string; - }; - onComplete: (updateState: EditorState) => void; - onClose: () => void; -} - -const ImageSource: React.FC = ({ - editorState, - entity, - entityKey, - entityType, - onComplete, - onClose -}) => { - const intl = useIntl(); - - const initial = entity ? entity.getData().href : ""; - - const handleSubmit = (href: string) => { - if (href) { - const content = editorState.getCurrentContent(); - if (entity) { - const nextContent = content.mergeEntityData(entityKey, { href }); - const nextState = EditorState.push( - editorState, - nextContent, - "apply-entity" - ); - onComplete(nextState); - } else { - const contentWithEntity = content.createEntity( - entityType.type, - "IMMUTABLE", - { href } - ); - const nextState = AtomicBlockUtils.insertAtomicBlock( - editorState, - contentWithEntity.getLastCreatedEntityKey(), - " " - ); - - onComplete(nextState); - } - } else { - onComplete(editorState); - } - }; - - return ( - -
handleSubmit(href)} - > - {({ data, change, submit }) => ( - <> - - - - - - - - - - - - )} -
-
- ); -}; - -export default ImageSource; diff --git a/src/components/RichTextEditor/LinkEntity.tsx b/src/components/RichTextEditor/LinkEntity.tsx deleted file mode 100644 index 86166fe2e..000000000 --- a/src/components/RichTextEditor/LinkEntity.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import Button from "@material-ui/core/Button"; -import ClickAwayListener from "@material-ui/core/ClickAwayListener"; -import Grow from "@material-ui/core/Grow"; -import IconButton from "@material-ui/core/IconButton"; -import Paper from "@material-ui/core/Paper"; -import Popper from "@material-ui/core/Popper"; -import { makeStyles } from "@material-ui/core/styles"; -import Typography from "@material-ui/core/Typography"; -import DeleteIcon from "@material-ui/icons/Delete"; -import { buttonMessages } from "@saleor/intl"; -import { ContentState } from "draft-js"; -import React from "react"; -import { FormattedMessage } from "react-intl"; - -import Link from "../Link"; - -interface LinkEntityProps { - children: React.ReactNode; - contentState: ContentState; - entityKey: string; - onEdit: (entityKey: string) => void; - onRemove: (entityKey: string) => void; -} - -const useStyles = makeStyles( - theme => ({ - anchor: { - display: "inline-block" - }, - container: { - alignItems: "center", - display: "flex" - }, - inline: { - display: "inline-block" - }, - popover: { - zIndex: 1 - }, - root: { - alignItems: "center", - display: "flex", - minHeight: 72, - padding: theme.spacing(1.5, 1.5, 1.5, 3) - }, - separator: { - backgroundColor: theme.palette.grey[300], - display: "inline-block", - height: 30, - marginLeft: theme.spacing(2), - marginRight: theme.spacing(), - width: 1 - } - }), - { name: "LinkEntity" } -); - -const LinkEntity: React.FC = props => { - const { children, contentState, entityKey, onEdit, onRemove } = props; - const classes = useStyles(props); - - const [isOpened, setOpenStatus] = React.useState(false); - const anchor = React.useRef(); - - const disable = () => setOpenStatus(false); - const toggle = () => setOpenStatus(!isOpened); - - return ( - <> -
- - {({ TransitionProps, placement }) => ( - - - -
- - {contentState.getEntity(entityKey).getData().url} - - - - onRemove(entityKey)}> - - -
-
-
-
- )} -
-
- - {children} - - - ); -}; -export default LinkEntity; diff --git a/src/components/RichTextEditor/LinkSource.tsx b/src/components/RichTextEditor/LinkSource.tsx deleted file mode 100644 index b6c55e7a5..000000000 --- a/src/components/RichTextEditor/LinkSource.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import Button from "@material-ui/core/Button"; -import Dialog from "@material-ui/core/Dialog"; -import DialogActions from "@material-ui/core/DialogActions"; -import DialogContent from "@material-ui/core/DialogContent"; -import DialogTitle from "@material-ui/core/DialogTitle"; -import TextField from "@material-ui/core/TextField"; -import { buttonMessages } from "@saleor/intl"; -import { EditorState, EntityInstance, RichUtils } from "draft-js"; -import React from "react"; -import { FormattedMessage, useIntl } from "react-intl"; - -import Form from "../Form"; - -interface LinkSourceProps { - editorState: EditorState; - entity?: EntityInstance; - entityType: { - type: string; - }; - onComplete: (updateState: EditorState) => void; - onClose: () => void; -} - -const LinkSource: React.FC = ({ - editorState, - entity, - entityType, - onComplete, - onClose -}) => { - const intl = useIntl(); - const initial = entity ? entity.getData().url : ""; - - const handleSubmit = (url: string) => { - if (url) { - const content = editorState.getCurrentContent(); - const contentWithEntity = content.createEntity( - entityType.type, - "MUTABLE", - { url } - ); - const entityKey = contentWithEntity.getLastCreatedEntityKey(); - const newEditorState = EditorState.set(editorState, { - currentContent: contentWithEntity - }); - const nextState = RichUtils.toggleLink( - newEditorState, - newEditorState.getSelection(), - entityKey - ); - - onComplete(nextState); - } else { - onComplete(editorState); - } - }; - - return ( - -
handleSubmit(url)} - > - {({ data, change, submit }) => ( - <> - - - - - - - - - - - - )} -
-
- ); -}; - -export default LinkSource; diff --git a/src/components/RichTextEditor/RichTextEditor.stories.tsx b/src/components/RichTextEditor/RichTextEditor.stories.tsx new file mode 100644 index 000000000..e250f5496 --- /dev/null +++ b/src/components/RichTextEditor/RichTextEditor.stories.tsx @@ -0,0 +1,28 @@ +import { OutputData } from "@editorjs/editorjs"; +import RichTextEditor from "@saleor/components/RichTextEditor"; +import CardDecorator from "@saleor/storybook/CardDecorator"; +import Decorator from "@saleor/storybook/Decorator"; +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import * as fixtures from "./fixtures.json"; +import { RichTextEditorProps } from "./RichTextEditor"; + +export const data: OutputData = fixtures.richTextEditor; + +const props: RichTextEditorProps = { + data, + disabled: false, + error: false, + helperText: "Lorem ipsum dolor sit amet, consectetur adipiscing elit", + label: "Content", + name: "content", + onChange: () => undefined +}; + +storiesOf("Generics / Rich text editor", module) + .addDecorator(CardDecorator) + .addDecorator(Decorator) + .add("default", () => ) + .add("disabled", () => ) + .add("error", () => ); diff --git a/src/components/RichTextEditor/RichTextEditor.tsx b/src/components/RichTextEditor/RichTextEditor.tsx index 68d8164ff..cc17b8de8 100644 --- a/src/components/RichTextEditor/RichTextEditor.tsx +++ b/src/components/RichTextEditor/RichTextEditor.tsx @@ -1,316 +1,99 @@ -import { makeStyles } from "@material-ui/core/styles"; -import { fade } from "@material-ui/core/styles/colorManipulator"; -import Typography from "@material-ui/core/Typography"; -import { CreateCSSProperties } from "@material-ui/styles/withStyles"; -import { ChangeEvent } from "@saleor/hooks/useForm"; +import EditorJS, { OutputData } from "@editorjs/editorjs"; +import FormControl from "@material-ui/core/FormControl"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import InputLabel from "@material-ui/core/InputLabel"; import classNames from "classnames"; -import { RawDraftContentState } from "draft-js"; -import { - BLOCK_TYPE, - DraftailEditor, - ENTITY_TYPE, - INLINE_STYLE -} from "draftail"; -import isEqual from "lodash-es/isEqual"; +import Undo from "editorjs-undo"; import React from "react"; -import ErrorBoundary from "react-error-boundary"; -import { FormattedMessage } from "react-intl"; -import BoldIcon from "../../icons/BoldIcon"; -import HeaderOne from "../../icons/HeaderOne"; -import HeaderThree from "../../icons/HeaderThree"; -import HeaderTwo from "../../icons/HeaderTwo"; -import ItalicIcon from "../../icons/ItalicIcon"; -import LinkIcon from "../../icons/LinkIcon"; -import OrderedListIcon from "../../icons/OrderedListIcon"; -import QuotationIcon from "../../icons/QuotationIcon"; -import StrikethroughIcon from "../../icons/StrikethroughIcon"; -import UnorderedListIcon from "../../icons/UnorderedListIcon"; -import LinkEntity from "./LinkEntity"; -import LinkSource from "./LinkSource"; +import { RichTextEditorContentProps, tools } from "./RichTextEditorContent"; +import useStyles from "./styles"; -export interface RichTextEditorProps { +export type RichTextEditorChange = (data: OutputData) => void; +export interface RichTextEditorProps extends RichTextEditorContentProps { disabled: boolean; error: boolean; helperText: string; - initial?: RawDraftContentState; label: string; name: string; - scroll?: boolean; - onChange: (event: React.ChangeEvent) => void; + onChange: RichTextEditorChange; } -const useStyles = makeStyles( - theme => { - const editorContainer: CreateCSSProperties = { - border: `1px ${theme.palette.divider} solid`, - borderRadius: 4, - padding: "27px 12px 10px", - position: "relative", - transition: theme.transitions.duration.shortest + "ms" - }; +const RichTextEditor: React.FC = ({ + data, + disabled, + error, + helperText, + label, + name, + onChange, + onReady +}) => { + const classes = useStyles({}); - return { - editorContainer, - error: { - color: theme.palette.error.main - }, - helperText: { - marginTop: theme.spacing(0.75) - }, - input: { - position: "relative" - }, - label: { - fontSize: theme.typography.caption.fontSize, - left: 12, - position: "absolute", - top: 9 - }, - linkIcon: { - marginTop: 2 - }, - root: { - "& .DraftEditor": { - "&-editorContainer": { - "& .public-DraftEditor-content": { - lineHeight: 1.62 - }, - "& a": { - color: theme.palette.primary.light - }, - "&:after": { - background: theme.palette.getContrastText( - theme.palette.background.default - ), - bottom: -11, - content: "''", - display: "block", - height: 2, - left: -12, - position: "absolute", - transform: "scaleX(0) scaleY(0)", - width: "calc(100% + 24px)" - }, - position: "relative" + const [isFocused, setFocus] = React.useState(false); + const editor = React.useRef(); + const editorContainer = React.useRef(); + React.useEffect( + () => { + if (data) { + editor.current = new EditorJS({ + data, + holder: editorContainer.current, + onChange: async api => { + const savedData = await api.saver.save(); + onChange(savedData); }, - "&-root": { - ...theme.typography.body1 - } - }, - "& .Draftail": { - "&-Editor": { - "&--focus": { - boxShadow: `inset 0px 0px 0px 2px ${theme.palette.primary.main}` - }, - "&:hover": { - borderColor: theme.palette.primary.main - }, - ...editorContainer - }, - "&-Toolbar": { - "&Button": { - "& svg": { - padding: 2 - }, - "&--active": { - "&:hover": { - background: theme.palette.primary.main - }, - "&:not(:hover)": { - borderRightColor: theme.palette.primary.main - }, - background: theme.palette.primary.main - }, - "&:focus": { - "&:active": { - "&:after": { - background: fade(theme.palette.primary.main, 0.3), - borderRadius: "100%", - content: "''", - display: "block", - height: "100%", - width: "100%" - } - } - }, - "&:hover": { - background: fade(theme.palette.primary.main, 0.3) - }, - background: "none", - border: "none", - borderRight: `1px ${theme.palette.divider} solid`, - color: theme.typography.body1.color, - cursor: "pointer", - display: "inline-flex", - height: 36, - justifyContent: "center", - padding: theme.spacing(1) + 2, - transition: theme.transitions.duration.short + "ms", - width: 36 - }, - "&Group": { - "&:last-of-type": { - "& .Draftail-ToolbarButton": { - "&:last-of-type": { - border: "none" - } - } - }, - display: "flex" - }, - background: theme.palette.background.default, - border: `1px ${theme.palette.divider} solid`, - display: "inline-flex", - flexWrap: "wrap", - marginBottom: theme.spacing(), - marginTop: 10, - [theme.breakpoints.down(460)]: { - width: "min-content" + onReady: () => { + const undo = new Undo({ editor }); + undo.initialize(data); + if (onReady) { + onReady(); } }, - "&-block": { - "&--blockquote": { - borderLeft: `2px solid ${theme.palette.divider}`, - margin: 0, - padding: theme.spacing(1, 2) - } - } - }, - "&$error": { - "& .Draftail": { - "&-Editor": { - borderColor: theme.palette.error.main - } - } - } - }, - scroll: { - "& .DraftEditor": { - "&-editorContainer": { - "& .public-DraftEditor-content": { - lineHeight: 1.62 - } - } - } - }, - smallIcon: { - marginLeft: 10 + readOnly: disabled, + tools + }); } - }; - }, - { name: "RichTextEditor" } -); -function handleSave( - value: any, - initial: any, - name: string, - onChange: (event: ChangeEvent) => void -) { - if (value && !isEqual(value, initial)) { - onChange({ - target: { - name, - value - } - }); - } -} - -const RichTextEditor: React.FC = props => { - const { error, helperText, initial, label, name, scroll, onChange } = props; - - const classes = useStyles(props); + return editor.current?.destroy; + }, + // Rerender editor only if changed from undefined to defined state + [data === undefined] + ); + React.useEffect(() => editor.current?.destroy, []); + React.useEffect(() => { + if (editor.current?.readOnly) { + editor.current.readOnly.toggle(disabled); + } + }, [disabled]); return ( -
-
- - {label} - - ( -
- - - -
- )} - > - 0 ? initial : null - } - onSave={value => handleSave(value, initial, name, onChange)} - blockTypes={[ - { - icon: , - type: BLOCK_TYPE.HEADER_ONE - }, - { icon: , type: BLOCK_TYPE.HEADER_TWO }, - { icon: , type: BLOCK_TYPE.HEADER_THREE }, - { icon: , type: BLOCK_TYPE.BLOCKQUOTE }, - { - icon: , - type: BLOCK_TYPE.UNORDERED_LIST_ITEM - }, - { icon: , type: BLOCK_TYPE.ORDERED_LIST_ITEM } - ]} - inlineStyles={[ - { - icon: , - type: INLINE_STYLE.BOLD - }, - { - icon: , - type: INLINE_STYLE.ITALIC - }, - { - icon: , - type: INLINE_STYLE.STRIKETHROUGH - } - ]} - enableLineBreak - entityTypes={[ - { - attributes: ["url"], - decorator: LinkEntity, - icon: , - source: LinkSource, - type: ENTITY_TYPE.LINK - } - ]} - /> -
-
- {helperText && ( - - {helperText} - - )} -
+ + {label} + +
setFocus(true)} + onBlur={() => setFocus(false)} + /> + {helperText} + ); }; RichTextEditor.displayName = "RichTextEditor"; -RichTextEditor.defaultProps = { - scroll: true -}; export default RichTextEditor; diff --git a/src/components/RichTextEditor/RichTextEditorContent.tsx b/src/components/RichTextEditor/RichTextEditorContent.tsx new file mode 100644 index 000000000..37ddf24e3 --- /dev/null +++ b/src/components/RichTextEditor/RichTextEditorContent.tsx @@ -0,0 +1,79 @@ +import EditorJS, { + OutputData, + ToolConstructable, + ToolSettings +} from "@editorjs/editorjs"; +import Header from "@editorjs/header"; +import List from "@editorjs/list"; +import Quote from "@editorjs/quote"; +import strikethroughIcon from "@saleor/icons/StrikethroughIcon"; +import classNames from "classnames"; +import createGenericInlineTool from "editorjs-inline-tool"; +import React from "react"; + +import useStyles from "./styles"; + +export interface RichTextEditorContentProps { + className?: string; + data: OutputData; + onReady?: () => void; +} + +export const tools: Record = { + header: { + class: Header, + config: { + defaultLevel: 1, + levels: [1, 2, 3] + } + }, + list: List, + quote: Quote, + strikethrough: createGenericInlineTool({ + sanitize: { + s: {} + }, + shortcut: "CMD+S", + tagName: "s", + toolboxIcon: strikethroughIcon + }) +}; + +const RichTextEditorContent: React.FC = ({ + className, + data, + onReady +}) => { + const classes = useStyles({}); + + const editor = React.useRef(); + const editorContainer = React.useRef(); + React.useEffect( + () => { + if (data) { + editor.current = new EditorJS({ + data, + holder: editorContainer.current, + onReady, + readOnly: true, + tools + }); + } + + return editor.current?.destroy; + }, + // Rerender editor only if changed from undefined to defined state + [data === undefined] + ); + React.useEffect(() => editor.current?.destroy, []); + + return ( +
+ ); +}; + +RichTextEditorContent.displayName = "RichTextEditorContent"; +export default RichTextEditorContent; diff --git a/src/components/RichTextEditor/fixtures.json b/src/components/RichTextEditor/fixtures.json new file mode 100644 index 000000000..d4846c867 --- /dev/null +++ b/src/components/RichTextEditor/fixtures.json @@ -0,0 +1,74 @@ +{ + "richTextEditor": { + "time": 1603898483525, + "blocks": [ + { "type": "header", "data": { "text": "Lorem ipsum ", "level": 1 } }, + { + "type": "paragraph", + "data": { + "text": "Dolor sit amet, consectetur adipiscing elit. Sed iaculis urna et justo accumsan, eget porta est egestas. Nunc odio libero, pharetra in tristique eget, pellentesque in lectus. Sed sed laoreet orci. Suspendisse dui nibh, iaculis ac dui posuere, placerat elementum dolor. In sit amet aliquet nibh. Maecenas sed felis sed lectus gravida vulputate et a mi. Sed a tristique neque, ut euismod arcu. Donec quis aliquet massa. Curabitur arcu purus, facilisis quis posuere sit amet, pharetra at erat." + } + }, + { + "type": "list", + "data": { + "style": "ordered", + "items": [ + "Maecenas pretium aliquam odio, a iaculis diam dictum ut.", + "Vestibulum pulvinar, quam quis sollicitudin luctus, libero odio laoreet lectus, in tristique ligula dui et ex.
", + "Nam quis nibh sed elit fermentum interdum non eget quam." + ] + } + }, + { + "type": "paragraph", + "data": { + "text": "Nulla sit amet cursus augue, in maximus tellus. Donec sit amet mollis neque, eget commodo odio." + } + }, + { "type": "header", "data": { "text": "Morbi aliquam", "level": 2 } }, + { + "type": "paragraph", + "data": { + "text": "Aliquam posuere nisi et ante malesuada egestas. Phasellus auctor risus a erat aliquam, tempus volutpat arcu rutrum. Duis bibendum id justo ut commodo. Suspendisse imperdiet tincidunt blandit." + } + }, + { "type": "header", "data": { "text": "Nam ipsum purus", "level": 3 } }, + { + "type": "paragraph", + "data": { + "text": "Nam ipsum purus, feugiat ut dapibus at, porttitor eget leo. Phasellus sodales urna quis mi viverra, non mollis magna tristique. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Pellentesque viverra est sit amet nisi hendrerit, pharetra vehicula neque volutpat. Maecenas feugiat a nulla id blandit. Sed sagittis tempus libero non dignissim. In lectus felis, mattis vitae lacinia nec, volutpat eu elit. Proin ultricies lacus id felis placerat mollis. Integer ultricies eros nec mauris interdum, sit amet sodales ipsum elementum. Vivamus quis dapibus turpis, eu dignissim quam." + } + }, + { + "type": "quote", + "data": { + "text": "Nam facilisis augue vel urna tristique rutrum id et tortor.", + "caption": "Morbi erat mi", + "alignment": "left" + } + }, + { + "type": "header", + "data": { "text": "Tempor ac posuere nec", "level": 3 } + }, + { + "type": "paragraph", + "data": { + "text": "Rhoncus ac lectus. Etiam viverra nisl feugiat tempus eleifend. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae." + } + }, + { + "type": "list", + "data": { + "style": "unordered", + "items": [ + "Phasellus nec ipsum non metus vestibulum semper", + "In tincidunt, dui vitae suscipit sodales, lacus justo porttitor nulla
" + ] + } + } + ], + "version": "2.19.0" + } +} diff --git a/src/components/RichTextEditor/styles.ts b/src/components/RichTextEditor/styles.ts new file mode 100644 index 000000000..2436bf2da --- /dev/null +++ b/src/components/RichTextEditor/styles.ts @@ -0,0 +1,112 @@ +import { makeStyles } from "@material-ui/core/styles"; +import { fade } from "@material-ui/core/styles/colorManipulator"; + +const useStyles = makeStyles( + theme => { + const hover = { + "&:hover": { + background: fade(theme.palette.primary.main, 0.1) + } + }; + + return { + editor: { + "& .cdx-quote__text": { + minHeight: 24 + }, + "& .ce-block--selected .ce-block__content": { + background: `${fade(theme.palette.primary.main, 0.2)} !important` + }, + "& .ce-block__content": { + margin: 0, + maxWidth: "unset" + }, + "& .ce-conversion-tool": { + ...hover + }, + "& .ce-conversion-tool--focused": { + background: `${fade(theme.palette.primary.main, 0.1)} !important` + }, + "& .ce-conversion-tool__icon": { + background: "none" + }, + "& .ce-conversion-toolbar": { + background: theme.palette.background.paper + }, + "& .ce-header": { + marginBottom: 0, + paddingBottom: theme.spacing(1) + }, + "& .ce-inline-tool": { + ...hover, + color: theme.palette.text.primary, + height: 32, + transition: theme.transitions.duration.short + "ms", + width: 32 + }, + "& .ce-inline-toolbar": { + "& input": { + background: "none" + }, + background: theme.palette.background.paper, + color: theme.palette.text.primary + }, + "& .ce-inline-toolbar__dropdown": { + ...hover, + height: 32, + marginRight: 0 + }, + "& .ce-inline-toolbar__toggler-and-button-wrapper": { + paddingRight: 0 + }, + "& .ce-toolbar__actions": { + right: 0, + top: 0 + }, + "& .ce-toolbar__content": { + maxWidth: "unset" + }, + "& .ce-toolbar__plus": { + left: -9 + }, + "& .ce-toolbox.ce-toolbox--opened": { + left: 16 + }, + "& .codex-editor__redactor": { + marginRight: `${theme.spacing(4)}px !important`, + paddingBottom: "0 !important" + }, + "& a": { + color: theme.palette.primary.light + }, + "&:not($rootDisabled):hover": { + borderColor: theme.palette.primary.main + } + }, + root: { + border: `1px solid ${fade(theme.palette.text.secondary, 0.4)}`, + borderRadius: 4, + boxShadow: `inset 0 0 0 0 ${theme.palette.primary.main}`, + fontSize: theme.typography.body1.fontSize, + minHeight: 56, + padding: theme.spacing(3, 2), + paddingBottom: theme.spacing(), + paddingLeft: 10, + position: "relative", + transition: theme.transitions.duration.short + "ms" + }, + rootActive: { + boxShadow: `inset 0px 0px 0 2px ${theme.palette.primary.main}` + }, + rootDisabled: { + ...theme.overrides.MuiOutlinedInput.root["&$disabled"]["& fieldset"] + }, + rootError: { + borderColor: theme.palette.error.main + } + }; + }, + { name: "RichTextEditor" } +); + +export default useStyles; diff --git a/src/icons/StrikethroughIcon.tsx b/src/icons/StrikethroughIcon.tsx index b409b4c00..68957fd06 100644 --- a/src/icons/StrikethroughIcon.tsx +++ b/src/icons/StrikethroughIcon.tsx @@ -1,11 +1,3 @@ -import createSvgIcon from "@material-ui/icons/utils/createSvgIcon"; -import React from "react"; +const Strikethrough = ``; -const HeaderOne = createSvgIcon( - <> - - , - "HeaderOne" -); - -export default HeaderOne; +export default Strikethrough; diff --git a/src/pages/components/PageDetailsPage/PageDetailsPage.tsx b/src/pages/components/PageDetailsPage/PageDetailsPage.tsx index 739433f0c..23e40fd48 100644 --- a/src/pages/components/PageDetailsPage/PageDetailsPage.tsx +++ b/src/pages/components/PageDetailsPage/PageDetailsPage.tsx @@ -2,9 +2,8 @@ import AppHeader from "@saleor/components/AppHeader"; import CardSpacer from "@saleor/components/CardSpacer"; import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; import Container from "@saleor/components/Container"; -import Form from "@saleor/components/Form"; import Grid from "@saleor/components/Grid"; -import Metadata, { MetadataFormData } from "@saleor/components/Metadata"; +import Metadata from "@saleor/components/Metadata"; import PageHeader from "@saleor/components/PageHeader"; import SaveButtonBar from "@saleor/components/SaveButtonBar"; import SeoForm from "@saleor/components/SeoForm"; @@ -13,30 +12,12 @@ import { PageErrorFragment } from "@saleor/fragments/types/PageErrorFragment"; import useDateLocalize from "@saleor/hooks/useDateLocalize"; import { SubmitPromise } from "@saleor/hooks/useForm"; import { sectionNames } from "@saleor/intl"; -import { mapMetadataItemToInput } from "@saleor/utils/maps"; -import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; -import { - ContentState, - convertFromRaw, - convertToRaw, - RawDraftContentState -} from "draft-js"; import React from "react"; import { useIntl } from "react-intl"; -import { maybe } from "../../../misc"; import { PageDetails_page } from "../../types/PageDetails"; import PageInfo from "../PageInfo"; - -export interface PageDetailsPageFormData extends MetadataFormData { - content: RawDraftContentState; - isPublished: boolean; - publicationDate: string; - seoDescription: string; - seoTitle: string; - slug: string; - title: string; -} +import PageForm, { PageData } from "./form"; export interface PageDetailsPageProps { disabled: boolean; @@ -46,7 +27,7 @@ export interface PageDetailsPageProps { saveButtonBarState: ConfirmButtonTransitionState; onBack: () => void; onRemove: () => void; - onSubmit: (data: PageDetailsPageFormData) => SubmitPromise; + onSubmit: (data: PageData) => SubmitPromise; } const PageDetailsPage: React.FC = ({ @@ -60,140 +41,94 @@ const PageDetailsPage: React.FC = ({ }) => { const intl = useIntl(); const localizeDate = useDateLocalize(); - const { - isMetadataModified, - isPrivateMetadataModified, - makeChangeHandler: makeMetadataChangeHandler - } = useMetadataChangeTrigger(); const pageExists = page !== null; - const initialForm: PageDetailsPageFormData = { - content: maybe( - () => JSON.parse(page.contentJson), - convertToRaw(ContentState.createFromText("")) - ), - isPublished: page?.isPublished, - metadata: pageExists ? page?.metadata?.map(mapMetadataItemToInput) : [], - privateMetadata: pageExists - ? page?.privateMetadata?.map(mapMetadataItemToInput) - : [], - publicationDate: page?.publicationDate || "", - seoDescription: page?.seoDescription || "", - seoTitle: page?.seoTitle || "", - slug: page?.slug || "", - title: page?.title || "" - }; - - const handleSubmit = (data: PageDetailsPageFormData) => { - const metadata = isMetadataModified ? data.metadata : undefined; - const privateMetadata = isPrivateMetadataModified - ? data.privateMetadata - : undefined; - - return onSubmit({ - ...data, - isPublished: data.isPublished || !!data.publicationDate, - metadata, - privateMetadata - }); - }; - return ( -
- {({ change, data, hasChanged, submit }) => { - const changeMetadata = makeMetadataChangeHandler(change); - - return ( - - - {intl.formatMessage(sectionNames.pages)} - - page.title) - } - /> - -
- - - - convertFromRaw(data.content) - .getPlainText() - .slice(0, 300), - "" - )} - onChange={change} - slug={data.slug} - slugPlaceholder={data.title} - title={data.seoTitle} - titlePlaceholder={data.title} - helperText={intl.formatMessage({ - defaultMessage: - "Add search engine title and description to make this page easier to find" - })} - /> - - -
-
- - -
-
- -
- ); - }} -
+ + {({ change, data, handlers, hasChanged, submit }) => ( + + + {intl.formatMessage(sectionNames.pages)} + + + +
+ + + + + +
+
+ + +
+
+ +
+ )} +
); }; PageDetailsPage.displayName = "PageDetailsPage"; diff --git a/src/pages/components/PageDetailsPage/form.tsx b/src/pages/components/PageDetailsPage/form.tsx new file mode 100644 index 000000000..9a71a2a55 --- /dev/null +++ b/src/pages/components/PageDetailsPage/form.tsx @@ -0,0 +1,118 @@ +import { OutputData } from "@editorjs/editorjs"; +import { MetadataFormData } from "@saleor/components/Metadata"; +import { RichTextEditorChange } from "@saleor/components/RichTextEditor"; +import useForm, { FormChange, SubmitPromise } from "@saleor/hooks/useForm"; +import { PageDetails_page } from "@saleor/pages/types/PageDetails"; +import getPublicationData from "@saleor/utils/data/getPublicationData"; +import handleFormSubmit from "@saleor/utils/handlers/handleFormSubmit"; +import { mapMetadataItemToInput } from "@saleor/utils/maps"; +import getMetadata from "@saleor/utils/metadata/getMetadata"; +import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; +import useRichText from "@saleor/utils/richText/useRichText"; +import React from "react"; + +export interface PageFormData extends MetadataFormData { + isPublished: boolean; + publicationDate: string; + seoDescription: string; + seoTitle: string; + slug: string; + title: string; +} +export interface PageData extends PageFormData { + content: OutputData; +} + +interface PageUpdateHandlers { + changeMetadata: FormChange; + changeContent: RichTextEditorChange; +} +export interface UsePageUpdateFormResult { + change: FormChange; + data: PageData; + handlers: PageUpdateHandlers; + hasChanged: boolean; + submit: () => void; +} + +export interface PageFormProps { + children: (props: UsePageUpdateFormResult) => React.ReactNode; + page: PageDetails_page; + onSubmit: (data: PageData) => SubmitPromise; +} + +function usePageForm( + page: PageDetails_page, + onSubmit: (data: PageData) => SubmitPromise +): UsePageUpdateFormResult { + const [changed, setChanged] = React.useState(false); + const triggerChange = () => setChanged(true); + + const pageExists = page !== null; + + const form = useForm({ + isPublished: page?.isPublished, + metadata: pageExists ? page?.metadata?.map(mapMetadataItemToInput) : [], + privateMetadata: pageExists + ? page?.privateMetadata?.map(mapMetadataItemToInput) + : [], + publicationDate: page?.publicationDate || "", + seoDescription: page?.seoDescription || "", + seoTitle: page?.seoTitle || "", + slug: page?.slug || "", + title: page?.title || "" + }); + const [content, changeContent] = useRichText({ + initial: pageExists ? page?.contentJson : null, + triggerChange + }); + + const { + isMetadataModified, + isPrivateMetadataModified, + makeChangeHandler: makeMetadataChangeHandler + } = useMetadataChangeTrigger(); + + const handleChange: FormChange = (event, cb) => { + form.change(event, cb); + triggerChange(); + }; + const changeMetadata = makeMetadataChangeHandler(handleChange); + + // Need to make it function to always have content.current up to date + const getData = (): PageData => ({ + ...form.data, + content: content.current + }); + + const getSubmitData = (): PageData => ({ + ...getData(), + ...getMetadata(form.data, isMetadataModified, isPrivateMetadataModified), + ...getPublicationData(form.data) + }); + + const submit = () => + pageExists + ? handleFormSubmit(getSubmitData(), onSubmit, setChanged) + : onSubmit(getSubmitData()); + + return { + change: handleChange, + data: getData(), + handlers: { + changeContent, + changeMetadata + }, + hasChanged: changed, + submit + }; +} + +const PageForm: React.FC = ({ children, page, onSubmit }) => { + const props = usePageForm(page, onSubmit); + + return
{children(props)}
; +}; + +PageForm.displayName = "PageForm"; +export default PageForm; diff --git a/src/pages/components/PageInfo/PageInfo.tsx b/src/pages/components/PageInfo/PageInfo.tsx index 12c2ecdeb..bf211d3e0 100644 --- a/src/pages/components/PageInfo/PageInfo.tsx +++ b/src/pages/components/PageInfo/PageInfo.tsx @@ -4,7 +4,9 @@ import { makeStyles } from "@material-ui/core/styles"; import TextField from "@material-ui/core/TextField"; import CardTitle from "@saleor/components/CardTitle"; import FormSpacer from "@saleor/components/FormSpacer"; -import RichTextEditor from "@saleor/components/RichTextEditor"; +import RichTextEditor, { + RichTextEditorChange +} from "@saleor/components/RichTextEditor"; import { PageErrorFragment } from "@saleor/fragments/types/PageErrorFragment"; import { commonMessages } from "@saleor/intl"; import { getFormErrors } from "@saleor/utils/errors"; @@ -12,16 +14,14 @@ import getPageErrorMessage from "@saleor/utils/errors/page"; import React from "react"; import { useIntl } from "react-intl"; -import { maybe } from "../../../misc"; -import { PageDetails_page } from "../../types/PageDetails"; -import { PageDetailsPageFormData } from "../PageDetailsPage"; +import { PageData } from "../PageDetailsPage/form"; export interface PageInfoProps { - data: PageDetailsPageFormData; + data: PageData; disabled: boolean; errors: PageErrorFragment[]; - page: PageDetails_page; onChange: (event: React.ChangeEvent) => void; + onContentChange: RichTextEditorChange; } const useStyles = makeStyles( @@ -34,7 +34,7 @@ const useStyles = makeStyles( ); const PageInfo: React.FC = props => { - const { data, disabled, errors, page, onChange } = props; + const { data, disabled, errors, onChange, onContentChange } = props; const classes = useStyles(props); const intl = useIntl(); @@ -56,22 +56,22 @@ const PageInfo: React.FC = props => { defaultMessage: "Title", description: "page title" })} - name={"title" as keyof PageDetailsPageFormData} + name={"title" as keyof PageData} value={data.title} onChange={onChange} /> JSON.parse(page.contentJson))} label={intl.formatMessage({ defaultMessage: "Content", description: "page content" })} - name={"content" as keyof PageDetailsPageFormData} - onChange={onChange} + name={"content" as keyof PageData} + onChange={onContentChange} /> diff --git a/src/pages/fixtures.ts b/src/pages/fixtures.ts index 6aba5a7ad..d912d5c09 100644 --- a/src/pages/fixtures.ts +++ b/src/pages/fixtures.ts @@ -1,7 +1,9 @@ -import { content } from "../storybook/stories/components/RichTextEditor"; +import * as richTextEditorFixtures from "../components/RichTextEditor/fixtures.json"; import { PageDetails_page } from "./types/PageDetails"; import { PageList_pages_edges_node } from "./types/PageList"; +const content = richTextEditorFixtures.richTextEditor; + export const pageList: PageList_pages_edges_node[] = [ { __typename: "Page", diff --git a/src/pages/mutations.ts b/src/pages/mutations.ts index 9cc38a9c1..e26d9e6fa 100644 --- a/src/pages/mutations.ts +++ b/src/pages/mutations.ts @@ -22,6 +22,7 @@ const pageCreate = gql` pageCreate(input: $input) { errors: pageErrors { ...PageErrorFragment + message } page { ...PageDetailsFragment diff --git a/src/pages/views/PageCreate.tsx b/src/pages/views/PageCreate.tsx index 4c5fb0fb8..628c84ebf 100644 --- a/src/pages/views/PageCreate.tsx +++ b/src/pages/views/PageCreate.tsx @@ -9,9 +9,8 @@ import { import React from "react"; import { useIntl } from "react-intl"; -import PageDetailsPage, { - PageDetailsPageFormData -} from "../components/PageDetailsPage"; +import PageDetailsPage from "../components/PageDetailsPage"; +import { PageData } from "../components/PageDetailsPage/form"; import { TypedPageCreate } from "../mutations"; import { PageCreate as PageCreateData } from "../types/PageCreate"; import { pageListUrl, pageUrl } from "../urls"; @@ -42,7 +41,7 @@ export const PageCreate: React.FC = () => { return ( {(pageCreate, pageCreateOpts) => { - const handleCreate = async (formData: PageDetailsPageFormData) => { + const handleCreate = async (formData: PageData) => { const result = await pageCreate({ variables: { input: { diff --git a/src/pages/views/PageDetails.tsx b/src/pages/views/PageDetails.tsx index 7b159d7de..d4fed8722 100644 --- a/src/pages/views/PageDetails.tsx +++ b/src/pages/views/PageDetails.tsx @@ -14,9 +14,8 @@ import { FormattedMessage, useIntl } from "react-intl"; import { getStringOrPlaceholder, maybe } from "../../misc"; import { PageInput } from "../../types/globalTypes"; -import PageDetailsPage, { - PageDetailsPageFormData -} from "../components/PageDetailsPage"; +import PageDetailsPage from "../components/PageDetailsPage"; +import { PageData } from "../components/PageDetailsPage/form"; import { TypedPageRemove, TypedPageUpdate } from "../mutations"; import { TypedPageDetailsQuery } from "../queries"; import { PageRemove } from "../types/PageRemove"; @@ -27,7 +26,7 @@ export interface PageDetailsProps { params: PageUrlQueryParams; } -const createPageInput = (data: PageDetailsPageFormData): PageInput => ({ +const createPageInput = (data: PageData): PageInput => ({ contentJson: JSON.stringify(data.content), isPublished: data.isPublished, publicationDate: data.publicationDate, @@ -63,7 +62,7 @@ export const PageDetails: React.FC = ({ id, params }) => { {(pageUpdate, pageUpdateOpts) => ( {pageDetails => { - const handleUpdate = async (data: PageDetailsPageFormData) => { + const handleUpdate = async (data: PageData) => { const result = await pageUpdate({ variables: { id, diff --git a/src/products/components/ProductCreatePage/ProductCreatePage.tsx b/src/products/components/ProductCreatePage/ProductCreatePage.tsx index ffebe8145..66bc08146 100644 --- a/src/products/components/ProductCreatePage/ProductCreatePage.tsx +++ b/src/products/components/ProductCreatePage/ProductCreatePage.tsx @@ -19,7 +19,6 @@ import { SearchCategories_search_edges_node } from "@saleor/searches/types/Searc import { SearchCollections_search_edges_node } from "@saleor/searches/types/SearchCollections"; import { SearchProductTypes_search_edges_node } from "@saleor/searches/types/SearchProductTypes"; import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/SearchWarehouses"; -import { ContentState, convertToRaw } from "draft-js"; import React from "react"; import { useIntl } from "react-intl"; @@ -86,12 +85,6 @@ export const ProductCreatePage: React.FC = ({ const intl = useIntl(); const localizeDate = useDateLocalize(); - // Ensures that it will not change after component rerenders, because it - // generates different block keys and it causes editor to lose its content. - const initialDescription = React.useRef( - convertToRaw(ContentState.createFromText("")) - ); - // Display values const [selectedCategory, setSelectedCategory] = useStateFromProps( initial?.category || "" @@ -144,8 +137,8 @@ export const ProductCreatePage: React.FC = ({ data={data} disabled={disabled} errors={errors} - initialDescription={initialDescription.current} onChange={change} + onDescriptionChange={handlers.changeDescription} /> {data.attributes.length > 0 && ( @@ -167,6 +160,7 @@ export const ProductCreatePage: React.FC = ({ weightUnit={weightUnit} onChange={change} /> + & - Record< - "changeStock" | "selectAttribute" | "selectAttributeMultiple", - FormsetChange - > & - Record<"addStock" | "deleteStock", (id: string) => void>; +interface ProductCreateHandlers + extends Record< + | "changeMetadata" + | "selectCategory" + | "selectCollection" + | "selectProductType" + | "selectTaxRate", + FormChange + >, + Record< + "changeStock" | "selectAttribute" | "selectAttributeMultiple", + FormsetChange + >, + Record<"addStock" | "deleteStock", (id: string) => void> { + changeDescription: RichTextEditorChange; +} export interface UseProductCreateFormResult { change: FormChange; data: ProductCreateData; @@ -106,7 +111,7 @@ const defaultInitialFormData: ProductCreateFormData & changeTaxCode: false, chargeTaxes: false, collections: [], - description: {} as any, + description: null, isAvailable: false, isAvailableForPurchase: false, isPublished: false, @@ -117,7 +122,7 @@ const defaultInitialFormData: ProductCreateFormData & publicationDate: "", seoDescription: "", seoTitle: "", - sku: null, + sku: "", slug: "", stockQuantity: null, taxCode: null, @@ -152,6 +157,10 @@ function useProductCreateForm( const [productType, setProductType] = useStateFromProps( initialProductType || null ); + const [description, changeDescription] = useRichText({ + initial: null, + triggerChange + }); const { makeChangeHandler: makeMetadataChangeHandler @@ -211,19 +220,21 @@ function useProductCreateForm( ); const changeMetadata = makeMetadataChangeHandler(handleChange); - const data: ProductCreateData = { + const getData = (): ProductCreateData => ({ ...form.data, attributes: attributes.data, + description: description.current, productType, stocks: stocks.data - }; - const submit = () => onSubmit(data); + }); + const submit = () => onSubmit(getData()); return { change: handleChange, - data, + data: getData(), handlers: { addStock: handleStockAdd, + changeDescription, changeMetadata, changeStock: handleStockChange, deleteStock: handleStockDelete, diff --git a/src/products/components/ProductDetailsForm/ProductDetailsForm.tsx b/src/products/components/ProductDetailsForm/ProductDetailsForm.tsx index aac585345..a533cf97c 100644 --- a/src/products/components/ProductDetailsForm/ProductDetailsForm.tsx +++ b/src/products/components/ProductDetailsForm/ProductDetailsForm.tsx @@ -1,27 +1,27 @@ +import { OutputData } from "@editorjs/editorjs"; import Card from "@material-ui/core/Card"; import CardContent from "@material-ui/core/CardContent"; import TextField from "@material-ui/core/TextField"; import CardTitle from "@saleor/components/CardTitle"; import FormSpacer from "@saleor/components/FormSpacer"; -import RichTextEditor from "@saleor/components/RichTextEditor"; +import RichTextEditor, { + RichTextEditorChange +} from "@saleor/components/RichTextEditor"; import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment"; import { commonMessages } from "@saleor/intl"; import { getFormErrors, getProductErrorMessage } from "@saleor/utils/errors"; -import { RawDraftContentState } from "draft-js"; import React from "react"; import { useIntl } from "react-intl"; interface ProductDetailsFormProps { data: { - description: RawDraftContentState; + description: OutputData; name: string; }; disabled?: boolean; errors: ProductErrorFragment[]; - // Draftail isn't controlled - it needs only initial input - // because it's autosaving on its own. - // Ref https://github.com/mirumee/saleor/issues/4470 - initialDescription: RawDraftContentState; + + onDescriptionChange: RichTextEditorChange; onChange(event: any); } @@ -29,7 +29,7 @@ export const ProductDetailsForm: React.FC = ({ data, disabled, errors, - initialDescription, + onDescriptionChange, onChange }) => { const intl = useIntl(); @@ -57,13 +57,13 @@ export const ProductDetailsForm: React.FC = ({ /> diff --git a/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx b/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx index 3e99347e2..4d51ad9cd 100644 --- a/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx +++ b/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx @@ -1,3 +1,4 @@ +import { OutputData } from "@editorjs/editorjs"; import AppHeader from "@saleor/components/AppHeader"; import AvailabilityCard from "@saleor/components/AvailabilityCard"; import CardSpacer from "@saleor/components/CardSpacer"; @@ -19,7 +20,6 @@ import { maybe } from "@saleor/misc"; import { SearchCategories_search_edges_node } from "@saleor/searches/types/SearchCategories"; import { SearchCollections_search_edges_node } from "@saleor/searches/types/SearchCollections"; import { FetchMoreProps, ListActions, ReorderAction } from "@saleor/types"; -import { convertFromRaw, RawDraftContentState } from "draft-js"; import React from "react"; import { useIntl } from "react-intl"; @@ -75,11 +75,12 @@ export interface ProductUpdatePageProps extends ListActions { } export interface ProductUpdatePageSubmitData extends ProductUpdatePageFormData { + addStocks: ProductStockInput[]; attributes: ProductAttributeInput[]; collections: string[]; - addStocks: ProductStockInput[]; - updateStocks: ProductStockInput[]; + description: OutputData; removeStocks: string[]; + updateStocks: ProductStockInput[]; } export const ProductUpdatePage: React.FC = ({ @@ -135,10 +136,6 @@ export const ProductUpdatePage: React.FC = ({ product?.taxType.description ); - const initialDescription = maybe(() => - JSON.parse(product.descriptionJson) - ); - const categories = getChoices(categoryChoiceList); const collections = getChoices(collectionChoiceList); const currency = product?.variants[0]?.price.currency; @@ -175,7 +172,7 @@ export const ProductUpdatePage: React.FC = ({ data={data} disabled={disabled} errors={errors} - initialDescription={initialDescription} + onDescriptionChange={handlers.changeDescription} onChange={change} /> @@ -262,11 +259,7 @@ export const ProductUpdatePage: React.FC = ({ title={data.seoTitle} titlePlaceholder={data.name} description={data.seoDescription} - descriptionPlaceholder={maybe(() => - convertFromRaw(data.description) - .getPlainText() - .slice(0, 300) - )} + descriptionPlaceholder={""} // TODO: cast description to string slug={data.slug} slugPlaceholder={data.name} loading={disabled} diff --git a/src/products/components/ProductUpdatePage/form.tsx b/src/products/components/ProductUpdatePage/form.tsx index 009b8b27a..21e59cd0e 100644 --- a/src/products/components/ProductUpdatePage/form.tsx +++ b/src/products/components/ProductUpdatePage/form.tsx @@ -1,5 +1,7 @@ +import { OutputData } from "@editorjs/editorjs"; 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 useForm, { FormChange, SubmitPromise } from "@saleor/hooks/useForm"; import useFormset, { @@ -20,8 +22,9 @@ import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/Searc import handleFormSubmit from "@saleor/utils/handlers/handleFormSubmit"; import createMultiAutocompleteSelectHandler from "@saleor/utils/handlers/multiAutocompleteSelectChangeHandler"; import createSingleAutocompleteSelectHandler from "@saleor/utils/handlers/singleAutocompleteSelectChangeHandler"; +import getMetadata from "@saleor/utils/metadata/getMetadata"; import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; -import { RawDraftContentState } from "draft-js"; +import useRichText from "@saleor/utils/richText/useRichText"; import { diff } from "fast-array-diff"; import React from "react"; @@ -35,7 +38,6 @@ export interface ProductUpdateFormData extends MetadataFormData { changeTaxCode: boolean; chargeTaxes: boolean; collections: string[]; - description: RawDraftContentState; isAvailable: boolean; isAvailableForPurchase: boolean; isPublished: boolean; @@ -52,27 +54,36 @@ export interface ProductUpdateFormData extends MetadataFormData { } export interface ProductUpdateData extends ProductUpdateFormData { attributes: ProductAttributeInput[]; + description: OutputData; stocks: ProductStockInput[]; } export interface ProductUpdateSubmitData extends ProductUpdateFormData { attributes: ProductAttributeInput[]; collections: string[]; + description: OutputData; addStocks: ProductStockInput[]; updateStocks: ProductStockInput[]; removeStocks: string[]; } -type ProductUpdateHandlers = Record< - "changeMetadata" | "selectCategory" | "selectCollection" | "selectTaxRate", - FormChange -> & - Record< - "changeStock" | "selectAttribute" | "selectAttributeMultiple", - FormsetChange - > & - Record<"addStock" | "deleteStock", (id: string) => void>; +interface ProductUpdateHandlers + extends Record< + | "changeMetadata" + | "selectCategory" + | "selectCollection" + | "selectTaxRate", + FormChange + >, + Record< + "changeStock" | "selectAttribute" | "selectAttributeMultiple", + FormsetChange + >, + Record<"addStock" | "deleteStock", (id: string) => void> { + changeDescription: RichTextEditorChange; +} export interface UseProductUpdateFormResult { change: FormChange; + data: ProductUpdateData; handlers: ProductUpdateHandlers; hasChanged: boolean; @@ -133,15 +144,6 @@ const getStocksData = ( }; }; -const getMetadata = ( - data: ProductUpdateFormData, - isMetadataModified: boolean, - isPrivateMetadataModified: boolean -) => ({ - metadata: isMetadataModified ? data.metadata : undefined, - privateMetadata: isPrivateMetadataModified ? data.privateMetadata : undefined -}); - function useProductUpdateForm( product: ProductDetails_product, onSubmit: (data: ProductUpdateSubmitData) => SubmitPromise, @@ -155,6 +157,10 @@ function useProductUpdateForm( ); const attributes = useFormset(getAttributeInputFromProduct(product)); const stocks = useFormset(getStockInputFromProduct(product)); + const [description, changeDescription] = useRichText({ + initial: product?.descriptionJson, + triggerChange + }); const { isMetadataModified, @@ -213,24 +219,28 @@ function useProductUpdateForm( const data: ProductUpdateData = { ...form.data, attributes: attributes.data, + description: description.current, stocks: stocks.data }; - const submitData: ProductUpdateSubmitData = { + // Need to make it function to always have description.current up to date + const getSubmitData = (): ProductUpdateSubmitData => ({ ...data, ...getAvailabilityData(data), ...getStocksData(product, stocks.data), ...getMetadata(data, isMetadataModified, isPrivateMetadataModified), addStocks: [], - attributes: attributes.data - }; + attributes: attributes.data, + description: description.current + }); - const submit = () => handleFormSubmit(submitData, onSubmit, setChanged); + const submit = () => handleFormSubmit(getSubmitData(), onSubmit, setChanged); return { change: handleChange, data, handlers: { addStock: handleStockAdd, + changeDescription, changeMetadata, changeStock: handleStockChange, deleteStock: handleStockDelete, diff --git a/src/products/components/ProductVariantPage/form.tsx b/src/products/components/ProductVariantPage/form.tsx index 9d9fc8203..b667c9cb4 100644 --- a/src/products/components/ProductVariantPage/form.tsx +++ b/src/products/components/ProductVariantPage/form.tsx @@ -11,6 +11,7 @@ import { } from "@saleor/products/utils/data"; import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/SearchWarehouses"; import { mapMetadataItemToInput } from "@saleor/utils/maps"; +import getMetadata from "@saleor/utils/metadata/getMetadata"; import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; import { diff } from "fast-array-diff"; import React from "react"; @@ -117,11 +118,6 @@ function useProductVariantUpdateForm( stocks.remove(id); }; - const metadata = isMetadataModified ? form.data.metadata : undefined; - const privateMetadata = isPrivateMetadataModified - ? form.data.privateMetadata - : undefined; - const dataStocks = stocks.data.map(stock => stock.id); const variantStocks = variant?.stocks.map(stock => stock.warehouse.id) || []; const stockDiff = diff(variantStocks, dataStocks); @@ -140,10 +136,9 @@ function useProductVariantUpdateForm( }; const submitData: ProductVariantUpdateSubmitData = { ...form.data, + ...getMetadata(form.data, isMetadataModified, isPrivateMetadataModified), addStocks, attributes: attributes.data, - metadata, - privateMetadata, removeStocks: stockDiff.removed, updateStocks }; diff --git a/src/products/fixtures.ts b/src/products/fixtures.ts index 928217fe1..734843360 100644 --- a/src/products/fixtures.ts +++ b/src/products/fixtures.ts @@ -5,11 +5,13 @@ import { } from "@saleor/types/globalTypes"; import { warehouseList } from "@saleor/warehouses/fixtures"; -import { content } from "../storybook/stories/components/RichTextEditor"; +import * as richTextEditorFixtures from "../components/RichTextEditor/fixtures.json"; import { ProductDetails_product } from "./types/ProductDetails"; import { ProductList_products_edges_node } from "./types/ProductList"; import { ProductVariantCreateData_product } from "./types/ProductVariantCreateData"; +const content = richTextEditorFixtures.richTextEditor; + export const product: ( placeholderImage: string ) => ProductDetails_product & diff --git a/src/products/utils/data.ts b/src/products/utils/data.ts index f581aecac..6bc8bc719 100644 --- a/src/products/utils/data.ts +++ b/src/products/utils/data.ts @@ -12,7 +12,6 @@ import { import { SearchProductTypes_search_edges_node_productAttributes } from "@saleor/searches/types/SearchProductTypes"; import { StockInput } from "@saleor/types/globalTypes"; import { mapMetadataItemToInput } from "@saleor/utils/maps"; -import { RawDraftContentState } from "draft-js"; import { ProductAttributeInput } from "../components/ProductAttributes"; import { ProductStockInput } from "../components/ProductStocks"; @@ -175,7 +174,6 @@ export interface ProductUpdatePageFormData extends MetadataFormData { changeTaxCode: boolean; chargeTaxes: boolean; collections: string[]; - description: RawDraftContentState; isAvailable: boolean; isAvailableForPurchase: boolean; isPublished: boolean; @@ -205,7 +203,6 @@ export function getProductUpdatePageFormData( () => product.collections.map(collection => collection.id), [] ), - description: maybe(() => JSON.parse(product.descriptionJson)), isAvailable: !!product?.isAvailable, isAvailableForPurchase: !!product?.isAvailableForPurchase, isPublished: maybe(() => product.isPublished, false), diff --git a/src/storybook/__snapshots__/Stories.test.ts.snap b/src/storybook/__snapshots__/Stories.test.ts.snap index de4af41f4..60d02047d 100644 --- a/src/storybook/__snapshots__/Stories.test.ts.snap +++ b/src/storybook/__snapshots__/Stories.test.ts.snap @@ -7655,520 +7655,96 @@ exports[`Storyshots Generics / Rich text editor default 1`] = ` class="MuiCardContent-root-id" >
-
-
- Content -
-
- -
-
-
-
-
-
- - - bold - - -
-
-
-
- - - italic - - -
-
-
-
- - - strikethrough - - -
-
-

-
- - - h1 - - -
-

-

-
- - - h2 - - -
-

-

-
- - - h3 - - -
-

-
-
- - - blockquote - - -
-
-
    -
  • -
    - - - ul - - -
    -
  • -
-
    -
  1. -
    - - - ol - - -
    -
  2. -
-
-
- - - link - - -
-
-
-
-
-
-
-
+ Content + +
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit +

+
+
+
+
+`; + +exports[`Storyshots Generics / Rich text editor disabled 1`] = ` +
+
+
+
+ +
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit +

+
+
+
+
+`; + +exports[`Storyshots Generics / Rich text editor error 1`] = ` +
+
+
+
+ +
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit +

@@ -31726,316 +31302,22 @@ exports[`Storyshots Views / Categories / Create category When loading 1`] = ` class="FormSpacer-spacer-id" />
-
-
- Category Description -
-
- -
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
+ Category Description + +
+

@@ -32309,316 +31591,22 @@ exports[`Storyshots Views / Categories / Create category default 1`] = ` class="FormSpacer-spacer-id" />
-
-
- Category Description -
-
- -
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
+ Category Description + +
+

@@ -32897,321 +31885,24 @@ exports[`Storyshots Views / Categories / Create category form errors 1`] = ` class="FormSpacer-spacer-id" />
-
-
- Category Description -
-
- -
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
+ Category Description +
+

This field is required -

+

@@ -33484,520 +32175,22 @@ exports[`Storyshots Views / Categories / Update category default 1`] = ` class="FormSpacer-spacer-id" />
-
-
- Category Description -
-
- -
-
-
-
-
-
- - - bold - - -
-
-
-
- - - italic - - -
-
-
-
- - - strikethrough - - -
-
-

-
- - - h1 - - -
-

-

-
- - - h2 - - -
-

-

-
- - - h3 - - -
-

-
-
- - - blockquote - - -
-
-
    -
  • -
    - - - ul - - -
    -
  • -
-
    -
  1. -
    - - - ol - - -
    -
  2. -
-
-
- - - link - - -
-
-
-
-
-
-
-
+ Category Description + +
+

@@ -34216,195 +32409,13 @@ Ctrl + K" class="CardTitle-hr-id" /> - - - - - - - - - - - -