Display PDF Files with Lightning Web Components

Have you ever wanted to view an uploaded PDF file in Salesforce, on a Lightning page? This use case came up for me during piano practice. I really wanted to see a PDF with a score and play the related music through a YouTube component on the same page. A business use case could involve a user reading a PDF and filling out a form, starting a flow, etc.

All the code covered in the blog post is shared on a GitHub repository, including instructions on how to deploy the component to your Salesforce org.

I’ve created two Lightning web components for two different use cases:

1. showPdfById shows a PDF whose ID you can set via the Lightning App Builder, and works great for App, Home, and Record pages .

2. showPdfRelatedToRecordId shows all Related PDF Files to the record. This example shows all the PDFs related to this Quote. This component is only for record pages.

Curious about how to implement these? Let’s get started with the simpler component, showPdfById.

Pre-requisite

Before writing any code, we need to set the default action for PDF files in Setup. This is important as we want to read the PDF file on navigating to the download link, instead of downloading it. Go to Setup → Security → File Upload and Download Security, find the .pdf label, click the Edit button, and set the picklist value of the pdf to Execute In Browser.

Note that this is an org-wide setting. After this change, when a user goes to a PDF file download URL, the file will be displayed, instead of downloaded automatically.


All done? Let’s move on to create Lightning web components to view PDF files in Lightning pages.

Code walkthrough: showPdfById component

Let’s start with the metadata file showPdfById.js-meta.xml. Since this component is meant for App, Home, and Record pages, we specify these targets in the targets block. We have a fileId property for the file ID and a heightInRem property that specifies the height of the PDF. We have a default height set to 40, and use rem to scale the viewer when the browser resizes (details). Both properties are configurable via the Lightning App Builder or a parent component.

<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata" fqn="showPdfById">
    <apiVersion>46.0</apiVersion>
    <isExposed>true</isExposed>
    <masterLabel>Show PDF File By ID</masterLabel>
    <targets>
        <target>lightning__AppPage</target>
        <target>lightning__HomePage</target>
        <target>lightning__RecordPage</target>
    </targets>
    <targetConfigs>
        <targetConfig targets="lightning__AppPage, lightning__HomePage, lightning__RecordPage">
            <property label="File ID" name="fileId" type="String" placeholder="Please enter PDF file's Content Document ID." />
            <property label="Height in rem" name="heightInRem" type="Integer" default="40" />
        </targetConfig>
    </targetConfigs>
</LightningComponentBundle>

Now let’s head over to the JavaScript file showPdfById.js. To use the fileId property defined in the config file, we declare @api fileId. The @api decorator marks fileId as a public property, meaning a parent component can inject it and/or read it. Lastly, we use a getter to define the relative URL to the file, which includes the fileId value.

import { LightningElement, api } from 'lwc';

export default class ShowPdfById extends LightningElement {
    @api fileId;
    @api heightInRem;

    get pdfHeight() {
        return this.heightInRem + 'rem';
    }
    get url() {
        return '/sfc/servlet.shepherd/document/download/' + this.fileId;
    }
}

What about the markup in showPdfById.html? It takes the file URL and renders it in an iframe. Notice we also added conditional markup to render an error if there is no file ID. Since fileId can be passed in from a parent component, it could be null so we need to handle it with a dedicated error message.

<template>
    <template if:true={fileId}>
        <iframe src={url}></iframe>
    </template>
    <template if:false={fileId}>
        Please enter a valid PDF File ID.
    </template>
</template>

The iframe element might come with some borders by default, usually not in a visually pleasant way. Let’s create a showPdfById.css file and remove the iframe border. We also specify the width: 100%, for the component to take up the entire width available to it.

iframe {
    border: 0;
    width: 100%;
}

That is it for the showPdfById component. Let’s now take a look at the parent component, which appears on Record pages and renders related PDF files.

Code walkthrough: showPdfRelatedToRecordId component

Let’s start with some configuration in showPdfRelatedToRecordId.js-meta.xml. Since this component is intended for Record pages, we specify lightning__RecordPage as the target. We also specify a heightInRem property to set the height for the child component which renders the PDF iframe. This property will be passed down to the child.

<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata" fqn="showPdfRelatedToRecordId">
    <apiVersion>46.0</apiVersion>
    <isExposed>true</isExposed>
    <masterLabel>Show PDF Files Related To Record</masterLabel>
    <targets>
        <target>lightning__RecordPage</target>
    </targets>
    <targetConfigs>
        <targetConfig targets="lightning__RecordPage">
            <property label="Height in rem" name="heightInRem" type="Integer" default="40" />
        </targetConfig>
    </targetConfigs> 
</LightningComponentBundle>

We will also need to pass in the file ID to the child component and we will also retrieve file title for a better user experience. To do so, we implement an Apex class called PDFViewController with a getRelatedFilesByRecordId method. We annotate the method with @AuraEnabled(cacheable=true) to cache the result on the client.

In the method we use the recordId passed in from the component to retrieve the list of related files, filter them by extension, and return a map of file IDs and titles. The resulting mapIdTitle will be an object consumed by the wired getRelatedFilesByRecordId method in the component’s showPdfRelatedToRecordId.js JavaScript file.

public with sharing class PDFViewerController {
    @AuraEnabled(cacheable=true)
    public static Map<ID, String> getRelatedFilesByRecordId(String recordId) {
        // Get record file IDs        
        List<ContentDocumentLink> files = [SELECT ContentDocumentId FROM ContentDocumentLink WHERE LinkedEntityId = :recordId];
        List<ID> fileIDs = new List<ID>();
        for (ContentDocumentLink docLink : files) {
            fileIDs.add(docLink.ContentDocumentId);
        }

        // Filter PDF files 
        List<ContentVersion> docs = [SELECT ContentDocumentId, FileExtension, Title 
            FROM ContentVersion WHERE ContentDocumentId IN : fileIDs AND FileExtension='pdf'];
        Map<ID, String> mapIdTitle = new Map<ID, String>();
        for (ContentVersion docLink : docs) {
            mapIdTitle.put(docLink.ContentDocumentId, docLink.Title);
        }

        return mapIdTitle;
    }
}

The showPdfRelatedToRecordId.js JavaScript file imports the PDFViewerController.getRelatedFilesByRecordId Apex method and invokes it via the wire service. The wire service provisions the results to the wiredFieldValue function via an object that holds either an error or a data property.

If the wire service provisions data, its assigned to this.pdfFiles. For a record with related PDF files,this.pdfFiles looks like {relatedFile1ID: relatedFile1Title, relatedFile2ID: relatedFile2Title, ...}.

If the wire service provisions error, the error is assigned to this.error, which is decorated with @track. If the value of a tracked property changes, the template re-renders with the updated value.

import { LightningElement, api, track, wire } from 'lwc';
import getRelatedFilesByRecordId from '@salesforce/apex/PDFViewerController.getRelatedFilesByRecordId';

export default class ShowPdfRelatedToRecordId extends LightningElement {
    // Current record ID. *recordId* is a reserved identifier
    @api recordId;
    // Declare to pass heightInRem to the child component in markup
    @api heightInRem;
    @track error;
    // Specify which file for child component to render
    @track fileID;
    pdfFiles = [];

    @wire(getRelatedFilesByRecordId, { recordId: '$recordId' })
    wiredFieldValue({ error, data }) {
        if (data) {
            this.pdfFiles = data;
            this.error = undefined;
            // Save the first related PDF's file ID to fileID            
            const fileIDs = Object.keys(data);
            this.fileID =  fileIDs.length ? fileIDs[0] : undefined; 
        } else if (error) {
            this.error = error;
            this.pdfFiles = undefined; 
            this.fileID = undefined; 
        }
    }

    // Maps file ID and title to tab value and label
    get tabs() {
        if (!this.fileID) return [];

        const tabs = [];
        const files = Object.entries(this.pdfFiles);
        for (const [ID, title] of files) {
            tabs.push({
                value: ID,
                label: title
            });
        }        
        return tabs;
    }

    // event handler for each tab: onclick tab, change fileID
    setFileID(e) {
        this.fileID = e.target.value;
    }
}

Let’s now dive in the markup file: showPdfRelatedToRecordId.html. When related PDF files are returned from the getRelatedFilesByRecordId method, the markup conditionally renders a lightning-tabset base component, which is a collection of tabs. It uses the tabs getter to return file titles and IDs, which respectively become tab labels and values. Each tab’s onactive attribute refers to the setFileID method, which updates the fileID property whenever the user clicks on a different tab.

<template>
    <template if:true={fileID}>
        <lightning-tabset variant="scoped">
            <template for:each={tabs} for:item="tab">
                <lightning-tab key={tab.value} label={tab.label} value={tab.value} onactive={setFileID}>
                </lightning-tab>
            </template>
        </lightning-tabset>        
        <c-show-pdf-by-id
            height-in-rem={heightInRem}
            file-id={fileID}
        ></c-show-pdf-by-id>
    </template>

    <template if:false={fileID}>
        <lightning-card><div class="slds-text-align_center">No related PDF file found.</div></lightning-card>
    </template>

    <!-- Conditionally render the error in error component. Taken from https://github.com/trailheadapps/lwc-recipes/tree/master/force-app/main/default/lwc/errorPanel -->
    <template if:true={error}>
        <c-error-panel errors={error}></c-error-panel>
    </template>
</template>

The show-pdf-by-id child component takes in the fileID and the heightInRem properties, to render the corresponding file with a given height. Notice the child component is outside the lightning-tabset. This is intentional, as moving the child component to inside the tab would re-render the entire child component when clicking on another tab. With our implementation we only update the fileID, the iframe stays put, and we only change the rendered PDF.

For a Quote record with plenty of related PDF files, the showPdfRelatedToRecordId component looks like this:


Wasn’t that fun? You can add this component to any Record page and show your users the PDF files related to this component.

Key points

  • Use Setup to change the default behavior of PDF file URLs in Salesforce. Without it, when the user goes to a file via a URL in the browser, the default action may be to download the file. Developers can further customize file downloads via Apex callback.
  • To get Files related to a record via Apex, use LinkedEntityId field on ContentDocumentLink object. Then filter them by FileExtension on ContentVersion object. These two objects are related by the field ContentDocumentId. These fields and objects are the foundation to programmatically view related Files.
  • To specify iframe height, use rem instead of pixels to keep component in proportion when the user resizes the browser window. Read more about rem here.
  • The iframe element in this case gave us access to a File on a Lightning Page. It does have drawbacks: there are many ways to make an iframe responsive, and just as many attributes to secure it.

All the code covered in the blog post can be found in this repository, including instructions on how to deploy the component to your Salesforce org.

Have fun while adding PDF Files to any Lightning Page!

About the author

Anny He works as Developer Evangelist at Salesforce. She focuses on UI components and integrations with the Salesforce Platform. You can follow her on Twitter @annyhehe.

Leave your comments...

Display PDF Files with Lightning Web Components