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