Merge pull request #800 from mirumee/SALEOR-916-editor-js

Replace Draftail with EditorJS
This commit is contained in:
Dominik Żegleń 2020-11-06 12:28:00 +01:00 committed by GitHub
commit 807fcc670d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 2379 additions and 17342 deletions

View file

@ -1785,28 +1785,6 @@
"src_dot_components_dot_RadioGroupField_dot_4205644805": { "src_dot_components_dot_RadioGroupField_dot_4205644805": {
"string": "No results found" "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": { "src_dot_components_dot_RowNumberSelect_dot_1154361791": {
"string": "No of Rows:" "string": "No of Rows:"
}, },

49
package-lock.json generated
View file

@ -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": { "@emotion/cache": {
"version": "10.0.19", "version": "10.0.19",
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.19.tgz", "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.19.tgz",
@ -8778,6 +8807,16 @@
"urlgrey": "0.4.4" "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": { "coffeescript": {
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/coffeescript/-/coffeescript-2.5.1.tgz", "resolved": "https://registry.npmjs.org/coffeescript/-/coffeescript-2.5.1.tgz",
@ -10306,6 +10345,16 @@
"safer-buffer": "^2.1.0" "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": { "ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",

View file

@ -17,6 +17,11 @@
"npm": ">=6.11.0" "npm": ">=6.11.0"
}, },
"dependencies": { "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/core": "^4.5.1",
"@material-ui/icons": "^4.5.1", "@material-ui/icons": "^4.5.1",
"@material-ui/styles": "^4.5.2", "@material-ui/styles": "^4.5.2",
@ -36,6 +41,8 @@
"draft-js": "^0.10.5", "draft-js": "^0.10.5",
"draftail": "^1.2.1", "draftail": "^1.2.1",
"draftjs-to-html": "^0.9.1", "draftjs-to-html": "^0.9.1",
"editorjs-inline-tool": "^0.4.0",
"editorjs-undo": "^0.1.4",
"fast-array-diff": "^0.2.0", "fast-array-diff": "^0.2.0",
"fuzzaldrin": "^2.1.0", "fuzzaldrin": "^2.1.0",
"graphql": "^14.4.2", "graphql": "^14.4.2",

View file

@ -1,12 +1,19 @@
import CssBaseline from "@material-ui/core/CssBaseline"; 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"; import React from "react";
const styles = createStyles({ const styles = createStyles((theme: Theme) => ({
"@global": { "@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, { const Baseline = withStyles(styles, {
name: "Baseline" name: "Baseline"

View file

@ -13,7 +13,7 @@ import React from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { CategoryDetails_category_backgroundImage } from "../../types/CategoryDetails"; import { CategoryDetails_category_backgroundImage } from "../../types/CategoryDetails";
import { FormData } from "../CategoryUpdatePage"; import { CategoryUpdateData } from "../CategoryUpdatePage/form";
const useStyles = makeStyles( const useStyles = makeStyles(
theme => ({ theme => ({
@ -42,7 +42,7 @@ const useStyles = makeStyles(
); );
export interface CategoryBackgroundProps { export interface CategoryBackgroundProps {
data: FormData; data: CategoryUpdateData;
image: CategoryDetails_category_backgroundImage; image: CategoryDetails_category_backgroundImage;
onChange: (event: React.ChangeEvent<any>) => void; onChange: (event: React.ChangeEvent<any>) => void;
onImageDelete: () => void; onImageDelete: () => void;

View file

@ -2,43 +2,23 @@ import AppHeader from "@saleor/components/AppHeader";
import { CardSpacer } from "@saleor/components/CardSpacer"; import { CardSpacer } from "@saleor/components/CardSpacer";
import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton";
import Container from "@saleor/components/Container"; import Container from "@saleor/components/Container";
import Form from "@saleor/components/Form"; import Metadata from "@saleor/components/Metadata";
import Metadata, { MetadataFormData } from "@saleor/components/Metadata";
import PageHeader from "@saleor/components/PageHeader"; import PageHeader from "@saleor/components/PageHeader";
import SaveButtonBar from "@saleor/components/SaveButtonBar"; import SaveButtonBar from "@saleor/components/SaveButtonBar";
import SeoForm from "@saleor/components/SeoForm"; import SeoForm from "@saleor/components/SeoForm";
import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment"; import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment";
import { sectionNames } from "@saleor/intl"; import { sectionNames } from "@saleor/intl";
import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger";
import { ContentState, convertToRaw, RawDraftContentState } from "draft-js";
import React from "react"; import React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import CategoryDetailsForm from "../../components/CategoryDetailsForm"; import CategoryDetailsForm from "../../components/CategoryDetailsForm";
import CategoryCreateForm, { CategoryCreateData } from "./form";
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: ""
};
export interface CategoryCreatePageProps { export interface CategoryCreatePageProps {
errors: ProductErrorFragment[]; errors: ProductErrorFragment[];
disabled: boolean; disabled: boolean;
saveButtonBarState: ConfirmButtonTransitionState; saveButtonBarState: ConfirmButtonTransitionState;
onSubmit(data: FormData); onSubmit(data: CategoryCreateData);
onBack(); onBack();
} }
@ -50,63 +30,57 @@ export const CategoryCreatePage: React.FC<CategoryCreatePageProps> = ({
saveButtonBarState saveButtonBarState
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const {
makeChangeHandler: makeMetadataChangeHandler
} = useMetadataChangeTrigger();
return ( return (
<Form onSubmit={onSubmit} initial={initialData} confirmLeave> <CategoryCreateForm onSubmit={onSubmit}>
{({ data, change, submit, hasChanged }) => { {({ data, change, handlers, submit, hasChanged }) => (
const changeMetadata = makeMetadataChangeHandler(change); <Container>
<AppHeader onBack={onBack}>
return ( {intl.formatMessage(sectionNames.categories)}
<Container> </AppHeader>
<AppHeader onBack={onBack}> <PageHeader
{intl.formatMessage(sectionNames.categories)} title={intl.formatMessage({
</AppHeader> defaultMessage: "Create New Category",
<PageHeader description: "page header"
title={intl.formatMessage({ })}
defaultMessage: "Create New Category", />
description: "page header" <div>
})} <CategoryDetailsForm
data={data}
disabled={disabled}
errors={errors}
onChange={change}
onDescriptionChange={handlers.changeDescription}
/> />
<div> <CardSpacer />
<CategoryDetailsForm <SeoForm
disabled={disabled} allowEmptySlug={true}
data={data} helperText={intl.formatMessage({
onChange={change} defaultMessage:
errors={errors} "Add search engine title and description to make this category easier to find"
/> })}
<CardSpacer /> slug={data.slug}
<SeoForm slugPlaceholder={data.name}
allowEmptySlug={true} title={data.seoTitle}
helperText={intl.formatMessage({ titlePlaceholder={data.name}
defaultMessage: description={data.seoDescription}
"Add search engine title and description to make this category easier to find" descriptionPlaceholder={data.name}
})} loading={disabled}
slug={data.slug} onChange={change}
slugPlaceholder={data.name} disabled={disabled}
title={data.seoTitle} />
titlePlaceholder={data.name} <CardSpacer />
description={data.seoDescription} <Metadata data={data} onChange={handlers.changeMetadata} />
descriptionPlaceholder={data.name} <SaveButtonBar
loading={disabled} onCancel={onBack}
onChange={change} onSave={submit}
disabled={disabled} state={saveButtonBarState}
/> disabled={disabled || !hasChanged}
<CardSpacer /> />
<Metadata data={data} onChange={changeMetadata} /> </div>
<SaveButtonBar </Container>
onCancel={onBack} )}
onSave={submit} </CategoryCreateForm>
state={saveButtonBarState}
disabled={disabled || !hasChanged}
/>
</div>
</Container>
);
}}
</Form>
); );
}; };
CategoryCreatePage.displayName = "CategoryCreatePage"; CategoryCreatePage.displayName = "CategoryCreatePage";

View file

@ -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<boolean>;
}
export interface CategoryCreateFormProps {
children: (props: UseCategoryCreateFormResult) => React.ReactNode;
onSubmit: (data: CategoryCreateData) => Promise<any[]>;
}
function useCategoryCreateForm(
onSubmit: (data: CategoryCreateData) => Promise<any[]>
): UseCategoryCreateFormResult {
const [changed, setChanged] = React.useState(false);
const triggerChange = () => setChanged(true);
const form = useForm<CategoryCreateFormData>({
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<CategoryCreateFormProps> = ({
children,
onSubmit
}) => {
const props = useCategoryCreateForm(onSubmit);
return <form onSubmit={props.submit}>{children(props)}</form>;
};
CategoryCreateForm.displayName = "CategoryCreateForm";
export default CategoryCreateForm;

View file

@ -1,35 +1,34 @@
import { OutputData } from "@editorjs/editorjs";
import Card from "@material-ui/core/Card"; import Card from "@material-ui/core/Card";
import CardContent from "@material-ui/core/CardContent"; import CardContent from "@material-ui/core/CardContent";
import TextField from "@material-ui/core/TextField"; import TextField from "@material-ui/core/TextField";
import CardTitle from "@saleor/components/CardTitle"; import CardTitle from "@saleor/components/CardTitle";
import FormSpacer from "@saleor/components/FormSpacer"; 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 { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment";
import { commonMessages } from "@saleor/intl"; import { commonMessages } from "@saleor/intl";
import { getFormErrors, getProductErrorMessage } from "@saleor/utils/errors"; import { getFormErrors, getProductErrorMessage } from "@saleor/utils/errors";
import { RawDraftContentState } from "draft-js";
import React from "react"; import React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { maybe } from "../../../misc";
import { CategoryDetails_category } from "../../types/CategoryDetails";
interface CategoryDetailsFormProps { interface CategoryDetailsFormProps {
category?: CategoryDetails_category;
data: { data: {
name: string; name: string;
description: RawDraftContentState; description: OutputData;
}; };
disabled: boolean; disabled: boolean;
errors: ProductErrorFragment[]; errors: ProductErrorFragment[];
onChange: (event: React.ChangeEvent<any>) => void; onChange: (event: React.ChangeEvent<any>) => void;
onDescriptionChange: RichTextEditorChange;
} }
export const CategoryDetailsForm: React.FC<CategoryDetailsFormProps> = ({ export const CategoryDetailsForm: React.FC<CategoryDetailsFormProps> = ({
category,
disabled, disabled,
data, data,
onChange, onChange,
onDescriptionChange,
errors errors
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@ -58,15 +57,15 @@ export const CategoryDetailsForm: React.FC<CategoryDetailsFormProps> = ({
</div> </div>
<FormSpacer /> <FormSpacer />
<RichTextEditor <RichTextEditor
data={data.description}
disabled={disabled} disabled={disabled}
error={!!formErrors.descriptionJson} error={!!formErrors.descriptionJson}
helperText={getProductErrorMessage(formErrors.descriptionJson, intl)} helperText={getProductErrorMessage(formErrors.descriptionJson, intl)}
label={intl.formatMessage({ label={intl.formatMessage({
defaultMessage: "Category Description" defaultMessage: "Category Description"
})} })}
initial={maybe(() => JSON.parse(category.descriptionJson))}
name="description" name="description"
onChange={onChange} onChange={onDescriptionChange}
/> />
</CardContent> </CardContent>
</Card> </Card>

View file

@ -5,9 +5,7 @@ import { CardSpacer } from "@saleor/components/CardSpacer";
import CardTitle from "@saleor/components/CardTitle"; import CardTitle from "@saleor/components/CardTitle";
import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton";
import Container from "@saleor/components/Container"; import Container from "@saleor/components/Container";
import Form from "@saleor/components/Form";
import Metadata from "@saleor/components/Metadata/Metadata"; import Metadata from "@saleor/components/Metadata/Metadata";
import { MetadataFormData } from "@saleor/components/Metadata/types";
import PageHeader from "@saleor/components/PageHeader"; import PageHeader from "@saleor/components/PageHeader";
import SaveButtonBar from "@saleor/components/SaveButtonBar"; import SaveButtonBar from "@saleor/components/SaveButtonBar";
import SeoForm from "@saleor/components/SeoForm"; 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 { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment";
import { SubmitPromise } from "@saleor/hooks/useForm"; import { SubmitPromise } from "@saleor/hooks/useForm";
import { sectionNames } from "@saleor/intl"; 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 React from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
@ -32,15 +27,7 @@ import {
} from "../../types/CategoryDetails"; } from "../../types/CategoryDetails";
import CategoryBackground from "../CategoryBackground"; import CategoryBackground from "../CategoryBackground";
import CategoryProducts from "../CategoryProducts"; import CategoryProducts from "../CategoryProducts";
import CategoryUpdateForm, { CategoryUpdateData } from "./form";
export interface FormData extends MetadataFormData {
backgroundImageAlt: string;
description: RawDraftContentState;
name: string;
slug: string;
seoTitle: string;
seoDescription: string;
}
export enum CategoryPageTab { export enum CategoryPageTab {
categories = "categories", categories = "categories",
@ -62,7 +49,7 @@ export interface CategoryUpdatePageProps
}; };
saveButtonBarState: ConfirmButtonTransitionState; saveButtonBarState: ConfirmButtonTransitionState;
onImageDelete: () => void; onImageDelete: () => void;
onSubmit: (data: FormData) => SubmitPromise; onSubmit: (data: CategoryUpdateData) => SubmitPromise;
onImageUpload(file: File); onImageUpload(file: File);
onNextPage(); onNextPage();
onPreviousPage(); onPreviousPage();
@ -106,180 +93,136 @@ export const CategoryUpdatePage: React.FC<CategoryUpdatePageProps> = ({
toggleAll toggleAll
}: CategoryUpdatePageProps) => { }: CategoryUpdatePageProps) => {
const intl = useIntl(); 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 ( return (
<Form onSubmit={handleSubmit} initial={initialData} confirmLeave> <CategoryUpdateForm category={category} onSubmit={onSubmit}>
{({ data, change, submit, hasChanged }) => { {({ data, change, handlers, submit, hasChanged }) => (
const changeMetadata = makeMetadataChangeHandler(change); <Container>
<AppHeader onBack={onBack}>
return ( {intl.formatMessage(sectionNames.categories)}
<Container> </AppHeader>
<AppHeader onBack={onBack}> <PageHeader title={category?.name} />
{intl.formatMessage(sectionNames.categories)} <CategoryDetailsForm
</AppHeader> data={data}
<PageHeader title={category ? category.name : undefined} /> disabled={disabled}
<CategoryDetailsForm errors={errors}
category={category} onChange={change}
data={data} onDescriptionChange={handlers.changeDescription}
disabled={disabled} />
errors={errors} <CardSpacer />
onChange={change} <CategoryBackground
/> data={data}
<CardSpacer /> onImageUpload={onImageUpload}
<CategoryBackground onImageDelete={onImageDelete}
data={data} image={maybe(() => category.backgroundImage)}
onImageUpload={onImageUpload} onChange={change}
onImageDelete={onImageDelete} />
image={maybe(() => category.backgroundImage)} <CardSpacer />
onChange={change} <SeoForm
/> helperText={intl.formatMessage({
<CardSpacer /> defaultMessage:
<SeoForm "Add search engine title and description to make this category easier to find"
helperText={intl.formatMessage({ })}
defaultMessage: errors={errors}
"Add search engine title and description to make this category easier to find" title={data.seoTitle}
})} titlePlaceholder={data.name}
errors={errors} description={data.seoDescription}
title={data.seoTitle} descriptionPlaceholder={data.name}
titlePlaceholder={data.name} slug={data.slug}
description={data.seoDescription} slugPlaceholder={data.name}
descriptionPlaceholder={data.name} loading={!category}
slug={data.slug} onChange={change}
slugPlaceholder={data.name} disabled={disabled}
loading={!category} />
onChange={change} <CardSpacer />
disabled={disabled} <Metadata data={data} onChange={handlers.changeMetadata} />
/> <CardSpacer />
<CardSpacer /> <TabContainer>
<Metadata data={data} onChange={changeMetadata} /> <CategoriesTab
<CardSpacer /> isActive={currentTab === CategoryPageTab.categories}
<TabContainer> changeTab={changeTab}
<CategoriesTab >
isActive={currentTab === CategoryPageTab.categories} <FormattedMessage
changeTab={changeTab} defaultMessage="Subcategories"
> description="number of subcategories in category"
<FormattedMessage />
defaultMessage="Subcategories" </CategoriesTab>
description="number of subcategories in category" <ProductsTab
/> isActive={currentTab === CategoryPageTab.products}
</CategoriesTab> changeTab={changeTab}
<ProductsTab >
isActive={currentTab === CategoryPageTab.products} <FormattedMessage
changeTab={changeTab} defaultMessage="Products"
> description="number of products in category"
<FormattedMessage />
defaultMessage="Products" </ProductsTab>
description="number of products in category" </TabContainer>
/> <CardSpacer />
</ProductsTab> {currentTab === CategoryPageTab.categories && (
</TabContainer> <Card>
<CardSpacer /> <CardTitle
{currentTab === CategoryPageTab.categories && ( title={intl.formatMessage({
<Card> defaultMessage: "All Subcategories",
<CardTitle description: "section header"
title={intl.formatMessage({ })}
defaultMessage: "All Subcategories", toolbar={
description: "section header" <Button
})} color="primary"
toolbar={ variant="text"
<Button onClick={onAddCategory}
color="primary" >
variant="text" <FormattedMessage
onClick={onAddCategory} defaultMessage="Create subcategory"
> description="button"
<FormattedMessage />
defaultMessage="Create subcategory" </Button>
description="button" }
/> />
</Button> <CategoryList
} categories={subcategories}
/>
<CategoryList
categories={subcategories}
disabled={disabled}
isChecked={isChecked}
isRoot={false}
pageInfo={pageInfo}
selected={selected}
sort={undefined}
toggle={toggle}
toggleAll={toggleAll}
toolbar={subcategoryListToolbar}
onNextPage={onNextPage}
onPreviousPage={onPreviousPage}
onRowClick={onCategoryClick}
onSort={() => undefined}
/>
</Card>
)}
{currentTab === CategoryPageTab.products && (
<CategoryProducts
categoryName={maybe(() => category.name)}
products={products}
disabled={disabled} disabled={disabled}
isChecked={isChecked}
isRoot={false}
pageInfo={pageInfo} pageInfo={pageInfo}
onNextPage={onNextPage} selected={selected}
onPreviousPage={onPreviousPage} sort={undefined}
onRowClick={onProductClick}
onAdd={onAddProduct}
toggle={toggle} toggle={toggle}
toggleAll={toggleAll} toggleAll={toggleAll}
selected={selected} toolbar={subcategoryListToolbar}
isChecked={isChecked} onNextPage={onNextPage}
toolbar={productListToolbar} onPreviousPage={onPreviousPage}
onRowClick={onCategoryClick}
onSort={() => undefined}
/> />
)} </Card>
<SaveButtonBar )}
onCancel={onBack} {currentTab === CategoryPageTab.products && (
onDelete={onDelete} <CategoryProducts
onSave={submit} categoryName={category?.name}
state={saveButtonBarState} products={products}
disabled={disabled || !hasChanged} disabled={disabled}
pageInfo={pageInfo}
onNextPage={onNextPage}
onPreviousPage={onPreviousPage}
onRowClick={onProductClick}
onAdd={onAddProduct}
toggle={toggle}
toggleAll={toggleAll}
selected={selected}
isChecked={isChecked}
toolbar={productListToolbar}
/> />
</Container> )}
); <SaveButtonBar
}} onCancel={onBack}
</Form> onDelete={onDelete}
onSave={submit}
state={saveButtonBarState}
disabled={disabled || !hasChanged}
/>
</Container>
)}
</CategoryUpdateForm>
); );
}; };
CategoryUpdatePage.displayName = "CategoryUpdatePage"; CategoryUpdatePage.displayName = "CategoryUpdatePage";

View file

@ -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<boolean>;
}
export interface CategoryUpdateFormProps {
children: (props: UseCategoryUpdateFormResult) => React.ReactNode;
category: CategoryDetails_category;
onSubmit: (data: CategoryUpdateData) => Promise<any[]>;
}
function useCategoryUpdateForm(
category: CategoryDetails_category,
onSubmit: (data: CategoryUpdateData) => Promise<any[]>
): UseCategoryUpdateFormResult {
const [changed, setChanged] = React.useState(false);
const triggerChange = () => setChanged(true);
const form = useForm<CategoryUpdateFormData>({
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<CategoryUpdateFormProps> = ({
children,
category,
onSubmit
}) => {
const props = useCategoryUpdateForm(category, onSubmit);
return <form onSubmit={props.submit}>{children(props)}</form>;
};
CategoryUpdateForm.displayName = "CategoryUpdateForm";
export default CategoryUpdateForm;

View file

@ -1,8 +1,10 @@
import { CategoryFragment } from "@saleor/fragments/types/CategoryFragment"; 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"; import { CategoryDetails_category } from "./types/CategoryDetails";
const content = richTextEditorFixtures.richTextEditor;
export const categories: CategoryFragment[] = [ export const categories: CategoryFragment[] = [
{ {
__typename: "Category", __typename: "Category",

View file

@ -9,7 +9,8 @@ import {
import React from "react"; import React from "react";
import { useIntl } from "react-intl"; 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 { useCategoryCreateMutation } from "../mutations";
import { CategoryCreate } from "../types/CategoryCreate"; import { CategoryCreate } from "../types/CategoryCreate";
import { categoryListUrl, categoryUrl } from "../urls"; import { categoryListUrl, categoryUrl } from "../urls";
@ -43,7 +44,7 @@ export const CategoryCreateView: React.FC<CategoryCreateViewProps> = ({
onCompleted: handleSuccess onCompleted: handleSuccess
}); });
const handleCreate = async (formData: FormData) => { const handleCreate = async (formData: CategoryCreateData) => {
const result = await createCategory({ const result = await createCategory({
variables: { variables: {
input: { input: {

View file

@ -27,9 +27,9 @@ import { productAddUrl, productUrl } from "../../products/urls";
import { CategoryInput } from "../../types/globalTypes"; import { CategoryInput } from "../../types/globalTypes";
import { import {
CategoryPageTab, CategoryPageTab,
CategoryUpdatePage, CategoryUpdatePage
FormData
} from "../components/CategoryUpdatePage/CategoryUpdatePage"; } from "../components/CategoryUpdatePage/CategoryUpdatePage";
import { CategoryUpdateData } from "../components/CategoryUpdatePage/form";
import { import {
useCategoryBulkDeleteMutation, useCategoryBulkDeleteMutation,
useCategoryDeleteMutation, useCategoryDeleteMutation,
@ -175,7 +175,7 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
params params
); );
const handleUpdate = async (formData: FormData) => { const handleUpdate = async (formData: CategoryUpdateData) => {
const result = await updateCategory({ const result = await updateCategory({
variables: { variables: {
id, id,

View file

@ -2,64 +2,31 @@ import AppHeader from "@saleor/components/AppHeader";
import { CardSpacer } from "@saleor/components/CardSpacer"; import { CardSpacer } from "@saleor/components/CardSpacer";
import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton";
import { Container } from "@saleor/components/Container"; import { Container } from "@saleor/components/Container";
import Form from "@saleor/components/Form";
import Grid from "@saleor/components/Grid"; 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 PageHeader from "@saleor/components/PageHeader";
import SaveButtonBar from "@saleor/components/SaveButtonBar"; import SaveButtonBar from "@saleor/components/SaveButtonBar";
import SeoForm from "@saleor/components/SeoForm"; import SeoForm from "@saleor/components/SeoForm";
import VisibilityCard from "@saleor/components/VisibilityCard"; import VisibilityCard from "@saleor/components/VisibilityCard";
import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment"; import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment";
import useDateLocalize from "@saleor/hooks/useDateLocalize"; import useDateLocalize from "@saleor/hooks/useDateLocalize";
import { SubmitPromise } from "@saleor/hooks/useForm";
import { sectionNames } from "@saleor/intl"; import { sectionNames } from "@saleor/intl";
import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger";
import { ContentState, convertToRaw, RawDraftContentState } from "draft-js";
import React from "react"; import React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import CollectionDetails from "../CollectionDetails/CollectionDetails"; import CollectionDetails from "../CollectionDetails/CollectionDetails";
import { CollectionImage } from "../CollectionImage/CollectionImage"; import { CollectionImage } from "../CollectionImage/CollectionImage";
import CollectionCreateForm, { CollectionCreateData } from "./form";
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;
}
export interface CollectionCreatePageProps { export interface CollectionCreatePageProps {
disabled: boolean; disabled: boolean;
errors: ProductErrorFragment[]; errors: ProductErrorFragment[];
saveButtonBarState: ConfirmButtonTransitionState; saveButtonBarState: ConfirmButtonTransitionState;
onBack: () => void; 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<CollectionCreatePageProps> = ({ const CollectionCreatePage: React.FC<CollectionCreatePageProps> = ({
disabled, disabled,
errors, errors,
@ -69,127 +36,121 @@ const CollectionCreatePage: React.FC<CollectionCreatePageProps> = ({
}: CollectionCreatePageProps) => { }: CollectionCreatePageProps) => {
const intl = useIntl(); const intl = useIntl();
const localizeDate = useDateLocalize(); const localizeDate = useDateLocalize();
const {
makeChangeHandler: makeMetadataChangeHandler
} = useMetadataChangeTrigger();
return ( return (
<Form initial={initialForm} onSubmit={onSubmit}> <CollectionCreateForm onSubmit={onSubmit}>
{({ change, data, hasChanged, submit }) => { {({ change, data, handlers, hasChanged, submit }) => (
const changeMetadata = makeMetadataChangeHandler(change); <Container>
<AppHeader onBack={onBack}>
return ( {intl.formatMessage(sectionNames.collections)}
<Container> </AppHeader>
<AppHeader onBack={onBack}> <PageHeader
{intl.formatMessage(sectionNames.collections)} title={intl.formatMessage({
</AppHeader> defaultMessage: "Add Collection",
<PageHeader description: "page header"
title={intl.formatMessage({ })}
defaultMessage: "Add Collection", />
description: "page header" <Grid>
})} <div>
/> <CollectionDetails
<Grid> data={data}
<div> disabled={disabled}
<CollectionDetails errors={errors}
data={data} onChange={change}
disabled={disabled} onDescriptionChange={handlers.changeDescription}
errors={errors} />
onChange={change} <CardSpacer />
/> <CollectionImage
<CardSpacer /> image={
<CollectionImage data.backgroundImage.url
image={ ? {
data.backgroundImage.url __typename: "Image",
? { alt: data.backgroundImageAlt,
__typename: "Image", url: data.backgroundImage.url
alt: data.backgroundImageAlt,
url: data.backgroundImage.url
}
: null
}
onImageDelete={() =>
change({
target: {
name: "backgroundImage",
value: {
url: null,
value: null
}
} }
} as any) : null
} }
onImageUpload={file => onImageDelete={() =>
change({ change({
target: { target: {
name: "backgroundImage", name: "backgroundImage",
value: { value: {
url: URL.createObjectURL(file), url: null,
value: file value: null
}
} }
} as any) }
} } as any)
onChange={change} }
data={data} onImageUpload={file =>
/> change({
<CardSpacer /> target: {
<SeoForm name: "backgroundImage",
allowEmptySlug={true} value: {
description={data.seoDescription} url: URL.createObjectURL(file),
disabled={disabled} value: file
descriptionPlaceholder=""
helperText={intl.formatMessage({
defaultMessage:
"Add search engine title and description to make this collection easier to find"
})}
slug={data.slug}
slugPlaceholder={data.name}
title={data.seoTitle}
titlePlaceholder={data.name}
onChange={change}
/>
<CardSpacer />
<Metadata data={data} onChange={changeMetadata} />
</div>
<div>
<VisibilityCard
data={data}
errors={errors}
disabled={disabled}
messages={{
hiddenLabel: intl.formatMessage({
defaultMessage: "Hidden",
description: "collection label"
}),
hiddenSecondLabel: intl.formatMessage(
{
defaultMessage: "will be visible from {date}",
description: "collection"
},
{
date: localizeDate(data.publicationDate, "L")
} }
), }
visibleLabel: intl.formatMessage({ } as any)
defaultMessage: "Visible", }
description: "collection label" onChange={change}
}) data={data}
}} />
onChange={change} <CardSpacer />
/> <SeoForm
</div> allowEmptySlug={true}
</Grid> description={data.seoDescription}
<SaveButtonBar disabled={disabled}
state={saveButtonBarState} descriptionPlaceholder=""
disabled={disabled || !hasChanged} helperText={intl.formatMessage({
onCancel={onBack} defaultMessage:
onSave={submit} "Add search engine title and description to make this collection easier to find"
/> })}
</Container> slug={data.slug}
); slugPlaceholder={data.name}
}} title={data.seoTitle}
</Form> titlePlaceholder={data.name}
onChange={change}
/>
<CardSpacer />
<Metadata data={data} onChange={handlers.changeMetadata} />
</div>
<div>
<VisibilityCard
data={data}
errors={errors}
disabled={disabled}
messages={{
hiddenLabel: intl.formatMessage({
defaultMessage: "Hidden",
description: "collection label"
}),
hiddenSecondLabel: intl.formatMessage(
{
defaultMessage: "will be visible from {date}",
description: "collection"
},
{
date: localizeDate(data.publicationDate, "L")
}
),
visibleLabel: intl.formatMessage({
defaultMessage: "Visible",
description: "collection label"
})
}}
onChange={change}
/>
</div>
</Grid>
<SaveButtonBar
state={saveButtonBarState}
disabled={disabled || !hasChanged}
onCancel={onBack}
onSave={submit}
/>
</Container>
)}
</CollectionCreateForm>
); );
}; };
CollectionCreatePage.displayName = "CollectionCreatePage"; CollectionCreatePage.displayName = "CollectionCreatePage";

View file

@ -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<boolean>;
}
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<CollectionCreateFormData>({
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<CollectionCreateFormProps> = ({
children,
onSubmit
}) => {
const props = useCollectionCreateForm(onSubmit);
return <form onSubmit={props.submit}>{children(props)}</form>;
};
CollectionCreateForm.displayName = "CollectionCreateForm";
export default CollectionCreateForm;

View file

@ -1,35 +1,34 @@
import { OutputData } from "@editorjs/editorjs";
import Card from "@material-ui/core/Card"; import Card from "@material-ui/core/Card";
import CardContent from "@material-ui/core/CardContent"; import CardContent from "@material-ui/core/CardContent";
import TextField from "@material-ui/core/TextField"; import TextField from "@material-ui/core/TextField";
import CardTitle from "@saleor/components/CardTitle"; import CardTitle from "@saleor/components/CardTitle";
import FormSpacer from "@saleor/components/FormSpacer"; 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 { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment";
import { commonMessages } from "@saleor/intl"; import { commonMessages } from "@saleor/intl";
import { maybe } from "@saleor/misc";
import { getFormErrors, getProductErrorMessage } from "@saleor/utils/errors"; import { getFormErrors, getProductErrorMessage } from "@saleor/utils/errors";
import { RawDraftContentState } from "draft-js";
import React from "react"; import React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { CollectionDetails_collection } from "../../types/CollectionDetails";
export interface CollectionDetailsProps { export interface CollectionDetailsProps {
collection?: CollectionDetails_collection;
data: { data: {
description: RawDraftContentState; description: OutputData;
name: string; name: string;
}; };
disabled: boolean; disabled: boolean;
errors: ProductErrorFragment[]; errors: ProductErrorFragment[];
onChange: (event: React.ChangeEvent<any>) => void; onChange: (event: React.ChangeEvent<any>) => void;
onDescriptionChange: RichTextEditorChange;
} }
const CollectionDetails: React.FC<CollectionDetailsProps> = ({ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
collection,
disabled, disabled,
data, data,
onChange, onChange,
onDescriptionChange,
errors errors
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@ -57,13 +56,13 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
/> />
<FormSpacer /> <FormSpacer />
<RichTextEditor <RichTextEditor
data={data.description}
error={!!formErrors.descriptionJson} error={!!formErrors.descriptionJson}
helperText={getProductErrorMessage(formErrors.descriptionJson, intl)} helperText={getProductErrorMessage(formErrors.descriptionJson, intl)}
initial={maybe(() => JSON.parse(collection.descriptionJson))}
label={intl.formatMessage(commonMessages.description)} label={intl.formatMessage(commonMessages.description)}
name="description" name="description"
disabled={disabled} disabled={disabled}
onChange={onChange} onChange={onDescriptionChange}
/> />
</CardContent> </CardContent>
</Card> </Card>

View file

@ -3,22 +3,18 @@ import { CardSpacer } from "@saleor/components/CardSpacer";
import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton";
import { Container } from "@saleor/components/Container"; import { Container } from "@saleor/components/Container";
import ControlledCheckbox from "@saleor/components/ControlledCheckbox"; import ControlledCheckbox from "@saleor/components/ControlledCheckbox";
import Form from "@saleor/components/Form";
import FormSpacer from "@saleor/components/FormSpacer"; import FormSpacer from "@saleor/components/FormSpacer";
import Grid from "@saleor/components/Grid"; import Grid from "@saleor/components/Grid";
import Hr from "@saleor/components/Hr"; import Hr from "@saleor/components/Hr";
import Metadata from "@saleor/components/Metadata/Metadata"; import Metadata from "@saleor/components/Metadata/Metadata";
import { MetadataFormData } from "@saleor/components/Metadata/types";
import PageHeader from "@saleor/components/PageHeader"; import PageHeader from "@saleor/components/PageHeader";
import SaveButtonBar from "@saleor/components/SaveButtonBar"; import SaveButtonBar from "@saleor/components/SaveButtonBar";
import SeoForm from "@saleor/components/SeoForm"; import SeoForm from "@saleor/components/SeoForm";
import VisibilityCard from "@saleor/components/VisibilityCard"; import VisibilityCard from "@saleor/components/VisibilityCard";
import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment"; import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment";
import useDateLocalize from "@saleor/hooks/useDateLocalize"; import useDateLocalize from "@saleor/hooks/useDateLocalize";
import { SubmitPromise } from "@saleor/hooks/useForm";
import { sectionNames } from "@saleor/intl"; 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 React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
@ -28,18 +24,7 @@ import { CollectionDetails_collection } from "../../types/CollectionDetails";
import CollectionDetails from "../CollectionDetails/CollectionDetails"; import CollectionDetails from "../CollectionDetails/CollectionDetails";
import { CollectionImage } from "../CollectionImage/CollectionImage"; import { CollectionImage } from "../CollectionImage/CollectionImage";
import CollectionProducts from "../CollectionProducts/CollectionProducts"; import CollectionProducts from "../CollectionProducts/CollectionProducts";
import CollectionUpdateForm, { CollectionUpdateData } from "./form";
export interface CollectionDetailsPageFormData extends MetadataFormData {
backgroundImageAlt: string;
description: RawDraftContentState;
name: string;
slug: string;
publicationDate: string;
seoDescription: string;
seoTitle: string;
isFeatured: boolean;
isPublished: boolean;
}
export interface CollectionDetailsPageProps extends PageListProps, ListActions { export interface CollectionDetailsPageProps extends PageListProps, ListActions {
collection: CollectionDetails_collection; collection: CollectionDetails_collection;
@ -51,7 +36,7 @@ export interface CollectionDetailsPageProps extends PageListProps, ListActions {
onImageDelete: () => void; onImageDelete: () => void;
onImageUpload: (file: File) => void; onImageUpload: (file: File) => void;
onProductUnassign: (id: string, event: React.MouseEvent<any>) => void; onProductUnassign: (id: string, event: React.MouseEvent<any>) => void;
onSubmit: (data: CollectionDetailsPageFormData) => void; onSubmit: (data: CollectionUpdateData) => SubmitPromise;
} }
const CollectionDetailsPage: React.FC<CollectionDetailsPageProps> = ({ const CollectionDetailsPage: React.FC<CollectionDetailsPageProps> = ({
@ -69,150 +54,113 @@ const CollectionDetailsPage: React.FC<CollectionDetailsPageProps> = ({
}: CollectionDetailsPageProps) => { }: CollectionDetailsPageProps) => {
const intl = useIntl(); const intl = useIntl();
const localizeDate = useDateLocalize(); 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 ( return (
<Form <CollectionUpdateForm
initial={{ collection={collection}
backgroundImageAlt: maybe(() => collection.backgroundImage.alt, ""), isFeatured={isFeatured}
description: maybe(() => JSON.parse(collection.descriptionJson)), onSubmit={onSubmit}
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 }) => { {({ change, data, handlers, hasChanged, submit }) => (
const changeMetadata = makeMetadataChangeHandler(change); <Container>
<AppHeader onBack={onBack}>
return ( {intl.formatMessage(sectionNames.collections)}
<Container> </AppHeader>
<AppHeader onBack={onBack}> <PageHeader title={maybe(() => collection.name)} />
{intl.formatMessage(sectionNames.collections)} <Grid>
</AppHeader> <div>
<PageHeader title={maybe(() => collection.name)} /> <CollectionDetails
<Grid> data={data}
disabled={disabled}
errors={errors}
onChange={change}
onDescriptionChange={handlers.changeDescription}
/>
<CardSpacer />
<CollectionImage
data={data}
image={maybe(() => collection.backgroundImage)}
onImageDelete={onImageDelete}
onImageUpload={onImageUpload}
onChange={change}
/>
<CardSpacer />
<Metadata data={data} onChange={handlers.changeMetadata} />
<CardSpacer />
<CollectionProducts
disabled={disabled}
collection={collection}
{...collectionProductsProps}
/>
<CardSpacer />
<SeoForm
description={data.seoDescription}
disabled={disabled}
descriptionPlaceholder=""
helperText={intl.formatMessage({
defaultMessage:
"Add search engine title and description to make this collection easier to find"
})}
errors={errors}
slug={data.slug}
slugPlaceholder={data.name}
title={data.seoTitle}
titlePlaceholder={maybe(() => collection.name)}
onChange={change}
/>
</div>
<div>
<div> <div>
<CollectionDetails <VisibilityCard
collection={collection}
data={data} data={data}
disabled={disabled}
errors={errors} errors={errors}
messages={{
hiddenLabel: intl.formatMessage({
defaultMessage: "Hidden",
description: "collection label"
}),
hiddenSecondLabel: intl.formatMessage(
{
defaultMessage: "will be visible from {date}",
description: "collection"
},
{
date: localizeDate(data.publicationDate, "L")
}
),
visibleLabel: intl.formatMessage({
defaultMessage: "Visible",
description: "collection label"
})
}}
onChange={change} onChange={change}
/> >
<CardSpacer /> <FormSpacer />
<CollectionImage <Hr />
data={data} <ControlledCheckbox
image={maybe(() => collection.backgroundImage)} name={"isFeatured" as keyof CollectionUpdateData}
onImageDelete={onImageDelete} label={intl.formatMessage({
onImageUpload={onImageUpload} defaultMessage: "Feature on Homepage",
onChange={change} description: "switch button"
/> })}
<CardSpacer /> checked={data.isFeatured}
<Metadata data={data} onChange={changeMetadata} />
<CardSpacer />
<CollectionProducts
disabled={disabled}
collection={collection}
{...collectionProductsProps}
/>
<CardSpacer />
<SeoForm
description={data.seoDescription}
disabled={disabled}
descriptionPlaceholder=""
helperText={intl.formatMessage({
defaultMessage:
"Add search engine title and description to make this collection easier to find"
})}
errors={errors}
slug={data.slug}
slugPlaceholder={data.name}
title={data.seoTitle}
titlePlaceholder={maybe(() => collection.name)}
onChange={change}
/>
</div>
<div>
<div>
<VisibilityCard
data={data}
errors={errors}
messages={{
hiddenLabel: intl.formatMessage({
defaultMessage: "Hidden",
description: "collection label"
}),
hiddenSecondLabel: intl.formatMessage(
{
defaultMessage: "will be visible from {date}",
description: "collection"
},
{
date: localizeDate(data.publicationDate, "L")
}
),
visibleLabel: intl.formatMessage({
defaultMessage: "Visible",
description: "collection label"
})
}}
onChange={change} onChange={change}
> disabled={disabled}
<FormSpacer /> />
<Hr /> </VisibilityCard>
<ControlledCheckbox
name={"isFeatured" as keyof CollectionDetailsPageFormData}
label={intl.formatMessage({
defaultMessage: "Feature on Homepage",
description: "switch button"
})}
checked={data.isFeatured}
onChange={change}
disabled={disabled}
/>
</VisibilityCard>
</div>
</div> </div>
</Grid> </div>
<SaveButtonBar </Grid>
state={saveButtonBarState} <SaveButtonBar
disabled={disabled || !hasChanged} state={saveButtonBarState}
onCancel={onBack} disabled={disabled || !hasChanged}
onDelete={onCollectionRemove} onCancel={onBack}
onSave={submit} onDelete={onCollectionRemove}
/> onSave={submit}
</Container> />
); </Container>
}} )}
</Form> </CollectionUpdateForm>
); );
}; };
CollectionDetailsPage.displayName = "CollectionDetailsPage"; CollectionDetailsPage.displayName = "CollectionDetailsPage";

View file

@ -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<boolean>;
}
export interface CollectionUpdateFormProps {
children: (props: UseCollectionUpdateFormResult) => React.ReactNode;
collection: CollectionDetails_collection;
isFeatured: boolean;
onSubmit: (data: CollectionUpdateData) => Promise<any[]>;
}
function useCollectionUpdateForm(
collection: CollectionDetails_collection,
onSubmit: (data: CollectionUpdateData) => Promise<any[]>,
isFeatured: boolean
): UseCollectionUpdateFormResult {
const [changed, setChanged] = React.useState(false);
const triggerChange = () => setChanged(true);
const form = useForm<CollectionUpdateFormData>({
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<CollectionUpdateFormProps> = ({
children,
collection,
isFeatured,
onSubmit
}) => {
const props = useCollectionUpdateForm(collection, onSubmit, isFeatured);
return <form onSubmit={props.submit}>{children(props)}</form>;
};
CollectionUpdateForm.displayName = "CollectionUpdateForm";
export default CollectionUpdateForm;

View file

@ -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 { CollectionDetails_collection } from "./types/CollectionDetails";
import { CollectionList_collections_edges_node } from "./types/CollectionList"; import { CollectionList_collections_edges_node } from "./types/CollectionList";
const content = richTextEditorFixtures.richTextEditor;
export const collections: CollectionList_collections_edges_node[] = [ export const collections: CollectionList_collections_edges_node[] = [
{ {
__typename: "Collection", __typename: "Collection",

View file

@ -2,6 +2,7 @@ import { WindowTitle } from "@saleor/components/WindowTitle";
import useNavigator from "@saleor/hooks/useNavigator"; import useNavigator from "@saleor/hooks/useNavigator";
import useNotifier from "@saleor/hooks/useNotifier"; import useNotifier from "@saleor/hooks/useNotifier";
import { commonMessages } from "@saleor/intl"; import { commonMessages } from "@saleor/intl";
import getPublicationData from "@saleor/utils/data/getPublicationData";
import createMetadataCreateHandler from "@saleor/utils/handlers/metadataCreateHandler"; import createMetadataCreateHandler from "@saleor/utils/handlers/metadataCreateHandler";
import { import {
useMetadataUpdate, useMetadataUpdate,
@ -11,9 +12,8 @@ import React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { CollectionCreateInput } from "../../types/globalTypes"; import { CollectionCreateInput } from "../../types/globalTypes";
import CollectionCreatePage, { import CollectionCreatePage from "../components/CollectionCreatePage/CollectionCreatePage";
CollectionCreatePageFormData import { CollectionCreateData } from "../components/CollectionCreatePage/form";
} from "../components/CollectionCreatePage/CollectionCreatePage";
import { useCollectionCreateMutation } from "../mutations"; import { useCollectionCreateMutation } from "../mutations";
import { collectionListUrl, collectionUrl } from "../urls"; import { collectionListUrl, collectionUrl } from "../urls";
@ -47,15 +47,7 @@ export const CollectionCreate: React.FC = () => {
} }
}); });
const getPublicationData = ({ const handleCreate = async (formData: CollectionCreateData) => {
publicationDate,
isPublished
}: CollectionCreatePageFormData) => ({
isPublished: !!publicationDate || isPublished,
publicationDate: publicationDate || null
});
const handleCreate = async (formData: CollectionCreatePageFormData) => {
const result = await createCollection({ const result = await createCollection({
variables: { variables: {
input: { input: {

View file

@ -25,9 +25,8 @@ import { FormattedMessage, useIntl } from "react-intl";
import { getMutationState, maybe } from "../../misc"; import { getMutationState, maybe } from "../../misc";
import { productUrl } from "../../products/urls"; import { productUrl } from "../../products/urls";
import { CollectionInput } from "../../types/globalTypes"; import { CollectionInput } from "../../types/globalTypes";
import CollectionDetailsPage, { import CollectionDetailsPage from "../components/CollectionDetailsPage/CollectionDetailsPage";
CollectionDetailsPageFormData import { CollectionUpdateData } from "../components/CollectionDetailsPage/form";
} from "../components/CollectionDetailsPage/CollectionDetailsPage";
import { import {
useCollectionAssignProductMutation, useCollectionAssignProductMutation,
useCollectionRemoveMutation, useCollectionRemoveMutation,
@ -168,9 +167,7 @@ export const CollectionDetails: React.FC<CollectionDetailsProps> = ({
return <NotFoundPage onBack={handleBack} />; return <NotFoundPage onBack={handleBack} />;
} }
const handleUpdate = async ( const handleUpdate = async (formData: CollectionUpdateData) => {
formData: CollectionDetailsPageFormData
) => {
const input: CollectionInput = { const input: CollectionInput = {
backgroundImageAlt: formData.backgroundImageAlt, backgroundImageAlt: formData.backgroundImageAlt,
descriptionJson: JSON.stringify(formData.description), descriptionJson: JSON.stringify(formData.description),

View file

@ -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<ImageEntityProps> = props => {
const { contentState, entityKey, onEdit, onRemove } = props;
const classes = useStyles(props);
const [isOpened, setOpenStatus] = React.useState(false);
const anchor = React.useRef<HTMLDivElement>();
const disable = () => setOpenStatus(false);
const toggle = () => setOpenStatus(!isOpened);
return (
<>
<div className={classes.anchor} ref={anchor}>
<Popper
open={isOpened}
anchorEl={anchor.current}
transition
disablePortal
placement="bottom"
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
style={{
transformOrigin: placement
}}
>
<Paper className={classes.root}>
<ClickAwayListener onClickAway={disable} mouseEvent="onClick">
<div className={classes.container}>
<Button
onClick={() => {
disable();
onEdit(entityKey);
}}
color="primary"
>
<FormattedMessage
defaultMessage="Replace"
description="replace image, button"
/>
</Button>
<IconButton onClick={() => onRemove(entityKey)}>
<DeleteIcon color="primary" />
</IconButton>
</div>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</div>
<img
className={classes.image}
src={contentState.getEntity(entityKey).getData().href}
onClick={toggle}
/>
</>
);
};
export default ImageEntity;

View file

@ -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<ImageSourceProps> = ({
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 (
<Dialog onClose={onClose} open={true} fullWidth maxWidth="sm">
<Form
initial={{ href: initial }}
onSubmit={({ href }) => handleSubmit(href)}
>
{({ data, change, submit }) => (
<>
<DialogTitle>
<FormattedMessage
defaultMessage="Add Image Link"
description="dialog header"
/>
</DialogTitle>
<DialogContent>
<TextField
name="href"
fullWidth
label={intl.formatMessage({
defaultMessage: "Image URL"
})}
value={data.href}
onChange={change}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>
<FormattedMessage {...buttonMessages.cancel} />
</Button>
<Button onClick={submit} color="primary" variant="contained">
<FormattedMessage {...buttonMessages.save} />
</Button>
</DialogActions>
</>
)}
</Form>
</Dialog>
);
};
export default ImageSource;

View file

@ -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<LinkEntityProps> = props => {
const { children, contentState, entityKey, onEdit, onRemove } = props;
const classes = useStyles(props);
const [isOpened, setOpenStatus] = React.useState(false);
const anchor = React.useRef<HTMLDivElement>();
const disable = () => setOpenStatus(false);
const toggle = () => setOpenStatus(!isOpened);
return (
<>
<div className={classes.anchor} ref={anchor}>
<Popper
className={classes.popover}
open={isOpened}
anchorEl={anchor.current}
transition
disablePortal
placement="bottom"
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
style={{
transformOrigin: placement
}}
>
<Paper className={classes.root}>
<ClickAwayListener onClickAway={disable} mouseEvent="onClick">
<div className={classes.container}>
<Typography className={classes.inline} variant="body1">
{contentState.getEntity(entityKey).getData().url}
</Typography>
<span className={classes.separator} />
<Button
onClick={() => {
disable();
onEdit(entityKey);
}}
color="primary"
>
<FormattedMessage {...buttonMessages.edit} />
</Button>
<IconButton onClick={() => onRemove(entityKey)}>
<DeleteIcon color="primary" />
</IconButton>
</div>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</div>
<Link
href={contentState.getEntity(entityKey).getData().url}
onClick={toggle}
>
{children}
</Link>
</>
);
};
export default LinkEntity;

View file

@ -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<LinkSourceProps> = ({
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 (
<Dialog onClose={onClose} open={true} fullWidth maxWidth="sm">
<Form
initial={{ url: initial }}
onSubmit={({ url }) => handleSubmit(url)}
>
{({ data, change, submit }) => (
<>
<DialogTitle>
<FormattedMessage
defaultMessage="Add or Edit Link"
description="button"
/>
</DialogTitle>
<DialogContent>
<TextField
name="url"
fullWidth
label={intl.formatMessage({
defaultMessage: "URL Linked"
})}
value={data.url}
onChange={change}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>
<FormattedMessage {...buttonMessages.cancel} />
</Button>
<Button onClick={submit} color="primary" variant="contained">
<FormattedMessage {...buttonMessages.save} />
</Button>
</DialogActions>
</>
)}
</Form>
</Dialog>
);
};
export default LinkSource;

View file

@ -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", () => <RichTextEditor {...props} />)
.add("disabled", () => <RichTextEditor {...props} disabled={true} />)
.add("error", () => <RichTextEditor {...props} error={true} />);

View file

@ -1,316 +1,99 @@
import { makeStyles } from "@material-ui/core/styles"; import EditorJS, { OutputData } from "@editorjs/editorjs";
import { fade } from "@material-ui/core/styles/colorManipulator"; import FormControl from "@material-ui/core/FormControl";
import Typography from "@material-ui/core/Typography"; import FormHelperText from "@material-ui/core/FormHelperText";
import { CreateCSSProperties } from "@material-ui/styles/withStyles"; import InputLabel from "@material-ui/core/InputLabel";
import { ChangeEvent } from "@saleor/hooks/useForm";
import classNames from "classnames"; import classNames from "classnames";
import { RawDraftContentState } from "draft-js"; import Undo from "editorjs-undo";
import {
BLOCK_TYPE,
DraftailEditor,
ENTITY_TYPE,
INLINE_STYLE
} from "draftail";
import isEqual from "lodash-es/isEqual";
import React from "react"; import React from "react";
import ErrorBoundary from "react-error-boundary";
import { FormattedMessage } from "react-intl";
import BoldIcon from "../../icons/BoldIcon"; import { RichTextEditorContentProps, tools } from "./RichTextEditorContent";
import HeaderOne from "../../icons/HeaderOne"; import useStyles from "./styles";
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";
export interface RichTextEditorProps { export type RichTextEditorChange = (data: OutputData) => void;
export interface RichTextEditorProps extends RichTextEditorContentProps {
disabled: boolean; disabled: boolean;
error: boolean; error: boolean;
helperText: string; helperText: string;
initial?: RawDraftContentState;
label: string; label: string;
name: string; name: string;
scroll?: boolean; onChange: RichTextEditorChange;
onChange: (event: React.ChangeEvent<any>) => void;
} }
const useStyles = makeStyles( const RichTextEditor: React.FC<RichTextEditorProps> = ({
theme => { data,
const editorContainer: CreateCSSProperties = { disabled,
border: `1px ${theme.palette.divider} solid`, error,
borderRadius: 4, helperText,
padding: "27px 12px 10px", label,
position: "relative", name,
transition: theme.transitions.duration.shortest + "ms" onChange,
}; onReady
}) => {
const classes = useStyles({});
return { const [isFocused, setFocus] = React.useState(false);
editorContainer, const editor = React.useRef<EditorJS>();
error: { const editorContainer = React.useRef<HTMLDivElement>();
color: theme.palette.error.main React.useEffect(
}, () => {
helperText: { if (data) {
marginTop: theme.spacing(0.75) editor.current = new EditorJS({
}, data,
input: { holder: editorContainer.current,
position: "relative" onChange: async api => {
}, const savedData = await api.saver.save();
label: { onChange(savedData);
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"
}, },
"&-root": { onReady: () => {
...theme.typography.body1 const undo = new Undo({ editor });
} undo.initialize(data);
}, if (onReady) {
"& .Draftail": { onReady();
"&-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"
} }
}, },
"&-block": { readOnly: disabled,
"&--blockquote": { tools
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
} }
};
},
{ name: "RichTextEditor" }
);
function handleSave( return editor.current?.destroy;
value: any, },
initial: any, // Rerender editor only if changed from undefined to defined state
name: string, [data === undefined]
onChange: (event: ChangeEvent) => void );
) { React.useEffect(() => editor.current?.destroy, []);
if (value && !isEqual(value, initial)) { React.useEffect(() => {
onChange({ if (editor.current?.readOnly) {
target: { editor.current.readOnly.toggle(disabled);
name, }
value }, [disabled]);
}
});
}
}
const RichTextEditor: React.FC<RichTextEditorProps> = props => {
const { error, helperText, initial, label, name, scroll, onChange } = props;
const classes = useStyles(props);
return ( return (
<div <FormControl
className={classNames({ data-test="richTextEditor"
[classes.error]: error, data-test-id={name}
[classes.root]: true, disabled={disabled}
[classes.scroll]: scroll error={error}
})} fullWidth
variant="outlined"
> >
<div className={classes.input}> <InputLabel focused={true} shrink={true}>
<Typography className={classes.label} variant="caption" color="primary"> {label}
{label} </InputLabel>
</Typography> <div
<ErrorBoundary className={classNames(classes.editor, classes.root, {
FallbackComponent={() => ( [classes.rootActive]: isFocused,
<div className={classes.editorContainer}> [classes.rootDisabled]: disabled,
<Typography color="error"> [classes.rootError]: error
<FormattedMessage })}
defaultMessage="Invalid content" ref={editorContainer}
description="rich text error" onFocus={() => setFocus(true)}
/> onBlur={() => setFocus(false)}
</Typography> />
</div> <FormHelperText>{helperText}</FormHelperText>
)} </FormControl>
>
<DraftailEditor
key={JSON.stringify(initial)}
rawContentState={
initial && Object.keys(initial).length > 0 ? initial : null
}
onSave={value => handleSave(value, initial, name, onChange)}
blockTypes={[
{
icon: <HeaderOne />,
type: BLOCK_TYPE.HEADER_ONE
},
{ icon: <HeaderTwo />, type: BLOCK_TYPE.HEADER_TWO },
{ icon: <HeaderThree />, type: BLOCK_TYPE.HEADER_THREE },
{ icon: <QuotationIcon />, type: BLOCK_TYPE.BLOCKQUOTE },
{
icon: <UnorderedListIcon />,
type: BLOCK_TYPE.UNORDERED_LIST_ITEM
},
{ icon: <OrderedListIcon />, type: BLOCK_TYPE.ORDERED_LIST_ITEM }
]}
inlineStyles={[
{
icon: <BoldIcon className={classes.smallIcon} />,
type: INLINE_STYLE.BOLD
},
{
icon: <ItalicIcon className={classes.smallIcon} />,
type: INLINE_STYLE.ITALIC
},
{
icon: <StrikethroughIcon />,
type: INLINE_STYLE.STRIKETHROUGH
}
]}
enableLineBreak
entityTypes={[
{
attributes: ["url"],
decorator: LinkEntity,
icon: <LinkIcon className={classes.linkIcon} />,
source: LinkSource,
type: ENTITY_TYPE.LINK
}
]}
/>
</ErrorBoundary>
</div>
{helperText && (
<Typography
className={classNames({
[classes.error]: error,
[classes.helperText]: true
})}
variant="caption"
>
{helperText}
</Typography>
)}
</div>
); );
}; };
RichTextEditor.displayName = "RichTextEditor"; RichTextEditor.displayName = "RichTextEditor";
RichTextEditor.defaultProps = {
scroll: true
};
export default RichTextEditor; export default RichTextEditor;

View file

@ -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<string, ToolConstructable | ToolSettings> = {
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<RichTextEditorContentProps> = ({
className,
data,
onReady
}) => {
const classes = useStyles({});
const editor = React.useRef<EditorJS>();
const editorContainer = React.useRef<HTMLDivElement>();
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 (
<div
className={classNames(classes.editor, className)}
ref={editorContainer}
/>
);
};
RichTextEditorContent.displayName = "RichTextEditorContent";
export default RichTextEditorContent;

View file

@ -0,0 +1,74 @@
{
"richTextEditor": {
"time": 1603898483525,
"blocks": [
{ "type": "header", "data": { "text": "Lorem ipsum&nbsp;", "level": 1 } },
{
"type": "paragraph",
"data": {
"text": "Dolor sit amet, consectetur adipiscing elit. <b>Sed iaculis urna et justo accumsan</b>, 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. <i>Donec quis aliquet massa.</i> 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.<br>",
"Nam quis nibh sed elit fermentum interdum non eget quam."
]
}
},
{
"type": "paragraph",
"data": {
"text": "Nulla sit amet cursus augue, in maximus tellus. <a href=\"http://lipsum.com\">Donec sit amet mollis neque</a>, 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. <s>Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.</s> 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<br>"
]
}
}
],
"version": "2.19.0"
}
}

View file

@ -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;

View file

@ -1,11 +1,3 @@
import createSvgIcon from "@material-ui/icons/utils/createSvgIcon"; const Strikethrough = `<svg height="32" width="32" viewBox="-4 -1 24 16"><path d="M6.53333 14H10.2667V11.2H6.53333V14ZM1.86667 0V2.8H6.53333V5.6H10.2667V2.8H14.9333V0H1.86667ZM0 9.33333H16.8V7.46667H0V9.33333Z"></path></svg>`;
import React from "react";
const HeaderOne = createSvgIcon( export default Strikethrough;
<>
<path d="M6.53333 14H10.2667V11.2H6.53333V14ZM1.86667 0V2.8H6.53333V5.6H10.2667V2.8H14.9333V0H1.86667ZM0 9.33333H16.8V7.46667H0V9.33333Z" />
</>,
"HeaderOne"
);
export default HeaderOne;

View file

@ -2,9 +2,8 @@ import AppHeader from "@saleor/components/AppHeader";
import CardSpacer from "@saleor/components/CardSpacer"; import CardSpacer from "@saleor/components/CardSpacer";
import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton";
import Container from "@saleor/components/Container"; import Container from "@saleor/components/Container";
import Form from "@saleor/components/Form";
import Grid from "@saleor/components/Grid"; 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 PageHeader from "@saleor/components/PageHeader";
import SaveButtonBar from "@saleor/components/SaveButtonBar"; import SaveButtonBar from "@saleor/components/SaveButtonBar";
import SeoForm from "@saleor/components/SeoForm"; import SeoForm from "@saleor/components/SeoForm";
@ -13,30 +12,12 @@ import { PageErrorFragment } from "@saleor/fragments/types/PageErrorFragment";
import useDateLocalize from "@saleor/hooks/useDateLocalize"; import useDateLocalize from "@saleor/hooks/useDateLocalize";
import { SubmitPromise } from "@saleor/hooks/useForm"; import { SubmitPromise } from "@saleor/hooks/useForm";
import { sectionNames } from "@saleor/intl"; 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 React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { maybe } from "../../../misc";
import { PageDetails_page } from "../../types/PageDetails"; import { PageDetails_page } from "../../types/PageDetails";
import PageInfo from "../PageInfo"; import PageInfo from "../PageInfo";
import PageForm, { PageData } from "./form";
export interface PageDetailsPageFormData extends MetadataFormData {
content: RawDraftContentState;
isPublished: boolean;
publicationDate: string;
seoDescription: string;
seoTitle: string;
slug: string;
title: string;
}
export interface PageDetailsPageProps { export interface PageDetailsPageProps {
disabled: boolean; disabled: boolean;
@ -46,7 +27,7 @@ export interface PageDetailsPageProps {
saveButtonBarState: ConfirmButtonTransitionState; saveButtonBarState: ConfirmButtonTransitionState;
onBack: () => void; onBack: () => void;
onRemove: () => void; onRemove: () => void;
onSubmit: (data: PageDetailsPageFormData) => SubmitPromise; onSubmit: (data: PageData) => SubmitPromise;
} }
const PageDetailsPage: React.FC<PageDetailsPageProps> = ({ const PageDetailsPage: React.FC<PageDetailsPageProps> = ({
@ -60,140 +41,94 @@ const PageDetailsPage: React.FC<PageDetailsPageProps> = ({
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const localizeDate = useDateLocalize(); const localizeDate = useDateLocalize();
const {
isMetadataModified,
isPrivateMetadataModified,
makeChangeHandler: makeMetadataChangeHandler
} = useMetadataChangeTrigger();
const pageExists = page !== null; 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 ( return (
<Form initial={initialForm} onSubmit={handleSubmit}> <PageForm page={page} onSubmit={onSubmit}>
{({ change, data, hasChanged, submit }) => { {({ change, data, handlers, hasChanged, submit }) => (
const changeMetadata = makeMetadataChangeHandler(change); <Container>
<AppHeader onBack={onBack}>
return ( {intl.formatMessage(sectionNames.pages)}
<Container> </AppHeader>
<AppHeader onBack={onBack}> <PageHeader
{intl.formatMessage(sectionNames.pages)} title={
</AppHeader> !pageExists
<PageHeader ? intl.formatMessage({
title={ defaultMessage: "Create Page",
!pageExists description: "page header"
? intl.formatMessage({ })
defaultMessage: "Create Page", : page?.title
description: "page header" }
}) />
: maybe(() => page.title) <Grid>
} <div>
/> <PageInfo
<Grid> data={data}
<div> disabled={disabled}
<PageInfo errors={errors}
data={data} onChange={change}
disabled={disabled} onContentChange={handlers.changeContent}
errors={errors} />
page={page} <CardSpacer />
onChange={change} <SeoForm
/> errors={errors}
<CardSpacer /> allowEmptySlug={!pageExists}
<SeoForm description={data.seoDescription}
errors={errors} disabled={disabled}
allowEmptySlug={!pageExists} descriptionPlaceholder={""} // TODO: Cast description to string and trim it
description={data.seoDescription} onChange={change}
disabled={disabled} slug={data.slug}
descriptionPlaceholder={maybe( slugPlaceholder={data.title}
() => title={data.seoTitle}
convertFromRaw(data.content) titlePlaceholder={data.title}
.getPlainText() helperText={intl.formatMessage({
.slice(0, 300), defaultMessage:
"" "Add search engine title and description to make this page easier to find"
)} })}
onChange={change} />
slug={data.slug} <CardSpacer />
slugPlaceholder={data.title} <Metadata data={data} onChange={handlers.changeMetadata} />
title={data.seoTitle} </div>
titlePlaceholder={data.title} <div>
helperText={intl.formatMessage({ <CardSpacer />
defaultMessage: <VisibilityCard
"Add search engine title and description to make this page easier to find" data={data}
})} errors={errors}
/> disabled={disabled}
<CardSpacer /> messages={{
<Metadata data={data} onChange={changeMetadata} /> hiddenLabel: intl.formatMessage({
</div> defaultMessage: "Hidden",
<div> description: "page label"
<CardSpacer /> }),
<VisibilityCard hiddenSecondLabel: intl.formatMessage(
data={data} {
errors={errors} defaultMessage: "will be visible from {date}",
disabled={disabled} description: "page"
messages={{ },
hiddenLabel: intl.formatMessage({ {
defaultMessage: "Hidden", date: localizeDate(data.publicationDate, "L")
description: "page label" }
}), ),
hiddenSecondLabel: intl.formatMessage( visibleLabel: intl.formatMessage({
{ defaultMessage: "Visible",
defaultMessage: "will be visible from {date}", description: "page label"
description: "page" })
}, }}
{ onChange={change}
date: localizeDate(data.publicationDate, "L") />
} </div>
), </Grid>
visibleLabel: intl.formatMessage({ <SaveButtonBar
defaultMessage: "Visible", disabled={disabled || !hasChanged}
description: "page label" state={saveButtonBarState}
}) onCancel={onBack}
}} onDelete={page === null ? undefined : onRemove}
onChange={change} onSave={submit}
/> />
</div> </Container>
</Grid> )}
<SaveButtonBar </PageForm>
disabled={disabled || !hasChanged}
state={saveButtonBarState}
onCancel={onBack}
onDelete={page === null ? undefined : onRemove}
onSave={submit}
/>
</Container>
);
}}
</Form>
); );
}; };
PageDetailsPage.displayName = "PageDetailsPage"; PageDetailsPage.displayName = "PageDetailsPage";

View file

@ -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<PageFormData>({
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<PageFormProps> = ({ children, page, onSubmit }) => {
const props = usePageForm(page, onSubmit);
return <form onSubmit={props.submit}>{children(props)}</form>;
};
PageForm.displayName = "PageForm";
export default PageForm;

View file

@ -4,7 +4,9 @@ import { makeStyles } from "@material-ui/core/styles";
import TextField from "@material-ui/core/TextField"; import TextField from "@material-ui/core/TextField";
import CardTitle from "@saleor/components/CardTitle"; import CardTitle from "@saleor/components/CardTitle";
import FormSpacer from "@saleor/components/FormSpacer"; 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 { PageErrorFragment } from "@saleor/fragments/types/PageErrorFragment";
import { commonMessages } from "@saleor/intl"; import { commonMessages } from "@saleor/intl";
import { getFormErrors } from "@saleor/utils/errors"; import { getFormErrors } from "@saleor/utils/errors";
@ -12,16 +14,14 @@ import getPageErrorMessage from "@saleor/utils/errors/page";
import React from "react"; import React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { maybe } from "../../../misc"; import { PageData } from "../PageDetailsPage/form";
import { PageDetails_page } from "../../types/PageDetails";
import { PageDetailsPageFormData } from "../PageDetailsPage";
export interface PageInfoProps { export interface PageInfoProps {
data: PageDetailsPageFormData; data: PageData;
disabled: boolean; disabled: boolean;
errors: PageErrorFragment[]; errors: PageErrorFragment[];
page: PageDetails_page;
onChange: (event: React.ChangeEvent<any>) => void; onChange: (event: React.ChangeEvent<any>) => void;
onContentChange: RichTextEditorChange;
} }
const useStyles = makeStyles( const useStyles = makeStyles(
@ -34,7 +34,7 @@ const useStyles = makeStyles(
); );
const PageInfo: React.FC<PageInfoProps> = props => { const PageInfo: React.FC<PageInfoProps> = props => {
const { data, disabled, errors, page, onChange } = props; const { data, disabled, errors, onChange, onContentChange } = props;
const classes = useStyles(props); const classes = useStyles(props);
const intl = useIntl(); const intl = useIntl();
@ -56,22 +56,22 @@ const PageInfo: React.FC<PageInfoProps> = props => {
defaultMessage: "Title", defaultMessage: "Title",
description: "page title" description: "page title"
})} })}
name={"title" as keyof PageDetailsPageFormData} name={"title" as keyof PageData}
value={data.title} value={data.title}
onChange={onChange} onChange={onChange}
/> />
<FormSpacer /> <FormSpacer />
<RichTextEditor <RichTextEditor
data={data.content}
disabled={disabled} disabled={disabled}
error={!!formErrors.contentJson} error={!!formErrors.contentJson}
helperText={getPageErrorMessage(formErrors.contentJson, intl)} helperText={getPageErrorMessage(formErrors.contentJson, intl)}
initial={maybe(() => JSON.parse(page.contentJson))}
label={intl.formatMessage({ label={intl.formatMessage({
defaultMessage: "Content", defaultMessage: "Content",
description: "page content" description: "page content"
})} })}
name={"content" as keyof PageDetailsPageFormData} name={"content" as keyof PageData}
onChange={onChange} onChange={onContentChange}
/> />
</CardContent> </CardContent>
</Card> </Card>

View file

@ -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 { PageDetails_page } from "./types/PageDetails";
import { PageList_pages_edges_node } from "./types/PageList"; import { PageList_pages_edges_node } from "./types/PageList";
const content = richTextEditorFixtures.richTextEditor;
export const pageList: PageList_pages_edges_node[] = [ export const pageList: PageList_pages_edges_node[] = [
{ {
__typename: "Page", __typename: "Page",

View file

@ -22,6 +22,7 @@ const pageCreate = gql`
pageCreate(input: $input) { pageCreate(input: $input) {
errors: pageErrors { errors: pageErrors {
...PageErrorFragment ...PageErrorFragment
message
} }
page { page {
...PageDetailsFragment ...PageDetailsFragment

View file

@ -9,9 +9,8 @@ import {
import React from "react"; import React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import PageDetailsPage, { import PageDetailsPage from "../components/PageDetailsPage";
PageDetailsPageFormData import { PageData } from "../components/PageDetailsPage/form";
} from "../components/PageDetailsPage";
import { TypedPageCreate } from "../mutations"; import { TypedPageCreate } from "../mutations";
import { PageCreate as PageCreateData } from "../types/PageCreate"; import { PageCreate as PageCreateData } from "../types/PageCreate";
import { pageListUrl, pageUrl } from "../urls"; import { pageListUrl, pageUrl } from "../urls";
@ -42,7 +41,7 @@ export const PageCreate: React.FC<PageCreateProps> = () => {
return ( return (
<TypedPageCreate onCompleted={handlePageCreate}> <TypedPageCreate onCompleted={handlePageCreate}>
{(pageCreate, pageCreateOpts) => { {(pageCreate, pageCreateOpts) => {
const handleCreate = async (formData: PageDetailsPageFormData) => { const handleCreate = async (formData: PageData) => {
const result = await pageCreate({ const result = await pageCreate({
variables: { variables: {
input: { input: {

View file

@ -14,9 +14,8 @@ import { FormattedMessage, useIntl } from "react-intl";
import { getStringOrPlaceholder, maybe } from "../../misc"; import { getStringOrPlaceholder, maybe } from "../../misc";
import { PageInput } from "../../types/globalTypes"; import { PageInput } from "../../types/globalTypes";
import PageDetailsPage, { import PageDetailsPage from "../components/PageDetailsPage";
PageDetailsPageFormData import { PageData } from "../components/PageDetailsPage/form";
} from "../components/PageDetailsPage";
import { TypedPageRemove, TypedPageUpdate } from "../mutations"; import { TypedPageRemove, TypedPageUpdate } from "../mutations";
import { TypedPageDetailsQuery } from "../queries"; import { TypedPageDetailsQuery } from "../queries";
import { PageRemove } from "../types/PageRemove"; import { PageRemove } from "../types/PageRemove";
@ -27,7 +26,7 @@ export interface PageDetailsProps {
params: PageUrlQueryParams; params: PageUrlQueryParams;
} }
const createPageInput = (data: PageDetailsPageFormData): PageInput => ({ const createPageInput = (data: PageData): PageInput => ({
contentJson: JSON.stringify(data.content), contentJson: JSON.stringify(data.content),
isPublished: data.isPublished, isPublished: data.isPublished,
publicationDate: data.publicationDate, publicationDate: data.publicationDate,
@ -63,7 +62,7 @@ export const PageDetails: React.FC<PageDetailsProps> = ({ id, params }) => {
{(pageUpdate, pageUpdateOpts) => ( {(pageUpdate, pageUpdateOpts) => (
<TypedPageDetailsQuery variables={{ id }}> <TypedPageDetailsQuery variables={{ id }}>
{pageDetails => { {pageDetails => {
const handleUpdate = async (data: PageDetailsPageFormData) => { const handleUpdate = async (data: PageData) => {
const result = await pageUpdate({ const result = await pageUpdate({
variables: { variables: {
id, id,

View file

@ -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 { SearchCollections_search_edges_node } from "@saleor/searches/types/SearchCollections";
import { SearchProductTypes_search_edges_node } from "@saleor/searches/types/SearchProductTypes"; import { SearchProductTypes_search_edges_node } from "@saleor/searches/types/SearchProductTypes";
import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/SearchWarehouses"; import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/SearchWarehouses";
import { ContentState, convertToRaw } from "draft-js";
import React from "react"; import React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
@ -86,12 +85,6 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
const intl = useIntl(); const intl = useIntl();
const localizeDate = useDateLocalize(); 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 // Display values
const [selectedCategory, setSelectedCategory] = useStateFromProps( const [selectedCategory, setSelectedCategory] = useStateFromProps(
initial?.category || "" initial?.category || ""
@ -144,8 +137,8 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
data={data} data={data}
disabled={disabled} disabled={disabled}
errors={errors} errors={errors}
initialDescription={initialDescription.current}
onChange={change} onChange={change}
onDescriptionChange={handlers.changeDescription}
/> />
<CardSpacer /> <CardSpacer />
{data.attributes.length > 0 && ( {data.attributes.length > 0 && (
@ -167,6 +160,7 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
weightUnit={weightUnit} weightUnit={weightUnit}
onChange={change} onChange={change}
/> />
<CardSpacer />
<ProductPricing <ProductPricing
currency={currency} currency={currency}
data={data} data={data}

View file

@ -1,5 +1,7 @@
import { OutputData } from "@editorjs/editorjs";
import { MetadataFormData } from "@saleor/components/Metadata"; import { MetadataFormData } from "@saleor/components/Metadata";
import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField"; import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField";
import { RichTextEditorChange } from "@saleor/components/RichTextEditor";
import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField"; import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField";
import useForm, { FormChange } from "@saleor/hooks/useForm"; import useForm, { FormChange } from "@saleor/hooks/useForm";
import useFormset, { FormsetChange } from "@saleor/hooks/useFormset"; import useFormset, { FormsetChange } from "@saleor/hooks/useFormset";
@ -17,7 +19,7 @@ import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/Searc
import createMultiAutocompleteSelectHandler from "@saleor/utils/handlers/multiAutocompleteSelectChangeHandler"; import createMultiAutocompleteSelectHandler from "@saleor/utils/handlers/multiAutocompleteSelectChangeHandler";
import createSingleAutocompleteSelectHandler from "@saleor/utils/handlers/singleAutocompleteSelectChangeHandler"; import createSingleAutocompleteSelectHandler from "@saleor/utils/handlers/singleAutocompleteSelectChangeHandler";
import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger";
import { RawDraftContentState } from "draft-js"; import useRichText from "@saleor/utils/richText/useRichText";
import React from "react"; import React from "react";
import { SearchProductTypes_search_edges_node } from "../../../searches/types/SearchProductTypes"; import { SearchProductTypes_search_edges_node } from "../../../searches/types/SearchProductTypes";
@ -34,7 +36,7 @@ export interface ProductCreateFormData extends MetadataFormData {
changeTaxCode: boolean; changeTaxCode: boolean;
chargeTaxes: boolean; chargeTaxes: boolean;
collections: string[]; collections: string[];
description: RawDraftContentState; description: OutputData;
isAvailable: boolean; isAvailable: boolean;
isAvailableForPurchase: boolean; isAvailableForPurchase: boolean;
isPublished: boolean; isPublished: boolean;
@ -56,19 +58,22 @@ export interface ProductCreateData extends ProductCreateFormData {
stocks: ProductStockInput[]; stocks: ProductStockInput[];
} }
type ProductCreateHandlers = Record< interface ProductCreateHandlers
| "changeMetadata" extends Record<
| "selectCategory" | "changeMetadata"
| "selectCollection" | "selectCategory"
| "selectProductType" | "selectCollection"
| "selectTaxRate", | "selectProductType"
FormChange | "selectTaxRate",
> & FormChange
Record< >,
"changeStock" | "selectAttribute" | "selectAttributeMultiple", Record<
FormsetChange<string> "changeStock" | "selectAttribute" | "selectAttributeMultiple",
> & FormsetChange<string>
Record<"addStock" | "deleteStock", (id: string) => void>; >,
Record<"addStock" | "deleteStock", (id: string) => void> {
changeDescription: RichTextEditorChange;
}
export interface UseProductCreateFormResult { export interface UseProductCreateFormResult {
change: FormChange; change: FormChange;
data: ProductCreateData; data: ProductCreateData;
@ -106,7 +111,7 @@ const defaultInitialFormData: ProductCreateFormData &
changeTaxCode: false, changeTaxCode: false,
chargeTaxes: false, chargeTaxes: false,
collections: [], collections: [],
description: {} as any, description: null,
isAvailable: false, isAvailable: false,
isAvailableForPurchase: false, isAvailableForPurchase: false,
isPublished: false, isPublished: false,
@ -117,7 +122,7 @@ const defaultInitialFormData: ProductCreateFormData &
publicationDate: "", publicationDate: "",
seoDescription: "", seoDescription: "",
seoTitle: "", seoTitle: "",
sku: null, sku: "",
slug: "", slug: "",
stockQuantity: null, stockQuantity: null,
taxCode: null, taxCode: null,
@ -152,6 +157,10 @@ function useProductCreateForm(
const [productType, setProductType] = useStateFromProps<ProductType>( const [productType, setProductType] = useStateFromProps<ProductType>(
initialProductType || null initialProductType || null
); );
const [description, changeDescription] = useRichText({
initial: null,
triggerChange
});
const { const {
makeChangeHandler: makeMetadataChangeHandler makeChangeHandler: makeMetadataChangeHandler
@ -211,19 +220,21 @@ function useProductCreateForm(
); );
const changeMetadata = makeMetadataChangeHandler(handleChange); const changeMetadata = makeMetadataChangeHandler(handleChange);
const data: ProductCreateData = { const getData = (): ProductCreateData => ({
...form.data, ...form.data,
attributes: attributes.data, attributes: attributes.data,
description: description.current,
productType, productType,
stocks: stocks.data stocks: stocks.data
}; });
const submit = () => onSubmit(data); const submit = () => onSubmit(getData());
return { return {
change: handleChange, change: handleChange,
data, data: getData(),
handlers: { handlers: {
addStock: handleStockAdd, addStock: handleStockAdd,
changeDescription,
changeMetadata, changeMetadata,
changeStock: handleStockChange, changeStock: handleStockChange,
deleteStock: handleStockDelete, deleteStock: handleStockDelete,

View file

@ -1,27 +1,27 @@
import { OutputData } from "@editorjs/editorjs";
import Card from "@material-ui/core/Card"; import Card from "@material-ui/core/Card";
import CardContent from "@material-ui/core/CardContent"; import CardContent from "@material-ui/core/CardContent";
import TextField from "@material-ui/core/TextField"; import TextField from "@material-ui/core/TextField";
import CardTitle from "@saleor/components/CardTitle"; import CardTitle from "@saleor/components/CardTitle";
import FormSpacer from "@saleor/components/FormSpacer"; 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 { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment";
import { commonMessages } from "@saleor/intl"; import { commonMessages } from "@saleor/intl";
import { getFormErrors, getProductErrorMessage } from "@saleor/utils/errors"; import { getFormErrors, getProductErrorMessage } from "@saleor/utils/errors";
import { RawDraftContentState } from "draft-js";
import React from "react"; import React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
interface ProductDetailsFormProps { interface ProductDetailsFormProps {
data: { data: {
description: RawDraftContentState; description: OutputData;
name: string; name: string;
}; };
disabled?: boolean; disabled?: boolean;
errors: ProductErrorFragment[]; errors: ProductErrorFragment[];
// Draftail isn't controlled - it needs only initial input
// because it's autosaving on its own. onDescriptionChange: RichTextEditorChange;
// Ref https://github.com/mirumee/saleor/issues/4470
initialDescription: RawDraftContentState;
onChange(event: any); onChange(event: any);
} }
@ -29,7 +29,7 @@ export const ProductDetailsForm: React.FC<ProductDetailsFormProps> = ({
data, data,
disabled, disabled,
errors, errors,
initialDescription, onDescriptionChange,
onChange onChange
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@ -57,13 +57,13 @@ export const ProductDetailsForm: React.FC<ProductDetailsFormProps> = ({
/> />
<FormSpacer /> <FormSpacer />
<RichTextEditor <RichTextEditor
data={data.description}
disabled={disabled} disabled={disabled}
error={!!formErrors.descriptionJson} error={!!formErrors.descriptionJson}
helperText={getProductErrorMessage(formErrors.descriptionJson, intl)} helperText={getProductErrorMessage(formErrors.descriptionJson, intl)}
initial={initialDescription}
label={intl.formatMessage(commonMessages.description)} label={intl.formatMessage(commonMessages.description)}
name="description" name="description"
onChange={onChange} onChange={onDescriptionChange}
/> />
</CardContent> </CardContent>
</Card> </Card>

View file

@ -1,3 +1,4 @@
import { OutputData } from "@editorjs/editorjs";
import AppHeader from "@saleor/components/AppHeader"; import AppHeader from "@saleor/components/AppHeader";
import AvailabilityCard from "@saleor/components/AvailabilityCard"; import AvailabilityCard from "@saleor/components/AvailabilityCard";
import CardSpacer from "@saleor/components/CardSpacer"; 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 { SearchCategories_search_edges_node } from "@saleor/searches/types/SearchCategories";
import { SearchCollections_search_edges_node } from "@saleor/searches/types/SearchCollections"; import { SearchCollections_search_edges_node } from "@saleor/searches/types/SearchCollections";
import { FetchMoreProps, ListActions, ReorderAction } from "@saleor/types"; import { FetchMoreProps, ListActions, ReorderAction } from "@saleor/types";
import { convertFromRaw, RawDraftContentState } from "draft-js";
import React from "react"; import React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
@ -75,11 +75,12 @@ export interface ProductUpdatePageProps extends ListActions {
} }
export interface ProductUpdatePageSubmitData extends ProductUpdatePageFormData { export interface ProductUpdatePageSubmitData extends ProductUpdatePageFormData {
addStocks: ProductStockInput[];
attributes: ProductAttributeInput[]; attributes: ProductAttributeInput[];
collections: string[]; collections: string[];
addStocks: ProductStockInput[]; description: OutputData;
updateStocks: ProductStockInput[];
removeStocks: string[]; removeStocks: string[];
updateStocks: ProductStockInput[];
} }
export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
@ -135,10 +136,6 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
product?.taxType.description product?.taxType.description
); );
const initialDescription = maybe<RawDraftContentState>(() =>
JSON.parse(product.descriptionJson)
);
const categories = getChoices(categoryChoiceList); const categories = getChoices(categoryChoiceList);
const collections = getChoices(collectionChoiceList); const collections = getChoices(collectionChoiceList);
const currency = product?.variants[0]?.price.currency; const currency = product?.variants[0]?.price.currency;
@ -175,7 +172,7 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
data={data} data={data}
disabled={disabled} disabled={disabled}
errors={errors} errors={errors}
initialDescription={initialDescription} onDescriptionChange={handlers.changeDescription}
onChange={change} onChange={change}
/> />
<CardSpacer /> <CardSpacer />
@ -262,11 +259,7 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
title={data.seoTitle} title={data.seoTitle}
titlePlaceholder={data.name} titlePlaceholder={data.name}
description={data.seoDescription} description={data.seoDescription}
descriptionPlaceholder={maybe(() => descriptionPlaceholder={""} // TODO: cast description to string
convertFromRaw(data.description)
.getPlainText()
.slice(0, 300)
)}
slug={data.slug} slug={data.slug}
slugPlaceholder={data.name} slugPlaceholder={data.name}
loading={disabled} loading={disabled}

View file

@ -1,5 +1,7 @@
import { OutputData } from "@editorjs/editorjs";
import { MetadataFormData } from "@saleor/components/Metadata"; import { MetadataFormData } from "@saleor/components/Metadata";
import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField"; import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField";
import { RichTextEditorChange } from "@saleor/components/RichTextEditor";
import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField"; import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField";
import useForm, { FormChange, SubmitPromise } from "@saleor/hooks/useForm"; import useForm, { FormChange, SubmitPromise } from "@saleor/hooks/useForm";
import useFormset, { import useFormset, {
@ -20,8 +22,9 @@ import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/Searc
import handleFormSubmit from "@saleor/utils/handlers/handleFormSubmit"; import handleFormSubmit from "@saleor/utils/handlers/handleFormSubmit";
import createMultiAutocompleteSelectHandler from "@saleor/utils/handlers/multiAutocompleteSelectChangeHandler"; import createMultiAutocompleteSelectHandler from "@saleor/utils/handlers/multiAutocompleteSelectChangeHandler";
import createSingleAutocompleteSelectHandler from "@saleor/utils/handlers/singleAutocompleteSelectChangeHandler"; import createSingleAutocompleteSelectHandler from "@saleor/utils/handlers/singleAutocompleteSelectChangeHandler";
import getMetadata from "@saleor/utils/metadata/getMetadata";
import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; 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 { diff } from "fast-array-diff";
import React from "react"; import React from "react";
@ -35,7 +38,6 @@ export interface ProductUpdateFormData extends MetadataFormData {
changeTaxCode: boolean; changeTaxCode: boolean;
chargeTaxes: boolean; chargeTaxes: boolean;
collections: string[]; collections: string[];
description: RawDraftContentState;
isAvailable: boolean; isAvailable: boolean;
isAvailableForPurchase: boolean; isAvailableForPurchase: boolean;
isPublished: boolean; isPublished: boolean;
@ -52,27 +54,36 @@ export interface ProductUpdateFormData extends MetadataFormData {
} }
export interface ProductUpdateData extends ProductUpdateFormData { export interface ProductUpdateData extends ProductUpdateFormData {
attributes: ProductAttributeInput[]; attributes: ProductAttributeInput[];
description: OutputData;
stocks: ProductStockInput[]; stocks: ProductStockInput[];
} }
export interface ProductUpdateSubmitData extends ProductUpdateFormData { export interface ProductUpdateSubmitData extends ProductUpdateFormData {
attributes: ProductAttributeInput[]; attributes: ProductAttributeInput[];
collections: string[]; collections: string[];
description: OutputData;
addStocks: ProductStockInput[]; addStocks: ProductStockInput[];
updateStocks: ProductStockInput[]; updateStocks: ProductStockInput[];
removeStocks: string[]; removeStocks: string[];
} }
type ProductUpdateHandlers = Record< interface ProductUpdateHandlers
"changeMetadata" | "selectCategory" | "selectCollection" | "selectTaxRate", extends Record<
FormChange | "changeMetadata"
> & | "selectCategory"
Record< | "selectCollection"
"changeStock" | "selectAttribute" | "selectAttributeMultiple", | "selectTaxRate",
FormsetChange<string> FormChange
> & >,
Record<"addStock" | "deleteStock", (id: string) => void>; Record<
"changeStock" | "selectAttribute" | "selectAttributeMultiple",
FormsetChange<string>
>,
Record<"addStock" | "deleteStock", (id: string) => void> {
changeDescription: RichTextEditorChange;
}
export interface UseProductUpdateFormResult { export interface UseProductUpdateFormResult {
change: FormChange; change: FormChange;
data: ProductUpdateData; data: ProductUpdateData;
handlers: ProductUpdateHandlers; handlers: ProductUpdateHandlers;
hasChanged: boolean; 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( function useProductUpdateForm(
product: ProductDetails_product, product: ProductDetails_product,
onSubmit: (data: ProductUpdateSubmitData) => SubmitPromise, onSubmit: (data: ProductUpdateSubmitData) => SubmitPromise,
@ -155,6 +157,10 @@ function useProductUpdateForm(
); );
const attributes = useFormset(getAttributeInputFromProduct(product)); const attributes = useFormset(getAttributeInputFromProduct(product));
const stocks = useFormset(getStockInputFromProduct(product)); const stocks = useFormset(getStockInputFromProduct(product));
const [description, changeDescription] = useRichText({
initial: product?.descriptionJson,
triggerChange
});
const { const {
isMetadataModified, isMetadataModified,
@ -213,24 +219,28 @@ function useProductUpdateForm(
const data: ProductUpdateData = { const data: ProductUpdateData = {
...form.data, ...form.data,
attributes: attributes.data, attributes: attributes.data,
description: description.current,
stocks: stocks.data stocks: stocks.data
}; };
const submitData: ProductUpdateSubmitData = { // Need to make it function to always have description.current up to date
const getSubmitData = (): ProductUpdateSubmitData => ({
...data, ...data,
...getAvailabilityData(data), ...getAvailabilityData(data),
...getStocksData(product, stocks.data), ...getStocksData(product, stocks.data),
...getMetadata(data, isMetadataModified, isPrivateMetadataModified), ...getMetadata(data, isMetadataModified, isPrivateMetadataModified),
addStocks: [], addStocks: [],
attributes: attributes.data attributes: attributes.data,
}; description: description.current
});
const submit = () => handleFormSubmit(submitData, onSubmit, setChanged); const submit = () => handleFormSubmit(getSubmitData(), onSubmit, setChanged);
return { return {
change: handleChange, change: handleChange,
data, data,
handlers: { handlers: {
addStock: handleStockAdd, addStock: handleStockAdd,
changeDescription,
changeMetadata, changeMetadata,
changeStock: handleStockChange, changeStock: handleStockChange,
deleteStock: handleStockDelete, deleteStock: handleStockDelete,

View file

@ -11,6 +11,7 @@ import {
} from "@saleor/products/utils/data"; } from "@saleor/products/utils/data";
import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/SearchWarehouses"; import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/SearchWarehouses";
import { mapMetadataItemToInput } from "@saleor/utils/maps"; import { mapMetadataItemToInput } from "@saleor/utils/maps";
import getMetadata from "@saleor/utils/metadata/getMetadata";
import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger";
import { diff } from "fast-array-diff"; import { diff } from "fast-array-diff";
import React from "react"; import React from "react";
@ -117,11 +118,6 @@ function useProductVariantUpdateForm(
stocks.remove(id); 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 dataStocks = stocks.data.map(stock => stock.id);
const variantStocks = variant?.stocks.map(stock => stock.warehouse.id) || []; const variantStocks = variant?.stocks.map(stock => stock.warehouse.id) || [];
const stockDiff = diff(variantStocks, dataStocks); const stockDiff = diff(variantStocks, dataStocks);
@ -140,10 +136,9 @@ function useProductVariantUpdateForm(
}; };
const submitData: ProductVariantUpdateSubmitData = { const submitData: ProductVariantUpdateSubmitData = {
...form.data, ...form.data,
...getMetadata(form.data, isMetadataModified, isPrivateMetadataModified),
addStocks, addStocks,
attributes: attributes.data, attributes: attributes.data,
metadata,
privateMetadata,
removeStocks: stockDiff.removed, removeStocks: stockDiff.removed,
updateStocks updateStocks
}; };

View file

@ -5,11 +5,13 @@ import {
} from "@saleor/types/globalTypes"; } from "@saleor/types/globalTypes";
import { warehouseList } from "@saleor/warehouses/fixtures"; 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 { ProductDetails_product } from "./types/ProductDetails";
import { ProductList_products_edges_node } from "./types/ProductList"; import { ProductList_products_edges_node } from "./types/ProductList";
import { ProductVariantCreateData_product } from "./types/ProductVariantCreateData"; import { ProductVariantCreateData_product } from "./types/ProductVariantCreateData";
const content = richTextEditorFixtures.richTextEditor;
export const product: ( export const product: (
placeholderImage: string placeholderImage: string
) => ProductDetails_product & ) => ProductDetails_product &

View file

@ -12,7 +12,6 @@ import {
import { SearchProductTypes_search_edges_node_productAttributes } from "@saleor/searches/types/SearchProductTypes"; import { SearchProductTypes_search_edges_node_productAttributes } from "@saleor/searches/types/SearchProductTypes";
import { StockInput } from "@saleor/types/globalTypes"; import { StockInput } from "@saleor/types/globalTypes";
import { mapMetadataItemToInput } from "@saleor/utils/maps"; import { mapMetadataItemToInput } from "@saleor/utils/maps";
import { RawDraftContentState } from "draft-js";
import { ProductAttributeInput } from "../components/ProductAttributes"; import { ProductAttributeInput } from "../components/ProductAttributes";
import { ProductStockInput } from "../components/ProductStocks"; import { ProductStockInput } from "../components/ProductStocks";
@ -175,7 +174,6 @@ export interface ProductUpdatePageFormData extends MetadataFormData {
changeTaxCode: boolean; changeTaxCode: boolean;
chargeTaxes: boolean; chargeTaxes: boolean;
collections: string[]; collections: string[];
description: RawDraftContentState;
isAvailable: boolean; isAvailable: boolean;
isAvailableForPurchase: boolean; isAvailableForPurchase: boolean;
isPublished: boolean; isPublished: boolean;
@ -205,7 +203,6 @@ export function getProductUpdatePageFormData(
() => product.collections.map(collection => collection.id), () => product.collections.map(collection => collection.id),
[] []
), ),
description: maybe(() => JSON.parse(product.descriptionJson)),
isAvailable: !!product?.isAvailable, isAvailable: !!product?.isAvailable,
isAvailableForPurchase: !!product?.isAvailableForPurchase, isAvailableForPurchase: !!product?.isAvailableForPurchase,
isPublished: maybe(() => product.isPublished, false), isPublished: maybe(() => product.isPublished, false),

File diff suppressed because it is too large Load diff

View file

@ -35,7 +35,6 @@ function loadStories() {
require("./stories/components/Percent"); require("./stories/components/Percent");
require("./stories/components/PhoneField"); require("./stories/components/PhoneField");
require("./stories/components/PriceField"); require("./stories/components/PriceField");
require("./stories/components/RichTextEditor");
require("./stories/components/SaveButtonBar"); require("./stories/components/SaveButtonBar");
require("./stories/components/SaveFilterTabDialog"); require("./stories/components/SaveFilterTabDialog");
require("./stories/components/SingleSelectField"); require("./stories/components/SingleSelectField");

View file

@ -1,119 +0,0 @@
import RichTextEditor from "@saleor/components/RichTextEditor";
import { storiesOf } from "@storybook/react";
import { RawDraftContentState } from "draft-js";
import React from "react";
import CardDecorator from "../../CardDecorator";
import Decorator from "../../Decorator";
export const content: RawDraftContentState = {
blocks: [
{
data: {},
depth: 0,
entityRanges: [],
inlineStyleRanges: [{ length: 4, offset: 0, style: "BOLD" }],
key: "rosn",
text: "bold",
type: "unstyled"
},
{
data: {},
depth: 0,
entityRanges: [],
inlineStyleRanges: [{ length: 6, offset: 0, style: "ITALIC" }],
key: "6tbch",
text: "italic",
type: "unstyled"
},
{
data: {},
depth: 0,
entityRanges: [],
inlineStyleRanges: [{ length: 13, offset: 0, style: "STRIKETHROUGH" }],
key: "1p044",
text: "strikethrough",
type: "unstyled"
},
{
data: {},
depth: 0,
entityRanges: [],
inlineStyleRanges: [],
key: "aven6",
text: "h1",
type: "header-one"
},
{
data: {},
depth: 0,
entityRanges: [],
inlineStyleRanges: [],
key: "9rabl",
text: "h2",
type: "header-two"
},
{
data: {},
depth: 0,
entityRanges: [],
inlineStyleRanges: [],
key: "bv0ac",
text: "h3",
type: "header-three"
},
{
data: {},
depth: 0,
entityRanges: [],
inlineStyleRanges: [],
key: "2ip7q",
text: "blockquote",
type: "blockquote"
},
{
data: {},
depth: 0,
entityRanges: [],
inlineStyleRanges: [],
key: "8r8ss",
text: "ul",
type: "unordered-list-item"
},
{
data: {},
depth: 0,
entityRanges: [],
inlineStyleRanges: [],
key: "911hc",
text: "ol",
type: "ordered-list-item"
},
{
data: {},
depth: 0,
entityRanges: [{ key: 0, length: 4, offset: 0 }],
inlineStyleRanges: [],
key: "5aejo",
text: "link",
type: "unstyled"
}
],
entityMap: {
"0": { data: { url: "#" }, mutability: "MUTABLE", type: "LINK" }
}
};
storiesOf("Generics / Rich text editor", module)
.addDecorator(CardDecorator)
.addDecorator(Decorator)
.add("default", () => (
<RichTextEditor
disabled={false}
error={false}
helperText={""}
initial={content}
label="Content"
name="content"
onChange={() => undefined}
/>
));

View file

@ -1,9 +1,9 @@
import { PageData } from "@saleor/pages/components/PageDetailsPage/form";
import { PageErrorCode } from "@saleor/types/globalTypes"; import { PageErrorCode } from "@saleor/types/globalTypes";
import { storiesOf } from "@storybook/react"; import { storiesOf } from "@storybook/react";
import React from "react"; import React from "react";
import PageDetailsPage, { import PageDetailsPage, {
PageDetailsPageFormData,
PageDetailsPageProps PageDetailsPageProps
} from "../../../pages/components/PageDetailsPage"; } from "../../../pages/components/PageDetailsPage";
import { page } from "../../../pages/fixtures"; import { page } from "../../../pages/fixtures";
@ -36,7 +36,7 @@ storiesOf("Views / Pages / Page details", module)
"isPublished", "isPublished",
"seoDescription", "seoDescription",
"seoTitle" "seoTitle"
] as Array<keyof PageDetailsPageFormData>).map(field => ({ ] as Array<keyof PageData>).map(field => ({
__typename: "PageError", __typename: "PageError",
code: PageErrorCode.INVALID, code: PageErrorCode.INVALID,
field field

View file

@ -1,3 +1,4 @@
import { OutputData } from "@editorjs/editorjs";
import Button from "@material-ui/core/Button"; import Button from "@material-ui/core/Button";
import Card from "@material-ui/core/Card"; import Card from "@material-ui/core/Card";
import CardContent from "@material-ui/core/CardContent"; import CardContent from "@material-ui/core/CardContent";
@ -36,7 +37,7 @@ export interface TranslationFieldsProps {
saveButtonState: ConfirmButtonTransitionState; saveButtonState: ConfirmButtonTransitionState;
onEdit: (field: string) => void; onEdit: (field: string) => void;
onDiscard: () => void; onDiscard: () => void;
onSubmit: (field: string, data: string) => void; onSubmit: (field: string, data: string | OutputData) => void;
} }
const useStyles = makeStyles( const useStyles = makeStyles(

View file

@ -1,8 +1,9 @@
import { OutputData } from "@editorjs/editorjs";
import Typography from "@material-ui/core/Typography"; import Typography from "@material-ui/core/Typography";
import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton";
import DraftRenderer from "@saleor/components/DraftRenderer";
import Form from "@saleor/components/Form";
import RichTextEditor from "@saleor/components/RichTextEditor"; import RichTextEditor from "@saleor/components/RichTextEditor";
import RichTextEditorContent from "@saleor/components/RichTextEditor/RichTextEditorContent";
import useRichText from "@saleor/utils/richText/useRichText";
import React from "react"; import React from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
@ -14,7 +15,7 @@ interface TranslationFieldsRichProps {
initial: string; initial: string;
saveButtonState: ConfirmButtonTransitionState; saveButtonState: ConfirmButtonTransitionState;
onDiscard: () => void; onDiscard: () => void;
onSubmit: (data: string) => void; onSubmit: (data: OutputData) => void;
} }
const TranslationFieldsRich: React.FC<TranslationFieldsRichProps> = ({ const TranslationFieldsRich: React.FC<TranslationFieldsRichProps> = ({
@ -26,40 +27,39 @@ const TranslationFieldsRich: React.FC<TranslationFieldsRichProps> = ({
onSubmit onSubmit
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const [content, change] = useRichText({
initial,
triggerChange: () => undefined
});
const submit = () => onSubmit(content.current);
return edit ? ( return edit ? (
<Form <form onSubmit={submit}>
initial={{ translation: initial }} <RichTextEditor
onSubmit={data => onSubmit(data.translation)} data={content.current}
> disabled={disabled}
{({ change, submit }) => ( error={undefined}
<div> helperText={undefined}
<RichTextEditor label={intl.formatMessage({
disabled={disabled} defaultMessage: "Translation"
error={undefined} })}
helperText={undefined} name="translation"
initial={JSON.parse(initial)} onChange={change}
label={intl.formatMessage({ />
defaultMessage: "Translation" <TranslationFieldsSave
})} saveButtonState={saveButtonState}
name="translation" onDiscard={onDiscard}
onChange={change} onSave={submit}
/> />
<TranslationFieldsSave </form>
saveButtonState={saveButtonState}
onDiscard={onDiscard}
onSave={submit}
/>
</div>
)}
</Form>
) : initial === null ? ( ) : initial === null ? (
<Typography color="textSecondary"> <Typography color="textSecondary">
<FormattedMessage defaultMessage="No translation yet" /> <FormattedMessage defaultMessage="No translation yet" />
</Typography> </Typography>
) : ( ) : (
<Typography> <Typography>
<DraftRenderer content={JSON.parse(initial)} /> <RichTextEditorContent data={JSON.parse(initial)} />
</Typography> </Typography>
); );
}; };

View file

@ -43,7 +43,7 @@ const TranslationFieldsSave: React.FC<TranslationFieldsSaveProps> = props => {
> >
<FormattedMessage {...buttonMessages.save} /> <FormattedMessage {...buttonMessages.save} />
</ConfirmButton> </ConfirmButton>
<Button onClick={onDiscard}> <Button onClick={onDiscard} type="submit">
<FormattedMessage defaultMessage="Discard" description="button" /> <FormattedMessage defaultMessage="Discard" description="button" />
</Button> </Button>
</div> </div>

View file

@ -1,3 +1,4 @@
import { OutputData } from "@editorjs/editorjs";
import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton";
import { ShopInfo_shop_languages } from "@saleor/components/Shop/types/ShopInfo"; import { ShopInfo_shop_languages } from "@saleor/components/Shop/types/ShopInfo";
@ -11,5 +12,5 @@ export interface TranslationsEntitiesPageProps {
onEdit: (field: string) => void; onEdit: (field: string) => void;
onDiscard: () => void; onDiscard: () => void;
onLanguageChange: (lang: string) => void; onLanguageChange: (lang: string) => void;
onSubmit: (field: string, data: string) => void; onSubmit: (field: string, data: string | OutputData) => void;
} }

View file

@ -0,0 +1,16 @@
interface PublicationData {
publicationDate: string;
isPublished: boolean;
}
function getPublicationData({
publicationDate,
isPublished
}: PublicationData): PublicationData {
return {
isPublished: !!publicationDate || isPublished,
publicationDate: publicationDate || null
};
}
export default getPublicationData;

View file

@ -0,0 +1,16 @@
import { MetadataFormData } from "@saleor/components/Metadata";
function getMetadata(
data: MetadataFormData,
isMetadataModified: boolean,
isPrivateMetadataModified: boolean
) {
return {
metadata: isMetadataModified ? data.metadata : undefined,
privateMetadata: isPrivateMetadataModified
? data.privateMetadata
: undefined
};
}
export default getMetadata;

View file

@ -0,0 +1,73 @@
import { OutputData } from "@editorjs/editorjs";
import { renderHook } from "@testing-library/react-hooks";
import useRichText from "./useRichText";
type Fixtures = Record<"short" | "long", OutputData>;
const fixtures: Fixtures = {
long: {
blocks: [
{
data: {
level: 1,
text: "Some header"
},
type: "header"
},
{
data: {
text: "Some text"
},
type: "paragraph"
}
]
},
short: {
blocks: [
{
data: {
text: "Some text"
},
type: "paragraph"
}
]
}
};
describe("useRichText", () => {
it("properly saves data in form", () => {
const triggerChange = jest.fn();
const hook = renderHook(() =>
useRichText({
initial: null,
triggerChange
})
);
const [data, change] = hook.result.current;
expect(data.current).toMatchObject({ blocks: [] });
change(fixtures.short);
expect(data.current).toMatchObject(fixtures.short);
expect(triggerChange).toHaveBeenCalled();
});
it("properly updates data in form", () => {
const triggerChange = jest.fn();
const hook = renderHook(() =>
useRichText({
initial: JSON.stringify(fixtures.short),
triggerChange
})
);
const [data, change] = hook.result.current;
expect(data.current).toMatchObject(fixtures.short);
change(fixtures.long);
expect(data.current).toMatchObject(fixtures.long);
expect(triggerChange).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,32 @@
import { OutputData } from "@editorjs/editorjs";
import { RichTextEditorChange } from "@saleor/components/RichTextEditor";
import { MutableRefObject, useEffect, useRef, useState } from "react";
function useRichText(opts: {
initial: string | null;
triggerChange: () => void;
}): [MutableRefObject<OutputData>, RichTextEditorChange] {
const data = useRef<OutputData>(
opts.initial === null ? { blocks: [] } : undefined
);
const [, setLoaded] = useState(false);
useEffect(() => {
if (opts.initial !== null) {
try {
data.current = JSON.parse(opts.initial);
setLoaded(true);
} catch {
data.current = undefined;
}
}
}, [opts.initial]);
const change: RichTextEditorChange = newData => {
opts.triggerChange();
data.current = newData;
};
return [data, change];
}
export default useRichText;