MediaWiki master
HTMLFormField.php
Go to the documentation of this file.
1<?php
2
3namespace MediaWiki\HTMLForm;
4
5use InvalidArgumentException;
16use RuntimeException;
17use StatusValue;
21
28abstract class HTMLFormField implements MessageLocalizer {
30 public $mParams;
31
33 protected $mValidationCallback;
35 protected $mFilterCallback;
37 protected $mName;
39 protected $mDir;
41 protected $mLabel;
43 protected $mID;
45 protected $mClass = '';
47 protected $mHelpClass = false;
49 protected $mDefault;
51 private $mNotices;
52
56 protected $mOptions = false;
62 protected $mCondState = [];
64 protected $mCondStateClass = [];
65
70 protected $mShowEmptyLabels = true;
71
75 public $mParent;
76
87 abstract public function getInputHTML( $value );
88
97 public function getInputOOUI( $value ) {
98 return false;
99 }
100
112 public function getInputCodex( $value, $hasErrors ) {
113 // If not overridden, fall back to getInputHTML()
114 return $this->getInputHTML( $value );
115 }
116
123 public function canDisplayErrors() {
124 return $this->hasVisibleOutput();
125 }
126
138 public function msg( $key, ...$params ) {
139 return $this->mParent->msg( $key, ...$params );
140 }
141
149 public function hasVisibleOutput() {
150 return true;
151 }
152
159 public function getName() {
160 return $this->mName;
161 }
162
174 protected function getNearestField( $name, $backCompat = false ) {
175 // When the field is belong to a HTMLFormFieldCloner
176 $cloner = $this->mParams['cloner'] ?? null;
177 if ( $cloner instanceof HTMLFormFieldCloner ) {
178 $field = $cloner->findNearestField( $this, $name );
179 if ( $field ) {
180 return $field;
181 }
182 }
183
184 if ( $backCompat && str_starts_with( $name, 'wp' ) &&
185 !$this->mParent->hasField( $name )
186 ) {
187 // Don't break the existed use cases.
188 return $this->mParent->getField( substr( $name, 2 ) );
189 }
190 return $this->mParent->getField( $name );
191 }
192
204 protected function getNearestFieldValue( $alldata, $name, $asDisplay = false, $backCompat = false ) {
205 $field = $this->getNearestField( $name, $backCompat );
206 // When the field belongs to a HTMLFormFieldCloner
207 $cloner = $field->mParams['cloner'] ?? null;
208 if ( $cloner instanceof HTMLFormFieldCloner ) {
209 $value = $cloner->extractFieldData( $field, $alldata );
210 } else {
211 // Note $alldata is an empty array when first rendering a form with a formIdentifier.
212 // In that case, $alldata[$field->mParams['fieldname']] is unset and we use the
213 // field's default value
214 $value = $alldata[$field->mParams['fieldname']] ?? $field->getDefault();
215 }
216
217 // Check invert state for HTMLCheckField
218 if ( $asDisplay && $field instanceof HTMLCheckField && ( $field->mParams['invert'] ?? false ) ) {
219 $value = !$value;
220 }
221
222 return $value;
223 }
224
235 protected function getNearestFieldByName( $alldata, $name, $asDisplay = false ) {
236 return (string)$this->getNearestFieldValue( $alldata, $name, $asDisplay );
237 }
238
245 protected function validateCondState( $params ) {
246 $origParams = $params;
247 $op = array_shift( $params );
248
249 $makeException = function ( string $details ) use ( $origParams ): InvalidArgumentException {
250 return new InvalidArgumentException(
251 "Invalid hide-if or disable-if specification for $this->mName: " .
252 $details . " in " . var_export( $origParams, true )
253 );
254 };
255
256 switch ( $op ) {
257 case 'NOT':
258 if ( count( $params ) !== 1 ) {
259 throw $makeException( "NOT takes exactly one parameter" );
260 }
261 // Fall-through intentionally
262
263 case 'AND':
264 case 'OR':
265 case 'NAND':
266 case 'NOR':
267 foreach ( $params as $i => $p ) {
268 if ( !is_array( $p ) ) {
269 $type = get_debug_type( $p );
270 throw $makeException( "Expected array, found $type at index $i" );
271 }
272 $this->validateCondState( $p );
273 }
274 break;
275
276 case '===':
277 case '!==':
278 case 'CONTAINS':
279 if ( count( $params ) !== 2 ) {
280 throw $makeException( "$op takes exactly two parameters" );
281 }
282 [ $name, $value ] = $params;
283 if ( !is_string( $name ) || !is_string( $value ) ) {
284 throw $makeException( "Parameters for $op must be strings" );
285 }
286 break;
287
288 default:
289 throw $makeException( "Unknown operation" );
290 }
291 }
292
300 protected function checkStateRecurse( array $alldata, array $params ) {
301 $op = array_shift( $params );
302 $valueChk = [ 'AND' => false, 'OR' => true, 'NAND' => false, 'NOR' => true ];
303 $valueRet = [ 'AND' => true, 'OR' => false, 'NAND' => false, 'NOR' => true ];
304
305 switch ( $op ) {
306 case 'AND':
307 case 'OR':
308 case 'NAND':
309 case 'NOR':
310 foreach ( $params as $p ) {
311 if ( $valueChk[$op] === $this->checkStateRecurse( $alldata, $p ) ) {
312 return !$valueRet[$op];
313 }
314 }
315 return $valueRet[$op];
316
317 case 'NOT':
318 return !$this->checkStateRecurse( $alldata, $params[0] );
319
320 case '===':
321 case '!==':
322 case 'CONTAINS':
323 [ $field, $value ] = $params;
324 $testValue = $this->getNearestFieldValue( $alldata, $field, true, true );
325 switch ( $op ) {
326 case '===':
327 return ( $value === (string)$testValue );
328 case '!==':
329 return ( $value !== (string)$testValue );
330 case 'CONTAINS':
331 return in_array( $value, $testValue, true );
332 }
333 }
334 }
335
344 protected function parseCondState( $params ) {
345 $op = array_shift( $params );
346
347 switch ( $op ) {
348 case 'AND':
349 case 'OR':
350 case 'NAND':
351 case 'NOR':
352 $ret = [ $op ];
353 foreach ( $params as $p ) {
354 $ret[] = $this->parseCondState( $p );
355 }
356 return $ret;
357
358 case 'NOT':
359 return [ 'NOT', $this->parseCondState( $params[0] ) ];
360
361 case '===':
362 case '!==':
363 case 'CONTAINS':
364 [ $name, $value ] = $params;
365 $field = $this->getNearestField( $name, true );
366 return [ $op, $field->getName(), $value ];
367 }
368 }
369
375 protected function parseCondStateForClient() {
376 $parsed = [];
377 foreach ( $this->mCondState as $type => $params ) {
378 // Omit 'hide-nojs' and 'disable-nojs' here, not needed.
379 if ( in_array( $type, [ 'hide', 'disable' ], true ) ) {
380 $parsed[$type] = $this->parseCondState( $params );
381 }
382 }
383 // Needed to distinguish fields that are initially disabled because of 'hide-if-nojs' or
384 // 'disable-if-nojs', from fields that are always disabled because of their parameters.
385 $parsed['alwaysDisabled'] = ( $this->mParams['disabled'] ?? false );
386 return $parsed;
387 }
388
397 public function isHidden( $alldata ) {
398 return isset( $this->mCondState['hide'] ) &&
399 $this->checkStateRecurse( $alldata, $this->mCondState['hide'] );
400 }
401
409 public function isHiddenNoJs( $alldata ) {
410 return isset( $this->mCondState['hide-nojs'] ) &&
411 $this->checkStateRecurse( $alldata, $this->mCondState['hide-nojs'] );
412 }
413
422 public function isDisabled( $alldata ) {
423 return ( $this->mParams['disabled'] ?? false ) ||
424 $this->isHidden( $alldata ) ||
425 ( isset( $this->mCondState['disable'] )
426 && $this->checkStateRecurse( $alldata, $this->mCondState['disable'] ) );
427 }
428
437 public function isDisabledNoJs( $alldata ) {
438 return ( $this->mParams['disabled'] ?? false ) ||
439 $this->isHiddenNoJs( $alldata ) ||
440 ( isset( $this->mCondState['disable-nojs'] )
441 && $this->checkStateRecurse( $alldata, $this->mCondState['disable-nojs'] ) );
442 }
443
455 public function cancelSubmit( $value, $alldata ) {
456 return false;
457 }
458
471 public function validate( $value, $alldata ) {
472 if ( $this->isHidden( $alldata ) ) {
473 return true;
474 }
475
476 if ( isset( $this->mParams['required'] )
477 && $this->mParams['required'] !== false
478 && ( $value === '' || $value === false || $value === null )
479 ) {
480 return $this->msg( 'htmlform-required' );
481 }
482
483 if ( $this->mValidationCallback === null ) {
484 return true;
485 }
486
487 $p = ( $this->mValidationCallback )( $value, $alldata, $this->mParent );
488
489 if ( $p instanceof StatusValue ) {
490 $language = $this->mParent->getLanguage();
491
492 return $p->isGood() ? true : Status::wrap( $p )->getHTML( false, false, $language );
493 }
494
495 return $p;
496 }
497
506 public function filter( $value, $alldata ) {
507 if ( $this->mFilterCallback !== null ) {
508 $value = ( $this->mFilterCallback )( $value, $alldata, $this->mParent );
509 }
510
511 return $value;
512 }
513
521 protected function needsLabel() {
522 return true;
523 }
524
534 public function setShowEmptyLabel( $show ) {
535 $this->mShowEmptyLabels = $show;
536 }
537
549 protected function isSubmitAttempt( WebRequest $request ) {
550 // HTMLForm would add a hidden field of edit token for forms that require to be posted.
551 return ( $request->wasPosted() && $request->getCheck( 'wpEditToken' ) )
552 // The identifier matching or not has been checked in HTMLForm::prepareForm()
553 || $request->getCheck( 'wpFormIdentifier' );
554 }
555
564 public function loadDataFromRequest( $request ) {
565 if ( $request->getCheck( $this->mName ) ) {
566 return $request->getText( $this->mName );
567 } else {
568 return $this->getDefault();
569 }
570 }
571
580 public function __construct( $params ) {
581 $this->mParams = $params;
582
583 if ( !( isset( $params['parent'] ) && $params['parent'] instanceof HTMLForm ) ) {
584 throw new RuntimeException( "Parent must be set." );
585 }
586 $this->mParent = $params['parent'];
587
588 # Generate the label from a message, if possible
589 if ( isset( $params['label-message'] ) ) {
590 $this->mLabel = $this->getMessage( $params['label-message'] )->parse();
591 } elseif ( isset( $params['label'] ) ) {
592 if ( $params['label'] === '&#160;' || $params['label'] === "\u{00A0}" ) {
593 // Apparently some things set &nbsp directly and in an odd format
594 $this->mLabel = "\u{00A0}";
595 } else {
596 $this->mLabel = htmlspecialchars( $params['label'] );
597 }
598 } elseif ( isset( $params['label-raw'] ) ) {
599 $this->mLabel = $params['label-raw'];
600 }
601
602 $this->mName = $params['name'] ?? 'wp' . $params['fieldname'];
603
604 if ( isset( $params['dir'] ) ) {
605 $this->mDir = $params['dir'];
606 }
607
608 $this->mID = "mw-input-{$this->mName}";
609
610 if ( isset( $params['default'] ) ) {
611 $this->mDefault = $params['default'];
612 }
613
614 if ( isset( $params['id'] ) ) {
615 $this->mID = $params['id'];
616 }
617
618 if ( isset( $params['cssclass'] ) ) {
619 $this->mClass = $params['cssclass'];
620 }
621
622 if ( isset( $params['csshelpclass'] ) ) {
623 $this->mHelpClass = $params['csshelpclass'];
624 }
625
626 if ( isset( $params['validation-callback'] ) ) {
627 $this->mValidationCallback = $params['validation-callback'];
628 }
629
630 if ( isset( $params['filter-callback'] ) ) {
631 $this->mFilterCallback = $params['filter-callback'];
632 }
633
634 if ( isset( $params['hidelabel'] ) ) {
635 $this->mShowEmptyLabels = false;
636 }
637 if ( isset( $params['notices'] ) ) {
638 $this->mNotices = $params['notices'];
639 }
640
641 if ( isset( $params['hide-if'] ) && $params['hide-if'] ) {
642 $this->validateCondState( $params['hide-if'] );
643 $this->mCondState['hide'] = $params['hide-if'];
644 }
645 if ( isset( $params['hide-if-nojs'] ) && $params['hide-if-nojs'] ) {
646 $this->validateCondState( $params['hide-if-nojs'] );
647 $this->mCondState['hide-nojs'] = $params['hide-if-nojs'];
648 // Merge the rules here so that we don't have to handle two options everywhere else.
649 // This value is sent to the client-side JS, so that stays consistent as well.
650 $this->mCondState['hide'] = isset( $this->mCondState['hide'] ) ?
651 [ 'OR', $this->mCondState['hide'], $params['hide-if-nojs'] ] :
652 $params['hide-if-nojs'];
653 }
654 if ( isset( $this->mCondState['hide'] ) ) {
655 $this->mCondStateClass[] = 'mw-htmlform-hide-if';
656 }
657
658 if ( !( isset( $params['disabled'] ) && $params['disabled'] ) &&
659 isset( $params['disable-if'] ) && $params['disable-if']
660 ) {
661 $this->validateCondState( $params['disable-if'] );
662 $this->mCondState['disable'] = $params['disable-if'];
663 }
664 if ( !( isset( $params['disabled'] ) && $params['disabled'] ) &&
665 isset( $params['disable-if-nojs'] ) && $params['disable-if-nojs']
666 ) {
667 $this->validateCondState( $params['disable-if-nojs'] );
668 $this->mCondState['disable-nojs'] = $params['disable-if-nojs'];
669 // Merge the rules here so that we don't have to handle two options everywhere else.
670 // This value is sent to the client-side JS, so that stays consistent as well.
671 $this->mCondState['disable'] = isset( $this->mCondState['disable'] ) ?
672 [ 'OR', $this->mCondState['disable'], $params['disable-if-nojs'] ] :
673 $params['disable-if-nojs'];
674 }
675 if ( isset( $this->mCondState['disable'] ) ) {
676 $this->mCondStateClass[] = 'mw-htmlform-disable-if';
677 }
678 }
679
689 public function getTableRow( $value ) {
690 [ $errors, $errorClass ] = $this->getErrorsAndErrorClass( $value );
691 $inputHtml = $this->getInputHTML( $value );
692 $fieldType = $this->getClassName();
693 $helptext = $this->getHelpTextHtmlTable( $this->getHelpText() );
694 $cellAttributes = [];
695 $rowAttributes = [];
696 $rowClasses = '';
697
698 if ( !empty( $this->mParams['vertical-label'] ) ) {
699 $cellAttributes['colspan'] = 2;
700 $verticalLabel = true;
701 } else {
702 $verticalLabel = false;
703 }
704
705 $label = $this->getLabelHtml( $cellAttributes );
706
707 $field = Html::rawElement(
708 'td',
709 [ 'class' => 'mw-input' ] + $cellAttributes,
710 $inputHtml . "\n$errors"
711 );
712
713 if ( $this->mCondState ) {
714 $rowAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
715 $rowClasses .= implode( ' ', $this->mCondStateClass );
716 if ( $this->isHiddenNoJs( $this->mParent->mFieldData ) ) {
717 $rowClasses .= ' mw-htmlform-hide-if-hidden-nojs';
718 } elseif ( $this->isHidden( $this->mParent->mFieldData ) ) {
719 $rowClasses .= ' mw-htmlform-hide-if-hidden';
720 }
721 }
722
723 if ( $verticalLabel ) {
724 $html = Html::rawElement( 'tr',
725 $rowAttributes + [ 'class' => "mw-htmlform-vertical-label $rowClasses" ], $label );
726 $html .= Html::rawElement( 'tr',
727 $rowAttributes + [
728 'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses"
729 ],
730 $field );
731 } else {
732 $html = Html::rawElement( 'tr',
733 $rowAttributes + [
734 'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses"
735 ],
736 $label . $field );
737 }
738
739 return $html . $helptext;
740 }
741
752 public function getDiv( $value ) {
753 [ $errors, $errorClass ] = $this->getErrorsAndErrorClass( $value );
754 $inputHtml = $this->getInputHTML( $value );
755 $fieldType = $this->getClassName();
756 $helptext = $this->getHelpTextHtmlDiv( $this->getHelpText() );
757 $cellAttributes = [];
758 $label = $this->getLabelHtml( $cellAttributes );
759
760 $outerDivClass = [
761 'mw-input',
762 'mw-htmlform-nolabel' => ( $label === '' )
763 ];
764
765 $horizontalLabel = $this->mParams['horizontal-label'] ?? false;
766
767 if ( $horizontalLabel ) {
768 $field = "\u{00A0}" . $inputHtml . "\n$errors";
769 } else {
770 $field = Html::rawElement(
771 'div',
772 // @phan-suppress-next-line PhanUselessBinaryAddRight
773 [ 'class' => $outerDivClass ] + $cellAttributes,
774 $inputHtml . "\n$errors"
775 );
776 }
777
778 $wrapperAttributes = [ 'class' => [
779 "mw-htmlform-field-$fieldType",
781 $errorClass,
782 ] ];
783 if ( $this->mCondState ) {
784 $wrapperAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
785 $wrapperAttributes['class'] = array_merge( $wrapperAttributes['class'], $this->mCondStateClass );
786 if ( $this->isHiddenNoJs( $this->mParent->mFieldData ) ) {
787 $wrapperAttributes['class'][] = 'mw-htmlform-hide-if-hidden-nojs';
788 } elseif ( $this->isHidden( $this->mParent->mFieldData ) ) {
789 $wrapperAttributes['class'][] = 'mw-htmlform-hide-if-hidden';
790 }
791 }
792 return Html::rawElement( 'div', $wrapperAttributes, $label . $field ) .
793 $helptext;
794 }
795
805 public function getOOUI( $value ) {
806 if ( $this->getDescriptionMessages() !== [] ) {
807 throw new InvalidArgumentException(
808 "OOUIHTMLForm does not support the descriptions for fields. Please use Codex"
809 );
810 }
811 $inputField = $this->getInputOOUI( $value );
812
813 if ( !$inputField ) {
814 // This field doesn't have an OOUI implementation yet at all. Fall back to getDiv() to
815 // generate the whole field, label and errors and all, then wrap it in a Widget.
816 // It might look weird, but it'll work OK.
817 return $this->getFieldLayoutOOUI(
818 new \OOUI\Widget( [ 'content' => new \OOUI\HtmlSnippet( $this->getDiv( $value ) ) ] ),
819 [ 'align' => 'top' ]
820 );
821 }
822
823 $infusable = true;
824 if ( is_string( $inputField ) ) {
825 // We have an OOUI implementation, but it's not proper, and we got a load of HTML.
826 // Cheat a little and wrap it in a widget. It won't be infusable, though, since client-side
827 // JavaScript doesn't know how to rebuilt the contents.
828 $inputField = new \OOUI\Widget( [ 'content' => new \OOUI\HtmlSnippet( $inputField ) ] );
829 $infusable = false;
830 }
831
832 $fieldType = $this->getClassName();
833 $help = $this->getHelpText();
834 $errors = $this->getErrorsRaw( $value );
835 foreach ( $errors as &$error ) {
836 $error = new \OOUI\HtmlSnippet( $error );
837 }
838
839 $config = [
840 'classes' => [ "mw-htmlform-field-$fieldType" ],
841 'align' => $this->getLabelAlignOOUI(),
842 'help' => ( $help !== null && $help !== '' ) ? new \OOUI\HtmlSnippet( $help ) : null,
843 'errors' => $errors,
844 'infusable' => $infusable,
845 'helpInline' => $this->isHelpInline(),
846 'notices' => $this->mNotices ?: [],
847 ];
848 if ( $this->mClass !== '' ) {
849 $config['classes'][] = $this->mClass;
850 }
851
852 $preloadModules = false;
853
854 if ( $infusable && $this->shouldInfuseOOUI() ) {
855 $preloadModules = true;
856 $config['classes'][] = 'mw-htmlform-autoinfuse';
857 }
858 if ( $this->mCondState ) {
859 $config['classes'] = array_merge( $config['classes'], $this->mCondStateClass );
860 if ( $this->isHiddenNoJs( $this->mParent->mFieldData ) ) {
861 $config['classes'][] = 'mw-htmlform-hide-if-hidden-nojs';
862 } elseif ( $this->isHidden( $this->mParent->mFieldData ) ) {
863 $config['classes'][] = 'mw-htmlform-hide-if-hidden';
864 }
865 }
866
867 // the element could specify, that the label doesn't need to be added
868 $label = $this->getLabel();
869 if ( $label && $label !== "\u{00A0}" && $label !== '&#160;' ) {
870 $config['label'] = new \OOUI\HtmlSnippet( $label );
871 }
872
873 if ( $this->mCondState ) {
874 $preloadModules = true;
875 $config['condState'] = $this->parseCondStateForClient();
876 }
877
878 $config['modules'] = $this->getOOUIModules();
879
880 if ( $preloadModules ) {
881 $this->mParent->getOutput()->addModules( 'mediawiki.htmlform.ooui' );
882 $this->mParent->getOutput()->addModules( $this->getOOUIModules() );
883 }
884
885 return $this->getFieldLayoutOOUI( $inputField, $config );
886 }
887
895 public function getCodex( $value ) {
896 $isDisabled = ( $this->mParams['disabled'] ?? false );
897
898 // Label
899 $labelDiv = '';
900 $labelValue = trim( $this->getLabel() );
901 // For weird historical reasons, a non-breaking space is treated as an empty label
902 // Check for both a literal nbsp ("\u{00A0}") and the HTML-encoded version
903 if ( $labelValue !== '' && $labelValue !== "\u{00A0}" && $labelValue !== '&#160;' ) {
904 $labelFor = $this->needsLabel() ? [ 'for' => $this->mID ] : [];
905 $labelClasses = [ 'cdx-label' ];
906 if ( $isDisabled ) {
907 $labelClasses[] = 'cdx-label--disabled';
908 }
909 $descriptionHtml = $this->getDescriptionHtmlSpan(
910 $this->getDescriptionText(),
911 [ 'cdx-label__description' ]
912 );
913 $optionalHtml = '';
914 if ( $this->showOptionalFlag() ) {
915 $messageKey = $this->mParams['optional-message'] ?? 'htmlform-optional-flag';
916 $optionalHtml = Html::rawElement(
917 'span',
918 [ 'class' => 'cdx-label__label__optional-flag' ],
919 ' ' . $this->getMessage( $messageKey )->parse(),
920 );
921 }
922 // <div class="cdx-label">
923 $labelDiv = Html::rawElement( 'div', [ 'class' => $labelClasses ],
924 // <label class="cdx-label__label" for="ID">
925 Html::rawElement( 'label', [ 'class' => 'cdx-label__label' ] + $labelFor,
926 // <span class="cdx-label__label__text">
927 Html::rawElement( 'span', [ 'class' => 'cdx-label__label__text' ],
928 $labelValue,
929 ) . $optionalHtml,
930 ) . $descriptionHtml,
931 );
932 }
933
934 // Help text
935 // <div class="cdx-field__help-text">
936 $helptext = $this->getHelpTextHtmlDiv( $this->getHelpText(), [ 'cdx-field__help-text' ] );
937
938 // Validation message
939 // <div class="cdx-field__validation-message">
940 // $errors is a <div class="cdx-message">
941 // FIXME right now this generates a block message (cdx-message--block), we want an inline message instead
942 $validationMessage = '';
943 [ $errors, $errorClass ] = $this->getErrorsAndErrorClass( $value );
944 if ( $errors !== '' ) {
945 $validationMessage = Html::rawElement( 'div', [ 'class' => 'cdx-field__validation-message' ],
946 $errors
947 );
948 }
949
950 // Control
951 $inputHtml = $this->getInputCodex( $value, $errors !== '' );
952 // <div class="cdx-field__control cdx-field__control--has-help-text">
953 $controlClasses = [ 'cdx-field__control' ];
954 if ( $helptext ) {
955 $controlClasses[] = 'cdx-field__control--has-help-text';
956 }
957 $control = Html::rawElement( 'div', [ 'class' => $controlClasses ], $inputHtml );
958
959 // <div class="cdx-field">
960 $fieldClasses = [
961 "mw-htmlform-field-{$this->getClassName()}",
963 $errorClass,
964 'cdx-field'
965 ];
966 if ( $isDisabled ) {
967 $fieldClasses[] = 'cdx-field--disabled';
968 }
969 $fieldAttributes = [];
970 // Set data attribute and CSS class for client side handling of hide-if / disable-if
971 if ( $this->mCondState ) {
972 $fieldAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
973 $fieldClasses = array_merge( $fieldClasses, $this->mCondStateClass );
974 if ( $this->isHiddenNoJs( $this->mParent->mFieldData ) ) {
975 $fieldClasses[] = 'mw-htmlform-hide-if-hidden-nojs';
976 } elseif ( $this->isHidden( $this->mParent->mFieldData ) ) {
977 $fieldClasses[] = 'mw-htmlform-hide-if-hidden';
978 }
979 }
980
981 return Html::rawElement( 'div', [ 'class' => $fieldClasses ] + $fieldAttributes,
982 $labelDiv . $control . $helptext . $validationMessage
983 );
984 }
985
986 private function showOptionalFlag(): bool {
987 $shouldShowOptionalFlag = $this->mParams['show-optional-flag'] ?? false;
988 if ( !$shouldShowOptionalFlag ) {
989 return false;
990 }
991
992 $isRequired = $this->mParams['required'] ?? false;
993 if ( $isRequired ) {
994 // field is both required AND set to show a label-suffix "(optional)". Something is wrong
995 throw new \InvalidArgumentException( 'A field cannot be both optional and required.' );
996 }
997 return true;
998 }
999
1007 protected function getClassName() {
1008 $name = explode( '\\', static::class );
1009 return end( $name );
1010 }
1011
1017 protected function getLabelAlignOOUI() {
1018 return 'top';
1019 }
1020
1027 protected function getFieldLayoutOOUI( $inputField, $config ) {
1028 return new HTMLFormFieldLayout( $inputField, $config );
1029 }
1030
1039 protected function shouldInfuseOOUI() {
1040 // Always infuse fields with popup help text, since the interface for it is nicer with JS
1041 return !$this->isHelpInline() && $this->getHelpMessages();
1042 }
1043
1051 protected function getOOUIModules() {
1052 return [];
1053 }
1054
1065 public function getRaw( $value ) {
1066 [ $errors, ] = $this->getErrorsAndErrorClass( $value );
1067 return "\n" . $errors .
1068 $this->getLabelHtml() .
1069 $this->getInputHTML( $value ) .
1070 $this->getHelpTextHtmlRaw( $this->getHelpText() );
1071 }
1072
1080 public function getInline( $value ) {
1081 [ $errors, ] = $this->getErrorsAndErrorClass( $value );
1082 return "\n" . $errors .
1083 $this->getLabelHtml() .
1084 "\u{00A0}" .
1085 $this->getInputHTML( $value ) .
1086 $this->getHelpTextHtmlDiv( $this->getHelpText() );
1087 }
1088
1096 public function getHelpTextHtmlTable( $helptext ) {
1097 if ( $helptext === null ) {
1098 return '';
1099 }
1100
1101 $rowAttributes = [];
1102 if ( $this->mCondState ) {
1103 $rowAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
1104 $rowAttributes['class'] = $this->mCondStateClass;
1105 }
1106
1107 $tdClasses = [ 'htmlform-tip' ];
1108 if ( $this->mHelpClass !== false ) {
1109 $tdClasses[] = $this->mHelpClass;
1110 }
1111 return Html::rawElement( 'tr', $rowAttributes,
1112 Html::rawElement( 'td', [ 'colspan' => 2, 'class' => $tdClasses ], $helptext )
1113 );
1114 }
1115
1125 public function getHelpTextHtmlDiv( $helptext, $cssClasses = [] ) {
1126 if ( $helptext === null ) {
1127 return '';
1128 }
1129
1130 $wrapperAttributes = [
1131 'class' => array_merge( $cssClasses, [ 'htmlform-tip' ] ),
1132 ];
1133 if ( $this->mHelpClass !== false ) {
1134 $wrapperAttributes['class'][] = $this->mHelpClass;
1135 }
1136 if ( $this->mCondState ) {
1137 $wrapperAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
1138 $wrapperAttributes['class'] = array_merge( $wrapperAttributes['class'], $this->mCondStateClass );
1139 }
1140 return Html::rawElement( 'div', $wrapperAttributes, $helptext );
1141 }
1142
1143 public function getDescriptionHtmlSpan( ?string $descriptionHtml, array $cssClasses = [] ): string {
1144 if ( $descriptionHtml === null ) {
1145 return '';
1146 }
1147
1148 return Html::rawElement( 'span', [ 'class' => $cssClasses ], $descriptionHtml );
1149 }
1150
1158 public function getHelpTextHtmlRaw( $helptext ) {
1159 return $this->getHelpTextHtmlDiv( $helptext );
1160 }
1161
1162 private function getHelpMessages(): array {
1163 if ( isset( $this->mParams['help-message'] ) ) {
1164 return [ $this->mParams['help-message'] ];
1165 } elseif ( isset( $this->mParams['help-messages'] ) ) {
1166 return $this->mParams['help-messages'];
1167 } elseif ( isset( $this->mParams['help-raw'] ) ) {
1168 return [ new HtmlArmor( $this->mParams['help-raw'] ) ];
1169 } elseif ( isset( $this->mParams['help'] ) ) {
1170 // @deprecated since 1.43, use 'help-raw' key instead
1171 return [ new HtmlArmor( $this->mParams['help'] ) ];
1172 }
1173
1174 return [];
1175 }
1176
1183 public function getHelpText() {
1184 $html = [];
1185
1186 foreach ( $this->getHelpMessages() as $msg ) {
1187 if ( $msg instanceof HtmlArmor ) {
1188 $html[] = HtmlArmor::getHtml( $msg );
1189 } else {
1190 $msg = $this->getMessage( $msg );
1191 if ( $msg->exists() ) {
1192 $html[] = $msg->parse();
1193 }
1194 }
1195 }
1196
1197 return $html ? implode( $this->msg( 'word-separator' )->escaped(), $html ) : null;
1198 }
1199
1200 private function getDescriptionMessages(): array {
1201 if ( isset( $this->mParams['description-message'] ) ) {
1202 return [ $this->mParams['description-message'] ];
1203 }
1204
1205 if ( isset( $this->mParams['description-messages'] ) ) {
1206 return $this->mParams['description-messages'];
1207 }
1208
1209 if ( isset( $this->mParams['description-raw'] ) ) {
1210 return [ new HtmlArmor( $this->mParams['description-raw'] ) ];
1211 }
1212
1213 return [];
1214 }
1215
1222 public function getDescriptionText(): ?string {
1223 $html = [];
1224
1225 foreach ( $this->getDescriptionMessages() as $msg ) {
1226 if ( $msg instanceof HtmlArmor ) {
1227 $html[] = HtmlArmor::getHtml( $msg );
1228 } else {
1229 $msg = $this->getMessage( $msg );
1230 if ( $msg->exists() ) {
1231 $html[] = $msg->parse();
1232 }
1233 }
1234 }
1235
1236 return $html ? implode( $this->msg( 'word-separator' )->escaped(), $html ) : null;
1237 }
1238
1247 public function isHelpInline() {
1248 return $this->mParams['help-inline'] ?? true;
1249 }
1250
1263 public function getErrorsAndErrorClass( $value ) {
1264 $errors = $this->validate( $value, $this->mParent->mFieldData );
1265
1266 if ( is_bool( $errors ) || !$this->mParent->wasSubmitted() ) {
1267 return [ '', '' ];
1268 }
1269
1270 return [ self::formatErrors( $errors ), 'mw-htmlform-invalid-input' ];
1271 }
1272
1280 public function getErrorsRaw( $value ) {
1281 $errors = $this->validate( $value, $this->mParent->mFieldData );
1282
1283 if ( is_bool( $errors ) || !$this->mParent->wasSubmitted() ) {
1284 return [];
1285 }
1286
1287 if ( !is_array( $errors ) ) {
1288 $errors = [ $errors ];
1289 }
1290 foreach ( $errors as &$error ) {
1291 if ( $error instanceof Message ) {
1292 $error = $error->parse();
1293 }
1294 }
1295
1296 return $errors;
1297 }
1298
1303 public function getLabel() {
1304 return $this->mLabel ?? '';
1305 }
1306
1313 public function getLabelHtml( $cellAttributes = [] ) {
1314 # Don't output a for= attribute for labels with no associated input.
1315 # Kind of hacky here, possibly we don't want these to be <label>s at all.
1316 $for = $this->needsLabel() ? [ 'for' => $this->mID ] : [];
1317
1318 $labelValue = trim( $this->getLabel() );
1319 $hasLabel = $labelValue !== '' && $labelValue !== "\u{00A0}" && $labelValue !== '&#160;';
1320
1321 $displayFormat = $this->mParent->getDisplayFormat();
1322 $horizontalLabel = $this->mParams['horizontal-label'] ?? false;
1323
1324 if ( $displayFormat === 'table' ) {
1325 return Html::rawElement( 'td',
1326 [ 'class' => 'mw-label' ] + $cellAttributes,
1327 Html::rawElement( 'label', $for, $labelValue ) );
1328 } elseif ( $hasLabel || $this->mShowEmptyLabels ) {
1329 if ( $displayFormat === 'div' && !$horizontalLabel ) {
1330 return Html::rawElement( 'div',
1331 [ 'class' => 'mw-label' ] + $cellAttributes,
1332 Html::rawElement( 'label', $for, $labelValue ) );
1333 } else {
1334 return Html::rawElement( 'label', $for, $labelValue );
1335 }
1336 }
1337
1338 return '';
1339 }
1340
1345 public function getDefault() {
1346 return $this->mDefault ?? null;
1347 }
1348
1354 public function getTooltipAndAccessKey() {
1355 if ( empty( $this->mParams['tooltip'] ) ) {
1356 return [];
1357 }
1358
1359 return Linker::tooltipAndAccesskeyAttribs( $this->mParams['tooltip'] );
1360 }
1361
1367 public function getTooltipAndAccessKeyOOUI() {
1368 if ( empty( $this->mParams['tooltip'] ) ) {
1369 return [];
1370 }
1371
1372 return [
1373 'title' => Linker::titleAttrib( $this->mParams['tooltip'] ),
1374 'accessKey' => Linker::accesskey( $this->mParams['tooltip'] ),
1375 ];
1376 }
1377
1385 public function getAttributes( array $list ) {
1386 static $boolAttribs = [ 'disabled', 'required', 'autofocus', 'multiple', 'readonly' ];
1387
1388 $ret = [];
1389 foreach ( $list as $key ) {
1390 if ( $key === 'disabled' ) {
1391 if ( $this->isDisabledNoJs( $this->mParent->mFieldData ) ) {
1392 $ret[$key] = '';
1393 }
1394 } elseif ( in_array( $key, $boolAttribs ) ) {
1395 if ( !empty( $this->mParams[$key] ) ) {
1396 $ret[$key] = '';
1397 }
1398 } elseif ( isset( $this->mParams[$key] ) ) {
1399 $ret[$key] = $this->mParams[$key];
1400 }
1401 }
1402
1403 return $ret;
1404 }
1405
1415 private function lookupOptionsKeys( $options, $needsParse ) {
1416 $ret = [];
1417 $langCode = $this->mParent->getLanguageCode()->toBcp47Code();
1418 foreach ( $options as $key => $value ) {
1419 $msg = $this->msg( $key );
1420 $msgAsText = $needsParse ? $msg->parse() : $msg->plain();
1421 if ( array_key_exists( $msgAsText, $ret ) ) {
1422 LoggerFactory::getInstance( 'translation-problem' )->error(
1423 'The option that uses the message key {msg_key_one} has the same translation as ' .
1424 'another option in {lang}. This means that {msg_key_one} will not be used as an option.',
1425 [
1426 'msg_key_one' => $key,
1427 'lang' => $langCode,
1428 ]
1429 );
1430 continue;
1431 }
1432 $ret[$msgAsText] = is_array( $value )
1433 ? $this->lookupOptionsKeys( $value, $needsParse )
1434 : strval( $value );
1435 }
1436 return $ret;
1437 }
1438
1446 public static function forceToStringRecursive( $array ) {
1447 if ( is_array( $array ) ) {
1448 return array_map( self::forceToStringRecursive( ... ), $array );
1449 } else {
1450 return strval( $array );
1451 }
1452 }
1453
1460 public function getOptions() {
1461 if ( $this->mOptions === false ) {
1462 if ( array_key_exists( 'options-messages', $this->mParams ) ) {
1463 $needsParse = $this->mParams['options-messages-parse'] ?? false;
1464 if ( $needsParse ) {
1465 $this->mOptionsLabelsNotFromMessage = true;
1466 }
1467 $this->mOptions = $this->lookupOptionsKeys( $this->mParams['options-messages'], $needsParse );
1468 } elseif ( array_key_exists( 'options', $this->mParams ) ) {
1469 $this->mOptionsLabelsNotFromMessage = true;
1470 $this->mOptions = self::forceToStringRecursive( $this->mParams['options'] );
1471 } elseif ( array_key_exists( 'options-message', $this->mParams ) ) {
1472 $message = $this->getMessage( $this->mParams['options-message'] )->inContentLanguage()->plain();
1473 $this->mOptions = Html::listDropdownOptions( $message );
1474 } else {
1475 $this->mOptions = null;
1476 }
1477 }
1478
1479 return $this->mOptions;
1480 }
1481
1487 public function getOptionsOOUI() {
1488 $oldoptions = $this->getOptions();
1489
1490 if ( $oldoptions === null ) {
1491 return null;
1492 }
1493
1494 return Html::listDropdownOptionsOoui( $oldoptions );
1495 }
1496
1504 public static function flattenOptions( $options ) {
1505 $flatOpts = [];
1506
1507 foreach ( $options as $value ) {
1508 if ( is_array( $value ) ) {
1509 $flatOpts = array_merge( $flatOpts, self::flattenOptions( $value ) );
1510 } else {
1511 $flatOpts[] = $value;
1512 }
1513 }
1514
1515 return $flatOpts;
1516 }
1517
1531 protected static function formatErrors( $errors ) {
1532 if ( is_array( $errors ) && count( $errors ) === 1 ) {
1533 $errors = array_shift( $errors );
1534 }
1535
1536 if ( is_array( $errors ) ) {
1537 foreach ( $errors as &$error ) {
1538 $error = Html::rawElement( 'li', [],
1539 $error instanceof Message ? $error->parse() : $error
1540 );
1541 }
1542 $errors = Html::rawElement( 'ul', [], implode( "\n", $errors ) );
1543 } elseif ( $errors instanceof Message ) {
1544 $errors = $errors->parse();
1545 }
1546
1547 return Html::errorBox( $errors );
1548 }
1549
1556 protected function getMessage( $value ) {
1557 $message = Message::newFromSpecifier( $value );
1558 $message->setContext( $this->mParent );
1559 return $message;
1560 }
1561
1569 public function skipLoadData( $request ) {
1570 return !empty( $this->mParams['nodata'] );
1571 }
1572
1581 // This is probably more restrictive than it needs to be, but better safe than sorry
1582 return (bool)$this->mCondState;
1583 }
1584
1596 protected function escapeLabel( $label ) {
1597 return $this->mOptionsLabelsNotFromMessage
1598 ? $label : htmlspecialchars( $label, ENT_NOQUOTES );
1599 }
1600
1612 protected function makeLabelSnippet( $label ) {
1613 return $this->mOptionsLabelsNotFromMessage
1614 ? new \OOUI\HtmlSnippet( $label ) : $label;
1615 }
1616}
1617
1619class_alias( HTMLFormField::class, 'HTMLFormField' );
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:71
A container for HTMLFormFields that allows for multiple copies of the set of fields to be displayed t...
The parent class to generate form fields.
parseCondStateForClient()
Parse the cond-state array for client-side.
getTooltipAndAccessKey()
Returns the attributes required for the tooltip and accesskey, for Html::element() etc.
getMessage( $value)
Turns a *-message parameter (which could be a MessageSpecifier, or a message name,...
getOptionsOOUI()
Get options and make them into arrays suitable for OOUI.
array $mCondState
Array to hold params for 'hide-if' or 'disable-if' statements.
getOOUI( $value)
Get the OOUI version of the div.
getDescriptionText()
Determine the help text to display.
makeLabelSnippet( $label)
The keys in the array returned by getOptions() can be either HTML or plain text depending on $this->m...
getName()
Get the field name that will be used for submission.
getNearestFieldValue( $alldata, $name, $asDisplay=false, $backCompat=false)
Fetch a field value from $alldata for the closest field matching a given name.
getHelpTextHtmlRaw( $helptext)
Generate help text HTML formatted for raw output.
static flattenOptions( $options)
flatten an array of options to a single array, for instance, a set of "<options>" inside "<optgroups>...
getTooltipAndAccessKeyOOUI()
Returns the attributes required for the tooltip and accesskey, for OOUI widgets' config.
getNearestFieldByName( $alldata, $name, $asDisplay=false)
Fetch a field value from $alldata for the closest field matching a given name.
bool $mShowEmptyLabels
If true will generate an empty div element with no label.
getErrorsAndErrorClass( $value)
Determine form errors to display and their classes.
isHidden( $alldata)
Test whether this field is supposed to be hidden, based on the values of the other form fields.
isHelpInline()
Determine if the help text should be displayed inline.
getOOUIModules()
Get the list of extra ResourceLoader modules which must be loaded client-side before it's possible to...
loadDataFromRequest( $request)
Get the value that this input has been set to from a posted form, or the input's default value if it ...
__construct( $params)
Initialise the object.
getClassName()
Gets the non namespaced class name.
skipLoadData( $request)
Skip this field when collecting data.
static forceToStringRecursive( $array)
Recursively forces values in an array to strings, because issues arise with integer 0 as a value.
hasVisibleOutput()
If this field has a user-visible output or not.
getLabelAlignOOUI()
Get label alignment when generating field for OOUI.
validateCondState( $params)
Validate the cond-state params, the existence check of fields should be done later.
needsLabel()
Should this field have a label, or is there no input element with the appropriate id for the label to...
getOptions()
Fetch the array of options from the field's parameters.
cancelSubmit( $value, $alldata)
Override this function if the control can somehow trigger a form submission that shouldn't actually s...
getDiv( $value)
Get the complete div for the input, including help text, labels, and whatever.
getAttributes(array $list)
Returns the given attributes from the parameters.
getFieldLayoutOOUI( $inputField, $config)
Get a FieldLayout (or subclass thereof) to wrap this field in when using OOUI output.
getHelpText()
Determine the help text to display.
getCodex( $value)
Get the Codex version of the div.
parseCondState( $params)
Parse the cond-state array to use the field name for submission, since the key in the form descriptor...
getNearestField( $name, $backCompat=false)
Get the closest field matching a given name.
getTableRow( $value)
Get the complete table row for the input, including help text, labels, and whatever.
msg( $key,... $params)
Get a translated interface message.
getRaw( $value)
Get the complete raw fields for the input, including help text, labels, and whatever.
getInputHTML( $value)
This function must be implemented to return the HTML to generate the input object itself.
getErrorsRaw( $value)
Determine form errors to display, returning them in an array.
shouldInfuseOOUI()
Whether the field should be automatically infused.
needsJSForHtml5FormValidation()
Whether this field requires the user agent to have JavaScript enabled for the client-side HTML5 form ...
canDisplayErrors()
True if this field type is able to display errors; false if validation errors need to be displayed in...
getInputOOUI( $value)
Same as getInputHTML, but returns an OOUI object.
isSubmitAttempt(WebRequest $request)
Can we assume that the request is an attempt to submit a HTMLForm, as opposed to an attempt to just v...
string $mLabel
String label, as HTML.
getInputCodex( $value, $hasErrors)
Same as getInputHTML, but for Codex.
isDisabled( $alldata)
Test whether this field is supposed to be disabled, based on the values of the other form fields.
isDisabledNoJs( $alldata)
Test whether this field is disabled in the generated HTML, either due to the 'disabled' parameter,...
isHiddenNoJs( $alldata)
Test whether this field is hidden in the generated HTML due to 'hide-if-nojs' rules.
getDescriptionHtmlSpan(?string $descriptionHtml, array $cssClasses=[])
validate( $value, $alldata)
Override this function to add specific validation checks on the field input.
escapeLabel( $label)
The keys in the array returned by getOptions() can be either HTML or plain text depending on $this->m...
checkStateRecurse(array $alldata, array $params)
Helper function for isHidden and isDisabled to handle recursive data structures.
getHelpTextHtmlDiv( $helptext, $cssClasses=[])
Generate help text HTML in div format.
getHelpTextHtmlTable( $helptext)
Generate help text HTML in table format.
static formatErrors( $errors)
Formats one or more errors as accepted by field validation-callback.
setShowEmptyLabel( $show)
Tell the field whether to generate a separate label element if its label is blank.
getInline( $value)
Get the complete field as an inline element.
Object handling generic submission, CSRF protection, layout and other logic for UI forms in a reusabl...
Definition HTMLForm.php:214
This class is a collection of static functions that serve two purposes:
Definition Html.php:44
JSON formatter wrapper class.
Some internal bits split of from Skin.php.
Definition Linker.php:48
Create PSR-3 logger objects.
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition Message.php:144
parse()
Fully parse the text from wikitext to HTML.
Definition Message.php:1125
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form,...
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:44
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Marks HTML that shouldn't be escaped.
Definition HtmlArmor.php:18
Value object representing a message parameter with one of the types from {.
Interface for localizing messages in MediaWiki.