MozReview: Review Page Redesign: Add reviewRequestEditorView_mozreview.js for future height calculation modification (Bug 1309964). r?glob draft
authorDavid Walsh <dwalsh@mozilla.com>
Thu, 10 Nov 2016 13:41:02 -0600
changeset 216 fc430c7846bb18694957d7b2f75e5a061cbed648
parent 213 d91ffa4859cb917c395146a9772ccca8872c03a9
child 217 6540daf171090dfa8a8ebe3ae930761ae79c2e24
push idunknown
push userunknown
push dateunknown
reviewersglob
bugs1309964
MozReview: Review Page Redesign: Add reviewRequestEditorView_mozreview.js for future height calculation modification (Bug 1309964). r?glob MozReview-Commit-ID: IYp4aMXW4Dg
reviewboard/reviewboard/static/rb/js/views/reviewRequestEditorView_mozreview.js
reviewboard/reviewboard/staticbundles.py
new file mode 100644
--- /dev/null
+++ b/reviewboard/reviewboard/static/rb/js/views/reviewRequestEditorView_mozreview.js
@@ -0,0 +1,1631 @@
+(function() {
+
+
+var BannerView,
+    ClosedBannerView,
+    DraftBannerView;
+
+
+/*
+ * Base class for review request banners.
+ *
+ * This will render a banner based on the data provided by subclasses,
+ * and handle actions and editing of text fields.
+ */
+BannerView = Backbone.View.extend({
+    className: 'banner',
+    title: '',
+    subtitle: '',
+    actions: [],
+    showChangesField: true,
+    describeText: '',
+    fieldOptions: {},
+    descriptionFieldID: 'changedescription',
+    descriptionFieldName: null,
+    descriptionFieldHTML: '',
+    descriptionFieldClasses: '',
+    showSendEmail: false,
+
+    template: _.template([
+        '<h1><%- title %></h1>',
+        '<% if (subtitle) { %>',
+        '<p><%- subtitle %></p>',
+        '<% } %>',
+        '<span class="banner-actions">',
+        '<% _.each(actions, function(action) { %>',
+        ' <input type="button" id="<%= action.id %>" ',
+        '        value="<%- action.label %>" />',
+        '<% }); %>',
+        '<% if (showSendEmail) { %>',
+        ' <label>',
+        '  <input type="checkbox" class="send-email" checked />',
+        '  <%- sendEmailText %>',
+        '</label>',
+        '<% } %>',
+        '</span>',
+        '<% if (showChangesField) { %>',
+        ' <p><label for="field_changedescription">',
+        '<%- describeText %></label></p>',
+        ' <pre id="field_changedescription"',
+        '      class="field field-text-area <%- descriptionFieldClasses %>"',
+        '      data-field-id="field_changedescription">',
+        '<%= descriptionFieldHTML %></pre>',
+        '<% } %>'
+    ].join('')),
+
+    /*
+     * Initializes the banner.
+     */
+    initialize: function(options) {
+        this.reviewRequestEditorView = options.reviewRequestEditorView;
+        this.reviewRequestEditor = this.reviewRequestEditorView.model;
+        this.reviewRequest = this.reviewRequestEditor.get('reviewRequest');
+
+        this.reviewRequestEditorView.registerField(_.defaults({
+            fieldID: this.descriptionFieldID,
+            fieldName: this.descriptionFieldName,
+            elementOptional: true,
+            allowMarkdown: true,
+            useExtraData: false,
+            formatter: function(view, data, $el, fieldOptions) {
+                view.formatText($el, {
+                    newText: data,
+                    fieldOptions: fieldOptions
+                });
+            }
+        }, this.fieldOptions));
+
+        this.$buttons = null;
+    },
+
+    /*
+     * Renders the banner.
+     *
+     * If there's an existing banner on the page, from the generated
+     * template, then this will make use of that template. Otherwise,
+     * it will construct a new one.
+     */
+    render: function() {
+        if (this.$el.children().length === 0) {
+            this.$el.html(this.template({
+                title: this.title,
+                subtitle: this.subtitle,
+                actions: this.actions,
+                showChangesField: this.showChangesField,
+                describeText: this.describeText,
+                descriptionFieldHTML: this.descriptionFieldHTML,
+                descriptionFieldClasses: this.descriptionFieldClasses,
+                showSendEmail: this.showSendEmail,
+                sendEmailText: gettext('Send E-Mail')
+            }));
+        }
+
+        this.$buttons = this.$('input');
+
+        this.reviewRequestEditor.on('saving destroying', function() {
+            this.$buttons.prop('disabled', true);
+        }, this);
+
+        this.reviewRequestEditor.on('saved saveFailed destroyed', function() {
+            this.$buttons.prop('disabled', false);
+        }, this);
+
+        this.reviewRequestEditorView.setupFieldEditor(this.descriptionFieldID);
+
+        return this;
+    }
+});
+
+
+/*
+ * Base class for a banner representing a closed review request.
+ *
+ * This provides a button for reopening the review request. It's up
+ * to subclasses to provide the other details.
+ */
+ClosedBannerView = BannerView.extend({
+    descriptionFieldName: 'closeDescription',
+
+    actions: [
+        {
+            id: 'btn-review-request-reopen',
+            label: gettext('Reopen for Review')
+        }
+    ],
+
+    fieldOptions: {
+        statusField: true
+    },
+
+    events: {
+        'click #btn-review-request-reopen': '_onReopenClicked'
+    },
+
+    /*
+     * Render the banner.
+     */
+    render: function() {
+        var descriptionFieldClasses = [];
+
+        if (this.reviewRequestEditor.get('statusMutableByUser')) {
+            descriptionFieldClasses.push('editable');
+        }
+
+        if (this.reviewRequest.get('closeDescriptionRichText')) {
+            descriptionFieldClasses.push('rich-text');
+        }
+
+        this.descriptionFieldClasses = descriptionFieldClasses.join(' ');
+        this.descriptionFieldHTML =
+            this.reviewRequestEditor.get('closeDescriptionRenderedText');
+
+        /*
+         * XXX: _super(this).render is causing recursion for some reason I
+         * don't understand.
+         */
+        return BannerView.prototype.render.apply(this, arguments);
+    },
+
+    /*
+     * Handler for Reopen Review Request.
+     */
+    _onReopenClicked: function() {
+        this.reviewRequest.reopen({
+            error: function(model, xhr) {
+                alert(xhr.errorText);
+            }
+        });
+
+        return false;
+    }
+});
+
+
+/*
+ * A banner representing a discarded review request.
+ */
+DiscardedBannerView = ClosedBannerView.extend({
+    id: 'discard-banner',
+    title: gettext('This change has been discarded.'),
+    describeText: gettext("Describe the reason it's discarded (optional):"),
+    fieldOptions: _.defaults({
+        closeType: RB.ReviewRequest.CLOSE_DISCARDED
+    }, ClosedBannerView.prototype.fieldOptions)
+});
+
+
+/*
+ * A banner representing a submitted review request.
+ */
+SubmittedBannerView = ClosedBannerView.extend({
+    id: 'submitted-banner',
+    title: gettext('This change has been marked as submitted.'),
+    describeText: gettext('Describe the submission (optional):'),
+    fieldOptions: _.defaults({
+        closeType: RB.ReviewRequest.CLOSE_SUBMITTED
+    }, ClosedBannerView.prototype.fieldOptions)
+});
+
+
+/*
+ * A banner representing a draft of a review request.
+ *
+ * Depending on the public state of the review request, this will
+ * show different text and a different set of buttons.
+ */
+DraftBannerView = BannerView.extend({
+    id: 'draft-banner',
+    title: gettext('This review request is a draft.'),
+    subtitle: gettext('Be sure to publish when finished.'),
+    describeText: gettext('Describe your changes (optional):'),
+    descriptionFieldName: 'changeDescription',
+
+    _newDraftTemplate: _.template([
+        '<div class="interdiff-link">',
+        '<%- newDiffText %> ',
+        '<a href="<%- interdiffLink %>"><%- showChangesText %></a>',
+        '</div>'
+    ].join('')),
+
+    events: {
+        'click #btn-draft-publish': '_onPublishDraftClicked',
+        'click #btn-draft-discard': '_onDiscardDraftClicked',
+        'click #btn-review-request-discard': '_onCloseDiscardedClicked'
+    },
+
+    /*
+     * Initializes the banner.
+     */
+    initialize: function() {
+        _super(this).initialize.apply(this, arguments);
+
+        if (this.reviewRequest.get('public')) {
+            this.showSendEmail = this.reviewRequestEditor.get('showSendEmail');
+            this.title = gettext('This review request is a draft.');
+            this.actions = [
+                {
+                    id: 'btn-draft-publish',
+                    label: gettext('Publish Changes')
+                },
+                {
+                    id: 'btn-draft-discard',
+                    label: gettext('Discard Draft')
+                }
+            ];
+        } else {
+            this.showChangesField = false;
+            this.actions = [
+                {
+                    id: 'btn-draft-publish',
+                    label: gettext('Publish')
+                },
+                {
+                    id: 'btn-review-request-discard',
+                    label: gettext('Discard Review Request')
+                }
+            ];
+        }
+    },
+
+    /*
+     * Handler for when the Publish Draft button is clicked.
+     *
+     * Begins publishing the review request. If there are any field editors
+     * still open, they'll be saved first.
+     */
+    _onPublishDraftClicked: function() {
+        var $sendEmail = this.$('.send-email');
+
+        this.reviewRequestEditorView.publishDraft({
+            trivial: ($sendEmail.length === 1 && !$sendEmail.is(':checked'))
+        });
+
+        return false;
+    },
+
+    /*
+     * Handler for when the Discard Draft button is clicked.
+     *
+     * Discards the draft of the review request.
+     */
+    _onDiscardDraftClicked: function() {
+        this.reviewRequest.draft.destroy({
+            error: function(xhr) {
+                alert(xhr.errorText);
+            }
+        });
+
+        return false;
+    },
+
+    /*
+     * Handler for when Discard Review request button is clicked.
+     */
+    _onDiscardedReviewRequestClicked: function() {
+        this.reviewRequestEditorView.closeDiscarded();
+
+        return false;
+    },
+
+    /*
+     * Handler for when Discard button is clicked.
+     */
+    _onCloseDiscardedClicked: function() {
+        this.reviewRequest.close({
+            type: RB.ReviewRequest.CLOSE_DISCARDED
+        });
+
+        return false;
+    },
+
+    /*
+     * Render the banner
+     */
+    render: function() {
+        var descriptionFieldClasses = [],
+            draft = this.reviewRequest.draft,
+            interdiffLink = draft.get('interdiffLink'),
+            result;
+
+        if (this.reviewRequestEditor.get('mutableByUser')) {
+            descriptionFieldClasses.push('editable');
+        }
+
+        if (draft.get('changeDescriptionRichText')) {
+            descriptionFieldClasses.push('rich-text');
+        }
+
+        this.descriptionFieldClasses = descriptionFieldClasses.join(' ');
+        this.descriptionFieldHTML =
+            this.reviewRequestEditor.get('changeDescriptionRenderedText');
+
+        result = _super(this).render.apply(this, arguments);
+
+        if (interdiffLink) {
+            this.$el.append(this._newDraftTemplate({
+                newDiffText: gettext('This draft adds a new diff.'),
+                showChangesText: gettext('Show changes'),
+                interdiffLink: interdiffLink
+            }));
+        }
+
+        return result;
+    }
+});
+
+
+/*
+ * Manages the user-visible state of an editable review request.
+ *
+ * This owns the fields, thumbnails, banners, and general interaction
+ * around editing a review request.
+ */
+RB.ReviewRequestEditorView = Backbone.View.extend({
+    defaultFields: [
+        {
+            fieldID: 'branch'
+        },
+        {
+            fieldID: 'bugs_closed',
+            fieldName: 'bugsClosed',
+            selector: '#field_bugs_closed',
+            useEditIconOnly: true,
+            formatter: function(view, data, $el) {
+                var reviewRequest = view.model.get('reviewRequest'),
+                    bugTrackerURL = reviewRequest.get('bugTrackerURL'),
+                    bugList,
+                    $bugList;
+
+                data = data || [];
+
+                if (bugTrackerURL) {
+                    bugList = view.urlizeList(data, function(item) {
+                        return bugTrackerURL.replace('--bug_id--', item);
+                    });
+
+                    $bugList = $(bugList)
+                        .addClass('bug')
+                        .bug_infobox();
+
+                    $el.html($bugList);
+                } else {
+                    $el.text(data.join(", "));
+                }
+            }
+        },
+        {
+            fieldID: 'depends_on',
+            fieldName: 'dependsOn',
+            useEditIconOnly: true,
+            formatter: function(view, data, $el) {
+                $el.html(view.urlizeList(
+                    data,
+                    function(item) { return item.url; },
+                    function(item) { return item.id; }
+                ));
+            }
+        },
+        {
+            fieldID: 'description',
+            allowMarkdown: true,
+            formatter: function(view, data, $el, fieldOptions) {
+                view.formatText($el, {
+                    newText: data,
+                    fieldOptions: fieldOptions
+                });
+            }
+        },
+        {
+            fieldID: 'summary'
+        },
+        {
+            fieldID: 'target_groups',
+            fieldName: 'targetGroups',
+            useEditIconOnly: true,
+            autocomplete: {
+                fieldName: 'groups',
+                nameKey: 'name',
+                descKey: 'display_name',
+                extraParams: {
+                    displayname: 1
+                }
+            },
+            formatter: function(view, data, $el) {
+                $el.html(view.urlizeList(
+                    data,
+                    function(item) { return item.url; },
+                    function(item) { return item.name; }
+                ));
+            }
+        },
+        {
+            fieldID: 'target_people',
+            fieldName: 'targetPeople',
+            useEditIconOnly: true,
+            autocomplete: {
+                fieldName: 'users',
+                nameKey: 'username',
+                descKey: 'fullname',
+                extraParams: {
+                    fullname: 1
+                },
+                cmp: function(term, a, b) {
+                    /*
+                     * Sort the results with username matches first (in
+                     * alphabetical order), followed by real name matches (in
+                     * alphabetical order)
+                     */
+                    var aUsername = a.data.username,
+                        bUsername = b.data.username,
+                        aFullname = a.data.fullname,
+                        bFullname = a.data.fullname;
+
+                    if (aUsername.indexOf(term) === 0) {
+                        if (bUsername.indexOf(term) === 0) {
+                            return aUsername.localeCompare(bUsername);
+                        }
+                        return -1;
+                    } else if (bUsername.indexOf(term) === 0) {
+                        return 1;
+                    } else {
+                        return aFullname.localeCompare(bFullname);
+                    }
+                }
+            },
+            formatter: function(view, data, $el) {
+                var $list = $(view.urlizeList(
+                    data,
+                    function(item) { return item.url; },
+                    function(item) { return item.username; }
+                ));
+
+                $el.html(
+                    $list
+                        .addClass("user")
+                        .user_infobox());
+            }
+        },
+        {
+            fieldID: 'testing_done',
+            fieldName: 'testingDone',
+            allowMarkdown: true,
+            formatter: function(view, data, $el, fieldOptions) {
+                view.formatText($el, {
+                    newText: data,
+                    fieldOptions: fieldOptions
+                });
+            }
+        }
+    ],
+
+    events: {
+        'click .has-menu .has-menu': '_onMenuClicked',
+        'click #archive-review-request-link': '_onArchiveClicked',
+        'click #unarchive-review-request-link': '_onUnarchiveClicked',
+        'click #mute-review-request-link': '_onMuteClicked',
+        'click #unmute-review-request-link': '_onUnmuteClicked'
+    },
+
+    _archiveActionsTemplate: _.template([
+        '<% if (visibility === RB.ReviewRequest.VISIBILITY_VISIBLE) { %>',
+        '<li><a id="archive-review-request-link" href="#"><%- archiveText %></a></li>',
+        '<li><a id="mute-review-request-link" href="#"><%- muteText %></a></li>',
+        '<% } else if (visibility === RB.ReviewRequest.VISIBILITY_ARCHIVED) { %>',
+        '<li><a id="unarchive-review-request-link" href="#"><%- unarchiveText %></a></li>',
+        '<% } else if (visibility === RB.ReviewRequest.VISIBILITY_MUTED) { %>',
+        '<li><a id="unmute-review-request-link" href="#"><%- unmuteText %></a></li>',
+        '<% } %>'
+    ].join('')),
+
+    initialize: function() {
+        var $issueSummary = $('#issue-summary');
+
+        _.bindAll(this, '_checkResizeLayout', '_scheduleResizeLayout',
+                  '_onCloseDiscardedClicked', '_onCloseSubmittedClicked',
+                  '_onDeleteReviewRequestClicked', '_onUpdateDiffClicked',
+                  '_onArchiveClicked', '_onUnarchiveClicked',
+                  '_onMuteClicked', '_onUnmuteClicked');
+
+        this._fieldEditors = {};
+        this._hasFields = (this.$('.editable').length > 0);
+
+        if (this._hasFields) {
+            _.each(this.defaultFields, function(fieldInfo) {
+                this.registerField(_.defaults({
+                    useExtraData: false
+                }, fieldInfo));
+            }, this);
+        }
+
+        this.draft = this.model.get('reviewRequest').draft;
+        this.banner = null;
+        this._$main = null;
+        this._$extra = null;
+        this._blockResizeLayout = false;
+
+        if ($issueSummary.length > 0) {
+            this.issueSummaryTableView = new RB.IssueSummaryTableView({
+                el: $('#issue-summary'),
+                model: this.model.get('commentIssueManager')
+            });
+        }
+    },
+
+    /*
+     * Registers an editor for a field.
+     *
+     * This will take a set of options for the editor.
+     *
+     * Required:
+     *
+     *     * fieldName
+     *       - The name of the field in the model. This is required.
+     *
+     * Optional:
+     *
+     *     * elementOptional
+     *       - true if the element doesn't have to be on the page.
+     *
+     *     * formatter
+     *       - A function that formats the field in the model into HTML.
+     *         Defaults to null.
+     *
+     *     * jsonFieldName
+     *       - The field name in the JSON payload. Defaults to fieldName.
+     *
+     *     * selector
+     *       - The jQuery selector for the element in the DOM.
+     *         Defaults to '#' + fieldName.
+     *
+     *     * useEditIconOnly
+     *       - If true, only clicking the edit icon will begin editing.
+     *         Defaults to false.
+     *
+     *     * useExtraData
+     *       - If true, field values will be stored in extraData.
+     *         Defaults to true for non-builtin fields.
+     */
+    registerField: function(options) {
+        var fieldID = options.fieldID,
+            useExtraData = options.useExtraData === undefined
+                           ? true
+                           : options.useExtraData;
+
+        console.assert(fieldID);
+
+        options = _.extend({
+            selector: '#field_' + fieldID,
+            elementOptional: false,
+            fieldID: fieldID,
+            fieldName: fieldID,
+            formatter: null,
+            jsonFieldName: fieldID,
+            jsonTextTypeFieldName: options.allowMarkdown ?
+                                   fieldID + '_text_type'
+                                   : null,
+            useEditIconOnly: false,
+            useExtraData: useExtraData
+        }, options);
+
+        /*
+         * This must be done one we have a solid fieldName set.
+         */
+        options.richTextAttr = options.allowMarkdown
+                               ? options.fieldName + 'RichText'
+                               : null;
+
+        this._fieldEditors[fieldID] = options;
+    },
+
+    /*
+     * Renders the editor.
+     *
+     * This will import all pre-rendered file attachment and screenshot
+     * thumbnails, turning them into FileAttachment and Screenshot objects.
+     */
+    render: function() {
+        var reviewRequest = this.model.get('reviewRequest'),
+            fileAttachments = this.model.get('fileAttachments'),
+            draft = reviewRequest.draft,
+            extraData = draft.get('extraData');
+
+        this._$box = this.$('.review-request');
+        this._$warning = $('#review-request-warning');
+        this._$screenshots = $('#screenshot-thumbnails');
+        this._$attachments = $('#file-list');
+        this._$attachmentsContainer = $(this._$attachments.parent()[0]);
+        this._$bannersContainer = $('#review_request_banners');
+        this._$main = $('#review_request_main');
+        this._$extra = $('#review_request_extra');
+
+        this.listenTo(reviewRequest, 'change:visibility',
+                      this._updateArchiveVisibility);
+        this._updateArchiveVisibility();
+
+        /*
+         * Find any editors that weren't registered. These may be from
+         * extensions.
+         */
+        if (this._hasFields) {
+            _.each(this.$('.field.editable'), function(field) {
+                var $field = $(field),
+                    fieldID = $field.data('field-id'),
+                    isCommaEditable,
+                    richTextFieldID,
+                    fieldInfo,
+                    rawValue;
+
+                if (!this._fieldEditors[fieldID] &&
+                    $field.hasClass('editable')) {
+                    isCommaEditable = $field.hasClass('comma-editable');
+
+                    fieldInfo = {
+                        fieldID: fieldID
+                    };
+
+                    rawValue = $field.data('raw-value');
+
+                    if (rawValue === undefined) {
+                        extraData[fieldID] = $field.text();
+                    } else {
+                        extraData[fieldID] = rawValue || '';
+                    }
+
+                    $field.removeAttr('data-raw-value');
+
+                    if ($field.data('allow-markdown')) {
+                        fieldInfo.allowMarkdown = true;
+
+                        if (fieldID === 'text') {
+                            richTextFieldID = 'rich_text';
+                        } else {
+                            richTextFieldID = fieldID + '_rich_text';
+                        }
+
+                        extraData[richTextFieldID] =
+                            $field.hasClass('rich-text');
+                    }
+
+                    if (isCommaEditable) {
+                        fieldInfo.useEditIconOnly = true;
+                        fieldInfo.formatter = function(view, data, $el) {
+                            data = data || [];
+                            $el.html(data.join(', '));
+                        };
+                    } else if (fieldInfo.allowMarkdown) {
+                        fieldInfo.formatter = function(view, data, $el,
+                                                       fieldOptions) {
+                            view.formatText($el, {
+                                newText: data,
+                                fieldOptions: fieldOptions
+                            });
+                        };
+                    }
+
+                    this.registerField(fieldInfo);
+                }
+            }, this);
+
+            /*
+             * Set up editors for every registered field.
+             */
+            _.each(this._fieldEditors, function(fieldOptions, fieldID) {
+                this.setupFieldEditor(fieldID);
+            }, this);
+        }
+
+        /*
+         * We need to show any banners before we continue with field setup,
+         * since the banners register and set up fields as well.
+         *
+         * If we do this any later, formatText() will be called prematurely,
+         * preventing proper Markdown text loading and saving from working
+         * correctly.
+         */
+        this.showBanner();
+
+        /*
+         * Let's resume with the field setup now.
+         */
+        if (this._hasFields) {
+            /*
+             * Linkify any text in the description, testing done, and change
+             * description fields.
+             *
+             * Do this as soon as possible, so that we don't show spinners for
+             * too long. It must be done after the fields are set up,
+             * though.
+             */
+            _.each(this.$('.field-text-area'), function(el) {
+                this.formatText($(el));
+            }, this);
+
+            if (this.model.get('editable')) {
+                this.dndUploader = new RB.DnDUploader({
+                    reviewRequestEditor: this.model
+                });
+            }
+
+            /*
+             * Update the layout constraints any time these properties
+             * change. Also, right away.
+             */
+            $(window).resize(this._scheduleResizeLayout);
+            this.listenTo(this.model, 'change:editCount', this._checkResizeLayout);
+            this._checkResizeLayout();
+
+            if (this.issueSummaryTableView) {
+                this.issueSummaryTableView.render();
+            }
+
+            fileAttachments.each(function(fileAttachment) {
+                this.buildFileAttachmentThumbnail(
+                    fileAttachment, fileAttachments, { noAnimation: true });
+            }, this);
+            fileAttachments.on('add', this.buildFileAttachmentThumbnail, this);
+
+            /*
+             * Import all the screenshots and file attachments rendered onto
+             * the page.
+             */
+            _.each(this._$screenshots.find('.screenshot-container'),
+                   this._importScreenshotThumbnail,
+                   this);
+            _.each($('.binary'),
+                   this._importFileAttachmentThumbnail,
+                   this);
+        }
+
+        this._setupActions();
+
+        this.model.on('publishError', function(errorText) {
+            alert(errorText);
+
+            this.$('#btn-draft-publish').enable();
+            this.$('#btn-draft-discard').enable();
+        }, this);
+
+        this.model.on('closeError', function(errorText) {
+            alert(errorText);
+        }, this);
+
+        this.model.on('saved', this.showBanner, this);
+        this.model.on('published', this._refreshPage, this);
+        reviewRequest.on('closed reopened', this._refreshPage, this);
+        draft.on('destroyed', this._refreshPage, this);
+
+        /*
+         * Warn the user if they try to navigate away with unsaved comments.
+         */
+        window.onbeforeunload = _.bind(function(evt) {
+            if ((this.model.get('editable') ||
+                 this.model.get('statusEditable')) &&
+                this.model.get('editCount') > 0) {
+                /*
+                 * On IE, the text must be set in evt.returnValue.
+                 *
+                 * On Firefox, it must be returned as a string.
+                 *
+                 * On Chrome, it must be returned as a string, but you
+                 * can't set it on evt.returnValue (it just ignores it).
+                 */
+                var msg = gettext("You have unsaved changes that will be lost if you navigate away from this page.");
+                evt = evt || window.event;
+
+                evt.returnValue = msg;
+                return msg;
+            }
+        }, this);
+
+        return this;
+    },
+
+    /*
+     * Sets up an editor for the given field.
+     *
+     * This will build the editor for a field and update the field contents
+     * any time the matching field changes on a draft.
+     */
+    setupFieldEditor: function(fieldID) {
+        var fieldOptions = this._fieldEditors[fieldID],
+            $el = this.$(fieldOptions.selector),
+            listenObj;
+
+        if ($el.length === 0) {
+            return;
+        }
+
+        this._buildEditor($el, fieldOptions);
+
+        if (_.has(fieldOptions, 'autocomplete')) {
+            this._buildAutoComplete($el, fieldOptions.autocomplete);
+            $el.inlineEditor('setupEvents');
+        }
+
+        this.listenTo(this.model, 'fieldChanged:' + fieldOptions.fieldName,
+                      _.bind(this._formatField, this, fieldOptions));
+    },
+
+    /*
+     * Shows a banner for the given state of the review request.
+     */
+    showBanner: function() {
+        var BannerClass,
+            reviewRequest = this.model.get('reviewRequest'),
+            state = reviewRequest.get('state'),
+            $existingBanner = this._$bannersContainer.children();
+
+        if (this.banner) {
+            return;
+        }
+
+        if (state === RB.ReviewRequest.CLOSE_SUBMITTED) {
+            BannerClass = SubmittedBannerView;
+        } else if (state === RB.ReviewRequest.CLOSE_DISCARDED) {
+            BannerClass = DiscardedBannerView;
+        } else if (state === RB.ReviewRequest.PENDING &&
+                   this.model.get('hasDraft')) {
+            BannerClass = DraftBannerView;
+        } else {
+            return;
+        }
+
+        console.assert(BannerClass);
+        console.assert($existingBanner.length <= 1);
+
+        if ($existingBanner.length === 0) {
+            $existingBanner = undefined;
+        }
+
+        this.banner = new BannerClass({
+            el: $existingBanner,
+            reviewRequestEditorView: this
+        });
+
+        if ($existingBanner) {
+            $existingBanner.show();
+        } else {
+            this.banner.$el.appendTo(this._$bannersContainer);
+        }
+
+        this.banner.render();
+    },
+
+    /*
+     * Handler for when the Publish Draft button is clicked.
+     *
+     * Begins publishing the review request. If there are any field editors
+     * still open, they'll be saved first.
+     */
+    publishDraft: function(options) {
+        /* Save all the fields if we need to. */
+        var fields = this.$(".editable:inlineEditorDirty");
+
+        this.model.set({
+            publishing: true,
+            pendingSaveCount: fields.length
+        });
+
+        if (fields.length === 0) {
+            this.model.publishDraft(options);
+        } else {
+            fields.inlineEditor("submit");
+        }
+    },
+
+    /*
+     * Converts an array of items to a list of hyperlinks.
+     *
+     * By default, this will use the item as the URL and as the hyperlink text.
+     * By overriding urlFunc and textFunc, the URL and text can be customized.
+     */
+    urlizeList: function(list, urlFunc, textFunc) {
+        var str = '',
+            len,
+            item,
+            i;
+
+        if (!list) {
+            return '';
+        }
+
+        len = list.length;
+
+        for (i = 0; i < len; i++) {
+            item = list[i];
+
+            str += '<a href="';
+            str += (urlFunc ? urlFunc(item) : item);
+            str += '">';
+            str += (textFunc ? textFunc(item) : item);
+            str += '</a>';
+
+            if (i < len - 1) {
+                str += ', ';
+            }
+        }
+
+        return str;
+    },
+
+    /*
+     * Linkifies a block of text, turning URLs, /r/#/ paths, and bug numbers
+     * into clickable links.
+     *
+     * This is a wrapper around RB.formatText that handles passing in the bug
+     * tracker.
+     */
+    formatText: function($el, options) {
+        var reviewRequest = this.model.get('reviewRequest'),
+            fieldOptions;
+
+        options = _.defaults({
+            bugTrackerURL: reviewRequest.get('bugTrackerURL'),
+            isHTMLEncoded: true
+        }, options);
+
+        fieldOptions = options.fieldOptions;
+
+        if (fieldOptions && fieldOptions.richTextAttr) {
+            options.richText = this.model.getDraftField(
+                fieldOptions.richTextAttr,
+                fieldOptions);
+        }
+
+        RB.formatText($el, options);
+
+        $el.find('img').load(this._checkResizeLayout);
+    },
+
+    /*
+     * Sets up all review request actions and listens for events.
+     */
+    _setupActions: function() {
+        var $closeDiscarded = this.$('#discard-review-request-link'),
+            $closeSubmitted = this.$('#link-review-request-close-submitted'),
+            $deletePermanently = this.$('#delete-review-request-link'),
+            $updateDiff = this.$('#upload-diff-link');
+
+        /*
+         * We don't want the click event filtering from these down to the
+         * parent menu, so we can't use events above.
+         */
+        $closeDiscarded.click(this._onCloseDiscardedClicked);
+        $closeSubmitted.click(this._onCloseSubmittedClicked);
+        $deletePermanently.click(this._onDeleteReviewRequestClicked);
+        $updateDiff.click(this._onUpdateDiffClicked);
+    },
+
+    /*
+     * Builds a thumbnail for a FileAttachment.
+     *
+     * The thumbnail will eb added to the page. The editor will listen
+     * for events on the thumbnail to update the current edit state.
+     *
+     * This can be called either when dynamically adding a new file
+     * attachment (through drag-and-drop or Add File), or after importing
+     * from the rendered page.
+     */
+    buildFileAttachmentThumbnail: function(fileAttachment, collection,
+                                           options) {
+        var fileAttachmentComments = this.model.get('fileAttachmentComments'),
+            $thumbnail,
+            view;
+
+        options = options || {};
+
+        $thumbnail = options.$el;
+
+        view = new RB.FileAttachmentThumbnail({
+            el: $thumbnail,
+            model: fileAttachment,
+            comments: fileAttachmentComments[fileAttachment.id],
+            renderThumbnail: ($thumbnail === undefined),
+            reviewRequest: this.model.get('reviewRequest'),
+            canEdit: (this.model.get('editable') === true)
+        });
+
+        view.render();
+
+        if (!$thumbnail) {
+            /* This is a newly added file attachment. */
+            this._$attachmentsContainer.show();
+            view.$el.insertBefore(this._$attachments.children('br'));
+
+            if (!options.noAnimation) {
+                view.fadeIn();
+            }
+        }
+
+        this.listenTo(view, 'hoverIn', function($thumbnail) {
+            this._$attachments
+                .find('.file')
+                .not(
+                    $thumbnail.find('.file')[0]
+                ).addClass('faded');
+        });
+
+        this.listenTo(view, 'hoverOut', function() {
+            this._$attachments.find('.file').removeClass('faded');
+        });
+
+        view.on('beginEdit', function() {
+            this.model.incr('editCount');
+        }, this);
+
+        view.on('endEdit', function() {
+            this.model.decr('editCount');
+        }, this);
+
+        view.on('commentSaved', function() {
+            RB.DraftReviewBannerView.instance.show();
+        }, this);
+    },
+
+    /*
+     * Imports file attachments from the rendered page.
+     *
+     * Each file attachment already rendered will be turned into a
+     * FileAttachment, and a new thumbnail will be built for it.
+     */
+    _importFileAttachmentThumbnail: function(thumbnailEl) {
+        var $thumbnail = $(thumbnailEl),
+            id = $thumbnail.data('file-id'),
+            $caption = $thumbnail.find('.file-caption .edit'),
+            reviewRequest = this.model.get('reviewRequest'),
+            fileAttachment = reviewRequest.draft.createFileAttachment({
+                id: id
+            });
+
+        if (!$caption.hasClass('empty-caption')) {
+            fileAttachment.set('caption', $caption.text());
+        }
+
+        this.model.get('fileAttachments').add(fileAttachment, {
+            $el: $thumbnail
+        });
+    },
+
+    /*
+     * Imports screenshots from the rendered page.
+     *
+     * Each screenshot already rendered will be turned into a Screenshot.
+     */
+    _importScreenshotThumbnail: function(thumbnailEl) {
+        var $thumbnail = $(thumbnailEl),
+            id = $thumbnail.data('screenshot-id'),
+            reviewRequest = this.model.get('reviewRequest'),
+            screenshot = reviewRequest.createScreenshot(id),
+            view = new RB.ScreenshotThumbnail({
+                el: $thumbnail,
+                model: screenshot
+            });
+
+        view.render();
+
+        this.model.get('screenshots').add(screenshot);
+
+        view.on('beginEdit', function() {
+            this.model.incr('editCount');
+        }, this);
+
+        view.on('endEdit', function() {
+            this.model.decr('editCount');
+        }, this);
+    },
+
+    /*
+     * Adds inline editing capabilities to a field for a review request.
+     */
+    _buildEditor: function($el, fieldOptions) {
+        var model = this.model,
+            el = $el[0],
+            id = el.id,
+            editableProp = (fieldOptions.statusField
+                            ? 'statusEditable'
+                            : 'editable'),
+            multiline = $el.hasClass('field-text-area'),
+            options = {
+                cls: id + '-editor',
+                editIconClass: 'rb-icon rb-icon-edit',
+                enabled: this.model.get(editableProp),
+                multiline: multiline,
+                useEditIconOnly: fieldOptions.useEditIconOnly,
+                showRequiredFlag: $el.hasClass('required'),
+                deferEventSetup: _.has(fieldOptions, 'autocomplete')
+            };
+
+        if (fieldOptions.allowMarkdown) {
+            _.extend(
+                options,
+                RB.TextEditorView.getInlineEditorOptions({
+                    minHeight: 0,
+                    richText: model.getDraftField(fieldOptions.richTextAttr,
+                                                  fieldOptions)
+                }),
+                {
+                    matchHeight: false,
+                    hasRawValue: true,
+                    rawValue: model.getDraftField(fieldOptions.fieldName,
+                                                  fieldOptions) || ''
+                });
+        }
+
+        $el
+            .inlineEditor(options)
+            .on({
+                beginEdit: function() {
+                    model.incr('editCount');
+                },
+                cancel: _.bind(function() {
+                    this._scheduleResizeLayout();
+                    model.decr('editCount');
+                }, this),
+                complete: _.bind(function(e, value) {
+                    var extraOptions = {},
+                        textEditor;
+
+                    if (fieldOptions.allowMarkdown) {
+                        textEditor =
+                            RB.TextEditorView.getFromInlineEditor($el);
+
+                        extraOptions.richText = textEditor.richText;
+                    }
+
+                    this._scheduleResizeLayout();
+                    model.decr('editCount');
+                    model.setDraftField(
+                        fieldOptions.fieldName,
+                        value,
+                        _.defaults({
+                            error: function(error) {
+                                this._formatField(fieldOptions);
+                                this._$warning
+                                    .delay(6000)
+                                    .fadeOut(400, function() {
+                                        $(this).hide();
+                                    })
+                                    .show()
+                                    .html(error.errorText);
+                            },
+                            success: function() {
+                                this._formatField(fieldOptions);
+                                this.showBanner();
+                            }
+                        }, fieldOptions, extraOptions),
+                        this);
+                }, this),
+                resize: this._checkResizeLayout
+            });
+
+        this.listenTo(
+            this.model,
+            'change:' + editableProp,
+            function(model, editable) {
+                $el.inlineEditor(editable ? 'enable' : 'disable');
+            });
+    },
+
+    /*
+     * Adds auto-complete functionality to a field.
+     *
+     * options expects the following fields:
+     *
+     *    fieldName   - The field name ("groups" or "people").
+     *    nameKey     - The key containing the name in the result data.
+     *    descKey     - The key containing the description in the result
+     *                  data. This is optional.
+     *    extraParams - Extra parameters to send in the query. This is optional.
+     */
+    _buildAutoComplete: function($el, options) {
+        var reviewRequest = this.model.get('reviewRequest');
+
+        $el.inlineEditor('field')
+            .rbautocomplete({
+                formatItem: function(data) {
+                    var s = data[options.nameKey];
+
+                    if (options.descKey && data[options.descKey]) {
+                        s += ' <span>(' + _.escape(data[options.descKey]) +
+                             ')</span>';
+                    }
+
+                    return s;
+                },
+                matchCase: false,
+                multiple: true,
+                parse: function(data) {
+                    var items = data[options.fieldName],
+                        itemsLen = items.length,
+                        parsed = [],
+                        value,
+                        i;
+
+                    for (i = 0; i < itemsLen; i++) {
+                        value = items[i];
+
+                        parsed.push({
+                            data: value,
+                            value: value[options.nameKey],
+                            result: value[options.nameKey]
+                        });
+                    }
+
+                    return parsed;
+                },
+                url: SITE_ROOT + reviewRequest.get('localSitePrefix') +
+                     'api/' + (options.resourceName || options.fieldName) + '/',
+                extraParams: options.extraParams,
+                cmp: options.cmp,
+                width: 350,
+                error: function(xhr) {
+                    var text;
+
+                    try {
+                        text = $.parseJSON(xhr.responseText).err.msg;
+                    } catch (e) {
+                        text = 'HTTP ' + xhr.status + ' ' + xhr.statusText;
+                    }
+
+                    alert(text);
+                }
+            })
+            .on('autocompleteshow', function() {
+                /*
+                 * Add the footer to the bottom of the results pane the
+                 * first time it's created.
+                 *
+                 * Note that we may have multiple .ui-autocomplete-results
+                 * elements, and we don't necessarily know which is tied to
+                 * this. So, we'll look for all instances that don't contain
+                 * a footer.
+                 */
+                var resultsPane = $('.ui-autocomplete-results:not(' +
+                                    ':has(.ui-autocomplete-footer))');
+
+                if (resultsPane.length > 0) {
+                    $('<div/>')
+                        .addClass('ui-autocomplete-footer')
+                        .text(gettext('Press Tab to auto-complete.'))
+                        .appendTo(resultsPane);
+                }
+            });
+    },
+
+    /*
+     * Wrapper for _resizeLayout that verifies that there's actually a layout
+     * to resize.
+     */
+    _checkResizeLayout: function() {
+        /*
+         * Not every page that uses this has a #review_request_main element
+         * (for instance, review UIs want to have the draft banners but not
+         * the review request box). In this case, just skip all of this.
+         */
+        if (this._$main.length !== 0 && !this._blockResizeLayout) {
+            this._resizeLayout();
+        }
+    },
+
+    /*
+     * Resizes the layout in response to size or position changes of fields.
+     *
+     * This will spread out the main text fields to cover the full height of
+     * the review request box's main area. That helps keep a consistent look
+     * and prevents a bunch of wasted-looking space.
+     */
+    _resizeLayout: function() {
+        var $lastContent = this._$main.children('.content:last-child'),
+            $lastFieldContainer = $lastContent.children('.field-container'),
+            $lastEditable = $lastFieldContainer.children('.editable'),
+            lastContentTop = Math.ceil($lastContent.position().top),
+            editor = $lastEditable.inlineEditor('field').data('text-editor'),
+            detailsWidth = 300, // Defined as @details-width in reviews.less
+            detailsPadding = 10,
+            $detailsBody = $('#review_request_details tbody'),
+            $detailsLabels = $detailsBody.find('th:first-child'),
+            $detailsValues = $detailsBody.find('span'),
+            contentHeight,
+            newEditableHeight,
+            height;
+
+        this._blockResizeLayout = true;
+
+        /*
+         * Make sure that the details fields wrap correctly, even if they don't
+         * have wrappable characters (this combines with the white-space:
+         * word-wrap: break-word style). This computation makes things handle
+         * potentially unknown field labels correctly.
+         */
+        $detailsValues.css('max-width', (detailsWidth -
+                                         $detailsLabels.outerWidth() -
+                                         detailsPadding * 3) + 'px');
+
+        /*
+         * Reset all the heights so we can do calculations based on their
+         * native sizes.
+         */
+        this._$main.height('auto');
+        $lastContent.height('auto');
+        $lastEditable.height('auto');
+
+        if (editor) {
+            editor.setSize(null, 'auto');
+        }
+
+        /*
+         * Set the review request box's main height to take up the full
+         * amount of spaces between its top and the top of the "extra"
+         * pane (where the issue summary table and stuff live).
+         */
+        this._$main.height(this._$extra.offset().top -
+                           this._$main.offset().top);
+        height = this._$main.height();
+
+        if ($lastContent.outerHeight() + lastContentTop < height) {
+            $lastContent.outerHeight(height - lastContentTop);
+
+            /*
+             * Get the size of the content box, and factor in the padding at
+             * the bottom, to balance out position()'s calculation of the
+             * padding at the top. This ensures we get a height that matches
+             * the content area of the content box.
+             */
+            contentHeight = $lastContent.height() -
+                            Math.ceil($lastFieldContainer.position().top);
+
+            /*
+             * Set the height of the editor or the editable field placeholder,
+             * depending on whether we're in edit mode. There's no need to do
+             * both, since this logic will be called again when the state
+             * changes.
+             */
+            if ($lastEditable.inlineEditor('editing') && editor) {
+                editor.setSize(
+                    null,
+                    contentHeight -
+                    $lastEditable.inlineEditor('buttons').height());
+            } else {
+                /*
+                 * It's possible to squish the editable element if we force
+                 * a size, so make sure it's always at least the natural
+                 * height.
+                 */
+                newEditableHeight = contentHeight +
+                                    $lastEditable.getExtents('m', 'tb');
+
+                if (newEditableHeight > $lastEditable.outerHeight()) {
+                    $lastEditable.outerHeight(newEditableHeight);
+                }
+            }
+        }
+
+        this._blockResizeLayout = false;
+    },
+
+    /*
+     * Schedules a layout resize after the stack unwinds.
+     *
+     * This will only trigger a layout resize after the stack has unwound,
+     * and only once every 100 milliseconds at most.
+     */
+    _scheduleResizeLayout: _.throttle(function() {
+        _.defer(this._checkResizeLayout);
+    }, 100),
+
+    /*
+     * Formats the contents of a field.
+     *
+     * If there's a registered field formatter for this field, it will
+     * be used to display the contents of a field in the draft.
+     */
+    _formatField: function(fieldOptions) {
+        var formatter = fieldOptions.formatter,
+            $el = this.$(fieldOptions.selector),
+            value = this.model.getDraftField(fieldOptions.fieldName,
+                                             fieldOptions);
+
+        if (_.isFunction(formatter)) {
+            formatter.call(fieldOptions.context || this, this, value, $el,
+                           fieldOptions);
+        } else {
+            $el.text(value);
+        }
+    },
+
+    /*
+     * Handler for when Close -> Discarded is clicked.
+     *
+     * The user will be asked for confirmation before the review request is
+     * discarded.
+     */
+    _onCloseDiscardedClicked: function() {
+        var confirmText = gettext(
+            "Are you sure you want to discard this review request?");
+
+        if (confirm(confirmText)) {
+            this.model.get('reviewRequest').close({
+                type: RB.ReviewRequest.CLOSE_DISCARDED,
+                error: function(model, xhr) {
+                    this.model.trigger('closeError', xhr.errorText);
+                }
+            }, this);
+        }
+
+        return false;
+    },
+
+    /*
+     * Handler for when Close -> Submitted is clicked.
+     *
+     * If there's an unpublished draft, this will first confirm if the
+     * user is sure.
+     */
+    _onCloseSubmittedClicked: function() {
+        /*
+         * This is a non-destructive event, so don't confirm unless there's
+         * a draft.
+         */
+        var submit = true;
+
+        if (this.banner) {
+            submit = confirm(gettext("You have an unpublished draft. If you close this review request, the draft will be discarded. Are you sure you want to close the review request?"));
+        }
+
+        if (submit) {
+            this.model.get('reviewRequest').close({
+                type: RB.ReviewRequest.CLOSE_SUBMITTED,
+                error: function(model, xhr) {
+                    this.model.trigger('closeError', xhr.errorText);
+                }
+            }, this);
+        }
+
+        return false;
+    },
+
+    /*
+     * Handler for Close -> Delete Permanently.
+     *
+     * The user will be asked for confirmation before the review request is
+     * deleted.
+     */
+    _onDeleteReviewRequestClicked: function() {
+        var dlg = $("<p/>")
+            .text(gettext("This deletion cannot be undone. All diffs and reviews will be deleted as well."))
+            .modalBox({
+                title: gettext("Are you sure you want to delete this review request?"),
+                buttons: [
+                    $('<input type="button" value="' + gettext('Cancel') + '"/>'),
+                    $('<input type="button" value="' + gettext('Delete') + '"/>')
+                        .click(_.bind(function() {
+                            this.model.get('reviewRequest').destroy({
+                                buttons: $("input", dlg.modalBox("buttons")),
+                                success: function() {
+                                    window.location = SITE_ROOT;
+                                }
+                            });
+                        }, this))
+                ]
+            });
+
+        return false;
+    },
+
+    /*
+     * Handler for Update -> Update Diff.
+     */
+    _onUpdateDiffClicked: function() {
+        var reviewRequest = this.model.get('reviewRequest');
+            updateDiffView = new RB.UpdateDiffView({
+                model: new RB.UploadDiffModel({
+                    changeNumber: reviewRequest.get('commitID'),
+                    repository: reviewRequest.get('repository'),
+                    reviewRequest: reviewRequest
+                })
+            });
+
+        updateDiffView.render();
+    },
+
+    /*
+     * Handler for when Archive -> Archive is clicked.
+     */
+    _onArchiveClicked: function() {
+        return this._updateArchiveState(
+            RB.UserSession.instance.archivedReviewRequests,
+            true,
+            RB.ReviewRequest.VISIBILITY_ARCHIVED);
+    },
+
+    /*
+     * Handler for when Archive -> Unarchive is clicked.
+     */
+    _onUnarchiveClicked: function() {
+        return this._updateArchiveState(
+            RB.UserSession.instance.archivedReviewRequests,
+            false,
+            RB.ReviewRequest.VISIBILITY_VISIBLE);
+    },
+
+    /*
+     * Handler for when Archive -> Mute is clicked.
+     */
+    _onMuteClicked: function() {
+        return this._updateArchiveState(
+            RB.UserSession.instance.mutedReviewRequests,
+            true,
+            RB.ReviewRequest.VISIBILITY_MUTED);
+    },
+
+    /*
+     * Handler for when Archive -> Unmute is clicked.
+     */
+    _onUnmuteClicked: function() {
+        return this._updateArchiveState(
+            RB.UserSession.instance.mutedReviewRequests,
+            false,
+            RB.ReviewRequest.VISIBILITY_VISIBLE);
+    },
+
+    /*
+     * Helper for updating archive/mute state.
+     */
+    _updateArchiveState: function(collection, add, newState) {
+        var reviewRequest = this.model.get('reviewRequest'),
+            options = {
+                success: function() {
+                    reviewRequest.set('visibility', newState);
+                }
+            };
+
+        if (add) {
+            collection.addImmediately(reviewRequest, options, this);
+        } else {
+            collection.removeImmediately(reviewRequest, options, this);
+        }
+
+        return false;
+    },
+
+    /*
+     * Update the visibility of the archive/mute menu items.
+     */
+    _updateArchiveVisibility: function() {
+        var visibility = this.model.get('reviewRequest').get('visibility'),
+            iconClass;
+
+        this.$('#hide-review-request-menu').html(this._archiveActionsTemplate({
+            visibility: visibility,
+            archiveText: gettext('Archive'),
+            muteText: gettext('Mute'),
+            unarchiveText: gettext('Unarchive'),
+            unmuteText: gettext('Unmute')
+        }));
+
+        iconClass = (visibility === RB.ReviewRequest.VISIBILITY_VISIBLE
+                     ? 'rb-icon-archive-off' : 'rb-icon-archive-on');
+
+        this.$('#hide-review-request-link')
+            .html('<span class="rb-icon ' + iconClass + '"></span>');
+    },
+
+    /*
+     * Generic handler for menu clicks.
+     *
+     * This simply prevents the click from bubbling up or invoking the
+     * default action.  This function is used for dropdown menu titles
+     * so that their links do not send a request to the server when one
+     * of their dropdown actions are clicked.
+     */
+    _onMenuClicked: function() {
+        return false;
+    },
+
+    _refreshPage: function() {
+        window.location = this.model.get('reviewRequest').get('reviewURL');
+    }
+});
+
+
+})();
--- a/reviewboard/reviewboard/staticbundles.py
+++ b/reviewboard/reviewboard/staticbundles.py
@@ -224,17 +224,17 @@ PIPELINE_JS = dict({
             'rb/js/views/issueSummaryTableView.js',
             'rb/js/views/regionCommentBlockView.js',
             'rb/js/views/changeBoxView.js',
             'rb/js/views/reviewBoxListView.js',
             'rb/js/views/reviewBoxView.js',
             'rb/js/views/reviewDialogView_mozreview.js',
             'rb/js/views/reviewReplyDraftBannerView.js',
             'rb/js/views/reviewReplyEditorView.js',
-            'rb/js/views/reviewRequestEditorView.js',
+            'rb/js/views/reviewRequestEditorView_mozreview.js',
             'rb/js/views/screenshotThumbnailView.js',
             'rb/js/views/imageReviewableView.js',
             'rb/js/views/dummyReviewableView.js',
             'rb/js/views/textBasedCommentBlockView.js',
             'rb/js/views/textBasedReviewableView.js',
             'rb/js/views/inlineCommentView_mozreview.js',
             'rb/js/views/textCommentRowSelector_mozreview.js',
             'rb/js/views/markdownReviewableView.js',