Merge pull request #800 from mirumee/SALEOR-916-editor-js
Replace Draftail with EditorJS
This commit is contained in:
commit
807fcc670d
58 changed files with 2379 additions and 17342 deletions
|
@ -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
49
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
|
|
96
src/categories/components/CategoryCreatePage/form.tsx
Normal file
96
src/categories/components/CategoryCreatePage/form.tsx
Normal 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;
|
|
@ -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>
|
||||
|
|
|
@ -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";
|
||||
|
|
111
src/categories/components/CategoryUpdatePage/form.tsx
Normal file
111
src/categories/components/CategoryUpdatePage/form.tsx
Normal 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;
|
|
@ -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",
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
|
110
src/collections/components/CollectionCreatePage/form.tsx
Normal file
110
src/collections/components/CollectionCreatePage/form.tsx
Normal 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;
|
|
@ -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>
|
||||
|
|
|
@ -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";
|
||||
|
|
122
src/collections/components/CollectionDetailsPage/form.tsx
Normal file
122
src/collections/components/CollectionDetailsPage/form.tsx
Normal 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;
|
|
@ -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",
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
28
src/components/RichTextEditor/RichTextEditor.stories.tsx
Normal file
28
src/components/RichTextEditor/RichTextEditor.stories.tsx
Normal 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} />);
|
|
@ -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;
|
||||
|
|
79
src/components/RichTextEditor/RichTextEditorContent.tsx
Normal file
79
src/components/RichTextEditor/RichTextEditorContent.tsx
Normal 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;
|
74
src/components/RichTextEditor/fixtures.json
Normal file
74
src/components/RichTextEditor/fixtures.json
Normal file
|
@ -0,0 +1,74 @@
|
|||
{
|
||||
"richTextEditor": {
|
||||
"time": 1603898483525,
|
||||
"blocks": [
|
||||
{ "type": "header", "data": { "text": "Lorem ipsum ", "level": 1 } },
|
||||
{
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "Dolor sit amet, consectetur adipiscing elit. <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"
|
||||
}
|
||||
}
|
112
src/components/RichTextEditor/styles.ts
Normal file
112
src/components/RichTextEditor/styles.ts
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
|
|
118
src/pages/components/PageDetailsPage/form.tsx
Normal file
118
src/pages/components/PageDetailsPage/form.tsx
Normal 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;
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -22,6 +22,7 @@ const pageCreate = gql`
|
|||
pageCreate(input: $input) {
|
||||
errors: pageErrors {
|
||||
...PageErrorFragment
|
||||
message
|
||||
}
|
||||
page {
|
||||
...PageDetailsFragment
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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 &
|
||||
|
|
|
@ -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
|
@ -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");
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
));
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
16
src/utils/data/getPublicationData.ts
Normal file
16
src/utils/data/getPublicationData.ts
Normal 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;
|
16
src/utils/metadata/getMetadata.ts
Normal file
16
src/utils/metadata/getMetadata.ts
Normal 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;
|
73
src/utils/richText/useRichText.test.ts
Normal file
73
src/utils/richText/useRichText.test.ts
Normal 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();
|
||||
});
|
||||
});
|
32
src/utils/richText/useRichText.ts
Normal file
32
src/utils/richText/useRichText.ts
Normal 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;
|
Loading…
Reference in a new issue