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