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