Localisation updates from https://translatewiki.net.
[mediawiki.git] / resources / src / mediawiki.special.block / stores / block.js
blobc8d10f3eaaebcb3f54232066e62253c20c163f5c
1 const { defineStore } = require( 'pinia' );
2 const { computed, ComputedRef, ref, Ref, watch } = require( 'vue' );
3 const api = new mw.Api();
5 /**
6  * Pinia store for the SpecialBlock application.
7  */
8 module.exports = exports = defineStore( 'block', () => {
9         // ** State properties (refs) **
11         // Form fields.
12         // TODO: Rid of the `mw.config.get( 'whatever' )` atrocity once we have Codex PHP (T377529)
14         /**
15          * Whether the multiblocks feature is enabled with $wgEnableMultiBlocks.
16          *
17          * @type {boolean}
18          */
19         const enableMultiblocks = mw.config.get( 'blockEnableMultiblocks' ) || false;
21         /**
22          * The target user to block. Beyond the initial value,
23          * this is set only by the UserLookup component.
24          *
25          * @type {Ref<string>}
26          */
27         const targetUser = ref( mw.config.get( 'blockTargetUser' ) || '' );
28         /**
29          * The block ID of the block to modify.
30          *
31          * @type {Ref<number|null>}
32          */
33         const blockId = ref( mw.config.get( 'blockId' ) || null );
34         /**
35          * The block type, either `sitewide` or `partial`. This is set by the BlockTypeField component.
36          *
37          * @type {Ref<string>}
38          */
39         const type = ref( mw.config.get( 'blockTypePreset' ) || 'sitewide' );
40         /**
41          * The pages to restrict the partial block to.
42          *
43          * @type {Ref<string[]>}
44          */
45         const pages = ref( ( mw.config.get( 'blockPageRestrictions' ) || '' )
46                 .split( '\n' )
47                 .filter( Boolean )
48         );
49         /**
50          * The namespaces to restrict the partial block to.
51          *
52          * @type {Ref<number[]>}
53          */
54         const namespaces = ref( ( mw.config.get( 'blockNamespaceRestrictions' ) || '' )
55                 .split( '\n' )
56                 .filter( Boolean )
57                 .map( Number )
58         );
59         /**
60          * Actions to apply the partial block to,
61          * i.e. `ipb-action-create`, `ipb-action-move`, `ipb-action-upload`.
62          *
63          * @type {Ref<string[]>}
64          */
65         const partialOptions = ref( [] );
66         /**
67          * The expiry of the block.
68          *
69          * @type {Ref<string>}
70          */
71         const expiry = ref(
72                 // From URL, ?wpExpiry=...
73                 mw.config.get( 'blockExpiryPreset' ) ||
74                 // From [[MediaWiki:ipb-default-expiry]] or [[MediaWiki:ipb-default-expiry-ip]].
75                 mw.config.get( 'blockExpiryDefault' ) ||
76                 ''
77         );
78         /**
79          * The block summary, as selected from via the dropdown in the ReasonField component.
80          * These options are ultimately defined by [[MediaWiki:Ipbreason-dropdown]].
81          *
82          * @type {Ref<string>}
83          * @todo Combine with `reasonOther` here within the store.
84          */
85         const reason = ref( 'other' );
86         /**
87          * The free-form text for the block summary.
88          *
89          * @type {Ref<string>}
90          * @todo Combine with `reason` here within the store.
91          */
92         const reasonOther = ref( mw.config.get( 'blockReasonOtherPreset' ) || '' );
93         const details = mw.config.get( 'blockDetailsPreset' ) || [];
94         /**
95          * Whether to block an IP or IP range from creating accounts.
96          *
97          * @type {Ref<boolean>}
98          */
99         const createAccount = ref( details.indexOf( 'wpCreateAccount' ) !== -1 );
100         /**
101          * Whether to disable the target's ability to send email via Special:EmailUser.
102          *
103          * @type {Ref<boolean>}
104          */
105         const disableEmail = ref( details.indexOf( 'wpDisableEmail' ) !== -1 );
106         /**
107          * Whether to disable the target's ability to edit their own user talk page.
108          *
109          * @type {Ref<boolean>}
110          */
111         const disableUTEdit = ref( details.indexOf( 'wpDisableUTEdit' ) !== -1 );
112         const additionalDetails = mw.config.get( 'blockAdditionalDetailsPreset' ) || [];
113         /**
114          * Whether to autoblock IP addresses used by the target.
115          *
116          * @type {Ref<boolean>}
117          * @see https://www.mediawiki.org/wiki/Autoblock
118          */
119         const autoBlock = ref( additionalDetails.indexOf( 'wpAutoBlock' ) !== -1 );
120         /**
121          * Whether to impose a "suppressed" block, hiding the target's username
122          * from block log, the active block list, and the user list.
123          *
124          * @type {Ref<boolean>}
125          */
126         const hideName = ref( additionalDetails.indexOf( 'wpHideName' ) !== -1 );
127         /**
128          * Whether to watch the target's user page and talk page.
129          *
130          * @type {Ref<boolean>}
131          */
132         const watchUser = ref( additionalDetails.indexOf( 'wpWatch' ) !== -1 );
133         /**
134          * Whether to apply a hard block, blocking accounts using the same IP address.
135          *
136          * @type {Ref<boolean>}
137          */
138         const hardBlock = ref( additionalDetails.indexOf( 'wpHardBlock' ) !== -1 );
140         // Other refs that don't have corresponding form fields.
142         /**
143          * Errors pertaining the form as a whole, shown at the top.
144          *
145          * @type {Ref<string[]>}
146          */
147         const formErrors = ref( mw.config.get( 'blockPreErrors' ) || [] );
148         /**
149          * Whether the form has been submitted. This is watched by UserLookup
150          * and ExpiryField to trigger validation on form submission.
151          * After submission, this remains true until a form field is altered.
152          * This is to ensure post-submission formErrors are not prematurely cleared.
153          *
154          * @type {Ref<boolean>}
155          */
156         const formSubmitted = ref( false );
157         /**
158          * Whether the form is visible. This is set by the SpecialBlock component,
159          * and unset by a watcher when the target user changes.
160          *
161          * @type {Ref<boolean>}
162          */
163         const formVisible = ref( false );
164         /**
165          * Whether the block was successful.
166          *
167          * @type {Ref<boolean>}
168          */
169         const success = ref( false );
170         /**
171          * Whether the target user is already blocked. This is set
172          * after fetching block log data from the API.
173          *
174          * @type {Ref<boolean>}
175          */
176         const alreadyBlocked = ref( mw.config.get( 'blockAlreadyBlocked' ) || false );
177         /**
178          * Keep track of all UI-blocking API requests that are currently in flight.
179          *
180          * @type {Ref<Set<Promise|jQuery.Promise>>}
181          */
182         const promises = ref( new Set() );
183         /**
184          * Confirmation dialog message. When not null, the confirmation dialog will be
185          * shown on submission. This is set automatically by a watcher in the store.
186          *
187          * @type {Ref<string>}
188          */
189         const confirmationMessage = ref( '' );
191         // ** Getters (computed properties) **
193         /**
194          * Whether the form is disabled due to an in-flight API request.
195          *
196          * @type {ComputedRef<boolean>}
197          */
198         const formDisabled = computed( () => !!promises.value.size );
199         /**
200          * Controls visibility of the 'Hide username' checkbox. True when the user has the
201          * hideuser right (this is passed from PHP), and the block is sitewide and infinite.
202          *
203          * @type {ComputedRef<boolean>}
204          */
205         const hideNameVisible = computed( () => {
206                 const typeVal = type.value;
207                 return mw.config.get( 'blockHideUser' ) &&
208                         typeVal === 'sitewide' &&
209                         mw.util.isInfinity( expiry.value );
210         } );
211         /**
212          * Convenience computed prop indicating if confirmation is needed on submission.
213          *
214          * @type {ComputedRef<boolean>}
215          */
216         const confirmationNeeded = computed( () => !!confirmationMessage.value );
218         // ** Watchers **
220         // Show confirmation dialog if 'Hide username' is visible and selected,
221         // or if the target user is the current user.
222         watch(
223                 computed( () => [ targetUser.value, hideName.value, hideNameVisible.value ] ),
224                 ( [ newTargetUser, newHideName, newHideNameVisible ] ) => {
225                         if ( newHideNameVisible && newHideName ) {
226                                 confirmationMessage.value = mw.message( 'ipb-confirmhideuser' ).parse();
227                         } else if ( newTargetUser === mw.config.get( 'wgUserName' ) ) {
228                                 confirmationMessage.value = mw.msg( 'ipb-blockingself' );
229                         } else {
230                                 confirmationMessage.value = '';
231                         }
232                 },
233                 // Ensure confirmationMessage is set on initial load.
234                 { immediate: true }
235         );
237         // Hide the form and clear form-related refs when the target user changes.
238         watch( targetUser, resetFormInternal );
240         /**
241          * The current in-flight API request for block log data. This is used to
242          * avoid redundant API queries when rendering multiple BlockLog components.
243          *
244          * @type {Promise|null}
245          */
246         let blockLogPromise = null;
247         // Reset the blockLogPromise when the target user changes or the form is submitted.
248         watch( [ targetUser, formSubmitted ], () => {
249                 blockLogPromise = null;
250         } );
252         // ** Actions (exported functions) **
254         /**
255          * Load block data from an action=blocks API response.
256          *
257          * @param {Object} blockData The block's item from the API.
258          * @param {boolean} [loadingFromParam=false] Whether the data is being loaded from URL parameters.
259          */
260         function loadFromData( blockData, loadingFromParam = false ) {
261                 if ( loadingFromParam ) {
262                         targetUser.value = blockData.user;
263                         formVisible.value = true;
264                 }
265                 blockId.value = blockData.id;
266                 type.value = blockData.partial ? 'partial' : 'sitewide';
267                 pages.value = ( blockData.restrictions.pages || [] ).map( ( i ) => i.title );
268                 namespaces.value = blockData.restrictions.namespaces || [];
269                 expiry.value = blockData.expiry;
270                 partialOptions.value = ( blockData.restrictions.actions || [] ).map( ( i ) => 'ipb-action-' + i );
271                 // The reason is a single string that possibly starts with one of the predefined reasons,
272                 // and can have an 'other' value separated by a colon.
273                 // Here we replicate what's done in PHP in HTMLSelectAndOtherField at https://w.wiki/CPMs
274                 reason.value = 'other';
275                 reasonOther.value = blockData.reason;
276                 for ( const opt of mw.config.get( 'blockReasonOptions' ) ) {
277                         const possPrefix = opt.value + mw.msg( 'colon-separator' );
278                         if ( reasonOther.value.startsWith( possPrefix ) ) {
279                                 reason.value = opt.value;
280                                 reasonOther.value = reasonOther.value.slice( possPrefix.length );
281                                 break;
282                         }
283                 }
284                 createAccount.value = blockData.nocreate;
285                 disableEmail.value = blockData.noemail;
286                 disableUTEdit.value = !blockData.allowusertalk;
287                 hardBlock.value = !blockData.anononly;
288                 hideName.value = blockData.hidden;
289                 autoBlock.value = blockData.autoblock;
290                 // We do not need to set watchUser as its state is never loaded from a block.
291         }
293         /**
294          * Reset the form to default values, optionally clearing the target user.
295          * The values here should be the defaults set on the OOUI elements in SpecialBlock.php.
296          * These are not the same as the *preset* values fetched from URL parameters.
297          *
298          * @param {boolean} [full=false] Whether to clear the target user.
299          * @todo Infuse default values once we have Codex PHP (T377529).
300          *   Until then this needs to be manually kept in sync with the PHP defaults.
301          */
302         function resetForm( full = false ) {
303                 // Form fields
304                 if ( full ) {
305                         targetUser.value = '';
306                 }
307                 blockId.value = null;
308                 type.value = 'sitewide';
309                 pages.value = [];
310                 namespaces.value = [];
311                 partialOptions.value = [];
312                 expiry.value = '';
313                 reason.value = 'other';
314                 reasonOther.value = '';
315                 createAccount.value = true;
316                 disableEmail.value = false;
317                 disableUTEdit.value = false;
318                 autoBlock.value = true;
319                 hideName.value = false;
320                 watchUser.value = false;
321                 hardBlock.value = false;
322                 // Other refs
323                 resetFormInternal();
324         }
326         /**
327          * Clear form behavioural refs.
328          *
329          * @param {boolean} [loadingFromParam=false] Whether the data is being loaded from URL parameters.
330          * @internal
331          */
332         function resetFormInternal( loadingFromParam = false ) {
333                 formErrors.value = [];
334                 formSubmitted.value = false;
335                 if ( !loadingFromParam ) {
336                         formVisible.value = false;
337                 }
338                 success.value = false;
339                 alreadyBlocked.value = false;
340                 promises.value.clear();
341         }
343         /**
344          * Execute the block.
345          *
346          * @return {jQuery.Promise}
347          */
348         function doBlock() {
349                 const params = {
350                         action: 'block',
351                         format: 'json',
352                         user: targetUser.value,
353                         expiry: expiry.value,
354                         // Localize errors
355                         uselang: mw.config.get( 'wgUserLanguage' ),
356                         errorlang: mw.config.get( 'wgUserLanguage' ),
357                         errorsuselocal: true
358                 };
360                 if ( !enableMultiblocks && alreadyBlocked.value ) {
361                         params.reblock = 1;
362                 }
364                 if ( enableMultiblocks ) {
365                         if ( blockId.value ) {
366                                 params.id = blockId.value;
367                                 delete params.user;
368                         } else {
369                                 params.newblock = 1;
370                         }
371                 }
373                 // Reason selected concatenated with 'Other' field
374                 if ( reason.value === 'other' ) {
375                         params.reason = reasonOther.value;
376                 } else {
377                         params.reason = reason.value + (
378                                 reasonOther.value ? mw.msg( 'colon-separator' ) + reasonOther.value : ''
379                         );
380                 }
382                 if ( type.value === 'partial' ) {
383                         const actionRestrictions = [];
384                         params.partial = 1;
385                         if ( partialOptions.value.indexOf( 'ipb-action-upload' ) !== -1 ) {
386                                 actionRestrictions.push( 'upload' );
387                         }
388                         if ( partialOptions.value.indexOf( 'ipb-action-move' ) !== -1 ) {
389                                 actionRestrictions.push( 'move' );
390                         }
391                         if ( partialOptions.value.indexOf( 'ipb-action-create' ) !== -1 ) {
392                                 actionRestrictions.push( 'create' );
393                         }
394                         params.actionrestrictions = actionRestrictions.join( '|' );
396                         if ( pages.value.length ) {
397                                 params.pagerestrictions = pages.value.join( '|' );
398                         }
399                         if ( namespaces.value.length ) {
400                                 params.namespacerestrictions = namespaces.value.join( '|' );
401                         }
402                 }
404                 if ( createAccount.value ) {
405                         params.nocreate = 1;
406                 }
408                 if ( disableEmail.value ) {
409                         params.noemail = 1;
410                 }
412                 if ( !disableUTEdit.value ) {
413                         params.allowusertalk = 1;
414                 }
416                 if ( autoBlock.value ) {
417                         params.autoblock = 1;
418                 }
420                 if ( hideNameVisible.value && hideName.value ) {
421                         params.hidename = 1;
422                 }
424                 if ( watchUser.value ) {
425                         params.watchuser = 1;
426                 }
428                 if ( !hardBlock.value && mw.util.isIPAddress( targetUser.value, true ) ) {
429                         params.anononly = 1;
430                 }
432                 // Clear any previous errors.
433                 formErrors.value = [];
435                 return pushPromise( api.postWithEditToken( params ) );
436         }
438         /**
439          * Query the API for data needed by the BlockLog component. This method caches the response
440          * by target user to consolidate API requests across multiple BlockLog components.
441          * The cache is cleared when the target user changes by a watcher in the store.
442          *
443          * @param {string} blockLogType Which data to fetch. One of 'recent', 'active', or 'suppress'.
444          * @return {Promise|jQuery.Promise}
445          */
446         function getBlockLogData( blockLogType ) {
447                 if ( blockLogPromise && blockLogType !== 'suppress' ) {
448                         // Serve block log data from cache if available.
449                         return blockLogPromise;
450                 }
452                 const params = {
453                         action: 'query',
454                         format: 'json',
455                         leprop: 'ids|title|type|user|timestamp|comment|details',
456                         letitle: `User:${ targetUser.value }`,
457                         list: 'logevents',
458                         formatversion: 2
459                 };
461                 if ( blockLogType === 'suppress' ) {
462                         const localPromises = [];
463                         // Query both the block and reblock actions of the suppression log.
464                         params.leaction = 'suppress/block';
465                         localPromises.push( pushPromise( api.get( params ) ) );
466                         params.leaction = 'suppress/reblock';
467                         localPromises.push( pushPromise( api.get( params ) ) );
468                         return Promise.all( localPromises );
469                 }
471                 // Cache miss for block log data.
472                 // Add params needed to fetch block log and active blocks in one request.
473                 params.list = 'logevents|blocks';
474                 params.letype = 'block';
475                 params.bkprop = 'id|user|by|timestamp|expiry|reason|range|flags|restrictions';
476                 params.bkusers = targetUser.value;
478                 const actualPromise = api.get( params );
479                 actualPromise.then( ( data ) => {
480                         alreadyBlocked.value = data.query.blocks.length > 0;
481                 } );
482                 blockLogPromise = Promise.all( [ actualPromise ] );
483                 return pushPromise( blockLogPromise );
484         }
486         /**
487          * Add a promise to the `Set` of pending promises.
488          * This is used solely to disable the form while waiting for a response,
489          * and should only be used for requests that need to block UI interaction.
490          * The promise will be removed from the Set when it resolves, and
491          * once the Set is empty, the form will be re-enabled.
492          *
493          * @param {Promise|jQuery.Promise} promise
494          * @return {Promise|jQuery.Promise} The same unresolved promise that was passed in.
495          */
496         function pushPromise( promise ) {
497                 promises.value.add( promise );
498                 // Can't use .finally() because it's not supported in jQuery.
499                 promise.then(
500                         () => promises.value.delete( promise ),
501                         () => promises.value.delete( promise )
502                 );
503                 return promise;
504         }
506         return {
507                 enableMultiblocks,
508                 formDisabled,
509                 formErrors,
510                 formSubmitted,
511                 formVisible,
512                 targetUser,
513                 success,
514                 blockId,
515                 alreadyBlocked,
516                 type,
517                 expiry,
518                 partialOptions,
519                 pages,
520                 namespaces,
521                 reason,
522                 reasonOther,
523                 createAccount,
524                 disableEmail,
525                 disableUTEdit,
526                 autoBlock,
527                 hideName,
528                 hideNameVisible,
529                 watchUser,
530                 hardBlock,
531                 confirmationMessage,
532                 confirmationNeeded,
533                 loadFromData,
534                 resetForm,
535                 doBlock,
536                 getBlockLogData
537         };
538 } );