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": {
"string": "No results found"
},
"src_dot_components_dot_RichTextEditor_dot_1603794322": {
"context": "dialog header",
"string": "Add Image Link"
},
"src_dot_components_dot_RichTextEditor_dot_2049070632": {
"context": "replace image, button",
"string": "Replace"
},
"src_dot_components_dot_RichTextEditor_dot_2160163587": {
"context": "button",
"string": "Add or Edit Link"
},
"src_dot_components_dot_RichTextEditor_dot_286109898": {
"context": "rich text error",
"string": "Invalid content"
},
"src_dot_components_dot_RichTextEditor_dot_2925475978": {
"string": "URL Linked"
},
"src_dot_components_dot_RichTextEditor_dot_4035057905": {
"string": "Image URL"
},
"src_dot_components_dot_RowNumberSelect_dot_1154361791": {
"string": "No of Rows:"
},

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": {
"version": "10.0.19",
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.19.tgz",
@ -8778,6 +8807,16 @@
"urlgrey": "0.4.4"
}
},
"codex-notifier": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/codex-notifier/-/codex-notifier-1.1.2.tgz",
"integrity": "sha512-DCp6xe/LGueJ1N5sXEwcBc3r3PyVkEEDNWCVigfvywAkeXcZMk9K41a31tkEFBW0Ptlwji6/JlAb49E3Yrxbtg=="
},
"codex-tooltip": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/codex-tooltip/-/codex-tooltip-1.0.1.tgz",
"integrity": "sha512-1xLb1NZbxguNtf02xBRhDphq/EXvMMeEbY0ievjQTHqf8UjXsD41evGk9rqcbjpl+JOjNgtwnp1OaU/X/h6fhQ=="
},
"coffeescript": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/coffeescript/-/coffeescript-2.5.1.tgz",
@ -10306,6 +10345,16 @@
"safer-buffer": "^2.1.0"
}
},
"editorjs-inline-tool": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/editorjs-inline-tool/-/editorjs-inline-tool-0.4.0.tgz",
"integrity": "sha512-Ppb4e8IFPjWuNcoNM4tg9bDSo7FgMYAlqP4UhuV5W2JoJBubV5pUcpLrFrSyGTt1HJVEpbrib134zf4wxO+7VA=="
},
"editorjs-undo": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/editorjs-undo/-/editorjs-undo-0.1.4.tgz",
"integrity": "sha512-YW+5x8tqMXQ+iQD+1OrroqUT1O/erDrysZLJ+8yB+zjls55rwUw9P7hdQCJ0DFm+WDSaRuEXb2p3dQ6VdrkYCg=="
},
"ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",

View file

@ -17,6 +17,11 @@
"npm": ">=6.11.0"
},
"dependencies": {
"@editorjs/editorjs": "^2.19.0",
"@editorjs/header": "^2.6.0",
"@editorjs/image": "^2.6.0",
"@editorjs/list": "^1.6.0",
"@editorjs/quote": "^2.4.0",
"@material-ui/core": "^4.5.1",
"@material-ui/icons": "^4.5.1",
"@material-ui/styles": "^4.5.2",
@ -36,6 +41,8 @@
"draft-js": "^0.10.5",
"draftail": "^1.2.1",
"draftjs-to-html": "^0.9.1",
"editorjs-inline-tool": "^0.4.0",
"editorjs-undo": "^0.1.4",
"fast-array-diff": "^0.2.0",
"fuzzaldrin": "^2.1.0",
"graphql": "^14.4.2",

View file

@ -1,12 +1,19 @@
import CssBaseline from "@material-ui/core/CssBaseline";
import { createStyles, withStyles } from "@material-ui/core/styles";
import { createStyles, Theme, withStyles } from "@material-ui/core/styles";
import { fade } from "@material-ui/core/styles/colorManipulator";
import React from "react";
const styles = createStyles({
const styles = createStyles((theme: Theme) => ({
"@global": {
"@import": "url('https://rsms.me/inter/inter.css')"
"@import": "url('https://rsms.me/inter/inter.css')",
// For some reason @import clause must be put on top
// eslint-disable-next-line sort-keys
"::selection": {
background: fade(theme.palette.primary.main, 0.2)
}
}
});
}));
const Baseline = withStyles(styles, {
name: "Baseline"

View file

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

View file

@ -2,43 +2,23 @@ import AppHeader from "@saleor/components/AppHeader";
import { CardSpacer } from "@saleor/components/CardSpacer";
import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton";
import Container from "@saleor/components/Container";
import Form from "@saleor/components/Form";
import Metadata, { MetadataFormData } from "@saleor/components/Metadata";
import Metadata from "@saleor/components/Metadata";
import PageHeader from "@saleor/components/PageHeader";
import SaveButtonBar from "@saleor/components/SaveButtonBar";
import SeoForm from "@saleor/components/SeoForm";
import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment";
import { sectionNames } from "@saleor/intl";
import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger";
import { ContentState, convertToRaw, RawDraftContentState } from "draft-js";
import React from "react";
import { useIntl } from "react-intl";
import CategoryDetailsForm from "../../components/CategoryDetailsForm";
export interface FormData extends MetadataFormData {
description: RawDraftContentState;
name: string;
slug: string;
seoTitle: string;
seoDescription: string;
}
const initialData: FormData = {
description: convertToRaw(ContentState.createFromText("")),
metadata: [],
name: "",
privateMetadata: [],
seoDescription: "",
seoTitle: "",
slug: ""
};
import CategoryCreateForm, { CategoryCreateData } from "./form";
export interface CategoryCreatePageProps {
errors: ProductErrorFragment[];
disabled: boolean;
saveButtonBarState: ConfirmButtonTransitionState;
onSubmit(data: FormData);
onSubmit(data: CategoryCreateData);
onBack();
}
@ -50,63 +30,57 @@ export const CategoryCreatePage: React.FC<CategoryCreatePageProps> = ({
saveButtonBarState
}) => {
const intl = useIntl();
const {
makeChangeHandler: makeMetadataChangeHandler
} = useMetadataChangeTrigger();
return (
<Form onSubmit={onSubmit} initial={initialData} confirmLeave>
{({ data, change, submit, hasChanged }) => {
const changeMetadata = makeMetadataChangeHandler(change);
return (
<Container>
<AppHeader onBack={onBack}>
{intl.formatMessage(sectionNames.categories)}
</AppHeader>
<PageHeader
title={intl.formatMessage({
defaultMessage: "Create New Category",
description: "page header"
})}
<CategoryCreateForm onSubmit={onSubmit}>
{({ data, change, handlers, submit, hasChanged }) => (
<Container>
<AppHeader onBack={onBack}>
{intl.formatMessage(sectionNames.categories)}
</AppHeader>
<PageHeader
title={intl.formatMessage({
defaultMessage: "Create New Category",
description: "page header"
})}
/>
<div>
<CategoryDetailsForm
data={data}
disabled={disabled}
errors={errors}
onChange={change}
onDescriptionChange={handlers.changeDescription}
/>
<div>
<CategoryDetailsForm
disabled={disabled}
data={data}
onChange={change}
errors={errors}
/>
<CardSpacer />
<SeoForm
allowEmptySlug={true}
helperText={intl.formatMessage({
defaultMessage:
"Add search engine title and description to make this category easier to find"
})}
slug={data.slug}
slugPlaceholder={data.name}
title={data.seoTitle}
titlePlaceholder={data.name}
description={data.seoDescription}
descriptionPlaceholder={data.name}
loading={disabled}
onChange={change}
disabled={disabled}
/>
<CardSpacer />
<Metadata data={data} onChange={changeMetadata} />
<SaveButtonBar
onCancel={onBack}
onSave={submit}
state={saveButtonBarState}
disabled={disabled || !hasChanged}
/>
</div>
</Container>
);
}}
</Form>
<CardSpacer />
<SeoForm
allowEmptySlug={true}
helperText={intl.formatMessage({
defaultMessage:
"Add search engine title and description to make this category easier to find"
})}
slug={data.slug}
slugPlaceholder={data.name}
title={data.seoTitle}
titlePlaceholder={data.name}
description={data.seoDescription}
descriptionPlaceholder={data.name}
loading={disabled}
onChange={change}
disabled={disabled}
/>
<CardSpacer />
<Metadata data={data} onChange={handlers.changeMetadata} />
<SaveButtonBar
onCancel={onBack}
onSave={submit}
state={saveButtonBarState}
disabled={disabled || !hasChanged}
/>
</div>
</Container>
)}
</CategoryCreateForm>
);
};
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 CardContent from "@material-ui/core/CardContent";
import TextField from "@material-ui/core/TextField";
import CardTitle from "@saleor/components/CardTitle";
import FormSpacer from "@saleor/components/FormSpacer";
import RichTextEditor from "@saleor/components/RichTextEditor";
import RichTextEditor, {
RichTextEditorChange
} from "@saleor/components/RichTextEditor";
import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment";
import { commonMessages } from "@saleor/intl";
import { getFormErrors, getProductErrorMessage } from "@saleor/utils/errors";
import { RawDraftContentState } from "draft-js";
import React from "react";
import { useIntl } from "react-intl";
import { maybe } from "../../../misc";
import { CategoryDetails_category } from "../../types/CategoryDetails";
interface CategoryDetailsFormProps {
category?: CategoryDetails_category;
data: {
name: string;
description: RawDraftContentState;
description: OutputData;
};
disabled: boolean;
errors: ProductErrorFragment[];
onChange: (event: React.ChangeEvent<any>) => void;
onDescriptionChange: RichTextEditorChange;
}
export const CategoryDetailsForm: React.FC<CategoryDetailsFormProps> = ({
category,
disabled,
data,
onChange,
onDescriptionChange,
errors
}) => {
const intl = useIntl();
@ -58,15 +57,15 @@ export const CategoryDetailsForm: React.FC<CategoryDetailsFormProps> = ({
</div>
<FormSpacer />
<RichTextEditor
data={data.description}
disabled={disabled}
error={!!formErrors.descriptionJson}
helperText={getProductErrorMessage(formErrors.descriptionJson, intl)}
label={intl.formatMessage({
defaultMessage: "Category Description"
})}
initial={maybe(() => JSON.parse(category.descriptionJson))}
name="description"
onChange={onChange}
onChange={onDescriptionChange}
/>
</CardContent>
</Card>

View file

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

View file

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

View file

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

View file

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

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

View file

@ -3,22 +3,18 @@ import { CardSpacer } from "@saleor/components/CardSpacer";
import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton";
import { Container } from "@saleor/components/Container";
import ControlledCheckbox from "@saleor/components/ControlledCheckbox";
import Form from "@saleor/components/Form";
import FormSpacer from "@saleor/components/FormSpacer";
import Grid from "@saleor/components/Grid";
import Hr from "@saleor/components/Hr";
import Metadata from "@saleor/components/Metadata/Metadata";
import { MetadataFormData } from "@saleor/components/Metadata/types";
import PageHeader from "@saleor/components/PageHeader";
import SaveButtonBar from "@saleor/components/SaveButtonBar";
import SeoForm from "@saleor/components/SeoForm";
import VisibilityCard from "@saleor/components/VisibilityCard";
import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment";
import useDateLocalize from "@saleor/hooks/useDateLocalize";
import { SubmitPromise } from "@saleor/hooks/useForm";
import { sectionNames } from "@saleor/intl";
import { mapMetadataItemToInput } from "@saleor/utils/maps";
import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger";
import { RawDraftContentState } from "draft-js";
import React from "react";
import { useIntl } from "react-intl";
@ -28,18 +24,7 @@ import { CollectionDetails_collection } from "../../types/CollectionDetails";
import CollectionDetails from "../CollectionDetails/CollectionDetails";
import { CollectionImage } from "../CollectionImage/CollectionImage";
import CollectionProducts from "../CollectionProducts/CollectionProducts";
export interface CollectionDetailsPageFormData extends MetadataFormData {
backgroundImageAlt: string;
description: RawDraftContentState;
name: string;
slug: string;
publicationDate: string;
seoDescription: string;
seoTitle: string;
isFeatured: boolean;
isPublished: boolean;
}
import CollectionUpdateForm, { CollectionUpdateData } from "./form";
export interface CollectionDetailsPageProps extends PageListProps, ListActions {
collection: CollectionDetails_collection;
@ -51,7 +36,7 @@ export interface CollectionDetailsPageProps extends PageListProps, ListActions {
onImageDelete: () => void;
onImageUpload: (file: File) => void;
onProductUnassign: (id: string, event: React.MouseEvent<any>) => void;
onSubmit: (data: CollectionDetailsPageFormData) => void;
onSubmit: (data: CollectionUpdateData) => SubmitPromise;
}
const CollectionDetailsPage: React.FC<CollectionDetailsPageProps> = ({
@ -69,150 +54,113 @@ const CollectionDetailsPage: React.FC<CollectionDetailsPageProps> = ({
}: CollectionDetailsPageProps) => {
const intl = useIntl();
const localizeDate = useDateLocalize();
const {
isMetadataModified,
isPrivateMetadataModified,
makeChangeHandler: makeMetadataChangeHandler
} = useMetadataChangeTrigger();
const handleSubmit = (data: CollectionDetailsPageFormData) => {
const metadata = isMetadataModified ? data.metadata : undefined;
const privateMetadata = isPrivateMetadataModified
? data.privateMetadata
: undefined;
return onSubmit({
...data,
isPublished: data.isPublished || !!data.publicationDate,
metadata,
privateMetadata
});
};
return (
<Form
initial={{
backgroundImageAlt: maybe(() => collection.backgroundImage.alt, ""),
description: maybe(() => JSON.parse(collection.descriptionJson)),
isFeatured,
isPublished: maybe(() => collection.isPublished, false),
metadata: collection?.metadata?.map(mapMetadataItemToInput),
name: maybe(() => collection.name, ""),
privateMetadata: collection?.privateMetadata?.map(
mapMetadataItemToInput
),
publicationDate: maybe(() => collection.publicationDate, ""),
seoDescription: maybe(() => collection.seoDescription, ""),
seoTitle: maybe(() => collection.seoTitle, ""),
slug: collection?.slug || ""
}}
onSubmit={handleSubmit}
confirmLeave
<CollectionUpdateForm
collection={collection}
isFeatured={isFeatured}
onSubmit={onSubmit}
>
{({ change, data, hasChanged, submit }) => {
const changeMetadata = makeMetadataChangeHandler(change);
return (
<Container>
<AppHeader onBack={onBack}>
{intl.formatMessage(sectionNames.collections)}
</AppHeader>
<PageHeader title={maybe(() => collection.name)} />
<Grid>
{({ change, data, handlers, hasChanged, submit }) => (
<Container>
<AppHeader onBack={onBack}>
{intl.formatMessage(sectionNames.collections)}
</AppHeader>
<PageHeader title={maybe(() => collection.name)} />
<Grid>
<div>
<CollectionDetails
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>
<CollectionDetails
collection={collection}
<VisibilityCard
data={data}
disabled={disabled}
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}
/>
<CardSpacer />
<CollectionImage
data={data}
image={maybe(() => collection.backgroundImage)}
onImageDelete={onImageDelete}
onImageUpload={onImageUpload}
onChange={change}
/>
<CardSpacer />
<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"
})
}}
>
<FormSpacer />
<Hr />
<ControlledCheckbox
name={"isFeatured" as keyof CollectionUpdateData}
label={intl.formatMessage({
defaultMessage: "Feature on Homepage",
description: "switch button"
})}
checked={data.isFeatured}
onChange={change}
>
<FormSpacer />
<Hr />
<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>
disabled={disabled}
/>
</VisibilityCard>
</div>
</Grid>
<SaveButtonBar
state={saveButtonBarState}
disabled={disabled || !hasChanged}
onCancel={onBack}
onDelete={onCollectionRemove}
onSave={submit}
/>
</Container>
);
}}
</Form>
</div>
</Grid>
<SaveButtonBar
state={saveButtonBarState}
disabled={disabled || !hasChanged}
onCancel={onBack}
onDelete={onCollectionRemove}
onSave={submit}
/>
</Container>
)}
</CollectionUpdateForm>
);
};
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 { CollectionList_collections_edges_node } from "./types/CollectionList";
const content = richTextEditorFixtures.richTextEditor;
export const collections: CollectionList_collections_edges_node[] = [
{
__typename: "Collection",

View file

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

View file

@ -25,9 +25,8 @@ import { FormattedMessage, useIntl } from "react-intl";
import { getMutationState, maybe } from "../../misc";
import { productUrl } from "../../products/urls";
import { CollectionInput } from "../../types/globalTypes";
import CollectionDetailsPage, {
CollectionDetailsPageFormData
} from "../components/CollectionDetailsPage/CollectionDetailsPage";
import CollectionDetailsPage from "../components/CollectionDetailsPage/CollectionDetailsPage";
import { CollectionUpdateData } from "../components/CollectionDetailsPage/form";
import {
useCollectionAssignProductMutation,
useCollectionRemoveMutation,
@ -168,9 +167,7 @@ export const CollectionDetails: React.FC<CollectionDetailsProps> = ({
return <NotFoundPage onBack={handleBack} />;
}
const handleUpdate = async (
formData: CollectionDetailsPageFormData
) => {
const handleUpdate = async (formData: CollectionUpdateData) => {
const input: CollectionInput = {
backgroundImageAlt: formData.backgroundImageAlt,
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 { fade } from "@material-ui/core/styles/colorManipulator";
import Typography from "@material-ui/core/Typography";
import { CreateCSSProperties } from "@material-ui/styles/withStyles";
import { ChangeEvent } from "@saleor/hooks/useForm";
import EditorJS, { OutputData } from "@editorjs/editorjs";
import FormControl from "@material-ui/core/FormControl";
import FormHelperText from "@material-ui/core/FormHelperText";
import InputLabel from "@material-ui/core/InputLabel";
import classNames from "classnames";
import { RawDraftContentState } from "draft-js";
import {
BLOCK_TYPE,
DraftailEditor,
ENTITY_TYPE,
INLINE_STYLE
} from "draftail";
import isEqual from "lodash-es/isEqual";
import Undo from "editorjs-undo";
import React from "react";
import ErrorBoundary from "react-error-boundary";
import { FormattedMessage } from "react-intl";
import BoldIcon from "../../icons/BoldIcon";
import HeaderOne from "../../icons/HeaderOne";
import HeaderThree from "../../icons/HeaderThree";
import HeaderTwo from "../../icons/HeaderTwo";
import ItalicIcon from "../../icons/ItalicIcon";
import LinkIcon from "../../icons/LinkIcon";
import OrderedListIcon from "../../icons/OrderedListIcon";
import QuotationIcon from "../../icons/QuotationIcon";
import StrikethroughIcon from "../../icons/StrikethroughIcon";
import UnorderedListIcon from "../../icons/UnorderedListIcon";
import LinkEntity from "./LinkEntity";
import LinkSource from "./LinkSource";
import { RichTextEditorContentProps, tools } from "./RichTextEditorContent";
import useStyles from "./styles";
export interface RichTextEditorProps {
export type RichTextEditorChange = (data: OutputData) => void;
export interface RichTextEditorProps extends RichTextEditorContentProps {
disabled: boolean;
error: boolean;
helperText: string;
initial?: RawDraftContentState;
label: string;
name: string;
scroll?: boolean;
onChange: (event: React.ChangeEvent<any>) => void;
onChange: RichTextEditorChange;
}
const useStyles = makeStyles(
theme => {
const editorContainer: CreateCSSProperties = {
border: `1px ${theme.palette.divider} solid`,
borderRadius: 4,
padding: "27px 12px 10px",
position: "relative",
transition: theme.transitions.duration.shortest + "ms"
};
const RichTextEditor: React.FC<RichTextEditorProps> = ({
data,
disabled,
error,
helperText,
label,
name,
onChange,
onReady
}) => {
const classes = useStyles({});
return {
editorContainer,
error: {
color: theme.palette.error.main
},
helperText: {
marginTop: theme.spacing(0.75)
},
input: {
position: "relative"
},
label: {
fontSize: theme.typography.caption.fontSize,
left: 12,
position: "absolute",
top: 9
},
linkIcon: {
marginTop: 2
},
root: {
"& .DraftEditor": {
"&-editorContainer": {
"& .public-DraftEditor-content": {
lineHeight: 1.62
},
"& a": {
color: theme.palette.primary.light
},
"&:after": {
background: theme.palette.getContrastText(
theme.palette.background.default
),
bottom: -11,
content: "''",
display: "block",
height: 2,
left: -12,
position: "absolute",
transform: "scaleX(0) scaleY(0)",
width: "calc(100% + 24px)"
},
position: "relative"
const [isFocused, setFocus] = React.useState(false);
const editor = React.useRef<EditorJS>();
const editorContainer = React.useRef<HTMLDivElement>();
React.useEffect(
() => {
if (data) {
editor.current = new EditorJS({
data,
holder: editorContainer.current,
onChange: async api => {
const savedData = await api.saver.save();
onChange(savedData);
},
"&-root": {
...theme.typography.body1
}
},
"& .Draftail": {
"&-Editor": {
"&--focus": {
boxShadow: `inset 0px 0px 0px 2px ${theme.palette.primary.main}`
},
"&:hover": {
borderColor: theme.palette.primary.main
},
...editorContainer
},
"&-Toolbar": {
"&Button": {
"& svg": {
padding: 2
},
"&--active": {
"&:hover": {
background: theme.palette.primary.main
},
"&:not(:hover)": {
borderRightColor: theme.palette.primary.main
},
background: theme.palette.primary.main
},
"&:focus": {
"&:active": {
"&:after": {
background: fade(theme.palette.primary.main, 0.3),
borderRadius: "100%",
content: "''",
display: "block",
height: "100%",
width: "100%"
}
}
},
"&:hover": {
background: fade(theme.palette.primary.main, 0.3)
},
background: "none",
border: "none",
borderRight: `1px ${theme.palette.divider} solid`,
color: theme.typography.body1.color,
cursor: "pointer",
display: "inline-flex",
height: 36,
justifyContent: "center",
padding: theme.spacing(1) + 2,
transition: theme.transitions.duration.short + "ms",
width: 36
},
"&Group": {
"&:last-of-type": {
"& .Draftail-ToolbarButton": {
"&:last-of-type": {
border: "none"
}
}
},
display: "flex"
},
background: theme.palette.background.default,
border: `1px ${theme.palette.divider} solid`,
display: "inline-flex",
flexWrap: "wrap",
marginBottom: theme.spacing(),
marginTop: 10,
[theme.breakpoints.down(460)]: {
width: "min-content"
onReady: () => {
const undo = new Undo({ editor });
undo.initialize(data);
if (onReady) {
onReady();
}
},
"&-block": {
"&--blockquote": {
borderLeft: `2px solid ${theme.palette.divider}`,
margin: 0,
padding: theme.spacing(1, 2)
}
}
},
"&$error": {
"& .Draftail": {
"&-Editor": {
borderColor: theme.palette.error.main
}
}
}
},
scroll: {
"& .DraftEditor": {
"&-editorContainer": {
"& .public-DraftEditor-content": {
lineHeight: 1.62
}
}
}
},
smallIcon: {
marginLeft: 10
readOnly: disabled,
tools
});
}
};
},
{ name: "RichTextEditor" }
);
function handleSave(
value: any,
initial: any,
name: string,
onChange: (event: ChangeEvent) => void
) {
if (value && !isEqual(value, initial)) {
onChange({
target: {
name,
value
}
});
}
}
const RichTextEditor: React.FC<RichTextEditorProps> = props => {
const { error, helperText, initial, label, name, scroll, onChange } = props;
const classes = useStyles(props);
return editor.current?.destroy;
},
// Rerender editor only if changed from undefined to defined state
[data === undefined]
);
React.useEffect(() => editor.current?.destroy, []);
React.useEffect(() => {
if (editor.current?.readOnly) {
editor.current.readOnly.toggle(disabled);
}
}, [disabled]);
return (
<div
className={classNames({
[classes.error]: error,
[classes.root]: true,
[classes.scroll]: scroll
})}
<FormControl
data-test="richTextEditor"
data-test-id={name}
disabled={disabled}
error={error}
fullWidth
variant="outlined"
>
<div className={classes.input}>
<Typography className={classes.label} variant="caption" color="primary">
{label}
</Typography>
<ErrorBoundary
FallbackComponent={() => (
<div className={classes.editorContainer}>
<Typography color="error">
<FormattedMessage
defaultMessage="Invalid content"
description="rich text error"
/>
</Typography>
</div>
)}
>
<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>
<InputLabel focused={true} shrink={true}>
{label}
</InputLabel>
<div
className={classNames(classes.editor, classes.root, {
[classes.rootActive]: isFocused,
[classes.rootDisabled]: disabled,
[classes.rootError]: error
})}
ref={editorContainer}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
/>
<FormHelperText>{helperText}</FormHelperText>
</FormControl>
);
};
RichTextEditor.displayName = "RichTextEditor";
RichTextEditor.defaultProps = {
scroll: true
};
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";
import React from "react";
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>`;
const HeaderOne = createSvgIcon(
<>
<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;
export default Strikethrough;

View file

@ -2,9 +2,8 @@ import AppHeader from "@saleor/components/AppHeader";
import CardSpacer from "@saleor/components/CardSpacer";
import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton";
import Container from "@saleor/components/Container";
import Form from "@saleor/components/Form";
import Grid from "@saleor/components/Grid";
import Metadata, { MetadataFormData } from "@saleor/components/Metadata";
import Metadata from "@saleor/components/Metadata";
import PageHeader from "@saleor/components/PageHeader";
import SaveButtonBar from "@saleor/components/SaveButtonBar";
import SeoForm from "@saleor/components/SeoForm";
@ -13,30 +12,12 @@ import { PageErrorFragment } from "@saleor/fragments/types/PageErrorFragment";
import useDateLocalize from "@saleor/hooks/useDateLocalize";
import { SubmitPromise } from "@saleor/hooks/useForm";
import { sectionNames } from "@saleor/intl";
import { mapMetadataItemToInput } from "@saleor/utils/maps";
import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger";
import {
ContentState,
convertFromRaw,
convertToRaw,
RawDraftContentState
} from "draft-js";
import React from "react";
import { useIntl } from "react-intl";
import { maybe } from "../../../misc";
import { PageDetails_page } from "../../types/PageDetails";
import PageInfo from "../PageInfo";
export interface PageDetailsPageFormData extends MetadataFormData {
content: RawDraftContentState;
isPublished: boolean;
publicationDate: string;
seoDescription: string;
seoTitle: string;
slug: string;
title: string;
}
import PageForm, { PageData } from "./form";
export interface PageDetailsPageProps {
disabled: boolean;
@ -46,7 +27,7 @@ export interface PageDetailsPageProps {
saveButtonBarState: ConfirmButtonTransitionState;
onBack: () => void;
onRemove: () => void;
onSubmit: (data: PageDetailsPageFormData) => SubmitPromise;
onSubmit: (data: PageData) => SubmitPromise;
}
const PageDetailsPage: React.FC<PageDetailsPageProps> = ({
@ -60,140 +41,94 @@ const PageDetailsPage: React.FC<PageDetailsPageProps> = ({
}) => {
const intl = useIntl();
const localizeDate = useDateLocalize();
const {
isMetadataModified,
isPrivateMetadataModified,
makeChangeHandler: makeMetadataChangeHandler
} = useMetadataChangeTrigger();
const pageExists = page !== null;
const initialForm: PageDetailsPageFormData = {
content: maybe(
() => JSON.parse(page.contentJson),
convertToRaw(ContentState.createFromText(""))
),
isPublished: page?.isPublished,
metadata: pageExists ? page?.metadata?.map(mapMetadataItemToInput) : [],
privateMetadata: pageExists
? page?.privateMetadata?.map(mapMetadataItemToInput)
: [],
publicationDate: page?.publicationDate || "",
seoDescription: page?.seoDescription || "",
seoTitle: page?.seoTitle || "",
slug: page?.slug || "",
title: page?.title || ""
};
const handleSubmit = (data: PageDetailsPageFormData) => {
const metadata = isMetadataModified ? data.metadata : undefined;
const privateMetadata = isPrivateMetadataModified
? data.privateMetadata
: undefined;
return onSubmit({
...data,
isPublished: data.isPublished || !!data.publicationDate,
metadata,
privateMetadata
});
};
return (
<Form initial={initialForm} onSubmit={handleSubmit}>
{({ change, data, hasChanged, submit }) => {
const changeMetadata = makeMetadataChangeHandler(change);
return (
<Container>
<AppHeader onBack={onBack}>
{intl.formatMessage(sectionNames.pages)}
</AppHeader>
<PageHeader
title={
!pageExists
? intl.formatMessage({
defaultMessage: "Create Page",
description: "page header"
})
: maybe(() => page.title)
}
/>
<Grid>
<div>
<PageInfo
data={data}
disabled={disabled}
errors={errors}
page={page}
onChange={change}
/>
<CardSpacer />
<SeoForm
errors={errors}
allowEmptySlug={!pageExists}
description={data.seoDescription}
disabled={disabled}
descriptionPlaceholder={maybe(
() =>
convertFromRaw(data.content)
.getPlainText()
.slice(0, 300),
""
)}
onChange={change}
slug={data.slug}
slugPlaceholder={data.title}
title={data.seoTitle}
titlePlaceholder={data.title}
helperText={intl.formatMessage({
defaultMessage:
"Add search engine title and description to make this page easier to find"
})}
/>
<CardSpacer />
<Metadata data={data} onChange={changeMetadata} />
</div>
<div>
<CardSpacer />
<VisibilityCard
data={data}
errors={errors}
disabled={disabled}
messages={{
hiddenLabel: intl.formatMessage({
defaultMessage: "Hidden",
description: "page label"
}),
hiddenSecondLabel: intl.formatMessage(
{
defaultMessage: "will be visible from {date}",
description: "page"
},
{
date: localizeDate(data.publicationDate, "L")
}
),
visibleLabel: intl.formatMessage({
defaultMessage: "Visible",
description: "page label"
})
}}
onChange={change}
/>
</div>
</Grid>
<SaveButtonBar
disabled={disabled || !hasChanged}
state={saveButtonBarState}
onCancel={onBack}
onDelete={page === null ? undefined : onRemove}
onSave={submit}
/>
</Container>
);
}}
</Form>
<PageForm page={page} onSubmit={onSubmit}>
{({ change, data, handlers, hasChanged, submit }) => (
<Container>
<AppHeader onBack={onBack}>
{intl.formatMessage(sectionNames.pages)}
</AppHeader>
<PageHeader
title={
!pageExists
? intl.formatMessage({
defaultMessage: "Create Page",
description: "page header"
})
: page?.title
}
/>
<Grid>
<div>
<PageInfo
data={data}
disabled={disabled}
errors={errors}
onChange={change}
onContentChange={handlers.changeContent}
/>
<CardSpacer />
<SeoForm
errors={errors}
allowEmptySlug={!pageExists}
description={data.seoDescription}
disabled={disabled}
descriptionPlaceholder={""} // TODO: Cast description to string and trim it
onChange={change}
slug={data.slug}
slugPlaceholder={data.title}
title={data.seoTitle}
titlePlaceholder={data.title}
helperText={intl.formatMessage({
defaultMessage:
"Add search engine title and description to make this page easier to find"
})}
/>
<CardSpacer />
<Metadata data={data} onChange={handlers.changeMetadata} />
</div>
<div>
<CardSpacer />
<VisibilityCard
data={data}
errors={errors}
disabled={disabled}
messages={{
hiddenLabel: intl.formatMessage({
defaultMessage: "Hidden",
description: "page label"
}),
hiddenSecondLabel: intl.formatMessage(
{
defaultMessage: "will be visible from {date}",
description: "page"
},
{
date: localizeDate(data.publicationDate, "L")
}
),
visibleLabel: intl.formatMessage({
defaultMessage: "Visible",
description: "page label"
})
}}
onChange={change}
/>
</div>
</Grid>
<SaveButtonBar
disabled={disabled || !hasChanged}
state={saveButtonBarState}
onCancel={onBack}
onDelete={page === null ? undefined : onRemove}
onSave={submit}
/>
</Container>
)}
</PageForm>
);
};
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 CardTitle from "@saleor/components/CardTitle";
import FormSpacer from "@saleor/components/FormSpacer";
import RichTextEditor from "@saleor/components/RichTextEditor";
import RichTextEditor, {
RichTextEditorChange
} from "@saleor/components/RichTextEditor";
import { PageErrorFragment } from "@saleor/fragments/types/PageErrorFragment";
import { commonMessages } from "@saleor/intl";
import { getFormErrors } from "@saleor/utils/errors";
@ -12,16 +14,14 @@ import getPageErrorMessage from "@saleor/utils/errors/page";
import React from "react";
import { useIntl } from "react-intl";
import { maybe } from "../../../misc";
import { PageDetails_page } from "../../types/PageDetails";
import { PageDetailsPageFormData } from "../PageDetailsPage";
import { PageData } from "../PageDetailsPage/form";
export interface PageInfoProps {
data: PageDetailsPageFormData;
data: PageData;
disabled: boolean;
errors: PageErrorFragment[];
page: PageDetails_page;
onChange: (event: React.ChangeEvent<any>) => void;
onContentChange: RichTextEditorChange;
}
const useStyles = makeStyles(
@ -34,7 +34,7 @@ const useStyles = makeStyles(
);
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 intl = useIntl();
@ -56,22 +56,22 @@ const PageInfo: React.FC<PageInfoProps> = props => {
defaultMessage: "Title",
description: "page title"
})}
name={"title" as keyof PageDetailsPageFormData}
name={"title" as keyof PageData}
value={data.title}
onChange={onChange}
/>
<FormSpacer />
<RichTextEditor
data={data.content}
disabled={disabled}
error={!!formErrors.contentJson}
helperText={getPageErrorMessage(formErrors.contentJson, intl)}
initial={maybe(() => JSON.parse(page.contentJson))}
label={intl.formatMessage({
defaultMessage: "Content",
description: "page content"
})}
name={"content" as keyof PageDetailsPageFormData}
onChange={onChange}
name={"content" as keyof PageData}
onChange={onContentChange}
/>
</CardContent>
</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 { PageList_pages_edges_node } from "./types/PageList";
const content = richTextEditorFixtures.richTextEditor;
export const pageList: PageList_pages_edges_node[] = [
{
__typename: "Page",

View file

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

View file

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

View file

@ -14,9 +14,8 @@ import { FormattedMessage, useIntl } from "react-intl";
import { getStringOrPlaceholder, maybe } from "../../misc";
import { PageInput } from "../../types/globalTypes";
import PageDetailsPage, {
PageDetailsPageFormData
} from "../components/PageDetailsPage";
import PageDetailsPage from "../components/PageDetailsPage";
import { PageData } from "../components/PageDetailsPage/form";
import { TypedPageRemove, TypedPageUpdate } from "../mutations";
import { TypedPageDetailsQuery } from "../queries";
import { PageRemove } from "../types/PageRemove";
@ -27,7 +26,7 @@ export interface PageDetailsProps {
params: PageUrlQueryParams;
}
const createPageInput = (data: PageDetailsPageFormData): PageInput => ({
const createPageInput = (data: PageData): PageInput => ({
contentJson: JSON.stringify(data.content),
isPublished: data.isPublished,
publicationDate: data.publicationDate,
@ -63,7 +62,7 @@ export const PageDetails: React.FC<PageDetailsProps> = ({ id, params }) => {
{(pageUpdate, pageUpdateOpts) => (
<TypedPageDetailsQuery variables={{ id }}>
{pageDetails => {
const handleUpdate = async (data: PageDetailsPageFormData) => {
const handleUpdate = async (data: PageData) => {
const result = await pageUpdate({
variables: {
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 { SearchProductTypes_search_edges_node } from "@saleor/searches/types/SearchProductTypes";
import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/SearchWarehouses";
import { ContentState, convertToRaw } from "draft-js";
import React from "react";
import { useIntl } from "react-intl";
@ -86,12 +85,6 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
const intl = useIntl();
const localizeDate = useDateLocalize();
// Ensures that it will not change after component rerenders, because it
// generates different block keys and it causes editor to lose its content.
const initialDescription = React.useRef(
convertToRaw(ContentState.createFromText(""))
);
// Display values
const [selectedCategory, setSelectedCategory] = useStateFromProps(
initial?.category || ""
@ -144,8 +137,8 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
data={data}
disabled={disabled}
errors={errors}
initialDescription={initialDescription.current}
onChange={change}
onDescriptionChange={handlers.changeDescription}
/>
<CardSpacer />
{data.attributes.length > 0 && (
@ -167,6 +160,7 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
weightUnit={weightUnit}
onChange={change}
/>
<CardSpacer />
<ProductPricing
currency={currency}
data={data}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,11 +5,13 @@ import {
} from "@saleor/types/globalTypes";
import { warehouseList } from "@saleor/warehouses/fixtures";
import { content } from "../storybook/stories/components/RichTextEditor";
import * as richTextEditorFixtures from "../components/RichTextEditor/fixtures.json";
import { ProductDetails_product } from "./types/ProductDetails";
import { ProductList_products_edges_node } from "./types/ProductList";
import { ProductVariantCreateData_product } from "./types/ProductVariantCreateData";
const content = richTextEditorFixtures.richTextEditor;
export const product: (
placeholderImage: string
) => ProductDetails_product &

View file

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

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/PhoneField");
require("./stories/components/PriceField");
require("./stories/components/RichTextEditor");
require("./stories/components/SaveButtonBar");
require("./stories/components/SaveFilterTabDialog");
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 { storiesOf } from "@storybook/react";
import React from "react";
import PageDetailsPage, {
PageDetailsPageFormData,
PageDetailsPageProps
} from "../../../pages/components/PageDetailsPage";
import { page } from "../../../pages/fixtures";
@ -36,7 +36,7 @@ storiesOf("Views / Pages / Page details", module)
"isPublished",
"seoDescription",
"seoTitle"
] as Array<keyof PageDetailsPageFormData>).map(field => ({
] as Array<keyof PageData>).map(field => ({
__typename: "PageError",
code: PageErrorCode.INVALID,
field

View file

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

View file

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

View file

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

View file

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