OfficeHelpers

This package is no longer being updated on NPM or GitHub.
The last version was released in March 2018 (1.0.2).
The first version was released in October 2016.

link - npmjs.com/package/@microsoft/office-js-helpers 
link - github.com/OfficeDev/office-js-helpers

This included helpers classes and methods for the following:
Authentication, Dialogs, Error Logging, Storage Helpers and Dictionary.

<link rel="stylesheet" 
      href="https://unpkg.com/@microsoft/office-js-helpers@1.0.2/dist/office.helpers.min.js">

authentication / authenticator.ts

import { EndpointStorage, IEndpointConfiguration } from './endpoint.manager';  
import { TokenStorage, IToken, ICode, IError } from './token.manager';
import { Dialog } from '../helpers/dialog';
import { CustomError } from '../errors/custom.error';

/**
 * Custom error type to handle OAuth specific errors.
 */
export class AuthError extends CustomError {
  /**
   * @constructor
   *
   * @param message Error message to be propagated.
   * @param state OAuth state if available.
  */
  constructor(message: string, public innerError?: Error) {
    super('AuthError', message, innerError);
  }
}

/**
 * Helper for performing Implicit OAuth Authentication with registered endpoints.
 */
export class Authenticator {
  /**
   * @constructor
   *
   * @param endpoints Depends on an instance of EndpointStorage.
   * @param tokens Depends on an instance of TokenStorage.
  */
  constructor(
    public endpoints?: EndpointStorage,
    public tokens?: TokenStorage
  ) {
    if (endpoints == null) {
      this.endpoints = new EndpointStorage();
    }
    if (tokens == null) {
      this.tokens = new TokenStorage();
    }
  }

  /**
   * Authenticate based on the given provider.
   * Either uses DialogAPI or Window Popups based on where it's being called from (either Add-in or Web).
   * If the token was cached, then it retrieves the cached token.
   * If the cached token has expired then the authentication dialog is displayed.
   *
   * NOTE: you have to manually check the expires_in or expires_at property to determine
   * if the token has expired.
   *
   * @param {string} provider Link to the provider.
   * @param {boolean} force Force re-authentication.
   * @return {Promise<IToken|ICode>} Returns a promise of the token, code, or error.
   */
  authenticate(
    provider: string,
    force: boolean = false,
    useMicrosoftTeams: boolean = false
  ): Promise<IToken> {
    let token = this.tokens.get(provider);
    let hasTokenExpired = TokenStorage.hasExpired(token);

    if (!hasTokenExpired && !force) {
      return Promise.resolve(token);
    }

    return this._openAuthDialog(provider, useMicrosoftTeams);
  }

  /**
   * Check if the current url is running inside of a Dialog that contains an access_token, code, or error.
   * If true then it calls messageParent by extracting the token information, thereby closing the dialog.
   * Otherwise, the caller should proceed with normal initialization of their application.
   *
   * This logic assumes that the redirect url is your application and hence when your code runs again in
   * the dialog, this logic takes over and closes it for you.
   *
   * @return {boolean}
   * Returns false if the code is running inside of a dialog without the required information
   * or is not running inside of a dialog at all.
   */
  static isAuthDialog(useMicrosoftTeams: boolean = false): boolean {
    // If the url doesn't contain an access_token, code, or error then return false.
    // This is in scenarios where we don't want to automatically control what happens to the dialog.
    if (!/(access_token|code|error|state)/gi.test(location.href)) {
      return false;
    }

    Dialog.close(location.href, useMicrosoftTeams);
    return true;
  }

  /**
   * Extract the token from the URL
   *
   * @param {string} url The url to extract the token from.
   * @param {string} exclude Exclude a particular string from the url, such as a query param or specific substring.
   * @param {string} delimiter[optional] Delimiter used by OAuth provider to mark the beginning of token response. Defaults to #.
   * @return {object} Returns the extracted token.
   */
  static getUrlParams(url: string = location.href, exclude: string = location.origin, delimiter: string = '#'): ICode | IToken | IError {
    if (exclude) {
      url = url.replace(exclude, '');
    }

    let [left, right] = url.split(delimiter);
    let tokenString = right == null ? left : right;

    if (tokenString.indexOf('?') !== -1) {
      tokenString = tokenString.split('?')[1];
    }

    return Authenticator.extractParams(tokenString);
  }

  static extractParams(segment: string): any {
    if (segment == null || segment.trim() === '') {
      return null;
    }

    let params: any = {};
    let regex = /([^&=]+)=([^&]*)/g;
    let matchParts;

    while ((matchParts = regex.exec(segment)) !== null) {
      // Fixes bugs when the state parameters contains a / before them
      if (matchParts[1] === '/state') {
        matchParts[1] = matchParts[1].replace('/', '');
      }
      params[decodeURIComponent(matchParts[1])] = decodeURIComponent(matchParts[2]);
    }

    return params;
  }

  private async _openAuthDialog(provider: string, useMicrosoftTeams: boolean): Promise<IToken> {
    // Get the endpoint configuration for the given provider and verify that it exists.
    let endpoint = this.endpoints.get(provider);
    if (endpoint == null) {
      return Promise.reject(new AuthError(`No such registered endpoint: ${provider} could be found.`)) as any;
    }

    // Set the authentication state to redirect and begin the auth flow.
    let { state, url } = EndpointStorage.getLoginParams(endpoint);

    // Launch the dialog and perform the OAuth flow. We launch the dialog at the redirect
    // url where we expect the call to isAuthDialog to be available.
    let redirectUrl = await new Dialog<string>(url, 1024, 768, useMicrosoftTeams).result;

    // Try and extract the result and pass it along.
    return this._handleTokenResult(redirectUrl, endpoint, state);
  }

  /**
   * Helper for exchanging the code with a registered Endpoint.
   * The helper sends a POST request to the given Endpoint's tokenUrl.
   *
   * The Endpoint must accept the data JSON input and return an 'access_token'
   * in the JSON output.
   *
   * @param {Endpoint} endpoint Endpoint configuration.
   * @param {object} data Data to be sent to the tokenUrl.
   * @param {object} headers Headers to be sent to the tokenUrl.
   * @return {Promise<IToken>} Returns a promise of the token or error.
   */
  private _exchangeCodeForToken(endpoint: IEndpointConfiguration, data: any, headers?: any): Promise<IToken> {
    return new Promise((resolve, reject) => {
      if (endpoint.tokenUrl == null) {
        console.warn('We couldn\'t exchange the received code for an access_token. The value returned is not an access_token. Please set the tokenUrl property or refer to our docs.');
        return resolve(data);
      }

      let xhr = new XMLHttpRequest();
      xhr.open('POST', endpoint.tokenUrl);

      xhr.setRequestHeader('Accept', 'application/json');
      xhr.setRequestHeader('Content-Type', 'application/json');

      for (let header in headers) {
        if (header === 'Accept' || header === 'Content-Type') {
          continue;
        }

        xhr.setRequestHeader(header, headers[header]);
      }

      xhr.onerror = () => reject(new AuthError('Unable to send request due to a Network error'));

      xhr.onload = () => {
        try {
          if (xhr.status === 200) {
            let json = JSON.parse(xhr.responseText);
            if (json == null) {
              return reject(new AuthError('No access_token or code could be parsed.'));
            }
            else if ('access_token' in json) {
              this.tokens.add(endpoint.provider, json);
              return resolve(json as IToken);
            }
            else {
              return reject(new AuthError(json.error, json.state));
            }
          }
          else if (xhr.status !== 200) {
            return reject(new AuthError('Request failed. ' + xhr.response));
          }
        }
        catch (e) {
          return reject(new AuthError('An error occurred while parsing the response'));
        }
      };

      xhr.send(JSON.stringify(data));
    });
  }

  private _handleTokenResult(redirectUrl: string, endpoint: IEndpointConfiguration, state: number) {
    let result = Authenticator.getUrlParams(redirectUrl, endpoint.redirectUrl);
    if (result == null) {
      throw new AuthError('No access_token or code could be parsed.');
    }
    else if (endpoint.state && +result.state !== state) {
      throw new AuthError('State couldn\'t be verified');
    }
    else if ('code' in result) {
      return this._exchangeCodeForToken(endpoint, result as ICode);
    }
    else if ('access_token' in result) {
      return this.tokens.add(endpoint.provider, result as IToken);
    }
    else {
      throw new AuthError((result as IError).error);
    }
  }
}

authentication / endpoint.manager.ts

import { Utilities } from '../helpers/utilities';  
import { Storage, StorageType } from '../helpers/storage';

export const DefaultEndpoints = {
  Google: 'Google',
  Microsoft: 'Microsoft',
  Facebook: 'Facebook',
  AzureAD: 'AzureAD',
  Dropbox: 'Dropbox'
};

export interface IEndpointConfiguration {
  // Unique name for the Endpoint
  provider?: string;

  // Registered OAuth ClientID
  clientId?: string;

  // Base URL of the endpoint
  baseUrl?: string;

  // URL segment for OAuth authorize endpoint.
  // The final authorize url is constructed as (baseUrl + '/' + authorizeUrl).
  authorizeUrl?: string;

  // Registered OAuth redirect url.
  // Defaults to window.location.origin
  redirectUrl?: string;

  // Optional token url to exchange a code with.
  // Not recommended if OAuth provider supports implicit flow.
  tokenUrl?: string;

  // Registered OAuth scope.
  scope?: string;

  // Resource parameter for the OAuth provider.
  resource?: string;

  // Automatically generate a state? defaults to false.
  state?: boolean;

  // Automatically generate a nonce? defaults to false.
  nonce?: boolean;

  // OAuth responseType.
  responseType?: string;

  // Additional object for query parameters.
  // Will be appending them after encoding the values.
  extraQueryParameters?: { [index: string]: string };
}

/**
 * Helper for creating and registering OAuth Endpoints.
 */
export class EndpointStorage extends Storage<IEndpointConfiguration> {
  /**
   * @constructor
  */
  constructor(storageType = StorageType.LocalStorage) {
    super('OAuth2Endpoints', storageType);
  }

  /**
   * Extends Storage's default add method.
   * Registers a new OAuth Endpoint.
   *
   * @param {string} provider Unique name for the registered OAuth Endpoint.
   * @param {object} config Valid Endpoint configuration.
   * @see {@link IEndpointConfiguration}.
   * @return {object} Returns the added endpoint.
   */
  add(provider: string, config: IEndpointConfiguration): IEndpointConfiguration {
    if (config.redirectUrl == null) {
      config.redirectUrl = window.location.origin;
    }
    config.provider = provider;
    return super.set(provider, config);
  }

  /**
   * Register Google Implicit OAuth.
   * If overrides is left empty, the default scope is limited to basic profile information.
   *
   * @param {string} clientId ClientID for the Google App.
   * @param {object} config Valid Endpoint configuration to override the defaults.
   * @return {object} Returns the added endpoint.
   */
  registerGoogleAuth(clientId: string, overrides?: IEndpointConfiguration) {
    let defaults = <IEndpointConfiguration>{
      clientId: clientId,
      baseUrl: 'https://accounts.google.com',
      authorizeUrl: '/o/oauth2/v2/auth',
      resource: 'https://www.googleapis.com',
      responseType: 'token',
      scope: 'https://www.googleapis.com/auth/plus.me',
      state: true
    };

    let config = { ...defaults, ...overrides };
    return this.add(DefaultEndpoints.Google, config);
  }

  /**
   * Register Microsoft Implicit OAuth.
   * If overrides is left empty, the default scope is limited to basic profile information.
   *
   * @param {string} clientId ClientID for the Microsoft App.
   * @param {object} config Valid Endpoint configuration to override the defaults.
   * @return {object} Returns the added endpoint.
   */
  registerMicrosoftAuth(clientId: string, overrides?: IEndpointConfiguration) {
    let defaults = <IEndpointConfiguration>{
      clientId: clientId,
      baseUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0',
      authorizeUrl: '/authorize',
      responseType: 'token',
      scope: 'https://graph.microsoft.com/user.read',
      extraQueryParameters: {
        response_mode: 'fragment'
      },
      nonce: true,
      state: true
    };

    let config = { ...defaults, ...overrides };
    this.add(DefaultEndpoints.Microsoft, config);
  }

  /**
   * Register Facebook Implicit OAuth.
   * If overrides is left empty, the default scope is limited to basic profile information.
   *
   * @param {string} clientId ClientID for the Facebook App.
   * @param {object} config Valid Endpoint configuration to override the defaults.
   * @return {object} Returns the added endpoint.
   */
  registerFacebookAuth(clientId: string, overrides?: IEndpointConfiguration) {
    let defaults = <IEndpointConfiguration>{
      clientId: clientId,
      baseUrl: 'https://www.facebook.com',
      authorizeUrl: '/dialog/oauth',
      resource: 'https://graph.facebook.com',
      responseType: 'token',
      scope: 'public_profile',
      nonce: true,
      state: true
    };

    let config = { ...defaults, ...overrides };
    this.add(DefaultEndpoints.Facebook, config);
  }

  /**
   * Register AzureAD Implicit OAuth.
   * If overrides is left empty, the default scope is limited to basic profile information.
   *
   * @param {string} clientId ClientID for the AzureAD App.
   * @param {string} tenant Tenant for the AzureAD App.
   * @param {object} config Valid Endpoint configuration to override the defaults.
   * @return {object} Returns the added endpoint.
   */
  registerAzureADAuth(clientId: string, tenant: string, overrides?: IEndpointConfiguration) {
    let defaults = <IEndpointConfiguration>{
      clientId: clientId,
      baseUrl: `https://login.windows.net/${tenant}`,
      authorizeUrl: '/oauth2/authorize',
      resource: 'https://graph.microsoft.com',
      responseType: 'token',
      nonce: true,
      state: true
    };

    let config = { ...defaults, ...overrides };
    this.add(DefaultEndpoints.AzureAD, config);
  }

  /**
   * Register Dropbox Implicit OAuth.
   * If overrides is left empty, the default scope is limited to basic profile information.
   *
   * @param {string} clientId ClientID for the Dropbox App.
   * @param {object} config Valid Endpoint configuration to override the defaults.
   * @return {object} Returns the added endpoint.
   */
  registerDropboxAuth(clientId: string, overrides?: IEndpointConfiguration) {
    let defaults = <IEndpointConfiguration>{
      clientId: clientId,
      baseUrl: `https://www.dropbox.com/1`,
      authorizeUrl: '/oauth2/authorize',
      responseType: 'token',
      state: true
    };

    let config = { ...defaults, ...overrides };
    this.add(DefaultEndpoints.Dropbox, config);
  }

  /**
   * Helper to generate the OAuth login url.
   *
   * @param {object} config Valid Endpoint configuration.
   * @return {object} Returns the added endpoint.
   */
  static getLoginParams(endpointConfig: IEndpointConfiguration): {
    url: string,
    state: number
  } {
    let scope = (endpointConfig.scope) ? encodeURIComponent(endpointConfig.scope) : null;
    let resource = (endpointConfig.resource) ? encodeURIComponent(endpointConfig.resource) : null;
    let state = endpointConfig.state && Utilities.generateCryptoSafeRandom();
    let nonce = endpointConfig.nonce && Utilities.generateCryptoSafeRandom();

    let urlSegments = [
      `response_type=${endpointConfig.responseType}`,
      `client_id=${encodeURIComponent(endpointConfig.clientId)}`,
      `redirect_uri=${encodeURIComponent(endpointConfig.redirectUrl)}`
    ];

    if (scope) {
      urlSegments.push(`scope=${scope}`);
    }
    if (resource) {
      urlSegments.push(`resource=${resource}`);
    }
    if (state) {
      urlSegments.push(`state=${state}`);
    }
    if (nonce) {
      urlSegments.push(`nonce=${nonce}`);
    }
    if (endpointConfig.extraQueryParameters) {
      for (let param of Object.keys(endpointConfig.extraQueryParameters)) {
        urlSegments.push(`${param}=${encodeURIComponent(endpointConfig.extraQueryParameters[param])}`);
      }
    }

    return {
      url: `${endpointConfig.baseUrl}${endpointConfig.authorizeUrl}?${urlSegments.join('&')}`,
      state: state
    };
  }
}

authentication / token.manager.ts

import { Storage, StorageType } from '../helpers/storage';  

export interface IToken {
  provider: string;
  id_token?: string;
  access_token?: string;
  token_type?: string;
  scope?: string;
  state?: string;
  expires_in?: string;
  expires_at?: Date;
}

export interface ICode {
  provider: string;
  code: string;
  scope?: string;
  state?: string;
}

export interface IError {
  error: string;
  state?: string;
}

/**
 * Helper for caching and managing OAuth Tokens.
 */
export class TokenStorage extends Storage<IToken> {
  /**
   * @constructor
  */
  constructor(storageType = StorageType.LocalStorage) {
    super('OAuth2Tokens', storageType);
  }

  /**
   * Compute the expiration date based on the expires_in field in a OAuth token.
   */
  static setExpiry(token: IToken) {
    let expire = seconds => seconds == null ? null : new Date(new Date().getTime() + ~~seconds * 1000);
    if (!(token == null) && token.expires_at == null) {
      token.expires_at = expire(token.expires_in);
    }
  }

  /**
   * Check if an OAuth token has expired.
   */
  static hasExpired(token: IToken): boolean {
    if (token == null) {
      return true;
    }
    if (token.expires_at == null) {
      return false;
    }
    else {
      // If the token was stored, it's Date type property was stringified, so it needs to be converted back to Date.
      token.expires_at = token.expires_at instanceof Date ? token.expires_at : new Date(token.expires_at as any);
      return token.expires_at.getTime() - new Date().getTime() < 0;
    }
  }

  /**
   * Extends Storage's default get method
   * Gets an OAuth Token after checking its expiry
   *
   * @param {string} provider Unique name of the corresponding OAuth Token.
   * @return {object} Returns the token or null if its either expired or doesn't exist.
   */
  get(provider: string): IToken {
    let token = super.get(provider);
    if (token == null) {
      return token;
    }

    let expired = TokenStorage.hasExpired(token);
    if (expired) {
      super.delete(provider);
      return null;
    }
    else {
      return token;
    }
  }

  /**
   * Extends Storage's default add method
   * Adds a new OAuth Token after settings its expiry
   *
   * @param {string} provider Unique name of the corresponding OAuth Token.
   * @param {object} config valid Token
   * @see {@link IToken}.
   * @return {object} Returns the added token.
   */
  add(provider: string, value: IToken) {
    value.provider = provider;
    TokenStorage.setExpiry(value);
    return super.set(provider, value);
  }
}

errors / api.error.ts

Custom error type to handle API specific errors.

import { CustomError } from './custom.error';  

export class APIError extends CustomError {
  constructor(message: string, public innerError?: Error) {
    super('APIError', message, innerError);
  }
}

errors / custom.error.ts

export abstract class CustomError extends Error { 
  constructor(public name: string, public message: string, public innerError?: Error) {
    super(message);
    if ((Error as any).captureStackTrace) {
      (Error as any).captureStackTrace(this, this.constructor);
    }
    else {
      let error = new Error();
      if (error.stack) {
        let last_part = error.stack.match(/[^\s]+$/);
        this.stack = `${this.name} at ${last_part}`;
      }
    }
  }
}

errors / exception.ts

import { CustomError } from './custom.error';  

/**
 * Error type to handle general errors.
 */
export class Exception extends CustomError {
  /**
   * @constructor
   *
   * @param message: Error message to be propagated.
   * @param innerError: Inner error if any
  */
  constructor(message: string, public innerError?: Error) {
    super('Exception', message, innerError);
  }
}

excel / utilities.ts

import { APIError } from '../errors/api.error';  

/**
 * Helper exposing useful Utilities for Excel Add-ins.
 */
export class ExcelUtilities {
  /**
   * Utility to create (or re-create) a worksheet, even if it already exists.
   * @param workbook
   * @param sheetName
   * @param clearOnly If the sheet already exists, keep it as is, and only clear its grid.
   * This results in a faster operation, and avoid a screen-update flash
   * (and the re-setting of the current selection).
   * Note: Clearing the grid does not remove floating objects like charts.
   * @returns the new worksheet
   */
  static async forceCreateSheet(
    workbook: Excel.Workbook,
    sheetName: string,
    clearOnly?: boolean
  ): Promise<Excel.Worksheet> {
    if (workbook == null && typeof workbook !== typeof Excel.Workbook) {
      throw new APIError('Invalid workbook parameter.');
    }

    if (sheetName == null || sheetName.trim() === '') {
      throw new APIError('Sheet name cannot be blank.');
    }

    if (sheetName.length > 31) {
      throw new APIError('Sheet name cannot be greater than 31 characters.');
    }

    let sheet: Excel.Worksheet;
    if (clearOnly) {
      sheet = await createOrClear(workbook.context as any, workbook, sheetName);
    }
    else {
      sheet = await recreateFromScratch(workbook.context as any, workbook, sheetName);
    }

    // To work around an issue with Office Online (tracked by the API team), it is
    // currently necessary to do a `context.sync()` before any call to `sheet.activate()`.
    // So to be safe, in case the caller of this helper method decides to immediately
    // turn around and call `sheet.activate()`, call `sync` before returning the sheet.
    await workbook.context.sync();

    return sheet;
  }
}

/**
 * Helpers
 */
async function createOrClear(
  context: Excel.RequestContext,
  workbook: Excel.Workbook,
  sheetName: string
): Promise<Excel.Worksheet> {
  if (Office.context.requirements.isSetSupported('ExcelApi', 1.4)) {
    const existingSheet = context.workbook.worksheets.getItemOrNullObject(sheetName);
    await context.sync();

    if (existingSheet.isNullObject) {
      return context.workbook.worksheets.add(sheetName);
    }
    else {
      existingSheet.getRange().clear();
      return existingSheet;
    }
  }
  else {
    // Flush anything already in the queue, so as to scope the error handling logic below.
    await context.sync();

    try {
      const oldSheet = workbook.worksheets.getItem(sheetName);
      oldSheet.getRange().clear();
      await context.sync();
      return oldSheet;
    }
    catch (error) {
      if (error instanceof OfficeExtension.Error && error.code === Excel.ErrorCodes.itemNotFound) {
        // This is an expected case where the sheet didn't exist. Create it now.
        return workbook.worksheets.add(sheetName);
      }
      else {
        throw new APIError('Unexpected error while trying to delete sheet.', error);
      }
    }
  }
}

async function recreateFromScratch(
  context: Excel.RequestContext,
  workbook: Excel.Workbook,
  sheetName: string
): Promise<Excel.Worksheet> {
  const newSheet = workbook.worksheets.add();

  if (Office.context.requirements.isSetSupported('ExcelApi', 1.4)) {
    context.workbook.worksheets.getItemOrNullObject(sheetName).delete();
  }
  else {
    // Flush anything already in the queue, so as to scope the error handling logic below.
    await context.sync();

    try {
      const oldSheet = workbook.worksheets.getItem(sheetName);
      oldSheet.delete();
      await context.sync();
    }
    catch (error) {
      if (error instanceof OfficeExtension.Error && error.code === Excel.ErrorCodes.itemNotFound) {
        // This is an expected case where the sheet didn't exist. Hence no-op.
      }
      else {
        throw new APIError('Unexpected error while trying to delete sheet.', error);
      }
    }
  }

  newSheet.name = sheetName;
  return newSheet;
}


helpers / dialog.ts

import { Utilities } from './utilities';  
import { CustomError } from '../errors/custom.error';

interface DialogResult {
  parse: boolean,
  value: any
}

/**
 * Custom error type to handle API specific errors.
 */
export class DialogError extends CustomError {
  /**
   * @constructor
   *
   * @param message Error message to be propagated.
   * @param state OAuth state if available.
  */
  constructor(message: string, public innerError?: Error) {
    super('DialogError', message, innerError);
  }
}


/**
 * An optimized size object computed based on Screen Height & Screen Width
 */
export interface IDialogSize {
  // Width in pixels
  width: number;

  // Width in percentage
  width$: number;

  // Height in pixels
  height: number;

  // Height in percentage
  height$: number;
}

export class Dialog<T> {
  /**
   * @constructor
   *
   * @param url Url to be opened in the dialog.
   * @param width Width of the dialog.
   * @param height Height of the dialog.
  */
  constructor(
    public url: string = location.origin,
    width: number = 1024,
    height: number = 768,
    public useTeamsDialog: boolean = false
  ) {
    if (!(/^https/.test(url))) {
      throw new DialogError('URL has to be loaded over HTTPS.');
    }

    this.size = this._optimizeSize(width, height);
  }

  private readonly _windowFeatures = ',menubar=no,toolbar=no,location=no,resizable=yes,scrollbars=yes,status=no';
  private static readonly key = 'VGVtcG9yYXJ5S2V5Rm9yT0pIQXV0aA==';

  private _result: Promise<T>;
  get result(): Promise<T> {
    if (this._result == null) {
      if (this.useTeamsDialog) {
        this._result = this._teamsDialog();
      }
      else if (Utilities.isAddin) {
        this._result = this._addinDialog();
      }
      else {
        this._result = this._webDialog();
      }
    }
    return this._result;
  }

  size: IDialogSize;

  private _addinDialog<T>(): Promise<T> {
    return new Promise((resolve, reject) => {
      Office.context.ui.displayDialogAsync(this.url, { width: this.size.width$, height: this.size.height$ }, (result: Office.AsyncResult) => {
        if (result.status === Office.AsyncResultStatus.Failed) {
          reject(new DialogError(result.error.message, result.error));
        }
        else {
          let dialog = result.value as Office.DialogHandler;
          dialog.addEventHandler(Office.EventType.DialogMessageReceived, args => {
            let result = this._safeParse(args.message) as T;
            resolve(result);
            dialog.close();
          });

          dialog.addEventHandler(Office.EventType.DialogEventReceived, args => {
            reject(new DialogError(args.message, args.error));
            dialog.close();
          });
        }
      });
    });
  }

  private _teamsDialog(): Promise<T> {
    return new Promise((resolve, reject) => {
      microsoftTeams.initialize();
      microsoftTeams.authentication.authenticate({
        url: this.url,
        width: this.size.width,
        height: this.size.height,
        failureCallback: exception => reject(new DialogError('Error while launching dialog', exception as any)),
        successCallback: message => resolve(this._safeParse(message))
      });
    });
  }

  private _webDialog(): Promise<T> {
    return new Promise((resolve, reject) => {
      try {
        const options = 'width=' + this.size.width + ',height=' + this.size.height + this._windowFeatures;
        window.open(this.url, this.url, options);
        if (Utilities.isIEOrEdge) {
          this._pollLocalStorageForToken(resolve, reject);
        }
        else {
          const handler = event => {
            if (event.origin === location.origin) {
              window.removeEventListener('message', handler, false);
              resolve(this._safeParse(event.data));
            }
          };
          window.addEventListener('message', handler);
        }
      }
      catch (exception) {
        return reject(new DialogError('Unexpected error occurred while creating popup', exception));
      }
    });
  }

  private _pollLocalStorageForToken(resolve: (value: T) => void, reject: (reason: DialogError) => void) {
    localStorage.removeItem(Dialog.key);
    const POLL_INTERVAL = 400;
    let interval = setInterval(() => {
      try {
        const data = localStorage.getItem(Dialog.key);
        if (!(data == null)) {
          clearInterval(interval);
          localStorage.removeItem(Dialog.key);
          return resolve(this._safeParse(data));
        }
      }
      catch (exception) {
        clearInterval(interval);
        localStorage.removeItem(Dialog.key);
        return reject(new DialogError('Unexpected error occurred in the dialog', exception));
      }
    }, POLL_INTERVAL);
  }

  /**
   * Close any open dialog by providing an optional message.
   * If more than one dialogs are attempted to be opened
   * an exception will be created.
   */
  static close(message?: any, useTeamsDialog: boolean = false) {
    let parse = false;
    let value = message;

    if (typeof message === 'function') {
      throw new DialogError('Invalid message. Cannot pass functions as arguments');
    }
    else if ((!(value == null)) && typeof value === 'object') {
      parse = true;
      value = JSON.stringify(value);
    }

    try {
      if (useTeamsDialog) {
        microsoftTeams.initialize();
        microsoftTeams.authentication.notifySuccess(JSON.stringify(<DialogResult>{ parse, value }));
      }
      else if (Utilities.isAddin) {
        Office.context.ui.messageParent(JSON.stringify(<DialogResult>{ parse, value }));
      }
      else {
        if (Utilities.isIEOrEdge) {
          localStorage.setItem(Dialog.key, JSON.stringify(<DialogResult>{ parse, value }));
        }
        else if (window.opener) {
          window.opener.postMessage(JSON.stringify(<DialogResult>{ parse, value }), location.origin);
        }
        window.close();
      }
    }
    catch (error) {
      throw new DialogError('Cannot close dialog', error);
    }
  }

  private _optimizeSize(desiredWidth: number, desiredHeight: number): IDialogSize {
    const { width: screenWidth, height: screenHeight } = window.screen;

    const width = this._maxSize(desiredWidth, screenWidth);
    const height = this._maxSize(desiredHeight, screenHeight);
    const width$ = this._percentage(width, screenWidth);
    const height$ = this._percentage(height, screenHeight);

    return { width$, height$, width, height };
  }

  private _maxSize(value: number, max: number) {
    return value < (max - 30) ? value : max - 30;
  }

  private _percentage(value: number, max: number) {
    return (value * 100 / max);
  }

  private _safeParse(data: string) {
    try {
      let result = JSON.parse(data) as DialogResult;
      if (result.parse === true) {
        return this._safeParse(result.value);
      }
      return result.value;
    }
    catch (_e) {
      return data;
    }
  }
}

helpers / dictionary.spec.ts

import { Dictionary } from './dictionary';  

describe('Dictionary creation', () => {
  it('creates a new empty dictionary', () => {
    // Setup
    const dictionary = new Dictionary();

    // Assert
    expect(dictionary).toBeDefined();
    expect(dictionary.count).toBe(0);
  });

  it('creates a dictionary from a Map<string, T>', () => {
    // Setup
    const dictionary = new Dictionary<any>(new Map<string, any>([
      ['item1', 1],
      ['item2', 'the second item'],
      ['item3', { number: 3 }]
    ]));

    // Assert
    expect(dictionary).toBeDefined();
    expect(dictionary.count).toBe(3);
    expect(dictionary.get('item1')).toBe(1);
    expect(dictionary.get('item2')).toBe('the second item');
  });

  it('creates a dictionary from Array<[string, T]>', () => {
    // Setup
    const dictionary = new Dictionary<any>([
      ['item1', 1],
      ['item2', 'the second item'],
      ['item3', { number: 3 }]
    ]);

    // Assert
    expect(dictionary).toBeDefined();
    expect(dictionary.count).toBe(3);
    expect(dictionary.get('item1')).toBe(1);
    expect(dictionary.get('item2')).toBe('the second item');
  });

  it('creates a dictionary from an Object', () => {
    // Setup
    const dictionary = new Dictionary<any>({
      item1: 1,
      item2: 'the second item',
      item3: { number: 3 }
    });

    // Assert
    expect(dictionary).toBeDefined();
    expect(dictionary.count).toBe(3);
    expect(dictionary.get('item1')).toBe(1);
    expect(dictionary.get('item2')).toBe('the second item');
  });

  it('fails if Dictionary is instantiated with invalid values', () => {
    // Assert
    expect(() => new Dictionary(2 as any)).toThrow(TypeError);
    expect(() => new Dictionary(new Set() as any)).toThrow(TypeError);
    expect(() => new Dictionary('new Set() as any)' as any)).toThrow(TypeError);
  });
});

describe('Dictionary operations', () => {
  let dictionary: Dictionary<any>;

  beforeEach(() => {
    dictionary = new Dictionary({
      item1: 1,
      item2: 'the second item',
      item3: { number: 3 }
    });
  });

  describe('Get', () => {
    it('returns an object', () => {
      // Assert
      expect(dictionary.get('item1')).toBe(1);
    });

    it('returns undefined if the object doesn\'t exist', () => {
      // Assert
      expect(dictionary.get('item4')).toBeUndefined();
    });

    it('returns undefined if the key is invalid', () => {
      // Assert
      expect(dictionary.get(undefined)).toBeUndefined();
    });
  });

  describe('Set', () => {
    it('inserts the object', () => {
      // Setup
      const originalCount = dictionary.count;
      dictionary.set('item4', 4);
      const item = dictionary.get('item4');

      // Assert
      expect(item).toBe(4);
      expect(dictionary.count).toBe(originalCount + 1);
    });

    it('returns the inserted object', () => {
      // Setup
      const item = dictionary.set('item4', 4);

      // Assert
      expect(item).toBe(4);
    });

    it('replaces if the key already exists', () => {
      // Setup
      dictionary.set('item4', 4);
      let item = dictionary.get('item4');
      expect(item).toBe(4);

      // Assert
      dictionary.set('item4', 'random');
      item = dictionary.get('item4');
      expect(item).toBe('random');
    });

    it('throws if the key is invalid', () => {
      // Assert
      expect(() => dictionary.set(null, 'random')).toThrow(TypeError);
      expect(() => dictionary.set(2 as any, 'random')).toThrow(TypeError);
    });
  });

  describe('Delete', () => {
    it('deletes the object', () => {
      // Setup
      const originalCount = dictionary.count;
      dictionary.delete('item3');
      const item = dictionary.get('item3');

      // Assert
      expect(item).toBeUndefined();
      expect(dictionary.count).toBe(originalCount - 1);
    });

    it('returns the deleted object', () => {
      // Setup
      const item = dictionary.delete('item1');

      // Assert
      expect(item).toBe(1);
    });

    it('throws if the doesn\'t exist', () => {
      // Assert
      expect(() => dictionary.delete('item4')).toThrow(ReferenceError);
    });

    it('throws if the key is invalid', () => {
      // Assert
      expect(() => dictionary.delete(null)).toThrow(TypeError);
      expect(() => dictionary.delete(2 as any)).toThrow(TypeError);
    });
  });

  describe('Clear', () => {
    it('empties the dictionary', () => {
      // Setup
      dictionary.clear();
      const item = dictionary.get('item4');

      // Assert
      expect(item).toBeUndefined();
      expect(dictionary.count).toBe(0);
    });
  });

  describe('Keys', () => {
    it('returns the keys in the dictionary', () => {
      // Setup
      const keys = dictionary.keys();

      // Assert
      expect(keys).toBeDefined();
      expect(keys[0]).toBe('item1');
    });
  });

  describe('Values', () => {
    it('returns the values in the dictionary', () => {
      // Setup
      const values = dictionary.values();

      // Assert
      expect(values).toBeDefined();
      expect(values[0]).toBe(1);
    });
  });

  describe('Clone', () => {
    it('returns a shallow copy of the dictonary', () => {
      // Setup
      const dictionaryCopy = dictionary.clone();

      // Assert
      expect(dictionaryCopy).toBeDefined();
    });

    it('ensure the copy is shallow', () => {
      // Setup
      const dictionaryCopy = dictionary.clone();
      dictionaryCopy.set('item1', 10);

      // Assert
      expect(dictionaryCopy.get('item1')).toBe(10);
      expect(dictionary.get('item1')).toBe(1);
    });
  });

  describe('Count', () => {
    it('returns the count of the dictonary', () => {
      // Setup
      const count = dictionary.count;

      // Assert
      expect(count).toBe(3);
    });
  });
});

helpers / dictionary.ts

import { isObject, isNil, isString, isEmpty } from 'lodash-es';  

/**
 * Helper for creating and querying Dictionaries.
 * A wrapper around ES6 Maps.
 */
export class Dictionary<T> {
  protected _items: Map<string, T>;

  /**
   * @constructor
   * @param {object} items Initial seed of items.
   */
  constructor(items?: { [index: string]: T } | Array<[string, T]> | Map<string, T>) {
    if (isNil(items)) {
      this._items = new Map();
    }
    else if (items instanceof Set) {
      throw new TypeError(`Invalid type of argument: Set`);
    }
    else if (items instanceof Map) {
      this._items = new Map(items);
    }
    else if (Array.isArray(items)) {
      this._items = new Map(items);
    }
    else if (isObject(items)) {
      this._items = new Map();
      for (const key of Object.keys(items)) {
        this._items.set(key, items[key]);
      }
    }
    else {
      throw new TypeError(`Invalid type of argument: ${typeof items}`);
    }
  }

  /**
   * Gets an item from the dictionary.
   *
   * @param {string} key The key of the item.
   * @return {object} Returns an item if found.
   */
  get(key: string): T {
    return this._items.get(key);
  }

  /**
   * Inserts an item into the dictionary.
   * If an item already exists with the same key, it will be overridden by the new value.
   *
   * @param {string} key The key of the item.
   * @param {object} value The item to be added.
   * @return {object} Returns the added item.
   */
  set(key: string, value: T): T {
    this._validateKey(key);
    this._items.set(key, value);
    return value;
  }

  /**
   * Removes an item from the dictionary.
   * Will throw if the key doesn't exist.
   *
   * @param {string} key The key of the item.
   * @return {object} Returns the deleted item.
   */
  delete(key: string): T {
    if (!this.has(key)) {
      throw new ReferenceError(`Key: ${key} not found.`);
    }
    let value = this._items.get(key);
    this._items.delete(key);
    return value;
  }

  /**
   * Clears the dictionary.
   */
  clear(): void {
    this._items.clear();
  }

  /**
   * Check if the dictionary contains the given key.
   *
   * @param {string} key The key of the item.
   * @return {boolean} Returns true if the key was found.
   */
  has(key: string): boolean {
    this._validateKey(key);
    return this._items.has(key);
  }

  /**
   * Lists all the keys in the dictionary.
   *
   * @return {array} Returns all the keys.
   */
  keys(): Array<string> {
    return Array.from(this._items.keys());
  }

  /**
   * Lists all the values in the dictionary.
   *
   * @return {array} Returns all the values.
   */
  values(): Array<T> {
    return Array.from(this._items.values());
  }

  /**
   * Get a shallow copy of the underlying map.
   *
   * @return {object} Returns the shallow copy of the map.
   */
  clone(): Map<string, T> {
    return new Map(this._items);
  }

  /**
   * Number of items in the dictionary.
   *
   * @return {number} Returns the number of items in the dictionary.
   */
  get count(): number {
    return this._items.size;
  }

  private _validateKey(key: string): void {
    if (!isString(key) || isEmpty(key)) {
      throw new TypeError('Key needs to be a string');
    }
  }
}

helpers / storage.spec.ts

import { Storage, StorageType } from './storage';  

describe('Storage creation', () => {
  beforeEach(() => {
    localStorage.clear();
  });

  it('creates a new empty storage with container', () => {
    // Setup
    const storage = new Storage('container');

    // Assert
    expect(storage).toBeDefined();
    expect(storage.count).toBe(0);
  });

  it('sets the container correctly', () => {
    // Setup
    const storage = new Storage('container');

    // Assert
    expect(storage.container).toBe('container');
  });

  it('defaults to localStorage', () => {
    // Setup
    const storage = new Storage('container');

    // Assert
    expect((storage as any)._storage).toBe(localStorage);
  });

  it('throws an error if an invalid key is passed', () => {
    // Assert
    expect(() => new Storage(null)).toThrowError(TypeError);
  });

  it('switches to a session storage if storage type is passed', () => {
    // Setup
    const storage = new Storage('container', StorageType.SessionStorage);

    // Assert
    expect((storage as any)._storage).toBe(sessionStorage);
  });

  it('switches to a in memory storage if storage type is passed', () => {
    // Setup
    const storage = new Storage('container', StorageType.InMemoryStorage);

    // Assert
    expect(((storage as any)._storage._map) instanceof Map).toBeTruthy();
  });
});

helpers / storage.ts

import { debounce, isEmpty, isString, isNil } from 'lodash-es';  
import { Observable } from 'rxjs';
import { Exception } from '../errors/exception';

const NOTIFICATION_DEBOUNCE = 300;
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;

export enum StorageType {
  LocalStorage,
  SessionStorage,
  InMemoryStorage
}

export interface Subscription {
  // A flag to indicate whether this Subscription has already been unsubscribed.
  closed: boolean;
  // Disposes the resources held by the subscription. May, for instance, cancel
  // an ongoing Observable execution or cancel any other type of work that
  // started when the Subscription was created.
  unsubscribe(): void;
}

/**
 * Helper for creating and querying Local Storage or Session Storage.
 * Uses {@link Dictionary} so all the data is encapsulated in a single
 * storage namespace. Writes update the actual storage.
 */
export class Storage<T> {
  private _storage: typeof localStorage | typeof sessionStorage;
  private _observable: Observable<string> = null;
  private _containerRegex: RegExp = null;

  /**
   * @constructor
   * @param {string} container Container name to be created in the LocalStorage.
   * @param {StorageType} type[optional] Storage Type to be used, defaults to Local Storage.
   */
  constructor(
    public container: string,
    private _type: StorageType = StorageType.LocalStorage
  ) {
    this._validateKey(container);
    this._containerRegex = new RegExp(`^@${this.container}\/`);
    this.switchStorage(this._type);
  }

  /**
   * Switch the storage type.
   * Switches the storage type and then reloads the in-memory collection.
   *
   * @type {StorageType} type The desired storage to be used.
   */
  switchStorage(type: StorageType) {
    switch (type) {
      case StorageType.LocalStorage:
        this._storage = window.localStorage;
        break;

      case StorageType.SessionStorage:
        this._storage = window.sessionStorage;
        break;

      case StorageType.InMemoryStorage:
        this._storage = new InMemoryStorage() as any;
        break;
    }
    if (isNil(this._storage)) {
      throw new Exception('Browser local or session storage is not supported.');
    }
    if (!this._storage.hasOwnProperty(this.container)) {
      this._storage[this.container] = null;
    }
  }

  /**
   * Gets an item from the storage.
   *
   * @param {string} key The key of the item.
   * @return {object} Returns an item if found.
   */
  get(key: string): T {
    const scopedKey = this._scope(key);
    const item = this._storage.getItem(scopedKey);
    try {
      return JSON.parse(item, this._reviver.bind(this));
    }
    catch (_error) {
      return item as any;
    }
  }

  /**
   * Inserts an item into the storage.
   * If an item already exists with the same key,
   * it will be overridden by the new value.
   *
   * @param {string} key The key of the item.
   * @param {object} value The item to be added.
   * @return {object} Returns the added item.
   */
  set(key: string, value: T): T {
    this._validateKey(key);
    try {
      const scopedKey = this._scope(key);
      const item = JSON.stringify(value);
      this._storage.setItem(scopedKey, item);
      return value;
    }
    catch (error) {
      throw new Exception(`Unable to serialize value for: ${key} `, error);
    }
  }

  /**
   * Removes an item from the storage.
   * Will throw if the key doesn't exist.
   *
   * @param {string} key The key of the item.
   * @return {object} Returns the deleted item.
   */
  delete(key: string): T {
    try {
      let value = this.get(key);
      if (value === undefined) {
        throw new ReferenceError(`Key: ${key} not found.`);
      }
      const scopedKey = this._scope(key);
      this._storage.removeItem(scopedKey);
      return value;
    }
    catch (error) {
      throw new Exception(`Unable to delete '${key}' from storage`, error);
    }
  }

  /**
   * Clear the storage.
   */
  clear() {
    this._storage.removeItem(this.container);
  }

  /**
   * Check if the storage contains the given key.
   *
   * @param {string} key The key of the item.
   * @return {boolean} Returns true if the key was found.
   */
  has(key: string): boolean {
    this._validateKey(key);
    return this.get(key) !== undefined;
  }

  /**
   * Lists all the keys in the storage.
   *
   * @return {array} Returns all the keys.
   */
  keys(): Array<string> {
    try {
      return Object.keys(this._storage).filter(key => this._containerRegex.test(key));
    }
    catch (error) {
      throw new Exception(`Unable to get keys from storage`, error);
    }
  }

  /**
   * Lists all the values in the storage.
   *
   * @return {array} Returns all the values.
   */
  values(): Array<T> {
    try {
      return this.keys().map(key => this.get(key));
    }
    catch (error) {
      throw new Exception(`Unable to get values from storage`, error);
    }
  }

  /**
   * Number of items in the store.
   *
   * @return {number} Returns the number of items in the dictionary.
   */
  get count(): number {
    try {
      return this.keys().length;
    }
    catch (error) {
      throw new Exception(`Unable to get size of localStorage`, error);
    }
  }

  /**
   * Clear all storages.
   * Completely clears both the localStorage and sessionStorage.
   */
  static clearAll(): void {
    window.localStorage.clear();
    window.sessionStorage.clear();
  }

  /**
   * Returns an observable that triggers every time there's a Storage Event
   * or if the collection is modified in a different tab.
   */
  notify(next: () => void, error?: (error: any) => void, complete?: () => void): Subscription {
    if (!(this._observable == null)) {
      return this._observable.subscribe(next, error, complete);
    }

    this._observable = new Observable<string>((observer) => {
      // Debounced listener to storage events
      let debouncedUpdate = debounce((event: StorageEvent) => {
        try {
          // If the change is on the current container
          if (this._containerRegex.test(event.key)) {
            // Notify the listener of the change
            observer.next(event.key);
          }
        }
        catch (e) {
          observer.error(e);
        }
      }, NOTIFICATION_DEBOUNCE);

      window.addEventListener('storage', debouncedUpdate, false);

      // Teardown
      return () => {
        window.removeEventListener('storage', debouncedUpdate, false);
        this._observable = null;
      };
    });

    return this._observable.subscribe(next, error, complete);
  }

  private _validateKey(key: string): void {
    if (!isString(key) || isEmpty(key)) {
      throw new TypeError('Key needs to be a string');
    }
  }

  /**
   * Determine if the value was a Date type and if so return a Date object instead.
   * https://blog.mariusschulz.com/2016/04/28/deserializing-json-strings-as-javascript-date-objects
   */
  private _reviver(_key: string, value: any) {
    if (isString(value) && DATE_REGEX.test(value)) {
      return new Date(value);
    }
    return value;
  }

  /**
   * Scope the key to the container as @<container>/<key> so as to easily identify
   * the item in localStorage and reduce collisions
   * @param key key to be scoped
   */
  private _scope(key: string): string {
    if (isEmpty(this.container)) {
      return key;
    }
    return `@${this.container}/${key}`;
  }
}

/**
 * Creating a mock for folks who don't want to use localStorage.
 * This will still allow them to use the APIs.
*/
class InMemoryStorage {
  private _map: Map<string, string>;

  constructor() {
    console.warn(`Using non persistent storage. Data will be lost when browser is refreshed/closed`);
    this._map = new Map();
  }

  get length(): number {
    return this._map.size;
  }

  clear(): void {
    this._map.clear();
  }

  getItem(key: string): string {
    return this._map.get(key);
  }

  removeItem(key: string): boolean {
    return this._map.delete(key);
  }

  setItem(key: string, data: string): void {
    this._map.set(key, data);
  }

  key(index: number): string {
    let result = undefined;
    let ctr = 0;
    this._map.forEach((_val, key) => {
      if (++ctr === index) {
        result = key;
      }
    });
    return result;
  }
}

helpers / utilities

import { CustomError } from '../errors/custom.error';  

interface IContext {
  host: string;
  platform: string;
}

/**
 * Constant strings for the host types
 */
export const HostType = {
  WEB: 'WEB',
  ACCESS: 'ACCESS',
  EXCEL: 'EXCEL',
  ONENOTE: 'ONENOTE',
  OUTLOOK: 'OUTLOOK',
  POWERPOINT: 'POWERPOINT',
  PROJECT: 'PROJECT',
  WORD: 'WORD'
};

/**
 * Constant strings for the host platforms
 */
export const PlatformType = {
  IOS: 'IOS',
  MAC: 'MAC',
  OFFICE_ONLINE: 'OFFICE_ONLINE',
  PC: 'PC'
};

/*
* Retrieves host info using a workaround that utilizes the internals of the
* Office.js library. Such workarounds should be avoided, as they can lead to
* a break in behavior, if the internals are ever changed. In this case, however,
* Office.js will soon be delivering a new API to provide the host and platform
* information.
*/
function getHostInfo(): {
  host: 'WEB' | 'ACCESS' | 'EXCEL' | 'ONENOTE' | 'OUTLOOK' | 'POWERPOINT' | 'PROJECT' | 'WORD',
  platform: 'IOS' | 'MAC' | 'OFFICE_ONLINE' | 'PC'
} {
  // A forthcoming API (partially rolled-out) will expose the host and platform info natively
  // when queried from within an add-in.
  // If the platform already exposes that info, then just return it
  // (but only after massaging it to fit the return types expected by this function)
  const Office = (window as any).Office as { context: IContext };
  const isHostExposedNatively = Office && Office.context && Office.context.host;
  const context: IContext = isHostExposedNatively
    ? Office.context
    : useHostInfoFallbackLogic();
  return {
    host: convertHostValue(context.host),
    platform: convertPlatformValue(context.platform)
  };
}

function useHostInfoFallbackLogic(): IContext {
  try {
    if (window.sessionStorage == null) {
      throw new Error(`Session Storage isn't supported`);
    }

    let hostInfoValue = window.sessionStorage['hostInfoValue'] as string;
    let [hostRaw, platformRaw, extras] = hostInfoValue.split('$');

    // Older hosts used "|", so check for that as well:
    if (extras == null) {
      [hostRaw, platformRaw] = hostInfoValue.split('|');
    }

    let host = hostRaw.toUpperCase() || 'WEB';
    let platform: string = null;

    if (Utilities.host !== HostType.WEB) {
      let platforms = {
'IOS': PlatformType.IOS,
'MAC': PlatformType.MAC,
'WEB': PlatformType.OFFICE_ONLINE,
'WIN32': PlatformType.PC
      };

      platform = platforms[platformRaw.toUpperCase()] || null;
    }

    return { host, platform };
  }
  catch (error) {
    return { host: 'WEB', platform: null };
  }
}

/** Convert the Office.context.host value to one of the Office JS Helpers constants. */
function convertHostValue(host: string) {
  const officeJsToHelperEnumMapping = {
'Word': HostType.WORD,
'Excel': HostType.EXCEL,
'PowerPoint': HostType.POWERPOINT,
'Outlook': HostType.OUTLOOK,
'OneNote': HostType.ONENOTE,
'Project': HostType.PROJECT,
'Access': HostType.ACCESS
  };

  return officeJsToHelperEnumMapping[host] || host;
}

/** Convert the Office.context.platform value to one of the Office JS Helpers constants. */
function convertPlatformValue(platform: string) {
  const officeJsToHelperEnumMapping = {
'PC': PlatformType.PC,
'OfficeOnline': PlatformType.OFFICE_ONLINE,
'Mac': PlatformType.MAC,
'iOS': PlatformType.IOS
  };

  return officeJsToHelperEnumMapping[platform] || platform;
}

/**
 * Helper exposing useful Utilities for Office-Add-ins.
 */
export class Utilities {
  /**
   * A promise based helper for Office initialize.
   * If Office.js was found, the 'initialize' event is waited for and
   * the promise is resolved with the right reason.
   *
   * Else the application starts as a web application.
   */
  static initialize() {
    return new Promise<string>((resolve, reject) => {
      try {
        Office.initialize = reason => resolve(reason as any);
      }
      catch (exception) {
        if (window['Office']) {
          reject(exception);
        }
        else {
          resolve('Office was not found. Running as web application.');
        }
      }
    });
  }

  /*
   * Returns the current host which is either the name of the application where the
   * Office Add-in is running ("EXCEL", "WORD", etc.) or simply "WEB" for all other platforms.
   * The property is always returned in ALL_CAPS.
   * Note that this property is guaranteed to return the correct value ONLY after Office has
   * initialized (i.e., inside, or sequentially after, an Office.initialize = function() { ... }; statement).
   *
   * This code currently uses a workaround that relies on the internals of Office.js.
   * A more robust approach is forthcoming within the official Office.js library.
   * Once the new approach is released, this implementation will switch to using it
   * instead of the current workaround.
   */
  static get host(): string {
    return getHostInfo().host;
  }

  /*
  * Returns the host application's platform ("IOS", "MAC", "OFFICE_ONLINE", or "PC").
  * This is only valid for Office Add-ins, and hence returns null if the HostType is WEB.
  * The platform is in ALL-CAPS.
  * Note that this property is guaranteed to return the correct value ONLY after Office has
  * initialized (i.e., inside, or sequentially after, an Office.initialize = function() { ... }; statement).
  *
  * This code currently uses a workaround that relies on the internals of Office.js.
  * A more robust approach is forthcoming within the official Office.js library.
  * Once the new approach is released, this implementation will switch to using it
  * instead of the current workaround.
  */
  static get platform(): string {
    return getHostInfo().platform;
  }

  /**
   * Utility to check if the code is running inside of an add-in.
   */
  static get isAddin() {
    return Utilities.host !== HostType.WEB;
  }

  /**
   * Utility to check if the browser is IE11 or Edge.
   */
  static get isIEOrEdge() {
    return /Edge\/|Trident\//gi.test(window.navigator.userAgent);
  }

  /**
   * Utility to generate crypto safe random numbers
   */
  static generateCryptoSafeRandom() {
    let random = new Uint32Array(1);
    if ('msCrypto' in window) {
      (<any>window).msCrypto.getRandomValues(random);
    }
    else if ('crypto' in window) {
      window.crypto.getRandomValues(random);
    }
    else {
      throw new Error('The platform doesn\'t support generation of cryptographically safe randoms. Please disable the state flag and try again.');
    }
    return random[0];
  }

  /**
   * Utility to print prettified errors.
   * If multiple parameters are sent then it just logs them instead.
   */
  static log(exception: Error | CustomError | string, extras?: any, ...args) {
    if (!(extras == null)) {
      return console.log(exception, extras, ...args);
    }
    if (exception == null) {
      console.error(exception);
    }
    else if (typeof exception === 'string') {
      console.error(exception);
    }
    else {
      console.group(`${exception.name}: ${exception.message}`);
      {
        let innerException = exception;
        if (exception instanceof CustomError) {
          innerException = exception.innerError;
        }
        if ((window as any).OfficeExtension && innerException instanceof OfficeExtension.Error) {
          console.groupCollapsed('Debug Info');
          console.error(innerException.debugInfo);
          console.groupEnd();
        }
        {
          console.groupCollapsed('Stack Trace');
          console.error(exception.stack);
          console.groupEnd();
        }
        {
          console.groupCollapsed('Inner Error');
          console.error(innerException);
          console.groupEnd();
        }
      }
      console.groupEnd();
    }
  }
}

ui / message-banner.html

<div class="office-js-helpers-notification ms-font-m ms-MessageBar @@CLASS"> 
  <style>
    .office-js-helpers-notification {
      position: fixed;
      z-index: 2147483647;
      top: 0;
      left: 0;
      right: 0;
      width: 100%;
      padding: 0 0 10px 0;
    }

    .office-js-helpers-notification > div > div {
      padding: 10px 15px;
      box-sizing: border-box;
    }

    .office-js-helpers-notification pre {
      white-space: pre-wrap;
      word-wrap: break-word;
      margin: 0px;
      font-size: smaller;
    }

    .office-js-helpers-notification > button {
      height: 52px;
      width: 40px;
      cursor: pointer;
      float: right;
      background: transparent;
      border: 0;
      margin-left: 10px;
      margin-right: '@@PADDING'
    }
  </style>
  <button>
      <i class="ms-Icon ms-Icon--Clear"></i>
  </button>
</div>

ui / ui.spec.ts

import { _parseNotificationParams as pnp } from './ui';  
import { stringify } from '../util/stringify';

describe('_parseNotificationParams', () => {
  it('returns null if given nothing', () => {
    expect(pnp(null)).toBeNull();
    expect(pnp(undefined)).toBeNull();
  });

  it('parses strings', () => {
    const messageText = 'Open the pod bay doors, HAL';

    let result = pnp([messageText]);
    expect(result.message).toBe(messageText);
    expect(result.type).toBe('default');

    result = pnp([new String(messageText)]);
    expect(result.message).toBe(messageText);
  });

  it('parses errors', () => {
    const errorText = `I'm sorry, Dave. I'm afraid I can't do that.`
    const error = new Error(errorText);

    const result = pnp([error]);
    expect(result.message).toBe(errorText);
    expect(result.type).toBe('error');
  });

  it('parses any', () => {
    // Objects
    const obj = { hello: 'world' };
    let result = pnp([obj]);
    expect(result.message).toBe(stringify(obj));
    expect(result.type).toBe('default');

    // Numbers
    result = pnp([5]);
    expect(result.message).toBe((5).toString());
    expect(result.type).toBe('default');

    // With Title
    const title = 'Untitled';
    result = pnp([5, title]);
    expect(result.message).toBe((5).toString());
    expect(result.title).toBe(title);

    // With Type
    const type = 'success';
    result = pnp([5, title, type]);
    expect(result.message).toBe((5).toString());
    expect(result.title).toBe(title);
    expect(result.type).toBe(type);
  });
});

ui / ui.ts

import { Utilities, PlatformType } from '../helpers/utilities';  
import { stringify } from '../util/stringify';
import html from './message-banner.html';

const DEFAULT_WHITESPACE = 2;

export interface IMessageBannerParams {
  title?: string;
  message?: string;
  type: 'default' | 'success' | 'error' | 'warning' | 'severe-warning';
  details?: string;
}

export class UI {
  /** Shows a basic notification at the top of the page
    * @param message - body of the notification
    */
  static notify(message: string);

  /** Shows a basic notification with a custom title at the top of the page
   * @param message - body of the notification
   * @param title - title of the notification
   */
  static notify(message: string, title: string);

  /** Shows a basic notification at the top of the page, with a background color set based on the type parameter
   * @param message - body of the notification
   * @param title - title of the notification
   * @param type - type of the notification - see https://dev.office.com/fabric-js/Components/MessageBar/MessageBar.html#Variants
   */
  static notify(message: string, title: string, type: 'default' | 'success' | 'error' | 'warning' | 'severe-warning');

  /** Shows a basic error notification at the top of the page
   * @param error - Error object
   */
  static notify(error: Error);

  /** Shows a basic error notification with a custom title at the top of the page
   * @param title - Title, bolded
   * @param error - Error object
   */
  static notify(error: Error, title: string);

  /** Shows a basic notification at the top of the page
   * @param message - The body of the notification
   */
  static notify(message: any);

  /** Shows a basic notification with a custom title at the top of the page
   * @param message - body of the notification
   * @param title - title of the notification
   */
  static notify(message: any, title: string);

  /** Shows a basic notification at the top of the page, with a background color set based on the type parameter
   * @param message - body of the notification
   * @param title - title of the notification
   * @param type - type of the notification - see https://dev.office.com/fabric-js/Components/MessageBar/MessageBar.html#Variants
   */
  static notify(message: any, title: string, type: 'default' | 'success' | 'error' | 'warning' | 'severe-warning');

  static notify(...args: any[]) {
    const params = _parseNotificationParams(args);
    if (params == null) {
      console.error(new Error('Invalid params. Cannot create a notification'));
      return null;
    }

    const messageBarClasses = {
'success': 'ms-MessageBar--success',
'error': 'ms-MessageBar--error',
'warning': 'ms-MessageBar--warning',
'severe-warning': 'ms-MessageBar--severeWarning'
    };

    const messageBarTypeClass = messageBarClasses[params.type] || '';

    let paddingForPersonalityMenu = '0';
    if (Utilities.platform === PlatformType.PC) {
      paddingForPersonalityMenu = '20px';
    }
    else if (Utilities.platform === PlatformType.MAC) {
      paddingForPersonalityMenu = '40px';
    }

    const messageBannerHtml = html.replace('@@CLASS', messageBarTypeClass).replace('\'@@PADDING\'', paddingForPersonalityMenu);

    const existingNotifications = document.getElementsByClassName('office-js-helpers-notification');
    while (existingNotifications[0]) {
      existingNotifications[0].parentNode.removeChild(existingNotifications[0]);
    }

    document.body.insertAdjacentHTML('afterbegin', messageBannerHtml);

    const notificationDiv = document.getElementsByClassName('office-js-helpers-notification')[0];
    const messageTextArea = document.createElement('div');
    notificationDiv.insertAdjacentElement('beforeend', messageTextArea);

    if (params.title) {
      const titleDiv = document.createElement('div');
      titleDiv.textContent = params.title;
      titleDiv.classList.add('ms-fontWeight-semibold');
      messageTextArea.insertAdjacentElement('beforeend', titleDiv);
    }

    params.message.split('\n').forEach(text => {
      const div = document.createElement('div');
      div.textContent = text;
      messageTextArea.insertAdjacentElement('beforeend', div);
    });

    if (params.details) {
      const labelDiv = document.createElement('div');
      messageTextArea.insertAdjacentElement('beforeend', labelDiv);
      const label = document.createElement('a');
      label.setAttribute('href', 'javascript:void(0)');
      label.onclick = () => {
        (document.querySelector('.office-js-helpers-notification pre') as HTMLPreElement)
          .parentElement.style.display = 'block';
        labelDiv.style.display = 'none';
      };
      label.textContent = 'Details';
      labelDiv.insertAdjacentElement('beforeend', label);

      const preDiv = document.createElement('div');
      preDiv.style.display = 'none';
      messageTextArea.insertAdjacentElement('beforeend', preDiv);
      const detailsDiv = document.createElement('pre');
      detailsDiv.textContent = params.details;
      preDiv.insertAdjacentElement('beforeend', detailsDiv);
    }

    (document.querySelector('.office-js-helpers-notification > button') as HTMLButtonElement)
      .onclick = () => notificationDiv.parentNode.removeChild(notificationDiv);
  }
}

export function _parseNotificationParams(params: any[]): IMessageBannerParams {
  if (params == null) {
    return null;
  }

  const [body, title, type] = params;
  if (body instanceof Error) {
    let details = '';
    const { innerError, stack } = body as any;
    if (innerError) {
      let error = JSON.stringify(innerError.debugInfo || innerError, null, DEFAULT_WHITESPACE);
      details += `Inner Error: \n${error}\n`;
    }
    if (stack) {
      details += `Stack Trace: \n${body.stack}\n`;
    }
    return {
      message: body.message,
      title: title || body.name,
      type: 'error',
      details: details
    };
  }
  else {
    return {
      message: stringify(body),
      title,
      type: type || 'default',
      details: null
    };
  }
}

ui / stringify.ts

export function stringify(value: any): string { 
  // JSON.stringify of undefined will return undefined rather than 'undefined'
  if (value === undefined) {
    return 'undefined';
  }

  // Don't JSON.stringify strings, we don't want quotes in the output
  if (typeof value === 'string') {
    return value;
  }

  // Use toString() only if it's useful
  if (typeof value.toString === 'function' && value.toString() !== '[object Object]') {
    return value.toString();
  }

  // Otherwise, JSON stringify the object
  return JSON.stringify(value, null, 2);
}

© 2023 Better Solutions Limited. All Rights Reserved. © 2023 Better Solutions Limited TopPrevNext