mediawiki.special.block: make submit destructive and use old messages

SpecialBlock calls HTMLForm::setSubmitDestructive(). This patch applies
that to the new Vue version of Special:Block.

Additionally, since we're not going to enable multiblocks in the initial
launch, the submit button message is changed to as it was before. "Save
block" is questionable even for multiblocks. It will likely be a while
before we iron out the UI for multiblocks, so we might as well remove
now-unused 'block-save' message.

The "[username] is already blocked" message is now also surfaced, i.e.
browsing to [[Special:Block/someblockuser]]. Like the old Special:Block,
changes to the block target field won't re-query to see if the user is
already blocked (though that would be a fine improvement). However
*unlike* the old Special:Block, such changes do make the message
disappear.

Bug: T373572
Change-Id: Iceaedbb1e3496c52b49a2b96d65445da45261b9f
This commit is contained in:
MusikAnimal 2024-08-28 22:11:35 -04:00
parent 4e0bc9ad11
commit de2b3f7a59
11 changed files with 135 additions and 38 deletions

View file

@ -152,6 +152,10 @@ class SpecialBlock extends FormSpecialPage {
if ( $this->useCodex ) {
$this->getOutput()->addModules( 'mediawiki.special.block.codex' );
$this->codexFormData[ 'blockAlreadyBlocked' ] = $this->alreadyBlocked;
$this->codexFormData[ 'blockTargetUser' ] = $this->target instanceof UserIdentity ?
$this->target->getName() :
$this->target ?? null;
$this->getOutput()->addJsConfigVars( $this->codexFormData );
}
}

View file

@ -2693,7 +2693,6 @@
"block-user-label": "Which user do you want to block?",
"block-user-description": "A user can be a username, IP address, or an IP range.",
"block-user-placeholder": "Start typing a user...",
"block-save": "Save block",
"block-target": "Username, IP address, or IP range:",
"block-target-placeholder": "UserName, 1.1.1.42, or 1.1.1.42/16",
"unblockip": "Unblock user",

View file

@ -2956,7 +2956,6 @@
"block-user-label": "Label for the input for specifying the user of a block on [[Special:Block]]",
"block-user-description": "Description for the input for specifying the user of a block on [[Special:Block]]",
"block-user-placeholder": "Placeholder text for the input specifying the user of a block on [[Special:Block]]",
"block-save": "Label for submit button on block form [[Special:Block]]",
"block-target": "Label for the input for specifying the target of a block on [[Special:Block]]",
"block-target-placeholder": "Placeholder text for the input specifying the target of a block on [[Special:Block]]",
"unblockip": "Used as title and legend for the form in [[Special:Unblock]].",

View file

@ -2279,20 +2279,25 @@ return [
'block-options-description',
'block-reason',
'block-reason-other',
'block-save',
'block-user-description',
'block-user-label',
'block-user-placeholder',
'blocklist-type-opt-partial',
'blocklist-type-opt-sitewide',
'colon-separator',
'block-user-label',
'block-user-description',
'block-user-placeholder',
'ipbsubmit',
'htmlform-optional-flag',
'htmlform-selectorother-other',
'ipb-action-create',
'ipb-action-move',
'ipb-action-upload',
'ipb-change-block',
'ipb-disableusertalk',
'ipb-hardblock',
'ipb-needreblock',
'ipb-partial-help',
'ipb-sitewide-help',
'ipbcreateaccount',

View file

@ -1,5 +1,15 @@
<template>
<user-lookup v-model="targetUser"></user-lookup>
<cdx-message
v-if="targetUser && alreadyBlocked"
type="error"
inline
>
{{ $i18n( 'ipb-needreblock', targetUser ).text() }}
</cdx-message>
<user-lookup
v-model="targetUser"
@input="alreadyBlocked = false"
></user-lookup>
<target-active-blocks></target-active-blocks>
<target-block-log></target-block-log>
<block-type-field
@ -25,18 +35,18 @@
></block-details-field>
<hr class="mw-block-hr">
<cdx-button
action="progressive"
action="destructive"
weight="primary"
@click="handleSubmit"
class="mw-block-submit"
>
{{ $i18n( 'block-save' ).text() }}
{{ submitBtnMsg }}
</cdx-button>
</template>
<script>
const { defineComponent, ref } = require( 'vue' );
const { CdxButton } = require( '@wikimedia/codex' );
const { CdxButton, CdxMessage } = require( '@wikimedia/codex' );
const UserLookup = require( './components/UserLookup.vue' );
const TargetActiveBlocks = require( './components/TargetActiveBlocks.vue' );
const TargetBlockLog = require( './components/TargetBlockLog.vue' );
@ -56,10 +66,11 @@ module.exports = exports = defineComponent( {
ExpiryField,
ReasonField,
BlockDetailsField,
CdxButton
CdxButton,
CdxMessage
},
setup() {
const targetUser = ref( '' );
const targetUser = ref( mw.config.get( 'blockTargetUser' ) );
const expiry = ref( {} );
const blockPartialOptions = mw.config.get( 'partialBlockActionOptions' ) ?
@ -206,9 +217,13 @@ module.exports = exports = defineComponent( {
} );
}
const alreadyBlocked = mw.config.get( 'blockAlreadyBlocked' );
return {
targetUser,
expiry,
alreadyBlocked,
submitBtnMsg: mw.message( alreadyBlocked ? 'ipb-change-block' : 'ipbsubmit' ).text(),
handleSubmit,
blockDetailsOptions,
blockDetailsSelected,

View file

@ -2,6 +2,7 @@
<cdx-field :is-fieldset="true">
<cdx-lookup
v-model:selected="wrappedModel"
v-model:input-value="wrappedModel"
name="wpTarget"
:menu-items="menuItems"
:start-icon="cdxIconSearch"
@ -36,7 +37,7 @@ module.exports = exports = defineComponent( {
],
setup( props, { emit } ) {
const menuItems = ref( [] );
const currentSearchTerm = ref( '' );
const currentSearchTerm = ref( props.modelValue || '' );
const wrappedModel = useModelWrapper(
toRef( props, 'modelValue' ),
emit
@ -54,7 +55,6 @@ module.exports = exports = defineComponent( {
const api = new mw.Api();
const params = {
origin: '*',
action: 'query',
format: 'json',
formatversion: 2,
@ -96,13 +96,10 @@ module.exports = exports = defineComponent( {
}
// Build an array of menu items.
const results = data.allusers.map( ( result ) => ( {
menuItems.value = data.allusers.map( ( result ) => ( {
label: result.name,
value: result.name
} ) );
// Update menuItems.
menuItems.value = results;
} )
.catch( () => {
// On error, set results to empty.

View file

@ -31,6 +31,12 @@ config.global.directives = {
}
};
function ApiMock() {}
ApiMock.prototype.get = jest.fn();
ApiMock.prototype.post = jest.fn();
ApiMock.prototype.postWithEditToken = jest.fn();
ApiMock.prototype.postWithToken = jest.fn();
function RestMock() {}
RestMock.prototype.get = jest.fn();
@ -41,6 +47,7 @@ TitleMock.prototype.getUrl = jest.fn();
// Mock the mw global object.
const mw = {
Api: ApiMock,
log: {
error: jest.fn(),
warn: jest.fn()

View file

@ -0,0 +1,30 @@
/**
* Mock calls to mw.config.get().
* The default implementation correlates to the SpecialBlock::codexFormData property in PHP.
*
* @param {Object} [config]
*/
function mockMwConfigGet( config = {} ) {
const mockConfig = Object.assign( {
blockAlreadyBlocked: false,
blockTargetUser: null,
blockAllowsEmailBan: true,
blockAllowsUTEdit: true,
blockAutoblockExpiry: '1 day',
blockDefaultExpiry: 'infinite',
blockHideUser: true,
blockExpiryOptions: {
infinite: 'infinite',
'Other time:': 'other'
},
blockReasonOptions: [
{ label: 'block-reason-1', value: 'block-reason-1' },
{ label: 'block-reason-2', value: 'block-reason-2' }
]
}, config );
mw.config.get.mockImplementation( ( key ) => mockConfig[ key ] );
}
module.exports = {
mockMwConfigGet
};

View file

@ -1,31 +1,21 @@
'use strict';
const { mount } = require( '@vue/test-utils' );
// Mock calls to mw.config.get.
// This correlates to the SpecialBlock::codexFormData property in PHP.
const mockConfig = {
blockAllowsEmailBan: true,
blockAllowsUTEdit: true,
blockAutoblockExpiry: '1 day',
blockDefaultExpiry: 'infinite',
blockHideUser: true,
blockExpiryOptions: {
infinite: 'infinite',
'Other time:': 'other'
},
blockReasonOptions: [
{ label: 'block-reason-1', value: 'block-reason-1' },
{ label: 'block-reason-2', value: 'block-reason-2' }
]
};
mw.config.get.mockImplementation( ( key ) => mockConfig[ key ] );
const { mockMwConfigGet } = require( './SpecialBlock.setup.js' );
const SpecialBlock = require( '../../../resources/src/mediawiki.special.block/SpecialBlock.vue' );
describe( 'SpecialBlock', () => {
it( 'should show a submit button with the correct text', () => {
const wrapper = mount( SpecialBlock );
expect( wrapper.find( 'button.cdx-button' ).text() ).toBe( 'block-save' );
it( 'should show a banner and a submit button with text based on if user is already blocked', () => {
mockMwConfigGet();
let wrapper = mount( SpecialBlock );
expect( wrapper.find( 'button.cdx-button' ).text() ).toStrictEqual( 'ipbsubmit' );
mockMwConfigGet( {
blockAlreadyBlocked: true,
blockTargetUser: 'ExampleUser'
} );
wrapper = mount( SpecialBlock );
expect( wrapper.find( '.cdx-message__content' ).text() )
.toStrictEqual( 'ipb-needreblock:[ExampleUser]' );
expect( wrapper.find( 'button.cdx-button' ).text() ).toStrictEqual( 'ipb-change-block' );
} );
} );

View file

@ -0,0 +1,31 @@
'use strict';
const { nextTick } = require( 'vue' );
const { mount } = require( '@vue/test-utils' );
const UserLookup = require( '../../../resources/src/mediawiki.special.block/components/UserLookup.vue' );
describe( 'UserLookup', () => {
it( 'should update menu items based on the API response', async () => {
mw.Api.prototype.get = jest.fn().mockResolvedValue( {
query: {
allusers: [
{ name: 'UserLookup1' },
{ name: 'UserLookup2' }
]
}
} );
const wrapper = mount( UserLookup, {
props: { modelValue: 'UserLookup' }
} );
const input = wrapper.find( '.cdx-text-input__input' );
expect( input.element.value ).toBe( 'UserLookup' );
const listBox = wrapper.find( '.cdx-menu__listbox' );
// "No results"
expect( listBox.element.children ).toHaveLength( 1 );
await input.trigger( 'input' );
await nextTick();
expect( listBox.element.children ).toHaveLength( 2 );
expect( listBox.element.children[ 0 ].textContent ).toBe( 'UserLookup1' );
expect( listBox.element.children[ 1 ].textContent ).toBe( 'UserLookup2' );
} );
} );

View file

@ -57,6 +57,7 @@ class SpecialBlockTest extends SpecialPageTestBase {
$this->overrideConfigValues( [
MainConfigNames::BlockAllowsUTEdit => true,
MainConfigNames::EnablePartialActionBlocks => true,
MainConfigNames::UseCodexSpecialBlock => false,
] );
$page = $this->newSpecialPage();
$wrappedPage = TestingAccessWrapper::newFromObject( $page );
@ -72,11 +73,30 @@ class SpecialBlockTest extends SpecialPageTestBase {
$this->assertArrayHasKey( 'PreviousTarget', $fields );
$this->assertArrayHasKey( 'Confirm', $fields );
$this->assertArrayHasKey( 'EditingRestriction', $fields );
$this->assertArrayNotHasKey( 'options-messages', $fields['EditingRestriction'] );
$this->assertArrayNotHasKey( 'option-descriptions-messages', $fields['EditingRestriction'] );
$this->assertArrayHasKey( 'PageRestrictions', $fields );
$this->assertArrayHasKey( 'NamespaceRestrictions', $fields );
$this->assertArrayHasKey( 'ActionRestrictions', $fields );
}
/**
* @covers ::getFormFields
*/
public function testGetFormFieldsCodex(): void {
$this->overrideConfigValues( [
MainConfigNames::BlockAllowsUTEdit => true,
MainConfigNames::EnablePartialActionBlocks => true,
MainConfigNames::UseCodexSpecialBlock => true,
] );
$page = $this->newSpecialPage();
$wrappedPage = TestingAccessWrapper::newFromObject( $page );
$fields = $wrappedPage->getFormFields();
$this->assertIsArray( $fields );
$this->assertArrayHasKey( 'options-messages', $fields[ 'EditingRestriction' ] );
$this->assertArrayHasKey( 'option-descriptions-messages', $fields[ 'EditingRestriction' ] );
}
/**
* @covers ::getFormFields
*/