diff --git a/client/views/grade/EditPanel.jsx b/client/components/feedback/EditPanel.jsx similarity index 87% rename from client/views/grade/EditPanel.jsx rename to client/components/feedback/EditPanel.jsx index f1f574ac6f383042b186a8ac1107caae34f00389..63eb950ab7b52b8f1f9cd6bb810675d5ab001638 100644 --- a/client/views/grade/EditPanel.jsx +++ b/client/components/feedback/EditPanel.jsx @@ -40,16 +40,19 @@ class EditPanel extends React.Component { } static getDerivedStateFromProps (nextProps, prevState) { + // In case nothing is set, use an empty function that no-ops + const updateCallback = nextProps.updateCallback || (_ => {}) if (nextProps.feedback && prevState.id !== nextProps.feedback.id) { const fb = nextProps.feedback return { id: fb.id, name: fb.name, description: fb.description, - score: fb.score + score: fb.score, + updateCallback: updateCallback } } - return null + return {updateCallback: updateCallback} } changeText = (event) => { @@ -84,10 +87,15 @@ class EditPanel extends React.Component { if (this.state.id) { fb.id = this.state.id api.put(uri, fb) - .then(() => this.props.goBack()) + .then(() => { + this.state.updateCallback(fb) + this.props.goBack() + }) } else { api.post(uri, fb) - .then(() => { + .then((response) => { + // Response is the feedback option + this.state.updateCallback(response) this.setState({ id: null, name: '', @@ -101,14 +109,20 @@ class EditPanel extends React.Component { deleteFeedback = () => { if (this.state.id) { api.del('feedback/' + this.props.problemID + '/' + this.state.id) - .then(() => this.props.goBack()) + .then(() => { + this.state.updateCallback({ + id: this.state.id, + deleted: true + }) + this.props.goBack() + }) } } render () { return ( - <nav className='panel'> - <p className='panel-heading'> + <React.Fragment> + <p className={this.props.grading ? 'panel-heading' : 'panel-heading is-radiusless'}> Manage feedback </p> @@ -168,7 +182,7 @@ class EditPanel extends React.Component { onCancel={() => { this.setState({deleting: false}) }} /> </div> - </nav> + </React.Fragment> ) } } diff --git a/client/views/grade/FeedbackBlock.jsx b/client/components/feedback/FeedbackBlock.jsx similarity index 84% rename from client/views/grade/FeedbackBlock.jsx rename to client/components/feedback/FeedbackBlock.jsx index 7bc7ba60798917605cb34fa14e589de666679d28..af1429fd855a49d6aad28bd3a5fe9d39f68f058b 100644 --- a/client/views/grade/FeedbackBlock.jsx +++ b/client/components/feedback/FeedbackBlock.jsx @@ -32,7 +32,9 @@ class FeedbackBlock extends React.Component { render () { const shortcut = (this.props.index < 11 ? '' : 'shift + ') + this.props.index % 10 return ( - <a className='panel-block is-active' onClick={this.toggle} + <a + className={this.props.grading ? 'panel-block is-active' : 'panel-block'} + onClick={this.props.grading ? this.toggle : this.props.editFeedback} style={this.props.selected ? {backgroundColor: '#209cee'} : {}} > <span @@ -40,7 +42,9 @@ class FeedbackBlock extends React.Component { ? ' tooltip is-tooltip-active is-tooltip-left' : '')} data-tooltip={shortcut} > - <i className={'fa fa-' + (this.props.checked ? 'check-square-o' : 'square-o')} /> + {this.props.grading && + <i className={'fa fa-' + (this.props.checked ? 'check-square-o' : 'square-o')} /> + } </span> <span style={{ width: '80%' }}> {this.props.feedback.name} diff --git a/client/views/grade/FeedbackPanel.jsx b/client/components/feedback/FeedbackPanel.jsx similarity index 77% rename from client/views/grade/FeedbackPanel.jsx rename to client/components/feedback/FeedbackPanel.jsx index ee5266e6f25447fdb6dcbc3040666bb411f09e98..e2e1ce622ea373b7772b6f14deb5df8f1f47177c 100644 --- a/client/views/grade/FeedbackPanel.jsx +++ b/client/components/feedback/FeedbackPanel.jsx @@ -4,7 +4,7 @@ import Notification from 'react-bulma-notification' import * as api from '../../api.jsx' -import withShortcuts from '../../components/ShortcutBinder.jsx' +import withShortcuts from '../ShortcutBinder.jsx' import FeedbackBlock from './FeedbackBlock.jsx' class FeedbackPanel extends React.Component { @@ -35,7 +35,7 @@ class FeedbackPanel extends React.Component { static getDerivedStateFromProps (nextProps, prevState) { if (prevState.problemID !== nextProps.problem.id || prevState.submissionID !== nextProps.submissionID) { return { - remark: nextProps.solution.remark, + remark: nextProps.grading && nextProps.solution.remark, problemID: nextProps.problem.id, submissionID: nextProps.submissionID, selectedFeedbackIndex: null @@ -90,29 +90,34 @@ class FeedbackPanel extends React.Component { const blockURI = this.props.examID + '/' + this.props.submissionID + '/' + this.props.problem.id let totalScore = 0 - for (let i = 0; i < this.props.solution.feedback.length; i++) { - const probIndex = this.props.problem.feedback.findIndex(fb => fb.id === this.props.solution.feedback[i]) - if (probIndex >= 0) totalScore += this.props.problem.feedback[probIndex].score + if (this.props.grading) { + for (let i = 0; i < this.props.solution.feedback.length; i++) { + const probIndex = this.props.problem.feedback.findIndex(fb => fb.id === this.props.solution.feedback[i]) + if (probIndex >= 0) totalScore += this.props.problem.feedback[probIndex].score + } } let selectedFeedbackId = this.state.selectedFeedbackIndex !== null && this.props.problem.feedback[this.state.selectedFeedbackIndex].id return ( - <nav className='panel'> - <p className='panel-heading'> - Total: <b>{totalScore}</b> - </p> + <React.Fragment> + {this.props.grading && + <p className='panel-heading'> + Total: <b>{totalScore}</b> + </p>} {this.props.problem.feedback.map((feedback, index) => <FeedbackBlock key={feedback.id} uri={blockURI} graderID={this.props.graderID} - feedback={feedback} checked={this.props.solution.feedback.includes(feedback.id)} + feedback={feedback} checked={this.props.grading && this.props.solution.feedback.includes(feedback.id)} editFeedback={() => this.props.editFeedback(feedback)} updateSubmission={this.props.updateSubmission} - ref={(selectedFeedbackId === feedback.id) ? this.feedbackBlock : null} + ref={(selectedFeedbackId === feedback.id) ? this.feedbackBlock : null} grading={this.props.grading} selected={selectedFeedbackId === feedback.id} showIndex={this.props.showTooltips} index={index + 1} /> )} - <div className='panel-block'> - <textarea className='textarea' rows='2' placeholder='remark' value={this.state.remark} onBlur={this.saveRemark} onChange={this.changeRemark} /> - </div> + {this.props.grading && + <div className='panel-block'> + <textarea className='textarea' rows='2' placeholder='remark' value={this.state.remark} onBlur={this.saveRemark} onChange={this.changeRemark} /> + </div> + } <div className='panel-block'> <button className='button is-link is-outlined is-fullwidth' onClick={() => this.props.editFeedback()}> <span className='icon is-small'> @@ -121,7 +126,7 @@ class FeedbackPanel extends React.Component { <span>option</span> </button> </div> - </nav> + </React.Fragment> ) } } diff --git a/client/views/Exam.jsx b/client/views/Exam.jsx index 5e18b77bf928cacb771d572f6c13e79d641db32c..b51eb7fe61e101dba054bcd2b37f889d43eacc67 100644 --- a/client/views/Exam.jsx +++ b/client/views/Exam.jsx @@ -11,6 +11,8 @@ import ExamEditor from './ExamEditor.jsx' import update from 'immutability-helper' import ExamFinalizeMarkdown from './ExamFinalize.md' import ConfirmationModal from '../components/ConfirmationModal.jsx' +import FeedbackPanel from '../components/feedback/FeedbackPanel.jsx' +import EditPanel from '../components/feedback/EditPanel.jsx' import * as api from '../api.jsx' @@ -18,6 +20,9 @@ class Exams extends React.Component { state = { examID: null, page: 0, + editActive: false, + feedbackToEdit: null, + problemIdToEditFeedbackOf: null, numPages: null, selectedWidgetId: null, changedWidgetId: null, @@ -42,6 +47,7 @@ class Exams extends React.Component { page: problem.page, name: problem.name, graded: problem.graded, + feedback: problem.feedback || [], mc_options: problem.mc_options.map((option) => { option.cbOffsetX = 7 // checkbox offset relative to option position on x axis option.cbOffsetY = 21 // checkbox offset relative to option position on y axis @@ -70,7 +76,6 @@ class Exams extends React.Component { previewing: false } } - return null } @@ -82,6 +87,10 @@ class Exams extends React.Component { // The onBlur event is not fired when the input field is being disabled if (prevState.selectedWidgetId !== this.state.selectedWidgetId) { this.saveProblemName() + this.setState({ + editActive: false, + problemIdToEditFeedbackOf: false + }) } } @@ -98,6 +107,43 @@ class Exams extends React.Component { } } + editFeedback = (feedback) => { + this.setState({ + editActive: true, + feedbackToEdit: feedback, + problemIdToEditFeedbackOf: this.state.selectedWidgetId + }) + } + + updateFeedback = (feedback) => { + let problemWidget = this.state.widgets[this.state.selectedWidgetId] + const index = problemWidget.problem.feedback.findIndex(e => { return e.id === feedback.id }) + this.updateFeedbackAtIndex(feedback, problemWidget, index) + } + + updateFeedbackAtIndex = (feedback, problemWidget, idx) => { + if (idx === -1) { + problemWidget.problem.feedback.push(feedback) + } else { + if (feedback.deleted) problemWidget.problem.feedback.splice(idx, 1) + else problemWidget.problem.feedback[idx] = feedback + } + this.setState({ + widgets: this.state.widgets + }) + } + + backToFeedback = () => { + this.props.updateExam(this.props.exam.id) + this.setState({ + editActive: false + }) + } + + isProblemWidget = (widget) => { + return widget && this.state.widgets[widget].problem + } + saveProblemName = () => { const changedWidgetId = this.state.changedWidgetId if (!changedWidgetId) return @@ -138,6 +184,8 @@ class Exams extends React.Component { selectedWidgetId: null, changedWidgetId: null, deletingWidget: false, + editActive: false, + problemIdToEditFeedbackOf: null, widgets: update(prevState.widgets, { $unset: [widgetId] }) @@ -275,11 +323,16 @@ class Exams extends React.Component { // update to try and get a consistent state this.props.updateExam(this.props.examID) }) + }).then(res => { + let index = widget.problem.feedback.findIndex(e => { return e.id === res.feedback_id }) + let feedback = widget.problem.feedback[index] + feedback.deleted = true + this.updateFeedbackAtIndex(feedback, widget, index) }) }) // remove the mc options from the state - // note that his can happen before they are removed in the DB due to async calls + // note that this can happen before they are removed in the DB due to async calls this.setState((prevState) => { return { widgets: update(prevState.widgets, { @@ -363,6 +416,12 @@ class Exams extends React.Component { generateAnswerBoxes = (problemWidget, labels, index, xPos, yPos) => { if (labels.length === index) return + let feedback = { + 'name': labels[index], + 'description': '', + 'score': 0 + } + let data = { 'label': labels[index], 'problem_id': problemWidget.problem.id, @@ -383,9 +442,14 @@ class Exams extends React.Component { formData.append('y', data.widget.y + data.cbOffsetY) formData.append('problem_id', data.problem_id) formData.append('label', data.label) + formData.append('fb_description', feedback.description) + formData.append('fb_score', feedback.score) api.put('mult-choice/', formData).then(result => { data.id = result.mult_choice_id + data.feedback_id = result.feedback_id + feedback.id = result.feedback_id this.createNewMCWidget(problemWidget, data) + this.updateFeedback(feedback) this.generateAnswerBoxes(problemWidget, labels, index + 1, xPos + problemWidget.problem.widthMCO, yPos) }).catch(err => { console.log(err) @@ -510,17 +574,29 @@ class Exams extends React.Component { </div> <div className='panel-block'> <div className='field'> - <label className='label'> - <input disabled={props.disableIsMCQ} type='checkbox' checked={props.isMCQProblem} onChange={ - (e) => { - props.onMCQChange(e.target.checked) - }} /> - Multiple choice question - </label> + <label className='label'> Multiple choice question </label> + <input disabled={props.disableIsMCQ} type='checkbox' checked={props.isMCQProblem} onChange={ + (e) => { + props.onMCQChange(e.target.checked) + }} /> </div> </div> </React.Fragment> )} + {this.isProblemWidget(selectedWidgetId) && + <React.Fragment> + <div className='panel-block'> + {!this.state.editActive && <label className='label'>Feedback options</label>} + </div> + {this.state.editActive + ? <EditPanel problemID={props.problem.id} feedback={this.state.feedbackToEdit} + goBack={this.backToFeedback} updateCallback={this.updateFeedback} /> + : <FeedbackPanel examID={this.props.examID} problem={props.problem} + editFeedback={this.editFeedback} showTooltips={this.state.showTooltips} + grading={false} + />} + </React.Fragment> + } <div className='panel-block'> <button disabled={props.disabledDelete} diff --git a/client/views/ExamEditor.jsx b/client/views/ExamEditor.jsx index 97c7feb6be60d135838048abe71d11afa515c559..4abdf15844b4980279f7450e362e615551ef92f8 100644 --- a/client/views/ExamEditor.jsx +++ b/client/views/ExamEditor.jsx @@ -88,6 +88,7 @@ class ExamEditor extends React.Component { const problemData = { name: 'New problem', // TODO: Name page: this.props.page, + feedback: [], mc_options: [], widthMCO: 24, heightMCO: 38, diff --git a/client/views/Grade.jsx b/client/views/Grade.jsx index f1f145aa0b339fa1e12b63a4f741ebc526b85819..e6d2496948339f39efc508b33dc3f9a73ef0189f 100644 --- a/client/views/Grade.jsx +++ b/client/views/Grade.jsx @@ -2,9 +2,9 @@ import React from 'react' import Hero from '../components/Hero.jsx' -import FeedbackPanel from './grade/FeedbackPanel.jsx' +import FeedbackPanel from '../components/feedback/FeedbackPanel.jsx' import ProblemSelector from './grade/ProblemSelector.jsx' -import EditPanel from './grade/EditPanel.jsx' +import EditPanel from '../components/feedback/EditPanel.jsx' import SearchBox from '../components/SearchBox.jsx' import ProgressBar from '../components/ProgressBar.jsx' import withShortcuts from '../components/ShortcutBinder.jsx' @@ -225,17 +225,18 @@ class Grade extends React.Component { <div className='column is-one-quarter-desktop is-one-third-tablet'> <ProblemSelector problems={exam.problems} changeProblem={this.changeProblem} current={this.state.pIndex} showTooltips={this.state.showTooltips} /> - {this.state.editActive - ? <EditPanel problemID={problem.id} feedback={this.state.feedbackToEdit} - goBack={this.backToFeedback} /> - : <FeedbackPanel examID={exam.id} submissionID={submission.id} - problem={problem} solution={solution} graderID={this.props.graderID} - editFeedback={this.editFeedback} showTooltips={this.state.showTooltips} - updateSubmission={() => { - this.props.updateSubmission(this.state.sIndex) - } - } /> - } + <nav className='panel'> + {this.state.editActive + ? <EditPanel problemID={problem.id} feedback={this.state.feedbackToEdit} + goBack={this.backToFeedback} /> + : <FeedbackPanel examID={exam.id} submissionID={submission.id} + problem={problem} solution={solution} graderID={this.props.graderID} + editFeedback={this.editFeedback} showTooltips={this.state.showTooltips} + updateSubmission={() => { + this.props.updateSubmission(this.state.sIndex) + }} grading /> + } + </nav> </div> <div className='column'> @@ -275,9 +276,13 @@ class Grade extends React.Component { renderSuggestion={(submission) => { const stud = submission.student return ( - <div> - <b>{`${stud.firstName} ${stud.lastName}`}</b> - <i style={{float: 'right'}}>({stud.id})</i> + <div className='flex-parent'> + <b className='flex-child truncated'> + {`${stud.firstName} ${stud.lastName}`} + </b> + <i className='flex-child fixed'> + ({stud.id}) + </i> </div> ) }} diff --git a/client/views/grade/Grade.css b/client/views/grade/Grade.css index 90e5dab9e507f7bcb2185c4541ce7632a4564d55..8b88019a647828998811e4a4df4a7a6dec7559c8 100644 --- a/client/views/grade/Grade.css +++ b/client/views/grade/Grade.css @@ -1,3 +1,19 @@ .box.is-graded { box-shadow: 0px 0px 6px #23d160, 0 0 0 1px rgba(10, 10, 10, 0.1); } + +.flex-parent { + display: flex; + align-items: center; +} + +.flex-child.truncated { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.flex-child.fixed { + white-space: nowrap; +} diff --git a/zesje/api/mult_choice.py b/zesje/api/mult_choice.py index 2fe2452f3981381d83f10f4a792164e5f862dbb0..a50f8aae5006601a420c8935d1064fdb7dc473d9 100644 --- a/zesje/api/mult_choice.py +++ b/zesje/api/mult_choice.py @@ -41,6 +41,8 @@ class MultipleChoice(Resource): put_parser.add_argument('x', type=int, required=True) put_parser.add_argument('y', type=int, required=True) put_parser.add_argument('label', type=str, required=False) + put_parser.add_argument('fb_description', type=str, required=False) + put_parser.add_argument('fb_score', type=str, required=False) put_parser.add_argument('problem_id', type=int, required=True) # Used for FeedbackOption def put(self): @@ -52,13 +54,18 @@ class MultipleChoice(Resource): """ args = self.put_parser.parse_args() + # Get request arguments + label = args['label'] + fb_description = args['fb_description'] + fb_score = args['fb_score'] problem_id = args['problem_id'] if not Problem.query.get(problem_id): return dict(status=404, message=f'Problem with id {problem_id} does not exist'), 404 # Insert new empty feedback option that links to the same problem - new_feedback_option = FeedbackOption(problem_id=problem_id, text='') + new_feedback_option = FeedbackOption(problem_id=problem_id, text=label, + description=fb_description, score=fb_score) db.session.add(new_feedback_option) db.session.commit() @@ -67,11 +74,6 @@ class MultipleChoice(Resource): update_mc_option(mc_entry, args, new_feedback_option.id) db.session.add(mc_entry) - - # Set the feedback label to the - if mc_entry.label: - new_feedback_option.description = mc_entry.label - db.session.commit() return dict(status=200, mult_choice_id=mc_entry.id, feedback_id=new_feedback_option.id, @@ -171,5 +173,6 @@ class MultipleChoice(Resource): db.session.delete(mult_choice.feedback) db.session.commit() - return dict(status=200, message=f'Multiple choice question with id {id} deleted.' + return dict(status=200, mult_choice_id=id, feedback_id=mult_choice.feedback_id, + message=f'Multiple choice question with id {id} deleted.' + f'Feedback option with id {mult_choice.feedback_id} deleted.'), 200 diff --git a/zesje/statistics.py b/zesje/statistics.py index adbf33ca5212c826c9f71a32f73821638642884a..ff74c4e6e9ee3a274e0d3cda0c3d29a609af5093 100644 --- a/zesje/statistics.py +++ b/zesje/statistics.py @@ -5,7 +5,7 @@ from sqlalchemy.orm.exc import NoResultFound import numpy as np import pandas -from .database import Exam, Problem, Student +from .database import Exam, Student def solution_data(exam_id, student_id):