Create Expiry Widget with Date Time Selector
authorDavid Barratt <dbarratt@wikimedia.org>
Thu, 22 Mar 2018 05:15:16 +0000 (01:15 -0400)
committerDavid Barratt <dbarratt@wikimedia.org>
Fri, 20 Apr 2018 00:24:08 +0000 (20:24 -0400)
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
includes/DefaultSettings.php
includes/htmlform/HTMLForm.php
includes/htmlform/fields/HTMLExpiryField.php [new file with mode: 0644]
includes/specials/SpecialBlock.php
includes/widget/ExpiryInputWidget.php [new file with mode: 0644]
resources/Resources.php
resources/src/mediawiki.special/mediawiki.special.block.js
resources/src/mediawiki.widgets/mw.widgets.ExpiryInputWidget.js [new file with mode: 0644]
resources/src/mediawiki.widgets/mw.widgets.ExpiryInputWidget.less [new file with mode: 0644]
resources/src/mediawiki.widgets/mw.widgets.SelectWithInputWidget.js

index 126362c..899da6b 100644 (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',
index f473b3e..33152ed 100644 (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
index af1743e..b14811c 100644 (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.
diff --git a/includes/htmlform/fields/HTMLExpiryField.php b/includes/htmlform/fields/HTMLExpiryField.php
new file mode 100644 (file)
index 0000000..b68c7e3
--- /dev/null
@@ -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 );
+       }
+
+}
index 23691b2..efe354a 100644 (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 );
+                       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 (file)
index 0000000..7395df1
--- /dev/null
@@ -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 );
+       }
+}
index a424b59..2f013d4 100644 (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',
index ba93195..180f040 100644 (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() {
                                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 (file)
index 0000000..54d4f2a
--- /dev/null
@@ -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 (file)
index 0000000..cd0cbd7
--- /dev/null
@@ -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;
+               }
+       }
+}
index 1960351..436ca2f 100644 (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 );
                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
         *
                        // 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 ) );