Create Expiry Widget with Date Time Selector

Special:Block needs a date time selector for easier selection of expiry. To
accommodate this cleanly, a new Expiry Widget is created that handles this
logic.

Bug: T132220
Change-Id: I2853a2ca0ae6ccead3978f4bb50a77c2baa3a150
This commit is contained in:
David Barratt 2018-03-22 01:15:16 -04:00
parent 71a653a495
commit 3481e3b2e0
No known key found for this signature in database
GPG key ID: 8C55B2BF3C1AD78F
11 changed files with 520 additions and 19 deletions

View file

@ -564,6 +564,7 @@ $wgAutoloadLocalClasses = [
'HTMLComboboxField' => __DIR__ . '/includes/htmlform/fields/HTMLComboboxField.php',
'HTMLDateTimeField' => __DIR__ . '/includes/htmlform/fields/HTMLDateTimeField.php',
'HTMLEditTools' => __DIR__ . '/includes/htmlform/fields/HTMLEditTools.php',
'HTMLExpiryField' => __DIR__ . '/includes/htmlform/fields/HTMLExpiryField.php',
'HTMLFileCache' => __DIR__ . '/includes/cache/HTMLFileCache.php',
'HTMLFloatField' => __DIR__ . '/includes/htmlform/fields/HTMLFloatField.php',
'HTMLForm' => __DIR__ . '/includes/htmlform/HTMLForm.php',
@ -992,6 +993,7 @@ $wgAutoloadLocalClasses = [
'MediaWiki\\Widget\\ComplexTitleInputWidget' => __DIR__ . '/includes/widget/ComplexTitleInputWidget.php',
'MediaWiki\\Widget\\DateInputWidget' => __DIR__ . '/includes/widget/DateInputWidget.php',
'MediaWiki\\Widget\\DateTimeInputWidget' => __DIR__ . '/includes/widget/DateTimeInputWidget.php',
'MediaWiki\\Widget\\ExpiryInputWidget' => __DIR__ . '/includes/widget/ExpiryInputWidget.php',
'MediaWiki\\Widget\\NamespaceInputWidget' => __DIR__ . '/includes/widget/NamespaceInputWidget.php',
'MediaWiki\\Widget\\SearchInputWidget' => __DIR__ . '/includes/widget/SearchInputWidget.php',
'MediaWiki\\Widget\\Search\\BasicSearchResultSetWidget' => __DIR__ . '/includes/widget/search/BasicSearchResultSetWidget.php',

View file

@ -8838,6 +8838,15 @@ $wgCommentTableSchemaMigrationStage = MIGRATION_OLD;
*/
$wgActorTableSchemaMigrationStage = MIGRATION_OLD;
/**
* Temporary option to disable the date picker from the Expiry Widget.
*
* @since 1.32
* @deprecated 1.32
* @var bool
*/
$wgExpiryWidgetNoDatePicker = false;
/**
* For really cool vim folding this needs to be at the end:
* vim: foldmarker=@{,@} foldmethod=marker

View file

@ -159,6 +159,7 @@ class HTMLForm extends ContextSource {
'date' => HTMLDateTimeField::class,
'time' => HTMLDateTimeField::class,
'datetime' => HTMLDateTimeField::class,
'expiry' => HTMLExpiryField::class,
// HTMLTextField will output the correct type="" attribute automagically.
// There are about four zillion other HTML5 input types, like range, but
// we don't use those at the moment, so no point in adding all of them.

View file

@ -0,0 +1,88 @@
<?php
use MediaWiki\Widget\ExpiryInputWidget;
/**
* Expiry Field that allows the user to specify a precise date or a
* relative date string.
*/
class HTMLExpiryField extends HTMLFormField {
/**
* @var HTMLFormField
*/
protected $relativeField;
/**
* Relative Date Time Field.
*/
public function __construct( array $params = [] ) {
parent::__construct( $params );
$type = !empty( $params['options'] ) ? 'selectorother' : 'text';
$this->relativeField = $this->getFieldByType( $type );
}
/**
* {@inheritdoc}
*
* Use whatever the relative field is as the standard HTML input.
*/
public function getInputHTML( $value ) {
return $this->relativeField->getInputHtml( $value );
}
protected function shouldInfuseOOUI() {
return true;
}
/**
* {@inheritdoc}
*/
protected function getOOUIModules() {
return array_merge(
[
'mediawiki.widgets.expiry',
],
$this->relativeField->getOOUIModules()
);
}
/**
* {@inheritdoc}
*/
public function getInputOOUI( $value ) {
return new ExpiryInputWidget(
$this->relativeField->getInputOOUI( $value ),
[
'id' => $this->mID,
'required' => isset( $this->mParams['required'] ) ? $this->mParams['required'] : false,
]
);
}
/**
* {@inheritdoc}
*/
public function loadDataFromRequest( $request ) {
return $this->relativeField->loadDataFromRequest( $request );
}
/**
* Get the HTMLForm field by the type string.
*
* @param string $type
* @return \HTMLFormField
*/
protected function getFieldByType( $type ) {
$class = HTMLForm::$typeMappings[$type];
$params = $this->mParams;
$params['type'] = $type;
$params['class'] = $class;
// Remove Parameters that are being used on the parent.
unset( $params['label-message'] );
return new $class( $params );
}
}

View file

@ -151,11 +151,10 @@ class SpecialBlock extends FormSpecialPage {
'validation-callback' => [ __CLASS__, 'validateTargetField' ],
],
'Expiry' => [
'type' => !count( $suggestedDurations ) ? 'text' : 'selectorother',
'type' => 'expiry',
'label-message' => 'ipbexpiry',
'required' => true,
'options' => $suggestedDurations,
'other' => $this->msg( 'ipbother' )->text(),
'default' => $this->msg( 'ipb-default-expiry' )->inContentLanguage()->text(),
],
'Reason' => [
@ -876,29 +875,38 @@ class SpecialBlock extends FormSpecialPage {
$a[$show] = $value;
}
if ( $a ) {
// if options exist, add other to the end instead of the begining (which
// is what happens by default).
$a[ wfMessage( 'ipbother' )->text() ] = 'other';
}
return $a;
}
/**
* Convert a submitted expiry time, which may be relative ("2 weeks", etc) or absolute
* ("24 May 2034", etc), into an absolute timestamp we can put into the database.
*
* @todo strtotime() only accepts English strings. This means the expiry input
* can only be specified in English.
* @see https://secure.php.net/manual/en/function.strtotime.php
*
* @param string $expiry Whatever was typed into the form
* @return string Timestamp or 'infinity'
* @return string|bool Timestamp or 'infinity' or false on error.
*/
public static function parseExpiryInput( $expiry ) {
if ( wfIsInfinity( $expiry ) ) {
$expiry = 'infinity';
} else {
$expiry = strtotime( $expiry );
if ( $expiry < 0 || $expiry === false ) {
return false;
}
$expiry = wfTimestamp( TS_MW, $expiry );
return 'infinity';
}
return $expiry;
$expiry = strtotime( $expiry );
if ( $expiry < 0 || $expiry === false ) {
return false;
}
return wfTimestamp( TS_MW, $expiry );
}
/**

View file

@ -0,0 +1,77 @@
<?php
namespace MediaWiki\Widget;
use OOUI\Widget;
/**
* Expiry widget.
*
* Allows the user to toggle between a precise time or enter a relative time,
* regardless, the value comes in as a relative time.
*
* @copyright 2018 MediaWiki Widgets Team and others; see AUTHORS.txt
* @license MIT
*/
class ExpiryInputWidget extends Widget {
/**
* @var Widget
*/
protected $relativeInput;
/**
* @var bool
*/
protected $noDatePicker;
/**
* @var bool
*/
protected $required;
/**
* @param Widget $relativeInput
* @param array $options Configuration options
*/
public function __construct( Widget $relativeInput, array $options = [] ) {
$config = \RequestContext::getMain()->getConfig();
$options['noDatePicker'] = $config->get( 'ExpiryWidgetNoDatePicker' );
parent::__construct( $options );
$this->noDatePicker = $options['noDatePicker'];
$this->required = isset( $options['required'] ) ? $options['required'] : false;
// Properties
$this->relativeInput = $relativeInput;
$this->relativeInput->addClasses( [ 'mw-widget-ExpiryWidget-relative' ] );
// Initialization
$classes = [
'mw-widget-ExpiryWidget',
];
if ( $options['noDatePicker'] === false ) {
$classes[] = 'mw-widget-ExpiryWidget-hasDatePicker';
}
$this
->addClasses( $classes )
->appendContent( $this->relativeInput );
}
protected function getJavaScriptClassName() {
return 'mw.widgets.ExpiryWidget';
}
/**
* {@inheritdoc}
*/
public function getConfig( &$config ) {
$config['noDatePicker'] = $this->noDatePicker;
$config['required'] = $this->required;
$config['relativeInput'] = [];
$this->relativeInput->getConfig( $config['relativeInput'] );
return parent::getConfig( $config );
}
}

View file

@ -2063,9 +2063,13 @@ return [
'scripts' => 'resources/src/mediawiki.special/mediawiki.special.block.js',
'dependencies' => [
'oojs-ui-core',
'oojs-ui.styles.icons-editing-core',
'oojs-ui.styles.icons-editing-advanced',
'mediawiki.widgets.SelectWithInputWidget',
'mediawiki.widgets.DateInputWidget',
'mediawiki.util',
'mediawiki.htmlform',
'moment',
],
],
'mediawiki.special.changecredentials.js' => [
@ -2604,6 +2608,21 @@ return [
],
'targets' => [ 'desktop', 'mobile' ],
],
'mediawiki.widgets.expiry' => [
'scripts' => [
'resources/src/mediawiki.widgets/mw.widgets.ExpiryInputWidget.js',
],
'dependencies' => [
'oojs-ui-core',
'oojs-ui-widgets',
'moment',
'mediawiki.widgets.datetime'
],
'skinStyles' => [
'default' => 'resources/src/mediawiki.widgets/mw.widgets.ExpiryInputWidget.less',
],
'targets' => [ 'desktop', 'mobile' ],
],
'mediawiki.widgets.CategoryMultiselectWidget' => [
'scripts' => [
'resources/src/mediawiki.widgets/mw.widgets.CategoryCapsuleItemWidget.js',

View file

@ -19,7 +19,6 @@
enableAutoblockField = infuseOrNull( $( '#mw-input-wpAutoBlock' ).closest( '.oo-ui-fieldLayout' ) ),
hideUserField = infuseOrNull( $( '#mw-input-wpHideUser' ).closest( '.oo-ui-fieldLayout' ) ),
watchUserField = infuseOrNull( $( '#mw-input-wpWatch' ).closest( '.oo-ui-fieldLayout' ) ),
// mw.widgets.SelectWithInputWidget
expiryWidget = infuseOrNull( 'mw-input-wpExpiry' );
function updateBlockOptions() {
@ -28,11 +27,10 @@
isIp = mw.util.isIPAddress( blocktarget, true ),
isIpRange = isIp && blocktarget.match( /\/\d+$/ ),
isNonEmptyIp = isIp && !isEmpty,
expiryValue = expiryWidget.dropdowninput.getValue(),
expiryValue = expiryWidget.getValue(),
// infinityValues are the values the SpecialBlock class accepts as infinity (sf. wfIsInfinity)
infinityValues = [ 'infinite', 'indefinite', 'infinity', 'never' ],
isIndefinite = infinityValues.indexOf( expiryValue ) !== -1 ||
( expiryValue === 'other' && infinityValues.indexOf( expiryWidget.textinput.getValue() ) !== -1 );
isIndefinite = infinityValues.indexOf( expiryValue ) !== -1;
if ( enableAutoblockField ) {
enableAutoblockField.toggle( !( isNonEmptyIp ) );
@ -51,8 +49,7 @@
if ( blockTargetWidget ) {
// Bind functions so they're checked whenever stuff changes
blockTargetWidget.on( 'change', updateBlockOptions );
expiryWidget.dropdowninput.on( 'change', updateBlockOptions );
expiryWidget.textinput.on( 'change', updateBlockOptions );
expiryWidget.on( 'change', updateBlockOptions );
// Call them now to set initial state (ie. Special:Block/Foobar?wpBlockExpiry=2+hours)
updateBlockOptions();

View file

@ -0,0 +1,227 @@
/*!
* MediaWiki Widgets - ExpiryWidget class.
*
* @copyright 2018 MediaWiki Widgets Team and others; see AUTHORS.txt
* @license The MIT License (MIT); see LICENSE.txt
*/
/* global moment */
( function ( $, mw ) {
/**
* Creates a mw.widgets.ExpiryWidget object.
*
* @class mw.widgets.ExpiryWidget
* @extends OO.ui.Widget
*
* @constructor
* @param {Object} [config] Configuration options
*/
mw.widgets.ExpiryWidget = function ( config ) {
var RFC2822 = 'ddd, DD MMM YYYY HH:mm:ss [GMT]';
// Config initialization
config = $.extend( {}, config );
this.relativeField = new config.RelativeInputClass( config.relativeInput );
this.relativeField.$element.addClass( 'mw-widget-ExpiryWidget-relative' );
// Parent constructor
mw.widgets.ExpiryWidget.parent.call( this, config );
// If the wiki does not want the date picker, then initialize the relative
// field and exit.
if ( config.noDatePicker ) {
this.relativeField.on( 'change', function ( event ) {
// Emit a change event for this widget.
this.emit( 'change', event );
}.bind( this ) );
// Initialization
this.$element
.addClass( 'mw-widget-ExpiryWidget' )
.append(
this.relativeField.$element
);
return;
}
// Properties
this.inputSwitch = new OO.ui.ButtonSelectWidget( {
tabIndex: -1,
items: [
new OO.ui.ButtonOptionWidget( {
data: 'relative',
icon: 'edit'
} ),
new OO.ui.ButtonOptionWidget( {
data: 'date',
icon: 'calendar'
} )
]
} );
this.dateTimeField = new mw.widgets.datetime.DateTimeInputWidget( {
min: new Date(), // The selected date must at least be now.
required: config.required
} );
// Initially hide the dateTime field.
this.dateTimeField.toggle( false );
// Initially set the relative input.
this.inputSwitch.selectItemByData( 'relative' );
// Events
// Toggle the visible inputs.
this.inputSwitch.on( 'choose', function ( event ) {
switch ( event.getData() ) {
case 'date':
this.dateTimeField.toggle( true );
this.relativeField.toggle( false );
break;
case 'relative':
this.dateTimeField.toggle( false );
this.relativeField.toggle( true );
break;
}
}.bind( this ) );
// When the date time field update, update the relative
// field.
this.dateTimeField.on( 'change', function ( value ) {
var datetime;
// Do not alter the visible input.
if ( this.relativeField.isVisible() ) {
return;
}
// If the value was cleared, do not attempt to parse it.
if ( !value ) {
this.relativeField.setValue( value );
return;
}
datetime = moment( value );
// If the datetime is invlaid for some reason, reset the relative field.
if ( !datetime.isValid() ) {
this.relativeField.setValue( undefined );
}
// Set the relative field value. The field only accepts English strings.
this.relativeField.setValue( datetime.utc().locale( 'en' ).format( RFC2822 ) );
}.bind( this ) );
// When the relative field update, update the date time field if it's a
// value that moment understands.
this.relativeField.on( 'change', function ( event ) {
var datetime;
// Emit a change event for this widget.
this.emit( 'change', event );
// Do not alter the visible input.
if ( this.dateTimeField.isVisible() ) {
return;
}
// Parsing of free text field may fail, so always check if the date is
// valid.
datetime = moment( event );
if ( datetime.isValid() ) {
this.dateTimeField.setValue( datetime.utc().toISOString() );
} else {
this.dateTimeField.setValue( undefined );
}
}.bind( this ) );
// Initialization
this.$element
.addClass( 'mw-widget-ExpiryWidget' )
.addClass( 'mw-widget-ExpiryWidget-hasDatePicker' )
.append(
this.inputSwitch.$element,
this.dateTimeField.$element,
this.relativeField.$element
);
// Trigger an initial onChange.
this.relativeField.emit( 'change', this.relativeField.getValue() );
};
/* Inheritance */
OO.inheritClass( mw.widgets.ExpiryWidget, OO.ui.Widget );
/**
* @inheritdoc
*/
mw.widgets.ExpiryWidget.static.reusePreInfuseDOM = function ( node, config ) {
var relativeElement = $( node ).find( '.mw-widget-ExpiryWidget-relative' );
config = mw.widgets.ExpiryWidget.parent.static.reusePreInfuseDOM( node, config );
if ( relativeElement.hasClass( 'oo-ui-textInputWidget' ) ) {
config.RelativeInputClass = OO.ui.TextInputWidget;
} else if ( relativeElement.hasClass( 'mw-widget-selectWithInputWidget' ) ) {
config.RelativeInputClass = mw.widgets.SelectWithInputWidget;
}
config.relativeInput = config.RelativeInputClass.static.reusePreInfuseDOM(
relativeElement,
config.relativeInput
);
return config;
};
/**
* @inheritdoc
*/
mw.widgets.ExpiryWidget.static.gatherPreInfuseState = function ( node, config ) {
var state = mw.widgets.ExpiryWidget.parent.static.gatherPreInfuseState( node, config );
state.relativeInput = config.RelativeInputClass.static.gatherPreInfuseState(
$( node ).find( '.mw-widget-ExpiryWidget-relative' ),
config.relativeInput
);
return state;
};
/**
* @inheritdoc
*/
mw.widgets.ExpiryWidget.prototype.restorePreInfuseState = function ( state ) {
mw.widgets.ExpiryWidget.parent.prototype.restorePreInfuseState.call( this, state );
this.relativeField.restorePreInfuseState( state.relativeInput );
};
/**
* @inheritdoc
*/
mw.widgets.ExpiryWidget.prototype.setDisabled = function ( disabled ) {
mw.widgets.ExpiryWidget.parent.prototype.setDisabled.call( this, disabled );
this.relativeField.setDisabled( disabled );
if ( this.inputSwitch ) {
this.inputSwitch.setDisabled( disabled );
}
if ( this.dateTimeField ) {
this.dateTimeField.setDisabled( disabled );
}
};
/**
* Gets the value of the widget.
*
* @return {string}
*/
mw.widgets.ExpiryWidget.prototype.getValue = function () {
return this.relativeField.getValue();
};
}( jQuery, mediaWiki ) );

View file

@ -0,0 +1,26 @@
@wm-expirywidget-text-width: 43.3em;
.mw-widget-ExpiryWidget.mw-widget-ExpiryWidget-hasDatePicker {
.oo-ui-buttonSelectWidget {
float: left;
}
.oo-ui-textInputWidget.mw-widget-ExpiryWidget-relative {
display: inline-block;
max-width: @wm-expirywidget-text-width;
}
.mw-widget-selectWithInputWidget.mw-widget-ExpiryWidget-relative .oo-ui-textInputWidget {
max-width: 22.8em;
}
.mw-widgets-datetime-dateTimeInputWidget {
margin-top: 0;
margin-bottom: 0;
max-width: @wm-expirywidget-text-width;
.mw-widgets-datetime-dateTimeInputWidget-handle {
max-height: 2.286em;
}
}
}

View file

@ -50,6 +50,9 @@
// Events
this.dropdowninput.on( 'change', this.onChange.bind( this ) );
this.textinput.on( 'change', function () {
this.emit( 'change', this.getValue() );
}.bind( this ) );
// Parent constructor
mw.widgets.SelectWithInputWidget.parent.call( this, config );
@ -125,6 +128,48 @@
this.textinput.setDisabled( textinputIsHidden || disabled );
};
/**
* Set the value from outside.
*
* @param {string|undefined} value
*/
mw.widgets.SelectWithInputWidget.prototype.setValue = function ( value ) {
var selectable = false;
if ( this.or ) {
if ( value !== 'other' ) {
selectable = !!this.dropdowninput.dropdownWidget.getMenu().findItemFromData( value );
}
if ( selectable ) {
this.dropdowninput.setValue( value );
this.textinput.setValue( undefined );
} else {
this.dropdowninput.setValue( 'other' );
this.textinput.setValue( value );
}
this.emit( 'change', value );
}
};
/**
* Get the value from outside.
*
* @return {string}
*/
mw.widgets.SelectWithInputWidget.prototype.getValue = function () {
if ( this.or ) {
if ( this.dropdowninput.getValue() !== 'other' ) {
return this.dropdowninput.getValue();
}
return this.textinput.getValue();
} else {
return '';
}
};
/**
* Handle change events on the DropdownInput
*
@ -140,6 +185,8 @@
// submitted with the form. So we should also disable fields when hiding them.
this.textinput.setDisabled( value !== 'other' || this.isDisabled() );
}
this.emit( 'change', this.getValue() );
};
}( jQuery, mediaWiki ) );