Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 168
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
RevisionReviewFormUI
0.00% covered (danger)
0.00%
0 / 168
0.00% covered (danger)
0.00%
0 / 12
2550
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 setDiffPriorRevRecord
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setTopNotice
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setBottomNotice
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setIncludeVersions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHtml
0.00% covered (danger)
0.00%
0 / 70
0.00% covered (danger)
0.00%
0 / 1
240
 rejectRefRevId
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 ratingInputs
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
72
 getTagMsg
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTagValueMsg
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getRatingFormLevels
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 submitButtons
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
132
1<?php
2
3use MediaWiki\CommentStore\CommentStore;
4use MediaWiki\Context\IContextSource;
5use MediaWiki\Html\Html;
6use MediaWiki\MediaWikiServices;
7use MediaWiki\Message\Message;
8use MediaWiki\Request\WebRequest;
9use MediaWiki\Revision\RevisionRecord;
10use MediaWiki\SpecialPage\SpecialPage;
11use MediaWiki\User\User;
12
13/**
14 * Main review form UI
15 *
16 * NOTE: use ONLY for diff-to-stable views and page version views
17 */
18class RevisionReviewFormUI {
19    private User $user;
20    private FlaggableWikiPage $article;
21    /** A notice inside the review box at the top (HTML) */
22    private string $topNotice = '';
23    /** A notice inside the review box at the bottom (HTML) */
24    private string $bottomNotice = '';
25    /** @var array<int,array<string,int>>|null */
26    private ?array $templateIds = null;
27    private WebRequest $request;
28    private RevisionRecord $revRecord;
29    private ?RevisionRecord $refRevRecord = null;
30
31    /**
32     * Generates a brief review form for a page
33     */
34    public function __construct(
35        IContextSource $context,
36        FlaggableWikiPage $article,
37        RevisionRecord $revRecord
38    ) {
39        $this->user = $context->getUser();
40        $this->request = $context->getRequest();
41        $this->article = $article;
42        $this->revRecord = $revRecord;
43    }
44
45    /**
46     * Call this only when the form is shown on a diff:
47     * (a) Shows the "reject" button
48     * (b) Default the rating tags to those of $this->revRecord (if flagged)
49     * @param RevisionRecord $refRevRecord Old revision for diffs ($this->revRecord is the new rev)
50     */
51    public function setDiffPriorRevRecord( RevisionRecord $refRevRecord ): void {
52        $this->refRevRecord = $refRevRecord;
53    }
54
55    /**
56     * Add on a notice inside the review box at the top
57     * @param string $notice HTML to show
58     */
59    public function setTopNotice( string $notice ): void {
60        $this->topNotice = $notice;
61    }
62
63    /**
64     * Add on a notice inside the review box at the bottom
65     * @param string $notice HTML to show
66     */
67    public function setBottomNotice( string $notice ): void {
68        $this->bottomNotice = $notice;
69    }
70
71    /**
72     * Set the template version parameters of what the user is viewing
73     * @param array<int,array<string,int>> $templateIds
74     */
75    public function setIncludeVersions( array $templateIds ): void {
76        $this->templateIds = $templateIds;
77    }
78
79    /**
80     * Generates a brief review form for a page
81     * @return array (html string, error string or true)
82     */
83    public function getHtml(): array {
84        $revId = $this->revRecord->getId();
85        if ( $this->revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
86            return [ '', 'review_bad_oldid' ]; # The revision must be valid and public
87        }
88        $article = $this->article; // convenience
89
90        $srev = $article->getStableRev();
91        # See if the version being displayed is flagged...
92        if ( $revId == $article->getStable() ) {
93            $frev = $srev; // avoid query
94        } else {
95            $frev = FlaggedRevision::newFromTitle( $article->getTitle(), $revId );
96        }
97        $oldTag = $frev ? $frev->getTag() : FlaggedRevs::quickTag();
98        $reviewTime = $frev ? $frev->getTimestamp() : ''; // last review of rev
99
100        $priorRevId = $this->refRevRecord ? $this->refRevRecord->getId() : 0;
101        # If we are reviewing updates to a page, start off with the stable revision's
102        # tag. Otherwise, we just fill them in with the selected revision's tag.
103        # @TODO: do we want to carry over info for other diffs?
104        if ( $srev && $srev->getRevId() == $priorRevId ) { // diff-to-stable
105            $tag = $srev->getTag();
106            # Check if user is allowed to renew the stable version.
107            # If not, then get the tag for the new revision itself.
108            if ( !FlaggedRevs::userCanSetTag( $this->user, $oldTag ) ) {
109                $tag = $oldTag;
110            }
111            # Re-review button is need for template only review case
112            $reviewIncludes = ( $srev->getRevId() == $revId && !$article->stableVersionIsSynced() );
113        } else { // views
114            $tag = $oldTag;
115            $reviewIncludes = false; // re-review button not needed
116        }
117
118        # Disable form for unprivileged users
119        $disabled = !MediaWikiServices::getInstance()->getPermissionManager()
120                ->quickUserCan( 'review', $this->user, $article->getTitle() ) ||
121            !FlaggedRevs::userCanSetTag( $this->user, $tag );
122
123        # Begin form...
124        $reviewTitle = SpecialPage::getTitleFor( 'RevisionReview' );
125        $action = $reviewTitle->getLocalURL( 'action=submit' );
126        $params = [ 'method' => 'post', 'action' => $action, 'id' => 'mw-fr-reviewform' ];
127        $form = Html::openElement( 'form', $params ) . "\n";
128        $form .= Html::openElement( 'fieldset',
129            [ 'class' => 'flaggedrevs_reviewform noprint cdx-card', 'style' => 'font-size: 90%;' ] ) . "\n";
130        # Add appropriate legend text
131        $legendMsg = $frev ? 'revreview-reflag' : 'revreview-flag';
132        $form .= Html::openElement( 'div', [ 'id' => 'mw-fr-reviewformlegend' ] );
133        $form .= '<div class="cdx-card__text__title">' . wfMessage( $legendMsg )->escaped() . '</div>';
134        # Show explanatory text
135        $form .= $this->topNotice;
136
137        # Start rating controls
138        $css = $disabled ? 'fr-rating-controls-disabled' : 'fr-rating-controls';
139        $form .= Html::openElement( 'p', [ 'class' => $css, 'id' => 'fr-rating-controls' ] ) . "\n";
140
141        # Add main checkboxes/selects
142        [ $radios, $radiosShown ] = $this->ratingInputs( $this->user, $tag, $disabled );
143        $form .= Html::rawElement(
144            'span',
145            [ 'id' => 'mw-fr-ratingselects', 'class' => 'fr-rating-options' ],
146            $radios
147        );
148
149        # Hide comment input if needed
150        if ( !$disabled ) {
151            $form .= '<div class="cdx-text-input" style="padding-bottom: 5px;">';
152            $form .= Html::label( wfMessage( 'revreview-log' )->text(), 'mw-fr-commentbox' );
153            $form .= Html::input( 'wpReason', '', 'text',
154                [
155                    'size' => 40,
156                    'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
157                    'class' => 'fr-comment-box cdx-text-input__input',
158                    'id' => 'mw-fr-commentbox'
159                ]
160            );
161            $form .= '</div>';
162        }
163
164        # Add the submit buttons...
165        $rejectId = $this->rejectRefRevId(); // determine if there will be reject button
166        $form .= $this->submitButtons( $rejectId, $frev, $disabled, $reviewIncludes, $radiosShown );
167
168        # Show stability log if there is anything interesting...
169        if ( $article->isPageLocked() ) {
170            $form .= FlaggedRevsHTML::stabilityLogExcerpt( $article->getTitle() );
171        }
172
173        # End rating controls
174        $form .= Html::closeElement( 'p' ) . "\n";
175
176        # Show explanatory text
177        $form .= $this->bottomNotice;
178
179        # Hidden params
180        $form .= Html::hidden( 'title', $reviewTitle->getPrefixedText() ) . "\n";
181        $form .= Html::hidden( 'target', $article->getTitle()->getPrefixedDBkey() ) . "\n";
182        $form .= Html::hidden( 'refid', $priorRevId, [ 'id' => 'mw-fr-input-refid' ] ) . "\n";
183        $form .= Html::hidden( 'oldid', $revId, [ 'id' => 'mw-fr-input-oldid' ] ) . "\n";
184        $form .= Html::hidden( 'wpEditToken', $this->user->getEditToken() ) . "\n";
185        $form .= Html::hidden( 'changetime', $reviewTime,
186            [ 'id' => 'mw-fr-input-changetime' ] ) . "\n"; // id for JS
187        # Special token to discourage fiddling...
188        $key = $this->request->getSessionData( 'wsFlaggedRevsKey' );
189        $checkCode = RevisionReviewForm::validationKey( $revId, $key );
190        $form .= Html::hidden( 'validatedParams', $checkCode ) . "\n";
191        $form .= Html::closeElement( 'fieldset' ) . "\n";
192        $form .= Html::closeElement( 'form' ) . "\n";
193        return [ $form, true /* ok */ ];
194    }
195
196    /**
197     * If the REJECT button should show then get the ID of the last good rev
198     */
199    private function rejectRefRevId(): int {
200        if ( $this->refRevRecord ) {
201            $priorId = $this->refRevRecord->getId();
202            if ( $priorId == $this->article->getStable() &&
203                $priorId != $this->revRecord->getId() &&
204                !$this->revRecord->hasSameContent( $this->refRevRecord )
205            ) {
206                return $priorId; // left rev must be stable and right one newer
207            }
208        }
209        return 0;
210    }
211
212    /**
213     * Generate main tag radio buttons for review form if necessary
214     * @param User $user
215     * @param int|null $selected selected tag
216     * @param bool $disabled form disabled
217     * @return array{0:string,1:bool} The tags HTML and whether it contains radio buttons
218     */
219    private function ratingInputs( User $user, ?int $selected, bool $disabled ): array {
220        if ( FlaggedRevs::binaryFlagging() ) {
221            return [ '', false ];
222        }
223
224        $quality = FlaggedRevs::getTagName();
225        $levels = $this->getRatingFormLevels( $user );
226        if ( $disabled || count( $levels ) === 0 ) {
227            // Display the value for the tag as text
228            return [
229                $this->getTagMsg( $quality )->escaped() . ': ' . $this->getTagValueMsg( $selected ?? 0 ),
230                false
231            ];
232        }
233
234        $minLevel = array_keys( $levels )[ 0 ];
235        $inputName = "wp$quality";
236        if ( count( $levels ) === 1 ) {
237            // No need to display the single settable level
238            return [
239                Html::hidden( $inputName, $minLevel ),
240                false
241            ];
242        }
243
244        # Determine the level selected by default
245        if ( !$selected || !isset( $levels[$selected] ) ) {
246            $selected = $minLevel;
247        }
248
249        $inputs = $this->getTagMsg( $quality )->escaped() . ":\n";
250        foreach ( $levels as $i => $name ) {
251            $id = $inputName . $i;
252            $item = Html::radio(
253                $inputName,
254                $i == $selected,
255                [ 'value' => $i, 'id' => $id, 'class' => "fr-rating-option-$i cdx-radio__input" ]
256            );
257            $item .= "\u{00A0}";
258            $item .= '<span class="cdx-radio__icon"></span>';
259            $item .= Html::label(
260                $this->getTagMsg( $name )->text(),
261                $id,
262                [ 'class' => "fr-rating-option-$i cdx-radio__label" ]
263            );
264            $inputs .= Html::rawElement( 'span', [ 'class' => 'cdx-radio cdx-radio--inline' ], $item );
265        }
266        return [ $inputs, true ];
267    }
268
269    /**
270     * Get the UI name for a tag
271     */
272    private function getTagMsg( string $tag ): Message {
273        return wfMessage( "revreview-$tag" );
274    }
275
276    /**
277     * Get the UI name for a value of a tag
278     */
279    private function getTagValueMsg( int $value ): string {
280        $quality = FlaggedRevs::getTagName();
281        // Possible message keys that come pre-defined with the extension:
282        // * revreview-accuracy-0
283        // * revreview-accuracy-1
284        // * revreview-accuracy-2
285        // * revreview-accuracy-3
286        // * revreview-accuracy-4
287        $msg = wfMessage( "revreview-$quality-$value" );
288        return $msg->isDisabled() ? (string)$value : $msg->escaped();
289    }
290
291    /**
292     * Get all available tags for this user
293     * @return array<int,string>
294     */
295    private function getRatingFormLevels( User $user ): array {
296        $quality = FlaggedRevs::getTagName();
297        $labels = []; // applicable tag levels
298        for ( $i = 1; $i <= FlaggedRevs::getMaxLevel(); $i++ ) {
299            # Some levels may be restricted or not applicable...
300            if ( FlaggedRevs::userCanSetValue( $user, $i ) ) {
301                $labels[$i] = "$quality-$i";
302            }
303        }
304        return $labels;
305    }
306
307    /**
308     * Generates review form submit buttons
309     * @param int $rejectId left rev ID for "reject" on diffs
310     * @param FlaggedRevision|null $frev the flagged revision, if any
311     * @param bool $disabled is the form disabled?
312     * @param bool $reviewIncludes force the review button to be usable?
313     * @param bool $radiosShown Whether any radio buttons appear. If not,
314     *  and there are no pending changes (either on the page itself or in
315     *  transcluded templates), the review button will be disabled.
316     */
317    private function submitButtons(
318        int $rejectId, ?FlaggedRevision $frev, bool $disabled, bool $reviewIncludes, bool $radiosShown
319    ): string {
320        $disAttrib = [ 'disabled' => 'disabled' ];
321        # ACCEPT BUTTON: accept a revision
322        # We may want to re-review to change:
323        # (a) notes (b) tags (c) pending template changes
324        if ( !$radiosShown ) { // just the buttons
325            $applicable = ( !$frev || $reviewIncludes ); // no tags/notes
326            $needsChange = false; // no state change possible
327        } else { // buttons + ratings
328            $applicable = true; // tags might change
329            $needsChange = ( $frev && !$reviewIncludes );
330        }
331        // Disable buttons unless state changes in some cases (non-JS compatible)
332        $needsChangeAttrib = $needsChange ? [ 'data-mw-fr-review-needs-change' => '' ] : [];
333        $s = Html::submitButton( wfMessage( 'revreview-submit-review' )->text(),
334            [
335                'name'      => 'wpApprove',
336                'id'        => 'mw-fr-submit-accept',
337                'class'     => 'cdx-button cdx-button--action-progressive',
338                'accesskey' => wfMessage( 'revreview-ak-review' )->text(),
339                'title'     => wfMessage( 'revreview-tt-flag' )->text() . ' [' .
340                    wfMessage( 'revreview-ak-review' )->text() . ']'
341            ] + ( ( $disabled || !$applicable ) ? $disAttrib : [] ) + $needsChangeAttrib
342        );
343        # REJECT BUTTON: revert from a pending revision to the stable
344        if ( $rejectId ) {
345            $s .= ' ';
346            $s .= Html::submitButton( wfMessage( 'revreview-submit-reject' )->text(),
347                [
348                    'name'  => 'wpReject',
349                    'id'    => 'mw-fr-submit-reject',
350                    'class' => 'cdx-button cdx-button--action-destructive',
351                    'title' => wfMessage( 'revreview-tt-reject' )->text(),
352                ] + ( $disabled ? $disAttrib : [] )
353            );
354        }
355        # UNACCEPT BUTTON: revoke a revision's acceptance
356        # Hide if revision is not flagged
357        $s .= ' ';
358        $s .= Html::submitButton( wfMessage( 'revreview-submit-unreview' )->text(),
359            [
360                'name'  => 'wpUnapprove',
361                'id'    => 'mw-fr-submit-unaccept',
362                'class' => 'cdx-button cdx-button--action-destructive',
363                'title' => wfMessage( 'revreview-tt-unflag' )->text(),
364                'style' => $frev ? '' : 'display:none'
365            ] + ( $disabled ? $disAttrib : [] )
366        ) . "\n";
367        return $s;
368    }
369}