wikimedia/mediawiki-core

View on GitHub
resources/src/mediawiki.Upload.BookletLayout/BookletLayout.js

Summary

Maintainability
D
3 days
Test Coverage
/* global moment */
( function () {

    /**
     * BookletLayout class for encapsulating the process of uploading a file.
     *
     * @classdesc mw.Upload.BookletLayout encapsulates the process of uploading a file
     * to MediaWiki using the {@link mw.Upload upload model}.
     * The booklet emits events that can be used to get the stashed
     * upload and the final file. It can be extended to accept
     * additional fields from the user for specific scenarios like
     * for Commons, or campaigns.
     *
     * ## Structure
     *
     * The {@link OO.ui.BookletLayout booklet layout} has three steps:
     *
     *  - **Upload**: Has a {@link OO.ui.SelectFileInputWidget field} to get the file object.
     *
     * - **Information**: Has a {@link OO.ui.FormLayout form} to collect metadata. This can be
     *   extended.
     *
     * - **Insert**: Has details on how to use the file that was uploaded.
     *
     * Each step has a form associated with it defined in
     * {@link mw.Upload.BookletLayout#renderUploadForm renderUploadForm},
     * {@link mw.Upload.BookletLayout#renderInfoForm renderInfoForm}, and
     * {@link mw.Upload.BookletLayout#renderInsertForm renderInfoForm}. The
     * {@link mw.Upload.BookletLayout#getFile getFile},
     * {@link mw.Upload.BookletLayout#getFilename getFilename}, and
     * {@link mw.Upload.BookletLayout#getText getText} methods are used to get
     * the information filled in these forms, required to call
     * {@link mw.Upload mw.Upload}.
     *
     * ## Usage
     *
     * See the {@link mw.Upload.Dialog upload dialog}.
     *
     * The {@link mw.Upload.BookletLayout.event:fileUploaded fileUploaded},
     * and {@link mw.Upload.BookletLayout.event:fileSaved fileSaved} events can
     * be used to get details of the upload.
     *
     * ## Extending
     *
     * To extend using {@link mw.Upload mw.Upload}, override
     * {@link mw.Upload.BookletLayout#renderInfoForm renderInfoForm} to render
     * the form required for the specific use-case. Update the
     * {@link mw.Upload.BookletLayout#getFilename getFilename}, and
     * {@link mw.Upload.BookletLayout#getText getText} methods to return data
     * from your newly created form. If you added new fields you'll also have
     * to update the {@link mw.Upload.BookletLayout#clear} method.
     *
     * If you plan to use a different upload model, apart from what is mentioned
     * above, you'll also have to override the
     * {@link mw.Upload.BookletLayout#createUpload createUpload} method to
     * return the new model. The {@link #saveFile saveFile}, and
     * the {@link mw.Upload.BookletLayout#uploadFile uploadFile} methods need to be
     * overridden to use the new model and data returned from the forms.
     *
     * @class mw.Upload.BookletLayout
     * @extends OO.ui.BookletLayout
     *
     * @constructor
     * @param {Object} config Configuration options; see also the config parameter for the
     *  {@link mw.Upload.BookletLayout} constructor.
     * @param {jQuery} [config.$overlay] Overlay to use for widgets in the booklet
     * @param {string} [config.filekey] Sets the stashed file to finish uploading. Overrides most of the file selection process, and fetches a thumbnail from the server.
     */
    mw.Upload.BookletLayout = function ( config ) {
        // Parent constructor
        mw.Upload.BookletLayout.super.call( this, config );

        this.$overlay = config.$overlay;

        this.filekey = config.filekey;

        this.renderUploadForm();
        this.renderInfoForm();
        this.renderInsertForm();

        this.addPages( [
            new OO.ui.PageLayout( 'initializing', {
                scrollable: true,
                padded: true,
                content: [ new OO.ui.ProgressBarWidget( { indeterminate: true } ) ]
            } ),
            new OO.ui.PageLayout( 'upload', {
                scrollable: true,
                padded: true,
                content: [ this.uploadForm ]
            } ),
            new OO.ui.PageLayout( 'info', {
                scrollable: true,
                padded: true,
                content: [ this.infoForm ]
            } ),
            new OO.ui.PageLayout( 'insert', {
                scrollable: true,
                padded: true,
                content: [ this.insertForm ]
            } )
        ] );
    };

    /* Setup */

    OO.inheritClass( mw.Upload.BookletLayout, OO.ui.BookletLayout );

    /* Events */

    /**
     * Progress events for the uploaded file.
     *
     * @event mw.Upload.BookletLayout.fileUploadProgress
     * @param {number} progress In percentage
     * @param {Object} duration Duration object from `moment.duration()`
     */

    /**
     * The file has finished uploading.
     *
     * @event mw.Upload.BookletLayout.fileUploaded
     */

    /**
     * The file has been saved to the database.
     *
     * @event mw.Upload.BookletLayout.fileSaved
     * @param {Object} imageInfo See {@link mw.Upload#getImageInfo}
     */

    /**
     * The upload form has changed.
     *
     * @event mw.Upload.BookletLayout.uploadValid
     * @param {boolean} isValid The form is valid
     */

    /**
     * The info form has changed.
     *
     * @event mw.Upload.BookletLayout.infoValid
     * @param {boolean} isValid The form is valid
     */

    /* Properties */

    /**
     * The form rendered in the first step to get the file object.
     * Rendered in {@link mw.Upload.BookletLayout#renderUploadForm renderUploadForm}.
     *
     * @name mw.Upload.BookletLayout.prototype.uploadForm
     * @type {OO.ui.FormLayout}
     */

    /**
     * The form rendered in the second step to get metadata.
     * Rendered in {@link mw.Upload.BookletLayout#renderInfoForm renderInfoForm}.
     *
     * @name mw.Upload.BookletLayout.prototype.infoForm
     * @type {OO.ui.FormLayout}
     */

    /**
     * The form rendered in the third step to show usage.
     * Rendered in {@link mw.Upload.BookletLayout#renderInsertForm renderInsertForm}.
     *
     * @name mw.Upload.BookletLayout.prototype.insertForm
     * @type {OO.ui.FormLayout}
     */

    /* Methods */

    /**
     * Initialize for a new upload.
     *
     * @return {jQuery.Promise} Promise resolved when everything is initialized
     */
    mw.Upload.BookletLayout.prototype.initialize = function () {
        var booklet = this;

        this.clear();
        this.upload = this.createUpload();

        this.setPage( 'initializing' );

        if ( this.filekey ) {
            this.setFilekey( this.filekey );
        }

        return this.upload.getApi().then(
            function ( api ) {
                // If the user can't upload anything, don't give them the option to.
                return api.getUserInfo().then(
                    function ( userInfo ) {
                        booklet.setPage( 'upload' );
                        if ( userInfo.rights.indexOf( 'upload' ) === -1 ) {
                            if ( !mw.user.isNamed() ) {
                                booklet.getPage( 'upload' ).$element.msg( 'apierror-mustbeloggedin', mw.msg( 'action-upload' ) );
                            } else {
                                booklet.getPage( 'upload' ).$element.msg( 'apierror-permissiondenied', mw.msg( 'action-upload' ) );
                            }
                        }
                        return $.Deferred().resolve();
                    },
                    // Always resolve, never reject
                    function () {
                        booklet.setPage( 'upload' );
                        return $.Deferred().resolve();
                    }
                );
            },
            function ( errorMsg ) {
                booklet.setPage( 'upload' );
                // eslint-disable-next-line mediawiki/msg-doc
                booklet.getPage( 'upload' ).$element.msg( errorMsg );
                return $.Deferred().resolve();
            }
        );
    };

    /**
     * Create a new upload model.
     *
     * @protected
     * @return {mw.Upload} Upload model
     */
    mw.Upload.BookletLayout.prototype.createUpload = function () {
        return new mw.Upload( {
            parameters: {
                errorformat: 'html',
                errorlang: mw.config.get( 'wgUserLanguage' ),
                errorsuselocal: 1,
                formatversion: 2
            }
        } );
    };

    /* Uploading */

    /**
     * Uploads the file that was added in the upload form. Uses
     * {@link mw.Upload.BookletLayout#getFile getFile} to get the HTML5
     * file object.
     *
     * @protected
     * @fires mw.Upload.BookletLayout.fileUploadProgress
     * @fires mw.Upload.BookletLayout.fileUploaded
     * @return {jQuery.Promise}
     */
    mw.Upload.BookletLayout.prototype.uploadFile = function () {
        var deferred = $.Deferred(),
            startTime = mw.now(),
            layout = this,
            file = this.getFile();

        this.setPage( 'info' );

        if ( this.filekey ) {
            if ( file === null ) {
                // Someone gonna get-a hurt real bad
                throw new Error( 'filekey not passed into file select widget, which is impossible. Quitting while we\'re behind.' );
            }

            // Stashed file already uploaded.
            deferred.resolve();
            this.uploadPromise = deferred;
            this.emit( 'fileUploaded' );
            return deferred;
        }

        this.setFilename( file.name );

        this.upload.setFile( file );
        // The original file name might contain invalid characters, so use our sanitized one
        this.upload.setFilename( this.getFilename() );

        this.uploadPromise = this.upload.uploadToStash();
        this.uploadPromise.then( function () {
            deferred.resolve();
            layout.emit( 'fileUploaded' );
        }, function () {
            // These errors will be thrown while the user is on the info page.
            layout.getErrorMessageForStateDetails().then( function ( errorMessage ) {
                deferred.reject( errorMessage );
            } );
        }, function ( progress ) {
            var elapsedTime = mw.now() - startTime,
                estimatedTotalTime = ( 1 / progress ) * elapsedTime,
                estimatedRemainingTime = moment.duration( estimatedTotalTime - elapsedTime );
            layout.emit( 'fileUploadProgress', progress, estimatedRemainingTime );
        } );

        // If there is an error in uploading, come back to the upload page
        deferred.fail( function () {
            layout.setPage( 'upload' );
        } );

        return deferred;
    };

    /**
     * Saves the stash finalizes upload. Uses
     * {@link mw.Upload.BookletLayout#getFilename getFilename}, and
     * {@link mw.Upload.BookletLayout#getText getText} to get details from
     * the form.
     *
     * @protected
     * @fires mw.Upload.BookletLayout.fileSaved
     * @return {jQuery.Promise} Rejects the promise with an
     * {@link OO.ui.Error error}, or resolves if the upload was successful.
     */
    mw.Upload.BookletLayout.prototype.saveFile = function () {
        var layout = this,
            deferred = $.Deferred();

        this.upload.setFilename( this.getFilename() );
        this.upload.setText( this.getText() );

        this.uploadPromise.then( function () {
            layout.upload.finishStashUpload().then( function () {
                var name;

                // Normalize page name and localise the 'File:' prefix
                name = new mw.Title( 'File:' + layout.upload.getFilename() ).toString();
                layout.filenameUsageWidget.setValue( '[[' + name + ']]' );
                layout.setPage( 'insert' );

                deferred.resolve();
                layout.emit( 'fileSaved', layout.upload.getImageInfo() );
            }, function () {
                layout.getErrorMessageForStateDetails().then( function ( errorMessage ) {
                    deferred.reject( errorMessage );
                } );
            } );
        } );

        return deferred.promise();
    };

    /**
     * Get an error message (as OO.ui.Error object) that should be displayed to the user for current
     * state and state details.
     *
     * @protected
     * @return {jQuery.Promise} A Promise that will be resolved with an OO.ui.Error.
     */
    mw.Upload.BookletLayout.prototype.getErrorMessageForStateDetails = function () {
        var state = this.upload.getState(),
            stateDetails = this.upload.getStateDetails(),
            warnings = stateDetails.upload && stateDetails.upload.warnings,
            $ul = $( '<ul>' ),
            $error;

        if ( state === mw.Upload.State.ERROR ) {
            $error = ( new mw.Api() ).getErrorMessage( stateDetails );

            return $.Deferred().resolve( new OO.ui.Error(
                $error,
                { recoverable: false }
            ) );
        }

        if ( state === mw.Upload.State.WARNING ) {
            // We could get more than one of these errors, these are in order
            // of importance. For example fixing the thumbnail like file name
            // won't help the fact that the file already exists.
            if ( warnings.exists !== undefined ) {
                return $.Deferred().resolve( new OO.ui.Error(
                    $( '<p>' ).msg( 'fileexists', 'File:' + warnings.exists ),
                    { recoverable: false }
                ) );
            } else if ( warnings[ 'exists-normalized' ] !== undefined ) {
                return $.Deferred().resolve( new OO.ui.Error(
                    $( '<p>' ).msg( 'fileexists', 'File:' + warnings[ 'exists-normalized' ] ),
                    { recoverable: false }
                ) );
            } else if ( warnings[ 'page-exists' ] !== undefined ) {
                return $.Deferred().resolve( new OO.ui.Error(
                    $( '<p>' ).msg( 'filepageexists', 'File:' + warnings[ 'page-exists' ] ),
                    { recoverable: false }
                ) );
            } else if ( Array.isArray( warnings.duplicate ) ) {
                warnings.duplicate.forEach( function ( filename ) {
                    var $a = $( '<a>' ).text( filename ),
                        href = mw.Title.makeTitle( mw.config.get( 'wgNamespaceIds' ).file, filename ).getUrl( {} );

                    $a.attr( { href: href, target: '_blank' } );
                    $ul.append( $( '<li>' ).append( $a ) );
                } );

                return $.Deferred().resolve( new OO.ui.Error(
                    $( '<p>' ).msg( 'file-exists-duplicate', warnings.duplicate.length ).append( $ul ),
                    { recoverable: false }
                ) );
            } else if ( warnings[ 'thumb-name' ] !== undefined ) {
                return $.Deferred().resolve( new OO.ui.Error(
                    $( '<p>' ).msg( 'filename-thumb-name' ),
                    { recoverable: false }
                ) );
            } else if ( warnings[ 'bad-prefix' ] !== undefined ) {
                return $.Deferred().resolve( new OO.ui.Error(
                    $( '<p>' ).msg( 'filename-bad-prefix', warnings[ 'bad-prefix' ] ),
                    { recoverable: false }
                ) );
            } else if ( warnings[ 'duplicate-archive' ] !== undefined ) {
                return $.Deferred().resolve( new OO.ui.Error(
                    $( '<p>' ).msg( 'file-deleted-duplicate', 'File:' + warnings[ 'duplicate-archive' ] ),
                    { recoverable: false }
                ) );
            } else if ( warnings[ 'was-deleted' ] !== undefined ) {
                return $.Deferred().resolve( new OO.ui.Error(
                    $( '<p>' ).msg( 'filewasdeleted', 'File:' + warnings[ 'was-deleted' ] ),
                    { recoverable: false }
                ) );
            } else if ( warnings.badfilename !== undefined ) {
                // Change the name if the current name isn't acceptable
                // TODO This might not really be the best place to do this
                this.setFilename( warnings.badfilename );
                return $.Deferred().resolve( new OO.ui.Error(
                    $( '<p>' ).msg( 'badfilename', warnings.badfilename )
                ) );
            } else {
                return $.Deferred().resolve( new OO.ui.Error(
                    // Let's get all the help we can if we can't pin point the error
                    $( '<p>' ).msg( 'api-error-unknown-warning', JSON.stringify( stateDetails ) ),
                    { recoverable: false }
                ) );
            }
        }
    };

    /* Form renderers */

    /**
     * Renders and returns the upload form and sets the
     * {@link mw.Upload.BookletLayout#uploadForm uploadForm} property.
     *
     * @protected
     * @return {OO.ui.FormLayout}
     */
    mw.Upload.BookletLayout.prototype.renderUploadForm = function () {
        var fieldset,
            layout = this;

        this.selectFileWidget = this.getFileWidget();
        fieldset = new OO.ui.FieldsetLayout();
        fieldset.addItems( [ this.selectFileWidget ] );
        this.uploadForm = new OO.ui.FormLayout( { items: [ fieldset ] } );

        // Validation (if the SFW is for a stashed file, this never fires)
        this.selectFileWidget.on( 'change', this.onUploadFormChange.bind( this ) );

        this.selectFileWidget.on( 'change', function () {
            layout.updateFilePreview();
        } );

        return this.uploadForm;
    };

    /**
     * Gets the widget for displaying or inputting the file to upload.
     *
     * @return {OO.ui.SelectFileInputWidget|mw.widgets.StashedFileWidget}
     */
    mw.Upload.BookletLayout.prototype.getFileWidget = function () {
        if ( this.filekey ) {
            return new mw.widgets.StashedFileWidget( {
                filekey: this.filekey
            } );
        }

        return new OO.ui.SelectFileInputWidget( {
            showDropTarget: true
        } );
    };

    /**
     * Updates the file preview on the info form when a file is added.
     *
     * @protected
     */
    mw.Upload.BookletLayout.prototype.updateFilePreview = function () {
        this.selectFileWidget.loadAndGetImageUrl().done( function ( url ) {
            this.filePreview.$element.find( 'p' ).remove();
            this.filePreview.$element.css( 'background-image', 'url(' + url + ')' );
            this.infoForm.$element.addClass( 'mw-upload-bookletLayout-hasThumbnail' );
        }.bind( this ) ).fail( function () {
            this.filePreview.$element.find( 'p' ).remove();
            if ( this.selectFileWidget.getValue() ) {
                this.filePreview.$element.append(
                    $( '<p>' ).text( this.selectFileWidget.getValue().name )
                );
            }
            this.filePreview.$element.css( 'background-image', '' );
            this.infoForm.$element.removeClass( 'mw-upload-bookletLayout-hasThumbnail' );
        }.bind( this ) );
    };

    /**
     * Handle change events to the upload form.
     *
     * @protected
     * @fires mw.Upload.BookletLayout.uploadValid
     */
    mw.Upload.BookletLayout.prototype.onUploadFormChange = function () {
        this.emit( 'uploadValid', !!this.selectFileWidget.getValue() );
    };

    /**
     * Renders and returns the information form for collecting
     * metadata and sets the {@link mw.Upload.BookletLayout#infoForm infoForm}
     * property.
     *
     * @protected
     * @return {OO.ui.FormLayout}
     */
    mw.Upload.BookletLayout.prototype.renderInfoForm = function () {
        var fieldset;

        this.filePreview = new OO.ui.Widget( {
            classes: [ 'mw-upload-bookletLayout-filePreview' ]
        } );
        this.progressBarWidget = new OO.ui.ProgressBarWidget( {
            progress: 0
        } );
        this.filePreview.$element.append( this.progressBarWidget.$element );

        this.filenameWidget = new OO.ui.TextInputWidget( {
            indicator: 'required',
            required: true,
            validate: /.+/
        } );
        this.descriptionWidget = new OO.ui.MultilineTextInputWidget( {
            indicator: 'required',
            required: true,
            validate: /\S+/,
            autosize: true
        } );

        fieldset = new OO.ui.FieldsetLayout( {
            label: mw.msg( 'upload-form-label-infoform-title' )
        } );
        fieldset.addItems( [
            new OO.ui.FieldLayout( this.filenameWidget, {
                label: mw.msg( 'upload-form-label-infoform-name' ),
                align: 'top',
                help: mw.msg( 'upload-form-label-infoform-name-tooltip' )
            } ),
            new OO.ui.FieldLayout( this.descriptionWidget, {
                label: mw.msg( 'upload-form-label-infoform-description' ),
                align: 'top',
                help: mw.msg( 'upload-form-label-infoform-description-tooltip' )
            } )
        ] );
        this.infoForm = new OO.ui.FormLayout( {
            classes: [ 'mw-upload-bookletLayout-infoForm' ],
            items: [ this.filePreview, fieldset ]
        } );

        this.on( 'fileUploadProgress', function ( progress ) {
            this.progressBarWidget.setProgress( progress * 100 );
        }.bind( this ) );

        this.filenameWidget.on( 'change', this.onInfoFormChange.bind( this ) );
        this.descriptionWidget.on( 'change', this.onInfoFormChange.bind( this ) );

        return this.infoForm;
    };

    /**
     * Handle change events to the info form.
     *
     * @protected
     * @fires mw.Upload.BookletLayout.infoValid
     */
    mw.Upload.BookletLayout.prototype.onInfoFormChange = function () {
        var layout = this;
        $.when(
            this.filenameWidget.getValidity(),
            this.descriptionWidget.getValidity()
        ).done( function () {
            layout.emit( 'infoValid', true );
        } ).fail( function () {
            layout.emit( 'infoValid', false );
        } );
    };

    /**
     * Renders and returns the insert form to show file usage and
     * sets the {@link mw.Upload.BookletLayout#insertForm insertForm} property.
     *
     * @protected
     * @return {OO.ui.FormLayout}
     */
    mw.Upload.BookletLayout.prototype.renderInsertForm = function () {
        var fieldset;

        this.filenameUsageWidget = new OO.ui.TextInputWidget();
        fieldset = new OO.ui.FieldsetLayout( {
            label: mw.msg( 'upload-form-label-usage-title' )
        } );
        fieldset.addItems( [
            new OO.ui.FieldLayout( this.filenameUsageWidget, {
                label: mw.msg( 'upload-form-label-usage-filename' ),
                align: 'top'
            } )
        ] );
        this.insertForm = new OO.ui.FormLayout( { items: [ fieldset ] } );

        return this.insertForm;
    };

    /* Getters */

    /**
     * Gets the file object from the
     * {@link mw.Upload.BookletLayout#uploadForm upload form}.
     *
     * @protected
     * @return {File|null}
     */
    mw.Upload.BookletLayout.prototype.getFile = function () {
        return this.selectFileWidget.getValue();
    };

    /**
     * Gets the file name from the
     * {@link mw.Upload.BookletLayout#infoForm information form}.
     *
     * @protected
     * @return {string}
     */
    mw.Upload.BookletLayout.prototype.getFilename = function () {
        var filename = this.filenameWidget.getValue();
        if ( this.filenameExtension ) {
            filename += '.' + this.filenameExtension;
        }
        return filename;
    };

    /**
     * Prefills the {@link mw.Upload.BookletLayout#infoForm information form} with the given filename.
     *
     * @protected
     * @param {string} filename
     */
    mw.Upload.BookletLayout.prototype.setFilename = function ( filename ) {
        var title = mw.Title.newFromFileName( filename );

        if ( title ) {
            this.filenameWidget.setValue( title.getNameText() );
            this.filenameExtension = mw.Title.normalizeExtension( title.getExtension() );
        } else {
            // Seems to happen for files with no extension, which should fail some checks anyway...
            this.filenameWidget.setValue( filename );
            this.filenameExtension = null;
        }
    };

    /**
     * Gets the page text from the
     * {@link mw.Upload.BookletLayout#infoForm information form}.
     *
     * @protected
     * @return {string}
     */
    mw.Upload.BookletLayout.prototype.getText = function () {
        return this.descriptionWidget.getValue();
    };

    /* Setters */

    /**
     * Sets the file object.
     *
     * @protected
     * @param {File|null} file File to select
     */
    mw.Upload.BookletLayout.prototype.setFile = function ( file ) {
        this.selectFileWidget.setValue( [ file ] );
    };

    /**
     * Sets the filekey of a file already stashed on the server
     * as the target of this upload operation.
     *
     * @protected
     * @param {string} filekey
     */
    mw.Upload.BookletLayout.prototype.setFilekey = function ( filekey ) {
        this.upload.setFilekey( this.filekey );
        this.selectFileWidget.setValue( filekey );

        this.onUploadFormChange();
    };

    /**
     * Clear the values of all fields.
     *
     * @protected
     */
    mw.Upload.BookletLayout.prototype.clear = function () {
        this.selectFileWidget.setValue( null );
        this.progressBarWidget.setProgress( 0 );
        this.filenameWidget.setValue( null ).setValidityFlag( true );
        this.descriptionWidget.setValue( null ).setValidityFlag( true );
        this.filenameUsageWidget.setValue( null );
    };

}() );