wikimedia/mediawiki-extensions-Flow

View on GitHub
modules/mw.flow.Initializer.js

Summary

Maintainability
F
5 days
Test Coverage
/* eslint-disable no-jquery/no-global-selector */
( function () {
    /**
     * Initializer object for flow-initialize
     *
     * @class
     *
     * @constructor
     * @param {Object} config Configuration object
     */
    mw.flow.Initializer = function ( config ) {
        config = config || {};

        this.$component = null;
        this.$board = null;
        this.siderailCollapsed = mw.user.options.get( 'flow-side-rail-state' ) === 'collapsed';
        this.pageTitle = config.pageTitle || mw.Title.newFromText( mw.config.get( 'wgPageName' ) );

        this.system = null;
        this.board = null;
        this.navWidget = null;
    };

    /* Inheritance */

    OO.initClass( mw.flow.Initializer );

    /**
     * Sets the DOM element that is the flow component
     *
     * @param {jQuery} $component The DOM element that is the component
     * @return {boolean} The component DOM element exists and is set
     */
    mw.flow.Initializer.prototype.setComponentDom = function ( $component ) {
        if ( !$component || !$component.length ) {
            return false;
        }
        this.$component = $component;
        return true;
    };

    /**
     * Sets the DOM element that is the flow board
     *
     * @param {jQuery} $board The DOM element that is the board
     * @return {boolean} The board DOM element exists and is set
     */
    mw.flow.Initializer.prototype.setBoardDom = function ( $board ) {
        if ( !$board || !$board.length ) {
            return false;
        }
        this.$board = $board;
        return true;
    };

    /**
     * Set the flowBoard object representing the 'old' Flow system board
     *
     * @param {Object} board flowBoard _RecursiveConstructor
     */
    mw.flow.Initializer.prototype.setBoardObject = function ( board ) {
        var self = this;

        this.flowBoard = board;

        this.flowBoard.connect( this, {
            loadmore: function ( topiclist ) {
                // Add to dm board
                if ( self.system ) {
                    self.system.populateBoardTopicsFromJson( topiclist );
                }

                // Replace reply forms
                self.replaceReplyForms( self.$board );
                self.reopenPostWidgets( self.$board );
            },
            // HACK: Update the DM when topic is refreshed
            refreshTopic: function ( workflowId, topicData ) {
                var revisionId, revision,
                    topic = self.board.getItemById( workflowId ),
                    data = topicData.flow[ 'view-topic' ].result.topic;

                if ( !topic ) {
                    // New topic
                    mw.flow.system.populateBoardTopicsFromJson( data, 0 );
                } else {
                    // Topic already exists. Repopulate
                    revisionId = data.posts[ workflowId ];
                    revision = data.revisions[ revisionId ];

                    topic.populate( revision );
                }

                // Replace reply forms
                self.replaceReplyForms( topicData.$topic );
                self.reopenPostWidgets( topicData.$topic );
            }
        } );
    };

    /**
     * Set up the window manager
     */
    mw.flow.Initializer.prototype.setupWindowManager = function () {
        $( document.body ).append( mw.flow.ui.windowManager.$element );
    };

    /**
     * Set up the sidebar widget if needed
     */
    mw.flow.Initializer.prototype.setupSidebarWidget = function () {
        var sidebarExpandWidget,
            self = this;

        if (
            // eslint-disable-next-line no-jquery/no-class-state
            this.$component.hasClass( 'flow-topic-page' ) &&
            // eslint-disable-next-line no-jquery/no-class-state
            $( document.body ).hasClass( 'action-view' )
        ) {
            this.$board.toggleClass( 'flow-board-expanded', this.siderailCollapsed );

            // We are in single-topic view. Initialize the sidebar expand widget
            sidebarExpandWidget = new mw.flow.ui.SidebarExpandWidget( {
                collapsed: this.siderailCollapsed,
                expandedButtonTitle: mw.msg( 'flow-topic-collapse-siderail' ),
                collapsedButtonTitle: mw.msg( 'flow-topic-expand-siderail' )
            } );
            sidebarExpandWidget.$element.insertAfter( this.$board );

            // Events
            sidebarExpandWidget.on( 'toggle', function ( collapsed ) {
                self.$board.toggleClass( 'flow-board-expanded', collapsed );
            } );
        }
    };

    /**
     * Initialize the UI widgets
     */
    mw.flow.Initializer.prototype.initializeWidgets = function () {
        // Set up window manager
        this.setupWindowManager();

        // Set up sidebar widget if it needs to be there
        this.setupSidebarWidget();

        // Set up navigation widget
        this.setupNavigationWidget( $( '.flow-board-navigation' ) );

        // Set up new topic widget
        this.setupNewTopicWidget( $( 'form.flow-newtopic-form' ) );

        // Set up description widget
        this.setupDescriptionWidget( $( '.flow-ui-boardDescriptionWidget' ) );

        // Replace reply forms on the board
        this.replaceReplyForms( this.$board );

        /* Take over click actions */
        this.setupReplyLinkActions();
        this.setupEditPostAction();
        this.setupEditTopicSummaryAction();
        this.setupEditTopicTitleAction();

        this.reopenNewTopicWidget();
        this.reopenDescriptionWidget();
        this.reopenPostWidgets();
    };

    /**
     * Re-open any post widgets with stored data
     *
     * @param {jQuery} [$container] Container, defaults to this.$component
     */
    mw.flow.Initializer.prototype.reopenPostWidgets = function ( $container ) {
        $container = $container || this.$component;

        this.reopenReplyWidgets( $container );
        this.reopenTopicTitleWidgets( $container );
        this.reopenEditPostWidgets( $container );
        this.reopenEditTopicSummaryWidget( $container );
    };

    /**
     * Initialize the 'old' Flow ui component
     */
    mw.flow.Initializer.prototype.initOldComponent = function () {
        if ( this.$component ) {
            mw.flow.initComponent( this.$component );
        }
    };

    /**
     * Initialize the data model objects
     *
     * @param {Object} config Configuration options for the mw.flow.dm.System
     */
    mw.flow.Initializer.prototype.initDataModel = function ( config ) {
        var self = this;

        this.system = new mw.flow.dm.System( config );
        this.board = this.system.getBoard();
        // Initialize the old system to accept the default
        // order for the topic order widget
        this.flowBoard.topicIdSort = this.board.getSortOrder();

        // Events
        this.board.connect( this, {
            add: function ( newItems ) {
                var i, len, item, itemId;

                for ( i = 0, len = newItems.length; i < len; i++ ) {
                    item = newItems[ i ];
                    itemId = item.getId();

                    if ( self.flowBoard.orderedTopicIds.indexOf( itemId ) === -1 ) {
                        self.flowBoard.orderedTopicIds.push( itemId );
                    }

                    self.flowBoard.topicTitlesById[ itemId ] = item.getContent();
                    self.flowBoard.updateTimestampsByTopicId[ itemId ] = item.getLastUpdate();
                }
                self.flowBoard.sortTopicIds( self.flowBoard );
            },
            // E.g. on topic re-order, before re-population.
            clear: function () {
                self.flowBoard.orderedTopicIds = [];
                self.flowBoard.topicTitlesById = {};
            }
            // We shouldn't have to worry about 'remove', since by the time we have filtering,
            // orderedTopicIds should be gone.
        } );
    };

    /**
     * Get the data model system
     *
     * @return {mw.flow.dm.System} DM system
     */
    mw.flow.Initializer.prototype.getDataModelSystem = function () {
        return this.system;
    };

    /**
     * Populate the data model
     *
     * @param {Object} dataBlob Data blob to populate the system with
     */
    mw.flow.Initializer.prototype.populateDataModel = function ( dataBlob ) {
        var preloadTopic = OO.getProp( dataBlob, 'blocks', 'topiclist', 'submitted', 'topic' ),
            preloadContent = OO.getProp( dataBlob, 'blocks', 'topiclist', 'submitted', 'content' ),
            preloadFormat = OO.getProp( dataBlob, 'blocks', 'topiclist', 'submitted', 'format' );

        if ( dataBlob && dataBlob.blocks ) {
            // Populate the rendered topics or topic (if we are in a single-topic view)
            this.system.populateBoardTopicsFromJson( dataBlob.blocks.topiclist || dataBlob.blocks.topic );
            // Populate header
            this.system.populateBoardDescriptionFromJson( dataBlob.blocks.header || {} );
            // Populate the ToC topics
            if ( dataBlob.toc ) {
                this.system.populateBoardTopicsFromJson( dataBlob.toc );
            }
        } else {
            this.system.populateBoardFromApi();
        }
        if ( preloadTopic || preloadContent ) {
            this.newTopicWidget.preload( preloadTopic, preloadContent, preloadFormat );
        }
    };

    /**
     * Set up the navigation widget and its events
     *
     * @param {jQuery} $navDom Navigation widget DOM element
     */
    mw.flow.Initializer.prototype.setupNavigationWidget = function ( $navDom ) {
        var self = this;

        if ( !$navDom.length ) {
            return;
        }

        this.navWidget = new mw.flow.ui.NavigationWidget( this.system, {
            defaultSort: this.flowBoard.topicIdSort
        } );
        $navDom.append( this.navWidget.$element );

        // Events
        // Load a topic from the ToC that isn't rendered on
        // the page yet. This will be gone once board, topic
        // and post are widgetized.
        this.navWidget.connect( this, {
            loadTopic: function ( topicId ) {
                self.flowBoard.jumpToTopic( topicId );
            },
            reorderTopics: function ( newOrder ) {
                self.flowBoard.topicIdSort = newOrder;
            }
        } );

        // Connect to system events

        // HACK: These event handlers should be in the prospective widgets
        // they will move once we have Board UI and Topic UI widgets
        this.system.connect( this, {
            resetBoardStart: function () {
                self.$component.addClass( 'flow-api-inprogress' );
                // Before we reinitialize the board we have to detach
                // the navigation widget. This should not be necessary when
                // the board and topics are OOUI widgets
                self.navWidget.$element.detach();
            },
            resetBoardEnd: function ( data ) {
                var $rendered;

                // populateBoardFromApi uses the larger TOC limit so the TOC can
                // be fully populated on re-sort.  To avoid two requests
                // (TOC and full topics) with different limits, we do a single
                // full-topic request with that limit.
                //
                // However, this is inconsistent with the number of topics
                // we show at page load.
                //
                // This could be addressed by either showing the larger number of
                // topics on page load, doing two separate requests (might still be
                // faster considering the backend doesn't have to get full data for
                // many topics), or filtering the topic list on render.
                //
                // The latter (filter on render) could be done when the topic- and
                // board-widget are operational using some sort of computed subset
                // data model.
                $rendered = $(
                    mw.flow.TemplateEngine.processTemplateGetFragment(
                        'flow_block_loop',
                        { blocks: data }
                    )
                ).children();
                // Run this on a short timeout so that the other board handler in FlowBoardComponentLoadMoreFeatureMixin can run
                // TODO: Using a timeout doesn't seem like the right way to do this.
                setTimeout( function () {
                    var boardEl = $rendered[ 1 ];

                    // Since we've replaced the entire board, we need to reinitialize
                    // it. This also takes away the original navWidget, so we need to
                    // make sure it's reinitialized too
                    self.flowBoard.reinitializeContainer( $rendered );
                    $( '.flow-board-navigation' ).append( self.navWidget.$element );

                    self.setBoardDom( $( boardEl ) );

                    self.replaceReplyForms( self.$board );

                    self.setupNewTopicWidget( $( 'form.flow-newtopic-form' ) );

                    self.$component.removeClass( 'flow-api-inprogress' );
                }, 50 );
            }
        } );
    };

    /**
     * Set up the new topic widget and its events
     *
     * @param {jQuery} $form New topic form DOM element
     */
    mw.flow.Initializer.prototype.setupNewTopicWidget = function ( $form ) {
        var self = this;

        this.newTopicWidget = new mw.flow.ui.NewTopicWidget( this.pageTitle.getPrefixedDb(), {
            editor: {
                confirmLeave: !!mw.user.options.get( 'useeditwarning' )
            }
        } );

        // Events
        this.newTopicWidget.connect( this, {
            save: function ( newTopicId ) {
                // Display the new topic with the old system
                var $stub = $( '<div>' ).addClass( 'flow-topic' ).append( $( '<div>' ) ).prependTo( self.flowBoard.$container.find( '.flow-topics' ) );
                return this.flowBoard.flowBoardComponentRefreshTopic( $stub.find( 'div' ), newTopicId );
            }
        } ).once( 'save', this.reloadOnCreate ); // Reload page if board is new so we get page actions at top

        $form.replaceWith( this.newTopicWidget.$element );
    };

    /**
     * Re-open the new topic widget if it has stored data
     */
    mw.flow.Initializer.prototype.reopenNewTopicWidget = function () {
        if ( this.newTopicWidget.title.getValue() || mw.storage.session.get( this.newTopicWidget.id + '/ve-docstate' ) ) {
            this.newTopicWidget.activate();
        }
    };

    /**
     * Set up the description widget and its events
     *
     * @param {jQuery} $element Description DOM element
     */
    mw.flow.Initializer.prototype.setupDescriptionWidget = function ( $element ) {
        var initializer = this;
        if ( !$element.length ) {
            return;
        }

        this.descriptionWidget = new mw.flow.ui.BoardDescriptionWidget( this.board, {
            $existing: $( '.flow-ui-boardDescriptionWidget-content' ).contents(),
            $categories: $( '.flow-board-header-category-view-nojs' ).contents(),
            editor: {
                confirmLeave: !!mw.user.options.get( 'useeditwarning' )
            }
        } )
            // Reload page if board is new so we get page actions at top
            .once( 'saveContent', this.reloadOnCreate )
            .on( 'saveContent', function () {
                mw.hook( 'wikipage.content' ).fire( initializer.descriptionWidget.$content );
            } );

        // The category widget is inside the board description widget.
        // Remove it from the nojs version here
        $( '.flow-board-header-category-view-nojs' ).detach();
        // HACK: Remove the MW page categories
        $( '.catlinks:not(.flow-ui-categoriesWidget)' ).detach();

        $element.replaceWith( this.descriptionWidget.$element );
    };

    /**
     * Re-open the board description widget if it has stored data
     */
    mw.flow.Initializer.prototype.reopenDescriptionWidget = function () {
        if ( this.descriptionWidget && mw.storage.session.get( this.descriptionWidget.id + '/ve-docstate' ) ) {
            this.descriptionWidget.onEditButtonClick();
        }
    };

    /**
     * If the board page is being saved for the first time, reload the page
     * to show actions like History, Move, Protect, etc.
     */
    mw.flow.Initializer.prototype.reloadOnCreate = function () {
        if ( mw.config.get( 'wgArticleId' ) === 0 ) {
            location.reload();
        }
    };

    /**
     * Replace the reply forms given by the php version with js editors
     *
     * @param {jQuery} $element The element to conduct the replacements in
     */
    mw.flow.Initializer.prototype.replaceReplyForms = function ( $element ) {
        var self = this;

        if ( !$element || !$element.length ) {
            return;
        }

        $element.find( '.flow-post.flow-reply-form' ).each( function () {
            var $topic = $( this ).parent(),
                placeholder = mw.msg( 'flow-reply-topic-title-placeholder', $topic.find( '.flow-topic-title' ).text().trim() ),
                replyTo = $( this ).find( 'input[name="topic_replyTo"]' ).val(),
                replyWidget = new mw.flow.ui.ReplyWidget( $topic.data( 'flowId' ), replyTo, {
                    placeholder: placeholder,
                    editor: {
                        confirmLeave: !!mw.user.options.get( 'useeditwarning' )
                    }
                } );

            replyWidget.on( 'saveContent', function ( workflow ) {
                replyWidget.destroy().then( function () {
                    replyWidget.$element.remove();

                    // HACK: get the old system to rerender the topic
                    self.flowBoard.flowBoardComponentRefreshTopic(
                        $topic,
                        workflow
                    );
                } );
            } );
            replyWidget.$element.data( 'self', replyWidget );

            // Replace the reply form with the new editor widget
            $( this ).replaceWith( replyWidget.$element );
        } );
    };

    /**
     * Take over the action of the 'edit post' links
     * This is delegated, so it applies to all future links as well.
     */
    mw.flow.Initializer.prototype.setupEditPostAction = function () {
        this.$component.on( 'click', '.flow-ui-edit-post-link', function ( event ) {
            var editPostWidget,
                $topic = $( this ).closest( '.flow-topic' ),
                topicId = $topic.data( 'flow-id' ),
                $post = $( this ).closest( '.flow-post' ),
                $postMain = $post.children( '.flow-post-main' ),
                postId = $post.data( 'flow-id' ),
                $board = $( '.flow-board' ),
                flowBoard = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $board );

            editPostWidget = new mw.flow.ui.EditPostWidget( topicId, postId, {
                editor: {
                    confirmLeave: !!mw.user.options.get( 'useeditwarning' )
                }
            } );
            editPostWidget
                .on( 'saveContent', function ( workflow ) {
                    editPostWidget.destroy().then( function () {
                        editPostWidget.$element.remove();

                        // HACK get the old system to rerender the topic
                        flowBoard.flowBoardComponentRefreshTopic(
                            $topic,
                            workflow
                        );
                    } );
                } )
                .on( 'cancel', function () {
                    editPostWidget.destroy().then( function () {
                        editPostWidget.$element.replaceWith( $postMain );
                    } );
                } );

            $postMain.replaceWith( editPostWidget.$element );
            editPostWidget.activate();

            event.preventDefault();
        } );
    };

    /**
     * Re-open any edit post widgets with stored data
     *
     * @param {jQuery} $container
     */
    mw.flow.Initializer.prototype.reopenEditPostWidgets = function ( $container ) {
        // Re-open widgets with stored data
        $container.find( '.flow-ui-edit-post-link' ).each( function () {
            var $post = $( this ).closest( '.flow-post' ),
                postId = $post.data( 'flow-id' );

            if ( mw.storage.session.get( 'edit/' + postId + '/ve-docstate' ) ) {
                $( this ).trigger( 'click' );
            }
        } );
    };

    /**
     * Take over the action of the 'edit topic summary' links
     * This is delegated, so it applies to all future links as well.
     */
    mw.flow.Initializer.prototype.setupEditTopicSummaryAction = function () {
        var self = this;

        this.$component
            // Summarize action
            .on( 'click', '.flow-ui-summarize-topic-link', function ( event ) {
                var $topic = $( this ).closest( '.flow-topic' ),
                    topicId = $topic.data( 'flow-id' );

                self.startEditTopicSummary( true, topicId );
                event.preventDefault();
            } )
            // Lock action
            .on( 'click', '.flow-ui-topicmenu-lock', function () {
                var promise,
                    action = $( this ).data( 'role' ),
                    $topic = $( this ).closest( '.flow-topic' ),
                    topicId = $topic.data( 'flow-id' ),
                    api = new mw.flow.dm.APIHandler();

                if ( action === 'lock' ) {
                    promise = api.resolveTopic( topicId );
                } else {
                    promise = api.reopenTopic( topicId );
                }

                promise
                    .then( function ( workflow ) {
                        return self.flowBoard.flowBoardComponentRefreshTopic(
                            $topic,
                            workflow
                        );
                    } )
                    .then( function ( data ) {
                        var revisionId = data.topic.posts[ topicId ],
                            revision = data.topic.revisions[ revisionId ],
                            summaryContent = OO.getProp( revision, 'summary', 'revision', 'content', 'content' ),
                            skipSummarize = action === 'unlock' && !summaryContent;

                        if ( !skipSummarize ) {
                            self.startEditTopicSummary( true, topicId, action );
                        }
                    } )
                    .fail( function ( code, result ) {
                        var errorMsg = self.flowBoard.constructor.static.getApiErrorMessage( code, result );

                        self.flowBoard.emit( 'removeError', $topic );
                        self.flowBoard.emit( 'showError', $topic, errorMsg );
                    } );

                // Prevent default
                return false;
            } );
    };

    /**
     * Re-open any edit topic summary widgets with stored data
     *
     * @param {jQuery} $container
     */
    mw.flow.Initializer.prototype.reopenEditTopicSummaryWidget = function ( $container ) {
        $container.find( '.flow-ui-summarize-topic-link' ).each( function () {
            var $topic = $( this ).closest( '.flow-topic' ),
                topicId = $topic.data( 'flow-id' );

            if ( mw.storage.session.get( 'edit-summary/' + topicId + '/ve-docstate' ) ) {
                $( this ).trigger( 'click' );
            }
        } );
    };

    /**
     * Take over the action of the 'edit topic title' links
     * This is delegated, so it applies to all future links as well.
     */
    mw.flow.Initializer.prototype.setupEditTopicTitleAction = function () {
        var self = this;

        this.$component
            .on( 'click', 'a.flow-ui-edit-title-link', function ( event ) {
                var $topic = $( this ).closest( '.flow-topic' ),
                    topicId = $topic.data( 'flow-id' ),
                    $container = $topic.find( '.flow-topic-titlebar-container' ),
                    $topicTitleViewMode = $container.find( 'h2.flow-topic-title' ),
                    $editForm = $topic.find( '.flow-ui-topicTitleWidget' ),
                    widget;

                if ( $editForm.length ) {
                    event.preventDefault();
                    return false;
                }

                widget = new mw.flow.ui.TopicTitleWidget( topicId, {
                    editor: {
                        confirmLeave: !!mw.user.options.get( 'useeditwarning' )
                    }
                } );
                widget
                    .on( 'saveContent', function ( workflow ) {
                        widget.$element.remove();

                        return self.flowBoard.flowBoardComponentRefreshTopic(
                            $topic,
                            workflow
                        );
                    } )
                    .on( 'cancel', function () {
                        widget.$element.remove();
                        $container.prepend( $topicTitleViewMode );
                    } );

                $topicTitleViewMode.remove();
                $container.prepend( widget.$element );

                event.preventDefault();
            } );
    };

    /**
     * Re-open any edit topic title widgets with stored data
     *
     * @param {jQuery} $container
     */
    mw.flow.Initializer.prototype.reopenTopicTitleWidgets = function ( $container ) {
        $container.find( 'a.flow-ui-edit-title-link' ).each( function () {
            var $topic = $( this ).closest( '.flow-topic' ),
                topicId = $topic.data( 'flow-id' );

            if ( mw.storage.session.get( 'edit-topic/' + topicId + '/title' ) ) {
                $( this ).trigger( 'click' );
            }
        } );
    };

    /**
     * Take over the action of the 'reply' links.  This is delegated,
     * so it applies to current and future links.
     */
    mw.flow.Initializer.prototype.setupReplyLinkActions = function () {
        var self = this;

        // Replace the handler used for reply links.
        this.$component.on( 'click', 'a.flow-reply-link', function () {
            // Store the needed details so we can get rid of the URL in JS mode
            var replyWidget,
                existingWidget,
                href = $( this ).attr( 'href' ),
                uri = new mw.Uri( href ),
                replyTo = uri.query.topic_postId,
                $topic = $( this ).closest( '.flow-topic' ),
                placeholder = mw.msg( 'flow-reply-topic-title-placeholder', $topic.find( '.flow-topic-title' ).text().trim() ),
                // replyTo can refer to a post ID or a topic ID
                // For posts, the ReplyWidget should go in .flow-replies
                // For topics, it's directly inside the topic
                $targetContainer = $( '#flow-post-' + replyTo + ' > .flow-replies, #flow-topic-' + replyTo ),
                $existingWidget = $targetContainer.children( '.flow-ui-replyWidget' );

            // Check that there's not already a reply widget existing in the same place
            if ( $existingWidget.length > 0 ) {
                // Focus the existing reply widget
                existingWidget = $existingWidget.data( 'self' );
                existingWidget.activateEditor();
                existingWidget.focus();
                return false;
            }

            replyWidget = new mw.flow.ui.ReplyWidget( $topic.data( 'flowId' ), replyTo, {
                placeholder: placeholder,
                expandable: false,
                editor: {
                    confirmLeave: !!mw.user.options.get( 'useeditwarning' )
                }
            } );
            // Create a reference so we can call it from the DOM above
            replyWidget.$element.data( 'self', replyWidget );

            // Add reply form below the post being replied to (WRT max depth)
            $targetContainer.append( replyWidget.$element );
            replyWidget.activateEditor();

            replyWidget
                .on( 'saveContent', function ( workflow ) {
                    replyWidget.destroy().then( function () {
                        replyWidget.$element.remove();

                        // HACK get the old system to rerender the topic
                        self.flowBoard.flowBoardComponentRefreshTopic(
                            $topic,
                            workflow
                        );
                    } );
                } )
                .on( 'cancel', function () {
                    replyWidget.destroy().then( function () {
                        replyWidget.$element.remove();
                    } );
                } );

            return false;
        } );
    };

    /**
     * Re-open any reply widgets with stored data
     *
     * @param {jQuery} $container
     */
    mw.flow.Initializer.prototype.reopenReplyWidgets = function ( $container ) {
        var queuedClicks = {};

        $container.find( 'a.flow-reply-link' ).each( function () {
            var href = $( this ).attr( 'href' ),
                uri = new mw.Uri( href ),
                replyTo = uri.query.topic_postId;

            if ( mw.storage.session.get( 'reply/' + replyTo + '/ve-docstate' ) ) {
                // There can be multiple links to reply to a given topicId. They all behave
                // the same but show the widget in a slightly different place.
                // Assume the user wanted to use the last one.
                queuedClicks[ replyTo ] = this;
            }
        } );

        Object.keys( queuedClicks ).forEach( function ( replyTo ) {
            $( queuedClicks[ replyTo ] ).trigger( 'click' );
        } );
    };

    /**
     * Initialize the edit topic summary action
     *
     * @param {boolean} isFullBoard The page is a full board page
     * @param {string} topicId Topic id
     * @param {string} [action] Lock action 'lock' or 'unlock'. If not given, the action
     *  is assumed as summary only.
     */
    mw.flow.Initializer.prototype.startEditTopicSummary = function ( isFullBoard, topicId, action ) {
        var editTopicSummaryWidget,
            self = this,
            $topic = $( '#flow-topic-' + topicId ),
            $summaryContainer = $topic.find( '.flow-topic-summary-container' ),
            $topicSummary = $summaryContainer.find( '.flow-topic-summary' ),
            editorOptions = {
                confirmLeave: !!mw.user.options.get( 'useeditwarning' )
            },
            pageName = mw.config.get( 'wgPageName' ),
            title = mw.Title.newFromText( pageName );

        if ( !$summaryContainer.length ) {
            return;
        }

        // Don't launch a summary editor when there is one already
        if ( $summaryContainer.find( '.flow-ui-editTopicSummaryWidget' ).length ) {
            return;
        }

        // TODO: This should be managed by the EditTopicSummary widget
        if ( action === 'lock' || action === 'unlock' ) {
            editorOptions.cancelMsgKey = 'flow-skip-summary';
        }

        editTopicSummaryWidget = new mw.flow.ui.EditTopicSummaryWidget( topicId, { editor: editorOptions } );
        editTopicSummaryWidget
            .on( 'saveContent', function ( workflow ) {
                editTopicSummaryWidget.destroy().then( function () {
                    editTopicSummaryWidget.$element.remove();

                    if ( isFullBoard ) {
                        // HACK get the old system to rerender the topic
                        self.flowBoard.flowBoardComponentRefreshTopic(
                            $topic,
                            workflow
                        );
                    } else {
                        // HACK: redirect to topic view
                        window.location.href = title.getUrl();
                    }
                } );
            } )
            .on( 'cancel', function () {
                editTopicSummaryWidget.destroy().then( function () {
                    editTopicSummaryWidget.$element.remove();
                    if ( isFullBoard ) {
                        $summaryContainer.append( $topicSummary );
                    } else {
                        // HACK: redirect to topic view
                        window.location.href = title.getUrl();
                    }
                } );
            } );

        $topicSummary.remove();
        $summaryContainer.append( editTopicSummaryWidget.$element );
        editTopicSummaryWidget.activate();
    };

    /**
     * Replace the editor in no-js pages, like editing in a separate window
     *
     * @param {jQuery} $element The element to conduct the replacements in
     */
    mw.flow.Initializer.prototype.replaceNoJSEditor = function ( $element ) {
        var editPostWidget,
            $post = $element.parent(),
            $topic = $post.closest( '.flow-topic' ),
            self = this;

        function saveOrCancelHandler( workflow ) {
            editPostWidget.destroy().then( function () {
                editPostWidget.$element.remove();

                // HACK get the old system to rerender the topic
                self.flowBoard.flowBoardComponentRefreshTopic(
                    $topic,
                    workflow
                );
            } );
        }

        if ( !$element.length ) {
            return;
        }

        editPostWidget = new mw.flow.ui.EditPostWidget( $topic.data( 'flowId' ), $post.data( 'flowId' ), {
            editor: {
                confirmLeave: !!mw.user.options.get( 'useeditwarning' )
            }
        } );

        editPostWidget
            .on( 'saveContent', saveOrCancelHandler )
            // HACK: In this case, we are in an edge case where the topic already
            // loaded with the editor open. We can't trust the content of the editor
            // for displaying the post in case of a 'cancel' event and we don't have
            // the actual content stored in the DOM anywhere else.
            // We must reload the topic -- just like we do on save -- for a cancel
            // event too.
            .on( 'cancel', saveOrCancelHandler.bind( null, $topic.data( 'flowId' ) ) );

        $element.replaceWith( editPostWidget.$element );
        editPostWidget.activate();
    };

    /**
     * Create an editor widget
     *
     * @param {jQuery} $domToReplace The element, usually a form, that the new editor replaces
     * @param {string} [content] The content of the editing area
     * @param {string} [saveMsgKey] The message key for the editor save button
     * @param {string} [id] Editor ID
     * @return {mw.flow.ui.EditorWidget}
     */
    mw.flow.Initializer.prototype.createEditorWidget = function ( $domToReplace, content, saveMsgKey, id ) {
        var $wrapper,
            $messages = $( '<div>' ).addClass( 'flow-ui-editorContainerWidget-messages' ),
            isProbablyEditable = mw.config.get( 'wgIsProbablyEditable' ),
            anonWarning = new mw.flow.ui.AnonWarningWidget( {
                isProbablyEditable: isProbablyEditable
            } ),
            canNotEdit = new mw.flow.ui.CanNotEditWidget( new mw.flow.dm.APIHandler(), {
                userGroups: mw.config.get( 'wgUserGroups' ),
                restrictionEdit: mw.config.get( 'wgRestrictionEdit' ),
                isProbablyEditable: isProbablyEditable
            } ),
            captcha = new mw.flow.dm.Captcha(),
            captchaWidget = new mw.flow.ui.CaptchaWidget( captcha ),
            error = new OO.ui.LabelWidget( {
                classes: [ 'flow-ui-boardDescriptionWidget-error flow-errors flow-errorbox mw-message-box mw-message-box-error' ]
            } ),
            editor = new mw.flow.ui.EditorWidget( {
                saveMsgKey: saveMsgKey,
                confirmLeave: !!mw.user.options.get( 'useeditwarning' ),
                id: id
            } );

        function handleFailure( errorCode, errorObj ) {
            captchaWidget.model.update( errorCode, errorObj );

            if ( !captchaWidget.model.isRequired() ) {
                error.setLabel( new OO.ui.HtmlSnippet( errorObj.error && errorObj.error.info || errorObj.exception ) );
                error.toggle( true );
            }

            editor.popPending();
        }

        error.toggle( false );
        editor.toggle( true );
        anonWarning.toggle( mw.user.isAnon() );
        canNotEdit.toggle( !isProbablyEditable );

        $wrapper = $( '<div>' )
            .append(
                $messages.append(
                    error.$element,
                    captchaWidget.$element,
                    anonWarning.$element,
                    canNotEdit.$element
                ),
                editor.$element
            );

        $domToReplace.replaceWith( $wrapper );

        // Prepare the editor
        editor.pushPending();
        editor.activate( { content: content || '', format: 'wikitext' } )
            .then( function () {
                editor.popPending();
            } );

        editor
            .on( 'saveContent', function ( content, contentFormat ) {
                var captchaResponse;

                editor.pushPending();

                captchaResponse = captchaWidget.getResponse();

                error.setLabel( '' );
                error.toggle( false );

                // HACK: This is a cheat so that we can have a single function
                // that creates the editor and a single error-handler, but multiple
                // uses, especially for the APIhandler in different cases
                editor.emit( 'afterSaveContent', content, contentFormat, captchaResponse, handleFailure );
            } )
            .on( 'cancel', function () {
                editor.pushPending();
                editor.emit( 'afterCancel' );
                // returnToBoard();
            } );

        return editor;
    };

    /**
     * Check whether we are on an undo form page
     *
     * @return {boolean} The page is an in-progress undo form
     */
    mw.flow.Initializer.prototype.isUndoForm = function () {
        return !!$( 'form[data-module="topic"], form[data-module="header"], form[data-module="topicsummary"]' ).length;
    };

    mw.flow.Initializer.prototype.setupUndoPage = function () {
        var $undoForm = $( 'form[data-module="topic"], form[data-module="header"], form[data-module="topicsummary"]' ),
            undoType = $undoForm.attr( 'data-module' ),
            pageName = mw.config.get( 'wgPageName' ),
            title = mw.Title.newFromText( pageName ),
            topicId = title.getNameText(),
            postId = $undoForm.find( 'input[name="topic_postId"]' ).val(),
            prevRevId = $undoForm.find( 'input[name="' + undoType + '_prev_revision"]' ).val(),
            content = $undoForm.find( 'textarea' ).val(),
            returnToTitle = function () {
                var url;
                if ( undoType === 'topic' ) {
                    // If we're undoing a post edit, redirect to the topic page with the right parameter
                    // and fragment to highlight the post
                    url = title.getUrl( { topic_showPostId: postId } ) + '#flow-post-' + postId;
                } else {
                    // When undoing a topic summary edit, redirect to the topic;
                    // when undoing a board description edit, redirect to the board
                    url = title.getUrl();
                }
                window.location.href = url;
            },
            apiHandler = new mw.flow.dm.APIHandler(
                title.getPrefixedDb(),
                { currentRevision: prevRevId }
            ),
            save = ( {
                topic: apiHandler.savePost.bind( apiHandler, topicId, postId ),
                header: apiHandler.saveDescription.bind( apiHandler ),
                topicsummary: apiHandler.saveTopicSummary.bind( apiHandler, topicId )
            } )[ undoType ],
            saveMsgKey = ( {
                topic: [
                    'flow-post-action-edit-post-submit-anonymously',
                    'flow-post-action-edit-post-submit'
                ],
                header: [
                    'flow-edit-header-submit-anonymously',
                    'flow-edit-header-submit'
                ],
                topicsummary: [
                    'flow-topic-action-update-topic-summary',
                    'flow-topic-action-update-topic-summary'
                ]
            } )[ undoType ][ mw.user.isAnon() ? 0 : 1 ],
            editor = this.createEditorWidget( $undoForm, content, saveMsgKey, 'undo/' + prevRevId );

        if (
            mw.config.get( 'wgEditSubmitButtonLabelPublish' ) &&
            undoType !== 'topicsummary'
        ) {
            // i18n messages:
            // 'flow-post-action-edit-post-submit-anonymously-publish',
            // 'flow-post-action-edit-post-submit-publish'
            // 'flow-edit-header-submit-anonymously-publish',
            // 'flow-edit-header-submit-publish'
            saveMsgKey += '-publish';
        }
        editor
            .on( 'afterSaveContent', function ( content, contentFormat, captcha, handleFailure ) {
                save( content, contentFormat, captcha )
                    .then(
                        // Success
                        returnToTitle,

                        // Failure
                        handleFailure
                    );
            } )
            .on( 'afterCancel', returnToTitle );
    };

    /**
     * Finish the loading process
     */
    mw.flow.Initializer.prototype.finishLoading = function () {
        if ( this.$component ) {
            this.$component.addClass( 'flow-component-ready' );
        }
        $( '.flow-ui-load-overlay' ).addClass( 'oo-ui-element-hidden' );
    };
}() );