diff options
Diffstat (limited to 'includes/htmlform/HTMLFormFieldCloner.php')
-rw-r--r-- | includes/htmlform/HTMLFormFieldCloner.php | 382 |
1 files changed, 382 insertions, 0 deletions
diff --git a/includes/htmlform/HTMLFormFieldCloner.php b/includes/htmlform/HTMLFormFieldCloner.php new file mode 100644 index 00000000..029911cd --- /dev/null +++ b/includes/htmlform/HTMLFormFieldCloner.php @@ -0,0 +1,382 @@ +<?php + +/** + * A container for HTMLFormFields that allows for multiple copies of the set of + * fields to be displayed to and entered by the user. + * + * Recognized parameters, besides the general ones, include: + * fields - HTMLFormField descriptors for the subfields this cloner manages. + * The format is just like for the HTMLForm. A field with key 'delete' is + * special: it must have type = submit and will serve to delete the group + * of fields. + * required - If specified, at least one group of fields must be submitted. + * format - HTMLForm display format to use when displaying the subfields: + * 'table', 'div', or 'raw'. + * row-legend - If non-empty, each group of subfields will be enclosed in a + * fieldset. The value is the name of a message key to use as the legend. + * create-button-message - Message key to use as the text of the button to + * add an additional group of fields. + * delete-button-message - Message key to use as the text of automatically- + * generated 'delete' button. Ignored if 'delete' is included in 'fields'. + * + * In the generated HTML, the subfields will be named along the lines of + * "clonerName[index][fieldname]", with ids "clonerId--index--fieldid". 'index' + * may be a number or an arbitrary string, and may likely change when the page + * is resubmitted. Cloners may be nested, resulting in field names along the + * lines of "cloner1Name[index1][cloner2Name][index2][fieldname]" and + * corresponding ids. + * + * Use of cloner may result in submissions of the page that are not submissions + * of the HTMLForm, when non-JavaScript clients use the create or remove buttons. + * + * The result is an array, with values being arrays mapping subfield names to + * their values. On non-HTMLForm-submission page loads, there may also be + * additional (string) keys present with other types of values. + * + * @since 1.23 + */ +class HTMLFormFieldCloner extends HTMLFormField { + private static $counter = 0; + + /** + * @var string String uniquely identifying this cloner instance and + * unlikely to exist otherwise in the generated HTML, while still being + * valid as part of an HTML id. + */ + protected $uniqueId; + + public function __construct( $params ) { + $this->uniqueId = get_class( $this ) . ++self::$counter . 'x'; + parent::__construct( $params ); + + if ( empty( $this->mParams['fields'] ) || !is_array( $this->mParams['fields'] ) ) { + throw new MWException( 'HTMLFormFieldCloner called without any fields' ); + } + + // Make sure the delete button, if explicitly specified, is sane + if ( isset( $this->mParams['fields']['delete'] ) ) { + $class = 'mw-htmlform-cloner-delete-button'; + $info = $this->mParams['fields']['delete'] + array( + 'cssclass' => $class + ); + unset( $info['name'], $info['class'] ); + + if ( !isset( $info['type'] ) || $info['type'] !== 'submit' ) { + throw new MWException( + 'HTMLFormFieldCloner delete field, if specified, must be of type "submit"' + ); + } + + if ( !in_array( $class, explode( ' ', $info['cssclass'] ) ) ) { + $info['cssclass'] .= " $class"; + } + + $this->mParams['fields']['delete'] = $info; + } + } + + /** + * Create the HTMLFormFields that go inside this element, using the + * specified key. + * + * @param string $key Array key under which these fields should be named + * @return HTMLFormField[] + */ + protected function createFieldsForKey( $key ) { + $fields = array(); + foreach ( $this->mParams['fields'] as $fieldname => $info ) { + $name = "{$this->mName}[$key][$fieldname]"; + if ( isset( $info['name'] ) ) { + $info['name'] = "{$this->mName}[$key][{$info['name']}]"; + } else { + $info['name'] = $name; + } + if ( isset( $info['id'] ) ) { + $info['id'] = Sanitizer::escapeId( "{$this->mID}--$key--{$info['id']}" ); + } else { + $info['id'] = Sanitizer::escapeId( "{$this->mID}--$key--$fieldname" ); + } + $field = HTMLForm::loadInputFromParameters( $name, $info ); + $field->mParent = $this->mParent; + $fields[$fieldname] = $field; + } + return $fields; + } + + /** + * Re-key the specified values array to match the names applied by + * createFieldsForKey(). + * + * @param string $key Array key under which these fields should be named + * @param array $values Values array from the request + * @return array + */ + protected function rekeyValuesArray( $key, $values ) { + $data = array(); + foreach ( $values as $fieldname => $value ) { + $name = "{$this->mName}[$key][$fieldname]"; + $data[$name] = $value; + } + return $data; + } + + protected function needsLabel() { + return false; + } + + public function loadDataFromRequest( $request ) { + // It's possible that this might be posted with no fields. Detect that + // by looking for an edit token. + if ( !$request->getCheck( 'wpEditToken' ) && $request->getArray( $this->mName ) === null ) { + return $this->getDefault(); + } + + $values = $request->getArray( $this->mName ); + if ( $values === null ) { + $values = array(); + } + + $ret = array(); + foreach ( $values as $key => $value ) { + if ( $key === 'create' || isset( $value['delete'] ) ) { + $ret['nonjs'] = 1; + continue; + } + + // Add back in $request->getValues() so things that look for e.g. + // wpEditToken don't fail. + $data = $this->rekeyValuesArray( $key, $value ) + $request->getValues(); + + $fields = $this->createFieldsForKey( $key ); + $subrequest = new DerivativeRequest( $request, $data, $request->wasPosted() ); + $row = array(); + foreach ( $fields as $fieldname => $field ) { + if ( !empty( $field->mParams['nodata'] ) ) { + continue; + } elseif ( !empty( $field->mParams['disabled'] ) ) { + $row[$fieldname] = $field->getDefault(); + } else { + $row[$fieldname] = $field->loadDataFromRequest( $subrequest ); + } + } + $ret[] = $row; + } + + if ( isset( $values['create'] ) ) { + // Non-JS client clicked the "create" button. + $fields = $this->createFieldsForKey( $this->uniqueId ); + $row = array(); + foreach ( $fields as $fieldname => $field ) { + if ( !empty( $field->mParams['nodata'] ) ) { + continue; + } else { + $row[$fieldname] = $field->getDefault(); + } + } + $ret[] = $row; + } + + return $ret; + } + + public function getDefault() { + $ret = parent::getDefault(); + + // The default default is one entry with all subfields at their + // defaults. + if ( $ret === null ) { + $fields = $this->createFieldsForKey( $this->uniqueId ); + $row = array(); + foreach ( $fields as $fieldname => $field ) { + if ( !empty( $field->mParams['nodata'] ) ) { + continue; + } else { + $row[$fieldname] = $field->getDefault(); + } + } + $ret = array( $row ); + } + + return $ret; + } + + public function cancelSubmit( $values, $alldata ) { + if ( isset( $values['nonjs'] ) ) { + return true; + } + + foreach ( $values as $key => $value ) { + $fields = $this->createFieldsForKey( $key ); + foreach ( $fields as $fieldname => $field ) { + if ( !empty( $field->mParams['nodata'] ) ) { + continue; + } + if ( $field->cancelSubmit( $value[$fieldname], $alldata ) ) { + return true; + } + } + } + + return parent::cancelSubmit( $values, $alldata ); + } + + public function validate( $values, $alldata ) { + if ( isset( $this->mParams['required'] ) + && $this->mParams['required'] !== false + && !$values + ) { + return $this->msg( 'htmlform-cloner-required' )->parseAsBlock(); + } + + if ( isset( $values['nonjs'] ) ) { + // The submission was a non-JS create/delete click, so fail + // validation in case cancelSubmit() somehow didn't already handle + // it. + return false; + } + + foreach ( $values as $key => $value ) { + $fields = $this->createFieldsForKey( $key ); + foreach ( $fields as $fieldname => $field ) { + if ( !empty( $field->mParams['nodata'] ) ) { + continue; + } + $ok = $field->validate( $value[$fieldname], $alldata ); + if ( $ok !== true ) { + return false; + } + } + } + + return parent::validate( $values, $alldata ); + } + + /** + * Get the input HTML for the specified key. + * + * @param string $key Array key under which the fields should be named + * @param array $values + * @return string + */ + protected function getInputHTMLForKey( $key, $values ) { + $displayFormat = isset( $this->mParams['format'] ) + ? $this->mParams['format'] + : $this->mParent->getDisplayFormat(); + + switch ( $displayFormat ) { + case 'table': + $getFieldHtmlMethod = 'getTableRow'; + break; + case 'vform': + // Close enough to a div. + $getFieldHtmlMethod = 'getDiv'; + break; + default: + $getFieldHtmlMethod = 'get' . ucfirst( $displayFormat ); + } + + $html = ''; + $hasLabel = false; + + $fields = $this->createFieldsForKey( $key ); + foreach ( $fields as $fieldname => $field ) { + $v = ( empty( $field->mParams['nodata'] ) && $values !== null ) + ? $values[$fieldname] + : $field->getDefault(); + $html .= $field->$getFieldHtmlMethod( $v ); + + $labelValue = trim( $field->getLabel() ); + if ( $labelValue != ' ' && $labelValue !== '' ) { + $hasLabel = true; + } + } + + if ( !isset( $fields['delete'] ) ) { + $name = "{$this->mName}[$key][delete]"; + $label = isset( $this->mParams['delete-button-message'] ) + ? $this->mParams['delete-button-message'] + : 'htmlform-cloner-delete'; + $field = HTMLForm::loadInputFromParameters( $name, array( + 'type' => 'submit', + 'name' => $name, + 'id' => Sanitizer::escapeId( "{$this->mID}--$key--delete" ), + 'cssclass' => 'mw-htmlform-cloner-delete-button', + 'default' => $this->msg( $label )->text(), + ) ); + $field->mParent = $this->mParent; + $v = $field->getDefault(); + + if ( $displayFormat === 'table' ) { + $html .= $field->$getFieldHtmlMethod( $v ); + } else { + $html .= $field->getInputHTML( $v ); + } + } + + if ( $displayFormat !== 'raw' ) { + $classes = array( + 'mw-htmlform-cloner-row', + ); + + if ( !$hasLabel ) { // Avoid strange spacing when no labels exist + $classes[] = 'mw-htmlform-nolabel'; + } + + $attribs = array( + 'class' => implode( ' ', $classes ), + ); + + if ( $displayFormat === 'table' ) { + $html = Html::rawElement( 'table', + $attribs, + Html::rawElement( 'tbody', array(), "\n$html\n" ) ) . "\n"; + } elseif ( $displayFormat === 'div' || $displayFormat === 'vform' ) { + $html = Html::rawElement( 'div', $attribs, "\n$html\n" ); + } + } + + if ( !empty( $this->mParams['row-legend'] ) ) { + $legend = $this->msg( $this->mParams['row-legend'] )->text(); + $html = Xml::fieldset( $legend, $html ); + } + + return $html; + } + + public function getInputHTML( $values ) { + $html = ''; + + foreach ( (array)$values as $key => $value ) { + if ( $key === 'nonjs' ) { + continue; + } + $html .= Html::rawElement( 'li', array( 'class' => 'mw-htmlform-cloner-li' ), + $this->getInputHTMLForKey( $key, $value ) + ); + } + + $template = $this->getInputHTMLForKey( $this->uniqueId, null ); + $html = Html::rawElement( 'ul', array( + 'id' => "mw-htmlform-cloner-list-{$this->mID}", + 'class' => 'mw-htmlform-cloner-ul', + 'data-template' => $template, + 'data-unique-id' => $this->uniqueId, + ), $html ); + + $name = "{$this->mName}[create]"; + $label = isset( $this->mParams['create-button-message'] ) + ? $this->mParams['create-button-message'] + : 'htmlform-cloner-create'; + $field = HTMLForm::loadInputFromParameters( $name, array( + 'type' => 'submit', + 'name' => $name, + 'id' => Sanitizer::escapeId( "{$this->mID}--create" ), + 'cssclass' => 'mw-htmlform-cloner-create-button', + 'default' => $this->msg( $label )->text(), + ) ); + $field->mParent = $this->mParent; + $html .= $field->getInputHTML( $field->getDefault() ); + + return $html; + } +} |