Skip to main content
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Guust Nieuwenhuis
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Guust Nieuwenhuis ( @Lagaffe )

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

By 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.

Want to use code from this post? Check out the license.

Reader Comments

2 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!

15,688 Comments

@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!

2 Comments

@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!

5 Comments

Nice one!

I was thinking of extending this..
What would be your recommended way to also handel "updateSettings"?

zE('webWidget', 'updateSettings', data<InvalidTag>);

15,688 Comments

@Markus,

I haven't used that method before; but, I think you would just want to keep with the same pattern - add a .updateSettings( data ) method that turns around and calls this.promisify() under the hood. Something like (just shooting from the hip here):

public updateSettings( data: any ) : Promise<void> {

	return( this.promisify( "updateSettings", [ data ] ) );

}

Obviously, I haven't tested this. But, this would be my first guess (and then something I would work through with trial-and-error).

5 Comments

@Ben,

Noticed that zendesk (for some reason) has several global objects and the settings belongs to another object so either one solutions where you need to pass in what object you are working on or to separate them completely :P

15,688 Comments

@Markus,

Ah, interesting. I haven't worked with the widget in a while, so I'll take your word for it :) Good luck, now that you have an eye for the right direction.

2 Comments

@Ben,

Thanks for sharing this article. may I ask you how are you importing the Zendesk CommonJS module into the angular project please?

Thanks

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel