Using DraftJS with GraphQL

I am prototyping all the time. Enjoying learning about DraftJS.

tl;dr I successfully created GraphQL types from DraftJS ContentState. You may be thinking “what the hell does that mean?” I’ll explain. This work will evolve daily, but I learned enough to share my learnings and traumas.

Facebook released a fantastic open source editor, Draft, built on top of React and ImmutableJS. Theoretically, you can use it in your apps as part of your CMS. As always, the devil is in the details.

  • Do I need a WYSIWYG editor? Maybe. The trend in CMSs these days is clutter-free UI and parity between editor / front-end
  • What does the data look like that comes out of Draft? The simple explanation: you get JSON. But really, you get objects that contain the current state of the editor. When saved, you can retrieve your previous state to pick up where you left off.
  • How do we store that data? That’s really the $1,000,000 question. I am going to show how I store the data using MongoDB.
  • How do I convert into something usable for my website? That will be another blog post. There are formatters available, most of which spit out HTML, which I would argue is bad. In a future post, I will explain how I convert this data into React components, both for web and React Native.
  • How am I retrieving my saved data? As the title of this post foreshadows, I am going to show you what I do with Apollo and GraphQL.

EVERYONE has to answer these questions when using Draft. There is no current boilerplate that makes all of this magically work.

The Editor

Here is some pseudo code to describe the flow of information from the editor:

import { Editor, convertToRaw } from ‘draft-js’;
// somewhere in your React code
const formFields = {};
const onChange = content => {
const converted = convertToRaw(content);
// logic will go here later
formFields.content = converted;
};
<Editor onChange={onChange} />

Storing the Data

Eventually, we want this data to end up in MongoDB as an embedded document. I have a collection called post, containing documents with a field named contentState.

I mention the Mongo piece now, because it is probably the simplest. However the data ends up flowing to us, our pseudo-code will eventually look like:

const collection = db.collection('post');
const docToInsert = Object.assign({}, formFields, {
createdAt: Date.now(),
updatedAt: Date.now(),
});
// logic will go here later
return (await collection.insertOne(docToInsert)).insertedId;

GraphQL Types

Before we can even consider querying the data or performing a mutation on it, we need GraphQL types for what in DraftJS is called ContentState. DraftJS uses Flow-typing, but we will not have a one-to-one relationship with those types. Many of the types used by Draft internally come from ImmutableJS. The data we get back from our convertToRaw(editorState) call is a vanilla JS representation of a much more sophisticated type system. Even being vanilla, we need to further mutate the data to make it possible to represent it in our GraphQL type system.

ContentState

ContentState is an Object with 2 top-level properties:

{ blocks: [...], entityMap: { 0: {...}, 1: {...}, 2: {...} }}

When you have this value, you can hydrate your Draft Editor instance with your existing editorState:

import { EditorState, convertFromRaw } from ‘draft-js’;
EditorState.createWithContent(convertFromRaw(contentState));

entityMap is the exported representation of an ImmutableJS OrderedMap. Knowing what we do about GraphQL, representing an object like this would be messy. The good news is that convertFromRaw still works if you pass an array for the value of entityMap.We can convert the value when responding the Editor’s onChange event:

const converted = convertToRaw(content);
const value = {
blocks: [ ...converted.blocks ],
entityMap: { ...converted.entityMap },
};
value.entityMap = Object.keys(value.entityMap)
.sort()
.map(i => value.entityMap[i]);

When converting the data, we also need to copy most objects, since much of that date is frozen or not mutable. After converting that structure, we are ready for our first GraphQL type, ContentState:

type ContentState {
blocks: [Block]
entityMap: [Entity]
}

And because we are going to be sending nested structured data with our GraphQL mutations, we also need an input type:

input ContentStateInput {
blocks: [BlockInput]
entityMap: [EntityInput]
}

Mutations take inputs, and when you pass objects as the value for fields, it expects those values to also be inputs.

Our two main types are Block and Entity. It is unimportant for the moment to describe each field within them, but know that they represent the internal structure Draft uses to describe the state of your content.

For each type, we need an input:

type InlineStyleRange {
offset: Int
length: Int
style: String
}
input InlineStyleRangeInput {
offset: Int
length: Int
style: String
}
type EntityRange {
offset: Int
length: Int
key: Int
}
input EntityRangeInput {
offset: Int
length: Int
key: Int
}
type Data {
id: String
}
input DataInput {
id: String
}
type Block {
key: String
text: String
type: String
depth: Int
inlineStyleRanges: [InlineStyleRange]
entityRanges: [EntityRange]
data: Data
}
input BlockInput {
key: String
text: String
type: String
depth: Int
inlineStyleRanges: [InlineStyleRangeInput]
entityRanges: [EntityRangeInput]
data: DataInput
}

For Block for need to create a type for Data, even though data is typically empty. The Block API has methods to interact with this field, but I cannot find instances of it being used by Draft internally. If it was used, data would be an opaque structure — meaning, we have no idea what is in it.

For our Entity types, we are going to use enums and a union:

type LinkData {
type: String
href: String
target: String
}
type EmbedData {
type: String
url: String
html: String
}
union EntityData = LinkData | EmbedData
input EntityDataInput {
type: String!
# EMBED fields
url: String
html: String
# LINK fields
href: String
target: String
}
enum EntityType {
LINK
TOKEN
PHOTO
IMAGE
EMBED
}
enum EntityMutability {
MUTABLE
IMMUTABLE
SEGMENTED
}
type Entity {
type: EntityType
mutability: EntityMutability
data: EntityData
}
input EntityInput {
type: EntityType
mutability: EntityMutability
data: EntityDataInput
}

data attached to Entity is also opaque, unless we describe with our union type, which I highly suggest doing — this won’t work otherwise. These values will evolve as your editor evolves.

We also notice a limitation of GraphQL as pertains to Union types and Input types:

  • When querying, we can specify a union as the type for a field.
  • For mutations, we cannot.

If a field can return a union when queried, a corresponding mutation needs to send all of the fields for every possible type to pass schema validation. See: EntityDataInput. If we create a ton of entities, this will be a mess. However, the concept of something like “embed” can mean any embeddable media, like YouTube, Vimeo, etc.

Once again, returning to our Editor’sonChange event:

const fields = ['url', 'html', 'href', 'target'];
const converted = convertToRaw(content);
const value = {
blocks: [ ...converted.blocks ],
entityMap: { ...converted.entityMap },
};
const entityMap = Object.keys(value.entityMap)
.sort()
.map(i => {
const entity = Object.assign({}, value.entityMap[i]);
// Input types cannot be unions, so all fields from all
// entity data input types have to exist when setting
// data for entities
const entityData = Object.assign({}, entity.data);
entityData.type = entity.type;
// Apollo adds this
delete entityData.__typename;
fields.forEach(key => {
if (!entityData[key]) {
entityData[key] = '';
}
});
return {
...entity,
data: entityData,
};
});
value.entityMap = entityMap;

We need the data from the extra fields so that EntityDataInput passes schema validation when are committing a mutation. We do not want to save the extra data though, so we will return to our MongoDB code to remove it at the last possible second:

// this is admittedly gnarly, albeit necessary
// remove the extra fields
function convertEntityData(entityMap) {
return entityMap.map(entity => {
const e = Object.assign({}, entity);
if (e.data.type === 'EMBED') {
delete e.data.href;
delete e.data.target;
} else if (e.data.type === 'LINK') {
delete e.data.url;
delete e.data.html;
}
return e;
});
}
// use in our insertion logic
const docToInsert = Object.assign({}, doc, {
createdAt: Date.now(),
updatedAt: Date.now(),
});
docToInsert.contentState.entityMap =
convertEntityData(docToInsert.contentState.entityMap);
return (await collection.insertOne(docToInsert)).insertedId;

Post

Looking into the future, we want posts to expose 2 fields in our schema: content and contentState. That doesn’t mean we store the data twice, it just means we have 2 different representations of our content.

type Post {
content: Content
contentState: ContentState
}
type Query {
post(id: ObjID!): Post
}
input CreatePostInput {
title: String
contentState: ContentStateInput
}
input UpdatePostInput {
title: String
contentState: ContentStateInput
}
type Mutation {
createPost(input: CreatePostInput!): Post
updatePost(id: ObjID!, input: UpdatePostInput!): Post
removePost(id: ObjID!): Boolean
}

Using ContentState in Post:

import gql from 'graphql-tag';
// Query
@graphql(
gql`
query PostAdminQuery($id: ObjID!) {
post(id: $id) {
contentState {
...Editor_contentState
}
}
}
${Editor.fragments.contentState}
`,
{
options: ({ match: { params } }) => ({
variables: { id: params.id },
}),
}
)
// Mutation
@graphql(gql`
mutation CreatePostMutation($input: CreatePostInput!) {
createPost(input: $input) {
title
contentState {
...Editor_contentState
}
}
}
${Editor.fragments.contentState}
`)
Editor.fragments = {  
contentState: gql`
fragment Editor_contentState on ContentState {
blocks {
key
text
type
depth
inlineStyleRanges {
offset
length
style
}
entityRanges {
offset
length
key
}
}
entityMap {
type
mutability
data {
... on LinkData {
href
target
}
... on EmbedData {
url
html
}
}
}
}
`,
};

I am not going to share every line of code I wrote to make this happen. I just wanted to share that it is technically possible, and I plan on moving forward with a fun project I am working on that re-imagines a CMS like WordPress using Node, MongoDB, React, Emotion, and Apollo. Stay tuned!

One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.