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');
+ }
+});
+
+
+})();