Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at NCDevCon 2016 (Raleigh, NC) with: Matthew Eash
Ben Nadel at NCDevCon 2016 (Raleigh, NC) with: Matthew Eash@mujimu )

Wrapping The Zendesk Web Widget In A Promise-Based Zendesk Service In Angular 2.4.9

By Ben Nadel on

Yesterday, I explored the possible race condition I was running into when rapidly invoking the .show() and .hide() methods on the Zendesk web widget. Now, not only is there this apparent race condition; but, because the Zendesk API is asynchronously loaded, you have to wrap calls to it inside an enqueuing method. This seems like a lot of logic for the calling context to know about. So, I wanted to try and encapsulate the complexity of the Zendesk web widget inside a Promise-base service in Angular.


 
 
 

 
 
 
 
 

When designing my ZendeskService, I wanted the calling context to be able to call the service methods without caring whether or not the underlying zEmbed object has been loaded. Of course, the zEmbed object is still - at times - asynchronous. So, in order to unify the API, all of my service methods return a Promise<void>. Internally, they just use the zEmbed() enqueuing method to fulfill the deferred value; but, from the calling context, it becomes a simple method call.

Of course, we still have to deal with the race condition presented by the .show() and .hide() methods. So, for these two methods, I am maintaining a separate internal queue that I flush on a debounced interval. This way, the calling context still sees a simple method call; but, internally, only the last method call in a given time-window is passed onto the underlying zEmbed object.

Here's what I came up with:

  • // The zEmbed function is a global object, so we have to Declare the interface so that
  • // TypeScript doesn't complain. The zEmbed object acts as both a pre-load queue as well
  • // as the API. As such, it must be invocable and expose the API.
  • declare var zEmbed: {
  • // zEmbed can queue functions to be invoked when the asynchronous script has loaded.
  • ( callback: () => void ) : void;
  •  
  • // ... and, once the asynchronous zEmbed script is loaded, the zEmbed object will
  • // expose the widget API.
  • activate?( options: any ): void;
  • hide?(): void;
  • identify?( user: any ): void;
  • setHelpCenterSuggestions?( options: any ): void;
  • setLocale?( locale: string ) : void;
  • show?(): void;
  • }
  •  
  • interface VisibilityQueueItem {
  • resolve: any;
  • reject: any;
  • methodName: string;
  • }
  •  
  • // I wrap the zEmbed object, providing Promise-based method calls so that the calling
  • // context doesn't have to worry about whether or not the underlying zEmbed object has
  • // been loaded.
  • export class ZendeskService {
  •  
  • private isLoaded: boolean;
  • private visibilityDelay: number;
  • private visibilityQueue: VisibilityQueueItem[];
  • private visibilityTimer: number;
  •  
  •  
  • // I initialize the service.
  • constructor() {
  •  
  • this.isLoaded = false;
  • this.visibilityDelay = 500; // Milliseconds.
  • this.visibilityQueue = [];
  • this.visibilityTimer = null;
  •  
  • // Since show() and hide() appear to have some sort of race condition, we're
  • // going to queue-up pre-loaded calls to those methods. Then, when the zEmbed
  • // object has fully loaded, we'll flush that queue, giving us more control over
  • // which method is actually applied.
  • zEmbed(
  • () : void => {
  •  
  • this.isLoaded = true;
  • this.flushVisibilityQueue();
  •  
  • }
  • )
  •  
  • }
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I activate and open the widget in its starting state.
  • public activate( options: any ) : Promise<void> {
  •  
  • return( this.promisify( "activate", [ options ] ) );
  •  
  • }
  •  
  •  
  • // I completely hide all parts of the widget from the page.
  • public hide() : Promise<void> {
  •  
  • return( this.promisifyVisibility( "hide" ) );
  •  
  • }
  •  
  •  
  • // I identify the user within Zendesk (and setup the pre-populated form data).
  • public identify( user: any ) : Promise<void> {
  •  
  • return( this.promisify( "identify", [ user ] ) );
  •  
  • }
  •  
  •  
  • // I enhance the contextual help provided by the Zendesk web widget.
  • public setHelpCenterSuggestions( options: any ) : Promise<void> {
  •  
  • return( this.promisify( "setHelpCenterSuggestions", [ options ] ) );
  •  
  • }
  •  
  •  
  • // I set the language used by the widget.
  • public setLocale( locale: string ) : Promise<void> {
  •  
  • // CAUTION: This method is provided for completeness; however, it really
  • // shouldn't be invoked from this Service. Really, it should be called from
  • // within the script that loads the bootstrapping script.
  • return( this.promisify( "setLocale", [ locale ] ) );
  •  
  • }
  •  
  •  
  • // I display the widget on the page in its starting 'button' state.
  • public show() : Promise<void> {
  •  
  • return( this.promisifyVisibility( "show" ) );
  •  
  • }
  •  
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  •  
  • // Since there is an apparent race condition in how often the show and hide methods
  • // can be called for the Zendesk widget, these methods get queued up and flushed
  • // periodically so that we can control the debouncing of these methods.
  • private flushVisibilityQueue() : void {
  •  
  • // The queue contains the Resolve and Reject methods for the associated Promise
  • // objects. We need to iterate over the queue and fulfill the Promises.
  • while ( this.visibilityQueue.length ) {
  •  
  • var item = this.visibilityQueue.shift();
  •  
  • // If the queue is still populated after the .shift(), then we are NOT on the
  • // last item. As such, we're going to resolve this Promise without actually
  • // calling the underlying zEmbed method.
  • if ( this.visibilityQueue.length ) {
  •  
  • console.warn( "Skipping queued method:", item.methodName );
  • item.resolve();
  •  
  • // If the queue is empty after the .shift(), then we are on the LAST ITEM,
  • // which is the one we want to actually apply to the page.
  • } else {
  •  
  • console.log( "Invoking last method:", item.methodName );
  • this.tryToApply( item.methodName, [], item.resolve, item.reject );
  •  
  • }
  •  
  • }
  •  
  • }
  •  
  •  
  • // I turn the given zEmbed method invocation into a Promise.
  • private promisify( methodName: string, methodArgs: any[] ) : Promise<void> {
  •  
  • var promise = new Promise<void>(
  • ( resolve: Function, reject: Function ) : void => {
  •  
  • zEmbed(
  • () : void => {
  •  
  • this.tryToApply( methodName, methodArgs, resolve, reject );
  •  
  • }
  • );
  •  
  • }
  • );
  •  
  • return( promise );
  •  
  • }
  •  
  •  
  • // I turn the zEmbed show/hide methods into Promises. Since there is an apparent race
  • // condition with these methods, they are queued internally rather than being queued
  • // directly with the zEmbed() function. This way, we can control the debouncing.
  • private promisifyVisibility( methodName: string ) : Promise<void> {
  •  
  • var promise = new Promise<void>(
  • ( resolve: Function, reject: Function ) : void => {
  •  
  • this.visibilityQueue.push({
  • resolve: resolve,
  • reject: reject,
  • methodName: methodName
  • });
  •  
  • // If the zEmbed object hasn't loaded yet, there's nothing more to do -
  • // the pre-load state will act as automatic debouncing.
  • if ( ! this.isLoaded ) {
  •  
  • return;
  •  
  • }
  •  
  • // If we've made it this far, it means the zEmbed object has fully
  • // loaded. As such, we need to explicitly debounce the show / hide method
  • // calls by delaying the flushing of our internal queue.
  •  
  • clearTimeout( this.visibilityTimer );
  •  
  • this.visibilityTimer = setTimeout(
  • () : void => {
  •  
  • this.flushVisibilityQueue();
  •  
  • },
  • this.visibilityDelay
  • );
  •  
  • }
  • );
  •  
  • return( promise );
  •  
  • }
  •  
  •  
  • // I try to apply the given method to the zEmbed object, resolving or rejecting the
  • // associated Promise object as necessary.
  • private tryToApply(
  • methodName: string,
  • methodArgs: any[],
  • resolve: Function,
  • reject: Function
  • ) : void {
  •  
  • try {
  •  
  • zEmbed[ methodName ]( ...methodArgs );
  • resolve();
  •  
  • } catch ( error ) {
  •  
  • reject( error );
  •  
  • }
  •  
  • }
  •  
  • }

As you can see, most of the methods - like .activate() and .setLocale() - just proxy a call to the underlying zEmbed enqueuing method and resolve a promise once the enqueued item has been consumed. But, the .show() and .hide() methods are different - they enqueue method calls (and promise fulfillers) in an internal queue that is either flushed at zEmbed load time; or, on a debounced interval. This should hopefully take care of the race condition while, at the same time, keeping the calling context quite simple.

The root component of the demo, which was previously quite complex, is now much more simple - no more waiting for the zEmbed object to load, no more working around the race condition. Now, we just listen for application state changes and call the ZendeskService as needed:

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  • import { Observable } from "rxjs/Observable";
  •  
  • // Import these for their side-effects.
  • import "rxjs/add/observable/of";
  •  
  • // Import the application components and services.
  • import { ZendeskService } from "./zendesk.service";
  •  
  • @Component({
  • selector: "my-app",
  • styleUrls: [ "./app.component.css" ],
  • template:
  • `
  • <p>
  • There may or may not be a Zendesk widget in the bottom-right.
  • </p>
  • `
  • })
  • export class AppComponent {
  •  
  • private zendeskService: ZendeskService;
  •  
  •  
  • // I initialize the app component.
  • constructor( zendeskService: ZendeskService ) {
  •  
  • this.zendeskService = zendeskService;
  •  
  • this.startWatchingZendeskStatus();
  •  
  • }
  •  
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  •  
  • // I return an Observable stream of values that should be used to drive the
  • // visibility of the Zendesk widget.
  • private getZendeskStatusStream() : Observable<boolean> {
  •  
  • return( Observable.of( true, false ) );
  •  
  • }
  •  
  •  
  • // I update the visibility of the Zendesk widget based on the application state.
  • private startWatchingZendeskStatus() {
  •  
  • // First, let's start by turning OFF the Zendesk widget until we have
  • // a reason to turn the widget back on.
  • this.zendeskService.hide();
  •  
  • // ... then, let's subscribe to changes in the Zendesk widget status to
  • // adjust the widget rendering as needed.
  • this.getZendeskStatusStream().subscribe(
  • ( value: boolean ) => {
  •  
  • console.log( `Zendesk status value changed to [${ value }].` );
  •  
  • value
  • ? this.zendeskService.show()
  • : this.zendeskService.hide()
  • ;
  •  
  • }
  • );
  •  
  • }
  •  
  • }

Now, when we run the above code, we get the following output page output:


 
 
 

 
 Zendesk Service in Angular 2 encapsulates the complexity of the zEmbed object. 
 
 
 

As you can see, the same series of calls that created a race condition yesterday - hide, show, hide - works fine in this demo because the ZendeskService is managing the race condition internally.

Regardless of the race condition in the Zendesk web widget, I happen to love the idea of taking a complex API and wrapping it inside of an Angular 2 service that greatly simplifies its consumption. Not only does this decouple the application from the ambient zEmbed object, it hides the life-cycle of said object and allows the application to evolve more independently of the web widget.



Looking For A New Job?

Ooops, there are no jobs. Post one now for only $29 and own this real estate!

100% of job board revenue is donated to Kiva. Loans that change livesFind out more »

Reader Comments

Hello!

Thanks for the article! I'm going to have to go through it again to fully understand some of the things you did here. But overall, I thought it was a great post!

I just wanted to ask you, "did you have any problems with the initial button state not working in your application?" I can't find the button anywhere in the DOM, and if I invoke the .show() method on the global zE in the console, nothing happens. But I am able to interact with zE otherwise: I can call .identify({...}) and .activate(), which both work. Without the button, activate seems to just bring up the popup like a click of the button would. I can easily add my own button, but it would be nice to just use the full solution that Zendesk provides, button and all. Afterall, at the very least, the position of the button and how pretty it looks is handled for me ;-)

Just wondering whether you ran into anything like that and what you did to fix it. I've searched through the DOM for anything zendesk or "launcher" related and nothing comes up except for the main.js file coming from Zendesk.

Thanks again!

Reply to this Comment

@Jake,

Hmm, that's really strange. From what I recall, when you just include the Zendesk library, the button shows up automatically. It's injected into the BODY tag, at the bottom (usually). I am not sure why your button wouldn't be showing up. Sorry!

Reply to this Comment

@Ben,

So here's what happened: I was setting up the widget on the zendesk agent admin portal and was choosing the color of the button. What I chose ended up getting saved on their end without a "#" in front of the color. So the css of the button was an invalid color and was transparent. I found this out by putting my own button in the same corner and noticed their text appear above mine!

I set the color again in the admin portal and all was good in the hood. Thanks for following up!

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.