Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / HTML

Programmatically Creating Modals in Ember.js

4.80/5 (5 votes)
3 Nov 2017CPOL2 min read 7.4K  
Creating modals (popups) in Ember can open you up to some serious code spaghettification. Here's a quick tip on setting up a re-usable modal creation and control system.

Introduction

"Modals", also referred to as "pop ups", are a very commonly used type of user-interface wherein the user is shown some information and is able to interact with the website, without leaving the page. They're great for showing small-medium amounts of information to a user, whilst keeping the flow of interaction on one page.

This article provides an approach to displaying and controlling the lifecycle of modals within Ember.js applications.

Pre-requisites

Using the Code

The source code is in this repository.

Start off by creating an ember util called ModalUtil.

In terminal:

ember g util modal

This utility will export an extension of the Parse.Object class, including methods set the header and body templates, show and hide the modal, etc.

JavaScript
import Ember from 'ember';

const {computed} = Ember;
export default Ember.Object.extend({

    /**
     * @Property
     * Set on the modal and outlet.
     * The modal element is suffixed with "-modal"
     * by BsModalComponent, but the outlet is not.
     * Therefore, use the computed property 'elementId'
     * to access the DOM element.
     */
    id: null,

    /**
     * @Constructor
     */
    init() {
        this.set('id', Math.random().toString(36).substr(2, 16));
    },

    /**
     * @Property
     * BsModalComponent appends '-modal'
     * to this.id. Use this property to
     * get the modal DOM element.
     **/
    elementId: computed('id', function () {
        return `${this.get('id')}-modal`;
    }),

    /**
     * @Property
     */
    isOpen: false,

    /**
     * @Property
     * Controls the body template partial.
     */
    controller: null,

    /**
     * @Method
     * @param {Ember.Controller} controller
     */
    setController(controller) {
        this.set('controller', controller);
        return this;
    },
    /**
     * @Property
     * Holds model.
     */
    model: null,

    /**
     * @method
     * Sets model on self and if given,
     * the controller too.
     * @param model
     */
    setModel(model) {
        this.set('model', model);
        if (this.get('controller')) {
            this.get('controller').set('model', model);
        }
        return this;
    },

    /**
     * @Property
     */
    title: null,

    /**
     * @method
     * Not required.
     * @param {string} title
     */
    setTitle(title) {
        this.set('title', title);
        return this;
    },
    /**
     * @Property
     * This is the partial for the body.
     */
    bodyTemplate: null,
    /**
     * @method
     * @param {string} template - partial path for the body
     */
    setBodyTemplate(template) {
        this.set('bodyTemplate', template);
        return this;
    },
    /**
     * @Method
     * Sets isOpen to true.
     * @param {Ember.Router|Ember.Route} context - to render the template and controller
     */
    show(context) {
        this.set('isOpen', true);
        context.render(this.get('bodyTemplate'), {
            into: 'application',
            outlet: this.get('id'),
            controller: this.get('controller')
        });

        return this;
    },
    /**
     * @method
     */
    hide() {
        this.set('isOpen', false);
        return this;
    },

    /**
     * @Property
     */
    onClose: null,

    /**
     * @Method
     * @param {function} callback
     */
    setOnCloseCallback(callback) {
        this.set('onClose', callback);
        return this;
    },

    /**
     * @Method
     * @param {function} callback
     */
    setOnSubmitCallback(callback) {
        this.set('onSubmit', callback);
        return this;
    },

    /**
     * @Property
     */
    onHidden: null,
    /**
     * @Property
     */
    onShown: null,
    /**
     * @Property
     */
    onSubmit: null,

    /**
     * @Property
     * If false, use will not be able to close
     * the modal by clicking the back drop.
     */
    backdropClose: true,

    /**
     * @method
     * @param {boolean} [canClose = false]
     */
    disableCloseOnOutsideClick(canClose = false) {
        this.set('backdropClose', canClose);
        return this;
    },
    
    /**
     * @Property
     */
    isWide: false,
    
    /**
     * @method
     * @param {boolean} [isWide = true]
     */
    makeWide(isWide = true) {
        this.set('isWide', isWide);
        return this;
    },

    /**
     * @Property
     * Partial for the header template
     */
    headerTemplate: null,

    /**
     * @method
     * @param {string} partial
     */
    setHeaderTemplate(partial) {
        this.set('headerTemplate', partial);
        return this;
    },

    /**
     * @property
     */
    cancelLabel: 'Cancel',
    submitLabel: 'Submit',
    showCancelButton: false,
    showSubmitButton: false,
    
    /**
     * @method
     * @param {string} label
     */
    setCancelLabel(label) {
        this.set('cancelLabel', label);
        this.displayCancelButton();
        return this;
    },
    /**
     * @method
     * @param {string} label
     */
    setSubmitLabel(label) {
        this.set('submitLabel', label);
        this.displaySubmitButton();
        return this;
    },
    /**
     * @method
     * @param {boolean} [show = true]
     */
    displayCancelButton(show = true) {
        this.set('showCancelButton', show);
        return this;
    },
    /**
     * @method
     * @param {boolean} [show = true]
     */
    displaySubmitButton(show = true) {
        this.set('showSubmitButton', show);
        if (!this.get('onSubmit')) {
            console.error
            ("Modal generated to display submit button, but no 'onSubmit' callback was set.");
        }
        return this;
    },

    /**
     * @Property
     */
    showFooter: computed('showCancelButton', 'showSubmitButton', function () {
        return this.get('showCancelButton')
            || this.get('showSubmitButton');
    })
})

Now, to keep track of active modals across the application, create an ember service called ModalsService.

This service will also abstract the boiler plate aspect of modal creation; setting the body template.

JavaScript
import Ember from 'ember';
import Modal from '../utils/modal';

export default Ember.Service.extend({
    /**
     * @Property
     */
    activeModals: new Ember.A(),

    /**
     * @Property
     * Saves having to get the DOM element
     * each time. Its needed to ensure
     * the 'modal-open' class is set on
     * the body.
     */
    bodyElement: null,

    /**
     * @method
     * @param {string} bodyTemplate - partial for the main body template
     * @param {object} [options] - title
     */
    buildModal(bodyTemplate, options = {}) {
        let modal = Modal.create();

        if (!this.get('bodyElement')) {
            Ember.run.later(() => {
                this.set('bodyElement', Ember.$("body"));
            }, 100);
        }

        modal.setBodyTemplate(bodyTemplate);
        modal.set('onHidden', (function () {
            // First fire onClose callback if provided
            // modal controller is required.
            if (modal.get('onClose')) {
                if (!modal.get('controller')) {
                    throw new Error("Cannot build modal: given onClose but no controller provided.");
                }
                modal.get('onClose')(modal.get('controller'));
            }
            // Then fire the removeModal method.
            this.removeModal(modal.get('id'));

            // Check if another modal has been opened whilst this one was close
            // If so, ensure that the body element has class 'modal-open'
            if (this.get('activeModals.length') && 
            !this.get('bodyElement').hasClass('modal-open')) {
                Ember.run.later(() => {
                    this.get('bodyElement').addClass('modal-open')
                }, 100);
            }

        }.bind(this)));

        if (options.disableOutsideClick) {
            modal.disableCloseOnOutsideClick()
        }

        if (options.wide) {
            modal.makeWide();
        }

        if (options.onClose) {
            modal.setOnCloseCallback(options.onClose);
        }
        if (options.onSubmit) {
            modal.setOnSubmitCallback(options.onSubmit);
        }

        if (options.title) {
            modal.setTitle(options.title);
        }

        if (options.showSubmitButton) {
            modal.displaySubmitButton();
        }
        if (options.showCancelButton) {
            modal.displayCancelButton();
        }
        if (options.submitLabel) {
            modal.setSubmitLabel(options.submitLabel);
        }
        if (options.cancelLabel) {
            modal.setCancelLabel(options.cancelLabel);
        }

        this.get('activeModals').pushObject(modal);

        return modal;
    },

    /**
     * @method
     * Finds, removes and destroys
     * modal.
     * @param {String} modalId
     */
    removeModal(modalId) {
        if (!modalId) {
            return;
        }
        let modal = this.get('activeModals').findBy('id', modalId);
        if (modal) {
            this.get('activeModals').removeObject(modal);
            modal.destroy();
        }
        return this;
    },

    /**
     * @Method
     * @param {Modal} modal
     * @param {Ember.Controller} context
     */
    showOnly(modal, context) {
        modal.show(context);
        Ember.run.later(() => {
            this.closeAll(modal);
        }, 100);
        return this;
    },

    /**
     * @Method
     * Closes and destroys all modals.
     * @param {Modal} [exceptThisModal]
     */
    closeAll(exceptThisModal) {
        this.get('activeModals').forEach((modal) => {
            if (!exceptThisModal || modal.get('id') !== exceptThisModal.get('id')) {
                modal.hide();
            }
        });
        return this;
    }
});

The array activeModals is maintained by this service and can be accessed (observed or computed) by injecting routes. Before using the build modal method, let's set the activeModal structure template in the ApplicationTemplate (application.hbs).

{{!-- The following component displays Ember's default welcome message. --}}
{{welcome-page}}
{{!-- Feel free to remove this! --}}

{{outlet}}

{{#each activeModals as |activeModal|}}
    {{#bs-modal
        id=activeModal.id
        open=activeModal.isOpen
        onHidden=activeModal.onHidden
        onSubmit=activeModal.onSubmit
        footer=false
        backdropClose=activeModal.backdropClose
        class=(if activeModal.isWide 'modal-wide')
as |modal|
}}
    {{#if activeModal.title}}
        {{#modal.header}}
            {{#if activeModal.headerTemplate}}
                {{partial activeModal.headerTemplate}}
            {{else}}
                <h4 class="modal-title">{{activeModal.title}}</h4>
            {{/if}}
        {{/modal.header}}
    {{/if}}
    {{#modal.body}}
       
            {{outlet activeModal.id}}
       
    {{/modal.body}}
    {{#if activeModal.showFooter}}
        {{#modal.footer as |footer|}}
            {{#if activeModal.showCancelButton}}
                {{#bs-button onClick=(action modal.close) 
                type="default"}}{{activeModal.cancelLabel}}{{/bs-button}}
            {{/if}}
            {{#if activeModal.showSubmitButton}}
                {{#bs-button onClick=(action modal.submit) 
                type=activeModal.submitButtonType}}{{activeModal.submitLabel}}{{/bs-button}}
            {{/if}}
        {{/modal.footer}}
    {{/if}}
{{/bs-modal}}
{{/each}}

Now the good part, create a simple body template and an action to use the methods above to build and show the modal. I tend to create the body templates in a subfolder for the route that creates the modal, in this case, 'application/modal/login':

ember g template application/modal/login
  • - application
  • -- modal
  • --- login.hbs
XML
<label>Email</label>
{{input type='email'
        value=email}}<span id="cke_bm_141E" style="display: none;"> </span>

<label>Password</label>
{{input type='password'
        value=password}}

<button class='btn btn-success'>Login</button>

Within ApplicationRoute, set an action that builds and displays this modal. First, ensure you have an ApplicationRoute file; if not, run the following command and press 'N' if it asks you to overwrite the template.

ember g route application

Inject ModalsService before creating the action.

import Ember from 'ember';
const {inject: {serivce}} = Ember;
export default Ember.Route.extend({
    
    modals: service(),
    
    actions: {        
        openLoginModal() {
            let loginModal = this.get('modals')
                .buildModal('application/modal/login')
                .setTitle('Login')
                .setController(this.controllerFor('application'))
                .show(this);            
        },
        
        closeAllModals() {
            this.get('modals').closeAll();
        }        
    }    
});

And that's it! Simply trigger the openLoginModal action and you'll have the modal up! You can inject ModalService into any route.

Points of Interest

Careful when setting the controller and model on the modal; if you are using the controller for another route or template, setting a different model will inactivate that route/template and therefore cause unexpected behaviour.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)