jenkins-bot has submitted this change and it was merged.

Change subject: Translationview rewrite based on OOJS, OOJS-UI
......................................................................


Translationview rewrite based on OOJS, OOJS-UI

The three column layout and the initialization code for
the components were rewritten using OOJS-UI. Rest of the modules
left untouched. So no functionality change expected.

The UI code is now organized under the ui folder with mw prefixed
files and modules, dropping 'ext' prefix so that the classes are
easily greppable with its names.

Another notable change is the infobar which used to be inside the header
module is now separated.

This is a beginning of changes that leads to further modeling of our
classes for VE integration.

Bug: T149163
Change-Id: I07cde72f9b4a7daa45415b4af16f086f6c172046
---
M .jshintrc
M ContentTranslation.hooks.php
M extension.json
M modules/dashboard/ext.cx.dashboard.js
D modules/header/ext.cx.header.js
M modules/publish/ext.cx.publish.js
M modules/stats/ext.cx.stats.js
D modules/translationview/ext.cx.translationview.js
A modules/ui/mw.cx.ui.Columns.js
A modules/ui/mw.cx.ui.Header.js
A modules/ui/mw.cx.ui.Infobar.js
A modules/ui/mw.cx.ui.SourceColumn.js
A modules/ui/mw.cx.ui.ToolsColumn.js
A modules/ui/mw.cx.ui.TranslationColumn.js
A modules/ui/mw.cx.ui.TranslationView.js
A modules/ui/mw.cx.ui.js
R modules/ui/styles/images/W_gray.png
R modules/ui/styles/images/W_gray.svg
R modules/ui/styles/images/clear.png
R modules/ui/styles/images/clear.svg
A modules/ui/styles/mw.cx.ui.Columns.less
R modules/ui/styles/mw.cx.ui.Header.less
R modules/ui/styles/mw.cx.ui.Header.vector.less
A modules/ui/styles/mw.cx.ui.Infobar.less
R modules/ui/styles/mw.cx.ui.TranslationView.less
M modules/widgets/common/ext.cx.column.less
M specials/SpecialContentTranslation.php
M specials/SpecialContentTranslationStats.php
D tests/qunit/header/ext.cx.header.test.js
M tests/qunit/publish/ext.cx.publish.prepare.test.js
M tests/qunit/publish/ext.cx.publish.test.js
A tests/qunit/ui/mw.cx.ui.Infobar.test.js
32 files changed, 864 insertions(+), 591 deletions(-)

Approvals:
  Nikerabbit: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/.jshintrc b/.jshintrc
index da351bf..dc06253 100644
--- a/.jshintrc
+++ b/.jshintrc
@@ -16,6 +16,7 @@
                "mediaWiki",
                "jQuery",
                "QUnit",
-               "moment"
+               "moment",
+               "OO"
        ]
 }
diff --git a/ContentTranslation.hooks.php b/ContentTranslation.hooks.php
index 121f73b..8b5e4bc 100644
--- a/ContentTranslation.hooks.php
+++ b/ContentTranslation.hooks.php
@@ -335,12 +335,12 @@
                        'remoteExtPath' => 'ContentTranslation',
                ];
 
-               $modules['qunit']['ext.cx.header.test'] = [
+               $modules['qunit']['mw.cx.ui.Infobar.test'] = [
                        'scripts' => [
-                               'tests/qunit/header/ext.cx.header.test.js',
+                               'tests/qunit/ui/mw.cx.ui.Infobar.test.js',
                        ],
                        'dependencies' => [
-                               'ext.cx.header',
+                               'mw.cx.ui.Infobar'
                        ],
                ] + $resourcePaths;
 
diff --git a/extension.json b/extension.json
index 1e55396..0dccd9d 100644
--- a/extension.json
+++ b/extension.json
@@ -244,20 +244,6 @@
                                "cx-feedback-link"
                        ]
                },
-               "ext.cx.translationview": {
-                       "scripts": [
-                               "translationview/ext.cx.translationview.js"
-                       ],
-                       "styles": [
-                               
"translationview/styles/ext.cx.translationview.less"
-                       ],
-                       "dependencies": [
-                               "ext.cx.header",
-                               "ext.cx.model",
-                               "ext.cx.sitemapper",
-                               "ext.cx.source"
-                       ]
-               },
                "ext.cx.dashboard": {
                        "scripts": [
                                "dashboard/ext.cx.dashboard.js"
@@ -267,7 +253,7 @@
                        ],
                        "dependencies": [
                                "ext.cx.feedback",
-                               "ext.cx.header",
+                               "mw.cx.ui.Header",
                                "ext.cx.model",
                                "ext.cx.sitemapper",
                                "ext.cx.source.selector",
@@ -308,6 +294,9 @@
                "ext.cx.util.selection": {
                        "scripts": [
                                "util/ext.cx.util.selection.js"
+                       ],
+                       "dependencies": [
+                               "ext.cx.model"
                        ]
                },
                "ext.cx.sitemapper": {
@@ -324,44 +313,6 @@
                                "desktop",
                                "mobile"
                        ]
-               },
-               "ext.cx.header": {
-                       "scripts": [
-                               "header/ext.cx.header.js"
-                       ],
-                       "styles": [
-                               "header/styles/ext.cx.header.less"
-                       ],
-                       "dependencies": [
-                               "jquery.throttle-debounce",
-                               "mediawiki.Uri",
-                               "mediawiki.jqueryMsg",
-                               "mediawiki.ui.button",
-                               "mediawiki.util"
-                       ],
-                       "messages": [
-                               "cx",
-                               "cx-error-server-connection",
-                               "cx-error-page-not-found",
-                               "cx-header-new-translation",
-                               "cx-header-all-translations",
-                               "cx-publish-button",
-                               "cx-special-login-error",
-                               "cx-translation-target-page-exists",
-                               "login"
-                       ],
-                       "targets": [
-                               "desktop",
-                               "mobile"
-                       ]
-               },
-               "ext.cx.header.skin": {
-                       "skinStyles": {
-                               "vector": [
-                                       
"header/styles/ext.cx.header.vector.less"
-                               ]
-                       },
-                       "position": "top"
                },
                "ext.cx.source": {
                        "scripts": [
@@ -485,6 +436,7 @@
                                "tools/styles/ext.cx.tools.less"
                        ],
                        "dependencies": [
+                               "ext.cx.model",
                                "ext.cx.feedback",
                                "ext.cx.progressbar",
                                "ext.cx.tools.categories",
@@ -506,7 +458,8 @@
                                "ext.cx.widgets.spinner",
                                "jquery.throttle-debounce",
                                "jquery.uls.data",
-                               "mediawiki.jqueryMsg"
+                               "mediawiki.jqueryMsg",
+                               "oojs"
                        ]
                },
                "ext.cx.tools.card": {
@@ -552,6 +505,7 @@
                                "cx-tools-view-guidelines-link"
                        ],
                        "dependencies": [
+                               "ext.cx.model",
                                "ext.cx.tools.card",
                                "ext.cx.tools.manager"
                        ]
@@ -692,6 +646,7 @@
                                "tools/ext.cx.tools.template.js"
                        ],
                        "dependencies": [
+                               "ext.cx.model",
                                "mediawiki.RegExp"
                        ]
                },
@@ -719,6 +674,9 @@
                        ],
                        "messages": [
                                "cx-tools-categories-count-message"
+                       ],
+                       "dependencies": [
+                               "ext.cx.model"
                        ]
                },
                "ext.cx.progressbar": {
@@ -954,6 +912,9 @@
                "ext.cx.recommendtool.client": {
                        "scripts": [
                                "dashboard/ext.cx.recommendtool.client.js"
+                       ],
+                       "dependencies": [
+                               "ext.cx.model"
                        ]
                },
                "ext.cx.translation.conflict": {
@@ -990,7 +951,7 @@
                        ],
                        "dependencies": [
                                "chart.js",
-                               "ext.cx.header",
+                               "mw.cx.ui.Header",
                                "ext.cx.sitemapper",
                                "ext.cx.util",
                                "ext.cx.widgets.callout",
@@ -1129,6 +1090,9 @@
                        "styles": [
                                "widgets/spinner/ext.cx.spinner.less"
                        ],
+                       "dependencies": [
+                               "ext.cx.model"
+                       ],
                        "targets": [
                                "desktop",
                                "mobile"
@@ -1153,14 +1117,112 @@
                        "styles": [
                                "widgets/translator/ext.cx.translator.less"
                        ],
-                       "messages":[
+                       "messages": [
                                "cx-translator-month-stats-label",
                                "cx-translator-total-translations-label"
                        ],
                        "dependencies": [
                                "chart.js",
+                               "ext.cx.model",
                                "mediawiki.api"
                        ]
+               },
+               "mw.cx.ui.TranslationView": {
+                       "scripts": [
+                               "ui/mw.cx.ui.TranslationView.js"
+                       ],
+                       "styles": [
+                               "ui/styles/mw.cx.ui.TranslationView.less"
+                       ],
+                       "messages":[
+                               "cx-publish-button"
+                       ],
+                       "dependencies": [
+                               "mw.cx.ui.Header",
+                               "mw.cx.ui.Columns",
+                               "ext.cx.model",
+                               "ext.cx.sitemapper"
+                       ]
+               },
+               "mw.cx.ui": {
+                       "scripts": [
+                               "ui/mw.cx.ui.js"
+                       ],
+                       "styles": [
+                               "widgets/common/ext.cx.common.less"
+                       ],
+                       "dependencies": [
+                               "ext.cx.model",
+                               "oojs-ui"
+                       ],
+                       "targets": [
+                               "desktop",
+                               "mobile"
+                       ]
+               },
+               "mw.cx.ui.Header": {
+                       "scripts": [
+                               "ui/mw.cx.ui.Header.js"
+                       ],
+                       "styles": [
+                               "ui/styles/mw.cx.ui.Header.less"
+                       ],
+                       "messages": [
+                               "cx",
+                               "login"
+                       ],
+                       "dependencies": [
+                               "mw.cx.ui",
+                               "mw.cx.ui.Infobar"
+                       ],
+                       "targets": [
+                               "desktop",
+                               "mobile"
+                       ]
+               },
+               "mw.cx.ui.Header.skin": {
+                       "skinStyles": {
+                               "vector": [
+                                       "ui/styles/mw.cx.ui.Header.vector.less"
+                               ]
+                       },
+                       "position": "top"
+               },
+               "mw.cx.ui.Infobar": {
+                       "scripts": [
+                               "ui/mw.cx.ui.Infobar.js"
+                       ],
+                       "styles": [
+                               "ui/styles/mw.cx.ui.Infobar.less"
+                       ],
+                       "messages": [
+                               "cx-error-server-connection",
+                               "cx-error-page-not-found",
+                               "cx-header-new-translation",
+                               "cx-header-all-translations",
+                               "cx-special-login-error",
+                               "cx-translation-target-page-exists"
+                       ],
+                       "dependencies": [
+                               "mw.cx.ui"
+                       ],
+                       "targets": [
+                               "desktop",
+                               "mobile"
+                       ]
+               },
+               "mw.cx.ui.Columns": {
+                       "scripts": [
+                               "ui/mw.cx.ui.Columns.js",
+                               "ui/mw.cx.ui.SourceColumn.js",
+                               "ui/mw.cx.ui.TranslationColumn.js",
+                               "ui/mw.cx.ui.ToolsColumn.js"                    
],
+                       "styles": [
+                               "ui/styles/mw.cx.ui.Columns.less"
+                       ],
+                       "dependencies": [
+                               "mw.cx.ui"
+                       ]
                }
        },
        "ResourceFileModulePaths": {
diff --git a/modules/dashboard/ext.cx.dashboard.js 
b/modules/dashboard/ext.cx.dashboard.js
index 0be1eb1..220a9cc 100644
--- a/modules/dashboard/ext.cx.dashboard.js
+++ b/modules/dashboard/ext.cx.dashboard.js
@@ -12,6 +12,8 @@
         * CXDashboard
         *
         * @class
+        * @param {HTMLElement} element
+        * @param {Object} siteMapper
         */
        function CXDashboard( element, siteMapper ) {
                this.$container = $( element );
@@ -110,17 +112,17 @@
        CXDashboard.prototype.getSidebarItems = function () {
                return [
                        {
-                               class: 'cx-sidebar__link 
cx-sidebar__link--information',
+                               'class': 'cx-sidebar__link 
cx-sidebar__link--information',
                                href: 
'https://www.mediawiki.org/wiki/ContentTranslation',
                                label: mw.msg( 
'cx-dashboard-sidebar-information' )
                        },
                        {
-                               class: 'cx-sidebar__link 
cx-sidebar__link--stats',
+                               'class': 'cx-sidebar__link 
cx-sidebar__link--stats',
                                href: mw.util.getUrl( 
'Special:ContentTranslationStats' ),
                                label: mw.msg( 'cx-dashboard-sidebar-stats' )
                        },
                        {
-                               class: 'cx-sidebar__link 
cx-sidebar__link--feedback',
+                               'class': 'cx-sidebar__link 
cx-sidebar__link--feedback',
                                href: 
'https://www.mediawiki.org/wiki/Talk:Content_translation',
                                label: mw.msg( 'cx-dashboard-sidebar-feedback' )
                        }
@@ -157,10 +159,17 @@
        };
 
        CXDashboard.prototype.render = function () {
+               var header;
+
                this.$header = $( '<div>' )
                        .addClass( 'cx-header--dashboard' );
 
-               this.$header.cxHeader( this.siteMapper, mw.msg( 
'cx-dashboard-header' ) );
+               header = new mw.cx.ui.Header( {
+                       siteMapper: this.siteMapper,
+                       titleText: mw.msg( 'cx-dashboard-header' )
+               } );
+
+               this.$header.append( header.$element );
                this.$translationListContainer = this.buildTranslationList();
                this.$sidebar = $( '<div>' )
                        .addClass( 'cx-dashboard__sidebar' )
diff --git a/modules/header/ext.cx.header.js b/modules/header/ext.cx.header.js
deleted file mode 100644
index 2651995..0000000
--- a/modules/header/ext.cx.header.js
+++ /dev/null
@@ -1,271 +0,0 @@
-/*!
- * ContentTranslation Translation view header
- * A tool that allows editors to translate pages from one language
- * to another with the help of machine translation and other translation tools
- *
- * @ingroup Extensions
- * @copyright See AUTHORS.txt
- * @license GPL-2.0+
- */
-( function ( $, mw ) {
-       'use strict';
-
-       var timer;
-       /**
-        * Handlers the top part of the three column interface.
-        *
-        * That includes notifications, user tools and the submit button.
-        *
-        * @class
-        */
-       function ContentTranslationHeader( element, siteMapper, headerText ) {
-               this.$container = $( element );
-               this.siteMapper = siteMapper;
-               this.$draftStatus = null;
-               this.$publishButton = null;
-               this.$infoBar = null;
-
-               this.headerText = headerText || mw.msg( 'cx' );
-
-               this.init();
-       }
-
-       ContentTranslationHeader.prototype.init = function () {
-               this.render();
-               this.listen();
-       };
-
-       /**
-        * Enable/Disable the publish button based on progress of translation.
-        *
-        * @param {Object} weights
-        */
-       ContentTranslationHeader.prototype.setPublishButtonState = function ( 
weights ) {
-               this.$publishButton.show().prop( 'disabled', weights.any === 0 
);
-       };
-
-       /**
-        * Show login message.
-        */
-       ContentTranslationHeader.prototype.showLoginMessage = function () {
-               var currentUri, returnToQueryString, loginUriHref;
-
-               currentUri = new mw.Uri();
-               delete currentUri.query.title;
-               returnToQueryString = currentUri.getQueryString();
-
-               loginUriHref = mw.util.getUrl( 'Special:UserLogin', {
-                       returnto: 'Special:ContentTranslation',
-                       returntoquery: returnToQueryString
-               } );
-
-               this.showError( mw.message( 'cx-special-login-error', 
loginUriHref ) );
-
-               // Do not show the columns
-               // TODO: use events
-               $( '.cx-widget__columns' ).remove();
-       };
-
-       /**
-        * Show a success message in the info bar.
-        *
-        * @param {mediawiki.Message|string} message Message objects are 
parsed, strings are plain text.
-        */
-       ContentTranslationHeader.prototype.showSuccess = function ( message ) {
-               this.showMessage( 'cx-success', message );
-       };
-
-       /**
-        * Show a warning message in the info bar.
-        *
-        * @param {mediawiki.Message|string} message Message objects are 
parsed, strings are plain text.
-        */
-       ContentTranslationHeader.prototype.showWarning = function ( message ) {
-               this.showMessage( 'cx-warning', message );
-       };
-
-       /**
-        * Show an error message in the info bar.
-        * TODO: This error handling and display deserve its own module. Header 
module is inappropriate.
-        *
-        * @param {mediawiki.Message|string} message Message objects are 
parsed, strings are plain text.
-        * @param {string} details The details of error in HTML.
-        */
-       ContentTranslationHeader.prototype.showError = function ( message, 
details ) {
-               this.showMessage( 'cx-error', message, details );
-       };
-
-       /**
-        * Shows a message in the info bar.
-        *
-        * For internal use. use showSuccess and showError instead.
-        *
-        * @param {string} type Message class.
-        * @param {mediawiki.Message|string} message Message objects are 
parsed, strings are plain text.
-        * @param {string} details The details of error in HTML.
-        */
-       ContentTranslationHeader.prototype.showMessage = function ( type, 
message, details ) {
-               if ( message instanceof mw.Message ) {
-                       this.$infoBar.find( '.text' ).html( message.parse() );
-               } else {
-                       this.$infoBar.find( '.text' ).text( message );
-               }
-               if ( details ) {
-                       this.$infoBar.find( '.details' ).html( details ).show();
-               } else {
-                       this.$infoBar.find( '.details' ).empty().hide();
-               }
-               this.clearMessages();
-               this.$infoBar.addClass( type ).show();
-       };
-
-       ContentTranslationHeader.prototype.clearMessages = function () {
-               this.$infoBar
-                       .removeClass( 'cx-success cx-error cx-warning' )
-                       .hide();
-       };
-
-       ContentTranslationHeader.prototype.listen = function () {
-               var self = this;
-               this.$publishButton.on( 'click', function () {
-                       mw.hook( 'mw.cx.publish' ).fire();
-               } );
-               // Click handler for remove icon in info bar.
-               this.$infoBar.on( 'click', '.remove', function () {
-                       $( this ).parent().hide();
-               } );
-
-               mw.hook( 'mw.cx.progress' ).add( $.proxy( 
this.setPublishButtonState, this ) );
-               mw.hook( 'mw.cx.error' ).add( $.proxy( this.showError, this ) );
-               mw.hook( 'mw.cx.warning' ).add( $.proxy( this.showWarning, this 
) );
-               mw.hook( 'mw.cx.success' ).add( $.proxy( this.showSuccess, this 
) );
-               mw.hook( 'mw.cx.error.anonuser' ).add( $.proxy( 
this.showLoginMessage, this ) );
-
-               mw.hook( 'mw.cx.translation.save-started' ).add( $.proxy( 
this.updateSaveStatus, this, 'progress' ) );
-               mw.hook( 'mw.cx.translation.saved' ).add( $.proxy( 
this.updateSaveStatus, this, 'success' ) );
-               mw.hook( 'mw.cx.translation.save-failed' ).add( $.proxy( 
this.updateSaveStatus, this, 'fail' ) );
-               mw.hook( 'mw.cx.translation.title.change' ).add( $.proxy( 
this.clearMessages, this ) );
-
-               mw.hook( 'mw.cx.draft.restoring' ).add( function () {
-                       self.$draftStatus.text( mw.msg( 'cx-draft-restoring' ) 
);
-               } );
-               mw.hook( 'mw.cx.draft.restored' ).add( function () {
-                       self.$draftStatus.text( mw.msg( 'cx-draft-restored' ) );
-               } );
-               mw.hook( 'mw.cx.draft.restore-failed' ).add( function () {
-                       self.$draftStatus.text( mw.msg( 
'cx-draft-restore-failed' ) );
-               } );
-               $( window ).scroll( $.throttle( 250, $.proxy( this.scroll, this 
) ) );
-       };
-
-       ContentTranslationHeader.prototype.updateSaveStatus = function ( status 
) {
-               var $status = this.$draftStatus,
-                       minutes = 0;
-
-               $status.attr( 'title', mw.msg( 'cx-save-draft-tooltip' ) );
-               clearTimeout( timer );
-               if ( status === 'progress' ) {
-                       $status.text( mw.msg( 'cx-save-draft-saving' ) );
-               } else if ( status === 'success' ) {
-                       $status.text( mw.msg( 'cx-save-draft-save-success', 0 ) 
);
-                       timer = setInterval( function () {
-                               minutes++;
-                               $status.text(
-                                       mw.msg( 'cx-save-draft-save-success', 
mw.language.convertNumber( minutes ) )
-                               );
-                       }, 60 * 1000 );
-               } else if ( status === 'fail' ) {
-                       $status.text( mw.msg( 'cx-save-draft-error' ) );
-               }
-       };
-
-       /**
-        * Render the header
-        */
-       ContentTranslationHeader.prototype.render = function () {
-               var $logo, $titleText, $headerTitle,
-                       $translationCenterLink, $translationCenter, 
$publishArea,
-                       $headerBar;
-
-               $logo = $( '<a>' )
-                       .prop( 'href', mw.config.get( 'wgScript' ) )
-                       .addClass( 'cx-header__logo' );
-               $titleText = $( '<span>' )
-                       .addClass( 'cx-header__title-text' )
-                       .text( this.headerText );
-
-               $headerTitle = $( '<div>' )
-                       .addClass( 'cx-header__title' )
-                       .append( $logo, $titleText );
-
-               $translationCenterLink = $( '<a>' )
-                       .attr( 'href', mw.util.getUrl( 
'Special:ContentTranslation' ) );
-
-               if ( mw.config.get( 'wgContentTranslationDatabase' ) !== null ) 
{
-                       $translationCenterLink.text( mw.msg( 
'cx-header-all-translations' ) );
-               } else {
-                       $translationCenterLink.text( mw.msg( 
'cx-header-new-translation' ) );
-               }
-
-               $translationCenter = $( '<div>' )
-                       .addClass( 'cx-header__translation-center' )
-                       .append( $translationCenterLink );
-
-               this.$draftStatus = $( '<div>' )
-                       .addClass( 'cx-header__draft-status' );
-
-               this.$publishButton = $( '<button>' )
-                       .addClass( 'cx-header__publish-button mw-ui-button 
mw-ui-progressive' )
-                       .prop( 'disabled', true )
-                       .text( mw.msg( 'cx-publish-button' ) );
-
-               $publishArea = $( '<div>' )
-                       .addClass( 'cx-header__publish' )
-                       .append( this.$draftButton, this.$publishButton );
-
-               $headerBar = $( '<div>' )
-                       .addClass( 'cx-header__bar' )
-                       .append( $translationCenter, this.$draftStatus, 
$publishArea );
-
-               this.$infoBar = $( '<div>' )
-                       .addClass( 'cx-header__infobar' )
-                       .append( $( '<span>' ).addClass( 'text' ) )
-                       .append( $( '<span>' ).addClass( 'remove' ) )
-                       .append( $( '<div>' ).addClass( 'details' ) )
-                       .hide();
-
-               this.$container
-                       .addClass( 'cx-header' )
-                       .append( $headerTitle, $headerBar, this.$infoBar );
-       };
-
-       ContentTranslationHeader.prototype.scroll = function () {
-               var scrollTop = $( window ).scrollTop();
-
-               if ( scrollTop > 0 ) {
-                       this.$container.addClass( 'sticky' );
-               } else {
-                       this.$container.removeClass( 'sticky' );
-               }
-       };
-
-       /**
-        * CX Header plugin. Prepares the Special:CX header and interactions.
-        *
-        * @param {mw.cx.SiteMapper} siteMapper
-        * @return {jQuery}
-        */
-       $.fn.cxHeader = function ( siteMapper, headerText ) {
-               return this.each( function () {
-                       var $this = $( this ),
-                               data = $this.data( 'cxHeader' );
-
-                       if ( !data ) {
-                               $this.data(
-                                       'cxHeader', ( data = new 
ContentTranslationHeader( this, siteMapper, headerText ) )
-                               );
-                       }
-               } );
-       };
-
-}( jQuery, mediaWiki ) );
diff --git a/modules/publish/ext.cx.publish.js 
b/modules/publish/ext.cx.publish.js
index 27069ec..c8e6b42 100644
--- a/modules/publish/ext.cx.publish.js
+++ b/modules/publish/ext.cx.publish.js
@@ -13,8 +13,8 @@
         *
         * @class
         */
-       function CXPublish( $trigger, siteMapper ) {
-               this.$trigger = $trigger;
+       function CXPublish( trigger, siteMapper ) {
+               this.trigger = trigger;
                this.targetTitle = null;
                this.siteMapper = siteMapper;
        }
@@ -40,9 +40,6 @@
                        html: EasyDeflate.deflate( self.getContent() ),
                        categories: this.getCategories().join( '|' )
                } );
-
-               // Disable the trigger button
-               this.$trigger.prop( 'disabled', true ).text( mw.msg( 
'cx-publish-button-publishing' ) );
 
                return this.checkTargetTitle( this.targetTitle ).then( function 
( title ) {
                        apiParams.title = self.targetTitle = title;
@@ -109,7 +106,7 @@
                $captchaForm.append( $captchaAnswer, $publishButton );
 
                // Show the captcha form
-               this.$trigger.after( $captchaForm );
+               this.trigger.$element.after( $captchaForm );
 
                deferred = $.Deferred();
 
@@ -165,8 +162,8 @@
                        }
 
                        // Show a dialog to decide what to do now
-                       self.$trigger.cxPublishingDialog();
-                       $dialog = self.$trigger.data( 'cxPublishingDialog' );
+                       self.trigger.$element.cxPublishingDialog();
+                       $dialog = self.trigger.$element.data( 
'cxPublishingDialog' );
 
                        return $dialog.listen().then( function ( overwrite ) {
                                if ( overwrite ) {
@@ -253,12 +250,6 @@
                        mw.cx.sourceTitle,
                        this.targetTitle
                );
-
-               // Disable the Publish button
-               // and change its label back from "Publishing..." to "Publish"
-               this.$trigger
-                       .prop( 'disabled', true )
-                       .text( mw.msg( 'cx-publish-button' ) );
        };
 
        /**
@@ -317,12 +308,6 @@
                        mw.hook( 'mw.cx.error' ).fire( mw.msg( 
'cx-publish-page-error', error ), errorDetails );
                        mw.log( '[CX] Error while publishing:', code, trace );
                }
-
-               // Enable the Publish button to allow retrying,
-               // and set the label back to "Publish"
-               this.$trigger
-                       .prop( 'disabled', false )
-                       .text( mw.msg( 'cx-publish-button' ) );
        };
 
        /**
@@ -398,15 +383,5 @@
        };
 
        // Expose the CXPublish
-       mw.cx.publish = CXPublish;
-
-       $( function () {
-               var $publishButton, cxPublish, siteMapper;
-
-               siteMapper = new mw.cx.SiteMapper( mw.config.get( 
'wgContentTranslationSiteTemplates' ) );
-               $publishButton = $( '.cx-header__publish 
.cx-header__publish-button' );
-               cxPublish = new mw.cx.publish( $publishButton, siteMapper );
-
-               mw.hook( 'mw.cx.publish' ).add( $.proxy( cxPublish.publish, 
cxPublish ) );
-       } );
+       mw.cx.Publish = CXPublish;
 }( jQuery, mediaWiki ) );
diff --git a/modules/stats/ext.cx.stats.js b/modules/stats/ext.cx.stats.js
index d39aa2b..32d9869 100644
--- a/modules/stats/ext.cx.stats.js
+++ b/modules/stats/ext.cx.stats.js
@@ -570,7 +570,7 @@
                        ]
                };
 
-               /*global Chart:false */
+               /* global Chart:false */
                cxCumulativeGraph = new Chart( ctx, {
                        type: 'line',
                        data: data,
@@ -628,7 +628,7 @@
                        ]
                };
 
-               /*global Chart:false */
+               /* global Chart:false */
                cxCumulativeGraph = new Chart( ctx, {
                        type: 'line',
                        data: data,
@@ -667,7 +667,7 @@
                        ]
                };
 
-               /*global Chart:false */
+               /* global Chart:false */
                cxTrendChart = new Chart( ctx, {
                        type: 'bar',
                        data: data,
@@ -716,7 +716,7 @@
                        ]
                };
 
-               /*global Chart:false */
+               /* global Chart:false */
                cxTrendChart = new Chart( ctx, {
                        type: 'bar',
                        data: data,
@@ -811,9 +811,10 @@
        }
 
        $( function () {
-               var cxLink, cxstats,
-                       $header = $( '<div>' ).addClass( 'cx-widget__header' ),
-                       $container = $( '<div>' ).addClass( 
'cx-stats-container' );
+               var cxLink, cxstats, header, $header, $container;
+
+               $header = $( '<div>' ).addClass( 'cx-widget__header' );
+               $container = $( '<div>' ).addClass( 'cx-stats-container' );
 
                // Set the global siteMapper for code which we cannot inject it
                mw.cx.siteMapper = new mw.cx.SiteMapper( mw.config.get( 
'wgContentTranslationSiteTemplates' ) );
@@ -821,7 +822,10 @@
                        $( '<div>' ).addClass( 'cx-widget' )
                        .append( $header, $container )
                );
-               $header.cxHeader( mw.cx.siteMapper );
+               header = new mw.cx.ui.Header( {
+                       siteMapper: this.siteMapper
+               } );
+               $header.append( header.$element );
                cxstats = new CXStats( $container, {
                        siteMapper: new mw.cx.SiteMapper( mw.config.get( 
'wgContentTranslationSiteTemplates' ) )
                } );
diff --git a/modules/translationview/ext.cx.translationview.js 
b/modules/translationview/ext.cx.translationview.js
deleted file mode 100644
index dc66c98..0000000
--- a/modules/translationview/ext.cx.translationview.js
+++ /dev/null
@@ -1,88 +0,0 @@
-/*!
- * ContentTranslation extension
- * A tool that allows editors to translate pages from one language
- * to another with the help of machine translation and other translation
- *
- * @ingroup Extensions
- * @copyright See AUTHORS.txt
- * @license GPL-2.0+
- */
-( function ( $, mw ) {
-       'use strict';
-
-       /**
-        * ContentTranslationView
-        *
-        * @class
-        */
-       function ContentTranslationView( element, siteMapper ) {
-               this.$container = $( element );
-
-               this.$translation = null;
-               this.$header = null;
-               this.$source = null;
-               this.$tools = null;
-               this.siteMapper = siteMapper;
-       }
-
-       ContentTranslationView.prototype.init = function () {
-               this.render();
-               this.initComponents();
-       };
-
-       /**
-        * Initialize the components
-        */
-       ContentTranslationView.prototype.initComponents = function () {
-               var cx = this,
-                       modules;
-               this.$header.cxHeader( this.siteMapper );
-               this.$source.cxSource( this.siteMapper );
-
-               modules = [
-                       'ext.cx.tools',
-                       'ext.cx.translation',
-                       'ext.cx.translation.progress',
-                       'ext.cx.publish',
-                       'ext.cx.translation.storage.init'
-               ];
-
-               if ( mw.cx.sourceTitle ) {
-                       mw.loader.using( modules ).then( function () {
-                               cx.$translation.cxTranslation( cx.siteMapper );
-                               cx.$tools.cxTools();
-                       } );
-               }
-       };
-
-       ContentTranslationView.prototype.render = function () {
-               var $content;
-
-               $content = $( '<div>' ).addClass( 'cx-widget' )
-                       .append(
-                               $( '<div>' ).addClass( 'cx-widget__header' ),
-                               $( '<div>' ).addClass( 'cx-widget__columns' )
-                               .append(
-                                       $( '<div>' ).addClass( 'cx-column 
cx-column--source' ),
-                                       $( '<div>' ).addClass( 'cx-column 
cx-column--translation' ),
-                                       $( '<div>' ).addClass( 'cx-column 
cx-column--tools' )
-                               )
-                       );
-
-               this.$container.append( $content );
-               this.$header = this.$container.find( '.cx-widget__header' );
-               this.$source = this.$container.find( '.cx-column--source' );
-               this.$translation = this.$container.find( 
'.cx-column--translation' );
-               this.$tools = this.$container.find( '.cx-column--tools' );
-       };
-
-       $( function () {
-               var cxview,
-                       container = document.body;
-
-               // Set the global siteMapper for code which we cannot inject it
-               mw.cx.siteMapper = new mw.cx.SiteMapper( mw.config.get( 
'wgContentTranslationSiteTemplates' ) );
-               cxview = new ContentTranslationView( container, 
mw.cx.siteMapper );
-               cxview.init();
-       } );
-}( jQuery, mediaWiki ) );
diff --git a/modules/ui/mw.cx.ui.Columns.js b/modules/ui/mw.cx.ui.Columns.js
new file mode 100644
index 0000000..a1dd928
--- /dev/null
+++ b/modules/ui/mw.cx.ui.Columns.js
@@ -0,0 +1,37 @@
+/*!
+ * TranslationView Columns
+ *
+ * @ingroup Extensions
+ * @copyright See AUTHORS.txt
+ * @license GPL-2.0+
+ */
+( function ( $, mw, OO ) {
+       'use strict';
+
+       /**
+        *
+        * @class
+        * @param {Object} config
+        */
+       mw.cx.ui.Columns = function ( config ) {
+               // Configuration initialization
+               this.config = config || {};
+               // Parent constructor
+               mw.cx.ui.Columns.parent.call( this, $.extend( {}, this.config, {
+                       continuous: true,
+                       expanded: false,
+                       classes: [ 'cx-widget__columns' ],
+                       items: [
+                               new mw.cx.ui.SourceColumn( this.config ),
+                               new mw.cx.ui.TranslationColumn( this.config ),
+                               new mw.cx.ui.ToolsColumn( this.config )
+                       ]
+               } ) );
+               this.$element.removeClass( 'oo-ui-horizontalLayout' );
+       };
+
+       /* Setup */
+
+       OO.inheritClass( mw.cx.ui.Columns, OO.ui.HorizontalLayout );
+
+}( jQuery, mediaWiki, OO ) );
diff --git a/modules/ui/mw.cx.ui.Header.js b/modules/ui/mw.cx.ui.Header.js
new file mode 100644
index 0000000..467433d
--- /dev/null
+++ b/modules/ui/mw.cx.ui.Header.js
@@ -0,0 +1,118 @@
+/*!
+ * TranslationView Header
+ *
+ * @ingroup Extensions
+ * @copyright See AUTHORS.txt
+ * @license GPL-2.0+
+ */
+( function ( $, mw, OO ) {
+       'use strict';
+
+       var timer;
+       /**
+        * TranslationView Header
+        *
+        * @class
+        * @param {Object} [config] Configuration object
+        */
+       mw.cx.ui.Header = function ( config ) {
+               // Configuration initialization
+               this.config = config || {};
+               this.$headerBar = null;
+               this.$infobar = null;
+               // Parent constructor
+               mw.cx.ui.Header.parent.call( this, $.extend( {}, this.config, {
+                       continuous: true,
+                       expanded: false,
+                       $content: this.getContent(),
+                       classes: [ 'cx-widget__header', 'cx-header' ]
+               } ) );
+               this.listen();
+       };
+
+       /* Setup */
+
+       OO.inheritClass( mw.cx.ui.Header, OO.ui.PanelLayout );
+
+       mw.cx.ui.Header.prototype.getContent = function () {
+               var $logo, $titleText, $headerTitle, $translationCenterLink, 
$translationCenter;
+
+               $logo = $( '<a>' )
+                       .prop( 'href', mw.config.get( 'wgScript' ) )
+                       .addClass( 'cx-header__logo' );
+               $titleText = $( '<span>' )
+                       .addClass( 'cx-header__title-text' )
+                       .text( this.config.titleText || mw.msg( 'cx' ) );
+
+               $headerTitle = $( '<div>' )
+                       .addClass( 'cx-header__title' )
+                       .append( $logo, $titleText );
+
+               $translationCenterLink = $( '<a>' )
+                       .attr( 'href', mw.util.getUrl( 
'Special:ContentTranslation' ) )
+                       .text( mw.msg( 'cx-header-all-translations' ) );
+
+               $translationCenter = $( '<div>' )
+                       .addClass( 'cx-header__translation-center' )
+                       .append( $translationCenterLink );
+
+               this.$draftStatus = $( '<div>' )
+                       .addClass( 'cx-header__draft-status' );
+
+               this.$headerBar = $( '<div>' )
+                       .addClass( 'cx-header__bar' )
+                       .append( $translationCenter, this.$draftStatus );
+
+               this.infobar = new mw.cx.ui.Infobar( this.config );
+               return $( '<div>' ).append( $headerTitle, this.$headerBar, 
this.infobar.$infobar );
+       };
+
+       mw.cx.ui.Header.prototype.listen = function () {
+               var self = this;
+               mw.hook( 'mw.cx.translation.save-started' ).add( $.proxy( 
this.updateSaveStatus, this, 'progress' ) );
+               mw.hook( 'mw.cx.translation.saved' ).add( $.proxy( 
this.updateSaveStatus, this, 'success' ) );
+               mw.hook( 'mw.cx.translation.save-failed' ).add( $.proxy( 
this.updateSaveStatus, this, 'fail' ) );
+               mw.hook( 'mw.cx.translation.title.change' ).add( $.proxy( 
this.clearMessages, this ) );
+
+               mw.hook( 'mw.cx.draft.restoring' ).add( function () {
+                       self.$draftStatus.text( mw.msg( 'cx-draft-restoring' ) 
);
+               } );
+               mw.hook( 'mw.cx.draft.restored' ).add( function () {
+                       self.$draftStatus.text( mw.msg( 'cx-draft-restored' ) );
+               } );
+               mw.hook( 'mw.cx.draft.restore-failed' ).add( function () {
+                       self.$draftStatus.text( mw.msg( 
'cx-draft-restore-failed' ) );
+               } );
+               $( window ).on( 'scroll resize', this.onWindowScroll.bind( this 
) );
+       };
+
+       mw.cx.ui.Header.prototype.updateSaveStatus = function ( status ) {
+               var $status = this.$draftStatus,
+                       minutes = 0;
+
+               $status.attr( 'title', mw.msg( 'cx-save-draft-tooltip' ) );
+               clearTimeout( timer );
+               if ( status === 'progress' ) {
+                       $status.text( mw.msg( 'cx-save-draft-saving' ) );
+               } else if ( status === 'success' ) {
+                       $status.text( mw.msg( 'cx-save-draft-save-success', 0 ) 
);
+                       timer = setInterval( function () {
+                               minutes++;
+                               $status.text(
+                                       mw.msg( 'cx-save-draft-save-success', 
mw.language.convertNumber( minutes ) )
+                               );
+                       }, 60 * 1000 );
+               } else if ( status === 'fail' ) {
+                       $status.text( mw.msg( 'cx-save-draft-error' ) );
+               }
+       };
+       mw.cx.ui.Header.prototype.onWindowScroll = function () {
+               var scrollTop = $( window ).scrollTop();
+
+               if ( scrollTop > 0 ) {
+                       this.$element.addClass( 'sticky' );
+               } else {
+                       this.$element.removeClass( 'sticky' );
+               }
+       };
+}( jQuery, mediaWiki, OO ) );
diff --git a/modules/ui/mw.cx.ui.Infobar.js b/modules/ui/mw.cx.ui.Infobar.js
new file mode 100644
index 0000000..e2a79c2
--- /dev/null
+++ b/modules/ui/mw.cx.ui.Infobar.js
@@ -0,0 +1,119 @@
+/*!
+ * Information bar UI
+ *
+ * @ingroup Extensions
+ * @copyright See AUTHORS.txt
+ * @license GPL-2.0+
+ */
+( function ( $, mw, OO ) {
+       'use strict';
+
+       /**
+        * @class
+        * @param {Object} [config] Configuration object
+        */
+       mw.cx.ui.Infobar = function ( config ) {
+               this.$infobar = null;
+               // Configuration initialization
+               this.config = $.extend( {}, config, {
+                       continuous: true,
+                       expanded: true,
+                       $content: this.getContent(),
+                       classes: [ 'cx-header__infobar' ]
+               } );
+               // Parent constructor
+               mw.cx.ui.Infobar.parent.call( this, this.config );
+               this.listen();
+       };
+
+       /* Setup */
+
+       OO.inheritClass( mw.cx.ui.Infobar, OO.ui.PanelLayout );
+
+       mw.cx.ui.Infobar.prototype.getContent = function () {
+               this.$infobar = $( '<div>' )
+                       .addClass( 'cx-header__infobar' )
+                       .append( $( '<span>' ).addClass( 'text' ) )
+                       .append( $( '<span>' ).addClass( 'remove' ) )
+                       .append( $( '<div>' ).addClass( 'details' ) )
+                       .hide();
+               return this.$infobar;
+       };
+
+       mw.cx.ui.Infobar.prototype.listen = function () {
+               mw.hook( 'mw.cx.error' ).add( $.proxy( this.showError, this ) );
+               mw.hook( 'mw.cx.warning' ).add( $.proxy( this.showWarning, this 
) );
+               mw.hook( 'mw.cx.success' ).add( $.proxy( this.showSuccess, this 
) );
+               mw.hook( 'mw.cx.error.anonuser' ).add( $.proxy( 
this.showLoginMessage, this ) );
+               mw.hook( 'mw.cx.translation.title.change' ).add( $.proxy( 
this.clearMessages, this ) );
+
+               // Click handler for remove icon in info bar.
+               this.$infobar.on( 'click', '.remove', function () {
+                       $( this ).parent().hide();
+               } );
+       };
+
+       mw.cx.ui.Infobar.prototype.showSuccess = function ( message, details ) {
+               this.showMessage( 'success', message, details );
+       };
+
+       mw.cx.ui.Infobar.prototype.showError = function ( message, details ) {
+               this.showMessage( 'error', message, details );
+       };
+
+       mw.cx.ui.Infobar.prototype.showWarning = function ( message, details ) {
+               this.showMessage( 'warning', message, details );
+       };
+
+       /**
+        * Shows a message in the info bar.
+        *
+        * For internal use. use showSuccess and showError instead.
+        *
+        * @param {string} type Message class.
+        * @param {mediawiki.Message|string} message Message objects are 
parsed, strings are plain text.
+        * @param {string} details The details of error in HTML.
+        */
+       mw.cx.ui.Infobar.prototype.showMessage = function ( type, message, 
details ) {
+               if ( message instanceof mw.Message ) {
+                       this.$infobar.find( '.text' ).html( message.parse() );
+               } else {
+                       this.$infobar.find( '.text' ).text( message );
+               }
+               if ( details ) {
+                       this.$infobar.find( '.details' ).html( details ).show();
+               } else {
+                       this.$infobar.find( '.details' ).empty().hide();
+               }
+               this.clearMessages();
+               this.$infobar.addClass( 'cx-' + type ).show();
+       };
+
+       /**
+        * Show login message.
+        */
+       mw.cx.ui.Infobar.prototype.showLoginMessage = function () {
+               var currentUri, returnToQueryString, loginUriHref;
+
+               currentUri = new mw.Uri();
+               delete currentUri.query.title;
+               returnToQueryString = currentUri.getQueryString();
+
+               loginUriHref = mw.util.getUrl( 'Special:UserLogin', {
+                       returnto: 'Special:ContentTranslation',
+                       returntoquery: returnToQueryString
+               } );
+
+               this.showError( mw.message( 'cx-special-login-error', 
loginUriHref ) );
+
+               // Do not show the columns
+               // TODO: use events
+               $( '.cx-widget__columns' ).remove();
+       };
+
+       mw.cx.ui.Infobar.prototype.clearMessages = function () {
+               this.$infobar
+                       .removeClass( 'cx-success cx-error cx-warning' )
+                       .hide();
+       };
+}( jQuery, mediaWiki, OO ) );
diff --git a/modules/ui/mw.cx.ui.SourceColumn.js 
b/modules/ui/mw.cx.ui.SourceColumn.js
new file mode 100644
index 0000000..5ebc7bd
--- /dev/null
+++ b/modules/ui/mw.cx.ui.SourceColumn.js
@@ -0,0 +1,41 @@
+/*!
+ * Source article container
+ *
+ * @ingroup Extensions
+ * @copyright See AUTHORS.txt
+ * @license GPL-2.0+
+ */
+( function ( $, mw, OO ) {
+       'use strict';
+
+       /**
+        * Source article container
+        *
+        * @class
+        * @param {Object} [config] Configuration object
+        */
+       mw.cx.ui.SourceColumn = function ( config ) {
+               // Configuration initialization
+               this.config = $.extend( {}, config, {
+                       continuous: true,
+                       classes: [ 'cx-column', 'cx-column--source' ],
+                       expanded: false
+               } );
+               // Parent constructor
+               mw.cx.ui.SourceColumn.parent.call( this, this.config );
+               this.siteMapper = config.siteMapper;
+               this.init();
+       };
+
+       /* Setup */
+
+       OO.inheritClass( mw.cx.ui.SourceColumn, OO.ui.StackLayout );
+
+       mw.cx.ui.SourceColumn.prototype.init = function () {
+               var self = this;
+               mw.loader.using( 'ext.cx.source', function () {
+                       self.$element.cxSource( self.siteMapper );
+               } );
+       };
+
+}( jQuery, mediaWiki, OO ) );
diff --git a/modules/ui/mw.cx.ui.ToolsColumn.js 
b/modules/ui/mw.cx.ui.ToolsColumn.js
new file mode 100644
index 0000000..c1ecabd
--- /dev/null
+++ b/modules/ui/mw.cx.ui.ToolsColumn.js
@@ -0,0 +1,40 @@
+/*!
+ * Translation container
+ *
+ * @ingroup Extensions
+ * @copyright See AUTHORS.txt
+ * @license GPL-2.0+
+ */
+( function ( $, mw, OO ) {
+       'use strict';
+
+       /**
+        * Translation container
+        *
+        * @class
+        * @param {Object} [config] Configuration object
+        */
+       mw.cx.ui.ToolsColumn = function ( config ) {
+               // Configuration initialization
+               this.config = $.extend( {}, config, {
+                       continuous: false,
+                       classes: [ 'cx-column', 'cx-column--tools' ],
+                       expanded: false
+               } );
+               // Parent constructor
+               mw.cx.ui.ToolsColumn.parent.call( this, this.config );
+               this.init();
+       };
+
+       /* Setup */
+
+       OO.inheritClass( mw.cx.ui.ToolsColumn, OO.ui.StackLayout );
+
+       mw.cx.ui.ToolsColumn.prototype.init = function () {
+               var self = this;
+               mw.loader.using( 'ext.cx.tools', function () {
+                       self.$element.cxTools();
+               } );
+       };
+
+}( jQuery, mediaWiki, OO ) );
diff --git a/modules/ui/mw.cx.ui.TranslationColumn.js 
b/modules/ui/mw.cx.ui.TranslationColumn.js
new file mode 100644
index 0000000..45095a5
--- /dev/null
+++ b/modules/ui/mw.cx.ui.TranslationColumn.js
@@ -0,0 +1,43 @@
+/*!
+ * ContentTranslation extension
+ * A tool that allows editors to translate pages from one language
+ * to another with the help of machine translation and other translation
+ *
+ * @ingroup Extensions
+ * @copyright See AUTHORS.txt
+ * @license GPL-2.0+
+ */
+( function ( $, mw, OO ) {
+       'use strict';
+
+       /**
+        * TranslationView Header
+        *
+        * @class
+        * @param {Object} [config] Configuration object
+        */
+       mw.cx.ui.TranslationColumn = function ( config ) {
+               // Configuration initialization
+               this.config = $.extend( {}, config, {
+                       continuous: true,
+                       classes: [ 'cx-column', 'cx-column--translation' ],
+                       expanded: false
+               } );
+               // Parent constructor
+               mw.cx.ui.TranslationColumn.parent.call( this, this.config );
+               this.siteMapper = config.siteMapper;
+               this.init();
+       };
+       /* Setup */
+
+       OO.inheritClass( mw.cx.ui.TranslationColumn, OO.ui.StackLayout );
+
+       mw.cx.ui.TranslationColumn.prototype.init = function () {
+               var self = this;
+               mw.loader.using( [ 'ext.cx.translation', 
'ext.cx.translation.progress',
+                       'ext.cx.publish', 'ext.cx.translation.storage.init' ], 
function () {
+                       self.$element.cxTranslation( self.siteMapper );
+               } );
+       };
+
+}( jQuery, mediaWiki, OO ) );
diff --git a/modules/ui/mw.cx.ui.TranslationView.js 
b/modules/ui/mw.cx.ui.TranslationView.js
new file mode 100644
index 0000000..503db5f
--- /dev/null
+++ b/modules/ui/mw.cx.ui.TranslationView.js
@@ -0,0 +1,106 @@
+/*!
+ * ContentTranslation extension
+ * A tool that allows editors to translate pages from one language
+ * to another with the help of machine translation and other translation
+ *
+ * @ingroup Extensions
+ * @copyright See AUTHORS.txt
+ * @license GPL-2.0+
+ */
+( function ( $, mw, OO ) {
+       'use strict';
+       /**
+        * TranslationView
+        *
+        * @class
+        * @param {Object} [config] Configuration object
+        */
+       mw.cx.ui.TranslationView = function ( config ) {
+               this.header = new mw.cx.ui.Header( config );
+               this.columns = new mw.cx.ui.Columns( config );
+               // Configuration initialization
+               this.config = $.extend( {}, config, {
+                       continuous: true,
+                       expanded: false,
+                       items: [ this.header, this.columns ],
+                       classes: [ 'cx-widget' ]
+               } );
+               // Parent constructor
+               mw.cx.ui.TranslationView.parent.call( this, this.config );
+               this.publishButton = null;
+               this.preparePublishButton();
+       };
+
+       /* Setup */
+
+       OO.inheritClass( mw.cx.ui.TranslationView, OO.ui.StackLayout );
+
+       mw.cx.ui.TranslationView.prototype.preparePublishButton = function () {
+               this.setupPublishButton();
+               this.attachPublishButton();
+       };
+
+       mw.cx.ui.TranslationView.prototype.setupPublishButton = function () {
+               var self = this;
+
+               this.publishButton = new OO.ui.ButtonWidget( {
+                       disabled: true,
+                       flags: [ 'progressive', 'primary' ],
+                       classes: [ 'cx-header__publish-button' ],
+                       label: mw.msg( 'cx-publish-button' )
+               } );
+               this.publishButton.connect( this, {
+                       click: 'onPublishButtonClick'
+               } );
+               mw.hook( 'mw.cx.progress' ).add( function ( weights ) {
+                       self.publishButton.setDisabled( weights.any === 0 );
+               } );
+       };
+
+       /**
+        * Add the publish button to the user interface.
+        */
+       mw.cx.ui.TranslationView.prototype.attachPublishButton = function () {
+               this.header.$headerBar.append( new OO.ui.Element( {
+                       classes: [ 'cx-header__publish' ],
+                       $content: this.publishButton.$element
+               } ).$element );
+       };
+
+       mw.cx.ui.TranslationView.prototype.onPublishButtonClick = function () {
+               this.publish();
+       };
+
+       mw.cx.ui.TranslationView.prototype.publish = function () {
+               var publisher, self = this;
+
+               // Disable the trigger button
+               this.publishButton.setDisabled( true ).setLabel( mw.msg( 
'cx-publish-button-publishing' ) );
+               publisher = new mw.cx.Publish( this.publishButton, 
this.config.siteMapper );
+               publisher.publish().always( function () {
+                       self.publishButton.setDisabled( true ).setLabel( 
mw.msg( 'cx-publish-button' ) );
+               } );
+       };
+
+       $( function () {
+               var cxview, query, config;
+               // Set the global siteMapper for code which we cannot inject it
+               mw.cx.siteMapper = new mw.cx.SiteMapper( mw.config.get( 
'wgContentTranslationSiteTemplates' ) );
+               query = new mw.Uri().query;
+               mw.cx.sourceTitle = query.page;
+               mw.cx.targetLanguage = query.to;
+               mw.cx.sourceLanguage = query.from;
+               mw.cx.sourceRevision = query.revision;
+
+               // Make them available in config.
+               config = {
+                       siteMapper: mw.cx.siteMapper,
+                       sourceTitle: mw.cx.sourceTitle,
+                       targetLanguage: mw.cx.targetLanguage,
+                       sourceLanguage: mw.cx.sourceLanguage,
+                       sourceRevision: mw.cx.sourceRevision
+               };
+               cxview = new mw.cx.ui.TranslationView( config );
+               $( 'body' ).append( cxview.$element );
+       } );
+}( jQuery, mediaWiki, OO ) );
diff --git a/modules/ui/mw.cx.ui.js b/modules/ui/mw.cx.ui.js
new file mode 100644
index 0000000..13c8f3c
--- /dev/null
+++ b/modules/ui/mw.cx.ui.js
@@ -0,0 +1,13 @@
+/*!
+ * ContentTranslation extension UI base module
+ *
+ * @ingroup Extensions
+ * @copyright See AUTHORS.txt
+ * @license GPL-2.0+
+ */
+( function ( mw ) {
+       'use strict';
+
+       mw.cx.ui = {};
+
+}( mediaWiki ) );
diff --git a/modules/header/images/W_gray.png 
b/modules/ui/styles/images/W_gray.png
similarity index 100%
rename from modules/header/images/W_gray.png
rename to modules/ui/styles/images/W_gray.png
Binary files differ
diff --git a/modules/header/images/W_gray.svg 
b/modules/ui/styles/images/W_gray.svg
similarity index 99%
rename from modules/header/images/W_gray.svg
rename to modules/ui/styles/images/W_gray.svg
index a0285a0..ae38c27 100644
--- a/modules/header/images/W_gray.svg
+++ b/modules/ui/styles/images/W_gray.svg
@@ -1,16 +1,16 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!-- Generator: Adobe Illustrator 17.1.0, SVG Export Plug-In . SVG Version: 
6.00 Build 0)  -->
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" 
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd";>
-<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg"; 
xmlns:xlink="http://www.w3.org/1999/xlink"; x="0px" y="0px"
-        viewBox="0 0 612 792" enable-background="new 0 0 612 792" 
xml:space="preserve">
-<g opacity="0.95">
-       <path fill="#777777" 
d="M343,199.7c15.9,0.6,33.1,1.3,47.2,1.3c14,0,28.7-0.6,43.4-1.3c3.8,3.8,3.8,15.9,0,19.8
-               
c-31.2,4.5-44,27.4-58.6,55.5l-43.4,82.9L408,515.2h2.6l112.8-244.8c14-32.5,13.4-48.5-25.5-51c-3.8-3.8-3.8-15.9,0-19.8
-               
c22.3,0.6,45.3,1.3,58.6,1.3c13.4,0,33.1-0.6,52.3-1.3c3.8,3.8,3.8,15.9,0,19.8c-33.8,3.8-45.3,26.8-58.6,55.5L409.9,578.3
-               
c-4.5,8.9-7.6,13.4-14,13.4c-5.7,0-10.8-5.1-13.4-13.4l-81-172.8l-88,172.8c-3.8,8.9-7.7,13.4-14,13.4c-5.7,0-10.2-4.5-13.4-13.4
-               
l-130-304.7c-17.9-42.1-22.3-52.3-53.5-54.2c-3.8-3.8-4.5-15.9-0.6-19.8c21,0.6,42.1,1.3,61.8,1.3c17.8,0,42.1-0.6,66.3-1.3
-               
c3.8,3.8,3.8,15.9,0,19.8c-37.6,4.5-39.5,13.4-24.2,47.8l109,248.6h2.6l66.3-135.2l-50.4-106.5c-19.8-42.1-24.9-50.4-50.4-54.2
-               
c-3.8-3.8-4.5-15.9-0.6-19.8c17.2,0.6,35.7,1.3,52.9,1.3c19.1,0,37-0.6,54.2-1.3c3.8,3.8,3.8,15.9,0,19.8
-               
c-24.9,3.8-22.9,13.4-6.4,47.8l29.3,59.9l2.6,2.6l31.9-59.3c18.5-34.4,21-47.8-4.5-51C339.8,215.6,339.8,203.5,343,199.7z"/>
-</g>
-</svg>
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 17.1.0, SVG Export Plug-In . SVG Version: 
6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" 
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd";>
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg"; 
xmlns:xlink="http://www.w3.org/1999/xlink"; x="0px" y="0px"
+        viewBox="0 0 612 792" enable-background="new 0 0 612 792" 
xml:space="preserve">
+<g opacity="0.95">
+       <path fill="#777777" 
d="M343,199.7c15.9,0.6,33.1,1.3,47.2,1.3c14,0,28.7-0.6,43.4-1.3c3.8,3.8,3.8,15.9,0,19.8
+               
c-31.2,4.5-44,27.4-58.6,55.5l-43.4,82.9L408,515.2h2.6l112.8-244.8c14-32.5,13.4-48.5-25.5-51c-3.8-3.8-3.8-15.9,0-19.8
+               
c22.3,0.6,45.3,1.3,58.6,1.3c13.4,0,33.1-0.6,52.3-1.3c3.8,3.8,3.8,15.9,0,19.8c-33.8,3.8-45.3,26.8-58.6,55.5L409.9,578.3
+               
c-4.5,8.9-7.6,13.4-14,13.4c-5.7,0-10.8-5.1-13.4-13.4l-81-172.8l-88,172.8c-3.8,8.9-7.7,13.4-14,13.4c-5.7,0-10.2-4.5-13.4-13.4
+               
l-130-304.7c-17.9-42.1-22.3-52.3-53.5-54.2c-3.8-3.8-4.5-15.9-0.6-19.8c21,0.6,42.1,1.3,61.8,1.3c17.8,0,42.1-0.6,66.3-1.3
+               
c3.8,3.8,3.8,15.9,0,19.8c-37.6,4.5-39.5,13.4-24.2,47.8l109,248.6h2.6l66.3-135.2l-50.4-106.5c-19.8-42.1-24.9-50.4-50.4-54.2
+               
c-3.8-3.8-4.5-15.9-0.6-19.8c17.2,0.6,35.7,1.3,52.9,1.3c19.1,0,37-0.6,54.2-1.3c3.8,3.8,3.8,15.9,0,19.8
+               
c-24.9,3.8-22.9,13.4-6.4,47.8l29.3,59.9l2.6,2.6l31.9-59.3c18.5-34.4,21-47.8-4.5-51C339.8,215.6,339.8,203.5,343,199.7z"/>
+</g>
+</svg>
diff --git a/modules/header/images/clear.png 
b/modules/ui/styles/images/clear.png
similarity index 100%
rename from modules/header/images/clear.png
rename to modules/ui/styles/images/clear.png
Binary files differ
diff --git a/modules/header/images/clear.svg 
b/modules/ui/styles/images/clear.svg
similarity index 100%
rename from modules/header/images/clear.svg
rename to modules/ui/styles/images/clear.svg
diff --git a/modules/ui/styles/mw.cx.ui.Columns.less 
b/modules/ui/styles/mw.cx.ui.Columns.less
new file mode 100644
index 0000000..3b92d6c
--- /dev/null
+++ b/modules/ui/styles/mw.cx.ui.Columns.less
@@ -0,0 +1,61 @@
+// Common styles for source or tanslation columns of translation view
+@import "../../widgets/common/ext.cx.common";
+
+.cx-widget__columns {
+       margin-top: 100px;
+       min-height: 100vh;
+
+       &.disabled {
+               pointer-events: none;
+               opacity: 0.5;
+       }
+}
+
+.cx-column {
+       .mw-ui-item;
+       .mw-ui-one-third;
+       .mw-ui-gutter(3%);
+}
+
+.cx-column__sub-heading {
+       .mw-ui-item;
+       .mw-ui-one-whole;
+       padding: 5px 0;
+       line-height: 2em;
+}
+
+.cx-column__content {
+       line-height: 1.5em;
+       clear: both;
+       word-wrap: break-word;
+       position: relative;
+
+       table,
+       figure,
+       figure[typeof*='mw:Image'] {
+               float: none;
+       }
+}
+
+.cx-column__language-label,
+.cx-column__sub-heading__view-page {
+       .mw-ui-one-half;
+       padding: 0;
+       overflow: hidden;
+       text-overflow: ellipsis;
+       white-space: nowrap;
+       max-height: 3em;
+}
+
+.cx-column__language-label {
+       text-align: left;
+       color: @gray-dark;
+}
+
+.cx-column__title {
+       font-size: 2.2em;
+       color: @gray-darker;
+       font-family: 'Linux Libertine', Georgia, Times, serif;
+       margin-bottom: 10px;
+       word-wrap: break-word;
+}
diff --git a/modules/header/styles/ext.cx.header.less 
b/modules/ui/styles/mw.cx.ui.Header.less
similarity index 60%
rename from modules/header/styles/ext.cx.header.less
rename to modules/ui/styles/mw.cx.ui.Header.less
index 65d81ec..cf7aced 100644
--- a/modules/header/styles/ext.cx.header.less
+++ b/modules/ui/styles/mw.cx.ui.Header.less
@@ -10,7 +10,7 @@
        width: 42px;
        height: 38px;
        margin: 0 10px 0 0;
-       .background-image-svg('../images/W_gray.svg', '../images/W_gray.png');
+       .background-image-svg('images/W_gray.svg', 'images/W_gray.png');
        background-repeat: no-repeat;
        background-position: center center;
        background-size: 28px;
@@ -64,57 +64,6 @@
        .mw-ui-one-third;
        color: #555555;
        font-size: normal;
-}
-
-.cx-header__infobar {
-       .mw-ui-item;
-       .mw-ui-one-whole;
-       padding: 10px;
-
-       &.cx-error {
-               border: 1px solid #fac5c5;
-               background-color: #fae3e3;
-               text-shadow: 0 1px #fae3e3;
-
-               .details {
-                       padding: 20px;
-               }
-       }
-
-       &.cx-success {
-               border-color: #b7fdb5;
-               background-color: #e1fddf;
-       }
-
-       &.cx-warning {
-               color: #705000;
-               border-color: #fde29b;
-               background-color: #fdf1d1;
-       }
-
-       border-bottom: 1px solid #ddd;
-       font-size: large;
-
-       .remove {
-               @vertical-margin: 10px;
-               @horizontal-margin: 15px;
-               float: right;
-               padding: @vertical-margin @horizontal-margin;
-
-               .background-image-svg('../images/clear.svg', 
'../images/clear.png');
-               background-attachment: scroll;
-               background-position: 10px center;
-               background-repeat: no-repeat;
-               background-size: 15px;
-               cursor: pointer;
-               clear: both;
-       }
-}
-
-.cx-header__publish {
-       .mw-ui-item;
-       .mw-ui-one-third;
-       float: right;
 }
 
 .cx-header.sticky {
diff --git a/modules/header/styles/ext.cx.header.vector.less 
b/modules/ui/styles/mw.cx.ui.Header.vector.less
similarity index 100%
rename from modules/header/styles/ext.cx.header.vector.less
rename to modules/ui/styles/mw.cx.ui.Header.vector.less
diff --git a/modules/ui/styles/mw.cx.ui.Infobar.less 
b/modules/ui/styles/mw.cx.ui.Infobar.less
new file mode 100644
index 0000000..9d589ab
--- /dev/null
+++ b/modules/ui/styles/mw.cx.ui.Infobar.less
@@ -0,0 +1,47 @@
+@import "../../widgets/common/ext.cx.common";
+@import "mediawiki.mixins";
+
+.cx-header__infobar {
+       .mw-ui-item;
+       .mw-ui-one-whole;
+       padding: 10px;
+
+       &.cx-error {
+               border: 1px solid #fac5c5;
+               background-color: #fae3e3;
+               text-shadow: 0 1px #fae3e3;
+
+               .details {
+                       padding: 20px;
+               }
+       }
+
+       &.cx-success {
+               border-color: #b7fdb5;
+               background-color: #e1fddf;
+       }
+
+       &.cx-warning {
+               color: #705000;
+               border-color: #fde29b;
+               background-color: #fdf1d1;
+       }
+
+       border-bottom: 1px solid #ddd;
+       font-size: large;
+
+       .remove {
+               @vertical-margin: 10px;
+               @horizontal-margin: 15px;
+               float: right;
+               padding: @vertical-margin @horizontal-margin;
+
+               .background-image-svg('images/clear.svg', 'images/clear.png');
+               background-attachment: scroll;
+               background-position: 10px center;
+               background-repeat: no-repeat;
+               background-size: 15px;
+               cursor: pointer;
+               clear: both;
+       }
+}
diff --git a/modules/translationview/styles/ext.cx.translationview.less 
b/modules/ui/styles/mw.cx.ui.TranslationView.less
similarity index 91%
rename from modules/translationview/styles/ext.cx.translationview.less
rename to modules/ui/styles/mw.cx.ui.TranslationView.less
index 31a9642..cc95760 100644
--- a/modules/translationview/styles/ext.cx.translationview.less
+++ b/modules/ui/styles/mw.cx.ui.TranslationView.less
@@ -50,3 +50,9 @@
        padding: 10px;
        margin: 2px 0;
 }
+
+.cx-header__publish {
+       .mw-ui-item;
+       .mw-ui-one-third;
+       float: right;
+}
diff --git a/modules/widgets/common/ext.cx.column.less 
b/modules/widgets/common/ext.cx.column.less
index 9ef7552..09010d4 100644
--- a/modules/widgets/common/ext.cx.column.less
+++ b/modules/widgets/common/ext.cx.column.less
@@ -15,6 +15,7 @@
        .mw-ui-item;
        .mw-ui-one-third;
        .mw-ui-gutter(3%);
+       margin: 0;
 }
 
 .cx-column__sub-heading {
diff --git a/specials/SpecialContentTranslation.php 
b/specials/SpecialContentTranslation.php
index 3a7f6f1..67c697a 100644
--- a/specials/SpecialContentTranslation.php
+++ b/specials/SpecialContentTranslation.php
@@ -141,10 +141,10 @@
                }
 
                // Preloading to avoid FOUC
-               $out->addModuleStyles( 'ext.cx.header.skin' );
+               $out->addModuleStyles( 'mw.cx.ui.Header.skin' );
 
                if ( $hasToken || $isExistingTranslation ) {
-                       $out->addModules( 'ext.cx.translationview' );
+                       $out->addModules( 'mw.cx.ui.TranslationView' );
 
                        // If Wikibase is installed, load the module for linking
                        // the published article with the source article
diff --git a/specials/SpecialContentTranslationStats.php 
b/specials/SpecialContentTranslationStats.php
index e89c492..67a3ea0 100644
--- a/specials/SpecialContentTranslationStats.php
+++ b/specials/SpecialContentTranslationStats.php
@@ -40,9 +40,9 @@
                ] );
 
                // Preloading to avoid FOUC
-               $out->addModuleStyles( 'ext.cx.header.skin' );
+               $out->addModuleStyles( 'mw.cx.ui.Header.skin' );
 
-               $out->addModules( [ 'ext.cx.header', 'ext.cx.stats' ] );
+               $out->addModules( 'ext.cx.stats' );
                // Add skin specific modules
                $modules = $skin->getDefaultModules();
                foreach ( $modules as $group ) {
diff --git a/tests/qunit/header/ext.cx.header.test.js 
b/tests/qunit/header/ext.cx.header.test.js
deleted file mode 100644
index 8521cf6..0000000
--- a/tests/qunit/header/ext.cx.header.test.js
+++ /dev/null
@@ -1,45 +0,0 @@
-/*!
- * QUnit tests for Content Translation.
- *
- * @ingroup Extensions
- * @licence GPL-2.0+
- */
-
-( function ( $, mw ) {
-       'use strict';
-
-       QUnit.module( 'ext.cx.header', QUnit.newMwEnvironment() );
-
-       QUnit.test( 'Show message with a string', function ( assert ) {
-               var $header, $fixture = $( '#qunit-fixture' );
-
-               QUnit.expect( 4 );
-
-               $fixture.cxHeader();
-               $header = $fixture.data( 'cxHeader' );
-
-               $header.showMessage( 'test-class', 'Test <b>message</b>' );
-               assert.equal( $header.$infoBar.text(), 'Test <b>message</b>', 
'Strings as escaped' );
-               assert.ok( $header.$infoBar.hasClass( 'test-class' ) );
-
-               $header.showMessage( 'changed-class', 'New message' );
-               assert.equal( $header.$infoBar.text(), 'New message', 'Message 
is updated' );
-               assert.ok( $header.$infoBar.hasClass( 'changed-class' ) );
-       } );
-
-       QUnit.test( 'Show message with a Message object', function ( assert ) {
-               var $header, $fixture = $( '#qunit-fixture' );
-
-               QUnit.expect( 1 );
-
-               $fixture.cxHeader();
-               $header = $fixture.data( 'cxHeader' );
-
-               mw.messages.set( 'cx-header-test', '[http://example.com $1] is 
<b>here</b>' );
-
-               $header.showMessage( 'test-class', mw.message( 
'cx-header-test', 'Kissa' ) );
-               // Taking a shortcut by testing the text content where html is 
dropped
-               assert.equal( $header.$infoBar.text(), 'Kissa is here', 'Html 
is accepted' );
-       } );
-
-}( jQuery, mediaWiki ) );
diff --git a/tests/qunit/publish/ext.cx.publish.prepare.test.js 
b/tests/qunit/publish/ext.cx.publish.prepare.test.js
index 467fb48..0ed0fa4 100644
--- a/tests/qunit/publish/ext.cx.publish.prepare.test.js
+++ b/tests/qunit/publish/ext.cx.publish.prepare.test.js
@@ -26,7 +26,7 @@
                $fixture.load( testDataPath + 'draft-sample-1.html', function 
() {
                        var cleanedHTML, publisher;
 
-                       publisher = new mw.cx.publish();
+                       publisher = new mw.cx.Publish();
                        cleanedHTML = publisher.prepareTranslationForPublish( 
$fixture );
                        assert.strictEqual( $( cleanedHTML ).find( 
'[contenteditable]' ).length, 0,
                                'No contenteditable attributes left' );
diff --git a/tests/qunit/publish/ext.cx.publish.test.js 
b/tests/qunit/publish/ext.cx.publish.test.js
index c54df9c..a4630f2 100644
--- a/tests/qunit/publish/ext.cx.publish.test.js
+++ b/tests/qunit/publish/ext.cx.publish.test.js
@@ -18,9 +18,9 @@
 
        QUnit.test( 'Publishing with Captcha handling', function ( assert ) {
                var publisher,
-                       oldCaptchaHandler = 
mw.cx.publish.prototype.captchaHandler,
-                       oldSuccessHandler = mw.cx.publish.prototype.onSuccess,
-                       oldTitleExists = 
mw.cx.publish.prototype.checkTargetTitle,
+                       oldCaptchaHandler = 
mw.cx.Publish.prototype.captchaHandler,
+                       oldSuccessHandler = mw.cx.Publish.prototype.onSuccess,
+                       oldTitleExists = 
mw.cx.Publish.prototype.checkTargetTitle,
                        newCaptchaHandler,
                        server = this.server,
                        $trigger = $( '<div>' );
@@ -36,7 +36,7 @@
                                captchaKey: 1234565
                        } ).promise();
                };
-               publisher = new mw.cx.publish( $trigger, this.sitemapper );
+               publisher = new mw.cx.Publish( $trigger, this.sitemapper );
                publisher.captchaHandler = newCaptchaHandler;
                publisher.getContent = function () {
                        return 'Content to publish';
@@ -56,19 +56,19 @@
                        html: '...'
                } ).done( function () {
                        assert.ok( true, 'Publishing was completed' );
-                       mw.cx.publish.prototype.captchaHandler = 
oldCaptchaHandler;
-                       mw.cx.publish.prototype.onSuccess = oldSuccessHandler;
-                       mw.cx.publish.prototype.titleExists = oldTitleExists;
+                       mw.cx.Publish.prototype.captchaHandler = 
oldCaptchaHandler;
+                       mw.cx.Publish.prototype.onSuccess = oldSuccessHandler;
+                       mw.cx.Publish.prototype.titleExists = oldTitleExists;
                        QUnit.start();
                } );
                server.requests[ 0 ].respond( 200, {
-                               'Content-Type': 'application/json'
-                       },
+                       'Content-Type': 'application/json'
+               },
                        '{ "cxpublish": { "result": "error", "edit": { 
"captcha": {"captchaKey":"1234565"} } } }'
                );
                server.requests[ 1 ].respond( 200, {
-                               'Content-Type': 'application/json'
-                       },
+                       'Content-Type': 'application/json'
+               },
                        '{ "cxpublish": { "result": "success" } }'
                );
        } );
diff --git a/tests/qunit/ui/mw.cx.ui.Infobar.test.js 
b/tests/qunit/ui/mw.cx.ui.Infobar.test.js
new file mode 100644
index 0000000..53dc8b6
--- /dev/null
+++ b/tests/qunit/ui/mw.cx.ui.Infobar.test.js
@@ -0,0 +1,45 @@
+/*!
+ * QUnit tests for Content Translation.
+ *
+ * @ingroup Extensions
+ * @licence GPL-2.0+
+ */
+
+( function ( $, mw ) {
+       'use strict';
+
+       QUnit.module( 'mw.cx.ui.Infobar', QUnit.newMwEnvironment() );
+
+       QUnit.test( 'Show message with a string', function ( assert ) {
+               var infobar, $fixture = $( '#qunit-fixture' );
+
+               QUnit.expect( 4 );
+
+               infobar = new mw.cx.ui.Infobar();
+               $fixture.append( infobar.$element );
+
+               infobar.showMessage( 'test-class', 'Test <b>message</b>' );
+               assert.equal( infobar.$infobar.text(), 'Test <b>message</b>', 
'Strings as escaped' );
+               assert.ok( infobar.$infobar.hasClass( 'cx-test-class' ) );
+
+               infobar.showMessage( 'changed-class', 'New message' );
+               assert.equal( infobar.$infobar.text(), 'New message', 'Message 
is updated' );
+               assert.ok( infobar.$infobar.hasClass( 'cx-changed-class' ) );
+       } );
+
+       QUnit.test( 'Show message with a Message object', function ( assert ) {
+               var infobar, $fixture = $( '#qunit-fixture' );
+
+               QUnit.expect( 1 );
+
+               infobar = new mw.cx.ui.Infobar();
+               $fixture.append( infobar.$element );
+
+               mw.messages.set( 'cx-header-test', '[http://example.com $1] is 
<b>here</b>' );
+
+               infobar.showMessage( 'test-class', mw.message( 
'cx-header-test', 'Kissa' ) );
+               // Taking a shortcut by testing the text content where html is 
dropped
+               assert.equal( infobar.$infobar.text(), 'Kissa is here', 'Html 
is accepted' );
+       } );
+
+}( jQuery, mediaWiki ) );

-- 
To view, visit https://gerrit.wikimedia.org/r/317993
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: I07cde72f9b4a7daa45415b4af16f086f6c172046
Gerrit-PatchSet: 9
Gerrit-Project: mediawiki/extensions/ContentTranslation
Gerrit-Branch: master
Gerrit-Owner: Santhosh <[email protected]>
Gerrit-Reviewer: Nikerabbit <[email protected]>
Gerrit-Reviewer: Santhosh <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to