How to use Draft.js WYSWYG with Next.js and Strapi Backend, Create and View Article with Image Upload

May 07, 2021

Introduction

In this post, we will use Draft.js WYSWYG editor in Next.js with strapi. And, we will create a write article page.

We will do following:

  • Use Draft.js with Next.js
  • Image Upload support in Draft.js WYSWYG editor
  • Create an Article with Image Upload support
  • Redirect to Article page after creation
  • URL will be slugified url
  • Redirect to 404 page, if url not found.

Previously we have seen:

In this post, I will be using my Next.js starter bootstrap

Integrating Draft.js

Draft.js as an awesome WYSWYG editor from facebook. It is one of the top used component in React. Lets integrate it into our Next.js application.

Modules required

draft-js
react-draft-wysiwyg

Its pertty simple to use. And you can customize it according to your need. I’m using the simple default one in this app.

Create a Component for Draft.js WYSWYG Editor with Image Upload

Lets create a separate component for Draft.js, so that we can easily customize it later on. Add a new file at: /components/editor/editor.jsx

I will be using my Utility class for REST API in Next.js

import React, { Component } from 'react'
import {EditorState} from "draft-js";
import dynamic from 'next/dynamic'; 
import apiClient from '../api/api_client'
import { convertFromRaw, convertToRaw } from 'draft-js';
const Editor = dynamic(
  () => import('react-draft-wysiwyg').then(mod => mod.Editor),
  { ssr: false }
)
import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css";

export default class ArticleEditor extends Component {
  constructor(props) {
    super(props);
    
    this.state = {
      editorState: EditorState.createEmpty()
    };
  }

  onEditorStateChange = (editorState) => {
    this.setState({
      editorState,
    });
    this.props.handleContent(
        convertToRaw(editorState.getCurrentContent()
    ));
  };

  uploadImageCallBack = async (file) => {
    const imgData = await apiClient.uploadInlineImageForArticle(file);
    return Promise.resolve({ data: { 
      link: `${process.env.NEXT_PUBLIC_API_URL}${imgData[0].formats.small.url}`
    }});
  }

  render() {
    const { editorState } = this.state;
    return (
      <Editor
        editorState={editorState}
        toolbarClassName="toolbar-class"
        wrapperClassName="wrapper-class"
        editorClassName="editor-class"
        onEditorStateChange={this.onEditorStateChange}
        // toolbarOnFocus
        toolbar={{
          options: ['inline', 'blockType', 'fontSize', 'fontFamily', 'list', 'textAlign', 'colorPicker', 'link', 'embedded', 'emoji', 'image', 'history'],
          inline: { inDropdown: true },
          list: { inDropdown: true },
          textAlign: { inDropdown: true },
          link: { inDropdown: true },
          history: { inDropdown: true },
          image: { 
            urlEnabled: true,
            uploadEnabled: true,
            uploadCallback: this.uploadImageCallBack, 
            previewImage: true,
            alt: { present: false, mandatory: false } 
          },
        }}
      />
    )
  }
}

Understanding Dynamic use for react-draft-wysiwyg

First notice, how we have used: react-draft-wysiwyg. It has been imported differently. If we import it like:

import Editor from 'react-draft-wysiwyg';

Then, we will get following error:

Server Error
ReferenceError: window is not defined

Event Handlers for Input

We have set two event handlers for title and body.

For the body where we have used Draft.js WYSWYG. We are using a Draft.js function convertToRaw to save data in state.

Image Upload

We have configured an option in options for Draft.js

image: { 
  urlEnabled: true,
  uploadEnabled: true,
  uploadCallback: this.uploadImageCallBack, 
  previewImage: true,
  alt: { present: false, mandatory: false } 
},

By default, We have enabled image upload. And have defined a callback handler for image upload. I have made alt off for users to enter. Else, the dialog box for image upload will not allow to upload image without entering alt attribute for image. Although, its a good feature but think from a user’s perspective. Why would he be forced to enter this.

For Image upload, I have used my Rest client class.

uploadImageCallBack = async (file) => {
    const imgData = await apiClient.uploadInlineImageForArticle(file);
    return Promise.resolve({ data: { 
      link: `${process.env.NEXT_PUBLIC_API_URL}${imgData[0].formats.small.url}`
    }});
  }

The upload image callback need to return a promise with link of image. So that, it can show that image in the editor. In your case, the path might change.

And, the code from apiClient for uploading image:

async uploadInlineImageForArticle(file) {
  const headers = await this.getAuthHeader();
  const formData = new FormData();
  formData.append('files', file);
  try {
    let { data } = await axios.post(
      `${process.env.NEXT_PUBLIC_API_URL}/upload`, 
      formData,
      {
        headers: headers,
      }
    )
    return data;
  } catch (e) {
    console.log('caught error');
    console.error(e);
    return null;
  }
}

Note, you would need to configure permission to upload image in your strapi backend.

Create Write Page and Redirect after save

Page: /pages/write.jsx

import SimpleLayout from '../components/layout/simple'
import React, { Component } from 'react'
import { Form, Button, Card } from 'react-bootstrap';
import { withRouter } from 'next/router'
import ArticleEditor from '../components/editor/editor'
import apiClient from '../components/api/api_client'

class Write extends Component {
  constructor(props) {
    super(props);
    this.state = {
      title: "",
      body: ""
    };

    this.handleInputs = this.handleInputs.bind(this);
    this.submitForm = this.submitForm.bind(this);
  }

  handleInputs = (event) => {
    let {name, value} = event.target
    this.setState({
      [name]: value
    });
  }
  handleEditorContent = (content) => {
    this.setState({
      body: content,
      articleUpdated: true
    });
  }

  submitForm = async (event) => {
    event.preventDefault()
    let article = await apiClient.saveArticle({
      title: this.state.title,
      body: JSON.stringify(this.state.body)
    })
    this.props.router.push(`/articles/${article.slug}`);
  }

  render() {
    return (
      <SimpleLayout>
      <div className="row">
        <div className="col-8">
        <Form onSubmit={this.submitForm}>
          <Form.Group controlId="formBasicEmail">
            <Form.Label>Headline</Form.Label>
            <Form.Control type="text" 
              name="title"
              value={this.state.title}
              onChange={this.handleInputs} />
            <Form.Text className="text-muted">
              Give a nice title to your article
            </Form.Text>
          </Form.Group>

          <Form.Group controlId="exampleForm.ControlTextarea2">
            <Form.Label>Body</Form.Label>
            <Card className="p-2">
              <ArticleEditor 
                handleContent={this.handleEditorContent}
                />
            </Card>
          </Form.Group>
          <Button variant="primary" type="submit">
            Submit
          </Button>
        </Form>
        </div>
        <div className="col-4">
          Another col
        </div>
      </div>
    </SimpleLayout>
    )
  }
}
export default withRouter(Write);

So we have integrated our Draft.js component here. We are setting the state as blank title and body.

Screenshots

Image uploader in draft.js

Image uploaded in draft.js

Rendered in draft.js

Redirect after saving

I hope you have read Configure strapi for sluify url

When we submit request to save an article. We get the data back and a slug field as well. We would want the URL to be something like:

/articles/article-title-slug

Example, I have put title as: Hello Article 1, following is the response:

author: {confirmed: true, blocked: false, _id: "608e0771a81d84396e94a1d8", username: "test", email: "[email protected]", …}
body: "{"blocks":[{"key":"dcdja","text":"Hi Body","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}},{"key":"8fnnj","text":"","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}},{"key":"7lcfr","text":" ","type":"atomic","depth":0,"inlineStyleRanges":[],"entityRanges":[{"offset":0,"length":1,"key":0}],"data":{}},{"key":"7dk6o","text":"","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{"0":{"type":"IMAGE","mutability":"MUTABLE","data":{"src":"http://localhost:1337/uploads/small_cricket_155965_640_ab625e04e3.png","height":"auto","width":"auto"}}}}"
createdAt: "2021-05-07T10:47:37.411Z"
id: "60951ac97425296c83caf0fe"
published_at: "2021-05-07T10:47:37.392Z"
slug: "hello-article-1"
title: "Hello Article 1"
updatedAt: "2021-05-07T10:47:37.418Z"
__v: 0
_id: "60951ac97425296c83caf0fe"

Note the slug: slug: "hello-article-1"

To redirect, I have imported withRouter

import { withRouter } from 'next/router'

And, exported the component wrapped under withRouter

export default withRouter(Write);

Now, in submitForm handler, I have used below code:

this.props.router.push(`/articles/${article.slug}`);

Now, I should be having a page which will receive this call: /pages/articles/[...slug].jsx

import SimpleLayout from '../../components/layout/simple'
import draftToHtml from 'draftjs-to-html';
import { useSession, getSession } from 'next-auth/client'
import React from 'react'
import Link from 'next/link'
import ErrorPage from 'next/error'
import apiClient from '../../components/api/api_client'

export default function Article(props) {
  if (props.error) {
    return <SimpleLayout>
      <ErrorPage statusCode={props.error.statusCode} />
    </SimpleLayout>
  }
  const body = draftToHtml(JSON.parse(props.body));
  return (
    <SimpleLayout>
      <h1>{props.title}</h1>
      <h4>Author: {props.author.username}</h4>
      <div dangerouslySetInnerHTML={{__html: body}}></div>
    </SimpleLayout>
  )
}

export async function getServerSideProps(context) {
  const session = await getSession(context);

  try {
    let data = await apiClient.getArticleBySlug(context.query.slug[0]);
    if (!data || data.length == 0) {
      return {props: {error: {statusCode: 404}}}
    }

    return {props: data[0]}
  } catch(error) {
    return {props: {error: {statusCode: 404}}}
  }
}

Here, I’m fetching article by slug by api:

GET /articles?slug=<slug>

Page will look like:

Page saved with draft.js

Note the url: http://localhost:3000/articles/hello-article-1

Understand Format saved by Draft.js

We have two workflows:

  1. Save content by Draft.js
  2. Load content from Draft.js

Save content by Draft.js

Whatever text and image we have written in the WYSWYG editor, it will not be saved as text. It generates a json.

If you notice above code, on editor content change, we are saving state as:

convertToRaw(editorState.getCurrentContent()

And, in write.jsx, before saving, we are doing:

body: JSON.stringify(this.state.body)

A sample json from above example:

{
    "blocks": [
        {
            "key": "dcdja",
            "text": "Hi Body",
            "type": "unstyled",
            "depth": 0,
            "inlineStyleRanges": [],
            "entityRanges": [],
            "data": {}
        },
        {
            "key": "8fnnj",
            "text": "",
            "type": "unstyled",
            "depth": 0,
            "inlineStyleRanges": [],
            "entityRanges": [],
            "data": {}
        },
        {
            "key": "7lcfr",
            "text": " ",
            "type": "atomic",
            "depth": 0,
            "inlineStyleRanges": [],
            "entityRanges": [
                {
                    "offset": 0,
                    "length": 1,
                    "key": 0
                }
            ],
            "data": {}
        },
        {
            "key": "7dk6o",
            "text": "",
            "type": "unstyled",
            "depth": 0,
            "inlineStyleRanges": [],
            "entityRanges": [],
            "data": {}
        }
    ],
    "entityMap": {
        "0": {
            "type": "IMAGE",
            "mutability": "MUTABLE",
            "data": {
                "src": "http://localhost:1337/uploads/small_cricket_155965_640_ab625e04e3.png",
                "height": "auto",
                "width": "auto"
            }
        }
    }
}

And we save this json as string in our database.

Load content from Draft.js

Now, its time to load the content.

We are using a module draftjs-to-html and below code:

import draftToHtml from 'draftjs-to-html';
const body = draftToHtml(JSON.parse(props.body));

<div dangerouslySetInnerHTML={{__html: body}}></div>

draftToHtml convert our draft.json to renderable html. Which we are using in last div tag.

Redirect to 404 page if Article not found

Next.js provides a handler for this. We have used following code:

import ErrorPage from 'next/error'
<ErrorPage statusCode={props.error.statusCode} />

From getServerSideProps, we are checking if we got nothing by get api. And, then setting error code. In the function component, we are checking if error is set, redirect to that error code page.

Editing Saved Articles

This article is already so long, I’m moving Edit part in nexrt article: Edit Article in Next.js with Draft.js

Complete Code at Github

In next post, we will see complete code in Github.


Similar Posts

Latest Posts