HTMLFormFieldCloner.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. <?php
  2. /**
  3. * A container for HTMLFormFields that allows for multiple copies of the set of
  4. * fields to be displayed to and entered by the user.
  5. *
  6. * Recognized parameters, besides the general ones, include:
  7. * fields - HTMLFormField descriptors for the subfields this cloner manages.
  8. * The format is just like for the HTMLForm. A field with key 'delete' is
  9. * special: it must have type = submit and will serve to delete the group
  10. * of fields.
  11. * required - If specified, at least one group of fields must be submitted.
  12. * format - HTMLForm display format to use when displaying the subfields:
  13. * 'table', 'div', or 'raw'. This is ignored when using OOUI.
  14. * row-legend - If non-empty, each group of subfields will be enclosed in a
  15. * fieldset. The value is the name of a message key to use as the legend.
  16. * create-button-message - Message to use as the text of the button to
  17. * add an additional group of fields.
  18. * delete-button-message - Message to use as the text of automatically-
  19. * generated 'delete' button. Ignored if 'delete' is included in 'fields'.
  20. *
  21. * In the generated HTML, the subfields will be named along the lines of
  22. * "clonerName[index][fieldname]", with ids "clonerId--index--fieldid". 'index'
  23. * may be a number or an arbitrary string, and may likely change when the page
  24. * is resubmitted. Cloners may be nested, resulting in field names along the
  25. * lines of "cloner1Name[index1][cloner2Name][index2][fieldname]" and
  26. * corresponding ids.
  27. *
  28. * Use of cloner may result in submissions of the page that are not submissions
  29. * of the HTMLForm, when non-JavaScript clients use the create or remove buttons.
  30. *
  31. * The result is an array, with values being arrays mapping subfield names to
  32. * their values. On non-HTMLForm-submission page loads, there may also be
  33. * additional (string) keys present with other types of values.
  34. *
  35. * @since 1.23
  36. */
  37. class HTMLFormFieldCloner extends HTMLFormField {
  38. private static $counter = 0;
  39. /**
  40. * @var string String uniquely identifying this cloner instance and
  41. * unlikely to exist otherwise in the generated HTML, while still being
  42. * valid as part of an HTML id.
  43. */
  44. protected $uniqueId;
  45. public function __construct( $params ) {
  46. $this->uniqueId = static::class . ++self::$counter . 'x';
  47. parent::__construct( $params );
  48. if ( empty( $this->mParams['fields'] ) || !is_array( $this->mParams['fields'] ) ) {
  49. throw new MWException( 'HTMLFormFieldCloner called without any fields' );
  50. }
  51. // Make sure the delete button, if explicitly specified, is sane
  52. if ( isset( $this->mParams['fields']['delete'] ) ) {
  53. $class = 'mw-htmlform-cloner-delete-button';
  54. $info = $this->mParams['fields']['delete'] + [
  55. 'formnovalidate' => true,
  56. 'cssclass' => $class
  57. ];
  58. unset( $info['name'], $info['class'] );
  59. if ( !isset( $info['type'] ) || $info['type'] !== 'submit' ) {
  60. throw new MWException(
  61. 'HTMLFormFieldCloner delete field, if specified, must be of type "submit"'
  62. );
  63. }
  64. if ( !in_array( $class, explode( ' ', $info['cssclass'] ) ) ) {
  65. $info['cssclass'] .= " $class";
  66. }
  67. $this->mParams['fields']['delete'] = $info;
  68. }
  69. }
  70. /**
  71. * Create the HTMLFormFields that go inside this element, using the
  72. * specified key.
  73. *
  74. * @param string $key Array key under which these fields should be named
  75. * @return HTMLFormField[]
  76. */
  77. protected function createFieldsForKey( $key ) {
  78. $fields = [];
  79. foreach ( $this->mParams['fields'] as $fieldname => $info ) {
  80. $name = "{$this->mName}[$key][$fieldname]";
  81. if ( isset( $info['name'] ) ) {
  82. $info['name'] = "{$this->mName}[$key][{$info['name']}]";
  83. } else {
  84. $info['name'] = $name;
  85. }
  86. if ( isset( $info['id'] ) ) {
  87. $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--{$info['id']}" );
  88. } else {
  89. $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--$fieldname" );
  90. }
  91. // Copy the hide-if rules to "child" fields, so that the JavaScript code handling them
  92. // (resources/src/mediawiki/htmlform/hide-if.js) doesn't have to handle nested fields.
  93. if ( $this->mHideIf ) {
  94. if ( isset( $info['hide-if'] ) ) {
  95. // Hide child field if either its rules say it's hidden, or parent's rules say it's hidden
  96. $info['hide-if'] = [ 'OR', $info['hide-if'], $this->mHideIf ];
  97. } else {
  98. // Hide child field if parent's rules say it's hidden
  99. $info['hide-if'] = $this->mHideIf;
  100. }
  101. }
  102. $field = HTMLForm::loadInputFromParameters( $name, $info, $this->mParent );
  103. $fields[$fieldname] = $field;
  104. }
  105. return $fields;
  106. }
  107. /**
  108. * Re-key the specified values array to match the names applied by
  109. * createFieldsForKey().
  110. *
  111. * @param string $key Array key under which these fields should be named
  112. * @param array $values Values array from the request
  113. * @return array
  114. */
  115. protected function rekeyValuesArray( $key, $values ) {
  116. $data = [];
  117. foreach ( $values as $fieldname => $value ) {
  118. $name = "{$this->mName}[$key][$fieldname]";
  119. $data[$name] = $value;
  120. }
  121. return $data;
  122. }
  123. protected function needsLabel() {
  124. return false;
  125. }
  126. public function loadDataFromRequest( $request ) {
  127. // It's possible that this might be posted with no fields. Detect that
  128. // by looking for an edit token.
  129. if ( !$request->getCheck( 'wpEditToken' ) && $request->getArray( $this->mName ) === null ) {
  130. return $this->getDefault();
  131. }
  132. $values = $request->getArray( $this->mName );
  133. if ( $values === null ) {
  134. $values = [];
  135. }
  136. $ret = [];
  137. foreach ( $values as $key => $value ) {
  138. if ( $key === 'create' || isset( $value['delete'] ) ) {
  139. $ret['nonjs'] = 1;
  140. continue;
  141. }
  142. // Add back in $request->getValues() so things that look for e.g.
  143. // wpEditToken don't fail.
  144. $data = $this->rekeyValuesArray( $key, $value ) + $request->getValues();
  145. $fields = $this->createFieldsForKey( $key );
  146. $subrequest = new DerivativeRequest( $request, $data, $request->wasPosted() );
  147. $row = [];
  148. foreach ( $fields as $fieldname => $field ) {
  149. if ( $field->skipLoadData( $subrequest ) ) {
  150. continue;
  151. } elseif ( !empty( $field->mParams['disabled'] ) ) {
  152. $row[$fieldname] = $field->getDefault();
  153. } else {
  154. $row[$fieldname] = $field->loadDataFromRequest( $subrequest );
  155. }
  156. }
  157. $ret[] = $row;
  158. }
  159. if ( isset( $values['create'] ) ) {
  160. // Non-JS client clicked the "create" button.
  161. $fields = $this->createFieldsForKey( $this->uniqueId );
  162. $row = [];
  163. foreach ( $fields as $fieldname => $field ) {
  164. if ( !empty( $field->mParams['nodata'] ) ) {
  165. continue;
  166. } else {
  167. $row[$fieldname] = $field->getDefault();
  168. }
  169. }
  170. $ret[] = $row;
  171. }
  172. return $ret;
  173. }
  174. public function getDefault() {
  175. $ret = parent::getDefault();
  176. // The default default is one entry with all subfields at their
  177. // defaults.
  178. if ( $ret === null ) {
  179. $fields = $this->createFieldsForKey( $this->uniqueId );
  180. $row = [];
  181. foreach ( $fields as $fieldname => $field ) {
  182. if ( !empty( $field->mParams['nodata'] ) ) {
  183. continue;
  184. } else {
  185. $row[$fieldname] = $field->getDefault();
  186. }
  187. }
  188. $ret = [ $row ];
  189. }
  190. return $ret;
  191. }
  192. public function cancelSubmit( $values, $alldata ) {
  193. if ( isset( $values['nonjs'] ) ) {
  194. return true;
  195. }
  196. foreach ( $values as $key => $value ) {
  197. $fields = $this->createFieldsForKey( $key );
  198. foreach ( $fields as $fieldname => $field ) {
  199. if ( !array_key_exists( $fieldname, $value ) ) {
  200. continue;
  201. }
  202. if ( $field->cancelSubmit( $value[$fieldname], $alldata ) ) {
  203. return true;
  204. }
  205. }
  206. }
  207. return parent::cancelSubmit( $values, $alldata );
  208. }
  209. public function validate( $values, $alldata ) {
  210. if ( isset( $this->mParams['required'] )
  211. && $this->mParams['required'] !== false
  212. && !$values
  213. ) {
  214. return $this->msg( 'htmlform-cloner-required' );
  215. }
  216. if ( isset( $values['nonjs'] ) ) {
  217. // The submission was a non-JS create/delete click, so fail
  218. // validation in case cancelSubmit() somehow didn't already handle
  219. // it.
  220. return false;
  221. }
  222. foreach ( $values as $key => $value ) {
  223. $fields = $this->createFieldsForKey( $key );
  224. foreach ( $fields as $fieldname => $field ) {
  225. if ( !array_key_exists( $fieldname, $value ) ) {
  226. continue;
  227. }
  228. if ( $field->isHidden( $alldata ) ) {
  229. continue;
  230. }
  231. $ok = $field->validate( $value[$fieldname], $alldata );
  232. if ( $ok !== true ) {
  233. return false;
  234. }
  235. }
  236. }
  237. return parent::validate( $values, $alldata );
  238. }
  239. /**
  240. * Get the input HTML for the specified key.
  241. *
  242. * @param string $key Array key under which the fields should be named
  243. * @param array $values
  244. * @return string
  245. */
  246. protected function getInputHTMLForKey( $key, array $values ) {
  247. $displayFormat = $this->mParams['format'] ?? $this->mParent->getDisplayFormat();
  248. // Conveniently, PHP method names are case-insensitive.
  249. $getFieldHtmlMethod = $displayFormat == 'table' ? 'getTableRow' : ( 'get' . $displayFormat );
  250. $html = '';
  251. $hidden = '';
  252. $hasLabel = false;
  253. $fields = $this->createFieldsForKey( $key );
  254. foreach ( $fields as $fieldname => $field ) {
  255. $v = array_key_exists( $fieldname, $values )
  256. ? $values[$fieldname]
  257. : $field->getDefault();
  258. if ( $field instanceof HTMLHiddenField ) {
  259. // HTMLHiddenField doesn't generate its own HTML
  260. list( $name, $value, $params ) = $field->getHiddenFieldData( $v );
  261. $hidden .= Html::hidden( $name, $value, $params ) . "\n";
  262. } else {
  263. $html .= $field->$getFieldHtmlMethod( $v );
  264. $labelValue = trim( $field->getLabel() );
  265. if ( $labelValue !== "\u{00A0}" && $labelValue !== '&#160;' && $labelValue !== '' ) {
  266. $hasLabel = true;
  267. }
  268. }
  269. }
  270. if ( !isset( $fields['delete'] ) ) {
  271. $field = $this->getDeleteButtonHtml( $key );
  272. if ( $displayFormat === 'table' ) {
  273. $html .= $field->$getFieldHtmlMethod( $field->getDefault() );
  274. } else {
  275. $html .= $field->getInputHTML( $field->getDefault() );
  276. }
  277. }
  278. if ( $displayFormat !== 'raw' ) {
  279. $classes = [
  280. 'mw-htmlform-cloner-row',
  281. ];
  282. if ( !$hasLabel ) { // Avoid strange spacing when no labels exist
  283. $classes[] = 'mw-htmlform-nolabel';
  284. }
  285. $attribs = [
  286. 'class' => implode( ' ', $classes ),
  287. ];
  288. if ( $displayFormat === 'table' ) {
  289. $html = Html::rawElement( 'table',
  290. $attribs,
  291. Html::rawElement( 'tbody', [], "\n$html\n" ) ) . "\n";
  292. } else {
  293. $html = Html::rawElement( 'div', $attribs, "\n$html\n" );
  294. }
  295. }
  296. $html .= $hidden;
  297. if ( !empty( $this->mParams['row-legend'] ) ) {
  298. $legend = $this->msg( $this->mParams['row-legend'] )->text();
  299. $html = Xml::fieldset( $legend, $html );
  300. }
  301. return $html;
  302. }
  303. /**
  304. * @param string $key Array key indicating to which field the delete button belongs
  305. * @return HTMLFormField
  306. */
  307. protected function getDeleteButtonHtml( $key ) : HTMLFormField {
  308. $name = "{$this->mName}[$key][delete]";
  309. $label = $this->mParams['delete-button-message'] ?? 'htmlform-cloner-delete';
  310. $field = HTMLForm::loadInputFromParameters( $name, [
  311. 'type' => 'submit',
  312. 'formnovalidate' => true,
  313. 'name' => $name,
  314. 'id' => Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--delete" ),
  315. 'cssclass' => 'mw-htmlform-cloner-delete-button',
  316. 'default' => $this->getMessage( $label )->text(),
  317. ], $this->mParent );
  318. return $field;
  319. }
  320. protected function getCreateButtonHtml() : HTMLFormField {
  321. $name = "{$this->mName}[create]";
  322. $label = $this->mParams['create-button-message'] ?? 'htmlform-cloner-create';
  323. return HTMLForm::loadInputFromParameters( $name, [
  324. 'type' => 'submit',
  325. 'formnovalidate' => true,
  326. 'name' => $name,
  327. 'id' => Sanitizer::escapeIdForAttribute( "{$this->mID}--create" ),
  328. 'cssclass' => 'mw-htmlform-cloner-create-button',
  329. 'default' => $this->getMessage( $label )->text(),
  330. ], $this->mParent );
  331. }
  332. public function getInputHTML( $values ) {
  333. $html = '';
  334. foreach ( (array)$values as $key => $value ) {
  335. if ( $key === 'nonjs' ) {
  336. continue;
  337. }
  338. $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
  339. $this->getInputHTMLForKey( $key, $value )
  340. );
  341. }
  342. $template = $this->getInputHTMLForKey( $this->uniqueId, [] );
  343. $html = Html::rawElement( 'ul', [
  344. 'id' => "mw-htmlform-cloner-list-{$this->mID}",
  345. 'class' => 'mw-htmlform-cloner-ul',
  346. 'data-template' => $template,
  347. 'data-unique-id' => $this->uniqueId,
  348. ], $html );
  349. $field = $this->getCreateButtonHtml();
  350. $html .= $field->getInputHTML( $field->getDefault() );
  351. return $html;
  352. }
  353. /**
  354. * Get the input OOUI HTML for the specified key.
  355. *
  356. * @param string $key Array key under which the fields should be named
  357. * @param array $values
  358. * @return string
  359. */
  360. protected function getInputOOUIForKey( $key, array $values ) {
  361. $html = '';
  362. $hidden = '';
  363. $fields = $this->createFieldsForKey( $key );
  364. foreach ( $fields as $fieldname => $field ) {
  365. $v = array_key_exists( $fieldname, $values )
  366. ? $values[$fieldname]
  367. : $field->getDefault();
  368. if ( $field instanceof HTMLHiddenField ) {
  369. // HTMLHiddenField doesn't generate its own HTML
  370. list( $name, $value, $params ) = $field->getHiddenFieldData( $v );
  371. $hidden .= Html::hidden( $name, $value, $params ) . "\n";
  372. } else {
  373. $html .= $field->getOOUI( $v );
  374. }
  375. }
  376. if ( !isset( $fields['delete'] ) ) {
  377. $field = $this->getDeleteButtonHtml( $key );
  378. $fieldHtml = $field->getInputOOUI( $field->getDefault() );
  379. $fieldHtml->setInfusable( true );
  380. $html .= $fieldHtml;
  381. }
  382. $classes = [
  383. 'mw-htmlform-cloner-row',
  384. ];
  385. $attribs = [
  386. 'class' => implode( ' ', $classes ),
  387. ];
  388. $html = Html::rawElement( 'div', $attribs, "\n$html\n" );
  389. $html .= $hidden;
  390. if ( !empty( $this->mParams['row-legend'] ) ) {
  391. $legend = $this->msg( $this->mParams['row-legend'] )->text();
  392. $html = Xml::fieldset( $legend, $html );
  393. }
  394. return $html;
  395. }
  396. public function getInputOOUI( $values ) {
  397. $html = '';
  398. foreach ( (array)$values as $key => $value ) {
  399. if ( $key === 'nonjs' ) {
  400. continue;
  401. }
  402. $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
  403. $this->getInputOOUIForKey( $key, $value )
  404. );
  405. }
  406. $template = $this->getInputOOUIForKey( $this->uniqueId, [] );
  407. $html = Html::rawElement( 'ul', [
  408. 'id' => "mw-htmlform-cloner-list-{$this->mID}",
  409. 'class' => 'mw-htmlform-cloner-ul',
  410. 'data-template' => $template,
  411. 'data-unique-id' => $this->uniqueId,
  412. ], $html );
  413. $field = $this->getCreateButtonHtml();
  414. $fieldHtml = $field->getInputOOUI( $field->getDefault() );
  415. $fieldHtml->setInfusable( true );
  416. $html .= $fieldHtml;
  417. return $html;
  418. }
  419. }