js/src/services/panel.service.js

Summary

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

/**
 * Panel needs to be reloaded to display different
 * content. This service will take care of this.
 */
class PanelService {
    constructor() {
        this.panels = [];
        this.service = { // needed because of Object.freeze
            currentVisibleId: null, // the current panel id that is in a visible state
            currentParams: null, // URL argument of the current panel
        };
    }

    /**
     * Remove existing panel from service panels and dom.
     */
    removePanel(id) {
        // remove from dom
        // TODO uncomment once "/demos/data-action/jsactions-panel.php" demo does not close itself immediately
        // this.getPropertyValue(id, '$panel').remove();
        const temp = this.panels.filter((panel) => !panel[id]);
        this.panels.splice(0, this.panels.length, ...temp);
    }

    /**
     * Add a panel to this service and
     * initial panel setup.
     *
     * Atk4/ui callback may call this on each callback so
     * we need to make sure it is not add multiple time.
     */
    addPanel(params) {
        // remove existing one
        // can be added by a reload
        if (this.getPropertyValue(params.id, 'id')) {
            this.removePanel(params.id);
        }

        const newPanel = {
            [params.id]: {
                id: params.id,
                $panel: $('#' + params.id),
                visible: params.visible,
                closeSelector: params.closeSelector,
                url: params.url,
                modal: params.modal,
                triggerElement: null,
                triggeredActive: { element: null, css: null },
                warning: { selector: params.warning.selector, trigger: params.warning.trigger },
                clearable: params.clearable,
                loader: { selector: params.loader.selector, trigger: params.loader.trigger },
                hasClickAway: params.hasClickAway,
                hasEscAway: params.hasEscAway,
                modalAction: null,
            },
        };

        // add click handler for closing panel
        newPanel[params.id].$panel.on('click', params.closeSelector, () => {
            this.closePanel(params.id);
        });

        newPanel[params.id].$panel.appendTo($('.atk-side-panels'));

        this.panels.push(newPanel);
    }

    /**
     * Open the panel.
     * Params expected the following arguments:
     * triggered: A string or jQuery object that will triggered panel to open.
     * activeCss: Either an object containing a jQuery selector with a CSS class or CSS class.
     * - As an Object: element: the jQuery selector within the triggered element;
     * -               css:     the css class to applying to the triggered element when panel is open.
     *
     * As a CSS class: the CSS class to applied to the triggered element when panel open.
     *
     * @param {object} params
     */
    openPanel(params) {
        // if no id is provide, then get the first one
        // no id mean the first panel in list
        const panelId = params.openId ?? Object.keys(this.panels[0])[0];
        // save our open param
        this.service.currentParams = params;
        if (this.isSameElement(panelId, params.triggered)) {
            return;
        }
        // first check if current panel can be click away
        if (this.service.currentVisibleId && !this.getPropertyValue(this.service.currentVisibleId, 'hasClickAway')) {
            return;
        }
        this.initOpen(panelId);
    }

    /**
     * Will check if panel can open or reload.
     */
    initOpen(id) {
        if (this.service.currentVisibleId && id !== this.service.currentVisibleId) {
            // trying to open a different panel so close current one if allowed
            if (this.needConfirmation(this.service.currentVisibleId)) {
                // need to ask user
                const $modal = $(this.getPropertyValue(this.service.currentVisibleId, 'modal'));
                $modal.modal('setting', 'onApprove', (e) => {
                    this.doClosePanel(id);
                });
                $modal.modal('show');
            } else {
                this.doClosePanel(this.service.currentVisibleId);
                this.doOpenPanel(id);
                this.initPanelReload(id);
            }
        } else if (this.service.currentVisibleId === id) {
            // current panel already open try to reload new content
            if (this.needConfirmation(id)) {
                const $modal = $(this.getPropertyValue(id, 'modal'));
                $modal.modal('setting', 'onApprove', (e) => {
                    this.doOpenPanel(id);
                    this.initPanelReload(id);
                });
                $modal.modal('show');
            } else {
                this.doOpenPanel(id);
                this.initPanelReload(id);
            }
        } else {
            this.doOpenPanel(id);
            this.initPanelReload(id);
        }
    }

    /**
     * Will check if panel is reloadable and
     * setup proper URL argument from triggered item
     * via it's data property.
     */
    initPanelReload(id) {
        const params = this.service.currentParams;
        // do we need to load anything in this panel
        if (this.getPropertyValue(id, 'url')) {
            // convert our array of args to object
            // args must be defined as data-attributeName in the triggered element
            const args = {};
            for (const k of params.reloadArgs) {
                args[k] = params.triggered.data(k);
            }
            // add URL argument if pass to panel
            if (params.urlArgs !== undefined) {
                $.extend(args, params.urlArgs);
            }
            this.doReloadPanel(id, args);
        }
    }

    /**
     * Do the actual opening.
     */
    doOpenPanel(panelId) {
        const params = this.service.currentParams;

        let triggerElement = params.triggered;

        if (typeof triggerElement === 'string') {
            triggerElement = $(triggerElement);
        }

        // will apply css class to triggering element if provide
        if (triggerElement.length > 0) {
            this.setTriggerElement(panelId, triggerElement, params);
        }

        this.getPropertyValue(panelId, '$panel').addClass(this.getPropertyValue(panelId, 'visible'));
        this.service.currentVisibleId = panelId;
        if (this.getPropertyValue(panelId, 'hasClickAway')) {
            this.addClickAwayEvent(panelId);
        }
        if (this.getPropertyValue(panelId, 'hasEscAway')) {
            this.addEscAwayEvent(panelId);
        }
    }

    /**
     * Close panel.
     * if confirmation is needed, will ask user.
     */
    closePanel(id) {
        if (this.needConfirmation(id)) {
            const $modal = $(this.getPropertyValue(id, 'modal'));
            $modal.modal('setting', 'onApprove', (e) => {
                this.doClosePanel(id);
            }).modal('show');
        } else {
            this.doClosePanel(id);
        }
    }

    /**
     * Close panel and cleanup.
     */
    doClosePanel(id) {
        // remove document event
        this.removeClickAwayEvent();
        this.removeWarning(id);

        // do the actual closing
        this.getPropertyValue(id, '$panel').removeClass(this.getPropertyValue(id, 'visible'));
        this.service.currentVisibleId = null;

        // clean up
        const triggeredActive = this.getPropertyValue(id, 'triggeredActive');
        if (triggeredActive.element && triggeredActive.element.length > 0) {
            this.deActivated(triggeredActive.element, triggeredActive.css);
        }
        triggeredActive.element = null;
        triggeredActive.css = null;
        this.setPropertyValue(id, 'triggeredActive', triggeredActive);
        this.setPropertyValue(id, 'triggerElement', null);
    }

    /**
     * Load panel content.
     */
    doReloadPanel(id, args) {
        const loader = this.getPropertyValue(id, 'loader');
        const $panel = this.getPropertyValue(id, '$panel');
        const url = this.getPropertyValue(id, 'url');

        // do some cleanup
        this.removeWarning(id);
        this.clearPanelContent(id);

        $panel.find(loader.selector).addClass(loader.trigger);
        $panel.api({
            on: 'now',
            url: url,
            data: args,
            method: 'GET',
            stateContext: null,
            onComplete: function (r, s) {
                $panel.find(loader.selector).removeClass(loader.trigger);
            },
        });
    }

    /**
     * Set triggering element that fire the panel to open.
     * If panel is open by HTML element, you can specified class on these
     * elements that will be add or remove, depending on the panel state.
     * Thus, creating a visual onto which HTML element has fire the event.
     */
    setTriggerElement(id, trigger, params) {
        this.setPropertyValue(id, 'triggerElement', trigger);

        // setup CSS class on triggering element
        if (params.activeCSS) {
            let element;
            let css;

            if (params.activeCSS instanceof Object) {
                element = this.getPropertyValue(id, 'triggerElement').find(params.activeCSS.element);
                css = params.activeCSS.css;
            } else {
                element = trigger;
                css = params.activeCSS;
            }

            this.deActivated(this.getPropertyValue(id, 'triggeredActive').element, this.getPropertyValue(id, 'triggeredActive').css);

            this.activated(element, css);
            const newTriggeredActive = { element: element, css: css };
            this.setPropertyValue(id, 'triggeredActive', newTriggeredActive);
        }
    }

    /**
     * Add click away closing event handler.
     */
    addClickAwayEvent(id) {
        // clicking anywhere in main tag will close panel
        $('main').on('click.atkPanel', atk.createDebouncedFx((evt) => {
            this.closePanel(id);
        }, 250));
    }

    /**
     * Add esc away closing event handler.
     */
    addEscAwayEvent(id) {
        // pressing esc key will close panel
        $(document).on('keyup.atkPanel', atk.createDebouncedFx((evt) => {
            if (evt.keyCode === 27) {
                this.closePanel(id);
            }
        }, 100));
    }

    /**
     * Remove click away and esc events.
     */
    removeClickAwayEvent() {
        $('main').off('click.atkPanel');
        $(document).off('keyup.atkPanel');
    }

    /**
     * Compare a  jQuery element to the actual triggered element for this panel.
     *
     * @returns {boolean} True when both jQuery element are equal.
     */
    isSameElement(id, el) {
        const triggerElement = this.getPropertyValue(id, 'triggerElement');
        let isSame = false;
        if (el && triggerElement) {
            isSame = el.length === triggerElement.length && el.length === el.filter(triggerElement).length;
        }

        return isSame;
    }

    /**
     * Removed a CSS class to a jQuery element.
     * This should normally be your triggering panel element.
     */
    deActivated(element, css) {
        if (element) {
            element.removeClass(css);
        }
    }

    /**
     * Add a CSS class name to a jQuery element.
     * This should normally be your triggering panel element.
     */
    activated(element, css) {
        if (element) {
            element.addClass(css);
        }
    }

    /**
     * Check if Warning sign is on.
     *
     * @returns {boolean}
     */
    isWarningOn(id) {
        const $panel = this.getPropertyValue(id, '$panel');
        const warning = this.getPropertyValue(id, 'warning');

        return $panel.find(warning.selector).hasClass(warning.trigger);
    }

    removeWarning(id) {
        const $panel = this.getPropertyValue(id, '$panel');
        const warning = this.getPropertyValue(id, 'warning');

        return $panel.find(warning.selector).removeClass(warning.trigger);
    }

    /**
     * Check if panel can be closed, i.e.
     * it has a confirmation modal attach and warning sign is not on.
     *
     * @returns {boolean}
     */
    needConfirmation(id) {
        return this.getPropertyValue(id, 'modal') && this.isWarningOn(id);
    }

    /**
     * Clear content.
     */
    clearPanelContent(id) {
        const $panel = this.getPropertyValue(id, '$panel');
        const clearables = this.getPropertyValue(id, 'clearable');
        for (const clearable of clearables) {
            $panel.find(clearable).html('');
        }
    }

    /**
     * Set a property value for a panel designated by id.
     *
     * @param {string} id    the id of the panel to set property too.
     * @param {string} prop  the property inside panel
     * @param {*}      value the value.
     */
    setPropertyValue(id, prop, value) {
        for (const panel of this.panels) {
            if (panel[id]) {
                panel[id][prop] = value;
            }
        }
    }

    /**
     * Return the panel property represent by id in collections.
     * If prop is null, then it will return the entire panel object.
     *
     * @returns {*}
     */
    getPropertyValue(id, prop = null) {
        let value = null;
        for (const panel of this.panels) {
            if (panel[id]) {
                value = prop ? panel[id][prop] : panel[id];
            }
        }

        return value;
    }
}

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