Skip to content

Instantly share code, notes, and snippets.

@gotdibbs
Last active June 9, 2017 16:01
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gotdibbs/4f58a0c7c2cbf8b11722fb32e51fd726 to your computer and use it in GitHub Desktop.
Save gotdibbs/4f58a0c7c2cbf8b11722fb32e51fd726 to your computer and use it in GitHub Desktop.
Asynchronous Blissfullness

Async is smooth, smooth is fast

Having worked with Dynamics 365 for Sales (CRM) for nearly seven years now, we've seen a lot of things which cannot be unseen when it comes to client-side code. Whenever we get called in to evaluate the current state of an implementation, a good gauge by which to judge the state of the union is a glance at the JavaScript. Since JavaScript as a language is so forgiving, you can end up with a lot of code that "just works" but suffers in the areas of durability, maintainability, and performance.

What's the number one offense we see? Synchronous service calls. When code degrades the end user experience, it shoots straight to the top of our "fix it now" list.

What's so bad about synchronous service calls?

Synchronous service calls have been prooven to be detrimental to the user experience. The browser literally stops everything to wait for the result of that call. When the browser is locked up waiting for the result of a synchronous service call, the user can't click anywhere else, enter any other information, cancel the operation, nor see any updates. Accordingly, users can't be given a glimpse into any synchronous operation's progress. In certain browsers, like Google Chrome, these types of synchronous requests are even becoming deprecated so you won't be able to do them much longer.

How do we fix this on forms?

Synchronous requests from the form are fairly straight forward to transition to asynchronous calls. Either continue processing the result in a callback function, or even better, you can implement promises for a cleaner way of dealing with multiple service calls. This does require a bit of thinking, but after a few refactories it quickly becomes second nature. Hastily written code almost always has a side effect, a few minutes saved coding could cost users hours of productivity in the long run.

Below is a contrived example where we retrieve the full name of the primary contact for the current record's parent account. Phew that's a mouthful. Long story short: let's retrieve a value from a related record. This example is designed as such purely to demonstrate the difference when multiple service calls are required, so yes, you can actually retrieve the required information in one service call. Also note that I've abstracted away all of the underlying XMLHttpRequest code, expecting that you are using a library to wrap that as well (though hopefully not jQuery, but that's another subject for another post).

<script src="https://gist.github.com/gotdibbs/4f58a0c7c2cbf8b11722fb32e51fd726.js?file=command-bar-script-example.js"></script>
/* Bad */
function onParentAccountChanged(parentAccountId) {
    var parentAccount,
        primaryContact;

    if (!parentAccountId) {
        return;
    }

    parentAccount = WebAPI.get('/accounts?$filter=accountid eq ' + parentAccountId');
    
    if (!parentAccount || !parentAccount._primarycontactid_value) {
        return;
    }
    
    primaryContact = WebAPI.get('/contacts?$filter=contactid eq ' + parentAccount._primarycontactid_value);
    
    if (primaryContact && primaryContact.fullname) {
        Xrm.Utility.alertDialog(primaryContact.fullname);
    }
}

/* Good (using callbacks) */
function onParentAccountChanged(parentAccountId) {
    if (!parentAccountId) {
        return;
    }

    WebAPI.get('/accounts?$filter=accountid eq ' + parentAccountId, 
        function onSuccess(parentAccount) {
            WebAPI.get('/contacts?$filter=contactid eq ' + parentAccount._primarycontactid_value,
                function onSuccess(primaryContact) {
                    if (primaryContact && primaryContact.fullname) {
                        Xrm.Utility.alertDialog(primaryContact.fullname);
                    }
                },
                function onError(e) { /* handle error */ });
        },
        function onError(e) { /* handle error */ });
}

/* Good (using promises) */
function onParentAccountChanged(parentAccountId) {
    if (!parentAccountId) {
        return;
    }

    WebAPI.get('/accounts?$filter=accountid eq ' + parentAccountId)
        .then(function onSuccess(parentAccount) {
            return WebAPI.get('/contacts?$filter=contactid eq ' + parentAccount._primarycontactid_value);
        })
        .then(function onSuccess(primaryContact) {
            if (primaryContact && primaryContact.fullname) {
                Xrm.Utility.alertDialog(primaryContact.fullname);
            }
        })
        .catch(function onError(e) { /* handle error */ });
}

Did you notice how with the last good example, using promises, that the flow is actually very similar to how you would do things synchronously? That's one of many reasons why we love promises.

Did you also notice how the code in the callbacks example, with all the indentation, starts to look like a pyramid? That's one of the many reasons why we don't like callback as much as promises. You could flatten that out by pulling out the callback functions and defining them alongside onParentAccountChanged but let's be honest here, that doesn't usually happen until it's too late.

What about the command bar (ribbon)?

Ok. Let's address the tricky bit: custom enable rules. You might think you need your code to immediately return the result of your service call so that Dynamics knows to show the button as enabled or disabled, but this is not the case. You can return a smart default (usually default to disabled), do your service calls to determine what the actual state of it should be, and then refresh the ribbon (by invoking Xrm.Page.ui.refreshRibbon()) such that it presents that newly determined state. A bit steppy, with a potential for a flash of disabled -> enabled or vice versa, but overall a better experience than having the form lockup.

Looking for an example? Have a look below. The following example checks if the parent account's primary contact field is set:

/* Bad */
function isPrimaryContactSet(accountId) {
    var account;

    if (!accountId) {
        throw new Error('`accountId` is a required parameter for `isPrimaryContactSet`');
    }
    
    account = WebAPI.get('/accounts?$filter=accountid eq ' + accountId);
    
    if (account._primarycontactid_value) {
        return true;
    }
    
    return false;
}

/* Good */
var isPrimaryContactCheckComplete = false,
    isPrimaryContactSetResult;

function isPrimaryContactSet(accountId) {
    if (!accountId) {
        throw new Error('`accountId` is a required parameter for `isPrimaryContactSet`');
    }
    
    if (isPrimaryContactCheckComplete) {
        return isPrimaryContactSetResult;
    }
    
    WebAPI.get('/accounts?$filter=accountid eq ' + accountId)
        .then(function onSuccess(parentAccount) {
            if (parentAccount._primarycontactid_value) {
                isPrimaryContactSetResult = true;
            }
            else {
                isPrimaryContactSetResult = false;
            }
            
            Xrm.Page.ui.refreshRibbon();
        })
        .catch(function onError(e) { /* handle error */ });
        
    return false;
}

Let's take note of a few key differences found in the "good" example:

  1. The service call is only ever run once as the result is cached.
  2. We default the button to disabled as the first time it hasn't run so we end up returning false.
  3. The variables storing the state of the call are stored outside the function being invoked. I would expect those variables not to be global variables however, they should instead be local to a parent scope that is not the global scope. This will help avoid conflicts.

A special note on progress indicators

I'm guessing by now you may have had the thought that since we're not locking up the browser any more, the user is now free to double click buttons and duplicate actions. I believe there are certain time frames in which that is fine. As long as a user sees a relatively instanteous response of any positive form, they are not very likely to anger click again and again.

However, if you do have a longer running request being executed, you'll probably need to introduce some form of progress notification in order to keep the user informed. For example, when you have a ribbon button that processes a bunch of records, perhaps have the button pop open a dialog which would display a progress indicator, and let the actual operation run inside the dialog's code. This way the user sees something "productive" happening immediately.

What's the cut off that determines if you need a progress indicator? The Nielsen Norman Group provides a good rule of thumb that if the operation is going to take longer than 10 seconds, you should provide a progress indicator, and consider displaying detail of changes in progress if possible. If the operation averages between 2 and 10 seconds, the recommmendation is to provide an indeterminate progress indicator.

In summary

You should (almost) never be using synchronous service calls. 99.99% of the time you can, with a little bit more elbow grease and human processing power, accomplish the same exact thing with the application of an asynchronous pattern.

/* Bad */
function isPrimaryContactSet(accountId) {
var account;
if (!accountId) {
throw new Error('`accountId` is a required parameter for `isPrimaryContactSet`');
}
account = WebAPI.get('/accounts?$filter=accountid eq ' + accountId);
if (account._primarycontactid_value) {
return true;
}
return false;
}
/* Good */
var isPrimaryContactCheckComplete = false,
isPrimaryContactSetResult;
function isPrimaryContactSet(accountId) {
if (!accountId) {
throw new Error('`accountId` is a required parameter for `isPrimaryContactSet`');
}
if (isPrimaryContactCheckComplete) {
return isPrimaryContactSetResult;
}
WebAPI.get('/accounts?$filter=accountid eq ' + accountId)
.then(function onSuccess(parentAccount) {
if (parentAccount._primarycontactid_value) {
isPrimaryContactSetResult = true;
}
else {
isPrimaryContactSetResult = false;
}
isPrimaryContactCheckComplete = true;
Xrm.Page.ui.refreshRibbon();
})
.catch(function onError(e) { /* handle error */ });
return false;
}
/* Bad */
function onParentAccountChanged(parentAccountId) {
var parentAccount,
primaryContact;
if (!parentAccountId) {
return;
}
parentAccount = WebAPI.get('/accounts?$filter=accountid eq ' + parentAccountId);
if (!parentAccount || !parentAccount._primarycontactid_value) {
return;
}
primaryContact = WebAPI.get('/contacts?$filter=contactid eq ' + parentAccount._primarycontactid_value);
if (primaryContact && primaryContact.fullname) {
Xrm.Utility.alertDialog(primaryContact.fullname);
}
}
/* Good (using callbacks) */
function onParentAccountChanged(parentAccountId) {
if (!parentAccountId) {
return;
}
WebAPI.get('/accounts?$filter=accountid eq ' + parentAccountId,
function onSuccess(parentAccount) {
WebAPI.get('/contacts?$filter=contactid eq ' + parentAccount._primarycontactid_value,
function onSuccess(primaryContact) {
if (primaryContact && primaryContact.fullname) {
Xrm.Utility.alertDialog(primaryContact.fullname);
}
},
function onError(e) { /* handle error */ });
},
function onError(e) { /* handle error */ });
}
/* Good (using promises) */
function onParentAccountChanged(parentAccountId) {
if (!parentAccountId) {
return;
}
WebAPI.get('/accounts?$filter=accountid eq ' + parentAccountId)
.then(function onSuccess(parentAccount) {
return WebAPI.get('/contacts?$filter=contactid eq ' + parentAccount._primarycontactid_value);
})
.then(function onSuccess(primaryContact) {
if (primaryContact && primaryContact.fullname) {
Xrm.Utility.alertDialog(primaryContact.fullname);
}
})
.catch(function onError(e) { /* handle error */ });
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment