How To: Add CSS Style to the Clone, Edit, and Copy pages
June 2, 2019
How To: Select Record when Copying to a Different Record Type
July 5, 2020

How To: Clone files and documents in Salesforce

Documents and files are stored two different ways in Salesforce. Traditionally, files were saved in an object called “Attachments”. They were a direct child of the object they related to. Now Salesforce stores fines in a much more complex set of objects. Benefits of the new structure include versioning and the ability for one copy of a document to be referenced by multiple objects.

Super Clone Pro has the ability to clone the file’s reference to the document. This is called the ContentDocumentLink. After cloning, both the original records and new records will be linked to the same document stored on the system. This is great for saving storage space, but you may have a use case that requires the newly cloned records reference their own version of the documents.

Super Clone Pro does not clone the document, but with an additional class and process builder you can clone the document.

Summary

  1. Create the Apex class and test class below that will clone a document/file.
  2. Create a new field on the object to receive the source record’s Id value.
  3. Update the Clone Configuration to assign the record Id value to the new source field.
  4. Create a Process Builder flow to only run on insert and will execute the Apex class created in step 1.

1. Apex Class & Test Class

The Apex class can be run from Process Builder. It expects parameters of a source record Id that has related documents and a record Id that will receive copies of the documents.

If you would like to only clone specific record types, change the ContentDocumentLink query to include FileExtension filtering in the where clause. This is around line 47.

        for (ContentDocumentLink cdl : [SELECT ContentDocumentId,LinkedEntityId,ShareType,Visibility 
                                            FROM ContentDocumentLink 
                                            WHERE LinkedEntityId IN :fromIdSet
                                                and ContentDocument.FileExtension = 'pdf']) {

Create a class named “PbCloneDocuments“, and paste in the code below.

/*
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL 
THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
public with sharing class PbCloneDocuments {   
    @InvocableVariable(
        Label='Source Record Id' 
        Description='Record Id of an object with related Documents'
        Required=false)
    public String sourceRecordId;
    @InvocableVariable(
        Label='Destination Record Id' 
        Description='Record Id of an object that receive copies of the Source record\'s Documents'
        Required=false)
    public String destinationRecordId;

    /**
    * all instantiation of class with no parameters or source/destination record Ids
    */
    public PbCloneDocuments(){}
    public PbCloneDocuments(String sourceRecordId, String destinationRecordId){
        this.sourceRecordId = sourceRecordId;
        this.destinationRecordId = destinationRecordId;
    }
    
    /**
    * clone related files from a source record to a destination record
    */
    @InvocableMethod(Label='Clone Documents' Description='Clone Documents related to the Source record onto the Destination record')
    public static void cloneDocuments(list<PbCloneDocuments> pfCloneDocumentsList) {
 
        // get set of To IDs by the From Id
        map<Id, set<Id>> fromToMap = formatFromToMap(pfCloneDocumentsList); 
        if (fromToMap.isEmpty()) {
            return;
        }

        map<Id, ContentDocumentLink> cdlMap = new map<Id, ContentDocumentLink>();
        map<Id, list<contentLinkCls>> docIdCdlListMap = new map<Id, list<contentLinkCls>>();

        // create Content Document Link records for each To ID
        set<Id> fromIdSet = fromToMap.keySet();
        for (ContentDocumentLink cdl : [SELECT ContentDocumentId,LinkedEntityId,ShareType,Visibility 
                                            FROM ContentDocumentLink 
                                            WHERE LinkedEntityId IN :fromIdSet]) {
            // by Link Id
            cdlMap.put(cdl.Id, cdl);

            // by Document Id
            list<contentLinkCls> cdlList = docIdCdlListMap.get(cdl.ContentDocumentId);
            if (null == cdlList) {
                cdlList = new list<contentLinkCls>();
                docIdCdlListMap.put(cdl.ContentDocumentId, cdlList);
            }
            set<Id> toSet = fromToMap.get(cdl.LinkedEntityId);
            if (null != toSet) {
                for (Id toId : toSet) {
                    ContentDocumentLink newCdl = cdl.clone();
                    newCdl.LinkedEntityId = toId;
                    cdlList.add(new contentLinkCls(newCdl));
                }
            }
        }

        if (docIdCdlListMap.isEmpty()) {
            return;
        }

        // create a new Content Version NOT linked to a Content Document for each Content Document Link for each Destination Id
        set<Id> cvIdSet = getNewContentVersionIdMap(docIdCdlListMap);

        // get new Content Document that was auto-created
        map<Id, Id> cvIdDocIdMap = new map<Id, Id>();
        for (ContentDocument cd : [SELECT Id, LatestPublishedVersionId 
                                   FROM ContentDocument
                                   WHERE LatestPublishedVersionId IN :cvIdSet]) {
            cvIdDocIdMap.put(cd.LatestPublishedVersionId, cd.Id);
        }

        // set ContentDocument Id on the ContentDocumentLink
        list<ContentDocumentLink> cdlInsertList = new list<ContentDocumentLink>();
        for (list<contentLinkCls> cLinkList : docIdCdlListMap.values()) {
            for (contentLinkCls cLink : cLinkList) {
                Id cdId = cvIdDocIdMap.get(cLink.cvId); 
                if (null != cdId) {
                    cLink.cdl.ContentDocumentId = cdId;
                    cdlInsertList.add(cLink.cdl);
                } else {
                    system.debug(LoggingLevel.ERROR, 'Not Found ' + cLink);
                }
            }            
        }

        if (!cdlInsertList.isEmpty()) {
            insert cdlInsertList;
        }
    }

    /**
    * create a new Content Version NOT linked to a Content Document for each Content Document Link
    */
    private static set<Id> getNewContentVersionIdMap(map<Id, list<contentLinkCls>> docIdCdlListMap) {
        set<Id> cvIdSet = new set<Id>();
        list<ContentVersion> cvInsertList = new list<ContentVersion>();

        for (ContentVersion cv : [SELECT ContentDocumentId, ContentLocation, Description, pathOnClient, TagCsv, Title, VersionData
                                    FROM ContentVersion
                                    WHERE ContentDocumentId IN :docIdCdlListMap.keySet()
                                            AND ContentLocation = 'S']) { // S=Document is located within Salesforce
            
            list<contentLinkCls> cdlList = docIdCdlListMap.get(cv.ContentDocumentId);
            if (null != cdlList) {
                for (contentLinkCls cdl : cdlList) {
                    ContentVersion newCv = new ContentVersion(
                                                              Description=cv.Description, 
                                                              TagCsv=cv.TagCsv, 
                                                              pathOnClient=cv.pathOnClient,
                                                              Title=cv.Title, 
                                                              VersionData=cv.VersionData);
                    cdl.cv = newCv;

                    cvInsertList.add(newCv);
                }
            }
        }
        if (!cvInsertList.isEmpty()) {
            insert cvInsertList;

            for (list<ContentLinkCls> clList : docIdCdlListMap.values()) {
                for (ContentLinkCls cl : clList) {
                    cl.clearCv();
                    cvIdSet.add(cl.cvId);
                }
            }
        }
        return cvIdSet;
    }

    /**
    * return a map of the from and to record ids
    */
    private static map<Id, set<Id>> formatFromToMap(list<PbCloneDocuments> pfCloneDocumentsList) {
        map<Id, set<Id>> fromToMap = new map<Id, set<Id>>();
        for (PbCloneDocuments cd : pfCloneDocumentsList) {
            // skip if blank 
            if (String.isBlank(cd.sourceRecordId) || String.isBlank(cd.destinationRecordId)) continue;
            
            // convert to Id types, and skip if either is invalid
            Id fromId;
            Id toId;
            try {
                fromId = cd.sourceRecordId;
                toId = cd.destinationRecordId;
            } catch (exception e) {
                system.debug(LoggingLevel.ERROR, 'Id exception: ' + e.getMessage());
                continue;
            }

            // put in cross-reference map
            set<Id> toSet = fromToMap.get(fromId);
            if (null == toSet) {
                toSet = new set<Id>();
                fromToMap.put(fromId, toSet);
            }
            toSet.add(toId);
        }
        return fromToMap;
    }

    /**
    * subclass to help associate the contentdocumentlink to the contentversion
    */
    private class contentLinkCls {
        ContentDocumentLink cdl {get; set;}
        ContentVersion cv {get; set;}
        Id cvId {get; set;}
        public contentLinkCls(ContentDocumentLink cdl) {
            this.cdl = cdl;
        }

        public void clearCv() {
            cvId = cv.Id;
            cv = null;
        }
    }
}

Create a class named “PbCloneDocumentsTest“, and paste in the code below.

/*
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL 
THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
@isTest
private class PbCloneDocumentsTest {
    
    @testSetup static void setup() {
        // create accounts
        List<Account> acctList = new List<Account>();
        acctList.add(new Account(Name = 'TestAcct (1)'));
        acctList.add(new Account(Name = 'TestAcct (2)'));
        acctList.add(new Account(Name = 'TestAcct (3)'));
        acctList.add(new Account(Name = 'TestAcct (4)'));
        insert acctList;

        // create ContentVersions
        list<ContentVersion> cvList = new list<ContentVersion>();
        cvList.add( new ContentVersion(Title = 'test1',PathOnClient = 'Test1.txt',VersionData = Blob.valueOf('test data 1'), IsMajorVersion = true) );
        cvList.add( new ContentVersion(Title = 'test2',PathOnClient = 'Test2.txt',VersionData = Blob.valueOf('test data 2'), IsMajorVersion = true) );
        cvList.add( new ContentVersion(Title = 'test3',PathOnClient = 'Test3.txt',VersionData = Blob.valueOf('test data 3'), IsMajorVersion = true) );
        insert cvList;

        // retrieve Ids for ContentDocuments created on insert of ContentVersions
        list<Id> cdIdList = new list<Id>();
        for (ContentDocument cd : [SELECT Id, LatestPublishedVersionId
                                   FROM ContentDocument
                                   WHERE (LatestPublishedVersionId = :cvList[0].Id 
                                          OR LatestPublishedVersionId = :cvList[1].Id
                                          OR LatestPublishedVersionId = :cvList[2].Id)
                                   ORDER BY Title]) {
            cdIdList.add(cd.Id);
        }
        system.assertEquals(3, cdIdList.size());
        
        // create ContentDocumentLink links. 2 for TestAcct (1) and 1 for TestAcct (2)
        list<ContentDocumentLink> cdlList = new list<ContentDocumentLink>();
        cdlList.add(new ContentDocumentLink(ContentDocumentId=cdIdList[0], LinkedEntityId=acctList[0].Id, ShareType='V'));
        cdlList.add(new ContentDocumentLink(ContentDocumentId=cdIdList[1], LinkedEntityId=acctList[0].Id, ShareType='V'));
        cdlList.add(new ContentDocumentLink(ContentDocumentId=cdIdList[2], LinkedEntityId=acctList[1].Id, ShareType='V'));
        insert cdlList; 
    }

    @isTest static void testDocumentClone() {
        PbCloneDocuments pbcd;
        list<PbCloneDocuments> pbcdList = new list<PbCloneDocuments>();
        list<Account> acctList = [SELECT Id FROM Account ORDER BY Name];

        // clone from account (1) to (2) - 2 docs
        pbcdList.add(new PbCloneDocuments(acctList[0].Id, acctList[1].Id) );
        // clone from account (1) to (3) - 2 docs
        pbcdList.add(new PbCloneDocuments(acctList[0].Id, acctList[2].Id) );
        // clone from account (1) to (4) - 2 docs
        pbcdList.add(new PbCloneDocuments(acctList[0].Id, acctList[3].Id) );
        // clone from account (2) to (4) - 1 doc
        pbcdList.add(new PbCloneDocuments(acctList[1].Id, acctList[3].Id) );
        
        // run PbCloneDocuments method
        PbCloneDocuments.cloneDocuments(pbcdList);

        // check total number documents
        system.assertEquals(10, [SELECT count() FROM ContentDocument]);
        // check documents linked per account
        system.assertEquals(2, [SELECT count() FROM ContentDocumentLink WHERE LinkedEntityId=:acctList[0].Id]);
        system.assertEquals(3, [SELECT count() FROM ContentDocumentLink WHERE LinkedEntityId=:acctList[1].Id]);
        system.assertEquals(2, [SELECT count() FROM ContentDocumentLink WHERE LinkedEntityId=:acctList[2].Id]);
        system.assertEquals(3, [SELECT count() FROM ContentDocumentLink WHERE LinkedEntityId=:acctList[3].Id]);
    }
}

2. New Object Field

Add a field to the object to store the original record’s Id value. This can be a lookup or an 18 position text field. This example uses a text field, so it doesn’t add the overhead of an index that comes with lookup fields.

3. Super Clone Pro Configuration

We do not need to clone the “Content Document Link” relationship because the file cloning will occur in our custom class. Remove this relationship if it is currently in the configuration.

Change the field configuration settings for the new field. We will use the “Action Formula List” option, and set the Value text to “FIELD(Id)”.  This will tell the cloning process to set the field to the Id of the record being cloned.

4. Process Builder

Create a new Process Builder flow for your object. This flow should only run on Insert. The flow condition should make sure the new source record Id field has a value, and then the Action should call Apex class action to “Clone Documents” that we created in the first step.

The flow condition below references the new source field checks if it is not equal to the global constant of Null.

Set the “Destination Record Id” as a Field Reference to the object’s Id. Set the “Source Record Id” as a Field Reference to the new source field that was created.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Privacy & Cookies: This site uses cookies. By continuing to use this website, you agree to their use.
Read more