From 3481e3b2e0afed1b0af66cadc9dffc32fdd30839 Mon Sep 17 00:00:00 2001 From: David Barratt Date: Thu, 22 Mar 2018 01:15:16 -0400 Subject: [PATCH] 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 --- autoload.php | 2 + includes/DefaultSettings.php | 9 + includes/htmlform/HTMLForm.php | 1 + includes/htmlform/fields/HTMLExpiryField.php | 88 +++++++ includes/specials/SpecialBlock.php | 30 ++- includes/widget/ExpiryInputWidget.php | 77 ++++++ resources/Resources.php | 19 ++ .../mediawiki.special.block.js | 9 +- .../mw.widgets.ExpiryInputWidget.js | 227 ++++++++++++++++++ .../mw.widgets.ExpiryInputWidget.less | 26 ++ .../mw.widgets.SelectWithInputWidget.js | 47 ++++ 11 files changed, 518 insertions(+), 17 deletions(-) create mode 100644 includes/htmlform/fields/HTMLExpiryField.php create mode 100644 includes/widget/ExpiryInputWidget.php create mode 100644 resources/src/mediawiki.widgets/mw.widgets.ExpiryInputWidget.js create mode 100644 resources/src/mediawiki.widgets/mw.widgets.ExpiryInputWidget.less diff --git a/autoload.php b/autoload.php index 126362c2e6..899da6b34f 100644 --- a/autoload.php +++ b/autoload.php @@ -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', diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index f473b3e7f7..33152ed8d8 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -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 diff --git a/includes/htmlform/HTMLForm.php b/includes/htmlform/HTMLForm.php index af1743e078..b14811c3a6 100644 --- a/includes/htmlform/HTMLForm.php +++ b/includes/htmlform/HTMLForm.php @@ -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. diff --git a/includes/htmlform/fields/HTMLExpiryField.php b/includes/htmlform/fields/HTMLExpiryField.php new file mode 100644 index 0000000000..b68c7e3435 --- /dev/null +++ b/includes/htmlform/fields/HTMLExpiryField.php @@ -0,0 +1,88 @@ +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 ); + } + +} diff --git a/includes/specials/SpecialBlock.php b/includes/specials/SpecialBlock.php index 23691b251a..efe354a346 100644 --- a/includes/specials/SpecialBlock.php +++ b/includes/specials/SpecialBlock.php @@ -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 ); + return 'infinity'; + } - if ( $expiry < 0 || $expiry === false ) { - return false; - } + $expiry = strtotime( $expiry ); - $expiry = wfTimestamp( TS_MW, $expiry ); + if ( $expiry < 0 || $expiry === false ) { + return false; } - return $expiry; + return wfTimestamp( TS_MW, $expiry ); } /** diff --git a/includes/widget/ExpiryInputWidget.php b/includes/widget/ExpiryInputWidget.php new file mode 100644 index 0000000000..7395df1ce7 --- /dev/null +++ b/includes/widget/ExpiryInputWidget.php @@ -0,0 +1,77 @@ +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 ); + } +} diff --git a/resources/Resources.php b/resources/Resources.php index a424b595b6..2f013d450c 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -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', diff --git a/resources/src/mediawiki.special/mediawiki.special.block.js b/resources/src/mediawiki.special/mediawiki.special.block.js index ba9319510d..180f040135 100644 --- a/resources/src/mediawiki.special/mediawiki.special.block.js +++ b/resources/src/mediawiki.special/mediawiki.special.block.js @@ -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(); diff --git a/resources/src/mediawiki.widgets/mw.widgets.ExpiryInputWidget.js b/resources/src/mediawiki.widgets/mw.widgets.ExpiryInputWidget.js new file mode 100644 index 0000000000..54d4f2a753 --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.ExpiryInputWidget.js @@ -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 ) ); diff --git a/resources/src/mediawiki.widgets/mw.widgets.ExpiryInputWidget.less b/resources/src/mediawiki.widgets/mw.widgets.ExpiryInputWidget.less new file mode 100644 index 0000000000..cd0cbd7ace --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.ExpiryInputWidget.less @@ -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; + } + } +} diff --git a/resources/src/mediawiki.widgets/mw.widgets.SelectWithInputWidget.js b/resources/src/mediawiki.widgets/mw.widgets.SelectWithInputWidget.js index 196035132f..436ca2f56b 100644 --- a/resources/src/mediawiki.widgets/mw.widgets.SelectWithInputWidget.js +++ b/resources/src/mediawiki.widgets/mw.widgets.SelectWithInputWidget.js @@ -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 ) ); -- 2.20.1