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> + <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