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:
- Node.js and npm
- Salesforce CLI
- A text editor, consider using Salesforce Extensions for VS Code
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'
— theLightningElement
base class and theapi
decorator, which is similar to thetrack
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.