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

/**
 * AJAX helper for the tag management page.
 *
 * @module     core/tag
 * @copyright  2015 Marina Glancy
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 * @since      3.0
 */

import $ from 'jquery';
import {call as fetchMany} from 'core/ajax';
import * as Notification from 'core/notification';
import * as Templates from 'core/templates';
import {getString} from 'core/str';
import * as ModalEvents from 'core/modal_events';
import Pending from 'core/pending';
import SaveCancelModal from 'core/modal_save_cancel';
import Config from 'core/config';
import {eventTypes as inplaceEditableEvents} from 'core/local/inplace_editable/events';
import * as reportSelectors from 'core_reportbuilder/local/selectors';

const getTagIndex = (tagindex) => fetchMany([{
    methodname: 'core_tag_get_tagindex',
    args: {tagindex}
}])[0];

const getCheckedTags = (root) => root.querySelectorAll('[data-togglegroup="report-select-all"][data-toggle="slave"]:checked');

const handleCombineRequest = async(tagManagementCombine) => {
    const pendingPromise = new Pending('core/tag:tag-management-combine');
    const form = tagManagementCombine.closest('form');

    const reportElement = document.querySelector(reportSelectors.regions.report);
    const checkedTags = getCheckedTags(reportElement);

    if (checkedTags.length <= 1) {
        // We need at least 2 tags to combine them.
        Notification.alert(
            getString('combineselected', 'tag'),
            getString('selectmultipletags', 'tag'),
            getString('ok'),
        );

        return;
    }

    const tags = Array.from(checkedTags.values()).map((tag) => {
        const namedElement = document.querySelector(`.inplaceeditable[data-itemtype=tagname][data-itemid="${tag.value}"]`);
        return {
            id: tag.value,
            name: namedElement.dataset.value,
        };
    });

    const modal = await SaveCancelModal.create({
        title: getString('combineselected', 'tag'),
        buttons: {
            save: getString('continue', 'core'),
        },
        body: Templates.render('core_tag/combine_tags', {tags}),
        show: true,
        removeOnClose: true,
    });

    // Handle save event.
    modal.getRoot().on(ModalEvents.save, (e) => {
        e.preventDefault();

        // Append this temp element in the form in the tags list, not the form in the modal. Confusing, right?!?
        const tempElement = document.createElement('input');
        tempElement.hidden = true;
        tempElement.name = tagManagementCombine.name;
        form.append(tempElement);

        // Append selected tags element.
        const tagsElement = document.createElement('input');
        tagsElement.hidden = true;
        tagsElement.name = 'tagschecked';
        tagsElement.value = [...checkedTags].map(check => check.value).join(',');
        form.append(tagsElement);

        // Get the selected tag from the modal.
        var maintag = $('input[name=maintag]:checked', '#combinetags_form').val();
        // Append this in the tags list form.
        $("<input type='hidden'/>").attr('name', 'maintag').attr('value', maintag).appendTo(form);
        // Submit the tags list form.
        form.submit();
    });

    await modal.getBodyPromise();
    // Tick the first option.
    const firstOption = document.querySelector('#combinetags_form input[type=radio]');
    firstOption.focus();
    firstOption.checked = true;

    pendingPromise.resolve();

    return;
};

const addStandardTags = async() => {
    var pendingPromise = new Pending('core/tag:addstandardtag');

    const modal = await SaveCancelModal.create({
        title: getString('addotags', 'tag'),
        body: Templates.render('core_tag/add_tags', {
            actionurl: window.location.href,
            sesskey: M.cfg.sesskey,
        }),
        buttons: {
            save: getString('continue', 'core'),
        },
        removeOnClose: true,
        show: true,
    });

    // Handle save event.
    modal.getRoot().on(ModalEvents.save, (e) => {
        var tagsInput = $(e.currentTarget).find('#id_tagslist');
        var name = tagsInput.val().trim();

        // Set the text field's value to the trimmed value.
        tagsInput.val(name);

        // Add submit event listener to the form.
        var tagsForm = $('#addtags_form');
        tagsForm.on('submit', function(e) {
            // Validate the form.
            var form = $('#addtags_form');
            if (form[0].checkValidity() === false) {
                e.preventDefault();
                e.stopPropagation();
            }
            form.addClass('was-validated');

            // BS2 compatibility.
            $('[data-region="tagslistinput"]').addClass('error');
            var errorMessage = $('#id_tagslist_error_message');
            errorMessage.removeAttr('hidden');
            errorMessage.addClass('help-block');
        });

        // Try to submit the form.
        tagsForm.submit();

        return false;
    });

    await modal.getBodyPromise();
    pendingPromise.resolve();
};

const deleteSelectedTags = async(bulkActionDeleteButton) => {
    const form = bulkActionDeleteButton.closest('form');

    const reportElement = document.querySelector(reportSelectors.regions.report);
    const checkedTags = getCheckedTags(reportElement);

    if (!checkedTags.length) {
        return;
    }

    try {
        await Notification.saveCancelPromise(
            getString('delete'),
            getString('confirmdeletetags', 'tag'),
            getString('yes'),
            getString('no'),
        );

        // Append this temp element in the form in the tags list, not the form in the modal. Confusing, right?!?
        const tempElement = document.createElement('input');
        tempElement.hidden = true;
        tempElement.name = bulkActionDeleteButton.name;
        form.append(tempElement);

        // Append selected tags element.
        const tagsElement = document.createElement('input');
        tagsElement.hidden = true;
        tagsElement.name = 'tagschecked';
        tagsElement.value = [...checkedTags].map(check => check.value).join(',');
        form.append(tagsElement);

        form.submit();
    } catch {
        return;
    }
};

const deleteSelectedTag = async(button) => {
    try {
        await Notification.saveCancelPromise(
            getString('delete'),
            getString('confirmdeletetag', 'tag'),
            getString('yes'),
            getString('no'),
        );

        window.location.href = button.href;
    } catch {
        return;
    }
};

const deleteSelectedCollection = async(button) => {
    try {
        await Notification.saveCancelPromise(
            getString('delete'),
            getString('suredeletecoll', 'tag', button.dataset.collname),
            getString('yes'),
            getString('no'),
        );

        const redirectTarget = new URL(button.dataset.url);
        redirectTarget.searchParams.set('sesskey', Config.sesskey);
        window.location.href = redirectTarget;
    } catch {
        return;
    }
};

const addTagCollection = async(link) => {
    const pendingPromise = new Pending('core/tag:initManageCollectionsPage-addtagcoll');
    const href = link.dataset.url;

    const modal = await SaveCancelModal.create({
        title: getString('addtagcoll', 'tag'),
        buttons: {
            save: getString('create', 'core'),
        },
        body: Templates.render('core_tag/add_tag_collection', {
            actionurl: href,
            sesskey: M.cfg.sesskey,
        }),
        removeOnClose: true,
        show: true,
    });

    // Handle save event.
    modal.getRoot().on(ModalEvents.save, (e) => {
        const collectionInput = $(e.currentTarget).find('#addtagcoll_name');
        const name = collectionInput.val().trim();
        // Set the text field's value to the trimmed value.
        collectionInput.val(name);

        // Add submit event listener to the form.
        const form = $('#addtagcoll_form');
        form.on('submit', function(e) {
            // Validate the form.
            if (form[0].checkValidity() === false) {
                e.preventDefault();
                e.stopPropagation();
            }
            form.addClass('was-validated');

            // BS2 compatibility.
            $('[data-region="addtagcoll_nameinput"]').addClass('error');
            const errorMessage = $('#id_addtagcoll_name_error_message');
            errorMessage.removeAttr('hidden');
            errorMessage.addClass('help-block');
        });

        // Try to submit the form.
        form.submit();

        return false;
    });

    pendingPromise.resolve();
};

/**
 * Initialises tag index page.
 *
 * @method initTagindexPage
 */
export const initTagindexPage = async() => {
    document.addEventListener('click', async(e) => {
        const targetArea = e.target.closest('a[data-quickload="1"]');
        if (!targetArea) {
            return;
        }
        const tagArea = targetArea.closest('.tagarea[data-ta]');
        if (!tagArea) {
            return;
        }

        e.preventDefault();
        const pendingPromise = new Pending('core/tag:initTagindexPage');

        const query = targetArea.search.replace(/^\?/, '');
        const params = Object.fromEntries((new URLSearchParams(query)).entries());

        try {
            const data = await getTagIndex(params);
            const {html, js} = await Templates.renderForPromise('core_tag/index', data);
            Templates.replaceNode(tagArea, html, js);
        } catch (error) {
            Notification.exception(error);
        }
        pendingPromise.resolve();
    });
};

/**
 * Initialises tag management page.
 *
 * @method initManagePage
 */
export const initManagePage = () => {
    // Toggle row class when updating flag.
    $('body').on(inplaceEditableEvents.elementUpdated, '[data-inplaceeditable][data-itemtype=tagflag]', function(e) {
        var row = $(e.target).closest('tr');
        row.toggleClass('table-warning', e.detail.ajaxreturn.value === '1');
    });

    // Confirmation for bulk tag combine button.
    document.addEventListener('click', async(e) => {
        const tagManagementCombine = e.target.closest('#tag-management-combine');
        if (tagManagementCombine) {
            e.preventDefault();
            handleCombineRequest(tagManagementCombine);
        }

        if (e.target.closest('[data-action="addstandardtag"]')) {
            e.preventDefault();
            addStandardTags();
        }

        const bulkActionDeleteButton = e.target.closest('#tag-management-delete');
        if (bulkActionDeleteButton) {
            e.preventDefault();
            deleteSelectedTags(bulkActionDeleteButton);
        }

        const rowDeleteButton = e.target.closest('.tagdelete');
        if (rowDeleteButton) {
            e.preventDefault();
            deleteSelectedTag(rowDeleteButton);
        }
    });

    // When user changes tag name to some name that already exists suggest to combine the tags.
    $('body').on(inplaceEditableEvents.elementUpdateFailed, '[data-inplaceeditable][data-itemtype=tagname]', async(e) => {
        var exception = e.detail.exception; // The exception object returned by the callback.
        var newvalue = e.detail.newvalue; // The value that user tried to udpated the element to.
        var tagid = $(e.target).attr('data-itemid');
        if (exception.errorcode !== 'namesalreadybeeingused') {
            return;
        }
        e.preventDefault(); // This will prevent default error dialogue.

        try {
            await Notification.saveCancelPromise(
                getString('confirm'),
                getString('nameuseddocombine', 'tag'),
                getString('yes'),
                getString('cancel'),
            );

            // The Promise will resolve on 'Yes' button, and reject on 'Cancel' button.
            const redirectTarget = new URL(window.location);
            redirectTarget.searchParams.set('newname', newvalue);
            redirectTarget.searchParams.set('tagid', tagid);
            redirectTarget.searchParams.set('action', 'renamecombine');
            redirectTarget.searchParams.set('sesskey', Config.sesskey);

            window.location.href = redirectTarget;
        } catch {
            return;
        }
    });
};

/**
 * Initialises tag collection management page.
 *
 * @method initManageCollectionsPage
 */
export const initManageCollectionsPage = () => {
    $('body').on(inplaceEditableEvents.elementUpdated, '[data-inplaceeditable]', function(e) {
        var pendingPromise = new Pending('core/tag:initManageCollectionsPage-updated');

        var ajaxreturn = e.detail.ajaxreturn,
            areaid, collid, isenabled;
        if (ajaxreturn.component === 'core_tag' && ajaxreturn.itemtype === 'tagareaenable') {
            areaid = $(this).attr('data-itemid');
            $(".tag-collections-table ul[data-collectionid] li[data-areaid=" + areaid + "]").hide();
            isenabled = ajaxreturn.value;
            if (isenabled === '1') {
                $(this).closest('tr').removeClass('dimmed_text');
                collid = $(this).closest('tr').find('[data-itemtype="tagareacollection"]').attr("data-value");
                $(".tag-collections-table ul[data-collectionid=" + collid + "] li[data-areaid=" + areaid + "]").show();
            } else {
                $(this).closest('tr').addClass('dimmed_text');
            }
        }
        if (ajaxreturn.component === 'core_tag' && ajaxreturn.itemtype === 'tagareacollection') {
            areaid = $(this).attr('data-itemid');
            $(".tag-collections-table ul[data-collectionid] li[data-areaid=" + areaid + "]").hide();
            collid = $(this).attr('data-value');
            isenabled = $(this).closest('tr').find('[data-itemtype="tagareaenable"]').attr("data-value");
            if (isenabled === "1") {
                $(".tag-collections-table ul[data-collectionid=" + collid + "] li[data-areaid=" + areaid + "]").show();
            }
        }

        pendingPromise.resolve();
    });

    document.addEventListener('click', async(e) => {
        const addTagCollectionNode = e.target.closest('.addtagcoll > a');
        if (addTagCollectionNode) {
            e.preventDefault();
            addTagCollection(addTagCollectionNode);
            return;
        }

        const deleteCollectionButton = e.target.closest('.tag-collections-table .action_delete');
        if (deleteCollectionButton) {
            e.preventDefault();
            deleteSelectedCollection(deleteCollectionButton);
        }
    });
};