Merge "Bump wikimedia/parsoid to 0.21.0-a11"
[mediawiki.git] / resources / src / mediawiki.editRecovery / edit.js
blob352c2f9a0173db9f67283bb306b81b0c2ee8b4f5
1 /**
2  * In-progress edit recovery for action=edit
3  */
4 'use strict';
6 const storage = require( './storage.js' );
7 const LoadNotification = require( './LoadNotification.js' );
9 const pageName = mw.config.get( 'wgPageName' );
10 const section = $( 'input[name="wpSection"]' ).val() || null;
11 const inputFields = {};
12 const fieldNamePrefix = 'field_';
13 let originalData = {};
14 let changeDebounceTimer = null;
16 // Number of miliseconds to debounce form input.
17 const debounceTime = 5000;
19 // This module is loaded for every edit form, but not all should have Edit Recovery functioning.
20 const wasPosted = mw.config.get( 'wgEditRecoveryWasPosted' );
21 const isUndo = $( 'input[name="wpUndoAfter"]' ).length > 0;
22 const isOldRevision = $( 'input[name="oldid"]' ).val() > 0;
23 const isConflict = mw.config.get( 'wgEditMessage' ) === 'editconflict';
24 const useEditRecovery = !isUndo && !isOldRevision && !isConflict;
25 if ( useEditRecovery ) {
26         mw.hook( 'wikipage.editform' ).add( onLoadHandler );
27 } else {
28         // Always remove the data-saved flag when editing without Edit Recovery.
29         // It may have been set by a previous editing session (within 5 minutes) that did use ER.
30         mw.storage.session.remove( 'EditRecovery-data-saved' );
33 const windowManager = OO.ui.getWindowManager();
34 windowManager.addWindows( [ new mw.widgets.AbandonEditDialog() ] );
36 function onLoadHandler( $editForm ) {
37         mw.hook( 'wikipage.editform' ).remove( onLoadHandler );
39         // Monitor all text-entry inputs for changes/typing.
40         const inputsToMonitorSelector = 'textarea, select, input:not([type="hidden"], [type="submit"])';
41         const $inputsToMonitor = $editForm.find( inputsToMonitorSelector );
42         $inputsToMonitor.each( ( _i, field ) => {
43                 if ( field.classList.contains( 'oo-ui-inputWidget-input' ) ) {
44                         try {
45                                 inputFields[ field.name ] = OO.ui.infuse( field.closest( '.oo-ui-widget' ) );
46                         } catch ( e ) {
47                                 // Ignore any non-infusable widget because we won't be able to set its value.
48                         }
49                 } else {
50                         inputFields[ field.name ] = field;
51                 }
52         } );
53         // Save the contents of all of those, as well as the following hidden inputs.
54         const inputsToSaveNames = [ 'wpSection', 'editRevId', 'oldid', 'parentRevId', 'format', 'model' ];
55         const $inputsToSave = $editForm.find( '[name="' + inputsToSaveNames.join( '"], [name="' ) + '"]' );
56         $inputsToSave.each( ( _i, field ) => {
57                 inputFields[ field.name ] = field;
58         } );
60         // Store the original data for later comparing to the data-to-save. Use the defaultValue/defaultChecked in order to
61         // avoid using any data remembered by the browser. Note that we have to be careful to store with the same types as
62         // it will be done later, in order to correctly compare it (e.g. checkboxes as booleans).
63         for ( const fieldName in inputFields ) {
64                 const field = inputFields[ fieldName ];
65                 if ( field.nodeName === 'INPUT' || field.nodeName === 'TEXTAREA' ) {
66                         if ( field.type === 'checkbox' ) {
67                                 // Checkboxes (Minoredit and Watchthis are handled below as they are OOUI widgets).
68                                 originalData[ fieldNamePrefix + fieldName ] = field.defaultChecked;
69                         } else {
70                                 // Other HTMLInputElements.
71                                 originalData[ fieldNamePrefix + fieldName ] = field.defaultValue;
72                         }
73                 } else if ( field.$input !== undefined ) {
74                         // OOUI widgets, which may not have been infused by this point.
75                         if ( field.$input[ 0 ].type === 'checkbox' ) {
76                                 // Checkboxes.
77                                 originalData[ fieldNamePrefix + fieldName ] = field.$input[ 0 ].defaultChecked;
78                         } else {
79                                 // Other OOUI widgets.
80                                 originalData[ fieldNamePrefix + fieldName ] = field.$input[ 0 ].defaultValue;
81                         }
82                 }
83         }
85         // Set a short-lived (5m / see postEdit.js) localStorage item to indicate which section is being edited.
86         if ( section ) {
87                 mw.storage.session.set( pageName + '-editRecoverySection', section, 300 );
88         }
89         // Open indexedDB database and load any saved data that might be there.
90         storage.openDatabase().then( () => {
91                 // Check for and delete any expired data for any page, before loading any saved data for the current page.
92                 storage.deleteExpiredData().then( () => {
93                         storage.loadData( pageName, section ).then( onLoadData );
94                 } );
95         } );
97         // Set up cancel handler to delete data.
98         const cancelButton = OO.ui.infuse( $editForm.find( '#mw-editform-cancel' )[ 0 ] );
99         cancelButton.on( 'click', () => {
100                 windowManager.openWindow( 'abandonedit' ).closed.then( ( data ) => {
101                         if ( data && data.action === 'discard' ) {
102                                 // Note that originalData is used below in onLoadData() but that's always called before this method.
103                                 // Here we set originalData to null in order to signal to saveFormData() to deleted the stored data.
104                                 originalData = null;
105                                 storage.deleteData( pageName, section ).finally( () => {
106                                         mw.storage.session.remove( pageName + '-editRecoverySection' );
107                                         // Release the beforeunload handler from mediawiki.action.edit.editWarning,
108                                         // per the documentation there
109                                         $( window ).off( 'beforeunload.editwarning' );
110                                         location.href = cancelButton.getHref();
111                                 } );
112                         }
113                 } );
114         } );
117 function track( metric, value ) {
118         const dbName = mw.config.get( 'wgDBname' );
119         mw.track( `counter.MediaWiki.edit_recovery.${ metric }.by_wiki.${ dbName }`, value );
122 function onLoadData( pageData ) {
123         if ( wasPosted ) {
124                 // If this is a POST request, save the current data (e.g. from a preview).
125                 saveFormData();
126         }
127         // If there is data stored, load it into the form.
128         if ( !wasPosted && pageData !== undefined && !isSameAsOriginal( pageData, true ) ) {
129                 const loadNotification = new LoadNotification( {
130                         differentRev: originalData.field_parentRevId !== pageData.field_parentRevId
131                 } );
133                 // statsv: Track the number of times the edit recovery notification is shown.
134                 track( 'show', 1 );
136                 const notification = loadNotification.getNotification();
137                 // On 'restore changes'.
138                 loadNotification.getRecoverButton().on( 'click', () => {
139                         loadData( pageData );
140                         notification.close();
141                         // statsv: Track the number of times the edit recovery data is recovered.
142                         track( 'recover', 1 );
143                 } );
144                 // On 'discard changes'.
145                 loadNotification.getDiscardButton().on( 'click', () => {
146                         storage.deleteData( pageName, section ).then( () => {
147                                 notification.close();
148                         } );
149                         // statsv: Track the number of times the edit recovery data is discarded.
150                         track( 'discard', 1 );
151                 } );
152         }
154         // Add change handlers.
155         for ( const fieldName in inputFields ) {
156                 const field = inputFields[ fieldName ];
157                 if ( field.nodeName !== undefined && field.nodeName === 'TEXTAREA' ) {
158                         field.addEventListener( 'input', fieldChangeHandler );
159                 } else if ( field instanceof OO.ui.Widget ) {
160                         field.on( 'change', fieldChangeHandler );
161                 } else {
162                         field.addEventListener( 'change', fieldChangeHandler );
163                 }
164         }
165         // Also add handlers for when the window is closed or hidden. Saving the data at these points is not guaranteed to
166         // work, but it often does and the save operation is atomic so there's no harm in trying.
167         window.addEventListener( 'beforeunload', saveFormData );
168         window.addEventListener( 'blur', saveFormData );
170         /**
171          * Fired after EditRecovery has loaded any recovery data, added event handlers, etc.
172          *
173          * @event ~'editRecovery.loadEnd'
174          * @memberof Hooks
175          * @param {Object} editRecovery
176          * @param {Function} editRecovery.fieldChangeHandler
177          */
178         mw.hook( 'editRecovery.loadEnd' ).fire( { fieldChangeHandler: fieldChangeHandler } );
181 function loadData( pageData ) {
182         for ( const fieldName in inputFields ) {
183                 if ( pageData[ fieldNamePrefix + fieldName ] === undefined ) {
184                         return;
185                 }
186                 const field = inputFields[ fieldName ];
187                 const $field = $( field );
188                 // Set the field value depending on what type of field it is.
189                 if ( field instanceof OO.ui.CheckboxInputWidget ) {
190                         // OOUI checkbox widgets.
191                         field.setSelected( pageData[ fieldNamePrefix + fieldName ] );
192                 } else if ( field instanceof OO.ui.Widget ) {
193                         // Other OOUI widgets.
194                         field.setValue( pageData[ fieldNamePrefix + fieldName ], field );
195                 } else if ( field.nodeName === 'TEXTAREA' ) {
196                         // Textareas (also reset caret location to top).
197                         $field.textSelection( 'setContents', pageData[ fieldNamePrefix + fieldName ] );
198                         $field.textSelection( 'setSelection', { start: 0 } );
199                 } else {
200                         // Anything else.
201                         field.value = pageData[ fieldNamePrefix + fieldName ];
202                 }
203         }
206 function fieldChangeHandler() {
207         clearTimeout( changeDebounceTimer );
208         changeDebounceTimer = setTimeout( saveFormData, debounceTime );
212  * Compare a set of form field values to their original values (as at page load time).
214  * @ignore
215  * @param {Object} pageData The page data to compare to the original.
216  * @param {boolean} [ignoreRevIds=false] Do not use parent revision info when determining similarity.
217  * @return {boolean}
218  */
219 function isSameAsOriginal( pageData, ignoreRevIds = false ) {
220         for ( const fieldName in inputFields ) {
221                 if ( ignoreRevIds && ( fieldName === 'editRevId' || fieldName === 'parentRevId' ) ) {
222                         continue;
223                 }
224                 // Trim trailing whitespace from string fields, to approximate what PHP does when saving.
225                 let currentVal = pageData[ fieldNamePrefix + fieldName ];
226                 if ( typeof currentVal === 'string' ) {
227                         currentVal = currentVal.replace( /\s+$/, '' );
228                 }
229                 let originalVal = originalData[ fieldNamePrefix + fieldName ];
230                 if ( typeof originalVal === 'string' ) {
231                         originalVal = originalVal.replace( /\s+$/, '' );
232                 }
233                 if ( currentVal !== originalVal ) {
234                         return false;
235                 }
236         }
237         return true;
240 function saveFormData() {
241         const pageData = getFormData();
242         if ( ( originalData === null || isSameAsOriginal( pageData ) ) && !wasPosted ) {
243                 // Delete the stored data if there's no change,
244                 // or if we've flagged originalData as irrelevant,
245                 // or if we can't determine this because this page was POSTed.
246                 storage.deleteData( pageName, section );
247                 mw.storage.session.remove( 'EditRecovery-data-saved' );
248         } else {
249                 storage.saveData( pageName, section, pageData );
250                 // Flag the data for deletion in the postEdit handler in ./postEdit.js
251                 mw.storage.session.set( 'EditRecovery-data-saved', true, 300 );
252         }
256  * Get the current form data.
258  * @ignore
259  * @return {Object}
260  */
261 function getFormData() {
262         const formData = {};
263         for ( const fieldName in inputFields ) {
264                 const field = inputFields[ fieldName ];
265                 let newValue = null;
266                 if ( !( field instanceof OO.ui.Widget ) && field.nodeName !== undefined && field.nodeName === 'TEXTAREA' ) {
267                         // Text areas.
268                         newValue = $( field ).textSelection( 'getContents' );
269                 } else if ( field instanceof OO.ui.CheckboxInputWidget ) {
270                         // OOUI checkbox widgets.
271                         newValue = field.isSelected();
272                 } else if ( field instanceof OO.ui.Widget ) {
273                         // Other OOUI widgets.
274                         newValue = field.getValue();
275                 } else {
276                         // Anything else.
277                         newValue = field.value;
278                 }
279                 formData[ fieldNamePrefix + fieldName ] = newValue;
280         }
281         return formData;