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