lib/form/amd/src/choicedropdown.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/>.

/**
 * Field controller for choicedropdown field.
 *
 * @module core_form/choicedropdown
 * @copyright 2023 Ferran Recio <ferran@moodle.com>
 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

import {getDropdownStatus} from 'core/local/dropdown/status';
import {markFormAsDirty} from 'core_form/changechecker';

const Classes = {
    notClickable: 'not-clickable',
    hidden: 'd-none',
};

/**
 * Internal form element class.
 *
 * @private
 * @class     FieldController
 * @copyright  2023 Ferran Recio <ferran@moodle.com>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class FieldController {
    /**
     * Class constructor.
     *
     * @param {String} elementId Form element id
     */
    constructor(elementId) {
        this.elementId = elementId;
        this.mainSelect = document.getElementById(this.elementId);
        this.dropdown = getDropdownStatus(`[data-form-controls="${this.elementId}"]`);
        this.dropdown.getElement().classList.remove(Classes.hidden);
    }

    /**
     * Add form element event listener.
     */
    addEventListeners() {
        this.dropdown.getElement().addEventListener(
            'change',
            this.updateSelect.bind(this)
        );
        // Click on a dropdown link can trigger a wrong dirty form reload warning.
        this.dropdown.getElement().addEventListener(
            'click',
            (event) => event.preventDefault()
        );
        this.mainSelect.addEventListener(
            'change',
            this.updateDropdown.bind(this)
        );
        // Enabling or disabling the select does not trigger any JS event.
        const observerCallback = (mutations) => {
            mutations.forEach((mutation) => {
                if (mutation.type !== 'attributes' || mutation.attributeName !== 'disabled') {
                    return;
                }
                this.updateDropdown();
            });
        };
        new MutationObserver(observerCallback).observe(
            this.mainSelect,
            {attributeFilter: ['disabled']}
        );
    }

    /**
     * Check if the field is disabled.
     * @returns {Boolean}
     */
    isDisabled() {
        return this.mainSelect?.hasAttribute('disabled');
    }

    /**
     * Update selected option preview in form.
     */
    async updateDropdown() {
        this.dropdown.setButtonDisabled(this.isDisabled());
        if (this.dropdown.getSelectedValue() == this.mainSelect.value) {
            return;
        }
        this.dropdown.setSelectedValue(this.mainSelect.value);
    }

    /**
     * Update selected option preview in form.
     */
    async updateSelect() {
        if (this.dropdown.getSelectedValue() == this.mainSelect.value) {
            return;
        }
        this.mainSelect.value = this.dropdown.getSelectedValue();
        markFormAsDirty(this.mainSelect.closest('form'));
        // Change the select element via JS does not trigger the standard change event.
        this.mainSelect.dispatchEvent(new Event('change'));
    }

    /**
     * Disable the choice dialog and convert it into a regular select field.
     */
    disableInteractiveDialog() {
        this.mainSelect?.classList.remove(Classes.hidden);
        const dropdownElement = this.dropdown.getElement();
        dropdownElement.classList.add(Classes.hidden);
    }

    /**
     * Check if the field has a force dialog attribute.
    //  *
     * The force dialog is a setting to force the javascript control even in
     * behat test.
     *
     * @returns {Boolean} if the dialog modal should be forced or not
     */
    hasForceDialog() {
        return !!this.mainSelect?.dataset.forceDialog;
    }
}

/**
 * Initialises a choice dialog field.
 *
 * @method init
 * @param {String} elementId Form element id
 * @listens event:uploadStarted
 * @listens event:uploadCompleted
 */
export const init = (elementId) => {
    const field = new FieldController(elementId);
    // This field is just a select wrapper. To optimize tests, we don't want to keep behat
    // waiting for extra loadings in this case. The set field steps are about testing other
    // stuff, not to test fancy javascript form fields. However, we keep the possibility of
    // testing the javascript part using behat when necessary.
    if (document.body.classList.contains('behat-site') && !field.hasForceDialog()) {
        field.disableInteractiveDialog();
        return;
    }
    field.addEventListeners();
};