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
// @phan-suppress-next-line PhanTypeMismatchDimFetch Phan is very confused
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;
}
/**
* @inheritDoc
* @phan-param array[] $values
*/
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 );
}
/**
* @inheritDoc
* @phan-param array[] $values
*/
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;
}
}