Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
18.98% covered (danger)
18.98%
26 / 137
20.00% covered (danger)
20.00%
3 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangesListFilterGroup
19.12% covered (danger)
19.12%
26 / 136
20.00% covered (danger)
20.00%
3 / 15
802.06
0.00% covered (danger)
0.00%
0 / 1
 __construct
79.31% covered (warning)
79.31%
23 / 29
0.00% covered (danger)
0.00%
0 / 1
8.57
 createFilter
n/a
0 / 0
n/a
0 / 0
0
 setDefault
n/a
0 / 0
n/a
0 / 0
0
 conflictsWith
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 setUnidirectionalConflict
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 getName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPriority
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFilters
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFilter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getJsData
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
42
 getConflictingGroups
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getConflictingFilters
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 anySelected
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 modifyQuery
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 modifyChangesListQuery
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
110
 addOptions
n/a
0 / 0
n/a
0 / 0
0
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\RecentChanges;
8
9use InvalidArgumentException;
10use MediaWiki\Html\FormOptions;
11use MediaWiki\RecentChanges\ChangesListQuery\ChangesListQuery;
12use MediaWiki\SpecialPage\ChangesListSpecialPage;
13use Wikimedia\Rdbms\IReadableDatabase;
14
15/**
16 * Represents a filter group (used on ChangesListSpecialPage and descendants)
17 *
18 * @todo Might want to make a super-class or trait to share behavior (especially re
19 * conflicts) between ChangesListFilter and ChangesListFilterGroup.
20 * What to call it.  FilterStructure?  That would also let me make
21 * setUnidirectionalConflict protected.
22 *
23 * @since 1.29
24 * @ingroup RecentChanges
25 * @author Matthew Flaschen
26 * @method registerFilter($filter)
27 */
28abstract class ChangesListFilterGroup {
29    /**
30     * Name (internal identifier)
31     *
32     * @var string
33     */
34    protected $name;
35
36    /**
37     * i18n key for title
38     *
39     * @var string
40     */
41    protected $title;
42
43    /**
44     * i18n key for header of What's This?
45     *
46     * @var string|null
47     */
48    protected $whatsThisHeader;
49
50    /**
51     * i18n key for body of What's This?
52     *
53     * @var string|null
54     */
55    protected $whatsThisBody;
56
57    /**
58     * URL of What's This? link
59     *
60     * @var string|null
61     */
62    protected $whatsThisUrl;
63
64    /**
65     * i18n key for What's This? link
66     *
67     * @var string|null
68     */
69    protected $whatsThisLinkText;
70
71    /**
72     * Type, from a TYPE constant of a subclass
73     *
74     * @var string
75     */
76    protected $type;
77
78    /**
79     * Priority integer.  Higher values means higher up in the
80     * group list.
81     *
82     * @var int
83     */
84    protected $priority;
85
86    /**
87     * Associative array of filters, as ChangesListFilter objects, with filter name as key
88     *
89     * @var ChangesListFilter[]
90     */
91    protected $filters;
92
93    /**
94     * Whether this group is full coverage.  This means that checking every item in the
95     * group means no changes list (e.g. RecentChanges) entries are filtered out.
96     *
97     * @var bool
98     */
99    protected $isFullCoverage;
100
101    /**
102     * Array of associative arrays with conflict information.  See
103     * setUnidirectionalConflict
104     *
105     * @var array
106     */
107    protected $conflictingGroups = [];
108
109    /**
110     * Array of associative arrays with conflict information.  See
111     * setUnidirectionalConflict
112     *
113     * @var array
114     */
115    protected $conflictingFilters = [];
116
117    private const DEFAULT_PRIORITY = -100;
118
119    private const RESERVED_NAME_CHAR = '_';
120
121    /**
122     * Create a new filter group with the specified configuration
123     *
124     * @param array $groupDefinition Configuration of group
125     * * $groupDefinition['name'] string Group name; use camelCase with no punctuation
126     * * $groupDefinition['title'] string i18n key for title (optional, can be omitted
127     *     only if none of the filters in the group display in the structured UI)
128     * * $groupDefinition['type'] string A type constant from a subclass of this one
129     * * $groupDefinition['priority'] int Priority integer.  Higher value means higher
130     *     up in the group list (optional, defaults to -100).
131     * * $groupDefinition['filters'] array Numeric array of filter definitions, each of which
132     *     is an associative array to be passed to the filter constructor.  However,
133     *     'priority' is optional for the filters.  Any filter that has priority unset
134     *     will be put to the bottom, in the order given.
135     * * $groupDefinition['isFullCoverage'] bool Whether the group is full coverage;
136     *     if true, this means that checking every item in the group means no
137     *     changes list entries are filtered out.
138     * * $groupDefinition['whatsThisHeader'] string i18n key for header of "What's
139     *     This" popup (optional).
140     * * $groupDefinition['whatsThisBody'] string i18n key for body of "What's This"
141     *     popup (optional).
142     * * $groupDefinition['whatsThisUrl'] string URL for main link of "What's This"
143     *     popup (optional).
144     * * $groupDefinition['whatsThisLinkText'] string i18n key of text for main link of
145     *     "What's This" popup (optional).
146     */
147    public function __construct( array $groupDefinition ) {
148        if ( str_contains( $groupDefinition['name'], self::RESERVED_NAME_CHAR ) ) {
149            throw new InvalidArgumentException( 'Group names may not contain \'' .
150                self::RESERVED_NAME_CHAR .
151                '\'.  Use the naming convention: \'camelCase\''
152            );
153        }
154
155        $this->name = $groupDefinition['name'];
156
157        if ( isset( $groupDefinition['title'] ) ) {
158            $this->title = $groupDefinition['title'];
159        }
160
161        if ( isset( $groupDefinition['whatsThisHeader'] ) ) {
162            $this->whatsThisHeader = $groupDefinition['whatsThisHeader'];
163            $this->whatsThisBody = $groupDefinition['whatsThisBody'];
164            $this->whatsThisUrl = $groupDefinition['whatsThisUrl'];
165            $this->whatsThisLinkText = $groupDefinition['whatsThisLinkText'];
166        }
167
168        $this->type = $groupDefinition['type'];
169        $this->priority = $groupDefinition['priority'] ?? self::DEFAULT_PRIORITY;
170
171        $this->isFullCoverage = $groupDefinition['isFullCoverage'];
172
173        $this->filters = [];
174        $lowestSpecifiedPriority = -1;
175        foreach ( $groupDefinition['filters'] as $filterDefinition ) {
176            if ( isset( $filterDefinition['priority'] ) ) {
177                $lowestSpecifiedPriority = min( $lowestSpecifiedPriority, $filterDefinition['priority'] );
178            }
179        }
180
181        // Convenience feature: If you specify a group (and its filters) all in
182        // one place, you don't have to specify priority.  You can just put them
183        // in order.  However, if you later add one (e.g. an extension adds a filter
184        // to a core-defined group), you need to specify it.
185        $autoFillPriority = $lowestSpecifiedPriority - 1;
186        foreach ( $groupDefinition['filters'] as $filterDefinition ) {
187            if ( !isset( $filterDefinition['priority'] ) ) {
188                $filterDefinition['priority'] = $autoFillPriority;
189                $autoFillPriority--;
190            }
191            $filterDefinition['group'] = $this;
192
193            $filter = $this->createFilter( $filterDefinition );
194            $this->registerFilter( $filter );
195        }
196    }
197
198    /**
199     * Creates a filter of the appropriate type for this group, from the definition
200     *
201     * @param array $filterDefinition
202     * @return ChangesListFilter Filter
203     */
204    abstract protected function createFilter( array $filterDefinition );
205
206    /**
207     * Set the default for this filter group.
208     *
209     * @since 1.45
210     * @param bool[]|string $defaultValue
211     */
212    abstract public function setDefault( $defaultValue );
213
214    /**
215     * Marks that the given ChangesListFilterGroup or ChangesListFilter conflicts with this object.
216     *
217     * WARNING: This means there is a conflict when both things are *shown*
218     * (not filtered out), even for the hide-based filters.  So e.g. conflicting with
219     * 'hideanons' means there is a conflict if only anonymous users are *shown*.
220     *
221     * @param ChangesListFilterGroup|ChangesListFilter $other
222     * @param string $globalKey i18n key for top-level conflict message
223     * @param string $forwardKey i18n key for conflict message in this
224     *  direction (when in UI context of $this object)
225     * @param string $backwardKey i18n key for conflict message in reverse
226     *  direction (when in UI context of $other object)
227     */
228    public function conflictsWith( $other, string $globalKey, string $forwardKey, string $backwardKey ) {
229        $this->setUnidirectionalConflict(
230            $other,
231            $globalKey,
232            $forwardKey
233        );
234
235        $other->setUnidirectionalConflict(
236            $this,
237            $globalKey,
238            $backwardKey
239        );
240    }
241
242    /**
243     * Marks that the given ChangesListFilterGroup or ChangesListFilter conflicts with
244     * this object.
245     *
246     * Internal use ONLY.
247     *
248     * @param ChangesListFilterGroup|ChangesListFilter $other
249     * @param string $globalDescription i18n key for top-level conflict message
250     * @param string $contextDescription i18n key for conflict message in this
251     *  direction (when in UI context of $this object)
252     */
253    public function setUnidirectionalConflict( $other, $globalDescription, $contextDescription ) {
254        if ( $other instanceof ChangesListFilterGroup ) {
255            $this->conflictingGroups[] = [
256                'group' => $other->getName(),
257                'groupObject' => $other,
258                'globalDescription' => $globalDescription,
259                'contextDescription' => $contextDescription,
260            ];
261        } elseif ( $other instanceof ChangesListFilter ) {
262            $this->conflictingFilters[] = [
263                'group' => $other->getGroup()->getName(),
264                'filter' => $other->getName(),
265                'filterObject' => $other,
266                'globalDescription' => $globalDescription,
267                'contextDescription' => $contextDescription,
268            ];
269        } else {
270            throw new InvalidArgumentException(
271                'You can only pass in a ChangesListFilterGroup or a ChangesListFilter'
272            );
273        }
274    }
275
276    /**
277     * @return string Internal name
278     */
279    public function getName() {
280        return $this->name;
281    }
282
283    /**
284     * @return string i18n key for title
285     */
286    public function getTitle() {
287        return $this->title;
288    }
289
290    /**
291     * @return string Type (TYPE constant from a subclass)
292     */
293    public function getType() {
294        return $this->type;
295    }
296
297    /**
298     * @return int Priority.  Higher means higher in the group list
299     */
300    public function getPriority() {
301        return $this->priority;
302    }
303
304    /**
305     * @return ChangesListFilter[] Associative array of ChangesListFilter objects, with
306     *   filter name as key
307     */
308    public function getFilters() {
309        return $this->filters;
310    }
311
312    /**
313     * Get filter by name
314     *
315     * @param string $name Filter name
316     * @return ChangesListFilter|null Specified filter, or null if it is not registered
317     */
318    public function getFilter( $name ) {
319        return $this->filters[$name] ?? null;
320    }
321
322    /**
323     * Gets the JS data in the format required by the front-end of the structured UI
324     *
325     * @return array|null Associative array, or null if there are no filters that
326     *  display in the structured UI.  messageKeys is a special top-level value, with
327     *  the value being an array of the message keys to send to the client.
328     */
329    public function getJsData() {
330        $output = [
331            'name' => $this->name,
332            'type' => $this->type,
333            'fullCoverage' => $this->isFullCoverage,
334            'filters' => [],
335            'priority' => $this->priority,
336            'conflicts' => [],
337            'messageKeys' => [ $this->title ]
338        ];
339
340        if ( $this->whatsThisHeader !== null ) {
341            $output['whatsThisHeader'] = $this->whatsThisHeader;
342            $output['whatsThisBody'] = $this->whatsThisBody;
343            $output['whatsThisUrl'] = $this->whatsThisUrl;
344            $output['whatsThisLinkText'] = $this->whatsThisLinkText;
345
346            array_push(
347                $output['messageKeys'],
348                $output['whatsThisHeader'],
349                $output['whatsThisBody'],
350                $output['whatsThisLinkText']
351            );
352        }
353
354        usort( $this->filters, static function ( ChangesListFilter $a, ChangesListFilter $b ) {
355            return $b->getPriority() <=> $a->getPriority();
356        } );
357
358        foreach ( $this->filters as $filter ) {
359            if ( $filter->displaysOnStructuredUi() ) {
360                $filterData = $filter->getJsData();
361                $output['messageKeys'] = array_merge(
362                    $output['messageKeys'],
363                    $filterData['messageKeys']
364                );
365                unset( $filterData['messageKeys'] );
366                $output['filters'][] = $filterData;
367            }
368        }
369
370        if ( count( $output['filters'] ) === 0 ) {
371            return null;
372        }
373
374        $output['title'] = $this->title;
375
376        $conflicts = array_merge(
377            $this->conflictingGroups,
378            $this->conflictingFilters
379        );
380
381        foreach ( $conflicts as $conflictInfo ) {
382            unset( $conflictInfo['filterObject'] );
383            unset( $conflictInfo['groupObject'] );
384            $output['conflicts'][] = $conflictInfo;
385            array_push(
386                $output['messageKeys'],
387                $conflictInfo['globalDescription'],
388                $conflictInfo['contextDescription']
389            );
390        }
391
392        return $output;
393    }
394
395    /**
396     * Get groups conflicting with this filter group
397     *
398     * @return ChangesListFilterGroup[]
399     */
400    public function getConflictingGroups() {
401        return array_column( $this->conflictingGroups, 'groupObject' );
402    }
403
404    /**
405     * Get filters conflicting with this filter group
406     *
407     * @return ChangesListFilter[]
408     */
409    public function getConflictingFilters() {
410        return array_column( $this->conflictingFilters, 'filterObject' );
411    }
412
413    /**
414     * Check if any filter in this group is selected
415     *
416     * @param FormOptions $opts
417     * @return bool
418     */
419    public function anySelected( FormOptions $opts ) {
420        return (bool)count( array_filter(
421            $this->getFilters(),
422            static function ( ChangesListFilter $filter ) use ( $opts ) {
423                return $filter->isSelected( $opts );
424            }
425        ) );
426    }
427
428    /**
429     * Modifies the query to include the filter group (legacy interface).
430     *
431     * The modification is only done if the filter group is in effect.  This means that
432     * one or more valid and allowed filters were selected.
433     *
434     * @param IReadableDatabase $dbr Database, for addQuotes, makeList, and similar
435     * @param ChangesListSpecialPage $specialPage Current special page
436     * @param array &$tables Array of tables; see IDatabase::select $table
437     * @param array &$fields Array of fields; see IDatabase::select $vars
438     * @param array &$conds Array of conditions; see IDatabase::select $conds
439     * @param array &$query_options Array of query options; see IDatabase::select $options
440     * @param array &$join_conds Array of join conditions; see IDatabase::select $join_conds
441     * @param FormOptions $opts Wrapper for the current request options and their defaults
442     * @param bool $isStructuredFiltersEnabled True if the Structured UI is currently enabled
443     */
444    public function modifyQuery( IReadableDatabase $dbr, ChangesListSpecialPage $specialPage,
445        &$tables, &$fields, &$conds, &$query_options, &$join_conds,
446        FormOptions $opts, $isStructuredFiltersEnabled
447    ) {
448    }
449
450    /**
451     * Modifies the query to include the filter group.
452     *
453     * @param ChangesListQuery $query
454     * @param FormOptions $opts
455     * @param bool $isStructuredFiltersEnabled
456     */
457    public function modifyChangesListQuery(
458        ChangesListQuery $query,
459        FormOptions $opts,
460        $isStructuredFiltersEnabled
461    ) {
462        foreach ( $this->getFilters() as $filter ) {
463            $action = $filter->getAction();
464            if ( $action !== null ) {
465                if ( $filter->isActive( $opts, $isStructuredFiltersEnabled ) ) {
466                    if ( is_array( $action[0] ) ) {
467                        foreach ( $action as $singleAction ) {
468                            // @phan-suppress-next-line PhanParamTooFewUnpack
469                            $query->applyAction( ...$singleAction );
470                        }
471                    } else {
472                        // @phan-suppress-next-line PhanParamTooFewUnpack
473                        $query->applyAction( ...$action );
474                    }
475                }
476                $highlightAction = $filter->getHighlightAction();
477                if ( $filter->getCssClass() !== null && $highlightAction ) {
478                    $name = $this->getName() . '/' . $filter->getName();
479                    if ( is_array( $highlightAction[0] ) ) {
480                        foreach ( $highlightAction as $singleAction ) {
481                            // @phan-suppress-next-line PhanParamTooFewUnpack
482                            $query->highlight( $name, ...$singleAction );
483                        }
484                    } else {
485                        // @phan-suppress-next-line PhanParamTooFewUnpack
486                        $query->highlight( $name, ...$highlightAction );
487                    }
488                }
489            }
490        }
491    }
492
493    /**
494     * Add all the options represented by this filter group to $opts
495     *
496     * @param FormOptions $opts
497     * @param bool $allowDefaults
498     * @param bool $isStructuredFiltersEnabled
499     */
500    abstract public function addOptions( FormOptions $opts, $allowDefaults,
501        $isStructuredFiltersEnabled );
502}
503
504/** @deprecated class alias since 1.44 */
505class_alias( ChangesListFilterGroup::class, 'ChangesListFilterGroup' );