Merge "mediawiki.api: Support assert parameter in getToken and postWithToken"
[lhc/web/wiklou.git] / includes / htmlform / HTMLFormFieldCloner.php
1 <?php
2
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.
6 *
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 key to use as the text of the button to
18 * add an additional group of fields.
19 * delete-button-message - Message key to use as the text of automatically-
20 * generated 'delete' button. Ignored if 'delete' is included in 'fields'.
21 *
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.
28 *
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.
31 *
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.
35 *
36 * @since 1.23
37 */
38 class HTMLFormFieldCloner extends HTMLFormField {
39 private static $counter = 0;
40
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.
45 */
46 protected $uniqueId;
47
48 public function __construct( $params ) {
49 $this->uniqueId = get_class( $this ) . ++self::$counter . 'x';
50 parent::__construct( $params );
51
52 if ( empty( $this->mParams['fields'] ) || !is_array( $this->mParams['fields'] ) ) {
53 throw new MWException( 'HTMLFormFieldCloner called without any fields' );
54 }
55
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'] + array(
60 'cssclass' => $class
61 );
62 unset( $info['name'], $info['class'] );
63
64 if ( !isset( $info['type'] ) || $info['type'] !== 'submit' ) {
65 throw new MWException(
66 'HTMLFormFieldCloner delete field, if specified, must be of type "submit"'
67 );
68 }
69
70 if ( !in_array( $class, explode( ' ', $info['cssclass'] ) ) ) {
71 $info['cssclass'] .= " $class";
72 }
73
74 $this->mParams['fields']['delete'] = $info;
75 }
76 }
77
78 /**
79 * Create the HTMLFormFields that go inside this element, using the
80 * specified key.
81 *
82 * @param string $key Array key under which these fields should be named
83 * @return HTMLFormField[]
84 */
85 protected function createFieldsForKey( $key ) {
86 $fields = array();
87 foreach ( $this->mParams['fields'] as $fieldname => $info ) {
88 $name = "{$this->mName}[$key][$fieldname]";
89 if ( isset( $info['name'] ) ) {
90 $info['name'] = "{$this->mName}[$key][{$info['name']}]";
91 } else {
92 $info['name'] = $name;
93 }
94 if ( isset( $info['id'] ) ) {
95 $info['id'] = Sanitizer::escapeId( "{$this->mID}--$key--{$info['id']}" );
96 } else {
97 $info['id'] = Sanitizer::escapeId( "{$this->mID}--$key--$fieldname" );
98 }
99 $field = HTMLForm::loadInputFromParameters( $name, $info );
100 $field->mParent = $this->mParent;
101 $fields[$fieldname] = $field;
102 }
103 return $fields;
104 }
105
106 /**
107 * Re-key the specified values array to match the names applied by
108 * createFieldsForKey().
109 *
110 * @param string $key Array key under which these fields should be named
111 * @param array $values Values array from the request
112 * @return array
113 */
114 protected function rekeyValuesArray( $key, $values ) {
115 $data = array();
116 foreach ( $values as $fieldname => $value ) {
117 $name = "{$this->mName}[$key][$fieldname]";
118 $data[$name] = $value;
119 }
120 return $data;
121 }
122
123 protected function needsLabel() {
124 return false;
125 }
126
127 public function loadDataFromRequest( $request ) {
128 // It's possible that this might be posted with no fields. Detect that
129 // by looking for an edit token.
130 if ( !$request->getCheck( 'wpEditToken' ) && $request->getArray( $this->mName ) === null ) {
131 return $this->getDefault();
132 }
133
134 $values = $request->getArray( $this->mName );
135 if ( $values === null ) {
136 $values = array();
137 }
138
139 $ret = array();
140 foreach ( $values as $key => $value ) {
141 if ( $key === 'create' || isset( $value['delete'] ) ) {
142 $ret['nonjs'] = 1;
143 continue;
144 }
145
146 // Add back in $request->getValues() so things that look for e.g.
147 // wpEditToken don't fail.
148 $data = $this->rekeyValuesArray( $key, $value ) + $request->getValues();
149
150 $fields = $this->createFieldsForKey( $key );
151 $subrequest = new DerivativeRequest( $request, $data, $request->wasPosted() );
152 $row = array();
153 foreach ( $fields as $fieldname => $field ) {
154 if ( !empty( $field->mParams['nodata'] ) ) {
155 continue;
156 } elseif ( !empty( $field->mParams['disabled'] ) ) {
157 $row[$fieldname] = $field->getDefault();
158 } else {
159 $row[$fieldname] = $field->loadDataFromRequest( $subrequest );
160 }
161 }
162 $ret[] = $row;
163 }
164
165 if ( isset( $values['create'] ) ) {
166 // Non-JS client clicked the "create" button.
167 $fields = $this->createFieldsForKey( $this->uniqueId );
168 $row = array();
169 foreach ( $fields as $fieldname => $field ) {
170 if ( !empty( $field->mParams['nodata'] ) ) {
171 continue;
172 } else {
173 $row[$fieldname] = $field->getDefault();
174 }
175 }
176 $ret[] = $row;
177 }
178
179 return $ret;
180 }
181
182 public function getDefault() {
183 $ret = parent::getDefault();
184
185 // The default default is one entry with all subfields at their
186 // defaults.
187 if ( $ret === null ) {
188 $fields = $this->createFieldsForKey( $this->uniqueId );
189 $row = array();
190 foreach ( $fields as $fieldname => $field ) {
191 if ( !empty( $field->mParams['nodata'] ) ) {
192 continue;
193 } else {
194 $row[$fieldname] = $field->getDefault();
195 }
196 }
197 $ret = array( $row );
198 }
199
200 return $ret;
201 }
202
203 public function cancelSubmit( $values, $alldata ) {
204 if ( isset( $values['nonjs'] ) ) {
205 return true;
206 }
207
208 foreach ( $values as $key => $value ) {
209 $fields = $this->createFieldsForKey( $key );
210 foreach ( $fields as $fieldname => $field ) {
211 if ( !empty( $field->mParams['nodata'] ) ) {
212 continue;
213 }
214 if ( $field->cancelSubmit( $value[$fieldname], $alldata ) ) {
215 return true;
216 }
217 }
218 }
219
220 return parent::cancelSubmit( $values, $alldata );
221 }
222
223 public function validate( $values, $alldata ) {
224 if ( isset( $this->mParams['required'] )
225 && $this->mParams['required'] !== false
226 && !$values
227 ) {
228 return $this->msg( 'htmlform-cloner-required' )->parseAsBlock();
229 }
230
231 if ( isset( $values['nonjs'] ) ) {
232 // The submission was a non-JS create/delete click, so fail
233 // validation in case cancelSubmit() somehow didn't already handle
234 // it.
235 return false;
236 }
237
238 foreach ( $values as $key => $value ) {
239 $fields = $this->createFieldsForKey( $key );
240 foreach ( $fields as $fieldname => $field ) {
241 if ( !empty( $field->mParams['nodata'] ) ) {
242 continue;
243 }
244 $ok = $field->validate( $value[$fieldname], $alldata );
245 if ( $ok !== true ) {
246 return false;
247 }
248 }
249 }
250
251 return parent::validate( $values, $alldata );
252 }
253
254 /**
255 * Get the input HTML for the specified key.
256 *
257 * @param string $key Array key under which the fields should be named
258 * @param array $values
259 * @return string
260 */
261 protected function getInputHTMLForKey( $key, $values ) {
262 $displayFormat = isset( $this->mParams['format'] )
263 ? $this->mParams['format']
264 : $this->mParent->getDisplayFormat();
265
266 switch ( $displayFormat ) {
267 case 'table':
268 $getFieldHtmlMethod = 'getTableRow';
269 break;
270 case 'vform':
271 // Close enough to a div.
272 $getFieldHtmlMethod = 'getDiv';
273 break;
274 default:
275 $getFieldHtmlMethod = 'get' . ucfirst( $displayFormat );
276 }
277
278 $html = '';
279 $hasLabel = false;
280
281 $fields = $this->createFieldsForKey( $key );
282 foreach ( $fields as $fieldname => $field ) {
283 $v = ( empty( $field->mParams['nodata'] ) && $values !== null )
284 ? $values[$fieldname]
285 : $field->getDefault();
286 $html .= $field->$getFieldHtmlMethod( $v );
287
288 $labelValue = trim( $field->getLabel() );
289 if ( $labelValue != '&#160;' && $labelValue !== '' ) {
290 $hasLabel = true;
291 }
292 }
293
294 if ( !isset( $fields['delete'] ) ) {
295 $name = "{$this->mName}[$key][delete]";
296 $label = isset( $this->mParams['delete-button-message'] )
297 ? $this->mParams['delete-button-message']
298 : 'htmlform-cloner-delete';
299 $field = HTMLForm::loadInputFromParameters( $name, array(
300 'type' => 'submit',
301 'name' => $name,
302 'id' => Sanitizer::escapeId( "{$this->mID}--$key--delete" ),
303 'cssclass' => 'mw-htmlform-cloner-delete-button',
304 'default' => $this->msg( $label )->text(),
305 ) );
306 $field->mParent = $this->mParent;
307 $v = $field->getDefault();
308
309 if ( $displayFormat === 'table' ) {
310 $html .= $field->$getFieldHtmlMethod( $v );
311 } else {
312 $html .= $field->getInputHTML( $v );
313 }
314 }
315
316 if ( $displayFormat !== 'raw' ) {
317 $classes = array(
318 'mw-htmlform-cloner-row',
319 );
320
321 if ( !$hasLabel ) { // Avoid strange spacing when no labels exist
322 $classes[] = 'mw-htmlform-nolabel';
323 }
324
325 $attribs = array(
326 'class' => implode( ' ', $classes ),
327 );
328
329 if ( $displayFormat === 'table' ) {
330 $html = Html::rawElement( 'table',
331 $attribs,
332 Html::rawElement( 'tbody', array(), "\n$html\n" ) ) . "\n";
333 } elseif ( $displayFormat === 'div' || $displayFormat === 'vform' ) {
334 $html = Html::rawElement( 'div', $attribs, "\n$html\n" );
335 }
336 }
337
338 if ( !empty( $this->mParams['row-legend'] ) ) {
339 $legend = $this->msg( $this->mParams['row-legend'] )->text();
340 $html = Xml::fieldset( $legend, $html );
341 }
342
343 return $html;
344 }
345
346 public function getInputHTML( $values ) {
347 $html = '';
348
349 foreach ( (array)$values as $key => $value ) {
350 if ( $key === 'nonjs' ) {
351 continue;
352 }
353 $html .= Html::rawElement( 'li', array( 'class' => 'mw-htmlform-cloner-li' ),
354 $this->getInputHTMLForKey( $key, $value )
355 );
356 }
357
358 $template = $this->getInputHTMLForKey( $this->uniqueId, null );
359 $html = Html::rawElement( 'ul', array(
360 'id' => "mw-htmlform-cloner-list-{$this->mID}",
361 'class' => 'mw-htmlform-cloner-ul',
362 'data-template' => $template,
363 'data-unique-id' => $this->uniqueId,
364 ), $html );
365
366 $name = "{$this->mName}[create]";
367 $label = isset( $this->mParams['create-button-message'] )
368 ? $this->mParams['create-button-message']
369 : 'htmlform-cloner-create';
370 $field = HTMLForm::loadInputFromParameters( $name, array(
371 'type' => 'submit',
372 'name' => $name,
373 'id' => Sanitizer::escapeId( "{$this->mID}--create" ),
374 'cssclass' => 'mw-htmlform-cloner-create-button',
375 'default' => $this->msg( $label )->text(),
376 ) );
377 $field->mParent = $this->mParent;
378 $html .= $field->getInputHTML( $field->getDefault() );
379
380 return $html;
381 }
382 }