Capture the name of the App where a record was created

Background

One of my clients asked me if there is a way to track which application the user was in when they created an Account record. The client did not have Event Monitoring. 

The thought was to store the application name where the user was at the time they created the account, on the account itself. But this storage can also be done in a more centralized location such as a custom object and be extended to more than just account object records. Whether we store the information on the record, or in a centralized location, the approach to get the application information would be the same using the two objects mentioned below.

The queries can be run in Apex trigger/LWC, or using Flow.

Use the following objects, in this order:

  1. UserAppInfo (Label: Last Used App)
  2. AppDefinition (Label: App Definition)

Sample Queries:

Query UserApInfo filtering for User ID first:

SELECT AppDefinitionId, UserId, Id FROM UserAppInfo where UserId='0052g0000023kgaAAA'

Take the AppDefinitionId and filter AppDefinition where DurableId=AppDefinitionId:

SELECT Id, DurableId, Label, MasterLabel, DeveloperName FROM AppDefinition Where DurableId='06md0000000bp06AAA'

Sample Flow

Apex Code Snippets

GET COUNT OF RECORDS BY A PARTICULAR ATTRIBUTE/FIELD

[SELECT Status, COUNT(id) numRecs FROM Case GROUP BY Status];

GET FIELD NAMES OF AN OBJECT

// replace Case with the appropriate object
Schema.DescribeSObjectResult dsr = Case.sObjectType.getDescribe();
Map<String, Schema.SObjectField> fieldMap = dsr.fields.getMap();
for (String fieldName : fieldMap.keySet())
{
  System.debug('%' + dsr.getName() + '% Label: %' + 
  fieldMap.get(fieldName).getDescribe().getLabel() + '% Field Name: %' + fieldName);
}

GET A LIST OF ALL OBJECTS

List<Schema.SObjectType> gd = Schema.getGlobalDescribe().Values();  
for(Schema.SObjectType f : gd)
{
   System.debug(f.getDescribe().getLabel());
}

GET A LIST OF ALL OUTBOUND CHANGE SETS

PageReference pr=new PageReference('/changemgmt/listOutboundChangeSet.apexp');
Blob output=pr.getContent();
System.debug('### Content = \n' + output.toString());

GET IP ADDRESS

// Get the IP address for the user submitting the webform. 

 public static String GetUserIPAddress() {
   string ReturnValue = ''; 
   // True-Client-IP has the value when the request is coming via the caching integration.
   ReturnValue = ApexPages.currentPage().getHeaders().get('True-Client-IP');
   // X-Salesforce-SIP has the value when no caching integration or via secure URL.
   if (ReturnValue == '' || ReturnValue == null) {
     ReturnValue = ApexPages.currentPage().getHeaders().get('X-Salesforce-SIP');
   } // get IP address when no caching (sandbox, dev, secure urls)
  
   if (ReturnValue == '' || ReturnValue == null) {
     ReturnValue = ApexPages.currentPage().getHeaders().get('X-Forwarded-For');
   } // get IP address from standard header if proxy in use

   return ReturnValue;
 } // GetUserIPAddress

GET DATA FROM CUSTOM METADATA TYPES

String queueExt = '69381';

CTI_Case_Queue__mdt[] queueMapping = [SELECT MasterLabel, Label, DeveloperName, Queue_Extension__c, Queue_Name__c 
                  FROM CTI_Case_Queue__mdt
                  WHERE Queue_Extension__c = :queueExt];

System.debug('==== queueExt: ' + queueExt);
System.debug('==== num of records: ' + queueMapping.size());

for(CTI_Case_Queue__mdt q : queueMapping)
{
 System.debug('==== MasterLabel: ' + q.MasterLabel);
 System.debug('==== Label: ' + q.Label);
 System.debug('==== DeveloperName: ' + q.DeveloperName);
 System.debug('==== Queue_Extension__c: ' + q.Queue_Extension__c);
 System.debug('==== Queue_Name__c: ' + q.Queue_Name__c);
}

PUBLISH A DOCUMENT INTO A LIBRARY

if(!documentIdsList.isEmpty()){ // add the document to the unpublished library first.
        Id internalLibraryId = FDICUtil.getLibraryId('EDOS Community - Unpublished Files');
        ContentWorkspace internalLib = [Select Id FROM ContentWorkspace WHERE NAME = 'EDOS Community - Unpublished Files'];
        if (internalLibraryId != null) {
// link the file to the unpublished library by default. When the order gets published the file will be
// shared with t he published library. We can remove the share with the public library but we can't remove it from
// the unpublished library since that was the first library the file was shared with. By default the first library
// the document gets linked to is the owner of that library, so link it to the unpublished library first.

          for(Id docId: documentIdsList) {
            //ContentWorkspaceDoc cwd = new ContentWorkspaceDoc(ContentDocumentId = docId, ContentWorkspaceId = internalLibraryId);             
            ContentWorkspaceDoc cwd = new ContentWorkspaceDoc(ContentDocumentId = docId, ContentWorkspaceId = internalLib.Id);  
            addToLibraryList.add(cwd);
          }
        }

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

GET A LIST OF ALL RELATED OBJECTS FROM A PARENT OBJECT

Set<String> results = new Set<String>();
for( ChildRelationship r: Case.SObjectType.getDescribe().getChildRelationships()) {
  results.add(string.valueOf(r.getChildSObject()));
}
system.debug(string.join(new List<String>(results), ', '));

GET FIELDS ON PAGE LAYOUT USING APEX METADATA API:

//list to hold all account layouts.
List<Metadata.Metadata> accountLayout = Metadata.Operations.retrieve(Metadata.MetadataType.Layout, new List<String> {'Account-Account Layout'});

//map to hold each section and a list of their fields.
Map<String, List<String>> layoutFieldsMap = new Map<String, List<String>>();

System.debug('accountLayouts: ' + accountLayout);

//get the first layout information
if(!accountLayout.isEmpty()) {
    Metadata.Layout layoutMd = (Metadata.Layout) accountLayout.get(0);
    
    //loop through each section and get fields in each column
    for (Metadata.LayoutSection section : layoutMd.layoutSections) {
        System.debug('Section: ' + section.label);
        List<String> fields = new List<String>();
        
        //go through each column and get the fields.
        for (Metadata.LayoutColumn column : section.layoutColumns) {     
            if (column.layoutItems != null) {
                for (Metadata.LayoutItem item : column.layoutItems) {
                    System.debug('Field: ' + item.field);
                    fields.add(item.field);
                }
            }
            //store fields list in a map with the section name as key
            layoutFieldsMap.put(section.label, fields);
        }
    }
    
    System.debug('fields map: ' + layoutFieldsMap); //fields is getting nulled out. figure it out.
    for(String key : layoutFieldsMap.keySet()){
        List<String> f = layoutFieldsMap.get(key);
        System.debug('key: ' + key);
        System.debug('f: ' + f);
    }
}
else {
    System.debug('Did not find the layout specified');
}

GET LIMITS FOR THE ORG

//Get list of what limits are available.
List<System.OrgLimit> limits = OrgLimits.getAll();
for (System.OrgLimit aLimit: limits) {
  System.debug('Limit Name ' + aLimit.getName());
}

Map<String,System.OrgLimit> limitsMap = OrgLimits.getMap();
System.OrgLimit dataStorageLimit = limitsMap.get('DataStorageMB');
System.OrgLimit fileStorageLimit = limitsMap.get('FileStorageMB');

// Max and usage limit values of dataStorageLimit
System.debug('dataStorageLimit Value: ' + dataStorageLimit.getValue());
System.debug('Maximum Limit: ' + dataStorageLimit.getLimit());

// Max and usage limit values of fileStorageLimit
System.debug('fileStorageLimit Value: ' + fileStorageLimit.getValue());
System.debug('Maximum Limit: ' + fileStorageLimit.getLimit());

GET NUMBER OF USERS ASSIGNED TO EACH PERMISSION SET

SELECT PermissionSet.Name, Count(Id) From PermissionSetAssignment
WHERE Assignee.isActive = true
AND PermissionSet.IsOwnedByProfile = false
Group By PermissionSet.Name
Order By Count(Id) DESC

GET NUMBER OF USERS ASSIGNED TO EACH PROFILE

SELECT count(Id), Profile.Name FROM User GROUP BY Profile.Name /* NOTE: make sure to filter by active users if you want only active users */

Export Object Level Permissions for All Profiles and Permission Sets – Data Loader

SELECT Parent.Profile.Name, Parent.Label, Parent.IsOwnedByProfile, SobjectType, PermissionsRead, PermissionsCreate, PermissionsEdit, PermissionsDelete, PermissionsViewAllRecords, PermissionsModifyAllRecords FROM ObjectPermissions ORDER BY Parent.Profile.Name, Parent.Label, SobjectType

Export Field Level Security for All Profiles and Permission Sets – Data Loader

SELECT Parent.Profile.Name, Parent.Label, Parent.IsOwnedByProfile, SobjectType, Field, PermissionsEdit, PermissionsRead FROM FieldPermissions ORDER BY Parent.Profile.Name, Parent.Label, SobjectType, Field

GET LIST OF APPS

SELECT Id, ApplicationId, Name, Label, Type FROM AppMenuItem Where type='TabSet' /* NOTE: remove where clause to see all application types (Network, Connected Apps, etc.). TabSet is for Salesforce Apps created for users */

GET LIST OF PROFILES WITH ACCESS TO APPS

SELECT Id, SetupEntityId, ParentId, Parent.Label, Parent.IsCustom, Parent.IsOwnedByProfile, Parent.ProfileId, Parent.Profile.Name FROM SetupEntityAccess WHERE SetupEntityType = 'TabSet' AND Parent.IsOwnedByProfile = true ORDER BY Parent.ProfileId

Get Data From Custom Metadata Types

What are Custom Metadata Types?

Essentially, Custom Metadata Types are a replacement for List Custom Settings. But they are more than that, you can:

  • Deploy Custom Metadata Types along with their records.
  • Control the visibility or the metadata type, as well as individual records.
  • Query them using SOQL and not count against governor limits.
  • Access them in Process Builder and Validation Rules
  • For more information, see official Salesforce Documentation

Sample Code to read Custom Metadata Type records:

String queueExt = '69381';

// you can query custom metadata type using SOQL. The advantage is that this query does not count against governor limits.
CTI_Case_Queue__mdt[] queueMapping = [SELECT 
                MasterLabel, Label, DeveloperName, 
                Queue_Extension__c, Queue_Name__c 
                  FROM CTI_Case_Queue__mdt
                  WHERE Queue_Extension__c = :queueExt];

System.debug('==== queueExt: ' + queueExt);
System.debug('==== num of records: ' + queueMapping.size());

for(CTI_Case_Queue__mdt q : queueMapping)
{
 System.debug('==== MasterLabel: ' + q.MasterLabel);
 System.debug('==== Label: ' + q.Label);
 System.debug('==== DeveloperName: ' + q.DeveloperName);
 System.debug('==== Queue_Extension__c: ' + q.Queue_Extension__c);
 System.debug('==== Queue_Name__c: ' + q.Queue_Name__c);
}

An Apex Brush For SyntaxHighlighter Evolved

I recently started using the great SyntaxHighlighter Evolved plugin to highlight some code samples I am posting. It is a great plugin if you haven’t tried it yet.

While looking at the available brushes (these are what color the different parts of the code), I noticed that there was no option for Apex, which is the language used to write code to extend the Salesforce Platform. So, I created a plugin of my own which registers the Apex Brush with SyntaxHighligher and make it aware of it so that the Apex brush can be presented as an option to the user.

Note that while you could edit the SyntaxHighlighter Evolved plugin file directly, doing so risks your changes being overwritten if the plugin is updated by the author. It is safer to write your own plugin and register it so that it can live independent of the upgrades.

The first step in creating this plugin is to create a folder under your wp-content/plugins folder I called my folder apexBrushSyntaxHighlighter. Below is my folder structure:

wp-content folder structure showing the new folder.
wp-content folder structure

Once you have the folder created, you will need to store 2 files under it:

  • A JavaScript file which defines the styles to be used to highlight the code
  • A PHP file which registers the new brush with the SyntaxHightlighter Evolved plugin.

I called my JS and PHP Files shBrushApex.js and apexBrush.php respectively. Below is the code for each file.

Apex Brush JS Code

/* 
**************************
shBrushApex.js:
**************************
*/

SyntaxHighlighter.brushes.Apex = function()
{
    var funcs = 'debug runAs';
    var keywords = 'case catch class do else if enum final for From global global if in inherited sharing interface new null override private protected public return \
Select static super switch System this transient try void Where while with sharing without';
    var datatypes = 'Blob Boolean Date Datetime Decimal Double ID Integer List Long Map Object Set String Time';

    this.regexList = [
        { regex: SyntaxHighlighter.regexLib.singleLineCComments,	css: 'comments' },		// one line comments
		{ regex: /\/\*([^\*][\s\S]*?)?\*\//gm,						css: 'comments' },	 	// multiline comments
        { regex: SyntaxHighlighter.regexLib.multiLineDoubleQuotedString,	css: 'string' },
		{ regex: SyntaxHighlighter.regexLib.singleLineCComments,	css: 'comments' },
		{ regex: SyntaxHighlighter.regexLib.multiLineCComments,		css: 'comments' },
		{ regex: SyntaxHighlighter.regexLib.singleQuotedString,		css: 'string' },
		{ regex: SyntaxHighlighter.regexLib.doubleQuotedString,		css: 'string' },
        { regex: new RegExp(this.getKeywords(funcs), 'gmi'),				css: 'color2' },
        { regex: new RegExp(this.getKeywords(keywords), 'gmi'),				css: 'keyword' },
        { regex: new RegExp(this.getKeywords(datatypes), 'gmi'),				css: 'variable' },
        { regex: new RegExp('^@[aA-zZ]*$', 'gmi'), css: 'color1'} //annotatations
        ];
};

SyntaxHighlighter.brushes.Apex.prototype = new SyntaxHighlighter.Highlighter();
SyntaxHighlighter.brushes.Apex.aliases	= ['apex', 'Apex', 'apx'];

Apex Brush For SyntaxHighlighter Plugin Code

Note: this is a plugin file. You have to create a new directory under your wp-content folder and add this PHP file and the above JS file in that directory. The plugin will register the brush with SyntaxHighlighter Evolved.

<?php
/*
Plugin Name: Apex Brush For SyntaxHighlighter Evolved
Description: Adds support for the Salesforce Apex language to the SyntaxHighlighter Evolved plugin.
Author: Sudhir Durvasula
Version: 1.0.0
Author URI: https://salesforceduo.com/
*/
 
// SyntaxHighlighter Evolved doesn't do anything until early in the "init" hook, so best to wait until after that
add_action( 'init', 'syntaxhighlighter_apexBrush_regscript' );
 
// Tell SyntaxHighlighter Evolved about this new language/brush
add_filter( 'syntaxhighlighter_brushes', 'syntaxhighlighter_apex_addlang' );

// Set aliases for the language
add_filter( 'syntaxhighlighter_brush_names', 'syntaxhighlighter_apex_addBrushName');
 
// Register the brush file with WordPress
function syntaxhighlighter_apexBrush_regscript() {
    wp_register_script( 'syntaxhighlighter-brush-apex', plugins_url( 'shBrushApex.js', __FILE__ ), array('syntaxhighlighter-core'), '1.0.0' );
}
 
// Filter SyntaxHighlighter Evolved's language array
function syntaxhighlighter_apex_addlang( $brushes ) {
    $brushes['apex'] = 'apex';
    $brushes['Apex'] = 'apex';
 
    return $brushes;
}

// Add brush name to be exposed in the block's language dropdown
function syntaxhighlighter_apex_addBrushName( $brush_names ) {
    $brush_names['apex'] = __( 'Apex', 'syntaxhighlighter' );
    
    return $brush_names;
}
 
?>

Apex Syntax Highlighting Sample

System.debug('===');
String a;
Integer i;
@AuraEnabled
public static void test() {
  this.name = 'Sudhir';
};

@isTest
public static void test2() {
  this.name = 'Sudhir';
};

That’s all there is to it! Easy, peasy. Lemon squeezy.