├── .eslintignore ├── .forceignore ├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── config └── project-scratch-def.json ├── force-app └── main │ └── default │ ├── classes │ ├── RecordSync.cls │ ├── RecordSync.cls-meta.xml │ ├── RecordSyncHandlerTest.cls │ ├── RecordSyncHandlerTest.cls-meta.xml │ ├── RecordSyncTestCallable.cls │ ├── RecordSyncTestCallable.cls-meta.xml │ ├── RecordSync_Test.cls │ ├── RecordSync_Test.cls-meta.xml │ ├── TriggerHandler.cls │ ├── TriggerHandler.cls-meta.xml │ ├── TriggerHandler_Test.cls │ └── TriggerHandler_Test.cls-meta.xml │ ├── customMetadata │ └── Trigger_Controller.RecordSyncHandlerTest.md-meta.xml │ ├── layouts │ └── Trigger_Controller__mdt-Trigger Controller Layout.layout-meta.xml │ ├── objects │ └── Trigger_Controller__mdt │ │ ├── Trigger_Controller__mdt.object-meta.xml │ │ ├── fields │ │ ├── After_Delete__c.field-meta.xml │ │ ├── After_Insert__c.field-meta.xml │ │ ├── After_Undelete__c.field-meta.xml │ │ ├── After_Update__c.field-meta.xml │ │ ├── Applies_To_Type__c.field-meta.xml │ │ ├── Applies_To_Value__c.field-meta.xml │ │ ├── Before_Delete__c.field-meta.xml │ │ ├── Before_Insert__c.field-meta.xml │ │ ├── Before_Undelete__c.field-meta.xml │ │ └── Before_Update__c.field-meta.xml │ │ ├── listViews │ │ └── All_Trigger_Controllers.listView-meta.xml │ │ └── validationRules │ │ └── Applies_To_Value.validationRule-meta.xml │ └── triggers │ ├── RecordSyncTestTrigger.trigger │ └── RecordSyncTestTrigger.trigger-meta.xml ├── jest.config.js ├── manifest └── package.xml ├── package.json └── sfdx-project.json /.eslintignore: -------------------------------------------------------------------------------- 1 | **/lwc/**/*.css 2 | **/lwc/**/*.html 3 | **/lwc/**/*.json 4 | **/lwc/**/*.svg 5 | **/lwc/**/*.xml 6 | **/aura/**/*.auradoc 7 | **/aura/**/*.cmp 8 | **/aura/**/*.css 9 | **/aura/**/*.design 10 | **/aura/**/*.evt 11 | **/aura/**/*.json 12 | **/aura/**/*.svg 13 | **/aura/**/*.tokens 14 | **/aura/**/*.xml 15 | **/aura/**/*.app 16 | .sfdx 17 | -------------------------------------------------------------------------------- /.forceignore: -------------------------------------------------------------------------------- 1 | # List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status 2 | # More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm 3 | # 4 | 5 | package.xml 6 | 7 | # LWC configuration files 8 | **/jsconfig.json 9 | **/.eslintrc.json 10 | 11 | # LWC Jest 12 | **/__tests__/** -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This file is used for Git repositories to specify intentionally untracked files that Git should ignore. 2 | # If you are not using git, you can delete this file. For more information see: https://git-scm.com/docs/gitignore 3 | # For useful gitignore templates see: https://github.com/github/gitignore 4 | 5 | # Salesforce cache 6 | .sf/ 7 | .sfdx/ 8 | .localdevserver/ 9 | deploy-options.json 10 | 11 | # LWC VSCode autocomplete 12 | **/lwc/jsconfig.json 13 | 14 | # LWC Jest coverage reports 15 | coverage/ 16 | 17 | # Logs 18 | logs 19 | *.log 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | # Dependency directories 25 | node_modules/ 26 | 27 | # Eslint cache 28 | .eslintcache 29 | 30 | # MacOS system files 31 | .DS_Store 32 | 33 | # Windows system files 34 | Thumbs.db 35 | ehthumbs.db 36 | [Dd]esktop.ini 37 | $RECYCLE.BIN/ 38 | 39 | # Local environment variables 40 | .env 41 | 42 | .vscode/ 43 | .husky/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # List files or directories below to ignore them when running prettier 2 | # More information: https://prettier.io/docs/en/ignore.html 3 | # 4 | 5 | **/staticresources/** 6 | .localdevserver 7 | .sfdx 8 | .vscode 9 | 10 | coverage/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "overrides": [ 4 | { 5 | "files": "**/lwc/**/*.html", 6 | "options": { "parser": "lwc" } 7 | }, 8 | { 9 | "files": "*.{cmp,page,component}", 10 | "options": { "parser": "html" } 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Salesforce Simple Trigger Framework and Record Sync 2 | 3 | A simplified trigger framework to extract logic from triggers, with an added feature of synchronising between two records uni or bi-directionally. 4 | 5 | ## Features 6 | + Apex triggers are kept to one line, and logic is extracted into conveniently-formatted classes. 7 | + Control trigger activation by user, profile, role, or permission set, and for each individual trigger event. 8 | + Automatically create and synchronise a child record, either one-way or two-ways. Records are kept in-sync after insert, update, delete, and undelete. 9 | 10 | ## Deployment 11 | 12 | 13 | Deploy to Salesforce 14 | 15 | 16 | ## How do I make a trigger? 17 | 18 | Start by creating a trigger handler class. This must extend the TriggerHandler class: 19 | ```Apex 20 | public without sharing class MyTriggerHandler extends TriggerHandler { 21 | } 22 | ``` 23 | 24 | The following methods are available to override: 25 | + beforeInsert 26 | + beforeUpdate 27 | + beforeDelete 28 | + beforeUndelete 29 | + afterInsert 30 | + afterUpdate 31 | + afterDelete 32 | + afterUndelete 33 | 34 | For example: 35 | ```Apex 36 | public override void beforeUpdate() { 37 | //Get new and old records 38 | List newCases = (List)Trigger.new, 39 | oldCases = (List)Trigger.old; 40 | 41 | //Do some stuff here 42 | } 43 | ``` 44 | 45 | Next, create a trigger for the object and instantiate your trigger handler: 46 | ```Apex 47 | trigger ScheduleObjectTrigger on Case(before update) { 48 | new MyTriggerHandler(); 49 | } 50 | ``` 51 | 52 | Done. Simple, right? 53 | 54 | ## Exception Handling 55 | 56 | By default, exceptions in the trigger are thrown back and crash the current transaction. The error handler can be overridden to implement custom functionality or to ignore the exception altogether: 57 | 58 | ```Apex 59 | public override void onError(Exception e) { 60 | //Handle exception here 61 | } 62 | ``` 63 | 64 | My personal approach is that most trigger errors should not block the entire transaction, so creating an onError handler that does not throw the exception (and perhaps logs or reports it via email) is recommended. But I'm not your mother, I can't tell you what to do. 65 | 66 | ## Enabling and Disabling Trigger Functionality via Metadata 67 | 68 | The provided custom metadata type can be used to enable or disable triggers. 69 | Enter Setup -> Custom Metadata Types and select the Trigger Controller object. 70 | Create a new record. In the Master Label field, enter the name of your trigger handler class (for example: MyTriggerHandler). 71 | Use the checkboxes to activate or deactivate specific trigger events. 72 | 73 | Triggers can be deactivated globally, for a single username, profile, role, or permission set. Use the Applies To (Type) and Applies To (Value) to control this. For example, to deactivate a trigger for the System Administrator profile, select: 74 | Applies To (Type): Profile 75 | Applies To (Value): System Administrator 76 | 77 | In the event of a clash (for example, one controller affects a user's profile and another affects their role), then the most restrictive options win (disabled triggers win over enabled ones). 78 | 79 | ## About Uni-Directional Record Synchronisation 80 | 81 | This feature allows you to create a trigger on any object that creates a parallel, always-synchronised record in another object. 82 | This is great for synchronising data between two applications on the platform. 83 | 84 | Child record values can come from the parent record, from hard-coded values, or from a Callable Apex class. Callable Apex classes are cached for increased performance. 85 | 86 | Synchronisation handles the following events: 87 | 88 | ### Record Creation 89 | When a parent record is created, a synchronised (child) record is created. 90 | 91 | ### Record Update 92 | When a parent record is updated, if a child record already exists, it will also be updated. Otherwise, it will be created. 93 | 94 | ### Record Deletion 95 | When a parent record is deleted, the child record is deleted. 96 | 97 | ### Record Undeletion 98 | When a record is undeleted, a child record is re-created (not restored from the recycle bin, at least at this stage). 99 | 100 | ## Synchronisation Example 101 | 102 | Here is a test class that shows how easy it is to synchronise two records: 103 | ```Apex 104 | public without sharing class SyncCaseAndTask extends RecordSync { 105 | public override Schema.DescribeSObjectResult getObjectType() { 106 | //This returns the type of object that needs to be created 107 | return Schema.SObjectType.Task; 108 | } 109 | 110 | public override Schema.DescribeFieldResult getChildRelationalField() { 111 | //This returns the field where the parent record ID will be written. 112 | //This should be a lookup field pointing to the parent object. 113 | return Schema.SObjectType.Task.fields.WhatId; 114 | } 115 | 116 | public override Schema.DescribeFieldResult getParentRelationalField() { 117 | //Implement this method to write the ID of the generated child record 118 | //into a field on the original, parent record. Also required for two- 119 | //way synchronisation. 120 | return Schema.SObjectType.Case.fields.Primary_Task_ID__c; 121 | } 122 | 123 | public override Boolean shouldSync(SObject record) { 124 | //Implement this method to only synchronise records conditionally. 125 | //If true, the parent record will create a child record. 126 | return (String)record.get('Subject') != 'Delete me'; 127 | } 128 | 129 | public override Boolean isAsynchronous(List records) { 130 | //Implement this method to optionally execute synchronisation 131 | //as an asynchronous, queueable class. This will run in a new transaction. 132 | //Note that there are Apex governor limits on asynchronous executions. 133 | return false; 134 | } 135 | 136 | public override Map getFieldMapping() { 137 | //Returns a map containing field mappings from the child object perspective: 138 | //Field API Name on the Child Object => Value 139 | return new Map { 140 | 'Subject' => 'Complete the case', //The task subject will be a constant string value (this can be any kind of object, like a decimal or date) 141 | 'WhatId' => Case.fields.Id, //The WhatId field will be mapped to the Case ID field (fields must belong to the parent object) 142 | 'Description' => Case.fields.Subject, //The Description field will be mapped to the case Subject field 143 | 'Status' => MyCallableClass.class //The Status field will be calculated by the class MyCallableClass, which implements the Callable interface 144 | }; 145 | } 146 | } 147 | ``` 148 | 149 | This should then be followed by a trigger: 150 | ```Apex 151 | trigger RecordSyncTestTrigger on Case (after insert, after update, before delete, after undelete) { 152 | new SyncCaseAndTask(); 153 | } 154 | ``` 155 | 156 | ## Bi-Directional Synchronisation 157 | 158 | Sometimes, you may want records to synchronise bi-directionally. Meaning, if the parent record changes, so will the child. And if the child record changes, so should the parent. This can be implemented by having two triggers, one on each object, that both synchronise one object into the other. In our example, we may choose to create a trigger on the Task object that syncronises back into a case. 159 | 160 | For this to work, both triggers must implement the getParentRelationalField function. This ensures that the newly-created child record does not create another child of its own, but instead synchronises with the parent record. By definition, this means that both synchronised objects must have lookup fields pointing to one another (in a fictional world where SObjects can have lookups to activities). 161 | 162 | ## Who wasted their time writing this garbage? 163 | 164 | This garbage was written by Amnon Kruvi, whom you can reach for assistance at amnon@kruvi.co.uk 165 | 166 | And if you're involved in a project for managing shift workers or field service, why not give [Isimio](https://www.isimio.com) a try? 167 | Or if you're looking for Salesforce architectural support, implementation, or managed services, reach out to [The Architech Club](https://architechclub.com). 168 | -------------------------------------------------------------------------------- /config/project-scratch-def.json: -------------------------------------------------------------------------------- 1 | { 2 | "orgName": "Demo company", 3 | "edition": "Developer", 4 | "features": ["EnableSetPasswordInApi"], 5 | "settings": { 6 | "lightningExperienceSettings": { 7 | "enableS1DesktopEnabled": true 8 | }, 9 | "mobileSettings": { 10 | "enableS1EncryptedStoragePref2": false 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /force-app/main/default/classes/RecordSync.cls: -------------------------------------------------------------------------------- 1 | /* Copyright: Amnon Kruvi, Kruvi Solutions, 30/08/2022 */ 2 | global abstract class RecordSync extends TriggerHandler implements Queueable { 3 | private Schema.DescribeSObjectResult objectType; 4 | private Map fieldMapping; 5 | private Map callableCache; 6 | private List records; 7 | 8 | private static Boolean isTest = Test.isRunningTest(); 9 | 10 | global abstract Schema.DescribeSObjectResult getObjectType(); 11 | global abstract Schema.DescribeFieldResult getChildRelationalField(); 12 | global virtual Schema.DescribeFieldResult getParentRelationalField() {return null;} 13 | global virtual void postProcess(SObject record, SObject newRecord) {} 14 | global abstract Map getFieldMapping(); 15 | 16 | private Object getParentValue(SObject record, Object value, String childField, List allRecords) { 17 | //Process the value based on the type of object it is 18 | if (value instanceof Schema.SObjectField) { 19 | //Field: get the field value from the record 20 | return record.get((Schema.SObjectField)value); 21 | } else if (value instanceof Type) { 22 | //Class: instantiate and execute the class 23 | return runCallable(record, value, childField, allRecords); 24 | } else if (value instanceof Callable) { 25 | //Callable instance: execute the instance as-is 26 | return runCallableInstance(record, (Callable)value, childField, allRecords); 27 | } 28 | return value; 29 | } 30 | 31 | private Object runCallableInstance(SObject record, Callable callableInstance, String childField, List allRecords) { 32 | return callableInstance.call('Calculate', new Map { 33 | 'record' => record, 34 | 'allRecords' => allRecords, 35 | 'field' => childField 36 | }); 37 | } 38 | 39 | private Object runCallable(SObject record, Object value, String childField, List allRecords) { 40 | //Cache callables. This is so callables can query and cache their own data over multiple runs if they need to. 41 | if (callableCache == null) { 42 | callableCache = new Map(); 43 | } 44 | 45 | //Find callable instance in the cache 46 | Callable inst = callableCache.get(childField); 47 | 48 | if (inst == null) { 49 | //Not found in cache, instantiate a new one 50 | inst = (Callable)((Type)value).newInstance(); 51 | } 52 | 53 | //Cache the callable instance 54 | callableCache.put(childField, inst); 55 | 56 | //Execute callable 57 | return runCallableInstance(record, inst, childField, allRecords); 58 | } 59 | 60 | global virtual Boolean shouldSync(SObject record) { 61 | //Override this method to determine which parent records should synchronise (assume all) 62 | return true; 63 | } 64 | 65 | global virtual Boolean isAsynchronous(List records) { 66 | //Override this method to create asynchronous synchronisation 67 | return false; 68 | } 69 | 70 | public override void afterInsert() { 71 | synchronise(Trigger.new); 72 | } 73 | 74 | public override void afterUndelete() { 75 | afterInsert(); 76 | } 77 | 78 | private Map loadExistingRecords(List records, String idField, String parentRelationalField) { 79 | //Make a list of fields we need to query from the child object 80 | Set childFields = new Set(); 81 | for (String childField : fieldMapping.keySet()) { 82 | childFields.add(childField.toLowerCase()); 83 | } 84 | childFields.add(idField.toLowerCase()); 85 | childFields.add('id'); 86 | 87 | //If the records are linked on the parent side as well, query those directly (to allow bi-directional sync) 88 | Set directlyRelatedRecords = new Set(); 89 | if (parentRelationalField != null) { 90 | for (SObject record : records) { 91 | Id idValue = (Id)record.get(parentRelationalField); 92 | if (idValue != null) { 93 | directlyRelatedRecords.add(idValue); 94 | } 95 | } 96 | } 97 | 98 | //Query all the existing child records alongside their field values 99 | List childRecords; 100 | try { 101 | childRecords = Database.Query('SELECT ' + String.join(new List(childFields), ',') + ' FROM ' + objectType.getName() + ' WHERE ' + idField + ' IN :records OR Id IN :directlyRelatedRecords'); 102 | } catch (QueryException e) { 103 | //This could happen if the ID field was also mapped to another field but without the namespace 104 | childFields.remove(idField.toLowerCase()); 105 | childRecords = Database.Query('SELECT ' + String.join(new List(childFields), ',') + ' FROM ' + objectType.getName() + ' WHERE ' + idField + ' IN :records OR Id IN :directlyRelatedRecords'); 106 | } 107 | 108 | //Map records by parent ID 109 | Map mapChildren = new Map(); 110 | for (SObject child : childRecords) { 111 | Id idValue = (Id)child.get(idField); 112 | 113 | //Map the child record by the parent ID 114 | mapChildren.put(new Object[] {1, idValue}, child); 115 | 116 | //Map the child record by its record ID 117 | mapChildren.put(new Object[] {2, child.Id}, child); 118 | } 119 | 120 | return mapChildren; 121 | } 122 | 123 | public override void afterUpdate() { 124 | synchronise(Trigger.new); 125 | } 126 | 127 | global void synchronise(List newRecords) { 128 | //If we're running in a synchronous context, and the 129 | //process is meant to run asynchronously, call the 130 | //future method. Otherwise, run it immediately. 131 | if (!System.isFuture() && 132 | !System.isBatch() && 133 | !System.isQueueable() && 134 | !System.isScheduled() && 135 | isAsynchronous(newRecords)) { 136 | //Asynchronous 137 | synchroniseFuture(newRecords); 138 | } else { 139 | //Synchronous 140 | synchroniseNow(newRecords); 141 | } 142 | } 143 | 144 | global void synchroniseFuture(List newRecords) { 145 | //Enqueue this job for future processing 146 | this.records = newRecords; 147 | System.enqueueJob(this); 148 | } 149 | 150 | public void execute(QueueableContext ctx) { 151 | //Process enqueued records 152 | synchroniseNow(this.records); 153 | } 154 | 155 | global void synchroniseNow(List newRecords) { 156 | this.objectType = this.getObjectType(); 157 | this.fieldMapping = this.getFieldMapping(); 158 | 159 | List toDelete = new List(); 160 | List toUpdate = new List(); 161 | List toInsert = new List(); 162 | 163 | //Load existing child records relating to the changed records 164 | String idField = this.getChildRelationalField().getName(); 165 | String parentRelationalField = getParentRelationalField()?.getName(); 166 | Map mapChildren = loadExistingRecords(newRecords, idField, parentRelationalField); 167 | 168 | //Iterate over the records to see which ones changed 169 | Schema.SObjectType objType = objectType.getSObjectType(); 170 | Integer cnt = newRecords.size(); 171 | 172 | for (Integer i=0;i < cnt;i++) { 173 | SObject newRec = newRecords[i]; 174 | 175 | //Fetch child record 176 | SObject childRecord = mapChildren.get(new Object[] {1, newRec.Id}); 177 | if (childRecord == null && 178 | parentRelationalField != null) { 179 | //Child record not found, but there is a parent relationship: look for the record from there 180 | Id childRecordId = (Id)newRec.get(parentRelationalField); 181 | if (childRecordId != null) { 182 | childRecord = mapChildren.get(new Object[] {2, childRecordId}); 183 | } 184 | } 185 | 186 | if (this.shouldSync(newRec)) { 187 | if (childRecord != null) { 188 | //Child record found. Check each field on the parent to see if there was a change. 189 | Boolean changed = false; 190 | for (String childField : fieldMapping.keySet()) { 191 | Object parentField = fieldMapping.get(childField); 192 | 193 | //Calculate new value 194 | Object newValue = getParentValue(newRec, parentField, childField, newRecords); 195 | 196 | if (newValue != childRecord.get(childField)) { 197 | //Change found, update child record 198 | changed = true; 199 | childRecord.put( 200 | childField, 201 | newValue 202 | ); 203 | } 204 | } 205 | 206 | if (changed == true) { 207 | toUpdate.add(childRecord); 208 | } 209 | } else { 210 | //Child record not found. Create one. 211 | childRecord = objType.newSObject(); 212 | 213 | //Copy all the fields from the parent to child record 214 | for (String childField : fieldMapping.keySet()) { 215 | Object parentField = fieldMapping.get(childField); 216 | childRecord.put( 217 | childField, 218 | getParentValue(newRec, parentField, childField, newRecords) 219 | ); 220 | } 221 | 222 | toInsert.add(childRecord); 223 | } 224 | } else { 225 | //Child record, if it exists, should be deleted 226 | if (childRecord != null) { 227 | toDelete.add(childRecord); 228 | } 229 | } 230 | } 231 | 232 | Database.update(toUpdate, isTest); 233 | Database.insert(toInsert, isTest); 234 | Database.delete(toDelete, isTest); 235 | 236 | //Link the parent to any newly created child records 237 | if (parentRelationalField != null) { 238 | toUpdate = new List(); 239 | 240 | //Map all records by ID 241 | Map newRecordsMap = new Map(); 242 | for (SObject record : newRecords) { 243 | newRecordsMap.put(record.Id, record); 244 | } 245 | 246 | //Copy the newly-created child record IDs to a field on the parent 247 | for (SObject newRecord : toInsert) { 248 | Id parentId = (Id)newRecord.get(getChildRelationalField().getSobjectField()); 249 | SObject parent = newRecordsMap.get(parentId); 250 | if (parent != null && parent.get(parentRelationalField) != newRecord.Id) { 251 | SObject recordToUpdate = newRecords[0].getSObjectType().newSObject(parentId); 252 | recordToUpdate.put(parentRelationalField, newRecord.Id); 253 | toUpdate.add(recordToUpdate); 254 | } 255 | } 256 | 257 | Database.update(toUpdate, isTest); 258 | } 259 | } 260 | 261 | public override void beforeDelete() { 262 | this.objectType = this.getObjectType(); 263 | this.fieldMapping = this.getFieldMapping(); 264 | 265 | //Delete associated child records 266 | String idField = this.getChildRelationalField().getName(); 267 | List records = Trigger.old; 268 | Database.delete(Database.Query('SELECT Id FROM ' + objectType.getName() + ' WHERE ' + idField + ' IN :records'), isTest); 269 | } 270 | } -------------------------------------------------------------------------------- /force-app/main/default/classes/RecordSync.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 55.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/RecordSyncHandlerTest.cls: -------------------------------------------------------------------------------- 1 | public without sharing class RecordSyncHandlerTest extends RecordSync { 2 | public static Boolean TEST_RECORD_SYNC = false; 3 | public static Boolean ASYNC_MODE = false; 4 | 5 | public override Schema.DescribeSObjectResult getObjectType() { 6 | return Schema.SObjectType.Task; 7 | } 8 | 9 | public override Schema.DescribeFieldResult getChildRelationalField() { 10 | return Schema.SObjectType.Task.fields.WhatId; 11 | } 12 | 13 | public override Boolean shouldSync(SObject record) { 14 | return (String)record.get('Subject') != 'Delete me'; 15 | } 16 | 17 | public override Boolean isAsynchronous(List records) { 18 | return ASYNC_MODE; 19 | } 20 | 21 | public override Map getFieldMapping() { 22 | return new Map { 23 | 'Subject' => 'Complete the case', 24 | 'WhatId' => Case.fields.Id, 25 | 'Description' => Case.fields.Subject, 26 | 'Status' => RecordSyncTestCallable.class, 27 | 'Priority' => new RecordSyncTestCallable() 28 | }; 29 | } 30 | } -------------------------------------------------------------------------------- /force-app/main/default/classes/RecordSyncHandlerTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 55.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/RecordSyncTestCallable.cls: -------------------------------------------------------------------------------- 1 | public with sharing class RecordSyncTestCallable implements Callable { 2 | public String call(String method, Map params) { 3 | //This class is called as part of the unit test 4 | 5 | //Verify we have the right parameters coming in 6 | String field = (String)params.get('field'); 7 | List allRecords = (List)params.get('allRecords'); 8 | SObject record = (SObject)params.get('record'); 9 | 10 | Assert.isTrue(record instanceof Case, 'The record that came in was not a Case: ' + record.getSObjectType().getDescribe().getName()); 11 | Assert.areEqual(5, allRecords.size(), 'We expect the same number of tasks to come into the class as are created in the unit test'); 12 | 13 | //Return a value based on the field 14 | if (field == 'Status') { 15 | //For the status field, return In Progress 16 | return 'In Progress'; 17 | } else if (field == 'Priority') { 18 | //For priority, return High 19 | return 'High'; 20 | } else { 21 | Assert.fail('Unexpected field name: ' + field); 22 | } 23 | return null; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /force-app/main/default/classes/RecordSyncTestCallable.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 55.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/RecordSync_Test.cls: -------------------------------------------------------------------------------- 1 | @isTest 2 | public class RecordSync_Test { 3 | /* Check RecordSyncHandlerTest for the test class t hat converts objects into fields */ 4 | 5 | @IsTest static void testCreateAndUpdate() { 6 | RecordSyncHandlerTest.TEST_RECORD_SYNC = true; 7 | 8 | //Create new cases 9 | List cases = new List(); 10 | for (Integer i=0;i < 5;i++) { 11 | cases.add( 12 | new Case( 13 | Subject = 'Test Case ' + (i+1) 14 | ) 15 | ); 16 | } 17 | 18 | Test.startTest(); 19 | insert cases; 20 | Test.stopTest(); 21 | 22 | //Verify tasks were created 23 | cases = [SELECT Id, Subject, (SELECT Subject, Description, Status, Priority FROM Tasks) FROM Case]; 24 | 25 | Assert.areEqual(5, cases.size(), 'Some cases were not created or loaded successfully'); 26 | for (Case c : cases) { 27 | Assert.areEqual(1, c.Tasks.size(), 'Task was not created for case, or too many tasks created'); 28 | Assert.areEqual('Complete the case', c.Tasks[0].Subject, 'Task subject was not set correctly from a constant string value'); 29 | Assert.areEqual(c.Subject, c.Tasks[0].Description, 'Description was not set correctly from the case Subject'); 30 | Assert.areEqual('In Progress', c.Tasks[0].Status, 'Status was not set correctly from a callable class'); 31 | Assert.areEqual('High', c.Tasks[0].Priority, 'Priority was not set correctly from a callable instance'); 32 | } 33 | 34 | //Update case subjects 35 | for (Case c : cases) { 36 | c.Subject = 'Updated subject'; 37 | } 38 | update cases; 39 | 40 | //Verify tasks were updated correctly 41 | cases = [SELECT Id, Subject, (SELECT Subject, Description, Status, Priority FROM Tasks) FROM Case]; 42 | 43 | Assert.areEqual(5, cases.size(), 'Some cases were not created or loaded successfully'); 44 | for (Case c : cases) { 45 | Assert.areEqual(1, c.Tasks.size(), 'Task was not created for case, or too many tasks created'); 46 | Assert.areEqual('Complete the case', c.Tasks[0].Subject, 'Task subject was not set correctly from a constant string value'); 47 | Assert.areEqual(c.Subject, c.Tasks[0].Description, 'Description was not set correctly from the case Subject'); 48 | Assert.areEqual('In Progress', c.Tasks[0].Status, 'Status was not set correctly from a callable class'); 49 | Assert.areEqual('High', c.Tasks[0].Priority, 'Priority was not set correctly from a callable instance'); 50 | } 51 | } 52 | 53 | @IsTest static void testDeleteAndRecreate() { 54 | RecordSyncHandlerTest.TEST_RECORD_SYNC = true; 55 | 56 | //Create new cases 57 | List cases = new List(); 58 | for (Integer i=0;i < 5;i++) { 59 | cases.add( 60 | new Case( 61 | Subject = 'Test Case ' + (i+1) 62 | ) 63 | ); 64 | } 65 | 66 | insert cases; 67 | 68 | //Delete the tasks and make sure they're recreated when the cases are updated 69 | delete [SELECT Id FROM Task]; 70 | 71 | Test.startTest(); 72 | //Update the cases. This should trigger the child tasks to be recreated. 73 | update cases; 74 | Test.stopTest(); 75 | 76 | //Verify tasks were re-created 77 | cases = [SELECT Id, Subject, (SELECT Subject, Description, Status, Priority FROM Tasks) FROM Case]; 78 | 79 | Assert.areEqual(5, cases.size(), 'Some cases were not created or loaded successfully'); 80 | for (Case c : cases) { 81 | Assert.areEqual(1, c.Tasks.size(), 'Task was not created for case, or too many tasks created'); 82 | Assert.areEqual('Complete the case', c.Tasks[0].Subject, 'Task subject was not set correctly from a constant string value'); 83 | Assert.areEqual(c.Subject, c.Tasks[0].Description, 'Description was not set correctly from the case Subject'); 84 | Assert.areEqual('In Progress', c.Tasks[0].Status, 'Status was not set correctly from a callable class'); 85 | Assert.areEqual('High', c.Tasks[0].Priority, 'Priority was not set correctly from a callable instance'); 86 | } 87 | 88 | //Delete cases 89 | delete cases; 90 | 91 | //Verify tasks were deleted 92 | Assert.areEqual(0, [SELECT Id FROM Task].size(), 'Tasks were not deleted when cases were'); 93 | } 94 | 95 | @IsTest static void testUndelete() { 96 | RecordSyncHandlerTest.TEST_RECORD_SYNC = true; 97 | 98 | //Create new cases 99 | List cases = new List(); 100 | for (Integer i=0;i < 5;i++) { 101 | cases.add( 102 | new Case( 103 | Subject = 'Test Case ' + (i+1) 104 | ) 105 | ); 106 | } 107 | 108 | insert cases; 109 | 110 | //Delete the cases so that tasks are also deleted 111 | delete cases; 112 | Assert.areEqual(0, [SELECT Id FROM Task].size(), 'Tasks were not deleted when cases were'); 113 | 114 | //Undelete cases 115 | Test.startTest(); 116 | undelete cases; 117 | Test.stopTest(); 118 | 119 | //Verify tasks were re-created 120 | cases = [SELECT Id, Subject, (SELECT Subject, Description, Status, Priority FROM Tasks) FROM Case]; 121 | 122 | Assert.areEqual(5, cases.size(), 'Some cases were not created or loaded successfully'); 123 | for (Case c : cases) { 124 | Assert.areEqual(1, c.Tasks.size(), 'Task was not created for case, or too many tasks created'); 125 | Assert.areEqual('Complete the case', c.Tasks[0].Subject, 'Task subject was not set correctly from a constant string value'); 126 | Assert.areEqual(c.Subject, c.Tasks[0].Description, 'Description was not set correctly from the case Subject'); 127 | Assert.areEqual('In Progress', c.Tasks[0].Status, 'Status was not set correctly from a callable class'); 128 | Assert.areEqual('High', c.Tasks[0].Priority, 'Priority was not set correctly from a callable instance'); 129 | } 130 | } 131 | 132 | @IsTest static void testShouldSync() { 133 | RecordSyncHandlerTest.TEST_RECORD_SYNC = true; 134 | 135 | //Create new cases 136 | List cases = new List(); 137 | for (Integer i=0;i < 5;i++) { 138 | cases.add( 139 | new Case( 140 | Subject = 'Test Case ' + (i+1) 141 | ) 142 | ); 143 | } 144 | 145 | //Mark one case as 'Delete me' to not sync it 146 | cases[4].Subject = 'Delete me'; 147 | 148 | Test.startTest(); 149 | insert cases; 150 | Test.stopTest(); 151 | 152 | Id ignoredCaseId = cases[4].Id; 153 | 154 | //Verify tasks were created 155 | cases = [SELECT Id, Subject, (SELECT Subject, Description, Status, Priority FROM Tasks) FROM Case]; 156 | 157 | Assert.areEqual(5, cases.size(), 'Some cases were not created or loaded successfully'); 158 | for (Case c : cases) { 159 | if (c.Id == ignoredCaseId) { 160 | //Delete-me case 161 | Assert.areEqual(0, c.Tasks.size(), 'Task was created despite not meeting criteria'); 162 | } else { 163 | //Other cases 164 | Assert.areEqual(1, c.Tasks.size(), 'Task was not created for case, or too many tasks created'); 165 | Assert.areEqual('Complete the case', c.Tasks[0].Subject, 'Task subject was not set correctly from a constant string value'); 166 | Assert.areEqual(c.Subject, c.Tasks[0].Description, 'Description was not set correctly from the case Subject'); 167 | Assert.areEqual('In Progress', c.Tasks[0].Status, 'Status was not set correctly from a callable class'); 168 | Assert.areEqual('High', c.Tasks[0].Priority, 'Priority was not set correctly from a callable instance'); 169 | } 170 | } 171 | 172 | //Update another case to not meet criteria 173 | cases[0].Subject = 'Delete me'; 174 | update cases; 175 | 176 | Id ignoredCaseId2 = cases[0].Id; 177 | 178 | //Verify that the task belonging to this case was deleted 179 | cases = [SELECT Id, Subject, (SELECT Subject, Description, Status, Priority FROM Tasks) FROM Case]; 180 | 181 | Assert.areEqual(5, cases.size(), 'Some cases were not created or loaded successfully'); 182 | for (Case c : cases) { 183 | if (c.Id == ignoredCaseId || 184 | c.Id == ignoredCaseId2) { 185 | //Delete-me case 186 | Assert.areEqual(0, c.Tasks.size(), 'Task was created despite not meeting criteria'); 187 | } else { 188 | //Other cases 189 | Assert.areEqual(1, c.Tasks.size(), 'Task was not created for case, or too many tasks created'); 190 | Assert.areEqual('Complete the case', c.Tasks[0].Subject, 'Task subject was not set correctly from a constant string value'); 191 | Assert.areEqual(c.Subject, c.Tasks[0].Description, 'Description was not set correctly from the case Subject'); 192 | Assert.areEqual('In Progress', c.Tasks[0].Status, 'Status was not set correctly from a callable class'); 193 | Assert.areEqual('High', c.Tasks[0].Priority, 'Priority was not set correctly from a callable instance'); 194 | } 195 | } 196 | } 197 | 198 | @IsTest static void testAsynchronousCreate() { 199 | RecordSyncHandlerTest.TEST_RECORD_SYNC = true; 200 | 201 | //Set trigger handler to run asynchronously 202 | //The rest of the logic doesn't need to be changed, the same results should continue to apply 203 | RecordSyncHandlerTest.ASYNC_MODE = true; 204 | 205 | //Create new cases 206 | List cases = new List(); 207 | for (Integer i=0;i < 5;i++) { 208 | cases.add( 209 | new Case( 210 | Subject = 'Test Case ' + (i+1) 211 | ) 212 | ); 213 | } 214 | 215 | Test.startTest(); 216 | insert cases; 217 | Test.stopTest(); 218 | 219 | //Verify tasks were created 220 | cases = [SELECT Id, Subject, (SELECT Subject, Description, Status, Priority FROM Tasks) FROM Case]; 221 | 222 | Assert.areEqual(5, cases.size(), 'Some cases were not created or loaded successfully'); 223 | for (Case c : cases) { 224 | Assert.areEqual(1, c.Tasks.size(), 'Task was not created for case, or too many tasks created'); 225 | Assert.areEqual('Complete the case', c.Tasks[0].Subject, 'Task subject was not set correctly from a constant string value'); 226 | Assert.areEqual(c.Subject, c.Tasks[0].Description, 'Description was not set correctly from the case Subject'); 227 | Assert.areEqual('In Progress', c.Tasks[0].Status, 'Status was not set correctly from a callable class'); 228 | Assert.areEqual('High', c.Tasks[0].Priority, 'Priority was not set correctly from a callable instance'); 229 | } 230 | 231 | //Update case subjects 232 | for (Case c : cases) { 233 | c.Subject = 'Updated subject'; 234 | } 235 | update cases; 236 | 237 | //Verify tasks were updated correctly 238 | cases = [SELECT Id, Subject, (SELECT Subject, Description, Status, Priority FROM Tasks) FROM Case]; 239 | 240 | Assert.areEqual(5, cases.size(), 'Some cases were not created or loaded successfully'); 241 | for (Case c : cases) { 242 | Assert.areEqual(1, c.Tasks.size(), 'Task was not created for case, or too many tasks created'); 243 | Assert.areEqual('Complete the case', c.Tasks[0].Subject, 'Task subject was not set correctly from a constant string value'); 244 | Assert.areEqual(c.Subject, c.Tasks[0].Description, 'Description was not set correctly from the case Subject'); 245 | Assert.areEqual('In Progress', c.Tasks[0].Status, 'Status was not set correctly from a callable class'); 246 | Assert.areEqual('High', c.Tasks[0].Priority, 'Priority was not set correctly from a callable instance'); 247 | } 248 | } 249 | } -------------------------------------------------------------------------------- /force-app/main/default/classes/RecordSync_Test.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 55.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/TriggerHandler.cls: -------------------------------------------------------------------------------- 1 | global virtual class TriggerHandler { 2 | private static final String APPLIES_TO_EVERYONE = 'Everyone', 3 | APPLIES_TO_USER = 'Username', 4 | APPLIES_TO_PROFILE = 'Profile', 5 | APPLIES_TO_ROLE = 'Role', 6 | APPLIES_TO_PERM_SET = 'Permission Set'; 7 | public static Map mapTriggerControllers = null; 8 | 9 | global TriggerHandler() { 10 | try { 11 | Trigger_Controller__mdt controller = loadTriggerConfig(); 12 | 13 | if (Trigger.isBefore) { 14 | if (Trigger.isInsert && controller.Before_Insert__c) { 15 | beforeInsert(); 16 | } else if (Trigger.isUpdate && controller.Before_Update__c) { 17 | beforeUpdate(); 18 | } else if (Trigger.isDelete && controller.Before_Delete__c) { 19 | beforeDelete(); 20 | } else if (Trigger.isUndelete && controller.Before_Undelete__c) { 21 | beforeUndelete(); 22 | } 23 | } else if (Trigger.isAfter) { 24 | if (Trigger.isInsert && controller.After_Insert__c) { 25 | afterInsert(); 26 | } else if (Trigger.isUpdate && controller.After_Update__c) { 27 | afterUpdate(); 28 | } else if (Trigger.isDelete && controller.After_Delete__c) { 29 | afterDelete(); 30 | } else if (Trigger.isUndelete && controller.After_Undelete__c) { 31 | afterUndelete(); 32 | } 33 | } 34 | } catch (Exception e) { 35 | onError(e); 36 | } 37 | } 38 | 39 | global virtual void onError(Exception e) { 40 | throw e; 41 | } 42 | 43 | private Trigger_Controller__mdt loadTriggerConfig() { 44 | if (TriggerHandler.mapTriggerControllers == null) { 45 | //Trigger map is not loaded. Query and populate map. 46 | TriggerHandler.mapTriggerControllers = new Map(); 47 | 48 | UserData ud = new UserData(); 49 | 50 | //Query trigger controls 51 | List triggers = [SELECT MasterLabel, Applies_To_Type__c, Applies_To_Value__c, 52 | Before_Insert__c, Before_Update__c, Before_Delete__c, Before_Undelete__c, 53 | After_Insert__c, After_Update__c, After_Delete__c, After_Undelete__c 54 | FROM Trigger_Controller__mdt]; 55 | 56 | //Map trigger controllers by label (trigger handler name) and filter them to only ones relevant for this user 57 | for (Trigger_Controller__mdt trig : triggers) { 58 | //Check that this trigger controller applies to the user 59 | if (ud.isApplicable(trig)) { 60 | //Find an existing controller for this class 61 | Trigger_Controller__mdt existing = TriggerHandler.mapTriggerControllers.get(trig.MasterLabel.toLowerCase()); 62 | 63 | if (existing != null) { 64 | //Existing controller found. Merge the two. 65 | mergeControllers(existing, trig); 66 | } else { 67 | //New controller found. 68 | TriggerHandler.mapTriggerControllers.put(trig.MasterLabel.toLowerCase(), trig); 69 | } 70 | } 71 | } 72 | } 73 | 74 | //Figure out the name of the current trigger 75 | String className = String.valueOf(this).substring(0,String.valueOf(this).indexOf(':')).toLowerCase(); 76 | Trigger_Controller__mdt controller = TriggerHandler.mapTriggerControllers.get(className); 77 | 78 | if (controller != null) { 79 | //Controller found, return it 80 | return controller; 81 | } 82 | 83 | //Return default controller with all options on 84 | return new Trigger_Controller__mdt( 85 | MasterLabel = 'Default', 86 | Before_Insert__c = true, 87 | Before_Update__c = true, 88 | Before_Delete__c = true, 89 | Before_Undelete__c = true, 90 | After_Insert__c = true, 91 | After_Update__c = true, 92 | After_Delete__c = true, 93 | After_Undelete__c = true 94 | ); 95 | } 96 | 97 | @TestVisible 98 | static private void mergeControllers(Trigger_Controller__mdt main, Trigger_Controller__mdt secondary) { 99 | main.Before_Insert__c &= secondary.Before_Insert__c; 100 | main.Before_Update__c &= secondary.Before_Update__c; 101 | main.Before_Delete__c &= secondary.Before_Delete__c; 102 | main.Before_Undelete__c &= secondary.Before_Undelete__c; 103 | main.After_Insert__c &= secondary.After_Insert__c; 104 | main.After_Update__c &= secondary.After_Update__c; 105 | main.After_Delete__c &= secondary.After_Delete__c; 106 | main.After_Undelete__c &= secondary.After_Undelete__c; 107 | } 108 | 109 | private class UserData { 110 | private Set roleNames; 111 | private Set permSets; 112 | private String profileName; 113 | private String userName; 114 | 115 | private UserData() { 116 | //Load user details 117 | User u = [SELECT Id, Username, Profile.Name, UserRole.Name, UserRole.DeveloperName, 118 | (SELECT PermissionSet.Name FROM PermissionSetAssignments) 119 | FROM User 120 | WHERE Id = :UserInfo.geTUserId()]; 121 | 122 | //Create a set of assigned permission sets 123 | this.permSets = new Set(); 124 | for (PermissionSetAssignment psa : u.PermissionSetAssignments) { 125 | permSets.add(psa.PermissionSet?.Name); 126 | } 127 | 128 | //Extract profile and role names from the user 129 | this.profileName = u?.Profile?.Name; 130 | 131 | this.roleNames = new Set(); 132 | this.roleNames.add(u?.UserRole?.Name); 133 | this.roleNames.add(u?.UserRole?.DeveloperName); 134 | 135 | this.userName = u.Username; 136 | } 137 | 138 | private Boolean isApplicable(Trigger_Controller__mdt trig) { 139 | if (trig.Applies_To_Type__c == APPLIES_TO_PROFILE) { 140 | return trig.Applies_To_Value__c.equalsIgnoreCase(this.profileName); 141 | } else if (trig.Applies_To_Type__c == APPLIES_TO_ROLE) { 142 | return this.roleNames.contains(trig.Applies_To_Value__c); 143 | } else if (trig.Applies_To_Type__c == APPLIES_TO_PERM_SET) { 144 | return this.permSets.contains(trig.Applies_To_Value__c); 145 | } else if (trig.Applies_To_Type__c == APPLIES_TO_USER) { 146 | return trig.Applies_To_Value__c.equalsIgnoreCase(this.userName); 147 | } 148 | 149 | return true; 150 | } 151 | } 152 | 153 | global virtual void beforeInsert() {} 154 | global virtual void beforeUpdate() {} 155 | global virtual void beforeDelete() {} 156 | global virtual void beforeUndelete() {} 157 | 158 | global virtual void afterInsert() {} 159 | global virtual void afterUpdate() {} 160 | global virtual void afterDelete() {} 161 | global virtual void afterUndelete() {} 162 | } 163 | -------------------------------------------------------------------------------- /force-app/main/default/classes/TriggerHandler.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 48.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/TriggerHandler_Test.cls: -------------------------------------------------------------------------------- 1 | @isTest public class TriggerHandler_Test { 2 | @IsTest static void testMergeControllers() { 3 | //Create two controllers to merge with different configuration 4 | Trigger_Controller__mdt ctrl1 = new Trigger_Controller__mdt(MasterLabel='Main', Before_Insert__c = true, Before_Update__c = true); 5 | Trigger_Controller__mdt ctrl2 = new Trigger_Controller__mdt(MasterLabel='Secondary', Before_Insert__c = false, Before_Update__c = true); 6 | 7 | //Merge the two controllers 8 | Test.startTest(); 9 | TriggerHandler.mergeControllers(ctrl1, ctrl2); 10 | Test.stopTest(); 11 | 12 | //Check results 13 | Assert.areEqual(false, ctrl1.Before_Insert__c, 'Field was not merged correctly on the main controller'); 14 | Assert.areEqual(true, ctrl1.Before_Update__c, 'Field was deactivated incorrectly on the main controller'); 15 | } 16 | 17 | @IsTest static void testOnError() { 18 | try { 19 | (new TriggerHandler()); 20 | Assert.fail('Exception not thrown when instantiating trigger handler outside of trigger context'); 21 | } catch (System.NullPointerException ex) { 22 | //OK 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /force-app/main/default/classes/TriggerHandler_Test.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 55.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/customMetadata/Trigger_Controller.RecordSyncHandlerTest.md-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | false 5 | 6 | After_Delete__c 7 | true 8 | 9 | 10 | After_Insert__c 11 | true 12 | 13 | 14 | After_Undelete__c 15 | true 16 | 17 | 18 | After_Update__c 19 | true 20 | 21 | 22 | Applies_To_Type__c 23 | Everyone 24 | 25 | 26 | Applies_To_Value__c 27 | 28 | 29 | 30 | Before_Delete__c 31 | true 32 | 33 | 34 | Before_Insert__c 35 | true 36 | 37 | 38 | Before_Undelete__c 39 | true 40 | 41 | 42 | Before_Update__c 43 | true 44 | 45 | 46 | -------------------------------------------------------------------------------- /force-app/main/default/layouts/Trigger_Controller__mdt-Trigger Controller Layout.layout-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | false 5 | false 6 | true 7 | 8 | 9 | 10 | Required 11 | MasterLabel 12 | 13 | 14 | Required 15 | DeveloperName 16 | 17 | 18 | 19 | 20 | Edit 21 | IsProtected 22 | 23 | 24 | Required 25 | NamespacePrefix 26 | 27 | 28 | 29 | 30 | 31 | true 32 | true 33 | true 34 | 35 | 36 | 37 | Required 38 | Applies_To_Type__c 39 | 40 | 41 | 42 | 43 | Edit 44 | Applies_To_Value__c 45 | 46 | 47 | 48 | 49 | 50 | true 51 | true 52 | true 53 | 54 | 55 | 56 | Edit 57 | After_Insert__c 58 | 59 | 60 | Edit 61 | After_Update__c 62 | 63 | 64 | Edit 65 | After_Delete__c 66 | 67 | 68 | Edit 69 | After_Undelete__c 70 | 71 | 72 | 73 | 74 | Edit 75 | Before_Insert__c 76 | 77 | 78 | Edit 79 | Before_Delete__c 80 | 81 | 82 | Edit 83 | Before_Update__c 84 | 85 | 86 | Edit 87 | Before_Undelete__c 88 | 89 | 90 | 91 | 92 | 93 | false 94 | false 95 | true 96 | 97 | 98 | 99 | Readonly 100 | CreatedById 101 | 102 | 103 | 104 | 105 | Readonly 106 | LastModifiedById 107 | 108 | 109 | 110 | 111 | 112 | true 113 | true 114 | false 115 | 116 | 117 | 118 | 119 | 120 | 121 | false 122 | false 123 | false 124 | false 125 | false 126 | 127 | 00h8d00000582QN 128 | 4 129 | 0 130 | Default 131 | 132 | 133 | -------------------------------------------------------------------------------- /force-app/main/default/objects/Trigger_Controller__mdt/Trigger_Controller__mdt.object-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Trigger Controllers 5 | Public 6 | 7 | -------------------------------------------------------------------------------- /force-app/main/default/objects/Trigger_Controller__mdt/fields/After_Delete__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | After_Delete__c 4 | true 5 | false 6 | SubscriberControlled 7 | 8 | Checkbox 9 | 10 | -------------------------------------------------------------------------------- /force-app/main/default/objects/Trigger_Controller__mdt/fields/After_Insert__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | After_Insert__c 4 | true 5 | false 6 | SubscriberControlled 7 | 8 | Checkbox 9 | 10 | -------------------------------------------------------------------------------- /force-app/main/default/objects/Trigger_Controller__mdt/fields/After_Undelete__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | After_Undelete__c 4 | true 5 | false 6 | SubscriberControlled 7 | 8 | Checkbox 9 | 10 | -------------------------------------------------------------------------------- /force-app/main/default/objects/Trigger_Controller__mdt/fields/After_Update__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | After_Update__c 4 | true 5 | false 6 | SubscriberControlled 7 | 8 | Checkbox 9 | 10 | -------------------------------------------------------------------------------- /force-app/main/default/objects/Trigger_Controller__mdt/fields/Applies_To_Type__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Applies_To_Type__c 4 | false 5 | SubscriberControlled 6 | 7 | true 8 | Picklist 9 | 10 | true 11 | 12 | false 13 | 14 | Everyone 15 | true 16 | 17 | 18 | 19 | Username 20 | false 21 | 22 | 23 | 24 | Profile 25 | false 26 | 27 | 28 | 29 | Role 30 | false 31 | 32 | 33 | 34 | Permission Set 35 | false 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /force-app/main/default/objects/Trigger_Controller__mdt/fields/Applies_To_Value__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Applies_To_Value__c 4 | false 5 | SubscriberControlled 6 | If Applies To (Type) is specified, please enter a relevant username, profile, role, or permission set name for which to apply this configuration. 7 | 8 | 255 9 | false 10 | Text 11 | false 12 | 13 | -------------------------------------------------------------------------------- /force-app/main/default/objects/Trigger_Controller__mdt/fields/Before_Delete__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Before_Delete__c 4 | true 5 | false 6 | SubscriberControlled 7 | 8 | Checkbox 9 | 10 | -------------------------------------------------------------------------------- /force-app/main/default/objects/Trigger_Controller__mdt/fields/Before_Insert__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Before_Insert__c 4 | true 5 | false 6 | SubscriberControlled 7 | 8 | Checkbox 9 | 10 | -------------------------------------------------------------------------------- /force-app/main/default/objects/Trigger_Controller__mdt/fields/Before_Undelete__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Before_Undelete__c 4 | true 5 | false 6 | SubscriberControlled 7 | 8 | Checkbox 9 | 10 | -------------------------------------------------------------------------------- /force-app/main/default/objects/Trigger_Controller__mdt/fields/Before_Update__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Before_Update__c 4 | true 5 | false 6 | SubscriberControlled 7 | 8 | Checkbox 9 | 10 | -------------------------------------------------------------------------------- /force-app/main/default/objects/Trigger_Controller__mdt/listViews/All_Trigger_Controllers.listView-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | All_Trigger_Controllers 4 | MasterLabel 5 | Before_Insert__c 6 | Before_Update__c 7 | Before_Delete__c 8 | Before_Undelete__c 9 | After_Insert__c 10 | After_Update__c 11 | After_Delete__c 12 | After_Undelete__c 13 | Everything 14 | 15 | en_US 16 | 17 | -------------------------------------------------------------------------------- /force-app/main/default/objects/Trigger_Controller__mdt/validationRules/Applies_To_Value.validationRule-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Applies_To_Value 4 | true 5 | AND ( 6 | TEXT(Applies_To_Type__c) != 'Everyone', 7 | ISBLANK(Applies_To_Value__c) 8 | ) 9 | Applies_To_Value__c 10 | When selecting configuration to apply to anything other than Everyone, please specify a relevant username, profile name, role, or permission set for the configuration to apply. 11 | 12 | -------------------------------------------------------------------------------- /force-app/main/default/triggers/RecordSyncTestTrigger.trigger: -------------------------------------------------------------------------------- 1 | trigger RecordSyncTestTrigger on Case (after insert, after update, before delete, after undelete) { 2 | if (RecordSyncHandlerTest.TEST_RECORD_SYNC == true) { 3 | new RecordSyncHandlerTest(); 4 | } 5 | } -------------------------------------------------------------------------------- /force-app/main/default/triggers/RecordSyncTestTrigger.trigger-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 55.0 4 | Active 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { jestConfig } = require('@salesforce/sfdx-lwc-jest/config'); 2 | 3 | module.exports = { 4 | ...jestConfig, 5 | modulePathIgnorePatterns: ['/.localdevserver'] 6 | }; 7 | -------------------------------------------------------------------------------- /manifest/package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | * 5 | ApexClass 6 | 7 | 8 | * 9 | ApexTrigger 10 | 11 | 12 | * 13 | Layout 14 | 15 | 16 | * 17 | CustomMetadata 18 | 19 | 55.0 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "salesforce-app", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Salesforce App", 6 | "scripts": { 7 | "lint": "eslint **/{aura,lwc}/**", 8 | "test": "npm run test:unit", 9 | "test:unit": "sfdx-lwc-jest", 10 | "test:unit:watch": "sfdx-lwc-jest --watch", 11 | "test:unit:debug": "sfdx-lwc-jest --debug", 12 | "test:unit:coverage": "sfdx-lwc-jest --coverage", 13 | "prettier": "prettier --write \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"", 14 | "prettier:verify": "prettier --list-different \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"", 15 | "postinstall": "husky install", 16 | "precommit": "lint-staged" 17 | }, 18 | "devDependencies": { 19 | "@lwc/eslint-plugin-lwc": "^1.1.2", 20 | "@prettier/plugin-xml": "^2.0.1", 21 | "@salesforce/eslint-config-lwc": "^3.2.3", 22 | "@salesforce/eslint-plugin-aura": "^2.0.0", 23 | "@salesforce/eslint-plugin-lightning": "^1.0.0", 24 | "@salesforce/sfdx-lwc-jest": "^1.1.0", 25 | "eslint": "^8.11.0", 26 | "eslint-plugin-import": "^2.25.4", 27 | "eslint-plugin-jest": "^26.1.2", 28 | "husky": "^7.0.4", 29 | "lint-staged": "^12.3.7", 30 | "prettier": "^2.6.0", 31 | "prettier-plugin-apex": "^1.10.0" 32 | }, 33 | "lint-staged": { 34 | "**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}": [ 35 | "prettier --write" 36 | ], 37 | "**/{aura,lwc}/**": [ 38 | "eslint" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /sfdx-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageDirectories": [ 3 | { 4 | "path": "force-app", 5 | "default": true 6 | } 7 | ], 8 | "name": "TriggerFramework", 9 | "namespace": "", 10 | "sfdcLoginUrl": "https://login.salesforce.com", 11 | "sourceApiVersion": "55.0" 12 | } 13 | --------------------------------------------------------------------------------