Added release notes for 'ContentHandler::runLegacyHooks' removal
[mediawiki.git] / includes / htmlform / fields / HTMLFormFieldCloner.php
blob8fb840a136cb05daf5aae8554156146afa773459
1 <?php
3 /**
4 * A container for HTMLFormFields that allows for multiple copies of the set of
5 * fields to be displayed to and entered by the user.
7 * Recognized parameters, besides the general ones, include:
8 * fields - HTMLFormField descriptors for the subfields this cloner manages.
9 * The format is just like for the HTMLForm. A field with key 'delete' is
10 * special: it must have type = submit and will serve to delete the group
11 * of fields.
12 * required - If specified, at least one group of fields must be submitted.
13 * format - HTMLForm display format to use when displaying the subfields:
14 * 'table', 'div', or 'raw'.
15 * row-legend - If non-empty, each group of subfields will be enclosed in a
16 * fieldset. The value is the name of a message key to use as the legend.
17 * create-button-message - Message to use as the text of the button to
18 * add an additional group of fields.
19 * delete-button-message - Message to use as the text of automatically-
20 * generated 'delete' button. Ignored if 'delete' is included in 'fields'.
22 * In the generated HTML, the subfields will be named along the lines of
23 * "clonerName[index][fieldname]", with ids "clonerId--index--fieldid". 'index'
24 * may be a number or an arbitrary string, and may likely change when the page
25 * is resubmitted. Cloners may be nested, resulting in field names along the
26 * lines of "cloner1Name[index1][cloner2Name][index2][fieldname]" and
27 * corresponding ids.
29 * Use of cloner may result in submissions of the page that are not submissions
30 * of the HTMLForm, when non-JavaScript clients use the create or remove buttons.
32 * The result is an array, with values being arrays mapping subfield names to
33 * their values. On non-HTMLForm-submission page loads, there may also be
34 * additional (string) keys present with other types of values.
36 * @since 1.23
38 class HTMLFormFieldCloner extends HTMLFormField {
39 private static $counter = 0;
41 /**
42 * @var string String uniquely identifying this cloner instance and
43 * unlikely to exist otherwise in the generated HTML, while still being
44 * valid as part of an HTML id.
46 protected $uniqueId;
48 public function __construct( $params ) {
49 $this->uniqueId = get_class( $this ) . ++self::$counter . 'x';
50 parent::__construct( $params );
52 if ( empty( $this->mParams['fields'] ) || !is_array( $this->mParams['fields'] ) ) {
53 throw new MWException( 'HTMLFormFieldCloner called without any fields' );
56 // Make sure the delete button, if explicitly specified, is sane
57 if ( isset( $this->mParams['fields']['delete'] ) ) {
58 $class = 'mw-htmlform-cloner-delete-button';
59 $info = $this->mParams['fields']['delete'] + [
60 'formnovalidate' => true,
61 'cssclass' => $class
63 unset( $info['name'], $info['class'] );
65 if ( !isset( $info['type'] ) || $info['type'] !== 'submit' ) {
66 throw new MWException(
67 'HTMLFormFieldCloner delete field, if specified, must be of type "submit"'
71 if ( !in_array( $class, explode( ' ', $info['cssclass'] ) ) ) {
72 $info['cssclass'] .= " $class";
75 $this->mParams['fields']['delete'] = $info;
79 /**
80 * Create the HTMLFormFields that go inside this element, using the
81 * specified key.
83 * @param string $key Array key under which these fields should be named
84 * @return HTMLFormField[]
86 protected function createFieldsForKey( $key ) {
87 $fields = [];
88 foreach ( $this->mParams['fields'] as $fieldname => $info ) {
89 $name = "{$this->mName}[$key][$fieldname]";
90 if ( isset( $info['name'] ) ) {
91 $info['name'] = "{$this->mName}[$key][{$info['name']}]";
92 } else {
93 $info['name'] = $name;
95 if ( isset( $info['id'] ) ) {
96 $info['id'] = Sanitizer::escapeId( "{$this->mID}--$key--{$info['id']}" );
97 } else {
98 $info['id'] = Sanitizer::escapeId( "{$this->mID}--$key--$fieldname" );
100 // Copy the hide-if rules to "child" fields, so that the JavaScript code handling them
101 // (resources/src/mediawiki/htmlform/hide-if.js) doesn't have to handle nested fields.
102 if ( $this->mHideIf ) {
103 if ( isset( $info['hide-if'] ) ) {
104 // Hide child field if either its rules say it's hidden, or parent's rules say it's hidden
105 $info['hide-if'] = [ 'OR', $info['hide-if'], $this->mHideIf ];
106 } else {
107 // Hide child field if parent's rules say it's hidden
108 $info['hide-if'] = $this->mHideIf;
111 $field = HTMLForm::loadInputFromParameters( $name, $info, $this->mParent );
112 $fields[$fieldname] = $field;
114 return $fields;
118 * Re-key the specified values array to match the names applied by
119 * createFieldsForKey().
121 * @param string $key Array key under which these fields should be named
122 * @param array $values Values array from the request
123 * @return array
125 protected function rekeyValuesArray( $key, $values ) {
126 $data = [];
127 foreach ( $values as $fieldname => $value ) {
128 $name = "{$this->mName}[$key][$fieldname]";
129 $data[$name] = $value;
131 return $data;
134 protected function needsLabel() {
135 return false;
138 public function loadDataFromRequest( $request ) {
139 // It's possible that this might be posted with no fields. Detect that
140 // by looking for an edit token.
141 if ( !$request->getCheck( 'wpEditToken' ) && $request->getArray( $this->mName ) === null ) {
142 return $this->getDefault();
145 $values = $request->getArray( $this->mName );
146 if ( $values === null ) {
147 $values = [];
150 $ret = [];
151 foreach ( $values as $key => $value ) {
152 if ( $key === 'create' || isset( $value['delete'] ) ) {
153 $ret['nonjs'] = 1;
154 continue;
157 // Add back in $request->getValues() so things that look for e.g.
158 // wpEditToken don't fail.
159 $data = $this->rekeyValuesArray( $key, $value ) + $request->getValues();
161 $fields = $this->createFieldsForKey( $key );
162 $subrequest = new DerivativeRequest( $request, $data, $request->wasPosted() );
163 $row = [];
164 foreach ( $fields as $fieldname => $field ) {
165 if ( $field->skipLoadData( $subrequest ) ) {
166 continue;
167 } elseif ( !empty( $field->mParams['disabled'] ) ) {
168 $row[$fieldname] = $field->getDefault();
169 } else {
170 $row[$fieldname] = $field->loadDataFromRequest( $subrequest );
173 $ret[] = $row;
176 if ( isset( $values['create'] ) ) {
177 // Non-JS client clicked the "create" button.
178 $fields = $this->createFieldsForKey( $this->uniqueId );
179 $row = [];
180 foreach ( $fields as $fieldname => $field ) {
181 if ( !empty( $field->mParams['nodata'] ) ) {
182 continue;
183 } else {
184 $row[$fieldname] = $field->getDefault();
187 $ret[] = $row;
190 return $ret;
193 public function getDefault() {
194 $ret = parent::getDefault();
196 // The default default is one entry with all subfields at their
197 // defaults.
198 if ( $ret === null ) {
199 $fields = $this->createFieldsForKey( $this->uniqueId );
200 $row = [];
201 foreach ( $fields as $fieldname => $field ) {
202 if ( !empty( $field->mParams['nodata'] ) ) {
203 continue;
204 } else {
205 $row[$fieldname] = $field->getDefault();
208 $ret = [ $row ];
211 return $ret;
214 public function cancelSubmit( $values, $alldata ) {
215 if ( isset( $values['nonjs'] ) ) {
216 return true;
219 foreach ( $values as $key => $value ) {
220 $fields = $this->createFieldsForKey( $key );
221 foreach ( $fields as $fieldname => $field ) {
222 if ( !array_key_exists( $fieldname, $value ) ) {
223 continue;
225 if ( $field->cancelSubmit( $value[$fieldname], $alldata ) ) {
226 return true;
231 return parent::cancelSubmit( $values, $alldata );
234 public function validate( $values, $alldata ) {
235 if ( isset( $this->mParams['required'] )
236 && $this->mParams['required'] !== false
237 && !$values
239 return $this->msg( 'htmlform-cloner-required' );
242 if ( isset( $values['nonjs'] ) ) {
243 // The submission was a non-JS create/delete click, so fail
244 // validation in case cancelSubmit() somehow didn't already handle
245 // it.
246 return false;
249 foreach ( $values as $key => $value ) {
250 $fields = $this->createFieldsForKey( $key );
251 foreach ( $fields as $fieldname => $field ) {
252 if ( !array_key_exists( $fieldname, $value ) ) {
253 continue;
255 if ( $field->isHidden( $alldata ) ) {
256 continue;
258 $ok = $field->validate( $value[$fieldname], $alldata );
259 if ( $ok !== true ) {
260 return false;
265 return parent::validate( $values, $alldata );
269 * Get the input HTML for the specified key.
271 * @param string $key Array key under which the fields should be named
272 * @param array $values
273 * @return string
275 protected function getInputHTMLForKey( $key, array $values ) {
276 $displayFormat = isset( $this->mParams['format'] )
277 ? $this->mParams['format']
278 : $this->mParent->getDisplayFormat();
280 // Conveniently, PHP method names are case-insensitive.
281 $getFieldHtmlMethod = $displayFormat == 'table' ? 'getTableRow' : ( 'get' . $displayFormat );
283 $html = '';
284 $hidden = '';
285 $hasLabel = false;
287 $fields = $this->createFieldsForKey( $key );
288 foreach ( $fields as $fieldname => $field ) {
289 $v = array_key_exists( $fieldname, $values )
290 ? $values[$fieldname]
291 : $field->getDefault();
293 if ( $field instanceof HTMLHiddenField ) {
294 // HTMLHiddenField doesn't generate its own HTML
295 list( $name, $value, $params ) = $field->getHiddenFieldData( $v );
296 $hidden .= Html::hidden( $name, $value, $params ) . "\n";
297 } else {
298 $html .= $field->$getFieldHtmlMethod( $v );
300 $labelValue = trim( $field->getLabel() );
301 if ( $labelValue != '&#160;' && $labelValue !== '' ) {
302 $hasLabel = true;
307 if ( !isset( $fields['delete'] ) ) {
308 $name = "{$this->mName}[$key][delete]";
309 $label = isset( $this->mParams['delete-button-message'] )
310 ? $this->mParams['delete-button-message']
311 : 'htmlform-cloner-delete';
312 $field = HTMLForm::loadInputFromParameters( $name, [
313 'type' => 'submit',
314 'formnovalidate' => true,
315 'name' => $name,
316 'id' => Sanitizer::escapeId( "{$this->mID}--$key--delete" ),
317 'cssclass' => 'mw-htmlform-cloner-delete-button',
318 'default' => $this->getMessage( $label )->text(),
319 ], $this->mParent );
320 $v = $field->getDefault();
322 if ( $displayFormat === 'table' ) {
323 $html .= $field->$getFieldHtmlMethod( $v );
324 } else {
325 $html .= $field->getInputHTML( $v );
329 if ( $displayFormat !== 'raw' ) {
330 $classes = [
331 'mw-htmlform-cloner-row',
334 if ( !$hasLabel ) { // Avoid strange spacing when no labels exist
335 $classes[] = 'mw-htmlform-nolabel';
338 $attribs = [
339 'class' => implode( ' ', $classes ),
342 if ( $displayFormat === 'table' ) {
343 $html = Html::rawElement( 'table',
344 $attribs,
345 Html::rawElement( 'tbody', [], "\n$html\n" ) ) . "\n";
346 } else {
347 $html = Html::rawElement( 'div', $attribs, "\n$html\n" );
351 $html .= $hidden;
353 if ( !empty( $this->mParams['row-legend'] ) ) {
354 $legend = $this->msg( $this->mParams['row-legend'] )->text();
355 $html = Xml::fieldset( $legend, $html );
358 return $html;
361 public function getInputHTML( $values ) {
362 $html = '';
364 foreach ( (array)$values as $key => $value ) {
365 if ( $key === 'nonjs' ) {
366 continue;
368 $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
369 $this->getInputHTMLForKey( $key, $value )
373 $template = $this->getInputHTMLForKey( $this->uniqueId, [] );
374 $html = Html::rawElement( 'ul', [
375 'id' => "mw-htmlform-cloner-list-{$this->mID}",
376 'class' => 'mw-htmlform-cloner-ul',
377 'data-template' => $template,
378 'data-unique-id' => $this->uniqueId,
379 ], $html );
381 $name = "{$this->mName}[create]";
382 $label = isset( $this->mParams['create-button-message'] )
383 ? $this->mParams['create-button-message']
384 : 'htmlform-cloner-create';
385 $field = HTMLForm::loadInputFromParameters( $name, [
386 'type' => 'submit',
387 'formnovalidate' => true,
388 'name' => $name,
389 'id' => Sanitizer::escapeId( "{$this->mID}--create" ),
390 'cssclass' => 'mw-htmlform-cloner-create-button',
391 'default' => $this->getMessage( $label )->text(),
392 ], $this->mParent );
393 $html .= $field->getInputHTML( $field->getDefault() );
395 return $html;