mediawiki-extensions-Discus.../modules/dt.ui.ReplyWidget.js

762 lines
23 KiB
JavaScript
Raw Normal View History

var controller = require( 'ext.discussionTools.init' ).controller,
modifier = require( 'ext.discussionTools.init' ).modifier,
utils = require( 'ext.discussionTools.init' ).utils,
logger = require( 'ext.discussionTools.init' ).logger,
dtConf = require( 'ext.discussionTools.init' ).config,
enable2017Wikitext = dtConf.enable2017Wikitext;
require( './AbandonCommentDialog.js' );
require( './AbandonTopicDialog.js' );
/**
* @external CommentController
* @external CommentItem
*/
/**
* DiscussionTools ReplyWidget class
*
* @class mw.dt.ReplyWidget
* @extends OO.ui.Widget
* @constructor
* @param {CommentController} commentController Comment controller
* @param {CommentItem} comment Comment item
* @param {string} pageName Page name the reply is being saved to
* @param {number} oldId Revision ID of page at time of editing
* @param {Object} [config] Configuration options
* @param {Object} [config.input] Configuration options for the comment input widget
*/
function ReplyWidget( commentController, comment, pageName, oldId, config ) {
var returnTo, contextNode, inputConfig,
widget = this;
config = config || {};
// Parent constructor
ReplyWidget.super.call( this, config );
this.pending = false;
this.commentController = commentController;
this.comment = comment;
this.isNewTopic = !!comment.isNewTopic;
this.pageName = pageName;
this.oldId = oldId;
contextNode = utils.closestElement( comment.range.endContainer, [ 'dl', 'ul', 'ol' ] );
this.context = contextNode ? contextNode.nodeName.toLowerCase() : 'dl';
// TODO: Should storagePrefix include pageName?
this.storagePrefix = 'reply/' + comment.id;
this.storage = mw.storage.session;
// eslint-disable-next-line no-jquery/no-global-selector
this.contentDir = $( '#mw-content-text' ).css( 'direction' );
inputConfig = $.extend(
{
placeholder: this.isNewTopic ?
mw.msg( 'discussiontools-replywidget-placeholder-newtopic' ) :
mw.msg( 'discussiontools-replywidget-placeholder-reply', comment.author ),
authors: comment.getHeading().getAuthorsBelow()
},
config.input
);
this.replyBodyWidget = this.createReplyBodyWidget( inputConfig );
this.replyButtonLabel = this.isNewTopic ?
mw.msg( 'discussiontools-replywidget-newtopic' ) :
mw.msg( 'discussiontools-replywidget-reply' );
this.replyButton = new OO.ui.ButtonWidget( {
flags: [ 'primary', 'progressive' ],
label: this.replyButtonLabel,
title: this.replyButtonLabel + ' ' +
// TODO: Use VE keyboard shortcut generating code
( $.client.profile().platform === 'mac' ?
'⌘⏎' :
mw.msg( 'visualeditor-key-ctrl' ) + '+' + mw.msg( 'visualeditor-key-enter' )
)
} );
this.cancelButton = new OO.ui.ButtonWidget( {
flags: [ 'destructive' ],
label: mw.msg( 'discussiontools-replywidget-cancel' ),
framed: false,
title: mw.msg( 'discussiontools-replywidget-cancel' ) + ' ' +
// TODO: Use VE keyboard shortcut generating code
( $.client.profile().platform === 'mac' ?
'⎋' :
mw.msg( 'visualeditor-key-escape' )
)
} );
this.modeTabSelect = new OO.ui.TabSelectWidget( {
classes: [ 'ext-discussiontools-ui-replyWidget-modeTabs' ],
items: [
new OO.ui.TabOptionWidget( {
label: mw.msg( 'discussiontools-replywidget-mode-visual' ),
data: 'visual'
} ),
new OO.ui.TabOptionWidget( {
label: mw.msg( 'discussiontools-replywidget-mode-source' ),
data: 'source'
} )
],
framed: false
} );
// Initialize to avoid flicker when switching mode
this.modeTabSelect.selectItemByData( this.getMode() );
this.$headerWrapper = $( '<div>' ).addClass( 'ext-discussiontools-ui-replyWidget-headerWrapper' );
this.$headerWrapper.append(
// Visual mode toolbar attached here by CommentTarget#attachToolbar
this.modeTabSelect.$element
);
this.$preview = $( '<div>' )
.addClass( 'ext-discussiontools-ui-replyWidget-preview' )
.attr( 'data-label', mw.msg( 'discussiontools-replywidget-preview' ) )
// Set preview direction to content direction
.attr( 'dir', this.contentDir );
this.$actionsWrapper = $( '<div>' ).addClass( 'ext-discussiontools-ui-replyWidget-actionsWrapper' );
this.$actions = $( '<div>' ).addClass( 'ext-discussiontools-ui-replyWidget-actions' ).append(
this.cancelButton.$element,
this.replyButton.$element
);
this.editSummaryInput = new OO.ui.TextInputWidget( {
classes: [ 'ext-discussiontools-ui-replyWidget-editSummary' ]
} );
mw.widgets.visibleCodePointLimit( this.editSummaryInput, mw.config.get( 'wgCommentCodePointLimit' ) );
this.editSummaryField = new OO.ui.FieldLayout(
this.editSummaryInput,
{
align: 'top',
classes: [ 'ext-discussiontools-ui-replyWidget-editSummaryField' ],
label: mw.msg( 'discussiontools-replywidget-summary' )
}
);
this.advancedToggle = new OO.ui.ButtonWidget( {
label: mw.msg( 'discussiontools-replywidget-advanced' ),
indicator: 'down',
framed: false,
flags: [ 'progressive' ],
classes: [ 'ext-discussiontools-ui-replyWidget-advancedToggle' ]
} );
this.advanced = new OO.ui.MessageWidget( {
type: 'message',
$content: this.editSummaryField.$element,
classes: [ 'ext-discussiontools-ui-replyWidget-advanced' ]
} ).toggle( false ).setIcon( '' );
this.$footer = $( '<div>' ).addClass( 'ext-discussiontools-ui-replyWidget-footer' );
if ( this.pageName !== mw.config.get( 'wgRelevantPageName' ) ) {
this.$footer.append( $( '<p>' ).append(
mw.message( 'discussiontools-replywidget-transcluded', this.pageName ).parseDom()
) );
}
this.$footer.append(
$( '<p>' ).addClass( 'plainlinks' ).append(
mw.message( 'discussiontools-replywidget-terms-click', this.replyButtonLabel ).parseDom()
),
$( '<p>' ).append(
$( '<a>' )
.attr( {
href: mw.msg( 'discussiontools-replywidget-feedback-link' ),
target: '_blank',
rel: 'noopener'
} )
.text( mw.msg( 'discussiontools-replywidget-feedback' ) )
)
);
this.$actionsWrapper.append( this.$footer, this.$actions );
// Events
this.replyButton.connect( this, { click: 'onReplyClick' } );
this.cancelButton.connect( this, { click: 'tryTeardown' } );
this.$element.on( 'keydown', this.onKeyDown.bind( this, true ) );
this.beforeUnloadHandler = this.onBeforeUnload.bind( this );
this.unloadHandler = this.onUnload.bind( this );
this.modeTabSelect.connect( this, {
choose: 'onModeTabSelectChoose'
} );
this.advancedToggle.connect( this, { click: 'onAdvancedToggleClick' } );
this.editSummaryInput.connect( this, { change: 'onEditSummaryChange' } );
this.editSummaryInput.$input.on( 'keydown', this.onKeyDown.bind( this, false ) );
if ( this.isNewTopic ) {
this.commentController.sectionTitle.$input.on( 'keydown', this.onKeyDown.bind( this, false ) );
}
this.onInputChangeThrottled = OO.ui.throttle( this.onInputChange.bind( this ), 1000 );
// Initialization
this.$element.addClass( 'ext-discussiontools-ui-replyWidget' ).append(
this.$headerWrapper,
this.replyBodyWidget.$element,
this.$preview,
this.advancedToggle.$element,
this.advanced.$element,
this.$actionsWrapper
);
// Set direction to interface direction
this.$element.attr( 'dir', $( document.body ).css( 'direction' ) );
if ( this.isNewTopic ) {
this.$element.addClass( 'ext-discussiontools-ui-replyWidget-newTopic' );
}
if ( mw.user.isAnon() ) {
returnTo = {
returntoquery: encodeURIComponent( window.location.search ),
returnto: mw.config.get( 'wgPageName' )
};
this.anonWarning = new OO.ui.MessageWidget( {
classes: [ 'ext-discussiontools-ui-replyWidget-anonWarning' ],
type: 'warning',
label: mw.message( 'discussiontools-replywidget-anon-warning' )
.params( [
mw.util.getUrl( 'Special:Userlogin', returnTo ),
mw.util.getUrl( 'Special:Userlogin/signup', returnTo )
] )
.parseDom()
} );
this.anonWarning.$element.append( this.$actions );
this.$element.append( this.anonWarning.$element, this.$footer );
this.$actionsWrapper.detach();
}
this.checkboxesPromise = controller.getCheckboxesPromise( this.pageName, this.oldId );
this.checkboxesPromise.then( function ( checkboxes ) {
var name;
function trackCheckbox( n ) {
mw.track( 'dt.schemaVisualEditorFeatureUse', {
feature: 'dtReply',
action: 'checkbox-' + n
} );
}
if ( checkboxes.checkboxFields ) {
widget.$checkboxes = $( '<div>' ).addClass( 'ext-discussiontools-ui-replyWidget-checkboxes' );
checkboxes.checkboxFields.forEach( function ( field ) {
widget.$checkboxes.append( field.$element );
} );
widget.advanced.$element.prepend( widget.$checkboxes );
// bind logging:
for ( name in checkboxes.checkboxesByName ) {
checkboxes.checkboxesByName[ name ].$element.off( '.dtReply' ).on( 'click.dtReply', trackCheckbox.bind( this, name ) );
}
}
} );
}
/* Inheritance */
OO.inheritClass( ReplyWidget, OO.ui.Widget );
/* Methods */
ReplyWidget.prototype.createReplyBodyWidget = null;
/**
* Focus the widget
*
* @method
* @chainable
* @return {ReplyWidget}
*/
ReplyWidget.prototype.focus = null;
ReplyWidget.prototype.getValue = null;
ReplyWidget.prototype.isEmpty = null;
ReplyWidget.prototype.getMode = null;
/**
* Restore the widget to its original state
*
* Clear any widget values, reset UI states, and clear
* any auto-save values.
*/
ReplyWidget.prototype.clear = function () {
if ( this.errorMessage ) {
this.errorMessage.$element.remove();
}
this.$preview.empty();
this.previewWikitext = null;
this.previewTitle = null;
this.toggleAdvanced( false );
this.storage.remove( this.storagePrefix + '/mode' );
this.storage.remove( this.storagePrefix + '/saveable' );
this.storage.remove( this.storagePrefix + '/summary' );
this.storage.remove( this.storagePrefix + '/showAdvanced' );
};
ReplyWidget.prototype.setPending = function ( pending ) {
this.pending = pending;
if ( pending ) {
this.replyButton.setDisabled( true );
this.cancelButton.setDisabled( true );
this.replyBodyWidget.setReadOnly( true );
this.replyBodyWidget.pushPending();
} else {
this.replyButton.setDisabled( false );
this.cancelButton.setDisabled( false );
this.replyBodyWidget.setReadOnly( false );
this.replyBodyWidget.popPending();
this.updateButtons();
}
};
ReplyWidget.prototype.saveEditMode = function ( mode ) {
controller.getApi().saveOption( 'discussiontools-editmode', mode ).then( function () {
mw.user.options.set( 'discussiontools-editmode', mode );
} );
};
ReplyWidget.prototype.onAdvancedToggleClick = function () {
var
summary, selectFromIndex, titleText, defaultReplyTrail, endCommentIndex,
showAdvanced = !this.showAdvanced;
mw.track( 'dt.schemaVisualEditorFeatureUse', {
feature: 'dtReply',
action: 'advanced-' + ( showAdvanced ? 'show' : 'hide' )
} );
controller.getApi().saveOption( 'discussiontools-showadvanced', +showAdvanced ).then( function () {
mw.user.options.set( 'discussiontools-showadvanced', +showAdvanced );
} );
this.toggleAdvanced( showAdvanced );
if ( showAdvanced ) {
summary = this.editSummaryInput.getValue();
// If the current summary has not been edited yet, select the text following the autocomment to
// make it easier to change. Otherwise, move cursor to end.
selectFromIndex = summary.length;
if ( this.isNewTopic ) {
titleText = this.commentController.sectionTitle.getValue();
if ( summary === this.commentController.generateSummary( titleText ) ) {
selectFromIndex = titleText.length + '/* '.length + ' */ '.length;
}
} else {
// Same as summary.endsWith( defaultReplyTrail )
defaultReplyTrail = '*/ ' + mw.msg( 'discussiontools-defaultsummary-reply' );
endCommentIndex = summary.indexOf( defaultReplyTrail );
if ( endCommentIndex + defaultReplyTrail.length === summary.length ) {
selectFromIndex = endCommentIndex + 3;
}
}
this.editSummaryInput.selectRange( selectFromIndex, summary.length );
this.editSummaryInput.focus();
} else {
this.focus();
}
};
ReplyWidget.prototype.toggleAdvanced = function ( showAdvanced ) {
this.showAdvanced = showAdvanced === undefined ? !this.showAdvanced : showAdvanced;
this.advanced.toggle( !!this.showAdvanced );
this.advancedToggle.setIndicator( this.showAdvanced ? 'up' : 'down' );
this.storeEditSummary();
this.storage.set( this.storagePrefix + '/showAdvanced', this.showAdvanced ? '1' : '' );
};
ReplyWidget.prototype.onEditSummaryChange = function () {
this.storeEditSummary();
};
ReplyWidget.prototype.storeEditSummary = function () {
this.storage.set( this.storagePrefix + '/summary', this.getEditSummary() );
};
ReplyWidget.prototype.getEditSummary = function () {
return this.editSummaryInput.getValue();
};
ReplyWidget.prototype.onModeTabSelectChoose = function ( option ) {
var promise,
mode = option.getData(),
widget = this;
if ( mode === this.getMode() ) {
return;
}
this.setPending( true );
this.modeTabSelect.setDisabled( true );
switch ( mode ) {
case 'source':
promise = this.commentController.switchToWikitext();
break;
case 'visual':
promise = this.commentController.switchToVisual();
break;
}
// TODO: We rely on #setup to call #saveEditMode, so when we have 2017WTE
// we will need to save the new preference here as switching will not
// reload the editor.
promise.then( function () {
// Switch succeeded
mw.track( 'dt.schemaVisualEditorFeatureUse', {
feature: 'editor-switch',
action: (
mode === 'visual' ?
'visual' :
( enable2017Wikitext ? 'source-nwe' : 'source' )
) + '-desktop'
} );
}, function () {
// Switch failed, restore previous tab selection
widget.modeTabSelect.selectItemByData( mode === 'source' ? 'visual' : 'source' );
} ).always( function () {
widget.setPending( false );
widget.modeTabSelect.setDisabled( false );
} );
};
/**
* Setup the widget
*
* @param {Object} [data] Initial data
* @param {Mixed} [data.value] Initial value
* @param {string} [data.showAdvanced] Whether the "Advanced" menu is initially visible
* @param {string} [data.editSummary] Initial edit summary
* @chainable
* @return {ReplyWidget}
*/
ReplyWidget.prototype.setup = function ( data ) {
var title, summary;
data = data || {};
this.bindBeforeUnloadHandler();
if ( this.modeTabSelect ) {
this.modeTabSelect.selectItemByData( this.getMode() );
this.saveEditMode( this.getMode() );
}
summary = this.storage.get( this.storagePrefix + '/summary' ) || data.editSummary;
if ( !summary ) {
if ( this.isNewTopic ) {
// Edit summary is filled in when the user inputs the topic title,
// in NewTopicController#onSectionTitleChange
summary = '';
} else {
title = this.comment.getHeading().getLinkableTitle();
summary = ( title ? '/* ' + title + ' */ ' : '' ) +
mw.msg( 'discussiontools-defaultsummary-reply' );
}
}
this.toggleAdvanced(
!!this.storage.get( this.storagePrefix + '/showAdvanced' ) ||
!!+mw.user.options.get( 'discussiontools-showadvanced' ) ||
!!data.showAdvanced
);
this.editSummaryInput.setValue( summary );
if ( this.isNewTopic ) {
this.commentController.sectionTitle.connect( this, { change: 'onInputChangeThrottled' } );
}
return this;
};
ReplyWidget.prototype.afterSetup = function () {
// Init preview and button state
this.onInputChange();
// Autosave
this.storage.set( this.storagePrefix + '/mode', this.getMode() );
};
/**
* Try to teardown the widget, prompting the user if unsaved changes will be lost.
*
* @chainable
* @return {jQuery.Promise} Resolves if widget was torn down, rejects if it wasn't
*/
ReplyWidget.prototype.tryTeardown = function () {
var promise,
widget = this;
if ( !this.isEmpty() || ( this.isNewTopic && this.commentController.sectionTitle.getValue() ) ) {
promise = OO.ui.getWindowManager().openWindow( this.isNewTopic ? 'abandontopic' : 'abandoncomment' )
.closed.then( function ( data ) {
if ( !( data && data.action === 'discard' ) ) {
return $.Deferred().reject().promise();
}
logger( {
action: 'abort',
mechanism: 'cancel',
type: 'abandon'
} );
} );
} else {
promise = $.Deferred().resolve().promise();
logger( {
action: 'abort',
mechanism: 'cancel',
type: 'nochange'
} );
}
promise = promise.then( function () {
widget.teardown( true );
} );
return promise;
};
/**
* Teardown the widget
*
* @param {boolean} [abandoned] Widget was torn down after a reply was abandoned
* @chainable
* @return {ReplyWidget}
*/
ReplyWidget.prototype.teardown = function ( abandoned ) {
if ( this.isNewTopic ) {
this.commentController.sectionTitle.disconnect( this );
}
// Make sure that the selector is blurred before it gets removed from the document, otherwise
// event handlers for arrow keys are not removed, and it keeps trying to switch modes (T274423)
this.modeTabSelect.blur();
this.unbindBeforeUnloadHandler();
this.clear();
this.emit( 'teardown', abandoned );
return this;
};
ReplyWidget.prototype.onKeyDown = function ( isMultiline, e ) {
if ( e.which === OO.ui.Keys.ESCAPE ) {
this.tryTeardown();
return false;
}
// VE surfaces already handle CTRL+Enter, but this will catch
// the plain surface, and the edit summary input.
if ( e.which === OO.ui.Keys.ENTER && ( !isMultiline || e.ctrlKey || e.metaKey ) ) {
this.onReplyClick();
return false;
}
};
ReplyWidget.prototype.onInputChange = function () {
this.updateButtons();
this.storage.set( this.storagePrefix + '/saveable', this.isEmpty() ? '' : '1' );
this.preparePreview();
};
/**
* Update the interface with the preview of the given wikitext.
*
* @param {string} [wikitext] Wikitext to preview, defaults to current value
* @return {jQuery.Promise} Promise resolved when we're done
*/
ReplyWidget.prototype.preparePreview = function ( wikitext ) {
var parsePromise, widget, title, indent, signature;
if ( this.getMode() !== 'source' ) {
return $.Deferred().resolve().promise();
}
widget = this;
// For now, indentation is always ':'. If we need context-aware
// indentation we would use the following:
// indent = {
// dl: ':',
// ul: '*',
// ol: '#'
// }[ this.context ];
indent = ':';
wikitext = wikitext !== undefined ? wikitext : this.getValue();
title = this.isNewTopic && this.commentController.sectionTitle.getValue();
if ( this.previewWikitext === wikitext && this.previewTitle === title ) {
return $.Deferred().resolve().promise();
}
this.previewWikitext = wikitext;
this.previewTitle = title;
if ( this.previewRequest ) {
this.previewRequest.abort();
this.previewRequest = null;
}
if ( !wikitext.trim() ) {
parsePromise = $.Deferred().resolve( null ).promise();
} else {
wikitext = this.commentController.doIndentReplacements( wikitext, indent );
if ( !modifier.isWikitextSigned( wikitext ) ) {
// Add signature.
signature = mw.msg( 'discussiontools-signature-prefix' ) + '~~~~';
// Drop opacity of signature in preview to make message body preview clearer.
// Extract any leading spaces outside the <span> markup to ensure accurate previews.
signature = signature.replace( /^( *)(.+)$/, function ( _, leadingSpaces, sig ) {
return leadingSpaces + '<span style="opacity: 0.6;">' + sig + '</span>';
} );
wikitext += signature;
}
if ( title ) {
wikitext = '== ' + title + ' ==\n' + wikitext;
}
this.previewRequest = parsePromise = controller.getApi().post( {
action: 'parse',
text: wikitext,
pst: true,
preview: true,
disableeditsection: true,
prop: [ 'text', 'modules', 'jsconfigvars' ],
title: this.pageName
} );
}
// TODO: Add list context
return parsePromise.then( function ( response ) {
widget.$preview.html( response ? response.parse.text : '' );
if ( response ) {
mw.config.set( response.parse.jsconfigvars );
mw.loader.load( response.parse.modulestyles );
mw.loader.load( response.parse.modules );
}
} );
};
ReplyWidget.prototype.updateButtons = function () {
this.replyButton.setDisabled( this.isEmpty() );
};
ReplyWidget.prototype.onFirstTransaction = function () {
logger( { action: 'firstChange' } );
};
/**
* Bind the beforeunload handler, if needed and if not already bound.
*
* @private
*/
ReplyWidget.prototype.bindBeforeUnloadHandler = function () {
$( window ).on( 'beforeunload', this.beforeUnloadHandler );
$( window ).on( 'unload', this.unloadHandler );
};
/**
* Unbind the beforeunload handler if it is bound.
*
* @private
*/
ReplyWidget.prototype.unbindBeforeUnloadHandler = function () {
$( window ).off( 'beforeunload', this.beforeUnloadHandler );
$( window ).off( 'unload', this.unloadHandler );
};
/**
* Respond to beforeunload event.
*
* @private
* @param {jQuery.Event} e Event
* @return {string|undefined}
*/
ReplyWidget.prototype.onBeforeUnload = function ( e ) {
if ( !this.isEmpty() ) {
e.preventDefault();
return '';
}
};
/**
* Respond to unload event.
*
* @private
* @param {jQuery.Event} e Event
*/
ReplyWidget.prototype.onUnload = function () {
logger( {
action: 'abort',
type: this.isEmpty() ? 'nochange' : 'abandon',
mechanism: 'navigate'
} );
};
ReplyWidget.prototype.onReplyClick = function () {
var widget = this,
pageName = this.pageName,
comment = this.comment;
if ( this.pending || this.isEmpty() ) {
return;
}
if ( this.errorMessage ) {
this.errorMessage.$element.remove();
}
this.setPending( true );
logger( { action: 'saveIntent' } );
// TODO: When editing a transcluded page, VE API returning the page HTML is a waste, since we won't use it
logger( { action: 'saveAttempt' } );
widget.commentController.save( comment, pageName ).fail( function ( code, data ) {
// Compare to ve.init.mw.ArticleTargetEvents.js in VisualEditor.
var typeMap = {
badtoken: 'userBadToken',
assertanonfailed: 'userNewUser',
assertuserfailed: 'userNewUser',
assertnameduserfailed: 'userNewUser',
'abusefilter-disallowed': 'extensionAbuseFilter',
'abusefilter-warning': 'extensionAbuseFilter',
captcha: 'extensionCaptcha',
spamblacklist: 'extensionSpamBlacklist',
'titleblacklist-forbidden': 'extensionTitleBlacklist',
pagedeleted: 'editPageDeleted',
editconflict: 'editConflict'
};
if ( widget.captchaMessage ) {
widget.captchaMessage.$element.detach();
}
widget.captchaInput = undefined;
if ( OO.getProp( data, 'discussiontoolsedit', 'edit', 'captcha' ) ) {
code = 'captcha';
widget.captchaInput = new mw.libs.confirmEdit.CaptchaInputWidget(
OO.getProp( data, 'discussiontoolsedit', 'edit', 'captcha' )
);
// Save when pressing 'Enter' in captcha field as it is single line.
widget.captchaInput.on( 'enter', function () {
widget.onReplyClick();
} );
widget.captchaMessage = new OO.ui.MessageWidget( {
type: 'notice',
label: widget.captchaInput.$element,
classes: [ 'ext-discussiontools-ui-replyWidget-captcha' ]
} );
widget.captchaMessage.$element.insertAfter( widget.$preview );
widget.captchaInput.focus();
widget.captchaInput.scrollElementIntoView();
} else {
widget.errorMessage = new OO.ui.MessageWidget( {
type: 'error',
label: code instanceof Error ? code.toString() : controller.getApi().getErrorMessage( data ),
classes: [ 'ext-discussiontools-ui-replyWidget-error' ]
} );
widget.errorMessage.$element.insertBefore( widget.replyBodyWidget.$element );
}
logger( {
action: 'saveFailure',
message: code,
type: typeMap[ code ] || 'responseUnknown'
} );
} ).always( function () {
widget.setPending( false );
} );
};
module.exports = ReplyWidget;