modules/ve-mw/ui/dialogs/ve.ui.MWTemplateDialog.js
/*!
* VisualEditor user interface MWTemplateDialog class.
*
* @copyright See AUTHORS.txt
* @license The MIT License (MIT); see LICENSE.txt
*/
/**
* Abstract base class for dialogs that allow to insert and edit MediaWiki transclusions, i.e. a
* sequence of one or more template invocations that strictly belong to each other (e.g. because
* they are unbalanced), possibly mixed with raw wikitext snippets. Currently used for:
* - {@see ve.ui.MWTransclusionDialog} for arbitrary transclusions. Registered via the name
* "transclusion".
* - {@see ve.ui.MWCitationDialog} in the Cite extension for the predefined citation types from
* [[MediaWiki:visualeditor-cite-tool-definition.json]]. These are strictly limited to a single
* template invocation. Registered via the name "cite".
*
* @class
* @abstract
* @extends ve.ui.NodeDialog
*
* @constructor
* @param {Object} [config] Configuration options
* @property {ve.dm.MWTransclusionModel|null} transclusionModel
* @property {ve.ui.MWTransclusionOutlineWidget} sidebar
* @property {boolean} [canGoBack=false]
*/
ve.ui.MWTemplateDialog = function VeUiMWTemplateDialog( config ) {
// Parent constructor
ve.ui.MWTemplateDialog.super.call( this, config );
// Properties
this.transclusionModel = null;
this.loaded = false;
this.altered = false;
this.canGoBack = false;
this.preventReselection = false;
this.confirmDialogs = new ve.ui.WindowManager( { factory: ve.ui.windowFactory, isolate: true } );
$( OO.ui.getTeleportTarget() ).append( this.confirmDialogs.$element );
};
/* Inheritance */
OO.inheritClass( ve.ui.MWTemplateDialog, ve.ui.NodeDialog );
/* Static Properties */
ve.ui.MWTemplateDialog.static.modelClasses = [ ve.dm.MWTransclusionNode ];
/**
* Configuration for the {@see ve.ui.MWTwoPaneTransclusionDialogLayout} used in this dialog.
*
* @static
* @property {Object}
* @inheritable
*/
ve.ui.MWTemplateDialog.static.bookletLayoutConfig = {};
/* Methods */
/**
* @inheritdoc
*/
ve.ui.MWTemplateDialog.prototype.getReadyProcess = function ( data ) {
return ve.ui.MWTemplateDialog.super.prototype.getReadyProcess.call( this, data )
.next( () => {
if ( this.transclusionModel.isEmpty() ) {
// Focus the template placeholder input field.
this.bookletLayout.focus();
}
this.bookletLayout.getPagesOrdered().forEach( ( page ) => {
if ( page instanceof ve.ui.MWParameterPage ) {
page.updateSize();
}
} );
} );
};
/**
* Update dialog actions whenever the content changes.
*
* @private
*/
ve.ui.MWTemplateDialog.prototype.touch = function () {
if ( this.loaded ) {
this.altered = true;
this.setApplicableStatus();
}
};
/**
* Handle parts being replaced.
*
* @protected
* @param {ve.dm.MWTransclusionPartModel|null} removed Removed part
* @param {ve.dm.MWTransclusionPartModel|null} added Added part
*/
ve.ui.MWTemplateDialog.prototype.onReplacePart = function ( removed, added ) {
const removePages = [];
if ( removed ) {
// Remove parameter pages of removed templates
if ( removed instanceof ve.dm.MWTemplateModel ) {
const params = removed.getParameters();
for ( const name in params ) {
removePages.push( params[ name ].getId() );
}
removed.disconnect( this );
}
removePages.push( removed.getId() );
this.bookletLayout.removePages( removePages );
}
if ( added ) {
const page = this.getPageFromPart( added );
if ( page ) {
let reselect;
this.bookletLayout.addPages( [ page ], this.transclusionModel.getIndex( added ) );
if ( removed ) {
// When we're replacing a part, it can only be a template placeholder
// becoming an actual template. Focus this new template.
reselect = added.getId();
}
if ( added instanceof ve.dm.MWTemplateModel ) {
// Prevent selection changes while parameters are added
this.preventReselection = true;
// Add existing params to templates (the template might be being moved)
const names = added.getOrderedParameterNames();
for ( let i = 0; i < names.length; i++ ) {
this.onAddParameter( added.getParameter( names[ i ] ) );
}
added.connect( this, { add: 'onAddParameter', remove: 'onRemoveParameter' } );
this.preventReselection = false;
if ( this.loaded ) {
if ( reselect ) {
this.bookletLayout.focusPart( reselect );
}
}
const documentedParameters = added.getSpec().getDocumentedParameterOrder(),
undocumentedParameters = added.getSpec().getUndocumentedParameterNames();
if ( !documentedParameters.length || undocumentedParameters.length ) {
page.addPlaceholderParameter();
}
}
}
}
if ( added || removed ) {
this.touch();
}
this.updateTitle();
};
/**
* Handle add param events.
*
* @private
* @param {ve.dm.MWParameterModel} param Added param
*/
ve.ui.MWTemplateDialog.prototype.onAddParameter = function ( param ) {
let page;
if ( param.getName() ) {
page = new ve.ui.MWParameterPage( param, {
$overlay: this.$overlay, readOnly: this.isReadOnly()
} )
.connect( this, {
hasValueChange: 'onHasValueChange'
} );
} else {
// Create parameter placeholder.
page = new ve.ui.MWAddParameterPage( param, param.getId(), {
$overlay: this.$overlay
} )
.connect( this, {
templateParameterAdded: this.bookletLayout.focusPart.bind( this.bookletLayout )
} );
}
this.bookletLayout.addPages( [ page ], this.transclusionModel.getIndex( param ) );
if ( this.loaded ) {
this.touch();
if ( page instanceof ve.ui.MWParameterPage ) {
page.updateSize();
}
}
};
/**
* Handle remove param events.
*
* @private
* @param {ve.dm.MWParameterModel} param Removed param
*/
ve.ui.MWTemplateDialog.prototype.onRemoveParameter = function ( param ) {
this.bookletLayout.removePages( [ param.getId() ] );
this.touch();
};
/**
* Sets transclusion applicable status
*
* If the transclusion is empty or only contains a placeholder it will not be insertable.
* If the transclusion only contains a placeholder it will not be editable.
*
* @private
*/
ve.ui.MWTemplateDialog.prototype.setApplicableStatus = function () {
const canSave = !this.transclusionModel.isEmpty();
this.actions.setAbilities( { done: canSave && this.altered } );
};
/**
* @inheritdoc
*/
ve.ui.MWTemplateDialog.prototype.getBodyHeight = function () {
return 400;
};
/**
* Get a page for a transclusion part.
*
* @protected
* @param {ve.dm.MWTransclusionModel} part Part to get page for
* @return {OO.ui.PageLayout|null} Page for part, null if no matching page could be found
*/
ve.ui.MWTemplateDialog.prototype.getPageFromPart = function ( part ) {
if ( part instanceof ve.dm.MWTemplateModel ) {
return new ve.ui.MWTemplatePage( part, part.getId(), { $overlay: this.$overlay, isReadOnly: this.isReadOnly() } );
} else if ( part instanceof ve.dm.MWTemplatePlaceholderModel ) {
return new ve.ui.MWTemplatePlaceholderPage(
part,
part.getId(),
{ $overlay: this.$overlay }
);
}
return null;
};
/**
* @inheritdoc
*/
ve.ui.MWTemplateDialog.prototype.getSelectedNode = function ( data ) {
const selectedNode = ve.ui.MWTemplateDialog.super.prototype.getSelectedNode.call( this );
// Data initialization
data = data || {};
// Require template to match if specified
if ( selectedNode && data.template && !selectedNode.isSingleTemplate( data.template ) ) {
return null;
}
return selectedNode;
};
/**
* Update the dialog title.
*
* @protected
*/
ve.ui.MWTemplateDialog.prototype.updateTitle = function () {
let title = ve.msg( 'visualeditor-dialog-transclusion-loading' );
if ( this.transclusionModel.isSingleTemplate() ) {
const part = this.transclusionModel.getParts()[ 0 ];
if ( part instanceof ve.dm.MWTemplateModel ) {
title = ve.msg(
this.getMode() === 'insert' ?
'visualeditor-dialog-transclusion-title-insert-known-template' :
'visualeditor-dialog-transclusion-title-edit-known-template',
part.getSpec().getLabel()
);
} else {
title = ve.msg( 'visualeditor-dialog-transclusion-title-insert-template' );
}
}
this.title.setLabel( title );
};
/**
* @inheritdoc
*/
ve.ui.MWTemplateDialog.prototype.initialize = function () {
// Parent method
ve.ui.MWTemplateDialog.super.prototype.initialize.call( this );
// Properties
this.bookletLayout = new ve.ui.MWTwoPaneTransclusionDialogLayout( this.constructor.static.bookletLayoutConfig );
// TODO: Remove once all references are gone.
this.sidebar = this.bookletLayout.sidebar;
// Initialization
this.$content.addClass( 've-ui-mwTemplateDialog' );
// bookletLayout is appended after the form has been built in getSetupProcess for performance
};
/**
* If the user has left blank required parameters, confirm that they actually want to do this.
* If no required parameters were left blank, or if they were but the user decided to go ahead
* anyway, the returned deferred will be resolved.
* Otherwise, the returned deferred will be rejected.
*
* @private
* @return {jQuery.Deferred}
*/
ve.ui.MWTemplateDialog.prototype.checkRequiredParameters = function () {
const blankRequired = [],
deferred = ve.createDeferred();
this.bookletLayout.stackLayout.getItems().forEach( ( page ) => {
if ( !( page instanceof ve.ui.MWParameterPage ) ) {
return;
}
if ( page.parameter.isRequired() && !page.valueInput.getValue() ) {
blankRequired.push( mw.msg(
'quotation-marks',
page.parameter.template.getSpec().getParameterLabel( page.parameter.getName() )
) );
}
} );
if ( blankRequired.length ) {
this.confirmDialogs.openWindow( 'requiredparamblankconfirm', {
message: mw.msg(
'visualeditor-dialog-transclusion-required-parameter-is-blank',
mw.language.listToText( blankRequired ),
blankRequired.length
),
title: mw.msg(
'visualeditor-dialog-transclusion-required-parameter-dialog-title',
blankRequired.length
)
} ).closed.then( ( data ) => {
if ( data && data.action === 'ok' ) {
deferred.resolve();
} else {
deferred.reject();
}
} );
} else {
deferred.resolve();
}
return deferred.promise();
};
/**
* @inheritdoc
*/
ve.ui.MWTemplateDialog.prototype.getActionProcess = function ( action ) {
if ( action === 'done' ) {
return new OO.ui.Process( () => {
const deferred = ve.createDeferred();
this.checkRequiredParameters().done( () => {
const surfaceModel = this.getFragment().getSurface(),
obj = this.transclusionModel.getPlainObject();
this.pushPending();
let modelPromise = ve.createDeferred().resolve().promise();
if ( this.selectedNode instanceof ve.dm.MWTransclusionNode ) {
this.transclusionModel.updateTransclusionNode( surfaceModel, this.selectedNode );
// TODO: updating the node could result in the inline/block state change
} else if ( obj !== null ) {
// Collapse returns a new fragment, so update this.fragment
this.fragment = this.getFragment().collapseToEnd();
modelPromise = this.transclusionModel.insertTransclusionNode( this.getFragment() );
}
// TODO tracking will only be implemented temporarily to answer questions on
// template usage for the Technical Wishes topic area see T258917
const templateEvent = {
action: 'save',
// eslint-disable-next-line camelcase
template_names: []
};
const editCountBucket = mw.config.get( 'wgUserEditCountBucket' );
if ( editCountBucket !== null ) {
// eslint-disable-next-line camelcase
templateEvent.user_edit_count_bucket = editCountBucket;
}
const parts = this.transclusionModel.getParts();
for ( let i = 0; i < parts.length; i++ ) {
// Only {@see ve.dm.MWTemplateModel} have a title
const title = parts[ i ].getTitle && parts[ i ].getTitle();
if ( title ) {
templateEvent.template_names.push( title );
}
}
mw.track( 'event.VisualEditorTemplateDialogUse', templateEvent );
return modelPromise.then( () => {
this.close( { action: action } ).closed.always( this.popPending.bind( this ) );
} );
} ).always( deferred.resolve );
return deferred;
} );
}
return ve.ui.MWTemplateDialog.super.prototype.getActionProcess.call( this, action );
};
/**
* @inheritdoc
*/
ve.ui.MWTemplateDialog.prototype.getSetupProcess = function ( data ) {
data = data || {};
return ve.ui.MWTemplateDialog.super.prototype.getSetupProcess.call( this, data )
.next( () => {
let promise;
// Properties
this.loaded = false;
this.altered = false;
this.transclusionModel = new ve.dm.MWTransclusionModel( this.getFragment().getDocument() );
// Events
this.transclusionModel.connect( this, {
replace: 'onReplacePart',
change: 'touch'
} );
// Detach the form while building for performance
this.bookletLayout.$element.detach();
this.transclusionModel.connect( this.bookletLayout, { replace: 'onReplacePart' } );
// Initialization
if ( !this.selectedNode ) {
if ( data.template ) {
// The template name is from MediaWiki:Visualeditor-cite-tool-definition.json,
// passed via a ve.ui.Command, which triggers a ve.ui.MWCitationAction, which
// executes ve.ui.WindowAction.open(), which opens this dialog.
const template = ve.dm.MWTemplateModel.newFromName(
this.transclusionModel, data.template
);
promise = this.transclusionModel.addPart( template );
} else {
// Open the dialog to add a new template, always starting with a placeholder
const placeholderPage = new ve.dm.MWTemplatePlaceholderModel( this.transclusionModel );
promise = this.transclusionModel.addPart( placeholderPage );
promise.then( () => {
this.bookletLayout.setPage( placeholderPage.getId() );
} );
this.canGoBack = true;
}
} else {
// Open the dialog to edit an existing template
// TODO tracking will only be implemented temporarily to answer questions on
// template usage for the Technical Wishes topic area see T258917
const templateEvent = {
action: 'edit',
// eslint-disable-next-line camelcase
template_names: []
};
const editCountBucket = mw.config.get( 'wgUserEditCountBucket' );
if ( editCountBucket !== null ) {
// eslint-disable-next-line camelcase
templateEvent.user_edit_count_bucket = editCountBucket;
}
for ( let i = 0; i < this.selectedNode.partsList.length; i++ ) {
if ( this.selectedNode.partsList[ i ].templatePage ) {
templateEvent.template_names.push( this.selectedNode.partsList[ i ].templatePage );
}
}
mw.track( 'event.VisualEditorTemplateDialogUse', templateEvent );
promise = this.transclusionModel
.load( ve.copy( this.selectedNode.getAttribute( 'mw' ) ) )
.then( this.initializeTemplateParameters.bind( this ) );
}
this.actions.setAbilities( { done: false } );
return promise.then( () => {
// Add missing required and suggested parameters to each transclusion.
this.transclusionModel.addPromptedParameters();
this.$body.append( this.bookletLayout.$element );
this.$element.addClass( 've-ui-mwTemplateDialog-ready' );
this.loaded = true;
} );
} );
};
/**
* Intentionally empty. This is provided for Wikia extensibility.
*/
ve.ui.MWTemplateDialog.prototype.initializeTemplateParameters = function () {};
/**
* @private
* @param {string} pageName
* @param {boolean} hasValue
*/
ve.ui.MWTemplateDialog.prototype.onHasValueChange = function ( pageName, hasValue ) {
this.sidebar.toggleHasValueByPageName( pageName, hasValue );
};
/**
* @inheritdoc
*/
ve.ui.MWTemplateDialog.prototype.getTeardownProcess = function ( data ) {
return ve.ui.MWTemplateDialog.super.prototype.getTeardownProcess.call( this, data )
.first( () => {
// Cleanup
this.$element.removeClass( 've-ui-mwTemplateDialog-ready' );
this.transclusionModel.disconnect( this );
this.transclusionModel.abortAllApiRequests();
this.transclusionModel = null;
this.bookletLayout.clearPages();
this.content = null;
} );
};