123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487 |
- <?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'. This is ignored when using OOUI.
- * 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 to use as the text of the button to
- * add an additional group of fields.
- * delete-button-message - Message 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 = static::class . ++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'] + [
- 'formnovalidate' => true,
- '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 = [];
- 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::escapeIdForAttribute( "{$this->mID}--$key--{$info['id']}" );
- } else {
- $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--$fieldname" );
- }
- // Copy the hide-if rules to "child" fields, so that the JavaScript code handling them
- // (resources/src/mediawiki/htmlform/hide-if.js) doesn't have to handle nested fields.
- if ( $this->mHideIf ) {
- if ( isset( $info['hide-if'] ) ) {
- // Hide child field if either its rules say it's hidden, or parent's rules say it's hidden
- $info['hide-if'] = [ 'OR', $info['hide-if'], $this->mHideIf ];
- } else {
- // Hide child field if parent's rules say it's hidden
- $info['hide-if'] = $this->mHideIf;
- }
- }
- $field = HTMLForm::loadInputFromParameters( $name, $info, $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 = [];
- 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 = [];
- }
- $ret = [];
- 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 = [];
- foreach ( $fields as $fieldname => $field ) {
- if ( $field->skipLoadData( $subrequest ) ) {
- 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 = [];
- 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 = [];
- foreach ( $fields as $fieldname => $field ) {
- if ( !empty( $field->mParams['nodata'] ) ) {
- continue;
- } else {
- $row[$fieldname] = $field->getDefault();
- }
- }
- $ret = [ $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 ( !array_key_exists( $fieldname, $value ) ) {
- 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' );
- }
- 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 ( !array_key_exists( $fieldname, $value ) ) {
- continue;
- }
- if ( $field->isHidden( $alldata ) ) {
- 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, array $values ) {
- $displayFormat = $this->mParams['format'] ?? $this->mParent->getDisplayFormat();
- // Conveniently, PHP method names are case-insensitive.
- $getFieldHtmlMethod = $displayFormat == 'table' ? 'getTableRow' : ( 'get' . $displayFormat );
- $html = '';
- $hidden = '';
- $hasLabel = false;
- $fields = $this->createFieldsForKey( $key );
- foreach ( $fields as $fieldname => $field ) {
- $v = array_key_exists( $fieldname, $values )
- ? $values[$fieldname]
- : $field->getDefault();
- if ( $field instanceof HTMLHiddenField ) {
- // HTMLHiddenField doesn't generate its own HTML
- list( $name, $value, $params ) = $field->getHiddenFieldData( $v );
- $hidden .= Html::hidden( $name, $value, $params ) . "\n";
- } else {
- $html .= $field->$getFieldHtmlMethod( $v );
- $labelValue = trim( $field->getLabel() );
- if ( $labelValue !== "\u{00A0}" && $labelValue !== ' ' && $labelValue !== '' ) {
- $hasLabel = true;
- }
- }
- }
- if ( !isset( $fields['delete'] ) ) {
- $field = $this->getDeleteButtonHtml( $key );
- if ( $displayFormat === 'table' ) {
- $html .= $field->$getFieldHtmlMethod( $field->getDefault() );
- } else {
- $html .= $field->getInputHTML( $field->getDefault() );
- }
- }
- if ( $displayFormat !== 'raw' ) {
- $classes = [
- 'mw-htmlform-cloner-row',
- ];
- if ( !$hasLabel ) { // Avoid strange spacing when no labels exist
- $classes[] = 'mw-htmlform-nolabel';
- }
- $attribs = [
- 'class' => implode( ' ', $classes ),
- ];
- if ( $displayFormat === 'table' ) {
- $html = Html::rawElement( 'table',
- $attribs,
- Html::rawElement( 'tbody', [], "\n$html\n" ) ) . "\n";
- } else {
- $html = Html::rawElement( 'div', $attribs, "\n$html\n" );
- }
- }
- $html .= $hidden;
- if ( !empty( $this->mParams['row-legend'] ) ) {
- $legend = $this->msg( $this->mParams['row-legend'] )->text();
- $html = Xml::fieldset( $legend, $html );
- }
- return $html;
- }
- /**
- * @param string $key Array key indicating to which field the delete button belongs
- * @return HTMLFormField
- */
- protected function getDeleteButtonHtml( $key ) : HTMLFormField {
- $name = "{$this->mName}[$key][delete]";
- $label = $this->mParams['delete-button-message'] ?? 'htmlform-cloner-delete';
- $field = HTMLForm::loadInputFromParameters( $name, [
- 'type' => 'submit',
- 'formnovalidate' => true,
- 'name' => $name,
- 'id' => Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--delete" ),
- 'cssclass' => 'mw-htmlform-cloner-delete-button',
- 'default' => $this->getMessage( $label )->text(),
- ], $this->mParent );
- return $field;
- }
- protected function getCreateButtonHtml() : HTMLFormField {
- $name = "{$this->mName}[create]";
- $label = $this->mParams['create-button-message'] ?? 'htmlform-cloner-create';
- return HTMLForm::loadInputFromParameters( $name, [
- 'type' => 'submit',
- 'formnovalidate' => true,
- 'name' => $name,
- 'id' => Sanitizer::escapeIdForAttribute( "{$this->mID}--create" ),
- 'cssclass' => 'mw-htmlform-cloner-create-button',
- 'default' => $this->getMessage( $label )->text(),
- ], $this->mParent );
- }
- public function getInputHTML( $values ) {
- $html = '';
- foreach ( (array)$values as $key => $value ) {
- if ( $key === 'nonjs' ) {
- continue;
- }
- $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
- $this->getInputHTMLForKey( $key, $value )
- );
- }
- $template = $this->getInputHTMLForKey( $this->uniqueId, [] );
- $html = Html::rawElement( 'ul', [
- 'id' => "mw-htmlform-cloner-list-{$this->mID}",
- 'class' => 'mw-htmlform-cloner-ul',
- 'data-template' => $template,
- 'data-unique-id' => $this->uniqueId,
- ], $html );
- $field = $this->getCreateButtonHtml();
- $html .= $field->getInputHTML( $field->getDefault() );
- return $html;
- }
- /**
- * Get the input OOUI HTML for the specified key.
- *
- * @param string $key Array key under which the fields should be named
- * @param array $values
- * @return string
- */
- protected function getInputOOUIForKey( $key, array $values ) {
- $html = '';
- $hidden = '';
- $fields = $this->createFieldsForKey( $key );
- foreach ( $fields as $fieldname => $field ) {
- $v = array_key_exists( $fieldname, $values )
- ? $values[$fieldname]
- : $field->getDefault();
- if ( $field instanceof HTMLHiddenField ) {
- // HTMLHiddenField doesn't generate its own HTML
- list( $name, $value, $params ) = $field->getHiddenFieldData( $v );
- $hidden .= Html::hidden( $name, $value, $params ) . "\n";
- } else {
- $html .= $field->getOOUI( $v );
- }
- }
- if ( !isset( $fields['delete'] ) ) {
- $field = $this->getDeleteButtonHtml( $key );
- $fieldHtml = $field->getInputOOUI( $field->getDefault() );
- $fieldHtml->setInfusable( true );
- $html .= $fieldHtml;
- }
- $classes = [
- 'mw-htmlform-cloner-row',
- ];
- $attribs = [
- 'class' => implode( ' ', $classes ),
- ];
- $html = Html::rawElement( 'div', $attribs, "\n$html\n" );
- $html .= $hidden;
- if ( !empty( $this->mParams['row-legend'] ) ) {
- $legend = $this->msg( $this->mParams['row-legend'] )->text();
- $html = Xml::fieldset( $legend, $html );
- }
- return $html;
- }
- public function getInputOOUI( $values ) {
- $html = '';
- foreach ( (array)$values as $key => $value ) {
- if ( $key === 'nonjs' ) {
- continue;
- }
- $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
- $this->getInputOOUIForKey( $key, $value )
- );
- }
- $template = $this->getInputOOUIForKey( $this->uniqueId, [] );
- $html = Html::rawElement( 'ul', [
- 'id' => "mw-htmlform-cloner-list-{$this->mID}",
- 'class' => 'mw-htmlform-cloner-ul',
- 'data-template' => $template,
- 'data-unique-id' => $this->uniqueId,
- ], $html );
- $field = $this->getCreateButtonHtml();
- $fieldHtml = $field->getInputOOUI( $field->getDefault() );
- $fieldHtml->setInfusable( true );
- $html .= $fieldHtml;
- return $html;
- }
- }
|