Mooeypoo has uploaded a new change for review. https://gerrit.wikimedia.org/r/248082
Change subject: [wip] Organize flow-initialize and add an Initializer object ...................................................................... [wip] Organize flow-initialize and add an Initializer object Change-Id: I315d7c423eaeaa0686f748644c460967fb2c7b9e --- M modules/flow-initialize.js A modules/mw.flow.Initializer.js 2 files changed, 787 insertions(+), 679 deletions(-) git pull ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/Flow refs/changes/82/248082/1 diff --git a/modules/flow-initialize.js b/modules/flow-initialize.js index 9772ec5..6ed0bfe 100644 --- a/modules/flow-initialize.js +++ b/modules/flow-initialize.js @@ -1,7 +1,6 @@ /*! * Runs Flow code, using methods in FlowUI. */ - ( function ( $ ) { // Pretend we got some data and run with it /* @@ -9,697 +8,70 @@ * @todo not like this */ $( document ).ready( function () { - var dataBlob, navWidget, flowBoard, dmBoard, newTopicWidget, sidebarExpandWidget, descriptionWidget, - siderailCollapsed = mw.user.options.get( 'flow-side-rail-state' ) === 'collapsed', - pageTitle = mw.Title.newFromText( mw.config.get( 'wgPageName' ) ), + var flowBoard, $component = $( '.flow-component' ), $board = $( '.flow-board' ), - preloadTopic, preloadContent, preloadFormat, - finishLoading = function () { - $component.addClass( 'flow-component-ready' ); - $( '.flow-ui-load-overlay' ).addClass( 'oo-ui-element-hidden' ); - }; + pageTitle = mw.Title.newFromText( mw.config.get( 'wgPageName' ) ), + initializer = new mw.flow.Initializer( { + $component: $component, + $board: $board + } ); - // HACK: If there is no component, we are not on a flow - // board at all, and there's no need to load anything. - // This is especially true for tests, though we should - // fix this by telling ResourceLoader to not load - // flow-initialize at all on tests. - if ( $component.length === 0 ) { - finishLoading(); + // Initialize component + if ( !initializer.setComponentDom( $component ) ) { + initializer.finishLoading(); return; } - mw.flow.initComponent( $component ); + // Initializer old system + initializer.initOldComponent( $component ); - // HACK: Similarly to the above hack, if there is no board - // we shouldn't proceed. This is true mainly to history pages - // that have the component but not the board DOM element. - if ( $board.length === 0 ) { + // Initialize board + if ( initializer.setBoardDom( $board ) ) { + // Set up flowBoard + flowBoard = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $board ); + initializer.setBoardObject( flowBoard ); + + // Initialize DM system and board + initializer.initDataModel( { + pageTitle: pageTitle, + tocPostsLimit: 50, + renderedTopics: $( '.flow-topic' ).length, + boardId: $component.data( 'flow-id' ), + defaultSort: $board.data( 'flow-sortby' ) + } ); + + // For reference and debugging + mw.flow.system = initializer.getDataModelSystem(); + + // Special board views (undo) + if ( $( 'form[data-module="topic"]' ) ) { + this.replaceEditorInUndoEditPost( $( 'form[data-module="topic"]' ) ); + } else if ( $( 'form[data-module="header"]' ) ) { + this.replaceEditorInUndoHeaderPost( $( 'form[data-module="header"]' ) ); + } else { + // Replace the no-js editor if we are editing in a + // new page + this.replaceNoJSEditor( $( '.flow-edit-post-form' ) ); + + // Create and replace UI widgets + initializer.initializeWidgets(); + + // Fall back to mw.flow.data, which was used until September 2015 + // NOTICE: This block must be after the initialization of the ui widgets so + // they can populate themselves according to the events. + initializer.populateDataModel( mw.config.get( 'wgFlowData' ) || ( mw.flow && mw.flow.data ) ); + } + } else { // Editing summary in a separate window. That has no - // flow-board, but we should still replace it - startEditTopicSummary( + // flow-board, but we should still replace the widget + initializer.startEditTopicSummary( false, $component.data( 'flow-id' ) ); - - // Mark as finished loading - finishLoading(); - return; - } - - // Set up window overlay - $( 'body' ).append( mw.flow.ui.windowOverlay.$element ); - mw.flow.ui.windowOverlay.$element.append( mw.flow.ui.windowManager.$element ); - - flowBoard = mw.flow.getPrototypeMethod( 'component', 'getInstanceByElement' )( $board ); - - if ( - $component.hasClass( 'topic-page' ) && - $( 'body' ).hasClass( 'action-view' ) - ) { - $board - .toggleClass( 'flow-board-expanded', siderailCollapsed ); - // We are in single-topic view. Initialize the sidebar expand widget - sidebarExpandWidget = new mw.flow.ui.SidebarExpandWidget( { - collapsed: siderailCollapsed, - expandedButtonTitle: mw.msg( 'flow-topic-collapse-siderail' ), - collapsedButtonTitle: mw.msg( 'flow-topic-expand-siderail' ) - } ); - sidebarExpandWidget.$element.insertAfter( $board ); - sidebarExpandWidget.on( 'toggle', function ( collapsed ) { - $board.toggleClass( 'flow-board-expanded', collapsed ); - } ); - } - - // Load data model - mw.flow.system = new mw.flow.dm.System( { - pageTitle: pageTitle, - tocPostsLimit: 50, - renderedTopics: $( '.flow-topic' ).length, - boardId: $component.data( 'flow-id' ), - defaultSort: $board.data( 'flow-sortby' ) - } ); - - dmBoard = mw.flow.system.getBoard(); - dmBoard.on( 'add', function ( newItems ) { - var i, len, item, itemId; - - for ( i = 0, len = newItems.length; i < len; i++ ) { - item = newItems[ i ]; - itemId = item.getId(); - - if ( $.inArray( itemId, flowBoard.orderedTopicIds ) === -1 ) { - flowBoard.orderedTopicIds.push( itemId ); - } - - flowBoard.topicTitlesById[ itemId ] = item.getContent(); - flowBoard.updateTimestampsByTopicId[ itemId ] = item.getLastUpdate(); - } - flowBoard.sortTopicIds( flowBoard ); - } ); - - // E.g. on topic re-order, before re-population. - dmBoard.on( 'clear', function () { - flowBoard.orderedTopicIds = []; - flowBoard.topicTitlesById = {}; - } ); - // We shouldn't have to worry about 'remove', since by the time we have filtering, - // orderedTopicIds should be gone. - - // Initialize the old system to accept the default - // 'newest' order for the topic order widget - // Get the current default sort - flowBoard.topicIdSort = mw.flow.system.getBoard().getSortOrder(); - - /* UI Widgets */ - navWidget = new mw.flow.ui.NavigationWidget( mw.flow.system, { - defaultSort: flowBoard.topicIdSort - } ); - $( '.flow-board-navigation' ).append( navWidget.$element ); - - // HACK: These event handlers should be in the prospective widgets - // they will move once we have Board UI and Topic UI widgets - mw.flow.system.on( 'resetBoardStart', function () { - $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 - navWidget.$element.detach(); - } ); - - mw.flow.system.on( 'resetBoardEnd', function ( data ) { - var $rendered; - - // HACK: Trim the data for only the rendered topics - // We can't have the API request use a limit because when we - // rebuild the board (when we reorder) we want the full 50 - // topics for the ToC widget. When the topic- and board-widget - // are operational, we should render properly without using this - // hack. - data.topiclist.roots = data.topiclist.roots.splice( 0, mw.flow.system.getRenderedTopics() ); - - $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 () { - // Reinitialize the whole board with these nodes - // flowBoard.reinitializeContainer( $rendered ); - $board.empty().append( $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 - flowBoard.reinitializeContainer( $rendered ); - $( '.flow-board-navigation' ).append( navWidget.$element ); - - $component.removeClass( 'flow-api-inprogress' ); - }, 50 ); - - } ); - - // HACK: On load more, populate the board dm - flowBoard.on( 'loadmore', function ( topiclist ) { - // Add to the DM board - mw.flow.system.populateBoardTopicsFromJson( topiclist ); - - replaceReplyForms( $board ); - deactivateReplyLinks( $board ); - } ); - - // HACK: Update the DM when topic is refreshed - flowBoard.on( 'refreshTopic', function ( workflowId, topicData ) { - var revisionId, revision, - topic = dmBoard.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 ); - } - - replaceReplyForms( topicData.$topic ); - deactivateReplyLinks( topicData.$topic ); - } ); - - // 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. - navWidget.on( 'loadTopic', function ( topicId ) { - flowBoard.jumpToTopic( topicId ); - } ); - navWidget.on( 'reorderTopics', function ( newOrder ) { - flowBoard.topicIdSort = newOrder; - } ); - - // Replace reply inputs with the editor widget - function replaceReplyForms( $element ) { - $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 - } ); - - replyWidget.on( 'saveContent', function ( workflow ) { - replyWidget.destroy(); - replyWidget.$element.remove(); - - // HACK get the old system to rerender the topic - return flowBoard.flowBoardComponentRefreshTopic( - $topic, - workflow - ); - } ); - replyWidget.$element.data( 'self', replyWidget ); - - // Replace the reply form with the new editor widget - $( this ).replaceWith( replyWidget.$element ); - } ); - } - replaceReplyForms( $board ); - - // No-JS view with JavaScript on (replace old editor) - function replaceNoJSEditor( $element ) { - var editPostWidget, - $post = $element.parent(), - $topic = $post.closest( '.flow-topic' ); - - function saveOrCancelHandler( workflow ) { - editPostWidget.destroy(); - editPostWidget.$element.remove(); - - // HACK get the old system to rerender the topic - return flowBoard.flowBoardComponentRefreshTopic( - $topic, - workflow - ); - } - - if ( !$element.length ) { - return; - } - - editPostWidget = new mw.flow.ui.EditPostWidget( $topic.data( 'flowId' ), $post.data( 'flowId' ) ); - - 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 ); - } - // Editing in a separate window - replaceNoJSEditor( $( '.flow-edit-post-form' ) ); - - // Undo actions - if ( $( 'form[data-module="topic"]' ).length ) { - replaceEditorInUndoEditPost( $( 'form[data-module="topic"]' ) ); - finishLoading(); - return; - } else if ( $( 'form[data-module="header"]' ).length ) { - replaceEditorInUndoHeaderPost( $( 'form[data-module="header"]' ) ); - finishLoading(); - return; - } - - function createEditorWidget( $domToReplace, content, saveMsgKey ) { - var $wrapper, - anonWarning = new mw.flow.ui.AnonWarningWidget(), - error = new OO.ui.LabelWidget( { - classes: [ 'flow-ui-boardDescriptionWidget-error flow-errors errorbox' ] - } ), - editor = new mw.flow.ui.EditorWidget( { - saveMsgKey: saveMsgKey - } ); - - error.toggle( false ); - editor.toggle( true ); - anonWarning.toggle( mw.user.isAnon() ); - - // HACK: We still need a reference to the error widget, for - // the api responses in the intialized widgets that use this - // function, so make a forced connection - editor.error = error; - - // Prepare the editor - editor.pushPending(); - editor.activate(); - - editor.setContent( content, 'wikitext' ) - .then( function () { - editor.popPending(); - } ); - - editor - .on( 'saveContent', function ( content, contentFormat ) { - var $captchaField, captcha; - - editor.pushPending(); - - $captchaField = error.$label.find( '[name="wpCaptchaWord"]' ); - if ( $captchaField.length > 0 ) { - captcha = { - id: this.error.$label.find( '[name="wpCaptchaId"]' ).val(), - answer: $captchaField.val() - }; - } - error.setLabel( '' ); - error.toggle( false ); - - // HACK: This is a cheat so that we can have a single function - // that creates the editor, but multiple uses, especially for the - // APIhandler in different cases - editor.emit( 'afterSaveContent', content, contentFormat, captcha ); - } ) - .on( 'cancel', function () { - editor.pushPending(); - editor.emit( 'afterCancel' ); - // returnToBoard(); - } ); - - $wrapper = $( '<div>' ) - .append( - error.$element, - anonWarning.$element, - editor.$element - ); - $domToReplace.replaceWith( $wrapper ); - - return editor; - } - - function replaceEditorInUndoEditPost( $form ) { - var apiHandler, content, postId, editor, prevRevId, - pageName = mw.config.get( 'wgPageName' ), - title = mw.Title.newFromText( pageName ), - topicId = title.getNameText(), - returnToTitle = function () { - // HACK: redirect to topic view - window.location.href = title.getUrl(); - }; - - if ( !$form.length ) { - return; - } - - postId = $form.find( 'input[name="topic_postId"]' ).val(); - prevRevId = $form.find( 'input[name="topic_prev_revision"]' ).val(); - content = $form.find( 'textarea' ).val(); - - apiHandler = new mw.flow.dm.APIHandler( - 'Topic:' + topicId, - { - currentRevision: prevRevId - } - ); - - // Create the editor - editor = createEditorWidget( - $form, - content, - mw.user.isAnon() ? 'flow-post-action-edit-post-submit-anonymously' : 'flow-post-action-edit-post-submit' - ); - - // Events - editor - .on( 'afterSaveContent', function ( content, contentFormat, captcha ) { - apiHandler.savePost( topicId, postId, content, contentFormat, captcha ) - .then( - // Success - returnToTitle, - // Failure - function ( errorCode, errorObj ) { - if ( /spamfilter$/.test( errorCode ) && errorObj.error.spamfilter === 'flow-spam-confirmedit-form' ) { - editor.error.setLabel( - // CAPTCHA form - new OO.ui.HtmlSnippet( errorObj.error.info ) - ); - } else { - editor.error.setLabel( errorObj.error && errorObj.error.info || errorObj.exception ); - } - - editor.error.toggle( true ); - editor.popPending(); - } - ); - } ) - .on( 'afterCancel', returnToTitle ); - } - - function replaceEditorInUndoHeaderPost( $form ) { - var prevRevId, editor, content, - error, apiHandler, - pageName = mw.config.get( 'wgPageName' ), - title = mw.Title.newFromText( pageName ), - returnToBoard = function () { - window.location.href = title.getUrl(); - }; - - if ( !$form.length ) { - return; - } - - prevRevId = $form.find( 'input[name="header_prev_revision"]' ).val(); - content = $form.find( 'textarea[name="header_content"]' ).val(); - - apiHandler = new mw.flow.dm.APIHandler( - title.getPrefixedDb(), - { - currentRevision: prevRevId - } - ); - - // Create the editor - editor = createEditorWidget( - $form, - content, - mw.user.isAnon() ? 'flow-edit-header-submit-anonymously' : 'flow-edit-header-submit' - ); - - // Events - editor - .on( 'afterSaveContent', function ( content, contentFormat, captcha ) { - apiHandler.saveDescription( content, contentFormat, captcha ) - .then( - // Success - returnToBoard, - // Failure - function ( errorCode, errorObj ) { - if ( /spamfilter$/.test( errorCode ) && errorObj.error.spamfilter === 'flow-spam-confirmedit-form' ) { - error.setLabel( - // CAPTCHA form - new OO.ui.HtmlSnippet( errorObj.error.info ) - ); - } else { - error.setLabel( errorObj.error && errorObj.error.info || errorObj.exception ); - } - editor.popPending(); - } - ); - } ) - .on( 'afterCancel', function () { - returnToBoard(); - } ); - } - - // Replace the 'reply' buttons so they all produce replyWidgets rather - // than the reply forms from the API - $board - .on( 'click', '.flow-ui-reply-link-trigger', function () { - var replyWidget, - $topic = $( this ).closest( '.flow-topic' ), - placeholder = mw.msg( 'flow-reply-topic-title-placeholder', $topic.find( '.flow-topic-title' ).text().trim() ), - replyTo = $( this ).data( 'postId' ), - // 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.data( 'self' ).activateEditor(); - $existingWidget.data( 'self' ).focus(); - return false; - } - - replyWidget = new mw.flow.ui.ReplyWidget( $topic.data( 'flowId' ), replyTo, { - placeholder: placeholder, - expandable: false - } ); - // 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(); - replyWidget.$element.remove(); - - // HACK get the old system to rerender the topic - return flowBoard.flowBoardComponentRefreshTopic( - $topic, - workflow - ); - } ) - .on( 'cancel', function () { - replyWidget.destroy(); - replyWidget.$element.remove(); - } ); - - return false; - } ); - - function deactivateReplyLinks( $element ) { - // Cancel the interactive handler so "old" system doesn't get triggered for internal replies - $element.find( 'a.flow-reply-link' ).each( function () { - // Store the needed details so we can get rid of the URL in JS mode - var href = $( this ).attr( 'href' ), - uri = new mw.Uri( href ), - postId = uri.query.topic_postId; - - $( this ) - .data( 'postId', postId ) - .attr( 'data-flow-interactive-handler', '' ) - .attr( 'href', '' ) - .addClass( 'flow-ui-reply-link-trigger' ); - } ); - } - deactivateReplyLinks( $board ); - - // New topic form - newTopicWidget = new mw.flow.ui.NewTopicWidget( pageTitle.getPrefixedDb() ); - newTopicWidget.on( 'save', function ( newTopicId ) { - // Display the new topic with the old system - var $stub = $( '<div class="flow-topic"><div></div></div>' ).prependTo( flowBoard.$container.find( '.flow-topics' ) ); - return flowBoard.flowBoardComponentRefreshTopic( $stub.find( 'div' ), newTopicId ); - } ); - $( 'form.flow-newtopic-form' ).replaceWith( newTopicWidget.$element ); - - $board.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 ); - editPostWidget - .on( 'saveContent', function ( workflow ) { - editPostWidget.destroy(); - editPostWidget.$element.remove(); - - // HACK get the old system to rerender the topic - return flowBoard.flowBoardComponentRefreshTopic( - $topic, - workflow - ); - } ) - .on( 'cancel', function () { - editPostWidget.destroy(); - editPostWidget.$element.replaceWith( $postMain ); - } ); - - $postMain.replaceWith( editPostWidget.$element ); - - event.preventDefault(); - } ); - - function startEditTopicSummary( isFullBoard, topicId, action ) { - var editTopicSummaryWidget, - $topic = $( '#flow-topic-' + topicId ), - $summaryContainer = $topic.find( '.flow-topic-summary-container' ), - $topicSummary = $summaryContainer.find( '.flow-topic-summary' ), - options = {}, - pageName = mw.config.get( 'wgPageName' ), - title = mw.Title.newFromText( pageName ); - - if ( !$summaryContainer.length ) { - return; - } - - if ( action === 'lock' || action === 'unlock' ) { - options = { - cancelMsgKey: 'flow-skip-summary' - }; - } - - editTopicSummaryWidget = new mw.flow.ui.EditTopicSummaryWidget( topicId, options ); - editTopicSummaryWidget - .on( 'saveContent', function ( workflow ) { - editTopicSummaryWidget.destroy(); - editTopicSummaryWidget.$element.remove(); - - if ( isFullBoard ) { - // HACK get the old system to rerender the topic - return flowBoard.flowBoardComponentRefreshTopic( - $topic, - workflow - ); - } else { - // HACK: redirect to topic view - window.location.href = title.getUrl(); - } - } ) - .on( 'cancel', function () { - editTopicSummaryWidget.destroy(); - 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 ); - } - - $board.on( 'click', '.flow-ui-summarize-topic-link', function ( event ) { - var $topic = $( this ).closest( '.flow-topic' ), - topicId = $topic.data( 'flow-id' ); - startEditTopicSummary( true, topicId ); - event.preventDefault(); - } ); - - $board.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 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 ) { - startEditTopicSummary( true, topicId, action ); - } - } ); - - // Prevent default - return false; - } ); - - // Board description widget - descriptionWidget = new mw.flow.ui.BoardDescriptionWidget( dmBoard, { - $existing: $( '.flow-ui-boardDescriptionWidget-content' ).contents(), - $categories: $( '.flow-board-header-category-view-nojs' ).contents() - } ); - $( '.flow-ui-boardDescriptionWidget' ).replaceWith( descriptionWidget.$element ); - - // The category widget is inside the board description widget. - // Remove it here - $( '.flow-board-header-category-view-nojs' ).detach(); - // HACK: Remove the MW page categories - $( '.catlinks:not(.flow-ui-categoriesWidget)' ).detach(); - - // Fall back to mw.flow.data, which was used until September 2015 - // NOTICE: This block must be after the initialization of the ui widgets so - // they can populate themselves according to the events. - dataBlob = mw.config.get( 'wgFlowData' ) || ( mw.flow && mw.flow.data ); - if ( dataBlob && dataBlob.blocks ) { - // Populate the rendered topics or topic (if we are in a single-topic view) - mw.flow.system.populateBoardTopicsFromJson( dataBlob.blocks.topiclist || dataBlob.blocks.topic ); - // Populate header - mw.flow.system.populateBoardDescriptionFromJson( dataBlob.blocks.header || {} ); - // Populate the ToC topics - if ( dataBlob.toc ) { - mw.flow.system.populateBoardTopicsFromJson( dataBlob.toc ); - } - } else { - mw.flow.system.populateBoardFromApi(); - } - - 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 ( preloadTopic || preloadContent ) { - newTopicWidget.preload( preloadTopic, preloadContent, preloadFormat ); } // Show the board - finishLoading(); + initializer.finishLoading(); } ); }( jQuery ) ); diff --git a/modules/mw.flow.Initializer.js b/modules/mw.flow.Initializer.js new file mode 100644 index 0000000..e83b7b0 --- /dev/null +++ b/modules/mw.flow.Initializer.js @@ -0,0 +1,736 @@ +( function ( $ ) { + mw.flow.Initializer = function ( config ) { + config = config || {}; + + // Mixin constructor + OO.EventEmitter.call( this ); + + this.$component = null; + this.$board = null; + this.siderailCollapsed = mw.user.options.get( 'flow-side-rail-state' ) === 'collapsed'; + + this.system = null; + this.board = null; + this.navWidget = null; + }; + + /* Inheritance */ + + OO.initClass( mw.flow.Initializer ); + OO.mixinClass( mw.flow.Initializer, OO.EventEmitter ); + + /** + * Sets the DOM element that is the flow component + * + * @throws {Error} If there is no component + * @param {jQuery} $component The DOM element that is the component + */ + mw.flow.Initializer.prototype.setComponentDom = function ( $component ) { + if ( !$component || !$component.length ) { + throw new Error( 'No component found.' ); + } + this.$component = $component; + }; + + /** + * Sets the DOM element that is the flow board + * + * @throws {Error} If there is no board + * @param {jQuery} $board The DOM element that is the board + */ + mw.flow.Initializer.prototype.setBoardDom = function ( $board ) { + if ( !$board || !$board.length ) { + throw new Error( 'Not a board page.' ); + } + this.$board = $board; + }; + + 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 ); + // Deactivate reply links + self.deactivateReplyLinks( 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 ); + // Deactivate reply links + self.deactivateReplyLinks( topicData.$topic ); + } + } ); + }; + + /** + * Set up the window overlay + */ + mw.flow.Initializer.prototype.setupWindowOverlay = function () { + // Set up window overlay + $( 'body' ).append( mw.flow.ui.windowOverlay.$element ); + mw.flow.ui.windowOverlay.$element.append( mw.flow.ui.windowManager.$element ); + }; + + /** + * Set up the sidebar widget if needed + */ + mw.flow.Initializer.prototype.setupSidebarWidget = function () { + var sidebarExpandWidget; + + if ( + this.$component.hasClass( 'topic-page' ) && + $( '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 ) { + this.$board.toggleClass( 'flow-board-expanded', collapsed ); + } ); + } + }; + + mw.flow.Initializer.prototype.initializeWidgets = function () { + // Set up window overlay + this.setupWindowOverlay(); + + // 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.setupDesciptionWidget( $( '.flow-ui-boardDescriptionWidget' ) ); + + // Replace reply forms on the board + this.replaceReplyForms( this.$board ); + + /* Take over actions */ + this.setupEditPostAction(); + this.setupEditTopicSummaryAction(); + }; + + /** + * Initialize the 'old' Flow ui component + */ + mw.flow.Initializer.prototype.initOldComponent = function () { + if ( this.$component ) { + mw.flow.initComponent( this.$component ); + } + }; + + 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 + // 'newest' order for the topic order widget + // Get the current default sort + this.flowBoard.topicIdSort = mw.flow.system.getBoard().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 ( $.inArray( itemId, self.flowBoard.orderedTopicIds ) === -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. + } ); + }; + + mw.flow.Initializer.prototype.getDataModelSystem = function () { + return this.system; + }; + + 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 ); + } + }; + + 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; + + // HACK: Trim the data for only the rendered topics + // We can't have the API request use a limit because when we + // rebuild the board (when we reorder) we want the full 50 + // topics for the ToC widget. When the topic- and board-widget + // are operational, we should render properly without using this + // hack. + data.topiclist.roots = data.topiclist.roots.splice( 0, mw.flow.system.getRenderedTopics() ); + $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 () { + // Reinitialize the whole board with these nodes + // flowBoard.reinitializeContainer( $rendered ); + self.$board.empty().append( $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.$component.removeClass( 'flow-api-inprogress' ); + }, 50 ); + } + } ); + }; + + mw.flow.Initializer.prototype.setupNewTopicWidget = function ( $form ) { + var self = this; + + this.newTopicWidget = new mw.flow.ui.NewTopicWidget( this.pageTitle.getPrefixedDb() ); + + // Events + this.newTopicWidget.connect( this, { + save: function ( newTopicId ) { + // Display the new topic with the old system + var $stub = $( '<div class="flow-topic"><div></div></div>' ).prependTo( self.flowBoard.$container.find( '.flow-topics' ) ); + return this.flowBoard.flowBoardComponentRefreshTopic( $stub.find( 'div' ), newTopicId ); + } + } ); + + $form.replaceWith( this.newTopicWidget.$element ); + }; + + mw.flow.Initializer.prototype.setupDesciptionWidget = function ( $element ) { + var descriptionWidget; + + if ( !$element.length ) { + return; + } + + descriptionWidget = new mw.flow.ui.BoardDescriptionWidget( this.board, { + $existing: $( '.flow-ui-boardDescriptionWidget-content' ).contents(), + $categories: $( '.flow-board-header-category-view-nojs' ).contents() + } ); + + // 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( descriptionWidget.$element ); + }; + + mw.flow.Initializer.prototype.replaceReplyForms = function ( $element ) { + var self = this; + + if ( !this.$board || !this.$board.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 + } ); + + replyWidget.on( 'saveContent', function ( workflow ) { + replyWidget.destroy(); + replyWidget.$element.remove(); + + // HACK: get the old system to rerender the topic + return self.flowBoard.flowBoardComponentRefreshTopic( + $topic, + workflow + ); + } ); + replyWidget.$element.data( 'self', replyWidget ); + + // Replace the reply form with the new editor widget + $( this ).replaceWith( replyWidget.$element ); + } ); + }; + + mw.flow.Initializer.prototype.setupEditPostAction = function () { + this.$board.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 ); + editPostWidget + .on( 'saveContent', function ( workflow ) { + editPostWidget.destroy(); + editPostWidget.$element.remove(); + + // HACK get the old system to rerender the topic + return flowBoard.flowBoardComponentRefreshTopic( + $topic, + workflow + ); + } ) + .on( 'cancel', function () { + editPostWidget.destroy(); + editPostWidget.$element.replaceWith( $postMain ); + } ); + + $postMain.replaceWith( editPostWidget.$element ); + + event.preventDefault(); + } ); + }; + + mw.flow.Initializer.prototype.setupEditTopicSummaryAction = function () { + var self = this; + + this.$board + // Summarize action + .on( 'click', '.flow-ui-summarize-topic-link', function ( event ) { + var $topic = $( this ).closest( '.flow-topic' ), + topicId = $topic.data( 'flow-id' ); + this.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 ); + } + } ); + + // Prevent default + return false; + } ); + }; + + 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' ), + options = {}, + pageName = mw.config.get( 'wgPageName' ), + title = mw.Title.newFromText( pageName ); + + if ( !$summaryContainer.length ) { + return; + } + + if ( action === 'lock' || action === 'unlock' ) { + options = { + cancelMsgKey: 'flow-skip-summary' + }; + } + + editTopicSummaryWidget = new mw.flow.ui.EditTopicSummaryWidget( topicId, options ); + editTopicSummaryWidget + .on( 'saveContent', function ( workflow ) { + editTopicSummaryWidget.destroy(); + editTopicSummaryWidget.$element.remove(); + + if ( isFullBoard ) { + // HACK get the old system to rerender the topic + return self.flowBoard.flowBoardComponentRefreshTopic( + $topic, + workflow + ); + } else { + // HACK: redirect to topic view + window.location.href = title.getUrl(); + } + } ) + .on( 'cancel', function () { + editTopicSummaryWidget.destroy(); + 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 ); + }; + + mw.flow.Initializer.prototype.replaceNoJSEditor = function ( $element ) { + var editPostWidget, + $post = $element.parent(), + $topic = $post.closest( '.flow-topic' ); + + function saveOrCancelHandler( workflow ) { + editPostWidget.destroy(); + editPostWidget.$element.remove(); + + // HACK get the old system to rerender the topic + return this.flowBoard.flowBoardComponentRefreshTopic( + $topic, + workflow + ); + } + + if ( !$element.length ) { + return; + } + + editPostWidget = new mw.flow.ui.EditPostWidget( $topic.data( 'flowId' ), $post.data( 'flowId' ) ); + + 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 ); + }; + + mw.flow.Initializer.prototype.deactivateReplyLinks = function ( $element ) { + // Cancel the interactive handler so "old" system doesn't get triggered for internal replies + $element.find( 'a.flow-reply-link' ).each( function () { + // Store the needed details so we can get rid of the URL in JS mode + var href = $( this ).attr( 'href' ), + uri = new mw.Uri( href ), + postId = uri.query.topic_postId; + + $( this ) + .data( 'postId', postId ) + .attr( 'data-flow-interactive-handler', '' ) + .attr( 'href', '' ) + .addClass( 'flow-ui-reply-link-trigger' ); + } ); + }; + + mw.flow.Initializer.prototype.createEditorWidget = function ( $domToReplace, content, saveMsgKey ) { + var $wrapper, + anonWarning = new mw.flow.ui.AnonWarningWidget(), + error = new OO.ui.LabelWidget( { + classes: [ 'flow-ui-boardDescriptionWidget-error flow-errors errorbox' ] + } ), + editor = new mw.flow.ui.EditorWidget( { + saveMsgKey: saveMsgKey + } ); + + error.toggle( false ); + editor.toggle( true ); + anonWarning.toggle( mw.user.isAnon() ); + + // HACK: We still need a reference to the error widget, for + // the api responses in the intialized widgets that use this + // function, so make a forced connection + editor.error = error; + + // Prepare the editor + editor.pushPending(); + editor.activate(); + + editor.setContent( content, 'wikitext' ) + .then( function () { + editor.popPending(); + } ); + + editor + .on( 'saveContent', function ( content, contentFormat ) { + var $captchaField, captcha; + + editor.pushPending(); + + $captchaField = error.$label.find( '[name="wpCaptchaWord"]' ); + if ( $captchaField.length > 0 ) { + captcha = { + id: this.error.$label.find( '[name="wpCaptchaId"]' ).val(), + answer: $captchaField.val() + }; + } + error.setLabel( '' ); + error.toggle( false ); + + // HACK: This is a cheat so that we can have a single function + // that creates the editor, but multiple uses, especially for the + // APIhandler in different cases + editor.emit( 'afterSaveContent', content, contentFormat, captcha ); + } ) + .on( 'cancel', function () { + editor.pushPending(); + editor.emit( 'afterCancel' ); + // returnToBoard(); + } ); + + $wrapper = $( '<div>' ) + .append( + error.$element, + anonWarning.$element, + editor.$element + ); + $domToReplace.replaceWith( $wrapper ); + + return editor; + }; + + mw.flow.Initializer.prototype.replaceEditorInUndoEditPost = function ( $form ) { + var apiHandler, content, postId, editor, prevRevId, + pageName = mw.config.get( 'wgPageName' ), + title = mw.Title.newFromText( pageName ), + topicId = title.getNameText(), + returnToTitle = function () { + // HACK: redirect to topic view + window.location.href = title.getUrl(); + }; + + if ( !$form.length ) { + return; + } + + postId = $form.find( 'input[name="topic_postId"]' ).val(); + prevRevId = $form.find( 'input[name="topic_prev_revision"]' ).val(); + content = $form.find( 'textarea' ).val(); + + apiHandler = new mw.flow.dm.APIHandler( + 'Topic:' + topicId, + { + currentRevision: prevRevId + } + ); + + // Create the editor + editor = this.createEditorWidget( + $form, + content, + mw.user.isAnon() ? 'flow-post-action-edit-post-submit-anonymously' : 'flow-post-action-edit-post-submit' + ); + + // Events + editor + .on( 'afterSaveContent', function ( content, contentFormat, captcha ) { + apiHandler.savePost( topicId, postId, content, contentFormat, captcha ) + .then( + // Success + returnToTitle, + // Failure + function ( errorCode, errorObj ) { + if ( /spamfilter$/.test( errorCode ) && errorObj.error.spamfilter === 'flow-spam-confirmedit-form' ) { + editor.error.setLabel( + // CAPTCHA form + new OO.ui.HtmlSnippet( errorObj.error.info ) + ); + } else { + editor.error.setLabel( errorObj.error && errorObj.error.info || errorObj.exception ); + } + + editor.error.toggle( true ); + editor.popPending(); + } + ); + } ) + .on( 'afterCancel', returnToTitle ); + }; + + mw.flow.Initializer.prototype.replaceEditorInUndoHeaderPost = function ( $form ) { + var prevRevId, editor, content, + error, apiHandler, + pageName = mw.config.get( 'wgPageName' ), + title = mw.Title.newFromText( pageName ), + returnToBoard = function () { + window.location.href = title.getUrl(); + }; + + if ( !$form.length ) { + return; + } + + prevRevId = $form.find( 'input[name="header_prev_revision"]' ).val(); + content = $form.find( 'textarea[name="header_content"]' ).val(); + + apiHandler = new mw.flow.dm.APIHandler( + title.getPrefixedDb(), + { + currentRevision: prevRevId + } + ); + + // Create the editor + editor = this.createEditorWidget( + $form, + content, + mw.user.isAnon() ? 'flow-edit-header-submit-anonymously' : 'flow-edit-header-submit' + ); + + // Events + editor + .on( 'afterSaveContent', function ( content, contentFormat, captcha ) { + apiHandler.saveDescription( content, contentFormat, captcha ) + .then( + // Success + returnToBoard, + // Failure + function ( errorCode, errorObj ) { + if ( /spamfilter$/.test( errorCode ) && errorObj.error.spamfilter === 'flow-spam-confirmedit-form' ) { + error.setLabel( + // CAPTCHA form + new OO.ui.HtmlSnippet( errorObj.error.info ) + ); + } else { + error.setLabel( errorObj.error && errorObj.error.info || errorObj.exception ); + } + editor.popPending(); + } + ); + } ) + .on( 'afterCancel', function () { + returnToBoard(); + } ); + }; + + /** + * Finish the loading process + */ + mw.flow.Initializer.prototype.finishLoading = function () { + this.$component.addClass( 'flow-component-ready' ); + $( '.flow-ui-load-overlay' ).addClass( 'oo-ui-element-hidden' ); + }; +}( jQuery ) ); -- To view, visit https://gerrit.wikimedia.org/r/248082 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: newchange Gerrit-Change-Id: I315d7c423eaeaa0686f748644c460967fb2c7b9e Gerrit-PatchSet: 1 Gerrit-Project: mediawiki/extensions/Flow Gerrit-Branch: master Gerrit-Owner: Mooeypoo <mor...@gmail.com> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits