Bladeren bron

[ADD] support bulk operations, spinner and notification system

Gogs 7 jaren geleden
bovenliggende
commit
d18c78f758

+ 63 - 6
src/actions/index.js

@@ -1,13 +1,71 @@
 import axios from 'axios'
 import { API_URL } from '../constants/ResourceNames'
-import { REQUEST_OK, REQUEST_KO } from '../constants/ActionTypes'
+import { 
+    REQUEST_START,
+    REQUEST_OK, 
+    REQUEST_KO, 
+    SHOW_SPINNER, 
+    HIDE_SPINNER, 
+    SHOW_NOTIFICATION, 
+    HIDE_NOTIFICATION 
+} from '../constants/ActionTypes'
+
+/**
+ * 
+ * @param {*} message 
+ */
+export const spinner = (show, message) => dispatch => {
+    dispatch({
+        type: show ? SHOW_SPINNER : HIDE_SPINNER,
+        payload: message
+    })
+}
+
+/**
+ * 
+ * @param {*} text 
+ */
+export const notify = message => dispatch  => {
+    dispatch({
+        type: message ? SHOW_NOTIFICATION : HIDE_NOTIFICATION,
+        payload: message
+    })
+}
 
 /**
  * 
  * @param {*} resource 
  */
-export const get = (resource) => dispatch => {
-    return axios.get(`${API_URL}${resource}`).then(response => dispatch(ok(resource, response.data))).catch(error => dispatch(ko(resource, error)));
+export const get = resource => async dispatch => {
+    dispatch({
+        type: REQUEST_START
+    })
+
+    try {
+        const response = await axios.get(`${API_URL}${resource}`)
+        dispatch(ok(response.data))
+    } catch (error) {
+        dispatch(ko(resource, error))
+    }
+}
+
+/**
+ * 
+ * @param {*} resource 
+ */
+export const post = resources => async dispatch => {
+    for (let r of resources) {
+        dispatch({
+            type: REQUEST_START
+        })
+
+        try {
+            const response = await axios.post(`${API_URL}${r}`)
+            dispatch(ok(response.data))
+        } catch (error) {
+            dispatch(ko(r, error))
+        }
+    }
 }
 
 /**
@@ -15,10 +73,9 @@ export const get = (resource) => dispatch => {
  * @param {*} resource 
  * @param {*} payload 
  */
-export const ok = (resource, payload) => {
+const ok = payload => {
     return {
         type: REQUEST_OK,
-        resource,
         payload
     }
 }
@@ -28,7 +85,7 @@ export const ok = (resource, payload) => {
  * @param {*} resource 
  * @param {*} payload 
  */
-export const ko = (resource, payload) => {
+const ko = (resource, payload) => {
     return {
         type: REQUEST_KO,
         resource,

+ 47 - 3
src/components/common/Base.js

@@ -1,9 +1,13 @@
 import React, { Component } from 'react'
+import { connect } from 'react-redux'
 import { Helmet } from 'react-helmet'
 import Topbar from './Topbar'
 import Sidebar from './Sidebar'
 import Typography from 'material-ui/Typography'
+import Snackbar from 'material-ui/Snackbar'
+import Spinner from './Spinner'
 import { withStyles } from 'material-ui/styles'
+import { notify } from '../../actions'
 
 const styles = theme => ({
     root: {
@@ -27,7 +31,7 @@ const styles = theme => ({
 
 class Base extends Component {
     render() {
-        const { classes } = this.props
+        const { classes, app, onHideNotification } = this.props
 
         return (
             <div className={classes.root}>
@@ -38,12 +42,52 @@ class Base extends Component {
                 <Sidebar />
                 <main className={classes.content}>
                     <div className={classes.toolbar}></div>
-                    <Typography variant='headline' color='primary' className={classes.header}>{this.props.title}</Typography>
+                    <Typography 
+                        variant='headline' 
+                        color='primary' 
+                        className={classes.header} 
+                        children={this.props.title} 
+                    />
                     {this.props.children}
                 </main>
+                <Snackbar 
+                    open={app.notification.isOpen} 
+                    autoHideDuration={2000} 
+                    onClose={(e) => onHideNotification(e)} 
+                    message={app.notification.message} 
+                />
+                <Spinner 
+                    open={app.spinner.isOpen}
+                    message={app.spinner.message || 'Cargando, espere...'}
+                />
             </div>
         )
     }
 }
 
-export default withStyles(styles)(Base)
+/**
+ * 
+ * @param {*} state 
+ * @param {*} props 
+ */
+const mapStateToProps = (state, props) => {
+    return {
+        app: state.app
+    }
+}
+
+/**
+ * 
+ * @param {*} state 
+ * @param {*} props 
+ */
+const mapDispatchToProps = (dispatch, props) => ({
+    /**
+     * 
+     */
+    onHideNotification() {
+        dispatch(notify(null))
+    }
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(Base))

+ 0 - 11
src/components/common/Loader.js

@@ -1,11 +0,0 @@
-import React, { Component } from 'react'
-
-class Loader extends Component {
-    render() {
-        return (
-            <div></div>
-        )
-    }
-}
-
-export default Loader

+ 46 - 0
src/components/common/Spinner.js

@@ -0,0 +1,46 @@
+import React, { Component } from 'react'
+import { CircularProgress } from 'material-ui/Progress'
+import Typography from 'material-ui/Typography'
+import { withStyles } from 'material-ui/styles'
+
+const styles = theme => ({
+    root: {
+        justifyContent: 'center',
+        alignItems: 'center',
+        flexDirection: 'column',
+        position: 'absolute',
+        width: '100%',
+        height: '100%',
+        background: '#fff',
+        opacity: 0.7,
+        zIndex: 9999,
+        userSelect: 'none'
+    },
+    spinnerText: {
+        marginTop: '1.735em'
+    }
+})
+
+class Spinner extends Component {
+    
+    /**
+     * 
+     */
+    render() {
+        const { classes } = this.props
+
+        return (
+            <div className={classes.root} style={this.props.open ? {display: 'flex'} : {display: 'none'}}>
+                <CircularProgress />
+                <Typography 
+                    variant='subheading' 
+                    color='primary' 
+                    className={classes.spinnerText} 
+                    children={this.props.message} 
+                />
+            </div>
+        )
+    }
+}
+
+export default withStyles(styles)(Spinner)

+ 13 - 1
src/components/common/Topbar.js

@@ -25,8 +25,20 @@ const styles = theme => ({
 })
 
 class Topbar extends Component {
+    constructor(props) {
+        super(props)
+
+        this.state = {
+            menuOpened: false
+        }
+    }
+
+    /**
+     * 
+     */
     render() {
         const { classes } = this.props
+        const { menuOpened } = this.state
 
         return (
             <AppBar position="absolute" className={classes.root}>
@@ -39,7 +51,7 @@ class Topbar extends Component {
                             <UserIcon />
                         </Avatar>
                         <Button color="inherit">Anónimo</Button>
-                        <Menu>
+                        <Menu open={menuOpened}>
                             <MenuItem>Mi Perfil</MenuItem>
                             <MenuItem>Salir</MenuItem>
                         </Menu>

+ 57 - 6
src/components/pages/ContainersList.js

@@ -2,14 +2,29 @@ import React, { Component } from 'react'
 import { connect } from 'react-redux'
 import Base from '../common/Base'
 import ContainersTable from '../tables/ContainersTable'
-import { get } from '../../actions'
-import { CONTAINERS } from '../../constants/ResourceNames'
+import { get, post, notify } from '../../actions'
+import { DOCKER } from '../../constants/ResourceNames'
+import { forEach, concat } from 'lodash'
 
 class ContainerList extends Component {
+
+    /**
+     * 
+     */
     componentDidMount() {
         this.props.loadData()
     }
 
+    /**
+     * 
+     */
+    onAction(e, action, ids) {
+        this.props.sendData(action, ids)
+    }
+
+    /**
+     * 
+     */
     render() {
         const columns = [
             {
@@ -21,14 +36,26 @@ class ContainerList extends Component {
                 key: 'name'
             },
             {
-                title: 'Status',
-                key: 'status'
+                title: 'Estado',
+                key: 'status',
+                options: {
+                    exited: {
+                        text: 'Offline'
+                    },
+                    running: {
+                        text: 'Online'
+                    }
+                }
             }
         ]
 
         return (
             <Base title={this.props.title}>
-                <ContainersTable columns={columns} rows={this.props.data} />
+                <ContainersTable 
+                    columns={columns} 
+                    rows={this.props.data} 
+                    onAction={(e, action, ids) => this.onAction(e, action, ids)}
+                    notify={(message) => this.props.notify(message)} />
             </Base>
         )
     }
@@ -51,8 +78,32 @@ const mapStateToProps = (state, props) => {
  * @param {*} props 
  */
 const mapDispatchToProps = (dispatch, props) => ({
+    /**
+     * 
+     */
     loadData() {
-        dispatch(get(CONTAINERS))
+        dispatch(get(`${DOCKER}container/all/`))
+    },
+    /**
+     * 
+     * @param {*} action 
+     * @param {*} ids 
+     */
+    sendData(action, ids) {
+        let urls = []
+
+        forEach(ids, id => {
+            urls = concat(urls, `${DOCKER}container/${action}/${id}/`)
+        })
+
+        dispatch(post(urls))
+    },
+    /**
+     * 
+     * @param {*} text 
+     */
+    notify(message) {
+        dispatch(notify(message))
     }
 })
 

+ 41 - 17
src/components/tables/ContainersTable.js

@@ -4,7 +4,7 @@ import Paper from 'material-ui/Paper'
 import Button from 'material-ui/Button'
 import Table, { TableHead, TableBody, TableRow, TableCell} from 'material-ui/Table'
 import Checkbox from 'material-ui/Checkbox'
-import { difference } from 'lodash'
+import { indexOf, concat, difference, slice, isEqual, size, map, pick, get, has } from 'lodash';
 
 const styles = theme => ({
     root: {
@@ -34,20 +34,47 @@ class ContainersTable extends Component {
 
     /**
      * 
+     * @param {*} e 
      */
-    selectAll(e) {
-        console.log(e)
+    onAction(e, action) {
+        if (isEqual(size(this.state.selectedRows), 0) && has(this.props, 'notify')) {
+            this.props.notify('No se ha seleccionado ningún elemento')
+            return;
+        }
+
+        this.props.onAction(e, action, this.state.selectedRows);
+        this.clearSelection()
+    }
+
+    /**
+     * 
+     */
+    onSelectAll = (e) => {
+        this.setState({
+            selectedRows: isEqual(size(this.state.selectedRows), size(this.props.rows)) ? [] : map(this.props.rows, item => {
+                return get(pick(item, 'id'), 'id')
+            })
+        })        
     }
 
     /**
      * 
      */
-    selectRow(e, id) {
+    onSelect(e, id) {
+        let index = indexOf(this.state.selectedRows, id)
+
         this.setState({
-            selectedRows: difference([id], this.state.selectedRows)
+            selectedRows: index === -1 ? concat(this.state.selectedRows, id) : difference(this.state.selectedRows, slice(this.state.selectedRows, index, index + 1))
         })
+    }
 
-        console.log(this.state.selectedRows)
+    /**
+     * 
+     */
+    clearSelection() {
+        this.setState({
+            selectedRows: []
+        })
     }
 
     /**
@@ -55,32 +82,29 @@ class ContainersTable extends Component {
      */
     render() {
         const { classes, columns, rows } = this.props
+        const { selectedRows } = this.state
 
         return (
             <div>
-                <Button variant='raised' color='primary' size='small' className={classes.buttons}>Arrancar</Button>
-                <Button variant='raised' color='primary' size='small' className={classes.buttons}>Parar</Button>
-                <Button variant='raised' color='primary' size='small' className={classes.buttons}>Reiniciar</Button>
+                <Button variant='raised' color='primary' size='small' className={classes.buttons} onClick={(e) => this.onAction(e, 'start')}>Arrancar</Button>
+                <Button variant='raised' color='primary' size='small' className={classes.buttons} onClick={(e) => this.onAction(e, 'stop')}>Parar</Button>
+                <Button variant='raised' color='primary' size='small' className={classes.buttons} onClick={(e) => this.onAction(e, 'restart')}>Reiniciar</Button>
                 <Paper className={classes.root}>
                     <Table>
                         <TableHead>
                             <TableRow>
-                                <TableCell>
-                                    { <Checkbox color='primary' onClick={(e) => this.selectAll(e)} /> }
-                                </TableCell>
+                                <TableCell children={<Checkbox color='primary' onClick={(e) => this.onSelectAll(e)} checked={isEqual(size(selectedRows), size(this.props.rows))} />} />
                                 {columns.map(column => 
-                                    <TableCell>{column.title}</TableCell>
+                                    <TableCell key={column.key} children={column.title} />
                                 )}
                             </TableRow>
                         </TableHead>
                         <TableBody>
                             {rows.map(item => 
                                 <TableRow key={item.id} className={classes.tableRow}>
-                                    <TableCell>
-                                        <Checkbox color='primary' onClick={(e) => this.selectRow(e, item.id)} />
-                                    </TableCell>
+                                    <TableCell children={<Checkbox color='primary' onClick={(e) => this.onSelect(e, item.id)} checked={!isEqual(indexOf(selectedRows, item.id), -1)} />} />
                                     {columns.map(column => 
-                                        <TableCell>{item[column.key]}</TableCell>
+                                        <TableCell key={column.key} children={!column.options ? item[column.key] : column.options[item[column.key]].text} />
                                     )}
                                 </TableRow>
                             )}

+ 20 - 10
src/constants/ActionTypes.js

@@ -1,24 +1,34 @@
 /**
- * Request get signal
+ *
  */
-export const REQUEST_GET = 'request_get'
+export const REQUEST_START = 'request_start'
 
 /**
- * Request post signal
+ *
  */
-export const REQUEST_POST = 'request_post'
+export const REQUEST_OK = 'request_ok'
 
 /**
- * Request wait signal
+ *
  */
-export const REQUEST_WAIT = 'request_wait'
+export const REQUEST_KO = 'request_ko'
 
 /**
- * Request response ok signal
+ * 
  */
-export const REQUEST_OK = 'request_ok'
+export const SHOW_SPINNER = 'show_spinner'
 
 /**
- * Request error signal
+ * 
  */
-export const REQUEST_KO = 'request_ko'
+export const HIDE_SPINNER = 'hide_spinner'
+
+/**
+ * 
+ */
+export const SHOW_NOTIFICATION = 'show_notification'
+
+/**
+ * 
+ */
+export const HIDE_NOTIFICATION = 'hide_notification'

+ 1 - 1
src/constants/ResourceNames.js

@@ -6,4 +6,4 @@ export const API_URL = 'http://localhost:8000/api/v1/'
 /** 
  * 
  */
-export const CONTAINERS = 'docker/container/all/'
+export const DOCKER = 'docker/'

+ 46 - 19
src/reducers/app.js

@@ -1,8 +1,23 @@
-import { REQUEST_KO, REQUEST_GET, REQUEST_WAIT } from "../constants/ActionTypes";
+import { isString } from 'lodash'
+import {
+    REQUEST_START,
+    REQUEST_KO, 
+    SHOW_SPINNER,
+    HIDE_SPINNER,
+    SHOW_NOTIFICATION, 
+    HIDE_NOTIFICATION, 
+    REQUEST_OK
+} from '../constants/ActionTypes';
 
 const initialState = {
-    status: 0,
-    message: null
+    spinner: {
+        isOpen: false,
+        message: null,
+    },
+    notification: {
+        isOpen: false,
+        message: null
+    }
 }
 
 /**
@@ -11,31 +26,43 @@ const initialState = {
  * @param {*} action 
  */
 export const app = (state = initialState, action) => {
-    if (action.type === REQUEST_GET) {
-        return {
-            status: 0,
-            message: action.payload
+    if (action.type === SHOW_SPINNER || action.type === REQUEST_START) {
+        state = {
+            ...state,
+            spinner: {
+                isOpen: true,
+                message: action.payload
+            }
         }
     }
 
-    if (action.type === REQUEST_GET) {
-        return {
-            status: 1,
-            message: action.payload
+    if (action.type === HIDE_SPINNER || action.type === REQUEST_OK) {
+        state = {
+            ...state,
+            spinner: {
+                isOpen: false,
+                message: isString(action.payload) ? action.payload : null
+            }
         }
     }
 
-    if (action.type === REQUEST_WAIT) {
-        return {
-            status: 2,
-            message: action.payload
+    if (action.type === SHOW_NOTIFICATION || action.type === REQUEST_KO) {
+        state = {
+            ...state,
+            notification: {
+                isOpen: true,
+                message: isString(action.payload) ? action.payload : null
+            }
         }
     }
 
-    if (action.type === REQUEST_KO) {
-        return {
-            status: 3,
-            message: action.payload
+    if (action.type === HIDE_NOTIFICATION) {
+        state = {
+            ...state,
+            notification: {
+                isOpen: false,
+                message: null
+            }
         }
     }
 

+ 10 - 5
src/reducers/containers.js

@@ -1,6 +1,5 @@
-import { CONTAINERS } from '../constants/ResourceNames'
 import { REQUEST_OK } from '../constants/ActionTypes'
-
+import { has, map, isEqual } from 'lodash'
 
 /**
  * 
@@ -8,11 +7,17 @@ import { REQUEST_OK } from '../constants/ActionTypes'
  * @param {*} action 
  */
 export const containers = (state = [], action) => {
-    if (action.resource !== CONTAINERS) {
-        return state
+    if (action.type !== REQUEST_OK) {
+        return state;
+    }
+
+    if (has(action.payload, 'container')) {
+        return map(state, c => {
+            return isEqual(c.id, action.payload.container.id) ? action.payload.container : c
+        })
     }
 
-    if (action.type === REQUEST_OK) {
+    if (has(action.payload, 'containers')) {
         return action.payload.containers
     }