Apply All buttons in JavaScript and Extensions API

Both the Tableau JS API and Extensions API send commands using Asynchronous JavaScript, based on the Promises JavaScript framework. If you are not well-versed Promises or asynchronous JavaScript in general, it can be difficult to go from the basic examples in the reference guide to more complicated solutions.

Over the years, customers have frequently asked about doing an “Apply All” button using the JavaScript or Extensions API, where filters on multiple different fields can all be applied from a single button click. While there is no way to do this directly in either API at the current time, you can create a process that very efficiently applies all of the filter changes as quickly as possible. This article will show one solution to the “Apply All button” request.

What does an “Apply All” button do?

If we want to make an Apply All button, we first need to think about what it needs to do. There are two aspects:

  1. Determine if a filter has changed since the last button press
  2. Send an update to any changed Filter via the appropriate API commands, one-at-a-time

Determining if a Filter Has Changed: Building a Change Queue

A “naive” implementation of an Apply All button might send all filter values, regardless of if there has been any change since the last application. However, because we must send out filter update commands one-at-a-time, any command that is unnecessary will lengthen the overall time to completion.

Luckily, JavaScript already has an events model that plays into this need. In this example, we’re displaying single-value drop-down menus for the selections (represented by select tags in HTML). select tags have an onchange event handler which only fires if the user select a different value from previous (they can open the menu, but it only fires if they select an option that is different from before). Almost any type of filter display you can come up with will have enough event handlers available to detect if there has been a change.

Once there has been a change, we’ll record it into a Change Queue. The queue in this example is very simple; it simply adds the ID of any select element that has been changed (with a true boolean for good measure) into a global scoped object. You could also store the actual values, or a more thorough representation of the filter object, if you found there was a need for more info to send your Update commands later.

// Structure to represent any changes to a filter between "Apply" clicks
// Basically a Queue for the filter changes
var changedFilters = {};	

// This function is attached to the onchange event of each dropdown selector, so that we only send
// changes, rather than the full state each time. The more filter options you have the more important
function recordFilterChange(event){
	// This gives you the element ID of the dropdown that was changed
	let changedFilterId = event.srcElement.id;
	console.log('Filter ' + changedFilterId + ' changed');
	// Add that element ID to the changeFilters queue object. You could record more data here potentially in a more scaled out model.
	changedFilters[changedFilterId] = true;
	// Logging new state of the queue
	console.log('Currently queued filter changes');
	console.log(changedFilters);
}

A global scoped object in JavaScript is just a variable that is declared with var outside of any functions or other objects. It can then be referenced from any other function or method. While this may not be best practice in other programming languages, global scoping this Change Queue has a lot of benefits when the Asynchronous callback functions start firing off later.

Sending the Queued Changes One-at-a-Time

At this point you are probably thinking I’ve seriously oversold the necessity for a whole article on this, but I promise that sending Filter Updates is where the difficulty lies.

Any update command in the JavaScript or Extensions API will have Async at the end of the name, which means it is an Asynchronous call using the Promises framework. When you send the first Async command, you immediately receive back a Promise object, which represents the state of the Async request.

If you want something to happen only AFTER the Promise has been fulfilled (i.e. the command completed and returned something back), you can use the .then() method of the Promise object to include a callback function, which will only fire off AFTER fulfillment.

You can chain a bunch of actions together in a long series of nested callback functions, each .then() calling the next action, but this is difficult to construct programmatically in an arbitrary fashion based on all the elements we want send in the queue.

The solution presented here is the sendNextFilter() recursive function which creates a Promise whose .then() callback calls the same function after the first one has finished. Each time the function is called, it checks to see if there are any more filters in the Change Queue to send. If there are, it takes the next one off the top of the queue and fires off the applyFilterAsync() method for that filter. In the .then() function, the filter that was just updated is removed from the queue. That way when the next sendNextFilter() is called, the queue has been reduced by one.

// This function checks out the list of changed filters that exists and then will work the next one
// Working down the "queue" until they have all been applied
function sendNextFilter(){

	dashboard.worksheets.forEach(
		function (worksheet) {
			// Only do this if the worksheet is the one you specified
			if (worksheet.name === sheetNameWithFilters){

				let changedFilterList = Object.keys(changedFilters);
				if (changedFilterList.length > 0){
					let filterId = changedFilterList[0]; // Grab next key
					// Get the selection value
					let element = document.getElementById(filterId);
					let selectedIndex = element.selectedIndex;
					let filterValue = element.options[selectedIndex].value; 
					
					// Get the Filter Field name
					let filterFieldName = element.dataset.fieldName;
					console.log('Applying filter for ' + filterFieldName);
					var prom = worksheet.applyFilterAsync(filterFieldName, [filterValue], "replace").then(
							function(){
								// Clear this one from the change queue, since it has been sent
								let elementToRemove = filterId;
								console.log(changedFilters);
								delete changedFilters[elementToRemove]; 
								// Keep pinging until they've all been sent
								sendNextFilter();
							}
						);
				}
				else{
					console.log('All filters have been applied');
					// This should be redundant by just to be safe
					changedFilters = {};
				}
			}
		}
	);
}

The final effect is that each filter fires off as soon as the previous filter has completed, avoiding the underlying viz being confused with multiple filter commands being received at the same time.

Fulfillingness’ First Finale

As a bonus, here’s is another useful aspect of Promises that could help as you work with the Tableau APIs.

If you are working through an array of elements (for examples, all of the of the Filters on a Worksheet) and you start sending async actions (particularly ones that would not conflict with one another), you might want to know when all of those Promises have been fulfilled before doing your next action. This is exactly the purpose of

In the following code, we’re retrieving ALL of the Domains from any Filter (this code is for the Extensions API which has the getDomain methods for Categorical Filters). We want to grab them all before proceeding, but the requests are all Async methods.

The solution works by creating an Array to hold all of the Promises that are returned from each of the Async commands. Then we create a new Promise using Promise.all([PromisesArray]). The .then() function of this .all Promise will only fire off when all the other Promises achieve a Fulfilled state.

// This array will contain the returned Promise objects from the filter calls,
// to be passed into a new Promise.all() Promise
var filterPromises = [];

// Parse through the filters to get back all the filter values
worksheet.getFiltersAsync().then(
	function (filters) {
		// Iterate through the returned filters
		for (let i = filters.length - 1; i >= 0; i--) {
			// This HAS to be a let rather than a var
			// https://codeburst.io/difference-between-let-and-var-in-javascript-537410b2d707
			let filterField = filters[i].fieldName;
		
			// Create an array in the global allFiltersDomain object to handle
			// the domain we're about to receive
			allFilterDomains[filterField] = [];
		
			// All Async calls in Extensions API return a Promise object
			// If you want to do something after that particular promise, 
			// you put it in the .then() after. But if you want to do something
			// after ALL the promises, then we need to pass that Promise object
			// in to the Promise.all() Promise we create down below
			var prom = filters[i].getDomainAsync().then(
				function (domain) {
					let thisField = filterField;
					// Get the domain values and push them into the allFiltersDomain object
					// with the correct key
					for (let k=0; k < domain.values.length; k++) {
						allFilterDomains[thisField].push(domain.values[k]['formattedValue']);
					}
				});
		
			// Now push the Promise into the filterPromises array	
			filterPromises.push(prom);
		}
	}
).then( 
	// This .then is attached to getFiltersAsync(). It runs after all that code has
	// Note that at this point the filter domains have not necessarily returned
	// but we have the Promise objects from them
	function() { 
		// Create new Promise.all promise that will fire after all 
		// of the Filter domain promises have completed
		Promise.all(filterPromises).then( 
			function () {
				// Now we build the filter Displays and then push into the page 
				buildFilterDisplay(allFilterDomains, containerDiv);
				filterAreaOuterContainerDiv.append(containerDiv);
			}
		);
	}
);

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s