js/src/services/api.service.js

Summary

Maintainability
A
0 mins
Test Coverage
import $ from 'external/jquery';
import atk from 'atk';
import lodashEscape from 'lodash/escape';

/**
 * Handle Fomantic-UI API functionality throughout the app.
 */
class ApiService {
    constructor() {
        this.afterSuccessCallbacks = [];
    }

    getDefaultFomanticSettings() {
        return [
            {},
            {
                // override supported via "../setup-fomantic-ui.js", both callbacks are always evaluated
                successTest: this.successTest,
                onFailure: this.onFailure,
                onSuccess: this.onSuccess,
                onAbort: this.onAbort,
                onError: this.onError,
            },
        ];
    }

    /**
     * Execute JS code.
     *
     * @param {object} thisObject
     * @param {string} code
     */
    evalJsCode(thisObject, code) {
        (function () {
            eval('\'use strict\'; (() => {' + code + '})()'); // eslint-disable-line no-eval
        }).call(thisObject);
    }

    /**
     * Check server response.
     *
     * @returns {boolean}
     */
    successTest(response) {
        if (response.success) {
            return true;
        }

        return false;
    }

    onAbort(message) {
        console.warn(message);
    }

    onError(message) {
        console.warn(message);
    }

    /**
     * Handle a server response success
     * If successTest return true, then this function is call;
     * Within this function "this" is place in proper context
     * and allow us to properly eval the response.
     * Furthermore, the dom element responsible of the api call is returned if needed.
     *
     * Change in response object property from eval to atkjs.
     * Under certain circumstance, response.eval was run and execute prior to onSuccess eval,
     * thus causing some code to be running twice.
     * To avoid conflict, property name in response was change from eval to atkjs.
     * Which mean response.atkjs now contains code to be eval.
     */
    onSuccess(response) {
        try {
            if (response.success) {
                if (response.html && response.id) {
                    const $target = $('#' + response.id);
                    if ($target.length !== 1) {
                        throw new Error('Target DOM element not found');
                    }

                    let responseBody = new DOMParser().parseFromString('<body>' + response.html.trim() + '</body>', 'text/html').body;
                    const responseElement = responseBody.childNodes[0];
                    if (responseBody.childNodes.length !== 1 || responseElement.id !== response.id) {
                        throw new Error('Unexpected HTML response');
                    }
                    responseBody = null;

                    // prevent modal duplication
                    const $modalsContainers = $('body > .ui.dimmer.modals.page, body > .atk-side-panels');
                    $(responseElement).find('.ui.modal[id], .atk-right-panel[id]').each((i, e) => {
                        $modalsContainers.find('#' + e.id).remove();
                    });

                    if ($target.hasClass('ui modal') || $target.hasClass('atk-right-panel')) {
                        $.each([...$target[0].childNodes], (i, node) => {
                            if (node instanceof Element && node.classList.contains('ui') && node.classList.contains('dimmer')) {
                                return;
                            }

                            $(node).remove();
                        });
                        $.each([...responseElement.childNodes], (i, node) => {
                            if (node instanceof Element && node.classList.contains('ui') && node.classList.contains('dimmer')) {
                                return;
                            }

                            $target.append(node);
                        });
                    } else {
                        $target.replaceWith(response.html);
                    }
                }

                if (response.atkjs) {
                    atk.apiService.evalJsCode(this, response.atkjs);
                }

                if (atk.apiService.afterSuccessCallbacks.length > 0) {
                    const callbacks = atk.apiService.afterSuccessCallbacks;
                    for (const callback of callbacks) {
                        atk.apiService.evalJsCode(this, callback);
                    }
                    atk.apiService.afterSuccessCallbacks.splice(0);
                }
            } else if (response.isServiceError) {
                throw new Error(response.message);
            }
        } catch (e) {
            atk.apiService.showErrorModal(atk.apiService.getErrorHtml('API JavaScript Error', e.message));
        }
    }

    /**
     * Accumulate callbacks function to run after onSuccess.
     * Callback is a string containing code to be eval.
     */
    onAfterSuccess(callback) {
        this.afterSuccessCallbacks.push(callback);
    }

    /**
     * Handle a server response failure.
     */
    onFailure(response) {
        // if JSON is returned, it should contain the HTML error in message property
        if (Object.prototype.hasOwnProperty.call(response, 'success') && !response.success) {
            atk.apiService.showErrorModal(response.message);
        } else {
            atk.apiService.showErrorModal(
                atk.apiService.getErrorHtml('API Server Error', '')
                    + '<div><pre style="margin-bottom: 0px;"><code style="display: block; padding: 1em; color: #adbac7; background: #22272e;">'
                    + lodashEscape(response)
                    + '</code></pre></div>'
            );
        }
    }

    /**
     * Make our own ajax request test if need to.
     * if a plugin must call $.ajax or $.getJson directly instead of Fomantic-UI api,
     * we could send the JSON response to this.
     */
    atkProcessExternalResponse(response, content = null) {
        if (response.success) {
            this.onSuccess(response, content);
        } else {
            this.onFailure(response);
        }
    }

    /**
     * Will wrap Fomantic-UI api call into a Promise.
     * Can be used to retrieve JSON data from the server.
     * Using this will bypass regular successTest i.e. any
     * atkjs (JavaScript) return from server will not be evaluated.
     *
     * Make sure to control the server output when using
     * this function. It must at least return { success: true } in order for
     * the Promise to resolve properly, will reject otherwise.
     *
     * ex: $app->terminateJson(['success' => true, 'data' => $data]);
     *
     * @param   {string}       url      the URL to fetch data
     * @param   {object}       settings the Fomantic-UI api settings object.
     *
     * @returns {Promise<any>}
     */
    suiFetch(url, settings = {}, el = 'body') {
        const $el = $(el);
        const apiSettings = Object.assign(settings);

        if (!('on' in apiSettings)) {
            apiSettings.on = 'now';
        }

        if (!('method' in apiSettings)) {
            apiSettings.method = 'GET';
        }

        apiSettings.url = url;

        return new Promise((resolve, reject) => {
            apiSettings.onFailure = function (r) {
                atk.apiService.onFailure(r);
                reject(r);
            };
            apiSettings.onSuccess = function (r, e) {
                resolve(r);
            };
            $el.api(apiSettings);
        });
    }

    /**
     * Display App error in a Fomantic-UI modal.
     */
    showErrorModal(contentHtml) {
        if (atk.modalService.modals.length > 0) {
            const $modal = $(atk.modalService.modals.at(-1));
            if ($modal.data('closeOnLoadingError')) {
                $modal.removeData('closeOnLoadingError').modal('hide');
            }
        }

        // catch application error and display them in a new modal window
        const m = $('<div>')
            .appendTo('body')
            .addClass('ui scrolling modal')
            .css('padding', '1em')
            .html(contentHtml);
        m.data('needRemove', true).modal().modal('show');
    }

    getErrorHtml(titleHtml, messageHtml) {
        return `<div class="ui negative icon message" style="margin: 0px;">
              <i class="warning sign icon"></i>
              <div class="content">
                <div class="header">${titleHtml}</div>
                <div>${messageHtml}</div>
              </div>
            </div>`;
    }
}

export default Object.freeze(new ApiService());