Ir a la última revisión | Autoría | Comparar con el anterior | Ultima modificación | Ver Log |
// 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 provides change detection to forms, allowing a browser to warn the user before navigating away if changes* have been made.** Two flags are stored for each form:* * a 'dirty' flag; and* * a 'submitted' flag.** When the page is unloaded each watched form is checked. If the 'dirty' flag is set for any form, and the 'submitted'* flag is not set for any form, then a warning is shown.** The 'dirty' flag is set when any form element is modified within a watched form.* The flag can also be set programatically. This may be required for custom form elements.** It is not possible to customise the warning message in any modern browser.** Please note that some browsers have controls on when these alerts may or may not be shown.* See {@link https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload} for browser-specific* notes and references.** @module core_form/changechecker* @copyright 2021 Andrew Lyons <andrew@nicols.co.uk>* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later* @example <caption>Usage where the FormElement is already held</caption>** import {watchForm} from 'core_form/changechecker';** // Fetch the form element somehow.* watchForm(formElement);** @example <caption>Usage from the child of a form - i.e. an input, button, div, etc.</caption>** import {watchForm} from 'core_form/changechecker';** // Watch the form by using a child of it.* watchForm(document.querySelector('input[data-foo="bar"]'););** @example <caption>Usage from within a template</caption>* <form id="mod_example-entry-{{uniqid}}" ...>* <!--** -->* </form>* {{#js}}* require(['core_form/changechecker'], function(changeChecker) {* watchFormById('mod_example-entry-{{uniqid}}');* });* {{/js}}*/import {eventTypes} from 'core_editor/events';import {getString} from 'core/str';/*** @property {Bool} initialised Whether the change checker has been initialised* @private*/let initialised = false;/*** @property {String} warningString The warning string to show on form change failure* @private*/let warningString;/*** @property {Array} watchedForms The list of watched forms* @private*/let watchedForms = [];/*** @property {Bool} formChangeCheckerDisabled Whether the form change checker has been actively disabled* @private*/let formChangeCheckerDisabled = false;/*** Get the nearest form element from a child element.** @param {HTMLElement} formChild* @returns {HTMLFormElement|null}* @private*/const getFormFromChild = formChild => formChild.closest('form');/*** Watch the specified form for changes.** @method* @param {HTMLElement} formNode*/export const watchForm = formNode => {// Normalise the formNode.formNode = getFormFromChild(formNode);if (!formNode) {// No form found.return;}if (isWatchingForm(formNode)) {// This form is already watched.return;}watchedForms.push(formNode);};/*** Stop watching the specified form for changes.** If the form was not watched, then no change is made.** A child of the form may be passed instead.** @method* @param {HTMLElement} formNode* @example <caption>Stop watching a form for changes</caption>* import {unWatchForm} from 'core_form/changechecker';** // ...* document.addEventListener('click', e => {* if (e.target.closest('[data-action="changePage"]')) {* unWatchForm(e.target);* }* });*/export const unWatchForm = formNode => {watchedForms = watchedForms.filter(watchedForm => !!watchedForm.contains(formNode));};/*** Reset the 'dirty' flag for all watched forms.** If a form was previously marked as 'dirty', then this flag will be cleared and when the page is unloaded no warning* will be shown.** @method*/export const resetAllFormDirtyStates = () => {watchedForms.forEach(watchedForm => {watchedForm.dataset.formSubmitted = "false";watchedForm.dataset.formDirty = "false";});};/*** Reset the 'dirty' flag of the specified form.** @method* @param {HTMLElement} formNode*/export const resetFormDirtyState = formNode => {formNode = getFormFromChild(formNode);if (!formNode) {return;}formNode.dataset.formSubmitted = "false";formNode.dataset.formDirty = "false";};/*** Mark all forms as dirty.** This function is only for backwards-compliance with the old YUI module and should not be used in any other situation.* It will be removed in Moodle 4.4.** @method*/export const markAllFormsAsDirty = () => {watchedForms.forEach(watchedForm => {watchedForm.dataset.formDirty = "true";});};/*** Mark a specific form as dirty.** This behaviour may be required for custom form elements which are not caught by the standard change listeners.** @method* @param {HTMLElement} formNode*/export const markFormAsDirty = formNode => {formNode = getFormFromChild(formNode);if (!formNode) {return;}// Mark it as dirty.formNode.dataset.formDirty = "true";};/*** Actively disable the form change checker.** Please note that it cannot be re-enabled once disabled.** @method*/export const disableAllChecks = () => {formChangeCheckerDisabled = true;};/*** Check whether any watched from is dirty.** @method* @returns {Bool}*/export const isAnyWatchedFormDirty = () => {if (formChangeCheckerDisabled) {// The form change checker is disabled.return false;}const hasSubmittedForm = watchedForms.some(watchedForm => watchedForm.dataset.formSubmitted === "true");if (hasSubmittedForm) {// Do not warn about submitted forms, ever.return false;}const hasDirtyForm = watchedForms.some(watchedForm => {if (!watchedForm.isConnected) {// The watched form is not connected to the DOM.return false;}if (watchedForm.dataset.formDirty === "true") {// The form has been marked as dirty.return true;}// Elements currently holding focus will not have triggered change detection.// Check whether the value matches the original value upon form load.if (document.activeElement && document.activeElement.dataset.propertyIsEnumerable('initialValue')) {const isActiveElementWatched = isWatchingForm(document.activeElement)&& !shouldIgnoreChangesForNode(document.activeElement);const hasValueChanged = document.activeElement.dataset.initialValue !== document.activeElement.value;if (isActiveElementWatched && hasValueChanged) {return true;}}return false;});if (hasDirtyForm) {// At least one form is dirty.return true;}// Handle TinyMCE editor instances.// TinyMCE forms may not have been initialised at the time that startWatching is called.// Check whether any tinyMCE editor is dirty.if (typeof window.tinyMCE !== 'undefined' && window.tinyMCE.editors) {if (window.tinyMCE.editors.some(editor => editor.isDirty())) {return true;}}// No dirty forms detected.return false;};/*** Get the watched form for the specified target.** @method* @param {HTMLNode} target* @returns {HTMLFormElement}* @private*/const getFormForNode = target => watchedForms.find(watchedForm => watchedForm.contains(target));/*** Whether the specified target is a watched form.** @method* @param {HTMLNode} target* @returns {Bool}* @private*/const isWatchingForm = target => watchedForms.some(watchedForm => watchedForm.contains(target));/*** Whether the specified target should ignore changes or not.** @method* @param {HTMLNode} target* @returns {Bool}* @private*/const shouldIgnoreChangesForNode = target => !!target.closest('.ignoredirty');/*** Mark a form as changed.** @method* @param {HTMLElement} changedNode An element in the form which was changed*/export const markFormChangedFromNode = changedNode => {if (changedNode.dataset.formChangeCheckerOverride) {// Changes to this form node disable the form change checker entirely.// This is intended for select fields which cause an immediate redirect.disableAllChecks();return;}if (!isWatchingForm(changedNode)) {return;}if (shouldIgnoreChangesForNode(changedNode)) {return;}// Mark the form as dirty.const formNode = getFormForNode(changedNode);formNode.dataset.formDirty = "true";};/*** Mark a form as submitted.** @method* @param {HTMLElement} formNode An element in the form to mark as submitted*/export const markFormSubmitted = formNode => {formNode = getFormFromChild(formNode);if (!formNode) {return;}formNode.dataset.formSubmitted = "true";};/*** Mark all forms as submitted.** This function is only for backwards-compliance with the old YUI module and should not be used in any other situation.* It will be removed in Moodle 4.4.** @method*/export const markAllFormsSubmitted = () => {watchedForms.forEach(watchedForm => markFormSubmitted(watchedForm));};/*** Handle the beforeunload event.** @method* @param {Event} e* @returns {string|null}* @private*/const beforeUnloadHandler = e => {// Please note: The use of Promises in this function is forbidden.// This is an event handler and _cannot_ be asynchronous.let warnBeforeUnload = isAnyWatchedFormDirty() && !M.cfg.behatsiterunning;if (warnBeforeUnload) {// According to the specification, to show the confirmation dialog an event handler should call preventDefault()// on the event.e.preventDefault();// However note that not all browsers support this method, and some instead require the event handler to// implement one of two legacy methods:// * assigning a string to the event's returnValue property; and// * returning a string from the event handler.// Assigning a string to the event's returnValue property.e.returnValue = warningString;// Returning a string from the event handler.return e.returnValue;}// Attaching an event handler/listener to window or document's beforeunload event prevents browsers from using// in-memory page navigation caches, like Firefox's Back-Forward cache or WebKit's Page Cache.// Remove the handler.window.removeEventListener('beforeunload', beforeUnloadHandler);return null;};/*** Start watching for form changes.** This function is called on module load, and should not normally be called.** @method* @protected*/export const startWatching = () => {if (initialised) {return;}document.addEventListener('change', e => {if (!isWatchingForm(e.target)) {return;}markFormChangedFromNode(e.target);});document.addEventListener('click', e => {const ignoredButton = e.target.closest('[data-formchangechecker-ignore-submit]');if (!ignoredButton) {return;}const ownerForm = getFormFromChild(e.target);if (ownerForm) {ownerForm.dataset.ignoreSubmission = "true";}});document.addEventListener('focusin', e => {if (e.target.matches('input, textarea, select')) {if (e.target.dataset.propertyIsEnumerable('initialValue')) {// The initial value has already been set.return;}e.target.dataset.initialValue = e.target.value;}});document.addEventListener('submit', e => {const formNode = getFormFromChild(e.target);if (!formNode) {// Weird, but watch for this anyway.return;}if (formNode.dataset.ignoreSubmission) {// This form was submitted by a button which requested that the form checked should not mark it as submitted.formNode.dataset.ignoreSubmission = "false";return;}markFormSubmitted(formNode);});document.addEventListener(eventTypes.editorContentRestored, e => {if (e.target != document) {resetFormDirtyState(e.target);} else {resetAllFormDirtyStates();}});getString('changesmadereallygoaway', 'moodle').then(changesMadeString => {warningString = changesMadeString;return;}).catch();window.addEventListener('beforeunload', beforeUnloadHandler);};/*** Watch the form matching the specified ID for changes.** @method* @param {String} formId*/export const watchFormById = formId => {watchForm(document.getElementById(formId));};/*** Reset the dirty state of the form matching the specified ID..** @method* @param {String} formId*/export const resetFormDirtyStateById = formId => {resetFormDirtyState(document.getElementById(formId));};/*** Mark the form matching the specified ID as dirty.** @method* @param {String} formId*/export const markFormAsDirtyById = formId => {markFormAsDirty(document.getElementById(formId));};// Configure all event listeners.startWatching();