Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 197 |
|
0.00% |
0 / 18 |
CRAP | |
0.00% |
0 / 1 |
SearchFormWidget | |
0.00% |
0 / 197 |
|
0.00% |
0 / 18 |
1406 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
render | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
20 | |||
shortDialogHtml | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
30 | |||
profileTabsHtml | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
30 | |||
startsWithImage | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
makeSearchLink | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
2 | |||
optionsHtml | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
powerSearchBox | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
getHookContainer | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getHookRunner | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
searchFilterSeparatorHtml | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
createPowerSearchRememberCheckBoxHtml | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
2 | |||
createNamespaceToggleBoxHtml | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
2 | |||
createSearchBoxHeadHtml | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
createNamespaceCheckbox | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
2 | |||
getNamespaceDisplayName | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
createCheckboxesForEverySearchableNamespace | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
20 | |||
createHiddenOptsHtml | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Search\SearchWidgets; |
4 | |
5 | use ILanguageConverter; |
6 | use MediaWiki\Config\ServiceOptions; |
7 | use MediaWiki\HookContainer\HookContainer; |
8 | use MediaWiki\HookContainer\HookRunner; |
9 | use MediaWiki\Html\Html; |
10 | use MediaWiki\MainConfigNames; |
11 | use MediaWiki\Specials\SpecialSearch; |
12 | use MediaWiki\Title\NamespaceInfo; |
13 | use MediaWiki\Widget\SearchInputWidget; |
14 | use OOUI\ActionFieldLayout; |
15 | use OOUI\ButtonInputWidget; |
16 | use OOUI\CheckboxInputWidget; |
17 | use OOUI\FieldLayout; |
18 | use SearchEngineConfig; |
19 | use Xml; |
20 | |
21 | class SearchFormWidget { |
22 | /** @internal For use by SpecialSearch only */ |
23 | public const CONSTRUCTOR_OPTIONS = [ |
24 | MainConfigNames::CapitalLinks, |
25 | ]; |
26 | private ServiceOptions $options; |
27 | /** @var SpecialSearch */ |
28 | protected $specialSearch; |
29 | /** @var SearchEngineConfig */ |
30 | protected $searchConfig; |
31 | /** @var array */ |
32 | protected $profiles; |
33 | /** @var HookContainer */ |
34 | private $hookContainer; |
35 | /** @var HookRunner */ |
36 | private $hookRunner; |
37 | /** @var ILanguageConverter */ |
38 | private $languageConverter; |
39 | /** @var NamespaceInfo */ |
40 | private $namespaceInfo; |
41 | |
42 | /** |
43 | * @param ServiceOptions $options |
44 | * @param SpecialSearch $specialSearch |
45 | * @param SearchEngineConfig $searchConfig |
46 | * @param HookContainer $hookContainer |
47 | * @param ILanguageConverter $languageConverter |
48 | * @param NamespaceInfo $namespaceInfo |
49 | * @param array $profiles |
50 | */ |
51 | public function __construct( |
52 | ServiceOptions $options, |
53 | SpecialSearch $specialSearch, |
54 | SearchEngineConfig $searchConfig, |
55 | HookContainer $hookContainer, |
56 | ILanguageConverter $languageConverter, |
57 | NamespaceInfo $namespaceInfo, |
58 | array $profiles |
59 | ) { |
60 | $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
61 | $this->options = $options; |
62 | $this->specialSearch = $specialSearch; |
63 | $this->searchConfig = $searchConfig; |
64 | $this->hookContainer = $hookContainer; |
65 | $this->hookRunner = new HookRunner( $hookContainer ); |
66 | $this->languageConverter = $languageConverter; |
67 | $this->namespaceInfo = $namespaceInfo; |
68 | $this->profiles = $profiles; |
69 | } |
70 | |
71 | /** |
72 | * @param string $profile The current search profile |
73 | * @param string $term The current search term |
74 | * @param int $numResults The number of results shown |
75 | * @param int $totalResults The total estimated results found |
76 | * @param int $offset Current offset in search results |
77 | * @param bool $isPowerSearch Is the 'advanced' section open? |
78 | * @param array $options Widget options |
79 | * @return string HTML |
80 | */ |
81 | public function render( |
82 | $profile, |
83 | $term, |
84 | $numResults, |
85 | $totalResults, |
86 | $offset, |
87 | $isPowerSearch, |
88 | array $options = [] |
89 | ) { |
90 | $user = $this->specialSearch->getUser(); |
91 | |
92 | $form = Xml::openElement( |
93 | 'form', |
94 | [ |
95 | 'id' => $isPowerSearch ? 'powersearch' : 'search', |
96 | // T151903: default to POST in case JS is disabled |
97 | 'method' => ( $isPowerSearch && $user->isRegistered() ) ? 'post' : 'get', |
98 | 'action' => wfScript(), |
99 | ] |
100 | ) . |
101 | Html::rawElement( |
102 | 'div', |
103 | [ 'id' => 'mw-search-top-table' ], |
104 | $this->shortDialogHtml( $profile, $term, $numResults, $totalResults, $offset, $options ) |
105 | ) . |
106 | Html::rawElement( 'div', [ 'class' => 'mw-search-visualclear' ] ) . |
107 | Html::rawElement( |
108 | 'div', |
109 | [ 'class' => 'mw-search-profile-tabs' ], |
110 | $this->profileTabsHtml( $profile, $term ) . |
111 | Html::rawElement( 'div', [ 'style' => 'clear:both' ] ) |
112 | ) . |
113 | $this->optionsHtml( $term, $isPowerSearch, $profile ) . |
114 | Xml::closeElement( 'form' ); |
115 | |
116 | return Html::rawElement( 'div', [ 'class' => 'mw-search-form-wrapper' ], $form ); |
117 | } |
118 | |
119 | /** |
120 | * @param string $profile The current search profile |
121 | * @param string $term The current search term |
122 | * @param int $numResults The number of results shown |
123 | * @param int $totalResults The total estimated results found |
124 | * @param int $offset Current offset in search results |
125 | * @param array $options Widget options |
126 | * @return string HTML |
127 | */ |
128 | protected function shortDialogHtml( |
129 | $profile, |
130 | $term, |
131 | $numResults, |
132 | $totalResults, |
133 | $offset, |
134 | array $options = [] |
135 | ) { |
136 | $autoCapHint = $this->options->get( MainConfigNames::CapitalLinks ); |
137 | |
138 | $searchWidget = new SearchInputWidget( $options + [ |
139 | 'id' => 'searchText', |
140 | 'name' => 'search', |
141 | 'autofocus' => trim( $term ) === '', |
142 | 'title' => $this->specialSearch->msg( 'searchsuggest-search' )->text(), |
143 | 'value' => $term, |
144 | 'dataLocation' => 'content', |
145 | 'infusable' => true, |
146 | 'autocapitalize' => $autoCapHint ? 'sentences' : 'none', |
147 | ] ); |
148 | |
149 | $html = new ActionFieldLayout( $searchWidget, new ButtonInputWidget( [ |
150 | 'type' => 'submit', |
151 | 'label' => $this->specialSearch->msg( 'searchbutton' )->text(), |
152 | 'flags' => [ 'progressive', 'primary' ], |
153 | ] ), [ |
154 | 'align' => 'top', |
155 | ] ); |
156 | |
157 | if ( $this->specialSearch->getPrefix() !== '' ) { |
158 | $html .= Html::hidden( 'prefix', $this->specialSearch->getPrefix() ); |
159 | } |
160 | |
161 | if ( $totalResults > 0 && $offset < $totalResults ) { |
162 | $html .= Xml::tags( |
163 | 'div', |
164 | [ |
165 | 'class' => 'results-info', |
166 | 'data-mw-num-results-offset' => $offset, |
167 | 'data-mw-num-results-total' => $totalResults |
168 | ], |
169 | $this->specialSearch->msg( 'search-showingresults' ) |
170 | ->numParams( $offset + 1, $offset + $numResults, $totalResults ) |
171 | ->numParams( $numResults ) |
172 | ->parse() |
173 | ); |
174 | } |
175 | |
176 | $html .= |
177 | Html::hidden( 'title', $this->specialSearch->getPageTitle()->getPrefixedText() ) . |
178 | Html::hidden( 'profile', $profile ) . |
179 | Html::hidden( 'fulltext', '1' ); |
180 | |
181 | return $html; |
182 | } |
183 | |
184 | /** |
185 | * Generates HTML for the list of available search profiles. |
186 | * |
187 | * @param string $profile The currently selected profile |
188 | * @param string $term The user provided search terms |
189 | * @return string HTML |
190 | */ |
191 | protected function profileTabsHtml( $profile, $term ) { |
192 | $bareterm = $this->startsWithImage( $term ) |
193 | ? substr( $term, strpos( $term, ':' ) + 1 ) |
194 | : $term; |
195 | $lang = $this->specialSearch->getLanguage(); |
196 | $items = []; |
197 | foreach ( $this->profiles as $id => $profileConfig ) { |
198 | $profileConfig['parameters']['profile'] = $id; |
199 | $tooltipParam = isset( $profileConfig['namespace-messages'] ) |
200 | ? $lang->commaList( $profileConfig['namespace-messages'] ) |
201 | : null; |
202 | $items[] = Xml::tags( |
203 | 'li', |
204 | [ 'class' => $profile === $id ? 'current' : 'normal' ], |
205 | $this->makeSearchLink( |
206 | $bareterm, |
207 | $this->specialSearch->msg( $profileConfig['message'] )->text(), |
208 | $this->specialSearch->msg( $profileConfig['tooltip'], $tooltipParam )->text(), |
209 | $profileConfig['parameters'] |
210 | ) |
211 | ); |
212 | } |
213 | |
214 | return Html::rawElement( |
215 | 'div', |
216 | [ 'class' => 'search-types' ], |
217 | Html::rawElement( 'ul', [], implode( '', $items ) ) |
218 | ); |
219 | } |
220 | |
221 | /** |
222 | * Check if query starts with image: prefix |
223 | * |
224 | * @param string $term The string to check |
225 | * @return bool |
226 | */ |
227 | protected function startsWithImage( $term ) { |
228 | $parts = explode( ':', $term ); |
229 | return count( $parts ) > 1 |
230 | && $this->specialSearch->getContentLanguage()->getNsIndex( $parts[0] ) === NS_FILE; |
231 | } |
232 | |
233 | /** |
234 | * Make a search link with some target namespaces |
235 | * |
236 | * @param string $term The term to search for |
237 | * @param string $label Link's text |
238 | * @param string $tooltip Link's tooltip |
239 | * @param array $params Query string parameters |
240 | * @return string HTML fragment |
241 | */ |
242 | protected function makeSearchLink( $term, $label, $tooltip, array $params = [] ) { |
243 | $params += [ |
244 | 'search' => $term, |
245 | 'fulltext' => 1, |
246 | ]; |
247 | |
248 | return Xml::element( |
249 | 'a', |
250 | [ |
251 | 'href' => $this->specialSearch->getPageTitle()->getLocalURL( $params ), |
252 | 'title' => $tooltip, |
253 | ], |
254 | $label |
255 | ); |
256 | } |
257 | |
258 | /** |
259 | * Generates HTML for advanced options available with the currently |
260 | * selected search profile. |
261 | * |
262 | * @param string $term User provided search term |
263 | * @param bool $isPowerSearch Is the advanced search profile enabled? |
264 | * @param string $profile The current search profile |
265 | * @return string HTML |
266 | */ |
267 | protected function optionsHtml( $term, $isPowerSearch, $profile ) { |
268 | if ( $isPowerSearch ) { |
269 | $html = $this->powerSearchBox( $term, [] ); |
270 | } else { |
271 | $html = ''; |
272 | $this->getHookRunner()->onSpecialSearchProfileForm( |
273 | $this->specialSearch, $html, $profile, $term, [] |
274 | ); |
275 | } |
276 | |
277 | return $html; |
278 | } |
279 | |
280 | /** |
281 | * @param string $term The current search term |
282 | * @param array $opts Additional key/value pairs that will be submitted |
283 | * with the generated form. |
284 | * @return string HTML |
285 | */ |
286 | protected function powerSearchBox( $term, array $opts ) { |
287 | $namespaceTables = |
288 | [ 'namespaceTables' => $this->createCheckboxesForEverySearchableNamespace() ]; |
289 | $this->getHookRunner()->onSpecialSearchPowerBox( $namespaceTables, $term, $opts ); |
290 | |
291 | $outputHtml = ''; |
292 | $outputHtml .= $this->createSearchBoxHeadHtml(); |
293 | $outputHtml .= $this->searchFilterSeparatorHtml(); |
294 | $outputHtml .= implode( $this->searchFilterSeparatorHtml(), $namespaceTables ); |
295 | $outputHtml .= $this->createHiddenOptsHtml( $opts ); |
296 | |
297 | // Stuff to feed SpecialSearch::saveNamespaces() |
298 | if ( $this->specialSearch->getUser()->isRegistered() ) { |
299 | $outputHtml .= $this->searchFilterSeparatorHtml(); |
300 | $outputHtml .= $this->createPowerSearchRememberCheckBoxHtml(); |
301 | } |
302 | |
303 | return Html::rawElement( 'fieldset', [ 'id' => 'mw-searchoptions' ], $outputHtml ); |
304 | } |
305 | |
306 | /** |
307 | * @return HookContainer |
308 | * @since 1.35 |
309 | */ |
310 | protected function getHookContainer() { |
311 | return $this->hookContainer; |
312 | } |
313 | |
314 | /** |
315 | * @return HookRunner |
316 | * @since 1.35 |
317 | * @internal This is for use by core only. Hook interfaces may be removed |
318 | * without notice. |
319 | */ |
320 | protected function getHookRunner() { |
321 | return $this->hookRunner; |
322 | } |
323 | |
324 | private function searchFilterSeparatorHtml(): string { |
325 | return Html::rawElement( 'div', [ 'class' => 'divider' ], '' ); |
326 | } |
327 | |
328 | private function createPowerSearchRememberCheckBoxHtml(): string { |
329 | return new FieldLayout( |
330 | new CheckboxInputWidget( [ |
331 | 'name' => 'nsRemember', |
332 | 'selected' => false, |
333 | 'inputId' => 'mw-search-powersearch-remember', |
334 | // The token goes here rather than in a hidden field so it |
335 | // is only sent when necessary (not every form submission) |
336 | 'value' => $this->specialSearch->getContext()->getCsrfTokenSet() |
337 | ->getToken( 'searchnamespace' ) |
338 | ] ), |
339 | [ |
340 | 'label' => $this->specialSearch->msg( 'powersearch-remember' )->text(), |
341 | 'align' => 'inline' |
342 | ] |
343 | ); |
344 | } |
345 | |
346 | private function createNamespaceToggleBoxHtml(): string { |
347 | $toggleBoxContents = ""; |
348 | $toggleBoxContents .= Html::rawElement( 'label', [], |
349 | $this->specialSearch->msg( 'powersearch-togglelabel' )->escaped() ); |
350 | $toggleBoxContents .= Html::rawElement( 'input', [ |
351 | 'type' => 'button', |
352 | 'id' => 'mw-search-toggleall', |
353 | 'value' => $this->specialSearch->msg( 'powersearch-toggleall' )->text(), |
354 | ] ); |
355 | $toggleBoxContents .= Html::rawElement( 'input', [ |
356 | 'type' => 'button', |
357 | 'id' => 'mw-search-togglenone', |
358 | 'value' => $this->specialSearch->msg( 'powersearch-togglenone' )->text(), |
359 | ] ); |
360 | |
361 | // Handled by JavaScript if available |
362 | return Html::rawElement( 'div', [ 'id' => 'mw-search-togglebox' ], $toggleBoxContents ); |
363 | } |
364 | |
365 | private function createSearchBoxHeadHtml(): string { |
366 | return Html::rawElement( 'legend', [], |
367 | $this->specialSearch->msg( 'powersearch-legend' )->escaped() ) . |
368 | Html::rawElement( 'h4', [], $this->specialSearch->msg( 'powersearch-ns' )->parse() ) . |
369 | $this->createNamespaceToggleBoxHtml(); |
370 | } |
371 | |
372 | private function createNamespaceCheckbox( int $namespace, array $activeNamespaces ): string { |
373 | $namespaceDisplayName = $this->getNamespaceDisplayName( $namespace ); |
374 | |
375 | return new FieldLayout( |
376 | new CheckboxInputWidget( [ |
377 | 'name' => "ns{$namespace}", |
378 | 'selected' => in_array( $namespace, $activeNamespaces ), |
379 | 'inputId' => "mw-search-ns{$namespace}", |
380 | 'value' => '1' |
381 | ] ), |
382 | [ |
383 | 'label' => $namespaceDisplayName, |
384 | 'align' => 'inline' |
385 | ] |
386 | ); |
387 | } |
388 | |
389 | private function getNamespaceDisplayName( int $namespace ): string { |
390 | $name = $this->languageConverter->convertNamespace( $namespace ); |
391 | if ( $name === '' ) { |
392 | $name = $this->specialSearch->msg( 'blanknamespace' )->text(); |
393 | } |
394 | |
395 | return $name; |
396 | } |
397 | |
398 | private function createCheckboxesForEverySearchableNamespace(): string { |
399 | $rows = []; |
400 | $activeNamespaces = $this->specialSearch->getNamespaces(); |
401 | foreach ( $this->searchConfig->searchableNamespaces() as $namespace => $name ) { |
402 | $subject = $this->namespaceInfo->getSubject( $namespace ); |
403 | if ( !isset( $rows[$subject] ) ) { |
404 | $rows[$subject] = ""; |
405 | } |
406 | |
407 | $rows[$subject] .= $this->createNamespaceCheckbox( $namespace, $activeNamespaces ); |
408 | } |
409 | |
410 | $namespaceTables = []; |
411 | foreach ( array_chunk( $rows, 4 ) as $chunk ) { |
412 | $namespaceTables[] = implode( '', $chunk ); |
413 | } |
414 | |
415 | return '<div class="checkbox-container">' . |
416 | implode( '</div><div class="checkbox-container">', $namespaceTables ) . '</div>'; |
417 | } |
418 | |
419 | private function createHiddenOptsHtml( array $opts ): string { |
420 | $hidden = ''; |
421 | foreach ( $opts as $key => $value ) { |
422 | $hidden .= Html::hidden( $key, $value ); |
423 | } |
424 | |
425 | return $hidden; |
426 | } |
427 | } |