OfficeJS: Second Dialog Does not Display

I have been spending a lot of time in the Officeui.dialog lately. One of my customers has been too and it has been an adventure working out exactly the best way to get messages displayed while running code in the background asynchronously. wlEmoticon-disappointedsmile.png

I am not sure if this problem is limited to the online version of Outlook, but this is where I have been seeing the problem (and where I have spent virtually all of my time). If my code tried to open two dialogs (using Office.context.ui.displayDialogAsync()) one right after the other, the second dialog would not ever be displayed. If I waited a period and then tried again, it would. But we don’t want that. We want boom-boom, dialogs. When I looked at the console, I would see an error like the following:

Uncaught TypeError: Cannot read property ‘addEventHandler’ of undefined

Or, if I read the error object from the displayDialogAsync() function/asyncResult, I would see this:

{name: “Display Dialog Error”, message: “The operation failed because this add-in already has an active dialog.“, code: 12007}

Here is an example of some code that will reproduce the issue:

[code lang=”javascript” collapse=”true” title=”click to expand if the github.com embedding below is not visible.”]
var i = 0;
function displayDialog() {
var url = "https://localhost:3000/test.html";
Office.context.ui.displayDialogAsync(url,{height:20, width:30, displayInIframe:true},
function (asyncResult) {
var dialog = asyncResult.value; // get the dialog
var error = asyncResult.error;
if(dialog == undefined && error.code > 0) {
// log the error
console.log(error.message);
} else {
// attache the events
dialog.addEventHandler(Office.EventType.DialogEventReceived, function (arg) {
// close this dialog, open the next
dialog.close();
i++;
if(i<4) {
displayDialog();
}
});
dialog.addEventHandler(Office.EventType.DialogMessageReceived, function (arg) {
// close this dialog, open the next
dialog.close();
i++;
if(i<4) {
displayDialog();
}
});
}
});
}
[/code]


var i = 0;
function displayDialog() {
var url = "https://localhost:3000/test.html&quot;;
Office.context.ui.displayDialogAsync(url,{height:20, width:30, displayInIframe:true},
function (asyncResult) {
var dialog = asyncResult.value; // get the dialog
var error = asyncResult.error;
if(dialog == undefined && error.code > 0) {
// log the error
console.log(error.message);
} else {
// attache the events
dialog.addEventHandler(Office.EventType.DialogEventReceived, function (arg) {
// close this dialog, open the next
dialog.close();
i++;
if(i<4) {
displayDialog();
}
});
dialog.addEventHandler(Office.EventType.DialogMessageReceived, function (arg) {
// close this dialog, open the next
dialog.close();
i++;
if(i<4) {
displayDialog();
}
});
}
});
}

view raw

dialogIssue.js

hosted with ❤ by GitHub

Notice I had used dialog.close(), but it did not work as designed. What I believe is happening is that the previous dialog is still in memory and has not been cleaned up. What needs to likely happen is a closeAsync().

In order to resolve this, I created the following function: dialogCloseAsync(). This works by issuing the close() and then attempting to add an event handler to the dialog in ansyc (setTimeout) loop. When it errors, we trap the error and issue the async callback. It is a bit ugly as we are trapping an error to get around the problem, but this was the only way I could find a way around the problem. Here is what the function looks like:

[code lang=”javascript” collapse=”true” title=”click to expand if the github.com embedding below is not visible.”]
/**
* Closes the currently open dialog asynchronously.
* This has an ugly workaround which is to try to set a new
* event handler on the dialog until it fails. When it failed
* we know the original dialog object was destroyed and we
* can then proceed. The issue we are working around is that
* if you call two dialogs back to back, the second one will
* likely not open at all.
* @param {Office.context.ui.dialog} dialog The dialog to be closed
* @param {function()} asyncResult The callback when close is complete
*/
function dialogCloseAsync(dialog, asyncResult){
// issue the close
dialog.close();
// and then try to add a handler
// when that fails it is closed
setTimeout(function() {
try{
dialog.addEventHandler(Office.EventType.DialogMessageReceived, function() {});
dialogCloseAsync(dialog, asyncResult);
} catch(e) {
asyncResult(); // done – closed
}
}, 0);
}
[/code]


/**
* Closes the currently open dialog asynchronously.
* This has an ugly workaround which is to try to set a new
* event handler on the dialog until it fails. When it failed
* we know the original dialog object was destroyed and we
* can then proceed. The issue we are working around is that
* if you call two dialogs back to back, the second one will
* likely not open at all.
* @param {Office.context.ui.dialog} dialog The dialog to be closed
* @param {function()} asyncResult The callback when close is complete
*/
function dialogCloseAsync(dialog, asyncResult){
// issue the close
dialog.close();
// and then try to add a handler
// when that fails it is closed
setTimeout(function() {
try{
dialog.addEventHandler(Office.EventType.DialogMessageReceived, function() {});
dialogCloseAsync(dialog, asyncResult);
} catch(e) {
asyncResult(); // done – closed
}
}, 0);
}

I had been encountering this issue with different systems when developing the OfficeJS.dialogs library and had tried to set a timeout before I showed each dialog. That worked on some systems, but on others the timeout needed to be longer. So, setting a default timeout did not work. Using this in the original sample, provided above, the code would look like this:

[code lang=”javascript” collapse=”true” title=”click to expand if the github.com embedding below is not visible.”]
var i = 0;
function displayDialog() {
var url = "https://localhost:3000/test.html&quot;;
Office.context.ui.displayDialogAsync(url,{height:20, width:30, displayInIframe:true},
function (asyncResult) {
var dialog = asyncResult.value; // get the dialog
var error = asyncResult.error;
if(dialog == undefined && error.code > 0) {
// log the error
console.log(error.message);
} else {
// attache the events
dialog.addEventHandler(Office.EventType.DialogEventReceived, function (arg) {
// close this dialog, open the next
dialogCloseAsync(dialog, function() {
i++;
if(i<4) {
displayDialog();
}
});
});
dialog.addEventHandler(Office.EventType.DialogMessageReceived, function (arg) {
// close this dialog, open the next
dialogCloseAsync(dialog, function() {
i++;
if(i<4) {
displayDialog();
}
});
});
}
});
}
[/code]


var i = 0;
function displayDialog() {
var url = "https://localhost:3000/test.html&quot;;
Office.context.ui.displayDialogAsync(url,{height:20, width:30, displayInIframe:true},
function (asyncResult) {
var dialog = asyncResult.value; // get the dialog
var error = asyncResult.error;
if(dialog == undefined && error.code > 0) {
// log the error
console.log(error.message);
} else {
// attache the events
dialog.addEventHandler(Office.EventType.DialogEventReceived, function (arg) {
// close this dialog, open the next
dialogCloseAsync(dialog, function() {
i++;
if(i<4) {
displayDialog();
}
});
});
dialog.addEventHandler(Office.EventType.DialogMessageReceived, function (arg) {
// close this dialog, open the next
dialogCloseAsync(dialog, function() {
i++;
if(i<4) {
displayDialog();
}
});
});
}
});
}

As I found this workaround, I have updated OfficeJS.dialogs to use dialogCloseAsync(). Now, the MessageBox, Wait and Form objects will use closeDialogAsync() commands to replace the original closeDialog() commands I provided previously. I will be blogging about the updates to v1.0.6, shortly. wlEmoticon-hotsmile.png

Office wide After Save As Event (and tangent on extension methods and lambdas in Office code)

First off, I am way behind on my blogging. I actually owe a few blog entries to some folks that I will be getting around to. Life and work has been busy, complicated, not quite as balanced as I would like. Disappointed smile But this one issue has recently come up and is directly customer focused, therefore it gets the priority.

I was recently asked how to handle an After Save As scenario exactly the same in each application – as closely as possible. And only the Save As scenario. This is the scenario in which the user s saving a document for the first time or the user is choosing to same the same file with a different name and/or location.

So, wild tangent time… Hot smile  If you have been following my blogs for a while you will find out two things I like to do, call them programming style:

  1. Use extension methods
  2. Use inline Lambda expressions

This example today is no different. However, I recently got into a philosophical discussion on why I take these two approaches and WHY I think why you should too.

<rant>
Extension methods allow you to encapsulate a lot of code, allow for multiple re-use in other projects and once tested and vetted, keep the root entry points of your code (usually event methods or ribbon button clicks) cleaner. They are not any harder to debug, but do allow a debugger to potentially step over a large operation with an F10. I am very much about clean and neat code.

Lambda expressions are – lets admit it – cool. Smile But they serve a purpose, especially in threads to make “flow” more obvious. This is sort of the opposite end of the extension method argument in that sometimes putting a smaller operational block into the same method from which it will only ever be derived/called, just makes sense. It keeps it all in one place and easier to follow/debug.

Anyway, these are my opinions and different developers have their own styles. I invariably lose the conversation each time with my customers because by the time I get to their code: “it is just not the way we do things here.” Oh well. Punch
</rant>

With that said, lets get down to business. I have created a class called OfficeExtentions that works best in a separate Windows DLL project that you then reference in your core VSTO project. I demonstrate how to call it later. But here is the class:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using PowerPoint = Microsoft.Office.Interop.PowerPoint;
using Excel = Microsoft.Office.Interop.Excel;
using Word = Microsoft.Office.Interop.Word;

namespace OfficeExtensions
{
public static class OfficeExtensions
{
public delegate void AfterPowerPointSaveHandler(PowerPoint.Presentation PobjPres);
public delegate void AfterWordSaveHandler(Word.Document PobjDoc);
public delegate void AfterExcelSaveHandler(Excel.Workbook PobjWb);
private const int MCintDELAY = 1000;

/// <summary>
/// POWERPOINT EXTENSION METHOD - AFTER SAVE
/// This function allows you to pass in a function that you want to
/// have called after PowerPoint has completed a SaveAs. If the user
/// only performs a save, your method will not be called. This only
/// gets called when the Save As Dialog is used.
/// </summary>
/// <param name="PobjApp"></param>
/// <param name="PobjFunc"></param>
public static void AttachToPowerPointAfterSaveAsEvent(this PowerPoint.Application PobjApp, AfterPowerPointSaveHandler PobjFunc)
{
// FIRST - we attach to the BeforeSave event
PobjApp.PresentationBeforeSave += (PowerPoint.Presentation PobjPres, ref bool PbolCancel) =>
{
// start a new thread using LAMBDA
new Thread(() =>
{
Thread.Sleep(MCintDELAY); // need this delay
if (hasSaveAsDialogOpen())
{
while (hasSaveAsDialogOpen())
{
Thread.Sleep(1);
System.Windows.Forms.Application.DoEvents();
}
// look for the saveAs dialog and as long as it
// is open we will wait right here
PobjFunc.Invoke(PobjPres);
}
}
).Start();
};
}

/// <summary>
/// WORD EXTENSION METHOD - AFTER SAVE
/// This function allows you to pass in a function that you want to
/// have called after Word has completed a SaveAs. If the user
/// only performs a save, your method will not be called. This only
/// gets called when the Save As Dialog is used.
/// </summary>
/// <param name="PobjApp"></param>
/// <param name="PobjFunc"></param>
public static void AttachToWordAfterSaveAsEvent(this Word.Application PobjApp, AfterWordSaveHandler PobjFunc)
{
// FIRST - we attach to the BeforeSave event
PobjApp.DocumentBeforeSave += (Word.Document PobjDoc, ref bool PbolSaveAsUi, ref bool PbolCancel) =>
{
// start a new thread using LAMBDA
new Thread(() =>
{
Thread.Sleep(MCintDELAY); // need this delay
if (hasSaveAsDialogOpen())
{
while (hasSaveAsDialogOpen())
{
Thread.Sleep(1);
System.Windows.Forms.Application.DoEvents();
}
// look for the saveAs dialog and as long as it
// is open we will wait right here
PobjFunc.Invoke(PobjDoc);
}
}
).Start();
};
}

/// <summary>
/// EXCEL EXTENSION METHOD - AFTER SAVE
/// This function allows you to pass in a function that you want to
/// have called after Excel has completed a SaveAs. If the user
/// only performs a save, your method will not be called. This only
/// gets called when the Save As Dialog is used.
/// </summary>
/// <param name="PobjApp"></param>
/// <param name="PobjFunc"></param>
public static void AttachToExcelAfterSaveAsEvent(this Excel.Application PobjApp, AfterExcelSaveHandler PobjFunc)
{
// FIRST - we attach to the BeforeSave event
PobjApp.WorkbookBeforeSave += (Excel.Workbook PobjWb, bool PbolSaveAsUi, ref bool PbolCancel) =>
{
// start a new thread using LAMBDA
new Thread(() =>
{
Thread.Sleep(MCintDELAY); // need this delay
if (hasSaveAsDialogOpen())
{
while (hasSaveAsDialogOpen())
{
Thread.Sleep(1);
System.Windows.Forms.Application.DoEvents();
}
// look for the saveAs dialog and as long as it
// is open we will wait right here
PobjFunc.Invoke(PobjWb);
}
}
).Start();
};
}

#region API CODE
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
private static extern IntPtr FindWindowEx(IntPtr parentHandle, IntPtr childAfter, string className, string windowTitle);

[DllImport("user32.dll", SetLastError = true)]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);

/// <summary>
/// Helper function to see if Word has any dialogs open
/// </summary>
/// <returns></returns>
private static bool hasSaveAsDialogOpen()
{
const string LCstrWIN_CLASS = "#32770";
const string LCstrWIN_CAPTION = "Save As";
IntPtr LintHWin = IntPtr.Zero;
LintHWin = FindWindowEx(IntPtr.Zero, LintHWin, LCstrWIN_CLASS, LCstrWIN_CAPTION);
uint PID = 0;
while (LintHWin != IntPtr.Zero)
{
// Make sure that the window handle that we got is for the current running
// Office Application process. We do this by checking if the PID for this window
// our Office application are the same.
GetWindowThreadProcessId(LintHWin, out PID);
if (PID == Process.GetCurrentProcess().Id)
break; // found it and it belongs to our app
// get next window
LintHWin = FindWindowEx(IntPtr.Zero, LintHWin, LCstrWIN_CLASS, LCstrWIN_CAPTION);
}
return LintHWin != IntPtr.Zero;
}
#endregion
}
}

What this is doing is giving an extension method off the root of Application that allows you to attach to the event. When the event occurs it calls your function parameter.

I have built three extensions methods:

  • AttachToExcelAfterSaveEvent
  • AttachToWordAfterSaveEvent
  • AttachToPowerPointAfterSaveEvent

NOTE: I tried to get creative and simply create one overloaded function called “AttachToAfterSaveAsEvent” but this failed to compile in the Excel and PowerPoint VSTO add-ins because they required the Word.Application to be defined. Fair enough – if I added Word.Application references to my Excel and PowerPoint VSTO project all was copasetic – but WHY. Seems there is some strangeness going on in the Interops when using overloaded functions. I did not have time to investigate this further, so rather than require you to reference all the apps in each of your separate application specific projects, I opted for different names. If you feel so inclined to get it to work… please let me know if you do and you figured it out.

Each of these methods works the same. They attach to the BeforeSave event in each application, and kick off a thread. The thread is used so we will know we are OUTSIDE of the event handler when they are executed. In the thread we issue a small delay to allow the dialog to appear, and then look for a Save As dialog. If we detect one we go into a loop looking for that Save As dialog to disappear. Once it does – we call the function you define/passed as a parameter.

Here are the different ways you can call it:

  • As a LAMBA expression:
/// <summary>
/// STARTUP
/// </summary>
/// <param name="PobjSender"></param>
/// <param name="PobjEventArgs"></param>
private void ThisAddIn_Startup(object PobjSender, System.EventArgs PobjEventArgs)
{
// Attach a method to the Extension methods After Before Save As event
// in this case we are doing LAMBDA expression
Application.AttachToPowerPointAfterSaveAsEvent((PowerPoint.Presentation PobjPres) =>
{
MessageBox.Show("The filename is: " + PobjPres.FullName);
});
}

  • Or traditional means:
/// <summary>
/// STARTUP
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void ThisAddIn_Startup(object sender, System.EventArgs e)
{
// Attach a method to the Extension methods After Before Save As event
Application.AttachToExcelAfterSaveAsEvent(HandleAfterBeforeSaveAs);
}

/// <summary>
/// Handles the after SaveAs dialog
/// </summary>
/// <param name="PobjPres"></param>
private void HandleAfterBeforeSaveAs(Excel.Workbook PobjWb)
{
MessageBox.Show("The filename is: " + PobjWb.FullName);
}

There it is.

Also, I recently added RATINGS to my posts. Please rate this post if you so feel inspired to. I would like to know it is being read and that you found it useful. Open-mouthed smile