1 const { defineStore } = require( 'pinia' );
2 const { computed, ComputedRef, ref, Ref, watch } = require( 'vue' );
3 const api = new mw.Api();
6 * Pinia store for the SpecialBlock application.
8 module.exports = exports = defineStore( 'block', () => {
9 // ** State properties (refs) **
12 // TODO: Rid of the `mw.config.get( 'whatever' )` atrocity once we have Codex PHP (T377529)
15 * Whether the multiblocks feature is enabled with $wgEnableMultiBlocks.
19 const enableMultiblocks = mw.config.get( 'blockEnableMultiblocks' ) || false;
22 * The target user to block. Beyond the initial value,
23 * this is set only by the UserLookup component.
27 const targetUser = ref( mw.config.get( 'blockTargetUser' ) || '' );
29 * The block ID of the block to modify.
31 * @type {Ref<number|null>}
33 const blockId = ref( mw.config.get( 'blockId' ) || null );
35 * The block type, either `sitewide` or `partial`. This is set by the BlockTypeField component.
39 const type = ref( mw.config.get( 'blockTypePreset' ) || 'sitewide' );
41 * The pages to restrict the partial block to.
43 * @type {Ref<string[]>}
45 const pages = ref( ( mw.config.get( 'blockPageRestrictions' ) || '' )
50 * The namespaces to restrict the partial block to.
52 * @type {Ref<number[]>}
54 const namespaces = ref( ( mw.config.get( 'blockNamespaceRestrictions' ) || '' )
60 * Actions to apply the partial block to,
61 * i.e. `ipb-action-create`, `ipb-action-move`, `ipb-action-upload`.
63 * @type {Ref<string[]>}
65 const partialOptions = ref( [] );
67 * The expiry of the block.
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' ) ||
79 * The block summary, as selected from via the dropdown in the ReasonField component.
80 * These options are ultimately defined by [[MediaWiki:Ipbreason-dropdown]].
83 * @todo Combine with `reasonOther` here within the store.
85 const reason = ref( 'other' );
87 * The free-form text for the block summary.
90 * @todo Combine with `reason` here within the store.
92 const reasonOther = ref( mw.config.get( 'blockReasonOtherPreset' ) || '' );
93 const details = mw.config.get( 'blockDetailsPreset' ) || [];
95 * Whether to block an IP or IP range from creating accounts.
97 * @type {Ref<boolean>}
99 const createAccount = ref( details.indexOf( 'wpCreateAccount' ) !== -1 );
101 * Whether to disable the target's ability to send email via Special:EmailUser.
103 * @type {Ref<boolean>}
105 const disableEmail = ref( details.indexOf( 'wpDisableEmail' ) !== -1 );
107 * Whether to disable the target's ability to edit their own user talk page.
109 * @type {Ref<boolean>}
111 const disableUTEdit = ref( details.indexOf( 'wpDisableUTEdit' ) !== -1 );
112 const additionalDetails = mw.config.get( 'blockAdditionalDetailsPreset' ) || [];
114 * Whether to autoblock IP addresses used by the target.
116 * @type {Ref<boolean>}
117 * @see https://www.mediawiki.org/wiki/Autoblock
119 const autoBlock = ref( additionalDetails.indexOf( 'wpAutoBlock' ) !== -1 );
121 * Whether to impose a "suppressed" block, hiding the target's username
122 * from block log, the active block list, and the user list.
124 * @type {Ref<boolean>}
126 const hideName = ref( additionalDetails.indexOf( 'wpHideName' ) !== -1 );
128 * Whether to watch the target's user page and talk page.
130 * @type {Ref<boolean>}
132 const watchUser = ref( additionalDetails.indexOf( 'wpWatch' ) !== -1 );
134 * Whether to apply a hard block, blocking accounts using the same IP address.
136 * @type {Ref<boolean>}
138 const hardBlock = ref( additionalDetails.indexOf( 'wpHardBlock' ) !== -1 );
140 // Other refs that don't have corresponding form fields.
143 * Errors pertaining the form as a whole, shown at the top.
145 * @type {Ref<string[]>}
147 const formErrors = ref( mw.config.get( 'blockPreErrors' ) || [] );
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.
154 * @type {Ref<boolean>}
156 const formSubmitted = ref( false );
158 * Whether the form is visible. This is set by the SpecialBlock component,
159 * and unset by a watcher when the target user changes.
161 * @type {Ref<boolean>}
163 const formVisible = ref( false );
165 * Whether the block was successful.
167 * @type {Ref<boolean>}
169 const success = ref( false );
171 * Whether the target user is already blocked. This is set
172 * after fetching block log data from the API.
174 * @type {Ref<boolean>}
176 const alreadyBlocked = ref( mw.config.get( 'blockAlreadyBlocked' ) || false );
178 * Keep track of all UI-blocking API requests that are currently in flight.
180 * @type {Ref<Set<Promise|jQuery.Promise>>}
182 const promises = ref( new Set() );
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.
187 * @type {Ref<string>}
189 const confirmationMessage = ref( '' );
191 // ** Getters (computed properties) **
194 * Whether the form is disabled due to an in-flight API request.
196 * @type {ComputedRef<boolean>}
198 const formDisabled = computed( () => !!promises.value.size );
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.
203 * @type {ComputedRef<boolean>}
205 const hideNameVisible = computed( () => {
206 const typeVal = type.value;
207 return mw.config.get( 'blockHideUser' ) &&
208 typeVal === 'sitewide' &&
209 mw.util.isInfinity( expiry.value );
212 * Convenience computed prop indicating if confirmation is needed on submission.
214 * @type {ComputedRef<boolean>}
216 const confirmationNeeded = computed( () => !!confirmationMessage.value );
220 // Show confirmation dialog if 'Hide username' is visible and selected,
221 // or if the target user is the current user.
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' );
230 confirmationMessage.value = '';
233 // Ensure confirmationMessage is set on initial load.
237 // Hide the form and clear form-related refs when the target user changes.
238 watch( targetUser, resetFormInternal );
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.
244 * @type {Promise|null}
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;
252 // ** Actions (exported functions) **
255 * Load block data from an action=blocks API response.
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.
260 function loadFromData( blockData, loadingFromParam = false ) {
261 if ( loadingFromParam ) {
262 targetUser.value = blockData.user;
263 formVisible.value = true;
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 );
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.
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.
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.
302 function resetForm( full = false ) {
305 targetUser.value = '';
307 blockId.value = null;
308 type.value = 'sitewide';
310 namespaces.value = [];
311 partialOptions.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;
327 * Clear form behavioural refs.
329 * @param {boolean} [loadingFromParam=false] Whether the data is being loaded from URL parameters.
332 function resetFormInternal( loadingFromParam = false ) {
333 formErrors.value = [];
334 formSubmitted.value = false;
335 if ( !loadingFromParam ) {
336 formVisible.value = false;
338 success.value = false;
339 alreadyBlocked.value = false;
340 promises.value.clear();
346 * @return {jQuery.Promise}
352 user: targetUser.value,
353 expiry: expiry.value,
355 uselang: mw.config.get( 'wgUserLanguage' ),
356 errorlang: mw.config.get( 'wgUserLanguage' ),
360 if ( !enableMultiblocks && alreadyBlocked.value ) {
364 if ( enableMultiblocks ) {
365 if ( blockId.value ) {
366 params.id = blockId.value;
373 // Reason selected concatenated with 'Other' field
374 if ( reason.value === 'other' ) {
375 params.reason = reasonOther.value;
377 params.reason = reason.value + (
378 reasonOther.value ? mw.msg( 'colon-separator' ) + reasonOther.value : ''
382 if ( type.value === 'partial' ) {
383 const actionRestrictions = [];
385 if ( partialOptions.value.indexOf( 'ipb-action-upload' ) !== -1 ) {
386 actionRestrictions.push( 'upload' );
388 if ( partialOptions.value.indexOf( 'ipb-action-move' ) !== -1 ) {
389 actionRestrictions.push( 'move' );
391 if ( partialOptions.value.indexOf( 'ipb-action-create' ) !== -1 ) {
392 actionRestrictions.push( 'create' );
394 params.actionrestrictions = actionRestrictions.join( '|' );
396 if ( pages.value.length ) {
397 params.pagerestrictions = pages.value.join( '|' );
399 if ( namespaces.value.length ) {
400 params.namespacerestrictions = namespaces.value.join( '|' );
404 if ( createAccount.value ) {
408 if ( disableEmail.value ) {
412 if ( !disableUTEdit.value ) {
413 params.allowusertalk = 1;
416 if ( autoBlock.value ) {
417 params.autoblock = 1;
420 if ( hideNameVisible.value && hideName.value ) {
424 if ( watchUser.value ) {
425 params.watchuser = 1;
428 if ( !hardBlock.value && mw.util.isIPAddress( targetUser.value, true ) ) {
432 // Clear any previous errors.
433 formErrors.value = [];
435 return pushPromise( api.postWithEditToken( params ) );
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.
443 * @param {string} blockLogType Which data to fetch. One of 'recent', 'active', or 'suppress'.
444 * @return {Promise|jQuery.Promise}
446 function getBlockLogData( blockLogType ) {
447 if ( blockLogPromise && blockLogType !== 'suppress' ) {
448 // Serve block log data from cache if available.
449 return blockLogPromise;
455 leprop: 'ids|title|type|user|timestamp|comment|details',
456 letitle: `User:${ targetUser.value }`,
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 );
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;
482 blockLogPromise = Promise.all( [ actualPromise ] );
483 return pushPromise( blockLogPromise );
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.
493 * @param {Promise|jQuery.Promise} promise
494 * @return {Promise|jQuery.Promise} The same unresolved promise that was passed in.
496 function pushPromise( promise ) {
497 promises.value.add( promise );
498 // Can't use .finally() because it's not supported in jQuery.
500 () => promises.value.delete( promise ),
501 () => promises.value.delete( promise )