question/bank/columnsortorder/amd/src/user_actions.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/>.

/**
 * Javascript for customising the user's view of the question bank
 *
 * @module     qbank_columnsortorder/user_actions
 * @copyright  2021 Catalyst IT Australia Pty Ltd
 * @author     Ghaly Marc-Alexandre <marc-alexandreghaly@catalyst-ca.net>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

import * as actions from 'qbank_columnsortorder/actions';
import * as repository from 'qbank_columnsortorder/repository';
import {get_string as getString} from 'core/str';
import ModalEvents from 'core/modal_events';
import ModalSaveCancel from 'core/modal_save_cancel';
import Notification from "core/notification";
import SortableList from 'core/sortable_list';
import Templates from "core/templates";


const SELECTORS = {
    uiRoot: '.questionbankwindow',
    moveAction: '.menu-action[data-action=move]',
    resizeAction: '.menu-action[data-action=resize]',
    resizeHandle: '.qbank_columnsortorder-action-handle.resize',
    handleContainer: '.handle-container',
    headerContainer: '.header-container',
    tableColumn: identifier => `td[data-columnid="${identifier.replace(/["\\]/g, '\\$&')}"]`,
};

/** To track mouse event on a table header */
let currentHeader;

/** Current mouse x postion, to track mouse event on a table header */
let currentX;

/** Minimum size for the column currently being resized. */
let currentMin;

/**
 * Flag to temporarily prevent move and resize handles from being shown or hidden.
 *
 * @type {boolean}
 */
let suspendShowHideHandles = false;

/**
 * Add handle containers for move and resize handles.
 *
 * @param {Element} uiRoot The root element of the quesiton bank UI.
 * @return {Promise} Resolved after the containers have been added to each column header.
 */
const addHandleContainers = uiRoot => {
    return new Promise((resolve) => {
        const headerContainers = uiRoot.querySelectorAll(SELECTORS.headerContainer);
        Templates.renderForPromise('qbank_columnsortorder/handle_container', {})
            .then(({html, js}) => {
                headerContainers.forEach(container => {
                    Templates.prependNodeContents(container, html, js);
                });
                resolve();
                return headerContainers;
            }).catch(Notification.exception);
    });
};

/**
 * Render move handles in each container.
 *
 * This takes a list of the move actions rendered in each column header, and creates a corresponding drag handle for each.
 *
 * @param {NodeList} moveActions Menu actions for moving columns.
 */
const setUpMoveHandles = moveActions => {
    moveActions.forEach(moveAction => {
        const header = moveAction.closest('th');
        header.classList.add('qbank-sortable-column');
        const handleContainer = header.querySelector(SELECTORS.handleContainer);
        const context = {
            action: "move",
            dragtype: "move",
            target: '',
            title: moveAction.title,
            pixicon: "i/dragdrop",
            pixcomponent: "core",
            popup: true
        };
        return Templates.renderForPromise('qbank_columnsortorder/action_handle', context)
            .then(({html, js}) => {
                Templates.prependNodeContents(handleContainer, html, js);
                return handleContainer;
            }).catch(Notification.exception);
    });
};

/**
 * Serialise the current column sizes.
 *
 * This finds the current width set in each column header's style property, and returns them encoded as a JSON string.
 *
 * @param {Element} uiRoot The root element of the quesiton bank UI.
 * @return {String} JSON array containing a list of objects with column and width properties.
 */
const serialiseColumnSizes = (uiRoot) => {
    const columnSizes = [];
    const tableHeaders = uiRoot.querySelectorAll('th');
    tableHeaders.forEach(header => {
        // Only get the width set via style attribute (set by move action).
        const width = parseInt(header.style.width);
        if (!width || isNaN(width)) {
            return;
        }
        columnSizes.push({
            column: header.dataset.columnid,
            width: width
        });
    });
    return JSON.stringify(columnSizes);
};

/**
 * Find the minimum width for a header, based on the width of its contents.
 *
 * This is to simulate `min-width: min-content;`, which doesn't work on Chrome because
 * min-width is ignored width `table-layout: fixed;`.
 *
 * @param {Element} header The table header
 * @return {Number} The minimum width in pixels
 */
const getMinWidth = (header) => {
    const contents = Array.from(header.querySelector('.header-text').children);
    const contentWidth = contents.reduce((width, contentElement) => width + contentElement.getBoundingClientRect().width, 0);
    return Math.ceil(contentWidth);
};

/**
 * Render resize handles in each container.
 *
 * This takes a list of the resize actions rendered in each column header, and creates a corresponding drag handle for each.
 * It also initialises the event handlers for the drag handles and resize modal.
 *
 * @param {Element} uiRoot Question bank UI root element.
 */
const setUpResizeHandles = (uiRoot) => {
    const resizeActions = uiRoot.querySelectorAll(SELECTORS.resizeAction);
    resizeActions.forEach(resizeAction => {
        const headerContainer = resizeAction.closest(SELECTORS.headerContainer);
        const header = resizeAction.closest(actions.SELECTORS.sortableColumn);
        const minWidth = getMinWidth(header);
        if (header.offsetWidth < minWidth) {
            header.style.width = minWidth + 'px';
        }
        const handleContainer = headerContainer.querySelector(SELECTORS.handleContainer);
        const context = {
            action: "resize",
            target: '',
            title: resizeAction.title,
            pixicon: 'i/twoway',
            pixcomponent: 'core',
            popup: true
        };
        return Templates.renderForPromise('qbank_columnsortorder/action_handle', context)
            .then(({html, js}) => {
                Templates.appendNodeContents(handleContainer, html, js);
                return handleContainer;
            }).catch(Notification.exception);
    });

    let moveTracker = false;
    let currentResizeHandle = null;
    // Start mouse event on headers.
    uiRoot.addEventListener('mousedown', e => {
        currentResizeHandle = e.target.closest(SELECTORS.resizeHandle);
        // Return if it is not ' resize' button.
        if (!currentResizeHandle) {
            return;
        }
        // Save current position.
        currentX = e.pageX;
        // Find the header.
        currentHeader = e.target.closest(actions.SELECTORS.sortableColumn);
        currentMin = getMinWidth(currentHeader);
        moveTracker = false;
        suspendShowHideHandles = true;
    });

    // Resize column as the mouse move.
    document.addEventListener('mousemove', e => {
        if (!currentHeader || !currentResizeHandle || currentX === 0) {
            return;
        }

        // Prevent text selection as the handle is dragged.
        document.getSelection().removeAllRanges();

        // Adjust the column width according the amount the handle was dragged.
        const offset = e.pageX - currentX;
        currentX = e.pageX;
        const newWidth = currentHeader.offsetWidth + offset;
        if (newWidth >= currentMin) {
            currentHeader.style.width = newWidth + 'px';
        }
        moveTracker = true;
    });

    // Set new size when mouse is up.
    document.addEventListener('mouseup', () => {
        if (!currentHeader || !currentResizeHandle || currentX === 0) {
            return;
        }
        if (moveTracker) {
            // If the mouse moved, we are changing the size by drag, so save the change.
            repository.setColumnSize(serialiseColumnSizes(uiRoot)).catch(Notification.exception);
        } else {
            // If the mouse didn't move, display a modal to change the size using a form.
            showResizeModal(currentHeader, uiRoot);
        }
        currentMin = null;
        currentHeader = null;
        currentResizeHandle = null;
        currentX = 0;
        moveTracker = false;
        suspendShowHideHandles = false;
    });
};

/**
 * Event handler for resize actions in each column header.
 *
 * This will listen for a click on any resize action, and activate the corresponding resize modal.
 *
 * @param {Element} uiRoot Question bank UI root element.
 */
const setUpResizeActions = uiRoot => {
    uiRoot.addEventListener('click', (e) => {
        const resizeAction = e.target.closest(SELECTORS.resizeAction);
        if (resizeAction) {
            e.preventDefault();
            const currentHeader = resizeAction.closest('th');
            showResizeModal(currentHeader, uiRoot);
        }
    });
};

/**
 * Show a modal containing a number input for changing a column width without click-and-drag.
 *
 * @param {Element} currentHeader The header element that is being resized.
 * @param {Element} uiRoot The question bank UI root element.
 * @returns {Promise<void>}
 */
const showResizeModal = async(currentHeader, uiRoot) => {
    const initialWidth = currentHeader.offsetWidth;
    const minWidth = getMinWidth(currentHeader);

    const modal = await ModalSaveCancel.create({
        title: getString('resizecolumn', 'qbank_columnsortorder', currentHeader.dataset.name),
        body: Templates.render('qbank_columnsortorder/resize_modal', {width: initialWidth, min: minWidth}),
        show: true,
    });
    const root = modal.getRoot();
    root.on(ModalEvents.cancel, () => {
        currentHeader.style.width = `${initialWidth}px`;
    });
    root.on(ModalEvents.save, () => {
        repository.setColumnSize(serialiseColumnSizes(uiRoot)).catch(Notification.exception);
    });

    const body = await modal.bodyPromise;
    const input = body.get(0).querySelector('input');

    input.addEventListener('change', e => {
        const valid = e.target.checkValidity();
        e.target.closest('.has-validation').classList.add('was-validated');
        if (valid) {
            const newWidth = e.target.value;
            currentHeader.style.width = `${newWidth}px`;
        }
    });
};

/**
 * Event handler for move actions in each column header.
 *
 * This will listen for a click on any move action, pass the click to the corresponding move handle, causing its modal to be shown.
 *
 * @param {Element} uiRoot Question bank UI root element.
 */
const setUpMoveActions = uiRoot => {
    uiRoot.addEventListener('click', e => {
        const moveAction = e.target.closest(SELECTORS.moveAction);
        if (moveAction) {
            e.preventDefault();
            const sortableColumn = moveAction.closest(actions.SELECTORS.sortableColumn);
            const moveHandle = sortableColumn.querySelector(actions.SELECTORS.moveHandler);
            moveHandle.click();
        }
    });
};

/**
 * Event handler for showing and hiding handles when the mouse is over a column header.
 *
 * Implementing this behaviour using the :hover CSS pseudoclass is not sufficient, as the mouse may move over the neighbouring
 * header while dragging, leading to some odd behaviour. This allows us to suspend the show/hide behaviour while a handle is being
 * dragged, and so keep the active handle visible until the drag is finished.
 *
 * @param {Element} uiRoot Question bank UI root element.
 */
const setupShowHideHandles = uiRoot => {
    let shownHeader = null;
    let tableHead = uiRoot.querySelector('thead');
    uiRoot.addEventListener('mouseover', e => {
        if (suspendShowHideHandles) {
            return;
        }
        const header = e.target.closest(actions.SELECTORS.sortableColumn);
        if (!header && !shownHeader) {
            return;
        }
        if (!header || header !== shownHeader) {
            tableHead.querySelector('.show-handles')?.classList.remove('show-handles');
            shownHeader = header;
            if (header) {
                header.classList.add('show-handles');
            }
        }
    });
};

/**
 * Event handler for sortable list DROP event.
 *
 * Find all table cells corresponding to the column of the dropped header, and move them to the new position.
 *
 * @param {Event} event
 */
const reorderColumns = event => {
    // Current header.
    const header = event.target;
    // Find the previous sibling of the header, which will be used when moving columns.
    const insertAfter = header.previousElementSibling;
    // Move columns.
    const uiRoot = document.querySelector(SELECTORS.uiRoot);
    const columns = uiRoot.querySelectorAll(SELECTORS.tableColumn(header.dataset.columnid));
    columns.forEach(column => {
        const row = column.parentElement;
        if (insertAfter) {
            // Find the column to insert after.
            const insertAfterColumn = row.querySelector(SELECTORS.tableColumn(insertAfter.dataset.columnid));
            // Insert the column.
            insertAfterColumn.after(column);
        } else {
            // Insert as the first child (first column in the table).
            row.insertBefore(column, row.firstChild);
        }
    });
};

/**
 * Initialize module
 *
 * Add containers for the drag handles to each column header, then render handles, enable show/hide behaviour, set up drag/drop
 * column sorting, then enable the move and resize modals to be triggered from menu actions.
 */
export const init = async() => {
    const uiRoot = document.getElementById('questionscontainer');
    await addHandleContainers(uiRoot);
    setUpMoveHandles(uiRoot.querySelectorAll(SELECTORS.moveAction));
    setUpResizeHandles(uiRoot);
    setupShowHideHandles(uiRoot);
    const sortableColumns = actions.setupSortableLists(uiRoot.querySelector(actions.SELECTORS.columnList));
    sortableColumns.on(SortableList.EVENTS.DROP, reorderColumns);
    sortableColumns.on(SortableList.EVENTS.DRAGSTART, () => {
        suspendShowHideHandles = true;
    });
    sortableColumns.on(SortableList.EVENTS.DRAGEND, () => {
        suspendShowHideHandles = false;
    });
    setUpMoveActions(uiRoot);
    setUpResizeActions(uiRoot);
    actions.setupActionButtons(uiRoot);
};