From 7d880553997868171c6ce5918ea49c795c70682a Mon Sep 17 00:00:00 2001
From: Roosted7 <thomasroos@live.nl>
Date: Tue, 20 Mar 2018 23:11:29 +0100
Subject: [PATCH] Totally reworked exam handeling, internally and visually

---
 client/components/DropzoneContent.jsx |  18 ++
 client/components/NavBar.jsx          | 128 ++++++++-----
 client/index.jsx                      |  58 +++++-
 client/views/AddExam.jsx              |  61 ++++++
 client/views/Exam.jsx                 | 173 +++++++++++++++++
 client/views/Exams.jsx                | 259 --------------------------
 webpack.common.js                     |   3 +-
 7 files changed, 391 insertions(+), 309 deletions(-)
 create mode 100644 client/components/DropzoneContent.jsx
 create mode 100644 client/views/AddExam.jsx
 create mode 100644 client/views/Exam.jsx
 delete mode 100644 client/views/Exams.jsx

diff --git a/client/components/DropzoneContent.jsx b/client/components/DropzoneContent.jsx
new file mode 100644
index 000000000..a1938ee3b
--- /dev/null
+++ b/client/components/DropzoneContent.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+
+const DropzoneContent = () => (
+    <div className="file has-name is-boxed is-centered">
+        <label className="file-label">
+            <span className="file-cta">
+                <span className="file-icon">
+                    <i className="fa fa-upload"></i>
+                </span>
+                <span className="file-label">
+                    Choose a file…
+        </span>
+            </span>
+        </label>
+    </div>
+)
+
+export default DropzoneContent;
\ No newline at end of file
diff --git a/client/components/NavBar.jsx b/client/components/NavBar.jsx
index 6db978a43..52f718f5b 100644
--- a/client/components/NavBar.jsx
+++ b/client/components/NavBar.jsx
@@ -1,49 +1,91 @@
 import React from 'react';
 import { Link } from 'react-router-dom';
 
-const NavBar = () => {
-  return (
-        <nav className="navbar" role="navigation" aria-label="dropdown navigation">
-          
-          <div className="navbar-brand">
-            <div className="navbar-item has-text-info">
-              <span className="icon">
-                <i className="fa fa-edit fa-3x"></i>
-              </span>
-            </div>
-            <div className="navbar-item has-text-info">
-              <b>Zesje</b>
-            </div>
-
-            <button className="button navbar-burger" onClick={() => { 
-                let menu = document.querySelector(".navbar-menu");
-                menu.classList.toggle("is-active"); 
-              }}>
-              <span></span>
-              <span></span>
-              <span></span>
-            </button>
-          </div>
-
-          <div className="navbar-menu">
-            <div className="navbar-start">
-              <Link className="navbar-item" to='/'>Home</Link>
-              <Link className="navbar-item" to='/exams'>Exams</Link>
-              <Link className="navbar-item" to='/students'>Students</Link>
-              <Link className="navbar-item" to='/grade'><strong><i>Grade</i></strong></Link>  
-              <Link className="navbar-item" to='/statistics'>Statistics</Link>  
-            </div>
-
-            <div className="navbar-end">
-              <Link className="navbar-item" to='/graders'>Manage graders</Link>
-              <Link className="navbar-item has-text-info" to='/reset'>reset</Link>
-              <div className="navbar-item">
-                <i>Version 0.6.4</i>
-              </div>
-            </div>
-          </div>
-        </nav>
-  )
+const BurgerButton = (props) => (
+    <button className={"button navbar-burger" + (props.foldOut ? " is-active" : "")}
+        onClick={props.burgerClick}>
+        <span></span>
+        <span></span>
+        <span></span>
+    </button>
+)
+
+const ExamDropdown = (props) => (
+    <div className="navbar-item has-dropdown is-hoverable">
+        <Link className="navbar-link" to='/exams'>{props.exam ? <i>{props.exam.name}</i> : "Add exam"} </Link>
+        <div className="navbar-dropdown">
+            {props.list.map((exam) => (
+                <Link className={"navbar-item" + (props.exam.id === exam.id ? " is-active" : "")}
+                    to={'/exams/' + exam.id} key={exam.id} >
+                    <i>{exam.name}</i>
+                </Link>
+            ))}
+            <hr className="navbar-divider" />
+            <Link className="navbar-item" to={'/exams'} >
+                Add new
+            </Link>
+        </div>
+    </div>
+)
+
+class NavBar extends React.Component {
+
+    state = {
+        foldOut: false
+    }
+
+    burgerClick = () => {
+        this.setState({
+            foldOut: !this.state.foldOut
+        })
+    }
+
+    render() {
+
+        const examStyle = this.props.exam !== null ? {} : { pointerEvents: 'none', opacity: .65 }
+
+        return (
+            <nav className="navbar" role="navigation" aria-label="dropdown navigation">
+
+                <div className="navbar-brand">
+                    <div className="navbar-item has-text-info">
+                        <span className="icon">
+                            <i className="fa fa-edit fa-3x"></i>
+                        </span>
+                    </div>
+
+                    <Link className="navbar-item has-text-info" to='/'><b>Zesje</b></Link>
+                    <div className="navbar-item"></div>
+
+                    <BurgerButton foldOut={this.props.foldOut} burgerClick={this.burgerClick} />
+                </div>
+
+                <div className={"navbar-menu" + (this.state.foldOut ? " is-active" : "")} >
+                    <div className="navbar-start">
+
+                        {this.props.exam ?
+                            <ExamDropdown exam={this.props.exam} list={this.props.list} />
+                            :
+                            <Link className="navbar-item" to='/exams'>Add exam</Link>
+                        }
+
+                        <Link className="navbar-item" to='/students'>Students</Link>
+                        <Link className="navbar-item" style={examStyle} to='/grade'><strong><i>Grade</i></strong></Link>
+                        <Link className="navbar-item" style={examStyle} to='/statistics'>Statistics</Link>
+                    </div>
+
+                    <div className="navbar-end">
+                        <Link className="navbar-item" to='/graders'>Manage graders</Link>
+                        <Link className="navbar-item has-text-info" to='/reset'>reset</Link>
+                        <div className="navbar-item">
+                            <i>Version 0.6.4</i>
+                        </div>
+                    </div>
+                </div>
+            </nav>
+        )
+    }
+
 }
 
 export default NavBar;
diff --git a/client/index.jsx b/client/index.jsx
index 4ed70ed3d..ab684bb94 100644
--- a/client/index.jsx
+++ b/client/index.jsx
@@ -5,19 +5,27 @@ import ReactDOM from 'react-dom';
 import Loadable from 'react-loadable';
 import {BrowserRouter as Router, Route, Switch} from 'react-router-dom';
 
+import * as api from './api.jsx'
+
 import NavBar from './components/NavBar.jsx';
 import Footer from './components/Footer.jsx';
 
 const Loading = () => <div>Loading...</div>;
+const NotFound = () => <div>404 OMG NO.</div>;
+const NoExams = () => <div>No exams found, please upload at least one and do not use this direct access :(</div>;
 
 const Home = Loadable({
   loader: () => import('./views/Home.jsx'),
   loading: Loading,
 });
-const Exams = Loadable({
-  loader: () => import('./views/Exams.jsx'),
+const AddExam = Loadable({
+  loader: () => import('./views/AddExam.jsx'),
   loading: Loading,
 });
+const Exam = Loadable({
+    loader: () => import('./views/Exam.jsx'),
+    loading: Loading,
+  });
 const Students = Loadable({
   loader: () => import('./views/Students.jsx'),
   loading: Loading,
@@ -42,20 +50,58 @@ const Reset = Loadable({
 
 class App extends React.Component {
 
+    state = {
+        examIndex: null,
+        examList: []
+    }
+
+    componentDidMount() {
+        api.get('exams')
+            .then(exams => {
+                if (exams.length) {
+                    this.setState({
+                        examIndex: exams.length - 1,
+                        examList: exams
+                    })
+                }
+            })
+            .catch(resp => {
+                alert('failed to get exams (see javascript console for details)')
+                console.error('failed to get exams:', resp)
+            })
+    }
+
+    changeExam = (examID) => {
+        const index = this.state.examList.findIndex(exam => exam.id === examID)
+        if (index === -1) {
+            alert('Wrong exam url entered');
+            return;
+        } else {
+            this.setState({
+                examIndex: index
+            })
+        }
+    }
+
     render() {
 
+        const exam = this.state.examIndex === null ? null : this.state.examList[this.state.examIndex];
+
         return (
             <Router>
                 <div>
-                    <NavBar />
+                    <NavBar exam={exam} list={this.state.examList} changeExam={this.changeExam} />
                     <Switch>
                         <Route exact path="/" component={Home} />
-                        <Route path="/exams" component={Exams} />
+                        <Route path="/exams/:examID" render={({match}) => 
+                            <Exam exam={exam} urlID={match.params.examID} changeExam={this.changeExam} />} />
+                        <Route path="/exams" component={AddExam}  />
                         <Route path="/students" component={Students} />
-                        <Route path="/grade" component={Grade} />
+                        <Route path="/grade" component={exam ? Grade : NoExams} />
+                        <Route path="/statistics" component={exam ? Statistics : NoExams} />
                         <Route path="/graders" component={Graders} />
                         <Route path="/reset" component={Reset} />
-                        <Route path="/statistics" component={Statistics} />
+                        <Route component={NotFound} />
                     </Switch>
                     <Footer />
                 </div>
diff --git a/client/views/AddExam.jsx b/client/views/AddExam.jsx
new file mode 100644
index 000000000..5d7b27f48
--- /dev/null
+++ b/client/views/AddExam.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import Dropzone from 'react-dropzone'
+
+import Hero from '../components/Hero.jsx';
+import DropzoneContent from '../components/DropzoneContent.jsx';
+
+import * as api from '../api.jsx'
+
+class Exams extends React.Component {
+
+    onDropYAML = (accepted, rejected) => {
+        if (rejected.length > 0) {
+            alert('Please upload a YAML..')
+            return
+        }
+        const data = new FormData()
+        data.append('yaml', accepted[0])
+        api.post('exams', data)
+            .then(exam => {
+                this.props.history.push('/exams/' + exam.id)
+            })
+            .catch(resp => {
+                alert('failed to upload yaml (see javascript console for details)')
+                console.error('failed to upload YAML:', resp)
+            })
+    }
+
+
+    render() {
+
+        return (
+            <div>
+
+                <Hero title='Exams' subtitle="Omnomnomnom PDF's!" />
+
+                <section className="section">
+
+                    <div className="container">
+
+                        <h3 className='title'>Upload new exam config</h3>
+                        <h5 className='subtitle'>then we know that to do with PDF's</h5>
+
+                        <Dropzone accept=".yml, text/yaml, text/x-yaml, application/yaml, application/x-yaml"
+                            style={{}} activeStyle={{ borderStyle: 'dashed', width: 'fit-content', margin: 'auto' }}
+                            onDrop={this.onDropYAML}
+                            disablePreview
+                            multiple={false}
+                        >
+                            <DropzoneContent />
+                        </Dropzone>
+
+                    </div>
+
+                </section>
+
+            </div >
+        )
+    }
+}
+
+export default Exams;
diff --git a/client/views/Exam.jsx b/client/views/Exam.jsx
new file mode 100644
index 000000000..eb2899092
--- /dev/null
+++ b/client/views/Exam.jsx
@@ -0,0 +1,173 @@
+import React from 'react';
+import Dropzone from 'react-dropzone'
+
+import Hero from '../components/Hero.jsx';
+import DropzoneContent from '../components/DropzoneContent.jsx';
+
+import * as api from '../api.jsx'
+
+const StatusPDF = (props) => {
+    let iconClass = "fa fa-";
+    switch (props.pdf.status) {
+        case "processing":
+            iconClass += "refresh fa-spin";
+            break;
+        case "success":
+            iconClass += "check";
+            break;
+        case "error":
+            iconClass += "times";
+            break;
+    }
+    return (
+        <div>
+            {props.pdf.name}&emsp;<i className={iconClass} />
+            <i>&nbsp;{props.pdf.message}</i>
+        </div>
+    )
+}
+
+class Exams extends React.Component {
+
+    state = {
+        yaml: "",
+        pdfs: []
+    };
+
+    loadExam = (id) => {
+        if (this.props.exam.id !== parseInt(id)) {
+            console.log('Changing exam id to ' + id)            
+            this.props.changeExam(parseInt(id));
+        }
+
+        api.get('exams/' + id)
+            .then(exam => {
+                this.setState({
+                    yaml: exam.yaml
+                })
+            })
+    }
+
+    putYaml = () => {
+        api.patch('exams/' + this.props.urlID, { yaml: this.state.yaml })
+            .then(() => alert('thank you for the update; it was delicious'))
+            .catch(resp => {
+                alert('failed to update the YAML (see javascript console)')
+                console.error('failed to update YAML', resp)
+            })
+    }
+
+    updateYaml = (event) => {
+        this.setState({
+            yaml: event.target.value
+        })
+    }
+
+    updatePDFs = () => {
+        api.get('pdfs/' + this.props.urlID)
+            .then(pdfs =>
+                this.setState({
+                        pdfs: pdfs
+                })
+            )
+    }
+
+    onDropPDF = (accepted, rejected) => {
+        if (rejected.length > 0) {
+            alert('Please upload a PDF..')
+            return
+        }
+        accepted.map(file => {
+            const data = new FormData()
+            data.append('pdf', file)
+            api.post('pdfs/' + this.props.urlID, data)
+                .then(() => {
+                    api.get('pdfs/' + this.props.urlID)
+                        .then(pdfs =>
+                            this.setState({
+                                pdfs: pdfs
+                            })
+                        )
+                })
+                .catch(resp => {
+                    alert('failed to upload pdf (see javascript console for details)')
+                    console.error('failed to upload PDF:', resp)
+                })
+        })
+    }
+
+    componentDidMount = () => {
+        this.loadExam(this.props.urlID);
+        this.pdfUpdater = setInterval(this.updatePDFs, 1000)        
+    }
+
+    componentWillReceiveProps = (newProps) => {
+        if (newProps.urlID !== this.props.urlID) {
+            console.log('received updated prop : ' + newProps.urlID)
+            this.loadExam(newProps.urlID)    
+        }
+    }
+
+    componentWillUnmount = () => {
+        clearInterval(this.pdfUpdater);
+    }
+
+    render() {
+
+        return <div>
+
+            <Hero title="Exam details" subtitle={"Selected: " + this.props.exam.name} />
+
+            <section className="section">
+
+                <div className="container">
+                    <div className="columns">
+
+
+                        <div className="column has-text-centered">
+                            <h3 className='title'>Tweak the config</h3>
+                            <h5 className='subtitle'>to fix possible misalignments</h5>
+                            <textarea className="textarea" rows="10"
+                                value={this.state.yaml} onChange={this.updateYaml} />
+                            <button className='button is-success'
+                                onClick={this.putYaml}>
+                                Save
+                            </button>
+                        </div>
+
+
+                        <div className="column has-text-centered">
+                            <h3 className='title'>And upload PDF's</h3>
+                            <h5 className='subtitle'>we will work some magic!</h5>
+                            <Dropzone accept={"application/pdf"} style={{}}
+                                activeStyle={{ borderStyle: 'dashed', width: 'fit-content', margin: 'auto' }}
+                                onDrop={this.onDropPDF}
+                                disablePreview
+                                multiple
+                            >
+                                <DropzoneContent/>
+                            </Dropzone>
+
+                            <br />
+                            <aside className="menu">
+                                <p className="menu-label">
+                                    Previously uploaded
+              </p>
+                                <ul className="menu-list">
+                                    {this.state.pdfs.map(pdf =>
+                                        <li key={pdf.id}><StatusPDF pdf={pdf} /></li>
+                                    )}
+                                </ul>
+                            </aside>
+
+                        </div>
+                    </div>
+
+                </div>
+            </section>
+
+        </div>
+    }
+}
+
+export default Exams;
diff --git a/client/views/Exams.jsx b/client/views/Exams.jsx
deleted file mode 100644
index 0c94dca87..000000000
--- a/client/views/Exams.jsx
+++ /dev/null
@@ -1,259 +0,0 @@
-import React from 'react';
-import Dropzone from 'react-dropzone'
-
-import Hero from '../components/Hero.jsx';
-
-import * as api from '../api.jsx'
-
-const StatusPDF = props => {
-  let iconClass = "fa fa-";
-  switch (props.pdf.status) {
-    case "processing":
-      iconClass += "refresh fa-spin";
-      break;
-    case "success":
-      iconClass += "check";
-      break;
-    case "error":
-      iconClass += "times";
-      break;
-  }
-  return <div>
-    {props.pdf.name}&emsp;<i className={iconClass} />
-    <i>&nbsp;{props.pdf.message}</i>
-  </div>
-}
-
-const DropzoneContent = props => (
-  <div className="file has-name is-boxed is-centered">
-    <label className="file-label">
-      <span className="file-cta" disabled={props.disabled}>
-        <span className="file-icon">
-          <i className="fa fa-upload"></i>
-        </span>
-        <span className="file-label">
-          Choose a file…
-        </span>
-      </span>
-    </label>
-  </div>
-)
-
-class Exams extends React.Component {
-  state = {
-    exams: [],
-    selected_exam: {
-      id: undefined,
-      name: undefined,
-      yaml: undefined,
-      pdfs: [],
-    },
-  };
-
-
-  onDropYAML = (accepted, rejected) => {
-    if (rejected.length > 0) {
-      alert('Please upload a YAML..')
-      return
-    }
-    const data = new FormData()
-    data.append('yaml', accepted[0])
-    api.post('exams', data)
-      .then(new_exam => {
-        // if reall is new exam then add to list of exams
-        if (!this.state.exams.some(exam => new_exam.id == exam.id)) {
-          this.setState(prev => ({
-            exams: [...prev.exams, new_exam],
-          }))
-        }
-        this.selectExam(new_exam.id)
-        alert('Thank you for your upload, it was delicious')
-      })
-      .catch(resp => {
-        alert('failed to upload yaml (see javascript console for details)')
-        console.error('failed to upload YAML:', resp)
-      })
-  }
-
-  putYaml = () => {
-    const exam_id = this.state.selected_exam.id
-    api.patch('exams/' + exam_id, { yaml: this.state.selected_exam.yaml })
-      .then(() => alert('thank you for the update; it was delicious'))
-      .catch(resp => {
-        alert('failed to update the YAML (see javascript console)')
-        console.error('failed to update YAML', resp)
-      })
-  }
-
-  updateYaml = (event) => {
-    this.setState({
-      selected_exam: {
-        ...this.state.selected_exam,
-        yaml: event.target.value
-      }
-    })
-  }
-
-  selectExam = (exam_id) => {
-    api.get('exams/' + exam_id)
-      .then(exam => {
-        api.get('pdfs/' + exam_id)
-          .then(pdfs => {
-            exam.pdfs = pdfs;
-            this.setState({
-              selected_exam: exam
-            })
-          })
-      })
-  }
-
-  updatePDFList = () => {
-    if (this.state.selected_exam.id == null) {
-      return
-    }
-    api.get('pdfs/' + this.state.selected_exam.id)
-      .then(pdfs =>
-        this.setState({
-          selected_exam: {
-            ...this.state.selected_exam,
-            pdfs: pdfs
-          }
-        })
-      )
-  }
-
-  onDropPDF = (accepted, rejected) => {
-    if (rejected.length > 0) {
-      alert('Please upload a PDF..')
-      return
-    }
-    accepted.map(file => {
-      const data = new FormData()
-      data.append('pdf', file)
-      api.post('pdfs/' + this.state.selected_exam.id, data)
-        .then(() => {
-          api.get('pdfs/' + this.state.selected_exam.id)
-            .then(pdfs =>
-              this.setState({
-                selected_exam: {
-                  ...this.state.selected_exam,
-                  pdfs: pdfs
-                }
-              })
-            )
-        })
-        .catch(resp => {
-          alert('failed to upload pdf (see javascript console for details)')
-          console.error('failed to upload PDF:', resp)
-        })
-    })
-  }
-
-  componentDidMount() {
-    api.get('exams')
-      .then(exams => {
-        this.setState({ exams: exams })
-        if (exams.length > 0) {
-          this.selectExam(exams[0].id)
-        }
-      })
-      .catch(err => {
-        alert('failed to get exams (see javascript console for details)')
-        console.error('failed to get exams:', err)
-        throw err
-      })
-
-    this.pdfUpdater = setInterval(this.updatePDFList, 1000)
-  }
-
-  componentWillUnmount() {
-    clearInterval(this.pdfUpdater);
-  }
-
-  render() {
-
-    const isDisabled = this.state.exams.length == 0;
-    const textStyle = {
-      color: isDisabled ? 'grey' : 'black'
-    };
-
-    return <div>
-
-      <Hero title='Exams' subtitle="Omnomnomnom PDF's!" />
-
-      <section className="section">
-
-        <div className="container">
-          <div className="columns">
-            <div className="column has-text-centered">
-              <h3 className='title'>Upload new exam config</h3>
-              <h5 className='subtitle'>then we know that to do with PDF's</h5>
-
-              <Dropzone accept=".yml, text/yaml, text/x-yaml, application/yaml, application/x-yaml"
-                style={{}} activeStyle={{ borderStyle: 'dashed', width: 'fit-content', margin: 'auto' }}
-                onDrop={this.onDropYAML}
-                disablePreview
-                multiple={false}
-              >
-                <DropzoneContent disabled={false} />
-              </Dropzone>
-            </div>
-
-            <div className="column has-text-centered" style={textStyle}>
-              <h3 className='title' style={textStyle}>And tweak the config</h3>
-              <h5 className='subtitle' style={textStyle}>Fix misalignments</h5>
-              <div className="select">
-                <select disabled={isDisabled}
-                  value={this.state.selected_exam.id}
-                  onChange={ev => this.selectExam(ev.target.value)}>
-                  {this.state.exams.map((exam) => {
-                    return <option key={exam.id} value={exam.id}>{exam.name}</option>
-                  })}
-                </select>
-              </div>
-              <textarea className="textarea" placeholder="YAML config will appear here..." disabled={isDisabled}
-                value={this.state.selected_exam.yaml} onChange={this.updateYaml}>
-              </textarea>
-              <button className='button is-success' disabled={isDisabled}
-                onClick={this.putYaml}>
-                Save
-              </button>
-            </div>
-
-
-            <div className="column has-text-centered">
-              <h3 className='title' style={textStyle}>And upload PDF's</h3>
-              <h5 className='subtitle' style={textStyle}>we will work some magic!</h5>
-              <Dropzone accept={"application/pdf"} style={{}}
-                activeStyle={{ borderStyle: 'dashed', width: 'fit-content', margin: 'auto' }}
-                onDrop={this.onDropPDF}
-                disabled={isDisabled}
-                disablePreview
-                multiple
-              >
-                <DropzoneContent disabled={isDisabled} />
-              </Dropzone>
-
-              <br />
-              <aside className="menu" style={textStyle}>
-                <p className="menu-label">
-                  Previously uploaded
-              </p>
-                <ul className="menu-list">
-                  {this.state.selected_exam.pdfs.map(pdf =>
-                    <li key={pdf.id}><StatusPDF pdf={pdf} /></li>
-                  )}
-                </ul>
-              </aside>
-
-            </div>
-          </div>
-
-        </div>
-      </section>
-
-    </div>
-  }
-}
-
-export default Exams;
diff --git a/webpack.common.js b/webpack.common.js
index bf37a0dcf..f6faf2a82 100644
--- a/webpack.common.js
+++ b/webpack.common.js
@@ -15,7 +15,8 @@ module.exports = {
   entry: './client/index.jsx',
   output: {
     path: path.resolve('zesje/static'),
-    filename: 'index_bundle.js'
+    filename: 'index_bundle.js',
+    publicPath: '/'
   },
   module: {
     loaders: [
-- 
GitLab