API Docs for: 2.0.20133.2
Show:

File: src\internal\ui\uiComponent.ts

///<reference path="../common/constants.ts" />
///<reference path="../proxy/sdkClientProxyImp.ts" />
///<reference path="../telemetry/telemetry.ts" />
///<reference path="../utils/browser.ts" />
///<reference path="../utils/guid.ts" />
///<reference path="../utils/object.ts" />
///<reference path="../utils/url.ts" />
///<reference path="uiConfig.ts" />

namespace internal.ui {

    const DEFAULT_WINDOW_WIDTH = 600;
    const DEFAULT_WINDOW_HEIGHT = 800;
    export const RENDER_OPERATION_NAME = "ui.render";

    /**
     * Refer to https://www.typescriptlang.org/docs/handbook/generics.html for usage of Generics in TypeScript
     * Render a UI component with Qos event enabled
     * @method renderComponent
     * @private
     * @param {TComponent} component Generic type that extends the UIComponent class
     * @param {TConfig} config Generic type that extends the UIConfig class
     * @param {string} componentType A string defined by each UI component to represent its type. e.g. "Chat", "Callback", "VirtualAgent", etc ...
     * @return {Promise}
     */
    export function renderComponent<TComponent extends UIComponent, TConfig extends UIConfig>(
        component: { new (config: TConfig): TComponent },
        config: TConfig,
        componentType: string): JQueryPromise<any> {
        return telemetry.captureIncomingRequestAsync(
            (qos: telemetry.QosEventData): JQueryPromise<any> => {
                return new component(config).render();
            },
            RENDER_OPERATION_NAME,
            componentType,
            config.uiInfo);
    }

    export interface WindowProperties {
        width: number;
        height: number;
        resizable: number; // 1 or 0
        scrollbars: number; // 1 or 0
    }

    export interface ComponentSDK { }

    export abstract class UIComponent {

        public config: UIConfig;
        public id: string;
        public loadTimeoutMs: number;
        public reportsHeightChanges: boolean;

        public proxy: proxy.SdkClientProxy;
        public iframe: HTMLIFrameElement;
        public popup: Window;
        protected renderDeferred: JQueryDeferred<any>;
        protected renderTimeout: number;

        // Abstract methods which MUST be overridden by every UI Component class
        public abstract getComponentUrl(): string;
        public abstract getComponentSdk(): ComponentSDK;
        public abstract getXframeProxyUrl(): string;

        constructor(config: UIConfig, loadTimeoutMs?: number, reportsHeightChanges?: boolean) {
            if (!config || !config.uiInfo || !config.uiInfo.type) {
                throw new Error("config does not contain the type of UI to be rendered");
            }

            this.config = utils.cloneObject({}, config);
            this.id = utils.generateUniqueId();
            this.loadTimeoutMs = loadTimeoutMs || 0; // 0 means renderUI will not wait for LOADED event from the UI component, and always resolves as success
            this.reportsHeightChanges = !!reportsHeightChanges;
        }

        public render(): JQueryPromise<any> {
            let cV = internal.telemetry.getCorrelationVector();
            let sessionId = SdkConfig.current.sessionId;
            let params: any = {};
            params[`${internal.SDK_QUERY_PARAM_NAME.APP_ID}`] = SdkConfig.current.appId;
            params[`${internal.SDK_QUERY_PARAM_NAME.PARTNER_ID}`] = SdkConfig.current.partnerId;
            params[`${internal.SDK_QUERY_PARAM_NAME.COMPONENT_ID}`] = this.id;
            params[`${internal.SDK_QUERY_PARAM_NAME.TARGET_ORIGIN}`] = utils.getCurrentOrigin();
            cV && (params[`${internal.SDK_QUERY_PARAM_NAME.MS_CV}`] = cV);
            sessionId && (params[`${internal.SDK_QUERY_PARAM_NAME.SESSION_ID}`] = sessionId);

            // TODO cheliu: remove the following line after all service backend move to the new sdk integration library
            params[`${internal.SDK_QUERY_PARAM_NAME.HOST_TYPE}`] = this.config.uiInfo.type;

            let listeners = this.getEventListeners();

            // add LOADED and HEIGHT_CHANGED event handlers for all ui components if supported
            if (this.loadTimeoutMs > 0) {
                listeners[<any>InternalEvent.LOADED] = (e: Event) => loadedHandler(this.renderDeferred, this.renderTimeout, this);
            }

            if (this.reportsHeightChanges) {
                listeners[<any>InternalEvent.HEIGHT_CHANGED] = (e: Event) => heightChangeHandler(this.iframe, e.data);
            }

            // add event listeners for signInRequest
            listeners[<any>CommonEvent.SIGNIN_REQUEST] = (e: Event) => {
                // delegated auth request is handled internally
                if (e.data && e.data.length > 0 && e.data[0].type === internal.ui.AuthType.DELEGATED) {
                    delegatedAuthHandler(this);
                }
                else if (this.config.onSignInRequest instanceof Function) {
                    window.setTimeout(() => this.config.onSignInRequest(e), 0);
                }
            };

            switch (this.config.uiInfo.type) {
                case HostType.IFRAME:
                    this.renderDeferred = renderIFrame(this, params, listeners);
                    break;
                case HostType.POPUP:
                    this.renderDeferred = renderPopup(this, params, listeners);
                    break;
                default:
                    return jQuery.Deferred().reject(`HostType: ${this.config.uiInfo.type} is not supported`);
            }

            if (this.loadTimeoutMs > 0) {
                this.renderTimeout = window.setTimeout(
                    () => {
                        if (this.renderDeferred.state() === PROMISE_STATE_PENDING) {
                            this.renderDeferred.reject(`Load timeout expired: ${this.getComponentUrl()}`);
                        }
                    },
                    this.loadTimeoutMs);
            }
            else if (this.renderDeferred.state() === PROMISE_STATE_PENDING) {
                // Component does not support loaded event
                // Don't bother listening to onload event since it fires very late, and fires even on most errors
                this.renderDeferred.resolve(this.getComponentSdk());
            }

            return this.renderDeferred.promise();
        }

        // Override this method in the UI component to define default window size
        public getDefaultWindowProperties(): WindowProperties {
            return { width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT, resizable: 1, scrollbars: 1 };
        }

        protected getEventListeners(): Map<EventListener> {
            return UIComponent.getEventListeners(this.config);
        }

        protected static getEventListeners(config: any, prefix?: string): Map<EventListener> {
            // iterate through the config object properties and add event listener if
            // the property name starts with "on" and the property is a function
            let listeners: Map<EventListener> = {};
            for (let prop in config) {
                if (typeof prop === "string" &&
                    prop.length > 2 &&
                    prop.slice(0, 2) === "on" &&
                    config.hasOwnProperty(prop) &&
                    config[prop] instanceof Function) {
                    listeners[getEventName(prefix, prop.substr(2))] = config[prop];
                }
            }
            return listeners;
        }
    }

    // Get a DOM element on the page using a jquery selector
    function getElement(containerSelector: string): HTMLElement {
        let elements = jQuery(containerSelector);
        return elements.length > 0 ? elements[0] : undefined;
    }

    // Creates an iframe element that can be attached to the DOM
    function createIframeElement(src: string, id: string, params: any, style?: any, title?: string): HTMLIFrameElement {
        let iframe = document.createElement("iframe");
        iframe.src = utils.appendParams(src, params, true);
        iframe.id = id;
        iframe.style.display = (style && style.display) || "block"; // Avoids inline whitespace that causes scrollbars at 100%
        iframe.style.height = (style && style.height) || "100%";
        iframe.style.width = (style && style.width) || "100%";
        iframe.title = title || "";
        iframe.frameBorder = "0";
        return iframe;
    }

    // get window height and width from (in order of preference):
    // - the user-specified config
    // - the default config
    function getWindowProperties(config: UIConfig, defaultProperties: WindowProperties): string {
        let width = config && config.uiInfo && config.uiInfo.width;
        let height = config && config.uiInfo && config.uiInfo.height;

        if (!(width >= 0 && height >= 0)) {
            width = defaultProperties.width;
            height = defaultProperties.height;
        }

        // https://developer.mozilla.org/en-US/docs/Web/API/Window/open
        // windowFeatures Optional
        //     A DOMString containing a comma- separated list of window features given with their corresponding values in the form "name=value".
        //     These features include options such as the window's default size and position, whether or not to include scroll bars, and so forth. 
        //     *** There must be no whitespace in the string. ***
        let windowFeaturesString = `resizable=${defaultProperties.resizable},scrollbars=${defaultProperties.scrollbars}`;

        if (width >= 0 && height >= 0) {
            return `${windowFeaturesString},width=${width},height=${height}`;
        }

        return windowFeaturesString;
    }

    function heightChangeHandler(iframe: HTMLElement, height: number): void {
        if (iframe && height > 0) {
            iframe.style.height = `${height}px`;
        }
    }

    function loadedHandler(deferredRender: JQueryDeferred<ComponentSDK>, timeout: number, uiComponent: UIComponent): void {
        // always send the client config to the ui component after it's loaded.
        uiComponent.proxy.dispatchEvent(<any>InternalEvent.CLIENT_CONFIG_UPDATE, uiComponent.config);

        // only resolve the render() promise on first loaded event
        if (deferredRender.state() === PROMISE_STATE_PENDING) {
            try {
                window.clearTimeout(timeout);
                deferredRender.resolve(uiComponent.getComponentSdk());
            }
            catch (e) {
                deferredRender.reject(`Could not create component SDK. Error: ${e}`);
            }
        }
        else {
            // TODO: Will end up here if the popup window refreshes after sending LOADED event.
        }
    }

    function delegatedAuthHandler(uiComponent: UIComponent) {
        let errorHandler = (message: string, error?: Error): void => {
            console.log(message);
            if (uiComponent.config.onError && (uiComponent.config.onError instanceof Function)) {
                uiComponent.config.onError(
                    {
                        type: <any>CommonEvent.ERROR,
                        id: uiComponent.id,
                        data: {
                            message: message,
                            exception: error
                        }
                    });
            }
        };

        try {
            if (uiComponent.proxy && uiComponent.config.authInfo && uiComponent.config.authInfo.token) {
                uiComponent.proxy.dispatchEvent(<any>CommonEvent.SIGNIN_RESPONSE, { token: uiComponent.config.authInfo.token });
            }
            else {
                errorHandler("delegated auth token must be provided!");
            }
        }
        catch (e) {
            errorHandler(`Could not handle delegated auth request. Error: ${utils.stringify(e)}`, e);
        }
    }

    function createProxy(targetWindow: Window, componentId: string, componentUrl: string, listeners: Map<EventListener>, onError: (e: Event) => void): proxy.SdkClientProxy {
        let messageProxy = new proxy.WindowPostMessageProxy(targetWindow, utils.getOriginFromUrl(componentUrl));
        return new proxy.SdkClientProxyImp(componentId, listeners, messageProxy);
    }

    function createXframeProxy(uiComponent: UIComponent, listeners: Map<EventListener>): proxy.SdkClientProxy {
        let xframeStyle: any = {
            display: "none",
            height: "0",
            width: "0"
        };

        let cV = internal.telemetry.getCorrelationVector();
        let sessionId = SdkConfig.current.sessionId;
        let params: any = {};
        params[`${internal.SDK_QUERY_PARAM_NAME.APP_ID}`] = SdkConfig.current.appId;
        params[`${internal.SDK_QUERY_PARAM_NAME.PARTNER_ID}`] = SdkConfig.current.partnerId;
        params[`${internal.SDK_QUERY_PARAM_NAME.COMPONENT_ID}`] = uiComponent.id;
        params[`${internal.SDK_QUERY_PARAM_NAME.TARGET_ORIGIN}`] = utils.getCurrentOrigin();
        cV && (params[`${internal.SDK_QUERY_PARAM_NAME.MS_CV}`] = cV);
        sessionId && (params[`${internal.SDK_QUERY_PARAM_NAME.SESSION_ID}`] = sessionId);

        // TODO cheliu: remove the following line after all service backend move to the new sdk integration library
        params[`${internal.SDK_QUERY_PARAM_NAME.HOST_TYPE}`] = HostType.IFRAME;

        let xframe = createIframeElement(uiComponent.getXframeProxyUrl(), `${uiComponent.id}-xframeproxy`, params, xframeStyle);
        getElement("body").appendChild(xframe);

        // TODO: write qos for xframe proxy page
        return createProxy(xframe.contentWindow, uiComponent.id, uiComponent.getComponentUrl(), listeners, uiComponent.config.onError);
    }

    function renderIFrame(uiComponent: UIComponent, params: any, listeners: Map<EventListener>): JQueryDeferred<any> {
        let deferred = jQuery.Deferred();
        let hostElement = getElement(uiComponent.config.uiInfo.containerSelector);
        if (hostElement) {
            uiComponent.iframe = createIframeElement(uiComponent.getComponentUrl(), uiComponent.id, params, undefined, uiComponent.config.uiInfo.accessibilityTitle);
            hostElement.appendChild(uiComponent.iframe);
            uiComponent.proxy = createProxy(uiComponent.iframe.contentWindow, uiComponent.id, uiComponent.getComponentUrl(), listeners, uiComponent.config.onError);
        } else {
            deferred.reject("Could not find hosting container: " + uiComponent.config.uiInfo.containerSelector);
        }
        return deferred;
    }

    function renderPopup(uiComponent: UIComponent, params: any, listeners: Map<EventListener>): JQueryDeferred<any> {
        let deferred = jQuery.Deferred();
        let windowProperties = getWindowProperties(uiComponent.config, uiComponent.getDefaultWindowProperties());
        let useXframe = utils.isInternetExplorer() && !!uiComponent.getXframeProxyUrl();
        params[`${internal.SDK_QUERY_PARAM_NAME.XFRAME}`] = useXframe;
        let src = utils.appendParams(uiComponent.getComponentUrl(), params, true);

        // IE9 only supports numbers and letters in window name
        // Make sure uiComponent.id doesn't include any space or special characters.
        uiComponent.popup = window.open(src, uiComponent.id, windowProperties);
        uiComponent.proxy = useXframe ?
            createXframeProxy(uiComponent, listeners) :
            createProxy(uiComponent.popup, uiComponent.id, uiComponent.getComponentUrl(), listeners, uiComponent.config.onError);
        return deferred;
    }

    function getEventName(prefix: string, eventName: string) {
        // add prefix for non-common events
        return (prefix && !CommonEvent[<any>eventName]) ? `${prefix + eventName}` : eventName;
    }
}