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