Sbisson has uploaded a new change for review. ( 
https://gerrit.wikimedia.org/r/403749 )

Change subject: [EXPERIMENTAL] Fleact lite
......................................................................

[EXPERIMENTAL] Fleact lite

NOJS is serverside handlerbars app

JS is client-side React+Redux app

It loads like Special:Notifications:
 - hide the main content when client-js is there
 - use the json blob to render the js app

It includes VE for editing the board description.

Change-Id: I4b408dbccafb30603e6f01676b4a417b9d2ef39c
---
M .gitignore
M FlowActions.php
M Gruntfile.js
M extension.json
A modules/fleact/src/api.js
A modules/fleact/src/app.js
A modules/fleact/src/client.js
A modules/fleact/src/controller.js
A modules/fleact/src/store.js
A modules/fleact/styles/initial.less
M package.json
11 files changed, 428 insertions(+), 1 deletion(-)


  git pull ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/Flow 
refs/changes/49/403749/1

diff --git a/.gitignore b/.gitignore
index 8fe72ff..d5bf0d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@
 \#*#
 /composer.lock
 /docs
+modules/fleact/dist
diff --git a/FlowActions.php b/FlowActions.php
index c2cd49f..3e60a12 100644
--- a/FlowActions.php
+++ b/FlowActions.php
@@ -665,6 +665,8 @@
                'actions' => [], // view is not a recorded change type, no 
actions will be requested
                'history' => [], // views don't generate history
                'handler-class' => 'Flow\Actions\ViewAction',
+               'modules' => [ 'fleact-client' ],
+               'moduleStyles' => [ 'fleact-initial-styles' ],
        ],
 
        'reply' => [
diff --git a/Gruntfile.js b/Gruntfile.js
index db18109..3166dc3 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -14,6 +14,7 @@
        grunt.loadNpmTasks( 'grunt-jsonlint' );
        grunt.loadNpmTasks( 'grunt-stylelint' );
        grunt.loadNpmTasks( 'grunt-tyops' );
+       grunt.loadNpmTasks( 'grunt-browserify' );
 
        grunt.initConfig( {
                tyops: {
@@ -56,10 +57,22 @@
                                '!node_modules/**',
                                '!vendor/**'
                        ]
+               },
+               browserify: {
+                       client: {
+                               src: [
+                                       'modules/fleact/src/client.js'
+                               ],
+                               dest: 'modules/fleact/dist/client.js',
+                               options: {
+                                       transform: [ [ 'babelify', { presets: [ 
"es2015", "react" ] } ] ]
+                               }
+                       }
                }
        } );
 
        grunt.registerTask( 'lint', [ 'tyops', 'eslint', 'stylelint', 
'jsonlint', 'banana' ] );
        grunt.registerTask( 'test', 'lint' );
+       grunt.registerTask( 'fleact', [ 'browserify:client' ] );
        grunt.registerTask( 'default', 'test' );
 };
diff --git a/extension.json b/extension.json
index ce0ab2d..9bfa915 100644
--- a/extension.json
+++ b/extension.json
@@ -600,6 +600,8 @@
                },
                "ext.flow.visualEditor": {
                        "scripts": [
+                               "flow/mw.flow.js",
+                               "flow/ui/mw.flow.ui.js",
                                
"editor/editors/visualeditor/mw.flow.ve.Target.js",
                                
"editor/editors/visualeditor/mw.flow.ve.UserCache.js",
                                
"editor/editors/visualeditor/ui/inspectors/mw.flow.ve.ui.MentionInspector.js",
@@ -669,6 +671,28 @@
                                "desktop",
                                "mobile"
                        ]
+               },
+               "just-flow": {
+                       "scripts": [
+                               "flow/mw.flow.js"
+                       ]
+               },
+               "fleact-client": {
+                       "scripts": [
+                               "fleact/dist/client.js"
+                       ],
+                       "messages": [
+                               "flow-board-header"
+                       ],
+                       "dependencies": [
+                               "ext.flow.visualEditor"
+                       ]
+               },
+               "fleact-initial-styles": {
+                       "position": "top",
+                       "styles": [
+                               "fleact/styles/initial.less"
+                       ]
                }
        },
        "ResourceFileModulePaths": {
diff --git a/modules/fleact/src/api.js b/modules/fleact/src/api.js
new file mode 100644
index 0000000..5a2d5ab
--- /dev/null
+++ b/modules/fleact/src/api.js
@@ -0,0 +1,68 @@
+
+const formatUser = ( rawUser ) => {
+       return {
+               name: rawUser.name,
+               talkUrl: rawUser.links.talk.url
+       }
+}
+
+const formatPost = ( postId, data ) => {
+       const post = data.revisions[ data.posts[ postId ] ]
+       return {
+               id: postId,
+               content: post.content.content,
+               user:  formatUser( post.creator ),
+               ts: post.timestamp,
+               replies: post.replies.map( ( postId ) => formatPost( postId, 
data ) )
+       }
+}
+
+const formatDescription = ( rawDesc ) => {
+       if ( rawDesc.revision.revisionId ) {
+               return {
+                       content: rawDesc.revision.content.content,
+                       revisionId: rawDesc.revision.revisionId,
+                       actions: rawDesc.revision.actions,
+                       links: rawDesc.revision.links,
+                       editToken: rawDesc.editToken
+               }
+       } else {
+               return {
+                       content: '',
+                       revisionId: null,
+                       actions: rawDesc.revision.actions,
+                       links: rawDesc.revision.links
+               }
+       }
+}
+
+export function convert ( apiResponse ) {
+       const data = apiResponse[ 'blocks' ],
+               description = formatDescription( data[ 'header' ] ),
+               topicList = data[ 'topiclist' ],
+               topics = topicList ? topicList.roots.map( ( root ) => 
formatPost( root, topicList ) ) : []
+
+       return { description: { view: description }, topics }
+}
+
+export function getHeader ( format='wikitext' ) {
+       return new mw.Api().get( {
+               action: 'flow',
+               submodule: 'view-header',
+               page: mw.config.get( 'wgPageName' ),
+               vhformat: format
+       } ).then( function ( response ) {
+               return formatDescription( 
response.flow['view-header']['result']['header'] )
+       } )
+}
+
+export function saveHeader ( content, previousRevisionId ) {
+       return new mw.Api().postWithToken('csrf', {
+               action: 'flow',
+               submodule: 'edit-header',
+               page: mw.config.get( 'wgPageName' ),
+               ehformat: 'wikitext',
+               ehcontent: content,
+               ehprev_revision: previousRevisionId
+       } )
+}
diff --git a/modules/fleact/src/app.js b/modules/fleact/src/app.js
new file mode 100644
index 0000000..f230b2d
--- /dev/null
+++ b/modules/fleact/src/app.js
@@ -0,0 +1,179 @@
+import React, { Component } from 'react'
+
+const Content = ( props ) => (
+  <div dangerouslySetInnerHTML={ { __html: props.html } } />
+)
+
+class VisualEditor extends Component {
+       constructor( props ) {
+               super( props )
+               this.onSave = this.onSave.bind( this )
+       }
+       componentDidMount() {
+               this.target = ve.init.mw.targetFactory.create( 'flow' )
+               this.target.setDefaultMode( 'source' )
+               this.target.loadContent( this.props.content )
+               // target.connect( widget, {
+               //   surfaceReady: 'onTargetSurfaceReady',
+               //   switchMode: 'onTargetSwitchMode'
+               // } );
+               $(this.el).prepend( this.target.$element );
+       }
+       shouldComponentUpdate() {
+               return false
+       }
+       getContent() {
+               const dom = this.target.getSurface().getDom();
+               let content = '',
+                       format = ''
+               if ( typeof dom === 'string' ) {
+                       content = dom;
+                       format = 'wikitext';
+               } else {
+                       // Document content will include html, head & body 
nodes; get only content inside body node
+                       content = ve.properInnerHtml( dom.body );
+                       format = 'html';
+               }
+               return { content: content, format: format };
+       }
+       onSave() {
+               const content = this.getContent()
+               window.controller[ this.props.saveAction ]( {
+                       content: content.content,
+                       format: content.format,
+                       previousRevisionId: this.props.previousRevisionId
+               } )
+       }
+       render() {
+               return (
+                       <div className="flow-ui-editorWidget-editor" ref={el => 
this.el = el}>
+                               <div 
className="flow-ui-editorControlsWidget-buttons">
+                                       <div className="mw-ui-button 
mw-ui-destructive mw-ui-quiet mw-ui-flush-right" data-action={ 
this.props.cancelAction }>Cancel</div>
+                                       <div className="mw-ui-button 
mw-ui-progressive" onClick={ this.onSave }>Save</div>
+                               </div>
+                       </div>
+               )
+       }
+}
+
+class TextareaEditor extends Component {
+       constructor( props ) {
+               super( props )
+               this.state = { content: props.content }
+               this.handleTextareaChange = this.handleTextareaChange.bind( 
this )
+       }
+       handleTextareaChange( e ) {
+               this.setState( { content: e.target.value } )
+       }
+       render() {
+               return (
+                       <form method="post" action={ this.props.formActionUrl }>
+                               <input type="hidden" 
name="header_prev_revision" value={ this.props.previousRevisionId } />
+                               <input type="hidden" name="wpEditToken" value={ 
this.props.editToken } />
+                               <textarea
+                                       name="header_content"
+                                       value={ this.state.content }
+                                       onChange={ this.handleTextareaChange }
+                                       rows="5" />
+                                               <a href={ this.props.cancelUrl 
} data-action={ this.props.cancelAction }>Cancel</a>&nbsp;
+                               <input type="submit" value="Save"
+                                       data-action={ this.props.saveAction }
+                                       data-action-params={ JSON.stringify( { 
content: this.state.content, previousRevisionId: this.props.previousRevisionId 
} ) } />
+                       </form>
+               )
+       }
+}
+
+class Editor extends Component {
+       constructor( props ) {
+               super( props )
+               this.state = { ve: false }
+       }
+       componentDidMount() { // only executed client-side
+               //todo: only set {ve: true} if VE is actually available
+               this.setState( { ve: true } )
+       }
+       render() {
+               return this.state.ve ?
+                       <VisualEditor { ...this.props } /> :
+                       <TextareaEditor { ...this.props } />
+       }
+}
+
+const User = ( props ) => (
+  <div>
+    <a href={ props.user.talkUrl }>
+      { props.user.name }
+    </a>
+  </div>
+)
+
+const ViewDesc = ( props ) => (
+  <div className="flow-board-header-content">
+    <a href={ props.description.actions.edit.url } 
data-action="editHeaderPrepare">
+      { props.description.actions.edit.text }
+    </a>
+    <Content html={ props.description.content } />
+  </div>
+)
+
+const EditDesc = ( props ) => (
+  <div>
+    {
+      props.description.pending ?
+        <div>Please wait...</div> :
+        <Editor
+          content={ props.description.content }
+          previousRevisionId={ props.description.revisionId }
+          formActionUrl={ props.description.actions.edit.url }
+          cancelUrl={ props.description.links.workflow.url }
+          editToken={ props.description.editToken }
+          cancelAction="editHeaderCancel"
+          saveAction="editHeaderSave" />
+    }
+  </div>
+)
+
+const Desc = ( props ) => (
+  <div className="flow-board-header">
+    <h2 className="flow-board-header-title">
+      { mw.msg( 'flow-board-header' ) }
+    </h2>
+    {
+      props.description.edit ?
+        <EditDesc description={ props.description.edit } /> :
+        <ViewDesc description={ props.description.view } />
+    }
+    <hr />
+    <div className="flow-board-header-footer">
+      Text is available under the <a 
href="https://creativecommons.org/licenses/by-sa/3.0/";>Creative Commons 
Attribution-ShareAlike License</a>; additional terms may apply. See <a 
href="https://wikimediafoundation.org/wiki/Terms_of_Use";>Terms of Use</a> for 
details.
+    </div>
+  </div>
+)
+
+const Comment = ( props ) => (
+  <div className="flow-comment" style={ { marginLeft: props.indent*20 } }>
+    <User user={ props.comment.user } />
+    <Content html={ props.comment.content } />
+    <small>{ props.comment.ts }</small>
+    { props.comment.replies.map( c => <Comment comment={ c } key={ c.id } 
indent={ props.indent+1 } /> ) }
+  </div>
+)
+
+const Topic = ( props ) => (
+  <div className="flow-topic">
+    <h3><Content html={ props.topic.content } /></h3>
+    <small>{ props.topic.replies.length } comment(s) - { props.topic.ts 
}</small>
+    { props.topic.replies.map( c => <Comment comment={ c } key={ c.id } 
indent={ 0 } /> ) }
+  </div>
+)
+
+const Board = ( props ) => (
+  <div className="flow-board">
+    <Desc description={ props.description } />
+    { !!props.topics.length && <h2>Topics</h2> }
+    { props.topics.map( t => <Topic topic={ t } key={ t.id } /> ) }
+  </div>
+)
+
+export default Board;
diff --git a/modules/fleact/src/client.js b/modules/fleact/src/client.js
new file mode 100644
index 0000000..f10efaa
--- /dev/null
+++ b/modules/fleact/src/client.js
@@ -0,0 +1,36 @@
+import React from 'react'
+import ReactDOM from 'react-dom'
+import Board from './app'
+import { convert } from './api'
+import Controller from './controller'
+
+// console.log( mw.config.get( 'wgFlowData' ) )
+const state = convert( mw.config.get( 'wgFlowData' ) )
+
+const controller = window.controller = new Controller( state )
+
+const render = ( state ) => {
+       console.time( 'fleact render' )
+       ReactDOM.render(
+               <Board description={state.description} topics={state.topics} />,
+               document.getElementById( 'fleact-root' )
+       )
+       console.timeEnd( 'fleact render' )
+}
+
+$( '.flow-component' ).replaceWith( $( '<div>' ).attr( 'id', 'fleact-root' ) )
+render( controller.getState() )
+controller.onStateChange( ( state ) => render( state ) )
+
+$( '#fleact-root' ).on( 'click', '[data-action]', function ( e ) {
+       e.preventDefault();
+       let action = $( this ).data( 'action' )
+       let params = $( this ).data( 'action-params' )
+       if ( controller[ action ] ) {
+               controller[ action ]( params )
+       } else {
+               console.log( 'Unrecognized controller action:', action )
+       }
+} )
+
+console.log( 'client initialization: done' )
diff --git a/modules/fleact/src/controller.js b/modules/fleact/src/controller.js
new file mode 100644
index 0000000..6f53e5a
--- /dev/null
+++ b/modules/fleact/src/controller.js
@@ -0,0 +1,40 @@
+import initStore from './store'
+import { getHeader, saveHeader } from './api'
+
+export default class Controller {
+       constructor( state ) {
+               this.store = initStore( state )
+       }
+
+       getState() {
+               return this.store.getState()
+       }
+
+       onStateChange( callback ) {
+               this.store.subscribe( () => callback( this.store.getState() ) )
+       }
+
+       editHeaderPrepare() {
+               this.store.dispatch( { type: 'edit-header-prepare' } )
+               getHeader().then( this.editHeaderReady.bind( this ) )
+       }
+
+       editHeaderReady( description ) {
+               this.store.dispatch( { type: 'edit-header-ready', description } 
)
+       }
+
+       editHeaderCancel() {
+               this.store.dispatch( { type: 'edit-header-cancel' } )
+       }
+
+       editHeaderConfirmed( description ) {
+               this.store.dispatch( { type: 'edit-header-confirmed', 
description } )
+       }
+
+       editHeaderSave( { content, previousRevisionId } ) {
+               this.store.dispatch( { type: 'edit-header-save' } )
+               saveHeader( content, previousRevisionId )
+                       .then( getHeader.bind( null, 'fixed-html' ) )
+                       .then( this.editHeaderConfirmed.bind( this ) )
+       }
+}
diff --git a/modules/fleact/src/store.js b/modules/fleact/src/store.js
new file mode 100644
index 0000000..226f04f
--- /dev/null
+++ b/modules/fleact/src/store.js
@@ -0,0 +1,49 @@
+import { combineReducers, createStore } from 'redux'
+
+const description = ( state=null, action ) => {
+       if ( action.type === 'edit-header-prepare' ) {
+               return Object.assign( {}, state, {
+                       edit: {
+                               pending: true
+                       }
+               } )
+       }
+
+       if ( action.type === 'edit-header-cancel' ) {
+               return Object.assign( {}, state, {
+                       edit: false
+               } )
+       }
+
+       if ( action.type === 'edit-header-ready' ) {
+               return Object.assign( {}, state, {
+                       edit: action.description
+               } )
+       }
+
+       if ( action.type === 'edit-header-save' ) {
+               return Object.assign( {}, state, {
+                       edit: {
+                               pending: true
+                       }
+               } )
+       }
+
+       if ( action.type === 'edit-header-confirmed' ) {
+               return Object.assign( {}, state, {
+                       edit: false,
+                       view: action.description
+               } )
+       }
+       return state
+}
+
+const topics = ( state=[], action ) => state
+
+export default function initStore ( state ) {
+       return createStore(
+               combineReducers( { description, topics } ),
+               state,
+               window.__REDUX_DEVTOOLS_EXTENSION__ && 
window.__REDUX_DEVTOOLS_EXTENSION__()
+       )
+}
diff --git a/modules/fleact/styles/initial.less 
b/modules/fleact/styles/initial.less
new file mode 100644
index 0000000..e06c760
--- /dev/null
+++ b/modules/fleact/styles/initial.less
@@ -0,0 +1,5 @@
+.client-js {
+       .flow-component {
+               display: none;
+       }
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index 016f5e5..e5ddf85 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,16 @@
     "grunt-stylelint": "0.8.0",
     "grunt-tyops": "0.1.0",
     "stylelint": "8.2.0",
-    "stylelint-config-wikimedia": "0.4.2"
+    "stylelint-config-wikimedia": "0.4.2",
+    "react": "^16.2.0",
+    "react-dom": "^16.2.0",
+    "redux": "^3.7.2",
+    "babel-core": "^6.26.0",
+    "babel-preset-env": "^1.6.1",
+    "babel-preset-es2015": "^6.24.1",
+    "babel-preset-react": "^6.24.1",
+    "babelify": "^8.0.0",
+    "browserify": "^14.5.0",
+    "grunt-browserify": "^5.2.0"
   }
 }

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

Gerrit-MessageType: newchange
Gerrit-Change-Id: I4b408dbccafb30603e6f01676b4a417b9d2ef39c
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/extensions/Flow
Gerrit-Branch: master
Gerrit-Owner: Sbisson <sbis...@wikimedia.org>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to