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