How To: Add CSS Style to the Clone, Edit, and Copy pages
June 2, 2019How To: Select Record when Copying to a Different Record Type
July 5, 2020How 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
- Create the Apex class and test class below that will clone a document/file.
- Create a new field on the object to receive the source record’s Id value.
- Update the Clone Configuration to assign the record Id value to the new source field.
- 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.