// 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);
};