Introduction

There are plenty of times where a user might want to generate documents for each item in one of the related lists on the current record they're viewing. For example, the user may want to click a button on an Opportunity Record that sends an email to each Contact Role related to that Opportunity, or the user might want to click a button on an Account record that generates an Opportunity Summary for each Opportunity record related to that Account. Setting up this feature is as simple as copying and pasting the following code into your org.

Note: You can also generate a single document with information merged in from each record in a related list that can be formatted in any way you'd like. This feature utilizes S-Docs component templates. Click here to read more about this feature.

Step 1: Create Apex Class

Head over to Setup > Build > Develop > Apex Classes > New and copy/paste the following code into your new Apex class, then click Save.

public class SDRelatedListDocumentsController {    
    private final String TYPE_FILTER;
    public String parentId {get;set;}
    public Boolean allowEmail {get;set;}
    public Boolean allowCombineAll {get;set;}
    public Boolean emailedDocuments {get;set;}
    public String objSortFieldOrderbyDirection {get;set;}
    public Map<Id, SObject> childRecordMap {get;set;}
    public SDRelatedListDocumentsController() {
        parentId = ApexPages.currentPage().getParameters().get('parentId');
        TYPE_FILTER = 'Related List Document - ' + parentId;
    }
    public List<SDJobWrapper> sdocJobWrappers {
        get {
            List<SDJobWrapper> sdjwList = new List<SDJobWrapper>(); 
            List<SDOC__SDJob__c> relatedListDocJobs = [
                SELECT SDOC__Status_Link__c, SDOC__Oid__c, 
                SDOC__SDoc1__r.Id, SDOC__SDoc1__r.SDOC__Attachment_Name__c
                FROM SDOC__SDJob__c
                WHERE OwnerId=:UserInfo.getUserId()
                AND SDOC__Type__c=:TYPE_FILTER
            ];
            for (SDOC__SDJob__c sdj : relatedListDocJobs) {
                sdjwList.add(
                    new SDJobWrapper(
                        sdj, childRecordMap.get(sdj.SDOC__Oid__c)
                    )
                );
            }
            return sdjwList;
        }
        set;
    }
    public class SDJobWrapper {
        public SDOC__SDJob__c job {get;set;}
        public SObject obj {get;set;}
        public SDJobWrapper(SDOC__SDJob__c job, SObject obj) {
            this.job = job;
            this.obj = obj;
        }
    }
    public void createSDocJobs() {
        delete [SELECT Id FROM SDOC__SDJob__c WHERE OwnerId=:UserInfo.getUserId() AND SDOC__Type__c=:TYPE_FILTER AND (SDOC__Status__c = 'Completed' OR SDOC__Status__c = 'Error')];
        List<SDOC__SDJob__c> sdJobs = new List<SDOC__SDJob__c>();
        childRecordMap = new Map<Id, SObject>();
        emailedDocuments = false;
        String childObjName = ApexPages.currentPage().getParameters().get('childObjName').toLowerCase();
        String lookupFieldName = ApexPages.currentPage().getParameters().get('lookupFieldName');
        String doclist = ApexPages.currentPage().getParameters().get('doclist');
        String allowCombineAllParam = ApexPages.currentPage().getParameters().get('allowCombineAll');
        allowCombineAll =  allowCombineAllParam == 'true' || allowCombineAllParam == '1';
        String templateBaseObject; String fieldPrefix;
        if (childObjName == 'opportunitycontactrole' ||
            childObjName == 'accountcontactrelation') {
            templateBaseObject = 'Contact';
            fieldPrefix = 'Contact.';
        } else {
            templateBaseObject = childObjName;
            fieldPrefix = '';
        }
        lookupFieldName = lookupFieldName.toLowercase();
        lookupFieldName = lookupFieldName.replace('__c', '__r');
        if (lookupFieldName.endsWith('id')) {
            lookupFieldName = lookupFieldName.substring(0, lookupFieldName.length() - 2);
        }
        String childrenQuery 
            = 'SELECT ' + fieldPrefix + 'Name, ' + fieldPrefix + 'Id '
            + ' FROM ' + childObjName 
            + ' WHERE ' + lookupFieldName + '.Id =\'' + parentId + '\' ';
        String additionalFilters = ApexPages.currentPage().getParameters().get('additionalFilters');
        if (additionalFilters != null) childrenQuery += additionalFilters;
        objSortFieldOrderbyDirection = ' ASC';
        String objSortFields = ApexPages.currentPage().getParameters().get('objSortFields');
        if (objSortFields != null) {
            if (objSortFields.contains(' DESC')) objSortFieldOrderbyDirection = 'DESC';
            objSortFields = objSortFields.remove(' DESC').remove('ASC').deleteWhitespace();
        }
        for (SObject child : Database.query(childrenQuery)) {
            String oid;
            if (childObjName == 'opportunitycontactrole' ||
                childObjName == 'accountcontactrelation') {
                oid = String.valueOf(child.getSObject('Contact').get('Id'));
                childRecordMap.put(oid, child.getSObject('Contact'));
            } else {
                oid = child.Id;
                childRecordMap.put(oid, child);
            }
            SDOC__SDJob__c sdj = 
            new SDOC__SDJob__c(
                SDOC__Start__c=true,
                SDOC__Oid__c=oid,
                SDOC__ObjApiName__c=templateBaseObject,
                SDOC__SendEmail__c='0',
                SDOC__Doclist__c=doclist,
                SDOC__Type__c=TYPE_FILTER,
                SDOC__ObjSortValFieldsForSDoc__c=objSortFields
            );
            sdJobs.add(sdj);
        }
        
        insert sdJobs;
    }
    public Boolean jobsAreCompleted {
        get {
            Integer totalNumJobs = Database.countQuery(
                'SELECT COUNT()'
                + ' FROM SDOC__SDJob__c'
                + ' WHERE OwnerId = \'' + UserInfo.getUserId() + '\''
                + ' AND SDOC__Type__c=\'' + TYPE_FILTER + '\''
                + ' AND SDOC__Status__c'
                + ' IN(\'Selected\',\'0\',\'10\',\'20\',\'40\',\'60\','
                + '\'80\',\'90\',\'95\',\'Queued\',\'Completed\',\'Error\')'
            );
            Integer completedSize = Database.countQuery(
                'SELECT COUNT()'
                + ' FROM SDOC__SDJob__c'
                + ' WHERE OwnerId = \'' + UserInfo.getUserId() + '\''
                + ' AND SDOC__Type__c=\'' + TYPE_FILTER + '\''
                + ' AND SDOC__Status__c = \'Completed\''
            );
            return (completedSize + getErrorSize() == totalNumJobs) && totalNumJobs > 0;
        }
        set;
    }
    public Integer getErrorSize() {
        return Database.countQuery(
            'SELECT COUNT()'
            + ' FROM SDOC__SDJob__c'
            + ' WHERE OwnerId = \'' + UserInfo.getUserId() + '\''
            + ' AND SDOC__Status__c = \'Error\''
        );
    }
    public PageReference returnToParentRecord() {
        return new PageReference('/' + parentId);
    }
    public PageReference emailDocuments() {
        if (!emailedDocuments) {
            emailedDocuments = true;
            String aid = ApexPages.currentPage().getParameters().get('aid');
            String did = ApexPages.currentPage().getParameters().get('did');
            PageReference emailPage = new PageReference('/apex/SDOC__SDEmail');
            emailPage.setRedirect(true);
            if (aid != null && aid != '') {
                emailPage.getParameters().put('aid', aid);
            } 
            if (did != null && did != '') {
                emailPage.getParameters().put('did', did);
            }
            /* EMAIL SECTION A */
            /* Emails each document individually on a per-record basis.
            This occurs in the background, so the email body can't be edited in this case. */
            for (SDJobWrapper sdjw : sdocJobWrappers) {
                emailPage.getParameters().put('SDId', sdjw.job.SDOC__SDoc1__r.Id);
                if (!Test.isRunningTest()) {
                    emailPage.getContent();
                }
            }
            /* EMAIL SECTION B */
            /* UNCOMMENT THIS SECTION and COMMENT OUT EMAIL SECTION A
            if you want to include all documents in a single email and have
            the user be redirected to the email page (where they can edit the email body)
            when they click the email button. */
            /*String sdocIds = '';
            for (SDJobWrapper sdjw : sdocJobWrappers) {
                sdocIds += sdjw.job.SDOC__SDoc1__r.Id + ',';
            }
            sdocIds = sdocIds.substring(0, sdocIds.length() - 1); // remove last comma
            emailPage.getParameters().put('SDId', sdocIds);
            return emailPage;*/
        }
        return null;
    }
    private String combineAllType = ApexPages.currentPage().getParameters().get('combineAll');
    private String combinedDocUrl;
    public void combineIntoSingleDocument() {
        if (combineAllType == null) return;
        throwExceptionIfErrors();
        combineAllType = combineAllType.toLowerCase();
        List<String> sdocIds = new List<String>();
        for (SDJobWrapper sdjw : sdocJobWrappers) sdocIds.add(sdjw.job.SDOC__SDoc1__r.Id);
        if (Test.isRunningTest()) sdocIds.add(ApexPages.currentPage().getParameters().get('testSDocId'));
        String filters = 'WHERE OwnerId=\'' + UserInfo.getUserId() + '\' AND Id IN (\'' + String.join(sdocIds, '\',\'') + '\')';
        List<String> orderBys = new List<String>();
        if (objSortValExistsOnSDoc()) orderBys.add(' SDOC__ObjSortVal__c ' + objSortFieldOrderbyDirection + ' ');
        String sdocSortFields = ApexPages.currentPage().getParameters().get('sdocSortFields');
        if (sdocSortFields != null) orderBys.add(sdocSortFields);
        if (!orderBys.isEmpty()) filters += ' ORDER BY ' + String.join(orderBys, ',');
        if (!Test.isRunningTest()) combinedDocUrl = new List<String>( SDOC.SDJobTemplateController.combineSDocs(filters, 'PDF') )[0];
    }
    public void finishCombineAll() {
        if (combineAllType == 'file' || combineAllType == 'attachment') {
            String filename; Blob filebody; 
            if (Test.isRunningTest()) {
                filename = 'test.txt';
                filebody = Blob.valueOf('test');
            } else {
                filename = sdocJobWrappers[0].job.SDOC__SDoc1__r.SDOC__Attachment_Name__c;
                filebody = new PageReference(combinedDocUrl).getContent();
            }
            // &combineall=true,file,attachment&autoRedirect=record
            if (combineAllType == 'file') {
                insert new ContentVersion(
                    Title=filename,
                    PathOnClient=filename,
                    VersionData=filebody,
                    FirstPublishLocationId=parentId
                );
            } else if (combineAllType == 'attachment') {
                insert new Attachment(
                    Name=filename,
                    Body=filebody,
                    ParentId=parentId
                );
            }
        }
    }
    private Boolean doFinishRedirect = false;
    public void redirectIfComplete() {
        if (!jobsAreCompleted) return;
        combineIntoSingleDocument();
        doFinishRedirect = true;
    }
    public PageReference finishRedirect() {
        if (!doFinishRedirect) return null;
        // PageReference.getContent() must be done in a separate transaction, 
        // hence splitting up redirectIfComplete and finishRedirect
        finishCombineAll();
        String autoRedirect = ApexPages.currentPage().getParameters().get('autoRedirect');
        if (autoRedirect == null) return null;
        throwExceptionIfErrors();
        autoRedirect = autoRedirect.toLowerCase();
        if (autoRedirect == 'record') return returnToParentRecord();
        else if (autoRedirect == 'combineall') return new PageReference(combinedDocUrl);
        else if (autoRedirect == 'email') return emailDocuments();
        else if (autoRedirect == 'email,record') { emailDocuments(); return returnToParentRecord(); }
        return null;
    }
    public Boolean objSortValExistsOnSDoc() {
        return Schema.getGlobalDescribe().get('SDOC__SDoc__c').getDescribe().fields.getMap().get('SDOC__ObjSortVal__c') != null;
    }
    public void throwExceptionIfErrors() {
        if (getErrorSize() > 0) throw new SDException('Error: one or more S-Docs Jobs failed');
    }
    public class SDException extends Exception {}
}

Step 2: Create Visualforce Page

Head over to Setup > Build > Develop > Visualforce Pages > New and copy/paste the following code into your new Visualforce Page. Name the page SDRelatedListDocuments (i.e. enter this value into both the "Label" and "Name" fields), then click Save.

<apex:page controller="SDRelatedListDocumentsController" action="{!createSDocJobs}" tabStyle="SDOC__SDTemplate__c" lightningStylesheets="true">
    <apex:form >
        <apex:sectionHeader title="Create S-Docs" subtitle="Generating Document(s)"/>
        &lt;&lt; <apex:commandLink action="{!returnToParentRecord}">Return to record</apex:commandLink>
        <br />
        <br />
        <apex:actionPoller action="{!redirectIfComplete}" interval="5" reRender="job_table" oncomplete="finishRedirect();" />
        <apex:actionFunction action="{!finishRedirect}" name="finishRedirect" reRender="job_table" />
        <div style="width: 700px;">
            <apex:pageBlock id="job_table" >
                <script type="text/javascript">
                    function displaySuccessfulEmailMsg() {
                        document.getElementById('email_msg').innerHtml
                            = "Successfully emailed documents.";
                    }
                </script>
                <apex:commandButton value="Email Documents" action="{!emailDocuments}"
                disabled="{!NOT(jobsAreCompleted) || emailedDocuments}"
                oncomplete="alert('Successfully emailed documents.');"
                rendered="{!allowEmail}" reRender="" />
                <apex:pageBlockTable value="{!sdocJobWrappers}" var="ow">
                    <apex:column headerValue="Name">
                        <a href="/{!ow['obj.Id']}" target="_blank">{!ow['obj.Name']}</a>
                    </apex:column>
                    <apex:column headerValue="Status" value="{!ow['job.SDOC__Status_Link__c']}"
                    style="width: 150px;" />
                </apex:pageBlockTable>
            </apex:pageBlock>
        </div>
    </apex:form>
</apex:page>

Step 3: Create Record Detail Button(s)

For each object that requires this functionality, you'll need to create a button similar to the following example created for emailing a document to each Contact Role related to an Opportunity.

{!URLFOR('/apex/SDRelatedListDocuments', null,
[
  parentId=Opportunity.Id,
  childObjName='OpportunityContactRole',
  lookupFieldName='Opportunity',
  doclist='a0K1M000000C7XO',
  allowEmail='1',
  aid='a0B1O000000TGNB',
  did='a0V2L000000LJAH',
  combineALL='file',
  autoRedirect='record',
  sdocSortFields='SDOC__SDTemplate__R.Name',
  objSortFields='Contact.LastName'
]
)}

Here's what each query parameter denotes:
parentId: The ID of the record that users will see this button on. For example, if your users will click a button on the Opportunity record detail page that creates and emails a document for each Contact Role, this value should be {!Opportunity.Id}.
childObjName: The API name of the object type of the records in the related list. For example, if your users will click a button on the Opportunity record detail page that creates and emails a document for each Contact Role, this value should be OpportunityContactRole.
lookupFieldName: The API name of the field relating the related list object to the parent object that the user will see this button on. For example, if your users will click a button on the Opportunity record detail page that creates and emails a document for each Contact Role, this value should be Opportunity because a Contact Role record is related to its parent Opportunity record via a lookup field with API name Opportunity (e.g. if you wanted to query the IDs of all the Opportunities related to some Opportunity Contact Role with ID aXM1F000000N7XL, you would run the query SELECT Opportunity.Id FROM OpportunityContactRole WHERE Id='aXM1F000000N7XL' - notice how the Opportunity ID is accessed from the Opportunity Contact Role record through a lookup field with API name "Opportunity").
doclist: A comma-delimited list of S-Docs Template names or IDs that will be used to generate the related list documents. These S-Docs Template records should have the Related To Type set to the object of the related list records, rather than the object of the parent records. For example, if your users will click a button on the Account record detail page that creates a document for each Opportunity, this should be Opportunity, rather than Account. Going back to our Opportunity Contact Roles case is a bit more interesting: if your users will click a button on the Opportunity record detail page that creates and emails a document for each Contact Role, this shouldn't be Opportunity, of course, but it also shouldn't be OpportunityContactRole as you might have expected; rather, it should be Contact, since OpportunityContactRole is really just a relationship record linking a Contact (where the real meat of the Opportunity Contact Role data lies) to an Opportunity.
allowEmail (optional): Set this to 1 if you want to allow the user to email the generated documents. Set this to 0 or omit it altogether if you don't want to allow the user to email the generated documents.
aid (optional): A comma-delimited list of the Salesforce Attachments that you would like to attach to each email (note that emails can only be sent out if you set allowEmail to 1).
did (optional):
 A comma-delimited list of the Salesforce Documents that you would like to attach to each email (note that emails can only be sent out if you set allowEmail to 1).
sdocSortFields (optional): Field (or fields) on the S-Doc or S-Docs Template object to sort the documents by. In the example above, this sorts by template name.
objSortFields (optional): Field (or fields) on the base object to sort the documents by. In the example above, this sorts by Contact Last Name.
combineAll=[true|file|attachment] (optional): If this is set to true, all documents will be combined into a single PDF that can be saved as either a file or attachment.

Note: The combineall functionality will throw an error if you're not on S-Docs version 2.944 or above.

autoRedirect=[record|combineall|email|email,record] (optional): This parameter will automatically redirect you depending on which of the four values you input. Record will redirect you back to the record you started on. Combineall will take you to the combined PDF document if you've set the comebineall parameter to true. Email will take you to the email page so you can edit the email fields before sending your documents. Email,record will automatically email the documents and redirect you back to the record you started on.

Step 4: Create Test Class

Head over to Setup > Build > Develop > Apex Classes > New and copy/paste the following code into your new Apex class, then click Save.

@isTest
private class SDRelatedListDocumentsTest {
    @isTest
    private static void testSDRelatedListDocuments() {
        Account acct = new Account(Name='test');
        insert acct;
        Opportunity opp = new Opportunity(Name='test', AccountId=acct.Id, StageName='Closed', CloseDate=Date.today());
        insert opp;
        
        Test.setCurrentPage(Page.SDRelatedListDocuments);
        ApexPages.currentPage().getParameters().put('parentId', String.valueOf(acct.Id));
        ApexPages.currentPage().getParameters().put('childObjName', 'Opportunity');
        ApexPages.currentPage().getParameters().put('lookupFieldName', 'Account');
        ApexPages.currentPage().getParameters().put('doclist', '');
        ApexPages.currentPage().getParameters().put('sendEmail', '0');
        ApexPages.currentPage().getParameters().put('combineAll', 'file');
        
        SDRelatedListDocumentsController sdrldc = new SDRelatedListDocumentsController();
        sdrldc.createSDocJobs();
        Boolean tmp = sdrldc.jobsAreCompleted;
        sdrldc.returnToParentRecord();
        sdrldc.emailDocuments();
        sdrldc.combineIntoSingleDocument();
        sdrldc.finishCombineAll();
        sdrldc.redirectIfComplete();
        sdrldc.finishRedirect();
    }
}