lib/form/amd/src/filetypes.js

// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * This module allows to enhance the form elements MoodleQuickForm_filetypes
 *
 * @module     core_form/filetypes
 * @copyright  2017 David Mudrak <david@moodle.com>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 * @since      3.3
 */
define(['jquery', 'core/log', 'core/str', 'core/modal_factory', 'core/modal_events', 'core/ajax',
        'core/templates', 'core/tree'],
    function($, Log, Str, ModalFactory, ModalEvents, Ajax, Templates, Tree) {

    "use strict";

    /**
     * Constructor of the FileTypes instances.
     *
     * @constructor
     * @param {String} elementId The id of the form element to enhance
     * @param {String} elementLabel The label of the form element used as the modal selector title
     * @param {String} onlyTypes Limit the list of offered types to this
     * @param {Bool} allowAll Allow presence of the "All file types" item
     */
    var FileTypes = function(elementId, elementLabel, onlyTypes, allowAll) {

        this.elementId = elementId;
        this.elementLabel = elementLabel;
        this.onlyTypes = onlyTypes;
        this.allowAll = allowAll;

        this.inputField = $('#' + elementId);
        this.wrapperBrowserTrigger = $('[data-filetypesbrowser="' + elementId + '"]');
        this.wrapperDescriptions = $('[data-filetypesdescriptions="' + elementId + '"]');

        if (!this.wrapperBrowserTrigger.length) {
            // This is a valid case. Most probably the element is frozen and
            // the filetypes browser should not be available.
            return;
        }

        if (!this.inputField.length || !this.wrapperDescriptions.length) {
            Log.error('core_form/filetypes: Unexpected DOM structure, unable to enhance filetypes field ' + elementId);
            return;
        }

        this.prepareBrowserTrigger()
            .then(function() {
                return this.prepareBrowserModal();
            }.bind(this))

            .then(function() {
                return this.prepareBrowserTree();
            }.bind(this));
    };

    /**
     * Create and set the browser trigger widget (this.browserTrigger).
     *
     * @method prepareBrowserTrigger
     * @returns {Promise}
     */
    FileTypes.prototype.prepareBrowserTrigger = function() {
        return Templates.render('core_form/filetypes-trigger', {})
            .then(function(html) {
                this.wrapperBrowserTrigger.html(html);
                this.browserTrigger = this.wrapperBrowserTrigger.find('[data-filetypeswidget="browsertrigger"]');
            }.bind(this));
    };

    /**
     * Create and set the modal for displaying the browser (this.browserModal).
     *
     * @method prepareBrowserModal
     * @returns {Promise}
     */
    FileTypes.prototype.prepareBrowserModal = function() {
        return ModalFactory.create({
            type: ModalFactory.types.SAVE_CANCEL,
            title: this.elementLabel

        }).then(function(modal) {
            this.browserModal = modal;
        }.bind(this))

        .then(function() {
            // Because we have custom conditional modal trigger, we need to
            // handle the focus after closing ourselves, too.
            this.browserModal.getRoot().on(ModalEvents.hidden, function() {
                this.browserTrigger.focus();
            }.bind(this));

            this.browserModal.getRoot().on(ModalEvents.save, function() {
                this.saveBrowserModal();
            }.bind(this));
        }.bind(this));

    };

    /**
     * Create and set the tree in the browser modal's body.
     *
     * @method prepareBrowserTree
     * @returns {Promise}
     */
    FileTypes.prototype.prepareBrowserTree = function() {

        this.browserTrigger.on('click', function(e) {
            e.preventDefault();

            // We want to display the browser modal only when the associated input
            // field is not frozen (disabled).
            if (this.inputField.is('[disabled]')) {
                return;
            }

            var bodyContent = this.loadBrowserModalBody();

            bodyContent.then(function() {

                // Turn the list of groups and extensions into the tree.
                this.browserTree = new Tree(this.browserModal.getBody());

                // Override the behaviour of the Enter and Space keys to toggle our checkbox,
                // rather than toggle the tree node expansion status.
                this.browserTree.handleKeyDown = function(item, e) {
                    if (e.keyCode == this.browserTree.keys.enter || e.keyCode == this.browserTree.keys.space) {
                        e.preventDefault();
                        e.stopPropagation();
                        this.toggleCheckbox(item.attr('data-filetypesbrowserkey'));
                    } else {
                        Tree.prototype.handleKeyDown.call(this.browserTree, item, e);
                    }
                }.bind(this);

                if (this.allowAll) {
                    // Hide all other items if "All file types" is enabled.
                    this.hideOrShowItemsDependingOnAllowAll(this.browserModal.getRoot()
                        .find('input[type="checkbox"][data-filetypesbrowserkey="*"]').first());
                    // And do the same whenever we click that checkbox.
                    this.browserModal.getRoot().on('change', 'input[type="checkbox"][data-filetypesbrowserkey="*"]', function(e) {
                        this.hideOrShowItemsDependingOnAllowAll($(e.currentTarget));
                    }.bind(this));
                }

                // Synchronize checked status if the file extension is present in multiple groups.
                this.browserModal.getRoot().on('change', 'input[type="checkbox"][data-filetypesbrowserkey]', function(e) {
                    var checkbox = $(e.currentTarget);
                    var key = checkbox.attr('data-filetypesbrowserkey');
                    this.browserModal.getRoot().find('input[type="checkbox"][data-filetypesbrowserkey="' + key + '"]')
                        .prop('checked', checkbox.prop('checked'));
                }.bind(this));

            }.bind(this))

            .then(function() {
                this.browserModal.show();
            }.bind(this));

            this.browserModal.setBody(bodyContent);

        }.bind(this));

        // Return a resolved promise.
        return $.when();
    };

    /**
     * Load the browser modal body contents.
     *
     * @returns {Promise}
     */
    FileTypes.prototype.loadBrowserModalBody = function() {

        var args = {
            onlytypes: this.onlyTypes.join(),
            allowall: this.allowAll,
            current: this.inputField.val()
        };

        return Ajax.call([{
            methodname: 'core_form_get_filetypes_browser_data',
            args: args

        }])[0].then(function(browserData) {
            return Templates.render('core_form/filetypes-browser', {
                elementid: this.elementId,
                groups: browserData.groups
            });
        }.bind(this));
    };

    /**
     * Change the checked status of the given file type (group or extension).
     *
     * @method toggleCheckbox
     * @param {String} key
     */
    FileTypes.prototype.toggleCheckbox = function(key) {

        var checkbox = this.browserModal.getRoot().find('input[type="checkbox"][data-filetypesbrowserkey="' + key + '"]').first();

        checkbox.prop('checked', !checkbox.prop('checked'));
    };

    /**
     * Update the associated input field with selected file types.
     *
     * @method saveBrowserModal
     */
    FileTypes.prototype.saveBrowserModal = function() {

        // Check the "All file types" first.
        if (this.allowAll) {
            var allcheckbox = this.browserModal.getRoot().find('input[type="checkbox"][data-filetypesbrowserkey="*"]');
            if (allcheckbox.length && allcheckbox.prop('checked')) {
                this.inputField.val('*');
                this.updateDescriptions(['*']);
                return;
            }
        }

        // Iterate over all checked boxes and populate the list.
        var newvalue = [];

        this.browserModal.getRoot().find('input[type="checkbox"]').each(/** @this represents the checkbox */ function() {
            var checkbox = $(this);
            var key = checkbox.attr('data-filetypesbrowserkey');

            if (checkbox.prop('checked')) {
                newvalue.push(key);
            }
        });

        // Remove duplicates (e.g. file types present in multiple groups).
        newvalue = newvalue.filter(function(x, i, a) {
            return a.indexOf(x) == i;
        });

        this.inputField.val(newvalue.join(' '));
        this.updateDescriptions(newvalue);
    };

    /**
     * Describe the selected filetypes in the form when saving the browser.
     *
     * @param {Array} keys List of keys to describe
     * @returns {Promise}
     */
    FileTypes.prototype.updateDescriptions = function(keys) {

        var descriptions = [];

        keys.forEach(function(key) {
            descriptions.push({
                description: this.browserModal.getRoot().find('[data-filetypesname="' + key + '"]').first().text().trim(),
                extensions: this.browserModal.getRoot().find('[data-filetypesextensions="' + key + '"]').first().text().trim()
            });
        }.bind(this));

        var templatedata = {
            hasdescriptions: (descriptions.length > 0),
            descriptions: descriptions
        };

        return Templates.render('core_form/filetypes-descriptions', templatedata)
            .then(function(html) {
                this.wrapperDescriptions.html(html);
            }.bind(this));
    };

    /**
     * If "All file types" is checked, all other browser items are made hidden, and vice versa.
     *
     * @param {jQuery} allcheckbox The "All file types" checkbox.
     */
    FileTypes.prototype.hideOrShowItemsDependingOnAllowAll = function(allcheckbox) {
        var others = this.browserModal.getRoot().find('[role="treeitem"][data-filetypesbrowserkey!="*"]');
        if (allcheckbox.prop('checked')) {
            others.hide();
        } else {
            others.show();
        }
    };

    return {
        init: function(elementId, elementLabel, onlyTypes, allowAll) {
            new FileTypes(elementId, elementLabel, onlyTypes, allowAll);
        }
    };
});