Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
26.19% covered (danger)
26.19%
33 / 126
20.83% covered (danger)
20.83%
5 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangesListFilter
26.40% covered (danger)
26.40%
33 / 125
20.83% covered (danger)
20.83%
5 / 24
889.62
0.00% covered (danger)
0.00%
0 / 1
 __construct
73.91% covered (warning)
73.91%
17 / 23
0.00% covered (danger)
0.00%
0 / 1
6.64
 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
 setAsSupersetOf
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGroup
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLabel
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 displaysOnUnstructuredUi
n/a
0 / 0
n/a
0 / 0
0
 displaysOnStructuredUi
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isFeatureAvailableOnStructuredUi
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
 getCssClass
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 applyCssClassIfNeeded
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getJsData
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
6
 isSelected
n/a
0 / 0
n/a
0 / 0
0
 isActive
n/a
0 / 0
n/a
0 / 0
0
 getAction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHighlightAction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 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
 activelyInConflictWithGroup
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
42
 hasConflictWithGroup
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 activelyInConflictWithFilter
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 hasConflictWithFilter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSiblings
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 setDefaultHighlightColor
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\RecentChanges;
8
9use InvalidArgumentException;
10use MediaWiki\Context\IContextSource;
11use MediaWiki\Html\FormOptions;
12use MediaWiki\RecentChanges\ChangesListQuery\ChangesListQuery;
13
14/**
15 * Represents a filter (used on ChangesListSpecialPage and descendants)
16 *
17 * @since 1.29
18 * @ingroup RecentChanges
19 * @author Matthew Flaschen
20 */
21abstract class ChangesListFilter {
22    /**
23     * Filter name
24     *
25     * @var string
26     */
27    protected $name;
28
29    /**
30     * CSS class suffix used for attribution, e.g. 'bot'.
31     *
32     * In this example, if bot actions are included in the result set, this CSS class
33     * will then be included in all bot-flagged actions.
34     *
35     * @var string|null
36     */
37    protected $cssClassSuffix;
38
39    /**
40     * Callable that returns true if and only if a row is attributed to this filter
41     *
42     * @var callable|null
43     */
44    protected $isRowApplicableCallable;
45
46    /**
47     * Group.  ChangesListFilterGroup this belongs to
48     *
49     * @var ChangesListFilterGroup
50     */
51    protected $group;
52
53    /**
54     * i18n key of label for structured UI
55     *
56     * @var string
57     */
58    protected $label;
59
60    /**
61     * i18n key of description for structured UI
62     *
63     * @var string
64     */
65    protected $description;
66
67    /**
68     * Array of associative arrays with conflict information.  See
69     * setUnidirectionalConflict
70     *
71     * @var array
72     */
73    protected $conflictingGroups = [];
74
75    /**
76     * Array of associative arrays with conflict information.  See
77     * setUnidirectionalConflict
78     *
79     * @var array
80     */
81    protected $conflictingFilters = [];
82
83    /**
84     * Array of associative arrays with subset information
85     *
86     * @var array
87     */
88    protected $subsetFilters = [];
89
90    /**
91     * Priority integer.  Higher value means higher up in the group's filter list.
92     *
93     * @var int
94     */
95    protected $priority;
96
97    /**
98     * @var string
99     */
100    protected $defaultHighlightColor;
101
102    /** @var array|null */
103    private $action = null;
104    /** @var array|null */
105    private $highlight = null;
106
107    private const RESERVED_NAME_CHAR = '_';
108
109    /**
110     * Creates a new filter with the specified configuration, and registers it to the
111     * specified group.
112     *
113     * It infers which UI (it can be either or both) to display the filter on based on
114     * which messages are provided.
115     *
116     * If 'label' is provided, it will be displayed on the structured UI.  Thus,
117     * 'label', 'description', and sub-class parameters are optional depending on which
118     * UI it's for.
119     *
120     * @param array $filterDefinition ChangesListFilter definition
121     * * $filterDefinition['name'] string Name of filter; use lowercase with no
122     *     punctuation
123     * * $filterDefinition['cssClassSuffix'] string CSS class suffix, used to mark
124     *     that a particular row belongs to this filter (when a row is included by the
125     *     filter) (optional)
126     * * $filterDefinition['isRowApplicableCallable'] callable Callable taking two parameters, the
127     *     IContextSource, and the RecentChange object for the row, and returning true if
128     *     the row is attributed to this filter.  The above CSS class will then be
129     *     automatically added (optional, required if cssClassSuffix is used).
130     * * $filterDefinition['action'] array Array of parameters to pass to
131     *     ChangesListQuery::filter(). The first element is the verb "require" or "exclude", the
132     *     second element is the module name, and the optional third element is the value.
133     *     Or an array of such actions. This supersedes isRowApplicableCallable.
134     * * $filterDefinition['highlight'] array An action in the same format as
135     *    $filterDefinition['action'] to be used for highlighting. Falls back to 'action'
136     *    if not set.
137     * * $filterDefinition['group'] ChangesListFilterGroup Group.  Filter group this
138     *     belongs to.
139     * * $filterDefinition['label'] string i18n key of label for structured UI.
140     * * $filterDefinition['description'] string i18n key of description for structured
141     *     UI.
142     * * $filterDefinition['priority'] int Priority integer.  Higher value means higher
143     *     up in the group's filter list.
144     * @phpcs:ignore Generic.Files.LineLength
145     * @phan-param array{name:string,cssClassSuffix?:string,isRowApplicableCallable?:callable,group:ChangesListFilterGroup,label:string,description:string,priority:int} $filterDefinition
146     */
147    public function __construct( array $filterDefinition ) {
148        if ( isset( $filterDefinition['group'] ) ) {
149            $this->group = $filterDefinition['group'];
150        } else {
151            throw new InvalidArgumentException( 'You must use \'group\' to specify the ' .
152                'ChangesListFilterGroup this filter belongs to' );
153        }
154
155        if ( str_contains( $filterDefinition['name'], self::RESERVED_NAME_CHAR ) ) {
156            throw new InvalidArgumentException( 'Filter names may not contain \'' .
157                self::RESERVED_NAME_CHAR .
158                '\'.  Use the naming convention: \'lowercase\''
159            );
160        }
161
162        if ( $this->group->getFilter( $filterDefinition['name'] ) ) {
163            throw new InvalidArgumentException( 'Two filters in a group cannot have the ' .
164                "same name: '{$filterDefinition['name']}'" );
165        }
166
167        $this->name = $filterDefinition['name'];
168
169        if ( isset( $filterDefinition['cssClassSuffix'] ) ) {
170            $this->cssClassSuffix = $filterDefinition['cssClassSuffix'];
171            $this->isRowApplicableCallable = $filterDefinition['isRowApplicableCallable'] ?? null;
172        }
173
174        if ( isset( $filterDefinition['label'] ) ) {
175            $this->label = $filterDefinition['label'];
176            $this->description = $filterDefinition['description'];
177        }
178
179        $this->priority = $filterDefinition['priority'];
180        $this->action = $filterDefinition['action'] ?? null;
181        $this->highlight = $filterDefinition['highlight'] ?? null;
182
183        $this->group->registerFilter( $this );
184    }
185
186    /**
187     * Marks that the given ChangesListFilterGroup or ChangesListFilter conflicts with this object.
188     *
189     * WARNING: This means there is a conflict when both things are *shown*
190     * (not filtered out), even for the hide-based filters.  So e.g. conflicting with
191     * 'hideanons' means there is a conflict if only anonymous users are *shown*.
192     *
193     * @param ChangesListFilterGroup|ChangesListFilter $other
194     * @param string $globalKey i18n key for top-level conflict message
195     * @param string $forwardKey i18n key for conflict message in this
196     *  direction (when in UI context of $this object)
197     * @param string $backwardKey i18n key for conflict message in reverse
198     *  direction (when in UI context of $other object)
199     */
200    public function conflictsWith( $other, string $globalKey, string $forwardKey, string $backwardKey ) {
201        $this->setUnidirectionalConflict(
202            $other,
203            $globalKey,
204            $forwardKey
205        );
206
207        $other->setUnidirectionalConflict(
208            $this,
209            $globalKey,
210            $backwardKey
211        );
212    }
213
214    /**
215     * Marks that the given ChangesListFilterGroup or ChangesListFilter conflicts with
216     * this object.
217     *
218     * Internal use ONLY.
219     *
220     * @param ChangesListFilterGroup|ChangesListFilter $other
221     * @param string $globalDescription i18n key for top-level conflict message
222     * @param string $contextDescription i18n key for conflict message in this
223     *  direction (when in UI context of $this object)
224     */
225    public function setUnidirectionalConflict( $other, $globalDescription, $contextDescription ) {
226        if ( $other instanceof ChangesListFilterGroup ) {
227            $this->conflictingGroups[] = [
228                'group' => $other->getName(),
229                'groupObject' => $other,
230                'globalDescription' => $globalDescription,
231                'contextDescription' => $contextDescription,
232            ];
233        } elseif ( $other instanceof ChangesListFilter ) {
234            $this->conflictingFilters[] = [
235                'group' => $other->getGroup()->getName(),
236                'filter' => $other->getName(),
237                'filterObject' => $other,
238                'globalDescription' => $globalDescription,
239                'contextDescription' => $contextDescription,
240            ];
241        } else {
242            throw new InvalidArgumentException(
243                'You can only pass in a ChangesListFilterGroup or a ChangesListFilter'
244            );
245        }
246    }
247
248    /**
249     * Marks that the current instance is (also) a superset of the filter passed in.
250     * This can be called more than once.
251     *
252     * This means that anything in the results for the other filter is also in the
253     * results for this one.
254     *
255     * @param ChangesListFilter $other The filter the current instance is a superset of
256     */
257    public function setAsSupersetOf( ChangesListFilter $other ) {
258        if ( $other->getGroup() !== $this->getGroup() ) {
259            throw new InvalidArgumentException( 'Supersets can only be defined for filters in the same group' );
260        }
261
262        $this->subsetFilters[] = [
263            // It's always the same group, but this makes the representation
264            // more consistent with conflicts.
265            'group' => $other->getGroup()->getName(),
266            'filter' => $other->getName(),
267        ];
268    }
269
270    /**
271     * @return string Name, e.g. hideanons
272     */
273    public function getName() {
274        return $this->name;
275    }
276
277    /**
278     * @return ChangesListFilterGroup Group this belongs to
279     */
280    public function getGroup() {
281        return $this->group;
282    }
283
284    /**
285     * @return string i18n key of label for structured UI
286     */
287    public function getLabel() {
288        return $this->label;
289    }
290
291    /**
292     * @return string i18n key of description for structured UI
293     */
294    public function getDescription() {
295        return $this->description;
296    }
297
298    /**
299     * Checks whether the filter should display on the unstructured UI
300     *
301     * @return bool Whether to display
302     */
303    abstract public function displaysOnUnstructuredUi();
304
305    /**
306     * Checks whether the filter should display on the structured UI
307     * This refers to the exact filter.  See also isFeatureAvailableOnStructuredUi.
308     *
309     * @return bool Whether to display
310     */
311    public function displaysOnStructuredUi() {
312        return $this->label !== null;
313    }
314
315    /**
316     * Checks whether an equivalent feature for this filter is available on the
317     * structured UI.
318     *
319     * This can either be the exact filter, or a new filter that replaces it.
320     * @return bool
321     */
322    public function isFeatureAvailableOnStructuredUi() {
323        return $this->displaysOnStructuredUi();
324    }
325
326    /**
327     * @return int Priority.  Higher value means higher up in the group list
328     */
329    public function getPriority() {
330        return $this->priority;
331    }
332
333    /**
334     * Gets the CSS class
335     *
336     * @return string|null CSS class, or null if not defined
337     */
338    public function getCssClass() {
339        if ( $this->cssClassSuffix !== null ) {
340            return ChangesList::CSS_CLASS_PREFIX . $this->cssClassSuffix;
341        } else {
342            return null;
343        }
344    }
345
346    /**
347     * Add CSS class if needed
348     *
349     * @param IContextSource $ctx Context source
350     * @param RecentChange $rc Recent changes object
351     * @param array &$classes Non-associative array of CSS class names; appended to if needed
352     */
353    public function applyCssClassIfNeeded( IContextSource $ctx, RecentChange $rc, array &$classes ) {
354        if ( $this->isRowApplicableCallable === null ) {
355            return;
356        }
357
358        if ( ( $this->isRowApplicableCallable )( $ctx, $rc ) ) {
359            $classes[] = $this->getCssClass();
360        }
361    }
362
363    /**
364     * Gets the JS data required by the front-end of the structured UI
365     *
366     * @return array Associative array Data required by the front-end.  messageKeys is
367     *  a special top-level value, with the value being an array of the message keys to
368     *  send to the client.
369     */
370    public function getJsData() {
371        $output = [
372            'name' => $this->getName(),
373            'label' => $this->getLabel(),
374            'description' => $this->getDescription(),
375            'cssClass' => $this->getCssClass(),
376            'priority' => $this->priority,
377            'subset' => $this->subsetFilters,
378            'conflicts' => [],
379            'defaultHighlightColor' => $this->defaultHighlightColor
380        ];
381
382        $output['messageKeys'] = [
383            $this->getLabel(),
384            $this->getDescription(),
385        ];
386
387        $conflicts = array_merge(
388            $this->conflictingGroups,
389            $this->conflictingFilters
390        );
391
392        foreach ( $conflicts as $conflictInfo ) {
393            unset( $conflictInfo['filterObject'] );
394            unset( $conflictInfo['groupObject'] );
395            $output['conflicts'][] = $conflictInfo;
396            array_push(
397                $output['messageKeys'],
398                $conflictInfo['globalDescription'],
399                $conflictInfo['contextDescription']
400            );
401        }
402
403        return $output;
404    }
405
406    /**
407     * Checks whether this filter is selected in the provided options
408     *
409     * @param FormOptions $opts
410     * @return bool
411     */
412    abstract public function isSelected( FormOptions $opts );
413
414    /**
415     * @param FormOptions $opts Query parameters merged with defaults
416     * @param bool $isStructuredUI Whether the structured UI is currently enabled
417     * @return bool Whether this filter should be considered active
418     */
419    abstract public function isActive( FormOptions $opts, $isStructuredUI );
420
421    /**
422     * Get the action to apply to the ChangesListQuery for filtering. This may
423     * also return an array of actions.
424     *
425     * @see ChangesListQuery::applyAction
426     *
427     * @return array|null
428     */
429    public function getAction(): ?array {
430        return $this->action;
431    }
432
433    /**
434     * Get the action to apply to the ChangesListQuery for highlighting.
435     *
436     * @return array|null
437     */
438    public function getHighlightAction(): ?array {
439        return $this->highlight ?? $this->action;
440    }
441
442    /**
443     * Get groups conflicting with this filter
444     *
445     * @return ChangesListFilterGroup[]
446     */
447    public function getConflictingGroups() {
448        return array_column( $this->conflictingGroups, 'groupObject' );
449    }
450
451    /**
452     * Get filters conflicting with this filter
453     *
454     * @return ChangesListFilter[]
455     */
456    public function getConflictingFilters() {
457        return array_column( $this->conflictingFilters, 'filterObject' );
458    }
459
460    /**
461     * Check if the conflict with a group is currently "active"
462     *
463     * @param ChangesListFilterGroup $group
464     * @param FormOptions $opts
465     * @return bool
466     */
467    public function activelyInConflictWithGroup( ChangesListFilterGroup $group, FormOptions $opts ) {
468        if ( $group->anySelected( $opts ) && $this->isSelected( $opts ) ) {
469            /** @var ChangesListFilter $siblingFilter */
470            foreach ( $this->getSiblings() as $siblingFilter ) {
471                if ( $siblingFilter->isSelected( $opts ) && !$siblingFilter->hasConflictWithGroup( $group ) ) {
472                    return false;
473                }
474            }
475            return true;
476        }
477        return false;
478    }
479
480    private function hasConflictWithGroup( ChangesListFilterGroup $group ): bool {
481        return in_array( $group, $this->getConflictingGroups() );
482    }
483
484    /**
485     * Check if the conflict with a filter is currently "active"
486     *
487     * @param ChangesListFilter $filter
488     * @param FormOptions $opts
489     * @return bool
490     */
491    public function activelyInConflictWithFilter( ChangesListFilter $filter, FormOptions $opts ) {
492        if ( $this->isSelected( $opts ) && $filter->isSelected( $opts ) ) {
493            /** @var ChangesListFilter $siblingFilter */
494            foreach ( $this->getSiblings() as $siblingFilter ) {
495                if (
496                    $siblingFilter->isSelected( $opts ) &&
497                    !$siblingFilter->hasConflictWithFilter( $filter )
498                ) {
499                    return false;
500                }
501            }
502            return true;
503        }
504        return false;
505    }
506
507    private function hasConflictWithFilter( ChangesListFilter $filter ): bool {
508        return in_array( $filter, $this->getConflictingFilters() );
509    }
510
511    /**
512     * Get filters in the same group
513     *
514     * @return ChangesListFilter[]
515     */
516    protected function getSiblings() {
517        return array_filter(
518            $this->getGroup()->getFilters(),
519            function ( $filter ) {
520                return $filter !== $this;
521            }
522        );
523    }
524
525    /**
526     * @param string $defaultHighlightColor
527     */
528    public function setDefaultHighlightColor( $defaultHighlightColor ) {
529        $this->defaultHighlightColor = $defaultHighlightColor;
530    }
531}
532
533/** @deprecated class alias since 1.44 */
534class_alias( ChangesListFilter::class, 'ChangesListFilter' );