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} <i className={iconClass} /> + <i> {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} <i className={iconClass} /> - <i> {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