Mark Tyrrell

Salesforce Architect

Build a Markdown Editor in Salesforce using Lightning Web Components

In this tutorial you will learn how to build a markdown editor in Salesforce using Lightning Web Components and a markdown parser.

markdown_editor_gif

Prerequisites #

Before you can start building Lightning Web Components you will need to setup your development environment and choose a development workflow.

If you haven't done this before, complete the Quick Start: Lightning Web Components project on Trailhead to learn the basics of setting up SFDX and creating Lightning Web Components.

As a minimum, you will need the following installed:


Create a new SFDX project #

The first step is to create a new SFDX project. Open up a terminal window, navigate to a directory you use for storing projects, and execute the following command to create a new project. You'll see from the output of this command that a new directory is created for the new project.

$ sfdx force:project:create --projectname markdown-editor --manifest
target dir = /Users/marktuk/projects
   create markdown-editor/sfdx-project.json
   create markdown-editor/README.md
   create markdown-editor/.forceignore
   create markdown-editor/config/project-scratch-def.json
   create markdown-editor/manifest/package.xml

Execute the following commands to navigate in to the new directory and create a basic package.json file which we'll need later to install some dependencies.

$ cd markdown-editor
$ npm init -y
Wrote to /Users/marktuk/projects/markdown-editor/package.json:
{
  "name": "markdown-editor",
  "version": "1.0.0",
  "description": "## Dev, Build and Test",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

Create an editor component #

Let's make a start by building a simple editor component. Execute the following command from within your project directory to create a new Lightning Web Component.

$ sfdx force:lightning:component:create --componentname MarkdownEditor --type lwc --outputdir force-app/main/default/lwc
target dir = /Users/marktuk/projects/markdown-editor/lwc
   create markdownEditor/markdownEditor.js
   create markdownEditor/markdownEditor.js-meta.xml
   create markdownEditor/markdownEditor.html

Open the markdownEditor.js JavaScript file. Let's create a property to store the raw markdown body. Add the track decorator to the imports at the top of the file, and define a new tracked property called body .

import { LightningElement, track } from 'lwc';

export default class MarkdownEditor extends LightningElement {
    @track body;
}

Tracked properties, or private reactive properties, re-render the component when their value changes. We want the body property to be tracked so that the preview updates in real-time.

We also need to make sure the property is updated when the value is changed in the UI. To do this, create an event handler method called handleBodyChange() that assigns the updated value to the property.

import { LightningElement, track } from 'lwc';

export default class MarkdownEditor extends LightningElement {
    @track body;

    handleBodyChange(event) {
        this.body = event.target.value;
    }
}

We need to give our markdown editor a UI, so let's build the HTML template. Open the markdownEditor.html HTML file, and start by giving the editor a container using <lightning-card> .

<template>
    <lightning-card title="Markdown Editor"></lightning-card>
</template>

Inside the card, we can use <lightning-layout> and <lightning-layout-item> to define a two column layout.

<template>
    <lightning-card title="Markdown Editor">
        <lightning-layout>
            <lightning-layout-item
                size="6"
                padding="around-small"
            ></lightning-layout-item>
            <lightning-layout-item
                size="6"
                padding="around-small"
            ></lightning-layout-item>
        </lightning-layout>
    </lightning-card>
</template>

In the first <lightning-layout-item> i.e. the left column, we are going to use a <textarea> to allow users to enter the raw markdown body. Importantly, we want to make sure anything entered in the <textarea> is assigned to the body property. To do this, we bind the handleBodyChange() method to the onkeyup event to capture changes when typing in the <textarea> , and the onchange event to capture changes from the context-menu such as right click copy & paste.

<template>
    <lightning-card title="Markdown Editor">
        <lightning-layout>
            <lightning-layout-item size="6" padding="around-small">
                <textarea
                    class="slds-textarea"
                    onchange="{handleBodyChange}"
                    onkeyup="{handleBodyChange}"
                    rows="20"
                ></textarea>
            </lightning-layout-item>

            <lightning-layout-item
                size="6"
                padding="around-small"
            ></lightning-layout-item>
        </lightning-layout>
    </lightning-card>
</template>

In the second <lightning-layout-item> i.e. the right column, we want to display a rendered markdown preview. For the moment, we can simply output the value of the body property.

<template>
    <lightning-card title="Markdown Editor">
        <lightning-layout>
            <lightning-layout-item size="6" padding="around-small">
                <textarea
                    class="slds-textarea"
                    onchange="{handleBodyChange}"
                    onkeyup="{handleBodyChange}"
                    rows="20"
                ></textarea>
            </lightning-layout-item>

            <lightning-layout-item size="6" padding="around-small">
                {body}
                <!-- markdown preview placeholder -->
            </lightning-layout-item>
        </lightning-layout>
    </lightning-card>
</template>

Now is a good opportunity to deploy/push the component to a sandbox or scratch org to give it a test drive. To allow us to do this we will need to update the markdownEditor.js-meta.xml configuration file. Update the isExposed property to true and add lightning__AppPage as a target to allow the component to be added to an app page using the Lightning App Builder.

<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata" fqn="MarkdownEditor">
    <apiVersion>45.0</apiVersion>
    <isExposed>true</isExposed>
    <masterLabel>Markdown Editor</masterLabel>
    <description>A simple markdown text editor.</description>
    <targets>
        <target>lightning__AppPage</target>
    </targets>
</LightningComponentBundle>

Create a new page in your org using the Lightning App Builder. You should see "Markdown Editor" listed under Custom on the left. Drag it on to the page, and then Save and Activate your page.

If you type something in to the <textarea> you will see the raw markdown appear in the right column as you type. Pretty neat, but it’s not particularly useful as a markdown editor. We need to build the preview to render the raw markdown.


Create a preview component #

We will build the markdown preview as a separate component to make it reusable. Execute the following command from within your project directory to create a new component.

$ sfdx force:lightning:component:create --componentname MarkdownPreview --type lwc --outputdir force-app/main/default/lwc

target dir = /Users/marktuk/projects/markdown-editor/lwc
   create markdownPreview/markdownPreview.js
   create markdownPreview/markdownPreview.js-meta.xml
   create markdownPreview/markdownPreview.html

To parse the markdown and generate HTML we’ll need to use a third party library. Grab a copy of markedjs and upload it to your sandbox or scratch org as a static resource named marked .

Open the markdownPreview.js JavaScript file. Update the imports to include the following:

  • import { LightningElement, api } from 'lwc'  — the LightningElement base class and the api decorator, which is similar to the track decorator, but exposes properties or methods as part of the components API.
  • import { loadScript } from 'lightning/platformResourceLoader' — a function that loads third-party JavaScript libraries and returns a promise that resolves when the file has loaded.
  • import MARKED_JS from '@salesforce/resourceUrl/marked' — a reference to the markedjs library we uploaded as a static resource.

After adding these imports, your markdownPreview.js file should look like this.

import { LightningElement, api } from 'lwc';
import { loadScript } from 'lightning/platformResourceLoader';
import MARKED_JS from '@salesforce/resourceUrl/marked';

export default class MarkdownPreview extends LightningElement {}

Before we can render any markdown we need to load the markedjs library. We can do this using the renderedCallback() life-cycle hook. This will ensure the DOM has loaded and is ready to be manipulated. The renderedCallback() is called every time the component re-renders, so we want to make sure we only load third party libraries once. We can do this by using a isRendered boolean variable to track if renderedCallback() has been executed.

Once the library is loaded, we can use it to render the markdown. However, we will need a property to store the markdown. Add a body property with a default value of an empty string to the component and use the api decorator to expose it as part of the component's API. This is how your markdownPreview.js file should look so far.

import { LightningElement, api } from 'lwc';
import { loadScript } from 'lightning/platformResourceLoader';
import MARKED_JS from '@salesforce/resourceUrl/marked';

export default class MarkdownPreview extends LightningElement {
    isRendered = false;
    @api body = '';

    renderedCallback() {
        if (this.isRendered) {
            return;
        }

        this.isRendered = true;

        loadScript(this, MARKED_JS).then(() => {
            // script has loaded
        });
    }
}

We have a place to store the raw markdown, but we will need a place in the component template to render it. Open the markdownPreview.html file and update the template to include a <div> element to act as a container for the rendered markdown. Importantly, we need to add the lwc:dom="manual" directive to the <div> element to tell the LWC engine to preserve styling on the rendered markdown.

<template>
    <div lwc:dom="manual"></div>
</template>

Let's have a go at rendering some markdown, add a renderMarkdown() method to the component that calls the marked function provided by the markedjs library and appends the result to the container <div> . We want to render the markdown as soon as everything is loaded, so add a call to this method inside the then after loadScript .

import { LightningElement, api } from 'lwc';
import { loadScript } from 'lightning/platformResourceLoader';
import MARKED_JS from '@salesforce/resourceUrl/marked';

export default class MarkdownPreview extends LightningElement {
    isRendered = false;
    @api body = '';

    renderedCallback() {
        if (this.isRendered) {
            return;
        }

        this.isRendered = true;

        loadScript(this, MARKED_JS).then(() => {
            this.renderMarkdown();
        });
    }

    renderMarkdown() {
        this.template.querySelector('div').innerHTML = marked(this.body);
    }
}

This is a good opportunity to start testing the preview component, open up the markdownEditor.html file and update it to use the new <c-markdown-preview> component, passing the editor body property through to the preview body property.

<template>
    <lightning-card title="Markdown Editor">
        <lightning-layout>
            <lightning-layout-item size="6" padding="around-small">
                <textarea
                    class="slds-textarea slds-text-font_monospace"
                    onchange="{handleBodyChange}"
                    onkeyup="{handleBodyChange}"
                    rows="20"
                ></textarea>
            </lightning-layout-item>

            <lightning-layout-item size="6" padding="around-small">
                <c-markdown-preview body="{body}"></c-markdown-preview>
            </lightning-layout-item>
        </lightning-layout>
    </lightning-card>
</template>

Go ahead and deploy/push to your org, open up the page with the editor component, type some markdown in, and...

It doesn't work. But why?

We only called the renderMarkdown() method once, in the renderedCallback() , what we need to do is call it every time the body property changes. To do that, we will need to change the body property to an accessor i.e. use a getter and setter.

Open up markdownPreview.js again and add a new private _body property with a default value of an empty string. Replace the original body property with a getter that returns the value of _body , and a setter that sets the the value of _body , but also calls the renderMarkdown() method. We can only safely call the renderMarkdown() method after the markedjs library has loaded, so check the isRendered boolean first. Don't forget to add the api decorator before the getter to make sure the property is still exposed as part of the component's API.

import { LightningElement, api } from 'lwc';
import { loadScript } from 'lightning/platformResourceLoader';
import MARKED_JS from '@salesforce/resourceUrl/marked';

export default class MarkdownPreview extends LightningElement {
    isRendered = false;
    _body = '';

    @api
    get body() {
        return this._body;
    }
    set body(value) {
        this._body = value;

        if (this.isRendered) {
            this.renderMarkdown();
        }
    }

    renderedCallback() {
        if (this.isRendered) {
            return;
        }

        this.isRendered = true;

        loadScript(this, MARKED_JS).then(() => {
            this.renderMarkdown();
        });
    }

    renderMarkdown() {
        this.template.querySelector('div').innerHTML = marked(this.body);
    }
}

If you deploy/push the updated component and test it, you should start seeing text in the preview again. However, you'll probably notice that it doesn't appear to be rendering the markdown. This is because we haven't defined any styles. Create a markdownPreview.css file inside the markdownPreview folder and add the following stylesheet.

h1,
h2,
h3,
h4,
h5,
h6,
p,
ul,
ol,
table {
    margin-bottom: 1rem;
}

h1,
h2 {
    border-bottom: 1px solid #dddbda;
}

h1 {
    font-size: 3.5em;
}
h2 {
    font-size: 3em;
}
h3 {
    font-size: 2.5em;
}
h4 {
    font-size: 2em;
}
h5 {
    font-size: 1.5em;
}
h6 {
    font-size: 1em;
}

h5,
h6 {
    font-weight: bold;
}

ul,
ol {
    margin-left: 2em;
}
ul {
    list-style-type: disc;
}
ol {
    list-style-type: decimal;
}

pre {
    padding: 1rem;
    background: #f8f8f8;
}

table {
    border: 1px solid #dddbda;
    border-collapse: collapse;
}

table th,
table td {
    border: 1px solid #dddbda;
    padding: 0.5em;
}

table th[align='left'],
table td[align='left'] {
    text-align: left;
}

table th[align='right'],
table td[align='right'] {
    text-align: right;
}

table th[align='center'],
table td[align='center'] {
    text-align: center;
}

Save the markdownPreview.css file, deploy/push to your org again and refresh the page. Now when you enter markdown you should see the rendered result in the preview.

It works! However, we only know it works because we tested it in the browser. We can go one step further by writing some automated tests to tell us it works without even having to deploy it to an org!


Write unit tests for the editor component #

Unit testing Lightning Web Components is really simple thanks to @salesforce/lwc-jest , a version of the Jest testing framework configured specifically to test Lightning Web Components.

To start using @salesforce/lwc-jest you will need to install it as a dependency. Remember when we executed npm init to create a package.json file? We did that so we could use npm to install dependencies such as @salesforce/lwc-jest . Execute the following command from within your project directory to install @salesforce/lwc-jest .

$ npm install @salesforce/lwc-jest --save-dev

It may take a moment to download all the dependencies. When it's done, open up your package.json file and you will see @salesforce/lwc-jest listed under devDependancies . While we've got the package.json file open, let's update it to include some scripts to make running Jest a bit simpler.

{
    "name": "markdown-editor",
    "private": true,
    "scripts": {
        "test:unit": "lwc-jest",
        "test:unit:watch": "lwc-jest --watch",
        "test:unit:debug": "lwc-jest --debug",
        "test:unit:coverage": "lwc-jest --coverage"
    },
    "devDependencies": {
        "@salesforce/lwc-jest": "^0.5.1"
    }
}

This will allow you to use npm run to run Jest from the terminal. For example, to run Jest and generate code coverage reports you can simple execute npm run test:unit:coverage .

We are now ready to write our first unit test. Create a directory named __tests__ inside the markdownEditor component directory. Create a file named markdownEditor.test.js inside the new __tests__ directory. Jest by default will only look for files inside a directory named __tests__ that end with .test.js so it's important to always stick to this naming convention.

Open the markdownEditor.test.js file and add the following imports.

import { createElement } from 'lwc';
import MarkdownEditor from 'c/markdownEditor';

The createElement method is a special method only available in tests, and is used to create an instance of the MarkdownEditor component, which we've also imported.

Next, let's add a describe block to group all the tests for the component. It's a best practice to have a single top level describe with a description matching the component name.

import { createElement } from 'lwc';
import MarkdownEditor from 'c/markdownEditor';

describe('c-markdown-editor', () => {});

Define a test using either it or test . Both functions define a test, so you can use whichever you prefer. Set a description that describes the functionality that will be tested.

import { createElement } from 'lwc';
import MarkdownEditor from 'c/markdownEditor';

describe('c-markdown-editor', () => {
    it('displays a preview when markdown is entered', () => {});
});

To start testing the component, we firstly need to create an instance of it and attach it to the DOM.

const element = createElement('c-markdown-editor', {
    is: MarkdownEditor
});
document.body.appendChild(element);

Then, we can query the <textarea> element and set it's value to something we can assert later.

const MARKDOWN_HEADING = '# Test Heading';

const textarea = element.shadowRoot.querySelector('textarea');
textarea.value = MARKDOWN_HEADING;

We then simulate the change event by dispatching it directly on the <textarea> .

textarea.dispatchEvent(new CustomEvent('change'));

Then, to make an assertion, we can simply query the <c-markdown-preview> component and assert the body property matches the value of the <textarea> .

const markdownPreview = element.shadowRoot.querySelector('c-markdown-preview');

expect(markdownPreview.body).toBe(MARKDOWN_HEADING);

So now we've written the first test, let's run it. Make sure your markdownEditor.test.js file looks like the one below, and run it by executing npm run test:unit in the terminal.

import { createElement } from 'lwc';
import MarkdownEditor from 'c/markdownEditor';

describe('c-markdown-editor', () => {
    it('displays a preview when markdown is entered', () => {
        const element = createElement('c-markdown-editor', {
            is: MarkdownEditor
        });
        document.body.appendChild(element);

        const MARKDOWN_HEADING = '# Test Heading';

        const textarea = element.shadowRoot.querySelector('textarea');
        textarea.value = MARKDOWN_HEADING;
        textarea.dispatchEvent(new CustomEvent('change'));

        const markdownPreview =
            element.shadowRoot.querySelector('c-markdown-preview');

        expect(markdownPreview.body).toBe(MARKDOWN_HEADING);
    });
});

After the test runs, you'll probably notice that it fails. There are still a few things we will need to address. You will probably be seeing ReferenceError: marked is not defined in the test results. This is because the markedjs library won't be loaded during the test. The test is running locally and doesn't have access to the static resource stored in the org. The simplest way to fix this is to mock the marked function.

global.marked = jest.fn();

You'll also notice the assertion is failing, the received value is undefined . This is because all DOM changes are performed asynchronously, so the test will need to wait for the DOM updates before checking for updated values. This can be done by returning a Promise in the test and performing the assertion after the promise resolves. Your updated test should look like the following.

import { createElement } from 'lwc';
import MarkdownEditor from 'c/markdownEditor';

global.marked = jest.fn();

describe('c-markdown-editor', () => {
    it('displays a preview when markdown is entered', () => {
        const element = createElement('c-markdown-editor', {
            is: MarkdownEditor
        });
        document.body.appendChild(element);

        const MARKDOWN_HEADING = '# Test Heading';

        const textarea = element.shadowRoot.querySelector('textarea');
        textarea.value = MARKDOWN_HEADING;
        textarea.dispatchEvent(new CustomEvent('change'));

        return Promise.resolve().then(() => {
            const markdownPreview =
                element.shadowRoot.querySelector('c-markdown-preview');

            expect(markdownPreview.body).toBe(MARKDOWN_HEADING);
        });
    });
});

Congratulations, you should now have a passing unit test for your new component. Strictly speaking there is still another test that needs writing. We only tested the change event, the keyup event also needs testing. You could simply copy the existing test and replace change with keyup , but that wouldn't be very DRY. Instead, with some re-factoring your completed tests for markdownEditor will look something like this.

import { createElement } from 'lwc';
import MarkdownEditor from 'c/markdownEditor';

global.marked = jest.fn();
const MARKDOWN_HEADING = '# Test Heading';

describe('c-markdown-editor', () => {
    beforeEach(() => {
        const element = createElement('c-markdown-editor', {
            is: MarkdownEditor
        });
        document.body.appendChild(element);
    });

    afterEach(() => {
        // The JSDOM instance is shared across test cases in a single file so
        // reset the DOM
        while (document.body.firstChild) {
            document.body.removeChild(document.body.firstChild);
        }
    });

    it('displays a preview when the textarea is changed', () => {
        const element = document.querySelector('c-markdown-editor');
        const textarea = element.shadowRoot.querySelector('textarea');
        textarea.value = MARKDOWN_HEADING;
        textarea.dispatchEvent(new CustomEvent('change'));

        return Promise.resolve().then(() => {
            const markdownPreview =
                element.shadowRoot.querySelector('c-markdown-preview');

            expect(markdownPreview.body).toBe(MARKDOWN_HEADING);
        });
    });

    it('displays a preview when typing in the textrea', () => {
        const element = document.querySelector('c-markdown-editor');
        const textarea = element.shadowRoot.querySelector('textarea');
        textarea.value = MARKDOWN_HEADING;
        textarea.dispatchEvent(new KeyboardEvent('keyup'));

        return Promise.resolve().then(() => {
            const markdownPreview =
                element.shadowRoot.querySelector('c-markdown-preview');

            expect(markdownPreview.body).toBe(MARKDOWN_HEADING);
        });
    });
});

Write unit tests for the preview component #

Testing the markdownPreview component is going to be very similar to testing the markdownEditor component. Create the __tests__ directory inside of markdownEditor directory, and inside the directory create a markdownEditor.test.js file that looks like the following.

import { createElement } from 'lwc';
import MarkdownPreview from 'c/markdownPreview';

const MARKDOWN_HEADING = '# Test Heading';
const HTML_HEADING = '<h1>Test Heading</h1>';

global.marked = jest.fn(() => HTML_HEADING);

describe('c-markdown-preview', () => {
    it('renders markdown when body is set', () => {
        const element = createElement('c-markdown-preview', {
            is: MarkdownPreview
        });
        document.body.appendChild(element);
    });
});

Before writing the rest of the test, let's look at the jest.fn() mock we're using for the marked function. You'll notice that this time we are passing in a function that returns a value. This is actually a mock implementation. Each time the marked function is called during the test it will now return the HTML_HEADING value. This means we can test the markdownPreview component without depending on the behaviour of the markedjs third party library.

Let's finish the test for markdownPreview by first setting the body property.

element.body = MARKDOWN_HEADING;

As before, the DOM changes will be asynchronous so we will need to wait for them before performing any assertions. However, the loadScript method used to load the markedjs library is also asynchronous, so we will also need to wait for it to resolve. Ideally, we should only perform assertions after all asynchronous actions have finished. We can do this using a helper function called flushPromises .

const flushPromises = () => new Promise((resolve) => setImmediate(resolve));

This function will return a Promise that resolves after all other asynchronous processing has finished. Using this, we can now finish the markdownPreview unit tests.

import { createElement } from 'lwc';
import MarkdownPreview from 'c/markdownPreview';

const MARKDOWN_HEADING = '# Test Heading';
const HTML_HEADING = '<h1>Test Heading</h1>';

global.marked = jest.fn(() => HTML_HEADING);

const flushPromises = () => new Promise((resolve) => setImmediate(resolve));

describe('c-markdown-preview', () => {
    it('renders markdown when body is set', () => {
        const element = createElement('c-markdown-preview', {
            is: MarkdownPreview
        });
        document.body.appendChild(element);

        element.body = MARKDOWN_HEADING;

        return flushPromises().then(() => {
            const div = element.shadowRoot.querySelector('div');

            expect(global.marked).toHaveBeenCalledWith(MARKDOWN_HEADING);
            expect(div.innerHTML).toBe(HTML_HEADING);
        });
    });
});

Run all tests by executing the npm run unit:test command in a terminal window. You should hopefully have 3 passing tests. You can also run the npm run unit:test:coverage command to generate a code coverage report. This will create a directory named coverage which contains the coverage report in a few different formats, including a nice interactive web page stored in the lcov-report sub directory.


Conclusion #

You now have a working Markdown Editor with a working suite of unit tests, and hopefully you learnt something about Lightning Web Components along the way. If you need it, all the code is available for free here under the terms of the MIT licence.

It doesn't have to stop here if you don't want it to. You could extend this editor even further if you are up for the challenge. For example, you could...

  • Add the ability to save the markdown to a file or custom object in Salesforce, and reopen it later.
  • Persist the markdown so users can leave the page or close the browser without saving, and then return and see their work restored. Hint, this could be achieved using localStorage .
  • Add support for using the component to render and update markdown stored in text fields on records, providing an alternative to rich text fields. Bonus points for making it possible to configure the target object and field via the Lightning App Builder.

Further reading #

If you want to learn more about Lightning Web Components I would definitely recommend checking out the Lightning Web Component Recipes repository on GitHub and the official Developer Guide.

In terms of unit testing, other than the createElement method, everything we used to write the tests is part of the Jest API, so check out the Jest API documentation to learn more.