HTMLFormField.php 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200
  1. <?php
  2. /**
  3. * The parent class to generate form fields. Any field type should
  4. * be a subclass of this.
  5. */
  6. abstract class HTMLFormField {
  7. public $mParams;
  8. protected $mValidationCallback;
  9. protected $mFilterCallback;
  10. protected $mName;
  11. protected $mDir;
  12. protected $mLabel; # String label, as HTML. Set on construction.
  13. protected $mID;
  14. protected $mClass = '';
  15. protected $mVFormClass = '';
  16. protected $mHelpClass = false;
  17. protected $mDefault;
  18. protected $mOptions = false;
  19. protected $mOptionsLabelsNotFromMessage = false;
  20. protected $mHideIf = null;
  21. /**
  22. * @var bool If true will generate an empty div element with no label
  23. * @since 1.22
  24. */
  25. protected $mShowEmptyLabels = true;
  26. /**
  27. * @var HTMLForm|null
  28. */
  29. public $mParent;
  30. /**
  31. * This function must be implemented to return the HTML to generate
  32. * the input object itself. It should not implement the surrounding
  33. * table cells/rows, or labels/help messages.
  34. *
  35. * @param string $value The value to set the input to; eg a default
  36. * text for a text input.
  37. *
  38. * @return string Valid HTML.
  39. */
  40. abstract public function getInputHTML( $value );
  41. /**
  42. * Same as getInputHTML, but returns an OOUI object.
  43. * Defaults to false, which getOOUI will interpret as "use the HTML version"
  44. *
  45. * @param string $value
  46. * @return OOUI\Widget|false
  47. */
  48. public function getInputOOUI( $value ) {
  49. return false;
  50. }
  51. /**
  52. * True if this field type is able to display errors; false if validation errors need to be
  53. * displayed in the main HTMLForm error area.
  54. * @return bool
  55. */
  56. public function canDisplayErrors() {
  57. return $this->hasVisibleOutput();
  58. }
  59. /**
  60. * Get a translated interface message
  61. *
  62. * This is a wrapper around $this->mParent->msg() if $this->mParent is set
  63. * and wfMessage() otherwise.
  64. *
  65. * Parameters are the same as wfMessage().
  66. *
  67. * @return Message
  68. */
  69. public function msg() {
  70. $args = func_get_args();
  71. if ( $this->mParent ) {
  72. $callback = [ $this->mParent, 'msg' ];
  73. } else {
  74. $callback = 'wfMessage';
  75. }
  76. return call_user_func_array( $callback, $args );
  77. }
  78. /**
  79. * If this field has a user-visible output or not. If not,
  80. * it will not be rendered
  81. *
  82. * @return bool
  83. */
  84. public function hasVisibleOutput() {
  85. return true;
  86. }
  87. /**
  88. * Fetch a field value from $alldata for the closest field matching a given
  89. * name.
  90. *
  91. * This is complex because it needs to handle array fields like the user
  92. * would expect. The general algorithm is to look for $name as a sibling
  93. * of $this, then a sibling of $this's parent, and so on. Keeping in mind
  94. * that $name itself might be referencing an array.
  95. *
  96. * @param array $alldata
  97. * @param string $name
  98. * @return string
  99. */
  100. protected function getNearestFieldByName( $alldata, $name ) {
  101. $tmp = $this->mName;
  102. $thisKeys = [];
  103. while ( preg_match( '/^(.+)\[([^\]]+)\]$/', $tmp, $m ) ) {
  104. array_unshift( $thisKeys, $m[2] );
  105. $tmp = $m[1];
  106. }
  107. if ( substr( $tmp, 0, 2 ) == 'wp' &&
  108. !array_key_exists( $tmp, $alldata ) &&
  109. array_key_exists( substr( $tmp, 2 ), $alldata )
  110. ) {
  111. // Adjust for name mangling.
  112. $tmp = substr( $tmp, 2 );
  113. }
  114. array_unshift( $thisKeys, $tmp );
  115. $tmp = $name;
  116. $nameKeys = [];
  117. while ( preg_match( '/^(.+)\[([^\]]+)\]$/', $tmp, $m ) ) {
  118. array_unshift( $nameKeys, $m[2] );
  119. $tmp = $m[1];
  120. }
  121. array_unshift( $nameKeys, $tmp );
  122. $testValue = '';
  123. for ( $i = count( $thisKeys ) - 1; $i >= 0; $i-- ) {
  124. $keys = array_merge( array_slice( $thisKeys, 0, $i ), $nameKeys );
  125. $data = $alldata;
  126. while ( $keys ) {
  127. $key = array_shift( $keys );
  128. if ( !is_array( $data ) || !array_key_exists( $key, $data ) ) {
  129. continue 2;
  130. }
  131. $data = $data[$key];
  132. }
  133. $testValue = (string)$data;
  134. break;
  135. }
  136. return $testValue;
  137. }
  138. /**
  139. * Helper function for isHidden to handle recursive data structures.
  140. *
  141. * @param array $alldata
  142. * @param array $params
  143. * @return bool
  144. * @throws MWException
  145. */
  146. protected function isHiddenRecurse( array $alldata, array $params ) {
  147. $origParams = $params;
  148. $op = array_shift( $params );
  149. try {
  150. switch ( $op ) {
  151. case 'AND':
  152. foreach ( $params as $i => $p ) {
  153. if ( !is_array( $p ) ) {
  154. throw new MWException(
  155. "Expected array, found " . gettype( $p ) . " at index $i"
  156. );
  157. }
  158. if ( !$this->isHiddenRecurse( $alldata, $p ) ) {
  159. return false;
  160. }
  161. }
  162. return true;
  163. case 'OR':
  164. foreach ( $params as $i => $p ) {
  165. if ( !is_array( $p ) ) {
  166. throw new MWException(
  167. "Expected array, found " . gettype( $p ) . " at index $i"
  168. );
  169. }
  170. if ( $this->isHiddenRecurse( $alldata, $p ) ) {
  171. return true;
  172. }
  173. }
  174. return false;
  175. case 'NAND':
  176. foreach ( $params as $i => $p ) {
  177. if ( !is_array( $p ) ) {
  178. throw new MWException(
  179. "Expected array, found " . gettype( $p ) . " at index $i"
  180. );
  181. }
  182. if ( !$this->isHiddenRecurse( $alldata, $p ) ) {
  183. return true;
  184. }
  185. }
  186. return false;
  187. case 'NOR':
  188. foreach ( $params as $i => $p ) {
  189. if ( !is_array( $p ) ) {
  190. throw new MWException(
  191. "Expected array, found " . gettype( $p ) . " at index $i"
  192. );
  193. }
  194. if ( $this->isHiddenRecurse( $alldata, $p ) ) {
  195. return false;
  196. }
  197. }
  198. return true;
  199. case 'NOT':
  200. if ( count( $params ) !== 1 ) {
  201. throw new MWException( "NOT takes exactly one parameter" );
  202. }
  203. $p = $params[0];
  204. if ( !is_array( $p ) ) {
  205. throw new MWException(
  206. "Expected array, found " . gettype( $p ) . " at index 0"
  207. );
  208. }
  209. return !$this->isHiddenRecurse( $alldata, $p );
  210. case '===':
  211. case '!==':
  212. if ( count( $params ) !== 2 ) {
  213. throw new MWException( "$op takes exactly two parameters" );
  214. }
  215. list( $field, $value ) = $params;
  216. if ( !is_string( $field ) || !is_string( $value ) ) {
  217. throw new MWException( "Parameters for $op must be strings" );
  218. }
  219. $testValue = $this->getNearestFieldByName( $alldata, $field );
  220. switch ( $op ) {
  221. case '===':
  222. return ( $value === $testValue );
  223. case '!==':
  224. return ( $value !== $testValue );
  225. }
  226. default:
  227. throw new MWException( "Unknown operation" );
  228. }
  229. } catch ( Exception $ex ) {
  230. throw new MWException(
  231. "Invalid hide-if specification for $this->mName: " .
  232. $ex->getMessage() . " in " . var_export( $origParams, true ),
  233. 0, $ex
  234. );
  235. }
  236. }
  237. /**
  238. * Test whether this field is supposed to be hidden, based on the values of
  239. * the other form fields.
  240. *
  241. * @since 1.23
  242. * @param array $alldata The data collected from the form
  243. * @return bool
  244. */
  245. public function isHidden( $alldata ) {
  246. if ( !$this->mHideIf ) {
  247. return false;
  248. }
  249. return $this->isHiddenRecurse( $alldata, $this->mHideIf );
  250. }
  251. /**
  252. * Override this function if the control can somehow trigger a form
  253. * submission that shouldn't actually submit the HTMLForm.
  254. *
  255. * @since 1.23
  256. * @param string|array $value The value the field was submitted with
  257. * @param array $alldata The data collected from the form
  258. *
  259. * @return bool True to cancel the submission
  260. */
  261. public function cancelSubmit( $value, $alldata ) {
  262. return false;
  263. }
  264. /**
  265. * Override this function to add specific validation checks on the
  266. * field input. Don't forget to call parent::validate() to ensure
  267. * that the user-defined callback mValidationCallback is still run
  268. *
  269. * @param string|array $value The value the field was submitted with
  270. * @param array $alldata The data collected from the form
  271. *
  272. * @return bool|string|Message True on success, or String/Message error to display, or
  273. * false to fail validation without displaying an error.
  274. */
  275. public function validate( $value, $alldata ) {
  276. if ( $this->isHidden( $alldata ) ) {
  277. return true;
  278. }
  279. if ( isset( $this->mParams['required'] )
  280. && $this->mParams['required'] !== false
  281. && $value === ''
  282. ) {
  283. return $this->msg( 'htmlform-required' );
  284. }
  285. if ( isset( $this->mValidationCallback ) ) {
  286. return call_user_func( $this->mValidationCallback, $value, $alldata, $this->mParent );
  287. }
  288. return true;
  289. }
  290. public function filter( $value, $alldata ) {
  291. if ( isset( $this->mFilterCallback ) ) {
  292. $value = call_user_func( $this->mFilterCallback, $value, $alldata, $this->mParent );
  293. }
  294. return $value;
  295. }
  296. /**
  297. * Should this field have a label, or is there no input element with the
  298. * appropriate id for the label to point to?
  299. *
  300. * @return bool True to output a label, false to suppress
  301. */
  302. protected function needsLabel() {
  303. return true;
  304. }
  305. /**
  306. * Tell the field whether to generate a separate label element if its label
  307. * is blank.
  308. *
  309. * @since 1.22
  310. *
  311. * @param bool $show Set to false to not generate a label.
  312. * @return void
  313. */
  314. public function setShowEmptyLabel( $show ) {
  315. $this->mShowEmptyLabels = $show;
  316. }
  317. /**
  318. * Can we assume that the request is an attempt to submit a HTMLForm, as opposed to an attempt to
  319. * just view it? This can't normally be distinguished for e.g. checkboxes.
  320. *
  321. * Returns true if the request has a field for a CSRF token (wpEditToken) or a form identifier
  322. * (wpFormIdentifier).
  323. *
  324. * @param WebRequest $request
  325. * @return bool
  326. */
  327. protected function isSubmitAttempt( WebRequest $request ) {
  328. return $request->getCheck( 'wpEditToken' ) || $request->getCheck( 'wpFormIdentifier' );
  329. }
  330. /**
  331. * Get the value that this input has been set to from a posted form,
  332. * or the input's default value if it has not been set.
  333. *
  334. * @param WebRequest $request
  335. * @return string The value
  336. */
  337. public function loadDataFromRequest( $request ) {
  338. if ( $request->getCheck( $this->mName ) ) {
  339. return $request->getText( $this->mName );
  340. } else {
  341. return $this->getDefault();
  342. }
  343. }
  344. /**
  345. * Initialise the object
  346. *
  347. * @param array $params Associative Array. See HTMLForm doc for syntax.
  348. *
  349. * @since 1.22 The 'label' attribute no longer accepts raw HTML, use 'label-raw' instead
  350. * @throws MWException
  351. */
  352. public function __construct( $params ) {
  353. $this->mParams = $params;
  354. if ( isset( $params['parent'] ) && $params['parent'] instanceof HTMLForm ) {
  355. $this->mParent = $params['parent'];
  356. }
  357. # Generate the label from a message, if possible
  358. if ( isset( $params['label-message'] ) ) {
  359. $this->mLabel = $this->getMessage( $params['label-message'] )->parse();
  360. } elseif ( isset( $params['label'] ) ) {
  361. if ( $params['label'] === '&#160;' ) {
  362. // Apparently some things set &nbsp directly and in an odd format
  363. $this->mLabel = '&#160;';
  364. } else {
  365. $this->mLabel = htmlspecialchars( $params['label'] );
  366. }
  367. } elseif ( isset( $params['label-raw'] ) ) {
  368. $this->mLabel = $params['label-raw'];
  369. }
  370. $this->mName = "wp{$params['fieldname']}";
  371. if ( isset( $params['name'] ) ) {
  372. $this->mName = $params['name'];
  373. }
  374. if ( isset( $params['dir'] ) ) {
  375. $this->mDir = $params['dir'];
  376. }
  377. $validName = urlencode( $this->mName );
  378. $validName = str_replace( [ '%5B', '%5D' ], [ '[', ']' ], $validName );
  379. if ( $this->mName != $validName && !isset( $params['nodata'] ) ) {
  380. throw new MWException( "Invalid name '{$this->mName}' passed to " . __METHOD__ );
  381. }
  382. $this->mID = "mw-input-{$this->mName}";
  383. if ( isset( $params['default'] ) ) {
  384. $this->mDefault = $params['default'];
  385. }
  386. if ( isset( $params['id'] ) ) {
  387. $id = $params['id'];
  388. $validId = urlencode( $id );
  389. if ( $id != $validId ) {
  390. throw new MWException( "Invalid id '$id' passed to " . __METHOD__ );
  391. }
  392. $this->mID = $id;
  393. }
  394. if ( isset( $params['cssclass'] ) ) {
  395. $this->mClass = $params['cssclass'];
  396. }
  397. if ( isset( $params['csshelpclass'] ) ) {
  398. $this->mHelpClass = $params['csshelpclass'];
  399. }
  400. if ( isset( $params['validation-callback'] ) ) {
  401. $this->mValidationCallback = $params['validation-callback'];
  402. }
  403. if ( isset( $params['filter-callback'] ) ) {
  404. $this->mFilterCallback = $params['filter-callback'];
  405. }
  406. if ( isset( $params['hidelabel'] ) ) {
  407. $this->mShowEmptyLabels = false;
  408. }
  409. if ( isset( $params['hide-if'] ) ) {
  410. $this->mHideIf = $params['hide-if'];
  411. }
  412. }
  413. /**
  414. * Get the complete table row for the input, including help text,
  415. * labels, and whatever.
  416. *
  417. * @param string $value The value to set the input to.
  418. *
  419. * @return string Complete HTML table row.
  420. */
  421. public function getTableRow( $value ) {
  422. list( $errors, $errorClass ) = $this->getErrorsAndErrorClass( $value );
  423. $inputHtml = $this->getInputHTML( $value );
  424. $fieldType = static::class;
  425. $helptext = $this->getHelpTextHtmlTable( $this->getHelpText() );
  426. $cellAttributes = [];
  427. $rowAttributes = [];
  428. $rowClasses = '';
  429. if ( !empty( $this->mParams['vertical-label'] ) ) {
  430. $cellAttributes['colspan'] = 2;
  431. $verticalLabel = true;
  432. } else {
  433. $verticalLabel = false;
  434. }
  435. $label = $this->getLabelHtml( $cellAttributes );
  436. $field = Html::rawElement(
  437. 'td',
  438. [ 'class' => 'mw-input' ] + $cellAttributes,
  439. $inputHtml . "\n$errors"
  440. );
  441. if ( $this->mHideIf ) {
  442. $rowAttributes['data-hide-if'] = FormatJson::encode( $this->mHideIf );
  443. $rowClasses .= ' mw-htmlform-hide-if';
  444. }
  445. if ( $verticalLabel ) {
  446. $html = Html::rawElement( 'tr',
  447. $rowAttributes + [ 'class' => "mw-htmlform-vertical-label $rowClasses" ], $label );
  448. $html .= Html::rawElement( 'tr',
  449. $rowAttributes + [
  450. 'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses"
  451. ],
  452. $field );
  453. } else {
  454. $html =
  455. Html::rawElement( 'tr',
  456. $rowAttributes + [
  457. 'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses"
  458. ],
  459. $label . $field );
  460. }
  461. return $html . $helptext;
  462. }
  463. /**
  464. * Get the complete div for the input, including help text,
  465. * labels, and whatever.
  466. * @since 1.20
  467. *
  468. * @param string $value The value to set the input to.
  469. *
  470. * @return string Complete HTML table row.
  471. */
  472. public function getDiv( $value ) {
  473. list( $errors, $errorClass ) = $this->getErrorsAndErrorClass( $value );
  474. $inputHtml = $this->getInputHTML( $value );
  475. $fieldType = static::class;
  476. $helptext = $this->getHelpTextHtmlDiv( $this->getHelpText() );
  477. $cellAttributes = [];
  478. $label = $this->getLabelHtml( $cellAttributes );
  479. $outerDivClass = [
  480. 'mw-input',
  481. 'mw-htmlform-nolabel' => ( $label === '' )
  482. ];
  483. $horizontalLabel = isset( $this->mParams['horizontal-label'] )
  484. ? $this->mParams['horizontal-label'] : false;
  485. if ( $horizontalLabel ) {
  486. $field = '&#160;' . $inputHtml . "\n$errors";
  487. } else {
  488. $field = Html::rawElement(
  489. 'div',
  490. [ 'class' => $outerDivClass ] + $cellAttributes,
  491. $inputHtml . "\n$errors"
  492. );
  493. }
  494. $divCssClasses = [ "mw-htmlform-field-$fieldType",
  495. $this->mClass, $this->mVFormClass, $errorClass ];
  496. $wrapperAttributes = [
  497. 'class' => $divCssClasses,
  498. ];
  499. if ( $this->mHideIf ) {
  500. $wrapperAttributes['data-hide-if'] = FormatJson::encode( $this->mHideIf );
  501. $wrapperAttributes['class'][] = ' mw-htmlform-hide-if';
  502. }
  503. $html = Html::rawElement( 'div', $wrapperAttributes, $label . $field );
  504. $html .= $helptext;
  505. return $html;
  506. }
  507. /**
  508. * Get the OOUI version of the div. Falls back to getDiv by default.
  509. * @since 1.26
  510. *
  511. * @param string $value The value to set the input to.
  512. *
  513. * @return OOUI\FieldLayout|OOUI\ActionFieldLayout
  514. */
  515. public function getOOUI( $value ) {
  516. $inputField = $this->getInputOOUI( $value );
  517. if ( !$inputField ) {
  518. // This field doesn't have an OOUI implementation yet at all. Fall back to getDiv() to
  519. // generate the whole field, label and errors and all, then wrap it in a Widget.
  520. // It might look weird, but it'll work OK.
  521. return $this->getFieldLayoutOOUI(
  522. new OOUI\Widget( [ 'content' => new OOUI\HtmlSnippet( $this->getDiv( $value ) ) ] ),
  523. [ 'infusable' => false, 'align' => 'top' ]
  524. );
  525. }
  526. $infusable = true;
  527. if ( is_string( $inputField ) ) {
  528. // We have an OOUI implementation, but it's not proper, and we got a load of HTML.
  529. // Cheat a little and wrap it in a widget. It won't be infusable, though, since client-side
  530. // JavaScript doesn't know how to rebuilt the contents.
  531. $inputField = new OOUI\Widget( [ 'content' => new OOUI\HtmlSnippet( $inputField ) ] );
  532. $infusable = false;
  533. }
  534. $fieldType = static::class;
  535. $help = $this->getHelpText();
  536. $errors = $this->getErrorsRaw( $value );
  537. foreach ( $errors as &$error ) {
  538. $error = new OOUI\HtmlSnippet( $error );
  539. }
  540. $notices = $this->getNotices();
  541. foreach ( $notices as &$notice ) {
  542. $notice = new OOUI\HtmlSnippet( $notice );
  543. }
  544. $config = [
  545. 'classes' => [ "mw-htmlform-field-$fieldType", $this->mClass ],
  546. 'align' => $this->getLabelAlignOOUI(),
  547. 'help' => ( $help !== null && $help !== '' ) ? new OOUI\HtmlSnippet( $help ) : null,
  548. 'errors' => $errors,
  549. 'notices' => $notices,
  550. 'infusable' => $infusable,
  551. ];
  552. $preloadModules = false;
  553. if ( $infusable && $this->shouldInfuseOOUI() ) {
  554. $preloadModules = true;
  555. $config['classes'][] = 'mw-htmlform-field-autoinfuse';
  556. }
  557. // the element could specify, that the label doesn't need to be added
  558. $label = $this->getLabel();
  559. if ( $label && $label !== '&#160;' ) {
  560. $config['label'] = new OOUI\HtmlSnippet( $label );
  561. }
  562. if ( $this->mHideIf ) {
  563. $preloadModules = true;
  564. $config['hideIf'] = $this->mHideIf;
  565. }
  566. $config['modules'] = $this->getOOUIModules();
  567. if ( $preloadModules ) {
  568. $this->mParent->getOutput()->addModules( 'mediawiki.htmlform.ooui' );
  569. $this->mParent->getOutput()->addModules( $this->getOOUIModules() );
  570. }
  571. return $this->getFieldLayoutOOUI( $inputField, $config );
  572. }
  573. /**
  574. * Get label alignment when generating field for OOUI.
  575. * @return string 'left', 'right', 'top' or 'inline'
  576. */
  577. protected function getLabelAlignOOUI() {
  578. return 'top';
  579. }
  580. /**
  581. * Get a FieldLayout (or subclass thereof) to wrap this field in when using OOUI output.
  582. * @param string $inputField
  583. * @param array $config
  584. * @return OOUI\FieldLayout|OOUI\ActionFieldLayout
  585. */
  586. protected function getFieldLayoutOOUI( $inputField, $config ) {
  587. if ( isset( $this->mClassWithButton ) ) {
  588. $buttonWidget = $this->mClassWithButton->getInputOOUI( '' );
  589. return new HTMLFormActionFieldLayout( $inputField, $buttonWidget, $config );
  590. }
  591. return new HTMLFormFieldLayout( $inputField, $config );
  592. }
  593. /**
  594. * Whether the field should be automatically infused. Note that all OOUI HTMLForm fields are
  595. * infusable (you can call OO.ui.infuse() on them), but not all are infused by default, since
  596. * there is no benefit in doing it e.g. for buttons and it's a small performance hit on page load.
  597. *
  598. * @return bool
  599. */
  600. protected function shouldInfuseOOUI() {
  601. // Always infuse fields with help text, since the interface for it is nicer with JS
  602. return $this->getHelpText() !== null;
  603. }
  604. /**
  605. * Get the list of extra ResourceLoader modules which must be loaded client-side before it's
  606. * possible to infuse this field's OOUI widget.
  607. *
  608. * @return string[]
  609. */
  610. protected function getOOUIModules() {
  611. return [];
  612. }
  613. /**
  614. * Get the complete raw fields for the input, including help text,
  615. * labels, and whatever.
  616. * @since 1.20
  617. *
  618. * @param string $value The value to set the input to.
  619. *
  620. * @return string Complete HTML table row.
  621. */
  622. public function getRaw( $value ) {
  623. list( $errors, ) = $this->getErrorsAndErrorClass( $value );
  624. $inputHtml = $this->getInputHTML( $value );
  625. $helptext = $this->getHelpTextHtmlRaw( $this->getHelpText() );
  626. $cellAttributes = [];
  627. $label = $this->getLabelHtml( $cellAttributes );
  628. $html = "\n$errors";
  629. $html .= $label;
  630. $html .= $inputHtml;
  631. $html .= $helptext;
  632. return $html;
  633. }
  634. /**
  635. * Get the complete field for the input, including help text,
  636. * labels, and whatever. Fall back from 'vform' to 'div' when not overridden.
  637. *
  638. * @since 1.25
  639. * @param string $value The value to set the input to.
  640. * @return string Complete HTML field.
  641. */
  642. public function getVForm( $value ) {
  643. // Ewwww
  644. $this->mVFormClass = ' mw-ui-vform-field';
  645. return $this->getDiv( $value );
  646. }
  647. /**
  648. * Get the complete field as an inline element.
  649. * @since 1.25
  650. * @param string $value The value to set the input to.
  651. * @return string Complete HTML inline element
  652. */
  653. public function getInline( $value ) {
  654. list( $errors, $errorClass ) = $this->getErrorsAndErrorClass( $value );
  655. $inputHtml = $this->getInputHTML( $value );
  656. $helptext = $this->getHelpTextHtmlDiv( $this->getHelpText() );
  657. $cellAttributes = [];
  658. $label = $this->getLabelHtml( $cellAttributes );
  659. $html = "\n" . $errors .
  660. $label . '&#160;' .
  661. $inputHtml .
  662. $helptext;
  663. return $html;
  664. }
  665. /**
  666. * Generate help text HTML in table format
  667. * @since 1.20
  668. *
  669. * @param string|null $helptext
  670. * @return string
  671. */
  672. public function getHelpTextHtmlTable( $helptext ) {
  673. if ( is_null( $helptext ) ) {
  674. return '';
  675. }
  676. $rowAttributes = [];
  677. if ( $this->mHideIf ) {
  678. $rowAttributes['data-hide-if'] = FormatJson::encode( $this->mHideIf );
  679. $rowAttributes['class'] = 'mw-htmlform-hide-if';
  680. }
  681. $tdClasses = [ 'htmlform-tip' ];
  682. if ( $this->mHelpClass !== false ) {
  683. $tdClasses[] = $this->mHelpClass;
  684. }
  685. $row = Html::rawElement( 'td', [ 'colspan' => 2, 'class' => $tdClasses ], $helptext );
  686. $row = Html::rawElement( 'tr', $rowAttributes, $row );
  687. return $row;
  688. }
  689. /**
  690. * Generate help text HTML in div format
  691. * @since 1.20
  692. *
  693. * @param string|null $helptext
  694. *
  695. * @return string
  696. */
  697. public function getHelpTextHtmlDiv( $helptext ) {
  698. if ( is_null( $helptext ) ) {
  699. return '';
  700. }
  701. $wrapperAttributes = [
  702. 'class' => 'htmlform-tip',
  703. ];
  704. if ( $this->mHelpClass !== false ) {
  705. $wrapperAttributes['class'] .= " {$this->mHelpClass}";
  706. }
  707. if ( $this->mHideIf ) {
  708. $wrapperAttributes['data-hide-if'] = FormatJson::encode( $this->mHideIf );
  709. $wrapperAttributes['class'] .= ' mw-htmlform-hide-if';
  710. }
  711. $div = Html::rawElement( 'div', $wrapperAttributes, $helptext );
  712. return $div;
  713. }
  714. /**
  715. * Generate help text HTML formatted for raw output
  716. * @since 1.20
  717. *
  718. * @param string|null $helptext
  719. * @return string
  720. */
  721. public function getHelpTextHtmlRaw( $helptext ) {
  722. return $this->getHelpTextHtmlDiv( $helptext );
  723. }
  724. /**
  725. * Determine the help text to display
  726. * @since 1.20
  727. * @return string|null HTML
  728. */
  729. public function getHelpText() {
  730. $helptext = null;
  731. if ( isset( $this->mParams['help-message'] ) ) {
  732. $this->mParams['help-messages'] = [ $this->mParams['help-message'] ];
  733. }
  734. if ( isset( $this->mParams['help-messages'] ) ) {
  735. foreach ( $this->mParams['help-messages'] as $msg ) {
  736. $msg = $this->getMessage( $msg );
  737. if ( $msg->exists() ) {
  738. if ( is_null( $helptext ) ) {
  739. $helptext = '';
  740. } else {
  741. $helptext .= $this->msg( 'word-separator' )->escaped(); // some space
  742. }
  743. $helptext .= $msg->parse(); // Append message
  744. }
  745. }
  746. } elseif ( isset( $this->mParams['help'] ) ) {
  747. $helptext = $this->mParams['help'];
  748. }
  749. return $helptext;
  750. }
  751. /**
  752. * Determine form errors to display and their classes
  753. * @since 1.20
  754. *
  755. * @param string $value The value of the input
  756. * @return array array( $errors, $errorClass )
  757. */
  758. public function getErrorsAndErrorClass( $value ) {
  759. $errors = $this->validate( $value, $this->mParent->mFieldData );
  760. if ( is_bool( $errors ) || !$this->mParent->wasSubmitted() ) {
  761. $errors = '';
  762. $errorClass = '';
  763. } else {
  764. $errors = self::formatErrors( $errors );
  765. $errorClass = 'mw-htmlform-invalid-input';
  766. }
  767. return [ $errors, $errorClass ];
  768. }
  769. /**
  770. * Determine form errors to display, returning them in an array.
  771. *
  772. * @since 1.26
  773. * @param string $value The value of the input
  774. * @return string[] Array of error HTML strings
  775. */
  776. public function getErrorsRaw( $value ) {
  777. $errors = $this->validate( $value, $this->mParent->mFieldData );
  778. if ( is_bool( $errors ) || !$this->mParent->wasSubmitted() ) {
  779. $errors = [];
  780. }
  781. if ( !is_array( $errors ) ) {
  782. $errors = [ $errors ];
  783. }
  784. foreach ( $errors as &$error ) {
  785. if ( $error instanceof Message ) {
  786. $error = $error->parse();
  787. }
  788. }
  789. return $errors;
  790. }
  791. /**
  792. * Determine notices to display for the field.
  793. *
  794. * @since 1.28
  795. * @return string[]
  796. */
  797. public function getNotices() {
  798. $notices = [];
  799. if ( isset( $this->mParams['notice-message'] ) ) {
  800. $notices[] = $this->getMessage( $this->mParams['notice-message'] )->parse();
  801. }
  802. if ( isset( $this->mParams['notice-messages'] ) ) {
  803. foreach ( $this->mParams['notice-messages'] as $msg ) {
  804. $notices[] = $this->getMessage( $msg )->parse();
  805. }
  806. } elseif ( isset( $this->mParams['notice'] ) ) {
  807. $notices[] = $this->mParams['notice'];
  808. }
  809. return $notices;
  810. }
  811. /**
  812. * @return string HTML
  813. */
  814. public function getLabel() {
  815. return is_null( $this->mLabel ) ? '' : $this->mLabel;
  816. }
  817. public function getLabelHtml( $cellAttributes = [] ) {
  818. # Don't output a for= attribute for labels with no associated input.
  819. # Kind of hacky here, possibly we don't want these to be <label>s at all.
  820. $for = [];
  821. if ( $this->needsLabel() ) {
  822. $for['for'] = $this->mID;
  823. }
  824. $labelValue = trim( $this->getLabel() );
  825. $hasLabel = false;
  826. if ( $labelValue !== '&#160;' && $labelValue !== '' ) {
  827. $hasLabel = true;
  828. }
  829. $displayFormat = $this->mParent->getDisplayFormat();
  830. $html = '';
  831. $horizontalLabel = isset( $this->mParams['horizontal-label'] )
  832. ? $this->mParams['horizontal-label'] : false;
  833. if ( $displayFormat === 'table' ) {
  834. $html =
  835. Html::rawElement( 'td',
  836. [ 'class' => 'mw-label' ] + $cellAttributes,
  837. Html::rawElement( 'label', $for, $labelValue ) );
  838. } elseif ( $hasLabel || $this->mShowEmptyLabels ) {
  839. if ( $displayFormat === 'div' && !$horizontalLabel ) {
  840. $html =
  841. Html::rawElement( 'div',
  842. [ 'class' => 'mw-label' ] + $cellAttributes,
  843. Html::rawElement( 'label', $for, $labelValue ) );
  844. } else {
  845. $html = Html::rawElement( 'label', $for, $labelValue );
  846. }
  847. }
  848. return $html;
  849. }
  850. public function getDefault() {
  851. if ( isset( $this->mDefault ) ) {
  852. return $this->mDefault;
  853. } else {
  854. return null;
  855. }
  856. }
  857. /**
  858. * Returns the attributes required for the tooltip and accesskey, for Html::element() etc.
  859. *
  860. * @return array Attributes
  861. */
  862. public function getTooltipAndAccessKey() {
  863. if ( empty( $this->mParams['tooltip'] ) ) {
  864. return [];
  865. }
  866. return Linker::tooltipAndAccesskeyAttribs( $this->mParams['tooltip'] );
  867. }
  868. /**
  869. * Returns the attributes required for the tooltip and accesskey, for OOUI widgets' config.
  870. *
  871. * @return array Attributes
  872. */
  873. public function getTooltipAndAccessKeyOOUI() {
  874. if ( empty( $this->mParams['tooltip'] ) ) {
  875. return [];
  876. }
  877. return [
  878. 'title' => Linker::titleAttrib( $this->mParams['tooltip'] ),
  879. 'accessKey' => Linker::accesskey( $this->mParams['tooltip'] ),
  880. ];
  881. }
  882. /**
  883. * Returns the given attributes from the parameters
  884. *
  885. * @param array $list List of attributes to get
  886. * @return array Attributes
  887. */
  888. public function getAttributes( array $list ) {
  889. static $boolAttribs = [ 'disabled', 'required', 'autofocus', 'multiple', 'readonly' ];
  890. $ret = [];
  891. foreach ( $list as $key ) {
  892. if ( in_array( $key, $boolAttribs ) ) {
  893. if ( !empty( $this->mParams[$key] ) ) {
  894. $ret[$key] = '';
  895. }
  896. } elseif ( isset( $this->mParams[$key] ) ) {
  897. $ret[$key] = $this->mParams[$key];
  898. }
  899. }
  900. return $ret;
  901. }
  902. /**
  903. * Given an array of msg-key => value mappings, returns an array with keys
  904. * being the message texts. It also forces values to strings.
  905. *
  906. * @param array $options
  907. * @return array
  908. */
  909. private function lookupOptionsKeys( $options ) {
  910. $ret = [];
  911. foreach ( $options as $key => $value ) {
  912. $key = $this->msg( $key )->plain();
  913. $ret[$key] = is_array( $value )
  914. ? $this->lookupOptionsKeys( $value )
  915. : strval( $value );
  916. }
  917. return $ret;
  918. }
  919. /**
  920. * Recursively forces values in an array to strings, because issues arise
  921. * with integer 0 as a value.
  922. *
  923. * @param array $array
  924. * @return array|string
  925. */
  926. public static function forceToStringRecursive( $array ) {
  927. if ( is_array( $array ) ) {
  928. return array_map( [ __CLASS__, 'forceToStringRecursive' ], $array );
  929. } else {
  930. return strval( $array );
  931. }
  932. }
  933. /**
  934. * Fetch the array of options from the field's parameters. In order, this
  935. * checks 'options-messages', 'options', then 'options-message'.
  936. *
  937. * @return array|null Options array
  938. */
  939. public function getOptions() {
  940. if ( $this->mOptions === false ) {
  941. if ( array_key_exists( 'options-messages', $this->mParams ) ) {
  942. $this->mOptions = $this->lookupOptionsKeys( $this->mParams['options-messages'] );
  943. } elseif ( array_key_exists( 'options', $this->mParams ) ) {
  944. $this->mOptionsLabelsNotFromMessage = true;
  945. $this->mOptions = self::forceToStringRecursive( $this->mParams['options'] );
  946. } elseif ( array_key_exists( 'options-message', $this->mParams ) ) {
  947. $message = $this->getMessage( $this->mParams['options-message'] )->inContentLanguage()->plain();
  948. $this->mOptions = Xml::listDropDownOptions( $message );
  949. } else {
  950. $this->mOptions = null;
  951. }
  952. }
  953. return $this->mOptions;
  954. }
  955. /**
  956. * Get options and make them into arrays suitable for OOUI.
  957. * @return array Options for inclusion in a select or whatever.
  958. */
  959. public function getOptionsOOUI() {
  960. $oldoptions = $this->getOptions();
  961. if ( $oldoptions === null ) {
  962. return null;
  963. }
  964. return Xml::listDropDownOptionsOoui( $oldoptions );
  965. }
  966. /**
  967. * flatten an array of options to a single array, for instance,
  968. * a set of "<options>" inside "<optgroups>".
  969. *
  970. * @param array $options Associative Array with values either Strings or Arrays
  971. * @return array Flattened input
  972. */
  973. public static function flattenOptions( $options ) {
  974. $flatOpts = [];
  975. foreach ( $options as $value ) {
  976. if ( is_array( $value ) ) {
  977. $flatOpts = array_merge( $flatOpts, self::flattenOptions( $value ) );
  978. } else {
  979. $flatOpts[] = $value;
  980. }
  981. }
  982. return $flatOpts;
  983. }
  984. /**
  985. * Formats one or more errors as accepted by field validation-callback.
  986. *
  987. * @param string|Message|array $errors Array of strings or Message instances
  988. * @return string HTML
  989. * @since 1.18
  990. */
  991. protected static function formatErrors( $errors ) {
  992. // Note: If you change the logic in this method, change
  993. // htmlform.Checker.js to match.
  994. if ( is_array( $errors ) && count( $errors ) === 1 ) {
  995. $errors = array_shift( $errors );
  996. }
  997. if ( is_array( $errors ) ) {
  998. $lines = [];
  999. foreach ( $errors as $error ) {
  1000. if ( $error instanceof Message ) {
  1001. $lines[] = Html::rawElement( 'li', [], $error->parse() );
  1002. } else {
  1003. $lines[] = Html::rawElement( 'li', [], $error );
  1004. }
  1005. }
  1006. return Html::rawElement( 'ul', [ 'class' => 'error' ], implode( "\n", $lines ) );
  1007. } else {
  1008. if ( $errors instanceof Message ) {
  1009. $errors = $errors->parse();
  1010. }
  1011. return Html::rawElement( 'span', [ 'class' => 'error' ], $errors );
  1012. }
  1013. }
  1014. /**
  1015. * Turns a *-message parameter (which could be a MessageSpecifier, or a message name, or a
  1016. * name + parameters array) into a Message.
  1017. * @param mixed $value
  1018. * @return Message
  1019. */
  1020. protected function getMessage( $value ) {
  1021. $message = Message::newFromSpecifier( $value );
  1022. if ( $this->mParent ) {
  1023. $message->setContext( $this->mParent );
  1024. }
  1025. return $message;
  1026. }
  1027. /**
  1028. * Skip this field when collecting data.
  1029. * @param WebRequest $request
  1030. * @return bool
  1031. * @since 1.27
  1032. */
  1033. public function skipLoadData( $request ) {
  1034. return !empty( $this->mParams['nodata'] );
  1035. }
  1036. /**
  1037. * Whether this field requires the user agent to have JavaScript enabled for the client-side HTML5
  1038. * form validation to work correctly.
  1039. *
  1040. * @return bool
  1041. * @since 1.29
  1042. */
  1043. public function needsJSForHtml5FormValidation() {
  1044. if ( $this->mHideIf ) {
  1045. // This is probably more restrictive than it needs to be, but better safe than sorry
  1046. return true;
  1047. }
  1048. return false;
  1049. }
  1050. }