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
1482
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
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 / 40
0.00% covered (danger)
0.00%
0 / 1
56
 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 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 createSearchBoxHeadHtml
0.00% covered (danger)
0.00%
0 / 4
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\Config\ServiceOptions;
6use MediaWiki\HookContainer\HookContainer;
7use MediaWiki\HookContainer\HookRunner;
8use MediaWiki\Html\Html;
9use MediaWiki\Language\ILanguageConverter;
10use MediaWiki\MainConfigNames;
11use MediaWiki\Specials\SpecialSearch;
12use MediaWiki\Title\NamespaceInfo;
13use MediaWiki\Widget\SearchInputWidget;
14use MediaWiki\Xml\Xml;
15use OOUI\ActionFieldLayout;
16use OOUI\ButtonInputWidget;
17use OOUI\CheckboxInputWidget;
18use OOUI\FieldLayout;
19use SearchEngineConfig;
20
21class 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}