UNPKG

@eclipse-scout/core

Version:
653 lines (585 loc) 25.6 kB
/* * Copyright (c) 2010, 2025 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 */ import { access, AjaxError, AjaxSettings, AppEventMap, aria, codes, config, Desktop, Device, ErrorHandler, ErrorInfo, Event, EventEmitter, EventHandler, EventListener, EventMapOf, FontDescriptor, fonts, InitModelOf, Locale, locales, logging, numbers, ObjectFactory, objects, scout, Session, SessionModel, texts, uiPreferences, webstorage, Widget } from './index'; import $ from 'jquery'; let instance: App = null; let listeners: EventListener[] = []; let bootstrappers: (() => JQuery.Promise<void>)[] = []; export interface AppModel { /** * Object to configure the session, see {@link Session.init} for the available options. */ session?: SessionModel; bootstrap?: AppBootstrapOptions; /** * True, to check whether the browser fulfills all requirements to run the application. If the check fails, a notification is shown to warn the user about his old browser. Default is true. */ checkBrowserCompatibility?: boolean; version?: string; } export type JsonErrorResponse = { code: number; message: string; }; export type JsonErrorResponseContainer = { url: string; error: JsonErrorResponse; }; export interface AppBootstrapOptions { /** * Fonts that should be preloaded, which means the initialization will not continue until the fonts are loaded. * If no fonts are specified, the list of fonts to preload is automatically calculated from the available CSS "@font-face" definitions. This is the default.<br> * To disable preloading entirely, set fonts to an empty array. */ fonts?: FontDescriptor[]; /** * URL or multiple URLs pointing to a resource providing texts that will be available through {@link texts}. */ textsUrl?: string | string[]; /** * URL pointing to a resource providing locale information processed by {@link locales}. */ localesUrl?: string; /** * URL pointing to a resource providing codes that will be available through {@link codes}. */ codesUrl?: string; /** * URL pointing to a resource providing permissions that will be available through {@link access}. * * @see PermissionCollectionModel */ permissionsUrl?: string; /** * URL pointing to a resource providing config properties that will be available through {@link config}. */ configUrl?: string | string[]; /** * Custom functions that needs to be executed while bootstrapping. * All custom and default bootstrappers need to finish successfully before the app will proceed with the initialization. */ bootstrappers?: (() => JQuery.Promise<void>)[]; } export class App extends EventEmitter { static addListener<K extends string & keyof EventMapOf<App>>(type: K, handler: EventHandler<(EventMapOf<App>)[K] & Event<App>>): EventListener { let listener = { type: type, func: handler }; if (instance) { instance.events.addListener(listener); } else { listeners.push(listener); } return listener; } /** * Adds a function that needs to be executed while bootstrapping. * @see AppModel.bootstrappers */ static addBootstrapper(bootstrapper: () => JQuery.Promise<void>) { if (bootstrappers.indexOf(bootstrapper) > -1) { throw new Error('Bootstrapper is already registered.'); } bootstrappers.push(bootstrapper); } /** * The response of a successful ajax call with status 200 may contain a {@link JsonErrorResponse}. * This method detects this case and throws an error containing the error details of the response together with the given request url. * @throws JsonErrorResponseContainer * @returns the given data as it is if it does not contain an error */ static handleJsonError(url: string, data: any): any { if (data?.error) { // The result may contain a json error (e.g. session timeout) -> abort processing throw { error: data.error, url: url }; } return data; } static get(): App { return instance; } protected static _set(newApp: App) { if (instance) { $.log.isWarnEnabled() && $.log.warn('Overwriting already existing App "' + instance + '" with "' + newApp + '".'); } instance = newApp; } declare model: AppModel; declare eventMap: AppEventMap; declare self: App; remote: boolean; initialized: boolean; sessions: Session[]; errorHandler: ErrorHandler; version: string; bootstrappers: (() => JQuery.Promise<void>)[]; protected _loadingTimeoutId: number; constructor() { super(); this.remote = false; this.initialized = false; this.sessions = []; this.bootstrappers = []; this._loadingTimeoutId = null; // register the listeners which were added to scout before the app is created listeners.forEach(listener => { this.addListener(listener); }); listeners = []; App._set(this); this.errorHandler = this._createErrorHandler(); } /** * Main initialization function. * * Calls {@link _prepare}, {@link _bootstrap} and {@link _init}.<br> * At the initial phase the essential objects are initialized, those which are required for the next phases like logging and the object factory.<br> * During the bootstrap phase additional scripts may get loaded required for a successful session startup.<br> * The actual initialization does not get started before these bootstrap scripts are loaded. */ init(options?: InitModelOf<this>): JQuery.Promise<any> { options = options || {} as InitModelOf<this>; return this._prepare(options) .then(this._bootstrap.bind(this, options.bootstrap)) .then(this._init.bind(this, options)) .then(this._initDone.bind(this, options)) .catch(this._fail.bind(this, options)); } /** * Initializes the logging framework and the object factory. * This happens at the prepare phase because all these things should be available from the beginning. */ protected _prepare(options: AppModel): JQuery.Promise<any> { return this._prepareLogging(options) .done(() => { this._prepareEssentials(options); this._prepareDone(options); }); } protected _prepareEssentials(options: AppModel) { ObjectFactory.get().init(); } protected _prepareDone(options: AppModel) { this.trigger('prepare', { options: options }); $.log.isDebugEnabled() && $.log.debug('App prepared'); } protected _prepareLogging(options: AppModel): JQuery.Promise<JQuery> { return logging.bootstrap(); } /** * Executes the bootstrappers. * * The actual session startup begins only when all promises of the bootstrappers are completed. * This gives the possibility to dynamically load additional scripts or files which are mandatory for a successful application startup. */ protected _bootstrap(options: AppBootstrapOptions): JQuery.Promise<any> { options = options || {}; options.bootstrappers = options.bootstrappers || []; this.bootstrappers = [ ...this._defaultBootstrappers(options), ...options.bootstrappers, ...this.bootstrappers, ...bootstrappers ].filter(bootstrapper => !!bootstrapper); return $.promiseAll(this._doBootstrap()) .catch(this._bootstrapFail.bind(this, options)) .then(this._bootstrapDone.bind(this, options)); // BootstrapDone must only be executed if there are no boostrap errors } protected _defaultBootstrappers(options: AppBootstrapOptions): (() => JQuery.Promise<void>)[] { return [ Device.get().bootstrap.bind(Device.get()), fonts.bootstrap.bind(fonts, options.fonts), locales.bootstrap.bind(locales, options.localesUrl), texts.bootstrap.bind(texts, options.textsUrl), codes.bootstrap.bind(codes, options.codesUrl), access.bootstrap.bind(access, options.permissionsUrl), config.bootstrap.bind(config, options.configUrl), uiPreferences.bootstrap.bind(uiPreferences) ]; } protected _doBootstrap(): JQuery.Promise<any>[] { return this.bootstrappers.map(bootstrapper => bootstrapper()); } protected _bootstrapDone(options: AppBootstrapOptions) { webstorage.removeItemFromSessionStorage('scout:bootstrapErrorPageReload'); this.trigger('bootstrap', { options: options }); $.log.isDebugEnabled() && $.log.debug('App bootstrapped'); } /** * @param vararg may either be * - an {@link AjaxError} for requests executed with {@link ajax} or {@link AjaxCall} * - a {@link JQuery.jqXHR} for requests executed with {@link $.ajax}. The parameters `textStatus`, `errorThrown` and `requestOptions` are only set in this case. * - a {@link JsonErrorResponseContainer} if a successful response contained a {@link JsonErrorResponse} which was transformed to an error (e.g. using {@link App.handleJsonError}). */ protected _bootstrapFail(options: AppBootstrapOptions, vararg: AjaxError | JQuery.jqXHR | JsonErrorResponseContainer, textStatus?: JQuery.Ajax.ErrorTextStatus, errorThrown?: string, requestOptions?: AjaxSettings): JQuery.Promise<any> { $.log.isInfoEnabled() && $.log.info('App bootstrap failed'); // If one of the bootstrap ajax call fails due to a session timeout, the index.html is probably loaded from cache without asking the server for its validity. // Normally, loading the index.html should already return a session timeout, but if it is loaded from the (back button) cache, no request will be done and therefore no timeout can be returned. // The browser is allowed to display a page when navigating back without issuing a request even though cache-headers are set to must-revalidate. // The only way to prevent it would be the no-store header but then pressing back would always reload the page and not only on a session timeout. // Sometimes the JavaScript and therefore the ajax calls won't be executed in case the page is loaded from that cache, but sometimes they will nevertheless (we don't know the reasons). // So, if it that happens, the server will either return a session timeout or a status 401 (Unauthorized) and the best thing we can do is to reload the page hoping a request for the index.html // will be done which eventually will be forwarded to the login page. // Additionally, requests may fail due to other various reasons, e.g. Chrome may report ERR_NETWORK_CHANGED or ERR_CERT_VERIFER_CHANGED. // Since a page reload normally solves these issues as well, the reload is done on any error not just session timeouts. let {url, message} = this._analyzeBootstrapError(vararg, textStatus, errorThrown, requestOptions); $.log.isInfoEnabled() && $.log.info(`Error for resource ${url}. Reloading page...`); if (webstorage.getItemFromSessionStorage('scout:bootstrapErrorPageReload')) { // Prevent loop in case reloading did not solve the problem $.log.isWarnEnabled() && $.log.warn('Prevented automatic reload, startup will likely fail.'); webstorage.removeItemFromSessionStorage('scout:bootstrapErrorPageReload'); throw new Error(`Bootstrap resource ${url} could not be loaded: ${message}`); } webstorage.setItemToSessionStorage('scout:bootstrapErrorPageReload', 'true'); scout.reloadPage(); // Make sure promise will be rejected with all original arguments so that it can be eventually handled by this._fail // eslint-disable-next-line prefer-rest-params let args = objects.argumentsToArray(arguments).slice(1); return $.rejectedPromise(...args); } protected _analyzeBootstrapError(vararg: AjaxError | JQuery.jqXHR | JsonErrorResponseContainer, textStatus?: JQuery.Ajax.ErrorTextStatus, errorThrown?: string, requestOptions?: AjaxSettings) { let ajaxError: AjaxError; let jsonError: JsonErrorResponseContainer; if (vararg instanceof AjaxError) { ajaxError = vararg; } else if ($.isJqXHR(vararg)) { ajaxError = new AjaxError({jqXHR: vararg, textStatus: textStatus, errorThrown: errorThrown, requestOptions: requestOptions}); } else if (objects.isObject(vararg) && vararg.error) { jsonError = vararg; } let url; let message; if (ajaxError) { // AJAX error // If a resource returns 401 (unauthorized) it is likely a session timeout. // This may happen for REST resources or if a reverse proxy returned the response url = ajaxError.requestOptions?.url || ''; message = `${ajaxError.errorDo?.message || ''}`; let httpStatus = `${this.errorHandler.formatAjaxStatus(ajaxError.jqXHR, ajaxError.errorThrown)}`; if (!message.includes(httpStatus)) { message = `${message} [${this.errorHandler.formatAjaxStatus(ajaxError.jqXHR, ajaxError.errorThrown)}]`.trim(); } } else if (jsonError) { // Json based error // Json errors (normally processed by Session.js) are returned with http status 200 url = jsonError.url; message = `${jsonError.error.message} [Code ${jsonError.error.code}]`; } return {url, message}; } /** * Initializes a session for each html element with class '.scout' and stores them in scout.sessions. */ protected _init(options: InitModelOf<this>): JQuery.Promise<any> { options = options || {} as InitModelOf<this>; this.setLoading(true); let compatibilityPromise = this._checkBrowserCompatibility(options); if (compatibilityPromise) { this.setLoading(false); return compatibilityPromise.then(newOptions => this._init(newOptions)); } this._initVersion(options); this._prepareDOM(); this._installErrorHandler(); this._installGlobalMouseDownInterceptor(); this._installSyntheticActiveStateHandler(); this._ajaxSetup(); this._installExtensions(); this._triggerInstallExtensions(); return this._load(options) .then(this._loadSessions.bind(this, options.session)); } /** * Maybe implemented to load data from a server before the desktop is created. * @returns promise which is resolved after the loading is complete */ protected _load(options: AppModel): JQuery.Promise<any> { return $.resolvedPromise(); } protected _checkBrowserCompatibility(options: AppModel): JQuery.Promise<InitModelOf<this>> | null { let device = Device.get(); $.log.isInfoEnabled() && $.log.info('Detected browser ' + device.browser + ' version ' + device.browserVersion); if (!scout.nvl(options.checkBrowserCompatibility, true) || device.isSupportedBrowser()) { // No check requested or browser is supported return; } let deferred = $.Deferred(); let newOptions = objects.valueCopy(options); newOptions.checkBrowserCompatibility = false; $('.scout').each(function() { let $entryPoint = $(this); let $box = $entryPoint.appendDiv(); $box.load('unsupported-browser.html', () => { $box.find('button').on('click', () => { $box.remove(); deferred.resolve(newOptions); }); }); }); return deferred.promise(); } setLoading(loading: boolean) { if (loading) { this._loadingTimeoutId = setTimeout(() => { // Don't start loading if a desktop is already rendered to prevent flickering when the loading will be set to false after app initialization finishes if (!this.sessions.some(session => session.desktop && session.desktop.rendered)) { this._renderLoading(); } }, 200); } else { clearTimeout(this._loadingTimeoutId); this._loadingTimeoutId = null; this._removeLoading(); } } protected _renderLoading() { let $body = $('body'), $loadingRoot = $body.children('.application-loading-root'); if (!$loadingRoot.length) { $loadingRoot = $body.appendDiv('application-loading-root') .addClass('application-loading-root') .fadeIn(); } aria.role($loadingRoot, 'alert'); aria.screenReaderOnly($loadingRoot.appendDiv('text').attr('lang', 'en-US').text('Loading')); this._renderLoadingElement($loadingRoot, 'application-loading01'); this._renderLoadingElement($loadingRoot, 'application-loading02'); this._renderLoadingElement($loadingRoot, 'application-loading03'); } protected _renderLoadingElement($loadingRoot: JQuery, cssClass: string) { if ($loadingRoot.children('.' + cssClass).length) { return; } $loadingRoot.appendDiv(cssClass).hide() .fadeIn(); } protected _removeLoading() { let $loadingRoot = $('body').children('.application-loading-root'); // the fadeout animation only contains a to-value and no from-value // therefore set the current value to the elements style $loadingRoot.css('opacity', $loadingRoot.css('opacity')); // Add animation listener before adding the classes to ensure the listener will always be triggered even while debugging $loadingRoot.oneAnimationEnd(() => $loadingRoot.remove()); if ($loadingRoot.css('opacity') === '1') { $loadingRoot.addClass('fadeout and-more'); } else { $loadingRoot.addClass('fadeout'); } if (!Device.get().supportsCssAnimation()) { // fallback for old browsers that do not support the animation-end event $loadingRoot.remove(); } } protected _initVersion(options: AppModel) { this.version = scout.nvl( this.version, options.version, $('scout-version').data('value')); } protected _prepareDOM() { scout.prepareDOM(document); } protected _installGlobalMouseDownInterceptor() { scout.installGlobalMouseDownInterceptor(document); } protected _installSyntheticActiveStateHandler() { scout.installSyntheticActiveStateHandler(document); } /** * Installs a global error handler. * * Note: we do not install an error handler on popup-windows because everything is controlled by the main-window * so exceptions will also occur in that window. This also means, the fatal message-box will be displayed in the * main-window, even when a popup-window is opened and active. * * Caution: The error.stack doesn't look the same in different browsers. Chrome for instance puts the error message * on the first line of the stack. Firefox does only contain the stack lines, without the message, but in return * the stack trace is much longer :) */ protected _installErrorHandler() { window.onerror = this.errorHandler.windowErrorHandler; // FIXME bsh, cgu: use ErrorHandler to handle unhandled promise rejections. Just replacing jQuery.Deferred.exceptionHook(error, stack) does not work // because it is called on every exception and not only on unhandled. // https://developer.mozilla.org/en-US/docs/Web/API/Window/unhandledrejection_event would be exactly what we need, but jQuery does not support it. // Bluebird has a polyfill -> can it be ported to jQuery? } protected _createErrorHandler(opts?: InitModelOf<ErrorHandler>): ErrorHandler { opts = $.extend({}, opts); return scout.create(ErrorHandler, opts); } /** * Uses the object returned by {@link _ajaxDefaults} to set up ajax. The values in that object are used as default values for every ajax call. */ protected _ajaxSetup() { let ajaxDefaults = this._ajaxDefaults(); if (ajaxDefaults) { $.ajaxSetup(ajaxDefaults); } } /** * Returns the defaults for every ajax call. You may override it to set custom defaults. * By default {@link _beforeAjaxCall} is assigned to the beforeSend method. * * Note: This will affect every ajax call, so use it with care! See also the advice on https://api.jquery.com/jquery.ajaxsetup/. */ protected _ajaxDefaults(): AjaxSettings { return { beforeSend: this._beforeAjaxCall.bind(this) }; } /** * Called before every ajax call. Sets the header X-Scout-Correlation-Id. * * Maybe overridden to set custom headers or to execute other code which should run before an ajax call. */ protected _beforeAjaxCall(request: JQuery.jqXHR, settings: AjaxSettings) { request.setRequestHeader('X-Scout-Correlation-Id', numbers.correlationId()); request.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); // explicitly add here because jQuery only adds it automatically if it is no crossDomain request if (this.sessions[0] && this.sessions[0].ready) { request.setRequestHeader('Accept-Language', this.sessions[0].locale.languageTag); } } protected _loadSessions(options: SessionModel): JQuery.Promise<any> { options = options || {}; let promises = []; $('.scout').each((i, elem) => { let $entryPoint = $(elem); options.portletPartId = options.portletPartId || $entryPoint.data('partid') || '0'; let promise = this._loadSession($entryPoint, options); promises.push(promise); }); return $.promiseAll(promises); } /** * @returns promise which is resolved when the session is ready */ protected _loadSession($entryPoint: JQuery, model: Omit<SessionModel, '$entryPoint'>): JQuery.Promise<any> { let sessionModel: InitModelOf<Session> = {$entryPoint: $entryPoint}; let options = $.extend({}, model, sessionModel); options.locale = options.locale || this._loadLocale(); let session = this._createSession(options); this.sessions.push(session); // TODO [7.0] cgu improve this, start must not be executed because it currently does a server request let desktop = this._createDesktop(session.root); this._triggerDesktopReady(desktop); session.render(() => { session._renderDesktop(); // Ensure layout is valid (explicitly layout immediately and don't wait for setTimeout to run to make layouting invisible to the user) session.layoutValidator.validate(); session.focusManager.validateFocus(); session.ready = true; this._triggerSessionReady(session); $.log.isInfoEnabled() && $.log.info('Session initialized. Detected ' + Device.get()); }); return $.resolvedPromise(); } /** @internal */ _triggerDesktopReady(desktop: Desktop) { this.trigger('desktopReady', { desktop: desktop }); } /** @internal */ _triggerSessionReady(session: Session) { this.trigger('sessionReady', { session: session }); } protected _createSession(options: InitModelOf<Session>): Session { return scout.create(Session, options); } protected _createDesktop(parent: Widget): Desktop { return scout.create(Desktop, { parent: parent }); } /** * @returns the locale to be used when no locale is provided as session option. By default, the navigators locale is used. */ protected _loadLocale(): Locale { return locales.getNavigatorLocale(); } protected _initDone(options: AppModel) { this.initialized = true; this.setLoading(false); this.trigger('init', { options: options }); $.log.isInfoEnabled() && $.log.info('App initialized'); } protected _fail(options: AppModel, error: any, ...args: any[]): JQuery.Promise<any> { $.log.error('App initialization failed.'); this.setLoading(false); let promises = []; if (webstorage.getItemFromSessionStorage('scout:bootstrapErrorPageReload')) { // Do not append a message, page is about to be reloaded } else if (this.sessions.length === 0) { promises.push(this.errorHandler.handle(error, ...args) .then(errorInfo => { this._appendStartupError($('body'), errorInfo); })); } else { // Session.js may already display a fatal message box // -> don't handle the error again and display multiple error messages this.sessions .filter(session => !session.ready && !session.isFatalMessageShown()) .forEach(session => { session.$entryPoint.empty(); const errorHandler = this._createErrorHandler({session: session}); const promise = errorHandler.analyzeError(error).then(info => { info.showAsFatalError = true; return errorHandler.handleErrorInfo(info); }); promises.push(promise); }); } this.trigger('fail', {error}); // Reject with original rejection arguments return $.promiseAll(promises).then(errorInfo => $.rejectedPromise(error, ...args)); } protected _appendStartupError($parent: JQuery, errorInfo: ErrorInfo) { let $error = $parent.appendDiv('startup-error'); $error.appendDiv('startup-error-title').text('The application could not be started'); let message = errorInfo.message; if (errorInfo?.httpStatus) { message = this.errorHandler.getMessageBodyForHttpStatus(errorInfo?.httpStatus); } if (message) { $error.appendDiv('startup-error-message').text(message); } } /** * Override this method to install extensions to Scout objects. Since the extension feature replaces functions * on the prototype of the Scout objects you must apply 'function patches' to Scout framework or other code before * the extensions are installed. * * The default implementation does nothing. */ protected _installExtensions() { // NOP } /** * @see AppEventMap#installExtensions */ protected _triggerInstallExtensions() { this.trigger('installExtensions'); } }