wikimedia/mediawiki-extensions-VisualEditor

View on GitHub
modules/ve-mw/ui/dialogs/ve.ui.MWTemplateDialog.js

Summary

Maintainability
D
2 days
Test Coverage
/*!
 * 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;
        } );
};