Compare commits

...

1 Commits

Author SHA1 Message Date
florent
32478e470b FB proposition for react (WIP)
after a few try, I think it can work this way :

 * everything is in one store: xoApi, session, even component states. try to use boolean when possible to simplify usage in components
 * components states should rarely be used : let components be stateless, a pure function of their props
 * use connect on high level component to give them access to part of the store and the actions. A component should not have access to the full store and action.
 * use event to get data back from child components ( if they can't dispatch action themselves)
 * redux-thunk allow to dispatch multiple action in cascade (even async one) , like want to save->saving ->saved (or errored)
 * use imutable object, this will allow faster performance in the long run
2016-01-31 22:06:12 +01:00
9 changed files with 460 additions and 132 deletions

View File

@@ -55,6 +55,7 @@
"gulp-uglify": "^1.5.1",
"gulp-watch": "^4.3.5",
"history": "^2.0.0-rc2",
"jsonrpc-websocket-client": "0.0.1-4",
"mocha": "^2.3.4",
"must": "^0.13.1",
"nice-pipe": "^0.3.4",
@@ -64,6 +65,8 @@
"react-intl": "^1.2.2",
"react-redux": "^4.0.6",
"react-router": "^2.0.0-rc5",
"react-router-redux": "^2.1.0",
"readable-stream": "^2.0.5",
"redux": "^3.0.5",
"redux-devtools": "^3.0.1",
"redux-router": "^1.0.0-beta7",
@@ -72,6 +75,7 @@
"source-map-support": "^0.4.0",
"standard": "^5.4.1",
"trace": "^2.0.2",
"vinyl": "^1.1.1",
"watchify": "^3.7.0",
"xo-lib": "^0.8.0-1"
},

View File

@@ -1,13 +1,9 @@
import React from 'react'
import { Provider } from 'react-redux'
import { render } from 'react-dom'
import store from './store'
import XoApp from './xo-app'
render(
<Provider store={ store }>
<XoApp />
</Provider>,
<XoApp/>,
document.getElementsByTagName('xo-app')[0]
)

View File

@@ -1,46 +1,89 @@
import Xo from 'xo-lib'
import { createBackoff } from 'jsonrpc-websocket-client'
/*import Xo from 'xo-lib'
import { createBackoff } from 'jsonrpc-websocket-client'*/
// ===================================================================
/* action type */
export const VM_CREATE = 'VM_CREATE' // create a VM in store
export const VM_EDIT = 'VM_EDIT' // edit a vm in store
export const VM_SAVE = 'VM_SAVE' // want to save a vm from store to server
export const VM_SAVED = 'VM_SAVED' // VM is saved on server
const createAction = (() => {
const { defineProperty } = Object
const identity = payload => payload
export const SIGN_IN = 'SIGN_IN' // ask to signin
export const SIGNED_IN = 'SIGNED_IN' // is signed in
export const SIGN_OUT = 'SIGN_OUT' // want to sign out
export const SIGNED_OUT = 'SIGNED_OUT' // signed out
export const SESSION_PATCH = 'SESSION_PATCH' // signed out
return (type, payloadCreator = identity) => defineProperty(
(...args) => ({
type,
payload: payloadCreator(...args)
}),
'toString',
{ value: () => type }
)
})()
// ===================================================================
const xo = new Xo({
url: 'localhost:9000'
})
{
const connect = () => {
xo.open(createBackoff()).catch(error => {
console.error('failed to connect to xo-server', error)
})
/* action creator
* they HAVE TO return an action with the mandatory field type, and an optiona payload
* they MAY dispatch ( emit ) other actions, async or not
* action will be used by reducers
*/
export function patchSession (patch) {
return {
type: SESSION_PATCH,
payload: patch
}
xo.on('scheduledAttempt', ({ delay }) => {
console.log('next attempt in %s ms', delay)
})
}
export function signIn () {
// using redux thunk https://github.com/gaearon/redux-thunk
// instead of returning one promise, it can dispatch multiple events
// that way it become trivial to inform user of progress
console.log(' will signin')
return dispatch => {
setTimeout(() => {
// Yay! Can invoke sync or async actions with `dispatch`
dispatch(signedIn({userId: Math.floor(Math.random() * 1000)}))
}, 2500)
xo.on('closed', connect)
connect()
dispatch({type: 'SIGN_IN'}) // immediatly inform the sore that we'll try to signin
}
}
export const updateStatus = createAction('UPDATE_STATUS')
export const signIn = createAction('SIGN_IN', async credentials => {
await xo.signIn(credentials)
})
export const signOut = createAction('SIGN_OUT')
// you can also directly return an action
export function signedIn (payload) {
return {
type: 'SIGNED_IN',
payload
}
}
export const increment = createAction('INCREMENT')
export const decrement = createAction('DECREMENT')
export function VMCreate (payload) {
return {
type: VM_CREATE,
payload
}
}
export function VMEdit( payload){
// should check if there s a vm id ?
return {
type: VM_EDIT,
payload
}
}
export function VMSave (vmId) {
//should call xoApi and save to server
return dispatch => {
setTimeout(function(){
console.log('really save')
// Yay! Can invoke sync or async actions with `dispatch`
dispatch(VMSaved({id: Math.floor(Math.random() * 1000)}))
}, 1000)
dispatch({
type: VM_SAVE,
payload: {id: vmId}
}) // immediatly inform the sore that we'll try to save
}
}
export function VMSaved (vm) {
//xoAPi is happy, let's tell everyone this vm is save
return {
type: VM_SAVED,
payload: vm
}
}

View File

@@ -1,21 +1,25 @@
import reduxPromise from 'redux-promise'
// import reduxThunk from 'redux-thunk'
import { createHashHistory } from 'history'
import {
applyMiddleware,
combineReducers,
compose,
createStore
} from 'redux'
import {
reduxReactRouter,
routerStateReducer
} from 'redux-router'
import * as reducers from './reducers'
import reducer from './reducers'
import thunk from 'redux-thunk'
import initialState from './initialStoreState'
let createStoreWithMiddleware = applyMiddleware(thunk)(createStore);
const store = createStoreWithMiddleware(reducer)
export default store
// ===================================================================
/*
export default compose(
applyMiddleware(reduxPromise),
// applyMiddleware(reduxThunk),
@@ -28,3 +32,4 @@ export default compose(
}))
export * as actions from './actions'
*/

View File

@@ -0,0 +1,11 @@
export default {
xoApi: {
}, // somehting like xoApi.all
components: {}, // UI state for each widget /components
session: {
isLoggingIn: false,
isLoggingOut: false,
isLoggued: false
} // session specific information
}

View File

@@ -1,58 +1,110 @@
import * as actions from './actions'
import { combineReducers } from 'redux'
// ===================================================================
import initialState from './initialStoreState'
/* Reducers are synchronous and repeatable : it's not the place to put api call
*/
// import { combineReducers } from 'redux'
function sessionReducer (currentSession = initialState.session, action) {
switch (action.type) {
case actions.SESSION_PATCH :
let payload = action.payload
// don't change loggued state from this action
delete payload.isLoggingIn
delete payload.isLoggingOut
delete payload.isLoggued
return Object.assign(
{},
currentSession,
payload // login , password
)
case actions.SIGN_IN :
// user tried to sign in
return Object.assign(
{},
currentSession,
action.payload, // login , password
{isLoggingIn: true}
)
const createAsyncHandler = ({ error, next }) => (state, action) => {
if (action.error) {
if (error) {
return error(state, action.payload)
}
} else {
if (next) {
return next(state, action.payload)
}
case actions.SIGN_OUT :
return Object.assign({}, currentSession, {isLoggingOut: true})
case actions.SIGNED_OUT:
// when user is signed out : reset session
return initialState.session
case actions.SIGNED_IN :
// when user is signed in : session informations are received from server
return Object.assign(
{},
currentSession,
action.payload,
{
isLoggued: true,
isLoggingIn: false,
isLoggingOut: false,
password: 'redacted , don t need it '
}
)
default :
return currentSession // reducer HAVE TO return a state, even when they did nothing
}
}
function xoApiReducer (state = initialState.xoApi, action) {
let payload = action.payload || {}
switch (action.type){
case actions.VM_EDIT:
payload = Object.assign({},
state[action.payload.id],
payload,
{isSaved: false}
)
return Object.assign(
{},
state,
{
[action.payload.id]: action.payload
}
)
case actions.VM_SAVE:
console.log(' SAVE ',action.payload)
return Object.assign(
{},
state,
{
[action.payload.id]: {isSaving:true}
}
)
return state
case actions.VM_SAVED:
// put the VM at its real id if it's a creation
payload = Object.assign({},
state[action.payload.id],
payload,
{isSaved: true, isSaving: false}
)
console.log('VM_SAVED',payload)
return Object.assign(
{},
state,
{
[action.payload.id]: payload
}
)
}
return state
}
// Action handlers are reducers but bound to a specific action.
const combineActionHandlers = (initialState, handlers) => {
for (const action in handlers) {
const handler = handlers[action]
if (typeof handler === 'object') {
handlers[action] = createAsyncHandler(handler)
}
}
return (state = initialState, action) => {
const handler = handlers[action.type]
return handler
? handler(state, action)
: state
}
function componentsReducer (state = initialState.components, action) {
return state
}
// ===================================================================
export const counter = combineActionHandlers(0, {
[actions.decrement]: counter => counter - 1,
[actions.increment]: counter => counter + 1
})
export const user = combineActionHandlers(null, {
[actions.signIn]: {
next: user => {
console.log(String(actions.signIn), user)
return user
}
},
[actions.signOut]: () => null
})
export const status = combineActionHandlers('disconnected', {
[actions.updateStatus]: status => status
export default combineReducers({
session: sessionReducer,
components: componentsReducer,
xoApi: xoApiReducer
})

View File

@@ -1,54 +1,109 @@
import React, { Component, PropTypes } from 'react'
import { connect } from 'react-redux'
import { Link, Route, IndexLink, IndexRoute } from 'react-router'
import { ReduxRouter } from 'redux-router'
import { connect, Provider } from 'react-redux'
import store from '../store'
import LoginForm from './login/loginForm.js'
import VMForm from './vm/form.js'
import { Router, Route, Link } from 'react-router'
// from https://github.com/rackt/react-router/blob/master/upgrade-guides/v2.0.0.md#no-default-history
import { hashHistory } from 'react-router'
import About from './about'
import Home from './home'
import { actions } from '../store'
let XoApp = class extends Component {
static propTypes = {
children: PropTypes.node,
counter: PropTypes.number
};
const Dashboard = React.createClass({
render() {
return <div>Welcome to the app!</div>
}
})
render () {
const App = React.createClass({
render() {
return (
<div>
<ul>
<li><Link to='/about'>About</Link></li>
<li><IndexLink to='/'>Home</IndexLink></li>
<li><button onClick={ () => this.props.signIn({
email: 'admin@admin.net',
password: 'admin'
}) }>Sign in</button></li>
<li><button onClick={ () => this.props.signOut() }>Sign out</button></li>
<li><button onClick={ this.props.increment }>Increment</button></li>
<li><button onClick={ this.props.decrement }>Decrement</button></li>
<li><Link to="/vm/create">createvm</Link></li>
<li><Link to="/inbox">Inbox</Link></li>
</ul>
{this.props.children}
</div>
)
}
})
<p>{ this.props.user }</p>
<p>{ this.props.counter }</p>
{ this.props.children }
const Inbox = React.createClass({
render() {
return (
<div>
<h2>Inbox</h2>
{this.props.children || "Welcome to your Inbox"}
</div>
)
}
})
const Message = React.createClass({
render() {
return <h3>Message {this.props.params.id}</h3>
}
})
class XoAppUnconnected extends Component {
render () {
const {isLoggued, login, password, userId} = this.props
return (
<div>
<h2> Xen Orchestra {login} {isLoggued ? 'connecté' : ' non connecté'}</h2>
{!isLoggued &&
<LoginForm />
}
{isLoggued &&
<Router history={hashHistory}>
<Route path="/" component={App}>
<Route path="vm/:vmId" component={ VMForm } />
<Route path="inbox" component={Inbox}>
<Route path="messages/:id" component={Message} />
</Route>
</Route>
</Router>
}
</div>
)
}
}
const XoApp = connect(state => state.session)(XoAppUnconnected)
/*
* Provider allow the compontn directly bellow XoApp to have acces to the store
* and to the dispatch method
*/
export default () =>
<Provider store={ store }>
<XoApp/>
</Provider>
/*
import About from './about'
import Home from './home'
import CreateVm from './create-vm'
//import CreatVM from './create-vm'
class XoApp extends Component {
static propTypes = {
children: PropTypes.node
};
render () {
const pick = propNames => object => {
const props = {}
for (const name of propNames) {
props[name] = object[name]
}
return props
_do (action) {
return () => this.props.dispatch(action)
}
}
XoApp = connect(pick([
'counter',
'user'
]), actions)(XoApp)
XoApp = connect(state => state)(XoApp)
export default () => <div>
<h1>Xen Orchestra</h1>
@@ -57,6 +112,7 @@ export default () => <div>
<Route path='/' component={ XoApp }>
<IndexRoute component={ Home } />
<Route path='/about' component={ About } />
<Route path='/create-vm' component={ CreateVm } />
</Route>
</ReduxRouter>
</div>
</div>*/

View File

@@ -0,0 +1,61 @@
import React, { Component, PropTypes } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import {signIn, signedIn, patchSession } from '../../store/actions'
class LoginForm extends Component {
handleSigninIn (e) {
const { actions } = this.props
e.preventDefault()
actions.signIn()
}
updateLogin (event) {
this.props.actions.patchSession({login: event.target.value})
}
updatePassword (event) {
this.props.actions.patchSession({password: event.target.value})
}
render () {
/*remeber : this.propos.session is from the connect call*/
const { login, password, isLoggingIn } = this.props.session
return (
<div>
<form onSubmit={(e) => this.handleSigninIn(e)}>
<input type='text' name='login' value={login} onChange={(e) => this.updateLogin(e)/* autobinding is only in ES5*/}/>
<input type='password' name='password' value={password} onChange={(e) => this.updatePassword(e)}/>
{isLoggingIn &&
<p>signing in , waiting for server</p>
}
{!isLoggingIn &&
<input type='submit' value='Sign in'/>
}
</form>
</div>
)
}
}
/* Which part of the global app state this component can see ?
* make it as small as possible to reduce the rerender */
export default connect(
(state) => {
return {session: state.session}
},
/* Transmit action and actions creators
* It can be usefull to transmit only a few selected actions.
* Also bind them to dispatch , so the component can call action creator directly,
* without having to manually wrap each
*/
(dispatch) => {
return {
actions: bindActionCreators({
signIn, patchSession, signedIn
},
dispatch) }
}
)(LoginForm)

100
src/xo-app/vm/form.js Normal file
View File

@@ -0,0 +1,100 @@
import React, { Component, PropTypes } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import { VMEdit, VMSave } from '../../store/actions'
// should move to glbal style
const fieldsetStyle = {
'display': 'flex',
'flexDirection': 'row'
}
const inputContainerStyle = {
'flex': 1
}
const legendStyle = {
'width': '150px'
}
/* I don't use fieldset / legend here since they don't
* really like being styled with flex
*/
class VmForm extends Component {
constructor (props) {
super(props)
this.state = {
vmName: '',
vmTemplateId: 2
}
}
saveAuthorized () {
return !!this.state.vmName && !!this.state.vmTemplateId
}
save (e){
e.preventDefault()
this.props.actions.VMSave(this.props.routeParams.vmId)
}
patch (field,value){
this.props.actions.VMEdit({
id:this.props.routeParams.vmId,
[field]:value
})
}
render () {
const s = this.state
var saveable = this.saveAuthorized()
const {name,templateId, isSaving, isSaved } = this.props.vm || {}
return (
<form onSubmit={(e)=>this.save(e)}>
<h2>Vm name : {name}</h2>
<div style={fieldsetStyle}>
<div style={legendStyle}>
</div>
<div style={inputContainerStyle}>
<label htmlFor='vm-edit-name'>Name </label>
<input id ='vm-edit-name' type='text' value={name} onChange={(e)=>this.patch("name",e.target.value)}/>
</div>
</div>
{!isSaving && !isSaved &&
<button type='submit'>Save</button>
}
{isSaving &&
<p>Saving to server</p>
}
{!isSaving && isSaved &&
<p>Not modified</p>
}
</form>
)
}
}
/* Which part of the global app state this component can see ?
* make it as small as possible to reduce the rerender */
//ownprop is the prop given to the component
function mapStateToProps(state, ownProps) {
console.log(ownProps.routeParams.vmId);
return {
vm :state.xoApi[ownProps.routeParams.vmId]
}
}
export default connect(
mapStateToProps,
/* Transmit action and actions creators
* It can be usefull to transmit only a few selected actions.
* Also bind them to dispatch , so the component can call action creator directly,
* without having to manually wrap each
*/
(dispatch) => {
return {
actions: bindActionCreators({
VMEdit, VMSave
},
dispatch) }
}
)(VmForm)