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