Creating a Custom Action

You can create custom actions to perform various tasks such as sending automated emails or creating invoices.

Custom actions are generated using Apex classes that you create in your org. To create a custom action, the Apex class must specify that the action can be invoked. Additionally, you must indicate whether the Apex class is multi-select or single-select, and specify any parameters or information from the database that the Apex class requires.

Salesforce Update

If you are using any Order and Inventory Management plugins, or custom Apex classes as part of you org settings, you must have a visible constructor initialized. This follows the Salesforce update applied on August 18, 2020. Ensure your custom Apex classes comply with this requirement to avoid any errors when executing them:

  • All Apex classes must have a visible constructor to be initialized
  • Public (Managed Classes)
  • Global (Unmanaged Classes)

For more information, see Restrict Reflective Access to Non-Global Constructors in Packages (Critical Update) in the Salesforce Spring '20 Release Notes.

To create a custom action:

  1. Create an Apex class that implements ffr.IActionViewsAction and create an available action custom metadata type. You must add the name of your Apex class to the available action custom metadata type. For more information, see the Salesforce Help. An example Apex class has been provided below.
  2. In Action Views, select the Dataviews tab and open the dataview you want to add a custom action to.
  3. Select the Actions tab and click Add.
  4. Provide a name, description, and parameter for the action.
  5. Select the Apex class from the picklist in the Apex Class column.
  6. Click Save.
  7. The custom action is created and ready to run.
Note:

Not all actions can work in all dataviews. If an action contains a required field that is not recognized by the dataview, you cannot save the action.

Sample Apex Class

Here is an example of an Apex class for creating a custom action:

Copy
global class ActionConsolidateBillingDocuments implements ffr.IActionViewsAction, ffr.IActionViewsActionValidatable 
{
    public class MyException extends Exception {}

    private static final String DOCUMENT_DATE = 'DOCUMENTDATE'; // This is saying that the Action requires a document date
    private final String REQUIREDFIELDNAME = 'Billing Document Id'; // This is saying that this field name MUST exist on action vieww
    private final String ID = 'Id';

    public ActionConsolidateBillingDocuments() // Constructor
    {
    }

    public ffr.ActionViewsService.ValidationResult Validate(ffr.DataviewService.Dataview dataview, ffr.SelectionService.Result selectedData) //Implement base class method
    {
        ffr.ActionViewsService.ValidationResult result = new ffr.ActionViewsService.ValidationResult();

        result.StatusCategory = ffr.ActionViewsService.MessageLevel.SUCCESS;
        result.StatusMessage = '';
        result.parameterMetaData = getParameterMetadataList();
        return result;
    }

    public ffr.ActionViewsService.ActionResult invokeAction(ffr.DataViewService.Dataview dataview, ffr.SelectionService.Result selectedData, String actionParameter, Map<String, String> promptValues)
    {
        if(selectedData.Header == null)
        {
            return createActionResult(ffr.ActionViewsService.MessageLevel.ERROR, 'No data selected for action', false);
        }

        // Get the list of field.Id where the field.Id name is BillingDocumentId
        Id billingDocumentField = getDataViewFieldId(dataview, REQUIREDFIELDNAME, ID);
        
        Set<Id> billingDocumentIds  = new Set<Id>();
        Integer billingDocumentIdIndex = null;

        for(Integer index = 0; index < selectedData.Header.Fields.size(); index++)
        {
            Id fieldId = selectedData.Header.Fields[index];
            if(billingDocumentField == fieldId)
            {
                billingDocumentIdIndex = index;
                break;
            } 
        }

        if(billingDocumentIdIndex == null)
        {
            return createActionResult(ffr.ActionViewsService.MessageLevel.ERROR,'No billing documents selected', false);
        }

        for(ffr.SelectionService.Row row : selectedData.Rows)
        {
            if( String.isBlank((String)row.Values.get(billingDocumentIdIndex)))
            {
                // Looks like this could be something other than an billingDocument as it has no value for billingDocumentId
                return createActionResult(ffr.ActionViewsService.MessageLevel.ERROR, 'This action is only available for billing documents', false);
            }
            billingDocumentIds.add((Id)row.Values.get(billingDocumentIdIndex));
        }

        if(billingDocumentIds.size() < 1)
        {
            return createActionResult(ffr.ActionViewsService.MessageLevel.ERROR, 'In order to consolidate, billing documents need to be selected', false);
        }

        //Acquire the ISO formatted due date string from the promptValues map container.
        String promptedDocumentDate = promptValues.get(DOCUMENT_DATE);

        //If no documentdate could be extracted throw error
        if(promptedDocumentDate == null || promptedDocumentDate=='')
        {
            return createActionResult(ffr.ActionViewsService.MessageLevel.ERROR, 'No Document Date has been specified', false);
        }

        //Convert the ISO formatted date string into a Salesforce Date object.
        Date documentDate = Date.valueOf(promptedDocumentDate);

        //Call the API for consolidating documents
        try
        {
            // Consolidation of billingDocument call to API
        }
        catch(DMLException ex)
        {
            String errors = '';
            // For each of the failed rows extract the error message.
            for (Integer rowNumber = 0; rowNumber < ex.getNumDml(); rowNumber++)
            {
                String message = ex.getDmlMessage(rowNumber);
                if (String.isNotBlank(message))
                {
                    errors += message + '\n';
                }                
            }
            return createActionResult(ffr.ActionViewsService.MessageLevel.ERROR, errors, true);
        }

        return createActionResult(ffr.ActionViewsService.MessageLevel.SUCCESS, 'Documents consolidated', true); // This true will refresh the grid 
    }

    public List<ffr.DataViewService.ParameterMetadataType> getParameterMetadataList()
    { 
        String now = String.valueOf(System.today());
        return new List<ffr.DataViewService.ParameterMetadataType>{
            new ffr.DataViewService.ParameterMetadataType(DOCUMENT_DATE, 'Document Date', 'DATE', now, false, null)
        };
    }

    // Is the action only allowed on single select.
    public Boolean isSingleSelect()
    {
        return false;
    }

    public Set<Id> getRequiredFields(ffr.DataviewService.Dataview dataview)
    {
        Set<Id> fields = new Set<Id>();

        fields.add( getDataViewFieldId(dataview, REQUIREDFIELDNAME, ID) );

        if(fields.isEmpty())
        {
            throw new MyException( 'Required field is missing from dataview') ;
        }
        
        return fields;
    }

    private ffr.ActionViewsService.ActionResult createActionResult(ffr.ActionViewsService.MessageLevel statusCategory, String statusMessage, Boolean requiresRefresh)
    {
        ffr.ActionViewsService.ActionResult actionResult = new ffr.ActionViewsService.ActionResult();
        
        actionResult.StatusCategory = statusCategory;
        actionResult.StatusMessage = statusMessage;
        actionResult.RequiresRefresh = requiresRefresh;

        return actionResult;
    }

    private ffr.DataViewService.ParameterMetadataType createParameterMetadataType(String key, String label, String type)
    {
        ffr.DataViewService.ParameterMetadataType metaData = new ffr.DataViewService.ParameterMetadataType();
        metaData.Key     = key; 
        metaData.Label   = label;
        metaData.Type    = type;

        return metaData;
    }

    /**
*    The aim of this method is to make the act of comparing fieldnames
*    for actions more forgiving as the naming conventions used for our
*    field that trigger action accessibility are quite varied.
*/
    private Boolean compareFieldNames(String field1, String field2)
    {
        //Firstly remove the whitespace from both the test strings
        field1 = field1.replaceAll('\\s+', '');
        field2 = field2.replaceAll('\\s+', '');

        // Note that the String comparison in Apex with the '==' operator,
        // is case insensitive and is also faster than .equalsIgnoreCase()
        return (field1 == field2);
    }

    private ID getDataViewFieldId(ffr.DataviewService.Dataview dataview, String fieldToFind, String fieldType)
    {
        // Below is the Common Methods for Invoke Actions
        if(dataview == null )
        throw new MyException( 'Dataview is null' );

        List<ffr.DataviewService.Field> f = dataview.Fields;
        if( f.isEmpty() )
        throw new MyException( 'Dataview has no fields'  );    

        for(ffr.DataviewService.Field field : dataview.Fields)
        {
            if(compareFieldNames(field.Name, fieldToFind) && field.ObjectField == fieldType)
            return field.Id;
        }

        throw new MyException( 'Field '+fieldToFind+' not found' );
    }
}