Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 99
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
MentorFilterHooks
0.00% covered (danger)
0.00%
0 / 99
0.00% covered (danger)
0.00%
0 / 5
240
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 onChangesListSpecialPageStructuredFilters
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
0.00%
0 / 1
72
 getStarredMenteeIds
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getUnstarredMenteeIds
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 convertUserIdsToActorIds
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace GrowthExperiments\Mentorship\Hooks;
4
5use ChangesListStringOptionsFilter;
6use ChangesListStringOptionsFilterGroup;
7use GrowthExperiments\MentorDashboard\MenteeOverview\StarredMenteesStore;
8use GrowthExperiments\Mentorship\Provider\MentorProvider;
9use GrowthExperiments\Mentorship\Store\MentorStore;
10use GrowthExperiments\WikiConfigException;
11use MediaWiki\Config\Config;
12use MediaWiki\Context\IContextSource;
13use MediaWiki\SpecialPage\Hook\ChangesListSpecialPageStructuredFiltersHook;
14use MediaWiki\User\UserIdentity;
15use RecentChange;
16use Wikimedia\ObjectCache\HashBagOStuff;
17use Wikimedia\Rdbms\IDatabase;
18
19/**
20 * RecentChanges filters for mentors. Separate from MentorHooks because MentorManager
21 * depends on the session so making more common hooks depend on it would break ResourceLoader.
22 */
23class MentorFilterHooks implements ChangesListSpecialPageStructuredFiltersHook {
24
25    /** @var Config */
26    private $config;
27
28    /** @var MentorStore */
29    private $mentorStore;
30
31    /** @var StarredMenteesStore */
32    private $starredMenteesStore;
33
34    /** @var MentorProvider */
35    private $mentorProvider;
36
37    /** @var HashBagOStuff Mentor [starred|unstarred]:username => UserIdentity[] list of mentees */
38    private $menteeCache;
39
40    /**
41     * @param Config $config
42     * @param MentorStore $mentorStore
43     * @param StarredMenteesStore $starredMenteesStore
44     * @param MentorProvider $mentorProvider
45     */
46    public function __construct(
47        Config $config,
48        MentorStore $mentorStore,
49        StarredMenteesStore $starredMenteesStore,
50        MentorProvider $mentorProvider
51    ) {
52        $this->config = $config;
53        $this->mentorStore = $mentorStore;
54        $this->starredMenteesStore = $starredMenteesStore;
55        $this->mentorProvider = $mentorProvider;
56        $this->menteeCache = new HashBagOStuff();
57    }
58
59    /** @inheritDoc */
60    public function onChangesListSpecialPageStructuredFilters( $special ) {
61        // Somewhat arbitrarily, use the dashboard feature flag to expose the mentor filters.
62        // Also make sure the user is actually a mentor.
63        try {
64            if ( !$this->config->get( 'GEMentorDashboardEnabled' )
65                 || !$this->mentorProvider->isMentor( $special->getUser() )
66            ) {
67                return;
68            }
69        } catch ( WikiConfigException $wikiConfigException ) {
70            return;
71        }
72
73        $group = new ChangesListStringOptionsFilterGroup( [
74            'name' => 'mentorship',
75            'title' => 'growthexperiments-rcfilters-mentorship-title',
76            'isFullCoverage' => false,
77            'filters' => [],
78            'default' => '',
79            'queryCallable' => function (
80                string $specialPageClassName,
81                IContextSource $context,
82                IDatabase $dbr,
83                array &$tables,
84                array &$fields,
85                array &$conds,
86                array &$query_options,
87                array &$join_conds,
88                array $selectedValues
89            ) {
90                if ( !$selectedValues ) {
91                    return;
92                }
93
94                $targetUserIds = [];
95                if ( in_array( 'starred', $selectedValues, true ) ) {
96                    $targetUserIds = $this->getStarredMenteeIds( $context->getUser() );
97                }
98                if ( in_array( 'unstarred', $selectedValues, true ) ) {
99                    $targetUserIds = array_merge( $targetUserIds, $this->getUnstarredMenteeIds( $context->getUser() ) );
100                }
101                $targetActorIds = $this->convertUserIdsToActorIds( $dbr, $targetUserIds );
102
103                // The query is shared with other hook handlers, so with the associative array format
104                // there is a risk of key conflict. Convert into non-associate instead.
105                // Only apply when $targetIds has at least one ID
106                if ( $targetActorIds !== [] ) {
107                    $conds['rc_actor'] = $targetActorIds;
108                } else {
109                    $conds[] = '0=1';
110                }
111            },
112        ] );
113        $special->registerFilterGroup( $group );
114
115        $starredMenteesFilter = new ChangesListStringOptionsFilter( [
116            'name' => 'starred',
117            'group' => $group,
118            'label' => 'growthexperiments-rcfilters-mentorship-starred-label',
119            'description' => 'growthexperiments-rcfilters-mentorship-starred-desc',
120            'priority' => 0,
121            'cssClassSuffix' => 'starred-mentee',
122            'isRowApplicableCallable' => function ( IContextSource $context, RecentChange $rc ) {
123                $starredMenteeIds = $this->getStarredMenteeIds( $context->getUser() );
124                return in_array( $rc->getPerformerIdentity()->getId(), $starredMenteeIds, true );
125            },
126        ] );
127        $unstarredMenteesFilter = new ChangesListStringOptionsFilter( [
128            'name' => 'unstarred',
129            'group' => $group,
130            'label' => 'growthexperiments-rcfilters-mentorship-unstarred-label',
131            'description' => 'growthexperiments-rcfilters-mentorship-unstarred-desc',
132            'priority' => -1,
133            'cssClassSuffix' => 'unstarred-mentee',
134            'isRowApplicableCallable' => function ( IContextSource $context, RecentChange $rc ) {
135                $unstarredMenteeIds = $this->getUnstarredMenteeIds( $context->getUser() );
136                return in_array( $rc->getPerformerIdentity()->getId(), $unstarredMenteeIds, true );
137            },
138        ] );
139    }
140
141    /**
142     * Helper method to load the current user's starred mentees, with caching.
143     * @param UserIdentity $user
144     * @return int[]
145     */
146    private function getStarredMenteeIds( UserIdentity $user ): array {
147        $key = $this->menteeCache->makeKey( 'starred', $user->getId() );
148        if ( $this->menteeCache->hasKey( $key ) ) {
149            return $this->menteeCache->get( $key );
150        }
151
152        $starredMentees = $this->starredMenteesStore->getStarredMentees( $user );
153        $starredMenteeIds = array_map( static function ( UserIdentity $user ) {
154            return $user->getId();
155        }, $starredMentees );
156
157        $this->menteeCache->set( $key, $starredMenteeIds );
158        return $starredMenteeIds;
159    }
160
161    /**
162     * Helper method to load the current user's unstarred mentees, with caching.
163     * @param UserIdentity $user
164     * @return int[]
165     */
166    private function getUnstarredMenteeIds( UserIdentity $user ): array {
167        $key = $this->menteeCache->makeKey( 'unstarred', $user->getId() );
168        if ( $this->menteeCache->hasKey( $key ) ) {
169            return $this->menteeCache->get( $key );
170        }
171
172        $mentees = $this->mentorStore->getMenteesByMentor(
173            $user,
174            MentorStore::ROLE_PRIMARY,
175            false,
176            false
177        );
178        $menteeIds = array_map( static function ( UserIdentity $user ) {
179            return $user->getId();
180        }, $mentees );
181        $starredMenteeIds = $this->getStarredMenteeIds( $user );
182        $unstarredMenteeIds = array_diff( $menteeIds, $starredMenteeIds );
183
184        $this->menteeCache->set( $key, $unstarredMenteeIds );
185        return $unstarredMenteeIds;
186    }
187
188    /**
189     * @param IDatabase $db
190     * @param int[] $userIds
191     * @return int[]
192     */
193    private function convertUserIdsToActorIds( IDatabase $db, array $userIds ) {
194        if ( !$userIds ) {
195            return [];
196        }
197
198        // No need to worry about properly acquiring actor IDs - if it shows up in
199        // recent changes, it already has an actor ID
200        $res = $db->newSelectQueryBuilder()
201            ->select( 'actor_id' )
202            ->from( 'actor' )
203            ->where( [ 'actor_user' => $userIds ] )
204            ->caller( __METHOD__ )
205            ->fetchFieldValues();
206        return array_map( 'intval', $res );
207    }
208
209}