aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMusikAnimal <musikanimal@gmail.com>2024-12-11 21:34:21 -0500
committerMusikAnimal <musikanimal@gmail.com>2024-12-15 06:44:39 +0000
commitd71957b4125600d44705940835ba30f0b21f51c3 (patch)
treefdab7e911781dcc64d331f0a809056ac26607329
parentc0a60844892bc0d718f297bf5d7c40c6c26911b5 (diff)
downloadmediawikicore-d71957b4125600d44705940835ba30f0b21f51c3.tar.gz
mediawikicore-d71957b4125600d44705940835ba30f0b21f51c3.zip
SpecialBlock [Codex]: fix reblocking and rework store
Attempt to give the store some structure, somewhat mimicking how Option Stores are structured in having a section for state prop (refs), getters (computed props), and actions (functions). Add JSDoc blocks for all exported symbols, detailing their purpose and reactivity. Remove anything from the store that isn't shared across components or necessary for other store logic, and remove ref()s from constants. $reset() is now resetForm(), since we're resetting to default values (as established in SpecialBlock.php) rather than the initial state of the store. We call this when adding a new block, or when resetting the form via the clear button in the UserLookup component. As such, the initial values for properties are restored to the ref declarations. Add basic selenium test that creates an account, logs in as an admin, blocks the newly created user, and asserts the success message. This test passes an example expiry via URL query parameter, effectively testing that it gets set properly in SpecialBlock.vue. Bug fixes: * Ensure alreadyBlocked prop is set when it should be, and reblock=1 is passed to API * Pre-fill form fields form URL parameters, as applicable * The default value for the wpCreateAccount URL param should be true * Hide the form when the targetUser changes * Fix Vue warning in SpecialBlock.vue Bug: T382035 Change-Id: I864e7cd259b14fbf6444b4c54eeedd6fb01db844
-rw-r--r--includes/specials/SpecialBlock.php3
-rw-r--r--languages/i18n/en.json1
-rw-r--r--languages/i18n/qqq.json1
-rw-r--r--resources/Resources.php1
-rw-r--r--resources/src/mediawiki.special.block/SpecialBlock.vue51
-rw-r--r--resources/src/mediawiki.special.block/components/AdditionalDetailsField.vue17
-rw-r--r--resources/src/mediawiki.special.block/components/BlockDetailsField.vue19
-rw-r--r--resources/src/mediawiki.special.block/components/BlockLog.vue16
-rw-r--r--resources/src/mediawiki.special.block/components/UserLookup.vue5
-rw-r--r--resources/src/mediawiki.special.block/stores/block.js328
-rw-r--r--tests/jest/mediawiki.special.block/AdditionalDetailsField.test.js23
-rw-r--r--tests/jest/mediawiki.special.block/BlockDetailsField.test.js29
-rw-r--r--tests/jest/mediawiki.special.block/ExpiryField.test.js2
-rw-r--r--tests/jest/mediawiki.special.block/SpecialBlock.setup.js5
-rw-r--r--tests/jest/mediawiki.special.block/SpecialBlock.test.js15
-rw-r--r--tests/jest/mediawiki.special.block/stores/block.test.js52
-rw-r--r--tests/selenium/pageobjects/block.page.js59
-rw-r--r--tests/selenium/specs/user.js13
18 files changed, 460 insertions, 180 deletions
diff --git a/includes/specials/SpecialBlock.php b/includes/specials/SpecialBlock.php
index 399833f9a13b..3776f9359c96 100644
--- a/includes/specials/SpecialBlock.php
+++ b/includes/specials/SpecialBlock.php
@@ -252,7 +252,8 @@ class SpecialBlock extends FormSpecialPage {
$this->codexFormData[ 'blockReasonOtherPreset' ] = $request->getVal( 'wpReason-other' );
$blockAdditionalDetailsPreset = $blockDetailsPreset = [];
- if ( $request->getBool( 'wpCreateAccount' ) ) {
+ // Default is to always block account creation.
+ if ( $request->getBool( 'wpCreateAccount', true ) ) {
$blockDetailsPreset[] = 'wpCreateAccount';
}
diff --git a/languages/i18n/en.json b/languages/i18n/en.json
index 0971bf091ad6..d442a0bd1060 100644
--- a/languages/i18n/en.json
+++ b/languages/i18n/en.json
@@ -2712,6 +2712,7 @@
"block-target-placeholder": "Username, IP address, or IP range",
"block-pages-placeholder": "Add more pages...",
"block-namespaces-placeholder": "Add more namespaces...",
+ "block-update": "Update block",
"unblockip": "Unblock user",
"unblockiptext": "Use the form below to restore write access to a previously blocked IP address or username.",
"ipusubmit": "Remove this block",
diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json
index 4d2b99748cb6..32d099cdfe21 100644
--- a/languages/i18n/qqq.json
+++ b/languages/i18n/qqq.json
@@ -2976,6 +2976,7 @@
"block-target-placeholder": "Placeholder text for the input specifying the target of a block on [[Special:Block]]",
"block-pages-placeholder": "Placeholder text for the input specifying the pages the user is blocked from editing on [[Special:Block]]",
"block-namespaces-placeholder": "Placeholder text for the input specifying the namespaces the user is blocked from editing on [[Special:Block]]",
+ "block-update": "Button label for the button with which to update a block on [[Special:Block]]",
"unblockip": "Used as title and legend for the form in [[Special:Unblock]].",
"unblockiptext": "Used in the {{msg-mw|Unblockip}} form on [[Special:Unblock]].",
"ipusubmit": "Used as button text on [{{canonicalurl:Special:BlockList|action=unblock}} Special:BlockList?action=unblock]. To see the message:\n* Go to [[Special:BlockList]]\n* Click \"unblock\" for any block (but you can only see \"unblock\" if you have administrator rights)\n* It is now the button below the form",
diff --git a/resources/Resources.php b/resources/Resources.php
index 1efdb7b5f059..75bdd79871d6 100644
--- a/resources/Resources.php
+++ b/resources/Resources.php
@@ -2346,6 +2346,7 @@ return [
'block-success',
'block-target',
'block-target-placeholder',
+ 'block-update',
'block-user-active-blocks',
'block-user-label-count-exceeds-limit',
'block-user-no-active-blocks',
diff --git a/resources/src/mediawiki.special.block/SpecialBlock.vue b/resources/src/mediawiki.special.block/SpecialBlock.vue
index f4cbf42c94a9..c09a9de321cd 100644
--- a/resources/src/mediawiki.special.block/SpecialBlock.vue
+++ b/resources/src/mediawiki.special.block/SpecialBlock.vue
@@ -6,7 +6,7 @@
<cdx-field
class="mw-block-fieldset"
:is-fieldset="true"
- :disabled="!!store.promises.size"
+ :disabled="store.formDisabled"
>
<div ref="messagesContainer" class="mw-block-messages">
<cdx-message
@@ -38,8 +38,8 @@
<block-log
:key="`${submitCount}-active`"
:open="success"
- block-log-type="active"
:can-delete-log-entry="false"
+ block-log-type="active"
@create-block="onCreateBlock"
@edit-block="onEditBlock"
></block-log>
@@ -55,7 +55,7 @@
:can-delete-log-entry="canDeleteLogEntry"
></block-log>
- <div v-if="showForm" class="mw-block__block-form">
+ <div v-if="formVisible" class="mw-block__block-form">
<block-type-field></block-type-field>
<expiry-field></expiry-field>
<reason-field
@@ -80,7 +80,6 @@
action="destructive"
weight="primary"
class="mw-block-submit"
- :disabled="!!store.promises.size"
@click="onFormSubmission"
>
{{ submitButtonMessage }}
@@ -104,6 +103,9 @@ const BlockDetailsField = require( './components/BlockDetailsField.vue' );
const AdditionalDetailsField = require( './components/AdditionalDetailsField.vue' );
const ConfirmationDialog = require( './components/ConfirmationDialog.vue' );
+/**
+ * Top-level component for the Special:Block Vue application.
+ */
module.exports = exports = defineComponent( {
name: 'SpecialBlock',
components: {
@@ -121,33 +123,49 @@ module.exports = exports = defineComponent( {
},
setup() {
const store = useBlockStore();
- store.$reset();
const blockEnableMultiblocks = mw.config.get( 'blockEnableMultiblocks' ) || false;
const blockShowSuppressLog = mw.config.get( 'blockShowSuppressLog' ) || false;
const canDeleteLogEntry = mw.config.get( 'canDeleteLogEntry' ) || false;
- const showForm = ref( false );
- const { formErrors, formSubmitted, success } = storeToRefs( store );
+ const { formErrors, formSubmitted, formVisible, success } = storeToRefs( store );
const messagesContainer = ref();
// Value to use for BlockLog component keys, so they reload after saving.
const submitCount = ref( 0 );
// eslint-disable-next-line arrow-body-style
const submitButtonMessage = computed( () => {
- return mw.message( store.alreadyBlocked ? 'ipb-change-block' : 'ipbsubmit' ).text();
+ return mw.message( store.alreadyBlocked ? 'block-update' : 'ipbsubmit' ).text();
} );
const confirmationOpen = ref( false );
+ let initialLoad = true;
+ /**
+ * Show the form for a new block.
+ */
function onCreateBlock() {
- store.$reset();
- showForm.value = true;
+ // On initial load, we want the preset values from the URL to be set.
+ if ( initialLoad ) {
+ initialLoad = false;
+ } else {
+ // Subsequent loads should reset the form to the established defaults.
+ store.resetForm();
+ }
+ formVisible.value = true;
scrollToForm();
}
- function onEditBlock( event ) {
- store.loadFromData( event );
- showForm.value = true;
+ /**
+ * Show the form for an existing block.
+ *
+ * @param {Object} blockData
+ */
+ function onEditBlock( blockData ) {
+ store.loadFromData( blockData );
+ formVisible.value = true;
scrollToForm();
}
+ /**
+ * Animate scrolling to the form.
+ */
function scrollToForm() {
nextTick( () => {
document.querySelector( '.mw-block__block-form' ).scrollIntoView( { behavior: 'smooth' } );
@@ -177,7 +195,7 @@ module.exports = exports = defineComponent( {
return;
}
doBlock();
- showForm.value = false;
+ formVisible.value = false;
} else {
// nextTick() needed to ensure error messages are rendered before scrolling.
nextTick( () => {
@@ -193,7 +211,8 @@ module.exports = exports = defineComponent( {
}
/**
- * @internal
+ * Execute the block request, set the success state and form errors,
+ * and scroll to the messages container.
*/
function doBlock() {
store.doBlock()
@@ -225,7 +244,7 @@ module.exports = exports = defineComponent( {
blockShowSuppressLog,
canDeleteLogEntry,
confirmationOpen,
- showForm,
+ formVisible,
onCreateBlock,
onEditBlock,
onFormSubmission,
diff --git a/resources/src/mediawiki.special.block/components/AdditionalDetailsField.vue b/resources/src/mediawiki.special.block/components/AdditionalDetailsField.vue
index 50867dce2d7a..6f57bd317a8c 100644
--- a/resources/src/mediawiki.special.block/components/AdditionalDetailsField.vue
+++ b/resources/src/mediawiki.special.block/components/AdditionalDetailsField.vue
@@ -25,6 +25,7 @@
v-if="hardBlockVisible"
v-model="hardBlock"
input-value="wpHardBlock"
+ class="mw-block-hardblock"
>
{{ $i18n( 'ipb-hardblock' ) }}
</cdx-checkbox>
@@ -38,7 +39,7 @@
</template>
<script>
-const { defineComponent } = require( 'vue' );
+const { computed, defineComponent } = require( 'vue' );
const { storeToRefs } = require( 'pinia' );
const { CdxCheckbox, CdxField } = require( '@wikimedia/codex' );
const useBlockStore = require( '../stores/block.js' );
@@ -58,16 +59,22 @@ module.exports = exports = defineComponent( {
const store = useBlockStore();
const {
autoBlock,
- autoBlockVisible,
hideName,
hideNameVisible,
watchUser,
- hardBlock,
- hardBlockVisible
+ hardBlock
} = storeToRefs( store );
+ const autoBlockExpiry = mw.config.get( 'blockAutoblockExpiry' ) || '';
+ const autoBlockVisible = computed(
+ () => !mw.util.isIPAddress( store.targetUser, true )
+ );
+ const hardBlockVisible = computed(
+ () => mw.util.isIPAddress( store.targetUser, true ) || false
+ );
+
return {
autoBlock,
- autoBlockExpiry: store.autoBlockExpiry,
+ autoBlockExpiry,
autoBlockVisible,
hideName,
hideNameVisible,
diff --git a/resources/src/mediawiki.special.block/components/BlockDetailsField.vue b/resources/src/mediawiki.special.block/components/BlockDetailsField.vue
index 339cfb2df5bf..9a9f7cf9dd43 100644
--- a/resources/src/mediawiki.special.block/components/BlockDetailsField.vue
+++ b/resources/src/mediawiki.special.block/components/BlockDetailsField.vue
@@ -33,7 +33,7 @@
</template>
<script>
-const { defineComponent } = require( 'vue' );
+const { computed, defineComponent } = require( 'vue' );
const { storeToRefs } = require( 'pinia' );
const { CdxCheckbox, CdxField } = require( '@wikimedia/codex' );
const useBlockStore = require( '../stores/block.js' );
@@ -42,13 +42,16 @@ module.exports = exports = defineComponent( {
name: 'BlockDetailsField',
components: { CdxCheckbox, CdxField },
setup() {
- const {
- createAccount,
- disableEmail,
- disableEmailVisible,
- disableUTEdit,
- disableUTEditVisible
- } = storeToRefs( useBlockStore() );
+ const store = useBlockStore();
+ const { createAccount, disableEmail, disableUTEdit } = storeToRefs( store );
+ const disableEmailVisible = mw.config.get( 'blockDisableEmailVisible' ) || false;
+ const disableUTEditVisible = computed( () => {
+ const isVisible = mw.config.get( 'blockDisableUTEditVisible' ) || false;
+ const isPartial = store.type === 'partial';
+ const blocksUT = store.namespaces.indexOf( mw.config.get( 'wgNamespaceIds' ).user_talk ) !== -1;
+ return isVisible && ( !isPartial || ( isPartial && blocksUT ) );
+ } );
+
return {
createAccount,
disableEmail,
diff --git a/resources/src/mediawiki.special.block/components/BlockLog.vue b/resources/src/mediawiki.special.block/components/BlockLog.vue
index 0985e9b21329..c199719889ae 100644
--- a/resources/src/mediawiki.special.block/components/BlockLog.vue
+++ b/resources/src/mediawiki.special.block/components/BlockLog.vue
@@ -16,7 +16,7 @@
:use-row-headers="false"
:hide-caption="true"
>
- <template v-if="blockLogType === 'active'" #header>
+ <template v-if="shouldShowAddBlockButton" #header>
<cdx-button
type="button"
action="progressive"
@@ -135,7 +135,7 @@ module.exports = exports = defineComponent( {
],
setup( props ) {
const store = useBlockStore();
- const { targetUser } = storeToRefs( store );
+ const { targetUser, alreadyBlocked } = storeToRefs( store );
let title = mw.message( 'block-user-previous-blocks' ).text();
let emptyState = mw.message( 'block-user-no-previous-blocks' ).text();
@@ -265,6 +265,15 @@ module.exports = exports = defineComponent( {
}
}, { immediate: true } );
+ // Show the 'Add block' button in the active blocks accordion if:
+ // * Multiblocks is enabled
+ // * Multiblocks is disabled and the user is not already blocked
+ const shouldShowAddBlockButton = computed(
+ () => props.blockLogType === 'active' && (
+ mw.config.get( 'blockEnableMultiblocks' ) || !alreadyBlocked.value
+ )
+ );
+
return {
mw,
util,
@@ -277,7 +286,8 @@ module.exports = exports = defineComponent( {
targetUser,
logEntriesCount,
infoChipIcon,
- infoChipStatus
+ infoChipStatus,
+ shouldShowAddBlockButton
};
}
} );
diff --git a/resources/src/mediawiki.special.block/components/UserLookup.vue b/resources/src/mediawiki.special.block/components/UserLookup.vue
index d55b427be541..706be29d8abc 100644
--- a/resources/src/mediawiki.special.block/components/UserLookup.vue
+++ b/resources/src/mediawiki.special.block/components/UserLookup.vue
@@ -7,6 +7,7 @@
<cdx-lookup
v-model:selected="selection"
v-model:input-value="currentSearchTerm"
+ class="mw-block-target"
name="wpTarget"
required
:clearable="true"
@@ -183,9 +184,7 @@ module.exports = exports = defineComponent( {
* When the clear button is clicked.
*/
function onClear() {
- targetUser.value = '';
- store.$reset();
- // Focus the input after clearing.
+ store.resetForm( true );
htmlInput.focus();
}
diff --git a/resources/src/mediawiki.special.block/stores/block.js b/resources/src/mediawiki.special.block/stores/block.js
index bebd3b58c433..c1c417cb35b0 100644
--- a/resources/src/mediawiki.special.block/stores/block.js
+++ b/resources/src/mediawiki.special.block/stores/block.js
@@ -2,77 +2,177 @@ const { defineStore } = require( 'pinia' );
const { computed, ComputedRef, ref, Ref, watch } = require( 'vue' );
const api = new mw.Api();
+/**
+ * Pinia store for the SpecialBlock application.
+ */
module.exports = exports = defineStore( 'block', () => {
- const formErrors = ref( mw.config.get( 'blockPreErrors' ) || [] );
- const formSubmitted = ref( false );
+ // ** State properties (refs) **
+
+ // Form fields.
+ // TODO: Rid of the `mw.config.get( 'whatever' )` atrocity once we have Codex PHP (T377529)
+
/**
- * Whether the block was successful.
+ * The target user to block. Beyond the initial value,
+ * this is set only by the UserLookup component.
*
- * @type {Ref<boolean>}
+ * @type {Ref<string>}
*/
- const success = ref( false );
const targetUser = ref( mw.config.get( 'blockTargetUser' ) || '' );
- const blockId = ref( String );
- const alreadyBlocked = ref( Boolean );
- const type = ref( String );
- const expiry = ref( String );
- const partialOptions = ref( Array );
- const pages = ref(
- ( mw.config.get( 'blockPageRestrictions' ) || '' )
- .split( '\n' )
- .filter( Boolean )
+ /**
+ * The block ID of the block to modify.
+ *
+ * @type {Ref<number|null>}
+ */
+ const blockId = ref( mw.config.get( 'blockId' ) || null );
+ /**
+ * The block type, either `sitewide` or `partial`. This is set by the BlockTypeField component.
+ *
+ * @type {Ref<string>}
+ */
+ const type = ref( mw.config.get( 'blockTypePreset' ) || 'sitewide' );
+ /**
+ * The pages to restrict the partial block to.
+ *
+ * @type {Ref<string[]>}
+ */
+ const pages = ref( ( mw.config.get( 'blockPageRestrictions' ) || '' )
+ .split( '\n' )
+ .filter( Boolean )
);
- const namespaces = ref(
- ( mw.config.get( 'blockNamespaceRestrictions' ) || '' )
- .split( '\n' )
- .filter( Boolean )
- .map( Number )
+ /**
+ * The namespaces to restrict the partial block to.
+ *
+ * @type {Ref<number[]>}
+ */
+ const namespaces = ref( ( mw.config.get( 'blockNamespaceRestrictions' ) || '' )
+ .split( '\n' )
+ .filter( Boolean )
+ .map( Number )
);
- const reason = ref( String );
- const reasonOther = ref( String );
- const createAccount = ref( Boolean );
- const disableEmail = ref( Boolean );
- const disableEmailVisible = ref( mw.config.get( 'blockDisableEmailVisible' ) || false );
- const disableUTEdit = ref( Boolean );
- const disableUTEditVisible = computed( () => {
- const isVisible = mw.config.get( 'blockDisableUTEditVisible' ) || false;
- const isPartial = type.value === 'partial';
- const blocksUT = namespaces.value.indexOf( mw.config.get( 'wgNamespaceIds' ).user_talk ) !== -1;
- return isVisible && ( !isPartial || ( isPartial && blocksUT ) );
- } );
-
- const autoBlock = ref( Boolean );
- const autoBlockExpiry = mw.config.get( 'blockAutoblockExpiry' ) || '';
- // eslint-disable-next-line arrow-body-style
- const autoBlockVisible = computed( () => {
- return !mw.util.isIPAddress( targetUser.value, true );
- } );
-
- const hideName = ref( Boolean );
- // Hide the 'Hide username' checkbox if the user doesn't have the hideuser right (this is passed from PHP),
- // and the block is not sitewide and infinite.
- const hideNameVisible = computed( () => {
- const typeVal = type.value;
- return mw.config.get( 'blockHideUser' ) &&
- typeVal === 'sitewide' &&
- mw.util.isInfinity( expiry.value );
- } );
-
- const watchUser = ref( Boolean );
+ /**
+ * Actions to apply the partial block to,
+ * i.e. `ipb-action-create`, `ipb-action-move`, `ipb-action-upload`.
+ *
+ * @type {Ref<string[]>}
+ */
+ const partialOptions = ref( [] );
+ /**
+ * The expiry of the block.
+ *
+ * @type {Ref<string>}
+ */
+ const expiry = ref(
+ // From URL, ?wpExpiry=...
+ mw.config.get( 'blockExpiryPreset' ) ||
+ // From [[MediaWiki:ipb-default-expiry]] or [[MediaWiki:ipb-default-expiry-ip]].
+ mw.config.get( 'blockExpiryDefault' ) ||
+ ''
+ );
+ /**
+ * The block summary, as selected from via the dropdown in the ReasonField component.
+ * These options are ultimately defined by [[MediaWiki:Ipbreason-dropdown]].
+ *
+ * @type {Ref<string>}
+ * @todo Combine with `reasonOther` here within the store.
+ */
+ const reason = ref( 'other' );
+ /**
+ * The free-form text for the block summary.
+ *
+ * @type {Ref<string>}
+ * @todo Combine with `reason` here within the store.
+ */
+ const reasonOther = ref( mw.config.get( 'blockReasonOtherPreset' ) || '' );
+ const details = mw.config.get( 'blockDetailsPreset' ) || [];
+ /**
+ * Whether to block an IP or IP range from creating accounts.
+ *
+ * @type {Ref<boolean>}
+ */
+ const createAccount = ref( details.indexOf( 'wpCreateAccount' ) !== -1 );
+ /**
+ * Whether to disable the target's ability to send email via Special:EmailUser.
+ *
+ * @type {Ref<boolean>}
+ */
+ const disableEmail = ref( details.indexOf( 'wpDisableEmail' ) !== -1 );
+ /**
+ * Whether to disable the target's ability to edit their own user talk page.
+ *
+ * @type {Ref<boolean>}
+ */
+ const disableUTEdit = ref( details.indexOf( 'wpDisableUTEdit' ) !== -1 );
+ const additionalDetails = mw.config.get( 'blockAdditionalDetailsPreset' ) || [];
+ /**
+ * Whether to autoblock IP addresses used by the target.
+ *
+ * @type {Ref<boolean>}
+ * @see https://www.mediawiki.org/wiki/Autoblock
+ */
+ const autoBlock = ref( additionalDetails.indexOf( 'wpAutoBlock' ) !== -1 );
+ /**
+ * Whether to impose a "suppressed" block, hiding the target's username
+ * from block log, the active block list, and the user list.
+ *
+ * @type {Ref<boolean>}
+ */
+ const hideName = ref( additionalDetails.indexOf( 'wpHideName' ) !== -1 );
+ /**
+ * Whether to watch the target's user page and talk page.
+ *
+ * @type {Ref<boolean>}
+ */
+ const watchUser = ref( additionalDetails.indexOf( 'wpWatch' ) !== -1 );
+ /**
+ * Whether to apply a hard block, blocking accounts using the same IP address.
+ *
+ * @type {Ref<boolean>}
+ */
+ const hardBlock = ref( additionalDetails.indexOf( 'wpHardBlock' ) !== -1 );
- const hardBlock = ref( Boolean );
- // eslint-disable-next-line arrow-body-style
- const hardBlockVisible = computed( () => {
- return mw.util.isIPAddress( targetUser.value, true ) || false;
- } );
+ // Other refs that don't have corresponding form fields.
/**
+ * Errors pertaining the form as a whole, shown at the top.
+ *
+ * @type {Ref<string[]>}
+ */
+ const formErrors = ref( mw.config.get( 'blockPreErrors' ) || [] );
+ /**
+ * Whether the form has been submitted. This is watched by UserLookup
+ * and ExpiryField to trigger validation on form submission.
+ * After submission, this remains true until a form field is altered.
+ * This is to ensure post-submission formErrors are not prematurely cleared.
+ *
+ * @type {Ref<boolean>}
+ */
+ const formSubmitted = ref( false );
+ /**
+ * Whether the form is visible. This is set by the SpecialBlock component,
+ * and unset by a watcher when the target user changes.
+ *
+ * @type {Ref<boolean>}
+ */
+ const formVisible = ref( false );
+ /**
+ * Whether the block was successful.
+ *
+ * @type {Ref<boolean>}
+ */
+ const success = ref( false );
+ /**
+ * Whether the target user is already blocked. This is set
+ * after fetching block log data from the API.
+ *
+ * @type {Ref<boolean>}
+ */
+ const alreadyBlocked = ref( mw.config.get( 'blockAlreadyBlocked' ) || false );
+ /**
* Keep track of all UI-blocking API requests that are currently in flight.
*
* @type {Ref<Set<Promise|jQuery.Promise>>}
*/
const promises = ref( new Set() );
-
/**
* Confirmation dialog message. When not null, the confirmation dialog will be
* shown on submission. This is set automatically by a watcher in the store.
@@ -81,6 +181,26 @@ module.exports = exports = defineStore( 'block', () => {
*/
const confirmationMessage = ref( '' );
+ // ** Getters (computed properties) **
+
+ /**
+ * Whether the form is disabled due to an in-flight API request.
+ *
+ * @type {ComputedRef<boolean>}
+ */
+ const formDisabled = computed( () => !!promises.value.size );
+ /**
+ * Controls visibility of the 'Hide username' checkbox. True when the user has the
+ * hideuser right (this is passed from PHP), and the block is sitewide and infinite.
+ *
+ * @type {ComputedRef<boolean>}
+ */
+ const hideNameVisible = computed( () => {
+ const typeVal = type.value;
+ return mw.config.get( 'blockHideUser' ) &&
+ typeVal === 'sitewide' &&
+ mw.util.isInfinity( expiry.value );
+ } );
/**
* Convenience computed prop indicating if confirmation is needed on submission.
*
@@ -88,13 +208,12 @@ module.exports = exports = defineStore( 'block', () => {
*/
const confirmationNeeded = computed( () => !!confirmationMessage.value );
- // Show confirm checkbox if 'Hide username' is visible and selected,
+ // ** Watchers **
+
+ // Show confirmation dialog if 'Hide username' is visible and selected,
// or if the target user is the current user.
- const computedConfirmation = computed(
- () => [ targetUser.value, hideName.value, hideNameVisible.value ]
- );
watch(
- computedConfirmation,
+ computed( () => [ targetUser.value, hideName.value, hideNameVisible.value ] ),
( [ newTargetUser, newHideName, newHideNameVisible ] ) => {
if ( newHideNameVisible && newHideName ) {
confirmationMessage.value = mw.message( 'ipb-confirmhideuser' ).parse();
@@ -108,6 +227,9 @@ module.exports = exports = defineStore( 'block', () => {
{ immediate: true }
);
+ // Hide the form and clear form-related refs when the target user changes.
+ watch( targetUser, resetFormInternal );
+
/**
* The current in-flight API request for block log data. This is used to
* avoid redundant API queries when rendering multiple BlockLog components.
@@ -128,7 +250,6 @@ module.exports = exports = defineStore( 'block', () => {
* @param {Object} blockData The block's item from the API.
*/
function loadFromData( blockData ) {
- $reset();
blockId.value = blockData.id;
type.value = blockData.partial ? 'partial' : 'sitewide';
pages.value = ( blockData.restrictions.pages || [] ).map( ( i ) => i.title );
@@ -158,35 +279,50 @@ module.exports = exports = defineStore( 'block', () => {
}
/**
- * Reset the form to its initial state.
+ * Reset the form to default values, optionally clearing the target user.
+ * The values here should be the defaults set on the OOUI elements in SpecialBlock.php.
+ * These are not the same as the *preset* values fetched from URL parameters.
+ *
+ * @param {boolean} [full=false] Whether to clear the target user.
+ * @todo Infuse default values once we have Codex PHP (T377529).
+ * Until then this needs to be manually kept in sync with the PHP defaults.
+ */
+ function resetForm( full = false ) {
+ // Form fields
+ if ( full ) {
+ targetUser.value = '';
+ }
+ blockId.value = null;
+ type.value = 'sitewide';
+ pages.value = [];
+ namespaces.value = [];
+ partialOptions.value = [];
+ expiry.value = '';
+ reason.value = 'other';
+ reasonOther.value = '';
+ createAccount.value = true;
+ disableEmail.value = false;
+ disableUTEdit.value = false;
+ autoBlock.value = true;
+ hideName.value = false;
+ watchUser.value = false;
+ hardBlock.value = false;
+ // Other refs
+ resetFormInternal();
+ }
+
+ /**
+ * Clear form behavioural refs.
+ *
+ * @internal
*/
- function $reset() {
+ function resetFormInternal() {
+ formErrors.value = [];
formSubmitted.value = false;
+ formVisible.value = false;
success.value = false;
- alreadyBlocked.value = mw.config.get( 'blockAlreadyBlocked' ) || false;
- type.value = mw.config.get( 'blockTypePreset' ) || 'sitewide';
- pages.value = ( mw.config.get( 'blockPageRestrictions' ) || '' )
- .split( '\n' )
- .filter( Boolean );
- namespaces.value = ( mw.config.get( 'blockNamespaceRestrictions' ) || '' )
- .split( '\n' )
- .filter( Boolean )
- .map( Number );
-
- expiry.value = mw.config.get( 'blockExpiryPreset' ) || mw.config.get( 'blockExpiryDefault' ) || '';
- partialOptions.value = [ 'ipb-action-create' ];
- reason.value = 'other';
- reasonOther.value = mw.config.get( 'blockReasonOtherPreset' ) || '';
- const details = mw.config.get( 'blockDetailsPreset' ) || [];
- createAccount.value = details.indexOf( 'wpCreateAccount' ) !== -1;
- disableEmail.value = details.indexOf( 'wpDisableEmail' ) !== -1;
- disableUTEdit.value = details.indexOf( 'wpDisableUTEdit' ) !== -1;
- const additionalDetails = mw.config.get( 'blockAdditionalDetailsPreset' ) || [];
- watchUser.value = additionalDetails.indexOf( 'wpWatch' ) !== -1;
- hardBlock.value = additionalDetails.indexOf( 'wpHardBlock' ) !== -1;
- hideName.value = additionalDetails.indexOf( 'wpHideName' ) !== -1;
- autoBlock.value = additionalDetails.indexOf( 'wpAutoBlock' ) !== -1;
- autoBlockExpiry.value = mw.config.get( 'blockAutoblockExpiry' ) || '';
+ alreadyBlocked.value = false;
+ promises.value.clear();
}
/**
@@ -315,7 +451,11 @@ module.exports = exports = defineStore( 'block', () => {
params.bkprop = 'id|user|by|timestamp|expiry|reason|range|flags|restrictions';
params.bkusers = targetUser.value;
- blockLogPromise = Promise.all( [ api.get( params ) ] );
+ const actualPromise = api.get( params );
+ actualPromise.then( ( data ) => {
+ alreadyBlocked.value = data.query.blocks.length > 0;
+ } );
+ blockLogPromise = Promise.all( [ actualPromise ] );
return pushPromise( blockLogPromise );
}
@@ -340,8 +480,10 @@ module.exports = exports = defineStore( 'block', () => {
}
return {
+ formDisabled,
formErrors,
formSubmitted,
+ formVisible,
targetUser,
success,
blockId,
@@ -355,22 +497,16 @@ module.exports = exports = defineStore( 'block', () => {
reasonOther,
createAccount,
disableEmail,
- disableEmailVisible,
disableUTEdit,
- disableUTEditVisible,
autoBlock,
- autoBlockExpiry,
- autoBlockVisible,
hideName,
hideNameVisible,
watchUser,
hardBlock,
- hardBlockVisible,
- promises,
confirmationMessage,
confirmationNeeded,
loadFromData,
- $reset,
+ resetForm,
doBlock,
getBlockLogData
};
diff --git a/tests/jest/mediawiki.special.block/AdditionalDetailsField.test.js b/tests/jest/mediawiki.special.block/AdditionalDetailsField.test.js
new file mode 100644
index 000000000000..39f9f988f620
--- /dev/null
+++ b/tests/jest/mediawiki.special.block/AdditionalDetailsField.test.js
@@ -0,0 +1,23 @@
+'use strict';
+
+const { shallowMount } = require( '@vue/test-utils' );
+const { createTestingPinia } = require( '@pinia/testing' );
+const AdditionalDetailsField = require( '../../../resources/src/mediawiki.special.block/components/AdditionalDetailsField.vue' );
+const useBlockStore = require( '../../../resources/src/mediawiki.special.block/stores/block.js' );
+
+describe( 'AdditionalDetailsField', () => {
+ it( 'should set hardBlockVisible when blocking an IP address', () => {
+ const wrapper = shallowMount( AdditionalDetailsField, {
+ global: { plugins: [ createTestingPinia( { stubActions: false } ) ] }
+ } );
+ const store = useBlockStore();
+ // A username should not have the hardBlock option shown.
+ store.targetUser = 'ExampleUser';
+ mw.util.isIPAddress.mockReturnValue( false );
+ expect( wrapper.vm.hardBlockVisible ).toStrictEqual( false );
+ // An IP address should have hardBlock shown.
+ store.targetUser = '192.0.2.34';
+ mw.util.isIPAddress.mockReturnValue( true );
+ expect( wrapper.vm.hardBlockVisible ).toStrictEqual( true );
+ } );
+} );
diff --git a/tests/jest/mediawiki.special.block/BlockDetailsField.test.js b/tests/jest/mediawiki.special.block/BlockDetailsField.test.js
new file mode 100644
index 000000000000..c7b94eab1ec1
--- /dev/null
+++ b/tests/jest/mediawiki.special.block/BlockDetailsField.test.js
@@ -0,0 +1,29 @@
+'use strict';
+
+const { shallowMount } = require( '@vue/test-utils' );
+const { createTestingPinia } = require( '@pinia/testing' );
+const { mockMwConfigGet } = require( './SpecialBlock.setup.js' );
+const BlockDetailsField = require( '../../../resources/src/mediawiki.special.block/components/BlockDetailsField.vue' );
+const useBlockStore = require( '../../../resources/src/mediawiki.special.block/stores/block.js' );
+
+describe( 'AdditionalDetailsField', () => {
+ it( 'show the wpDisableUTEdit field for partial blocks, unless the block is against the User_talk namespace', () => {
+ mockMwConfigGet( { blockDisableUTEditVisible: true } );
+ const wrapper = shallowMount( BlockDetailsField, {
+ global: { plugins: [ createTestingPinia( { stubActions: false } ) ] }
+ } );
+ const store = useBlockStore();
+ // Visible for sitewide blocks.
+ store.type = 'sitewide';
+ expect( wrapper.vm.disableUTEditVisible ).toStrictEqual( true );
+ // But not visible for partial.
+ store.type = 'partial';
+ expect( wrapper.vm.disableUTEditVisible ).toStrictEqual( false );
+ // Including if they block a different namespace (the Talk NS in this case, ID 1).
+ store.namespaces.push( 1 );
+ expect( wrapper.vm.disableUTEditVisible ).toStrictEqual( false );
+ // But if it's the User_talk NS (ID 3), then it is visible.
+ store.namespaces.push( 3 );
+ expect( wrapper.vm.disableUTEditVisible ).toStrictEqual( true );
+ } );
+} );
diff --git a/tests/jest/mediawiki.special.block/ExpiryField.test.js b/tests/jest/mediawiki.special.block/ExpiryField.test.js
index 26941fe66620..86447bb8e2a5 100644
--- a/tests/jest/mediawiki.special.block/ExpiryField.test.js
+++ b/tests/jest/mediawiki.special.block/ExpiryField.test.js
@@ -100,8 +100,6 @@ describe( 'ExpiryField', () => {
const wrapper = mount( ExpiryField, {
global: { plugins: [ createTestingPinia( { stubActions: false } ) ] }
} );
- const store = useBlockStore();
- store.$reset();
await wrapper.vm.$nextTick();
Object.keys( expected ).forEach( ( key ) => {
diff --git a/tests/jest/mediawiki.special.block/SpecialBlock.setup.js b/tests/jest/mediawiki.special.block/SpecialBlock.setup.js
index 8caa496b0a98..7b8a2fe45df9 100644
--- a/tests/jest/mediawiki.special.block/SpecialBlock.setup.js
+++ b/tests/jest/mediawiki.special.block/SpecialBlock.setup.js
@@ -63,7 +63,7 @@ function mockMwConfigGet( config = {} ) {
blockAllowsEmailBan: true,
blockAllowsUTEdit: true,
blockAutoblockExpiry: '1 day',
- blockDetailsPreset: [],
+ blockDetailsPreset: [ 'wpCreateAccount' ],
blockExpiryDefault: '',
blockExpiryPreset: null,
blockHideUser: true,
@@ -141,7 +141,8 @@ function mockMwApiGet( additionalMocks = [] ) {
},
response: {
query: {
- logevents: []
+ logevents: [],
+ blocks: []
}
}
},
diff --git a/tests/jest/mediawiki.special.block/SpecialBlock.test.js b/tests/jest/mediawiki.special.block/SpecialBlock.test.js
index 45c38f0cca52..fd2a47b57b23 100644
--- a/tests/jest/mediawiki.special.block/SpecialBlock.test.js
+++ b/tests/jest/mediawiki.special.block/SpecialBlock.test.js
@@ -20,13 +20,13 @@ describe( 'SpecialBlock', () => {
wrapper = getSpecialBlock( config );
};
- it( 'should show no banner and no new-block button on page load', async () => {
+ it( 'should show no banner and no "Add block" button on page load', async () => {
wrapper = getSpecialBlock();
expect( wrapper.find( '.cdx-message__content' ).exists() ).toBeFalsy();
expect( wrapper.find( '.mw-block-submit' ).exists() ).toBeFalsy();
} );
- it( 'should show no banner and "Block this user" button after selecting a target', async () => {
+ it( 'should show no banner and an "Add block" button after selecting a target', async () => {
wrapper = getSpecialBlock();
expect( wrapper.find( '.cdx-message__content' ).exists() ).toBeFalsy();
await wrapper.find( '[name=wpTarget]' ).setValue( 'ExampleUser' );
@@ -34,15 +34,16 @@ describe( 'SpecialBlock', () => {
expect( wrapper.find( '.mw-block-submit' ).text() ).toStrictEqual( 'ipbsubmit' );
} );
- it( 'should show a banner and a submit button with text based on if user is already blocked', () => {
+ it( 'should show a banner and no "New block" button based on if user is already blocked', () => {
expect( wrapper.find( '.mw-block-error' ).exists() ).toBeFalsy();
wrapper = getSpecialBlock( {
blockAlreadyBlocked: true,
blockTargetUser: 'ExampleUser',
blockPreErrors: [ 'ExampleUser is already blocked.' ]
} );
- expect( wrapper.find( '.mw-block-error' ).exists() ).toBeTruthy();
- expect( wrapper.find( 'button.cdx-button' ).text() ).toStrictEqual( 'block-create' );
+ // Server-generated message, hence why it's in English.
+ expect( wrapper.find( '.mw-block-error' ).text() ).toStrictEqual( 'ExampleUser is already blocked.' );
+ expect( wrapper.find( '.mw-block-log__create-button' ).exists() ).toBeFalsy();
} );
it( 'should submit an API request to block the user', async () => {
@@ -61,8 +62,9 @@ describe( 'SpecialBlock', () => {
user: 'ExampleUser',
expiry: '2999-01-23T12:34',
reason: 'This is a test',
- autoblock: 1,
+ nocreate: 1,
allowusertalk: 1,
+ autoblock: 1,
errorlang: 'en',
errorsuselocal: true,
uselang: 'en',
@@ -121,7 +123,6 @@ describe( 'SpecialBlock', () => {
it( 'should require confirmation for self-blocking', async () => {
wrapper = getSpecialBlock( { wgUserName: 'ExampleUser' } );
const store = useBlockStore();
- store.$reset();
expect( wrapper.find( '.mw-block-error' ).exists() ).toBeFalsy();
expect( store.confirmationNeeded ).toBeFalsy();
expect( store.confirmationMessage ).toStrictEqual( '' );
diff --git a/tests/jest/mediawiki.special.block/stores/block.test.js b/tests/jest/mediawiki.special.block/stores/block.test.js
index 320adf45112e..c6ad25430f7a 100644
--- a/tests/jest/mediawiki.special.block/stores/block.test.js
+++ b/tests/jest/mediawiki.special.block/stores/block.test.js
@@ -15,10 +15,11 @@ describe( 'Block store', () => {
} );
it( 'should require confirmation if the target user is the current user', async () => {
- const store = useBlockStore();
- mw.util.isInfinity.mockReturnValue( true );
mockMwConfigGet( { wgUserName: 'ExampleUser' } );
+ const store = useBlockStore();
store.targetUser = 'ExampleUserOther';
+ // Trigger the watchers.
+ await nextTick();
expect( store.confirmationMessage ).toStrictEqual( '' );
store.targetUser = 'ExampleUser';
await nextTick();
@@ -26,8 +27,8 @@ describe( 'Block store', () => {
} );
it( 'should require confirmation for hide user', async () => {
- const store = useBlockStore();
mw.util.isInfinity.mockReturnValue( true );
+ const store = useBlockStore();
expect( store.confirmationMessage ).toStrictEqual( '' );
store.type = 'sitewide';
store.hideName = true;
@@ -58,47 +59,17 @@ describe( 'Block store', () => {
expect( store.hideNameVisible ).toStrictEqual( true );
} );
- it( 'should set hardBlockVisible when blocking an IP address', () => {
- const store = useBlockStore();
- // A username should not have the hardBlock option shown.
- store.targetUser = 'ExampleUser';
- mw.util.isIPAddress.mockReturnValue( false );
- expect( store.hardBlockVisible ).toStrictEqual( false );
- // An IP address should have hardBlock shown.
- store.targetUser = '192.0.2.34';
- mw.util.isIPAddress.mockReturnValue( true );
- expect( store.hardBlockVisible ).toStrictEqual( true );
- } );
-
- it( 'show the wpDisableUTEdit field for partial blocks, unless the block is against the User_talk namespace', () => {
- mockMwConfigGet( { blockDisableUTEditVisible: true } );
- const store = useBlockStore();
- // Visible for sitewide blocks.
- store.type = 'sitewide';
- expect( store.disableUTEditVisible ).toStrictEqual( true );
- // But not visible for partial.
- store.type = 'partial';
- expect( store.disableUTEditVisible ).toStrictEqual( false );
- // Including if they block a different namespace (the Talk NS in this case, ID 1).
- store.namespaces.push( 1 );
- expect( store.disableUTEditVisible ).toStrictEqual( false );
- // But if it's the User_talk NS (ID 3), then it is visible.
- store.namespaces.push( 3 );
- expect( store.disableUTEditVisible ).toStrictEqual( true );
- } );
-
it( 'should only pass the reblock param to the API if there was an "already blocked" error', () => {
const jQuery = jest.requireActual( '../../../../resources/lib/jquery/jquery.js' );
mw.Api.prototype.postWithEditToken.mockReturnValue( jQuery.Deferred().resolve().promise() );
mockMwConfigGet( { blockAlreadyBlocked: false } );
const store = useBlockStore();
- store.$reset();
store.doBlock();
const spy = jest.spyOn( mw.Api.prototype, 'postWithEditToken' );
const expected = {
action: 'block',
allowusertalk: 1,
- anononly: 1,
+ nocreate: 1,
autoblock: 1,
errorlang: 'en',
errorsuselocal: true,
@@ -119,13 +90,20 @@ describe( 'Block store', () => {
const jQuery = jest.requireActual( '../../../../resources/lib/jquery/jquery.js' );
mw.Api.prototype.postWithEditToken.mockReturnValue( jQuery.Deferred().resolve().promise() );
const store = useBlockStore();
+ mw.Api.prototype.get = jest.fn().mockReturnValue( jQuery.Deferred().resolve( { query: { blocks: [] } } ).promise() );
const spy = jest.spyOn( mw.Api.prototype, 'get' );
- store.$reset();
store.getBlockLogData( 'recent' );
store.getBlockLogData( 'active' );
- expect( store.promises.size ).toStrictEqual( 1 );
+ expect( store.formDisabled ).toBeTruthy();
expect( spy ).toHaveBeenCalledTimes( 1 );
+ // Flushes the promise created in getBlockLogData()
+ await flushPromises();
+ // Flushes the promise returned by getBlockLogData()
await flushPromises();
- expect( store.promises.size ).toStrictEqual( 0 );
+ expect( store.formDisabled ).toBeFalsy();
+ } );
+
+ afterEach( () => {
+ jest.clearAllMocks();
} );
} );
diff --git a/tests/selenium/pageobjects/block.page.js b/tests/selenium/pageobjects/block.page.js
new file mode 100644
index 000000000000..eba5771982d6
--- /dev/null
+++ b/tests/selenium/pageobjects/block.page.js
@@ -0,0 +1,59 @@
+'use strict';
+
+const Page = require( 'wdio-mediawiki/Page' );
+
+class BlockPage extends Page {
+ get target() {
+ return $( '.mw-block-target input[name=wpTarget]' );
+ }
+
+ get messages() {
+ return $( '.mw-block-messages' );
+ }
+
+ get activeBlocksHeader() {
+ return $( '.mw-block-log__type-active .cdx-accordion__header' );
+ }
+
+ get addBlockButton() {
+ return $( '.mw-block-log__create-button' );
+ }
+
+ get otherReasonInput() {
+ return $( 'input[name=wpReason-other]' );
+ }
+
+ get submitButton() {
+ return $( '.mw-block-submit' );
+ }
+
+ async open( expiry ) {
+ return super.openTitle( 'Special:Block', {
+ // Pass only the expiry and not also the target;
+ // This effectively asserts wpExpiry gets set correctly in SpecialBlock.vue
+ wpExpiry: expiry,
+ usecodex: 1
+ } );
+ }
+
+ async block( target, expiry, reason ) {
+ await this.open( expiry );
+ await browser.waitUntil(
+ async () => ( await this.target.isDisplayed() ),
+ { timeout: 5000 }
+ );
+ await this.target.setValue( target );
+ // Remove focus from input. Temporary workaround until T382093 is resolved.
+ await $( 'body' ).click();
+ await browser.waitUntil(
+ async () => ( await this.activeBlocksHeader.isClickable() ),
+ { timeout: 5000 }
+ );
+ await this.activeBlocksHeader.click();
+ await this.addBlockButton.click();
+ await this.otherReasonInput.setValue( reason );
+ await this.submitButton.click();
+ }
+}
+
+module.exports = new BlockPage();
diff --git a/tests/selenium/specs/user.js b/tests/selenium/specs/user.js
index a77602b90d7c..4c263bd5b2ab 100644
--- a/tests/selenium/specs/user.js
+++ b/tests/selenium/specs/user.js
@@ -6,6 +6,7 @@
const CreateAccountPage = require( 'wdio-mediawiki/CreateAccountPage' );
const EditPage = require( '../pageobjects/edit.page' );
const LoginPage = require( 'wdio-mediawiki/LoginPage' );
+const BlockPage = require( '../pageobjects/block.page' );
const Api = require( 'wdio-mediawiki/Api' );
const Util = require( 'wdio-mediawiki/Util' );
@@ -89,4 +90,16 @@ describe( 'User', () => {
expect( actualUsername ).toBe( username );
await expect( await CreateAccountPage.heading ).toHaveText( `Welcome, ${ username }!` );
} );
+
+ it( 'should be able to block a user', async () => {
+ await Api.createAccount( bot, username, password );
+
+ await LoginPage.loginAdmin();
+
+ const expiry = '31 hours';
+ const reason = Util.getTestString();
+ await BlockPage.block( username, expiry, reason );
+
+ await expect( await BlockPage.messages ).toHaveTextContaining( 'Block succeeded' );
+ } );
} );