wikimedia/mediawiki-extensions-VisualEditor

View on GitHub
modules/ve-mw/ui/widgets/ve.ui.MWTransclusionOutlineTemplateWidget.js

Summary

Maintainability
C
1 day
Test Coverage
/**
 * Container for a template as rendered in the template dialog sidebar.
 * Contains search and visibility inputs, and a list of parameters when available.
 *
 * @class
 * @extends ve.ui.MWTransclusionOutlinePartWidget
 *
 * @constructor
 * @param {ve.dm.MWTemplateModel} template
 * @param {boolean} [replacesPlaceholder=false]
 * @property {ve.dm.MWTemplateModel} templateModel
 * @property {ve.ui.MWTransclusionOutlineParameterSelectWidget} parameterList
 */
ve.ui.MWTransclusionOutlineTemplateWidget = function VeUiMWTransclusionOutlineTemplateWidget( template, replacesPlaceholder ) {
    const spec = template.getSpec();

    // Parent constructor
    ve.ui.MWTransclusionOutlineTemplateWidget.super.call( this, template, {
        icon: 'puzzle',
        label: spec.getLabel(),
        ariaDescriptionUnselected: ve.msg( 'visualeditor-dialog-transclusion-template-widget-aria' ),
        ariaDescriptionSelected: ve.msg( 'visualeditor-dialog-transclusion-template-widget-aria-selected' ),
        ariaDescriptionSelectedSingle: ve.msg( 'visualeditor-dialog-transclusion-template-widget-aria-selected-single' )
    } );

    this.$element.addClass( 've-ui-mwTransclusionOutlineTemplateWidget' );

    // Initialization
    this.templateModel = template.connect( this, {
        add: 'onParameterAddedToTemplateModel',
        remove: 'onParameterRemovedFromTemplateModel'
    } );

    const canFilter = this.shouldFiltersBeShown(),
        initiallyHideUnused = canFilter && !replacesPlaceholder && !this.transclusionModel.isSingleTemplate();

    const parameterNames = this.getRelevantTemplateParameters( initiallyHideUnused ? 'used' : 'all' );
    if ( parameterNames.length ) {
        this.initializeParameterList();
        this.parameterList.addItems( parameterNames.map( this.createCheckbox.bind( this ) ) );
    } else if ( !canFilter ) {
        this.$noParametersNote = $( '<div>' )
            .text( ve.msg( 'visualeditor-dialog-transclusion-no-template-parameters' ) )
            .addClass( 've-ui-mwTransclusionOutlineTemplateWidget-no-template-parameters' );
        this.$element.append( this.$noParametersNote );
    }

    this.toggleFilters();
    if ( initiallyHideUnused ) {
        // This is only to update the label of the "Hide unused" button
        this.toggleUnusedWidget.toggleUnusedParameters( false );
    }
};

/* Inheritance */

OO.inheritClass( ve.ui.MWTransclusionOutlineTemplateWidget, ve.ui.MWTransclusionOutlinePartWidget );

/* Events */

/**
 * Triggered when the user uses the search widget at the top to filter the list of parameters.
 *
 * @event ve.ui.MWTransclusionOutlineTemplateWidget#filterParametersById
 * @param {Object.<string,boolean>} visibility Keyed by unique id of the parameter, e.g. something
 *  like "part_1/param1". Note this lists only parameters that are currently shown as a checkbox.
 *  The spec might contain more parameters (e.g. deprecated).
 */

/**
 * @event ve.ui.MWTransclusionOutlineTemplateWidget#transclusionOutlineItemSelected
 * @param {string} id Item ID
 * @param {boolean} soft If true, focus should stay in the sidebar.
 */

/* Static Properties */

/**
 * Minimum number of parameters required before search and filter options appear.
 *
 * @static
 * @property {number}
 */
ve.ui.MWTransclusionOutlineTemplateWidget.static.searchableParameterCount = 4;

/* Methods */

/**
 * @private
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.initializeParameterList = function () {
    if ( this.parameterList ) {
        return;
    }

    const $parametersAriaDescription = $( '<span>' )
        .text( ve.msg( 'visualeditor-dialog-transclusion-param-selection-aria-description' ) )
        .addClass( 've-ui-mwTransclusionOutline-ariaHidden' );

    this.parameterList = new ve.ui.MWTransclusionOutlineParameterSelectWidget( {
        ariaLabel: ve.msg( 'visualeditor-dialog-transclusion-param-selection-aria-label', this.templateModel.getSpec().getLabel() ),
        $ariaDescribedBy: $parametersAriaDescription
    } ).connect( this, {
        choose: 'onTemplateParameterChoose',
        templateParameterSpaceDown: 'onTemplateParameterSpaceDown'
    } );

    this.$element.append(
        $parametersAriaDescription,
        this.parameterList.$element
    );
};

/**
 * @private
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.createAllParameterCheckboxes = function () {
    if ( !this.parameterListComplete ) {
        this.initializeParameterList();
        this.getRelevantTemplateParameters().forEach( ( paramName ) => {
            if ( !this.parameterList.findItemFromData( paramName ) ) {
                this.parameterList.addItems(
                    [ this.createCheckbox( paramName ) ],
                    this.findCanonicalPosition( paramName )
                );
            }
        } );
        this.parameterListComplete = true;
    }
};

/**
 * @private
 * @param {string} [filter='all'] Either "used", "unused", or "all"
 * @return {string[]}
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.getRelevantTemplateParameters = function ( filter ) {
    const template = this.templateModel;

    let parameterNames;
    switch ( filter ) {
        case 'used':
            parameterNames = template.getOrderedParameterNames();
            break;
        case 'unused':
            parameterNames = template.getAllParametersOrdered().filter( ( name ) => !( name in template.getParameters() ) );
            break;
        default:
            parameterNames = template.getAllParametersOrdered();
    }

    return parameterNames.filter( ( name ) => {
        // Don't offer deprecated parameters, unless they are already used
        if ( template.getSpec().isParameterDeprecated( name ) && !template.hasParameter( name ) ) {
            return false;
        }
        // Never create a checkbox for a not yet named parameter placeholder
        return !!name;
    } );
};

/**
 * @private
 * @param {string} paramName Parameter name or alias as used in the model
 * @return {OO.ui.OptionWidget}
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.createCheckbox = function ( paramName ) {
    const spec = this.templateModel.getSpec(),
        parameterModel = this.templateModel.getParameter( paramName );

    return ve.ui.MWTransclusionOutlineParameterSelectWidget.static.createItem( {
        required: spec.isParameterRequired( paramName ),
        label: spec.getParameterLabel( paramName ),
        data: paramName,
        selected: !!parameterModel,
        hasValue: !!parameterModel && !!parameterModel.getValue()
    } );
};

/**
 * @private
 * @param {string} paramName
 * @return {number}
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.findCanonicalPosition = function ( paramName ) {
    // Note this might include parameters that don't have a checkbox, e.g. deprecated
    const allParamNames = this.templateModel.getAllParametersOrdered();
    let insertAt = 0;
    for ( let i = 0; i < allParamNames.length; i++ ) {
        if ( allParamNames[ i ] === paramName || !this.parameterList.items[ insertAt ] ) {
            break;
        } else if ( this.parameterList.items[ insertAt ].getData() === allParamNames[ i ] ) {
            insertAt++;
        }
    }
    return insertAt;
};

/**
 * @param {string} [paramName] Parameter name to set, e.g. "param1". Omit to remove setting.
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.setParameter = function ( paramName ) {
    if ( this.parameterList ) {
        this.parameterList.setActiveParameter( paramName );
    }
};

/**
 * @param {string} paramName
 * @param {boolean} hasValue
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.toggleHasValue = function ( paramName, hasValue ) {
    if ( this.parameterList ) {
        const item = this.parameterList.findItemFromData( paramName );
        if ( item ) {
            item.toggleHasValue( hasValue );
        }
    }
};

/**
 * @inheritDoc
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.setSelected = function ( state ) {
    // FIXME: This is a super-specific hack; should be replaced with a more generic solution
    if ( !state && this.isSelected() && this.parameterList ) {
        this.parameterList.highlightItem();
    }
    ve.ui.MWTransclusionOutlineTemplateWidget.super.prototype.setSelected.call( this, state );
};

/**
 * @private
 * @param {ve.dm.MWParameterModel} param
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.onParameterAddedToTemplateModel = function ( param ) {
    const paramName = param.getName();
    // The placeholder (currently) doesn't get a corresponding item in the sidebar
    if ( !paramName ) {
        return;
    }

    if ( this.$noParametersNote ) {
        this.$noParametersNote.remove();
        delete this.$noParametersNote;
    }

    this.initializeParameterList();

    // All parameters known via the spec already have a checkbox
    let item = this.parameterList.findItemFromData( paramName );
    if ( item ) {
        // Reset the "hide unused" filter for this field, it's going to be used
        item.toggle( true );
    } else {
        item = this.createCheckbox( paramName );
        this.parameterList.addItems( [ item ], this.findCanonicalPosition( paramName ) );

        this.toggleFilters();

        // Make sure an active filter is applied to the new checkbox as well
        const filter = this.searchWidget && this.searchWidget.getValue();
        if ( filter ) {
            this.filterParameters( filter );
        }
    }

    item.setSelected( true, true );

    // Reset filter, but only if it hides the relevant checkbox
    if ( !item.isVisible() ) {
        this.searchWidget.setValue( '' );
    }
};

/**
 * @private
 * @param {ve.dm.MWParameterModel} param
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.onParameterRemovedFromTemplateModel = function ( param ) {
    this.parameterList.markParameterAsUnused( param.getName() );
};

/**
 * @private
 * @param {OO.ui.OptionWidget} item
 * @param {boolean} selected
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.onTemplateParameterChoose = function ( item, selected ) {
    this.toggleParameter( item, selected, false );
};

/**
 * @private
 * @param {OO.ui.OptionWidget} item
 * @param {boolean} selected
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.onTemplateParameterSpaceDown = function ( item, selected ) {
    this.toggleParameter( item, selected, true );
};

/**
 * @private
 * @param {OO.ui.OptionWidget} item
 * @param {boolean} selected
 * @param {boolean} soft If true, focus should stay in the sidebar.
 * @fires ve.ui.MWTransclusionOutlineTemplateWidget#transclusionOutlineItemSelected
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.toggleParameter = function ( item, selected, soft ) {
    const paramName = item.getData();
    let param = this.templateModel.getParameter( paramName );
    if ( !selected ) {
        this.templateModel.removeParameter( param );
    } else if ( !param ) {
        param = new ve.dm.MWParameterModel( this.templateModel, paramName );
        this.templateModel.addParameter( param );
    }

    this.updateUnusedParameterToggleState();

    if ( param && selected ) {
        this.emit( 'transclusionOutlineItemSelected', param.getId(), soft );
    }
};

/**
 * @private
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.updateUnusedParameterToggleState = function () {
    if ( this.toggleUnusedWidget ) {
        this.toggleUnusedWidget.setDisabled( !this.getRelevantTemplateParameters( 'unused' ).length );
    }
};

/**
 * @private
 * @return {boolean}
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.shouldFiltersBeShown = function () {
    const min = this.constructor.static.searchableParameterCount,
        existingParameterWidgets = this.parameterList && this.parameterList.getItemCount();
    // Avoid expensive calls when there are already enough items in the parameter list
    return existingParameterWidgets >= min || this.getRelevantTemplateParameters().length >= min;
};

/**
 * @private
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.toggleFilters = function () {
    const visible = this.shouldFiltersBeShown();
    if ( this.searchWidget ) {
        this.searchWidget.toggle( visible );
        this.toggleUnusedWidget.toggle( visible );
    } else if ( visible ) {
        this.initializeFilters();
        this.updateUnusedParameterToggleState();
    }

    this.recalculateStickyHeaderHeight();
};

ve.ui.MWTransclusionOutlineTemplateWidget.prototype.recalculateStickyHeaderHeight = function () {
    // A template with no used parameters might have a sticky header, but no paramater list yet
    if ( this.$stickyHeader && this.parameterList ) {
        this.parameterList.stickyHeaderHeight = Math.floor( this.$stickyHeader.outerHeight() );
    }
};

/**
 * @private
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.initializeFilters = function () {
    this.searchWidget = new OO.ui.SearchInputWidget( {
        title: ve.msg( 'visualeditor-dialog-transclusion-filter-title', this.templateModel.getSpec().getLabel() ),
        placeholder: ve.msg( 'visualeditor-dialog-transclusion-filter-placeholder' ),
        classes: [ 've-ui-mwTransclusionOutlineTemplateWidget-searchWidget' ]
    } ).connect( this, {
        change: 'filterParameters'
    } );
    this.searchWidget.$element.attr( 'role', 'search' );

    this.toggleUnusedWidget = new ve.ui.MWTransclusionOutlineToggleUnusedWidget();
    this.toggleUnusedWidget.connect( this, {
        toggleUnusedFields: 'onToggleUnusedFields'
    } );

    this.infoWidget = new OO.ui.LabelWidget( {
        label: ve.msg( 'visualeditor-dialog-transclusion-filter-no-match' ),
        classes: [ 've-ui-mwTransclusionOutlineTemplateWidget-no-match' ]
    } ).toggle( false );

    this.$stickyHeader = $( '<div>' )
        .addClass( 've-ui-mwTransclusionOutlineTemplateWidget-sticky' )
        .append(
            this.header.$element,
            this.searchWidget.$element,
            this.toggleUnusedWidget.$element
        );

    this.$element.prepend(
        this.$stickyHeader,
        this.infoWidget.$element
    );
};

/**
 * Narrows the list of checkboxes down to parameters that match the user's input. We search the
 * parameter's primary name, aliases, label, and description. But not e.g. the example value.
 *
 * @private
 * @param {string} query user input
 * @fires ve.ui.MWTransclusionOutlineTemplateWidget#filterParametersById
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.filterParameters = function ( query ) {
    const template = this.templateModel,
        spec = this.templateModel.getSpec(),
        visibility = {};
    let nothingFound = true;

    query = query.trim().toLowerCase();
    this.createAllParameterCheckboxes();

    // Note: We can't really cache this because the list of know parameters can change any time
    this.parameterList.items.forEach( ( item ) => {
        const paramName = item.getData(),
            placesToSearch = [
                spec.getPrimaryParameterName( paramName ),
                spec.getParameterLabel( paramName ),
                spec.getParameterDescription( paramName )
            ].concat( spec.getParameterAliases( paramName ) );

        const foundSomeMatch = placesToSearch.some(
            // Aliases missed validation for a long time and aren't guaranteed to be strings
            ( term ) => term && typeof term === 'string' && term.toLowerCase().indexOf( query ) !== -1
        );

        item.toggle( foundSomeMatch );

        nothingFound = nothingFound && !foundSomeMatch;

        const param = template.getParameter( paramName );
        if ( param ) {
            visibility[ param.getId() ] = foundSomeMatch;
        }
    } );

    this.toggleUnusedWidget.toggle( !query );
    this.infoWidget.toggle( nothingFound );
    this.parameterList.setTabIndex( nothingFound ? -1 : 0 );
    // The "hide unused" button might be hidden now, which changes the height of the sticky header
    this.recalculateStickyHeaderHeight();
    this.emit( 'filterParametersById', visibility );
};

/**
 * @private
 * @param {boolean} visibility
 * @param {boolean} [fromClick]
 */
ve.ui.MWTransclusionOutlineTemplateWidget.prototype.onToggleUnusedFields = function ( visibility, fromClick ) {
    if ( visibility ) {
        this.createAllParameterCheckboxes();
    }

    if ( this.parameterList ) {
        this.parameterList.items.forEach( ( item ) => {
            item.toggle( visibility || item.isSelected() );
        } );
    }

    if ( !visibility && fromClick ) {
        this.header.scrollElementIntoView().then( () => {
            if ( this.parameterList ) {
                this.parameterList.ensureVisibilityOfFirstCheckedParameter();
            }
        } );
    }
};