1<?php
2
3/**
4 * A checkbox matrix
5 * Operates similarly to HTMLMultiSelectField, but instead of using an array of
6 * options, uses an array of rows and an array of columns to dynamically
7 * construct a matrix of options. The tags used to identify a particular cell
8 * are of the form "columnName-rowName"
9 *
10 * Options:
11 *   - columns
12 *     - Required associative array mapping column labels (as HTML) to their tags.
13 *   - rows
14 *     - Required associative array mapping row labels (as HTML) to their tags.
15 *   - force-options-on
16 *     - Array of column-row tags to be displayed as enabled but unavailable to change.
17 *   - force-options-off
18 *     - Array of column-row tags to be displayed as disabled but unavailable to change.
19 *   - tooltips
20 *     - Optional associative array mapping row labels to tooltips (as text, will be escaped).
21 *   - tooltip-class
22 *     - Optional CSS class used on tooltip container span. Defaults to mw-icon-question.
23 *       Not used by OOUI form fields.
24 *
25 * @stable to extend
26 */
27class HTMLCheckMatrix extends HTMLFormField implements HTMLNestedFilterable {
28	private static $requiredParams = [
29		// Required by underlying HTMLFormField
30		'fieldname',
31		// Required by HTMLCheckMatrix
32		'rows',
33		'columns'
34	];
35
36	/**
37	 * @stable to call
38	 * @inheritDoc
39	 */
40	public function __construct( $params ) {
41		$missing = array_diff( self::$requiredParams, array_keys( $params ) );
42		if ( $missing ) {
43			throw new HTMLFormFieldRequiredOptionsException( $this, $missing );
44		}
45		parent::__construct( $params );
46	}
47
48	public function validate( $value, $alldata ) {
49		$rows = $this->mParams['rows'];
50		$columns = $this->mParams['columns'];
51
52		// Make sure user-defined validation callback is run
53		$p = parent::validate( $value, $alldata );
54		if ( $p !== true ) {
55			return $p;
56		}
57
58		// Make sure submitted value is an array
59		if ( !is_array( $value ) ) {
60			return false;
61		}
62
63		// If all options are valid, array_intersect of the valid options
64		// and the provided options will return the provided options.
65		$validOptions = [];
66		foreach ( $rows as $rowTag ) {
67			foreach ( $columns as $columnTag ) {
68				$validOptions[] = $columnTag . '-' . $rowTag;
69			}
70		}
71		$validValues = array_intersect( $value, $validOptions );
72		if ( count( $validValues ) == count( $value ) ) {
73			return true;
74		} else {
75			return $this->msg( 'htmlform-select-badoption' );
76		}
77	}
78
79	/**
80	 * Build a table containing a matrix of checkbox options.
81	 * The value of each option is a combination of the row tag and column tag.
82	 * mParams['rows'] is an array with row labels as keys and row tags as values.
83	 * mParams['columns'] is an array with column labels as keys and column tags as values.
84	 *
85	 * @param array $value Array of the options that should be checked
86	 *
87	 * @return string
88	 */
89	public function getInputHTML( $value ) {
90		$html = '';
91		$tableContents = '';
92		$rows = $this->mParams['rows'];
93		$columns = $this->mParams['columns'];
94
95		$attribs = $this->getAttributes( [ 'disabled', 'tabindex' ] );
96
97		// Build the column headers
98		$headerContents = Html::rawElement( 'td', [], "\u{00A0}" );
99		foreach ( $columns as $columnLabel => $columnTag ) {
100			$headerContents .= Html::rawElement( 'th', [], $columnLabel );
101		}
102		$thead = Html::rawElement( 'tr', [], "\n$headerContents\n" );
103		$tableContents .= Html::rawElement( 'thead', [], "\n$thead\n" );
104
105		$tooltipClass = 'mw-icon-question';
106		if ( isset( $this->mParams['tooltip-class'] ) ) {
107			$tooltipClass = $this->mParams['tooltip-class'];
108		}
109
110		// Build the options matrix
111		foreach ( $rows as $rowLabel => $rowTag ) {
112			// Append tooltip if configured
113			if ( isset( $this->mParams['tooltips'][$rowLabel] ) ) {
114				$tooltipAttribs = [
115					'class' => "mw-htmlform-tooltip $tooltipClass",
116					'title' => $this->mParams['tooltips'][$rowLabel],
117					'aria-label' => $this->mParams['tooltips'][$rowLabel]
118				];
119				$rowLabel .= ' ' . Html::element( 'span', $tooltipAttribs, '' );
120			}
121			$rowContents = Html::rawElement( 'td', [], $rowLabel );
122			foreach ( $columns as $columnTag ) {
123				$thisTag = "$columnTag-$rowTag";
124				// Construct the checkbox
125				$thisAttribs = [
126					'id' => "{$this->mID}-$thisTag",
127					'value' => $thisTag,
128				];
129				$checked = in_array( $thisTag, (array)$value, true );
130				if ( $this->isTagForcedOff( $thisTag ) ) {
131					$checked = false;
132					$thisAttribs['disabled'] = 1;
133					$thisAttribs['class'] = 'checkmatrix-forced checkmatrix-forced-off';
134				} elseif ( $this->isTagForcedOn( $thisTag ) ) {
135					$checked = true;
136					$thisAttribs['disabled'] = 1;
137					$thisAttribs['class'] = 'checkmatrix-forced checkmatrix-forced-on';
138				}
139
140				$checkbox = $this->getOneCheckboxHTML( $checked, $attribs + $thisAttribs );
141
142				$rowContents .= Html::rawElement(
143					'td',
144					[],
145					$checkbox
146				);
147			}
148			$tableContents .= Html::rawElement( 'tr', [], "\n$rowContents\n" );
149		}
150
151		// Put it all in a table
152		$html .= Html::rawElement( 'table',
153				[ 'class' => 'mw-htmlform-matrix' ],
154				Html::rawElement( 'tbody', [], "\n$tableContents\n" ) ) . "\n";
155
156		return $html;
157	}
158
159	public function getInputOOUI( $value ) {
160		$attribs = $this->getAttributes( [ 'disabled', 'tabindex' ] );
161
162		return new MediaWiki\Widget\CheckMatrixWidget(
163			[
164				'name' => $this->mName,
165				'infusable' => true,
166				'id' => $this->mID,
167				'rows' => $this->mParams['rows'],
168				'columns' => $this->mParams['columns'],
169				'tooltips' => $this->mParams['tooltips'] ?? [],
170				'forcedOff' => $this->mParams['force-options-off'] ?? [],
171				'forcedOn' => $this->mParams['force-options-on'] ?? [],
172				'values' => $value,
173			] + OOUI\Element::configFromHtmlAttributes( $attribs )
174		);
175	}
176
177	protected function getOneCheckboxHTML( $checked, $attribs ) {
178		$checkbox = Xml::check( "{$this->mName}[]", $checked, $attribs );
179		if ( $this->mParent->getConfig()->get( 'UseMediaWikiUIEverywhere' ) ) {
180			$checkbox = Html::openElement( 'div', [ 'class' => 'mw-ui-checkbox' ] ) .
181				$checkbox .
182				Html::element( 'label', [ 'for' => $attribs['id'] ] ) .
183				Html::closeElement( 'div' );
184		}
185		return $checkbox;
186	}
187
188	protected function isTagForcedOff( $tag ) {
189		return isset( $this->mParams['force-options-off'] )
190			&& in_array( $tag, $this->mParams['force-options-off'] );
191	}
192
193	protected function isTagForcedOn( $tag ) {
194		return isset( $this->mParams['force-options-on'] )
195			&& in_array( $tag, $this->mParams['force-options-on'] );
196	}
197
198	/**
199	 * Get the complete table row for the input, including help text,
200	 * labels, and whatever.
201	 * We override this function since the label should always be on a separate
202	 * line above the options in the case of a checkbox matrix, i.e. it's always
203	 * a "vertical-label".
204	 *
205	 * @param string|array $value The value to set the input to
206	 *
207	 * @return string Complete HTML table row
208	 */
209	public function getTableRow( $value ) {
210		list( $errors, $errorClass ) = $this->getErrorsAndErrorClass( $value );
211		$inputHtml = $this->getInputHTML( $value );
212		$fieldType = $this->getClassName();
213		$helptext = $this->getHelpTextHtmlTable( $this->getHelpText() );
214		$cellAttributes = [ 'colspan' => 2 ];
215
216		$hideClass = '';
217		$hideAttributes = [];
218		if ( $this->mHideIf ) {
219			$hideAttributes['data-hide-if'] = FormatJson::encode( $this->mHideIf );
220			$hideClass = 'mw-htmlform-hide-if';
221		}
222
223		$label = $this->getLabelHtml( $cellAttributes );
224
225		$field = Html::rawElement(
226			'td',
227			[ 'class' => 'mw-input' ] + $cellAttributes,
228			$inputHtml . "\n$errors"
229		);
230
231		$html = Html::rawElement( 'tr',
232			[ 'class' => "mw-htmlform-vertical-label $hideClass" ] + $hideAttributes,
233			$label );
234		$html .= Html::rawElement( 'tr',
235			[ 'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $hideClass" ] +
236				$hideAttributes,
237			$field );
238
239		return $html . $helptext;
240	}
241
242	/**
243	 * @param WebRequest $request
244	 *
245	 * @return array
246	 */
247	public function loadDataFromRequest( $request ) {
248		if ( $this->isSubmitAttempt( $request ) ) {
249			// Checkboxes are just not added to the request arrays if they're not checked,
250			// so it's perfectly possible for there not to be an entry at all
251			return $request->getArray( $this->mName, [] );
252		} else {
253			// That's ok, the user has not yet submitted the form, so show the defaults
254			return $this->getDefault();
255		}
256	}
257
258	public function getDefault() {
259		return $this->mDefault ?? [];
260	}
261
262	public function filterDataForSubmit( $data ) {
263		$columns = HTMLFormField::flattenOptions( $this->mParams['columns'] );
264		$rows = HTMLFormField::flattenOptions( $this->mParams['rows'] );
265		$res = [];
266		foreach ( $columns as $column ) {
267			foreach ( $rows as $row ) {
268				// Make sure option hasn't been forced
269				$thisTag = "$column-$row";
270				if ( $this->isTagForcedOff( $thisTag ) ) {
271					$res[$thisTag] = false;
272				} elseif ( $this->isTagForcedOn( $thisTag ) ) {
273					$res[$thisTag] = true;
274				} else {
275					$res[$thisTag] = in_array( $thisTag, $data );
276				}
277			}
278		}
279
280		return $res;
281	}
282
283	protected function getOOUIModules() {
284		return [ 'mediawiki.widgets.CheckMatrixWidget' ];
285	}
286
287	protected function shouldInfuseOOUI() {
288		return true;
289	}
290}
291